PO0 到 RFC 再到多落地:一次 nftables 转发链路学习记录

3123 字
16 分钟
PO0 到 RFC 再到多落地:一次 nftables 转发链路学习记录
Abstract

这篇是一次网络转发配置的学习记录。目标不是公开一份可直接照抄的生产配置,而是把「客户端只连入口机,入口机再转到中继机,中继机再转到多台落地机」这条链路里的关键概念整理清楚。文中的 IP 使用 192.0.2.0/24198.51.100.0/24203.0.113.0/24 这些文档保留地址,端口和机器名也都做了替换。

这篇在整个系列里更偏实践复盘:默认你已经知道 DNAT、SNAT、masquerade 和 iptables 主机防火墙的大概分工。基础概念可以先看后面拆出来的两篇:

背景#

最开始我理解的 IP 中转非常简单:

客户端 -> 中转机:入口端口 -> 落地机:服务端口

中转机上写一组 nftables 规则,入口端口做 DNAT 到落地机,再用 SNAT 或 masquerade 保证回包回到中转机。这个模型能跑起来,但当落地机变多、协议从 Snell 切到 SS2022、客户端希望只记一个入口地址时,就需要更清晰的分层。

这次整理后的结构是:

flowchart LR C["客户端"] --> P["PO0 入口机<br/>只暴露统一公网入口"] P --> R["RFC 中继机<br/>负责二级分发"] R --> D0["RFC 本机 SS2022"] R --> D1["落地 1 SS2022"] R --> D2["落地 2 SS2022"] R --> D3["落地 3 SS2022"] R --> D4["落地 4 SS2022"]

这里的 PO0RFC 都是化名。它们不解析代理协议,只做四层转发。真正的 SS2022 服务运行在 RFC 本机和多台落地机上。

脱敏后的地址规划#

为了避免泄露真实环境,下面统一使用占位信息。

角色示例地址作用
PO0 公网地址203.0.113.10客户端唯一填写的服务器地址
PO0 到 RFC 的内网地址10.0.0.10PO0 转发到 RFC 时使用的源地址
RFC 公网地址198.51.100.10二级中继,也是落地机看到的来源
落地 1192.0.2.11SS2022 服务
落地 2192.0.2.12SS2022 服务
落地 3192.0.2.13SS2022 服务
落地 4192.0.2.14SS2022 服务

端口也用示例值:

线路客户端连接地址客户端连接端口最终服务
RFC 直出203.0.113.1050000RFC 本机 50000
落地 1203.0.113.1051001192.0.2.11:51001
落地 2203.0.113.1051002192.0.2.12:51002
落地 3203.0.113.1051003192.0.2.13:51003
落地 4203.0.113.1051004192.0.2.14:51004

核心原则是:客户端永远只填写 PO0 的公网地址。后面 RFC 和落地机怎么调整,不应该暴露给客户端。

SS2022 服务层#

SS2022 服务只需要跑在真正的服务节点上,也就是 RFC 本机和各个落地机。PO0 不需要安装代理服务。

关键配置大概长这样:

{
"server": "0.0.0.0",
"server_port": 51001,
"password": "REPLACE_WITH_RANDOM_SECRET",
"method": "2022-blake3-aes-128-gcm",
"mode": "tcp_and_udp"
}

这里有三个点容易漏:

  1. 每条线路的 password 要对应最终服务节点自己的密码,不是 PO0 或 RFC 的统一密码。
  2. mode 要确认支持 tcp_and_udp,否则 UDP relay 后面一定会出问题。
  3. SS2022 有时间戳校验,服务端时间不同步时,可能表现为 TCP 连接建立后立刻被断开。

PO0 第一跳转发#

PO0 做的事情只有一件:把客户端访问 PO0 的端口转到 RFC,并把源地址改成 PO0 到 RFC 这条路径上的内网地址。

转发方向:

PO0:50000 -> RFC:50000
PO0:51001 -> RFC:51001
PO0:51002 -> RFC:51002
PO0:51003 -> RFC:51003
PO0:51004 -> RFC:51004

先打开内核转发:

Terminal window
apt update
apt install -y nftables conntrack tcpdump
systemctl enable --now nftables
cat <<'EOF' > /etc/sysctl.d/99-po0-forward.conf
net.ipv4.ip_forward = 1
net.ipv4.tcp_mtu_probing = 1
net.netfilter.nf_conntrack_max = 65536
EOF
sysctl --system

再写 nftables。这里不用真实 IP,全部是示例值:

