前面七篇裡 nftables 一直是配角:第 2 篇說它是「kernel 防火牆、只看 IP」,第 6 篇說封鎖決策會落到它身上,第 7 篇說它的 allowlist 是自鎖保險。本篇把它拉到台前,用一張真實的規則表說明它在主機 B 上到底管什麼、不管什麼、跟 docker 與 WAF 怎麼分工,最後附四個日常檢查指令的實際畫面。看完應該能回答:「這個 IP 現在到底有沒有被擋、還剩多久、是誰擋的」。
先講結論。nftables 在主機 B 上是最外層的殼:所有從實體網卡進來的封包,在 WAF、Suricata、docker 看到之前先經過它。它只做四件事——自鎖保險(管理來源與平台主機永遠放行)、封鎖落地(案件決策寫進帶 TTL 的 set)、ingest 面來源管制(能注入事件的埠只讓平台主機與管理來源連)、管理面來源管制(Grafana、EveBox 等只讓管理來源連)。它不看 HTTP 內容、不做速率限制、不讀 EDL,這三件事各有別人負責。整套規則集中在一張叫 inet secstack 的表裡,由 nftables.sh 從 .env 產生,不要手改。
第 1 篇的封包路徑裡,主機 B 的第一站就是 nftables。但「第一站」要再拆細:Linux 的 netfilter 有五個 hook,一個封包依它的目的地只會經過其中幾個,而 nftables 的每條 chain 掛在其中一個 hook 上、帶一個 priority 決定先後。主機 B 上與我們有關的只有兩個 hook:
| hook | 什麼封包會經過 | 主機 B 上誰掛在這裡(依 priority 由小到大=先到後) |
|---|---|---|
| input | 目的地是主機自己的封包:SSH(22)、od-bridge(8500,host network)、本機服務 | ingest_guard_input(-150)→ ssh_guard_input(-150,選用)→ input(-100,自鎖保險與封鎖)→ docker 的 ip filter(0) |
| forward | 要轉送給容器的封包:WAF(8080/8082)、Vector 注入口(8688)、Grafana(3000)、EveBox(5636)、Vector API(8686)、Portainer(9443)。docker 發布的埠先在 prerouting 做 DNAT,之後進 forward | ingest_guard_forward(-150)→ mgmt_guard_forward(-150)→ docker 的 ip filter(0) |
封鎖 set 只掛在 input,不掛在 forward。這是刻意的。對外服務走 Cloudflare Tunnel 時,訪客封包從 cloudflared 容器經 docker 內部網段送到 WAF,根本不從實體網卡進來,nftables 看到的來源 IP 是 docker 網段而不是訪客。這種部署下攔截訪客的責任在 WAF(讀 Cf-Connecting-Ip)與 EDL(邊界設備),不在 nftables;nftables 的 blocklist 擋的是直接打到主機 B 的來源——掃描、暴力嘗試 SSH、直連 8500 之類。第 6 篇說「為什麼不建議用 Linux 軟體防火牆去讀 EDL」,根源就在這裡:它站的位置看不到該看的來源。

