645 lines
22 KiB
Python
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()
|