#!/usr/sbin/nft -f
define WAN_IF = "eth0"
define PO0_LAN_IP = 10.0.0.10
define RFC_IP = 198.51.100.10
define PORT_DIRECT = 50000
define PORT_LAND1 = 51001
define PORT_LAND2 = 51002
define PORT_LAND3 = 51003
define PORT_LAND4 = 51004
destroy table inet ss2022_po0_mangle
destroy table ip ss2022_po0_nat
destroy table inet ss2022_po0_filter
table inet ss2022_po0_mangle {
chain forward {
type filter hook forward priority -150; policy accept;
ip daddr $RFC_IP tcp dport {
$PORT_DIRECT,
$PORT_LAND1,
$PORT_LAND2,
$PORT_LAND3,
$PORT_LAND4
} tcp flags syn tcp option maxseg size set 1360
}
}
table ip ss2022_po0_nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname $WAN_IF meta l4proto { tcp, udp } th dport $PORT_DIRECT dnat to $RFC_IP:$PORT_DIRECT
iifname $WAN_IF meta l4proto { tcp, udp } th dport $PORT_LAND1 dnat to $RFC_IP:$PORT_LAND1
iifname $WAN_IF meta l4proto { tcp, udp } th dport $PORT_LAND2 dnat to $RFC_IP:$PORT_LAND2
iifname $WAN_IF meta l4proto { tcp, udp } th dport $PORT_LAND3 dnat to $RFC_IP:$PORT_LAND3
iifname $WAN_IF meta l4proto { tcp, udp } th dport $PORT_LAND4 dnat to $RFC_IP:$PORT_LAND4
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip daddr $RFC_IP meta l4proto { tcp, udp } th dport {
$PORT_DIRECT,
$PORT_LAND1,
$PORT_LAND2,
$PORT_LAND3,
$PORT_LAND4
} snat to $PO0_LAN_IP
}
}
table inet ss2022_po0_filter {
chain forward {
type filter hook forward priority 0; policy drop;
ct state invalid drop
ct state established,related accept
ct status dnat ip daddr $RFC_IP meta l4proto { tcp, udp } th dport {
$PORT_DIRECT,
$PORT_LAND1,
$PORT_LAND2,
$PORT_LAND3,
$PORT_LAND4
} ct state new accept
}
}

加载前先检查语法:

Terminal window
nft -c -f /etc/nftables.conf
nft -f /etc/nftables.conf
nft list ruleset

如果系统的 nftables 不支持 destroy table,不要把 2>/dev/null 这种 shell 重定向写进 nft 配置文件。正确做法是在 shell 里先删表:

Terminal window
nft delete table inet ss2022_po0_mangle 2>/dev/null || true
nft delete table ip ss2022_po0_nat 2>/dev/null || true
nft delete table inet ss2022_po0_filter 2>/dev/null || true
nft -c -f /etc/nftables.conf
nft -f /etc/nftables.conf

RFC 第二跳转发#

RFC 的职责有两部分:

  1. 50000 端口由 RFC 本机 SS2022 服务直接处理。
  2. 5100151004 继续转发到不同落地机。

转发方向:

RFC:51001 -> 192.0.2.11:51001
RFC:51002 -> 192.0.2.12:51002
RFC:51003 -> 192.0.2.13:51003
RFC:51004 -> 192.0.2.14:51004

RFC 同样要打开 IPv4 转发:

Terminal window
apt update
apt install -y nftables conntrack tcpdump
systemctl enable --now nftables
cat <<'EOF' > /etc/sysctl.d/99-rfc-forward.conf
net.ipv4.ip_forward = 1
net.ipv4.tcp_mtu_probing = 1
net.netfilter.nf_conntrack_max = 65536
EOF
sysctl --system

RFC 的 nftables 模板:

