Loading...

文章背景图

用一台小 VPS 把家「接」进公网:WireGuard 组网与转发设计

2026-10-14
6
- 字
- 分钟
|

上一篇写了网站「时好时坏」的排查过程,其中反复出现的一条链路是:外部流量 → 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 │
                    └──────────────────────────────┘

四个要点:

  1. 所有节点都是主动连 hub,因为家里和手机都在 NAT 后面,谁也连不上谁。这样 hub 只需要一个固定端口对外,其它节点连端口都不用开。
  2. 家里那条链路 PersistentKeepalive = 25,保证 NAT 映射不会被回收(否则隧道看着"在",实际已经失效)。
  3. 组网网段用 10.66.0.0/24,每个节点一个 /32,最小权限。
  4. 把"访问内网段"也写进 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

读一遍逻辑:

  1. 从隧道进来的连接,在 conntrack 上打标记 0x200;
  2. 这些连接的回包,无论从本机发出还是转发给局域网设备,都把标记恢复到包上;
  3. 带标记的包走 rule 100 → table 200,而 table 200 的默认路由是指回隧道的;
  4. 没标记的流量老老实实走宽带默认路由。

这样既做到了"谁从隧道来的,回复也走隧道"(源 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 → 生成二维码 → 手机扫码导入。全程不用登录服务器。

踩坑清单

  1. resolvconf: signature mismatch:wg-quick 在部分发行版上处理 DNS= 会失败,直接报错起不来。删掉 DNS= 行即可(或者用 Table = off + 自己管路由)。
  2. wg set 不写路由:运行时添加 peer 只影响 peer 列表,路由要自己补,补完记得持久化。
  3. DNAT 别把自己转发掉:一定要把 WireGuard 端口(和 hub 自身的管理端口)排除在转发范围外。
  4. Docker 的 DOCKER-USER 顺序:放行规则要插链首。
  5. rp_filter:严格模式下反向路径校验会把隧道流量判为异常而丢包,跨网段组网时放宽它(net.ipv4.conf.all.rp_filter = 0)并清楚自己在做什么。
  6. 手机端做分流:AllowedIPs 只写 10.66.0.0/24 + 家里的 LAN 段,其它流量直连。既省电省流量,也避免把手机的日常上网都塞进家里的宽带上行。
  7. 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)。把这三件事想明白,剩下就是十行左右的配置和几个开机单元。

如果只记一句话:隧道不是"通了就行",而是"每个方向、每个包、每个源地址都要有明确的归属"。

原创

用一台小 VPS 把家「接」进公网:WireGuard 组网与转发设计

本文链接: 用一台小 VPS 把家「接」进公网:WireGuard 组网与转发设计

本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

评论交流

文章目录