Compare commits

...

10 commits

Author SHA1 Message Date
e11fc00840 Package the panel as a Nix flake with a NixOS module
The panel now lives in its own repository instead of the nixos config.
The flake provides:

- packages.<system>.default: panelctl plus a panel-api wrapper, with the
  frontend under share/panel and runtime tools appended to PATH
- nixosModules.default: services.reudy-panel (service, directories,
  shared routes group, Caddy vhost with optional Authelia forward_auth,
  routes import and reload path unit, Forgejo integration)
- overlays.default, and checks that build the package and evaluate a
  host using the module

panelctl takes the routes file owner from PANEL_USER/PANEL_GROUP
instead of hardcoding reudy:panelroutes (defaults unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
2026-09-27 17:51:55 +00:00
b27bbc19cb Move all services from srazka.com to reudy.net
Renames every domain in the config: Caddy virtual hosts (auth, hello,
nextcloud, git, panel), the ACME contact email, Authelia's session cookie
domain / portal URL / default redirect and TOTP issuer, Nextcloud's
hostName and overwritehost, and Forgejo's DOMAIN (ROOT_URL and ssh clone
URLs follow from it; the panel picks them up via panel.nix). Panel docs and
examples updated too, and a README paragraph that had run together is
split again.

Requires DNS for auth/hello/nextcloud/git/panel.reudy.net (or a
*.reudy.net wildcard) pointing at the server before deploying.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
2026-09-27 15:32:49 +00:00
agent
db46c5e793 panel: Forgejo integration, ssh deploy key and per-app environment variables
Forgejo:
- panel.nix passes the local Forgejo's public, API and ssh URLs (derived
  from forgejo.nix) to panel-api.
- Settings dialog: connect a Forgejo access token (verified against
  /api/v1/user, stored 0600 in state/panel/forgejo-token).
- New-app dialog gets a Forgejo repository picker with search and a branch
  dropdown; private repos are cloned over https with the stored token, or
  over ssh with the deploy key when no token is connected. The app name and
  domain are filled in from the repository name.
- Commit and compare links in the Source tab point at Forgejo; cards show
  the provider ("Forgejo · main").

Git over ssh:
- ssh:// and git@host:owner/repo URLs are accepted; the panel generates an
  ed25519 deploy key in state/panel/ssh and uses it for clone/fetch
  (BatchMode, accept-new host keys). openssh added to the service path.
- Credential redaction only applies to http(s) URLs, so ssh usernames are
  kept; git errors now report the meaningful line instead of git's advice.

Environment variables:
- Stored per app in state/env/<app>.env (0600), outside the repo and stack.
- panelctl passes them to every compose command via env(1), so ${VAR}
  interpolation works; by default deploy/restart also generate a compose
  override listing the keys under every service's environment (values are
  read from compose's environment, never quoted into YAML).
- Environment tab (and a section in the new-app dialog) with .env paste
  import, hidden values, validation of names (reserved podman/compose vars
  rejected), hints for ${VAR}s the compose file uses but aren't set, and
  Save / Save & deploy. Removing an app deletes its variables.

The API still accepts the old source_type "github" / github_* fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
2026-09-26 22:52:09 +00:00
agent
2d3b30d078 panel: redesign web UI and make syncing responsive
Web UI (panel/frontend/index.html) rewritten:
- Cards update in place from a single /status poll instead of being rebuilt
  on every action, so open tabs, unsaved compose/route edits, logs and the
  file browser position survive refreshes. Polling speeds up while an
  operation runs and pauses in background tabs; a header indicator shows
  when the panel last synced and detects an expired Authelia session.
- New-app dialog (starter / compose / git), suggested port and domain,
  proper confirm dialogs (the old "OK = keep volumes" remove prompt is gone),
  toasts, an activity drawer with operation output, overflow menu, search,
  status filters, keyboard shortcuts, deep links, dark mode and mobile layout.
