為什麼開始認真處理家居網絡安全 #
近一年家居網絡安全的壞消息不少: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
原因有兩個:
ban_icmplimit會不分敵我地丟棄入站 ICMP,包括 echo reply(TYPE=0)和「需要分片」(TYPE=3 CODE=4)。後者是 path MTU discovery 的關鍵封包,丟了它,有些網站會載入到一半就卡住——表現出來就是「間歇性上唔到網」。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 輪替後數字會暴跌,不加這條就會誤報「一切安好」。
我踩過的坑 #
誠實記錄這次旅程裏的幾個錯誤,希望你能避開:
- 把
f_monitor的無限迴圈當成程式 hang。banIP 的監視進程本來就是一個logread -f常駐迴圈,看到它停在do_sys_poll是正常的,不是死鎖。 - 讀了一次
ps快照就下結論。「進程還在」「進程死了」都要再確認一次——我好幾次被過期的快照誤導。 - 計數器腳本加總了 allowlist 的正常流量。只要抓
(drop|reject)結尾的規則,別用jsonfilter一把抓。 logread -e是區分大小寫的。banIP 的 tag 是banIP-...,用-e banip永遠抓不到。nft get element需要精確比對。set 裏存的是 CIDR,直接查單一 IP 會落空;日誌裏的drop/<set>標籤才是權威。- 隨手
ifdown wan測試「順不順」,結果釋放了正常的 DHCP lease,換了三次 IP。驗證狀態用唯讀指令,別動網卡。 - 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之前,先備份。