端口转发已配置,却只放行 INPUT,连接仍不通。先确定报文怎样经过路由与 hook,再讨论规则、跟踪和 NAT;XDP/TC 又位于不同处理层次。
| 查阅目标 | 阅读位置 |
| 报文路径与链控制流 | 第 1~2 节 |
| 隔离规则实验与 conntrack | 第 3~4 节 |
| NAT、BPF 与卸载观测 | 第 5~7 节 |
1. 先判断包走哪条路径
发往本机服务的外部报文常经 PREROUTING、INPUT;本机进程发出的报文经 OUTPUT、POSTROUTING;路由转发的报文经 PREROUTING、FORWARD、POSTROUTING。桥接、netdev ingress、隧道和卸载存在其他路径,不能说所有报文一定遍历全部五个 hook。
DNAT 在路由之前改变目标地址,可以使原本发给网关地址的流量变成向内网服务器转发。因此端口转发后的报文通常需要 FORWARD 规则,而不是只添加 INPUT 放行。
同一 hook 中按数值从小到大的 priority 执行;不同 base chain 可共享 hook,但相同 priority 的相对顺序不应成为策略依赖。参见 Netfilter hooks。
2. nftables 的 table、chain、rule 与 set
| 对象 | 职责 |
| family | 指定协议/处理域,例如 ip、ip6、inet、bridge、netdev |
| table | 组织 chain、set、map 等对象,本身不是固定执行阶段 |
| base chain | 绑定 hook、priority 和 policy,成为执行入口 |
| regular chain | 通过 jump/goto 等规则进入,不自行绑定 hook |
| rule | 顺序求值的匹配表达式与动作 |
| set / map | 表达集合查询或键到值的映射,减少重复规则 |
nftables 仍会按链中的规则顺序求值,并非完全摆脱线性规则检查。集合可以改善表达和特定查询效率,但不能无条件声称“任意 nft 规则集都只遍历一次且比 iptables 快”。
iptables 命令还可能使用 nft backend,不能仅凭命令名字判断底层是 legacy xtables;多套工具共同管理时应确认实际 backend 和当前 ruleset。
accept 为什么不总是最终通行证
在某条 base chain 中 accept 结束该链相应处理,但后续同 hook 的其他 base chain 或更后面的 hook 仍可拒绝。drop 通常立即终止该报文继续处理。regular chain 的 return/jump 与 base chain policy 又是不同控制流。
| 执行位置 | 对该来源的动作 | 是否已经最终放行 |
| input base chain,priority 0 | accept SSH | 尚未,后续链仍会处理 |
| input base chain,priority 10 | drop 该来源 | 报文被拒绝 |
两条链共同决定结果。在前一条链末尾重复加 accept,不能覆盖后一条的 drop。见 nftables chain 配置。
下面固定两个不同优先级的 base chain,分别改变目标端口与来源条件。一个 chain 的 accept 之后,后续 chain 仍可拒绝;drop 则使后面的规则失去执行机会。
正在呈现知识画面
前一条链 accept,为何后一条仍能 drop?accept 结束当前 base chain 的处理,后续链仍可拒绝;drop 终止这条路径。多个同 hook 的 base chain 按 priority 由小到大执行。
{"id":"netfilter-base-chain-verdicts","title":"前一条链 accept,为何后一条仍能 drop?","summary":"accept 结束当前 base chain 的处理,后续链仍可拒绝;drop 终止这条路径。多个同 hook 的 base chain 按 priority 由小到大执行。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><label for=\"port\" data-i18n=\"port\"></label><select id=\"port\"><option>22</option><option>443</option></select><label class=\"check\"><input type=\"checkbox\" id=\"blocked\" checked><span data-i18n=\"blocked\"></span></label><div class=\"chain\"><header>A · input · priority 0</header><div class=\"rule\" id=\"a1\"><code>tcp dport 22 → accept</code><span></span></div><div class=\"rule\" id=\"a2\"><code>all → drop</code><span></span></div></div><div class=\"chain\"><header>B · input · priority 10</header><div class=\"rule\" id=\"b1\"><code>source ∈ deny → drop</code><span></span></div><div class=\"rule\" id=\"b2\"><code>policy accept</code><span></span></div></div><div class=\"card\"><header data-i18n=\"verdict\"></header><div id=\"verdict\" class=\"readout\"></div></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"note\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n.chain{border-left:2px solid var(--accent);padding-left:12px;margin:22px 0}.chain header{font:12px var(--viz-font-ui);margin-bottom:8px}.rule{border:1px solid var(--rule);border-radius:6px;padding:12px;margin:7px 0;min-height:6em;font-size:12px}.rule code{font-size:12px;overflow-wrap:anywhere}.rule span{display:block;color:var(--muted);margin-top:6px}.rule[data-state=skip]{opacity:.5;border-style:dashed}.rule[data-state=accept]{border-color:var(--accent);background:var(--accent-soft)}.rule[data-state=drop]{border-color:var(--second);background:var(--second-soft)}.readout{min-height:3.2em}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}function draw(){const ssh=$('#port').value==='22',blocked=$('#blocked').checked,states={a1:ssh?'accept':'miss',a2:ssh?'skip':'drop',b1:!ssh?'skip':blocked?'drop':'miss',b2:!ssh||blocked?'skip':'accept'};for(const[k,s]of Object.entries(states)){$('#'+k).dataset.state=s;$('#'+k+' span').textContent=t(s);}set('verdict',t(ssh&&!blocked?'passed':'denied'));set('explanation',t('note'));}$('#port').onchange=$('#blocked').onchange=draw;draw();","audio":false,"strings":{"accept":"结束本链","blocked":"来源命中 B 的拒绝集合","denied":"被丢弃","drop":"终止路径","heading":"前一条链 accept,为何后一条仍能 drop?","intro":"切换目标端口和后一条链的来源拒绝条件。逐条对照实际经过与跳过的规则,观察 accept 的作用范围。","miss":"不匹配,继续","note":"A 中的 accept 会跳过 A 的后续 drop,但不会跳过 B。把来源拒绝打开和关闭,对照同一条 accept 之后的不同结果。","passed":"通过此 hook","port":"TCP 目标端口","reset":"重置","scope":"两个 input base chain,priority 0 与 10,不使用相同 priority。只追踪本 hook 的控制流,最终通过仍不保证应用服务可用;省略 regular chain、jump/return、conntrack 和其他 hook。","scopeLabel":"模型范围与约定","skip":"未执行","verdict":"本 hook 的最终结果"}}
3. 一个仅计数的学习规则集
保存为 observe.nft。它不改变默认接受策略,分别统计从三个主要路径经过该链的包;不是完整安全策略。
table inet kb_observe {
chain input {
type filter hook input priority 0; policy accept;
counter
}
chain forward {
type filter hook forward priority 0; policy accept;
counter
}
chain output {
type filter hook output priority 0; policy accept;
counter
}
}
可以在支持非特权 user/network namespace 的机器上,用全新网络 namespace 检查并加载:
unshare --user --map-root-user --net sh -eu -c '
nft -c -f observe.nft
nft -f observe.nft
nft list table inet kb_observe
'
-c 检查而不应用事务,但仍需要内核相关支持和权限。这里没有连接任何外部接口,namespace 随进程结束回收;如果系统禁用非特权 namespace,该例会失败,不应直接改成在宿主防火墙加载。实际抓流量还需在同一 namespace 中建立测试接口和负载。
nft 的批量事务可避免用户态逐条命令造成部分更新窗口,但不意味着已有 conntrack/NAT 状态也随规则变化一起重建。
4. Conntrack 状态不是 TCP socket 状态
conntrack 记录协议、双向 tuple、zone、状态及计时等,既能处理 TCP,也可给 UDP 等无连接协议建立流跟踪。ct state established 与 ss 显示的 TCP ESTAB 不是同一个状态机。
NEW、ESTABLISHED、RELATED、INVALID、UNTRACKED 描述跟踪视角。RELATED 可包括关联 ICMP 错误或受控 helper 建立的关系;notrack 需要在足够早的位置执行,且会改变依赖跟踪的 NAT/规则行为。
表项的创建、确认、超时和销毁不同于应用 socket 的打开关闭。观察表满时,应检查新流速率、超时、zone、内存和异常流量,不能默认只提高 nf_conntrack_max 就解决问题。count/max 是容量观测,不是端到端健康指标。
5. NAT 建立映射,后续包复用状态
典型有状态 NAT 用流的首包建立 binding,后续包根据 conntrack 中保存的映射转换,不是每个包都重新遍历 NAT 链选择一条新映射。修改 NAT 规则后,已有流因此可能继续使用旧映射。
| 方向 | 地址变化 |
| 客户端发起 | 10.0.0.8:40000 访问 198.51.100.20:443 |
| 网关源转换 | 源地址变为 203.0.113.5:51000 |
| 返回流量 | 发往网关 51000,按反向状态还原到客户端 40000 |
这些地址只用于推演。回包的还原依赖已有映射,不能只把 NAT 理解成单方向字符串替换。
SNAT 通常在出口选择源映射;MASQUERADE 根据出口接口地址工作,并有接口状态变化相关处理。DNAT 常在 PREROUTING/OUTPUT 改目标。不要把 nat INPUT 链写成“给本机的 DNAT”通用位置;它支持的源转换用途应单独理解。
映射之外:转发许可与回程路由仍需成立
NAT 不自动启用 IP forwarding、修复回程路由或允许 FORWARD。hairpin 访问还可能需要相应源转换,以使返回流量经过同一状态设备。见 NAT 文档。
6. XDP 与 TC:处理位置决定可用信息
native XDP 位于驱动早期接收路径,可以在较少协议栈开销下丢弃、发回或重定向报文;generic XDP 已处于 skb 路径,硬件 offload 又依网卡支持。三者不能混成固定的“skb 前 hook”。
XDP_PASS 表示继续后续栈处理;XDP_TX 表示从接收设备发回;XDP_REDIRECT 依赖正确的重定向目标和相关准备。返回动作不是创建路由、修改 MAC、更新校验和、维护连接跟踪的完整替代品。
TC 处理 skb,适合结合队列、分类、地址修改和重定向等功能。direct-action 使用 TC_ACT_*,不能复制 XDP 常数当返回值。socket filter 控制交给 socket 的接收数据,不是“socket 创建 hook”。
BPF helper 对 conntrack、路由等有受限接口,但可用性和责任依类型/版本而异,不能认为程序默认继承整套 Netfilter 状态和规则。程序类型参阅 eBPF 程序类型。
7. 流量卸载会改变可观测路径
Netfilter flowtable 和硬件 offload 可让已建立流走更短路径。首包的策略建立、后续流量的实际经过位置和计数因此需要一起分析;某条 FORWARD 计数器没有逐包增加,不一定代表所有流量停止。
抓包为空:先定位观察点,再判断报文是否到达
同理,XDP 提前丢包可能发生在常规 packet socket 抓包点之前。本机 tcpdump 看不到,既可能是报文没来,也可能是更早就被处理。排查时结合物理端口计数、XDP/TC 状态、Netfilter trace/counter 和 socket 状态,按层缩小范围。见flowtable 文档。