diff --git a/caddy.nix b/caddy.nix index 34f0b09..24486f2 100755 --- a/caddy.nix +++ b/caddy.nix @@ -4,7 +4,10 @@ enable = true; email = "admin@reudy.net"; - # App routes generated by the panel are imported by its module (panel.nix). + # Generated app routes from the panel backend. + extraConfig = '' + import /var/lib/containers/routes/routes.caddy + ''; # Authelia's own login portal virtualHosts."auth.reudy.net".extraConfig = '' diff --git a/flake.lock b/flake.lock index 232fe32..9bb902e 100644 --- a/flake.lock +++ b/flake.lock @@ -149,32 +149,10 @@ "type": "github" } }, - "panel": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1790531515, - "narHash": "sha256-EeanYOcMIrPGgJt3azJIFjdttHRU07jVY0Iw4awEdpg=", - "ref": "main", - "rev": "e11fc00840f2fe68a076ea56305ca67bd2ace332", - "revCount": 39, - "type": "git", - "url": "ssh://git@git.reudy.net:14922/reudy-net/panel" - }, - "original": { - "ref": "main", - "type": "git", - "url": "ssh://git@git.reudy.net:14922/reudy-net/panel" - } - }, "root": { "inputs": { "agenix": "agenix", "nixpkgs": "nixpkgs", - "panel": "panel", "vpsadminos": "vpsadminos" } }, diff --git a/flake.nix b/flake.nix index 35a2fc6..89ee6d6 100644 --- a/flake.nix +++ b/flake.nix @@ -11,12 +11,6 @@ inputs.nixpkgs.follows = "nixpkgs"; inputs.darwin.follows = ""; }; - - # The deployment panel (panel.reudy.net), packaged in its own repository. - panel = { - url = "git+ssh://git@git.reudy.net:14922/reudy-net/panel?ref=main"; - inputs.nixpkgs.follows = "nixpkgs"; - }; }; outputs = @@ -24,7 +18,6 @@ nixpkgs, vpsadminos, agenix, - panel, ... }: let @@ -38,7 +31,6 @@ { nixpkgs.overlays = [ agenix.overlays.default ]; } vpsadminos.nixosModules.container_25_11 ./configuration.nix - panel.nixosModules.default ./panel.nix ./caddy.nix ./authelia.nix diff --git a/panel.nix b/panel.nix index ddac7ec..1fa6da2 100644 --- a/panel.nix +++ b/panel.nix @@ -1,14 +1,89 @@ -{ ... }: +{ config, lib, pkgs, ... }: -# The panel itself lives in its own repository (reudy-net/panel) and is pulled -# in as the `panel` flake input. To deploy a new panel version: -# nix flake update panel && sudo nixos-rebuild switch --flake .#vps +let + forgejoServer = config.services.forgejo.settings.server; +in { - services.reudy-panel = { - enable = true; - domain = "panel.reudy.net"; - autheliaAddress = "127.0.0.1:9091"; - # Runs as reudy with the shared panelroutes group, keeps everything in - # /var/lib/containers and picks up the local Forgejo automatically. + environment.systemPackages = [ + (pkgs.writeShellScriptBin "panelctl" (builtins.readFile ./panel/panelctl.sh)) + ]; + + users.groups.panelroutes = { }; + + users.users.reudy.extraGroups = [ "panelroutes" ]; + users.users.caddy.extraGroups = [ "panelroutes" ]; + + systemd.tmpfiles.rules = [ + "d /var/lib/containers 0750 reudy panelroutes -" + "d /var/lib/containers/stacks 0750 reudy panelroutes -" + "d /var/lib/containers/volumes 0750 reudy panelroutes -" + "d /var/lib/containers/routes 0750 reudy panelroutes -" + "d /var/lib/containers/state 0750 reudy panelroutes -" + "d /var/lib/containers/state/apps 0750 reudy panelroutes -" + "d /var/lib/containers/backups 0750 reudy panelroutes -" + "f /var/lib/containers/routes/routes.caddy 0640 reudy panelroutes -" + ]; + + systemd.services.panel-api = { + description = "Minimal panel API service"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + path = [ + pkgs.podman + pkgs.podman-compose + pkgs.curl + pkgs.coreutils + pkgs.zip + pkgs.unzip + pkgs.git + pkgs.util-linux # flock, used by panelctl to serialise routes file writes + pkgs.openssh # cloning repositories over ssh with the panel's deploy key + ]; + + serviceConfig = { + Type = "simple"; + User = "reudy"; + Group = "panelroutes"; + Restart = "always"; + RestartSec = 3; + WorkingDirectory = "/var/lib/containers"; + ExecStart = "${pkgs.python3}/bin/python3 ${./panel/panel-api.py}"; + }; + + environment = { + PANEL_API_BIND = "127.0.0.1"; + PANEL_API_PORT = "9911"; + PANEL_BASE_DIR = "/var/lib/containers"; + PANELCTL_PATH = "/run/current-system/sw/bin/panelctl"; + PANEL_FRONTEND_DIR = "${./panel/frontend}"; + + # Forgejo integration (repo picker, private clones, commit links). + # The API is reached on localhost; clones use the public URLs. + PANEL_FORGEJO_URL = lib.removeSuffix "/" forgejoServer.ROOT_URL; + PANEL_FORGEJO_API_URL = "http://${forgejoServer.HTTP_ADDR}:${toString forgejoServer.HTTP_PORT}"; + PANEL_FORGEJO_SSH_URL = "ssh://${forgejoServer.BUILTIN_SSH_SERVER_USER}@${forgejoServer.DOMAIN}:${toString forgejoServer.SSH_PORT}"; + }; }; -} + + services.caddy.virtualHosts."panel.reudy.net".extraConfig = '' + forward_auth 127.0.0.1:9091 { + uri /api/authz/forward-auth + copy_headers Remote-User Remote-Groups Remote-Email Remote-Name + } + reverse_proxy 127.0.0.1:9911 + ''; + + systemd.paths."caddy-routes-reload" = { + wantedBy = [ "multi-user.target" ]; + pathConfig = { + PathChanged = "/var/lib/containers/routes/routes.caddy"; + }; + }; + + systemd.services."caddy-routes-reload" = { + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.systemd}/bin/systemctl reload caddy.service"; + }; + }; +} \ No newline at end of file diff --git a/panel/API.md b/panel/API.md new file mode 100644 index 0000000..62912a6 --- /dev/null +++ b/panel/API.md @@ -0,0 +1,287 @@ +# panel-api + +HTTP API wrapper around panelctl with a web UI. + +Default bind: `127.0.0.1:9911` + +## Endpoints + +### Health & UI + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/` | Web UI (served from `frontend/index.html`) | +| GET | `/health` | Health check | +| GET | `/status` | All apps with routes, container status and running operation (what the UI polls) | +| GET | `/integrations` | Forgejo connection (`configured`, `url`, `has_token`, `user`) and the SSH deploy public key | +| POST | `/integrations/forgejo` | `{"token": "..."}` — verify against Forgejo and store; `""` disconnects | +| GET | `/forgejo/repos?q=` | Search repositories visible to the stored token (public ones without) | +| GET | `/forgejo/branches?repo=owner/name` | Branch names of a Forgejo repository | + +### Apps — Read + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/apps` | List all apps | +| GET | `/apps/` | Show single app manifest | +| GET | `/apps//routes` | Get parsed route entries | +| 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 | +| GET | `/apps//env` | Environment variables: `{"vars": [{"key", "value"}], "inject": true}` | +| GET | `/apps//repo` | Git source info (URL, web URL, provider, branch, deployed commit, local changes, deploy key for ssh) | +| GET | `/apps//repo?fetch=1` | Same, plus fetches the remote and reports `behind` / `remote` | +| GET | `/apps//volumes` | Volumes the file browser can open | +| GET | `/apps//volume/files?vol=&path=` | List a folder in a volume | +| GET | `/apps//volume/download?vol=&path=` | Download a file from a volume | +| PUT | `/apps//volume/files?vol=&path=` | Upload a file (raw body) | +| DELETE | `/apps//volume/files?vol=&path=` | Delete a file or folder | + +### Apps — Write + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/apps/init` | Create a new app | +| POST | `/apps//routes` | Update routes (hot — Caddy reloads automatically) | +| 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 | +| POST | `/apps//repo-pull` | Git apps: fetch branch, hard-reset checkout to it, redeploy | +| POST | `/apps//env` | Replace environment variables: `{"vars": [...], "inject": true, "deploy": false}` | +| POST | `/apps//volume-clear` | Stop the app and empty its default data folder | + +Write operations are serialised per app. While one runs, another write to the +same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`. +`deploy` returns the compose output in `stdout` (or `stderr` on failure). + +### Create app (git repository, with environment variables) + +```json +{ + "name": "blog", + "routes": [{"domain": "blog.reudy.net", "upstream": "127.0.0.1:18090"}], + "auth": true, + "source_type": "git", + "repo_url": "https://git.reudy.net/reudy-net/blog.git", + "repo_branch": "", + "use_forgejo_token": true, + "env": [{"key": "DATABASE_URL", "value": "postgres://..."}], + "env_inject": true +} +``` + +- `repo_url` may be `https://…`, `ssh://git@host:port/owner/repo.git` or + `git@host:owner/repo.git`. ssh URLs use the panel's deploy key. +- `repo_token` sets an https token explicitly; `use_forgejo_token` uses the + token stored in Settings (only for URLs on the configured Forgejo host). +- An empty branch uses the repository's default branch. The compose file must + be at the repository root. +- The older `source_type: "github"` with `github_url` / `github_branch` / + `github_pat` is still accepted. + +### Sync response (`repo-pull`) + +```json +{ + "ok": true, + "stdout": "HEAD is now at d7df557 Bump image tag\n...compose output...", + "before": {"sha": "4fb7976...", "short": "4fb7976", "subject": "Initial compose", "author": "reudy", "time": 1790460618}, + "after": {"sha": "d7df557...", "short": "d7df557", "subject": "Bump image tag", "author": "reudy", "time": 1790460643}, + "changed": true +} +``` + +### Status response (`/status`) + +```json +{ + "ok": true, + "time": 1790460650, + "apps": [ + { + "name": "whoami", + "routes": [{"domain": "whoami.reudy.net", "upstream": "127.0.0.1:18080"}], + "auth": true, + "compose_file": "/var/lib/containers/stacks/whoami/compose.yaml", + "repo_url": "", + "repo_branch": "", + "busy": null, + "status": { + "state": "running", + "running": true, + "running_count": 1, + "total": 1, + "containers": [{"name": "whoami-app-1", "state": "running", "status": "Up 3 minutes", "image": "docker.io/traefik/whoami:latest", "running": true}] + } + } + ] +} +``` + +`state` is one of `running`, `partial` (some containers down), `stopped` or `unknown`. + +## Example payloads + +### Create app (single route) + +```json +{ + "name": "whoami", + "routes": [ + {"domain": "whoami.reudy.net", "upstream": "127.0.0.1:18080"} + ], + "auth": true +} +``` + +### Create app (multiple routes, different ports) + +```json +{ + "name": "myapp", + "routes": [ + {"domain": "app.reudy.net", "upstream": "127.0.0.1:18080"}, + {"domain": "api.app.reudy.net", "upstream": "127.0.0.1:18081"} + ], + "auth": true +} +``` + +### Create app (multiple routes, different ports, with paths) + +```json +{ + "name": "pocketbase", + "routes": [ + {"domain": "pb.reudy.net", "upstream": "127.0.0.1:8090", "path": "/_/*"} + ], + "auth": true +} +``` + +The `path` field is optional. When present, it generates a Caddy `reverse_proxy /_/* 127.0.0.1:8090` rule, letting you route requests to a specific path prefix within a domain. + +### Create app (wildcard domain) + +```json +{ + "name": "wildcard", + "routes": [ + {"domain": "*.reudy.net", "upstream": "127.0.0.1:18082"} + ], + "auth": false +} +``` + +Note: Wildcard domains require DNS challenge configuration in Caddy. + +### Update routes (hot) + +```json +{ + "routes": [ + {"domain": "app.reudy.net", "upstream": "127.0.0.1:18080"}, + {"domain": "api.reudy.net", "upstream": "127.0.0.1:18081"}, + {"domain": "pb.reudy.net", "upstream": "127.0.0.1:8090", "path": "/_/*"} + ] +} +``` + +The optional `path` field generates a Caddy `reverse_proxy ` rule for sub-path routing. + +Caddy reloads automatically via the systemd path watcher. Containers stay running. + +### 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 +{ + "keepVolumes": true +} +``` + +### Restore from backup + +```json +{ + "file": "whoami-20260101-120000.zip" +} +``` + +## Routes response + +```json +{ + "ok": true, + "name": "myapp", + "routes": [ + {"domain": "app.reudy.net", "upstream": "127.0.0.1:18080"}, + {"domain": "api.reudy.net", "upstream": "127.0.0.1:18081", "path": "/api/*"} + ] +} +``` + +The `path` field is only present when a route has a path configured. + +## 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/panel/README.md b/panel/README.md new file mode 100644 index 0000000..eb98901 --- /dev/null +++ b/panel/README.md @@ -0,0 +1,167 @@ +# panelctl quickstart + +Minimal container management panel for rootless Podman + Caddy. + +## Base directory + +`/var/lib/containers` + +## Generated structure + +``` +/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 +``` + +All app routes are written to a single `routes/routes.caddy` file that Caddy imports. + +## Quick workflow + +```bash +# Routes are "domain|upstream[|path]" entries, comma-separated. + +# Create a new app (single route, protected by Authelia) +panelctl init whoami "whoami.reudy.net|127.0.0.1:18080" true + +# Create with multiple routes (different ports, optional path) +panelctl init myapp "app.reudy.net|127.0.0.1:18081,api.reudy.net|127.0.0.1:18082|/api/*" true + +# Create with wildcard domain (requires DNS challenge in Caddy) +panelctl init wild "*.reudy.net|127.0.0.1:18083" false + +# Change routes later (Caddy reloads automatically) +panelctl set-routes whoami "whoami.reudy.net|127.0.0.1:18080,who.reudy.net|127.0.0.1:18080" + +# 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 +``` + +## 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.reudy.net` → panel-api with Authelia forward_auth. +- Open `https://panel.reudy.net` for the web UI. +- API docs: [API.md](API.md) + +### Web UI features + +- Live status: one `/status` poll every few seconds (faster while something is + running, paused when the tab is hidden) updates cards in place, so open tabs, + unsaved edits and scroll positions are never lost. The header shows when the + panel last synced and warns when the Authelia session has expired. +- Per-app status (running / partial / stopped), container list, and a busy + indicator that is shared between browsers while an operation runs. +- New-app dialog: starter container, pasted compose file or git repository; + suggests the next free port and a domain based on the app name. +- Compose editor with unsaved-changes tracking, Ctrl+S, save & deploy, validate. +- Logs with follow mode, routes editor with validation, file browser with + drag-and-drop upload, backups with restore (and optional redeploy). +- Git source tab: deployed commit, "check for updates", and sync & deploy. +- Activity drawer with the output of every operation (e.g. why a deploy failed). +- Keyboard: `/` search, `N` new app, `Esc` closes menus. Deep links like + `#/whoami/logs` open an app on a specific tab. + +### Git-backed apps + +Apps created from a repository (Forgejo, GitHub or any git host, over https or +ssh) are cloned to `stacks//repo`. + +**Forgejo.** `panel.nix` points the panel at the local Forgejo +(`PANEL_FORGEJO_URL`, `PANEL_FORGEJO_API_URL`, `PANEL_FORGEJO_SSH_URL`, taken +from `forgejo.nix`). In the panel's **Settings** you can connect a Forgejo +access token (read access to repositories and user). With it, the new-app +dialog lists your repositories and branches, and private ones are cloned over +https with the token. Without it, public repositories are listed and private +ones are cloned over ssh with the deploy key. Commit and compare links point at +Forgejo. The token is stored in `state/panel/forgejo-token` (mode 0600). + +**SSH / deploy key.** The panel generates an ed25519 key pair in +`state/panel/ssh/` the first time it is needed. Its public half is shown in +Settings (and next to ssh URLs); add it as a read-only deploy key to a +repository — or to your Forgejo account for access to all repositories — to +clone `ssh://git@git.reudy.net:14922/owner/repo.git` style URLs. + +**Sync** fetches the configured branch and +hard-resets the checkout to it before redeploying, so the repository is the +source of truth: compose edits made in the panel are discarded on the next sync +(the UI warns about this). An access token for a private repository is stored in +the clone's `.git/config`; use a read-only token. + +### Environment variables + +Each app can have environment variables (the **Environment** tab, or when +creating the app; `.env` text can be pasted in). They are stored in +`state/env/.env` as `KEY=VALUE` lines (mode 0600) — outside the repository +and stack directory, so git syncs never touch them — and `panelctl` passes them +to every compose command: + +- They are always available for `${VAR}` interpolation in the compose file. + The UI points out variables the compose file uses without a default that + aren't set. +- With **Pass to every container** (the default), `deploy`/`restart` also + generate `stacks//.panel-env.yaml`, a compose override that lists the + keys under every service's `environment:`. Compose reads the values from its + own environment, so they are never quoted into YAML, and they take + precedence over values set in the compose file. + +Values must be single-line. Names that would change how podman/compose run +(`PATH`, `HOME`, `XDG_*`, `DOCKER_*`, `COMPOSE_*`, `PODMAN_*`, …) are rejected. +Changes apply on the next deploy. Backups do not include variables. + +### Concurrency + +`panel-api` handles requests concurrently, so a long deploy never blocks status +or logs. Mutating operations are serialised per app — a second operation on a +busy app gets HTTP 409 — and `panelctl` takes a `flock` on the shared routes +file while rewriting it. diff --git a/panel/frontend/index.html b/panel/frontend/index.html new file mode 100644 index 0000000..298ffb1 --- /dev/null +++ b/panel/frontend/index.html @@ -0,0 +1,2877 @@ + + + + + +Containers Panel + + + + + +
+
+
Panel
+ +
+ + + + +
+
+
+ + + +
+
+
+
/ search · N new app
+
+
+
+
+
+ + + +
+ + + +
+
+

