抓圖方便,所以直接把寫作環境的部份IP直接透露,反正鐵人賽後會刪VM,真的別拿來說酸,文章就是要讓人看得懂得學得會
防禦節點(主機 B)上除了 WAF,還裝了幾個別人寫的系統。這一系列每篇只談一個,不深入它的設定細節,只回答三件事:它是什麼、它在主機 A 與主機 B 之間佔哪一段、少了它整體會缺什麼。本篇是 cloudflared。
先講結論。cloudflared 讓 Internet 上的訪客連得到主機 B 的 WAF,而主機 B 不必有公網位址、不必開任何入站埠、不必自己準備 HTTPS 憑證。做法是反過來:由 cloudflared 從主機 B 往 Cloudflare 撥出幾條長連線,訪客的請求沿這幾條連線送回來。它是選用的,安裝時沒有給 tunnel token 就不會有這個容器,其他元件照常運作,WAF 只能從內網連。它只負責搬運,不檢查內容,也不是封鎖決策的落地點。它停掉,對外服務立刻中斷,內網的一切不受影響。
cloudflared 是 Cloudflare 公司開源的一支程式,授權 Apache 2.0。它是 Cloudflare Tunnel 這項服務放在使用者這一端的那一半,Cloudflare 的文件稱它為連接器(connector)。本安裝包用的容器映像是 cloudflare/cloudflared:2026.8.3。
要把內網的網站公開到 Internet,傳統做法是由外往內:申請固定的公網位址,在路由器上做通訊埠轉發,再替網域申請憑證。Cloudflare Tunnel 的方向相反。cloudflared 啟動後主動連到 Cloudflare 的資料中心,把連線留著;訪客連的是 Cloudflare,Cloudflare 再把請求沿這些已經建立的連線交給 cloudflared。對主機 B 的防火牆來說,從頭到尾只有往外的連線。
它在本安裝包裡的樣子:
| 項目 | 內容 |
|---|---|
| 啟動條件 | 主機 B 的 .env 裡 CLOUDFLARE_TUNNEL_TOKEN 有值才啟動。沒有值時這個容器不存在,docker compose ps 也看不到它 |
| 對外的連線 | 往 Cloudflare 的 7844 埠,預設用 QUIC(UDP),連不上時改用 HTTP/2(TCP)。一共 4 條,分別連到至少兩個資料中心 |
| 入站埠 | 沒有。容器沒有發布任何埠到主機 |
| 容器位址 | 固定為 172.18.0.250。這是主機 B 內部容器網段的位址,只在主機 B 上存在,內網其他電腦連不到;其他容器的位址由系統分配,只有它是指定的。位址固定是有用途的,見第三節 |
| 主機 B 保管的東西 | 只有 token 一個值。這條 tunnel 接哪個網域、請求轉給誰,都存在 Cloudflare 的帳號裡,cloudflared 連上之後才取得 |
| 狀態端點 | 容器內的 20241 埠,沒有發布到主機的埠,在主機 B 上要用容器位址連 |
| 執行身分 | 非 root 的一般帳號 |
| 版本 | 固定在安裝包指定的那一版,啟動時帶 --no-autoupdate,不會自己更新 |
啟用的方式有兩種,都在安裝文件的「對外公開(選用)」一節。一是給安裝腳本一組 Cloudflare API token 與要用的網域名稱(--cf-api-token、--cf-hostname),腳本會建立 tunnel、設定分流規則、建立 DNS 記錄,再把取得的 tunnel token 寫進 .env。二是自己在 Cloudflare 的後台做完這三件事,只把 tunnel token 交給安裝腳本(--tunnel-token)。兩種方式的前提相同:要有一個由 Cloudflare 代管的網域。

