↓快轉到主要內容
  1. 文章列表/

banIP 與 CrowdSec 踩坑分享

目錄

為什麼開始認真處理家居網絡安全
#

近一年家居網絡安全的壞消息不少:Synology NAS 被勒索軟件攻擊的事件接連出現,PHP 生態又接連爆出高風險漏洞。這些事件有一個共同點——被攻擊的往往不是「沒有防護」的機器,而是「自以為有防護、但防護其實已經失效」的機器。

我自己的經歷也印證了這一點。我家裏的 ImmortalWrt 路由器裝了 banIP 一段時間,一直以為它正在運作;後來一次排查才發現,它因為一個設定問題已經靜悄悄地停止封鎖了幾個星期——服務在,進程在,但防火牆規則表是空的。

這篇文章不是一份「照抄就成功」的教學,而是一份經驗分享:記錄我在香港的家居網絡裏,如何把 banIP 和 CrowdSec 兩個工具配搭起來,如何追查問題,以及如何建立一套每天只看一眼手機就能安心的監察機制。每個人的網絡環境都不同,請把它當成路線圖,而不是說明書。

兩個互補的工具
#

banIP
#

banIP 是 OpenWrt / ImmortalWrt 上的封鎖清單管理工具。它做的事很直接:

  • 下載多個公開的 IP 信譽清單(firehol、threatview、dshield 等)
  • 按國家封鎖來源 IP(透過 RIR 的 delegated 資料)
  • 把這些 CIDR 載入 nftables 的 named sets,在 WAN 側直接丟棄符合條件的封包

它解決的問題是**「已知的壞人」**——那些已經被社群標記為掃描器、暴力破解來源的 IP,不值得讓它們碰到你的服務。

CrowdSec
#

CrowdSec 是新一代的入侵偵測工具,概念上像是「現代化的 fail2ban」:

  • 透過 parsers 分析日誌,識別暴力破解、掃描等行為
  • 有一個社群共享的中央信譽資料庫(CAPI),全球用戶上報的壞 IP 會自動同步到你的機器
  • 由 bouncer(執行器)把決策轉成實際的防火牆規則

它解決的問題是**「還沒被清單收錄的壞人」**,以及「針對你服務的行為偵測」。

為什麼兩個都要
#

兩者其實不重疊,而是互補:

banIP CrowdSec
方法 靜態清單 + 國家封鎖 行為偵測 + 社群信譽
反應 已知壞 IP 新出現的攻擊
情報 下載的 feeds 社群 CAPI
執行 路由器 nftables 路由器 bouncer

其中一個關鍵認知:bouncer 只能封鎖它所在機器收到的流量。如果 CrowdSec 只裝在 Raspberry Pi 上,它只能保護 Pi 自己,對家裏其他裝置一點作用都沒有。這是很多人第一次安裝時沒搞清楚的。

我的環境
#

  • 路由器:Netcore N60 Pro,ImmortalWrt 24.10-SNAPSHOT(MT7986 / filogic,4 核、2GB RAM)
  • 儲存:韌體在 NAND,overlay 掛在 USB 手指上(這點對後面的 log 容量規劃很重要)
  • Raspberry Pi:DietPi(Debian 底),與路由器同網段
  • 對外服務:80/443 轉發到 Pi 上的 nginx proxy manager,另外有 Plex

網絡安全紅線:文章裏所有公開 IP、MAC 位址、密碼都已經隱去;內網 IP 統一用 192.168.1.x 表達。

第一部分:banIP 設定
#

安裝
#

opkg update
opkg install banip luci-app-banip

裝完之後,LuCI 的「Services → banIP」就有完整的設定介面,包括 per-set 的封包計數、set 內容檢視,以及一個地圖檢視。

核心設定
#

以下是我最終採用的關鍵設定,以及背後的考慮:

uci set banip.global.ban_dev='eth1'        # WAN 網卡
uci set banip.global.ban_ifv4='wan'        # WAN 介面
uci set banip.global.ban_autodetect='0'    # 重要,見下文
uci set banip.global.ban_feed='country' 'dshield' 'firehol1' 'firehol2' 'threat' 'threatview'
uci set banip.global.ban_country='br' 'cn' 'in' 'id' 'ir' 'kp' 'ru' 'vn'
uci commit banip
/etc/init.d/banip restart