- Tabs: overview (containers + routes), compose editor (dirty tracking,
  Ctrl+S), logs with follow, validated routes editor, file browser with
  drag-and-drop upload, backups, and a git source tab (deployed commit,
  check for updates, sync & deploy).

API (panel/panel-api.py):
- ThreadingHTTPServer so a long deploy no longer blocks every other request.
- Per-app operation lock; concurrent writes to a busy app return 409.
- GET /status: all apps, routes and container status in one request
  (statuses gathered in parallel); status reports running/partial/stopped.
- Git sync is fetch + hard reset instead of pull-or-reclone, keeps the stored
  token, reports before/after commits; GET /apps/<name>/repo[?fetch=1].
- Any http(s) git host (e.g. Forgejo), default branch detection, git
  timeouts, no credential prompts, tokens redacted from errors, and manifest
  values validated before being written into the bash-sourced manifest.

panelctl:
- flock around routes.caddy rewrites (util-linux added to the service path).
- deploy returns compose output so failures are visible in the UI.
- inspect-volumes no longer fails for apps without named podman volumes,
  which broke the file browser.

Docs: README/API.md updated; fixed outdated panelctl init examples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
2026-09-26 22:17:07 +00:00
Jakub Dorfman
85e29b8734 added path to routing 2026-05-20 01:07:37 +02:00
Jakub Dorfman
7fd2531501 added support for multiple routes multiple ports 2026-05-20 00:21:25 +02:00
Jakub Dorfman
7399cf83f9 fix: add --build flag to run_compose for deployment and restart commands
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 19:36:02 +02:00
Jakub Dorfman
40503ef5b7 fix: reset leftover variables in cmd_list function
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 18:56:26 +02:00
Jakub Dorfman
f173246581 feat: add repo_url to app data and update frontend to conditionally display Git Pull button
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 18:53:02 +02:00
Jakub Dorfman
4be4ae4144 feat: add repo-pull action to re-clone/pull repository and redeploy app
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 18:47:11 +02:00
10 changed files with 4604 additions and 1179 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
result
result-*

150
API.md
View file

