Merge pull request 'Use the panel from its own flake (reudy-net/panel)' (#3) from agent/panel-flake into main

Reviewed-on: #3
This commit is contained in:
reudy 2026-09-27 20:00:29 +02:00
commit 9aa395814c
9 changed files with 42 additions and 5925 deletions

View file

@ -4,10 +4,7 @@
enable = true;
email = "admin@reudy.net";
# Generated app routes from the panel backend.
extraConfig = ''
import /var/lib/containers/routes/routes.caddy
'';
# App routes generated by the panel are imported by its module (panel.nix).
# Authelia's own login portal
virtualHosts."auth.reudy.net".extraConfig = ''

22
flake.lock generated
View file

@ -149,10 +149,32 @@
"type": "github"
}
},
"panel": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1790531515,
"narHash": "sha256-EeanYOcMIrPGgJt3azJIFjdttHRU07jVY0Iw4awEdpg=",
"ref": "main",
"rev": "e11fc00840f2fe68a076ea56305ca67bd2ace332",
"revCount": 39,
"type": "git",
"url": "ssh://git@git.reudy.net:14922/reudy-net/panel"
},
"original": {
"ref": "main",
"type": "git",
"url": "ssh://git@git.reudy.net:14922/reudy-net/panel"
}
},
"root": {
"inputs": {
"agenix": "agenix",
"nixpkgs": "nixpkgs",
"panel": "panel",
"vpsadminos": "vpsadminos"
}
},

View file

@ -11,6 +11,12 @@
inputs.nixpkgs.follows = "nixpkgs";
inputs.darwin.follows = "";
};
# The deployment panel (panel.reudy.net), packaged in its own repository.
panel = {
url = "git+ssh://git@git.reudy.net:14922/reudy-net/panel?ref=main";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
@ -18,6 +24,7 @@
nixpkgs,
vpsadminos,
agenix,
panel,
...
}:
let
@ -31,6 +38,7 @@
{ nixpkgs.overlays = [ agenix.overlays.default ]; }
vpsadminos.nixosModules.container_25_11
./configuration.nix
panel.nixosModules.default
./panel.nix
./caddy.nix
./authelia.nix

View file

@ -1,89 +1,14 @@
{ config, lib, pkgs, ... }:
{ ... }:
let
forgejoServer = config.services.forgejo.settings.server;
in
# The panel itself lives in its own repository (reudy-net/panel) and is pulled
# in as the `panel` flake input. To deploy a new panel version:
# nix flake update panel && sudo nixos-rebuild switch --flake .#vps
{
environment.systemPackages = [
(pkgs.writeShellScriptBin "panelctl" (builtins.readFile ./panel/panelctl.sh))
];
users.groups.panelroutes = { };
users.users.reudy.extraGroups = [ "panelroutes" ];
users.users.caddy.extraGroups = [ "panelroutes" ];
systemd.tmpfiles.rules = [
"d /var/lib/containers 0750 reudy panelroutes -"
"d /var/lib/containers/stacks 0750 reudy panelroutes -"
"d /var/lib/containers/volumes 0750 reudy panelroutes -"
"d /var/lib/containers/routes 0750 reudy panelroutes -"
"d /var/lib/containers/state 0750 reudy panelroutes -"
"d /var/lib/containers/state/apps 0750 reudy panelroutes -"
"d /var/lib/containers/backups 0750 reudy panelroutes -"
"f /var/lib/containers/routes/routes.caddy 0640 reudy panelroutes -"
];
systemd.services.panel-api = {
description = "Minimal panel API service";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
path = [
pkgs.podman
pkgs.podman-compose
pkgs.curl
pkgs.coreutils
pkgs.zip
pkgs.unzip
pkgs.git
pkgs.util-linux # flock, used by panelctl to serialise routes file writes
pkgs.openssh # cloning repositories over ssh with the panel's deploy key
];
serviceConfig = {
Type = "simple";
User = "reudy";
Group = "panelroutes";
Restart = "always";
RestartSec = 3;
WorkingDirectory = "/var/lib/containers";
ExecStart = "${pkgs.python3}/bin/python3 ${./panel/panel-api.py}";
};
environment = {
PANEL_API_BIND = "127.0.0.1";
PANEL_API_PORT = "9911";
PANEL_BASE_DIR = "/var/lib/containers";
PANELCTL_PATH = "/run/current-system/sw/bin/panelctl";
PANEL_FRONTEND_DIR = "${./panel/frontend}";
# Forgejo integration (repo picker, private clones, commit links).
# The API is reached on localhost; clones use the public URLs.
PANEL_FORGEJO_URL = lib.removeSuffix "/" forgejoServer.ROOT_URL;
PANEL_FORGEJO_API_URL = "http://${forgejoServer.HTTP_ADDR}:${toString forgejoServer.HTTP_PORT}";
PANEL_FORGEJO_SSH_URL = "ssh://${forgejoServer.BUILTIN_SSH_SERVER_USER}@${forgejoServer.DOMAIN}:${toString forgejoServer.SSH_PORT}";
};
services.reudy-panel = {
enable = true;
domain = "panel.reudy.net";
autheliaAddress = "127.0.0.1:9091";
# Runs as reudy with the shared panelroutes group, keeps everything in
# /var/lib/containers and picks up the local Forgejo automatically.
};
services.caddy.virtualHosts."panel.reudy.net".extraConfig = ''
forward_auth 127.0.0.1:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
reverse_proxy 127.0.0.1:9911
'';
systemd.paths."caddy-routes-reload" = {
wantedBy = [ "multi-user.target" ];
pathConfig = {
PathChanged = "/var/lib/containers/routes/routes.caddy";
};
};
systemd.services."caddy-routes-reload" = {
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.systemd}/bin/systemctl reload caddy.service";
};
};
}
}