ban_autodetect='0' 是整個設定裏最重要的一行。

banIP 1.5.x 有一個行為:自動偵測 WAN 網卡時,如果某個瞬間網卡狀態看起來是 down,它會執行 uci_remove_list banip global ban_dev,把自己的設定檔刪掉,然後在下一次啟動時報錯 no wan devices,從此停止運作。我就這樣中了招——進程還在,但 nftables 表永遠是空的,而且完全沒有報錯。

關掉 autodetect、手動指定 ban_dev,就永久避開這個問題。

開啟 country feed(一個容易忽略的坑)
#

我原本設了 ban_country='cn ru br in id ir vn kp',但一直沒有生效。追查後發現:banIP 的國家封鎖寄託在一個名字真的叫 country 的 feed 上。如果你的 ban_feed 清單沒有包含 country,那八個國家設定就是一個「沒人執行的設定」。

uci add_list banip.global.ban_feed='country'
uci commit banip
/etc/init.d/banip restart

啟動後,country.v4 set 應該有大約兩萬個 CIDR。注意:在 ban_countrysplit='0' 下,八個國家會合併成一個 country.v4,日誌只會寫「country.v4 擋下了」,不會告訴你是哪個國家。想分開統計就設 ban_countrysplit='1',代價是多幾個 nftables sets。

還有一點,對香港用戶特別重要:RIR 資料把 HK 和 CN 分開,所以封鎖 cn 不會誤傷香港本地網站。我實測 gov.hk 和本地 ISP 網站都正常。

關閉 rate limiter
#

banIP 內建 ICMP / SYN / UDP 的速率限制。我的建議是全部關掉:

uci set banip.global.ban_icmplimit='0'
uci set banip.global.ban_synlimit='0'
uci set banip.global.ban_udplimit='0'
uci commit banip
/etc/init.d/banip restart

原因有兩個:

  1. ban_icmplimit 會不分敵我地丟棄入站 ICMP,包括 echo reply(TYPE=0)和「需要分片」(TYPE=3 CODE=4)。後者是 path MTU discovery 的關鍵封包,丟了它,有些網站會載入到一半就卡住——表現出來就是「間歇性上唔到網」。
  2. ban_synlimit=10 對一個同時載入十幾個資源的網頁來說太低了,正常瀏覽都可能被誤殺。

你要的是封鎖清單,不是一個誤傷自己的限速器。清單照常運作,限速關掉。

持久化 log 與容量規劃
#

OpenWrt 預設的 log 在 RAM,重開機就消失,而且容量小得可憐。攻擊一來,緩衝區幾分鐘就被沖走,事後想查都查不到。

我把它寫到檔案,並逐步加大:

uci set system.@system[0].log_file='/root/messages'
uci set system.@system[0].log_size='524288'   # 512 MB
uci commit system
/etc/init.d/log restart

每個人的情況不同,這一步不能照抄:

  • 我的 overlay 在 46GB 的 USB 手指上,512MB 只是零頭,不怕寫壞。
  • 如果你的 overlay 在 NAND 閃存上,別這樣做——大量寫入會磨損 NAND,而且空間根本不夠。那種情況應該改用遠端 syslog,把 log 送到內網的另一部機器。

容量規劃要用實際數據算。我的基準流量大約 5.5MB/day;但遇到全端口掃描時,可以飆到每小時 5MB。64MB 在攻擊下撐不到一天就輪替,最後定在 512MB——攻擊期約四天,平常約三個月。

一個重要的驗證:OpenWrt 的 log_size 是檔案大小上限,不是 RAM 緩衝。我從 64MB 升到 512MB 時,free -m 的可用記憶體不跌反升,logread 的 RSS 維持在 1.6MB——所以不用擔心它吃掉記憶體。

把地圖換成 OpenStreetMap
#

