規則の存在・一致・最終許可は別。input・forward・output と table の管理主体を先に確認する。例は教材で、Docker 等と共有する host ruleset の置換用ではない。
hook を特定してから規則順を読む
表の所有者と hook を先に確認する
sudo nft list tables
inet/ip/ip6 は対象範囲が異なる。Docker/VPN 管理の表は今回の編集に含めない。
sudo nft -a list ruleset
-a は厳密な変更に使う handle を表示する。全体を見るのは後続 drop を調べるためで、全体置換の範囲ではない。
sudo nft list table inet ops_filter
初回前の NotFound は想定内。既存なら全体をこのファイルで管理してよいか確認する。
パケットの想定経路を確認する
ip route get 192.0.2.10
実宛先を使う。ローカル検索であり、全転送パケットの送信元・mark を再現しない。
ss -lntup
accept はサービスを作らない。別ホスト宛の転送なら対象側で確認する。
図を準備しています
accept 後に別 chain で drop される理由同じ input hook の早い accept は最終許可ではなく後 chain が drop し得る。forward/output はこの input chain を通らない。
{"id":"ops-nft-verdict","title":"accept 後に別 chain で drop される理由","summary":"同じ input hook の早い accept は最終許可ではなく後 chain が drop し得る。forward/output はこの input chain を通らない。","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":"chain A:accept(他は drop)","allow":"図の二つを通過。他 hook・route・アプリの正常性は未検証。","b":"chain 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。先頭規則は経路上の全 packet に一致。他 hook と障害を省略。"}}
外部から本機は input、通過は forward、本機発は output。小さい priority が先で同値の順序に依存しない。accept 後も別 base chain で drop され得る。通常 chain は名前だけで hook に接続されない。
一括処理は旧規則削除を意味しない
nft -f は batch 処理で add だけなら規則が増え得る。所有する table と置換範囲を決める。次は accept policy の独立 table 構造例で、完全な防御設定や他 table の上書きではない。
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 を置き換え、対象表がないことを確認する。この表だけを管理し、既存の自主管理設定と独立した接続を保存する。
sudo nft -c -f ./ops-filter.nft
-c は規則を変更しない。構文・参照・カーネル対応のエラーを先に直す。SSH 到達性は検証しない。
sudo nft -f ./ops-filter.nft
失敗時はエラーと再一覧を読む。検証成功と読込成功は別。
sudo nft -a list table inet ops_filter
独立クライアントから新しい SSH・業務接続と counter を確認する。永続化は nftables.service の実読込先を確認して行い、配布版で同じパスと仮定しない。
初回は table 不在を確認する。全体を所有する既存 table なら同じファイルに delete と完全な再定義を入れて検査・読込する。delete は存在が前提で無条件冪等ではない。共有 host で flush ruleset を使わず、別接続で到達性も確認する。
handle で指定し狭い条件で trace
行番号ではなく 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 は既定で先頭。先行する終端判定を確認する。policy accept の例では主に counter 観測用で、他の送信元を禁止しない。
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
削除後に再一覧。実験なら元設定を復元して通信を確認する。
集合要素の追加と撤去を分ける
sudo nft 'add element inet ops_filter admin4 { 198.51.100.10 }'
引用符で波括弧を nft 構文として渡す。list set で確認し、既存区間に含まれる場合は先に集合を読む。
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 の chain と判定をたどる。出なければ印を付ける規則に到達するか確認する。Ctrl-C は表示停止だけ。
sudo nft flush chain inet ops_filter trace_input
今回の規則だけと確認してから空にし、その後削除する。
sudo nft delete chain inet ops_filter trace_input
table を再一覧して片付けを確認する。他の chain や ruleset は消さない。
削除・replace 前に現在の handle を読む。行番号とは別。counter のない規則にはその計数がない。monitor trace は nftrace 済みの packet を見るだけ。所有する早期 chain に宛先・port 等を絞った一時規則を置き、再現後に handle で除去する。
DNAT は宛先変換で転送許可ではない
| 層 | 必要条件 |
| Routing | 転送有効、往路と復路がある |
| DNAT | 外来は通常 prerouting、本機発は output を考える |
| Forward filter | 変換後の実際の address・port 等で必要な転送を許可 |
| 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 片断で、管理する gateway・機器名・address・forward・復路が前提。既存接続の NAT は conntrack に残り得るため新接続で試す。iptables-nft と Docker native nftables は別方式。
Chain verdicts · Atomic replacement · NAT · Docker nftables