summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-10-31 22:45:58 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-10-31 22:45:58 +0900
commit05e1418efa56a773c295a2e072796e220d8d8f51 (patch)
tree5669d97650919db74e6f5f0174880e6e9221d770
parentf48002a02c7b75de8add369672c861f63e900df4 (diff)
hetzner-admin: stop/start individual services, shown as STOPPED on status page
- run-command.sh: services / stop <name> / start <name> for app and infra stacks (not caddy, hetzner-admin, status). Stopped names go to status/stopped.txt (gitignored). - status: a stopped service that does not respond is STOPPED (gray), excluded from uptime and DOWN alerts. If it responds it is UP as usual. - Infra deploy.sh skips stopped stacks; app repo pushes clear the flag. - claude-code@.service: Restart=on-failure, and restart-sessions.sh restarts one unit at a time, to avoid the tmux "server exited unexpectedly" race. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N5eS3U54gzjRXzm65Np1RS
-rw-r--r--.gitignore1
-rw-r--r--cgit/deploy.sh6
-rw-r--r--claude/restart-sessions.sh2
-rw-r--r--claude/systemd/claude-code@.service4
-rwxr-xr-xgit/hooks/microblog/post-receive2
-rw-r--r--git/hooks/todo/post-receive2
-rwxr-xr-xgit/hooks/tokyo-livehouse-events/post-receive2
-rwxr-xr-xgit/hooks/whois-band/post-receive2
-rw-r--r--hetzner-admin/app.py57
-rwxr-xr-xhetzner-admin/run-command.sh41
-rwxr-xr-xmail/deploy.sh6
-rwxr-xr-xntfy/deploy.sh6
-rw-r--r--status/app.py57
-rwxr-xr-xtests/check.sh26
-rw-r--r--vpn/deploy.sh6
15 files changed, 203 insertions, 17 deletions
diff --git a/.gitignore b/.gitignore
index 89e2e94..eae39f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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 を実行してください)"