自架服務久了會遇到一個尷尬問題:監控自己的那台機器,自己掛了誰告訴你?

我跑 Uptime Kuma 監控自己的所有服務,但它就跑在被監控的那台機器上。停電、斷網、整台當掉,監控跟著一起死,通知一封都發不出來。

要解決這個,監控就得放在別的地方。方法其實不少:

做法成本適合誰
一台便宜 VPS 跑自己的 Kuma每月幾美金想完全自己掌控、不想欠人情
用現成的第三方監控服務免費方案通常夠用只想要「掛了通知我」,不想再多養一個服務
跟另一個自架的人交叉監控零成本剛好認識也在自架的人

我選第三個純粹是因為剛好有:另一個也在自架的站長,兩邊互相盯,他的 Kuma 盯我、我的 Kuma 盯他。附帶好處是兩邊的機房、線路、電力完全獨立,而且對方是活人,真的掛掉時會直接敲你。

這篇記錄整個過程,包括一開始那個把我們卡住的憑證誤解、v1 升到 v2 的資料庫遷移,以及最後查出來的一個坑:Trust Proxy 這個選項,在我的架構下開了之後反而比不開更危險。

(狀態頁的外觀可以用自訂 CSS 換掉,上面這個是我後來調成的像素風。Kuma 的狀態頁跟後台是分開的:後台要登入,狀態頁可以單獨公開,只露你想露的服務。)

憑證不用交換#

一開始的設定是這樣:對方開了一個子網域 status.<朋友網域>,把 A 紀錄指向我的固定 IP,Kuma 跑在我這台。

然後就卡住了。我的 nginx 用 Let's Encrypt,而那是他的網域。當時我們兩個的想法都是:要走 HTTPS,他就得把憑證給我。他有一張 *.<朋友網域>,我以為那就是唯一的路。

這個前提整個是錯的。

IMPORTANT

互相監控不需要交換任何憑證 關鍵在於:status.<朋友網域> 雖然是他的網域,但 A 紀錄已經指向我的 IP,所以 TLS 是在我的伺服器上終結的。Let's Encrypt 的 會去打 http://status.<朋友網域>/.well-known/acme-challenge/...,而這個請求會直接進到我的 nginx。所以我自己就能簽這張憑證,他什麼都不用給我。

certbot --nginx -d status.<朋友網域> 一行,就簽好了。續簽也一併解決:certbot.timer 自動跑,跟我自己的網域完全一樣。

wildcard 只綁「簽發那一刻」#

我當時的誤解是「wildcard 綁死 DNS,所以只能用他的」。這句話對了一半:wildcard 的「綁 DNS」指的是簽發那一刻的驗證方式,它一定要走 DNS-01。但那跟「憑證之後綁在哪」是兩回事。

憑證簽好之後就只是一個檔案,放在負責終結 TLS 的那台機器上。它不綁 DNS 商、也不綁某台伺服器。而且同一個主機名可以同時存在好幾張不同的憑證,互不衝突,CA 不會因為他有 wildcard 就不准我再簽一張單域名的。憑證簽發不是獨占的,只要你能證明你控制這個主機名,你就能拿到自己的那張。

反過來說,「用他的 wildcard」剛好踩中兩個雷:

共享 wildcard自己簽單域名
私鑰暴露面涵蓋他所有子網域,等於全站鑰匙給我只有這一個主機名
續簽每 ~90 天他要重發、我要重貼重啟,純手動易斷certbot.timer 自動,零參與