LuCI 的 banIP 地圖檢視預設用 Carto 的圖磚,但近年 Carto 要求 API key,結果地圖上一片空白(紅綠標記其實有畫出來,只是底圖載入失敗)。修法是把圖磚來源換成 OpenStreetMap:

sed -I "s|https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png|https://tile.openstreetmap.org/{z}/{x}/{y}.png|" /www/luci-static/resources/view/banip/map.html

注意:這個檔案屬於 luci-app-banip 套件,套件一升級就會被覆寫,要重新套用。

預期中的流量
#

設定完成後,你會看到這樣的數字:每天數萬到二十萬個封包被 banIP 丟棄。別被嚇到,這是互聯網的常態。

實際觀察下來的模式:

  • 目標端口集中在 80、443、23、445、3389、5353、8080、34567、2601——這是典型 botnet 掃描的簽名(Telnet、SMB、RDP、DVR 後門)。這些端口你家裏根本沒開。
  • 大量「每端口一個封包」的 TCP 全端口掃描,單一來源 IP 可以刷出一萬個封包。
  • 單一 IP 突發洪水、來源 IP 輪換的 CDN 式掃描,都是日常。

關鍵認知:被擋下來的封包是防火牆正在正常工作,不是出事了。真正值得緊張的,是那些「成功建立了連線」的異常流量——這部分我放在後面的健康檢查。

第二部分:CrowdSec 設定
#

架構決定:Pi 做腦,路由器做手
#

我選擇了分散式架構:

元件 位置
Agent + LAPI(本機 API)+ parsers Raspberry Pi(DietPi)
Firewall bouncer Pi 一個 + 路由器一個

為什麼不把 CrowdSec 全裝在路由器上?

  • 路由器上的 bouncer 才是保護全家的關鍵。bouncer 只封鎖它所在機器的流量,放在路由器才能過濾所有經過閘道的封包。
  • 韌體升級會把路由器上的套件和資料庫全部抹掉,而 Pi 不會受影響。
  • 路由器是閘道,裝一個會升級的 agent 上去,萬一出事就是全家斷網;Pi 壞了重灌就好。
  • OpenWrt 的 opkg 套件通常落後上游一年,而 Pi 能裝官方 Debian 源的最新版。

代價是:路由器依賴 Pi 的 LAPI。Pi 掛了的話,已經載入的 nftables sets 仍然會繼續封鎖(它們在 kernel 裏),只是收不到新決策。而 banIP 完全獨立,不受影響。

Pi 安裝
#

curl -s https://install.crowdsec.net | sudo sh
sudo apt install -y crowdsec
sudo apt install -y crowdsec-firewall-bouncer-nftables
sudo cscli lapi status

Pi 上的 bouncer 保護 Pi 自己;路由器上的 bouncer 保護整個網絡。

修 journald acquisition(DietPi 的坑)
#