#!/usr/sbin/nft -f
define OUT_IF = "eth0"
define PO0_LAN_IP = 10.0.0.10
define LAND1_IP = 192.0.2.11
define LAND1_PORT = 51001
define LAND2_IP = 192.0.2.12
define LAND2_PORT = 51002
define LAND3_IP = 192.0.2.13
define LAND3_PORT = 51003
define LAND4_IP = 192.0.2.14
define LAND4_PORT = 51004
destroy table inet ss2022_rfc_mangle
destroy table ip ss2022_rfc_nat
destroy table inet ss2022_rfc_filter
table inet ss2022_rfc_mangle {
chain forward {
type filter hook forward priority -150; policy accept;
ip daddr $LAND1_IP tcp dport $LAND1_PORT tcp flags syn tcp option maxseg size set 1360
ip daddr $LAND2_IP tcp dport $LAND2_PORT tcp flags syn tcp option maxseg size set 1360
ip daddr $LAND3_IP tcp dport $LAND3_PORT tcp flags syn tcp option maxseg size set 1360
ip daddr $LAND4_IP tcp dport $LAND4_PORT tcp flags syn tcp option maxseg size set 1360
}
}
table ip ss2022_rfc_nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
ip saddr $PO0_LAN_IP meta l4proto { tcp, udp } th dport $LAND1_PORT dnat to $LAND1_IP:$LAND1_PORT
ip saddr $PO0_LAN_IP meta l4proto { tcp, udp } th dport $LAND2_PORT dnat to $LAND2_IP:$LAND2_PORT
ip saddr $PO0_LAN_IP meta l4proto { tcp, udp } th dport $LAND3_PORT dnat to $LAND3_IP:$LAND3_PORT
ip saddr $PO0_LAN_IP meta l4proto { tcp, udp } th dport $LAND4_PORT dnat to $LAND4_IP:$LAND4_PORT
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname $OUT_IF ip daddr $LAND1_IP meta l4proto { tcp, udp } th dport $LAND1_PORT masquerade
oifname $OUT_IF ip daddr $LAND2_IP meta l4proto { tcp, udp } th dport $LAND2_PORT masquerade
oifname $OUT_IF ip daddr $LAND3_IP meta l4proto { tcp, udp } th dport $LAND3_PORT masquerade
oifname $OUT_IF ip daddr $LAND4_IP meta l4proto { tcp, udp } th dport $LAND4_PORT masquerade
}
}
table inet ss2022_rfc_filter {
chain forward {
type filter hook forward priority 0; policy drop;
ct state invalid drop
ct state established,related accept
ip saddr $PO0_LAN_IP ip daddr $LAND1_IP meta l4proto { tcp, udp } th dport $LAND1_PORT ct state new accept
ip saddr $PO0_LAN_IP ip daddr $LAND2_IP meta l4proto { tcp, udp } th dport $LAND2_PORT ct state new accept
ip saddr $PO0_LAN_IP ip daddr $LAND3_IP meta l4proto { tcp, udp } th dport $LAND3_PORT ct state new accept
ip saddr $PO0_LAN_IP ip daddr $LAND4_IP meta l4proto { tcp, udp } th dport $LAND4_PORT ct state new accept
}
}

这层配置里最重要的是 ip saddr $PO0_LAN_IP。它让 RFC 只处理来自 PO0 的二级转发流量,避免 RFC 公网端口被直接当成入口使用。

落地机只放行 RFC 来源#

落地机运行真正的 SS2022 服务。为了减少暴露面,入口规则尽量只放行 RFC 来源。

以落地 1 为例:

Terminal window
RELAY_PUBLIC_IP="198.51.100.10"
PORT="51001"
iptables -I INPUT 1 -s "${RELAY_PUBLIC_IP}/32" -p tcp --dport "$PORT" -j ACCEPT
iptables -I INPUT 1 -s "${RELAY_PUBLIC_IP}/32" -p udp --dport "$PORT" -j ACCEPT

如果机器默认策略不是 DROP,可以按自己的防火墙结构追加拒绝规则。重点是不要为了排障长期全网开放服务端口。

保存规则:

Terminal window
apt install -y iptables-persistent
netfilter-persistent save

这里要注意一个分工:

  • netfilter-persistent save 保存的是 iptables/ip6tables 规则。
  • /etc/nftables.conf 里的自定义 nftables 转发表需要由 nftables 服务加载。
  • 如果混用 iptables-nft 和 nftables,不要随手 nft flush ruleset,它可能把 iptables-nft 生成的表也清掉。

主机防火墙支撑#

这条实践链路里,nftables 负责 PO0 和 RFC 的 DNAT、SNAT、masquerade;iptables/ip6tables 则负责每台机器自己的 INPUT/OUTPUT 防护,比如 SSH 限速、服务端口来源限制、DNS 入站阻断、邮件端口出站阻断。

我在这篇里只保留和实践直接相关的三条原则:

  1. 落地机服务端口尽量只放行 RFC 来源。
  2. PO0/RFC 上不要把 53、5353、5355 这类 DNS/mDNS/LLMNR 入口暴露到公网。
  3. netfilter-persistent save 只保存 iptables/ip6tables,不负责保存 /etc/nftables.conf 里的自定义 nft 表。

完整的 SSH 和 iptables/ip6tables 加固整理单独放在:VPS 安全加固教程

与单跳中转的关系#

这篇的 PO0 -> RFC -> 多落地,本质上是单跳中转的扩展版。单跳中转只有一层:

客户端 -> 中转机 -> 落地机

这次实践变成两层:

客户端 -> PO0 -> RFC -> 落地机

基础的 DNAT、forward、masquerade 模型没有变,只是 PO0 和 RFC 各做了一次。单跳中转的基础笔记我拆到了:IP 中转学习:从 DNAT、SNAT 到 nftables 单跳转发

验证顺序#

不要一上来就看客户端报错。链路长了以后,最好按跳排查。

先从外部测 PO0 入口端口:

Terminal window
nc -vz 203.0.113.10 50000
nc -vz 203.0.113.10 51001
nc -vz 203.0.113.10 51002
nc -vz 203.0.113.10 51003
nc -vz 203.0.113.10 51004

PO0 上抓到 RFC 的流量:

Terminal window
tcpdump -ni any "host 198.51.100.10 and (port 50000 or port 51001 or port 51002 or port 51003 or port 51004)"

RFC 上抓 PO0 过来的流量:

Terminal window
tcpdump -ni any "host 10.0.0.10 and (port 50000 or port 51001 or port 51002 or port 51003 or port 51004)"

RFC 上抓去落地机的流量:

Terminal window
tcpdump -ni any "host 192.0.2.11 and port 51001"
tcpdump -ni any "host 192.0.2.12 and port 51002"
tcpdump -ni any "host 192.0.2.13 and port 51003"
tcpdump -ni any "host 192.0.2.14 and port 51004"

查看 conntrack:

Terminal window
conntrack -L | grep -E "198.51.100.10|192.0.2.11|192.0.2.12|192.0.2.13|192.0.2.14"

还有一个容易误判的点:NAT 转发端口不一定出现在 ss -lntup 里。因为它不是某个进程在监听,而是内核在 prerouting 阶段改包。

SS2022 的 invalid timestamp 排障#

这次最有价值的排障经验是:Socket closed by remote peer 不一定是端口没开。

当 RFC 能抓到包,而且 TCP 三次握手完成,但服务端随后返回 RST,就要去看 ss-rust 日志:

Terminal window
journalctl -u ss-rust.service -n 100 --no-pager

如果日志里出现类似:

invalid timestamp client_timestamp - now server_timestamp = 31

这说明 SS2022 的时间戳校验失败。客户端和服务端时间差过大时,服务端会拒绝握手。

修复方向是先同步系统时间:

Terminal window
apt update
apt install -y chrony
systemctl enable --now chrony
chronyc makestep
chronyc tracking
timedatectl
hwclock --systohc
systemctl restart ss-rust.service

排障顺序可以固定成这个模板:

现象优先怀疑
PO0 收到连接,RFC 抓不到包PO0 nftables、路由、ip_forward、防火墙
RFC 抓到 SYN,但服务端没响应RFC 服务未监听或端口写错
RFC 抓到 payload 后返回 RSTSS2022 协议层拒绝,查 method、password、时间戳
TCP 正常但 UDP 超时PO0、RFC、落地机任意一层漏了 UDP
外部能连端口但客户端不可用密码、method、mode、NTP、落地机防火墙

安全和维护注意点#

这类配置最容易踩的坑不是语法,而是维护边界不清楚。

  1. 改防火墙前先备份 iptables-saveip6tables-savenft list ruleset
  2. 远程改规则时用 tmux,并准备一个几分钟后的自动回滚。
  3. 不要关闭当前 SSH 会话,先开新会话验证还能登录。
  4. nft 配置写完先 nft -c -f,语法检查通过再加载。
  5. flush ruleset 只适合明确知道整台机器由 nftables 独占管理的场景。
  6. 公网 DNS、mDNS、LLMNR、邮件端口这类容易被滥用的入口和出口要单独处理。
  7. 入口服务如果只有自有上游会访问,尽量用 IP 或 ipset 白名单限制来源。
  8. UDP 不要只在客户端里打开,PO0、RFC、落地机三层都要放。

参考文档#

这篇实践的公开参考只保留 Astro 内部文章和外部网页:

最后总结#

这次我真正学到的是,nftables 转发不是“写一条 dnat 就完了”。一条稳定的链路至少要同时回答四个问题:

  1. 包从哪里进来,在哪一层 DNAT。
  2. 回包如何回到原路,在哪里 SNAT 或 masquerade。
  3. 防火墙 forward 链是否允许新连接和回包。
  4. 服务协议本身是否正常,比如 SS2022 的 method、password、UDP mode 和时间戳。

用一句话概括:

PO0 负责统一入口和第一跳 SNAT。
RFC 负责本机服务和多落地二级分发。
落地机只运行真实服务,并尽量只放行 RFC 来源。
客户端永远只连接 PO0。

理解这个分工以后,再看单跳中转、多跳转发、realm 前置、iptables 和 nftables 混用,就不会都糊成一团了。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

PO0 到 RFC 再到多落地:一次 nftables 转发链路学习记录
https://blog.idotcar.top/posts/po0-rfc-multi-landing-nftables/
作者
老鼠溺水
发布于
2026-05-11
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
老鼠溺水
事实上,我们每个人都不过是在给自己写信。
分类
标签
站点统计
文章
7
分类
2
标签
9
总字数
29,837
运行时长
0
最后活动
0 天前

目录