@ -12,6 +12,11 @@ Default bind: `127.0.0.1:9911`
|--------|------|-------------| |--------|------|-------------|
| GET | `/` | Web UI (served from `frontend/index.html`) | | GET | `/` | Web UI (served from `frontend/index.html`) |
| GET | `/health` | Health check | | 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 ### Apps — Read
@ -19,17 +24,27 @@ Default bind: `127.0.0.1:9911`
|--------|------|-------------| |--------|------|-------------|
| GET | `/apps` | List all apps | | GET | `/apps` | List all apps |
| GET | `/apps/<name>` | Show single app manifest | | GET | `/apps/<name>` | Show single app manifest |
| GET | `/apps/<name>/routes` | Get parsed route entries |
| GET | `/apps/<name>/status` | Container status (running/stopped) | | GET | `/apps/<name>/status` | Container status (running/stopped) |
| GET | `/apps/<name>/compose` | Read compose.yaml content | | GET | `/apps/<name>/compose` | Read compose.yaml content |
| GET | `/apps/<name>/logs?tail=N` | Fetch last N log lines (default 100) | | GET | `/apps/<name>/logs?tail=N` | Fetch last N log lines (default 100) |
| GET | `/apps/<name>/backups` | List available backups | | GET | `/apps/<name>/backups` | List available backups |
| GET | `/apps/<name>/backups/<file>` | Download backup zip | | GET | `/apps/<name>/backups/<file>` | Download backup zip |
| GET | `/apps/<name>/env` | Environment variables: `{"vars": [{"key", "value"}], "inject": true}` |
| GET | `/apps/<name>/repo` | Git source info (URL, web URL, provider, branch, deployed commit, local changes, deploy key for ssh) |
| GET | `/apps/<name>/repo?fetch=1` | Same, plus fetches the remote and reports `behind` / `remote` |
| GET | `/apps/<name>/volumes` | Volumes the file browser can open |
| GET | `/apps/<name>/volume/files?vol=&path=` | List a folder in a volume |
| GET | `/apps/<name>/volume/download?vol=&path=` | Download a file from a volume |
| PUT | `/apps/<name>/volume/files?vol=&path=` | Upload a file (raw body) |
| DELETE | `/apps/<name>/volume/files?vol=&path=` | Delete a file or folder |
### Apps — Write ### Apps — Write
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| POST | `/apps/init` | Create a new app | | POST | `/apps/init` | Create a new app |
| POST | `/apps/<name>/routes` | Update routes (hot — Caddy reloads automatically) |
| POST | `/apps/<name>/deploy` | Deploy (compose up + caddy reload) | | POST | `/apps/<name>/deploy` | Deploy (compose up + caddy reload) |
| POST | `/apps/<name>/restart` | Restart (compose down + up) | | POST | `/apps/<name>/restart` | Restart (compose down + up) |
| POST | `/apps/<name>/stop` | Stop (compose down) | | POST | `/apps/<name>/stop` | Stop (compose down) |
@ -39,55 +54,151 @@ Default bind: `127.0.0.1:9911`
| POST | `/apps/<name>/backup` | Create volume backup (zip) | | POST | `/apps/<name>/backup` | Create volume backup (zip) |
| POST | `/apps/<name>/restore` | Restore from backup | | POST | `/apps/<name>/restore` | Restore from backup |
| POST | `/apps/<name>/remove` | Remove app | | POST | `/apps/<name>/remove` | Remove app |
| POST | `/apps/<name>/repo-pull` | Git apps: fetch branch, hard-reset checkout to it, redeploy |
| POST | `/apps/<name>/env` | Replace environment variables: `{"vars": [...], "inject": true, "deploy": false}` |
| POST | `/apps/<name>/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 ## Example payloads
### Create app (single domain) ### Create app (single route)
```json ```json
{ {
"name": "whoami", "name": "whoami",
"domain": "whoami.srazka.com", "routes": [
"port": 18080, {"domain": "whoami.reudy.net", "upstream": "127.0.0.1:18080"}
],
"auth": true "auth": true
} }
``` ```
### Create app (multiple domains) ### Create app (multiple routes, different ports)
```json ```json
{ {
"name": "myapp", "name": "myapp",
"domain": "app.srazka.com,www.app.srazka.com", "routes": [
"port": 18081, {"domain": "app.reudy.net", "upstream": "127.0.0.1:18080"},
{"domain": "api.app.reudy.net", "upstream": "127.0.0.1:18081"}
],
"auth": true "auth": true
} }
``` ```
Or using the `domains` array format: ### Create app (multiple routes, different ports, with paths)
```json ```json
{ {
"name": "myapp", "name": "pocketbase",
"domains": ["app.srazka.com", "www.app.srazka.com"], "routes": [
"port": 18081, {"domain": "pb.reudy.net", "upstream": "127.0.0.1:8090", "path": "/_/*"}
],
"auth": true "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) ### Create app (wildcard domain)
```json ```json
{ {
"name": "wildcard", "name": "wildcard",
"domain": "*.srazka.com", "routes": [
"port": 18082, {"domain": "*.reudy.net", "upstream": "127.0.0.1:18082"}
],
"auth": false "auth": false
} }
``` ```
Note: Wildcard domains require DNS challenge configuration in Caddy. 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 <path> <upstream>` rule for sub-path routing.
Caddy reloads automatically via the systemd path watcher. Containers stay running.
### Save compose ### Save compose
```json ```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 ## Response format
All JSON responses include an `ok` boolean: All JSON responses include an `ok` boolean:

139
README.md
View file

