diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-06-19 13:39:27 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-06-19 13:39:27 +0900 |
| commit | 50e887cb22ca375e66f7948033e05f596c9f43f5 (patch) | |
| tree | 20ee5aeac8ec69f3f4b23c53ef407f6e62a8d71f /hetzner-admin/app.py | |
| parent | a7890e22d52677de267e0d4c79f2c7627f3a96c4 (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/app.py')
| -rw-r--r-- | hetzner-admin/app.py | 95 |
1 files changed, 95 insertions, 0 deletions
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) |
