diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..750baeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +result +result-* diff --git a/API.md b/API.md index 16f9ddb..62912a6 100644 --- a/API.md +++ b/API.md @@ -12,6 +12,11 @@ Default bind: `127.0.0.1:9911` |--------|------|-------------| | 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 @@ -19,17 +24,27 @@ Default bind: `127.0.0.1:9911` |--------|------|-------------| | 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) | @@ -39,55 +54,151 @@ Default bind: `127.0.0.1:9911` | 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 domain) +### Create app (single route) ```json { "name": "whoami", - "domain": "whoami.srazka.com", - "port": 18080, + "routes": [ + {"domain": "whoami.reudy.net", "upstream": "127.0.0.1:18080"} + ], "auth": true } ``` -### Create app (multiple domains) +### Create app (multiple routes, different ports) ```json { "name": "myapp", - "domain": "app.srazka.com,www.app.srazka.com", - "port": 18081, + "routes": [ + {"domain": "app.reudy.net", "upstream": "127.0.0.1:18080"}, + {"domain": "api.app.reudy.net", "upstream": "127.0.0.1:18081"} + ], "auth": true } ``` -Or using the `domains` array format: +### Create app (multiple routes, different ports, with paths) ```json { - "name": "myapp", - "domains": ["app.srazka.com", "www.app.srazka.com"], - "port": 18081, + "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", - "domain": "*.srazka.com", - "port": 18082, + "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 @@ -112,6 +223,21 @@ Note: Wildcard domains require DNS challenge configuration in Caddy. } ``` +## 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: diff --git a/README.md b/README.md index 02ede16..e65d818 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,51 @@ Minimal container management panel for rootless Podman + Caddy. +## Installing on NixOS + +This repository is a flake that provides the panel as a package +(`packages..default`) and a NixOS module (`nixosModules.default`). +Add it to your system flake: + +```nix +{ + inputs.panel = { + url = "git+https://git.reudy.net/reudy-net/panel"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = { nixpkgs, panel, ... }: { + nixosConfigurations.vps = nixpkgs.lib.nixosSystem { + modules = [ + panel.nixosModules.default + { + services.reudy-panel = { + enable = true; + domain = "panel.example.com"; # Caddy virtual host + autheliaAddress = "127.0.0.1:9091"; # forward_auth in front of it + }; + } + ]; + }; + }; +} +``` + +The module sets up the `panel-api` service, the directories below, the +`panelroutes` group shared with Caddy, the Caddy import of the generated routes +and a path unit that reloads Caddy when they change. With Forgejo enabled on +the same host, its URLs are passed to the panel automatically +(`services.reudy-panel.forgejo.enable`). See `nix/module.nix` for all options. + +To deploy a new panel version, bump the input and rebuild: + +```bash +nix flake update panel +sudo nixos-rebuild switch --flake .#vps +``` + +Checks (package build and a module evaluation) run with `nix flake check`. + ## Base directory `/var/lib/containers` @@ -22,14 +67,19 @@ All app routes are written to a single `routes/routes.caddy` file that Caddy imp ## Quick workflow ```bash -# Create a new app (single domain) -panelctl init whoami whoami.srazka.com 18080 true +# Routes are "domain|upstream[|path]" entries, comma-separated. -# Create with multiple domains -panelctl init myapp "app.srazka.com,www.srazka.com" 18081 true +# 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 "*.srazka.com" 18082 false +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 @@ -85,15 +135,78 @@ panelctl remove whoami ## Web UI & API - Nix runs `panel-api` as a systemd service on `127.0.0.1:9911`. -- Caddy proxies `https://panel.srazka.com` → panel-api with Authelia forward_auth. -- Open `https://panel.srazka.com` for the web UI. +- 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 -- Create apps with multiple domains and wildcard support -- Live container status indicators (auto-refreshes) -- Deploy, restart, stop, remove from the UI -- Inline compose editor with save, validate, and save+deploy -- Log viewer with configurable tail length -- Volume backup management: create, list, download, restore +- 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/flake.lock b/flake.lock new file mode 100644 index 0000000..227b212 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1782847189, + "narHash": "sha256-twXPFqFsrrY5r28Zh7Homgcp2gUMBgQ6WDS98Q/3xFI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b6018f87da91d19d0ab4cf979885689b469cdd41", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..172f289 --- /dev/null +++ b/flake.nix @@ -0,0 +1,58 @@ +{ + description = "Small web panel for deploying Podman compose apps behind Caddy"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + + outputs = + { self, nixpkgs }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + in + { + packages = forAllSystems (pkgs: rec { + reudy-panel = pkgs.callPackage ./nix/package.nix { }; + default = reudy-panel; + }); + + overlays.default = final: _prev: { + reudy-panel = final.callPackage ./nix/package.nix { }; + }; + + nixosModules = rec { + reudy-panel = ./nix/module.nix; + default = reudy-panel; + }; + + checks = forAllSystems (pkgs: { + package = self.packages.${pkgs.stdenv.hostPlatform.system}.default; + + # Evaluates a small host using the module, so option or wiring mistakes + # fail here rather than on the server. + module = + (nixpkgs.lib.nixosSystem { + system = pkgs.stdenv.hostPlatform.system; + modules = [ + self.nixosModules.default + { + boot.isContainer = true; + system.stateVersion = "25.11"; + users.users.reudy.isNormalUser = true; + services.caddy.enable = true; + services.forgejo.enable = true; + services.reudy-panel = { + enable = true; + domain = "panel.example.com"; + autheliaAddress = "127.0.0.1:9091"; + }; + } + ]; + }).config.system.build.toplevel; + }); + + formatter = forAllSystems (pkgs: pkgs.nixfmt-rfc-style); + }; +} diff --git a/frontend/index.html b/frontend/index.html index af3490b..298ffb1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,1081 +1,2877 @@ - - - Panel - - - - + .spinner { + display: inline-block; width: 13px; height: 13px; flex-shrink: 0; + border: 2px solid color-mix(in srgb, currentColor 25%, transparent); + border-top-color: currentColor; border-radius: 50%; + animation: spin .7s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } + + /* ── Menu ── */ + .menu { + position: fixed; z-index: 50; min-width: 190px; + background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px; + box-shadow: var(--shadow-lg); padding: 5px; + } + .menu-item { + display: flex; align-items: center; gap: 9px; width: 100%; + background: none; border: 0; border-radius: 6px; padding: 7px 10px; + color: var(--text); font: 13.5px var(--font); cursor: pointer; text-align: left; + } + .menu-item:hover:not(:disabled) { background: var(--surface-2); } + .menu-item:disabled { opacity: .45; cursor: default; } + .menu-item.danger { color: var(--danger); } + .menu-sep { height: 1px; background: var(--border); margin: 4px 2px; } + + /* ── Toasts ── */ + .toasts { position: fixed; right: 16px; bottom: 16px; z-index: 60; display: flex; flex-direction: column; gap: 8px; width: min(380px, calc(100vw - 32px)); } + .toast { + display: flex; gap: 10px; align-items: flex-start; + background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px; + box-shadow: var(--shadow-lg); padding: 11px 12px; + animation: toast-in .18s ease-out; + } + .toast.leaving { opacity: 0; transform: translateY(6px); transition: .18s; } + @keyframes toast-in { from { opacity: 0; transform: translateY(8px); } } + .toast > .icon { margin-top: 2px; } + .toast-success > .icon { color: var(--ok); } + .toast-error > .icon { color: var(--danger); } + .toast-info > .icon { color: var(--accent); } + .toast-body { flex: 1; min-width: 0; } + .toast-title { font-weight: 600; font-size: 13.5px; } + .toast-detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; word-break: break-word; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } + .toast .link { font-size: 12.5px; margin-top: 4px; } + .toast-close { background: none; border: 0; color: var(--muted); cursor: pointer; padding: 2px; border-radius: 4px; } + .toast-close:hover { color: var(--text); background: var(--surface-2); } + + /* ── Activity drawer ── */ + .drawer { + position: fixed; top: 0; right: 0; bottom: 0; z-index: 40; + width: min(460px, 100vw); + background: var(--surface); border-left: 1px solid var(--border-strong); + box-shadow: var(--shadow-lg); + display: flex; flex-direction: column; + animation: slide-in .18s ease-out; + } + @keyframes slide-in { from { transform: translateX(24px); opacity: 0; } } + .drawer-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border); } + .drawer-head h2 { font-size: 15px; flex: 1; } + .drawer-body { flex: 1; overflow: auto; padding: 8px; } + .act { border-radius: 8px; padding: 2px 0; } + .act + .act { border-top: 1px solid var(--border); } + .act summary { list-style: none; cursor: pointer; border-radius: 6px; } + .act summary::-webkit-details-marker { display: none; } + .act summary:hover { background: var(--surface-2); } + .act-head { display: flex; align-items: center; gap: 8px; padding: 8px; font-size: 13px; } + .act-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .act-ok { color: var(--ok); } + .act-error { color: var(--danger); } + .act pre { margin: 0 8px 8px; } + + /* ── Dialogs ── */ + dialog { + border: 1px solid var(--border-strong); border-radius: 14px; padding: 0; + background: var(--surface); color: var(--text); + box-shadow: var(--shadow-lg); + width: min(640px, calc(100vw - 24px)); max-height: calc(100vh - 32px); + } + dialog.sm { width: min(440px, calc(100vw - 24px)); } + dialog::backdrop { background: rgba(10, 12, 16, .45); backdrop-filter: blur(2px); } + dialog form { display: flex; flex-direction: column; max-height: calc(100vh - 34px); } + .dialog-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 6px; } + .dialog-head h2, dialog.sm h2 { font-size: 17px; } + dialog.sm form { padding: 18px; } + dialog.sm .dialog-body { padding: 10px 0 4px; } + .dialog-body { padding: 10px 18px; overflow: auto; } + .dialog-body p + p { margin-top: 8px; } + .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px 16px; border-top: 1px solid var(--border); } + dialog.sm .dialog-actions { padding: 14px 0 0; border: 0; } + .segmented { display: inline-flex; border: 1px solid var(--border-strong); border-radius: 9px; padding: 3px; gap: 2px; background: var(--surface-2); flex-wrap: wrap; } + .segmented button { + border: 0; background: none; padding: 6px 12px; border-radius: 6px; + font: 500 13px var(--font); color: var(--muted); cursor: pointer; + } + .segmented button.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow); } + + /* ── Repo picker ── */ + .picker-list { + margin-top: 6px; max-height: 232px; overflow: auto; + border: 1px solid var(--border); border-radius: 8px; + } + .picker-item { + display: flex; align-items: center; gap: 10px; width: 100%; + background: none; border: 0; border-top: 1px solid var(--border); + padding: 8px 10px; text-align: left; cursor: pointer; color: var(--text); font: inherit; + } + .picker-item:first-child { border-top: 0; } + .picker-item:hover, .picker-item:focus-visible { background: var(--surface-2); outline: none; } + .picker-item.selected { background: var(--accent-soft); } + .picker-item .icon { color: var(--muted); } + .picker-name { font-weight: 600; font-size: 13.5px; } + .picker-desc { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .picker-main { flex: 1; min-width: 0; } + + /* ── Settings ── */ + .settings-section + .settings-section { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); } + .settings-section h3 { font-size: 14px; margin-bottom: 4px; } + .keybox { + margin-top: 10px; display: flex; gap: 8px; align-items: flex-start; + background: var(--surface-2); border-radius: 8px; padding: 10px 12px; + font: 12px/1.5 var(--mono); word-break: break-all; + } + .keybox code { flex: 1; background: none; padding: 0; } + + /* ── Environment editor ── */ + .env-row { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) 28px; gap: 6px; align-items: center; margin-bottom: 6px; } + .env-row .input { height: 32px; padding: 5px 9px; font: 13px var(--mono); } + .env-head { font-size: 11.5px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; } + .env-details summary { cursor: pointer; list-style: none; } + .env-details summary::-webkit-details-marker { display: none; } + .env-details summary::before { content: "▸"; display: inline-block; width: 14px; color: var(--muted); transition: transform .15s; } + .env-details[open] summary::before { transform: rotate(90deg); } + .missing-vars { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } + .chip.sm { height: 24px; padding: 0 9px; font: 12px var(--mono); } + + /* ── Responsive ── */ + @media (max-width: 760px) { + .hide-sm { display: none !important; } + .topbar-inner { flex-wrap: wrap; } + .search { order: 3; max-width: none; margin-left: 0; flex-basis: 100%; } + .search kbd, .shortcuts { display: none; } + .grid-2 { grid-template-columns: 1fr; } + /* Card header: name + actions on top, status pill underneath. */ + .app-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 6px 10px; padding: 12px; } + .app-head > .chev { grid-row: 1 / span 2; align-self: start; margin-top: 3px; } + .app-title { grid-column: 2; grid-row: 1; } + .app-actions { grid-column: 3; grid-row: 1; align-self: start; } + .app-head > .pill { grid-column: 2; grid-row: 2; justify-self: start; min-width: 0; } + .route-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 28px; } + .route-row .arrow { display: none; } + .route-row .route-path { grid-column: 1 / 3; } + .route-head { display: none; } + .env-row { grid-template-columns: minmax(0, 1fr) 28px; } + .env-row > :nth-child(2) { grid-column: 1; } + .env-row > :nth-child(3) { grid-column: 2; grid-row: 1; } + .env-head { display: none; } + .file-row { grid-template-columns: 20px minmax(0, 1fr) auto; } + .file-row > :nth-child(3), .file-row > :nth-child(4) { display: none; } + .logs { height: 320px; } + } + + +
+
+
Panel
+ +
+ + + + +
+
+
+ + +
- -
-
-

