import functools
import json
import os
import urllib.request
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")
STATUS_API = os.environ.get("STATUS_API", "http://status-app:8080/api/status")
# /status ページでコンテナ一覧を取るコマンド(run-command.sh のホワイトリストに要登録)
STATUS_COMMAND = "docker ps -a --format '{{.Names}}|{{.Status}}'"
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"),
("Restart Claude Sessions", "bash /app/infra/claude/restart-sessions.sh"),
("Reboot Server", "reboot"),
]
COMMAND_MAP = {label: cmd for label, cmd in COMMANDS}
HTML = """
Server Admin
Server Admin
Status
{% for label, _ in commands %}
{% endfor %}
{% if output is not none %}
Output:
{{ output }}
{% endif %}
"""
STATUS_HTML = """
Server Status
Server Status
← Admin
Services
{% if services is none %}
{{ services_error }}
{% else %}
| Service | State | Detail | Latency | Uptime |
{% for s in services %}
| {{ s.name }} |
{{ 'UP' if s.up else 'DOWN' }} |
{{ s.detail }} | {{ s.ms }} ms | {{ s.uptime }}% |
{% endfor %}
{% endif %}
Containers
{% if containers is none %}
{{ containers_error }}
{% else %}
| Container | Status |
{% for name, st in containers %}
| {{ name }} |
{{ st }} |
{% endfor %}
{% endif %}
"""
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)
@app.route("/status")
@requires_auth
def status():
services = services_error = containers = containers_error = None
try:
with urllib.request.urlopen(STATUS_API, timeout=5) as r:
services = json.load(r)["services"]
except Exception as e:
services_error = f"status-app に接続できない: {e}"
try:
out = run_ssh(STATUS_COMMAND)
containers = sorted(
tuple(line.split("|", 1)) for line in out.splitlines() if "|" in line
)
except Exception as e:
containers_error = f"Error: {e}"
return render_template_string(
STATUS_HTML,
services=services,
services_error=services_error,
containers=containers,
containers_error=containers_error,
)