commit 8986399a92938816c08b18f920e4b1dab5cfa913 Author: Jakub Dorfman Date: Sun Apr 26 19:42:30 2026 +0200 Add panel-api and panelctl scripts for container management Co-authored-by: Copilot diff --git a/API.md b/API.md new file mode 100644 index 0000000..ce5fe4b --- /dev/null +++ b/API.md @@ -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/ +- POST /apps/init +- POST /apps//render-route +- POST /apps//deploy +- POST /apps//stop +- POST /apps//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 . +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..e391d76 --- /dev/null +++ b/README.md @@ -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//compose.yaml +- /home/reudy/containers/volumes//data +- /home/reudy/containers/routes/.caddy +- /home/reudy/containers/state/apps/.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. diff --git a/panel-api.py b/panel-api.py new file mode 100644 index 0000000..ebe8efb --- /dev/null +++ b/panel-api.py @@ -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 = """ + + + + + Panel + + + + + + +
+
+

Containers Panel

+

Rootless Podman + Caddy routes from one place.

+
+ +
+
+

Create App

+ + + + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+

Apps

+ + + + + + + + + + +
NameDomainUpstreamActions
+
+
+ +
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() diff --git a/panelctl.sh b/panelctl.sh new file mode 100644 index 0000000..70e0499 --- /dev/null +++ b/panelctl.sh @@ -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 [auth] + panelctl render-route + panelctl deploy + panelctl stop + panelctl remove [--keep-volumes] + panelctl list + panelctl show + +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" <"${manifest}" <"${APP_ROUTE_FILE}" </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 [auth]" + cmd_init "$2" "$3" "$4" "${5:-true}" + ;; + render-route) + [[ $# -eq 2 ]] || fail "usage: panelctl render-route " + cmd_render_route "$2" + ;; + deploy) + [[ $# -eq 2 ]] || fail "usage: panelctl deploy " + cmd_deploy "$2" + ;; + stop) + [[ $# -eq 2 ]] || fail "usage: panelctl stop " + cmd_stop "$2" + ;; + remove) + [[ $# -ge 2 ]] || fail "usage: panelctl remove [--keep-volumes]" + cmd_remove "$2" "${3:-}" + ;; + list) + [[ $# -eq 1 ]] || fail "usage: panelctl list" + cmd_list + ;; + show) + [[ $# -eq 2 ]] || fail "usage: panelctl show " + cmd_show "$2" + ;; + ""|-h|--help|help) + usage + ;; + *) + fail "unknown command '${cmd}'" + ;; + esac +} + +main "$@"