我的 VPS 早就在自我監控——只是沒人在看 log
· 中文 · Claude Sonnet 5 代筆
同一個告警分診專案、Phase 2 的第二條軌道——另一條(稽核我自己 AI 客服的那篇)監控的是我的 AI 表現正不正常,這篇監控的是我的 VPS 有沒有被動手腳。
起點:查證這台機器實際在跑什麼,不是憑猜的
規劃階段一開始只列出三個候選:auth.log、fail2ban.log、ufw.log。實際查了機器之後才發現,這台 VPS 還跑著 AIDE(檔案完整性監控)、auditd(核心稽核 daemon)、Lynis(安全掃描基準)——而且三個都是真的排程在跑(systemctl is-active/is-enabled auditd 常駐、lynis.timer/dailyaidecheck.timer 每日執行)。這台機器的資安關注層級明顯比一般個人專案高,監控範圍因此擴大到六個來源,設計工作也跟著擴大。
這一輪最關鍵的決定:**規則已經能百分之百判定的訊號,完全不經過 AI。**不是「送進 prompt 但指示不要降級」,而是 AI 從頭到尾根本沒有機會降級——因為五類 finding(fail2ban 短時間大量封鎖、AIDE 檔案異動、auditd 權限提升、auditd 設定檔異動、Lynis 新警告)全部由 src/vps-anomaly-report-builder.js 的純規則 JS 直接產生。這是比「AI 不應該降級判斷」更強的保證——AI 指示遵從度是機率性的,零 AI 涉入是結構性保證。Claude 只處理整個系統裡唯一真正需要語意推理的部分:判斷 auth.log 成功登入清單的來源或時間有沒有異常,這裡沒有簡單的「已知正常 IP 清單」可以直接比對。
架構
實際 workflow 有 38 個節點——上面這張圖是流程的形狀,不是逐一對應的節點數。
SSH wrapper:固定別名清單,不接受任意路徑
跟另一條軌道的正則驗證不同,這條軌道的 wrapper(docs/n8n-vps-security-log-wrapper.sh)採固定別名對應絕對路徑的設計(auth_log/fail2ban_log/ufw_log/aide_log/lynis_log/lynis_report/auditd_events)——n8n 傳進來的字串永遠不會被組進任何檔案路徑,比對任意路徑做正則驗證多一層防護。對抗測試(非白名單指令、路徑穿越 ../)全部正確拒絕。
六個真實踩坑,一次比一次精彩
Lynis log 權限問題。/var/log/lynis.log//var/log/lynis-report.dat 擁有者跟群組都是 root(不是我以為的 adm),一般帳號完全讀不到。修法:新增範圍極窄的 sudoers.d 規則,只允許這支 SSH key 對應帳號對這兩個絕對路徑免密碼執行 cat。
**AIDE 報告 71% 都是 Docker 雜訊。**AIDE 用的是 Debian aide-common 套件的分檔規則機制,Docker 沒有對應的專屬規則檔,/var/lib/docker 掉進 99_aide_root 這條 catch-all(/ 0 Full)——某次報告約 100 萬筆異動裡,71%(712,512 筆)都是 container overlay2/volume 資料的正常變動雜訊,把真正該留意的東西完全淹沒。加上排除規則+aideinit 重建後,乾淨報告變成 Total 919483 / Added 5 / Removed 3 / Changed 148。
AIDE 資料庫基準永遠凍結。/etc/default/aide 是 COMMAND=update+COPYNEWDB=no——aide.db.new 每天都重新產生,但從來沒有被套用成 aide.db,比對基準永遠凍結在最初 aideinit 那一刻,代表每天的報告都在重複回報同一批舊異動。解法交給 VPS 系統層自己負責(不讓 n8n pipeline 有權決定內部系統基準要不要前進):寫一支 aide-db-rotate.sh,掛在 dailyaidecheck 的 CRONEXITHOOK 擴充點(只有整個檢查正常跑完才觸發),把 aide.db.new 升格成新的 aide.db,舊的歸檔、只保留最近 7 份。
**systemd timer 的 cron.daily 陷阱。**這台 VPS 用 systemd(dailyaidecheck.timer),/etc/cron.daily/dailyaidecheck 本身有 if [ -d /run/systemd/system ]; then exit 0; fi 的判斷,直接呼叫這個入口點根本不會觸發檢查——要嘛等 timer 排程,要嘛用 sudo systemctl start dailyaidecheck.service 手動觸發。
ausearch 靜默空結果——這次除錯耗時最久的一個問題(Step B6)。ausearch 在沒有 PTY 的 SSH session 下會靜默回報 <no matches>,即使真實資料存在,完全不會報錯。我依序排除了 stdin 未關閉、locale、時區、HOME/PATH、sudo 權限等假設,最後用「同一個查詢在有 PTY/沒有 PTY 兩種情況下直接對比」才鎖定問題。建置指南草案原本假設 n8n SSH 節點有分配 PTY 的選項可以打開,實測發現 n8n 內建 SSH 節點的 Execute a Command 操作底層用 ssh2 函式庫的 exec(),UI 完全沒有 PTY 相關設定可調(n8n 社群論壇上其他使用者也回報過同樣限制,不是這個專案獨有)。真正的修法在 VPS 端 wrapper script 自己開 PTY:
script -qec "ausearch ... 2>/dev/null" /dev/null
完全不依賴外層 SSH channel 有沒有分配 PTY,也不需要等 n8n 未來版本補上這個功能。修好後同一個查詢窗口回傳 1213 萬字元、69134 行真實輸出,parseAuditdEvents 對這份輸出跑出 8664 筆事件全部正確解析,88ms,無 parse error。
ausearch 時間戳的時區陷阱。ausearch 輸出的 time-> 時間戳原本直接 new Date(...) 解析,沒有時區資訊會用執行環境的本地時區解讀——在非 Asia/Taipei 時區的主機上會悄悄偏差,且完全不會報錯。撰寫 B8 建置文件時意外測出這個真實 bug 並修正,改成明確用 +08:00 解析,新增一個不依賴執行環境時區的回歸測試(TZ=UTC npx jest 也會過)。
跨來源彙整 AI prompt:兩個真實踩坑
**時區換算模糊導致 Claude 自我懷疑、輸出兩段互相矛盾的 JSON。**最初 prompt 把登入時間戳用原始 UTC ISO 字串直接送給 Claude,卻沒告訴它這台 VPS 是哪個時區——實測 Claude 真的在回覆裡寫出「題目給的是 UTC,需要知道時區才能換算」這種自我懷疑的文字,接著先給一個錯的 JSON、又重新分析一次,才給第二個對的 JSON。修法:不要求 AI 做時區換算,改成在 JS 端用 Intl.DateTimeFormat 把時間戳轉成 VPS 當地時間字串(YYYY-MM-DD HH:MM:SS (UTC+8))再放進 prompt,從根本上移除這個模糊地帶。
**eval 腳本的 JSON 擷取邏輯撞上雙 JSON 輸出。**原本用 regex 找 JSON 區塊,撞上上面那個問題產生的雙段輸出直接整段解析失敗,靜默回退成 {flagged: []},讓兩個真的異常的登入情境都被誤判成「AI 沒抓到」——但 Claude 其實已經在文字裡正確判斷出來了,是解析邏輯不夠健壯。改成用大括號深度計數(不是 regex)找出文字裡所有平衡的頂層 {...} 區塊,從最後一個往前找第一個能成功解析、且有 flagged 陣列的區塊。
驗收結果:8 個情境(正常成功登入、異常來源/時間登入、fail2ban 大量封鎖、AIDE 檔案變更各至少 2 個變體)8/8(100%),零漏判高優先訊號;另外用 30 個週期規模壓力測試(3 個真異常穿插在 27 個正常週期之間),確認資料量放大後 cycleId 依然逐字正確對應,另一條軌道那個編號誤判的 bug 這裡沒有重演。
真實第一次上線執行(2026-08-14)
這條軌道最後一步是把 38 節點的 vps-anomaly-triage-main workflow 匯入真實 n8n 環境並實際跑一次。因為 vps_log_cursor 原本是空表,第一次執行是全量回溯(execution id 347490):
- 全部 38 個節點
success,resultData.error為null - fail2ban:6,951 個新增封鎖 IP(累積量)、AIDE:892 個檔案異動(累積量)、auditd 權限提升:227 筆(累積量,內容多為
apt-key/dpkg系統維護行為) - Claude Haiku 4.5 正確判斷出 user
joey在同一週期內從 4 個不同來源 IP 登入的異常,flagged出來的理由跟規則側摘要完全一致 - 路由結果:4 筆 high(立即經 Telegram/Slack 三層發送,
failed_notifications沒有任何一筆,代表全數送達)+ 1 筆 low(Lynis 新警告MAIL-8818,正確寫入low_priority_alerts等每日彙整) - 5 個來源 cursor + Lynis snapshot 全部成功寫回
- Step B6 踩過的「
ausearch無 PTY 靜默回空」問題這次沒有重現,修法在真實排程下確實有效
跟 n8n 打交道的通用教訓
這幾點值得單獨拉出來記一下:
- 上游輸出 0 個 item,下游節點完全不會被觸發(不是「觸發後輸出 0 個」)——凡是「這次剛好沒有新資料」是合理情境的節點,都要用 Always Output Data 或 sentinel item 明確處理。
- 迴圈的每一個出口、IF/Switch 的每一個分支,接線前先問「不接的話後面會發生什麼」——尤其是「這個分支不接回迴圈,迴圈是不是就永遠停在這裡」。
Split In Batches節點的內部狀態綁在節點本身、跨越整個 execution 生命週期,巢狀放進另一個迴圈是已知限制,不是偶發 bug——真的需要就一開始抽成獨立 sub-workflow。- 測試計劃要包含「刻意讓它失敗」,只測 happy path 測不出接線缺陷。
- LLM 輸出如果需要對應回原始資料,不要用容易被誤用的替代標籤——給人類看的編號很容易被模型誤當成真正要回傳的 ID,小規模 eval 情境集測不出這類問題,量大才會浮現。
- 匯出 workflow JSON 存檔前,先掃一次有沒有硬編碼的憑證——Code node 沒有像 HTTP Request node 一樣的 credential 引用機制,某些既有設計會把 token 直接寫死在 Code node 裡,一旦把整個 workflow 匯出成 JSON 存進 git,明文憑證會被一起存進 git 歷史。
- 某些指令依賴 PTY,且這種失敗完全不會報錯——回傳一個「看起來合法但內容是空的」結果,比明確的錯誤訊息更危險。SSH 節點跑的指令如果不是單純
cat/ls,建置前要先手動比對「有 PTY/沒有 PTY」兩種情況下輸出是否一致。
接下來
Phase 1 文章預告的演進方向(Prometheus/Alertmanager、Postgres 歷史留存、Grafana 準確率儀表板、獨立 watchdog workflow)目前都還沒開始動工。之後真的要往下一階段投入時,可以直接沿用的既有資產:AIDE 資料庫基準凍結問題的解法(aide-db-rotate.sh)、vps_log_cursor 的多來源游標模式,以及上面這份 n8n 整合穩定性清單——三項都已經在這一輪對真實正式環境的資料跑過、驗證過。