summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-10-31 13:20:27 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-10-31 13:20:27 +0900
commitb4ecef96fae14a92d12279927986e69c2e223180 (patch)
tree822b448e7544aa197a9db6efc43f2ddb875eae55
parent5a55c7893615c5a64856ecca681325a609d1fac0 (diff)
Add status pages, ntfy alerts, and license-slot-checker repo
- status/: lightweight status checker (status.yyamashita.com, /api/status) with ntfy DOWN/recovered alerts after consecutive failures - uptime-kuma/: Uptime Kuma at uptime.yyamashita.com - hetzner-admin: authenticated /status page (status-app results + docker ps -a) - ntfy/: self-hosted ntfy at ntfy.yyamashita.com (deny-all auth, install.sh creates admin user and status-app token) - Caddyfile, DNS, post-receive hook, tests/check.sh, docs updated - license-slot-checker: add to repos.txt, hook, and Claude sessions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md36
-rw-r--r--Deploy.md2
-rw-r--r--caddy/Caddyfile15
-rw-r--r--claude/sessions.txt1
-rwxr-xr-xgit/hooks/hetzner-infra/post-receive3
-rwxr-xr-xgit/hooks/license-slot-checker/post-receive4
-rw-r--r--git/repos.txt1
-rw-r--r--hetzner-admin/app.py66
-rwxr-xr-xhetzner-admin/run-command.sh3
-rwxr-xr-xmail/dns/setup-route53.py3
-rwxr-xr-xntfy/deploy.sh6
-rw-r--r--ntfy/docker-compose.yml23
-rwxr-xr-xntfy/install.sh34
-rw-r--r--ntfy/server.yml15
-rw-r--r--status/Dockerfile4
-rw-r--r--status/app.py198
-rwxr-xr-xstatus/deploy.sh5
-rw-r--r--status/docker-compose.yml20
-rw-r--r--status/targets.txt15
-rwxr-xr-xtests/check.sh6
-rwxr-xr-xuptime-kuma/deploy.sh5
-rw-r--r--uptime-kuma/docker-compose.yml17
22 files changed, 478 insertions, 4 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 1ab0691..eb317bf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,12 +12,15 @@ claude/ Claude Code セッション管理(sessions.txt, systemd unit,
hetzner-admin/ サーバー管理 Web UI(app.py, Dockerfile, docker-compose.yml, deploy.sh)
mail/ MTA(Postfix + OpenDKIM, docker-compose.yml, install.sh, dns/setup-route53.py)
vpn/ strongSwan IKEv2 VPN(Dockerfile, docker-compose.yml, install.sh, renew-cert.sh)
+status/ 軽量ステータスページ(app.py, targets.txt, Dockerfile, docker-compose.yml, deploy.sh)
+ntfy/ ntfy 通知サーバー(server.yml, docker-compose.yml, deploy.sh, install.sh)
+uptime-kuma/ Uptime Kuma 監視(docker-compose.yml, deploy.sh)
server/ サーバー共通設定(authorized_keys, requirements.md)
```
## デプロイの仕組み
-- `git push origin master` → post-receive フック → `caddy/deploy.sh` / `cgit/deploy.sh` / `mail/deploy.sh` / `hetzner-admin/deploy.sh` / `vpn/deploy.sh` を実行
+- `git push origin master` → post-receive フック → `caddy/deploy.sh` / `cgit/deploy.sh` / `mail/deploy.sh` / `hetzner-admin/deploy.sh` / `vpn/deploy.sh` / `ntfy/deploy.sh` / `status/deploy.sh` / `uptime-kuma/deploy.sh` を実行
- cgit の `lighttpd.conf` / `Dockerfile` 変更はイメージ再ビルドが必要(`deploy.sh` で `--build` するため push だけで反映される)
- cgit の `cgitrc` はボリュームマウントのため push だけで反映される
- フック・リポジトリの変更は push 後に `bash /app/infra/git/install.sh` を手動実行
@@ -35,6 +38,8 @@ server/ サーバー共通設定(authorized_keys, requirements.md)
| whois-band | /app/whois-band |
| mosquitone-admin | /home/yyamashita/workspaces/repos/mosquitone-admin |
| microblog | /app/microblog |
+| todo | /app/todo |
+| license-slot-checker | /home/yyamashita/workspaces/repos/license-slot-checker |
## よく使うコマンド
@@ -123,6 +128,35 @@ docker compose -f /app/infra/vpn/docker-compose.yml restart
vi caddy/docker-compose.yml # PROXY_NO_AUTH=1 を削除(VPN と同じ users.conf の Basic 認証に戻る)
git push origin master
+# ステータスページ(3 種類)
+# - status.yyamashita.com : 公開ステータスページ(status/)。監視対象は status/targets.txt
+# JSON は /api/status。targets.txt は push だけで反映(次回チェック時に再読込)
+# - uptime.yyamashita.com : Uptime Kuma(uptime-kuma/)。監視設定・通知は Web UI で行う(データは volume に永続化)
+# - hetzner-admin /status : 認証付き。status-app の結果 + docker ps -a のコンテナ状態
+vi status/targets.txt && git push origin master
+
+# ステータスページ初回セットアップ
+git push origin master
+ssh root@localhost 'bash /app/infra/git/install.sh' # post-receive フック更新
+ssh root@localhost 'bash /app/infra/status/deploy.sh && bash /app/infra/uptime-kuma/deploy.sh'
+python3 /app/infra/mail/dns/setup-route53.py # status / uptime の CNAME 追加
+# → 直後に https://uptime.yyamashita.com を開いて管理者アカウントを作成する
+# (初回アクセスした人が管理者になるため、放置しないこと)
+
+# ntfy 通知(ntfy.yyamashita.com)初回セットアップ
+# 認証必須(deny-all)。status-app が連続 2 回失敗で DOWN、復旧で recovered をトピック "status" に通知
+git push origin master
+ssh root@localhost 'bash /app/infra/git/install.sh' # post-receive フック更新
+python3 /app/infra/mail/dns/setup-route53.py # ntfy の CNAME 追加
+ssh -t root@localhost 'bash /app/infra/ntfy/install.sh' # 管理ユーザー作成(パスワード入力)
+# → トークンを発行して status/.env に NTFY_URL / NTFY_TOKEN を書き込み、status-app を再起動
+# スマホ: ntfy アプリでサーバー https://ntfy.yyamashita.com を追加し、トピック status を購読(admin でログイン)
+# Uptime Kuma: Settings → Notifications → ntfy
+# Server URL: http://ntfy / Topic: status / Access Token: ntfy token add admin で発行したもの
+
+# ntfy テスト送信
+ssh root@localhost 'docker exec ntfy ntfy publish --token "$(grep NTFY_TOKEN /app/infra/status/.env | cut -d= -f2)" http://localhost/status "test"'
+
# DNS 登録(初回のみ。setup-route53.py に proxy が含まれている)
ssh root@<server>
export AWS_ACCESS_KEY_ID=...
diff --git a/Deploy.md b/Deploy.md
index 9797459..a709a25 100644
--- a/Deploy.md
+++ b/Deploy.md
@@ -17,7 +17,7 @@ ssh root@<server-ip> 'bash /app/infra/git/install.sh'
## 通常デプロイ
```bash
-git push origin master # caddy / cgit / mail / hetzner-admin / vpn を再デプロイ(自動再ビルド含む)
+git push origin master # caddy / cgit / mail / hetzner-admin / vpn / ntfy / status / uptime-kuma を再デプロイ(自動再ビルド含む)
```
push 前に `bash tests/check.sh` で整合性チェックを実行できる。
diff --git a/caddy/Caddyfile b/caddy/Caddyfile
index e6b43a7..d90d804 100644
--- a/caddy/Caddyfile
+++ b/caddy/Caddyfile
@@ -49,6 +49,21 @@ todo.yyamashita.com {
reverse_proxy todo-app:3000
}
+status.yyamashita.com {
+ import noindex
+ reverse_proxy status-app:8080
+}
+
+uptime.yyamashita.com {
+ import noindex
+ reverse_proxy uptime-kuma:3001
+}
+
+ntfy.yyamashita.com {
+ import noindex
+ reverse_proxy ntfy:80
+}
+
import /tmp/proxy_forward.caddy
vpn.yyamashita.com {
diff --git a/claude/sessions.txt b/claude/sessions.txt
index c56df48..7727c9a 100644
--- a/claude/sessions.txt
+++ b/claude/sessions.txt
@@ -5,3 +5,4 @@ yyamashita:tokyo-livehouse-events
yyamashita:whois-band
yyamashita:mosquitone-admin
yyamashita:microblog
+yyamashita:license-slot-checker
diff --git a/git/hooks/hetzner-infra/post-receive b/git/hooks/hetzner-infra/post-receive
index 6eeff49..bbc792a 100755
--- a/git/hooks/hetzner-infra/post-receive
+++ b/git/hooks/hetzner-infra/post-receive
@@ -6,4 +6,7 @@ bash /app/infra/cgit/deploy.sh
bash /app/infra/mail/deploy.sh
bash /app/infra/hetzner-admin/deploy.sh
bash /app/infra/vpn/deploy.sh
+bash /app/infra/ntfy/deploy.sh
+bash /app/infra/status/deploy.sh
+bash /app/infra/uptime-kuma/deploy.sh
echo "Deploy complete: hetzner-infra"
diff --git a/git/hooks/license-slot-checker/post-receive b/git/hooks/license-slot-checker/post-receive
new file mode 100755
index 0000000..46ad873
--- /dev/null
+++ b/git/hooks/license-slot-checker/post-receive
@@ -0,0 +1,4 @@
+#!/bin/bash
+set -e
+GIT_WORK_TREE=/home/yyamashita/workspaces/repos/license-slot-checker git checkout -f
+echo "Deploy complete: license-slot-checker"
diff --git a/git/repos.txt b/git/repos.txt
index d57d36e..0be6379 100644
--- a/git/repos.txt
+++ b/git/repos.txt
@@ -6,3 +6,4 @@ whois-band:/app/whois-band
mosquitone-admin:/home/yyamashita/workspaces/repos/mosquitone-admin
microblog:/app/microblog
todo:/app/todo
+license-slot-checker:/home/yyamashita/workspaces/repos/license-slot-checker
diff --git a/hetzner-admin/app.py b/hetzner-admin/app.py
index c63da80..274364e 100644
--- a/hetzner-admin/app.py
+++ b/hetzner-admin/app.py
@@ -1,5 +1,7 @@
import functools
+import json
import os
+import urllib.request
import paramiko
from flask import Flask, Response, render_template_string, request
@@ -12,6 +14,10 @@ SSH_HOST = os.environ.get("SSH_HOST", "host.docker.internal")
SSH_PORT = int(os.environ.get("SSH_PORT", "22"))
SSH_USER = os.environ.get("SSH_USER", "root")
SSH_KEY_PATH = os.environ.get("SSH_KEY_PATH", "/app/id_rsa")
+STATUS_API = os.environ.get("STATUS_API", "http://status-app:8080/api/status")
+
+# /status ページでコンテナ一覧を取るコマンド(run-command.sh のホワイトリストに要登録)
+STATUS_COMMAND = "docker ps -a --format '{{.Names}}|{{.Status}}'"
COMMANDS = [
("Docker Status", "docker ps"),
@@ -30,6 +36,7 @@ HTML = """<!DOCTYPE html>
<head><title>Server Admin</title></head>
<body>
<h3>Server Admin</h3>
+<p><a href="/status">Status</a></p>
{% for label, _ in commands %}
<form method="post" action="/run" style="display:block;margin-bottom:8px">
<input type="hidden" name="cmd" value="{{ label }}">
@@ -47,6 +54,40 @@ HTML = """<!DOCTYPE html>
</body>
</html>"""
+STATUS_HTML = """<!DOCTYPE html>
+<html>
+<head><title>Server Status</title><meta http-equiv="refresh" content="60"></head>
+<body>
+<h3>Server Status</h3>
+<p><a href="/">&larr; Admin</a></p>
+<h4>Services</h4>
+{% if services is none %}
+<pre>{{ services_error }}</pre>
+{% else %}
+<table border="1" cellpadding="4" style="border-collapse:collapse">
+<tr><th>Service</th><th>State</th><th>Detail</th><th>Latency</th><th>Uptime</th></tr>
+{% for s in services %}
+<tr><td>{{ s.name }}</td>
+<td style="color:{{ 'green' if s.up else 'red' }}">{{ 'UP' if s.up else 'DOWN' }}</td>
+<td>{{ s.detail }}</td><td>{{ s.ms }} ms</td><td>{{ s.uptime }}%</td></tr>
+{% endfor %}
+</table>
+{% endif %}
+<h4>Containers</h4>
+{% if containers is none %}
+<pre>{{ containers_error }}</pre>
+{% else %}
+<table border="1" cellpadding="4" style="border-collapse:collapse">
+<tr><th>Container</th><th>Status</th></tr>
+{% for name, st in containers %}
+<tr><td>{{ name }}</td>
+<td style="color:{{ 'green' if st.startswith('Up') and '(unhealthy)' not in st else 'red' }}">{{ st }}</td></tr>
+{% endfor %}
+</table>
+{% endif %}
+</body>
+</html>"""
+
def requires_auth(f):
@functools.wraps(f)
@@ -94,3 +135,28 @@ def run():
except Exception as e:
output = f"Error: {e}"
return render_template_string(HTML, commands=COMMANDS, output=output)
+
+
+@app.route("/status")
+@requires_auth
+def status():
+ services = services_error = containers = containers_error = None
+ try:
+ with urllib.request.urlopen(STATUS_API, timeout=5) as r:
+ services = json.load(r)["services"]
+ except Exception as e:
+ services_error = f"status-app に接続できない: {e}"
+ try:
+ out = run_ssh(STATUS_COMMAND)
+ containers = sorted(
+ tuple(line.split("|", 1)) for line in out.splitlines() if "|" in line
+ )
+ except Exception as e:
+ containers_error = f"Error: {e}"
+ return render_template_string(
+ STATUS_HTML,
+ services=services,
+ services_error=services_error,
+ containers=containers,
+ containers_error=containers_error,
+ )
diff --git a/hetzner-admin/run-command.sh b/hetzner-admin/run-command.sh
index 0ed9fe3..12f96bf 100755
--- a/hetzner-admin/run-command.sh
+++ b/hetzner-admin/run-command.sh
@@ -6,6 +6,9 @@ case "$SSH_ORIGINAL_COMMAND" in
"docker ps")
exec docker ps
;;
+ "docker ps -a --format '{{.Names}}|{{.Status}}'")
+ exec docker ps -a --format '{{.Names}}|{{.Status}}'
+ ;;
"docker compose -f /app/infra/caddy/docker-compose.yml restart")
exec docker compose -f /app/infra/caddy/docker-compose.yml restart
;;
diff --git a/mail/dns/setup-route53.py b/mail/dns/setup-route53.py
index ecc3d0d..0b5d569 100755
--- a/mail/dns/setup-route53.py
+++ b/mail/dns/setup-route53.py
@@ -38,6 +38,9 @@ WEB_SUBDOMAINS = [
"hetzner-admin",
"blog",
"todo",
+ "status",
+ "uptime",
+ "ntfy",
"vpn",
"proxy",
]
diff --git a/ntfy/deploy.sh b/ntfy/deploy.sh
new file mode 100755
index 0000000..239c3db
--- /dev/null
+++ b/ntfy/deploy.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")"
+docker compose pull -q
+# server.yml はファイル単位のマウントなので、git checkout で差し替わった内容を反映するため再作成する
+docker compose up -d --force-recreate
diff --git a/ntfy/docker-compose.yml b/ntfy/docker-compose.yml
new file mode 100644
index 0000000..d794baf
--- /dev/null
+++ b/ntfy/docker-compose.yml
@@ -0,0 +1,23 @@
+services:
+ ntfy:
+ image: binwiederhier/ntfy
+ container_name: ntfy
+ command: serve
+ restart: unless-stopped
+ environment:
+ - TZ=Asia/Tokyo
+ volumes:
+ - ./server.yml:/etc/ntfy/server.yml:ro
+ - ntfy_cache:/var/cache/ntfy
+ - ntfy_lib:/var/lib/ntfy
+ networks:
+ - web
+
+networks:
+ web:
+ external: true
+ name: web
+
+volumes:
+ ntfy_cache:
+ ntfy_lib:
diff --git a/ntfy/install.sh b/ntfy/install.sh
new file mode 100755
index 0000000..f65f867
--- /dev/null
+++ b/ntfy/install.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+# 初回セットアップ: コンテナ起動 → 管理ユーザー作成 → status-app 用トークン発行
+set -euo pipefail
+cd "$(dirname "$0")"
+
+USER_NAME="${1:-admin}"
+TOPIC="${NTFY_TOPIC:-status}"
+STATUS_ENV="$(pwd)/../status/.env"
+
+bash ./deploy.sh
+sleep 2
+
+if docker exec ntfy ntfy user list 2>&1 | grep -q "^user $USER_NAME "; then
+ echo "User $USER_NAME already exists"
+else
+ echo "Creating ntfy admin user: $USER_NAME"
+ docker exec -it ntfy ntfy user add --role=admin "$USER_NAME"
+fi
+
+if grep -q '^NTFY_TOKEN=' "$STATUS_ENV" 2>/dev/null; then
+ echo "NTFY_TOKEN already set in $STATUS_ENV"
+else
+ TOKEN=$(docker exec ntfy ntfy token add --label=status-app "$USER_NAME" | grep -o 'tk_[a-z0-9]*')
+ {
+ echo "NTFY_URL=http://ntfy/$TOPIC"
+ echo "NTFY_TOKEN=$TOKEN"
+ } >> "$STATUS_ENV"
+ chmod 600 "$STATUS_ENV"
+ echo "Token written to $STATUS_ENV"
+ bash ../status/deploy.sh
+fi
+
+echo ""
+echo "購読: ntfy アプリで サーバー https://ntfy.yyamashita.com / トピック $TOPIC / ユーザー $USER_NAME でログイン"
diff --git a/ntfy/server.yml b/ntfy/server.yml
new file mode 100644
index 0000000..29a6a83
--- /dev/null
+++ b/ntfy/server.yml
@@ -0,0 +1,15 @@
+# ntfy サーバー設定 https://docs.ntfy.sh/config/
+base-url: "https://ntfy.yyamashita.com"
+listen-http: ":80"
+behind-proxy: true
+
+cache-file: "/var/cache/ntfy/cache.db"
+cache-duration: "72h"
+attachment-cache-dir: "/var/cache/ntfy/attachments"
+
+# 認証必須。ユーザー・トークンは install.sh で作成する
+auth-file: "/var/lib/ntfy/user.db"
+auth-default-access: "deny-all"
+
+# iOS アプリで即時通知を受けるために ntfy.sh 経由で push をトリガーする(本文は送られない)
+upstream-base-url: "https://ntfy.sh"
diff --git a/status/Dockerfile b/status/Dockerfile
new file mode 100644
index 0000000..0073f81
--- /dev/null
+++ b/status/Dockerfile
@@ -0,0 +1,4 @@
+FROM python:3.12-slim
+WORKDIR /app
+COPY app.py .
+CMD ["python", "-u", "app.py"]
diff --git a/status/app.py b/status/app.py
new file mode 100644
index 0000000..9cde2e9
--- /dev/null
+++ b/status/app.py
@@ -0,0 +1,198 @@
+"""軽量ステータスページ。targets.txt の各サービスを定期チェックし HTML / JSON で公開する。"""
+import json
+import os
+import socket
+import threading
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from collections import deque
+from concurrent.futures import ThreadPoolExecutor
+from datetime import datetime, timezone
+from html import escape
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+TARGETS_FILE = os.environ.get("TARGETS_FILE", "/app/targets.txt")
+INTERVAL = int(os.environ.get("CHECK_INTERVAL", "60"))
+TIMEOUT = float(os.environ.get("CHECK_TIMEOUT", "10"))
+HISTORY = int(os.environ.get("HISTORY_SIZE", "90"))
+PORT = int(os.environ.get("PORT", "8080"))
+NTFY_URL = os.environ.get("NTFY_URL", "") # 例: http://ntfy/status(空なら通知しない)
+NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "")
+FAIL_THRESHOLD = int(os.environ.get("FAIL_THRESHOLD", "2")) # 連続失敗この回数で DOWN 通知
+PUBLIC_URL = os.environ.get("PUBLIC_URL", "https://status.yyamashita.com")
+
+lock = threading.Lock()
+state = {} # name -> {"url", "up", "detail", "ms", "checked_at", "history": deque[bool], "fails", "alerted"}
+
+
+def load_targets():
+ targets = []
+ with open(TARGETS_FILE) as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ name, url = line.split(None, 1)
+ targets.append((name, url.strip()))
+ return targets
+
+
+def check(url):
+ start = time.monotonic()
+ parsed = urllib.parse.urlparse(url)
+ try:
+ if parsed.scheme == "tcp":
+ with socket.create_connection((parsed.hostname, parsed.port), timeout=TIMEOUT):
+ pass
+ up, detail = True, "connected"
+ else:
+ req = urllib.request.Request(url, headers={"User-Agent": "status-check/1.0"})
+ try:
+ with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
+ code = r.status
+ except urllib.error.HTTPError as e:
+ code = e.code
+ up, detail = code < 500, f"HTTP {code}"
+ except Exception as e:
+ up, detail = False, f"{type(e).__name__}: {e}"
+ return up, detail, int((time.monotonic() - start) * 1000)
+
+
+def notify(title, message, down):
+ if not NTFY_URL:
+ return
+ headers = {
+ "Title": title,
+ "Tags": "rotating_light" if down else "white_check_mark",
+ "Priority": "high" if down else "default",
+ "Click": PUBLIC_URL,
+ }
+ if NTFY_TOKEN:
+ headers["Authorization"] = f"Bearer {NTFY_TOKEN}"
+ req = urllib.request.Request(NTFY_URL, data=message.encode(), headers=headers, method="POST")
+ try:
+ urllib.request.urlopen(req, timeout=TIMEOUT).close()
+ except Exception as e:
+ print(f"ntfy notify failed: {e}", flush=True)
+
+
+def run_checks():
+ targets = load_targets()
+ with ThreadPoolExecutor(max_workers=max(1, len(targets))) as pool:
+ results = list(pool.map(lambda t: check(t[1]), targets))
+ now = datetime.now(timezone.utc).isoformat(timespec="seconds")
+ alerts = []
+ with lock:
+ for name in set(state) - {n for n, _ in targets}:
+ del state[name]
+ for (name, url), (up, detail, ms) in zip(targets, results):
+ entry = state.setdefault(
+ name, {"history": deque(maxlen=HISTORY), "fails": 0, "alerted": False}
+ )
+ entry.update(url=url, up=up, detail=detail, ms=ms, checked_at=now)
+ entry["history"].append(up)
+ entry["fails"] = 0 if up else entry["fails"] + 1
+ if not up and entry["fails"] >= FAIL_THRESHOLD and not entry["alerted"]:
+ entry["alerted"] = True
+ alerts.append((f"{name} is DOWN", f"{url}\n{detail}", True))
+ elif up and entry["alerted"]:
+ entry["alerted"] = False
+ alerts.append((f"{name} recovered", f"{url}\n{detail} ({ms} ms)", False))
+ for alert in alerts:
+ notify(*alert)
+
+
+def checker_loop():
+ while True:
+ try:
+ run_checks()
+ except Exception as e:
+ print(f"check failed: {e}", flush=True)
+ time.sleep(INTERVAL)
+
+
+def snapshot():
+ with lock:
+ services = [
+ {
+ "name": name,
+ "url": e["url"],
+ "up": e["up"],
+ "detail": e["detail"],
+ "ms": e["ms"],
+ "checked_at": e["checked_at"],
+ "uptime": round(100 * sum(e["history"]) / len(e["history"]), 1),
+ "history": list(e["history"]),
+ }
+ for name, e in state.items()
+ ]
+ return {"all_up": all(s["up"] for s in services), "services": services}
+
+
+def render_html(data):
+ rows = []
+ for s in data["services"]:
+ bars = "".join(
+ f'<i class="{"u" if ok else "d"}"></i>' for ok in s["history"]
+ )
+ rows.append(
+ f'<tr><td><span class="dot {"u" if s["up"] else "d"}"></span>{escape(s["name"])}</td>'
+ f'<td>{escape(s["detail"])}</td><td>{s["ms"]} ms</td><td>{s["uptime"]}%</td>'
+ f'<td class="bars">{bars}</td></tr>'
+ )
+ if not data["services"]:
+ banner = '<div class="banner">Checking...</div>'
+ elif data["all_up"]:
+ banner = '<div class="banner u">All systems operational</div>'
+ else:
+ banner = '<div class="banner d">Some systems are down</div>'
+ checked = data["services"][0]["checked_at"] if data["services"] else "-"
+ return f"""<!DOCTYPE html>
+<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<meta http-equiv="refresh" content="{INTERVAL}"><title>Status - yyamashita.com</title>
+<style>
+body{{font-family:system-ui,sans-serif;max-width:860px;margin:2em auto;padding:0 1em;color:#222}}
+.banner{{padding:.8em 1em;border-radius:6px;color:#fff;background:#888;font-weight:600}}
+.banner.u{{background:#2a9d4b}}.banner.d{{background:#d0393e}}
+table{{width:100%;border-collapse:collapse;margin-top:1em}}td{{padding:.5em;border-bottom:1px solid #eee;white-space:nowrap}}
+.dot{{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:.5em}}
+.u{{background:#2a9d4b}}.d{{background:#d0393e}}
+.bars{{width:100%}}.bars i{{display:inline-block;width:4px;height:18px;margin-right:1px;border-radius:1px}}
+small{{color:#888}}
+</style></head><body>
+<h2>yyamashita.com status</h2>
+{banner}
+<table>{"".join(rows)}</table>
+<p><small>Last checked: {checked} (every {INTERVAL}s) · <a href="/api/status">JSON</a></small></p>
+</body></html>"""
+
+
+class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ data = snapshot()
+ if self.path == "/api/status":
+ body = json.dumps(data).encode()
+ ctype = "application/json"
+ elif self.path == "/healthz":
+ body, ctype = b"ok", "text/plain"
+ elif self.path == "/":
+ body = render_html(data).encode()
+ ctype = "text/html; charset=utf-8"
+ else:
+ self.send_error(404)
+ return
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, *args):
+ pass
+
+
+if __name__ == "__main__":
+ threading.Thread(target=checker_loop, daemon=True).start()
+ ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
diff --git a/status/deploy.sh b/status/deploy.sh
new file mode 100755
index 0000000..946f779
--- /dev/null
+++ b/status/deploy.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")"
+touch .env # 通知未設定でも起動できるように
+docker compose up -d --build
diff --git a/status/docker-compose.yml b/status/docker-compose.yml
new file mode 100644
index 0000000..e150751
--- /dev/null
+++ b/status/docker-compose.yml
@@ -0,0 +1,20 @@
+services:
+ status:
+ build: .
+ container_name: status-app
+ restart: unless-stopped
+ # ディレクトリごとマウント(git checkout でファイルが差し替わっても追従できる)。
+ # targets.txt はチェックのたびに再読込されるので push だけで反映される。
+ volumes:
+ - ./:/app/conf:ro
+ environment:
+ - TARGETS_FILE=/app/conf/targets.txt
+ # NTFY_URL / NTFY_TOKEN(ntfy/install.sh が生成)
+ env_file: .env
+ networks:
+ - web
+
+networks:
+ web:
+ external: true
+ name: web
diff --git a/status/targets.txt b/status/targets.txt
new file mode 100644
index 0000000..5ee1dcc
--- /dev/null
+++ b/status/targets.txt
@@ -0,0 +1,15 @@
+# 監視対象
+# 形式: <name> <url>
+# http(s)://... → HTTP ステータス 5xx 未満なら UP(401 等の認証エラーも UP 扱い)
+# tcp://host:port → TCP 接続できれば UP(web ネットワーク上のコンテナ名が使える)
+golive https://golive.yyamashita.com
+whoisband https://whoisband.yyamashita.com
+blog https://blog.yyamashita.com
+todo https://todo.yyamashita.com
+git https://git.yyamashita.com
+code https://code.yyamashita.com
+hetzner-admin https://hetzner-admin.yyamashita.com
+uptime https://uptime.yyamashita.com
+vpn https://vpn.yyamashita.com
+ntfy http://ntfy/v1/health
+mail tcp://mail:25
diff --git a/tests/check.sh b/tests/check.sh
index 99add68..85d19e5 100755
--- a/tests/check.sh
+++ b/tests/check.sh
@@ -27,7 +27,7 @@ done < <(find git/hooks -type f -name 'post-receive' | sort)
echo ""
echo "=== Python 構文 ==="
-for f in hetzner-admin/app.py mail/dns/setup-route53.py; do
+for f in hetzner-admin/app.py mail/dns/setup-route53.py status/app.py; do
if python3 -m py_compile "$f" 2>/dev/null; then
ok "$f"
else
@@ -67,7 +67,7 @@ while IFS=: read -r user repo; do
done < claude/sessions.txt
echo ""
-echo "=== hetzner-admin: app.py の COMMANDS が run-command.sh で許可されているか ==="
+echo "=== hetzner-admin: app.py の COMMANDS / STATUS_COMMAND が run-command.sh で許可されているか ==="
while IFS= read -r cmd; do
if grep -qF "\"$cmd\")" hetzner-admin/run-command.sh; then
ok "$cmd"
@@ -81,6 +81,8 @@ for node in ast.walk(tree):
if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", "") == "COMMANDS":
for elt in node.value.elts:
print(elt.elts[1].value)
+ if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", "") == "STATUS_COMMAND":
+ print(node.value.value)
EOF
)
diff --git a/uptime-kuma/deploy.sh b/uptime-kuma/deploy.sh
new file mode 100755
index 0000000..c07b9e8
--- /dev/null
+++ b/uptime-kuma/deploy.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")"
+docker compose pull -q
+docker compose up -d
diff --git a/uptime-kuma/docker-compose.yml b/uptime-kuma/docker-compose.yml
new file mode 100644
index 0000000..d166ce2
--- /dev/null
+++ b/uptime-kuma/docker-compose.yml
@@ -0,0 +1,17 @@
+services:
+ uptime-kuma:
+ image: louislam/uptime-kuma:2
+ container_name: uptime-kuma
+ restart: unless-stopped
+ volumes:
+ - uptime_kuma_data:/app/data
+ networks:
+ - web
+
+networks:
+ web:
+ external: true
+ name: web
+
+volumes:
+ uptime_kuma_data: