panel/panel-api.py
Jakub Dorfman 3b48f38d89 Refactor is_safe_name and read_app_info functions for improved readability
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 19:50:53 +02:00

645 lines
22 KiB
Python

#!/usr/bin/env python3
import json
import os
import re
import subprocess
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse
PANELCTL = os.environ.get("PANELCTL_PATH", "/run/current-system/sw/bin/panelctl")
BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1")
PORT = int(os.environ.get("PANEL_API_PORT", "9911"))
BASE_DIR = os.environ.get("PANEL_BASE_DIR", "/home/reudy/containers")
INDEX_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet" />
<style>
:root {
--ink: #1f1f1f;
--paper: #fbf6ef;
--accent: #0e8a6b;
--accent-dark: #0a664f;
--line: #1f1f1f22;
--warn: #7a1818;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Space Grotesk", sans-serif;
color: var(--ink);
background:
radial-gradient(1200px 400px at -10% -20%, #ffd79a 0%, transparent 60%),
radial-gradient(900px 300px at 120% 5%, #9de2cf 0%, transparent 55%),
var(--paper);
}
.wrap {
max-width: 980px;
margin: 0 auto;
padding: 28px 16px 40px;
}
.hero {
border: 2px solid var(--ink);
border-radius: 14px;
padding: 18px;
background: #fff;
box-shadow: 7px 7px 0 #0000001a;
}
h1 {
margin: 0;
font-size: clamp(1.6rem, 3.5vw, 2.6rem);
letter-spacing: 0.02em;
}
.sub {
margin: 8px 0 0;
opacity: 0.85;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
margin-top: 14px;
}
@media (min-width: 880px) {
.grid {
grid-template-columns: 1.1fr 1.9fr;
}
}
.card {
border: 2px solid var(--ink);
border-radius: 12px;
background: #fff;
padding: 14px;
}
.card h2 {
margin: 0 0 12px;
font-size: 1.1rem;
}
label {
display: block;
font-size: 0.9rem;
font-weight: 500;
margin: 10px 0 5px;
}
input, select {
width: 100%;
border: 2px solid var(--ink);
border-radius: 8px;
padding: 8px 10px;
font: inherit;
background: #fff;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
button {
border: 2px solid var(--ink);
border-radius: 8px;
padding: 8px 12px;
font: inherit;
font-weight: 600;
background: #fff;
cursor: pointer;
}
button.primary {
background: var(--accent);
color: #fff;
border-color: var(--accent-dark);
}
button.primary:hover {
background: var(--accent-dark);
}
.stack {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
border-top: 1px solid var(--line);
text-align: left;
padding: 8px 6px;
vertical-align: top;
}
.table th {
border-top: 0;
padding-top: 0;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.9rem;
}
.status {
margin-top: 12px;
border: 2px solid var(--ink);
border-radius: 10px;
padding: 8px 10px;
background: #fff;
min-height: 44px;
}
.status.ok { border-color: #1b7a39; }
.status.err { border-color: var(--warn); }
.editor-wrap {
margin-top: 14px;
}
textarea {
width: 100%;
min-height: 260px;
border: 2px solid var(--ink);
border-radius: 8px;
padding: 10px;
font: 0.88rem/1.4 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background: #fff;
resize: vertical;
}
.fade-in {
animation: appear 280ms ease;
}
@keyframes appear {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<main class="wrap">
<section class="hero fade-in">
<h1>Containers Panel</h1>
<p class="sub">Rootless Podman + Caddy routes from one place.</p>
</section>
<section class="grid">
<article class="card fade-in">
<h2>Create App</h2>
<label for="name">Name</label>
<input id="name" placeholder="whoami" />
<label for="domain">Domain</label>
<input id="domain" placeholder="whoami.srazka.com" />
<div class="row">
<div>
<label for="port">Host Port</label>
<input id="port" type="number" min="1024" max="65535" value="18080" />
</div>
<div>
<label for="auth">Protected</label>
<select id="auth">
<option value="true" selected>true</option>
<option value="false">false</option>
</select>
</div>
</div>
<div class="stack" style="margin-top: 12px;">
<button class="primary" id="createBtn">Create</button>
<button id="refreshBtn">Refresh List</button>
</div>
</article>
<article class="card fade-in">
<h2>Apps</h2>
<table class="table" id="appsTable">
<thead>
<tr>
<th>Name</th>
<th>Domain</th>
<th>Upstream</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="appsBody"></tbody>
</table>
<div class="editor-wrap">
<h2 style="margin-top: 12px;">Compose Editor</h2>
<div class="stack" style="margin-bottom: 8px;">
<div class="mono" id="composeTarget">No app selected.</div>
<button id="saveComposeBtn">Save Compose</button>
</div>
<textarea id="composeText" placeholder="Select an app and click Compose to load compose.yaml"></textarea>
</div>
</article>
</section>
<section class="status mono" id="status">Ready.</section>
</main>
<script>
const statusEl = document.getElementById("status");
const appsBody = document.getElementById("appsBody");
const composeText = document.getElementById("composeText");
const composeTarget = document.getElementById("composeTarget");
let selectedComposeApp = null;
function setStatus(msg, isError = false) {
statusEl.textContent = msg;
statusEl.classList.toggle("ok", !isError);
statusEl.classList.toggle("err", isError);
}
async function api(path, method = "GET", payload = null) {
const opts = { method, headers: {} };
if (payload) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(payload);
}
const res = await fetch(path, opts);
const data = await res.json();
if (!res.ok || data.ok === false) {
throw new Error(data.stderr || data.error || data.stdout || "request failed");
}
return data;
}
function actionButton(label, fn) {
const btn = document.createElement("button");
btn.textContent = label;
btn.addEventListener("click", fn);
return btn;
}
async function runAction(name, action, body = null) {
try {
setStatus(`Running ${action} on ${name}...`);
await api(`/apps/${name}/${action}`, "POST", body);
setStatus(`${action} completed for ${name}.`);
await loadApps();
} catch (err) {
setStatus(`${action} failed for ${name}: ${err.message}`, true);
}
async function loadCompose(name) {
try {
setStatus(`Loading compose for ${name}...`);
const data = await api(`/apps/${name}/compose`);
composeText.value = data.content;
selectedComposeApp = name;
composeTarget.textContent = `Editing: ${name}`;
setStatus(`Compose loaded for ${name}.`);
} catch (err) {
setStatus(`Failed to load compose: ${err.message}`, true);
}
}
async function saveCompose() {
if (!selectedComposeApp) {
setStatus("No app selected for compose editing.", true);
return;
}
try {
setStatus(`Saving compose for ${selectedComposeApp}...`);
await api(`/apps/${selectedComposeApp}/compose`, "POST", { content: composeText.value });
setStatus(`Compose saved for ${selectedComposeApp}.`);
} catch (err) {
setStatus(`Failed to save compose: ${err.message}`, true);
}
}
}
function rowForApp(app) {
const tr = document.createElement("tr");
tr.className = "fade-in";
const actions = document.createElement("td");
actions.className = "stack";
actions.appendChild(actionButton("Deploy", () => runAction(app.name, "deploy")));
actions.appendChild(actionButton("Stop", () => runAction(app.name, "stop")));
actions.appendChild(actionButton("Compose", () => loadCompose(app.name)));
actions.appendChild(actionButton("Route", () => runAction(app.name, "render-route")));
actions.appendChild(actionButton("Remove", async () => {
const keep = window.confirm("Keep volumes? Press OK to keep, Cancel to delete.");
await runAction(app.name, "remove", { keepVolumes: keep });
}));
tr.innerHTML = `
<td class="mono">${app.name}</td>
<td class="mono">${app.domain}</td>
<td class="mono">${app.upstream}</td>
`;
tr.appendChild(actions);
return tr;
}
async function loadApps() {
try {
const data = await api("/apps");
appsBody.innerHTML = "";
if (!data.apps.length) {
const tr = document.createElement("tr");
tr.innerHTML = '<td colspan="4" class="mono">No apps yet.</td>';
appsBody.appendChild(tr);
} else {
data.apps.forEach((app) => appsBody.appendChild(rowForApp(app)));
}
setStatus(`Loaded ${data.apps.length} app(s).`);
} catch (err) {
setStatus(`Failed to load apps: ${err.message}`, true);
}
}
async function createApp() {
const name = document.getElementById("name").value.trim();
const domain = document.getElementById("domain").value.trim();
const port = Number(document.getElementById("port").value);
const auth = document.getElementById("auth").value === "true";
if (!name || !domain || !port) {
setStatus("Name, domain and port are required.", true);
return;
}
try {
setStatus(`Creating ${name}...`);
await api("/apps/init", "POST", { name, domain, port, auth });
setStatus(`Created ${name}.`);
await loadApps();
} catch (err) {
setStatus(`Create failed: ${err.message}`, true);
}
}
document.getElementById("createBtn").addEventListener("click", createApp);
document.getElementById("refreshBtn").addEventListener("click", loadApps);
document.getElementById("saveComposeBtn").addEventListener("click", saveCompose);
loadApps();
</script>
</body>
</html>
"""
def is_safe_name(name):
return re.match(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", name) is not None
def read_app_info(name):
if not is_safe_name(name):
return None, {"ok": False, "error": "invalid app name"}
result = run_panelctl(["show", name])
if not result["ok"]:
return None, result
app = parse_env_blob(result["stdout"])
compose_file = app.get("APP_COMPOSE_FILE", "")
if not compose_file:
return None, {"ok": False, "error": "missing APP_COMPOSE_FILE in manifest"}
base_stacks = os.path.join(BASE_DIR, "stacks") + os.sep
norm_compose = os.path.abspath(compose_file)
if not norm_compose.startswith(base_stacks):
return None, {"ok": False, "error": "compose path is outside allowed base directory"}
app["APP_COMPOSE_FILE"] = norm_compose
return app, None
def run_panelctl(args):
proc = subprocess.run(
[PANELCTL, *args],
check=False,
capture_output=True,
text=True,
)
return {
"ok": proc.returncode == 0,
"code": proc.returncode,
"stdout": proc.stdout.strip(),
"stderr": proc.stderr.strip(),
}
def parse_env_blob(blob):
out = {}
for line in blob.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
out[key] = value.strip().strip('"')
return out
class Handler(BaseHTTPRequestHandler):
def _html(self, code, body):
payload = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def _json(self, code, payload):
body = json.dumps(payload, indent=2).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _read_json(self):
length = int(self.headers.get("Content-Length", "0"))
if length == 0:
return {}
raw = self.rfile.read(length)
return json.loads(raw.decode("utf-8"))
def log_message(self, fmt, *args):
return
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
if path == "/":
self._html(200, INDEX_HTML)
return
if path == "/health":
self._json(200, {"ok": True, "service": "panel-api"})
return
if path == "/apps":
result = run_panelctl(["list"])
if not result["ok"]:
self._json(500, result)
return
apps = []
for line in result["stdout"].splitlines():
if not line.strip() or line.strip() == "no apps found":
continue
# format: name domain upstream auth=true|false
fields = line.split()
if len(fields) < 4:
continue
app = {
"name": fields[0],
"domain": fields[1],
"upstream": fields[2],
"auth": fields[3].replace("auth=", ""),
}
apps.append(app)
self._json(200, {"ok": True, "apps": apps})
return
if path.startswith("/apps/") and path.endswith("/compose"):
parts = [p for p in path.split("/") if p]
if len(parts) != 3:
self._json(404, {"ok": False, "error": "not found"})
return
_, name, _ = parts
app, err = read_app_info(name)
if err is not None:
self._json(404, err)
return
try:
with open(app["APP_COMPOSE_FILE"], "r", encoding="utf-8") as fh:
content = fh.read()
except OSError as exc:
self._json(500, {"ok": False, "error": f"failed to read compose file: {exc}"})
return
self._json(200, {"ok": True, "name": name, "content": content})
return
if path.startswith("/apps/"):
name = path.split("/")[-1]
if not name:
self._json(400, {"ok": False, "error": "missing app name"})
return
result = run_panelctl(["show", name])
if not result["ok"]:
self._json(404, result)
return
self._json(200, {"ok": True, "app": parse_env_blob(result["stdout"])})
return
self._json(404, {"ok": False, "error": "not found"})
def do_POST(self):
parsed = urlparse(self.path)
path = parsed.path
if path == "/apps/init":
try:
payload = self._read_json()
name = payload["name"]
domain = payload["domain"]
port = str(payload["port"])
auth = str(payload.get("auth", True)).lower()
except Exception as exc:
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
return
result = run_panelctl(["init", name, domain, port, auth])
self._json(200 if result["ok"] else 400, result)
return
action_prefix = "/apps/"
if path.startswith(action_prefix):
parts = [p for p in path.split("/") if p]
if len(parts) == 3 and parts[2] == "compose":
_, name, _ = parts
app, err = read_app_info(name)
if err is not None:
self._json(404, err)
return
try:
payload = self._read_json()
except Exception as exc:
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
return
content = payload.get("content", "")
if not isinstance(content, str) or not content.strip():
self._json(400, {"ok": False, "error": "compose content must be a non-empty string"})
return
try:
with open(app["APP_COMPOSE_FILE"], "w", encoding="utf-8") as fh:
fh.write(content)
except OSError as exc:
self._json(500, {"ok": False, "error": f"failed to write compose file: {exc}"})
return
self._json(200, {"ok": True, "name": name, "saved": True})
return
# /apps/<name>/<action>
if len(parts) == 3:
_, name, action = parts
if action in {"deploy", "stop", "render-route"}:
result = run_panelctl([action, name])
self._json(200 if result["ok"] else 400, result)
return
if action == "remove":
payload = {}
try:
payload = self._read_json()
except Exception:
payload = {}
keep = payload.get("keepVolumes", False)
args = ["remove", name]
if keep:
args.append("--keep-volumes")
result = run_panelctl(args)
self._json(200 if result["ok"] else 400, result)
return
self._json(404, {"ok": False, "error": "not found"})
def main():
server = HTTPServer((BIND, PORT), Handler)
print(f"panel-api listening on http://{BIND}:{PORT}")
server.serve_forever()
if __name__ == "__main__":
main()