日期:2026-06-18
公开版说明:本文保留方案结构、访问链路和关键判断,真实域名、内网地址、Token、KV 名称和服务端口均已替换为示例值。
本文记录一套轻量的“直连回家”链路:家里路由器没有公网 IPv4,处在运营商 NAT 后面;不依赖公网 IPv6,也不把家里的路由器或 Kwrt 放进 DMZ。家里只保留一条可回连入口,外部端口变化时,用固定跳转入口兜住。
家里侧跑四个核心组件:
```text
natmap:负责打洞,并拿到当前外部可访问端口
ddns-go:负责把域名指向当前外部 IP
nginx:负责统一接收 HTTPS 请求,并反向代理到内网服务
acme.sh:通过 Cloudflare DNS-01 给域名签发和续期证书
```
Cloudflare Worker 和 KV 是辅助层,只负责把固定入口做成 302 跳转。外面访问时不需要记 natmap 当前映射出来的动态端口。
要解决的问题是:
```text
家里路由器没有公网 IPv4
natmap 能拿到外部 IP:端口,但端口会变
ddns-go 可以解决 IP 变化,但解决不了 URL 里端口变化
nginx 可以统一内网服务入口,但外面仍然需要知道映射端口
Worker 返回 302,把固定入口跳到当前可用的域名:动态端口
```
外部使用效果是:
```text
访问 https://go.example.com/nas
自动 302 到 https://nas.example.com:<当前映射端口>/
nginx 收到请求后按域名转发到 NAS
```
## 相关折腾记录
- [树莓派 Tailscale Exit 与 Xray 透明代理配置整理](https://aside0.me/raspberry-pi-tailscale-xray-2026-06-15/)
- [树莓派 Wi-Fi 热点接入 Xray:DNS 防污染和透明代理排查](https://aside0.me/raspberry-pi-wifi-hotspot-xray-dns-2026-06-19/)
- [Cloudflare Tunnel 通过 Xray 出口:cloudflared、nftables 和 sniffing 排查](https://aside0.me/cloudflared-tunnel-xray-sniff-nft-2026-06-17/)
## 方案边界
这不是 VPN,也不是 Cloudflare Tunnel,更不是把家里服务公开成稳定公网服务器。
它更适合这些场景:
- 自己在外面临时访问 NAS、下载器、Home Assistant、内网页面。
- 家里路由器没有公网 IPv4,处在运营商 NAT 后面。
- 不想依赖 IPv6。
- 不想开 DMZ。
- 家里到运营商之间的 NAT 行为适合 natmap,通常要接近 Full Cone / NAT-1。
- 可以接受外部端口偶尔变化。
- 愿意用一个固定跳转入口隐藏端口变化。
它不适合这些场景:
- 对外提供高稳定公共服务。
- 多人长期访问且不能接受入口短暂变化。
- 运营商 NAT 行为不允许外部回连。
- 家里链路里有一层或多层严格 NAT,natmap 拿到的映射外部无法访问。
- 目标服务没有登录保护,却想直接暴露到公网。
前提不是“拥有公网 IP”,而是 natmap 能维护一条外部可访问的 NAT 映射。外部最终访问的仍然是公网 `IP:port`,只是这个入口来自 NAT 映射,不能当成固定端口手工维护。
## 需要的设备和账号
这里不需要公网服务器,但家里要有一台长期在线的入口设备。本文按下面这套设备关系来写:
| 设备或账号 | 示例 | 作用 |
| --- | --- | --- |
| 外部访问设备 | 手机、笔记本、平板 | 人在外面时访问固定入口,例如 `https://go.example.com/nas` |
| DNS 和 Worker 平台 | Cloudflare 账号 | 托管 DNS、Worker、KV,并给 ddns-go 和 acme.sh 提供 DNS API |
| 家里上级网络设备 | 光猫、主路由、运营商网关 | 负责连接运营商网络;不需要 DMZ,也不需要公网 IPv6 |
| Kwrt / OpenWrt 设备 | 软路由、旁路设备、迷你主机 | 运行 natmap、nginx、ddns-go、acme.sh |
| 内网服务设备 | NAS、Home Assistant、code-server 主机 | 真正被访问的服务,不直接暴露在外面 |
示例地址关系:
```text
上级路由 LAN:192.168.10.1
Kwrt 地址:192.168.10.2
NAS 地址:192.168.10.10
Home Assistant 地址:192.168.10.20
code-server 地址:192.168.10.30
```
域名关系:
```text
固定入口域名:go.example.com
服务域名后缀:example.com
NAS 域名:nas.example.com
Home Assistant 域名:ha.example.com
code-server 域名:code.example.com
```
Cloudflare 上需要准备:
```text
一个 Zone:example.com
一条 Worker 路由或自定义域名:go.example.com
一个 KV namespace:nat_port
一个 Worker KV 绑定名:REDIRECT_CONFIG
一个 DNS API Token:用于 ddns-go 和 acme.sh DNS-01,权限限制在这个 Zone
```
开始配置前,先准备一个自己控制的域名,并把 DNS 托管到 Cloudflare。示例里的 `example.com` 只是占位,实际部署时换成自己的域名。后面的组件按这组域名分工:
```text
example.com:你的主域名,由 Cloudflare 托管 DNS
go.example.com:固定入口,只负责跑 Worker 和返回 302
nas.example.com / ha.example.com / code.example.com:真实服务域名,由 ddns-go 更新到当前外部 IP
```
这里刻意不用 `nas.home.example.com` 这种三级服务域名。Cloudflare 免费 Universal SSL 在完整 DNS 托管下通常覆盖 `example.com` 和 `*.example.com` 这一层,不覆盖 `*.home.example.com`。即使 DNS only 加源站 acme.sh 也能解决证书,读者也容易把 Cloudflare 边缘证书和源站证书混在一起。为了少踩坑,示例统一用 `nas.example.com`、`ha.example.com` 这种一层子域名。
不要把 `go.example.com` 和 `nas.example.com` 混成一个域名。前者是“入口导航”,后者是“真实目标”。用户记住 `go.example.com/nas`,浏览器最后访问 `nas.example.com:<natmap 当前端口>`。
`go.example.com` 可以走 Cloudflare Worker。`nas.example.com`、`ha.example.com`、`code.example.com` 这些最终访问域名建议保持 DNS only,不开橙云代理。最终地址会带 natmap 的动态端口,浏览器需要直接连回家里的外部 `IP:port`;Cloudflare 代理不是这套链路的数据通道。
如果 `go.example.com` 用 Worker Custom Domain,Cloudflare 会把这个主机名直接接到 Worker,并处理边缘证书。如果用 Worker Route,则要确认 `go.example.com` 的 DNS 记录能命中 Cloudflare 代理和这条 Route。为了少绕弯,本文后面默认按 Custom Domain 来写。
## 家里网络拓扑
家里侧不是把整台 Kwrt 暴露出去,而是只让 natmap 维护一条入口映射。nginx 继续监听本地 `443`,natmap 绑定一个稳定的内侧端口,例如 `8443`,再通过 NATMap forward mode 或路由器端口转发把流量送到 nginx `443`。nginx 再把请求分发给内网服务。
```mermaid
flowchart LR
isp["运营商 NAT 网络<br/>家里无公网 IPv4"]
router["光猫 / 主路由<br/>普通 NAT"]
kwrt["Kwrt / OpenWrt<br/>192.168.10.2"]
natmap["natmap<br/>绑定 8443"]
nginx["nginx<br/>监听 443"]
nas["NAS<br/>192.168.10.10:5000"]
ha["Home Assistant<br/>192.168.10.20:8123"]
code["code-server<br/>192.168.10.30:8080"]
isp --> router
router --> kwrt
kwrt --> natmap
natmap --> nginx
nginx --> nas
nginx --> ha
nginx --> code
```
外部访问时,Cloudflare 只参与“入口跳转”和“DNS 解析”:
```mermaid
flowchart LR
user["外部设备"]
worker["go.example.com<br/>Cloudflare Worker"]
dns["Cloudflare DNS<br/>nas.example.com"]
nat["运营商 NAT 映射<br/>外部 IP:动态端口"]
natmap["Kwrt natmap<br/>绑定 8443"]
nginx["Kwrt nginx<br/>本地 443"]
service["内网服务"]
user -->|访问固定入口| worker
worker -->|302 到 nas.example.com:动态端口| user
user -->|查询域名| dns
user -->|直接访问外部 IP:动态端口| nat
nat --> natmap
natmap --> nginx
nginx --> service
```
这条链路里有两类域名:
```text
go.example.com:固定入口,走 Cloudflare Worker,负责 302
nas.example.com / ha.example.com / code.example.com:真实服务域名,DNS only,最终直接连回家
```
如果 Kwrt 不是最外层路由,而是接在光猫或主路由后面,上级设备不需要做 DMZ。把 Kwrt 的地址固定下来,只给这条入口链路保留必要的转发或放行规则。实际环境里有两种常见形态:
```text
Kwrt 是主路由:
运营商 NAT 映射 -> Kwrt natmap 8443 -> nginx 443
Kwrt 在上级路由后面:
运营商 NAT 映射 -> 上级路由端口转发 -> Kwrt natmap 8443 -> nginx 443
```
第二种情况下,上级路由只处理到 Kwrt 的必要入口,不把整台 Kwrt 放进 DMZ。入口是否能从外面打回来,最终还是以 natmap 实际拿到的映射和外网测试为准。
## 涉及的软件和分工
每个软件只负责一小段。打洞、证书、反代、域名和入口跳转分开以后,哪个环节出问题,就查哪个环节。
| 软件或服务 | 主要功能 | 在这里怎么用 | 解决的问题 |
| --- | --- | --- | --- |
| Kwrt / OpenWrt | 常驻运行环境 | 跑 nginx、natmap、ddns-go 和 acme.sh | 家里需要一台长期在线、低功耗、网络位置稳定的入口设备 |
| nginx | HTTPS 入口和反向代理 | 监听本地 `443`,根据 `server_name` 把请求转发到 NAS、Home Assistant、code-server 等内网服务 | 内网服务端口多、协议杂,外面不应该直接记一堆内网地址 |
| acme.sh | 自动申请和续期证书 | 使用 Cloudflare DNS API 做 DNS-01,签发 `example.com` 和 `*.example.com`,证书安装给 nginx | 没有固定公网 80/443 时,仍然能拿到可信 HTTPS 证书 |
| ddns-go | 更新动态外部 IP | 在 Kwrt 上常驻运行,检测当前外部 IP,并通过 Cloudflare DNS API 更新 `nas.example.com`、`ha.example.com` 等服务域名 | 家宽 IP 会变化,域名需要自动跟着变 |
| natmap | NAT 打洞和端口发现 | 绑定一个本地入口端口,例如 `8443`,拿到外部 `IP:动态端口`,并在端口变化时触发 callback | 没有固定公网端口,但需要外部能连回家里的 nginx |
| Cloudflare Worker | 固定入口和 302 跳转 | 接收 `https://go.example.com/nas` 这类固定地址,返回 302 到当前 `nas.example.com:动态端口` | natmap 端口会变,用户不应该每次手动记新端口 |
| Cloudflare KV | 保存当前动态端口 | Worker 的 `/update-port` 接口把 natmap 当前端口写入 KV,访问时再读出来 | Worker 是无状态的,需要一个地方保存最新端口 |
| Cloudflare DNS | 域名解析和 DNS-01 验证 | 管理 `go.example.com`、服务域名、ACME TXT 记录和 DDNS A 记录 | 域名解析、证书验证、固定入口都需要 DNS 支撑 |
本文里的 DDNS 明确使用 `ddns-go`。它本身支持 Cloudflare,可以在网页里配置域名、Token、同步间隔和日志,不需要再写一套 Cloudflare DNS 更新脚本。
## 它们怎么配合
先看配置阶段:
```text
acme 通过 DNS-01 签证书
-> 证书安装到 nginx
-> nginx 用域名区分不同内网服务
-> ddns-go 让这些域名指向当前外部 IP
-> natmap 把本地入口端口 8443 映射成外部动态端口
-> natmap callback 把当前端口写进 Worker KV
```
再看外部访问阶段:
```text
外部设备访问 go.example.com/nas
-> Worker 读取 KV 里的当前端口
-> Worker 返回 302 到 nas.example.com:<当前端口>
-> 浏览器解析 nas.example.com,拿到 DDNS 更新后的外部 IP
-> 浏览器访问 外部 IP:<当前端口>
-> NAT 映射把连接送到 Kwrt 的入口端口
-> 入口端口再转给 nginx 443
-> nginx 按 Host 转发到内网 NAS
```
这条链路里,DDNS 和 Worker 解决的是不同问题:
```text
ddns-go 负责“现在应该连哪个 IP”
Worker/KV 负责“现在应该连哪个端口”
nginx 负责“这个域名应该转到哪个内网服务”
acme.sh 负责“HTTPS 证书是否可信”
natmap 负责“外部连接能不能打回本地入口端口”
```
Cloudflare Worker 不承载内网流量。它只在最开始返回一次 302。真正的数据流是浏览器直接访问 `nas.example.com:<动态端口>`,再通过 natmap 维护的 NAT 映射回到 Kwrt 的入口端口,最后进入 nginx。
## 日常怎么使用
正常使用时,不需要看 natmap 当前端口。外面只访问固定入口:
```text
https://go.example.com/nas
https://go.example.com/ha
https://go.example.com/code
```
Worker 会把它们跳到当前可用的动态端口:
```text
https://nas.example.com:<当前端口>/
https://ha.example.com:<当前端口>/
https://code.example.com:<当前端口>/
```
如果 natmap 重新打洞后端口变了,callback 更新 KV,下一次访问固定入口时就会跳到新端口。用户侧仍然只记 `go.example.com/<服务名>`。
新增一个内网 Web 服务时,一般只需要做三件事:
1. 在 nginx 里新增一个 `server_name` 和 `proxy_pass`。
2. 确认证书覆盖这个新域名,通配符证书通常已经覆盖。
3. 把新服务域名加进 ddns-go 或 Cloudflare DNS,例如 `photo.example.com`。
4. 按固定入口路径访问,例如 `https://go.example.com/photo`。
只要还复用同一个 nginx `443` 入口,就不需要为每个服务重新跑一条 natmap,也不需要为每个服务维护一个动态端口。
## 示例环境
本文按 Kwrt 或类似 OpenWrt 设备来写。实际部署时,名字不重要,关键是这台机器要能稳定运行 natmap、nginx、acme.sh 和 ddns-go。
示例环境:
```text
入口设备:Kwrt
内网地址:192.168.10.2
nginx 本地监听:443
natmap 绑定端口:8443
本地转发目标:127.0.0.1:443;如果 nginx 在另一台内网机器上,就写那台机器的内网 IP
服务域名后缀:example.com
固定跳转入口:go.example.com
Cloudflare Worker KV 绑定名:REDIRECT_CONFIG
```
Kwrt 上至少需要这些东西:
```text
nginx:监听 443,做 HTTPS 反代
acme.sh:签发和续期证书
natmap:维护外部动态端口映射
ddns-go:维护 Cloudflare DNS 里的动态 A 记录
curl:供 natmap callback 调用 Worker 更新端口
```
内网服务示例:
```text
nas.example.com -> 192.168.10.10:5000
ha.example.com -> 192.168.10.20:8123
code.example.com -> 192.168.10.30:8080
```
推荐把所有 Web 服务统一放到 nginx 后面。natmap 只维护一个入口端口,外部也只需要一个动态映射端口。不同服务由 nginx 根据 `server_name` 分发。
## 1. nginx 做统一 HTTPS 入口
nginx 的角色不是打洞,而是把家里的多个服务整理成标准 HTTPS 站点。
一个典型配置是:
```nginx
server {
listen 443 ssl http2;
server_name nas.example.com;
ssl_certificate /etc/nginx/certs/example.com/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/example.com/privkey.pem;
location / {
proxy_pass http://192.168.10.10:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
```
如果服务需要 WebSocket,还要补上:
```nginx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```
多个服务可以写多个 `server`:
```nginx
server_name nas.example.com;
server_name ha.example.com;
server_name code.example.com;
```
外面访问时虽然 URL 里带的是动态端口,但 TLS SNI 和 HTTP Host 仍然是 `nas.example.com` 这类域名。nginx 仍然能按域名选证书和反向代理规则。
## 2. acme.sh 用 Cloudflare DNS 验证签证书
这套模式里,不建议依赖 HTTP-01 验证。
原因很简单:HTTP-01 需要外部稳定访问 80 端口,但这里入口端口是 natmap 映射出来的动态端口,且家里没有固定公网入口。更稳的做法是 DNS-01。
这里使用 `acme.sh` 的 Cloudflare DNS 插件。Cloudflare API Token 至少需要这个 Zone 的 DNS 编辑权限;为了让工具能定位 Zone,通常还要给 Zone 读取权限。Token 不要给全账号全 Zone 权限。
示例环境变量:
```sh
export CF_Token="<cloudflare-dns-api-token>"
export CF_Account_ID="<cloudflare-account-id>"
```
签发通配符证书:
```sh
acme.sh --issue --dns dns_cf \
-d example.com \
-d '*.example.com'
```
然后把证书安装给 nginx:
```sh
acme.sh --install-cert -d example.com \
--key-file /etc/nginx/certs/example.com/privkey.pem \
--fullchain-file /etc/nginx/certs/example.com/fullchain.pem \
--reloadcmd "nginx -s reload"
```
通配符证书适合这里。以后新增 `photo.example.com`、`download.example.com` 这类服务,不需要每个服务都重新走一遍证书签发。
注意,Cloudflare DNS API Token 不要写进公开脚本,也不要放进 nginx 配置。它只应该出现在 acme.sh、ddns-go 这类需要操作 DNS 的工具配置里,并限制在当前 Zone 的 DNS 权限内。
## 3. ddns-go 负责动态 IP
DDNS 只负责一件事:把真实服务域名解析到当前外部 IP。本文使用 `ddns-go`,DNS 服务商选择 Cloudflare。
Cloudflare DNS 里建议这样设计:
```text
go.example.com -> Worker 自定义域名或 Worker 路由
nas.example.com -> A 记录,DNS only,内容是当前外部 IP
ha.example.com -> A 记录,DNS only,内容是当前外部 IP
code.example.com -> A 记录,DNS only,内容是当前外部 IP
```
`go.example.com` 是固定入口,可以走 Cloudflare Worker。`nas.example.com`、`ha.example.com`、`code.example.com` 是最终访问目标,建议保持 DNS only,不开代理。否则浏览器最后访问 `https://nas.example.com:<动态端口>/` 时,Cloudflare 可能不会按这个动态端口代理,链路也会变成另一套模型。
ddns-go 可以直接跑在 Kwrt 上。常见用法有两种:
```text
方式一:安装 luci-app-ddns-go,通过 OpenWrt 页面配置
方式二:下载 ddns-go 二进制,安装成系统服务,然后访问 Web UI 配置
```
如果用二进制方式,基本流程是:
```sh
./ddns-go -s install
```
然后在浏览器里打开:
```text
http://<kwrt-ip>:9876
```
在 ddns-go 页面里配置:
```text
DNS 服务商:Cloudflare
Token:<cloudflare-dns-api-token>
IPv4:启用
IPv4 获取方式:通过公网接口、网卡、命令或 ddns-go 支持的检测方式
IPv4 域名:nas.example.com,ha.example.com,code.example.com
TTL:按 Cloudflare 实际支持值设置,自动或较短 TTL 都可以
同步间隔:默认 5 分钟即可,想更快发现变化可以再调小
```
ddns-go 官方支持通过网页配置,也支持以服务方式运行;它默认会定期同步,并能在页面里查看最近日志。在这套链路里,它比自己写 Cloudflare API 脚本更直观,出错时也更容易看日志。
ddns-go 的管理页面只应该在内网使用,不要把 `9876` 端口通过 natmap 或 nginx 暴露出去。它是运维面,不是给外部访问的服务入口。
外部 IP 的来源有两种。更严谨的是使用 natmap 实际报告的 mapped address,因为那才是外部会访问到的地址。如果你的环境里 natmap 映射地址和 ddns-go 检测到的 IPv4 一致,就可以直接用 ddns-go 的普通 IPv4 检测。配置完成后要从外网验证 `nas.example.com` 解析出来的地址确实是 natmap 映射所在的外部地址。
本文推荐让 ddns-go 分别维护服务域名:
```text
nas.example.com
ha.example.com
code.example.com
```
每加一个服务,就把新域名加进 ddns-go 或 Cloudflare DNS。这样比直接把 `*.example.com` 全部指回家里更保守,也不容易和 `go.example.com` 这个 Worker 入口混在一起。
如果你确认 wildcard 记录不会影响其他子域名,也可以用 `*.example.com` 做 DNS only 的 A 记录。本文不把它作为默认写法。
ddns-go 更新完成后,外部访问 `nas.example.com` 会找到正确的外部 IP,但端口仍然是动态的。端口要交给 natmap callback 和 Worker/KV。
## 4. natmap 负责动态端口
natmap 的作用是维护一条 NAT 映射,并告诉我们当前外部可以访问的 `公网 IP:公网端口`。它不是反向代理,也不是证书工具。它只解决一个问题:
```text
外面现在应该打到哪个 IP:端口,才能回到家里的入口端口?
```
这里有三个端口容易混在一起:
```text
公网动态端口:natmap 从外部看到的端口,例如 32451
natmap 绑定端口:家里入口侧的稳定端口,例如 8443
nginx 服务端口:真正提供 HTTPS 的端口,例如 443
```
浏览器最后访问的是公网动态端口:
```text
https://nas.example.com:32451/
```
但家里路由器和 Kwrt 里配置的是内侧稳定端口:
```text
natmap bind:8443
nginx listen:443
本地转发:8443 -> 443
```
也就是说,Worker/KV 里保存的是 `32451`,内侧转发规则处理的是 `8443 -> 443`。这两个端口不是同一个东西。
### NATMap 的两种接法
第一种是 NATMap forward mode。natmap 自己监听绑定端口,并把流量转发到 nginx:
```text
外部动态端口 -> natmap 8443 -> nginx 127.0.0.1:443
```
官方 CLI 形态类似这样:
```sh
natmap -4 -s turn.cloudflare.com -h example.com \
-b 8443 \
-t 127.0.0.1 \
-p 443 \
-e /etc/natmap/update-worker-port.sh
```
这里几个参数的含义是:
```text
-b 8443:natmap 绑定的本地入口端口
-4:只做 IPv4 映射
-h example.com:NATMap 用来建立 TCP 映射的外部 HTTP 主机,不是 nginx 的目标域名
-t 127.0.0.1:转发目标地址,这里是本机 nginx
-p 443:转发目标端口,这里是 nginx HTTPS
-e 脚本:映射建立或变化后通知外部地址和端口
```
`-h example.com` 是占位写法。实际部署时可以换成一个稳定可访问的外部 HTTP 主机;它只服务于 NATMap 探测和保活,不参与浏览器访问你的 NAS。
如果 nginx 不在 Kwrt 本机,而是在内网另一台机器上,就把 `-t` 改成那台机器的内网 IP:
```sh
natmap -4 -s turn.cloudflare.com -h example.com \
-b 8443 \
-t 192.168.10.50 \
-p 443 \
-e /etc/natmap/update-worker-port.sh
```
第二种是 NATMap bind mode 加防火墙 DNAT,适合 nginx 在 LAN 里的另一台机器上。natmap 只负责建立和维持映射,不负责转发业务流量;业务流量交给路由器防火墙转发:
```text
外部动态端口 -> Kwrt 防火墙 8443 -> nginx 192.168.10.50:443
```
NATMap 只绑定端口:
```sh
natmap -4 -s turn.cloudflare.com -h example.com \
-b 8443 \
-e /etc/natmap/update-worker-port.sh
```
然后在路由器端口转发里配:
```text
协议:TCP
外部端口:8443
内部 IP:192.168.10.50
内部端口:443
```
如果是 OpenWrt/Kwrt 页面,大概对应:
```text
Network -> Firewall -> Port Forwards
Source zone:wan
External port:8443
Destination zone:lan
Internal IP address:192.168.10.50
Internal port:443
Protocol:TCP
```
如果 nginx 就在 Kwrt 本机,普通 “Port Forwards” 页面通常不是最合适的写法,因为它主要面向转发到 LAN 主机。这时优先用 NATMap forward mode,让 NATMap 把 `8443` 转给本机 `127.0.0.1:443`。也可以用防火墙 redirect/input 规则处理本机 `8443 -> 443`,但要明确这是本机重定向,不是转发到 LAN。不要为了省事把 Kwrt 放进 DMZ。
### 和上级路由端口转发怎么配合
如果 Kwrt 前面还有一台光猫或主路由,有两种情况。
优先让 NATMap 跑在最靠外的那台 OpenWrt/Kwrt 上。NATMap 绑定端口、端口转发和 nginx 入口都在同一台设备或同一套路由规则里,排查最简单。
如果 NATMap 跑在 Kwrt,而 Kwrt 前面还有上级路由,就要保证上级路由收到入口流量后能送到 Kwrt。这里仍然不需要 DMZ,只配一条明确的端口转发:
```text
上级路由 WAN 8443 -> Kwrt 192.168.10.2:8443
Kwrt natmap/forward mode 8443 -> nginx 443
```
不要默认写成“上级路由直接转到 Kwrt 443,natmap 只 bind 8443”。这种写法把 NATMap 绑定端口和业务入口端口拆开了,对上级路由、端口保持和本机防火墙行为更敏感。实际使用时,优先选下面这条:
```text
上级路由转发到 Kwrt 8443
Kwrt 上 natmap 再转发到本机 nginx 443
```
按这种方式分工:
```text
上级路由:只知道 8443 要给 Kwrt
natmap:负责发现公网动态端口,并接住 8443
nginx:只管 HTTPS 和反向代理
Worker:只管把固定入口 302 到公网动态端口
```
注意,路由器端口转发里配置的 `8443` 是内侧稳定入口端口,不是 NATMap 发现出来的公网动态端口。公网动态端口可能是 `32451`、`41023` 或其他值,它只写进 Worker/KV,给外部访问使用。
### 回调怎么更新 Worker/KV
NATMap 映射建立后会调用通知脚本。官方脚本参数里会带 public address 和 public port。我们只需要把 public port 写进 Worker/KV。
示例脚本:
```sh
#!/bin/sh
set -eu
PUBLIC_ADDR="$1"
PUBLIC_PORT="$2"
PRIVATE_PORT="$4"
PROTO="$5"
curl -fsS -X POST https://go.example.com/update-port \
-H 'Authorization: Bearer <update-token>' \
-H 'Content-Type: application/json' \
-d "{\"port\":\"${PUBLIC_PORT}\",\"key\":\"home\"}"
```
脚本里 `PUBLIC_PORT` 才是外部访问要用的端口。`PRIVATE_PORT` 通常是 natmap 的绑定端口,比如 `8443`,不是浏览器最终 URL 里的端口。
如果你用的是 OpenWrt 插件或第三方包装脚本,界面里可能会把回调写成类似:
```sh
curl -X POST https://go.example.com/update-port \
-H 'Authorization: Bearer <update-token>' \
-H 'Content-Type: application/json' \
-d '{"port": "%MAPPED_PORT%", "key": "home"}'
```
不管具体变量名叫 `$2`、`%MAPPED_PORT%` 还是别的,核心都一样:把 NATMap 当前发现的公网动态端口写到 Worker/KV。
### 验证 NATMap 和端口转发
验证时不要先从固定入口测。先拆开看:
```text
natmap 日志里能看到 public address 和 public port
Worker KV 里已经更新成这个 public port
上级路由或 Kwrt 的端口转发规则存在
外网直接 curl https://nas.example.com:<public port>/ 能到 nginx
nginx access.log 能看到请求
```
只有这些都通了,再测:
```text
https://go.example.com/nas
```
如果固定入口失败,但直接访问 `nas.example.com:<public port>` 成功,问题在 Worker/KV 或 302。
如果直接访问 `nas.example.com:<public port>` 也失败,问题在 NATMap 映射、上级路由端口转发、防火墙或 nginx。
## 5. Gist / Worker / KV 固定入口怎么用
这个 Gist 不是直接拿来访问的服务。Gist 只是保存了一份 Cloudflare Worker 模板和一个更新端口的 `curl` 示例。真正运行的是你部署到 Cloudflare 的 Worker。
这层要解决的问题是:
```text
natmap 的公网端口会变化
ddns-go 只能更新 IP,不能更新 URL 里的端口
用户不想每次打开 natmap 日志找端口
Worker 读取 KV 里的当前端口,再返回 302
```
最终访问规则是:
```text
https://go.example.com/nas
-> 302 https://nas.example.com:<当前端口>/
https://go.example.com/ha/lovelace
-> 302 https://ha.example.com:<当前端口>/lovelace
```
这里的路径第一段会变成目标子域名:
```text
/nas -> nas.example.com
/ha/lovelace -> ha.example.com/lovelace
/code/?folder=x -> code.example.com/?folder=x
```
### 这个 Gist 里有什么
Gist 地址:
```text
https://gist.github.com/leconio/76ba824a97fb005f2bc3924d6e6dacae
```
里面三个文件的用途是:
```text
worker.js:部署到 Cloudflare Worker 的主逻辑
post.sh:手动 POST 更新端口的示例
README.md:原始使用说明
```
`worker.js` 做两件事:
```text
POST /update-port:验证 Bearer Token,把新端口写入 KV
GET /<service>:读取 KV 里的端口,返回 302 到 <service>.<TARGET_DOMAIN>:<port>
```
### 在 Cloudflare 上准备 Worker 和 KV
Cloudflare 侧要准备三样东西:
```text
Worker:运行 Gist 里的 worker.js
KV namespace:保存当前公网动态端口,例如 nat_port
自定义域名或路由:让 go.example.com 命中这个 Worker
```
用 Cloudflare 控制台操作时,顺序可以是:
```text
1. 进入 Workers & Pages,新建一个 Worker。
2. 把 Gist 里的 worker.js 复制进去。
3. 先改 SECRET_TOKEN、WEB_DOMAIN、TARGET_DOMAIN。
4. 创建一个 Workers KV namespace,例如 nat_port。
5. 到 Worker 的 Settings / Bindings 里添加 KV binding。
6. Binding name 填 REDIRECT_CONFIG,namespace 选 nat_port。
7. 到 Domains & Routes 里添加 go.example.com。
8. 部署 Worker。
```
如果这里用的是 Custom Domain,`go.example.com` 不需要再指向家里的 DDNS 记录。它是 Worker 的入口,不是回家链路的真实服务域名。
KV namespace 建好后,要绑定到 Worker,绑定名必须和代码里一致:
```text
REDIRECT_CONFIG
```
Gist 里的代码会调用:
```js
await REDIRECT_CONFIG.put(newKey ?? 'nat1', newPort.toString());
await REDIRECT_CONFIG.get(keyValue);
```
如果 Cloudflare 控制台里的绑定名不是 `REDIRECT_CONFIG`,Worker 会找不到 KV。要么绑定名保持 `REDIRECT_CONFIG`,要么同步改代码。
Worker 的入口域名可以用 Cloudflare Workers 的 Custom Domain,也可以用 Route。这里建议直接把 `go.example.com` 绑定到这个 Worker,后续排查更直观。
### 修改 Worker 里的三个值
复制 Gist 里的 `worker.js` 后,至少要改这三个常量:
```js
const SECRET_TOKEN = "<update-token>";
const WEB_DOMAIN = "go.example.com";
const TARGET_DOMAIN = "example.com";
```
含义分别是:
```text
SECRET_TOKEN:natmap 更新端口时用的 Bearer Token
WEB_DOMAIN:固定入口域名,也就是 Worker 自己的域名
TARGET_DOMAIN:真实服务域名后缀。Worker 会把 `/nas` 拼成 `nas.example.com`
```
这里的 `SECRET_TOKEN` 不是 Cloudflare API Token。它只是你自己定义的一串更新密码,用来防止别人随便 POST 改你的跳转端口。
### KV 里保存什么
KV 里只保存端口,不保存完整 URL:
```text
key: home
value: 32451
```
如果只有一条 natmap 入口,`key` 可以固定用 `home` 或 `nat1`。如果以后有多条入口,例如家里、办公室、备用线路,就可以用不同 key:
```text
home -> 32451
office -> 41023
backup -> 28001
```
访问时可以用查询参数指定 key:
```text
https://go.example.com/nas?key=home
```
如果不传 `key`,Gist 里的代码默认用 `nat1`。如果你想默认用 `home`,可以把代码里的默认值从 `nat1` 改成 `home`,或者 natmap 回调时也写入 `nat1`。
### 先手动测试更新端口
在接 natmap callback 前,先手动 POST 一次,确认 Worker 和 KV 是通的:
```http
POST https://go.example.com/update-port
Authorization: Bearer <update-token>
Content-Type: application/json
{
"port": "32451",
"key": "home"
}
```
也可以用命令:
```sh
curl -X POST https://go.example.com/update-port \
-H 'Authorization: Bearer <update-token>' \
-H 'Content-Type: application/json' \
-d '{"port":"32451","key":"home"}'
```
如果成功,Worker 会返回类似:
```text
Port updated successfully
```
然后测试 302:
```sh
curl -I 'https://go.example.com/nas?key=home'
```
期望看到:
```http
HTTP/1.1 302 Found
Location: https://nas.example.com:32451/
Cache-Control: no-store, max-age=0
```
看到这个 302 后,Worker、KV、域名和 Token 基本可用。
原始 Gist 模板可以完成跳转,但它不会校验端口是否为空、是否是数字、是否在 `1-65535` 范围内。公开使用前,建议至少补一个端口校验;如果 KV 里还没有端口,Worker 应该返回 404 或 503,而不是拼出 `:null` 这种地址。
### 再接 NATMap 回调
手动测试通过后,把前面 NATMap 小节里的回调脚本接到 `natmap -e` 参数或插件通知脚本里,不需要再写第二套逻辑。只检查三件事:
```text
POST 地址是 https://go.example.com/update-port
Authorization 里的 Bearer Token 和 Worker 里的 SECRET_TOKEN 一致
提交的 port 是 NATMap 公网动态端口,不是 8443
```
`key` 也要和手动测试一致。上面示例用的是 `home`:
```text
https://go.example.com/nas?key=home
```
如果保留 Gist 默认的 `nat1`,就让回调写入 `nat1`,或者访问时不传 `key`。每次 NATMap 发现公网端口变化,Worker/KV 里的端口都会更新,固定入口会跳到新端口。
### 这层不提供安全防护
Worker 这里只是固定入口和跳转层,不是访问控制层。它不会隐藏最终目标,也不会替 nginx 或 NAS 做登录保护。浏览器收到 302 后,会直接访问:
```text
https://nas.example.com:<当前端口>/
```
真正的安全边界仍然在目标服务本身、nginx 鉴权、访问控制和你是否公开这个固定入口。
`Cache-Control: no-store` 也要保留。这个跳转地址里包含动态端口,如果浏览器或中间缓存把旧端口记住,端口变化后会出现“KV 已更新但访问还跳旧端口”的错觉。
## 为什么不直接把端口写进 DDNS
DNS 本身不适合表达普通 HTTPS URL 的端口。
可以更新:
```text
nas.example.com -> 当前外部 IP
```
但不能靠普通 A/AAAA 记录表达:
```text
nas.example.com -> 当前外部 IP:当前端口
```
浏览器访问 HTTPS 时默认还是 443。除非用户手动输入 `:端口`,否则它不会从普通 DNS 记录里知道 natmap 当前映射端口。
这里拆成两层:
```text
DDNS 解决 IP
Worker/KV 解决端口
```
这就是固定入口存在的意义。
## 为什么 nginx 要放在 natmap 后面
如果每个内网服务都单独跑 natmap,会很快变乱:
```text
NAS 一个动态端口
Home Assistant 一个动态端口
code-server 一个动态端口
下载器一个动态端口
每个端口都要更新和记忆
```
用 nginx 聚合之后,natmap 只关心一个本地端口:
```text
外部动态端口 -> natmap 8443 -> nginx 443
nginx 再按域名分发到不同内网服务
```
运维面会简单很多。新增服务时,通常只需要:
1. 新增一个 nginx `server_name`。
2. 新增一个内网 `proxy_pass`。
3. 确认通配符证书覆盖这个域名。
4. 通过固定入口访问新的路径。
natmap 和 Worker 不需要因为每个 Web 服务都新增一套。
## 验证方式
验证必须在外网做。手机关掉 Wi-Fi,用移动网络访问固定入口。
第一步,看 Worker 是否 302:
```sh
curl -I https://go.example.com/nas
```
期望看到:
```text
HTTP/2 302
location: https://nas.example.com:<当前端口>/
cache-control: no-store, max-age=0
```
第二步,看 ddns-go 维护的 DNS 是否指向当前外部 IP:
```sh
dig +short nas.example.com
```
第三步,直接访问跳转后的地址:
```sh
curl -I https://nas.example.com:<当前端口>/
```
第四步,看 nginx 日志:
```sh
tail -f /var/log/nginx/access.log
tail -f /var/log/nginx/error.log
```
第五步,看目标服务日志,确认请求真的到了内网服务,而不是只到了 nginx。
## 踩过的坑
### 1. 只配 DDNS 还不够
DDNS 只能让域名找到当前外部 IP。natmap 的外部端口仍然要单独处理。没有 302 或其他入口包装时,用户每次都要手动记端口。
### 2. 内网访问成功不代表外网成功
在家里 Wi-Fi 下访问 `nas.example.com`,可能走了内网 DNS、NAT loopback 或路由器本地优化。这不能证明外网可用。
验收必须用移动网络。
### 3. HTTP-01 证书验证不适合这套链路
HTTP-01 依赖固定 80 入口。这里没有稳定公网 80,入口端口又是动态的。证书建议用 DNS-01。
### 4. Worker 只是跳转,不是代理
Worker 返回 302 后,浏览器会直接访问 `nas.example.com:<端口>`。后续流量不经过 Worker。
Worker 不会隐藏真实目标地址,也不会提升 natmap 链路稳定性。它只是把“找当前端口”这件事自动化。
### 5. nginx 的 Host 和 WebSocket 要处理好
很多内网 Web 应用依赖 Host、Scheme 或 WebSocket。如果反代配置太简略,会出现登录失败、跳转错域名、页面加载但实时连接失败等问题。
### 6. 不要暴露没有鉴权的服务
natmap 打出来的是外部可访问入口。只要别人知道域名和端口,就能尝试访问。固定入口虽然可以不公开,但它不是访问控制。
管理类服务至少要有自己的登录保护。更敏感的服务应该再加一层 Basic Auth、访问白名单或额外认证。
## 推荐操作顺序
先把内网链路做好:
```text
内网访问 nginx -> nginx 反代到目标服务
```
再把证书做好:
```text
acme DNS-01 签发通配符证书 -> nginx 正常加载 HTTPS
```
然后配置 ddns-go:
```text
nas.example.com / ha.example.com / code.example.com 指向当前外部 IP
```
再做 natmap:
```text
本地入口 8443 映射出外部动态端口,并转给 nginx 443
```
最后接 Worker:
```text
natmap callback 更新 KV
go.example.com/<service> 302 到 <service>.example.com:<动态端口>
```
按这个顺序排查,问题会落在单独一层。不要一开始就把 ddns-go、证书、natmap、Worker 全部串起来测;串起来之后再失败,很难判断是哪一层坏了。
## 回滚方式
如果 natmap 或 Worker 跳转不稳定,优先回滚到内网 nginx,不要动证书和内网服务。
回滚思路:
```text
停止 natmap
保留 nginx 和 acme
保留 ddns-go 管理的 DNS 记录
Worker 固定入口临时返回维护提示或 404
内网服务继续只在家里可用
```
如果只是端口更新出问题,可以先不改 nginx:
```text
手动 POST 当前端口到 /update-port
确认 KV 更新
curl -I 固定入口
确认 Location 是否变成新端口
```
这套链路层次比较清楚。natmap、ddns-go、nginx、acme.sh、Worker 各自负责一件事,出问题时可以单独验证、单独回滚。
## 参考
- NATMap 上游项目:`https://github.com/heiher/natmap`
- ddns-go 上游项目:`https://github.com/jeessy2/ddns-go`
- acme.sh Cloudflare DNS 插件:`https://github.com/acmesh-official/acme.sh/blob/master/dnsapi/dns_cf.sh`
- 当前使用的 Worker/KV 302 模板来自公开 Gist:`https://gist.github.com/leconio/76ba824a97fb005f2bc3924d6e6dacae`
- 模板里的思路是:natmap callback 更新端口,Cloudflare Worker 读取 KV,然后返回一次 302。
- Cloudflare Universal SSL:`https://developers.cloudflare.com/ssl/edge-certificates/universal-ssl/`
- Cloudflare Universal SSL 限制:`https://developers.cloudflare.com/ssl/edge-certificates/universal-ssl/limitations/`
- Cloudflare Workers Custom Domains:`https://developers.cloudflare.com/workers/configuration/routing/custom-domains/`
- Cloudflare Workers bindings:`https://developers.cloudflare.com/workers/runtime-apis/bindings/`
文章评论