DietPi 只有 journald,沒有 /var/log/* 檔案。CrowdSec 安裝器自動產生的 acquisitions 全部指向不存在的檔案,所以它靜靜地什麼都沒解析——沒有報錯,cscli metrics 裏根本沒有 Acquisition 區塊。

修法是把 sshd 的來源換成 journald:

# /etc/crowdsec/acquis.d/setup.sshd.yaml
source: journalctl
journalctl_filter:
  - "_SYSTEMD_UNIT=ssh.service"
labels:
  type: syslog
sudo systemctl restart crowdsec
sudo cscli metrics   # 應該看到 Acquisition + Parser 區塊

LAPI 綁定 LAN IP(三個檔案的坑)
#

預設 LAPI 只聽 127.0.0.1:8080,路由器連不到。要把三個檔案都改掉——只改 config.yaml 會令 agent 連不到自己的 LAPI 而崩潰:

# 1. 綁定 LAPI 到 Pi 的 LAN IP
sudo sed -I 's|^\(\s*listen_uri:\).*|\1 192.168.1.103:8080|' /etc/crowdsec/config.yaml

# 2. agent 自己的憑證 URL
sudo sed -I 's|url: http://127.0.0.1:8080|url: http://192.168.1.103:8080|' /etc/crowdsec/local_api_credentials.yaml

# 3. Pi 自己的 bouncer(第三個檔案!)
sudo sed -I 's|http://127.0.0.1:8080|http://192.168.1.103:8080|' /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml

sudo systemctl restart crowdsec
sudo systemctl restart crowdsec-firewall-bouncer

路由器安裝 bouncer
#

opkg install crowdsec-firewall-bouncer

然後為路由器建立一個 bouncer API key(在 Pi 上執行):

sudo cscli bouncers add router-openwrt

在路由器上設定(注意 @bouncer[0]——config bouncer 是匿名 section,寫 crowdsec.bouncer.xxx 會報 uci: Invalid argument):

uci set crowdsec.@bouncer[0].api_url='http://192.168.1.103:8080/'
uci set crowdsec.@bouncer[0].api_key='<你的-key>'
uci set crowdsec.@bouncer[0].enabled='1'
uci commit crowdsec
/etc/init.d/crowdsec-firewall-bouncer enable
/etc/init.d/crowdsec-firewall-bouncer start

驗證:

nft list tables | grep -I crowdsec
# ip crowdsec + ip6 crowdsec6

封鎖次序:banIP 先行
#

兩個工具用不同的 nftables 優先級:

chain priority
banIP -100
CrowdSec filter + 4

banIP 先跑,所以它擋下了絕大部分流量。實測 CrowdSec 每天只補刀 1–2% 的封包——但那些是 banIP 清單沒收錄的 IP,正是它的價值所在。不要把兩個工具的數字相加,它們不是互斥的。

誠實的局限
#

  • CrowdSec 的 CAPI 清單是**「當下的清單」而非「事件記錄」**——IP 進來、過期、被替換,沒有「某 IP 在幾點被擋」的歷史。要做每日統計,只能靠 nftables 的計數器。
  • 我的路由器 SSH(dropbear)只綁在 LAN,互聯網上的 SSH 暴力破解根本到不了路由器;而 banIP 的 drop 日誌也沒有現成的 parser。所以 CrowdSec 對我的價值主要來自 CAPI 社群清單,而非本地行為偵測。

第三部分:報告、計數與警報
#

banIP 的 LuCI 介面有 per-set 計數,但沒有「端口分佈」和「來源 IP 排行」。CrowdSec 更徹底——它根本沒有按日統計。所以我寫了幾個小腳本補上這個缺口。

每日計數器
#

banIP 每天 04:15 會 reload,reload 會把 nftables 計數器清零。所以每日快照必須在 reload 之前抓。

# /usr/bin/block-stats.sh
#!/bin/sh
D=$(date +%Y-%m-%d)
B=$(nft list table inet banIP 2>/dev/null \
    | grep -E 'counter packets [0-9]+ bytes [0-9]+ (drop|reject)' \
    | grep -o 'counter packets [0-9]*' | awk '{s+=$3} END{print s+0}')
C4=$(nft list table ip crowdsec 2>/dev/null \
    | grep -E 'counter packets [0-9]+ bytes [0-9]+ drop' \
    | grep -o 'packets [0-9]*' | awk '{s+=$2} END{print s+0}')
C6=$(nft list table ip6 crowdsec6 2>/dev/null \
    | grep -E 'counter packets [0-9]+ bytes [0-9]+ drop' \
    | grep -o 'packets [0-9]*' | awk '{s+=$2} END{print s+0}')
echo "$D banip=$B crowdsec_v4=$C4 crowdsec_v6=$C6" >> /root/block-stats.log

EXT=$(grep ESTABLISHED /proc/net/nf_conntrack 2>/dev/null \
  | grep 'src=192\.168\.' \
  | awk '{for(I=1;i<=NF;i++) if($I ~ /^dst=/) print $I}' \
  | cut -d= -f2 \
  | grep -vE '^(192\.168|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|127\.|224\.|239\.|255\.|0\.)' \
  | sort -u | tr '\n' ' ')
echo "$D ext=$EXT" >> /root/ext-connections.log

cron:

15 04 * * * /etc/init.d/banip reload
10 04 * * * /usr/bin/block-stats.sh

最容易犯的錯:抓計數器時用 jsonfilter 把所有 counter.packets 加總。這樣會把 allowlist(例如 Cloudflare 的 IP 段)裏的正常流量也加進去——我一度看到「九億九千萬」的荒謬數字,其實是全家一週的總流量。一定要限定 (drop|reject) 結尾的規則。

網頁報告
#

把 banIP 的 drop 日誌解析成一個有日曆下拉選單的 HTML 報告,架在 192.168.1.1:8080:

# /etc/init.d/reportd
#!/bin/sh /etc.rc.common
START=99
USE_PROCD=1
start_service() {
  mkdir -p /tmp/rep
  [ -x /usr/bin/banip-report.sh ] && /usr/bin/banip-report.sh >/dev/null 2>&1
  procd_open_instance
  procd_set_param command /usr/sbin/uhttpd -f -p 192.168.1.1:8080 -h /tmp/rep -T 60
  procd_set_param respawn
  procd_close_instance
}
/etc/init.d/reportd enable
/etc/init.d/reportd start

核心腳本 banip-report.sh 的思路是:logd 只有一個會輪替的檔案,沒有「每日檔案」,所以腳本把整個 log 逐行拆成 日期|set|端口|來源|協議,在伺服器端按日聚合,輸出成一個 data.js;網頁上的下拉選單純粹做前端切換,不用向伺服器發請求。

# /usr/bin/banip-report.sh(節錄——聚合邏輯)
grep 'banIP/inbound/drop' "$LOG" 2>/dev/null | awk '
BEGIN{m["Jan"]=1;m["Feb"]=2;m["Mar"]=3;m["Apr"]=4;m["May"]=5;m["Jun"]=6;
      m["Jul"]=7;m["Aug"]=8;m["Sep"]=9;m["Oct"]=10;m["Nov"]=11;m["Dec"]=12}
{
  d=sprintf("%s-%02d-%02d",$5,m[$2],$3)
  s="unknown"; if(match($0,/drop\/[a-z0-9.]+/)) s=substr($0,RSTART+5,RLENGTH-5)
  p="0";       if(match($0,/DPT=[0-9]+/))      p=substr($0,RSTART+4,RLENGTH-4)
  a="unknown"; if(match($0,/SRC=[0-9.]+/))     a=substr($0,RSTART+4,RLENGTH-4)
  r="other";   if(match($0,/PROTO=[A-Z]+/))    r=substr($0,RSTART+6,RLENGTH-6)
  print d"|"s"|"p"|"a"|"r
}' > $TMP

完整的 banip-report.sh 較長,我會把它放到文末附錄。cron 每十分鐘重建一次:

*/10 * * * * /usr/bin/banip-report.sh >/dev/null 2>&1

