diff --git a/caddy.nix b/caddy.nix index 24486f2..34f0b09 100755 --- a/caddy.nix +++ b/caddy.nix @@ -4,10 +4,7 @@ enable = true; email = "admin@reudy.net"; - # Generated app routes from the panel backend. - extraConfig = '' - import /var/lib/containers/routes/routes.caddy - ''; + # App routes generated by the panel are imported by its module (panel.nix). # Authelia's own login portal virtualHosts."auth.reudy.net".extraConfig = '' diff --git a/flake.lock b/flake.lock index 9bb902e..232fe32 100644 --- a/flake.lock +++ b/flake.lock @@ -149,10 +149,32 @@ "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 89ee6d6..35a2fc6 100644 --- a/flake.nix +++ b/flake.nix @@ -11,6 +11,12 @@ 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 = @@ -18,6 +24,7 @@ nixpkgs, vpsadminos, agenix, + panel, ... }: let @@ -31,6 +38,7 @@ { 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 1fa6da2..ddac7ec 100644 --- a/panel.nix +++ b/panel.nix @@ -1,89 +1,14 @@ -{ config, lib, pkgs, ... }: +{ ... }: -let - forgejoServer = config.services.forgejo.settings.server; -in +# 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 { - 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.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. }; - - 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 deleted file mode 100644 index 62912a6..0000000 --- a/panel/API.md +++ /dev/null @@ -1,287 +0,0 @@ -# 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 deleted file mode 100644 index eb98901..0000000 --- a/panel/README.md +++ /dev/null @@ -1,167 +0,0 @@ -# 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 deleted file mode 100644 index 298ffb1..0000000 --- a/panel/frontend/index.html +++ /dev/null @@ -1,2877 +0,0 @@ - - - - - -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 deleted file mode 100644 index 5e2edd6..0000000 --- a/panel/panel-api.py +++ /dev/null @@ -1,1591 +0,0 @@ -#!/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 deleted file mode 100644 index 3a6c540..0000000 --- a/panel/panelctl.sh +++ /dev/null @@ -1,913 +0,0 @@ -#!/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 "$@"