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