Pushover 每日健康檢查
#

這是我最滿意的部分:每天早上七點半,手機收到一條 Pushover 通知,告訴我網絡是否安好。

先準備 Pushover 憑證(存成獨立檔案,方便腳本 source):

# /root/.pushover
PUSHOVER_TOKEN="你的-app-token"
PUSHOVER_USER="你的-user-key"
# /usr/bin/pushme.sh
#!/bin/sh
[ -f /root/.pushover ] || { echo "missing /root/.pushover"; exit 1; }
. /root/.pushover
curl -s --max-time 15 -F "token=$PUSHOVER_TOKEN" -F "user=$PUSHOVER_USER" \
  -F "title=$1" -F "message=$2" -F "priority=${3:-0}" \
  https://api.pushover.net/1/messages.json >/dev/null

健康檢查腳本會把狀態分成三級:

級別 Pushover 優先級 觸發條件
ALERT 1(高,響鈴) banIP/CrowdSec 表消失、bouncer 沒跑、feeds 少於 6 個、overlay > 80%、多於 8 個新外部 IP
NOTICE 0(普通) 負載 > 3、單一 IP 佔 > 50% 流量、log 過大或剛輪替、3–8 個新外部 IP
OK -1(靜默) 一切正常,不打擾

完整腳本見文末附錄。cron:

30 7 * * * /usr/bin/router-health.sh

如何閱讀報告(以及一個重要的設計修正)
#

最早的版本把「新出現的外部 IP」直接當成警報。結果第二天就響了 ALERT——18 個「新」IP,全是 Cloudflare、Apple、Google、AWS 的正常輪換。CDN 的 IP 天天換,這是設計使然,不是入侵。

修正很簡單:把已知的 CDN / 雲端 IP 段標為 benign,只有真正不在預期範圍內的新 IP 才算數:

is_benign() {
  case "$1" in
    3.*|13.*|15.*|16.*|17.*|18.*|34.*|35.*|44.*|52.*|54.*) return 0 ;;
    64.233.*|74.125.*|108.177.*|142.250.*|142.251.*|172.217.*) return 0 ;;
    104.16.*|104.17.*|104.18.*|104.19.*|104.2[0-7].*) return 0 ;;
    172.6[4-9].*|172.7[0-1].*|162.15[89].*) return 0 ;;
    173.245.*|188.114.*|141.101.*|108.162.*|190.93.*) return 0 ;;
    199.165.*|192.200.*) return 0 ;;
  esac
  return 1
}

修正後,那 18 個 CDN IP 顯示為 benign=18,不會再誤報。警報系統最大的敵人不是漏報,而是誤報——響太多次,你就會開始無視它。

收到通知後怎麼讀:

  • drops 是 banIP 擋下的封包數。每天幾萬是正常,突然翻十倍就要看一眼。
  • top 是最活躍的單一來源。如果它佔了超過一半的 drops,那是洪水不是掃描。
  • newip 是真正值得點開來查的數字——「我的網絡在和誰建立新連線」。
  • log 只有 X MB - 剛輪替:這是特別加的一條,因為 log 輪替後數字會暴跌,不加這條就會誤報「一切安好」。

我踩過的坑
#

誠實記錄這次旅程裏的幾個錯誤,希望你能避開:

  1. 把 f_monitor 的無限迴圈當成程式 hang。banIP 的監視進程本來就是一個 logread -f 常駐迴圈,看到它停在 do_sys_poll 是正常的,不是死鎖。
  2. 讀了一次 ps 快照就下結論。「進程還在」「進程死了」都要再確認一次——我好幾次被過期的快照誤導。
  3. 計數器腳本加總了 allowlist 的正常流量。只要抓 (drop|reject) 結尾的規則,別用 jsonfilter 一把抓。
  4. logread -e 是區分大小寫的。banIP 的 tag 是 banIP-...,用 -e banip 永遠抓不到。
  5. nft get element 需要精確比對。set 裏存的是 CIDR,直接查單一 IP 會落空;日誌裏的 drop/<set> 標籤才是權威。
  6. 隨手 ifdown wan 測試「順不順」,結果釋放了正常的 DHCP lease,換了三次 IP。驗證狀態用唯讀指令,別動網卡。
  7. LuCI 的 Report 頁和 CLI 的 banip-service.sh report 是兩條路徑。CLI 在我這個版本會死鎖,但 LuCI 頁面運作正常——不要因為 CLI 失敗就判定 GUI 壞了。

結語
#

回顧這次的收穫:banIP 原本已經壞了幾個星期而無人知曉,國家封鎖從來沒有真正生效過,ICMP limiter 正在誤傷自己的連線。這些問題都不會自己喊痛——它們只會安靜地失效,直到你主動去查。

工具終究只是工具。真正有價值的,是那套每天早上看一眼手機就能安心的機制:一條通知告訴你「今天網絡安好」,或者在你需要行動時,把你叫醒。

附錄:完整的 banip-report.sh 與 router-health.sh,以及 sysupgrade.conf 的持久化清單,我放在 GitHub gist 連結(此處省略,可私訊索取)。記得一件事:這些自訂腳本放在 /usr/bin,韌體升級後會消失,要加入 /etc/sysupgrade.conf 的 keep-list。

本文所有程式碼僅供參考,請按自己的網絡環境調整。任何 uci commit 之前,先備份。

相關文章