建議標題:Caddy + CrowdSec 資安整合實戰:安裝踩雷與除錯全紀錄
承接上一篇 Caddy 部署文章,這篇記錄的是把 CrowdSec 接進 Caddy 之後,一路踩坑除錯的完整過程。環境是 Proxmox CT(Debian,512MB RAM + 512MB swap),Caddy 已經用 xcaddy 編進 caddy-crowdsec-bouncer 模組。
這篇在講什麼
不是從零開始的安裝教學,而是「照著標準流程做,卻一路撞牆」的真實記錄——每個坑背後的根本原因,比操作步驟本身更值得記下來。文中每個修法都附上為什麼要這樣做的邏輯,而不只是複製貼上的指令。
先搞懂架構:CrowdSec 怎麼判斷「誰是壞人」
在進坑之前,先花一點篇幅理解 CrowdSec 的運作模型,後面每個坑的原因都建立在這個基礎上。
CrowdSec 不是單一程式,而是幾個角色分工:
- Agent(引擎):讀取 log,透過 parser(解析器)把一行原始文字轉成結構化事件,再交給 scenario(場景)比對是不是符合某種攻擊模式(例如短時間內大量 404、SQL injection 特徵字串)。parser 和 scenario 打包起來發布,就是 collection(例如
crowdsecurity/caddy)。 - LAPI(Local API):agent 判斷出攻擊後,會把「這個 IP 該被擋」寫成一筆 decision(決策),存進 LAPI 的資料庫(預設 SQLite)。
- Bouncer(remediation component):實際負責「擋」的角色,定期向 LAPI 拉取目前生效的 decision 清單,照著清單擋請求。我們這次用的是 Caddy 內建的 caddy-crowdsec-bouncer 模組,它跟 Caddy 是同一個 process,用 HTTP 打
127.0.0.1:8080這個 LAPI 端點。
這張圖幾乎可以對應到後面每一個坑:坑一是「Agent 裝不起來」,坑二是「log 有沒有正確進到 Agent」,坑三、四是「Caddy 這端的請求處理鏈有沒有正確接上 crowdsec handler」,坑五是「Bouncer 跟 LAPI 的認證」。理解這張圖之後,除錯時就知道自己卡在哪一段。
坑一:CrowdSec 裝到一半失敗,兇手是不存在的 IPv6
apt install -y crowdsec跑到一半噴錯:
dial tcp [2606:4700:10::6814:2903]:443: connect: network is unreachable第一直覺是「我家電信根本沒有公網 IPv6,怎麼會去連 IPv6?」——但這其實是貨真價實的公網 IPv6(2606:4700:10:: 是 Cloudflare 的位址段,因為 CrowdSec 安裝腳本要下載 hub-data.crowdsec.net 上的規則資料),不是內網位址。
根本原因
Proxmox 的 bridge 網路對 CT 做了 SLAAC(Stateless Address Autoconfiguration)/RA(Router Advertisement),CT 因此拿到一個「看起來合法」的全域 IPv6 位址(不是 fe80:: 開頭的 link-local),但實際上這個位址背後沒有可用的對外路由(你的電信根本沒配發公網 IPv6 給這條線路)。系統做 DNS 解析時,如果同時查到 A(IPv4)跟 AAAA(IPv6)記錄,大部分 Linux 網路堆疊會優先嘗試 IPv6 連線,於是直接撞上一條「看起來存在、實際不通」的路由,得到 network is unreachable。這種問題最陰險的地方在於:它不會每次都發生,只有剛好連到只回應 IPv6 的 CDN 節點時才會炸,讓人很難第一時間聯想到 IPv6。
修法有兩層,建議都做,一個當保險:
CT 內關閉 IPv6(如果是 unprivileged CT,可能因為容器內沒有權限改這個 sysctl 而失敗,這時候要改用下面 host 端的方法):
cat > /etc/sysctl.d/99-disable-ipv6.conf << 'EOF'
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1
EOF
sysctl -p /etc/sysctl.d/99-disable-ipv6.conf
# 確認生效,這行應該回傳空白
ip -6 addr showProxmox host 端直接不分配 IPv6(比較根治的做法,之後裝任何其他套件都不會再踩到類似的坑,因為 CT 從一開始就不會拿到 IPv6 位址):
# 在 Proxmox host 上執行,不是在 CT 裡面
pct config <CTID> # 先看目前的 net0,通常是 ip6=auto 或 ip6=dhcp
pct set <CTID> -net0 name=eth0,bridge=vmbr0,ip=dhcp,ip6=none,firewall=1
pct reboot <CTID>排除問題後,dpkg 會把 crowdsec 標記成「未設定完成」的狀態,這是因為 post-install script 跑到一半失敗被中斷,重新觸發它繼續跑完:
apt --fix-broken install
# 或者
dpkg --configure -a坑二:cscli setup 自動產生的設定,跟你想的不一樣
CrowdSec 安裝腳本裝完後,會自動跑一次 cscli setup,偵測系統上有什麼服務在跑(透過檢查有沒有對應的 systemd unit、log 檔案路徑等特徵),幫你產生對應的 acquisition(log 讀取)設定:
Creating acquisition configuration
The following services will be configured:
- caddy-journal
- linux
- openssh-journal-ssh
- postfix打開自動產生的 setup.caddy.yaml 會看到:
journalctl_filter:
- _SYSTEMD_UNIT=caddy.service
labels:
type: syslog
source: journalctl第一眼看到 type: syslog 會以為裝錯了(明明是要抓 Caddy 的 log,為什麼標成 syslog?)。
其實這樣是對的
官方 crowdsecurity/caddy collection 的規則是分兩段式解析的:來源如果是 journalctl(語意上等同於 syslog,因為 journald 本身就是取代傳統 syslog 的角色),type 要設成 syslog,讓 syslog-logs 這個 parser 先接手,從原始訊息裡解出 program 這個欄位(對應到是哪個服務寫的 log);接著 caddy-logs parser 才會用 evt.Parsed.program startsWith 'caddy' 這個條件篩選,篩到才進一步用 JSON 格式解析內文。這是刻意設計的兩段式 pipeline,不是設定錯誤——如果直接把 type 設成 caddy,反而會跳過 syslog 這段前置處理,讓 program 欄位是空的,比對條件就永遠不會成立。
其他幾個自動產生的檔案:setup.linux.yaml 讀 /var/log/syslog 等系統 log,屬於基礎防護;setup.sshd.yaml 用 journald 抓 SSH 服務,防暴力破解;setup.postfix.yaml 如果機器上沒跑郵件伺服器,會一直讀不到對應檔案,不會報錯但也沒作用,可以之後清掉不影響其他功能。
這一步真正要注意的,反而是一件容易被忽略的事:Caddy 預設不會自動記錄 HTTP 請求。如果 Caddyfile 裡沒有 log 指令,journald 裡就只會有「Caddy 啟動」之類的內部訊息,完全沒有請求紀錄,CrowdSec 的 HTTP 場景(探測、爬蟲、bad user-agent 等)永遠不會被觸發。這點會在坑四詳細展開。
坑三:想全域套用 crowdsec,卻踩到 Caddyfile 的語法限制
如果你的 Caddy 是模組化架構(主檔用 import /etc/caddy/sites/* 載入,每個站台各自一個檔案,方便管理十幾個網域),會很直覺地想「把 crowdsec 寫在全域區塊,不就一次套用到所有站台了?」
答案是不行,而且這不是設定沒寫對,是 Caddyfile 語法本身的限制。
Caddyfile 最外層的全域區塊({ } 開頭那一塊),設計上只能放宣告 app 本身怎麼運作的設定——像 email(告訴 Caddy 申請 TLS 憑證時用哪個信箱)、log(設定 default logger 輸出到哪)、crowdsec { api_url ... api_key ... }(啟用 crowdsec 這個 app,告訴它怎麼連 CrowdSec LAPI)。這些設定的共通點是:它們描述的是「這個功能該怎麼運作」,而不是「該對哪些請求生效」。
真正決定「這個請求該怎麼處理」的,是每個網域 block 裡的 HTTP handler 指令——reverse_proxy、basic_auth、crowdsec(作為 handler 使用時)都屬於這一類,它們會被編譯進該網域專屬的一條 route(處理鏈)。全域區塊裡的 crowdsec { } 只是「把這個 app 準備好」,就像把一把鑰匙掛在牆上,但沒有插進任何一扇門的鎖孔裡——不會有任何請求真的被它攔截,除非你在某個網域的 route 裡明確寫上 crowdsec 這個 handler。
正確做法是用 Caddyfile 內建的 snippet + import 機制,只需要維護一個地方,之後要調整邏輯也只改一次:
{
email you@example.com
log {
output stdout
format json
}
order crowdsec first
crowdsec {
api_url http://127.0.0.1:8080
api_key <你的API_KEY>
ticker_interval 15s
}
}
(crowdsec_check) {
log
crowdsec
}
import /etc/caddy/sites/*每個站台檔案只要加一行 import crowdsec_check:
coder.example.com {
import crowdsec_check
reverse_proxy 192.168.1.38:3000
}十幾個站台檔案要一次補上這一行,手動改容易漏掉巢狀 block(例如 handle { }、tls { }、transport { } 這些內層區塊不該被誤塞進 import),用 awk 追蹤大括號深度、只在最外層 block 剛打開的那一行插入,比人工逐一檢查可靠:
cd /etc/caddy/sites
cp -r . /root/caddy-sites-backup-$(date +%Y%m%d) # 先備份
for f in *.caddy; do
awk '
{ print }
/\{[[:space:]]*$/ {
if (depth==0) print " import crowdsec_check"
depth++
}
/^[[:space:]]*\}[[:space:]]*$/ { depth-- }
' "$f"
這個 script 的邏輯是:每讀到一行結尾是 { 就把深度加一,讀到單獨一行 } 就把深度減一,只在深度即將從 0 變成 1 的那一行插入 import crowdsec_check——也就是網域最外層 block 剛打開的瞬間,巢狀區塊因為當下深度不是 0,不會被誤觸發插入。
坑四:log 加了、CrowdSec 也裝了,但 acquisition 顯示全部 unparsed
跑一次 cscli metrics show acquisition:
Source Lines read Lines parsed Lines unparsed
journalctl:...caddy.service 109 - 109109 行全部解析失敗,一行都沒成功。原因跟坑二埋的伏筆有關:全域區塊的 log { } 只設定「default logger」要輸出到哪裡(stdout、什麼格式),並不會自動幫每個站台啟用 access logging。這是 Caddy 一個很容易誤解的地方——很多人以為全域設定了 log 就等於全站都會記錄請求,但實際上 access logging 是逐站台 opt-in 的,要真的產生 access log,每個站台的 route 裡還是要有一個 log 指令去「啟用」它,套用剛剛全域設定好的那個 default logger(前面 snippet 範例裡已經把它加進去了)。
沒加這個指令之前,journald 裡只有 Caddy 自己的內部訊息(啟動、crowdsec 模組狀態),完全沒有 "logger":"http.log.access" 這種格式的行,caddy-logs parser 當然找不到任何符合預期格式的內容可以解析,全部落到 unparsed。
驗證方式很直接,看 journald 裡有沒有出現 access log 格式的行:
journalctl -u caddy.service -n 20 --no-pager
# 應該要看到類似
# {"level":"info","ts":...,"logger":"http.log.access","msg":"handled request",...}坑五:Bouncer 一直 access forbidden,最後挖出真正的資安問題
Bouncer 授權卡關是這次花最多時間的一段,Caddy 這端的 log 一直重複同一句:
failed to connect to LAPI, retrying in 10s: API error: access forbidden先補一點背景:CrowdSec 的 LAPI 有兩種認證身分——machine(用帳密登入,cscli 跟 agent 本身用這個,權限包含新增/刪除 decision)跟 bouncer(用 token 認證,只能讀取 decision,權限受限)。我們遇到的問題屬於後者,Caddy 裡的 crowdsec 模組是以 bouncer 身分打 API。
排查順序,一步步縮小範圍:
這次的問題最後是靠完整重啟解決的(可能是先前殘留的 process 狀態沒有真的套用新 key),但排查過程中,tail -f /var/log/crowdsec.log 意外印出了不該出現的東西:
line="CLOUDFLARE_API_TOKEN=cfut_YkRlz6U7opvGmgw47PWcQrtBytxnvvfw1kv7GjFKe7da162d"真正的問題:Cloudflare API Token 明文洩漏進 log
JOURNAL_STREAM、SYSTEMD_EXEC_PID、MEMORY_PRESSURE_WATCH 這些系統自動注入給 process 的環境變數,連同自訂的 CLOUDFLARE_API_TOKEN(用來讓 Caddy 走 DNS-01 驗證申請萬用字元憑證)一起被整包印出來。兇手是 systemd unit 裡多加了一個 --environ flag:
systemctl cat caddy.service | grep ExecStart
# ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile--environ 是官方拿來 debug 用的旗標,作用是「啟動時把整個 process 的環境變數印到 log」,方便開發者確認環境變數有沒有正確傳進去。很多網路上抄來的 systemd unit 範例會在除錯階段加上它,之後忘記拿掉就直接上線——而且這不是「發生一次」的洩漏,只要這個 flag還留著,每次 caddy 啟動或重啟都會再把完整環境變數印一次,這也解釋了為什麼我們前幾輪一直重啟測試時,log 才會反覆出現同樣的內容。
發現這種洩漏時的處置順序,記清楚:先撤銷金鑰,再修設定——換新 token 之前,先把已經外洩的舊 token 在服務商後台作廢,不然如果順序反過來,新 token 換上去、systemd unit 卻還沒改,下一次重啟又會把新 token 也印進 log,等於白做工。
修法:
sudo systemctl edit --full caddy.service
# 把 ExecStart 那行的 --environ 拿掉,改成:
# ExecStart=/usr/bin/caddy run --config /etc/caddy/Caddyfile
systemctl daemon-reload
systemctl restart caddy這個插曲背後真正的教訓,不只是「拿掉一個 flag」而已:任何時候你在為了除錯 A 問題而去看 log 時,都要順手掃一眼有沒有不該出現的敏感資訊,尤其是複製別人現成的 systemd unit、Docker Compose、CI pipeline 設定時,這些範例常常帶著作者自己除錯用的旗標,換到正式環境卻沒有人記得拿掉。
測試方法論:分層驗證,不要一次跳到「打真實流量」
除錯到這裡,整條鏈路(架構圖裡的每一段箭頭)理論上都通了,但不要急著相信它——用分層的方式驗證,每一層卡住都能立刻定位在哪一段,而不是一次打真實請求,結果沒被擋,卻不知道是 log 沒進去、parser 沒解析、還是 bouncer 沒拉到決策。
如果多個站台都要驗證(尤其是有巢狀結構的,像帶 handle { } 或自訂 matcher 的站台),建議至少挑 3、4 個結構不同的代表性站台各測一輪 Layer 3,而不是只測一個就假設全部都生效——因為批次腳本插入 import crowdsec_check 這種操作,理論上不會有例外,但巢狀結構複雜的檔案值得多一層人工確認。
常用指令速查
# ── Decisions(封鎖決策)───────────────────────
cscli decisions list # 目前生效中的封鎖
cscli decisions add --ip <IP> --duration 1m --reason "test"
cscli decisions delete --ip <IP
容易記錯的地方
cscli alerts delete 沒有 --reason 這個 flag,只能用 --ip、--range、--scenario、--id、--all 篩選。想依「你自己填的測試原因文字」去刪除是做不到的,只能靠 IP、range 或 ID 定位,下決策的時候養成習慣記下 IP 或 ID 會比記原因文字更好用。
小結
這一輪下來,真正花時間的不是「照文件操作」,而是幾個容易被忽略的細節:journald 來源要靠 syslog + caddy 兩段式 parser 才吃得到內容、全域 log 區塊不會自動幫每個站台開 access logging(是逐站台 opt-in 的)、Caddyfile 語法上就不支援「全域套用某個 handler 到所有站台」,snippet + import 是唯一正規解法,以及最意外的——一個從範例複製貼上的 debug flag 差點洩漏 API 金鑰。
下一篇會接著處理「CrowdSec 有在擋了,但我要怎麼知道流量長什麼樣子、被擋的都是誰」——用 Prometheus + Loki + Grafana 把流量跟資安事件視覺化。
參考連結