@ -2,6 +2,51 @@
Minimal container management panel for rootless Podman + Caddy. Minimal container management panel for rootless Podman + Caddy.
## Installing on NixOS
This repository is a flake that provides the panel as a package
(`packages.<system>.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 ## Base directory
`/var/lib/containers` `/var/lib/containers`
@ -22,14 +67,19 @@ All app routes are written to a single `routes/routes.caddy` file that Caddy imp
## Quick workflow ## Quick workflow
```bash ```bash
# Create a new app (single domain) # Routes are "domain|upstream[|path]" entries, comma-separated.
panelctl init whoami whoami.srazka.com 18080 true
# Create with multiple domains # Create a new app (single route, protected by Authelia)
panelctl init myapp "app.srazka.com,www.srazka.com" 18081 true 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) # 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) # Deploy (compose up + caddy reload)
panelctl deploy whoami panelctl deploy whoami
@ -85,15 +135,78 @@ panelctl remove whoami
## Web UI & API ## Web UI & API
- Nix runs `panel-api` as a systemd service on `127.0.0.1:9911`. - 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. - Caddy proxies `https://panel.reudy.net` → panel-api with Authelia forward_auth.
- Open `https://panel.srazka.com` for the web UI. - Open `https://panel.reudy.net` for the web UI.
- API docs: [API.md](API.md) - API docs: [API.md](API.md)
### Web UI features ### Web UI features
- Create apps with multiple domains and wildcard support - Live status: one `/status` poll every few seconds (faster while something is
- Live container status indicators (auto-refreshes) running, paused when the tab is hidden) updates cards in place, so open tabs,
- Deploy, restart, stop, remove from the UI unsaved edits and scroll positions are never lost. The header shows when the
- Inline compose editor with save, validate, and save+deploy panel last synced and warns when the Authelia session has expired.
- Log viewer with configurable tail length - Per-app status (running / partial / stopped), container list, and a busy
- Volume backup management: create, list, download, restore 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/<app>/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/<app>.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/<app>/.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.

27
flake.lock generated Normal file
View file

@ -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
}

58
flake.nix Normal file
View file

@ -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);
};
}

File diff suppressed because it is too large Load diff

178
nix/module.nix Normal file
View file

@ -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";
};
};
};
}

85
nix/package.nix Normal file
View file

