diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-10-31 13:20:27 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-10-31 13:20:27 +0900 |
| commit | b4ecef96fae14a92d12279927986e69c2e223180 (patch) | |
| tree | 822b448e7544aa197a9db6efc43f2ddb875eae55 | |
| parent | 5a55c7893615c5a64856ecca681325a609d1fac0 (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.md | 36 | ||||
| -rw-r--r-- | Deploy.md | 2 | ||||
| -rw-r--r-- | caddy/Caddyfile | 15 | ||||
| -rw-r--r-- | claude/sessions.txt | 1 | ||||
| -rwxr-xr-x | git/hooks/hetzner-infra/post-receive | 3 | ||||
| -rwxr-xr-x | git/hooks/license-slot-checker/post-receive | 4 | ||||
| -rw-r--r-- | git/repos.txt | 1 | ||||
| -rw-r--r-- | hetzner-admin/app.py | 66 | ||||
| -rwxr-xr-x | hetzner-admin/run-command.sh | 3 | ||||
| -rwxr-xr-x | mail/dns/setup-route53.py | 3 | ||||
| -rwxr-xr-x | ntfy/deploy.sh | 6 | ||||
| -rw-r--r-- | ntfy/docker-compose.yml | 23 | ||||
| -rwxr-xr-x | ntfy/install.sh | 34 | ||||
| -rw-r--r-- | ntfy/server.yml | 15 | ||||
| -rw-r--r-- | status/Dockerfile | 4 | ||||
| -rw-r--r-- | status/app.py | 198 | ||||
| -rwxr-xr-x | status/deploy.sh | 5 | ||||
| -rw-r--r-- | status/docker-compose.yml | 20 | ||||
| -rw-r--r-- | status/targets.txt | 15 | ||||
| -rwxr-xr-x | tests/check.sh | 6 | ||||
| -rwxr-xr-x | uptime-kuma/deploy.sh | 5 | ||||
| -rw-r--r-- | uptime-kuma/docker-compose.yml | 17 |
22 files changed, 478 insertions, 4 deletions
@@ -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=... @@ -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="/">← 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: |