New app

+ +
+
+
+ + +

Lowercase letters, digits and dashes. Used for the compose project and data folder.

+
+ +
+ Source +
+ + + +
+

+
+ + + + + +
+ Routes +
+ +

Domain → where Caddy forwards requests (the port the container publishes on 127.0.0.1). Path is optional, e.g. /api/*. Wildcard domains need a DNS challenge.

+
+ +
+ Environment variables optional +
+
+ + + + + +
+
+ + +
+
+
+ + +
+
+

Settings

+ +
+
+
+

Forgejo

+

Loading…

+
+ +
+ + +
+

Create one in Forgejo under Settings → Applications with read access to repositories (and your user). + It lets the panel list your repositories and clone private ones over https. Stored in the panel's state directory, readable only by the service.

+
+ +
+
+

SSH deploy key

+

Used for repositories cloned over SSH (e.g. ssh://git@host/owner/repo.git). Add it as a read-only deploy key in the repository's settings, or to your Forgejo account to give the panel access to all your repositories.

+
Loading…
+
+
+
+
+ + +
+

+
+ +
+ + +
+
+ + +
+
+
+ + + + diff --git a/panel/panel-api.py b/panel/panel-api.py new file mode 100644 index 0000000..5e2edd6 --- /dev/null +++ b/panel/panel-api.py @@ -0,0 +1,1591 @@ +#!/usr/bin/env python3 +"""panel-api — HTTP wrapper around panelctl with a web UI.""" + +import json +import os +import re +import shlex +import shutil +import socket +import subprocess +import threading +import time +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse, parse_qs, quote + +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") +FRONTEND_DIR = os.environ.get( + "PANEL_FRONTEND_DIR", + os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend"), +) + +# Optional Forgejo instance (set from panel.nix). The API URL may be an internal +# address; the public URL is what repositories are cloned from and linked to. +FORGEJO_URL = os.environ.get("PANEL_FORGEJO_URL", "").rstrip("/") +FORGEJO_API_URL = (os.environ.get("PANEL_FORGEJO_API_URL", "") or FORGEJO_URL).rstrip("/") +FORGEJO_SSH_URL = os.environ.get("PANEL_FORGEJO_SSH_URL", "").rstrip("/") +FORGEJO_HOST = urlparse(FORGEJO_URL).hostname or "" + +PANEL_STATE_DIR = os.path.join(BASE_DIR, "state", "panel") +FORGEJO_TOKEN_FILE = os.path.join(PANEL_STATE_DIR, "forgejo-token") +SSH_DIR = os.path.join(PANEL_STATE_DIR, "ssh") +SSH_KEY = os.path.join(SSH_DIR, "id_ed25519") +ENV_DIR = os.path.join(BASE_DIR, "state", "env") + +COMPOSE_FILENAMES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"] +GIT_TIMEOUT = 300 +# Manifest values are written into a file that panelctl sources with bash, so +# they must not contain anything that is special inside double quotes. +_URL_CHARS = r"[^\s\"'`$\\]" +REPO_URL_RE = re.compile( + rf"^(?:https?://{_URL_CHARS}+" # https://host/owner/repo.git + rf"|ssh://{_URL_CHARS}+" # ssh://git@host:port/owner/repo.git + rf"|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:{_URL_CHARS}+)$" # git@host:owner/repo.git +) +BRANCH_RE = re.compile(r"^[A-Za-z0-9._/][A-Za-z0-9._/-]*$") +FORGEJO_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") + +ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +# Variables are set on the compose process itself, so anything that changes how +# podman/compose run (or where they look for state) is off limits. +RESERVED_ENV = {"PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "PWD", "OLDPWD", "IFS", "TERM"} +RESERVED_ENV_PREFIXES = ("XDG_", "DBUS_", "DOCKER_", "CONTAINER_", "CONTAINERS_", "COMPOSE_", + "PODMAN_", "BUILDAH_", "LD_", "BASH_") + + +def is_safe_name(name): + return re.match(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", name) is not None + + +# ── Per-app operation locks ── +# Requests are handled concurrently, so two mutating operations on the same app +# (e.g. a double-clicked deploy, or deploy + restore) must not overlap. + +_busy = {} +_busy_lock = threading.Lock() + + +class AppBusy(Exception): + def __init__(self, name, action): + super().__init__(f"another operation ({action}) is already running on '{name}'") + self.action = action + + +@contextmanager +def app_operation(name, action): + with _busy_lock: + if name in _busy: + raise AppBusy(name, _busy[name]) + _busy[name] = action + try: + yield + finally: + with _busy_lock: + _busy.pop(name, None) + + +def busy_snapshot(): + with _busy_lock: + return dict(_busy) + + +def redact_credentials(text, replacement="***@"): + """Hide user:token@ credentials embedded in http(s) URLs. + (ssh://git@host is a username, not a secret, and must be kept.)""" + return re.sub(r"(https?://)[^/@\s]+@", r"\1" + replacement, text or "") + + +def last_line(text): + lines = [line.strip() for line in (text or "").splitlines() if line.strip()] + return lines[-1] if lines else "" + + +def git_error(stderr): + """The informative line of a git failure (git ends with generic advice).""" + lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()] + for line in lines: + if re.match(r"^(ssh|fatal|error|remote):", line, re.I) and "could not read from remote" not in line.lower(): + return re.sub(r"^fatal:\s*", "", line) + return last_line(stderr) + + +def write_private_file(path, content): + """Atomically write a file only the service user can read.""" + os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) + tmp = f"{path}.tmp" + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(content) + os.replace(tmp, path) + + +# ── SSH deploy key ── +# One key pair for the panel; add its public half as a (read-only) deploy key +# to repositories cloned over SSH. + +def ssh_public_key(create=True): + pub = SSH_KEY + ".pub" + if not os.path.isfile(pub) and create: + keygen = shutil.which("ssh-keygen") + if not keygen: + return None + os.makedirs(SSH_DIR, mode=0o700, exist_ok=True) + subprocess.run( + [keygen, "-t", "ed25519", "-N", "", "-q", "-C", f"panel@{socket.gethostname()}", "-f", SSH_KEY], + capture_output=True, check=False, timeout=30, + ) + try: + with open(pub, "r", encoding="utf-8") as fh: + return fh.read().strip() + except OSError: + return None + + +def is_ssh_url(url): + return not re.match(r"^https?://", url or "") + + +# ── Git helpers ── + +def git_env(): + # Never block on an interactive credential prompt. + env = dict(os.environ, GIT_TERMINAL_PROMPT="0") + if os.path.isfile(SSH_KEY): + env["GIT_SSH_COMMAND"] = " ".join([ + "ssh", "-i", shlex.quote(SSH_KEY), + "-o", "IdentitiesOnly=yes", + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "UserKnownHostsFile=" + shlex.quote(os.path.join(SSH_DIR, "known_hosts")), + ]) + return env + + +def run_git(args, cwd=None, timeout=GIT_TIMEOUT): + git_bin = shutil.which("git") + if not git_bin: + return {"ok": False, "stdout": "", "stderr": "git is not installed or not in PATH"} + cmd = [git_bin] + (["-C", cwd] if cwd else []) + args + try: + proc = subprocess.run(cmd, capture_output=True, text=True, env=git_env(), timeout=timeout) + except subprocess.TimeoutExpired: + return {"ok": False, "stdout": "", "stderr": f"git {args[0]} timed out after {timeout}s"} + return { + "ok": proc.returncode == 0, + "stdout": redact_credentials(proc.stdout.strip()), + "stderr": redact_credentials(proc.stderr.strip()), + } + + +def clone_repo(url, branch, target_dir, token=""): + auth_url = url + if token and not is_ssh_url(url): + auth_url = url.replace("://", f"://{quote(token, safe='')}@", 1) + elif is_ssh_url(url): + ssh_public_key() # make sure the deploy key exists before the first clone + args = ["clone"] + if branch: + args += ["--branch", branch] + return run_git(args + ["--", auth_url, target_dir]) + + +def repo_host_and_path(url): + url = redact_credentials(url or "", "") + m = (re.match(r"^https?://([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url) + or re.match(r"^ssh://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url) + or re.match(r"^(?:[^@/]+@)?([^/:]+):(?!/)(.+?)(?:\.git)?/?$", url)) + return (m.group(1), m.group(2)) if m else (None, None) + + +def repo_provider(url): + host, _ = repo_host_and_path(url) + if host and FORGEJO_HOST and host == FORGEJO_HOST: + return "forgejo" + if host in ("github.com", "www.github.com"): + return "github" + return "git" + + +def repo_web_url(url): + """Browser URL of a repository, for commit / compare links.""" + host, path = repo_host_and_path(url) + if not host: + return "" + if FORGEJO_HOST and host == FORGEJO_HOST: + return f"{FORGEJO_URL}/{path}" + m = re.match(r"^(https?)://", url or "") + return f"{m.group(1) if m else 'https'}://{host}/{path}" + + +# ── Forgejo ── + +class ForgejoError(Exception): + pass + + +def forgejo_token(): + try: + with open(FORGEJO_TOKEN_FILE, "r", encoding="utf-8") as fh: + return fh.read().strip() + except OSError: + return "" + + +def forgejo_api(path, token=None, timeout=10): + if not FORGEJO_API_URL: + raise ForgejoError("no Forgejo instance is configured") + token = forgejo_token() if token is None else token + req = urllib.request.Request(FORGEJO_API_URL + path, headers={"Accept": "application/json"}) + if token: + req.add_header("Authorization", f"token {token}") + try: + with urllib.request.urlopen(req, timeout=timeout) as res: + return json.loads(res.read().decode("utf-8") or "null") + except urllib.error.HTTPError as exc: + if exc.code in (401, 403): + raise ForgejoError("Forgejo rejected the token") from exc + if exc.code == 404: + raise ForgejoError("not found on Forgejo (or no access)") from exc + raise ForgejoError(f"Forgejo answered HTTP {exc.code}") from exc + except (urllib.error.URLError, TimeoutError, ValueError) as exc: + raise ForgejoError(f"can't reach Forgejo: {getattr(exc, 'reason', exc)}") from exc + + +def is_forgejo_https_url(url): + return bool(FORGEJO_URL) and not is_ssh_url(url) and repo_provider(url) == "forgejo" + + +# ── Environment variables ── +# Stored per app in state/env/.env as KEY=VALUE lines (0600). panelctl +# passes them to every compose command, so they work for ${VAR} interpolation +# and — unless disabled — are injected into every service. + +def env_file_path(name): + return os.path.join(ENV_DIR, f"{name}.env") + + +def read_app_env(name): + items = [] + try: + with open(env_file_path(name), "r", encoding="utf-8") as fh: + lines = fh.read().splitlines() + except OSError: + return items + for line in lines: + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + items.append({"key": key, "value": value}) + return items + + +def validate_env(items): + if items is None: + return [] + if not isinstance(items, list): + raise ValueError("env must be a list of {key, value}") + seen = set() + out = [] + for item in items: + if not isinstance(item, dict): + raise ValueError("env must be a list of {key, value}") + key = str(item.get("key", "")).strip() + value = item.get("value", "") + value = "" if value is None else str(value) + if not key and not value: + continue + if not ENV_KEY_RE.match(key): + raise ValueError(f"'{key}' is not a valid variable name (letters, digits and _, not starting with a digit)") + if key in RESERVED_ENV or key.startswith(RESERVED_ENV_PREFIXES): + raise ValueError(f"'{key}' is reserved because it would change how podman/compose run") + if key in seen: + raise ValueError(f"'{key}' is set more than once") + if any(c in value for c in "\n\r\0"): + raise ValueError(f"the value of '{key}' must be a single line") + seen.add(key) + out.append({"key": key, "value": value}) + return out + + +def write_app_env(name, items): + path = env_file_path(name) + if not items: + try: + os.remove(path) + except FileNotFoundError: + pass + return + write_private_file(path, "".join(f"{i['key']}={i['value']}\n" for i in items)) + + +def repo_commit(repo_dir, ref="HEAD"): + result = run_git(["log", "-1", "--format=%H%x1f%s%x1f%an%x1f%ct", ref], cwd=repo_dir, timeout=15) + if not result["ok"] or not result["stdout"]: + return None + sha, subject, author, ts = (result["stdout"].split("\x1f") + ["", "", "", ""])[:4] + return { + "sha": sha, + "short": sha[:7], + "subject": subject, + "author": author, + "time": int(ts) if ts.isdigit() else None, + } + + +def repo_current_branch(repo_dir): + result = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_dir, timeout=15) + if result["ok"] and result["stdout"] and result["stdout"] != "HEAD": + return result["stdout"] + return "" + + +def find_compose_file(repo_dir): + for fname in COMPOSE_FILENAMES: + candidate = os.path.join(repo_dir, fname) + if os.path.isfile(candidate): + return candidate + return None + + +# ── Manifest helpers ── + +def update_manifest(name, values): + """Set KEY="value" lines in an app manifest, replacing existing keys.""" + for key, value in values.items(): + if re.search(r'["`$\\\n]', value): + raise ValueError(f"unsafe characters in {key}") + manifest_path = os.path.join(BASE_DIR, "state", "apps", f"{name}.env") + with open(manifest_path, "r", encoding="utf-8") as fh: + lines = fh.readlines() + remaining = dict(values) + out = [] + for line in lines: + key = line.split("=", 1)[0].strip() + if key in remaining: + out.append(f'{key}="{remaining.pop(key)}"\n') + else: + out.append(line if line.endswith("\n") else line + "\n") + for key, value in remaining.items(): + out.append(f'{key}="{value}"\n') + with open(manifest_path, "w", encoding="utf-8") as fh: + fh.writelines(out) + + +def manifest_routes(env): + routes_raw = env.get("APP_ROUTES", "") + # Backward compat: build from old APP_DOMAIN/APP_PORT/APP_UPSTREAM + if not routes_raw and "APP_DOMAIN" in env: + upstream = env.get("APP_UPSTREAM", f"127.0.0.1:{env.get('APP_PORT', '18080')}") + domains = env.get("APP_DOMAINS", env["APP_DOMAIN"]) + routes_raw = ",".join(f"{d.strip()}|{upstream}" for d in domains.split(",") if d.strip()) + routes = [] + for entry in routes_raw.split(","): + entry = entry.strip() + if not entry: + continue + fields = entry.split("|", 2) + if len(fields) < 2: + continue + route = {"domain": fields[0].strip(), "upstream": fields[1].strip()} + if len(fields) > 2 and fields[2].strip(): + route["path"] = fields[2].strip() + routes.append(route) + return routes + + +def load_app_summaries(): + """Read every app manifest directly (much faster than shelling out per app).""" + apps_dir = os.path.join(BASE_DIR, "state", "apps") + try: + entries = sorted(os.listdir(apps_dir)) + except OSError: + return [] + apps = [] + for fname in entries: + if not fname.endswith(".env"): + continue + name = fname[:-4] + if not is_safe_name(name): + continue + try: + with open(os.path.join(apps_dir, fname), "r", encoding="utf-8") as fh: + env = parse_env_blob(fh.read()) + except OSError: + continue + repo_url = redact_credentials(env.get("APP_REPO_URL", ""), "") + apps.append({ + "name": name, + "routes": manifest_routes(env), + "auth": env.get("APP_AUTH_PROTECTED", "true") == "true", + "compose_file": env.get("APP_COMPOSE_FILE", ""), + "repo_url": repo_url, + "repo_branch": env.get("APP_REPO_BRANCH", ""), + "repo_provider": repo_provider(repo_url) if repo_url else "", + "repo_web_url": repo_web_url(repo_url) if repo_url else "", + "env_count": len(read_app_env(name)), + "env_inject": env.get("APP_ENV_INJECT", "true") != "false", + }) + return apps + + +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 + + +def get_app_volumes(name): + result = run_panelctl(["inspect-volumes", name]) + volumes = {} + # Parse whatever was printed even on a non-zero exit, so one failing + # `podman volume ls` doesn't hide the app's default data folder. + for line in result["stdout"].splitlines(): + if "|" in line: + vname, vpath = line.split("|", 1) + volumes[vname.strip()] = vpath.strip() + return volumes + +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 _decode_containers(stdout): + """`compose ps --format json` prints either a JSON array or one object per line, + sometimes mixed with other output. Returns a list of dicts, or None.""" + lines = stdout.splitlines() + for i, line in enumerate(lines): + if line.lstrip().startswith("["): + try: + data, _ = json.JSONDecoder().raw_decode("\n".join(lines[i:]).lstrip()) + except ValueError: + continue + if isinstance(data, list): + return [c for c in data if isinstance(c, dict)] + items = [] + for line in lines: + line = line.strip() + if not line.startswith("{"): + continue + try: + obj = json.loads(line) + except ValueError: + continue + if isinstance(obj, dict): + items.append(obj) + return items or None + + +def _container_name(c): + name = c.get("Name") or c.get("name") + if not name: + names = c.get("Names") + if isinstance(names, list) and names: + name = names[0] + elif isinstance(names, str): + name = names + return name or "?" + + +def parse_status_output(stdout): + """Summarise panelctl status output as running / partial / stopped / unknown.""" + stdout = stdout or "" + containers = _decode_containers(stdout) + if containers is None: + text = stdout.lower() + if not text.strip() or "no containers" in text: + return {"state": "stopped", "running": False, "running_count": 0, "total": 0, "containers": []} + running = re.search(r"\b(up|running)\b", text) is not None + return { + "state": "running" if running else "unknown", + "running": running, + "running_count": None, + "total": None, + "containers": [], + "raw": stdout, + } + + parsed = [] + for c in containers: + state = str(c.get("State") or c.get("state") or "").lower() + status = str(c.get("Status") or c.get("status") or "") + is_running = state == "running" or status.lower().startswith("up") + parsed.append({ + "name": _container_name(c), + "state": state or ("running" if is_running else "unknown"), + "status": status, + "image": c.get("Image") or c.get("image") or "", + "running": is_running, + }) + running_count = sum(1 for c in parsed if c["running"]) + total = len(parsed) + if total and running_count == total: + state = "running" + elif running_count: + state = "partial" + else: + state = "stopped" + return { + "state": state, + "running": running_count > 0, + "running_count": running_count, + "total": total, + "containers": parsed, + } + + +def app_status(name): + return parse_status_output(run_panelctl(["status", name])["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 + + +# Actions that only read state and may run alongside anything else. +LOCK_FREE_ACTIONS = {"validate-compose"} + + +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 _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))) + # The UI is a single file that changes with every rebuild. + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(data) + except OSError: + self._json(500, {"ok": False, "error": "failed to read file"}) + + def _read_json(self): + # Cached: do_POST may read the body before dispatching. + if hasattr(self, "_payload"): + return self._payload + length = int(self.headers.get("Content-Length", "0")) + if length == 0: + self._payload = {} + else: + raw = self.rfile.read(length) + self._payload = json.loads(raw.decode("utf-8")) + return self._payload + + def log_message(self, fmt, *args): + # 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): + path, parts, query = self._parse_path() + + if path == "/": + 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": + self._json(200, {"ok": True, "service": "panel-api"}) + return + + # /integrations — Forgejo connection and the panel's SSH deploy key + if path == "/integrations": + token = forgejo_token() + forgejo = { + "configured": bool(FORGEJO_URL), + "url": FORGEJO_URL, + "ssh_url": FORGEJO_SSH_URL, + "has_token": bool(token), + "user": None, + } + if FORGEJO_URL and token: + try: + forgejo["user"] = (forgejo_api("/api/v1/user", timeout=5) or {}).get("login") + except ForgejoError as exc: + forgejo["error"] = str(exc) + self._json(200, {"ok": True, "forgejo": forgejo, "ssh": {"public_key": ssh_public_key()}}) + return + + # /forgejo/repos?q= — repositories visible to the stored token (public ones without) + if path == "/forgejo/repos": + q = query.get("q", [""])[0].strip() + try: + data = forgejo_api(f"/api/v1/repos/search?q={quote(q)}&limit=50&sort=updated&order=desc") + except ForgejoError as exc: + self._json(502, {"ok": False, "error": str(exc)}) + return + repos = [{ + "full_name": r.get("full_name", ""), + "description": r.get("description", ""), + "private": bool(r.get("private")), + "empty": bool(r.get("empty")), + "archived": bool(r.get("archived")), + "default_branch": r.get("default_branch", ""), + "clone_url": r.get("clone_url", ""), + "ssh_url": r.get("ssh_url", ""), + "html_url": r.get("html_url", ""), + "updated_at": r.get("updated_at", ""), + } for r in (data or {}).get("data", [])] + self._json(200, {"ok": True, "repos": repos, "authenticated": bool(forgejo_token())}) + return + + # /forgejo/branches?repo=owner/name + if path == "/forgejo/branches": + repo = query.get("repo", [""])[0].strip() + if not FORGEJO_REPO_RE.match(repo): + self._json(400, {"ok": False, "error": "repo must look like owner/name"}) + return + try: + data = forgejo_api(f"/api/v1/repos/{repo}/branches?limit=100") + except ForgejoError as exc: + self._json(502, {"ok": False, "error": str(exc)}) + return + self._json(200, {"ok": True, "branches": [b.get("name", "") for b in (data or [])]}) + return + + # /apps//env — environment variables used when deploying + if len(parts) == 3 and parts[0] == "apps" and parts[2] == "env": + name = parts[1] + app, err = read_app_info(name) + if err is not None: + self._json(404, err) + return + self._json(200, { + "ok": True, + "name": name, + "vars": read_app_env(name), + "inject": app.get("APP_ENV_INJECT", "true") != "false", + }) + return + + # /status — every app with routes, container status and running operation. + # This is what the UI polls, so it is one request regardless of app count. + if path == "/status": + apps = load_app_summaries() + names = [a["name"] for a in apps] + statuses = {} + if names: + with ThreadPoolExecutor(max_workers=min(8, len(names))) as pool: + statuses = dict(zip(names, pool.map(app_status, names))) + busy = busy_snapshot() + for app in apps: + app["status"] = statuses.get(app["name"], {"state": "unknown"}) + app["busy"] = busy.get(app["name"]) + self._json(200, {"ok": True, "time": int(time.time()), "apps": apps}) + 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 + fields = line.split() + if len(fields) < 4: + continue + # New format: name domain|upstream routes=N auth=bool [repo_url] + first_route = fields[1] + route_parts = first_route.split("|") + domain = route_parts[0].split(",")[0] if route_parts else first_route + upstream = route_parts[1] if len(route_parts) > 1 else "" + route_count_str = fields[2].replace("routes=", "") + # Backward compat: fields[2] may be upstream if old format + if not route_count_str.isdigit(): + upstream = fields[2] + route_count_str = "1" + apps.append({ + "name": fields[0], + "domain": domain, + "domains": domain, + "upstream": upstream, + "first_route": first_route, + "route_count": route_count_str, + "auth": fields[3].replace("auth=", ""), + "repo_url": fields[4] if len(fields) >= 5 else "", + }) + self._json(200, {"ok": True, "apps": apps}) + return + + # /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: {exc}"}) + return + self._json(200, {"ok": True, "name": name, "content": content}) + return + + # /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//routes — get parsed routes + if len(parts) == 3 and parts[0] == "apps" and parts[2] == "routes": + 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 + routes = manifest_routes(parse_env_blob(result["stdout"])) + self._json(200, {"ok": True, "name": name, "routes": routes}) + return + + # /apps//repo[?fetch=1] — git source info; fetch=1 also checks the remote + if len(parts) == 3 and parts[0] == "apps" and parts[2] == "repo": + name = parts[1] + app, err = read_app_info(name) + if err is not None: + self._json(404, err) + return + repo_url = app.get("APP_REPO_URL", "") + if not repo_url: + self._json(404, {"ok": False, "error": "app is not linked to a git repository"}) + return + repo_dir = os.path.join(app["APP_STACK_DIR"], "repo") + branch = app.get("APP_REPO_BRANCH", "") + info = { + "ok": True, + "name": name, + "url": redact_credentials(repo_url, ""), + "web_url": repo_web_url(repo_url), + "provider": repo_provider(repo_url), + "ssh": is_ssh_url(repo_url), + "branch": branch, + "cloned": os.path.isdir(os.path.join(repo_dir, ".git")), + } + if info["ssh"]: + info["public_key"] = ssh_public_key() + if info["cloned"]: + info["commit"] = repo_commit(repo_dir) + status = run_git(["status", "--porcelain", "--untracked-files=no"], cwd=repo_dir, timeout=15) + info["dirty"] = bool(status["stdout"]) if status["ok"] else None + if query.get("fetch", ["0"])[0] == "1": + ref = branch or repo_current_branch(repo_dir) + fetched = run_git(["fetch", "--quiet", "origin", ref], cwd=repo_dir) + if not fetched["ok"]: + info["fetch_error"] = git_error(fetched["stderr"]) or "git fetch failed" + else: + info["remote"] = repo_commit(repo_dir, "FETCH_HEAD") + count = run_git(["rev-list", "--count", "HEAD..FETCH_HEAD"], cwd=repo_dir, timeout=15) + info["behind"] = int(count["stdout"]) if count["ok"] and count["stdout"].isdigit() else None + self._json(200, info) + 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 + + # /apps//volumes + if len(parts) == 3 and parts[0] == "apps" and parts[2] == "volumes": + name = parts[1] + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + return + volumes = get_app_volumes(name) + self._json(200, {"ok": True, "name": name, "volumes": volumes}) + return + + # /apps//volume/files + if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "files": + name = parts[1] + app, err = read_app_info(name) + if err is not None: + self._json(404, err) + return + + volumes = get_app_volumes(name) + vol_key = query.get("vol", ["default"])[0] + if vol_key not in volumes: + self._json(400, {"ok": False, "error": "invalid volume specified"}) + return + + subpath = query.get("path", [""])[0].strip("/") + data_dir = volumes[vol_key] + target_dir = os.path.abspath(os.path.join(data_dir, subpath)) + + # Ensure traversal didn't escape data_dir + if not target_dir.startswith(os.path.abspath(data_dir)): + self._json(403, {"ok": False, "error": "path traversal denied"}) + return + + if not os.path.exists(target_dir): + self._json(404, {"ok": False, "error": "directory not found"}) + return + + if not os.path.isdir(target_dir): + self._json(400, {"ok": False, "error": "target is not a directory"}) + return + + files = [] + for item in os.listdir(target_dir): + if item == "." or item == "..": + continue + item_path = os.path.join(target_dir, item) + try: + stat = os.stat(item_path) + files.append({ + "name": item, + "is_dir": os.path.isdir(item_path), + "size": stat.st_size, + "mtime": stat.st_mtime + }) + except OSError: + continue + + files.sort(key=lambda x: (not x["is_dir"], x["name"].lower())) + self._json(200, {"ok": True, "path": subpath, "files": files}) + return + + # /apps//volume/download + if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "download": + name = parts[1] + app, err = read_app_info(name) + if err is not None: + self._json(404, err) + return + + volumes = get_app_volumes(name) + vol_key = query.get("vol", ["default"])[0] + if vol_key not in volumes: + self._json(400, {"ok": False, "error": "invalid volume specified"}) + return + + subpath = query.get("path", [""])[0].strip("/") + if not subpath: + self._json(400, {"ok": False, "error": "path parameter required"}) + return + + data_dir = volumes[vol_key] + target_file = os.path.abspath(os.path.join(data_dir, subpath)) + + if not target_file.startswith(os.path.abspath(data_dir)): + self._json(403, {"ok": False, "error": "path traversal denied"}) + return + + if not os.path.isfile(target_file): + self._json(404, {"ok": False, "error": "file not found"}) + return + + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Disposition", f'attachment; filename="{os.path.basename(target_file)}"') + size = os.path.getsize(target_file) + self.send_header("Content-Length", str(size)) + self.end_headers() + with open(target_file, "rb") as fh: + while True: + chunk = fh.read(65536) + if not chunk: + break + self.wfile.write(chunk) + return + + self._json(404, {"ok": False, "error": "not found"}) + + # ── PUT ── + def do_PUT(self): + path, parts, query = self._parse_path() + if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "files": + name = parts[1] + app, err = read_app_info(name) + if err is not None: + self._json(404, err) + return + + volumes = get_app_volumes(name) + vol_key = query.get("vol", ["default"])[0] + if vol_key not in volumes: + self._json(400, {"ok": False, "error": "invalid volume specified"}) + return + + subpath = query.get("path", [""])[0].strip("/") + if not subpath: + self._json(400, {"ok": False, "error": "path parameter required"}) + return + + data_dir = volumes[vol_key] + target_file = os.path.abspath(os.path.join(data_dir, subpath)) + if not target_file.startswith(os.path.abspath(data_dir)): + self._json(403, {"ok": False, "error": "path traversal denied"}) + return + + try: + os.makedirs(os.path.dirname(target_file), exist_ok=True) + length = int(self.headers.get("Content-Length", "0")) + with open(target_file, "wb") as fh: + bytes_read = 0 + while bytes_read < length: + chunk = self.rfile.read(min(65536, length - bytes_read)) + if not chunk: + break + fh.write(chunk) + bytes_read += len(chunk) + self._json(200, {"ok": True, "path": subpath}) + except Exception as exc: + self._json(500, {"ok": False, "error": str(exc)}) + return + + self._json(404, {"ok": False, "error": "not found"}) + + # ── DELETE ── + def do_DELETE(self): + path, parts, query = self._parse_path() + + if len(parts) == 4 and parts[0] == "apps" and parts[2] == "volume" and parts[3] == "files": + name = parts[1] + app, err = read_app_info(name) + if err is not None: + self._json(404, err) + return + + volumes = get_app_volumes(name) + vol_key = query.get("vol", ["default"])[0] + if vol_key not in volumes: + self._json(400, {"ok": False, "error": "invalid volume specified"}) + return + + subpath = query.get("path", [""])[0].strip("/") + if not subpath: + self._json(400, {"ok": False, "error": "path parameter required"}) + return + + data_dir = volumes[vol_key] + target_file = os.path.abspath(os.path.join(data_dir, subpath)) + + if not target_file.startswith(os.path.abspath(data_dir)): + self._json(403, {"ok": False, "error": "path traversal denied"}) + return + + if not os.path.exists(target_file): + self._json(404, {"ok": False, "error": "file or directory not found"}) + return + + try: + if os.path.isdir(target_file): + import shutil + shutil.rmtree(target_file) + else: + os.remove(target_file) + self._json(200, {"ok": True, "deleted": subpath}) + except Exception as exc: + self._json(500, {"ok": False, "error": str(exc)}) + return + + self._json(404, {"ok": False, "error": "not found"}) + + # ── POST ── + + def do_POST(self): + path, parts, query = self._parse_path() + + name, action = None, None + if path == "/apps/init": + try: + payload = self._read_json() + except Exception as exc: + self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) + return + name = str(payload.get("name", "")) if isinstance(payload, dict) else "" + action = "init" + elif len(parts) >= 3 and parts[0] == "apps" and parts[2] not in LOCK_FREE_ACTIONS: + name, action = parts[1], parts[2] + + if not name: + self._handle_post(path, parts, query) + return + try: + with app_operation(name, action): + self._handle_post(path, parts, query) + except AppBusy as exc: + self._json(409, {"ok": False, "error": str(exc), "busy": exc.action}) + + def _handle_post(self, path, parts, query): + # POST /apps/init + if path == "/apps/init": + try: + payload = self._read_json() + name = payload["name"] + auth = str(payload.get("auth", True)).lower() + source_type = payload.get("source_type", "default") + + # Build routes string: "domain|upstream,domain|upstream,..." + routes_parts = [] + if "routes" in payload and isinstance(payload["routes"], list): + for r in payload["routes"]: + d = r.get("domain", "").strip() + u = r.get("upstream", "").strip() + p = r.get("path", "").strip() + if d and u: + if p: + routes_parts.append(f"{d}|{u}|{p}") + else: + routes_parts.append(f"{d}|{u}") + elif "domain" in payload and "port" in payload: + # Backward compat: single domain + port + domain_str = payload.get("domain", "") + if "domains" in payload and isinstance(payload["domains"], list): + domain_str = ",".join(payload["domains"]) + port = str(payload["port"]) + for d in domain_str.split(","): + d = d.strip() + if d: + routes_parts.append(f"{d}|127.0.0.1:{port}") + else: + self._json(400, {"ok": False, "error": "missing 'routes' array or 'domain'+'port' fields"}) + return + + routes_str = ",".join(routes_parts) + except Exception as exc: + self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) + return + + if source_type == "github": # older clients + source_type = "git" + if source_type not in ["default", "raw", "git"]: + self._json(400, {"ok": False, "error": "invalid source_type"}) + return + + # Validate everything before creating anything. + try: + env_items = validate_env(payload.get("env")) + except ValueError as exc: + self._json(400, {"ok": False, "error": str(exc)}) + return + env_inject = payload.get("env_inject", True) is not False + + if source_type == "git": + repo_url = str(payload.get("repo_url") or payload.get("github_url") or "").strip() + branch = str(payload.get("repo_branch") or payload.get("github_branch") or "").strip() + token = str(payload.get("repo_token") or payload.get("github_pat") or "").strip() + if not REPO_URL_RE.match(repo_url) or repo_url.startswith("-"): + self._json(400, {"ok": False, "error": "repository URL must be an https://, ssh:// or git@host:owner/repo URL"}) + return + if branch and not BRANCH_RE.match(branch): + self._json(400, {"ok": False, "error": f"invalid branch name '{branch}'"}) + return + # Clone a Forgejo repository with the panel's stored token. + if not token and payload.get("use_forgejo_token") and is_forgejo_https_url(repo_url): + token = forgejo_token() + + try: + result = run_panelctl(["init", name, routes_str, auth]) + if not result["ok"]: + self._json(400, result) + return + + app, err = read_app_info(name) + if err is not None or app is None: + run_panelctl(["remove", name]) + err_msg = (err or {}).get("error", "unknown error") if err else "app state unavailable" + self._json(500, {"ok": False, "error": f"failed to read app state: {err_msg}"}) + return + + if source_type == "raw": + content = payload.get("compose_content", "") + try: + with open(app["APP_COMPOSE_FILE"], "w", encoding="utf-8") as fh: + fh.write(content) + except OSError as exc: + run_panelctl(["remove", name]) + self._json(500, {"ok": False, "error": f"failed to write compose: {exc}"}) + return + + summary = "initialized successfully" + if source_type == "git": + # Any git host works (Forgejo, GitHub, ...), over https or ssh. + # An https token is embedded in the clone URL, so later syncs + # reuse it from .git/config; ssh uses the panel's deploy key. + target_dir = os.path.join(app["APP_STACK_DIR"], "repo") + if os.path.exists(target_dir): + shutil.rmtree(target_dir) + + cloned = clone_repo(repo_url, branch, target_dir, token) + if not cloned["ok"]: + run_panelctl(["remove", name]) + self._json(400, { + "ok": False, + "error": f"git clone failed: {git_error(cloned['stderr'])}", + "stderr": cloned["stderr"], + }) + return + branch = branch or repo_current_branch(target_dir) or "main" + + compose_path = find_compose_file(target_dir) + if not compose_path: + run_panelctl(["remove", name]) + self._json(400, {"ok": False, "error": "could not find a compose file in the repository root"}) + return + + try: + update_manifest(name, { + "APP_COMPOSE_FILE": compose_path, + "APP_REPO_URL": redact_credentials(repo_url, ""), + "APP_REPO_BRANCH": branch, + }) + except (OSError, ValueError) as exc: + run_panelctl(["remove", name]) + self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"}) + return + + commit = repo_commit(target_dir) + summary = f"cloned {branch} at {commit['short']}: {commit['subject']}" if commit else "cloned" + + if env_items or not env_inject: + write_app_env(name, env_items) + update_manifest(name, {"APP_ENV_INJECT": "true" if env_inject else "false"}) + summary += f"\n{len(env_items)} environment variable(s) set" + + self._json(200, {"ok": True, "code": 0, "stdout": summary}) + except Exception as exc: + run_panelctl(["remove", name]) + self._json(500, {"ok": False, "error": f"init failed: {exc}"}) + return + + # POST /integrations/forgejo {"token": "..."} — verify and store ("" clears it) + if path == "/integrations/forgejo": + if not FORGEJO_URL: + self._json(400, {"ok": False, "error": "no Forgejo instance is configured (PANEL_FORGEJO_URL)"}) + return + try: + token = str(self._read_json().get("token", "")).strip() + except Exception as exc: + self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) + return + if not token: + try: + os.remove(FORGEJO_TOKEN_FILE) + except FileNotFoundError: + pass + self._json(200, {"ok": True, "has_token": False}) + return + try: + user = (forgejo_api("/api/v1/user", token=token) or {}).get("login") + except ForgejoError as exc: + self._json(400, {"ok": False, "error": str(exc)}) + return + write_private_file(FORGEJO_TOKEN_FILE, token + "\n") + self._json(200, {"ok": True, "has_token": True, "user": user}) + return + + if len(parts) >= 3 and parts[0] == "apps": + name = parts[1] + action = parts[2] + + # POST /apps//env — replace environment variables, optionally redeploy + if action == "env": + app, err = read_app_info(name) + if err is not None: + self._json(404, err) + return + try: + payload = self._read_json() + items = validate_env(payload.get("vars", [])) + except ValueError as exc: + self._json(400, {"ok": False, "error": str(exc)}) + return + inject = payload.get("inject", True) is not False + try: + write_app_env(name, items) + update_manifest(name, {"APP_ENV_INJECT": "true" if inject else "false"}) + except (OSError, ValueError) as exc: + self._json(500, {"ok": False, "error": f"failed to save variables: {exc}"}) + return + result = {"ok": True, "name": name, "count": len(items), + "stdout": f"saved {len(items)} environment variable(s)"} + if payload.get("deploy"): + deployed = run_panelctl(["deploy", name]) + deployed["stdout"] = "\n".join(filter(None, [result["stdout"], deployed["stdout"]])) + deployed["count"] = len(items) + self._json(200 if deployed["ok"] else 400, deployed) + return + self._json(200, result) + return + + # 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: {exc}"}) + return + self._json(200, {"ok": True, "name": name, "saved": True}) + return + + # POST /apps//validate-compose + if action == "validate-compose": + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + return + 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 = {} + 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 + + # POST /apps//routes — hot update routes + if action == "routes": + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + return + try: + payload = self._read_json() + except Exception as exc: + self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) + return + route_list = payload.get("routes", []) + if not isinstance(route_list, list) or not route_list: + self._json(400, {"ok": False, "error": "routes must be a non-empty array"}) + return + routes_parts = [] + for r in route_list: + d = r.get("domain", "").strip() + u = r.get("upstream", "").strip() + p = r.get("path", "").strip() + if not d or not u: + self._json(400, {"ok": False, "error": "each route needs 'domain' and 'upstream'"}) + return + if p: + routes_parts.append(f"{d}|{u}|{p}") + else: + routes_parts.append(f"{d}|{u}") + routes_str = ",".join(routes_parts) + result = run_panelctl(["set-routes", name, routes_str]) + self._json(200 if result["ok"] else 400, result) + return + + # Simple panelctl pass-through actions + if action in {"deploy", "stop", "restart", "render-route", "volume-clear"}: + 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//repo-pull — sync the checkout to the remote branch and redeploy. + # The repository is the source of truth: fetch + hard reset, so local + # edits or force-pushes never leave the checkout stuck mid-merge. + if action == "repo-pull": + if not is_safe_name(name): + self._json(400, {"ok": False, "error": "invalid app name"}) + return + + app, err = read_app_info(name) + if err is not None or app is None: + self._json(404, {"ok": False, "error": "app not found"}) + return + repo_url = app.get("APP_REPO_URL", "").strip() + if not repo_url: + self._json(400, {"ok": False, "error": "app is not linked to a git repository"}) + return + + repo_dir = os.path.join(app["APP_STACK_DIR"], "repo") + branch = app.get("APP_REPO_BRANCH", "").strip() + git_log = [] + before = None + + if os.path.isdir(os.path.join(repo_dir, ".git")): + before = repo_commit(repo_dir) + ref = branch or repo_current_branch(repo_dir) + if not ref: + self._json(400, {"ok": False, "error": "cannot determine which branch to sync"}) + return + fetched = run_git(["fetch", "origin", ref], cwd=repo_dir) + if not fetched["ok"]: + self._json(400, { + "ok": False, + "error": f"git fetch failed: {git_error(fetched['stderr'])}", + "stderr": fetched["stderr"], + }) + return + reset = run_git(["reset", "--hard", "FETCH_HEAD"], cwd=repo_dir, timeout=60) + if not reset["ok"]: + self._json(400, { + "ok": False, + "error": f"git reset failed: {git_error(reset['stderr'])}", + "stderr": reset["stderr"], + }) + return + git_log.append(reset["stdout"]) + else: + # No checkout yet (e.g. deleted by hand): clone it fresh. + if os.path.exists(repo_dir): + shutil.rmtree(repo_dir) + cloned = clone_repo(repo_url, branch, repo_dir) + if not cloned["ok"]: + self._json(400, { + "ok": False, + "error": f"git clone failed: {git_error(cloned['stderr'])}", + "stderr": cloned["stderr"], + }) + return + git_log.append("cloned repository") + + after = repo_commit(repo_dir) + compose_path = find_compose_file(repo_dir) + if not compose_path: + self._json(400, {"ok": False, "error": "compose file not found in repository root"}) + return + try: + update_manifest(name, {"APP_COMPOSE_FILE": compose_path}) + except (OSError, ValueError) as exc: + self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"}) + return + + result = run_panelctl(["deploy", name]) + result["stdout"] = "\n".join(filter(None, git_log + [result["stdout"]])) + result["before"] = before + result["after"] = after + result["changed"] = not before or not after or before["sha"] != after["sha"] + 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"}) + + +def main(): + server = ThreadingHTTPServer((BIND, PORT), Handler) + server.daemon_threads = True + print(f"panel-api listening on http://{BIND}:{PORT}") + print(f"frontend dir: {FRONTEND_DIR}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/panel/panelctl.sh b/panel/panelctl.sh new file mode 100644 index 0000000..3a6c540 --- /dev/null +++ b/panel/panelctl.sh @@ -0,0 +1,913 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_DIR="${PANEL_BASE_DIR:-/var/lib/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" +ENV_DIR="${STATE_DIR}/env" +BACKUPS_DIR="${BASE_DIR}/backups" + +# Set by load_app: compose file arguments, and the app's environment variables +# as KEY=VALUE words (passed to compose via env(1), never sourced). +COMPOSE_ARGS=() +APP_ENV_ARGS=() +APP_ENV_KEYS=() + +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 + } +' + +validate_route_entry() { + local entry="$1" + # Format: domain|upstream[/path] or domain|upstream (path is optional) + IFS='|' read -r domain upstream path <<< "${entry}" + [[ -n "${domain}" ]] || fail "empty domain in route entry '${entry}'" + [[ -n "${upstream}" ]] || fail "empty upstream in route entry '${entry}'" + validate_single_domain "${domain}" + # Validate upstream has a port + local upstream_port="${upstream##*:}" + [[ "${upstream_port}" =~ ^[0-9]+$ ]] || fail "upstream '${upstream}' missing numeric port in route entry '${entry}'" + validate_port "${upstream_port}" + if [[ -n "${path}" ]]; then + [[ "${path}" == /* ]] || fail "path '${path}' must start with / in route entry '${entry}'" + fi +} + +validate_routes() { + local routes_str="$1" + IFS=',' read -ra entries <<< "${routes_str}" + [[ ${#entries[@]} -ge 1 ]] || fail "at least one route is required" + for entry in "${entries[@]}"; do + entry="$(echo "${entry}" | xargs)" + validate_route_entry "${entry}" + done +} + +usage() { + cat <<'EOF' +panelctl - minimal app panel helper + +Usage: + panelctl init "|[,...]" [auth] + panelctl set-routes "|[,...]" + 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 volume-clear + panelctl validate-compose + panelctl list + panelctl show + +Each route is a domain|upstream pair. Upstream is host:port. +Multiple routes are comma-separated: + panelctl init myapp "app.example.com|127.0.0.1:18080,api.example.com|127.0.0.1:18081" true + +Wildcard domains are supported (requires DNS challenge in Caddy): + panelctl init myapp "*.example.com|127.0.0.1:18080" true + +Examples: + panelctl init whoami "whoami.reudy.net|127.0.0.1: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 +} + +fail() { + echo "error: $*" >&2 + exit 1 +} + +log() { + local level="${1:-info}" + local msg="${2:-}" + echo "${msg}" | systemd-cat -t panelctl -p "${level}" 2>/dev/null || true +} + +ensure_base_dirs() { + mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}" "${BACKUPS_DIR}" +} + +validate_name() { + local name="$1" + [[ "${name}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || fail "invalid name '${name}' (use lowercase slug)" +} + +validate_single_domain() { + local domain="$1" + # 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() { + 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}" +} + +# All routes go into a single aggregate file that Caddy imports. +app_route_file() { + echo "${ROUTES_DIR}/routes.caddy" +} + +# The routes file is shared by all apps and rewritten read-modify-write, so +# concurrent panelctl runs (the API handles requests in parallel) must take turns. +routes_lock() { + exec 9>"${ROUTES_DIR}/.routes.lock" + if command -v flock >/dev/null 2>&1; then + flock -w 30 9 || fail "timed out waiting for the routes file lock" + fi +} + +routes_unlock() { + exec 9>&- +} + +load_app() { + local name="$1" + local manifest + manifest="$(app_manifest "${name}")" + [[ -f "${manifest}" ]] || fail "app '${name}' does not exist" + # shellcheck disable=SC1090 + source "${manifest}" + + # Backward compat: migrate old APP_DOMAIN/APP_PORT/APP_UPSTREAM to APP_ROUTES + if [[ -z "${APP_ROUTES:-}" && -n "${APP_DOMAIN:-}" ]]; then + local upstream="${APP_UPSTREAM:-127.0.0.1:${APP_PORT:-18080}}" + local routes="" + local domains_str="${APP_DOMAINS:-${APP_DOMAIN}}" + IFS=',' read -ra domain_arr <<< "${domains_str}" + for d in "${domain_arr[@]}"; do + d="$(echo "${d}" | xargs)" + if [[ -n "${routes}" ]]; then + routes="${routes},${d}|${upstream}" + else + routes="${d}|${upstream}" + fi + done + APP_ROUTES="${routes}" + fi + + load_app_env "${name}" +} + +app_env_file() { + echo "${ENV_DIR}/$1.env" +} + +# Generated compose override that passes the app's variables into every service. +app_env_override() { + echo "${STACKS_DIR}/$1/.panel-env.yaml" +} + +load_app_env() { + local name="$1" + local file line key override + file="$(app_env_file "${name}")" + APP_ENV_ARGS=() + APP_ENV_KEYS=() + if [[ -f "${file}" ]]; then + while IFS= read -r line || [[ -n "${line}" ]]; do + [[ -z "${line}" || "${line}" == \#* || "${line}" != *=* ]] && continue + key="${line%%=*}" + [[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue + APP_ENV_ARGS+=("${line}") + APP_ENV_KEYS+=("${key}") + done <"${file}" + fi + + COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}") + override="$(app_env_override "${name}")" + if [[ -f "${override}" ]]; then + COMPOSE_ARGS+=(-f "${override}") + fi +} + +# (Re)generate the env override before containers are created. Variables are +# always available for ${VAR} interpolation; with APP_ENV_INJECT (default true) +# every service also receives them. Bare keys make compose read the values from +# its own environment, so values never have to be quoted into YAML. +prepare_env_override() { + local name="$1" + local override services svc key tmp + override="$(app_env_override "${name}")" + + if [[ ${#APP_ENV_KEYS[@]} -eq 0 || "${APP_ENV_INJECT:-true}" != "true" ]]; then + rm -f "${override}" + COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}") + return + fi + + services="$(run_compose -f "${APP_COMPOSE_FILE}" config --services 2>/dev/null)" \ + || fail "could not list compose services to pass environment variables (is the compose file valid?)" + + tmp="$(mktemp)" + { + echo "# Generated by panelctl from the app's environment variables. Do not edit." + echo "services:" + while IFS= read -r svc; do + [[ "${svc}" =~ ^[A-Za-z0-9._-]+$ ]] || continue + printf ' "%s":\n environment:\n' "${svc}" + for key in "${APP_ENV_KEYS[@]}"; do + printf ' - %s\n' "${key}" + done + done <<<"${services}" + } >"${tmp}" + install -m 0640 "${tmp}" "${override}" + rm -f "${tmp}" + COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}" -f "${override}") +} + +compose_command() { + local podman_bin="" + local podman_compose_bin="" + + if command -v podman >/dev/null 2>&1; then + podman_bin="$(command -v podman)" + elif [[ -x /run/current-system/sw/bin/podman ]]; then + podman_bin="/run/current-system/sw/bin/podman" + fi + + if command -v podman-compose >/dev/null 2>&1; then + podman_compose_bin="$(command -v podman-compose)" + elif [[ -x /run/current-system/sw/bin/podman-compose ]]; then + podman_compose_bin="/run/current-system/sw/bin/podman-compose" + fi + + if [[ -n "${podman_bin}" ]] && "${podman_bin}" compose version >/dev/null 2>&1; then + echo "${podman_bin} compose" + return + fi + + if [[ -n "${podman_compose_bin}" ]]; then + echo "${podman_compose_bin}" + return + fi + + fail "no compose command available (need 'podman compose' or 'podman-compose')" +} + +ensure_podman_runtime_env() { + local uid + uid="$(id -u)" + + if [[ -z "${HOME:-}" ]]; then + HOME="$(getent passwd "${uid}" | cut -d: -f6 || true)" + export HOME + fi + + if [[ -z "${XDG_RUNTIME_DIR:-}" ]]; then + XDG_RUNTIME_DIR="/run/user/${uid}" + export XDG_RUNTIME_DIR + fi + + if [[ ! -d "${XDG_RUNTIME_DIR}" ]]; then + fail "XDG_RUNTIME_DIR '${XDG_RUNTIME_DIR}' does not exist for uid ${uid}. Ensure user runtime is available (e.g. loginctl enable-linger $(id -un))." + fi + + if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" && -S "${XDG_RUNTIME_DIR}/bus" ]]; then + DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus" + export DBUS_SESSION_BUS_ADDRESS + fi + + unset DOCKER_HOST + unset CONTAINER_HOST +} + +run_compose() { + local compose + compose="$(compose_command)" + + ensure_podman_runtime_env + + if [[ "${compose}" == *" compose" ]]; then + local podman_bin="${compose% compose}" + env "${APP_ENV_ARGS[@]}" "${podman_bin}" compose "$@" + return + fi + + env "${APP_ENV_ARGS[@]}" "${compose}" "$@" +} + +write_default_compose() { + local name="$1" + local routes="$2" + local stack_dir + local volume_dir + stack_dir="$(app_stack_dir "${name}")" + volume_dir="$(app_volume_dir "${name}")" + + # Use first route's upstream port for the default compose mapping + local first_route="${routes%%,*}" + local first_upstream="${first_route#*|}" + local container_port="${first_upstream##*:}" + + cat >"${stack_dir}/compose.yaml" <"${manifest}" <"${tmp}" || true + else + printf "" >"${tmp}" + fi + + { + printf "# route:%s:start\n" "${name}" + IFS=',' read -ra route_entries <<< "${APP_ROUTES}" + for entry in "${route_entries[@]}"; do + entry="$(echo "${entry}" | xargs)" + IFS='|' read -r domain upstream path <<< "${entry}" + # If upstream is empty (no second pipe), this is the old format + if [[ -z "${upstream}" ]]; then + upstream="${path}" + path="" + fi + if [[ -n "${path}" ]]; then + if [[ -n "${auth_block}" ]]; then + printf "%s {\n%s reverse_proxy %s %s\n}\n" "${domain}" "${auth_block}" "${path}" "${upstream}" + else + printf "%s {\n reverse_proxy %s %s\n}\n" "${domain}" "${path}" "${upstream}" + fi + else + if [[ -n "${auth_block}" ]]; then + printf "%s {\n%s reverse_proxy %s\n}\n" "${domain}" "${auth_block}" "${upstream}" + else + printf "%s {\n reverse_proxy %s\n}\n" "${domain}" "${upstream}" + fi + fi + done + printf "# route:%s:end\n" "${name}" + } >>"${tmp}" + + install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}" + rm -f "${tmp}" + routes_unlock + log info "rendered route ${route_file}" +} + +cmd_deploy() { + local name="$1" + validate_name "${name}" + load_app "${name}" + + log info "Starting deployment for app '${name}'" + + cmd_render_route "${name}" + prepare_env_override "${name}" + + # Capture compose output so callers (the web UI) can show why a deploy failed, + # and still forward it to the journal. + local output + if ! output="$(run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1)"; then + printf '%s\n' "${output}" | systemd-cat -t panelctl -p err 2>/dev/null || true + printf '%s\n' "${output}" >&2 + log err "Deployment failed for app '${name}'" + fail "compose up failed" + fi + printf '%s\n' "${output}" | systemd-cat -t panelctl -p info 2>/dev/null || true + printf '%s\n' "${output}" + + log info "Successfully deployed app '${name}'" +} + +cmd_restart() { + local name="$1" + validate_name "${name}" + load_app "${name}" + + log info "Restarting app '${name}'" + + run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed" + prepare_env_override "${name}" + + if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1; then + fail "compose up failed during restart" + fi + + log info "restarted app '${name}'" +} + +cmd_stop() { + local name="$1" + validate_name "${name}" + load_app "${name}" + + run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed" + log info "stopped app '${name}'" +} + +cmd_status() { + local name="$1" + validate_name "${name}" + load_app "${name}" + + run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null || \ + run_compose "${COMPOSE_ARGS[@]}" ps 2>/dev/null || \ + log info "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 "${COMPOSE_ARGS[@]}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available" +} + +cmd_validate_compose() { + local name="$1" + validate_name "${name}" + load_app "${name}" + + if run_compose "${COMPOSE_ARGS[@]}" config >/dev/null 2>&1; then + log info "compose file is valid" + else + local output + output="$(run_compose "${COMPOSE_ARGS[@]}" config 2>&1 || true)" + fail "compose validation failed: ${output}" + fi +} + +cmd_remove() { + local name="$1" + local keep_volumes="${2:-}" + validate_name "${name}" + load_app "${name}" + + run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true + + # Remove this app's block from the aggregate routes file. + local route_file + route_file="$(app_route_file)" + if [[ -f "${route_file}" ]]; then + routes_lock + local tmp + tmp="$(mktemp)" + sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true + install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}" + rm -f "${tmp}" + routes_unlock + fi + + rm -f "$(app_manifest "${name}")" "$(app_env_file "${name}")" + rm -rf "${APP_STACK_DIR}" + + if [[ "${keep_volumes}" != "--keep-volumes" ]]; then + rm -rf "${APP_VOLUME_DIR}" + fi + + log info "removed app '${name}'" +} + +cmd_set_routes() { + local name="$1" + local routes="$2" + local manifest + + validate_name "${name}" + validate_routes "${routes}" + manifest="$(app_manifest "${name}")" + [[ -f "${manifest}" ]] || fail "app '${name}' does not exist" + + # Update APP_ROUTES in the manifest file, strip old fields, preserve others + local tmp + tmp="$(mktemp)" + local found_routes=false + while IFS= read -r line; do + case "${line}" in + APP_ROUTES=*) + printf 'APP_ROUTES="%s"\n' "${routes}" >> "${tmp}" + found_routes=true + ;; + APP_DOMAIN=*|APP_DOMAINS=*|APP_PORT=*|APP_UPSTREAM=*) + # Strip old format fields + ;; + *) + printf '%s\n' "${line}" >> "${tmp}" + ;; + esac + done < "${manifest}" + if ! "${found_routes}"; then + printf 'APP_ROUTES="%s"\n' "${routes}" >> "${tmp}" + fi + install -m 0664 "${tmp}" "${manifest}" + + # Re-render Caddy routes (auto-reloads via systemd.path watcher) + cmd_render_route "${name}" + + log info "updated routes for app '${name}'. Edit compose file if new ports need exposing." +} + +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 "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then + was_running=true + log info "stopping containers for consistent backup..." + run_compose "${COMPOSE_ARGS[@]}" 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 + log info "restarting containers after backup..." + run_compose "${COMPOSE_ARGS[@]}" up -d 2>/dev/null || true + fi + + local size + size="$(du -h "${backup_file}" | cut -f1)" + log info "backup created: ${backup_file} (${size})" +} + +cmd_list_backups() { + local name="$1" + validate_name "${name}" + load_app "${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 + log info "no backups found for '${name}'" + fi +} + +cmd_volume_clear() { + local name="$1" + validate_name "${name}" + load_app "${name}" + + log info "clearing volume data for app '${name}'" + run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true + + local data_dir="${APP_VOLUME_DIR}/data" + if [[ -d "${data_dir}" ]]; then + rm -rf "${data_dir:?}"/* + rm -rf "${data_dir:?}"/.[!.]* 2>/dev/null || true + fi + mkdir -p "${APP_VOLUME_DIR}/data" + + log info "volume data cleared for app '${name}'" +} + +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 + log info "stopping containers for restore..." + run_compose "${COMPOSE_ARGS[@]}" 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" + + log info "restored '${name}' from $(basename "${norm_path}")" + log info "run 'panelctl deploy ${name}' to start the app'" +} + +cmd_inspect_volumes() { + local name="$1" + validate_name "${name}" + load_app "${name}" + + echo "default|${APP_VOLUME_DIR}/data" + + local podman_bin="" + if command -v podman >/dev/null 2>&1; then + podman_bin="$(command -v podman)" + elif [[ -x /run/current-system/sw/bin/podman ]]; then + podman_bin="/run/current-system/sw/bin/podman" + fi + + if [[ -n "${podman_bin}" ]]; then + "${podman_bin}" volume ls --filter label=com.docker.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true + "${podman_bin}" volume ls --filter label=io.podman.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true + # grep exits 1 when there are no named volumes; that is not an error. + fi | sort -u | grep -v '^$' || true +} + +cmd_list() { + ensure_base_dirs + local found=0 + for mf in "${APPS_DIR}"/*.env; do + [[ -e "${mf}" ]] || continue + found=1 + # shellcheck disable=SC1090 + source /dev/null # reset any leftover variables + unset APP_REPO_URL APP_REPO_BRANCH APP_REPO_DIR 2>/dev/null || true + source "${mf}" + # Backward compat: build APP_ROUTES from old format + local routes="${APP_ROUTES:-}" + if [[ -z "${routes}" && -n "${APP_DOMAIN:-}" ]]; then + local upstream="${APP_UPSTREAM:-127.0.0.1:${APP_PORT:-18080}}" + local domains_str="${APP_DOMAINS:-${APP_DOMAIN}}" + IFS=',' read -ra domain_arr <<< "${domains_str}" + for d in "${domain_arr[@]}"; do + d="$(echo "${d}" | xargs)" + if [[ -n "${routes}" ]]; then + routes="${routes},${d}|${upstream}" + else + routes="${d}|${upstream}" + fi + done + fi + # Show abbreviated: first route's domain + upstream, and count + local first_route="${routes%%,*}" + local route_count=1 + if [[ "${routes}" == *","* ]]; then + route_count="$(( $(grep -o ',' <<< "${routes}" | wc -l) + 1 ))" + fi + local repo_info="${APP_REPO_URL:-}" + echo "${APP_NAME} ${first_route} routes=${route_count} auth=${APP_AUTH_PROTECTED} ${repo_info}" + done + + if [[ "${found}" -eq 0 ]]; then + log info "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 3 ]] || fail "usage: panelctl init [auth]" + cmd_init "$2" "$3" "${4:-true}" + ;; + set-routes) + [[ $# -eq 3 ]] || fail "usage: panelctl set-routes " + cmd_set_routes "$2" "$3" + ;; + render-route) + [[ $# -eq 2 ]] || fail "usage: panelctl render-route " + cmd_render_route "$2" + ;; + deploy) + [[ $# -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" + ;; + volume-clear) + [[ $# -eq 2 ]] || fail "usage: panelctl volume-clear " + cmd_volume_clear "$2" + ;; + inspect-volumes) + [[ $# -eq 2 ]] || fail "usage: panelctl inspect-volumes " + cmd_inspect_volumes "$2" + ;; + 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 "$@"