#!/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 = """
Panel
Containers Panel
Rootless Podman + Caddy routes from one place.
"""
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//
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()