summaryrefslogtreecommitdiff
path: root/hetzner-admin
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 /hetzner-admin
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
Diffstat (limited to 'hetzner-admin')
-rw-r--r--hetzner-admin/app.py57
-rwxr-xr-xhetzner-admin/run-command.sh41
2 files changed, 97 insertions, 1 deletions
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
;;