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 | |
| 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>
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | CLAUDE.md | 26 | ||||
| -rw-r--r-- | caddy/Caddyfile | 6 | ||||
| -rwxr-xr-x | git/hooks/hetzner-infra/post-receive | 1 | ||||
| -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 |
10 files changed, 207 insertions, 7 deletions
@@ -1,2 +1,4 @@ .env .DS_Store +hetzner-admin/id_rsa +hetzner-admin/id_rsa.pub @@ -5,17 +5,18 @@ Hetzner VPS 上で動くサービス群のインフラ管理リポジトリ。 ## ディレクトリ構成 ``` -caddy/ リバースプロキシ(Caddyfile, docker-compose.yml, deploy.sh) -cgit/ cgit Web UI(Dockerfile, lighttpd.conf, cgitrc, deploy.sh) -git/ ベアリポジトリ・フック管理(repos.txt, hooks/, install.sh) -claude/ Claude Code セッション管理(sessions.txt, systemd unit, sync.sh) -mail/ MTA(Postfix + OpenDKIM, docker-compose.yml, install.sh, dns/setup-route53.py) -server/ サーバー共通設定(authorized_keys, requirements.md) +caddy/ リバースプロキシ(Caddyfile, docker-compose.yml, deploy.sh) +cgit/ cgit Web UI(Dockerfile, lighttpd.conf, cgitrc, deploy.sh) +git/ ベアリポジトリ・フック管理(repos.txt, hooks/, install.sh) +claude/ Claude Code セッション管理(sessions.txt, systemd unit, sync.sh) +hetzner-admin/ サーバー管理 Web UI(app.py, Dockerfile, docker-compose.yml, deploy.sh) +mail/ MTA(Postfix + OpenDKIM, docker-compose.yml, install.sh, dns/setup-route53.py) +server/ サーバー共通設定(authorized_keys, requirements.md) ``` ## デプロイの仕組み -- `git push origin master` → post-receive フック → `caddy/deploy.sh` / `cgit/deploy.sh` / `mail/deploy.sh` を実行 +- `git push origin master` → post-receive フック → `caddy/deploy.sh` / `cgit/deploy.sh` / `mail/deploy.sh` / `hetzner-admin/deploy.sh` を実行 - cgit の `lighttpd.conf` / `Dockerfile` 変更はイメージ再ビルドが必要(`deploy.sh` で `--build` するため push だけで反映される) - cgit の `cgitrc` はボリュームマウントのため push だけで反映される - フック・リポジトリの変更は push 後に `bash /app/infra/git/install.sh` を手動実行 @@ -73,4 +74,15 @@ python3 /app/infra/mail/dns/setup-route53.py vi mail/domains.txt git push origin master ssh root@localhost 'bash /app/infra/mail/install.sh' # テーブル再生成 + 再起動 + +# hetzner-admin 初回セットアップ(サーバー上で実行) +ssh root@<server> +cat > /app/infra/hetzner-admin/.env <<EOF +ADMIN_USER=admin +ADMIN_PASS=<strong-password> +SSH_USER=root +EOF +bash /app/infra/hetzner-admin/install.sh +# → SSH鍵生成 + authorized_keys登録(command= 制限付き)+ コンテナ起動 +# → admin.yyamashita.com でアクセス可能 ``` diff --git a/caddy/Caddyfile b/caddy/Caddyfile index f53ffca..f308bad 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -28,3 +28,9 @@ git.yyamashita.com { header X-Robots-Tag "noindex, nofollow" reverse_proxy cgit:80 } + +admin.yyamashita.com { + header Referrer-Policy "no-referrer" + header X-Robots-Tag "noindex, nofollow" + reverse_proxy hetzner-admin:5000 +} diff --git a/git/hooks/hetzner-infra/post-receive b/git/hooks/hetzner-infra/post-receive index 3448b17..190df69 100755 --- a/git/hooks/hetzner-infra/post-receive +++ b/git/hooks/hetzner-infra/post-receive @@ -4,4 +4,5 @@ GIT_WORK_TREE=/app/infra git checkout -f bash /app/infra/caddy/deploy.sh bash /app/infra/cgit/deploy.sh bash /app/infra/mail/deploy.sh +bash /app/infra/hetzner-admin/deploy.sh echo "Deploy complete: hetzner-infra" 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 |
