我没手动映射 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 动作

找到 WANIPConnectioncontrolURL 后,控制点通过 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、访客和服务器网络已经做了隔离。

如果保留,至少应做到:

  1. 定期查看路由器的 UPnP 动态映射表;
  2. 不让访客网络和不可信 IoT 随意访问可信 LAN;
  3. 公网服务仍然启用认证和最小权限;
  4. 不把数据库、开发调试端口、管理面板直接暴露公网;
  5. 自动化执行 AddPortMapping 前必须显示外部端口、内部目标并取得确认;
  6. 应用退出时清理自己创建的映射,但不能只依赖应用自觉清理。

3.8 如何只读检查,不再误开孔

优先使用不会修改状态的方法:

  • 查看路由器管理页面中的“UPnP”“动态映射”或“端口映射列表”;
  • 使用支持列表功能的工具,例如 upnpc -l(若已安装);
  • 通过 IGD 的 GetGenericPortMappingEntryGetSpecificPortMappingEntry 查询;
  • 从手机流量或异地 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. 为什么这件事很容易被误判成“穿透”

家用路由器常见两套入口配置:

  1. 虚拟服务器 / 端口转发:管理员手动创建;
  2. 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. 真正的教训

  1. “手动列表里没有”不等于公网没有。 UPnP 是另一张动态转发表。
  2. 自动化拥有网络权限时,错误理解需求也会变成真实安全变更。 所有公网开孔都应先确认,再执行。
  3. 出站代理与入站 NAT 是两个平面。 同时故障不代表互为因果。
  4. 高位端口不是安全边界。 认证、访问控制和最小暴露才是。
  5. 公网结论必须从公网验证。 内网 curl、NAT 回环和路由器页面都不能单独定案。

尾声

当时最惊悚的感觉是:“我没开 3000,为什么公网能进?”

复盘后的准确表述应该是:

我没有手动配置 3000,但排障自动化通过 UPnP 请求路由器创建了动态映射;路由器不是被穿透,而是在执行一项不该被授权的配置变更。

把这句话说准,比把故事写得玄学更重要。


环境信息已匿名化:Ubuntu 家宽主机 + 桥接光猫 + 路由拨号 + Docker Open WebUI + 云端 Caddy + sing-box TUN。
日期:2026-07-14

Read more

把 Codex CLI 的登录态"搬"到一台新服务器

场景:你在一台老机器上早就登录好了 Codex CLI,现在开了台新服务器、装好了 codex,但它没登录。你不想在新机上重新走一遍 OAuth 网页授权(有时候服务器上根本打不开浏览器),只想把老机器上那份"已经登录好的身份"复制过去。 这篇讲的就是这个搬运动作的完整方法论——为什么能搬、怎么搬、有哪些坑。命令里所有隐私都用占位符,照着换成你自己的即可。 一、先理解一件事:Codex 的登录就是一个文件 这是整个操作的地基。Codex CLI(ChatGPT OAuth 登录模式下)的登录状态,不在什么系统钥匙串里,也不在环境变量里,就是家目录下一个单独的 JSON 文件: ~/.codex/auth.json 它长这样(字段名是真的,值我打码了): { "auth_mode": "

By ladydd

哨兵机制:让 Agent 一触即醒

0. 一句话点破本质 **让"等"发生在便宜的子进程里,让贵的 agent 只在有事时醒。**心跳解决"最迟多久必有人查岗",探针解决"事情一发生几乎立刻有人到场"——两个机制回答的是两个不同的问题,谁也替代不了谁。 1. 机制全貌:会自杀的轮询进程 + 宿主的"尸体通知" 我的实现只有两块积木: 积木一:一个有明确死法的后台循环 # 放行任务的同时,后台挂上(run_in_background) for i in $(seq 1 20); do 信号=$(ssh data "tmux capture-pane -t dna

By ladydd

Agent 心跳机制·设计与实现

0. 一句话点破本质 **心跳不是闹钟,是"带着完整世界快照的自我唤醒"。**闹钟只解决"什么时候醒";心跳真正要解决的是你点出的那个问题——醒来的那个瞬间,清楚自己是谁、任务到哪了、这一跳该干什么。我所有跑得好的心跳,提示词都写得像给一个失忆的陌生人看的;所有出过事的心跳,都是因为假设"我还记得"。 1. 第一性原理:为什么"醒来知道干啥"这么难 一个长期任务里的 agent 面临三重失忆: 1. 上下文会被压缩——多轮之后早期细节只剩摘要,心跳打进来时,那条心跳提示词可能是上下文里唯一高保真的任务描述 2. 世界在你睡着时变了——下属可能干完了、卡死了、跑偏了,你脑子里的"进度"从睡着那刻就开始过期 3. 任务本身会变—

By ladydd

公网一度只剩 18000:透明代理、Docker 与端口映射该怎么分锅

先说结案:“为什么当时只剩 18000”没有找到可以被证据确认的最终根因。 故障形态在后续复测时已经消失,现场又缺少故障时刻的路由器配置快照、UPnP 表和双向抓包。本文记录的是已排除什么、还缺什么证据,而不是宣布一个并未证明的答案。 0. 三条结案结论 结论一:AI 工具超时的根因已经确认 sing-box 当时是 enabled,但实际状态为 inactive。因此 Codex、grok、agy 等程序直连国外服务并超时。恢复 sing-box 后,三者单轮请求均成功。 sing-box 被停止 → AI/开发工具出站超时 这条证据完整,已经结案。 结论二:公网 3000 意外开放的根因也已经确认 排障自动化误调用了路由器 UPnP AddPortMapping,临时创建了: 公网 3000 → 内网 Open WebUI :3000

By ladydd
陕公网安备61011302002223号 | 陕ICP备2025083092号