import functools import hmac import json import os import secrets import time from datetime import timedelta import paramiko from flask import Flask, redirect, render_template_string, request, session from webauthn import ( generate_authentication_options, generate_registration_options, options_to_json, verify_authentication_response, verify_registration_response, ) from webauthn.helpers import base64url_to_bytes, bytes_to_base64url from webauthn.helpers.structs import ( AuthenticatorSelectionCriteria, PublicKeyCredentialDescriptor, ResidentKeyRequirement, ) 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") RP_ID = os.environ.get("RP_ID", "hetzner-admin.yyamashita.com") ORIGIN = f"https://{RP_ID}" DATA_DIR = os.environ.get("DATA_DIR", "/app/data") PASSKEYS_PATH = os.path.join(DATA_DIR, "passkeys.json") SECRET_PATH = os.path.join(DATA_DIR, "secret_key") def load_secret(): os.makedirs(DATA_DIR, exist_ok=True) if not os.path.exists(SECRET_PATH): with open(SECRET_PATH, "w") as f: f.write(secrets.token_hex(32)) with open(SECRET_PATH) as f: return f.read().strip() app = Flask(__name__) app.secret_key = load_secret() app.config.update( SESSION_COOKIE_SECURE=True, SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", PERMANENT_SESSION_LIFETIME=timedelta(days=30), ) COMMANDS = [ ("Docker Status", "docker ps"), ("Container Stats", "docker stats"), ("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"), ("Restart Claude Sessions", "bash /app/infra/claude/restart-sessions.sh"), ("Reboot Server", "reboot"), ] COMMAND_MAP = {label: cmd for label, cmd in COMMANDS} # 個別に停止・起動できるサービス(run-command.sh の SERVICES と status/targets.txt の名前に合わせる) SERVICES = ["golive", "whoisband", "blog", "todo", "studio", "git", "vpn", "ntfy", "mail", "code"] # ブラウザ側: JSON ⇔ WebAuthn の変換はブラウザ組み込みの # parseCreationOptionsFromJSON / parseRequestOptionsFromJSON / toJSON に任せる PASSKEY_JS = """""" LOGIN_HTML = """ Server Admin

Server Admin

{% if error %}

{{ error }}

{% endif %} """ + PASSKEY_JS + """ """ HTML = """ Server Admin

Server Admin

Status

Resources

{% if resources is string %}

{{ resources }}

{% else %} {% for label, pct, text in resources %} {% endfor %}
{{ label }} {% if pct is not none %}{% endif %} {{ text }}
{% endif %}

Services

{% if services is string %}

{{ services }}

{% else %} {% for name, state in services %} {% endfor %}
{{ name }} {{ state }}
{% if state == "running" %} {% else %} {% endif %}
{% endif %}

Commands

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

Output:

{{ output }}
{% endif %}

Passkeys registered: {{ passkey_count }} Logout

