diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 750baeb..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -result -result-* diff --git a/API.md b/API.md index 62912a6..16f9ddb 100644 --- a/API.md +++ b/API.md @@ -12,11 +12,6 @@ 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 @@ -24,27 +19,17 @@ 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) | @@ -54,151 +39,55 @@ 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 route) +### Create app (single domain) ```json { "name": "whoami", - "routes": [ - {"domain": "whoami.reudy.net", "upstream": "127.0.0.1:18080"} - ], + "domain": "whoami.srazka.com", + "port": 18080, "auth": true } ``` -### Create app (multiple routes, different ports) +### Create app (multiple domains) ```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"} - ], + "domain": "app.srazka.com,www.app.srazka.com", + "port": 18081, "auth": true } ``` -### Create app (multiple routes, different ports, with paths) +Or using the `domains` array format: ```json { - "name": "pocketbase", - "routes": [ - {"domain": "pb.reudy.net", "upstream": "127.0.0.1:8090", "path": "/_/*"} - ], + "name": "myapp", + "domains": ["app.srazka.com", "www.app.srazka.com"], + "port": 18081, "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"} - ], + "domain": "*.srazka.com", + "port": 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 @@ -223,21 +112,6 @@ Caddy reloads automatically via the systemd path watcher. Containers stay runnin } ``` -## 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 e65d818..02ede16 100644 --- a/README.md +++ b/README.md @@ -2,51 +2,6 @@ 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` @@ -67,19 +22,14 @@ All app routes are written to a single `routes/routes.caddy` file that Caddy imp ## Quick workflow ```bash -# Routes are "domain|upstream[|path]" entries, comma-separated. +# Create a new app (single domain) +panelctl init whoami whoami.srazka.com 18080 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 multiple domains +panelctl init myapp "app.srazka.com,www.srazka.com" 18081 true # Create with wildcard domain (requires DNS challenge in Caddy) -panelctl init wild "*.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" +panelctl init wild "*.srazka.com" 18082 false # Deploy (compose up + caddy reload) panelctl deploy whoami @@ -135,78 +85,15 @@ panelctl remove whoami ## 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. +- Caddy proxies `https://panel.srazka.com` → panel-api with Authelia forward_auth. +- Open `https://panel.srazka.com` for the web UI. - API docs: [API.md](API.md) ### Web UI features -- 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. +- Create apps with multiple domains and wildcard support +- Live container status indicators (auto-refreshes) +- Deploy, restart, stop, remove from the UI +- Inline compose editor with save, validate, and save+deploy +- Log viewer with configurable tail length +- Volume backup management: create, list, download, restore diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 227b212..0000000 --- a/flake.lock +++ /dev/null @@ -1,27 +0,0 @@ -{ - "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 deleted file mode 100644 index 172f289..0000000 --- a/flake.nix +++ /dev/null @@ -1,58 +0,0 @@ -{ - 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 298ffb1..af3490b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,2877 +1,1081 @@ - - -Containers Panel - - + .spinner { + display: inline-block; + width: 14px; height: 14px; + border: 2px solid var(--line); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .6s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } + - -
-
-
Panel
- -
- - - - -
-
-
- - -
-
-
-
/ search · N new app
-
-
-
-
-
+ +
+
+

Containers Panel

+

Rootless Podman + Caddy routes from one place.

+
+
+ +
+
- + +
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…
-
+
- - - -
-

