我的 VPS 早就在自我監控——只是沒人在看 log

· 中文 · Claude Sonnet 5 代筆

showcasen8naiclaudedevopssecurity

同一個告警分診專案、Phase 2 的第二條軌道——另一條(稽核我自己 AI 客服的那篇)監控的是我的 AI 表現正不正常,這篇監控的是我的 VPS 有沒有被動手腳。

起點:查證這台機器實際在跑什麼,不是憑猜的

規劃階段一開始只列出三個候選:auth.logfail2ban.logufw.log。實際查了機器之後才發現,這台 VPS 還跑著 AIDE(檔案完整性監控)、auditd(核心稽核 daemon)、Lynis(安全掃描基準)——而且三個都是真的排程在跑(systemctl is-activeis-enabled auditd 常駐、lynis.timerdailyaidecheck.timer 每日執行)。這台機器的資安關注層級明顯比一般個人專案高,監控範圍因此擴大到六個來源,設計工作也跟著擴大。

這一輪最關鍵的決定:**規則已經能百分之百判定的訊號,完全不經過 AI。**不是「送進 prompt 但指示不要降級」,而是 AI 從頭到尾根本沒有機會降級——因為五類 finding(fail2ban 短時間大量封鎖、AIDE 檔案異動、auditd 權限提升、auditd 設定檔異動、Lynis 新警告)全部由 src/vps-anomaly-report-builder.js 的純規則 JS 直接產生。這是比「AI 不應該降級判斷」更強的保證——AI 指示遵從度是機率性的,零 AI 涉入是結構性保證。Claude 只處理整個系統裡唯一真正需要語意推理的部分:判斷 auth.log 成功登入清單的來源或時間有沒有異常,這裡沒有簡單的「已知正常 IP 清單」可以直接比對。

架構

high

low

排程觸發(每 30 分鐘)

平行讀取 5 個來源的 cursor/snapshot

SSH wrapper:auth_log / fail2ban_log / ufw_log
aide_log / lynis_report / ausearch auditd_events

各自的 parser 依 cursor 篩選新內容並結構化

純規則彙整
(fail2ban 大量封鎖/AIDE 異動/auditd 權限提升與設定異動/Lynis 警告)

高優先 finding,完全零 AI 涉入

auth_log 成功登入清單

Claude:來源/時間異常判斷
(唯一真正需要推理的部分)

彙整全部 finding,路由

立即通知

併入每日彙整

三層通知保護
(Telegram → Slack → failed_notifications)

寫回 5 個 cursor + Lynis snapshot

實際 workflow 有 38 個節點——上面這張圖是流程的形狀,不是逐一對應的節點數。

SSH wrapper:固定別名清單,不接受任意路徑

跟另一條軌道的正則驗證不同,這條軌道的 wrapper(docs/n8n-vps-security-log-wrapper.sh)採固定別名對應絕對路徑的設計(auth_logfail2ban_logufw_logaide_loglynis_loglynis_reportauditd_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/aideCOMMAND=updateCOPYNEWDB=no——aide.db.new 每天都重新產生,但從來沒有被套用成 aide.db,比對基準永遠凍結在最初 aideinit 那一刻,代表每天的報告都在重複回報同一批舊異動。解法交給 VPS 系統層自己負責(不讓 n8n pipeline 有權決定內部系統基準要不要前進):寫一支 aide-db-rotate.sh,掛在 dailyaidecheckCRONEXITHOOK 擴充點(只有整個檢查正常跑完才觸發),把 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、時區、HOMEPATHsudo 權限等假設,最後用「同一個查詢在有 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 個節點 successresultData.errornull
  • fail2ban:6,951 個新增封鎖 IP(累積量)、AIDE:892 個檔案異動(累積量)、auditd 權限提升:227 筆(累積量,內容多為 apt-keydpkg 系統維護行為)
  • 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 打交道的通用教訓

這幾點值得單獨拉出來記一下:

  1. 上游輸出 0 個 item,下游節點完全不會被觸發(不是「觸發後輸出 0 個」)——凡是「這次剛好沒有新資料」是合理情境的節點,都要用 Always Output Data 或 sentinel item 明確處理。
  2. 迴圈的每一個出口、IF/Switch 的每一個分支,接線前先問「不接的話後面會發生什麼」——尤其是「這個分支不接回迴圈,迴圈是不是就永遠停在這裡」。
  3. Split In Batches 節點的內部狀態綁在節點本身、跨越整個 execution 生命週期,巢狀放進另一個迴圈是已知限制,不是偶發 bug——真的需要就一開始抽成獨立 sub-workflow。
  4. 測試計劃要包含「刻意讓它失敗」,只測 happy path 測不出接線缺陷。
  5. LLM 輸出如果需要對應回原始資料,不要用容易被誤用的替代標籤——給人類看的編號很容易被模型誤當成真正要回傳的 ID,小規模 eval 情境集測不出這類問題,量大才會浮現。
  6. 匯出 workflow JSON 存檔前,先掃一次有沒有硬編碼的憑證——Code node 沒有像 HTTP Request node 一樣的 credential 引用機制,某些既有設計會把 token 直接寫死在 Code node 裡,一旦把整個 workflow 匯出成 JSON 存進 git,明文憑證會被一起存進 git 歷史。
  7. 某些指令依賴 PTY,且這種失敗完全不會報錯——回傳一個「看起來合法但內容是空的」結果,比明確的錯誤訊息更危險。SSH 節點跑的指令如果不是單純 cat/ls,建置前要先手動比對「有 PTY/沒有 PTY」兩種情況下輸出是否一致。

接下來

Phase 1 文章預告的演進方向(Prometheus/Alertmanager、Postgres 歷史留存、Grafana 準確率儀表板、獨立 watchdog workflow)目前都還沒開始動工。之後真的要往下一階段投入時,可以直接沿用的既有資產:AIDE 資料庫基準凍結問題的解法(aide-db-rotate.sh)、vps_log_cursor 的多來源游標模式,以及上面這份 n8n 整合穩定性清單——三項都已經在這一輪對真實正式環境的資料跑過、驗證過。