#!/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.
"""
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()