View file

@ -1,287 +0,0 @@
# panel-api
HTTP API wrapper around panelctl with a web UI.
Default bind: `127.0.0.1:9911`
## Endpoints
### Health & UI
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | Web UI (served from `frontend/index.html`) |
| GET | `/health` | Health check |
| GET | `/status` | All apps with routes, container status and running operation (what the UI polls) |
| GET | `/integrations` | Forgejo connection (`configured`, `url`, `has_token`, `user`) and the SSH deploy public key |
| POST | `/integrations/forgejo` | `{"token": "..."}` — verify against Forgejo and store; `""` disconnects |
| GET | `/forgejo/repos?q=` | Search repositories visible to the stored token (public ones without) |
| GET | `/forgejo/branches?repo=owner/name` | Branch names of a Forgejo repository |
### Apps — Read
| Method | Path | Description |
|--------|------|-------------|
| GET | `/apps` | List all apps |
| GET | `/apps/<name>` | Show single app manifest |
| GET | `/apps/<name>/routes` | Get parsed route entries |
| GET | `/apps/<name>/status` | Container status (running/stopped) |
| GET | `/apps/<name>/compose` | Read compose.yaml content |
| GET | `/apps/<name>/logs?tail=N` | Fetch last N log lines (default 100) |
| GET | `/apps/<name>/backups` | List available backups |
| 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
| Method | Path | Description |
|--------|------|-------------|
| 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>/restart` | Restart (compose down + up) |
| POST | `/apps/<name>/stop` | Stop (compose down) |
| POST | `/apps/<name>/render-route` | Re-render Caddy route |
| POST | `/apps/<name>/compose` | Save compose.yaml content |
| POST | `/apps/<name>/validate-compose` | Validate compose file |
| POST | `/apps/<name>/backup` | Create volume backup (zip) |
| POST | `/apps/<name>/restore` | Restore from backup |
| 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
### Create app (single route)
```json
{
"name": "whoami",
"routes": [
{"domain": "whoami.reudy.net", "upstream": "127.0.0.1:18080"}
],
"auth": true
}
```
### Create app (multiple routes, different ports)
```json
{
"name": "myapp",
"routes": [
{"domain": "app.reudy.net", "upstream": "127.0.0.1:18080"},
{"domain": "api.app.reudy.net", "upstream": "127.0.0.1:18081"}
],
"auth": true
}
```
### Create app (multiple routes, different ports, with paths)
```json
{
"name": "pocketbase",
"routes": [
{"domain": "pb.reudy.net", "upstream": "127.0.0.1:8090", "path": "/_/*"}
],
"auth": true
}
```
The `path` field is optional. When present, it generates a Caddy `reverse_proxy /_/* 127.0.0.1:8090` rule, letting you route requests to a specific path prefix within a domain.
### Create app (wildcard domain)
```json
{
"name": "wildcard",
"routes": [
{"domain": "*.reudy.net", "upstream": "127.0.0.1:18082"}
],
"auth": false
}
```
Note: Wildcard domains require DNS challenge configuration in Caddy.
### Update routes (hot)
```json
{
"routes": [
{"domain": "app.reudy.net", "upstream": "127.0.0.1:18080"},
{"domain": "api.reudy.net", "upstream": "127.0.0.1:18081"},
{"domain": "pb.reudy.net", "upstream": "127.0.0.1:8090", "path": "/_/*"}
]
}
```
The optional `path` field generates a Caddy `reverse_proxy <path> <upstream>` rule for sub-path routing.
Caddy reloads automatically via the systemd path watcher. Containers stay running.
### Save compose
```json
{
"content": "services:\n app:\n image: nginx:latest\n ports:\n - '127.0.0.1:18080:80'\n"
}
```
### Remove and keep volumes
```json
{
"keepVolumes": true
}
```
### Restore from backup
```json
{
"file": "whoami-20260101-120000.zip"
}
```
## Routes response
```json
{
"ok": true,
"name": "myapp",
"routes": [
{"domain": "app.reudy.net", "upstream": "127.0.0.1:18080"},
{"domain": "api.reudy.net", "upstream": "127.0.0.1:18081", "path": "/api/*"}
]
}
```
The `path` field is only present when a route has a path configured.
## Response format
All JSON responses include an `ok` boolean:
```json
{
"ok": true,
"apps": [...]
}
```
Error responses:
```json
{
"ok": false,
"error": "description",
"stderr": "panelctl error output"
}
```
## Status response
```json
{
"ok": true,
"name": "whoami",
"running": true,
"containers": [
{
"name": "whoami-app-1",
"state": "running",
"image": "docker.io/traefik/whoami:latest"
}
]
}
```
## Local test
```bash
curl -s http://127.0.0.1:9911/health | jq .
curl -s http://127.0.0.1:9911/apps | jq .
curl -s http://127.0.0.1:9911/apps/whoami/status | jq .
curl -s http://127.0.0.1:9911/apps/whoami/logs?tail=50 | jq .
curl -s http://127.0.0.1:9911/apps/whoami/backups | jq .
```

View file

@ -1,167 +0,0 @@
# panelctl quickstart
Minimal container management panel for rootless Podman + Caddy.
## Base directory
`/var/lib/containers`
## Generated structure
```
/var/lib/containers/
├── stacks/<app>/compose.yaml # Compose file per app
├── volumes/<app>/data # Persistent volumes
├── routes/routes.caddy # Single aggregate Caddy routes file
├── backups/<app>-<timestamp>.zip # Volume backups
└── state/apps/<app>.env # App manifest
```
All app routes are written to a single `routes/routes.caddy` file that Caddy imports.
## Quick workflow
```bash
# Routes are "domain|upstream[|path]" entries, comma-separated.
# Create a new app (single route, protected by Authelia)
panelctl init whoami "whoami.reudy.net|127.0.0.1:18080" true
# Create with multiple routes (different ports, optional path)
panelctl init myapp "app.reudy.net|127.0.0.1:18081,api.reudy.net|127.0.0.1:18082|/api/*" true
# Create with wildcard domain (requires DNS challenge in Caddy)
panelctl init wild "*.reudy.net|127.0.0.1:18083" false
# Change routes later (Caddy reloads automatically)
panelctl set-routes whoami "whoami.reudy.net|127.0.0.1:18080,who.reudy.net|127.0.0.1:18080"
# Deploy (compose up + caddy reload)
panelctl deploy whoami
# Check container status
panelctl status whoami
# View logs
panelctl logs whoami --tail 50
# Restart containers
panelctl restart whoami
# Stop containers
panelctl stop whoami
# Validate compose file
panelctl validate-compose whoami
# Backup volumes to zip
panelctl backup whoami
# List backups
panelctl list-backups whoami
# Restore from backup
panelctl restore whoami whoami-20260101-120000.zip
# List all apps
panelctl list
# Show app manifest
panelctl show whoami
# Remove app (keeps volumes)
panelctl remove whoami --keep-volumes
# Remove app and all data
panelctl remove whoami
```
## Notes
- The default compose file uses `traefik/whoami` for smoke testing — edit before production use.
- App names must be lowercase slugs (`[a-z0-9-]`).
- Wildcard domains (`*.example.com`) require DNS challenge in Caddy (provider-specific).
- Backups stop containers for consistency, then restart if they were running.
- If deploy reports `XDG_RUNTIME_DIR` missing, enable lingering:
```
sudo loginctl enable-linger reudy
```
## Web UI & API
- Nix runs `panel-api` as a systemd service on `127.0.0.1:9911`.
- Caddy proxies `https://panel.reudy.net` → panel-api with Authelia forward_auth.
- Open `https://panel.reudy.net` for the web UI.
- API docs: [API.md](API.md)
### Web UI features
- Live status: one `/status` poll every few seconds (faster while something is
running, paused when the tab is hidden) updates cards in place, so open tabs,
unsaved edits and scroll positions are never lost. The header shows when the
panel last synced and warns when the Authelia session has expired.
- Per-app status (running / partial / stopped), container list, and a busy
indicator that is shared between browsers while an operation runs.
- New-app dialog: starter container, pasted compose file or git repository;
suggests the next free port and a domain based on the app name.
- Compose editor with unsaved-changes tracking, Ctrl+S, save & deploy, validate.
- Logs with follow mode, routes editor with validation, file browser with
drag-and-drop upload, backups with restore (and optional redeploy).
- Git source tab: deployed commit, "check for updates", and sync & deploy.
- Activity drawer with the output of every operation (e.g. why a deploy failed).
- Keyboard: `/` search, `N` new app, `Esc` closes menus. Deep links like
`#/whoami/logs` open an app on a specific tab.
### Git-backed apps
Apps created from a repository (Forgejo, GitHub or any git host, over https or
ssh) are cloned to `stacks/<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.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,913 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_DIR="${PANEL_BASE_DIR:-/var/lib/containers}"
STACKS_DIR="${BASE_DIR}/stacks"
VOLUMES_DIR="${BASE_DIR}/volumes"
ROUTES_DIR="${BASE_DIR}/routes"
STATE_DIR="${BASE_DIR}/state"
APPS_DIR="${STATE_DIR}/apps"
ENV_DIR="${STATE_DIR}/env"
BACKUPS_DIR="${BASE_DIR}/backups"
# Set by load_app: compose file arguments, and the app's environment variables
# as KEY=VALUE words (passed to compose via env(1), never sourced).
COMPOSE_ARGS=()
APP_ENV_ARGS=()
APP_ENV_KEYS=()
FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
'
validate_route_entry() {
local entry="$1"
# Format: domain|upstream[/path] or domain|upstream (path is optional)
IFS='|' read -r domain upstream path <<< "${entry}"
[[ -n "${domain}" ]] || fail "empty domain in route entry '${entry}'"
[[ -n "${upstream}" ]] || fail "empty upstream in route entry '${entry}'"
validate_single_domain "${domain}"
# Validate upstream has a port
local upstream_port="${upstream##*:}"
[[ "${upstream_port}" =~ ^[0-9]+$ ]] || fail "upstream '${upstream}' missing numeric port in route entry '${entry}'"
validate_port "${upstream_port}"
if [[ -n "${path}" ]]; then
[[ "${path}" == /* ]] || fail "path '${path}' must start with / in route entry '${entry}'"
fi
}
validate_routes() {
local routes_str="$1"
IFS=',' read -ra entries <<< "${routes_str}"
[[ ${#entries[@]} -ge 1 ]] || fail "at least one route is required"
for entry in "${entries[@]}"; do
entry="$(echo "${entry}" | xargs)"
validate_route_entry "${entry}"
done
}
usage() {
cat <<'EOF'
panelctl - minimal app panel helper
Usage:
panelctl init <name> "<domain>|<upstream>[,...]" [auth]
panelctl set-routes <name> "<domain>|<upstream>[,...]"
panelctl render-route <name>
panelctl deploy <name>
panelctl restart <name>
panelctl stop <name>
panelctl status <name>
panelctl logs <name> [--tail N]
panelctl remove <name> [--keep-volumes]
panelctl backup <name>
panelctl list-backups <name>
panelctl restore <name> <backup-file>
panelctl volume-clear <name>
panelctl validate-compose <name>
panelctl list
panelctl show <name>
Each route is a domain|upstream pair. Upstream is host:port.
Multiple routes are comma-separated:
panelctl init myapp "app.example.com|127.0.0.1:18080,api.example.com|127.0.0.1:18081" true
Wildcard domains are supported (requires DNS challenge in Caddy):
panelctl init myapp "*.example.com|127.0.0.1:18080" true
Examples:
panelctl init whoami "whoami.reudy.net|127.0.0.1:18080" true
panelctl deploy whoami
panelctl restart whoami
panelctl status whoami
panelctl logs whoami --tail 50
panelctl backup whoami
panelctl list-backups whoami
panelctl restore whoami whoami-20260101-120000.zip
EOF
}
fail() {
echo "error: $*" >&2
exit 1
}
log() {
local level="${1:-info}"
local msg="${2:-}"
echo "${msg}" | systemd-cat -t panelctl -p "${level}" 2>/dev/null || true
}
ensure_base_dirs() {
mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}" "${BACKUPS_DIR}"
}
validate_name() {
local name="$1"
[[ "${name}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || fail "invalid name '${name}' (use lowercase slug)"
}
validate_single_domain() {
local domain="$1"
# Allow wildcard prefix *.
local check="${domain}"
if [[ "${check}" == \*.* ]]; then
check="${check#\*.}"
fi
[[ "${check}" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || fail "invalid domain '${domain}'"
[[ "${domain}" == *.* ]] || fail "domain '${domain}' must include a dot"
}
validate_domains() {
local domains_str="$1"
IFS=',' read -ra domains <<< "${domains_str}"
[[ ${#domains[@]} -ge 1 ]] || fail "at least one domain is required"
for d in "${domains[@]}"; do
d="$(echo "${d}" | xargs)" # trim whitespace
validate_single_domain "${d}"
done
}
validate_port() {
local port="$1"
[[ "${port}" =~ ^[0-9]+$ ]] || fail "port must be numeric"
(( port >= 1024 && port <= 65535 )) || fail "port must be in range 1024-65535"
}
app_manifest() {
local name="$1"
echo "${APPS_DIR}/${name}.env"
}
app_stack_dir() {
local name="$1"
echo "${STACKS_DIR}/${name}"
}
app_volume_dir() {
local name="$1"
echo "${VOLUMES_DIR}/${name}"
}
# All routes go into a single aggregate file that Caddy imports.
app_route_file() {
echo "${ROUTES_DIR}/routes.caddy"
}
# The routes file is shared by all apps and rewritten read-modify-write, so
# concurrent panelctl runs (the API handles requests in parallel) must take turns.
routes_lock() {
exec 9>"${ROUTES_DIR}/.routes.lock"
if command -v flock >/dev/null 2>&1; then
flock -w 30 9 || fail "timed out waiting for the routes file lock"
fi
}
routes_unlock() {
exec 9>&-
}
load_app() {
local name="$1"
local manifest
manifest="$(app_manifest "${name}")"
[[ -f "${manifest}" ]] || fail "app '${name}' does not exist"
# shellcheck disable=SC1090
source "${manifest}"
# Backward compat: migrate old APP_DOMAIN/APP_PORT/APP_UPSTREAM to APP_ROUTES
if [[ -z "${APP_ROUTES:-}" && -n "${APP_DOMAIN:-}" ]]; then
local upstream="${APP_UPSTREAM:-127.0.0.1:${APP_PORT:-18080}}"
local routes=""
local domains_str="${APP_DOMAINS:-${APP_DOMAIN}}"
IFS=',' read -ra domain_arr <<< "${domains_str}"
for d in "${domain_arr[@]}"; do
d="$(echo "${d}" | xargs)"
if [[ -n "${routes}" ]]; then
routes="${routes},${d}|${upstream}"
else
routes="${d}|${upstream}"
fi
done
APP_ROUTES="${routes}"
fi
load_app_env "${name}"
}
app_env_file() {
echo "${ENV_DIR}/$1.env"
}
# Generated compose override that passes the app's variables into every service.
app_env_override() {
echo "${STACKS_DIR}/$1/.panel-env.yaml"
}
load_app_env() {
local name="$1"
local file line key override
file="$(app_env_file "${name}")"
APP_ENV_ARGS=()
APP_ENV_KEYS=()
if [[ -f "${file}" ]]; then
while IFS= read -r line || [[ -n "${line}" ]]; do
[[ -z "${line}" || "${line}" == \#* || "${line}" != *=* ]] && continue
key="${line%%=*}"
[[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
APP_ENV_ARGS+=("${line}")
APP_ENV_KEYS+=("${key}")
done <"${file}"
fi
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}")
override="$(app_env_override "${name}")"
if [[ -f "${override}" ]]; then
COMPOSE_ARGS+=(-f "${override}")
fi
}
# (Re)generate the env override before containers are created. Variables are
# always available for ${VAR} interpolation; with APP_ENV_INJECT (default true)
# every service also receives them. Bare keys make compose read the values from
# its own environment, so values never have to be quoted into YAML.
prepare_env_override() {
local name="$1"
local override services svc key tmp
override="$(app_env_override "${name}")"
if [[ ${#APP_ENV_KEYS[@]} -eq 0 || "${APP_ENV_INJECT:-true}" != "true" ]]; then
rm -f "${override}"
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}")
return
fi
services="$(run_compose -f "${APP_COMPOSE_FILE}" config --services 2>/dev/null)" \
|| fail "could not list compose services to pass environment variables (is the compose file valid?)"
tmp="$(mktemp)"
{
echo "# Generated by panelctl from the app's environment variables. Do not edit."
echo "services:"
while IFS= read -r svc; do
[[ "${svc}" =~ ^[A-Za-z0-9._-]+$ ]] || continue
printf ' "%s":\n environment:\n' "${svc}"
for key in "${APP_ENV_KEYS[@]}"; do
printf ' - %s\n' "${key}"
done
done <<<"${services}"
} >"${tmp}"
install -m 0640 "${tmp}" "${override}"
rm -f "${tmp}"
COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}" -f "${override}")
}
compose_command() {
local podman_bin=""
local podman_compose_bin=""
if command -v podman >/dev/null 2>&1; then
podman_bin="$(command -v podman)"
elif [[ -x /run/current-system/sw/bin/podman ]]; then
podman_bin="/run/current-system/sw/bin/podman"
fi
if command -v podman-compose >/dev/null 2>&1; then
podman_compose_bin="$(command -v podman-compose)"
elif [[ -x /run/current-system/sw/bin/podman-compose ]]; then
podman_compose_bin="/run/current-system/sw/bin/podman-compose"
fi
if [[ -n "${podman_bin}" ]] && "${podman_bin}" compose version >/dev/null 2>&1; then
echo "${podman_bin} compose"
return
fi
if [[ -n "${podman_compose_bin}" ]]; then
echo "${podman_compose_bin}"
return
fi
fail "no compose command available (need 'podman compose' or 'podman-compose')"
}
ensure_podman_runtime_env() {
local uid
uid="$(id -u)"
if [[ -z "${HOME:-}" ]]; then
HOME="$(getent passwd "${uid}" | cut -d: -f6 || true)"
export HOME
fi
if [[ -z "${XDG_RUNTIME_DIR:-}" ]]; then
XDG_RUNTIME_DIR="/run/user/${uid}"
export XDG_RUNTIME_DIR
fi
if [[ ! -d "${XDG_RUNTIME_DIR}" ]]; then
fail "XDG_RUNTIME_DIR '${XDG_RUNTIME_DIR}' does not exist for uid ${uid}. Ensure user runtime is available (e.g. loginctl enable-linger $(id -un))."
fi
if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" && -S "${XDG_RUNTIME_DIR}/bus" ]]; then
DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
export DBUS_SESSION_BUS_ADDRESS
fi
unset DOCKER_HOST
unset CONTAINER_HOST
}
run_compose() {
local compose
compose="$(compose_command)"
ensure_podman_runtime_env
if [[ "${compose}" == *" compose" ]]; then
local podman_bin="${compose% compose}"
env "${APP_ENV_ARGS[@]}" "${podman_bin}" compose "$@"
return
fi
env "${APP_ENV_ARGS[@]}" "${compose}" "$@"
}
write_default_compose() {
local name="$1"
local routes="$2"
local stack_dir
local volume_dir
stack_dir="$(app_stack_dir "${name}")"
volume_dir="$(app_volume_dir "${name}")"
# Use first route's upstream port for the default compose mapping
local first_route="${routes%%,*}"
local first_upstream="${first_route#*|}"
local container_port="${first_upstream##*:}"
cat >"${stack_dir}/compose.yaml" <<EOF
services:
app:
image: docker.io/traefik/whoami:latest
restart: unless-stopped
ports:
- "127.0.0.1:${container_port}:80"
volumes:
- ${volume_dir}/data:/data
EOF
}
write_manifest() {
local name="$1"
local routes="$2"
local auth="$3"
local manifest
local stack_dir
local volume_dir
local route_file
manifest="$(app_manifest "${name}")"
stack_dir="$(app_stack_dir "${name}")"
volume_dir="$(app_volume_dir "${name}")"
route_file="$(app_route_file)"
cat >"${manifest}" <<EOF
APP_NAME="${name}"
APP_ROUTES="${routes}"
APP_AUTH_PROTECTED="${auth}"
APP_STACK_DIR="${stack_dir}"
APP_COMPOSE_FILE="${stack_dir}/compose.yaml"
APP_VOLUME_DIR="${volume_dir}"
APP_ROUTE_FILE="${route_file}"
EOF
}
cmd_init() {
local name="$1"
local routes="$2"
local auth="${3:-true}"
validate_name "${name}"
validate_routes "${routes}"
[[ "${auth}" == "true" || "${auth}" == "false" ]] || fail "auth must be true or false"
ensure_base_dirs
local manifest
local stack_dir
local volume_dir
manifest="$(app_manifest "${name}")"
stack_dir="$(app_stack_dir "${name}")"
volume_dir="$(app_volume_dir "${name}")"
[[ ! -f "${manifest}" ]] || fail "app '${name}' already exists"
mkdir -p "${stack_dir}" "${volume_dir}/data"
write_default_compose "${name}" "${routes}"
write_manifest "${name}" "${routes}" "${auth}"
cmd_render_route "${name}"
log info "initialized app '${name}'"
}
cmd_render_route() {
local name="$1"
validate_name "${name}"
load_app "${name}"
local auth_block=""
if [[ "${APP_AUTH_PROTECTED}" == "true" ]]; then
auth_block="${FORWARD_AUTH_BLOCK}"
fi
local route_file
route_file="$(app_route_file)"
routes_lock
# Strip any existing block for this app from the aggregate file.
local tmp
tmp="$(mktemp)"
if [[ -f "${route_file}" ]]; then
sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true
else
printf "" >"${tmp}"
fi
{
printf "# route:%s:start\n" "${name}"
IFS=',' read -ra route_entries <<< "${APP_ROUTES}"
for entry in "${route_entries[@]}"; do
entry="$(echo "${entry}" | xargs)"
IFS='|' read -r domain upstream path <<< "${entry}"
# If upstream is empty (no second pipe), this is the old format
if [[ -z "${upstream}" ]]; then
upstream="${path}"
path=""
fi
if [[ -n "${path}" ]]; then
if [[ -n "${auth_block}" ]]; then
printf "%s {\n%s reverse_proxy %s %s\n}\n" "${domain}" "${auth_block}" "${path}" "${upstream}"
else
printf "%s {\n reverse_proxy %s %s\n}\n" "${domain}" "${path}" "${upstream}"
fi
else
if [[ -n "${auth_block}" ]]; then
printf "%s {\n%s reverse_proxy %s\n}\n" "${domain}" "${auth_block}" "${upstream}"
else
printf "%s {\n reverse_proxy %s\n}\n" "${domain}" "${upstream}"
fi
fi
done
printf "# route:%s:end\n" "${name}"
} >>"${tmp}"
install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}"
rm -f "${tmp}"
routes_unlock
log info "rendered route ${route_file}"
}
cmd_deploy() {
local name="$1"
validate_name "${name}"
load_app "${name}"
log info "Starting deployment for app '${name}'"
cmd_render_route "${name}"
prepare_env_override "${name}"
# Capture compose output so callers (the web UI) can show why a deploy failed,
# and still forward it to the journal.
local output
if ! output="$(run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1)"; then
printf '%s\n' "${output}" | systemd-cat -t panelctl -p err 2>/dev/null || true
printf '%s\n' "${output}" >&2
log err "Deployment failed for app '${name}'"
fail "compose up failed"
fi
printf '%s\n' "${output}" | systemd-cat -t panelctl -p info 2>/dev/null || true
printf '%s\n' "${output}"
log info "Successfully deployed app '${name}'"
}
cmd_restart() {
local name="$1"
validate_name "${name}"
load_app "${name}"
log info "Restarting app '${name}'"
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed"
prepare_env_override "${name}"
if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1; then
fail "compose up failed during restart"
fi
log info "restarted app '${name}'"
}
cmd_stop() {
local name="$1"
validate_name "${name}"
load_app "${name}"
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed"
log info "stopped app '${name}'"
}
cmd_status() {
local name="$1"
validate_name "${name}"
load_app "${name}"
run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null || \
run_compose "${COMPOSE_ARGS[@]}" ps 2>/dev/null || \
log info "no containers running"
}
cmd_logs() {
local name="$1"
shift
validate_name "${name}"
load_app "${name}"
local tail_lines="100"
while [[ $# -gt 0 ]]; do
case "$1" in
--tail)
tail_lines="$2"
shift 2
;;
*)
shift
;;
esac
done
run_compose "${COMPOSE_ARGS[@]}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available"
}
cmd_validate_compose() {
local name="$1"
validate_name "${name}"
load_app "${name}"
if run_compose "${COMPOSE_ARGS[@]}" config >/dev/null 2>&1; then
log info "compose file is valid"
else
local output
output="$(run_compose "${COMPOSE_ARGS[@]}" config 2>&1 || true)"
fail "compose validation failed: ${output}"
fi
}
cmd_remove() {
local name="$1"
local keep_volumes="${2:-}"
validate_name "${name}"
load_app "${name}"
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true
# Remove this app's block from the aggregate routes file.
local route_file
route_file="$(app_route_file)"
if [[ -f "${route_file}" ]]; then
routes_lock
local tmp
tmp="$(mktemp)"
sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true
install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}"
rm -f "${tmp}"
routes_unlock
fi
rm -f "$(app_manifest "${name}")" "$(app_env_file "${name}")"
rm -rf "${APP_STACK_DIR}"
if [[ "${keep_volumes}" != "--keep-volumes" ]]; then
rm -rf "${APP_VOLUME_DIR}"
fi
log info "removed app '${name}'"
}
cmd_set_routes() {
local name="$1"
local routes="$2"
local manifest
validate_name "${name}"
validate_routes "${routes}"
manifest="$(app_manifest "${name}")"
[[ -f "${manifest}" ]] || fail "app '${name}' does not exist"
# Update APP_ROUTES in the manifest file, strip old fields, preserve others
local tmp
tmp="$(mktemp)"
local found_routes=false
while IFS= read -r line; do
case "${line}" in
APP_ROUTES=*)
printf 'APP_ROUTES="%s"\n' "${routes}" >> "${tmp}"
found_routes=true
;;
APP_DOMAIN=*|APP_DOMAINS=*|APP_PORT=*|APP_UPSTREAM=*)
# Strip old format fields
;;
*)
printf '%s\n' "${line}" >> "${tmp}"
;;
esac
done < "${manifest}"
if ! "${found_routes}"; then
printf 'APP_ROUTES="%s"\n' "${routes}" >> "${tmp}"
fi
install -m 0664 "${tmp}" "${manifest}"
# Re-render Caddy routes (auto-reloads via systemd.path watcher)
cmd_render_route "${name}"
log info "updated routes for app '${name}'. Edit compose file if new ports need exposing."
}
cmd_backup() {
local name="$1"
validate_name "${name}"
load_app "${name}"
ensure_base_dirs
local volume_dir
volume_dir="$(app_volume_dir "${name}")"
[[ -d "${volume_dir}" ]] || fail "volume directory '${volume_dir}' does not exist"
local timestamp
timestamp="$(date +%Y%m%d-%H%M%S)"
local backup_file="${BACKUPS_DIR}/${name}-${timestamp}.zip"
# Stop containers before backup for consistency
local was_running=false
if run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then
was_running=true
log info "stopping containers for consistent backup..."
run_compose "${COMPOSE_ARGS[@]}" down 2>/dev/null || true
fi
(cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed"
# Also include the compose file in the backup
local stack_dir
stack_dir="$(app_stack_dir "${name}")"
if [[ -f "${stack_dir}/compose.yaml" ]]; then
(cd "${stack_dir}" && zip -j "${backup_file}" compose.yaml) || true
fi
# Restart if it was running
if [[ "${was_running}" == "true" ]]; then
log info "restarting containers after backup..."
run_compose "${COMPOSE_ARGS[@]}" up -d 2>/dev/null || true
fi
local size
size="$(du -h "${backup_file}" | cut -f1)"
log info "backup created: ${backup_file} (${size})"
}
cmd_list_backups() {
local name="$1"
validate_name "${name}"
load_app "${name}"
ensure_base_dirs
local found=0
for bf in "${BACKUPS_DIR}/${name}"-*.zip; do
[[ -e "${bf}" ]] || continue
found=1
local fname size mtime
fname="$(basename "${bf}")"
size="$(du -h "${bf}" | cut -f1)"
mtime="$(stat -c '%Y' "${bf}" 2>/dev/null || stat -f '%m' "${bf}" 2>/dev/null || echo "0")"
echo "${fname} ${size} ${mtime}"
done
if [[ "${found}" -eq 0 ]]; then
log info "no backups found for '${name}'"
fi
}
cmd_volume_clear() {
local name="$1"
validate_name "${name}"
load_app "${name}"
log info "clearing volume data for app '${name}'"
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true
local data_dir="${APP_VOLUME_DIR}/data"
if [[ -d "${data_dir}" ]]; then
rm -rf "${data_dir:?}"/*
rm -rf "${data_dir:?}"/.[!.]* 2>/dev/null || true
fi
mkdir -p "${APP_VOLUME_DIR}/data"
log info "volume data cleared for app '${name}'"
}
cmd_restore() {
local name="$1"
local backup_file="$2"
validate_name "${name}"
load_app "${name}"
# Resolve backup file path
local full_path="${backup_file}"
if [[ ! -f "${full_path}" ]]; then
full_path="${BACKUPS_DIR}/${backup_file}"
fi
[[ -f "${full_path}" ]] || fail "backup file '${backup_file}' not found"
# Ensure it's a zip file within the backups directory
local norm_path
norm_path="$(realpath "${full_path}")"
local norm_backups
norm_backups="$(realpath "${BACKUPS_DIR}")"
[[ "${norm_path}" == "${norm_backups}"/* ]] || fail "backup file must be in the backups directory"
local volume_dir
volume_dir="$(app_volume_dir "${name}")"
# Stop containers before restore
log info "stopping containers for restore..."
run_compose "${COMPOSE_ARGS[@]}" down 2>/dev/null || true
# Clear existing volume data and extract backup
rm -rf "${volume_dir:?}"/*
mkdir -p "${volume_dir}"
(cd "${volume_dir}" && unzip -o "${norm_path}") || fail "unzip failed"
log info "restored '${name}' from $(basename "${norm_path}")"
log info "run 'panelctl deploy ${name}' to start the app'"
}
cmd_inspect_volumes() {
local name="$1"
validate_name "${name}"
load_app "${name}"
echo "default|${APP_VOLUME_DIR}/data"
local podman_bin=""
if command -v podman >/dev/null 2>&1; then
podman_bin="$(command -v podman)"
elif [[ -x /run/current-system/sw/bin/podman ]]; then
podman_bin="/run/current-system/sw/bin/podman"
fi
if [[ -n "${podman_bin}" ]]; then
"${podman_bin}" volume ls --filter label=com.docker.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true
"${podman_bin}" volume ls --filter label=io.podman.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true
# grep exits 1 when there are no named volumes; that is not an error.
fi | sort -u | grep -v '^$' || true
}
cmd_list() {
ensure_base_dirs
local found=0
for mf in "${APPS_DIR}"/*.env; do
[[ -e "${mf}" ]] || continue
found=1
# shellcheck disable=SC1090
source /dev/null # reset any leftover variables
unset APP_REPO_URL APP_REPO_BRANCH APP_REPO_DIR 2>/dev/null || true
source "${mf}"
# Backward compat: build APP_ROUTES from old format
local routes="${APP_ROUTES:-}"
if [[ -z "${routes}" && -n "${APP_DOMAIN:-}" ]]; then
local upstream="${APP_UPSTREAM:-127.0.0.1:${APP_PORT:-18080}}"
local domains_str="${APP_DOMAINS:-${APP_DOMAIN}}"
IFS=',' read -ra domain_arr <<< "${domains_str}"
for d in "${domain_arr[@]}"; do
d="$(echo "${d}" | xargs)"
if [[ -n "${routes}" ]]; then
routes="${routes},${d}|${upstream}"
else
routes="${d}|${upstream}"
fi
done
fi
# Show abbreviated: first route's domain + upstream, and count
local first_route="${routes%%,*}"
local route_count=1
if [[ "${routes}" == *","* ]]; then
route_count="$(( $(grep -o ',' <<< "${routes}" | wc -l) + 1 ))"
fi
local repo_info="${APP_REPO_URL:-}"
echo "${APP_NAME} ${first_route} routes=${route_count} auth=${APP_AUTH_PROTECTED} ${repo_info}"
done
if [[ "${found}" -eq 0 ]]; then
log info "no apps found"
fi
}
cmd_show() {
local name="$1"
validate_name "${name}"
local mf
mf="$(app_manifest "${name}")"
[[ -f "${mf}" ]] || fail "app '${name}' does not exist"
cat "${mf}"
}
main() {
local cmd="${1:-}"
case "${cmd}" in
init)
[[ $# -ge 3 ]] || fail "usage: panelctl init <name> <routes> [auth]"
cmd_init "$2" "$3" "${4:-true}"
;;
set-routes)
[[ $# -eq 3 ]] || fail "usage: panelctl set-routes <name> <routes>"
cmd_set_routes "$2" "$3"
;;
render-route)
[[ $# -eq 2 ]] || fail "usage: panelctl render-route <name>"
cmd_render_route "$2"
;;
deploy)
[[ $# -eq 2 ]] || fail "usage: panelctl deploy <name>"
cmd_deploy "$2"
;;
restart)
[[ $# -eq 2 ]] || fail "usage: panelctl restart <name>"
cmd_restart "$2"
;;
stop)
[[ $# -eq 2 ]] || fail "usage: panelctl stop <name>"
cmd_stop "$2"
;;
status)
[[ $# -eq 2 ]] || fail "usage: panelctl status <name>"
cmd_status "$2"
;;
logs)
[[ $# -ge 2 ]] || fail "usage: panelctl logs <name> [--tail N]"
cmd_logs "$2" "${@:3}"
;;
validate-compose)
[[ $# -eq 2 ]] || fail "usage: panelctl validate-compose <name>"
cmd_validate_compose "$2"
;;
remove)
[[ $# -ge 2 ]] || fail "usage: panelctl remove <name> [--keep-volumes]"
cmd_remove "$2" "${3:-}"
;;
backup)
[[ $# -eq 2 ]] || fail "usage: panelctl backup <name>"
cmd_backup "$2"
;;
list-backups)
[[ $# -eq 2 ]] || fail "usage: panelctl list-backups <name>"
cmd_list_backups "$2"
;;
restore)
[[ $# -eq 3 ]] || fail "usage: panelctl restore <name> <backup-file>"
cmd_restore "$2" "$3"
;;
volume-clear)
[[ $# -eq 2 ]] || fail "usage: panelctl volume-clear <name>"
cmd_volume_clear "$2"
;;
inspect-volumes)
[[ $# -eq 2 ]] || fail "usage: panelctl inspect-volumes <name>"
cmd_inspect_volumes "$2"
;;
list)
[[ $# -eq 1 ]] || fail "usage: panelctl list"
cmd_list
;;
show)
[[ $# -eq 2 ]] || fail "usage: panelctl show <name>"
cmd_show "$2"
;;
""|-h|--help|help)
usage
;;
*)
fail "unknown command '${cmd}'"
;;
esac
}
main "$@"