Add panel-api and panelctl scripts for container management

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Jakub Dorfman 2026-04-26 19:42:30 +02:00
parent 93e23748c2
commit c8ea1761ed
4 changed files with 930 additions and 0 deletions

48
panel/API.md Normal file
View file

@ -0,0 +1,48 @@
# panel-api
A tiny local HTTP API wrapper around panelctl for future frontend integration.
The service now also serves a lightweight web UI at `/`.
Default bind:
- 127.0.0.1:9911
## Endpoints
- GET /health
- GET /
- GET /apps
- GET /apps/<name>
- POST /apps/init
- POST /apps/<name>/render-route
- POST /apps/<name>/deploy
- POST /apps/<name>/stop
- POST /apps/<name>/remove
## Example payloads
Create app:
```json
{
"name": "whoami",
"domain": "whoami.srazka.com",
"port": 18080,
"auth": true
}
```
Remove and keep volumes:
```json
{
"keepVolumes": true
}
```
## Local test
```bash
curl -s http://127.0.0.1:9911/health | jq .
curl -s http://127.0.0.1:9911/apps | jq .
```

37
panel/README.md Normal file
View file

@ -0,0 +1,37 @@
# panelctl quickstart
This repository now includes a small helper command named panelctl for Phase 2.
Base directory:
- /home/reudy/containers
Generated structure:
- /home/reudy/containers/stacks/<app>/compose.yaml
- /home/reudy/containers/volumes/<app>/data
- /home/reudy/containers/routes/<app>.caddy
- /home/reudy/containers/state/apps/<app>.env
Quick workflow:
1. Create a new app definition:
panelctl init whoami whoami.srazka.com 18080 true
2. Deploy it with Podman compose:
panelctl deploy whoami
3. If deploy says caddy reload needs root:
sudo systemctl reload caddy
4. List apps:
panelctl list
5. Inspect one app:
panelctl show whoami
6. Remove app but keep data:
panelctl remove whoami --keep-volumes
Notes:
- The default compose file uses traefik/whoami for smoke testing.
- Edit each generated compose.yaml before production use.
- App names must be lowercase slugs.
API service:
- Nix now runs panel-api as a systemd service on 127.0.0.1:9911.
- Caddy proxies https://panel.srazka.com to panel-api with your existing forward_auth pattern.
- Open https://panel.srazka.com for the web UI.
- API docs are in panel/API.md.

510
panel/panel-api.py Normal file
View file

