summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--CLAUDE.md26
-rw-r--r--caddy/Caddyfile6
-rwxr-xr-xgit/hooks/hetzner-infra/post-receive1
-rw-r--r--hetzner-admin/Dockerfile5
-rw-r--r--hetzner-admin/app.py95
-rwxr-xr-xhetzner-admin/deploy.sh4
-rw-r--r--hetzner-admin/docker-compose.yml16
-rwxr-xr-xhetzner-admin/install.sh31
-rwxr-xr-xhetzner-admin/run-command.sh28
10 files changed, 207 insertions, 7 deletions
diff --git a/.gitignore b/.gitignore
index 3323b34..fd755d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
.env
.DS_Store
+hetzner-admin/id_rsa
+hetzner-admin/id_rsa.pub
diff --git a/CLAUDE.md b/CLAUDE.md
index 28124bf..8e43573 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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