自宅サーバーを長く運用していると、ばつの悪い問題にぶつかる。監視対象のマシンで監視ツールが動いているなら、そのマシンが落ちたとき誰が教えてくれるのか

自分のサービスはすべて Uptime Kuma で見ているのだが、その 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 が自動で回り、自分のドメインとまったく同じ扱いになる。

ワイルドカードが縛るのは「発行の瞬間」だけ#

こちらの誤解は「ワイルドカードは DNS に紐づくから、相手のものを使うしかない」だった。これは半分だけ正しい。ワイルドカードが「DNS に紐づく」というのは発行の瞬間の検証方式の話で、そこは確かに DNS-01 でなければならない。しかしそれと「発行後にどこに紐づくか」は別の話だ。

署名された証明書はただのファイルであり、TLS を終端するマシンの上に置かれる。DNS 事業者にも特定のサーバーにも縛られない。しかも同じホスト名に対して複数の異なる証明書が同時に存在してよく、互いに衝突しない。相手がワイルドカードを持っているからといって、CA がこちらの単一ドメイン証明書の発行を拒むことはない。証明書の発行は独占ではなく、そのホスト名を管理していると証明できれば自分の一枚が手に入る。

逆から見ると、「相手のワイルドカードを使う」は地雷をちょうど二つ踏む。

ワイルドカード共有自分で単一ドメインを発行
秘密鍵の露出面相手のすべてのサブドメインを覆う。サイト全体の鍵を預かるに等しいこのホスト名一つだけ
更新約 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/ に生の upgrade リクエストを直接投げると 400 が返るが、これは sid パラメータが無いことを理由に Socket.IO 自身が拒否しているのであって、nginx が塞いでいるのではない。この 400 を見て慌てて nginx をいじらないこと。

何を監視するか#

サービスを繋ぎ込んだあと、書き留めておく価値のあるやり方が二つあった。

ついでに、監視を追加するときは証明書期限通知にチェックを入れておくとよい。相互監視はどのみち定期的に相手の HTTPS を叩くので、残り日数が少なくなったら声を上げてもらう。ただで保険が一枚増える。

バックエンドに公開の /health を用意する

最初 NAS のバックエンドを監視したときは、認証が要るエンドポイントを叩いて「許容ステータスコード」を 401 に設定した。401 が返るなら生きている、という理屈だ。動きはするが意味論がゆがんでいる。バックエンドが壊れてもリバースプロキシが生きていれば、そのまま 401 を返し続けて監視は緑のまま、ということが起こりうる。

正解はバックエンドに公開・認証不要の /health を一本足して 200 を返させること。生きていれば 200、死んでいれば 502、許容ステータスコードは既定の 200-299 に戻す。これで意味論が揃う。

cron スクリプトには push 型監視を使う

HTTP 監視で確認できるのは「サービスが今この瞬間生きている」ことだけだ。日次バックアップや更新スクリプトのような定期実行タスクで本当に見たいのは、時間どおりに完走したかどうかである。

Kuma の はまさにこのために用意されている。URL を一つ渡されるので、スクリプトの最後で叩く。

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 を入れておくべきなのだ。

このアップグレードはタグを差し替えるだけの話ではない。先に知っておくべき点がいくつかある。

CAUTION

1.x → 2.x にはデータベース移行があり、不可逆として扱うべき

  • 移行は 2.x の初回起動時に自動で走る(個々のハートビートを新形式に集約する)。既定では SQLite のままで、MariaDB への移行を強制されるわけではない。
  • 途中で中断してはならない。中断した場合はバックアップから復元してやり直すしかない。公式の目安は 20 monitors / 90 日で約 7 分、遅いハードウェアではさらにかかる。
  • 1.x に戻せるかどうかは公式に記載がないので、不可逆として扱い、バックアップだけが唯一の退路と考える。
  • 移行中はサービスが短時間落ちる(相互監視の文脈では、相手側からこちらが一瞬赤くなるのが見える)。

手順はこうなる。バックアップ → image を 2.4.0 に変更 → up → 移行ログを見守る → 検証

実際に走らせると公式の見積もりよりずっと速かった。こちらは 8 monitor、12278 件のハートビートで、移行は 4 秒で終わった。

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

一目で驚く数字が一つある。ハートビートが 12278 から 10190 に、二千件ほど減っている。これは正常だ。2.x の移行は古い個別ハートビートを集約して圧縮するもので、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 に全面的に依存している

CrowdSec で fail2ban を置き換える:6 日で 1619 件のスキャンを遮断、そして自分を締め出した三回

そして、そこがまさに問題だった。追いかけてみると、トポロジーが想像と違っていた。こちらのドメインは Cloudflare のオレンジ雲を使っていないが、相手のドメインは使っていて、しかもそれがこちらの自宅を指している。

つまり、こちらの nginx に届くリクエストの一部は先に Cloudflare を経由している(直近のログを実測したところ、約 10% が CF のレンジからだった)。それらが access log に書かれるとき、$remote_addrCloudflare ノードの IP であって実際の訪問者ではなく、CrowdSec はまさにそのフィールドを見て誰を遮断するか決めている。

結果は二方向に転ぶが、どちらもよくない。

  • 誰かがその経路でこちらをスキャンや総当たりする → CrowdSec は攻撃元を CF ノードだと判定する → Cloudflare のレンジを遮断する。その結果、相手のステータスページが世界中から到達不能になり、しかも遮断されたのは攻撃者ではなく CF なので診断が極めて難しい。
  • 逆に、CF の裏に隠れた本物の攻撃者は CrowdSec にはまったく捕まらない。捕まるのは CF だけだ。

直し方:XFF を上書きに変える#

問題は Kuma ではなく nginx にあるので、二つを同時にやる。

nginx
# 1. Cloudflare の背後にいる本当のクライアント IP を nginx に教える
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 のログにも、相手側のどこかの端末が注入した 192.168.0.1 ではなく本物の IP が出るようになる。

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. ワイルドカードを共有しない。秘密鍵が相手のサイト全体を覆い、しかも 90 日ごとに手作業の再発行が要る。地雷が二つ。
  3. 前提は素の A レコード直結。相手がオレンジ雲を使っていると 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 は不可逆として扱う。先にデータボリュームをバックアップし、移行中は絶対に中断しない。ハートビート件数が減るのは集約であってデータ欠損ではない。
  8. ログインページが公開なら 2FA を入れる
  9. Trust Proxy を有効にする前に、XFF が上書きか追記かを確認する。そして set_real_ip_from で信頼できる上流を定義する。さもないと、有効にしたほうが無効より危険になる。
參考連結
  • Uptime Kuma —— プロジェクト本体と 2.x 移行の説明GitHub
  • Let's Encrypt —— HTTP-01 / DNS-01 検証方式の解説letsencrypt.org
  • CrowdSec —— 公式ドキュメントdocs.crowdsec.net
  • Cloudflare —— 訪問者の実 IP の復元(CF-Connecting-IP とレンジ一覧)Cloudflare Docs
  • nginx —— realip モジュールnginx.org