@ -0,0 +1,510 @@
#!/usr/bin/env python3
import json
import os
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"))
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); }
.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>
</article>
</section>
<section class="status mono" id="status">Ready.</section>
</main>
<script>
const statusEl = document.getElementById("status");
const appsBody = document.getElementById("appsBody");
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);
}
}
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("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);
loadApps();
</script>
</body>
</html>
"""
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/"):
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]
# /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()

335
panel/panelctl.sh Normal file
View file

@ -0,0 +1,335 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_DIR="${PANEL_BASE_DIR:-/home/reudy/containers}"
STACKS_DIR="${BASE_DIR}/stacks"
VOLUMES_DIR="${BASE_DIR}/volumes"
ROUTES_DIR="${BASE_DIR}/routes"
STATE_DIR="${BASE_DIR}/state"
APPS_DIR="${STATE_DIR}/apps"
FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
'
usage() {
cat <<'EOF'
panelctl - minimal app panel helper
Usage:
panelctl init <name> <domain> <port> [auth]
panelctl render-route <name>
panelctl deploy <name>
panelctl stop <name>
panelctl remove <name> [--keep-volumes]
panelctl list
panelctl show <name>
Examples:
panelctl init whoami whoami.srazka.com 18080 true
panelctl deploy whoami
EOF
}
fail() {
echo "error: $*" >&2
exit 1
}
ensure_base_dirs() {
mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}"
}
validate_name() {
local name="$1"
[[ "${name}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || fail "invalid name '${name}' (use lowercase slug)"
}
validate_domain() {
local domain="$1"
[[ "${domain}" =~ ^[A-Za-z0-9.-]+$ ]] || fail "invalid domain '${domain}'"
[[ "${domain}" == *.* ]] || fail "domain must include a dot"
}
validate_port() {
local port="$1"
[[ "${port}" =~ ^[0-9]+$ ]] || fail "port must be numeric"
(( port >= 1024 && port <= 65535 )) || fail "port must be in range 1024-65535"
}
app_manifest() {
local name="$1"
echo "${APPS_DIR}/${name}.env"
}
app_stack_dir() {
local name="$1"
echo "${STACKS_DIR}/${name}"
}
app_volume_dir() {
local name="$1"
echo "${VOLUMES_DIR}/${name}"
}
app_route_file() {
local name="$1"
echo "${ROUTES_DIR}/${name}.caddy"
}
load_app() {
local name="$1"
local manifest
manifest="$(app_manifest "${name}")"
[[ -f "${manifest}" ]] || fail "app '${name}' does not exist"
# shellcheck disable=SC1090
source "${manifest}"
}
compose_command() {
if podman compose version >/dev/null 2>&1; then
echo "podman compose"
return
fi
if command -v podman-compose >/dev/null 2>&1; then
echo "podman-compose"
return
fi
fail "no compose command available (need 'podman compose' or 'podman-compose')"
}
write_default_compose() {
local name="$1"
local port="$2"
local stack_dir
local volume_dir
stack_dir="$(app_stack_dir "${name}")"
volume_dir="$(app_volume_dir "${name}")"
cat >"${stack_dir}/compose.yaml" <<EOF
services:
app:
image: traefik/whoami:latest
restart: unless-stopped
ports:
- "127.0.0.1:${port}:80"
volumes:
- ${volume_dir}/data:/data
EOF
}
write_manifest() {
local name="$1"
local domain="$2"
local port="$3"
local auth="$4"
local manifest
local stack_dir
local volume_dir
local route_file
manifest="$(app_manifest "${name}")"
stack_dir="$(app_stack_dir "${name}")"
volume_dir="$(app_volume_dir "${name}")"
route_file="$(app_route_file "${name}")"
cat >"${manifest}" <<EOF
APP_NAME="${name}"
APP_DOMAIN="${domain}"
APP_PORT="${port}"
APP_UPSTREAM="127.0.0.1:${port}"
APP_AUTH_PROTECTED="${auth}"
APP_STACK_DIR="${stack_dir}"
APP_COMPOSE_FILE="${stack_dir}/compose.yaml"
APP_VOLUME_DIR="${volume_dir}"
APP_ROUTE_FILE="${route_file}"
EOF
}
cmd_init() {
local name="$1"
local domain="$2"
local port="$3"
local auth="${4:-true}"
validate_name "${name}"
validate_domain "${domain}"
validate_port "${port}"
[[ "${auth}" == "true" || "${auth}" == "false" ]] || fail "auth must be true or false"
ensure_base_dirs
local manifest
local stack_dir
local volume_dir
manifest="$(app_manifest "${name}")"
stack_dir="$(app_stack_dir "${name}")"
volume_dir="$(app_volume_dir "${name}")"
[[ ! -f "${manifest}" ]] || fail "app '${name}' already exists"
mkdir -p "${stack_dir}" "${volume_dir}/data"
write_default_compose "${name}" "${port}"
write_manifest "${name}" "${domain}" "${port}" "${auth}"
cmd_render_route "${name}"
echo "initialized app '${name}'"
}
cmd_render_route() {
local name="$1"
validate_name "${name}"
load_app "${name}"
local auth_block=""
if [[ "${APP_AUTH_PROTECTED}" == "true" ]]; then
auth_block="${FORWARD_AUTH_BLOCK}"
fi
cat >"${APP_ROUTE_FILE}" <<EOF
${APP_DOMAIN} {
${auth_block} reverse_proxy ${APP_UPSTREAM}
}
EOF
echo "rendered route ${APP_ROUTE_FILE}"
}
maybe_reload_caddy() {
if [[ -x /run/current-system/sw/bin/caddy && -f /etc/caddy/Caddyfile ]]; then
/run/current-system/sw/bin/caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
fi
if [[ "${EUID}" -eq 0 ]]; then
systemctl reload caddy
echo "reloaded caddy"
return
fi
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
sudo -n systemctl reload caddy
echo "reloaded caddy via sudo"
return
fi
echo "caddy reload requires root; run: sudo systemctl reload caddy"
}
cmd_deploy() {
local name="$1"
validate_name "${name}"
load_app "${name}"
local compose
compose="$(compose_command)"
cmd_render_route "${name}"
${compose} -f "${APP_COMPOSE_FILE}" up -d
maybe_reload_caddy
echo "deployed app '${name}'"
}
cmd_stop() {
local name="$1"
validate_name "${name}"
load_app "${name}"
local compose
compose="$(compose_command)"
${compose} -f "${APP_COMPOSE_FILE}" down
echo "stopped app '${name}'"
}
cmd_remove() {
local name="$1"
local keep_volumes="${2:-}"
validate_name "${name}"
load_app "${name}"
local compose
compose="$(compose_command)"
${compose} -f "${APP_COMPOSE_FILE}" down || true
rm -f "${APP_ROUTE_FILE}" "$(app_manifest "${name}")"
rm -rf "${APP_STACK_DIR}"
if [[ "${keep_volumes}" != "--keep-volumes" ]]; then
rm -rf "${APP_VOLUME_DIR}"
fi
maybe_reload_caddy
echo "removed app '${name}'"
}
cmd_list() {
ensure_base_dirs
local found=0
for mf in "${APPS_DIR}"/*.env; do
[[ -e "${mf}" ]] || continue
found=1
# shellcheck disable=SC1090
source "${mf}"
echo "${APP_NAME} ${APP_DOMAIN} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED}"
done
if [[ "${found}" -eq 0 ]]; then
echo "no apps found"
fi
}
cmd_show() {
local name="$1"
validate_name "${name}"
local mf
mf="$(app_manifest "${name}")"
[[ -f "${mf}" ]] || fail "app '${name}' does not exist"
cat "${mf}"
}
main() {
local cmd="${1:-}"
case "${cmd}" in
init)
[[ $# -ge 4 ]] || fail "usage: panelctl init <name> <domain> <port> [auth]"
cmd_init "$2" "$3" "$4" "${5:-true}"
;;
render-route)
[[ $# -eq 2 ]] || fail "usage: panelctl render-route <name>"
cmd_render_route "$2"
;;
deploy)
[[ $# -eq 2 ]] || fail "usage: panelctl deploy <name>"
cmd_deploy "$2"
;;
stop)
[[ $# -eq 2 ]] || fail "usage: panelctl stop <name>"
cmd_stop "$2"
;;
remove)
[[ $# -ge 2 ]] || fail "usage: panelctl remove <name> [--keep-volumes]"
cmd_remove "$2" "${3:-}"
;;
list)
[[ $# -eq 1 ]] || fail "usage: panelctl list"
cmd_list
;;
show)
[[ $# -eq 2 ]] || fail "usage: panelctl show <name>"
cmd_show "$2"
;;
""|-h|--help|help)
usage
;;
*)
fail "unknown command '${cmd}'"
;;
esac
}
main "$@"