上一篇写了网站「时好时坏」的排查过程,其中反复出现的一条链路是:外部流量 → CDN 边缘 → 云端 hub → WireGuard 隧道 → 家里。很多朋友问这套组网是怎么搭的,这篇就把它完整拆一遍。
核心问题只有一句话:家里宽带在 CGNAT 后面,没有公网 IP,但我又需要从公网访问家里的服务。
需求与约束
- 家里跑着博客、Git、MC 服务器、NAS,需要从公网访问(网站走 HTTPS,游戏走自定义 TCP/UDP 端口);
- 手机、笔记本在外面要能访问家里的内网设备(NAS、打印机、路由器管理页);
- 还有一台异地的服务器(机房里的 Manjaro),希望它的内网段也能被组网内其他设备访问;
- 约束:不能改运营商、不想买公网 IP、云上只保留一台最便宜的 VPS、不能把家里的内网随便暴露出去。
为什么是 WireGuard
- 内核态实现,吞吐和延迟都很好;
- 配置就是几个文本文件,没有复杂的握手协议,出问题一眼能看懂;
- 漫游友好:手机从 Wi-Fi 切到 5G、公网 IP 变了,连接几秒内自动恢复(这对"手机随时连回家"太重要了);
- 端口只有一个 UDP,防火墙规则极简。
拓扑:hub-and-spoke
┌──────────────────────────────┐
│ 云端 hub(一台小 VPS) │
│ 公网 IP,10.66.0.1 │
│ WireGuard 51820/udp │
│ nftables:DNAT + NAT + MSS │
└───────┬───────────┬──────────┘
│ │
主动连出(NAT 后面) │ │ 主动连出(NAT 后面)
│ │
┌─────────────────────┘ └──────────────────────┐
│ │
┌─────▼──────────────────────┐ ┌────────▼─────────┐
│ 家里(CGNAT,10.66.0.2) │ │ 手机(10.66.0.3) │
│ Debian:反代 + 各类服务 │ └──────────────────┘
│ 内网 192.168.22.0/24 │
└────────────────────────────┘
┌──────────────────────────────┐
│ 异地服务器(10.66.0.4) │
│ 机房 Manjaro,192.168.2.0/24 │
└──────────────────────────────┘
四个要点:
- 所有节点都是主动连 hub,因为家里和手机都在 NAT 后面,谁也连不上谁。这样 hub 只需要一个固定端口对外,其它节点连端口都不用开。
- 家里那条链路
PersistentKeepalive = 25,保证 NAT 映射不会被回收(否则隧道看着"在",实际已经失效)。 - 组网网段用
10.66.0.0/24,每个节点一个/32,最小权限。 - 把"访问内网段"也写进 AllowedIPs(家里挂
192.168.22.0/24、异地挂192.168.2.0/24),这样组网里的设备可以直接访问对方的 LAN,而不用在对方机器上再开口子。
设计一:hub 只做"接线板",公网入口用 DNAT
hub 的职责非常单一:把公网进来的流量全部丢给家里那台机器,自己不解析、不代理、不缓存。
table ip wireguard_nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "eth0" tcp dport { 1-52221, 52223-65535 } dnat to 10.66.0.2
iifname "eth0" udp dport { 1-51819, 51821-65535 } dnat to 10.66.0.2
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "eth0" ip saddr 10.66.0.0/24 masquerade
}
}
两个细节值得说:
- 端口范围里故意挖了两个洞:UDP 挖掉
51820(WireGuard 自己的端口,被转发掉就等于自己砍断隧道),TCP 挖掉一个保留端口(hub 自己的管理通道不能也被转发走)。 - postrouting 的 masquerade 只针对
oifname eth0,也就是"组网设备上网"这一件事。注意它没有对 wg0 方向做 SNAT——这正是下一个设计的伏笔。
整个配置文件持久化成一个 nft 文件,由 systemd 单元在开机时加载,不依赖任何面板工具。
设计二:真实客户端 IP 透传(connmark + 策略路由)
这是整套方案里最容易被忽略、但体验差异最大的一处。
问题
按常规做法,hub 会把去往家里的流量 SNAT 成 10.66.0.1。结果就是:家里的反代、博客、Git 看到的所有访客 IP 全是 10.66.0.1——评论防刷、封禁、访问日志、风控全部失效。
做法
hub 侧:对 wg0 方向不做任何 SNAT(上面的 nft 已经展示了),保留源 IP。
家里侧:只处理"回程"。家里的 wg0 配置里:
[Interface]
Address = 10.66.0.2/24
Table = off # 不接管默认路由,本机出网仍走宽带
PostUp = ip route replace 10.66.0.0/24 dev wg0
# 来自隧道的新连接打标记
PostUp = iptables -t mangle -A PREROUTING -i wg0 -j CONNMARK --set-mark 0x200
# 回程包把标记恢复到包上
PostUp = iptables -t mangle -A PREROUTING -m connmark --mark 0x200 -m conntrack --ctdir REPLY -j CONNMARK --restore-mark
PostUp = iptables -t mangle -A OUTPUT -m connmark --mark 0x200 -m conntrack --ctdir REPLY -j CONNMARK --restore-mark
# 带标记的包查独立的表
PostUp = ip rule add fwmark 0x200 table 200 priority 100
PostUp = ip route add default via 10.66.0.1 dev wg0 table 200
[Peer]
PublicKey = <hub 公钥>
Endpoint = <hub IP>:51820
AllowedIPs = 0.0.0.0/0 # 只用来描述"这条隧道能承载什么",不接管路由
PersistentKeepalive = 25
读一遍逻辑:
- 从隧道进来的连接,在 conntrack 上打标记
0x200; - 这些连接的回包,无论从本机发出还是转发给局域网设备,都把标记恢复到包上;
- 带标记的包走
rule 100 → table 200,而 table 200 的默认路由是指回隧道的; - 没标记的流量老老实实走宽带默认路由。
这样既做到了"谁从隧道来的,回复也走隧道"(源 IP 得以保留),又没有把家里整台机器变成全局代理。hub 上看,链路上的源 IP 就是访客真实 IP;家里后端日志里也是。
设计三:隧道 → 内网的转发
组网通了以后,手机上还是访问不到 NAS——因为家里的 Debian 默认不会转发"隧道 → 局域网"的流量,而且 Docker 还会插一脚。
这就是 wg-lan-access 这个 systemd 单元存在的原因,它做三件事:
# 1) 去内网的流量做源地址伪装:内网设备会把回复交给 Debian,再由隧道送回去
iptables -t nat -A POSTROUTING -s 10.66.0.0/24 -o ens18 -j MASQUERADE
if iptables -L DOCKER-USER >/dev/null 2>&1; then
# 2) 放行 隧道 → 内网(必须插在 DOCKER-USER 链的最前面)
iptables -I DOCKER-USER 1 -i wg0 -s 10.66.0.0/24 -d 192.168.22.0/24 -j ACCEPT
# 2b) 放行 内网 → 隧道的回包;少了这条,只有 Debian 本机能通,其它 LAN 设备一律不通
iptables -I DOCKER-USER 2 -i ens18 -s 192.168.22.0/24 -d 10.66.0.0/24 \
-m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
fi
# 3) 放行隧道客户端访问本机服务(反代、SSH、面板)
iptables -I INPUT 1 -i wg0 -s 10.66.0.0/24 -j ACCEPT
三个坑,全是踩出来的:
- Docker 的
DOCKER-USER链会挡掉转发。 Docker 会往里面塞规则,还有一条针对wg0的 DROP。必须把放行规则 插在链首(-I ... 1),追加(-A)在后面等于没写。 DOCKER-USER是 Docker 启动后才创建的,开机时脚本可能比 Docker 更早执行。所以脚本里有个最多 30 秒的等待循环,systemd 单元也声明了After=docker.service。- 回程必须单独放行。 只放行
wg0 → LAN是不够的:内网设备回的包走到 Debian 时,命中LAN → wg0这条路径,没有被放行就被丢掉了。表现就是"能 ping 通 Debian 本身,但 ping 不通同一网段的 NAS"。
设计四:MTU 与 MSS(一个真实的故障)
WireGuard 封装有额外开销(IPv4 下约 60 字节),所以隧道两端 wg0 的 MTU 用 1420。但只设 MTU 是不够的——这正是上一篇里那个"偶发 524"的根因:
家里容器在 1500 字节的 Docker 网桥上,后端回给 CDN 边缘的 SYN-ACK 会声明 MSS 1460;而这条连接实际要穿过 1420 的隧道(最大只允许 MSS 1380)。边缘按 1500 字节发包,进隧道塞不下,带 DF 位时直接丢,CDN 又通常不理 ICMP 的 PMTU 通知——一个标准的 PMTU 黑洞,表现是小请求正常、稍大响应随机卡死。
修法是在 hub 上做 MSS 钳制:
table inet mssclamp {
chain forward {
type filter hook forward priority mangle; policy accept;
iifname "wg0" tcp flags syn tcp option maxseg size set 1380 counter
oifname "wg0" tcp flags syn tcp option maxseg size set 1380 counter
}
}
用固定值 1380 而不是 rt mtu:后者只会按"出口路由的 MTU"改,方向一换就不对了。带 counter 是为了能自证——规则计数器在涨,就说明它真的在拦;再在出向网卡抓一次 SYN-ACK,看到 MSS 从 1460 变成 1380,才算修好。
同一个 systemd 单元负责开机加载,和 NAT 规则分开管理,互不影响。
运维:peer 管理不上云、也不手工改
新增设备(比如给朋友开一个)在传统做法里要在服务器上手工生成密钥、改配置、重启 wg——容易出错,也容易把线上配置改坏。
这里用一个小工具(wg-gen-web:一个 Go 二进制 + 预构建前端)做面板:
- 面板只监听隧道地址(
10.66.0.1:8080),公网完全看不到; - 数据落在一个 JSON 目录里;
- 关键在同步脚本:它只做"加法"——对比出面板里有、
wg里没有的 peer,用wg set添加上去,再wg syncconf生效;绝不重写/etc/wireguard/wg0.conf。
最后一条是刻意的设计:我自己在 wg0.conf 里写的 PostUp/策略路由是这套方案的命根子,任何"面板接管配置"的行为都可能把它们抹掉。工具可以方便,但不能碰生产配置。
新增设备的流程因此变成:面板里填个名字 → 分配一个 10.66.0.x → 生成二维码 → 手机扫码导入。全程不用登录服务器。
踩坑清单
resolvconf: signature mismatch:wg-quick在部分发行版上处理DNS=会失败,直接报错起不来。删掉DNS=行即可(或者用Table = off+ 自己管路由)。wg set不写路由:运行时添加 peer 只影响 peer 列表,路由要自己补,补完记得持久化。- DNAT 别把自己转发掉:一定要把 WireGuard 端口(和 hub 自身的管理端口)排除在转发范围外。
- Docker 的
DOCKER-USER顺序:放行规则要插链首。 rp_filter:严格模式下反向路径校验会把隧道流量判为异常而丢包,跨网段组网时放宽它(net.ipv4.conf.all.rp_filter = 0)并清楚自己在做什么。- 手机端做分流:
AllowedIPs只写10.66.0.0/24+ 家里的 LAN 段,其它流量直连。既省电省流量,也避免把手机的日常上网都塞进家里的宽带上行。 - DHCP 地址会变:家里那台机器是 DHCP,重启后地址可能变,所以运维脚本里做了地址探测(依次试几个常见地址),而不是死记一个 IP。
安全取舍
- 每个 peer 只给
/32,不给整段; - 公网只开一个 UDP 端口(自己的管理端口另算),其它服务一律不对公网暴露;
- 管理面板只绑隧道地址,公网访问不到;
- 异地服务器那条链路加了预共享密钥(PSK)作为额外的对称加密层;
- 内网设备的访问权限由
wg-lan-access精确到"哪个源段去哪个目标段",不是全放通。
成本
先给数字(均为实际支出):
| 项目 | 支出 | 说明 |
|---|---|---|
| 云端 VPS | 250 元/年(约 21 元/月) | 一台入门配置的小机器,100M 带宽,只跑 WireGuard + nftables 转发 |
| 组网软件 | 0 元 | WireGuard 与那套 Peer 管理面板都是开源软件,自己部署 |
| 家里的机器电费 | ≈0 元增量 | 功耗约 30W,但这台机器本来就 7×24 跑着博客、Git、MC 服务器,不加这套方案也是开着的 |
| 域名 | 既有支出 | 网站本来就要用 |
对比运营商侧的方案:固定公网 IP / 专线这类产品按 900 元/月算,一年就是 1.08 万元。本方案一年 250 元,一年省下约 1.05 万元——成本只有对方的 1/43。
如果一定要把家里那台机器的电费单独计入:30W × 24 小时 × 365 天 ≈ 263 度电,按 0.55 元/度估算约 145 元/年。即便这样算,整套「伪公网 IP」方案的年成本也只有 250~400 元,而且用的是手上已有的硬件。
带宽方面:VPS 给的是 100M,家里宽带上行受 CGNAT 那点带宽限制,但网站回源基本是文本和小文件(实测首页与各类接口都在几百毫秒内返回);真正吃带宽的是 Minecraft 这类回源流量,那属于家里上行的物理条件,不是这套设计的问题。
结语
WireGuard 的配置本身很简单,真正花时间的是三件事:谁主动连谁(NAT 后面的拓扑)、回程怎么走(真实 IP 与策略路由)、包能不能塞进去(MTU/MSS)。把这三件事想明白,剩下就是十行左右的配置和几个开机单元。
如果只记一句话:隧道不是"通了就行",而是"每个方向、每个包、每个源地址都要有明确的归属"。