专栏:FRP 内网穿透从入门到实战
前置阅读:已完成正篇部署与 webServer 仪表盘配置(第二篇、第四篇)。
本篇目标:解决边缘网关临时远程运维中的两个工程难题——动态端口获取与空闲自动断开。
前言
在视频物联网平台中,大量设备通过边缘网关以长连接方式注册上线。运维人员远程调试网关 Web 管理页时,平台会向边缘网关下发指令,由网关侧 frpc临时开启TCP 穿透,把内网 80 端口暴露到公网 frps。
这和正篇里「一台设备固定映射一个 remotePort」的场景不同,有两个现实约束:
- 端口多且临时:每台网关、每次远程会话都可能新建代理,不能给每个 client 预分配固定端口;
- 用完即关:远程运维结束或操作者离开,穿透隧道不能一直挂着,否则公网端口长期暴露。
本篇方案源自畅联云平台边缘网关远程运维的生产实践,用 Python 示例说明如何通过frps webServer API解决上述两个问题。
一、业务架构:平台下发,网关执行
整体链路如下:
云平台 ──长连接──▶ 边缘网关(frpc) │ │ │ ① 下发 openRemoteConfig │ ② remotePort=0 动态建代理 │ │ └──── ③ webServer API ───┼──▶ frps 查询实际端口 │ ④ 定时任务轮询流量 ──────┘ ⑤ 空闲 15 分钟 → 下发 closeRemoteConfig关键约定:
| 项目 | 说明 |
|---|---|
| 代理命名 | {网关序列号}-80,如GW20240001-80 |
| 动态端口 | 下发remotePort = 0,由 frps 从端口池自动分配 |
| 端口查询 | GET /api/proxies/{proxyName},读取conf.remotePort |
| 活跃检测 | 每 2 分钟拉取GET /api/proxy/tcp,对比todayTrafficIn |
| 空闲阈值 | 入站流量连续 15 分钟无变化,判定为空闲并关闭远程配置 |
二、frps 侧前置配置
动态端口分配需要在 frps 开启 webServer,并预留端口范围:
# frps.toml bindPort = 7000 auth.method = "token" auth.token = "YourStrongToken" # 仪表盘 / API(平台内网调用,勿对公网裸奔) webServer.addr = "0.0.0.0" webServer.port = 7500 webServer.user = "admin" webServer.password = "YourDashboardPass" # 动态端口池(remotePort=0 时从此范围分配) allowPorts = [ { start = 20000, end = 30000 } ] log.to = "/var/log/frps.log" log.level = "info"边缘网关侧 frpc 通过平台指令运行时创建代理(无需写死在 toml 里),核心参数示意:
{"clientID":"GW20240001","serverAddr":"frps公网IP","serverPort":7000,"authToken":"YourStrongToken","proxyName":"GW20240001-80","remotePort":0,"localIP":"127.0.0.1","localPort":80}remotePort = 0是本篇第一个问题的关键:不指定固定端口,交给 frps 动态分配。
三、问题一:获取动态分配的 remotePort
3.1 为什么需要反查端口
代理创建是异步的:平台下发指令后,frpc 连接 frps 并完成注册需要 1~3 秒。此时平台还不知道最终映射到哪个公网端口,运维人员也就无法打开远程 Web 页。
推荐做法:下发开启指令后,轮询 frps webServer API,直到代理状态为online且拿到conf.remotePort。
3.2 API 说明
| 接口 | 方法 | 用途 |
|---|---|---|
/api/proxies/{name} | GET | 查询单个代理详情(含动态端口) |
/api/proxy/tcp | GET | 列出所有 TCP 代理(含流量统计) |
认证方式:HTTP Basic Auth,用户名密码对应webServer.user/webServer.password。
单个代理响应示例:
{"name":"GW20240001-80","clientID":"GW20240001","status":"online","todayTrafficIn":1024,"todayTrafficOut":512,"lastStartTime":"08-13 14:30:00","conf":{"remotePort":23841}}3.3 Python 实现:等待动态端口就绪
importbase64importtimefromdataclassesimportdataclassfromtypingimportOptionalimportrequests@dataclassclassFrpServerConfig:inner_ip:str# frps 内网 IP,平台与 frps 同 VPC 时使用web_port:int# webServer 端口,默认 7500web_user:strweb_password:strout_ip:str# frps 公网 IP,拼接远程访问 URL 用classFrpWebApiClient:"""frps webServer API 封装"""def__init__(self,config:FrpServerConfig):self.config=config token=base64.b64encode(f"{config.web_user}:{config.web_password}".encode()).decode()self.headers={"Authorization":f"Basic{token}"}@propertydefbase_url(self)->str:returnf"http://{self.config.inner_ip}:{self.config.web_port}"defget_proxy(self,proxy_name:str)->Optional[dict]:url=f"{self.base_url}/api/proxies/{proxy_name}"resp=requests.get(url,headers=self.headers,timeout=5)resp.raise_for_status()returnresp.json()deflist_tcp_proxies(self)->list[dict]:url=f"{self.base_url}/api/proxy/tcp"resp=requests.get(url,headers=self.headers,timeout=5)resp.raise_for_status()data=resp.json()returndata.get("proxies")or[]defget_remote_config_url(frp_client:FrpWebApiClient,proxy_name:str,retry:int=3,interval:float=3.0,)->Optional[str]:""" 等待动态代理上线,返回远程 Web 访问 URL。 逻辑:下发 openRemoteConfig 后,轮询直到 status=online 且 remotePort 可用。 """forattemptinrange(retry+1):proxy=frp_client.get_proxy(proxy_name)ifproxyandproxy.get("status")=="online":remote_port=(proxy.get("conf")or{}).get("remotePort")ifremote_port:returnf"http://{frp_client.config.out_ip}:{remote_port}"ifattempt<retry:time.sleep(interval)returnNone# ---- 使用示例 ----if__name__=="__main__":frp=FrpWebApiClient(FrpServerConfig(inner_ip="10.0.0.5",web_port=7500,web_user="admin",web_password="YourDashboardPass",out_ip="123.45.67.89",))gateway_serial="GW20240001"proxy_name=f"{gateway_serial}-80"# ① 此处由平台向边缘网关下发 openRemoteConfig(remotePort=0)# send_open_remote_config(gateway_serial, proxy_name)# ② 轮询 API 获取动态端口url=get_remote_config_url(frp,proxy_name)ifurl:print(f"远程配置地址:{url}")else:print("代理未能在预期时间内上线")生产要点:
- 平台调用 API 应走 frps内网 IP(
inner_ip),避免 webServer 暴露公网; - 重试 3 次、间隔 3 秒,是生产环境验证过的参数;
- 返回 URL 直接给前端,运维人员点击即可访问网关 Web 页。
四、问题二:空闲 15 分钟自动断开
4.1 为什么用流量而不是连接数
TCP 连接在「页面打开但无操作」时可能仍然 ESTABLISHED,单纯看连接数无法判断是否真的在用。更可靠的做法是:对比todayTrafficIn是否在持续变化——有交互就有流量,无交互则流量停滞。
4.2 检测策略(与生产 Job 一致)
| 规则 | 说明 |
|---|---|
| 执行频率 | 每 2 分钟一次 |
| 活跃判定 | todayTrafficIn与上次不同 → 该代理活跃 |
| 空闲判定 | 流量连续 15 分钟无变化 → 空闲候选 |
| 启动保护 | 代理lastStartTime在 5 分钟内 → 跳过(避免刚创建就被误关) |
| 多代理合并 | 同一clientID下多个 proxy,任一活跃则整台网关视为活跃 |
| 关闭动作 | 向边缘网关下发closeRemoteConfig,frpc 删除临时代理 |
4.3 Python 实现:空闲检测定时任务
importloggingfromdatetimeimportdatetime,timedeltafromtypingimportDict,Set,Tuple logger=logging.getLogger(__name__)# 流量缓存:key = "{frp_server_id}_{proxy_name}" → (last_traffic_in, last_change_time)_traffic_cache:Dict[str,Tuple[int,datetime]]={}INACTIVE_THRESHOLD_MINUTES=15STARTUP_GRACE_MINUTES=5CACHE_CLEANUP_HOURS=24def_parse_start_time(last_start_time:str)->datetime|None:"""解析 frps 返回的启动时间,格式如 '08-13 14:30:00'"""ifnotlast_start_time:returnNonetry:year=datetime.now().yearreturndatetime.strptime(f"{year}-{last_start_time}","%Y-%m-%d %H:%M:%S")exceptValueError:returnNonedef_is_started_within_grace_period(last_start_time:str)->bool:start=_parse_start_time(last_start_time)ifnotstart:returnFalsereturnabs((datetime.now()-start).total_seconds())<=STARTUP_GRACE_MINUTES*60defcheck_proxy_activity(frp_server_id:str,proxy:dict,active_client_ids:Set[str],inactive_candidates:Set[str],)->None:"""检测单个代理的活跃状态,更新缓存与候选集合"""name=proxy.get("name")client_id=proxy.get("clientID")ifnotnameornotclient_id:returncache_key=f"{frp_server_id}_{name}"current_traffic=proxy.get("todayTrafficIn")or0now=datetime.now()cached=_traffic_cache.get(cache_key)ifcachedisNone:_traffic_cache[cache_key]=(current_traffic,now)returnlast_traffic,last_change_time=cachedifcurrent_traffic!=last_traffic:_traffic_cache[cache_key]=(current_traffic,now)active_client_ids.add(client_id)else:idle_minutes=(now-last_change_time).total_seconds()/60ifidle_minutes>=INACTIVE_THRESHOLD_MINUTES:inactive_candidates.add(client_id)defrun_activity_check(frp_client:FrpWebApiClient,frp_server_id:str,close_remote_config_fn,)->str:""" 主检测流程,等价于生产环境 FrpProxyActivityJob。 close_remote_config_fn: callable(client_id) → 向边缘网关下发关闭指令 """active_client_ids:Set[str]=set()inactive_candidates:Set[str]=set()current_keys:Set[str]=set()proxies=frp_client.list_tcp_proxies()forproxyinproxies:ifproxy.get("status")=="offline":continueif_is_started_within_grace_period(proxy.get("lastStartTime","")):continuecache_key=f"{frp_server_id}_{proxy['name']}"current_keys.add(cache_key)check_proxy_activity(frp_server_id,proxy,active_client_ids,inactive_candidates)# 多 proxy 合并:任一活跃 → 整台网关活跃final_inactive=inactive_candidates-active_client_ids# 清理 24 小时未出现的缓存now=datetime.now()expired=[kfork,(_,t)in_traffic_cache.items()ifknotincurrent_keysand(now-t).total_seconds()>=CACHE_CLEANUP_HOURS*3600]forkinexpired:del_traffic_cache[k]closed=0forclient_idinfinal_inactive:try:close_remote_config_fn(client_id)closed+=1logger.info("网关 %s 空闲超时,已关闭远程配置",client_id)exceptException:logger.exception("关闭网关 %s 远程配置失败",client_id)return(f"检测{len(proxies)}个代理,"f"发现{len(final_inactive)}个不活跃网关,"f"已关闭{closed}个")4.4 接入定时调度
importscheduleimporttime frp=FrpWebApiClient(FrpServerConfig(inner_ip="10.0.0.5",web_port=7500,web_user="admin",web_password="YourDashboardPass",out_ip="123.45.67.89",))defclose_remote_config(client_id:str)->None:"""向边缘网关下发 closeRemoteConfig(平台长连接通道实现)"""# platform.send_command(client_id, "closeRemoteConfig", {})passdefjob():result=run_activity_check(frp,frp_server_id="1",close_remote_config_fn=close_remote_config)print(f"[{datetime.now():%H:%M:%S}]{result}")schedule.every(2).minutes.do(job)if__name__=="__main__":whileTrue:schedule.run_pending()time.sleep(1)五、两个问题的协作时序
运维人员点击「远程配置」 │ ▼ 平台 ──openRemoteConfig(remotePort=0)──▶ 边缘网关 frpc 建代理 │ ▼ 平台轮询 GET /api/proxies/{serial}-80(最多 3 次) │ ▼ 返回 http://公网IP:{动态端口} 给前端 │ ▼ 运维人员操作网关 Web 页(产生 todayTrafficIn 变化) │ ▼ 定时任务每 2 分钟检测流量 │ ├── 有变化 → 重置计时,保持穿透 │ └── 15 分钟无变化 → closeRemoteConfig → 代理删除,端口释放六、常见问题
Q1:remotePort=0后 API 一直查不到端口?
- 检查 frps 是否配置了
allowPorts端口池; - 确认边缘网关 frpc 已成功连接 frps(仪表盘可见 client);
- 适当加大轮询次数或间隔。
Q2:刚打开远程页就被自动关闭?
- 确认定时任务对
lastStartTime5 分钟内的新代理做了启动保护; - 首次检测只写缓存、不做空闲判定。
Q3:页面开着但流量不变,会被误关吗?
- 会。纯静态页面无请求时,流量确实可能不变。生产环境可结合前端心跳,或适当延长阈值(如 30 分钟)。
Q4:webServer API 要不要暴露公网?
- 不要。平台与 frps 同 VPC,走内网 IP 调用即可;仪表盘端口安全组仅放行运维 IP。
本篇小结
边缘网关临时远程运维场景下,
remotePort=0+ webServer API 反查,是动态端口的标准解法;GET /api/proxies/{name}获取实际端口,GET /api/proxy/tcp批量拉取流量统计;以
todayTrafficIn变化判断活跃,15 分钟无变化自动关闭,比看连接数更可靠;多代理按
clientID合并判定,新代理 5 分钟保护期避免误关;方案可直接移植到同类视频物联网平台的边缘网关远程运维场景。
系列导航
- 正篇回顾:建议从第一篇至第七篇顺序阅读
- 本篇为番外实战,聚焦边缘网关 + FRP 工程化,与正篇互补
💡 提示:若你的业务涉及批量边缘网关与视频设备接入,可了解畅联云平台——面向视频物联网的底层设备接入平台。若只需个人级 FRP 穿透,正篇教程已足够。