summaryrefslogtreecommitdiff
path: root/hetzner-admin
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-06-19 13:39:27 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-06-19 13:39:27 +0900
commit50e887cb22ca375e66f7948033e05f596c9f43f5 (patch)
tree20ee5aeac8ec69f3f4b23c53ef407f6e62a8d71f /hetzner-admin
parenta7890e22d52677de267e0d4c79f2c7627f3a96c4 (diff)
Add hetzner-admin: SSH-based server management web UI
Basic Auth protected web UI with buttons to restart services and reboot. SSH key with authorized_keys command= restriction limits executable commands. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'hetzner-admin')
-rw-r--r--hetzner-admin/Dockerfile5
-rw-r--r--hetzner-admin/app.py95
-rwxr-xr-xhetzner-admin/deploy.sh4
-rw-r--r--hetzner-admin/docker-compose.yml16
-rwxr-xr-xhetzner-admin/install.sh31
-rwxr-xr-xhetzner-admin/run-command.sh28
6 files changed, 179 insertions, 0 deletions
diff --git a/hetzner-admin/Dockerfile b/hetzner-admin/Dockerfile
new file mode 100644
index 0000000..524612d
--- /dev/null
+++ b/hetzner-admin/Dockerfile
@@ -0,0 +1,5 @@
+FROM python:3.12-slim
+WORKDIR /app
+RUN pip install --no-cache-dir flask gunicorn paramiko
+COPY app.py .
+CMD ["gunicorn", "-b", "0.0.0.0:5000", "app:app"]
diff --git a/hetzner-admin/app.py b/hetzner-admin/app.py
new file mode 100644
index 0000000..ccaadce
--- /dev/null
+++ b/hetzner-admin/app.py
@@ -0,0 +1,95 @@
+import functools
+import os
+
+import paramiko
+from flask import Flask, Response, render_template_string, request
+
+app = Flask(__name__)
+
+ADMIN_USER = os.environ["ADMIN_USER"]
+ADMIN_PASS = os.environ["ADMIN_PASS"]
+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")
+
+COMMANDS = [
+ ("Docker Status", "docker ps"),
+ ("Restart Caddy", "docker compose -f /app/infra/caddy/docker-compose.yml restart"),
+ ("Restart Mail", "docker compose -f /app/infra/mail/docker-compose.yml restart"),
+ ("Restart cgit", "docker compose -f /app/infra/cgit/docker-compose.yml restart"),
+ ("Restart Admin", "docker compose -f /app/infra/hetzner-admin/docker-compose.yml restart"),
+ ("Reboot Server", "reboot"),
+]
+
+COMMAND_MAP = {label: cmd for label, cmd in COMMANDS}
+
+HTML = """<!DOCTYPE html>
+<html>
+<head><title>Server Admin</title></head>
+<body>
+<h3>Server Admin</h3>
+{% for label, _ in commands %}
+<form method="post" action="/run" style="display:inline;margin-right:4px">
+ <input type="hidden" name="cmd" value="{{ label }}">
+ {% if label == "Reboot Server" %}
+ <button onclick="return confirm('Really reboot?')">{{ label }}</button>
+ {% else %}
+ <button>{{ label }}</button>
+ {% endif %}
+</form>
+{% endfor %}
+{% if output is not none %}
+<h4>Output:</h4>
+<pre>{{ output }}</pre>
+{% endif %}
+</body>
+</html>"""
+
+
+def requires_auth(f):
+ @functools.wraps(f)
+ def decorated(*args, **kwargs):
+ auth = request.authorization
+ if not auth or auth.username != ADMIN_USER or auth.password != ADMIN_PASS:
+ return Response(
+ "Unauthorized", 401, {"WWW-Authenticate": 'Basic realm="Admin"'}
+ )
+ return f(*args, **kwargs)
+
+ return decorated
+
+
+def run_ssh(command):
+ key = paramiko.RSAKey.from_private_key_file(SSH_KEY_PATH)
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(SSH_HOST, port=SSH_PORT, username=SSH_USER, pkey=key, timeout=10)
+ try:
+ _, stdout, stderr = client.exec_command(command, timeout=30)
+ out = stdout.read().decode(errors="replace")
+ err = stderr.read().decode(errors="replace")
+ return (out + err).strip() or "(no output)"
+ finally:
+ client.close()
+
+
+@app.route("/")
+@requires_auth
+def index():
+ return render_template_string(HTML, commands=COMMANDS, output=None)
+
+
+@app.route("/run", methods=["POST"])
+@requires_auth
+def run():
+ label = request.form.get("cmd", "")
+ command = COMMAND_MAP.get(label)
+ if not command:
+ output = f"Unknown command: {label}"
+ else:
+ try:
+ output = run_ssh(command)
+ except Exception as e:
+ output = f"Error: {e}"
+ return render_template_string(HTML, commands=COMMANDS, output=output)
diff --git a/hetzner-admin/deploy.sh b/hetzner-admin/deploy.sh
new file mode 100755
index 0000000..276b188
--- /dev/null
+++ b/hetzner-admin/deploy.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")"
+docker compose up -d --build
diff --git a/hetzner-admin/docker-compose.yml b/hetzner-admin/docker-compose.yml
new file mode 100644
index 0000000..12d5c0e
--- /dev/null
+++ b/hetzner-admin/docker-compose.yml
@@ -0,0 +1,16 @@
+services:
+ hetzner-admin:
+ build: .
+ restart: unless-stopped
+ env_file: .env
+ volumes:
+ - ./id_rsa:/app/id_rsa:ro
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ networks:
+ - web
+
+networks:
+ web:
+ external: true
+ name: web
diff --git a/hetzner-admin/install.sh b/hetzner-admin/install.sh
new file mode 100755
index 0000000..f175cf9
--- /dev/null
+++ b/hetzner-admin/install.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+# 初回セットアップ: SSH鍵生成 → authorized_keys登録 → コンテナ起動
+set -euo pipefail
+cd "$(dirname "$0")"
+
+KEY="$(pwd)/id_rsa"
+
+if [ ! -f "$KEY" ]; then
+ ssh-keygen -t rsa -b 4096 -f "$KEY" -N "" -C "hetzner-admin"
+ echo "SSH key generated: $KEY"
+fi
+
+PUBKEY=$(cat "${KEY}.pub")
+RESTRICTION='command="/app/infra/hetzner-admin/run-command.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
+ENTRY="$RESTRICTION $PUBKEY"
+
+AUTH_KEYS="${HOME}/.ssh/authorized_keys"
+mkdir -p "${HOME}/.ssh"
+chmod 700 "${HOME}/.ssh"
+
+if grep -qF "hetzner-admin" "$AUTH_KEYS" 2>/dev/null; then
+ echo "Key already present in $AUTH_KEYS"
+else
+ echo "$ENTRY" >> "$AUTH_KEYS"
+ chmod 600 "$AUTH_KEYS"
+ echo "Public key added to $AUTH_KEYS"
+fi
+
+chmod +x "$(pwd)/run-command.sh"
+
+docker compose up -d --build
diff --git a/hetzner-admin/run-command.sh b/hetzner-admin/run-command.sh
new file mode 100755
index 0000000..50280b9
--- /dev/null
+++ b/hetzner-admin/run-command.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+# SSH authorized_keys の command= から呼ばれる。$SSH_ORIGINAL_COMMAND をホワイトリストで検証する。
+set -e
+
+case "$SSH_ORIGINAL_COMMAND" in
+ "docker ps")
+ exec docker ps
+ ;;
+ "docker compose -f /app/infra/caddy/docker-compose.yml restart")
+ exec docker compose -f /app/infra/caddy/docker-compose.yml restart
+ ;;
+ "docker compose -f /app/infra/mail/docker-compose.yml restart")
+ exec docker compose -f /app/infra/mail/docker-compose.yml restart
+ ;;
+ "docker compose -f /app/infra/cgit/docker-compose.yml restart")
+ exec docker compose -f /app/infra/cgit/docker-compose.yml restart
+ ;;
+ "docker compose -f /app/infra/hetzner-admin/docker-compose.yml restart")
+ exec docker compose -f /app/infra/hetzner-admin/docker-compose.yml restart
+ ;;
+ "reboot")
+ exec reboot
+ ;;
+ *)
+ echo "Not allowed: $SSH_ORIGINAL_COMMAND" >&2
+ exit 1
+ ;;
+esac