规则存在、命中、最终放行是三个判断。先确定流量走 input、forward 还是 output,再确定谁拥有这些表。以下配置是教学片段,不能直接覆盖混有 Docker、Tailscale、Nikki 等管理规则的主机。
先找 hook,再读规则顺序
先识别表的归属与 hook
sudo nft list tables
inet、ip、ip6 的规则范围不同;Docker/VPN 管理的表不并入本次编辑。
sudo nft -a list ruleset
-a 显示对象句柄,后续精确变更需要它;包含整个系统的视图用于判断后续链是否还能丢包,不意味着要整体覆盖。
sudo nft list table inet ops_filter
首次安装前 NotFound 是正常前提;已存在则先判断能否由本文件完整管理。
先确认包应该走哪条路径
ip route get 192.0.2.10
替换为真实目标;它查询本机选路,不直接模拟所有转发包的 mark/来源。
ss -lntup
没有监听时新增 accept 不会创建服务;转发到别机的目标则在目标机执行这项检查。
正在呈现知识画面
accept 为什么还会被后面的链 drop同一 input hook 上,较早 base chain 的 accept 不保证最终放行,后续链仍能 drop。forward/output 流量则不经过这两条 input 链。
{"id":"ops-nft-verdict","title":"accept 为什么还会被后面的链 drop","summary":"同一 input hook 上,较早 base chain 的 accept 不保证最终放行,后续链仍能 drop。forward/output 流量则不经过这两条 input 链。","height":600,"html":"<main><p class=\"quiet\" data-i18n=\"scope\"></p><div class=\"presets\" role=\"group\"><button type=\"button\" data-path=\"input\" aria-pressed=\"false\" data-i18n=\"local\"></button><button type=\"button\" data-path=\"forward\" aria-pressed=\"false\" data-i18n=\"forward\"></button><button type=\"button\" data-path=\"output\" aria-pressed=\"false\" data-i18n=\"output\"></button></div><div class=\"checks\"><label><input id=\"a\" type=\"checkbox\" checked><span data-i18n=\"a\"></span></label><label><input id=\"b\" type=\"checkbox\" ><span data-i18n=\"b\"></span></label></div><div class=\"flow\"><div class=\"row code\" id=\"path\"></div><div class=\"arrow\">↓</div><div class=\"row code\" id=\"chainA\"></div><div class=\"arrow\">↓</div><div class=\"row code\" id=\"chainB\"></div></div><div class=\"explain\"><div class=\"stable \"><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"allow\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"dropA\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"dropB\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"bypass\"></p><p id=\"message\" aria-live=\"polite\"></p></div></div></main>\n","css":"main {\n display: grid;\n gap: 18px;\n min-width: 0;\n}\np {\n overflow-wrap: anywhere;\n}\n.controls {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n align-items: center;\n}\n.presets {\n display: flex;\n flex-wrap: wrap;\n}\n.presets button {\n flex: 1 1 85px;\n font-size: 15px;\n}\n.cards {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 10px;\n}\n.card {\n border: 1px solid var(--rule);\n border-radius: 10px;\n padding: 14px;\n min-width: 0;\n background: var(--surface);\n}\n.card h3 {\n font-size: 15px;\n color: var(--muted);\n margin-bottom: 8px;\n}\n.card strong {\n font-size: 23px;\n font-weight: 500;\n overflow-wrap: anywhere;\n}\n.code {\n font-family: var(--viz-font-mono);\n font-size: 15px;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.stable {\n display: grid;\n min-width: 0;\n}\n.stable > * {\n grid-area: 1/1;\n margin: 0;\n}\n.stable > .reserve {\n visibility: hidden;\n pointer-events: none;\n}\n.explain {\n border-left: 3px solid var(--accent);\n padding: 12px 14px;\n background: var(--accent-soft);\n}\n.arrow {\n text-align: center;\n color: var(--accent);\n font-size: 25px;\n}\n.row {\n padding: 12px;\n border: 1px solid var(--rule);\n border-radius: 8px;\n min-width: 0;\n}\n.row.on {\n border-color: var(--accent);\n background: var(--accent-soft);\n}\n.row.off {\n color: var(--muted);\n}\n.bar {\n height: 18px;\n background: var(--surface);\n border: 1px solid var(--rule);\n border-radius: 5px;\n overflow: hidden;\n margin-top: 8px;\n}\n.bar > i {\n display: block;\n height: 100%;\n background: var(--accent);\n width: 0;\n transition: width 0.22s;\n}\n.checks {\n display: grid;\n gap: 8px;\n}\n.checks label {\n display: flex;\n gap: 10px;\n align-items: center;\n padding: 9px;\n border: 1px solid var(--rule);\n border-radius: 7px;\n font-size: 16px;\n}\n.checks input {\n width: 18px;\n height: 18px;\n accent-color: var(--accent);\n flex-shrink: 0;\n}\nlabel.range {\n display: block;\n font-size: 16px;\n}\nlabel.range output {\n float: right;\n font-variant-numeric: tabular-nums;\n}\n.pair {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 10px;\n}\n.flow {\n display: grid;\n gap: 6px;\n}\n.quiet {\n font-size: 14px;\n color: var(--muted);\n}\n.badge {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 5px;\n background: var(--accent-soft);\n color: var(--accent);\n}\nbutton {\n overflow-wrap: anywhere;\n}\n.large {\n font-size: 32px;\n color: var(--accent);\n font-weight: 500;\n}\n.layers {\n display: grid;\n gap: 12px;\n}\n.layer {\n border-left: 3px solid var(--rule);\n padding-left: 12px;\n}\n.layer.on {\n border-color: var(--accent);\n}\n@media (max-width: 520px) {\n .cards,\n .pair {\n grid-template-columns: 1fr;\n }\n .card {\n padding: 12px;\n }\n .card strong {\n font-size: 22px;\n }\n}\n","js":"const $ = (s) => document.querySelector(s);\nconst $$ = (s) => [...document.querySelectorAll(s)];\nconst t = (k) => viz.t(k);\nconst put = (id, v) => ($(\"#\" + id).textContent = v);\nconst say = (k) => put(\"message\", t(k));\nconst state = (v) => ($(\"main\").dataset.state = JSON.stringify(v));\nconst press = (key, v) =>\n $$(\"[data-\" + key + \"]\").forEach((b) =>\n b.setAttribute(\"aria-pressed\", String(b.dataset[key] === v)),\n );\nlet path = \"input\";\nfunction draw() {\n let a = $(\"#a\").checked,\n b = $(\"#b\").checked,\n visitA = path === \"input\",\n visitB = visitA && a;\n put(\"path\", path);\n put(\"chainA\", \"A · input · priority 0\\n\" + (visitA ? (a ? \"accept\" : \"drop\") : \"—\"));\n put(\"chainB\", \"B · input · priority 10\\n\" + (visitB ? (b ? \"accept\" : \"drop\") : \"—\"));\n say(!visitA ? \"bypass\" : !a ? \"dropA\" : !b ? \"dropB\" : \"allow\");\n press(\"path\", path);\n state({ path, a, b, visitA, visitB, verdict: !visitA ? \"unexamined\" : a && b ? \"pass\" : \"drop\" });\n}\n$$(\"[data-path]\").forEach(\n (b) =>\n (b.onclick = () => {\n path = b.dataset.path;\n draw();\n }),\n);\n$$(\"input\").forEach((e) => (e.onchange = draw));\ndraw();\n","audio":false,"strings":{"a":"链 A:accept(否则 drop)","allow":"通过图中的两条 input 链;这里不保证其他 hook、路由或应用正常。","b":"链 B:accept(否则 drop)","bypass":"这类流量不经过 input,图中规则对它没有判决。应检查它自己的 hook,不能据此说已放行。","dropA":"链 A 已 drop,链 B 不再处理这个包。","dropB":"A 的 accept 只结束 A;到 B 后被 drop,仍无法到达本机应用。","forward":"经过本机转发","local":"外部 → 本机","output":"本机发起","scope":"只画 input 上的两条 base chain,priority 0→10,每条首规则匹配所有该路径包。省略其他 hook 与网络故障。"}}
外部到本机走 input;经本机转发走 forward;本机进程发出的包走 output。base chain 的 priority 数字较小先执行,相同优先级之间不依赖稳定顺序。accept 结束当前链,但后续同 hook 的 base chain 仍可能 drop;drop 通常立即终止。普通链需被 jump/goto 引用,不因名为 input 就自动收到包。
一次提交不等于自动清除旧规则
nft -f 把文件作为一个批次提交;文件只 add 时,重复执行可能叠加规则。先明确自己拥有的表和替换边界。下面创建独立 ops_filter,默认 accept,仅展示结构;不是完整安全策略,也不会替其他表放行。
table inet ops_filter {
set admin4 {
type ipv4_addr
flags interval
elements = { 192.0.2.0/24 }
}
chain input {
type filter hook input priority 10; policy accept;
ct state established,related counter accept
iifname "lo" counter accept
meta l4proto ipv6-icmp counter accept
ip saddr @admin4 tcp dport 22 counter accept
}
}
首次加载:文件、校验、提交、实测
把上面的完整示例保存为 ./ops-filter.nft,替换 admin4 网段并确认 inet ops_filter 尚不存在;此文件只管理该表。先保留当前自有表的配置与独立管理连接。
sudo nft -c -f ./ops-filter.nft
-c 不实际修改规则;失败先修复语法、对象引用或内核能力。它不会验证 SSH 可达。
sudo nft -f ./ops-filter.nft
失败读错误并重新 list,不把只成功校验当成加载成功。
sudo nft -a list table inet ops_filter
从独立客户端发起新 SSH/业务连接,并检查规则计数。持久化应把同一已验证文件纳入系统的加载入口,用 systemctl cat nftables.service 确认实际路径;别假定所有发行版都读同一文件。
首次确认表不存在后加载。后续若整个表都由此文件管理,创建一个批次文件:第一行 delete table inet ops_filter,后面接完整 table 定义;先 -c 再 -f。delete 要求表存在,这不是无条件幂等安装脚本。不要用全局 flush ruleset 清掉其他程序管理的 NAT、集合与链。-c 检查可提交性,不证明远程 SSH 和业务必然可达,变更后从独立连接实测并保留原配置回退。
用 handle 精确修改,用窄条件抓证据
精确修改规则:handle 不是行号
sudo nft -a list chain inet ops_filter input
下文 42 仅表示刚核对的目标 handle;规则增删后重新读取,不能长期保存一个号码直接复用。
sudo nft add rule inet ops_filter input ip saddr 198.51.100.10 tcp dport 443 counter accept
add 在末尾;insert 默认在开头。先看前面是否已有终止判决,再决定位置。此规则在默认 accept 链中主要用于命中观察,不自动形成拒绝其他来源的策略。
sudo nft replace rule inet ops_filter input handle 42 ip saddr 198.51.100.10 tcp dport 8443 counter accept
这是修改既有规则的选择,不应紧接上面的 add 机械执行;列出规则并用新连接确认端口变化,再同步源配置。
sudo nft delete rule inet ops_filter input handle 42
删除后重新 list;若是临时实验,恢复原配置并检查计数/连通性。
集合元素的添加与撤销是分阶段操作
sudo nft 'add element inet ops_filter admin4 { 198.51.100.10 }'
引号把花括号作为 nft 语法传入;用 sudo nft list set inet ops_filter admin4 核对。地址若已被区间覆盖,先读现有元素再修改。
sudo nft 'delete element inet ops_filter admin4 { 198.51.100.10 }'
这是后续撤销,不与添加同时执行;不要删除整个共享集合。
trace:设置标记、复现、再清理
以下在自有表中新建尚不存在的 trace_input,只跟踪到本机、来自指定地址的 TCP 443;它不能解释此前已被更早 hook 丢弃的包。
sudo nft 'add chain inet ops_filter trace_input { type filter hook input priority -301; policy accept; }'
sudo nft add rule inet ops_filter trace_input ip saddr 198.51.100.10 tcp dport 443 meta nftrace set 1
sudo nft monitor trace
沿同一 trace id 读链与 verdict;没有输出先核对流量是否经过标记规则。Ctrl-C 只停止监听,不移除标记。
sudo nft flush chain inet ops_filter trace_input
确认 trace_input 仅包含本次规则,再执行;然后单独删除空链。
sudo nft delete chain inet ops_filter trace_input
再 list table 确认临时对象消失;不清空其他链或整个 ruleset。
handle 来自当前列表,删除/replace 前重新读取,不把行号当 handle。规则只有 counter 才有相应计数。monitor trace 只接收已设置 nftrace 的包,不会自动启用所有包跟踪;需要时在自己拥有的早期链加入限源地址、端口的 meta nftrace set 1,记录 handle,复现一次后删除该临时规则。输出可能包含通信地址。
DNAT 改目的地址,不代替转发许可
| 检查层 | 需要满足 |
| Routing | 转发开启、目标路由和回程路由成立 |
| DNAT | 外来包通常在 prerouting;本机发包需考虑 output 路径 |
| Forward filter | 规则按 DNAT 后地址/端口等实际字段匹配,允许所需流量 |
| SNAT / masquerade | 需要时解决返回路径与源地址转换,不无条件加在所有出口 |
table ip ops_nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "wan0" ip daddr 192.0.2.20 tcp dport 8443 dnat to 10.0.0.10:443
}
}
这是 IPv4 DNAT 片段,仅用于自主管理的网关;wan0、公网/内网地址均需替换,并另行设计 forward 与回程。NAT 绑定由连接跟踪维护,改规则后已有连接可能沿用旧映射,使用新连接验证。iptables-nft 与 Docker 原生 nftables 后端不是同一配置模式;不以 iptables=false 代替 Docker 后端设置。
Chain verdicts · Atomic replacement · NAT · Docker nftables