""" + PASSKEY_JS + """ """ def load_passkeys(): try: with open(PASSKEYS_PATH) as f: return json.load(f) except FileNotFoundError: return [] def save_passkeys(keys): tmp = PASSKEYS_PATH + ".tmp" with open(tmp, "w") as f: json.dump(keys, f, indent=2) os.replace(tmp, PASSKEYS_PATH) def requires_auth(f): @functools.wraps(f) def decorated(*args, **kwargs): if not session.get("auth"): return redirect("/login") return f(*args, **kwargs) return decorated def log_in(): session.clear() session.permanent = True session["auth"] = True 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() def load_services(): """[(name, "running"|"stopped"), ...]。取得できなければエラーメッセージ(str)""" try: out = run_ssh("services") except Exception as e: return f"Error loading services: {e}" states = dict(line.split(None, 1) for line in out.splitlines() if len(line.split()) == 2) return [(name, states.get(name, "unknown")) for name in SERVICES] def fmt_bytes(n): for unit in ("B", "KiB", "MiB", "GiB", "TiB"): if n < 1024 or unit == "TiB": return f"{n:.1f} {unit}" n /= 1024 def load_resources(): """[(label, percent|None, text), ...]。取得できなければエラーメッセージ(str)""" try: out = run_ssh("resources") v = {k: rest.split() for k, _, rest in (line.partition(" ") for line in out.splitlines())} cpus = int(v["cpus"][0]) load = [float(x) for x in v["load"]] mem_total, mem_used = (int(x) for x in v["mem"]) disk_total, disk_used = (int(x) for x in v["disk"]) up = int(v["uptime"][0]) except Exception as e: return f"Error loading resources: {e}" return [ ("CPU", min(100, load[0] / cpus * 100), f"load {' / '.join(f'{x:.2f}' for x in load)} ({cpus} cores)"), ("Memory", mem_used / mem_total * 100, f"{fmt_bytes(mem_used)} / {fmt_bytes(mem_total)}"), ("Disk /", disk_used / disk_total * 100, f"{fmt_bytes(disk_used)} / {fmt_bytes(disk_total)}"), ("Uptime", None, f"{up // 86400}d {up % 86400 // 3600}h {up % 3600 // 60}m"), ] def render_index(output=None): return render_template_string( HTML, commands=COMMANDS, resources=load_resources(), services=load_services(), output=output, passkey_count=len(load_passkeys()), ) @app.route("/login", methods=["GET", "POST"]) def login(): error = None if request.method == "POST": user_ok = hmac.compare_digest(request.form.get("username", ""), ADMIN_USER) pass_ok = hmac.compare_digest(request.form.get("password", ""), ADMIN_PASS) if user_ok and pass_ok: log_in() return redirect("/") error = "Wrong user or password" return render_template_string(LOGIN_HTML, error=error) @app.route("/logout") def logout(): session.clear() return redirect("/login") @app.route("/passkey/register/options", methods=["POST"]) @requires_auth def passkey_register_options(): opts = generate_registration_options( rp_id=RP_ID, rp_name="Server Admin", user_name=ADMIN_USER, user_id=ADMIN_USER.encode(), authenticator_selection=AuthenticatorSelectionCriteria( resident_key=ResidentKeyRequirement.REQUIRED ), exclude_credentials=[ PublicKeyCredentialDescriptor(id=base64url_to_bytes(k["id"])) for k in load_passkeys() ], ) session["challenge"] = bytes_to_base64url(opts.challenge) return app.response_class(options_to_json(opts), mimetype="application/json") @app.route("/passkey/register/verify", methods=["POST"]) @requires_auth def passkey_register_verify(): challenge = session.pop("challenge", None) if not challenge: return "No challenge", 400 try: v = verify_registration_response( credential=request.get_data(as_text=True), expected_challenge=base64url_to_bytes(challenge), expected_rp_id=RP_ID, expected_origin=ORIGIN, ) except Exception as e: return f"Registration failed: {e}", 400 keys = load_passkeys() keys.append( { "id": bytes_to_base64url(v.credential_id), "public_key": bytes_to_base64url(v.credential_public_key), "sign_count": v.sign_count, "created": time.strftime("%Y-%m-%d %H:%M:%S"), "user_agent": request.headers.get("User-Agent", ""), } ) save_passkeys(keys) return "Passkey registered" @app.route("/passkey/login/options", methods=["POST"]) def passkey_login_options(): # allow_credentials を空にしてブラウザ側で保存済みパスキーを選ばせる opts = generate_authentication_options(rp_id=RP_ID) session["challenge"] = bytes_to_base64url(opts.challenge) return app.response_class(options_to_json(opts), mimetype="application/json") @app.route("/passkey/login/verify", methods=["POST"]) def passkey_login_verify(): challenge = session.pop("challenge", None) if not challenge: return "No challenge", 400 cred = request.get_json(silent=True) or {} keys = load_passkeys() key = next((k for k in keys if k["id"] == cred.get("id")), None) if not key: return "Unknown passkey (register it first after password login)", 400 try: v = verify_authentication_response( credential=cred, expected_challenge=base64url_to_bytes(challenge), expected_rp_id=RP_ID, expected_origin=ORIGIN, credential_public_key=base64url_to_bytes(key["public_key"]), credential_current_sign_count=key["sign_count"], ) except Exception as e: return f"Login failed: {e}", 400 key["sign_count"] = v.new_sign_count save_passkeys(keys) log_in() return "OK" @app.route("/") @requires_auth def index(): return render_index() @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_index(output) @app.route("/service", methods=["POST"]) @requires_auth def service(): name = request.form.get("name", "") action = request.form.get("action", "") if name not in SERVICES or action not in ("stop", "start"): return render_index(f"Unknown service action: {action} {name}") try: output = run_ssh(f"{action} {name}") except Exception as e: output = f"Error: {e}" return render_index(output)