From 50e887cb22ca375e66f7948033e05f596c9f43f5 Mon Sep 17 00:00:00 2001 From: yyamashita Date: Fri, 19 Jun 2026 13:39:27 +0900 Subject: 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 --- hetzner-admin/Dockerfile | 5 +++ hetzner-admin/app.py | 95 ++++++++++++++++++++++++++++++++++++++++ hetzner-admin/deploy.sh | 4 ++ hetzner-admin/docker-compose.yml | 16 +++++++ hetzner-admin/install.sh | 31 +++++++++++++ hetzner-admin/run-command.sh | 28 ++++++++++++ 6 files changed, 179 insertions(+) create mode 100644 hetzner-admin/Dockerfile create mode 100644 hetzner-admin/app.py create mode 100755 hetzner-admin/deploy.sh create mode 100644 hetzner-admin/docker-compose.yml create mode 100755 hetzner-admin/install.sh create mode 100755 hetzner-admin/run-command.sh (limited to 'hetzner-admin') 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 = """ + +Server Admin + +

Server Admin

+{% for label, _ in commands %} +
+ + {% if label == "Reboot Server" %} + + {% else %} + + {% endif %} +
+{% endfor %} +{% if output is not none %} +

Output:

+
{{ output }}
+{% endif %} + +""" + + +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 -- cgit v1.2.3