整套規則只有一張表 inet secstack(inet 表示同時涵蓋 IPv4 與 IPv6)。表裡三個 set、四到五條 chain,全部由 nftables.sh 依 .env 產生:
| 物件 | 型態 | 內容 | 誰寫入 |
|---|---|---|---|
| set allowlist | ipv4_addr,interval | ADMIN_IPS + 從 BEAK_BASE_URL 解析出的平台主機 IP。命中者在 input chain 一律 accept,排在 blocklist 前面 | 安裝腳本,固定 |
| set blocklist | ipv4_addr,interval + timeout | 案件決策落地的封鎖目標,可以是單一 IP 或 CIDR,每個元素自帶剩餘時間,到期由 kernel 自動移除 | od-bridge,動態 |
| set blocklist6 | ipv6_addr,同上 | 同上,IPv6 版 | od-bridge,動態 |
| chain input | input,-100 | 三條規則:allowlist accept、blocklist drop、blocklist6 drop | 安裝腳本 |
| chain ingest_guard_forward | forward,-150 | 8080/8082/8688 只放 ADMIN_IPS 與平台主機,其餘 drop | 安裝腳本 |
| chain ingest_guard_input | input,-150 | 8500(od-bridge)同上 | 安裝腳本 |
| chain mgmt_guard_forward | forward,-150 | 3000/5636/8686/9443 只放 ADMIN_IPS 與平台主機,其餘 drop,兩條都帶 counter | 安裝腳本 |
| chain ssh_guard_input | input,-150 | 只在 SSH_GUARD=1 時產生:22 埠只放 ADMIN_IPS,其餘限速記 log 後 drop | 安裝腳本,選用 |
注意兩個「為什麼要有它」:
| 責任 | 做的事 | 不做的事(由誰做) |
|---|---|---|
| 自鎖保險 | allowlist 命中一律 accept,且排在所有 drop 之前。平台主機與管理來源不管流程下了什麼決策都連得進來 | 不影響 WAF 打分數(WAF 對所有請求照樣評分)、不影響 EDL 與 CrowdSec(它們有各自的免封清單,見第 7 篇) |
| 封鎖落地 | od-bridge 收到 block 決策後 nft add element ... timeout Ns;unblock 就 delete element,元素已不在時視為成功(冪等) | 不接受 allow 決策(第 7 篇:主機 B 只有一個封鎖來源,放行等於解封)、不做速率型封鎖(CrowdSec 的事)、不擋 HTTP 層攻擊(WAF 的事) |
| ingest 面來源管制 | 能注入事件或偽造歸因的埠只放平台主機與管理來源 | 不驗簽章(od-bridge 與平台之間的 HMAC 另外做)、不限制 docker 內部網段(cloudflared → WAF、vector → od-bridge 不受影響) |
| 管理面來源管制 | 無認證的管理介面只放管理來源 | 不替這些介面加認證(Grafana 自己有登入,EveBox 與 Vector API 沒有,所以來源管制是它們唯一的門) |
大多數 nftables 教學第一行是 flush ruleset。主機 B 上絕對不能這樣做:docker 自己維護 ip nat、ip filter、ip6 nat、ip6 filter、ip raw 五張表,所有容器的 port forwarding 都靠它們。flush 一下,WAF、Grafana、Vector 全部從外面連不到,而 docker 不會自動補回。
nftables.sh 的做法是只重建自己那一張表:先 table inet secstack(不存在就建、存在就無事)再 delete table inet secstack,然後整張重新宣告。docker 的五張表從頭到尾沒被碰。這也是為什麼 nft list tables 會看到六張表並存——那是正常狀態,不是誰污染了誰。
重建會清空 blocklist。刪表再建表,set 裡的元素當然跟著消失。腳本在重建前用 nft -j list set 把 blocklist 與 blocklist6 連同剩餘 timeout 存下來,重建後逐筆補回。所以改 .env 重跑 nftables.sh 不會把正在生效的封鎖弄丟。但如果你手動 nft flush table inet secstack 或 delete table,就沒有人替你補了——這種情況下 od-bridge 下一輪拉到的決策也不會重放已經落地過的封鎖,直到 TTL 到期前那個 IP 都處於「平台記錄是已封鎖、主機 B 實際沒擋」的狀態。
priority 的取值也是為了共存:docker 的 filter chain 掛在 priority 0,我們的 guard chain 用 -150(nft 顯示成 mangle,那只是 -150 的別名)、自鎖與封鎖用 -100,全部排在 docker 之前。這樣「來源不在名單就 drop」的判定發生在 docker 決定要不要轉送給容器之前,drop 掉的封包 docker 根本看不到。
od-bridge 每 5 秒向平台拉一次決策,其中執行點含 nftables 的走 enforcers/nftables.py。它做的事非常薄——只有 add element 與 delete element 兩個動作,表與 set 的存在假設安裝時已經建好:
# block,帶 TTL(秒);CIDR 也是同一句
nft add element inet secstack blocklist '{ 203.0.113.45 timeout 600s }'
# unblock;元素已不在(TTL 到期或從未封鎖)視為成功
nft delete element inet secstack blocklist '{ 203.0.113.45 }'
三件值得知道的:
od-bridge 另外提供 GET /state/nft(8500 埠,僅本機與 ADMIN_IPS 可連),把三個 set 的現況轉成 JSON。它讀的是 nft -j list set 的即時輸出,不是自己的快取,所以拿來核對「平台認為封了」與「主機 B 實際擋了」是否一致很方便,第六節第 3 個畫面就是這個用法。
以下輸出取自一台實際運作中的防禦節點(nftables 1.0.9,Ubuntu 24.04),主機名稱與 IP 已換成本系列文章的示範環境:主機 B waf-vm、平台主機 192.168.0.111、管理來源 192.168.0.50。示範用的封鎖目標取自 RFC 5737 文件保留網段(203.0.113.0/24、198.51.100.0/24),這兩段在 Internet 上不會路由,可以放心拿來練習。所有指令都要 root 或 sudo,nft 對一般使用者連 list 都不給看。
安裝後、改完 .env 重跑之後、或懷疑規則被動過的時候,第一件事就是把整張表印出來。要看的是:表在不在、三個 set 在不在、allowlist 的元素是不是你預期的那幾個、四條 chain 的 hook 與 priority 對不對。
admin@waf-vm:~$ sudo nft list table inet secstack
table inet secstack {
set allowlist {
type ipv4_addr
flags interval
elements = { 192.168.0.50, 192.168.0.111 }
}
set blocklist {
type ipv4_addr
flags interval,timeout
}
set blocklist6 {
type ipv6_addr
flags interval,timeout
}
chain input {
type filter hook input priority -100; policy accept;
ip saddr @allowlist accept
ip saddr @blocklist drop
ip6 saddr @blocklist6 drop
}
chain ingest_guard_forward {
type filter hook forward priority mangle; policy accept;
iifname != "ens18" accept
tcp dport { 8080, 8082, 8688 } ip saddr { 192.168.0.50, 192.168.0.111 } accept
tcp dport { 8080, 8082, 8688 } drop
}
chain ingest_guard_input {
type filter hook input priority mangle; policy accept;
iifname != "ens18" accept
tcp dport 8500 ip saddr { 192.168.0.50, 192.168.0.111 } accept
tcp dport 8500 drop
}
chain mgmt_guard_forward {
type filter hook forward priority mangle; policy accept;
iifname != "ens18" accept
tcp dport { 3000, 5636, 8686, 9443 } ip saddr { 192.168.0.50, 192.168.0.111 } counter packets 0 bytes 0 accept
tcp dport { 3000, 5636, 8686, 9443 } counter packets 0 bytes 0 drop
}
}
幾個讀法:
案件簽核完、od-bridge 說已落地,最直接的驗證就是看 set。下面先手動放兩個示範元素(一個單一 IP 十分鐘、一個 /24 一小時)模擬 od-bridge 的寫入,再列出來:
admin@waf-vm:~$ sudo nft add element inet secstack blocklist '{ 203.0.113.45 timeout 600s }'
admin@waf-vm:~$ sudo nft add element inet secstack blocklist '{ 198.51.100.0/24 timeout 3600s }'
admin@waf-vm:~$ sudo nft list set inet secstack blocklist
table inet secstack {
set blocklist {
type ipv4_addr
flags interval,timeout
elements = { 198.51.100.0/24 timeout 1h expires 59m57s949ms,
203.0.113.45 timeout 10m expires 9m57s932ms }
}
}
每個元素兩個時間:timeout 是加入時給的總長度,expires 是此刻剩下多久。第二個才是你要看的。要查某一個 IP 是否命中,不必肉眼掃整份清單,用 get element:
admin@waf-vm:~$ sudo nft get element inet secstack blocklist '{ 203.0.113.45 }'
table inet secstack {
set blocklist {
type ipv4_addr
flags interval,timeout
elements = { 203.0.113.45 timeout 10m expires 9m57s877ms }
}
}
admin@waf-vm:~$ echo $?
0
admin@waf-vm:~$ sudo nft get element inet secstack blocklist '{ 203.0.113.99 }'
Error: Could not process rule: No such file or directory
get element inet secstack blocklist { 203.0.113.99 }
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
admin@waf-vm:~$ echo $?
1
get element 對 interval set 會做區間比對:查 198.51.100.77 會命中 198.51.100.0/24 那一筆。所以「這個 IP 有沒有被擋」用它問就對了,不要拿 list set 的輸出去 grep 單一 IP,CIDR 元素會漏掉。回傳碼 0/1 也適合寫進腳本。
順帶驗證第五節那條:對已存在的元素再 add 一次、給更短的 timeout,回傳成功但倒數不變:
admin@waf-vm:~$ sudo nft add element inet secstack blocklist '{ 203.0.113.45 timeout 60s }'
admin@waf-vm:~$ echo $?
0
admin@waf-vm:~$ sudo nft list set inet secstack blocklist | grep 203.0.113.45
203.0.113.45 timeout 10m expires 9m41s102ms }
手動解除與 od-bridge 的 unblock 是同一句。第二次刪同一個元素會報錯,這就是 od-bridge 把「element does not exist」視為成功的原因——TTL 已到期或從未封鎖,結果狀態都是「不在名單裡」,沒有理由回報失敗:
admin@waf-vm:~$ sudo nft delete element inet secstack blocklist '{ 203.0.113.45 }'
admin@waf-vm:~$ sudo nft delete element inet secstack blocklist '{ 203.0.113.45 }'
Error: element does not exist
delete element inet secstack blocklist { 203.0.113.45 }
^^^^^^^^^^^^
平台的處置中心說某個 IP「已落地」,但那是 od-bridge 回報的結果;要確認 kernel 真的擋了,就拿 od-bridge 的狀態端點與 nft 自己的 JSON 輸出對照。兩邊讀的是同一份 kernel 資料,只是格式不同,所以數字應該對得上(expires 會差幾秒,那是兩次查詢之間的時間差):
admin@waf-vm:~$ curl -s http://127.0.0.1:8500/state/nft | python3 -m json.tool
{
"blocklist": {
"198.51.100.0/24": 3597,
"203.0.113.45": 597
},
"blocklist6": {},
"allowlist": [
"192.168.0.111",
"192.168.0.50"
]
}
admin@waf-vm:~$ sudo nft -j list set inet secstack blocklist | python3 -m json.tool
{
"nftables": [
{ "metainfo": { "version": "1.0.9", "release_name": "Old Doc Yak #3", "json_schema_version": 1 } },
{
"set": {
"family": "inet", "name": "blocklist", "table": "secstack",
"type": "ipv4_addr", "handle": 6, "flags": [ "interval", "timeout" ],
"elem": [
{ "elem": { "val": { "prefix": { "addr": "198.51.100.0", "len": 24 } }, "timeout": 3600, "expires": 3597 } },
{ "elem": { "val": "203.0.113.45", "timeout": 600, "expires": 597 } }
]
}
}
]
}
兩種情況下這個對照特別有用:
改了 .env 的 ADMIN_IPS 要重跑 nftables.sh 之前,或手上有一份改過的 .conf 想套用之前,先用 -c(check)讓 nft 只解析不套用。語法錯、set 型態不合、chain 重複宣告都會在這一步被抓出來,而 kernel 裡的規則一個位元都不會動:
admin@waf-vm:~$ sudo nft -c -f /etc/nftables.conf
admin@waf-vm:~$ echo $?
0
admin@waf-vm:~$ sudo head -8 /etc/nftables.conf
#!/usr/sbin/nft -f
#
# 防禦節點主機防火牆(由 Integrated-WAF/nftables.sh 產生,勿手改;改 .env 後重跑)
#
# 不使用 flush ruleset:docker 的 ip nat / ip filter 由 docker 管理。
# 只以 delete + create 重建 inet secstack 一張表。
table inet secstack
admin@waf-vm:~$ ls -la /etc/nftables.conf*
-rwxr-xr-x 1 root root 2448 Sep 1 00:53 /etc/nftables.conf
-rwxr-xr-x 1 root root 243 Aug 5 2025 /etc/nftables.conf.bak.20260901-003815
-rwxr-xr-x 1 root root 2448 Sep 1 00:52 /etc/nftables.conf.bak.20260901-005339
-c 沒有輸出就是通過。nftables.sh 每次重寫 .conf 之前都會留一份帶時間戳的備份,所以「改壞了想退回上一版」只要 nft -f 那個 .bak 檔。最舊那份 243 位元組的是 Ubuntu 套件出廠的空範本,可以看出這台主機是什麼時候第一次被安裝腳本接管的。
最後確認 docker 的表還在、沒被誤刪,以及開機會自動還原:
admin@waf-vm:~$ sudo nft list tables
table inet secstack
table ip nat
table ip filter
table ip6 nat
table ip6 filter
table ip raw
admin@waf-vm:~$ systemctl is-enabled nftables; systemctl is-active nftables
enabled
active
六張表並存是正常狀態(第四節)。nftables.service 開機時會執行 /etc/nftables.conf,把 inet secstack 連同 allowlist 還原;blocklist 不會還原,因為 .conf 裡的 blocklist 是空的——重開機等於所有生效中的封鎖歸零。這是刻意的取捨:TTL 型封鎖本來就是暫時措施,長期封鎖該由 EDL 交給邊界設備(第 6 篇)。
| 誤解 | 實際 |
|---|---|
| nftables 擋掉的就是 WAF 看到的攻擊者 | 只有直連主機 B 實體網卡的來源會被它擋。走 Cloudflare Tunnel 的訪客不經過實體網卡,nftables 對他們完全不可見;那條路上的攔截靠 WAF 與 EDL |
| 把 IP 加進 allowlist 就能免於 WAF 檢查 | allowlist 只影響 input chain 的 accept,WAF 對所有 HTTP 請求照常打分數。要免檢查的是 WAF 自己的設定,不是 nftables |
| 手改 /etc/nftables.conf 就能改規則 | 下次跑 nftables.sh(安裝腳本的 --reconfigure 會跑)整份會被覆寫。要改的是 .env;需要腳本沒提供的規則就另建一張表,不要動 inet secstack |
| blocklist 空的表示 od-bridge 沒在動 | 可能只是沒有生效中的封鎖:TTL 到期會自動消失、重開機會歸零、案件流程可能只選了 EDL 執行點。看 /state/nft 能不能回應才是 od-bridge 活著的證據 |
| 可以用 flush ruleset 重來 | 會連 docker 的五張表一起清掉,所有容器的 port forwarding 立刻失效,而 docker 不會自動補回。只能重建 inet secstack 那一張 |
| nft list set ... | grep IP 能判斷有沒有被擋 |
| 對同一個 IP 再下一次更長的封鎖就能延長 | 已存在的元素再 add 會成功但倒數不變(第五節實測)。要延長先 unblock 再 block,或等它到期後再封 |
| priority 顯示 mangle 表示規則跑到 mangle 表去了 | 那只是 -150 這個數值的別名,nft 列印時自動替換。這張表沒有任何 mangle 動作 |