圖上有三件事要看。
圖右邊的「內網直連」是另一條進 WAF 的路:在管理者的電腦上直接連主機 B 的 8080 埠。本系列各篇的驗證指令走的都是這一條,所以沒有 tunnel 的節點一樣做得完。兩條路在 WAF 之後完全相同,差別只在 WAF 之前,下一節說明。
對 WAF 來說,經 tunnel 進來的每一個請求,連線的來源都是同一個位址,就是 cloudflared 的 172.18.0.250。真正的訪客是誰,寫在 Cloudflare 加上的幾個標頭裡。下面是從 Internet 對一台有 tunnel 的節點送一個帶 SQL 注入字串的請求之後,WAF 的 audit.log 記下的內容(只列與來源有關的部分,位址與編號已去識別化):
連線來源 172.18.0.250
Host app.example.com
Cf-Connecting-Ip 203.0.113.80
Cf-Ipcountry TW
X-Forwarded-For 203.0.113.80
X-Forwarded-Proto https
Cf-Visitor {"scheme":"https"}
Cf-Ray 8f2c61d4ab12c3d4-TPE
Cdn-Loop cloudflare; loops=1
Cf-Warp-Tag-Id 6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f
| 標頭 | 內容與用途 |
|---|---|
| Cf-Connecting-Ip | 訪客連到 Cloudflare 時的位址。安裝包拿它當攻擊者位址 |
| Cf-Ipcountry | Cloudflare 依訪客位址判斷的國別。WAF 事件的國別只有這一個來源,所以從內網直連產生的 WAF 事件沒有國別 |
| X-Forwarded-For | 代理鏈上的位址清單。原文存進事件當證據,不拿來判斷攻擊者是誰 |
| Host | 訪客連的網域名稱。事件的目標主機欄位取的是它 |
| X-Forwarded-Proto、Cf-Visitor | 訪客與 Cloudflare 之間用的是 HTTPS。到了主機 B 這一段已經是 HTTP |
| Cf-Ray | Cloudflare 給這個請求的編號,回應的標頭裡也有同一個值,可以拿去對 Cloudflare 那一側的紀錄。安裝包沒有取用 |
| Cf-Warp-Tag-Id | 經手的那一個 cloudflared 的編號,和第六節狀態端點回報的 connectorId 相同 |
同一個請求在 ClickHouse 裡的那一列(搭配元件篇 3 第三節的欄位):
event_time: 2026-10-06 13:52:17.959
ingested_at: 2026-10-06 13:52:22.967
source_system: coraza
severity_id: 4
actor_ip: ::ffff:203.0.113.80
actor_country: TW
actor_ua: curl/8.5.0
actor_xff: 203.0.113.80
target_host: app.example.com
target_url: /beakplatform/?id=1%27%20UNION%20SELECT%201--
target_service: waf-nginx
finding_rule_id: 942100
攻擊者位址是訪客的,不是 cloudflared 的;國別有值;目標主機是網域名稱。規則欄位也和上一篇的例子不同:上一篇用 IP 位址連主機 B,排第一的規則是 920350(Host 標頭是數字位址);這裡 Host 是網域名稱,那一條不會命中,記下的是 SQL 注入的 942100。
標頭是可以偽造的,所以只信一個來源
任何人都可以在請求裡自己加一行 CF-Connecting-IP。如果 WAF 這一側看到標頭就信,能連到 8080 埠的人就能把攻擊者位址填成任意第三方,讓主機 A 去封一個無辜的位址。安裝包的做法是看連線的來源:只有來源是 172.18.0.250 的請求,標頭裡的訪客位址與國別才算數;其他來源一律是誰連進來就記誰。cloudflared 的容器位址之所以固定,就是為了這個判斷。
實測:在管理者的電腦(192.168.0.50)上直接連主機 B 的 8080 埠,自己帶兩個假標頭。
# 管理者的電腦
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'CF-Connecting-IP: 203.0.113.99' -H 'CF-IPCountry: ZZ' \
"http://192.168.0.112:8080/beakplatform/?id=1%27%20UNION%20SELECT%201--"
403
actor_ip: ::ffff:192.168.0.50
actor_country:
actor_xff:
target_host: 192.168.0.112:8080
finding_rule_id: 920350
事件記下的是實際連進來的那台電腦,假的位址與國別都沒有被採用。這個判斷是 Vector 在轉換事件時做的,不是 WAF 做的,細節在 WAF 深入篇第 2 篇。
被保護的網站那一端也有同樣的問題:它看到的連線來源是主機 B,真正的訪客同樣要從標頭取。BeakPlatform 只在連線來自它信任的代理時才採信這個標頭。
| 對外公開需要的東西 | 沒有 tunnel 時 | 有 tunnel 時 |
|---|---|---|
| 公網位址 | 要有固定位址,或自己處理位址變動 | 不需要。主機 B 只要能連出去 |
| 防火牆與路由器 | 要開入站埠、做通訊埠轉發 | 不必動。只有往外的連線 |
| HTTPS 憑證 | 要自己申請、定期更新,WAF 也要改成聽 HTTPS | 由 Cloudflare 處理。WAF 維持只聽 HTTP |
| 主機 B 暴露的範圍 | 位址公開,開著的埠都會被掃 | 位址不公開。Internet 上的訪客只到得了分流規則指向的 WAF,碰不到主機 B 的其他埠 |
| 訪客的位址與國別 | 位址直接看連線來源,國別要另外查 | 由 Cloudflare 放在標頭裡帶進來 |
這些好處有三個連帶的結果,前幾篇各自提過,這裡放在一起:
| 限制 | 說明 |
|---|---|
| 不檢查內容 | 它只搬運,交給它的請求原樣轉給 WAF。擋不擋是 WAF 的事 |
| 不是封鎖的執行點 | 主機 A 的封鎖節點可以勾選 cloudflare 這個執行點,但防禦節點出廠不認領它,對應的程式也只是預留的空殼。勾了不會有任何東西落到 Cloudflare 上,也和 cloudflared 這個容器無關 |
| Cloudflare 先擋下的,主機 B 不會知道 | Cloudflare 自己也有防護。請求若在那一側就被擋下,不會進 tunnel,WAF、ClickHouse、主機 A 都不會有紀錄。主機 B 上的「全量」是到得了主機 B 的全量 |
| 加密不到主機 B | 訪客的 HTTPS 在 Cloudflare 結束,Cloudflare 看得到請求與回應的明文。Cloudflare 到 cloudflared 之間由 tunnel 加密;cloudflared 到 WAF、WAF 到被保護的網站是 HTTP |
| 分流規則不在主機 B | 重裝主機 B 不會改變規則,改主機 B 的設定也改不到規則。要換網域或加路徑,得到 Cloudflare 的後台改,或用安裝包附的 cf_tunnel.py |
| token 就是鑰匙 | 拿到 token 的人可以在別的地方啟動另一個 cloudflared,接到同一條 tunnel 上,分走一部分訪客的請求。token 存在主機 B 的 .env,只有 root 讀得到(2026-10-06 之前安裝的節點例外,見第六節最後一段)。懷疑外流時,到 Cloudflare 的後台把 tunnel 刪掉重建 |
| 不會自己更新 | 版本跟著安裝包走。Cloudflare 出新版之後,cloudflared 會在紀錄裡寫一行提醒,但不會動作 |
| 只有一個 | 一個節點一個 cloudflared。它停了,對外就斷了 |
| 狀態只有「執行中」 | 安裝包沒有替這個容器設健康檢查,docker compose ps 顯示執行中,不代表連線是通的。要看連線,用第六節的方法 |
把它停掉會怎樣,實測過一次。在一台有 tunnel 的節點上停止 cloudflared 容器 21 秒:
訪客看到錯誤時,從狀態碼可以分辨是哪一段出問題:
| 訪客看到的 | 誰回的 | 代表什麼 |
|---|---|---|
| HTTP 530,error code: 1033 | Cloudflare | cloudflared 沒有連上。看主機 B 上這個容器 |
| HTTP 502 | WAF,經 Cloudflare 轉達 | cloudflared 與 WAF 都活著,但 WAF 連不到被保護的網站。看主機 A。實測被保護的網站離線時,從內網直連 8080 埠與從 Internet 連,得到的都是 502 |
| HTTP 403,nginx 的錯誤頁 | WAF | 請求被規則擋下。整條路是通的 |
所以它在整體裡的位置是這樣的:檢查靠 WAF,判斷靠主機 A,cloudflared 只負責把 Internet 上的請求帶到 WAF 面前。少了它,偵測、建案、處置的每一段都還在,只是進來的只剩內網的流量。
先看它有沒有連上。cloudflared 每建立一條連線就寫一行紀錄,正常是 4 行:
# 主機 B
cd /opt/integrated-waf
sudo docker compose logs cloudflared | grep 'Registered tunnel connection' | tail -4
INF Registered tunnel connection connIndex=0 connection=… ip=198.41.192.77 location=hkg09 protocol=quic
INF Registered tunnel connection connIndex=1 connection=… ip=198.41.200.193 location=tpe01 protocol=quic
INF Registered tunnel connection connIndex=2 connection=… ip=198.41.192.37 location=hkg01 protocol=quic
INF Registered tunnel connection connIndex=3 connection=… ip=198.41.200.23 location=tpe01 protocol=quic
location 是連到的資料中心,protocol=quic 代表用的是預設的 QUIC。安裝腳本的 --verify 在「對外入口」那一段檢查的就是這一行有沒有出現過。
紀錄只說曾經連上。要知道此刻有幾條連線,問它的狀態端點:
# 主機 B
curl -s http://172.18.0.250:20241/ready
{"status":200,"readyConnections":4,"connectorId":"6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f"}
readyConnections 是 4 就是全部連著。少一兩條不影響服務,是 0 的時候訪客看到的就是上一節的 530。
最後走一次完整的路。找一個不在內網的連線(例如手機的行動網路),對對外網址送一個一定會被擋的請求,十秒後在主機 B 查 ClickHouse 最新一筆:
# 不在內網的電腦
curl -s -o /dev/null -w '%{http_code}\n' "https://app.example.com/?id=1%27%20UNION%20SELECT%201--"
403
# 主機 B,十秒後(CH_PW 的取法見搭配元件篇 3 第五節)
sudo docker compose exec clickhouse clickhouse-client --user secstack --password "$CH_PW" -d secstack \
--query "SELECT event_time, actor_ip, actor_country, target_host FROM events ORDER BY ingested_at DESC LIMIT 1 FORMAT Vertical"
要看三個欄位:actor_ip 是你那個連線的公網位址,不是 172.18.0.250;actor_country 有值;target_host 是網域名稱。三個都對,就代表 Cloudflare、cloudflared、WAF、Vector 這一段,連同第三節的來源判斷,都是通的。這一筆的來源是公網位址,不屬於「內網打內網」,會照搭配元件篇 3 第四節的規則送往主機 A。
看紀錄時會遇到兩種不必處理的訊息。一是啟動時有一行 failed to sufficiently increase receive buffer size,說的是 UDP 緩衝區沒有調到它想要的大小,不影響運作。二是運作久了會零星出現 ERR 與 WRN,內容是某一條連線逾時、中斷;只要後面跟著同一個 connIndex 的 Registered tunnel connection,就是它自己重新連上了。四條連線輪流斷線重連是常態,訪客不會察覺。
在 2026-10-06 之前安裝的節點有一件事要處理。舊版安裝包把 token 當成命令列參數交給 cloudflared,而命令列參數會出現在主機的行程列表裡,主機 B 上任何帳號用 ps 都讀得到,不需要 root。新版改由環境變數傳入,行程列表裡不再有 token。更新方式是執行一次 sudo bash /opt/integrated-waf/install.sh --update,cloudflared 會重建,對外中斷幾秒。主機 B 上若有過其他人的帳號,更新之後再到 Cloudflare 的後台換一條 tunnel。