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/app.py | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 hetzner-admin/app.py (limited to 'hetzner-admin/app.py') 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) -- cgit v1.2.3