Containers Panel

-

Rootless Podman + Caddy routes from one place.

-
-
- -
-
+
+
+
/ search · N new app
+
+
+
+
+
- -
Ready.
+ - -
- -
-
-

Create App

- - +
+ - -
-
- - + +
+
+

New app

+ +
+
+
+ + +

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

+
+ +
+ Source +
+ + +
-

- Supports wildcards: *.example.com -

+

+
-
-
- - -
-
- - + + +
- +
+ + +
+ + + + +
+
+

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/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..47d976b --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,178 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.services.reudy-panel; + forgejoServer = config.services.forgejo.settings.server; + routesFile = "${cfg.baseDir}/routes/routes.caddy"; +in +{ + options.services.reudy-panel = { + enable = lib.mkEnableOption "the panel for deploying Podman compose apps behind Caddy"; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.callPackage ./package.nix { }; + defaultText = lib.literalExpression "pkgs.callPackage ./package.nix { }"; + description = "The panel package to use."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "reudy"; + description = '' + Existing user the panel runs as. Apps are deployed as rootless Podman + containers of this user, so it should have lingering enabled. + ''; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "panelroutes"; + description = "Group shared by the panel and Caddy for the generated routes file."; + }; + + baseDir = lib.mkOption { + type = lib.types.path; + default = "/var/lib/containers"; + description = "Where stacks, volumes, routes, state and backups are kept."; + }; + + listenAddress = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "Address the panel API listens on."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 9911; + description = "Port the panel API listens on."; + }; + + domain = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "panel.example.com"; + description = "Serve the panel on this domain through Caddy. Null disables the virtual host."; + }; + + autheliaAddress = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "127.0.0.1:9091"; + description = '' + Authelia instance that protects the panel's virtual host with + forward_auth. The panel has no login of its own, so leave this null + only if something else guards it. + ''; + }; + + forgejo.enable = lib.mkOption { + type = lib.types.bool; + default = config.services.forgejo.enable; + defaultText = lib.literalExpression "config.services.forgejo.enable"; + description = '' + Point the panel at the local Forgejo instance (repo picker, private + clones, commit links). URLs are taken from services.forgejo.settings. + ''; + }; + + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = "Extra environment variables for the panel API service."; + }; + }; + + config = lib.mkIf cfg.enable { + environment.systemPackages = [ cfg.package ]; + + users.groups.${cfg.group} = { }; + users.users.${cfg.user}.extraGroups = [ cfg.group ]; + users.users.caddy.extraGroups = lib.mkIf config.services.caddy.enable [ cfg.group ]; + + systemd.tmpfiles.rules = + map (dir: "d ${cfg.baseDir}${dir} 0750 ${cfg.user} ${cfg.group} -") [ + "" + "/stacks" + "/volumes" + "/routes" + "/state" + "/state/apps" + "/backups" + ] + ++ [ "f ${routesFile} 0640 ${cfg.user} ${cfg.group} -" ]; + + systemd.services.panel-api = { + description = "Panel API"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + Restart = "always"; + RestartSec = 3; + WorkingDirectory = cfg.baseDir; + ExecStart = lib.getExe cfg.package; + }; + + environment = { + PANEL_API_BIND = cfg.listenAddress; + PANEL_API_PORT = toString cfg.port; + PANEL_BASE_DIR = toString cfg.baseDir; + PANEL_USER = cfg.user; + PANEL_GROUP = cfg.group; + PANELCTL_PATH = lib.getExe' cfg.package "panelctl"; + } + // lib.optionalAttrs cfg.forgejo.enable { + # 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 or config.services.forgejo.user + }@${forgejoServer.DOMAIN}:${toString forgejoServer.SSH_PORT}"; + } + // cfg.environment; + }; + + services.caddy = lib.mkIf config.services.caddy.enable { + # App routes generated by panelctl. + extraConfig = '' + import ${routesFile} + ''; + + virtualHosts = lib.mkIf (cfg.domain != null) { + ${cfg.domain}.extraConfig = + lib.optionalString (cfg.autheliaAddress != null) '' + forward_auth ${cfg.autheliaAddress} { + uri /api/authz/forward-auth + copy_headers Remote-User Remote-Groups Remote-Email Remote-Name + } + '' + + '' + reverse_proxy ${cfg.listenAddress}:${toString cfg.port} + ''; + }; + }; + + # Reload Caddy whenever panelctl rewrites the routes file. + systemd.paths.caddy-routes-reload = lib.mkIf config.services.caddy.enable { + wantedBy = [ "multi-user.target" ]; + pathConfig.PathChanged = routesFile; + }; + + systemd.services.caddy-routes-reload = lib.mkIf config.services.caddy.enable { + serviceConfig = { + Type = "oneshot"; + ExecStart = "${config.systemd.package}/bin/systemctl reload caddy.service"; + }; + }; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..4557c49 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,85 @@ +{ + lib, + stdenvNoCC, + makeWrapper, + bash, + python3, + podman, + podman-compose, + curl, + coreutils, + gnugrep, + gnused, + gawk, + findutils, + zip, + unzip, + git, + util-linux, + openssh, +}: + +let + # Tools panelctl shells out to. They are appended to PATH, so the host's own + # versions (e.g. the system podman) still take precedence when present. + runtimeDeps = [ + podman + podman-compose + curl + coreutils + gnugrep + gnused + gawk + findutils + zip + unzip + git + util-linux # flock, used to serialise routes file writes + openssh # cloning repositories over ssh with the panel's deploy key + ]; +in +stdenvNoCC.mkDerivation { + pname = "reudy-panel"; + version = "0.1.0"; + + src = lib.fileset.toSource { + root = ../.; + fileset = lib.fileset.unions [ + ../panel-api.py + ../panelctl.sh + ../frontend + ]; + }; + + nativeBuildInputs = [ makeWrapper ]; + buildInputs = [ bash ]; + + dontConfigure = true; + dontBuild = true; + + installPhase = '' + runHook preInstall + + install -Dm644 panel-api.py $out/share/panel/panel-api.py + cp -r frontend $out/share/panel/frontend + + install -Dm755 panelctl.sh $out/bin/panelctl + patchShebangs --host $out/bin/panelctl + wrapProgram $out/bin/panelctl \ + --suffix PATH : ${lib.makeBinPath runtimeDeps} + + makeWrapper ${python3.interpreter} $out/bin/panel-api \ + --add-flags $out/share/panel/panel-api.py \ + --suffix PATH : ${lib.makeBinPath runtimeDeps} \ + --set-default PANELCTL_PATH $out/bin/panelctl \ + --set-default PANEL_FRONTEND_DIR $out/share/panel/frontend + + runHook postInstall + ''; + + meta = { + description = "Small web panel for deploying Podman compose apps behind Caddy"; + mainProgram = "panel-api"; + platforms = lib.platforms.linux; + }; +} diff --git a/panel-api.py b/panel-api.py index e320f88..d6b029d 100644 --- a/panel-api.py +++ b/panel-api.py @@ -4,12 +4,18 @@ import json import os import re +import shlex import shutil +import socket import subprocess -from http.server import BaseHTTPRequestHandler, HTTPServer -from urllib.parse import urlparse, parse_qs -import urllib.request +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") @@ -20,11 +26,415 @@ FRONTEND_DIR = os.environ.get( os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend"), ) +# Optional Forgejo instance (set by the NixOS module). 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], @@ -54,11 +464,12 @@ def parse_env_blob(blob): def get_app_volumes(name): result = run_panelctl(["inspect-volumes", name]) volumes = {} - if result["ok"]: - for line in result["stdout"].splitlines(): - if "|" in line: - vname, vpath = line.split("|", 1) - volumes[vname.strip()] = vpath.strip() + # 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): @@ -83,36 +494,92 @@ def read_app_info(name): 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): - """Try to determine if any container is running from panelctl status output.""" - text = stdout.lower() - if not text or "no containers" in text: - return {"running": False, "raw": stdout} - # podman compose ps --format json returns JSON array - try: - containers = json.loads(stdout) - if isinstance(containers, list): - running = any( - c.get("State", "").lower() == "running" - or c.get("status", "").lower().startswith("up") - for c in containers - ) - return { - "running": running, - "containers": [ - { - "name": c.get("Name", c.get("name", "?")), - "state": c.get("State", c.get("status", "unknown")), - "image": c.get("Image", c.get("image", "")), - } - for c in containers - ], - } - except (json.JSONDecodeError, TypeError): - pass - # Fallback: check for "Up" or "running" in text - running = "up" in text or "running" in text - return {"running": running, "raw": stdout} + """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): @@ -136,6 +603,10 @@ def parse_backups_output(stdout): 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") @@ -160,17 +631,24 @@ class Handler(BaseHTTPRequestHandler): 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: - return {} - raw = self.rfile.read(length) - return json.loads(raw.decode("utf-8")) + 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) @@ -202,6 +680,92 @@ class Handler(BaseHTTPRequestHandler): 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"]: @@ -214,12 +778,25 @@ class Handler(BaseHTTPRequestHandler): 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": fields[1].split(",")[0], - "domains": fields[1], - "upstream": fields[2], + "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 @@ -311,6 +888,61 @@ class Handler(BaseHTTPRequestHandler): 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] @@ -527,29 +1159,97 @@ class Handler(BaseHTTPRequestHandler): 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"] - # Support both "domain" (string, possibly comma-separated) and "domains" (array) - if "domains" in payload and isinstance(payload["domains"], list): - domain = ",".join(payload["domains"]) - else: - domain = str(payload.get("domain", "")) - port = str(payload["port"]) auth = str(payload.get("auth", True)).lower() 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 not in ["default", "raw", "github"]: + 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: - result = run_panelctl(["init", name, domain, port, auth]) + 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 @@ -571,105 +1271,117 @@ class Handler(BaseHTTPRequestHandler): self._json(500, {"ok": False, "error": f"failed to write compose: {exc}"}) return - elif source_type == "github": - repo_url = payload.get("github_url", "").strip() - branch = payload.get("github_branch", "main").strip() - pat = payload.get("github_pat", "").strip() - - if not repo_url: - run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": "github_url is required"}) - return - - # Validate URL and extract owner/repo - match = re.match(r'^https?://(?:www\.)?github\.com/([^/]+)/([^/]+?)(?:\.git)?$', repo_url) - if not match: - run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": "invalid github_url format"}) - return - - owner, repo = match.groups() - - if pat: - # Check GitHub API access - api_url = f"https://api.github.com/repos/{owner}/{repo}" - req = urllib.request.Request(api_url, headers={"Authorization": f"Bearer {pat}"}) - try: - urllib.request.urlopen(req) - except urllib.error.URLError as e: - run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": f"github api check failed: {e.reason}"}) - return - - # Clone the repository - git_bin = shutil.which("git") - if not git_bin: - run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": "git is not installed or not in PATH"}) - 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) - auth_url = repo_url - if pat: - auth_url = auth_url.replace("://", f"://{pat}@") - - if not auth_url.endswith(".git"): - auth_url += ".git" - - clone_result = subprocess.run( - [git_bin, "clone", "--branch", branch, auth_url, target_dir], - capture_output=True, text=True - ) - - if clone_result.returncode != 0: + 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: {clone_result.stderr.strip()}"}) + 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" - # Find compose file - compose_path = None - for fname in ["compose.yaml", "docker-compose.yml", "compose.yml", "docker-compose.yaml"]: - candidate = os.path.join(target_dir, fname) - if os.path.isfile(candidate): - compose_path = candidate - break - + compose_path = find_compose_file(target_dir) if not compose_path: run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": "could not find compose file in repository root"}) + self._json(400, {"ok": False, "error": "could not find a compose file in the repository root"}) return - - # Update manifest - manifest_path = os.path.join(BASE_DIR, "state", "apps", f"{name}.env") + try: - with open(manifest_path, "r", encoding="utf-8") as fh: - lines = fh.readlines() - with open(manifest_path, "w", encoding="utf-8") as fh: - for line in lines: - if line.startswith("APP_COMPOSE_FILE="): - fh.write(f'APP_COMPOSE_FILE="{compose_path}"\n') - else: - fh.write(line) - fh.write(f'APP_REPO_URL="{repo_url}"\n') - fh.write(f'APP_REPO_BRANCH="{branch}"\n') - except OSError as exc: + 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 - self._json(200, {"ok": True, "code": 0, "stdout": "initialized successfully"}) + 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) @@ -729,6 +1441,37 @@ class Handler(BaseHTTPRequestHandler): 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): @@ -738,6 +1481,84 @@ class Handler(BaseHTTPRequestHandler): 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): @@ -759,7 +1580,8 @@ class Handler(BaseHTTPRequestHandler): def main(): - server = HTTPServer((BIND, PORT), Handler) + 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() diff --git a/panelctl.sh b/panelctl.sh index cda8969..a7b9043 100644 --- a/panelctl.sh +++ b/panelctl.sh @@ -7,7 +7,17 @@ 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" +# Owner of generated files that Caddy has to read (via the shared group). +PANEL_USER="${PANEL_USER:-reudy}" +PANEL_GROUP="${PANEL_GROUP:-panelroutes}" + +# 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 @@ -15,12 +25,39 @@ FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 { } ' +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 init "|[,...]" [auth] + panelctl set-routes "|[,...]" panelctl render-route panelctl deploy panelctl restart @@ -36,14 +73,15 @@ Usage: panelctl list panelctl show -Domains can be comma-separated for multiple domains: - panelctl init myapp "app.example.com,www.example.com" 18080 true +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" 18080 true + panelctl init myapp "*.example.com|127.0.0.1:18080" true Examples: - panelctl init whoami whoami.srazka.com 18080 true + panelctl init whoami "whoami.reudy.net|127.0.0.1:18080" true panelctl deploy whoami panelctl restart whoami panelctl status whoami @@ -121,6 +159,19 @@ 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 @@ -128,6 +179,92 @@ load_app() { [[ -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() { @@ -194,28 +331,33 @@ run_compose() { if [[ "${compose}" == *" compose" ]]; then local podman_bin="${compose% compose}" - "${podman_bin}" compose "$@" + env "${APP_ENV_ARGS[@]}" "${podman_bin}" compose "$@" return fi - "${compose}" "$@" + env "${APP_ENV_ARGS[@]}" "${compose}" "$@" } write_default_compose() { local name="$1" - local port="$2" + 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}" - install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}" + install -m 0664 -o "${PANEL_USER}" -g "${PANEL_GROUP}" "${tmp}" "${route_file}" + rm -f "${tmp}" + routes_unlock log info "rendered route ${route_file}" } @@ -344,11 +483,19 @@ cmd_deploy() { log info "Starting deployment for app '${name}'" cmd_render_route "${name}" + prepare_env_override "${name}" - if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --remove-orphans 2>&1 | systemd-cat -t panelctl -p info 2>/dev/null; then + # 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}'" } @@ -360,9 +507,10 @@ cmd_restart() { log info "Restarting app '${name}'" - run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans || fail "compose down failed" + run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed" + prepare_env_override "${name}" - if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --remove-orphans 2>&1; then + if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1; then fail "compose up failed during restart" fi @@ -374,7 +522,7 @@ cmd_stop() { validate_name "${name}" load_app "${name}" - run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans || fail "compose down failed" + run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed" log info "stopped app '${name}'" } @@ -383,8 +531,8 @@ cmd_status() { validate_name "${name}" load_app "${name}" - run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null || \ - run_compose -f "${APP_COMPOSE_FILE}" ps 2>/dev/null || \ + run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null || \ + run_compose "${COMPOSE_ARGS[@]}" ps 2>/dev/null || \ log info "no containers running" } @@ -407,7 +555,7 @@ cmd_logs() { esac done - run_compose -f "${APP_COMPOSE_FILE}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available" + run_compose "${COMPOSE_ARGS[@]}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available" } cmd_validate_compose() { @@ -415,11 +563,11 @@ cmd_validate_compose() { validate_name "${name}" load_app "${name}" - if run_compose -f "${APP_COMPOSE_FILE}" config >/dev/null 2>&1; then + if run_compose "${COMPOSE_ARGS[@]}" config >/dev/null 2>&1; then log info "compose file is valid" else local output - output="$(run_compose -f "${APP_COMPOSE_FILE}" config 2>&1 || true)" + output="$(run_compose "${COMPOSE_ARGS[@]}" config 2>&1 || true)" fail "compose validation failed: ${output}" fi } @@ -430,19 +578,22 @@ cmd_remove() { validate_name "${name}" load_app "${name}" - run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans 2>/dev/null || true + 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}" + install -m 0664 -o "${PANEL_USER}" -g "${PANEL_GROUP}" "${tmp}" "${route_file}" + rm -f "${tmp}" + routes_unlock fi - rm -f "$(app_manifest "${name}")" + rm -f "$(app_manifest "${name}")" "$(app_env_file "${name}")" rm -rf "${APP_STACK_DIR}" if [[ "${keep_volumes}" != "--keep-volumes" ]]; then @@ -452,6 +603,45 @@ cmd_remove() { 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}" @@ -469,10 +659,10 @@ cmd_backup() { # Stop containers before backup for consistency local was_running=false - if run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then + 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 -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true + run_compose "${COMPOSE_ARGS[@]}" down 2>/dev/null || true fi (cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed" @@ -487,7 +677,7 @@ cmd_backup() { # Restart if it was running if [[ "${was_running}" == "true" ]]; then log info "restarting containers after backup..." - run_compose -f "${APP_COMPOSE_FILE}" up -d 2>/dev/null || true + run_compose "${COMPOSE_ARGS[@]}" up -d 2>/dev/null || true fi local size @@ -524,7 +714,7 @@ cmd_volume_clear() { load_app "${name}" log info "clearing volume data for app '${name}'" - run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans 2>/dev/null || true + run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true local data_dir="${APP_VOLUME_DIR}/data" if [[ -d "${data_dir}" ]]; then @@ -561,7 +751,7 @@ cmd_restore() { # Stop containers before restore log info "stopping containers for restore..." - run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true + run_compose "${COMPOSE_ARGS[@]}" down 2>/dev/null || true # Clear existing volume data and extract backup rm -rf "${volume_dir:?}"/* @@ -589,7 +779,8 @@ cmd_inspect_volumes() { 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 - fi | sort -u | grep -v '^$' + # grep exits 1 when there are no named volumes; that is not an error. + fi | sort -u | grep -v '^$' || true } cmd_list() { @@ -599,9 +790,32 @@ cmd_list() { [[ -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}" - local domains="${APP_DOMAINS:-${APP_DOMAIN}}" - echo "${APP_NAME} ${domains} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED}" + # 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 @@ -623,8 +837,12 @@ main() { case "${cmd}" in init) - [[ $# -ge 4 ]] || fail "usage: panelctl init [auth]" - cmd_init "$2" "$3" "$4" "${5:-true}" + [[ $# -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 "