我没手动映射 3000,公网为什么还能访问?一次 UPnP 误开孔复盘
写在前面:标题里的“自己打开”只是当时的主观感受。路由器没有失控,也不存在神秘穿透。真正发生的是:排障自动化从局域网主动调用了 UPnP
AddPortMapping,路由器按协议新增了公网映射。
1. 原本的设计边界
家里的 Open WebUI 跑在一台 Ubuntu 主机的 Docker 中:
内网主机 192.168.x.x:3000
路由器上手动配置的入口是:
公网 TCP 13000 → 内网主机:3000
外部用户不直接访问家宽端口,而是先到云端 Caddy:
用户浏览器
→ https://ai.example.com (云端 Caddy)
→ http://home.example.com:13000 (DDNS → 家宽公网 IP)
→ 路由器手动端口映射
→ 内网主机:3000 (Open WebUI)
这里有一条必须写死的边界:
✅ 公网 13000 → 内网 3000
❌ 公网 3000 不开放
选择 13000 而不是裸 3000,只能减少一些无差别扫描噪声,不构成真正的安全控制。还要注意:架构图里的 HTTPS 只覆盖“浏览器 → 云端 Caddy”;如果 Caddy 仍通过公网 HTTP 回源家里,回源内容并未加密,而且公网 13000 仍可绕过 Caddy 被直接访问。更稳妥的方案是后端 TLS 或加密隧道,并在路由器/主机防火墙中只允许云端跳板源地址;应用认证、强密码和关闭公开注册仍然不可少。
2. 当天其实有两条互不相干的故障线
排障现场很乱,因为两个问题同时出现了。
出站问题:sing-box 被停
本机使用 sing-box TUN 做透明分流:国内直连,国外经自建 VPS 出口。
当时服务仍是 enabled,但运行状态已经是 inactive。结果是:
- Codex 刷新模型超时;
- grok 无法请求 xAI;
- agy 刷新 Google OAuth token 超时。
它们都没有单独设置代理环境变量,透明代理一停,就只能直连国外服务。
入站问题:路由器映射与实际暴露面需要核对
Open WebUI 在本机和局域网始终正常:
127.0.0.1:3000 → HTTP 200
192.168.x.x:3000 → HTTP 200
但这些结果只能证明应用和 LAN 正常,不能证明公网映射正常。公网必须从手机流量或异地 VPS 验证。
这两条线要严格分开:
sing-box TUN → 本机出站
路由器映射 → 公网入站
停止 sing-box 不能修复缺失的端口映射;新增公网映射也不能修复 AI 工具出站超时。
3. UPnP 到底是什么?它为什么能让路由器开端口
3.1 它不是一个单独协议,而是一套“局域网即插即用”机制
UPnP 是 Universal Plug and Play(通用即插即用) 的缩写。它的目标是让同一局域网里的设备和软件自动完成三件事:
发现彼此 → 描述自己能做什么 → 调用对方提供的控制动作
例如:
- 电视发现局域网媒体服务器;
- 音箱发现投屏设备;
- 游戏机发现路由器,并申请一个公网端口;
- P2P 软件让路由器把公网连接转发到本机。
因此,“开启 UPnP”不是简单开启某一个 TCP 端口,而是允许局域网中的 控制点(control point) 发现并调用 UPnP 设备(device) 暴露的服务。
在这次事故里:
控制点:Ubuntu 主机上的排障脚本
设备: 家用路由器
服务: Internet Gateway Device(IGD)里的 WANIPConnection
动作: AddPortMapping
3.2 它要解决的背景问题:NAT 阻止公网主动连接内网
家用网络通常使用 NAT:多台内网设备共享一个公网 IPv4 地址。
内网主动访问互联网时,路由器会记录连接状态,所以响应能够回来;但公网客户端不能凭空发起连接到 192.168.x.x:3000,因为私网地址在互联网中不可路由,路由器也不知道该把入站连接交给哪台设备。
要让公网访问内网服务,需要一条映射:
公网 IP:外部端口 → 内网 IP:内部端口
例如:
公网 IP:13000 → 192.168.x.x:3000
手动端口转发要求管理员登录路由器填写这条规则。UPnP IGD 则允许内网应用通过协议向路由器申请规则,省去人工配置。
3.3 一次 UPnP 开孔实际经过哪些步骤
完整过程通常分为四步。
第一步:用 SSDP 在局域网发现设备
控制点向 IPv4 组播地址发送 SSDP 搜索:
下面是一个简化但字段完整的 IPv4 搜索示例;报文通过 UDP 发往 239.255.255.250:1900:
M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
MAN: "ssdp:discover"
MX: 2
ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1
SSDP 是 Simple Service Discovery Protocol。它的任务只是询问:“局域网里有没有 Internet Gateway Device?”
路由器响应中会给出一个 LOCATION,例如:
LOCATION: http://192.168.x.1:5500/rootDesc.xml
这一步没有修改路由器配置,只是发现设备描述文件在哪里。
第二步:下载 XML 设备描述
控制点通过普通 HTTP 获取 rootDesc.xml。里面会列出:
- 路由器的设备类型与型号;
- 支持哪些 UPnP 服务;
- 每个服务的控制地址
controlURL; - 服务描述文件
SCPDURL。
家用路由器如果支持端口映射,通常会暴露 IGD 中的服务,例如:
urn:schemas-upnp-org:service:WANIPConnection:1
部分 PPP 场景或旧设备也可能使用 WANPPPConnection。客户端需要根据设备描述选择实际存在的服务,不能只猜固定 URL。
第三步:向控制地址发送 SOAP 动作
找到 WANIPConnection 的 controlURL 后,控制点通过 HTTP 发送 SOAP 请求。
SOAP 可以理解为“用 XML 表达远程函数调用”。这次调用的函数是:
AddPortMapping(...)
关键参数包括:
| 参数 | 含义 | 本次误操作 |
|---|---|---|
NewRemoteHost |
允许的远端主机;常见为空,表示不按远端地址限制 | 空字符串 |
NewExternalPort |
公网侧端口 | 3000 |
NewProtocol |
TCP 或 UDP | TCP |
NewInternalClient |
接收流量的内网主机 | 192.168.x.x |
NewInternalPort |
内网服务端口 | 3000 |
NewEnabled |
是否立即启用 | 1 |
NewPortMappingDescription |
映射说明 | 排障脚本填写的名称 |
NewLeaseDuration |
租期秒数 | 0,表示不按租期自动到期;跨重启行为取决于实现 |
路由器也可能因为端口冲突、策略限制、来源不合法或功能关闭而拒绝请求;UPnP 并不保证每次申请都会成功。
第四步:路由器修改 NAT/防火墙状态
请求被接受后,路由器会建立这条转发路径;它最终能否从公网访问,还取决于主机防火墙、服务监听、是否拥有可路由公网地址,以及是否存在上级 NAT/CGNAT:
公网客户端
→ 家宽公网 IP:3000
→ 路由器 NAT / 端口转发表
→ 192.168.x.x:3000
→ Open WebUI
所以 UPnP 并没有建立一条绕过路由器的秘密隧道。它做的是:请求路由器自己修改转发表,然后后续流量仍然经过路由器。
3.4 为什么它通常不要求输入路由器管理密码
传统家用 UPnP IGD 的安全模型通常是:
能进入可信 LAN 的设备,可以调用 LAN 内开放的 IGD 控制服务
很多家用实现不会对每次 AddPortMapping 弹窗,也不会要求 Web 管理员密码。原因是 UPnP 最初追求“即插即用”:如果每次游戏或语音通话都需要登录路由器确认,它就失去了便利性。
这意味着它依赖一个很强的前提:局域网内的设备和软件是可信的。
现实中这个前提并不总成立:
- 访客设备可能与主机处在同一 LAN;
- IoT 设备可能存在漏洞;
- 恶意软件可以调用本地网络接口;
- 自动化脚本可能像本次一样误解需求;
- 管理员可能只检查手动转发表,不检查 UPnP 动态表。
不同路由器实现可能增加来源限制、映射限制或更严格的授权,但不能默认所有设备都会这样做。
3.5 UPnP 映射与手动映射有什么区别
| 对比项 | 手动端口转发 | UPnP 动态映射 |
|---|---|---|
| 谁创建 | 管理员登录路由器 | 内网应用或设备 |
| 创建方式 | Web 管理页面 | SSDP + HTTP/SOAP 控制 |
| 是否常见地需要管理密码 | 是 | 通常不需要逐次输入 |
| 存放位置 | 手动/虚拟服务器表 | UPnP 动态映射表 |
| 生命周期 | 管理员删除前通常一直存在 | 取决于租期、应用清理和路由器实现 |
| 可见性 | 管理页面通常明显 | 可能藏在单独的 UPnP 页面 |
| 适合场景 | 固定服务器、明确长期入口 | 游戏、P2P、临时会话 |
两者最终都会改变公网入站路径,所以审计公网暴露面时必须把两张表取并集。
如果手动映射和 UPnP 申请相同的外部端口,路由器通常会拒绝冲突请求,或者按固件自己的优先级处理;不能假设它会安全地合并。
3.6 UPnP 不等于所有“打洞”技术
几个常被混用的概念实际上不同:
- UPnP IGD:内网客户端直接请求家用路由器创建映射;
- NAT-PMP / PCP:也是向网关申请映射,但协议和消息格式不同;
- STUN:帮助客户端知道自己在 NAT 外看到的地址和端口,本身通常不创建路由器映射;
- TURN:通过中继服务器转发流量;
- ICE:收集 host、server-reflexive、relay 等候选地址,用 STUN 做连通性检查,必要时选择 TURN 中继;
- 反向隧道 / frp / Cloudflare Tunnel:内网设备主动连接云端,再由云端转发入站流量。
本次事故只涉及 UPnP IGD 端口映射,没有使用反向隧道,也没有绕过上级 NAT。
如果家宽处于运营商 CGNAT 后面,UPnP 通常只能修改自己家路由器这一层 NAT,无法替运营商修改上级 NAT;即使 AddPortMapping 成功,公网也未必能直接访问。
3.7 应该关闭 UPnP 吗
没有统一答案,取决于环境。
可以关闭的情况:
- 家里主要运行固定服务器,所有入口都能手动管理;
- 不依赖游戏机、P2P 或实时通信自动开孔;
- 更重视最小公网暴露面。
需要保留的情况:
- 某些游戏、语音、远程设备明确依赖它;
- 管理员愿意定期审计动态映射;
- IoT、访客和服务器网络已经做了隔离。
如果保留,至少应做到:
- 定期查看路由器的 UPnP 动态映射表;
- 不让访客网络和不可信 IoT 随意访问可信 LAN;
- 公网服务仍然启用认证和最小权限;
- 不把数据库、开发调试端口、管理面板直接暴露公网;
- 自动化执行
AddPortMapping前必须显示外部端口、内部目标并取得确认; - 应用退出时清理自己创建的映射,但不能只依赖应用自觉清理。
3.8 如何只读检查,不再误开孔
优先使用不会修改状态的方法:
- 查看路由器管理页面中的“UPnP”“动态映射”或“端口映射列表”;
- 使用支持列表功能的工具,例如
upnpc -l(若已安装); - 通过 IGD 的
GetGenericPortMappingEntry或GetSpecificPortMappingEntry查询; - 从手机流量或异地 VPS 验证公网端口是否真的可达。
需要特别区分:
Get*PortMapping* → 查询,通常是只读
AddPortMapping → 新增或修改公网入口
DeletePortMapping → 删除公网入口
后两种会改变网络暴露面,属于高影响操作,不应在“只是看看”的排障过程中未经确认执行。
4. 真正的事故:排障自动化主动申请了 UPnP 映射
路由器开启了 UPnP。排障过程中,自动化误把需求理解成“公网需要直接访问 3000”,于是从内网调用了路由器的 WANIPConnection 服务,等价于执行:
AddPortMapping
RemoteHost = 空(不限制远端地址)
ExternalPort = 3000
Protocol = TCP
InternalClient = 192.168.x.x
InternalPort = 3000
LeaseDuration = 0
这里的 LeaseDuration = 0 表示映射不按租期自动到期,并不是“过一会儿自动消失”;它是否跨路由器重启保留取决于具体实现。它只是排障期间被临时创建,协议层面却可能长期存在。
路由器接受请求后,真实边界变成:
手动表:公网 13000 → 内网 3000
UPnP 表:公网 3000 → 内网 3000 ← 不该存在
从异地 VPS 访问公网 3000,确实返回了 Open WebUI 的 HTTP 200。
这不是绕过路由器。恰恰相反,是路由器按照 UPnP 协议主动修改了 NAT 转发表。问题在于:这项变更未经授权,而且手动端口转发页面未必会展示 UPnP 动态规则。
5. 为什么这件事很容易被误判成“穿透”
家用路由器常见两套入口配置:
- 虚拟服务器 / 端口转发:管理员手动创建;
- UPnP 动态映射:局域网应用通过协议申请。
只检查第一套,就会形成错误心智模型:
我没在页面上配置 3000,所以公网 3000 一定关闭。
真实情况却是:
实际公网暴露面 = 手动映射 ∪ UPnP 动态映射 ∪ 其他转发机制
UPnP 本来是为游戏机、P2P、音视频设备减少配置成本而设计的。它并不等于漏洞,但它把“谁能改变公网入口”的权限下放给了内网程序。一旦自动化脚本、恶意软件或误配置调用它,端口可以在没有明显提醒的情况下被打开。
6. 回滚过程
第一步:停止自动恢复
排障自动化不仅创建了映射,还一度添加了定时“自愈”任务。必须先停止并删除它,否则手动删除映射后还会再次出现。
第二步:删除 UPnP TCP 3000 映射
调用 DeletePortMapping 删除:
RemoteHost = 空
ExternalPort = 3000
Protocol = TCP
随后用 GetSpecificPortMappingEntry 查询,路由器返回“映射不存在”。
第三步:从公网复测
本次排障会话最终从异地 VPS 验证:
| 公网入口 | 结果 |
|---|---|
公网IP:3000 |
测试点连接超时、无响应 |
公网IP:13000 |
HTTP 200,仍到达 Open WebUI |
TCP 超时本身更接近“不可达或被过滤”,不等同于收到 RST 的严格 closed。结合路由器 GetSpecificPortMappingEntry 明确返回映射不存在,才能确认误建的 UPnP 3000 已删除,同时没有破坏原有的手动 13000 映射。
第四步:恢复出站代理
恢复 sing-box 后:
- grok 单轮请求返回
OK; - agy 成功刷新 OAuth token 并返回
OK; - Codex 返回
OK。
sing-box 当前启用了 Linux 推荐的:
"auto_route": true,
"auto_redirect": true
auto_redirect 用来改善 Linux、策略路由与 Docker bridge 的兼容性,它不会创建任何公网端口映射。
7. 公网测试为什么必须站在公网
在家里访问 DDNS 域名,流量可能经过 NAT 回环(hairpin NAT)再回到内网。不同路由器对此支持不一致,因此结果只能当旁证。
可靠测试顺序是:
1. curl 127.0.0.1:3000 → 应用是否存活
2. curl 192.168.x.x:3000 → LAN 是否可达
3. 异地 VPS / 手机流量访问 13000 → 公网手动映射是否正常
4. 异地 VPS 访问 3000 → 不该开放的端口是否真的关闭
5. 查询路由器手动表和 UPnP 表 → 解释公网观察
不要把“内网 IP 能打开”当作公网映射正常,也不要只看路由器手动列表就断言某端口未暴露。
8. 一份更可靠的排查清单
A. 应用层
- 进程或容器是否在运行?
- 是否监听 0.0.0.0,而不是只监听 127.0.0.1?
B. 局域网层
- 其他 LAN 设备能否访问内网 IP:端口?
- Docker 端口发布和宿主机防火墙是否正常?
C. 公网入站层
- DDNS 是否指向当前公网 IP?
- 手动端口映射是否启用、内外端口是否写反?
- UPnP 动态表里是否存在额外映射?
- 用异地 VPS或手机流量复测。
D. 本机出站层
- sing-box 是否 active + enabled?
- 国外 API 是否经预期出口访问?
每层只回答自己的问题。不要用停止出站代理来“修”公网入站,也不要用临时开放新端口来“证明”应用存活。
9. 最终状态
| 项目 | 状态 |
|---|---|
| Open WebUI 内网 | 192.168.x.x:3000 正常 |
| 唯一设计公网入口 | 13000 → 3000 |
| 公网 3000 | 无 UPnP 映射,且本次公网测试不可达 |
| sing-box | active + enabled,仅负责出站透明代理 |
| UPnP 3000 自愈任务 | 已删除 |
| 预期用户入口 | 云端 HTTPS 反向代理(仍应限制公网 13000 的来源) |
10. 真正的教训
- “手动列表里没有”不等于公网没有。 UPnP 是另一张动态转发表。
- 自动化拥有网络权限时,错误理解需求也会变成真实安全变更。 所有公网开孔都应先确认,再执行。
- 出站代理与入站 NAT 是两个平面。 同时故障不代表互为因果。
- 高位端口不是安全边界。 认证、访问控制和最小暴露才是。
- 公网结论必须从公网验证。 内网 curl、NAT 回环和路由器页面都不能单独定案。
尾声
当时最惊悚的感觉是:“我没开 3000,为什么公网能进?”
复盘后的准确表述应该是:
我没有手动配置 3000,但排障自动化通过 UPnP 请求路由器创建了动态映射;路由器不是被穿透,而是在执行一项不该被授权的配置变更。
把这句话说准,比把故事写得玄学更重要。
环境信息已匿名化:Ubuntu 家宽主机 + 桥接光猫 + 路由拨号 + Docker Open WebUI + 云端 Caddy + sing-box TUN。
日期:2026-07-14
陕公网安备61011302002223号