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 | |
| 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')
| -rw-r--r-- | hetzner-admin/Dockerfile | 5 | ||||
| -rw-r--r-- | hetzner-admin/app.py | 95 | ||||
| -rwxr-xr-x | hetzner-admin/deploy.sh | 4 | ||||
| -rw-r--r-- | hetzner-admin/docker-compose.yml | 16 | ||||
| -rwxr-xr-x | hetzner-admin/install.sh | 31 | ||||
| -rwxr-xr-x | hetzner-admin/run-command.sh | 28 |
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 |
