diff --git a/API.md b/API.md index 90cc538..16f9ddb 100644 --- a/API.md +++ b/API.md @@ -1,29 +1,48 @@ # panel-api -A tiny local HTTP API wrapper around panelctl for future frontend integration. +HTTP API wrapper around panelctl with a web UI. -The service now also serves a lightweight web UI at `/`. - -Default bind: -- 127.0.0.1:9911 +Default bind: `127.0.0.1:9911` ## Endpoints -- GET /health -- GET / -- GET /apps -- GET /apps/ -- GET /apps//compose -- POST /apps/init -- POST /apps//compose -- POST /apps//render-route -- POST /apps//deploy -- POST /apps//stop -- POST /apps//remove +### Health & UI + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/` | Web UI (served from `frontend/index.html`) | +| GET | `/health` | Health check | + +### Apps — Read + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/apps` | List all apps | +| GET | `/apps/` | Show single app manifest | +| GET | `/apps//status` | Container status (running/stopped) | +| GET | `/apps//compose` | Read compose.yaml content | +| GET | `/apps//logs?tail=N` | Fetch last N log lines (default 100) | +| GET | `/apps//backups` | List available backups | +| GET | `/apps//backups/` | Download backup zip | + +### Apps — Write + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/apps/init` | Create a new app | +| POST | `/apps//deploy` | Deploy (compose up + caddy reload) | +| POST | `/apps//restart` | Restart (compose down + up) | +| POST | `/apps//stop` | Stop (compose down) | +| POST | `/apps//render-route` | Re-render Caddy route | +| POST | `/apps//compose` | Save compose.yaml content | +| POST | `/apps//validate-compose` | Validate compose file | +| POST | `/apps//backup` | Create volume backup (zip) | +| POST | `/apps//restore` | Restore from backup | +| POST | `/apps//remove` | Remove app | ## Example payloads -Create app: +### Create app (single domain) ```json { @@ -34,7 +53,50 @@ Create app: } ``` -Remove and keep volumes: +### Create app (multiple domains) + +```json +{ + "name": "myapp", + "domain": "app.srazka.com,www.app.srazka.com", + "port": 18081, + "auth": true +} +``` + +Or using the `domains` array format: + +```json +{ + "name": "myapp", + "domains": ["app.srazka.com", "www.app.srazka.com"], + "port": 18081, + "auth": true +} +``` + +### Create app (wildcard domain) + +```json +{ + "name": "wildcard", + "domain": "*.srazka.com", + "port": 18082, + "auth": false +} +``` + +Note: Wildcard domains require DNS challenge configuration in Caddy. + +### Save compose + +```json +{ + "content": "services:\n app:\n image: nginx:latest\n ports:\n - '127.0.0.1:18080:80'\n" +} +``` + +### Remove and keep volumes ```json { @@ -42,9 +104,58 @@ Remove and keep volumes: } ``` +### Restore from backup + +```json +{ + "file": "whoami-20260101-120000.zip" +} +``` + +## Response format + +All JSON responses include an `ok` boolean: + +```json +{ + "ok": true, + "apps": [...] +} +``` + +Error responses: + +```json +{ + "ok": false, + "error": "description", + "stderr": "panelctl error output" +} +``` + +## Status response + +```json +{ + "ok": true, + "name": "whoami", + "running": true, + "containers": [ + { + "name": "whoami-app-1", + "state": "running", + "image": "docker.io/traefik/whoami:latest" + } + ] +} +``` + ## Local test ```bash curl -s http://127.0.0.1:9911/health | jq . curl -s http://127.0.0.1:9911/apps | jq . +curl -s http://127.0.0.1:9911/apps/whoami/status | jq . +curl -s http://127.0.0.1:9911/apps/whoami/logs?tail=50 | jq . +curl -s http://127.0.0.1:9911/apps/whoami/backups | jq . ``` diff --git a/README.md b/README.md index c72551b..13401a2 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,102 @@ # panelctl quickstart -This repository now includes a small helper command named panelctl for Phase 2. +Minimal container management panel for rootless Podman + Caddy. -Base directory: -- /var/lib/containers +## Base directory -Generated structure: -- /var/lib/containers/stacks//compose.yaml -- /var/lib/containers/volumes//data -- /var/lib/containers/routes/.caddy -- /var/lib/containers/state/apps/.env +`/var/lib/containers` -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 +## Generated structure -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. -- If deploy reports XDG_RUNTIME_DIR missing, enable lingering for the runtime user: - `sudo loginctl enable-linger reudy` +``` +/var/lib/containers/ +├── stacks//compose.yaml # Compose file per app +├── volumes//data # Persistent volumes +├── routes/routes.caddy # Single aggregate Caddy routes file +├── backups/-.zip # Volume backups +└── state/apps/.env # App manifest +``` -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. +All app routes are written to a single `routes/routes.caddy` file that Caddy imports. + +## Quick workflow + +```bash +# Create a new app (single domain) +panelctl init whoami whoami.srazka.com 18080 true + +# Create with multiple domains +panelctl init myapp "app.srazka.com,www.srazka.com" 18081 true + +# Create with wildcard domain (requires DNS challenge in Caddy) +panelctl init wild "*.srazka.com" 18082 false + +# Deploy (compose up + caddy reload) +panelctl deploy whoami + +# Check container status +panelctl status whoami + +# View logs +panelctl logs whoami --tail 50 + +# Restart containers +panelctl restart whoami + +# Stop containers +panelctl stop whoami + +# Validate compose file +panelctl validate-compose whoami + +# Backup volumes to zip +panelctl backup whoami + +# List backups +panelctl list-backups whoami + +# Restore from backup +panelctl restore whoami whoami-20260101-120000.zip + +# List all apps +panelctl list + +# Show app manifest +panelctl show whoami + +# Remove app (keeps volumes) +panelctl remove whoami --keep-volumes + +# Remove app and all data +panelctl remove whoami + +# If deploy says caddy reload needs root: +sudo systemctl reload caddy +``` + +## Notes + +- The default compose file uses `traefik/whoami` for smoke testing — edit before production use. +- App names must be lowercase slugs (`[a-z0-9-]`). +- Wildcard domains (`*.example.com`) require DNS challenge in Caddy (provider-specific). +- Backups stop containers for consistency, then restart if they were running. +- If deploy reports `XDG_RUNTIME_DIR` missing, enable lingering: + ``` + sudo loginctl enable-linger reudy + ``` + +## Web UI & API + +- Nix runs `panel-api` as a systemd service on `127.0.0.1:9911`. +- Caddy proxies `https://panel.srazka.com` → panel-api with Authelia forward_auth. +- Open `https://panel.srazka.com` for the web UI. +- API docs: [API.md](API.md) + +### Web UI features + +- Create apps with multiple domains and wildcard support +- Live container status indicators (auto-refreshes) +- Deploy, restart, stop, remove from the UI +- Inline compose editor with save, validate, and save+deploy +- Log viewer with configurable tail length +- Volume backup management: create, list, download, restore diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5ccbdd1 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,772 @@ + + + + + + Panel + + + + + + +
+ +
+
+

