#!/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 = """ Panel

Containers Panel

Rootless Podman + Caddy routes from one place.

Create App

Apps

Name Domain Upstream Actions
Ready.
""" 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// 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()