-
- -
- - + +
+
+
Loading apps...
+
-
- - -
- -
+
+ diff --git a/nix/module.nix b/nix/module.nix deleted file mode 100644 index 47d976b..0000000 --- a/nix/module.nix +++ /dev/null @@ -1,178 +0,0 @@ -{ - 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 deleted file mode 100644 index 4557c49..0000000 --- a/nix/package.nix +++ /dev/null @@ -1,85 +0,0 @@ -{ - 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 d6b029d..e320f88 100644 --- a/panel-api.py +++ b/panel-api.py @@ -4,18 +4,12 @@ import json import os import re -import shlex import shutil -import socket import subprocess -import threading -import time -import urllib.error +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import urlparse, parse_qs 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 +import urllib.error PANELCTL = os.environ.get("PANELCTL_PATH", "/run/current-system/sw/bin/panelctl") BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1") @@ -26,415 +20,11 @@ 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], @@ -464,12 +54,11 @@ def parse_env_blob(blob): 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() + if result["ok"]: + 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): @@ -494,92 +83,36 @@ 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): - """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"]) + """Try to determine if any container is running from panelctl status output.""" + text = stdout.lower() + if not text or "no containers" in text: + return {"running": False, "raw": stdout} + # podman compose ps --format json returns JSON array + try: + containers = json.loads(stdout) + if isinstance(containers, list): + running = any( + c.get("State", "").lower() == "running" + or c.get("status", "").lower().startswith("up") + for c in containers + ) + return { + "running": running, + "containers": [ + { + "name": c.get("Name", c.get("name", "?")), + "state": c.get("State", c.get("status", "unknown")), + "image": c.get("Image", c.get("image", "")), + } + for c in containers + ], + } + except (json.JSONDecodeError, TypeError): + pass + # Fallback: check for "Up" or "running" in text + running = "up" in text or "running" in text + return {"running": running, "raw": stdout} def parse_backups_output(stdout): @@ -603,10 +136,6 @@ 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") @@ -631,24 +160,17 @@ 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: - self._payload = {} - else: - raw = self.rfile.read(length) - self._payload = json.loads(raw.decode("utf-8")) - return self._payload + return {} + raw = self.rfile.read(length) + return json.loads(raw.decode("utf-8")) def log_message(self, fmt, *args): # Log to stdout (goes to systemd journal) @@ -680,92 +202,6 @@ 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"]: @@ -778,25 +214,12 @@ 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": domain, - "domains": domain, - "upstream": upstream, - "first_route": first_route, - "route_count": route_count_str, + "domain": fields[1].split(",")[0], + "domains": fields[1], + "upstream": fields[2], "auth": fields[3].replace("auth=", ""), - "repo_url": fields[4] if len(fields) >= 5 else "", }) self._json(200, {"ok": True, "apps": apps}) return @@ -888,61 +311,6 @@ 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] @@ -1159,97 +527,29 @@ 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 == "github": # older clients - source_type = "git" - if source_type not in ["default", "raw", "git"]: + if source_type not in ["default", "raw", "github"]: 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]) + result = run_panelctl(["init", name, domain, port, auth]) if not result["ok"]: self._json(400, result) return @@ -1271,117 +571,105 @@ class Handler(BaseHTTPRequestHandler): 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. + 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 + 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" + auth_url = repo_url + if pat: + auth_url = auth_url.replace("://", f"://{pat}@") + + if not auth_url.endswith(".git"): + auth_url += ".git" - compose_path = find_compose_file(target_dir) + clone_result = subprocess.run( + [git_bin, "clone", "--branch", branch, auth_url, target_dir], + capture_output=True, text=True + ) + + if clone_result.returncode != 0: + run_panelctl(["remove", name]) + self._json(400, {"ok": False, "error": f"git clone failed: {clone_result.stderr.strip()}"}) + return + + # 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 + if not compose_path: run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": "could not find a compose file in the repository root"}) + self._json(400, {"ok": False, "error": "could not find compose file in repository root"}) return - + + # Update manifest + manifest_path = os.path.join(BASE_DIR, "state", "apps", f"{name}.env") 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: + 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: 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}) + self._json(200, {"ok": True, "code": 0, "stdout": "initialized successfully"}) 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) @@ -1441,37 +729,6 @@ 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): @@ -1481,84 +738,6 @@ 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): @@ -1580,8 +759,7 @@ class Handler(BaseHTTPRequestHandler): def main(): - server = ThreadingHTTPServer((BIND, PORT), Handler) - server.daemon_threads = True + server = HTTPServer((BIND, PORT), Handler) print(f"panel-api listening on http://{BIND}:{PORT}") print(f"frontend dir: {FRONTEND_DIR}") server.serve_forever() diff --git a/panelctl.sh b/panelctl.sh index a7b9043..cda8969 100644 --- a/panelctl.sh +++ b/panelctl.sh @@ -7,17 +7,7 @@ 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 @@ -25,39 +15,12 @@ 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 set-routes "|[,...]" + panelctl init [auth] panelctl render-route panelctl deploy panelctl restart @@ -73,15 +36,14 @@ Usage: 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 +Domains can be comma-separated for multiple domains: + panelctl init myapp "app.example.com,www.example.com" 18080 true Wildcard domains are supported (requires DNS challenge in Caddy): - panelctl init myapp "*.example.com|127.0.0.1:18080" true + panelctl init myapp "*.example.com" 18080 true Examples: - panelctl init whoami "whoami.reudy.net|127.0.0.1:18080" true + panelctl init whoami whoami.srazka.com 18080 true panelctl deploy whoami panelctl restart whoami panelctl status whoami @@ -159,19 +121,6 @@ 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 @@ -179,92 +128,6 @@ 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() { @@ -331,33 +194,28 @@ run_compose() { if [[ "${compose}" == *" compose" ]]; then local podman_bin="${compose% compose}" - env "${APP_ENV_ARGS[@]}" "${podman_bin}" compose "$@" + "${podman_bin}" compose "$@" return fi - env "${APP_ENV_ARGS[@]}" "${compose}" "$@" + "${compose}" "$@" } write_default_compose() { local name="$1" - local routes="$2" + local port="$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 "${PANEL_USER}" -g "${PANEL_GROUP}" "${tmp}" "${route_file}" - rm -f "${tmp}" - routes_unlock + install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}" log info "rendered route ${route_file}" } @@ -483,19 +344,11 @@ cmd_deploy() { 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 + if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --remove-orphans 2>&1 | systemd-cat -t panelctl -p info 2>/dev/null; then 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}'" } @@ -507,10 +360,9 @@ cmd_restart() { log info "Restarting app '${name}'" - run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed" - prepare_env_override "${name}" + run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans || fail "compose down failed" - if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1; then + if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --remove-orphans 2>&1; then fail "compose up failed during restart" fi @@ -522,7 +374,7 @@ cmd_stop() { validate_name "${name}" load_app "${name}" - run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed" + run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans || fail "compose down failed" log info "stopped app '${name}'" } @@ -531,8 +383,8 @@ cmd_status() { validate_name "${name}" load_app "${name}" - run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null || \ - run_compose "${COMPOSE_ARGS[@]}" ps 2>/dev/null || \ + run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null || \ + run_compose -f "${APP_COMPOSE_FILE}" ps 2>/dev/null || \ log info "no containers running" } @@ -555,7 +407,7 @@ cmd_logs() { esac done - run_compose "${COMPOSE_ARGS[@]}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available" + run_compose -f "${APP_COMPOSE_FILE}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available" } cmd_validate_compose() { @@ -563,11 +415,11 @@ cmd_validate_compose() { validate_name "${name}" load_app "${name}" - if run_compose "${COMPOSE_ARGS[@]}" config >/dev/null 2>&1; then + if run_compose -f "${APP_COMPOSE_FILE}" config >/dev/null 2>&1; then log info "compose file is valid" else local output - output="$(run_compose "${COMPOSE_ARGS[@]}" config 2>&1 || true)" + output="$(run_compose -f "${APP_COMPOSE_FILE}" config 2>&1 || true)" fail "compose validation failed: ${output}" fi } @@ -578,22 +430,19 @@ cmd_remove() { validate_name "${name}" load_app "${name}" - run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true + run_compose -f "${APP_COMPOSE_FILE}" 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 "${PANEL_USER}" -g "${PANEL_GROUP}" "${tmp}" "${route_file}" - rm -f "${tmp}" - routes_unlock + install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}" fi - rm -f "$(app_manifest "${name}")" "$(app_env_file "${name}")" + rm -f "$(app_manifest "${name}")" rm -rf "${APP_STACK_DIR}" if [[ "${keep_volumes}" != "--keep-volumes" ]]; then @@ -603,45 +452,6 @@ 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}" @@ -659,10 +469,10 @@ cmd_backup() { # 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 + if run_compose -f "${APP_COMPOSE_FILE}" 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 + run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true fi (cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed" @@ -677,7 +487,7 @@ cmd_backup() { # 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 + run_compose -f "${APP_COMPOSE_FILE}" up -d 2>/dev/null || true fi local size @@ -714,7 +524,7 @@ cmd_volume_clear() { load_app "${name}" log info "clearing volume data for app '${name}'" - run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true + run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans 2>/dev/null || true local data_dir="${APP_VOLUME_DIR}/data" if [[ -d "${data_dir}" ]]; then @@ -751,7 +561,7 @@ cmd_restore() { # Stop containers before restore log info "stopping containers for restore..." - run_compose "${COMPOSE_ARGS[@]}" down 2>/dev/null || true + run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true # Clear existing volume data and extract backup rm -rf "${volume_dir:?}"/* @@ -779,8 +589,7 @@ 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 - # grep exits 1 when there are no named volumes; that is not an error. - fi | sort -u | grep -v '^$' || true + fi | sort -u | grep -v '^$' } cmd_list() { @@ -790,32 +599,9 @@ 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}" - # 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}" + local domains="${APP_DOMAINS:-${APP_DOMAIN}}" + echo "${APP_NAME} ${domains} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED}" done if [[ "${found}" -eq 0 ]]; then @@ -837,12 +623,8 @@ main() { 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" + [[ $# -ge 4 ]] || fail "usage: panelctl init [auth]" + cmd_init "$2" "$3" "$4" "${5:-true}" ;; render-route) [[ $# -eq 2 ]] || fail "usage: panelctl render-route "