@ -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;
};
}

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,17 @@ VOLUMES_DIR="${BASE_DIR}/volumes"
ROUTES_DIR="${BASE_DIR}/routes" ROUTES_DIR="${BASE_DIR}/routes"
STATE_DIR="${BASE_DIR}/state" STATE_DIR="${BASE_DIR}/state"
APPS_DIR="${STATE_DIR}/apps" APPS_DIR="${STATE_DIR}/apps"
ENV_DIR="${STATE_DIR}/env"
BACKUPS_DIR="${BASE_DIR}/backups" 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 { FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
uri /api/authz/forward-auth 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() { usage() {
cat <<'EOF' cat <<'EOF'
panelctl - minimal app panel helper panelctl - minimal app panel helper
Usage: Usage:
panelctl init <name> <domains> <port> [auth] panelctl init <name> "<domain>|<upstream>[,...]" [auth]
panelctl set-routes <name> "<domain>|<upstream>[,...]"
panelctl render-route <name> panelctl render-route <name>
panelctl deploy <name> panelctl deploy <name>
panelctl restart <name> panelctl restart <name>
@ -36,14 +73,15 @@ Usage:
panelctl list panelctl list
panelctl show <name> panelctl show <name>
Domains can be comma-separated for multiple domains: Each route is a domain|upstream pair. Upstream is host:port.
panelctl init myapp "app.example.com,www.example.com" 18080 true 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): 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: 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 deploy whoami
panelctl restart whoami panelctl restart whoami
panelctl status whoami panelctl status whoami
@ -121,6 +159,19 @@ app_route_file() {
echo "${ROUTES_DIR}/routes.caddy" 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() { load_app() {
local name="$1" local name="$1"
local manifest local manifest
@ -128,6 +179,92 @@ load_app() {
[[ -f "${manifest}" ]] || fail "app '${name}' does not exist" [[ -f "${manifest}" ]] || fail "app '${name}' does not exist"
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "${manifest}" 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() { compose_command() {
@ -194,28 +331,33 @@ run_compose() {
if [[ "${compose}" == *" compose" ]]; then if [[ "${compose}" == *" compose" ]]; then
local podman_bin="${compose% compose}" local podman_bin="${compose% compose}"
"${podman_bin}" compose "$@" env "${APP_ENV_ARGS[@]}" "${podman_bin}" compose "$@"
return return
fi fi
"${compose}" "$@" env "${APP_ENV_ARGS[@]}" "${compose}" "$@"
} }
write_default_compose() { write_default_compose() {
local name="$1" local name="$1"
local port="$2" local routes="$2"
local stack_dir local stack_dir
local volume_dir local volume_dir
stack_dir="$(app_stack_dir "${name}")" stack_dir="$(app_stack_dir "${name}")"
volume_dir="$(app_volume_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" <<EOF cat >"${stack_dir}/compose.yaml" <<EOF
services: services:
app: app:
image: docker.io/traefik/whoami:latest image: docker.io/traefik/whoami:latest
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:${port}:80" - "127.0.0.1:${container_port}:80"
volumes: volumes:
- ${volume_dir}/data:/data - ${volume_dir}/data:/data
EOF EOF
@ -223,9 +365,8 @@ EOF
write_manifest() { write_manifest() {
local name="$1" local name="$1"
local domains="$2" local routes="$2"
local port="$3" local auth="$3"
local auth="$4"
local manifest local manifest
local stack_dir local stack_dir
local volume_dir local volume_dir
@ -236,17 +377,9 @@ write_manifest() {
volume_dir="$(app_volume_dir "${name}")" volume_dir="$(app_volume_dir "${name}")"
route_file="$(app_route_file)" route_file="$(app_route_file)"
# First domain is the primary (used for APP_DOMAIN backward compat)
local primary_domain
IFS=',' read -ra domain_arr <<< "${domains}"
primary_domain="$(echo "${domain_arr[0]}" | xargs)"
cat >"${manifest}" <<EOF cat >"${manifest}" <<EOF
APP_NAME="${name}" APP_NAME="${name}"
APP_DOMAIN="${primary_domain}" APP_ROUTES="${routes}"
APP_DOMAINS="${domains}"
APP_PORT="${port}"
APP_UPSTREAM="127.0.0.1:${port}"
APP_AUTH_PROTECTED="${auth}" APP_AUTH_PROTECTED="${auth}"
APP_STACK_DIR="${stack_dir}" APP_STACK_DIR="${stack_dir}"
APP_COMPOSE_FILE="${stack_dir}/compose.yaml" APP_COMPOSE_FILE="${stack_dir}/compose.yaml"
@ -257,13 +390,11 @@ EOF
cmd_init() { cmd_init() {
local name="$1" local name="$1"
local domains="$2" local routes="$2"
local port="$3" local auth="${3:-true}"
local auth="${4:-true}"
validate_name "${name}" validate_name "${name}"
validate_domains "${domains}" validate_routes "${routes}"
validate_port "${port}"
[[ "${auth}" == "true" || "${auth}" == "false" ]] || fail "auth must be true or false" [[ "${auth}" == "true" || "${auth}" == "false" ]] || fail "auth must be true or false"
ensure_base_dirs ensure_base_dirs
@ -278,8 +409,8 @@ cmd_init() {
[[ ! -f "${manifest}" ]] || fail "app '${name}' already exists" [[ ! -f "${manifest}" ]] || fail "app '${name}' already exists"
mkdir -p "${stack_dir}" "${volume_dir}/data" mkdir -p "${stack_dir}" "${volume_dir}/data"
write_default_compose "${name}" "${port}" write_default_compose "${name}" "${routes}"
write_manifest "${name}" "${domains}" "${port}" "${auth}" write_manifest "${name}" "${routes}" "${auth}"
cmd_render_route "${name}" cmd_render_route "${name}"
log info "initialized app '${name}'" log info "initialized app '${name}'"
@ -295,22 +426,11 @@ cmd_render_route() {
auth_block="${FORWARD_AUTH_BLOCK}" auth_block="${FORWARD_AUTH_BLOCK}"
fi fi
# Build domain list for Caddy block header.
local caddy_domains=""
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 "${caddy_domains}" ]]; then
caddy_domains="${caddy_domains}, ${d}"
else
caddy_domains="${d}"
fi
done
local route_file local route_file
route_file="$(app_route_file)" route_file="$(app_route_file)"
routes_lock
# Strip any existing block for this app from the aggregate file. # Strip any existing block for this app from the aggregate file.
local tmp local tmp
tmp="$(mktemp)" tmp="$(mktemp)"
@ -323,16 +443,35 @@ cmd_render_route() {
{ {
printf "# route:%s:start\n" "${name}" printf "# route:%s:start\n" "${name}"
printf "%s {\n" "${caddy_domains}" IFS=',' read -ra route_entries <<< "${APP_ROUTES}"
if [[ -n "${auth_block}" ]]; then for entry in "${route_entries[@]}"; do
printf "%s\n" "${auth_block}" entry="$(echo "${entry}" | xargs)"
fi IFS='|' read -r domain upstream path <<< "${entry}"
printf " reverse_proxy %s\n" "${APP_UPSTREAM}" # If upstream is empty (no second pipe), this is the old format
printf "}\n" if [[ -z "${upstream}" ]]; then
upstream="${path}"
path=""
fi
if [[ -n "${path}" ]]; then
if [[ -n "${auth_block}" ]]; then
printf "%s {\n%s reverse_proxy %s %s\n}\n" "${domain}" "${auth_block}" "${path}" "${upstream}"
else
printf "%s {\n reverse_proxy %s %s\n}\n" "${domain}" "${path}" "${upstream}"
fi
else
if [[ -n "${auth_block}" ]]; then
printf "%s {\n%s reverse_proxy %s\n}\n" "${domain}" "${auth_block}" "${upstream}"
else
printf "%s {\n reverse_proxy %s\n}\n" "${domain}" "${upstream}"
fi
fi
done
printf "# route:%s:end\n" "${name}" printf "# route:%s:end\n" "${name}"
} >>"${tmp}" } >>"${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}" log info "rendered route ${route_file}"
} }
@ -344,11 +483,19 @@ cmd_deploy() {
log info "Starting deployment for app '${name}'" log info "Starting deployment for app '${name}'"
cmd_render_route "${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}'" log err "Deployment failed for app '${name}'"
fail "compose up failed" fail "compose up failed"
fi 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}'" log info "Successfully deployed app '${name}'"
} }
@ -360,9 +507,10 @@ cmd_restart() {
log info "Restarting app '${name}'" 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" fail "compose up failed during restart"
fi fi
@ -374,7 +522,7 @@ cmd_stop() {
validate_name "${name}" validate_name "${name}"
load_app "${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}'" log info "stopped app '${name}'"
} }
@ -383,8 +531,8 @@ cmd_status() {
validate_name "${name}" validate_name "${name}"
load_app "${name}" load_app "${name}"
run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null || \ run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null || \
run_compose -f "${APP_COMPOSE_FILE}" ps 2>/dev/null || \ run_compose "${COMPOSE_ARGS[@]}" ps 2>/dev/null || \
log info "no containers running" log info "no containers running"
} }
@ -407,7 +555,7 @@ cmd_logs() {
esac esac
done 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() { cmd_validate_compose() {
@ -415,11 +563,11 @@ cmd_validate_compose() {
validate_name "${name}" validate_name "${name}"
load_app "${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" log info "compose file is valid"
else else
local output 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}" fail "compose validation failed: ${output}"
fi fi
} }
@ -430,19 +578,22 @@ cmd_remove() {
validate_name "${name}" validate_name "${name}"
load_app "${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. # Remove this app's block from the aggregate routes file.
local route_file local route_file
route_file="$(app_route_file)" route_file="$(app_route_file)"
if [[ -f "${route_file}" ]]; then if [[ -f "${route_file}" ]]; then
routes_lock
local tmp local tmp
tmp="$(mktemp)" tmp="$(mktemp)"
sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true 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 fi
rm -f "$(app_manifest "${name}")" rm -f "$(app_manifest "${name}")" "$(app_env_file "${name}")"
rm -rf "${APP_STACK_DIR}" rm -rf "${APP_STACK_DIR}"
if [[ "${keep_volumes}" != "--keep-volumes" ]]; then if [[ "${keep_volumes}" != "--keep-volumes" ]]; then
@ -452,6 +603,45 @@ cmd_remove() {
log info "removed app '${name}'" 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() { cmd_backup() {
local name="$1" local name="$1"
validate_name "${name}" validate_name "${name}"
@ -469,10 +659,10 @@ cmd_backup() {
# Stop containers before backup for consistency # Stop containers before backup for consistency
local was_running=false 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 was_running=true
log info "stopping containers for consistent backup..." 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 fi
(cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed" (cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed"
@ -487,7 +677,7 @@ cmd_backup() {
# Restart if it was running # Restart if it was running
if [[ "${was_running}" == "true" ]]; then if [[ "${was_running}" == "true" ]]; then
log info "restarting containers after backup..." 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 fi
local size local size
@ -524,7 +714,7 @@ cmd_volume_clear() {
load_app "${name}" load_app "${name}"
log info "clearing volume data for 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" local data_dir="${APP_VOLUME_DIR}/data"
if [[ -d "${data_dir}" ]]; then if [[ -d "${data_dir}" ]]; then
@ -561,7 +751,7 @@ cmd_restore() {
# Stop containers before restore # Stop containers before restore
log info "stopping containers for 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 # Clear existing volume data and extract backup
rm -rf "${volume_dir:?}"/* rm -rf "${volume_dir:?}"/*
@ -589,7 +779,8 @@ cmd_inspect_volumes() {
if [[ -n "${podman_bin}" ]]; then 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=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 "${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() { cmd_list() {
@ -599,9 +790,32 @@ cmd_list() {
[[ -e "${mf}" ]] || continue [[ -e "${mf}" ]] || continue
found=1 found=1
# shellcheck disable=SC1090 # 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}" source "${mf}"
local domains="${APP_DOMAINS:-${APP_DOMAIN}}" # Backward compat: build APP_ROUTES from old format
echo "${APP_NAME} ${domains} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED}" 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 done
if [[ "${found}" -eq 0 ]]; then if [[ "${found}" -eq 0 ]]; then
@ -623,8 +837,12 @@ main() {
case "${cmd}" in case "${cmd}" in
init) init)
[[ $# -ge 4 ]] || fail "usage: panelctl init <name> <domains> <port> [auth]" [[ $# -ge 3 ]] || fail "usage: panelctl init <name> <routes> [auth]"
cmd_init "$2" "$3" "$4" "${5:-true}" cmd_init "$2" "$3" "${4:-true}"
;;
set-routes)
[[ $# -eq 3 ]] || fail "usage: panelctl set-routes <name> <routes>"
cmd_set_routes "$2" "$3"
;; ;;
render-route) render-route)
[[ $# -eq 2 ]] || fail "usage: panelctl render-route <name>" [[ $# -eq 2 ]] || fail "usage: panelctl render-route <name>"