Containers Panel

+

Rootless Podman + Caddy routes from one place.

+
+
+ +
+
+ + +
Ready.
+ + +
+ +
+
+

Create App

+ + + + +
+
+ + +
+

+ Supports wildcards: *.example.com +

+ +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+
Loading apps...
+
+
+
+
+ + + + diff --git a/panel-api.py b/panel-api.py index 2df33dc..8bfd28a 100644 --- a/panel-api.py +++ b/panel-api.py @@ -1,437 +1,27 @@ #!/usr/bin/env python3 +"""panel-api — HTTP wrapper around panelctl with a web UI.""" + import json import os import re import subprocess from http.server import BaseHTTPRequestHandler, HTTPServer -from urllib.parse import urlparse +from urllib.parse import urlparse, parse_qs 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", "/var/lib/containers") - -INDEX_HTML = """ - - - - - Panel - - - - - - -
-
-

Containers Panel

-

Rootless Podman + Caddy routes from one place.

-
- -
-
-

Create App

- - - - -
-
- - -
-
- - -
-
-
- - -
-
- -
-

Apps

- - - - - - - - - - -
NameDomainUpstreamActions
- -
-

Compose Editor

-
-
No app selected.
- -
- -
-
-
- -
Ready.
-
- - - - -""" +FRONTEND_DIR = os.environ.get( + "PANEL_FRONTEND_DIR", + os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend"), +) 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], @@ -458,6 +48,81 @@ def parse_env_blob(blob): return out +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 parse_status_output(stdout): + """Try to determine if any container is running from panelctl status output.""" + text = stdout.lower() + if not text or "no containers" in text: + return {"running": False, "raw": stdout} + # podman compose ps --format json returns JSON array + try: + containers = json.loads(stdout) + if isinstance(containers, list): + running = any( + c.get("State", "").lower() == "running" + or c.get("status", "").lower().startswith("up") + for c in containers + ) + return { + "running": running, + "containers": [ + { + "name": c.get("Name", c.get("name", "?")), + "state": c.get("State", c.get("status", "unknown")), + "image": c.get("Image", c.get("image", "")), + } + for c in containers + ], + } + except (json.JSONDecodeError, TypeError): + pass + # Fallback: check for "Up" or "running" in text + running = "up" in text or "running" in text + return {"running": running, "raw": stdout} + + +def parse_backups_output(stdout): + """Parse panelctl list-backups output into structured data.""" + backups = [] + for line in stdout.splitlines(): + line = line.strip() + if not line or "no backups" in line.lower(): + continue + parts = line.split() + if len(parts) >= 1: + entry = {"name": parts[0]} + if len(parts) >= 2: + entry["size"] = parts[1] + if len(parts) >= 3: + try: + entry["mtime"] = int(parts[2]) + except ValueError: + pass + backups.append(entry) + return backups + + class Handler(BaseHTTPRequestHandler): def _html(self, code, body): payload = body.encode("utf-8") @@ -475,6 +140,18 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + def _file(self, code, filepath, content_type): + try: + with open(filepath, "rb") as fh: + data = fh.read() + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + except OSError: + self._json(500, {"ok": False, "error": "failed to read file"}) + def _read_json(self): length = int(self.headers.get("Content-Length", "0")) if length == 0: @@ -483,14 +160,29 @@ class Handler(BaseHTTPRequestHandler): return json.loads(raw.decode("utf-8")) def log_message(self, fmt, *args): - return + # Log to stdout (goes to systemd journal) + print(f"[panel-api] {self.address_string()} {fmt % args}") + + # ── Routing helpers ── + + def _parse_path(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip("/") or "/" + query = parse_qs(parsed.query) + parts = [p for p in path.split("/") if p] + return path, parts, query + + # ── GET ── def do_GET(self): - parsed = urlparse(self.path) - path = parsed.path + path, parts, query = self._parse_path() if path == "/": - self._html(200, INDEX_HTML) + index = os.path.join(FRONTEND_DIR, "index.html") + if os.path.isfile(index): + self._file(200, index, "text/html; charset=utf-8") + else: + self._html(200, "

Panel

Frontend not found.

") return if path == "/health": @@ -502,134 +194,237 @@ class Handler(BaseHTTPRequestHandler): 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 = { + apps.append({ "name": fields[0], - "domain": fields[1], + "domain": fields[1].split(",")[0], + "domains": 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 + # /apps//compose + if len(parts) == 3 and parts[0] == "apps" and parts[2] == "compose": + name = parts[1] 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}"}) + self._json(500, {"ok": False, "error": f"failed to read compose: {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"}) + # /apps//status + if len(parts) == 3 and parts[0] == "apps" and parts[2] == "status": + name = parts[1] + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) return + result = run_panelctl(["status", name]) + status = parse_status_output(result["stdout"]) + self._json(200, {"ok": True, "name": name, **status}) + return + # /apps//logs + if len(parts) == 3 and parts[0] == "apps" and parts[2] == "logs": + name = parts[1] + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + return + tail = query.get("tail", ["100"])[0] + try: + tail = str(int(tail)) + except ValueError: + tail = "100" + result = run_panelctl(["logs", name, "--tail", tail]) + self._json(200, {"ok": True, "name": name, "logs": result["stdout"]}) + return + + # /apps//backups + if len(parts) == 3 and parts[0] == "apps" and parts[2] == "backups": + name = parts[1] + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + return + result = run_panelctl(["list-backups", name]) + backups = parse_backups_output(result["stdout"]) + self._json(200, {"ok": True, "name": name, "backups": backups}) + return + + # /apps//backups/ — download backup zip + if len(parts) == 4 and parts[0] == "apps" and parts[2] == "backups": + name = parts[1] + filename = parts[3] + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + return + # Validate filename: must match -.zip + if not re.match(r"^[a-z0-9-]+-\d{8}-\d{6}\.zip$", filename): + self._json(400, {"ok": False, "error": "invalid backup filename"}) + return + backup_path = os.path.join(BASE_DIR, "backups", filename) + norm_path = os.path.abspath(backup_path) + norm_backups = os.path.abspath(os.path.join(BASE_DIR, "backups")) + os.sep + if not norm_path.startswith(norm_backups): + self._json(403, {"ok": False, "error": "path traversal denied"}) + return + if not os.path.isfile(norm_path): + self._json(404, {"ok": False, "error": "backup not found"}) + return + self.send_response(200) + self.send_header("Content-Type", "application/zip") + self.send_header("Content-Disposition", f'attachment; filename="{filename}"') + size = os.path.getsize(norm_path) + self.send_header("Content-Length", str(size)) + self.end_headers() + with open(norm_path, "rb") as fh: + while True: + chunk = fh.read(65536) + if not chunk: + break + self.wfile.write(chunk) + return + + # /apps/ — show single app + if len(parts) == 2 and parts[0] == "apps": + name = parts[1] + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid 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 + # ── POST ── + def do_POST(self): + path, parts, query = self._parse_path() + + # POST /apps/init if path == "/apps/init": try: payload = self._read_json() name = payload["name"] - domain = payload["domain"] + # Support both "domain" (string, possibly comma-separated) and "domains" (array) + if "domains" in payload and isinstance(payload["domains"], list): + domain = ",".join(payload["domains"]) + else: + domain = str(payload.get("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 + if len(parts) >= 3 and parts[0] == "apps": + name = parts[1] + action = parts[2] + + # POST /apps//compose — save compose file + if action == "compose": 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}"}) + self._json(500, {"ok": False, "error": f"failed to write compose: {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) + # POST /apps//validate-compose + if action == "validate-compose": + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) return - if action == "remove": + result = run_panelctl(["validate-compose", name]) + self._json(200 if result["ok"] else 400, result) + return + + # POST /apps//backup + if action == "backup": + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + return + result = run_panelctl(["backup", name]) + self._json(200 if result["ok"] else 400, result) + return + + # POST /apps//restore + if action == "restore": + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + return + try: + payload = self._read_json() + except Exception: 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) + backup_file = payload.get("file", "") + if not backup_file: + self._json(400, {"ok": False, "error": "backup file name is required"}) return + result = run_panelctl(["restore", name, backup_file]) + self._json(200 if result["ok"] else 400, result) + return + + # Simple panelctl pass-through actions + if action in {"deploy", "stop", "restart", "render-route"}: + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + return + result = run_panelctl([action, name]) + self._json(200 if result["ok"] else 400, result) + return + + # POST /apps//remove + if action == "remove": + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + return + 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"}) @@ -637,6 +432,7 @@ class Handler(BaseHTTPRequestHandler): def main(): server = HTTPServer((BIND, PORT), Handler) print(f"panel-api listening on http://{BIND}:{PORT}") + print(f"frontend dir: {FRONTEND_DIR}") server.serve_forever() diff --git a/panelctl.sh b/panelctl.sh index f68b749..3cc3578 100644 --- a/panelctl.sh +++ b/panelctl.sh @@ -7,6 +7,7 @@ VOLUMES_DIR="${BASE_DIR}/volumes" ROUTES_DIR="${BASE_DIR}/routes" STATE_DIR="${BASE_DIR}/state" APPS_DIR="${STATE_DIR}/apps" +BACKUPS_DIR="${BASE_DIR}/backups" FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 { uri /api/authz/forward-auth @@ -19,17 +20,36 @@ usage() { panelctl - minimal app panel helper Usage: - panelctl init [auth] + panelctl init [auth] panelctl render-route panelctl deploy + panelctl restart panelctl stop + panelctl status + panelctl logs [--tail N] panelctl remove [--keep-volumes] + panelctl backup + panelctl list-backups + panelctl restore + panelctl validate-compose panelctl list panelctl show +Domains can be comma-separated for multiple domains: + panelctl init myapp "app.example.com,www.example.com" 18080 true + +Wildcard domains are supported (requires DNS challenge in Caddy): + panelctl init myapp "*.example.com" 18080 true + Examples: panelctl init whoami whoami.srazka.com 18080 true panelctl deploy whoami + panelctl restart whoami + panelctl status whoami + panelctl logs whoami --tail 50 + panelctl backup whoami + panelctl list-backups whoami + panelctl restore whoami whoami-20260101-120000.zip EOF } @@ -39,7 +59,7 @@ fail() { } ensure_base_dirs() { - mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}" + mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}" "${BACKUPS_DIR}" } validate_name() { @@ -47,10 +67,25 @@ validate_name() { [[ "${name}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || fail "invalid name '${name}' (use lowercase slug)" } -validate_domain() { +validate_single_domain() { local domain="$1" - [[ "${domain}" =~ ^[A-Za-z0-9.-]+$ ]] || fail "invalid domain '${domain}'" - [[ "${domain}" == *.* ]] || fail "domain must include a dot" + # Allow wildcard prefix *. + local check="${domain}" + if [[ "${check}" == \*.* ]]; then + check="${check#\*.}" + fi + [[ "${check}" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || fail "invalid domain '${domain}'" + [[ "${domain}" == *.* ]] || fail "domain '${domain}' must include a dot" +} + +validate_domains() { + local domains_str="$1" + IFS=',' read -ra domains <<< "${domains_str}" + [[ ${#domains[@]} -ge 1 ]] || fail "at least one domain is required" + for d in "${domains[@]}"; do + d="$(echo "${d}" | xargs)" # trim whitespace + validate_single_domain "${d}" + done } validate_port() { @@ -74,8 +109,8 @@ app_volume_dir() { echo "${VOLUMES_DIR}/${name}" } +# All routes go into a single aggregate file that Caddy imports. app_route_file() { - local name="$1" echo "${ROUTES_DIR}/routes.caddy" } @@ -140,7 +175,6 @@ ensure_podman_runtime_env() { export DBUS_SESSION_BUS_ADDRESS fi - # Avoid inherited docker/podman remote host env from external callers. unset DOCKER_HOST unset CONTAINER_HOST } @@ -182,7 +216,7 @@ EOF write_manifest() { local name="$1" - local domain="$2" + local domains="$2" local port="$3" local auth="$4" local manifest @@ -193,11 +227,17 @@ write_manifest() { manifest="$(app_manifest "${name}")" stack_dir="$(app_stack_dir "${name}")" volume_dir="$(app_volume_dir "${name}")" - route_file="$(app_route_file "${name}")" + route_file="$(app_route_file)" + + # First domain is the primary (used for APP_DOMAIN backward compat) + local primary_domain + IFS=',' read -ra domain_arr <<< "${domains}" + primary_domain="$(echo "${domain_arr[0]}" | xargs)" cat >"${manifest}" <"${tmp}" || true + if [[ -f "${route_file}" ]]; then + sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true else printf "" >"${tmp}" fi { printf "# route:%s:start\n" "${name}" - printf "%s {\n" "${APP_DOMAIN}" + printf "%s {\n" "${caddy_domains}" if [[ -n "${auth_block}" ]]; then printf "%s\n" "${auth_block}" fi @@ -271,8 +325,8 @@ cmd_render_route() { printf "# route:%s:end\n" "${name}" } >>"${tmp}" - mv "${tmp}" "${APP_ROUTE_FILE}" - echo "rendered route ${APP_ROUTE_FILE}" + mv "${tmp}" "${route_file}" + echo "rendered route ${route_file}" } maybe_reload_caddy() { @@ -300,21 +354,37 @@ cmd_deploy() { validate_name "${name}" load_app "${name}" - echo "Starting deployment for app '${name}'" | systemd-cat -t panelctl -p info + echo "Starting deployment for app '${name}'" | systemd-cat -t panelctl -p info 2>/dev/null || true cmd_render_route "${name}" - - if ! run_compose -f "${APP_COMPOSE_FILE}" up -d 2>&1 | systemd-cat -t panelctl -p info; then - echo "Deployment failed for app '${name}'" | systemd-cat -t panelctl -p err + + if ! run_compose -f "${APP_COMPOSE_FILE}" up -d 2>&1 | systemd-cat -t panelctl -p info 2>/dev/null; then + echo "Deployment failed for app '${name}'" | systemd-cat -t panelctl -p err 2>/dev/null || true fail "compose up failed" fi - + maybe_reload_caddy - echo "Successfully deployed app '${name}'" | systemd-cat -t panelctl -p info + echo "Successfully deployed app '${name}'" | systemd-cat -t panelctl -p info 2>/dev/null || true echo "deployed app '${name}'" } +cmd_restart() { + local name="$1" + validate_name "${name}" + load_app "${name}" + + echo "Restarting app '${name}'" | systemd-cat -t panelctl -p info 2>/dev/null || true + + run_compose -f "${APP_COMPOSE_FILE}" down || fail "compose down failed" + + if ! run_compose -f "${APP_COMPOSE_FILE}" up -d 2>&1; then + fail "compose up failed during restart" + fi + + echo "restarted app '${name}'" +} + cmd_stop() { local name="$1" validate_name "${name}" @@ -324,6 +394,52 @@ cmd_stop() { echo "stopped app '${name}'" } +cmd_status() { + local name="$1" + validate_name "${name}" + load_app "${name}" + + run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null || \ + run_compose -f "${APP_COMPOSE_FILE}" ps 2>/dev/null || \ + echo "no containers running" +} + +cmd_logs() { + local name="$1" + shift + validate_name "${name}" + load_app "${name}" + + local tail_lines="100" + while [[ $# -gt 0 ]]; do + case "$1" in + --tail) + tail_lines="$2" + shift 2 + ;; + *) + shift + ;; + esac + done + + run_compose -f "${APP_COMPOSE_FILE}" logs --tail "${tail_lines}" 2>&1 || echo "no logs available" +} + +cmd_validate_compose() { + local name="$1" + validate_name "${name}" + load_app "${name}" + + if run_compose -f "${APP_COMPOSE_FILE}" config >/dev/null 2>&1; then + echo "compose file is valid" + else + local output + output="$(run_compose -f "${APP_COMPOSE_FILE}" config 2>&1 || true)" + fail "compose validation failed: ${output}" + fi +} + cmd_remove() { local name="$1" local keep_volumes="${2:-}" @@ -333,11 +449,13 @@ cmd_remove() { run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true # Remove this app's block from the aggregate routes file. - if [[ -f "${APP_ROUTE_FILE}" ]]; then + local route_file + route_file="$(app_route_file)" + if [[ -f "${route_file}" ]]; then local tmp tmp="$(mktemp)" - sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${APP_ROUTE_FILE}" >"${tmp}" || true - mv "${tmp}" "${APP_ROUTE_FILE}" + sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true + mv "${tmp}" "${route_file}" fi rm -f "$(app_manifest "${name}")" @@ -351,6 +469,107 @@ cmd_remove() { echo "removed app '${name}'" } +cmd_backup() { + local name="$1" + validate_name "${name}" + load_app "${name}" + + ensure_base_dirs + + local volume_dir + volume_dir="$(app_volume_dir "${name}")" + [[ -d "${volume_dir}" ]] || fail "volume directory '${volume_dir}' does not exist" + + local timestamp + timestamp="$(date +%Y%m%d-%H%M%S)" + local backup_file="${BACKUPS_DIR}/${name}-${timestamp}.zip" + + # Stop containers before backup for consistency + local was_running=false + if run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then + was_running=true + echo "stopping containers for consistent backup..." + run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true + fi + + (cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed" + + # Also include the compose file in the backup + local stack_dir + stack_dir="$(app_stack_dir "${name}")" + if [[ -f "${stack_dir}/compose.yaml" ]]; then + (cd "${stack_dir}" && zip -j "${backup_file}" compose.yaml) || true + fi + + # Restart if it was running + if [[ "${was_running}" == "true" ]]; then + echo "restarting containers after backup..." + run_compose -f "${APP_COMPOSE_FILE}" up -d 2>/dev/null || true + fi + + local size + size="$(du -h "${backup_file}" | cut -f1)" + echo "backup created: ${backup_file} (${size})" +} + +cmd_list_backups() { + local name="$1" + validate_name "${name}" + + ensure_base_dirs + + local found=0 + for bf in "${BACKUPS_DIR}/${name}"-*.zip; do + [[ -e "${bf}" ]] || continue + found=1 + local fname size mtime + fname="$(basename "${bf}")" + size="$(du -h "${bf}" | cut -f1)" + mtime="$(stat -c '%Y' "${bf}" 2>/dev/null || stat -f '%m' "${bf}" 2>/dev/null || echo "0")" + echo "${fname} ${size} ${mtime}" + done + + if [[ "${found}" -eq 0 ]]; then + echo "no backups found for '${name}'" + fi +} + +cmd_restore() { + local name="$1" + local backup_file="$2" + validate_name "${name}" + load_app "${name}" + + # Resolve backup file path + local full_path="${backup_file}" + if [[ ! -f "${full_path}" ]]; then + full_path="${BACKUPS_DIR}/${backup_file}" + fi + [[ -f "${full_path}" ]] || fail "backup file '${backup_file}' not found" + + # Ensure it's a zip file within the backups directory + local norm_path + norm_path="$(realpath "${full_path}")" + local norm_backups + norm_backups="$(realpath "${BACKUPS_DIR}")" + [[ "${norm_path}" == "${norm_backups}"/* ]] || fail "backup file must be in the backups directory" + + local volume_dir + volume_dir="$(app_volume_dir "${name}")" + + # Stop containers before restore + echo "stopping containers for restore..." + run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true + + # Clear existing volume data and extract backup + rm -rf "${volume_dir:?}"/* + mkdir -p "${volume_dir}" + (cd "${volume_dir}" && unzip -o "${norm_path}") || fail "unzip failed" + + echo "restored '${name}' from $(basename "${norm_path}")" + echo "run 'panelctl deploy ${name}' to start the app" +} + cmd_list() { ensure_base_dirs local found=0 @@ -359,7 +578,8 @@ cmd_list() { found=1 # shellcheck disable=SC1090 source "${mf}" - echo "${APP_NAME} ${APP_DOMAIN} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED}" + local domains="${APP_DOMAINS:-${APP_DOMAIN}}" + echo "${APP_NAME} ${domains} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED}" done if [[ "${found}" -eq 0 ]]; then @@ -381,7 +601,7 @@ main() { case "${cmd}" in init) - [[ $# -ge 4 ]] || fail "usage: panelctl init [auth]" + [[ $# -ge 4 ]] || fail "usage: panelctl init [auth]" cmd_init "$2" "$3" "$4" "${5:-true}" ;; render-route) @@ -392,14 +612,42 @@ main() { [[ $# -eq 2 ]] || fail "usage: panelctl deploy " cmd_deploy "$2" ;; + restart) + [[ $# -eq 2 ]] || fail "usage: panelctl restart " + cmd_restart "$2" + ;; stop) [[ $# -eq 2 ]] || fail "usage: panelctl stop " cmd_stop "$2" ;; + status) + [[ $# -eq 2 ]] || fail "usage: panelctl status " + cmd_status "$2" + ;; + logs) + [[ $# -ge 2 ]] || fail "usage: panelctl logs [--tail N]" + cmd_logs "$2" "${@:3}" + ;; + validate-compose) + [[ $# -eq 2 ]] || fail "usage: panelctl validate-compose " + cmd_validate_compose "$2" + ;; remove) [[ $# -ge 2 ]] || fail "usage: panelctl remove [--keep-volumes]" cmd_remove "$2" "${3:-}" ;; + backup) + [[ $# -eq 2 ]] || fail "usage: panelctl backup " + cmd_backup "$2" + ;; + list-backups) + [[ $# -eq 2 ]] || fail "usage: panelctl list-backups " + cmd_list_backups "$2" + ;; + restore) + [[ $# -eq 3 ]] || fail "usage: panelctl restore " + cmd_restore "$2" "$3" + ;; list) [[ $# -eq 1 ]] || fail "usage: panelctl list" cmd_list