diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | cgit/deploy.sh | 6 | ||||
| -rw-r--r-- | claude/restart-sessions.sh | 2 | ||||
| -rw-r--r-- | claude/systemd/claude-code@.service | 4 | ||||
| -rwxr-xr-x | git/hooks/microblog/post-receive | 2 | ||||
| -rw-r--r-- | git/hooks/todo/post-receive | 2 | ||||
| -rwxr-xr-x | git/hooks/tokyo-livehouse-events/post-receive | 2 | ||||
| -rwxr-xr-x | git/hooks/whois-band/post-receive | 2 | ||||
| -rw-r--r-- | hetzner-admin/app.py | 57 | ||||
| -rwxr-xr-x | hetzner-admin/run-command.sh | 41 | ||||
| -rwxr-xr-x | mail/deploy.sh | 6 | ||||
| -rwxr-xr-x | ntfy/deploy.sh | 6 | ||||
| -rw-r--r-- | status/app.py | 57 | ||||
| -rwxr-xr-x | tests/check.sh | 26 | ||||
| -rw-r--r-- | vpn/deploy.sh | 6 |
15 files changed, 203 insertions, 17 deletions
@@ -4,3 +4,4 @@ hetzner-admin/id_rsa hetzner-admin/id_rsa.pub __pycache__/ hetzner-admin/data/ +status/stopped.txt diff --git a/cgit/deploy.sh b/cgit/deploy.sh index 276b188..eecf5f9 100644 --- a/cgit/deploy.sh +++ b/cgit/deploy.sh @@ -1,4 +1,10 @@ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" + +# 管理画面で停止中なら起動しない(Start で stopped.txt から外れる) +if grep -qx git ../status/stopped.txt 2>/dev/null; then + echo "cgit: 管理画面で停止中のためスキップ" + exit 0 +fi docker compose up -d --build diff --git a/claude/restart-sessions.sh b/claude/restart-sessions.sh index 5f0e1b4..3e1c495 100644 --- a/claude/restart-sessions.sh +++ b/claude/restart-sessions.sh @@ -3,4 +3,4 @@ set -e uid=$(id -u yyamashita) runuser -u yyamashita -- \ env XDG_RUNTIME_DIR=/run/user/$uid DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$uid/bus \ - bash -c 'systemctl --user list-units --plain --no-legend "claude-code@*.service" | awk "{print \$1}" | xargs -r systemctl --user restart' + bash -c 'systemctl --user list-units --plain --no-legend "claude-code@*.service" | awk "{print \$1}" | xargs -r -n1 systemctl --user restart' diff --git a/claude/systemd/claude-code@.service b/claude/systemd/claude-code@.service index e85507e..7ce0e03 100644 --- a/claude/systemd/claude-code@.service +++ b/claude/systemd/claude-code@.service @@ -8,6 +8,10 @@ RemainAfterExit=yes WorkingDirectory=/home/yyamashita/workspaces/repos/%i ExecStart=/usr/bin/tmux new-session -d -s claude-%i -c /home/yyamashita/workspaces/repos/%i /usr/local/bin/claude --remote-control %i --dangerously-skip-permissions ExecStop=-/usr/bin/tmux kill-session -t claude-%i +# 全セッション同時に再起動すると、tmux サーバーが終了する瞬間に new-session が繋がり +# "server exited unexpectedly" で失敗することがあるので、少し待って再試行する +Restart=on-failure +RestartSec=3 Environment=HOME=/home/yyamashita Environment=USER=yyamashita Environment=XDG_RUNTIME_DIR=/run/user/1000 diff --git a/git/hooks/microblog/post-receive b/git/hooks/microblog/post-receive index 2627861..87d51cc 100755 --- a/git/hooks/microblog/post-receive +++ b/git/hooks/microblog/post-receive @@ -4,5 +4,7 @@ APP_DIR=/app/microblog GIT_WORK_TREE=$APP_DIR git checkout -f cd $APP_DIR mkdir -p data +# push でデプロイしたら管理画面の停止状態を解除する +sed -i '/^blog$/d' /app/infra/status/stopped.txt 2>/dev/null || true docker compose up -d --build echo "Deploy complete: microblog" diff --git a/git/hooks/todo/post-receive b/git/hooks/todo/post-receive index 645b856..0952ee5 100644 --- a/git/hooks/todo/post-receive +++ b/git/hooks/todo/post-receive @@ -4,5 +4,7 @@ APP_DIR=/app/todo GIT_WORK_TREE=$APP_DIR git checkout -f cd $APP_DIR mkdir -p data +# push でデプロイしたら管理画面の停止状態を解除する +sed -i '/^todo$/d' /app/infra/status/stopped.txt 2>/dev/null || true docker compose up -d --build echo "Deploy complete: todo" diff --git a/git/hooks/tokyo-livehouse-events/post-receive b/git/hooks/tokyo-livehouse-events/post-receive index ca64767..6a80e28 100755 --- a/git/hooks/tokyo-livehouse-events/post-receive +++ b/git/hooks/tokyo-livehouse-events/post-receive @@ -4,5 +4,7 @@ APP_DIR=/app/tokyo-livehouse-events GIT_WORK_TREE=$APP_DIR git checkout -f cd $APP_DIR mkdir -p data +# push でデプロイしたら管理画面の停止状態を解除する +sed -i '/^golive$/d' /app/infra/status/stopped.txt 2>/dev/null || true docker compose up -d --build echo "Deploy complete: tokyo-livehouse-events" diff --git a/git/hooks/whois-band/post-receive b/git/hooks/whois-band/post-receive index 60b368f..de4d7c7 100755 --- a/git/hooks/whois-band/post-receive +++ b/git/hooks/whois-band/post-receive @@ -4,5 +4,7 @@ APP_DIR=/app/whois-band GIT_WORK_TREE=$APP_DIR git checkout -f cd $APP_DIR mkdir -p data +# push でデプロイしたら管理画面の停止状態を解除する +sed -i '/^whoisband$/d' /app/infra/status/stopped.txt 2>/dev/null || true docker compose up -d --build echo "Deploy complete: whois-band" diff --git a/hetzner-admin/app.py b/hetzner-admin/app.py index 90a1cca..ac3178c 100644 --- a/hetzner-admin/app.py +++ b/hetzner-admin/app.py @@ -65,6 +65,9 @@ COMMANDS = [ COMMAND_MAP = {label: cmd for label, cmd in COMMANDS} +# 個別に停止・起動できるサービス(run-command.sh の SERVICES と status/targets.txt の名前に合わせる) +SERVICES = ["golive", "whoisband", "blog", "todo", "git", "vpn", "ntfy", "mail"] + # ブラウザ側: JSON ⇔ WebAuthn の変換はブラウザ組み込みの # parseCreationOptionsFromJSON / parseRequestOptionsFromJSON / toJSON に任せる PASSKEY_JS = """<script> @@ -107,6 +110,30 @@ HTML = """<!DOCTYPE html> <body> <h3>Server Admin</h3> <p><a href="https://status.yyamashita.com">Status</a></p> +<h4>Services</h4> +{% if services is string %} +<p style="color:red">{{ services }}</p> +{% else %} +<table> +{% for name, state in services %} +<tr> + <td>{{ name }}</td> + <td style="color:{{ 'green' if state == 'running' else 'gray' }}">{{ state }}</td> + <td> + <form method="post" action="/service" style="margin:0"> + <input type="hidden" name="name" value="{{ name }}"> + {% if state == "running" %} + <button name="action" value="stop" onclick="return confirm('Stop {{ name }}?')">Stop</button> + {% else %} + <button name="action" value="start">Start</button> + {% endif %} + </form> + </td> +</tr> +{% endfor %} +</table> +{% endif %} +<h4>Commands</h4> {% for label, _ in commands %} <form method="post" action="/run" style="display:block;margin-bottom:8px"> <input type="hidden" name="cmd" value="{{ label }}"> @@ -177,9 +204,23 @@ def run_ssh(command): client.close() +def load_services(): + """[(name, "running"|"stopped"), ...]。取得できなければエラーメッセージ(str)""" + try: + out = run_ssh("services") + except Exception as e: + return f"Error loading services: {e}" + states = dict(line.split(None, 1) for line in out.splitlines() if len(line.split()) == 2) + return [(name, states.get(name, "unknown")) for name in SERVICES] + + def render_index(output=None): return render_template_string( - HTML, commands=COMMANDS, output=output, passkey_count=len(load_passkeys()) + HTML, + commands=COMMANDS, + services=load_services(), + output=output, + passkey_count=len(load_passkeys()), ) @@ -305,3 +346,17 @@ def run(): except Exception as e: output = f"Error: {e}" return render_index(output) + + +@app.route("/service", methods=["POST"]) +@requires_auth +def service(): + name = request.form.get("name", "") + action = request.form.get("action", "") + if name not in SERVICES or action not in ("stop", "start"): + return render_index(f"Unknown service action: {action} {name}") + try: + output = run_ssh(f"{action} {name}") + except Exception as e: + output = f"Error: {e}" + return render_index(output) diff --git a/hetzner-admin/run-command.sh b/hetzner-admin/run-command.sh index 0ed9fe3..c5b8ea5 100755 --- a/hetzner-admin/run-command.sh +++ b/hetzner-admin/run-command.sh @@ -2,7 +2,48 @@ # SSH authorized_keys の command= から呼ばれる。$SSH_ORIGINAL_COMMAND をホワイトリストで検証する。 set -e +# 管理画面から停止・起動できるサービス(名前は status/targets.txt の名前に合わせる) +# Caddy / hetzner-admin / status は止めると管理画面に戻れなくなるので対象外 +SERVICES="golive whoisband blog todo git vpn ntfy mail" +service_compose() { + case "$1" in + golive) echo /app/tokyo-livehouse-events/docker-compose.yml ;; + whoisband) echo /app/whois-band/docker-compose.yml ;; + blog) echo /app/microblog/docker-compose.yml ;; + todo) echo /app/todo/docker-compose.yml ;; + git) echo /app/infra/cgit/docker-compose.yml ;; + vpn) echo /app/infra/vpn/docker-compose.yml ;; + ntfy) echo /app/infra/ntfy/docker-compose.yml ;; + mail) echo /app/infra/mail/docker-compose.yml ;; + *) return 1 ;; + esac +} + +# 管理画面で停止したサービスの一覧。status-app が読み、DOWN ではなく STOPPED と表示する +STOPPED_FILE=/app/infra/status/stopped.txt + case "$SSH_ORIGINAL_COMMAND" in + "services") + touch "$STOPPED_FILE" + for name in $SERVICES; do + running=$(docker compose -f "$(service_compose "$name")" ps -q --status running 2>/dev/null | wc -l) + if [ "$running" -gt 0 ]; then state=running; else state=stopped; fi + echo "$name $state" + done + ;; + "stop "*|"start "*) + action="${SSH_ORIGINAL_COMMAND%% *}" + name="${SSH_ORIGINAL_COMMAND#* }" + compose=$(service_compose "$name") || { echo "Unknown service: $name" >&2; exit 1; } + touch "$STOPPED_FILE" + if [ "$action" = stop ]; then + grep -qx "$name" "$STOPPED_FILE" || echo "$name" >> "$STOPPED_FILE" + docker compose -f "$compose" stop 2>&1 + else + sed -i "/^${name}\$/d" "$STOPPED_FILE" + docker compose -f "$compose" up -d 2>&1 + fi + ;; "docker ps") exec docker ps ;; diff --git a/mail/deploy.sh b/mail/deploy.sh index 6d00560..bc8e85e 100755 --- a/mail/deploy.sh +++ b/mail/deploy.sh @@ -1,5 +1,11 @@ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" + +# 管理画面で停止中なら起動しない(Start で stopped.txt から外れる) +if grep -qx mail ../status/stopped.txt 2>/dev/null; then + echo "mail: 管理画面で停止中のためスキップ" + exit 0 +fi docker network create web 2>/dev/null || true docker compose up -d --build diff --git a/ntfy/deploy.sh b/ntfy/deploy.sh index 239c3db..cc11b5f 100755 --- a/ntfy/deploy.sh +++ b/ntfy/deploy.sh @@ -1,6 +1,12 @@ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" + +# 管理画面で停止中なら起動しない(Start で stopped.txt から外れる) +if grep -qx ntfy ../status/stopped.txt 2>/dev/null; then + echo "ntfy: 管理画面で停止中のためスキップ" + exit 0 +fi docker compose pull -q # server.yml はファイル単位のマウントなので、git checkout で差し替わった内容を反映するため再作成する docker compose up -d --force-recreate diff --git a/status/app.py b/status/app.py index 9a20c04..d4b3129 100644 --- a/status/app.py +++ b/status/app.py @@ -14,6 +14,8 @@ from html import escape from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer TARGETS_FILE = os.environ.get("TARGETS_FILE", "/app/targets.txt") +# hetzner-admin で停止したサービス名(1 行 1 つ)。停止中で応答がなければ DOWN ではなく STOPPED 扱い +STOPPED_FILE = os.path.join(os.path.dirname(TARGETS_FILE), "stopped.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")) @@ -29,7 +31,7 @@ SUMMARY_HOUR = int(os.environ.get("SUMMARY_HOUR", "8")) LOCAL_TZ = timezone(timedelta(hours=float(os.environ.get("SUMMARY_TZ_OFFSET", "9")))) lock = threading.Lock() -state = {} # name -> {"url", "up", "detail", "ms", "checked_at", "history": deque[bool], "fails", "alerted", "day_checks", "day_up"} +state = {} # name -> {"url", "up", "stopped", "detail", "ms", "checked_at", "history": deque[bool|None], "fails", "alerted", "day_checks", "day_up"} period_start = datetime.now(LOCAL_TZ) # サマリー集計の開始時刻 @@ -45,6 +47,14 @@ def load_targets(): return targets +def load_stopped(): + try: + with open(STOPPED_FILE) as f: + return {line.strip() for line in f if line.strip()} + except FileNotFoundError: + return set() + + def check(url): start = time.monotonic() parsed = urllib.parse.urlparse(url) @@ -86,6 +96,7 @@ def notify(title, message, tags, priority="default"): def run_checks(): targets = load_targets() + stopped_names = load_stopped() 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") @@ -99,7 +110,16 @@ def run_checks(): {"history": deque(maxlen=HISTORY), "fails": 0, "alerted": False, "day_checks": 0, "day_up": 0}, ) - entry.update(url=url, up=up, detail=detail, ms=ms, checked_at=now) + # 停止中でも応答があれば(push で再デプロイされた等)普通に UP として扱う + stopped = name in stopped_names and not up + if stopped: + detail = "Stopped from admin" + entry.update(url=url, up=up, stopped=stopped, detail=detail, ms=ms, checked_at=now) + if stopped: + # 稼働率・DOWN 通知の対象外。履歴は灰色で残す + entry["history"].append(None) + entry["fails"] = 0 + continue entry["history"].append(up) entry["day_checks"] += 1 entry["day_up"] += up @@ -118,7 +138,7 @@ def send_summary(now): global period_start with lock: rows = [ - (100 * e["day_up"] / e["day_checks"], name, e["up"]) + (100 * e["day_up"] / e["day_checks"], name, e["up"] or e["stopped"]) for name, e in state.items() if e["day_checks"] ] @@ -158,37 +178,44 @@ def checker_loop(): def snapshot(): with lock: - services = [ - { + services = [] + for name, e in state.items(): + checked = [h for h in e["history"] if h is not None] + services.append({ "name": name, "url": e["url"], "up": e["up"], + "stopped": e["stopped"], + "state": "stopped" if e["stopped"] else "up" if e["up"] else "down", "detail": e["detail"], "ms": e["ms"], "checked_at": e["checked_at"], - "uptime": round(100 * sum(e["history"]) / len(e["history"]), 1), + "uptime": round(100 * sum(checked) / len(checked), 1) if checked else None, "history": list(e["history"]), - } - for name, e in state.items() - ] - return {"all_up": all(s["up"] for s in services), "services": services} + }) + return {"all_up": all(s["up"] or s["stopped"] 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"] + f'<i class="{"s" if ok is None else "u" if ok else "d"}"></i>' for ok in s["history"] ) + dot = {"up": "u", "down": "d", "stopped": "s"}[s["state"]] + uptime = "-" if s["uptime"] is None else f'{s["uptime"]}%' + ms = "-" if s["stopped"] else f'{s["ms"]} ms' 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'<tr><td><span class="dot {dot}"></span>{escape(s["name"])}</td>' + f'<td>{escape(s["detail"])}</td><td>{ms}</td><td>{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>' + n_stopped = sum(s["stopped"] for s in data["services"]) + note = f" ({n_stopped} stopped)" if n_stopped else "" + banner = f'<div class="banner u">All systems operational{note}</div>' else: banner = '<div class="banner d">Some systems are down</div>' checked = data["services"][0]["checked_at"] if data["services"] else "-" @@ -204,7 +231,7 @@ body{{font-family:system-ui,sans-serif;max-width:860px;margin:2em auto;padding:0 .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}} +.u{{background:#2a9d4b}}.d{{background:#d0393e}}.s{{background:#bbb}} .bars{{width:100%}}.bars i{{display:inline-block;width:4px;height:18px;margin-right:1px;border-radius:1px}} small{{color:#888}} </style></head><body> diff --git a/tests/check.sh b/tests/check.sh index a19b55e..e0c9bed 100755 --- a/tests/check.sh +++ b/tests/check.sh @@ -85,6 +85,32 @@ EOF ) echo "" +echo "=== hetzner-admin: SERVICES が run-command.sh / status/targets.txt と一致するか ===" +admin_services=$(python3 - <<'EOF' +import ast +tree = ast.parse(open("hetzner-admin/app.py").read()) +for node in ast.walk(tree): + if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", "") == "SERVICES": + print(" ".join(e.value for e in node.value.elts)) +EOF +) +script_services=$(sed -n 's/^SERVICES="\(.*\)"$/\1/p' hetzner-admin/run-command.sh) +if [[ "$admin_services" == "$script_services" ]]; then + ok "app.py と run-command.sh の SERVICES が一致" +else + ng "SERVICES 不一致: app.py=[$admin_services] run-command.sh=[$script_services]" +fi +for name in $admin_services; do + if ! grep -qE "^ +$name\) " hetzner-admin/run-command.sh; then + ng "run-command.sh の service_compose に $name がない" + elif ! grep -qE "^$name\s" status/targets.txt; then + ng "status/targets.txt に $name がない(ステータスページと連動しない)" + else + ok "service: $name" + fi +done + +echo "" echo "=== 秘密ファイルがコミットされていないか ===" for name in users.conf id_rsa id_rsa.pub .env; do regex="(^|/)$(printf '%s' "$name" | sed 's/\./\\./g')\$" diff --git a/vpn/deploy.sh b/vpn/deploy.sh index 0005d9a..71d5d6e 100644 --- a/vpn/deploy.sh +++ b/vpn/deploy.sh @@ -2,6 +2,12 @@ set -euo pipefail cd "$(dirname "$0")" +# 管理画面で停止中なら起動しない(Start で stopped.txt から外れる) +if grep -qx vpn ../status/stopped.txt 2>/dev/null; then + echo "vpn: 管理画面で停止中のためスキップ" + exit 0 +fi + # 初期化前はスキップ(install.sh を先に実行する必要がある) if [ ! -f users.conf ] || [ ! -f certs/server.crt ]; then echo "VPN: 未初期化のためスキップ (install.sh を実行してください)" |