三個角色分清楚就不會亂:簽發的永遠是 CA(Let's Encrypt);申請人是我(我跑 certbot、我證明控制權);網域擁有者是他,他唯一做的事就是把 A 紀錄指過來,而那早就做完了。沒有任何人需要「幫對方簽」。

WARNING

這招的唯一前提:對方的子網域必須是純 A 紀錄直連,不能掛 Cloudflare 橘雲 掛了橘雲的話 TLS 會在 Cloudflare 終結,HTTP-01 驗證會打到 Cloudflare 而不是你,這條路就不通(那要改用 Cloudflare Origin Cert)。這個前提在文章後半會再回來咬我一次。

反代:別漏了 WebSocket#

Kuma 的後台大量用 推即時心跳跟圖表。反向代理如果只寫一行裸的 proxy_pass,WebSocket 升級不了,症狀是即時圖表完全不動,或瀏覽器判定「不安全」(HTTPS 頁面裡混進 ws://)。

反代區塊該有的幾行:

nginx
location / {
    proxy_pass http://127.0.0.1:3011;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 86400s;          # 24 小時,長連線不會被 nginx 掐斷
}

有個很容易誤判的地方:直接對 /socket.io/ 發 raw upgrade 請求會拿到 400,那是 Socket.IO 自己因為缺 sid 參數拒絕的,不是 nginx 擋的。看到 400 別急著改 nginx。

該監控什麼#

服務接上來之後,有兩個做法值得寫下來。

順帶一提,新增監測器時記得勾憑證到期通知。交叉監控本來就會定期打對方的 HTTPS,順便讓它在憑證剩沒幾天時叫你一聲,等於免費多一層保險。

給後端一條公開的 /health

我一開始監控 NAS 後端,是打一個需要認證的端點、然後把「可接受狀態碼」設成 401,反正回 401 就代表它活著。這能動,但語意是歪的:哪天後端壞掉但反代還在,可能照樣回 401,監控卻顯示綠燈。

正解是在後端加一條公開、免認證的 /health 回 200。活著=200、掛了=502,可接受狀態碼設回預設的 200-299,語意就對了。

cron 腳本用 push 型監控

HTTP 監控只能確認「服務現在活著」,但像每日備份、更新腳本這種排程任務,真正該監控的是「它有沒有按時跑完」。

Kuma 的 就是為這個設計的:它給你一個網址,腳本跑完在最後打一下:

bash
curl -fsS "https://status.<你的網域>/api/push/<TOKEN>?status=up&msg=OK"

超過設定的間隔沒收到,就亮紅燈。下面這張是它真的沒收到心跳時的樣子,訊息寫得很直白:No heartbeat in the time window

這樣「腳本悄悄死掉」這種最難發現的故障就有人盯了。

所有東西接上之後,後台看起來就是這樣,左邊一整排心跳條:

v1 → v2:不可逆的遷移#

後來我把 Kuma 從 1.23.17 升到 2.4.0。想升的原因是 2.x 那些進階功能,而更早之前我就吃過苦頭:Kuma 的登入頁是公開在網際網路上的,所以帳號一建好就該開 2FA。

這個升級不是換個 tag 那麼單純,幾個關鍵點值得先知道:

CAUTION

1.x → 2.x 有資料庫遷移,而且當作不可逆

  • 遷移在首次啟動 2.x 時自動跑(把逐筆 heartbeat 聚合成新格式),預設維持 SQLite,不強制換 MariaDB。
  • 遷移中途不能打斷,斷了只能從備份還原重來。官方估 20 monitors / 90 天約 7 分鐘,慢的硬體更久。
  • 官方沒寫能不能降回 1.x,所以當作不可逆,備份是唯一的退路
  • 遷移期間服務會短暫下線(對交叉監控來說,就是對方那側會看到你紅一下)。

順序就是:備份 → 改 image 到 2.4.0 → up → 盯著遷移 log → 驗證

實際跑起來比官方估計快很多。我這邊 8 個 monitor、12278 筆 heartbeat,遷移 4 秒就跑完:

console
Uptime Kuma Version: 2.4.0
Aggregate Table Migration Completed
Listening on: ... ✓

有一個數字第一眼會嚇到:heartbeat 從 12278 變成 10190,少了兩千筆。那是正常的:2.x 的遷移會把舊的逐筆 heartbeat 聚合壓縮,monitor 一個都沒少

Trust Proxy:開了更危險#

升完 v2 之後,我在設定裡看到 Trust Proxy 是關的,而我的 nginx 有送 X-Forwarded-For。關著的話 Kuma 會把所有訪客都當成 127.0.0.1,登入速率限制就變成全域的:任何人爆破登入頁,會連我自己都一起被鎖在外面。

看起來該開。而且開它「應該」是安全的,因為 Kuma 只綁在 loopback、外面連不到,一定得經過 nginx。所以我開了。

然後實測打臉。

console
$ # 測試 1:不帶 X-Forwarded-For
  Kuma 記錄 → 我的真實 IP              ✅

$ # 測試 2:自己偽造一個 X-Forwarded-For
$ curl -H 'X-Forwarded-For: 203.0.113.99' https://status.example.com/...
  Kuma 記錄 → 203.0.113.99            ❌ 照單全收

(203.0.113.99 是 RFC 5737 保留給文件用的網段,不會打到真人。)

Kuma 完全相信了我偽造的標頭。 病灶不在 Kuma,在我的 nginx 那一行:

nginx
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#                                 ↑ 這是「附加」,不是「覆寫」

的意思是「把客戶端送來的 XFF 保留,再把 $remote_addr 接在後面」。而 Kuma(Express 的 trust proxy)取的是最左邊那一個,也就是客戶端自己說的那個。於是誰都能宣稱自己是任何 IP。

真正的風險:CrowdSec 會封掉 Cloudflare#

我第一時間的判斷是「登入速率限制形同虛設,可以無限試密碼」。但我已經開了 2FA,試對了也進不去,所以那不是主要問題。

真正的問題在另一個服務身上。我的機器上跑著 ,簡單說就是現代版的 fail2ban:它讀 nginx 的 access log,發現有人在掃描或爆破就自動把那個 IP 封掉。

NOTE

CrowdSec 本身的架設已經另外寫成一篇 它的安裝、場景選擇、bouncer 怎麼接,還有我自己踩過的坑(最經典的是測試自家網站時把自己的 IP 封掉,人在外面連不回家),都在那篇裡。這裡只需要知道一件事:它的判斷完全依賴 access log 裡的來源 IP

而這正是問題所在。追下去才發現拓樸跟我想的不一樣:我自己的網域沒開 Cloudflare 橘雲,但對方的網域開了,而它指向我家:

也就是說,進到我 nginx 的請求有一部分是先經過 Cloudflare 的(實測近期日誌約 10% 來自 CF 網段)。這些請求寫進 access log 時,$remote_addrCloudflare 節點的 IP,不是真實訪客,而 CrowdSec 就是照這個欄位在判斷誰該封。

後果有兩個方向,都不好:

  • 有人透過那條路徑對我掃描/爆破 → CrowdSec 判定攻擊來源是 CF 節點 → 把 Cloudflare 的網段封掉。後果是對方的狀態頁對全世界都連不上,而且極難診斷,因為封的是 CF 不是攻擊者。
  • 反過來,真正的攻擊者躲在 CF 後面,CrowdSec 根本抓不到他,只抓得到 CF。

修法:XFF 改成覆寫#

問題出在 nginx 不在 Kuma,兩件事一起做:

nginx
# 1. 讓 nginx 知道 Cloudflare 後面的真實客戶端 IP
set_real_ip_from 173.245.48.0/20;   # 從 CF 官方清單產生全部網段,定期更新
real_ip_header CF-Connecting-IP;

# 2. XFF 改成覆寫,而不是附加
proxy_set_header X-Forwarded-For $remote_addr;

第一條讓 $remote_addr 變成真正的訪客 IP;第二條讓客戶端送什麼進來都被整個丟掉,換成 nginx 自己認定的來源,偽造就失效了。改完之後拿同一組偽造測試再驗一次,確認 Kuma 不再吃假 IP。

副作用是好的:CrowdSec 開始看到真實的攻擊者 IP,防護實際變強;Kuma 的日誌也顯示真實 IP,而不是對方那端某個裝置注入的 192.168.0.1

NOTE

$proxy_add_x_forwarded_for 是很多人的預設寫法 它本身沒錯,在「你信任上游、而且要保留完整轉發鏈」時是對的。錯的是把它跟「下游應用開 trust proxy 並取最左值」組合在一起,又沒有 set_real_ip_from 界定誰可信。我主站的四個 server block 也都是這種寫法,只是主站是灰雲、CrowdSec 讀的是 $remote_addr 沒被影響,同一顆地雷躺在那裡而已。

一頁式清單#

交叉監控 + Kuma 自架檢查清單
  1. 互相監控不用換憑證:對方把子網域 A 紀錄指向你,你就自己 certbot --nginx -d <那個子網域>,HTTP-01 會打到你、CA 就發證給你,續簽自動。
  2. 別共享 wildcard:私鑰涵蓋對方全站,而且每 90 天手動重發一次,兩個雷。
  3. 前提是純 A 紀錄直連,對方掛了 Cloudflare 橘雲的話 HTTP-01 打不到你,得改用 Origin Cert。
  4. 反代 Kuma 一定要有 proxy_http_version 1.1 + Upgrade + Connection "upgrade",否則即時圖表不動。/socket.io/ 回 400 是正常的,別誤判。
  5. 後端加公開 /health,別用「接受 401」取巧。
  6. cron 腳本用 push 型監控,盯的是「沒按時跑完」這種沉默故障。
  7. v1→v2 當作不可逆:先備份資料卷,遷移中途不能斷;heartbeat 數量變少是聚合,不是掉資料。
  8. 登入頁公開就開 2FA
  9. 開 Trust Proxy 前先確認 XFF 是覆寫不是附加,並用 set_real_ip_from 界定可信上游;否則開了比不開更危險。
參考連結