panel/API.md
agent 71fed39f17 Deployments, auto deploy, service routes, live logs, metrics and a terminal
Deployments: deploy, restart and git sync now run in the background, one at
a time per app (a newer request replaces a queued one). Each run is recorded
in SQLite with its log, streamed to the UI while it runs, and can be
cancelled. Any earlier deployment can be deployed again, which rolls back to
its commit, or to its saved compose file for compose apps.

Auto deploy: POST /hooks/<app>, verified with the app's secret (Forgejo,
Gitea and GitHub HMAC signatures, or the secret as a token for CI). With a
Forgejo token stored, the panel adds the webhook to the repository itself.
The NixOS module routes /hooks/* past Authelia. Caddy matches the cleaned
path but forwards the original, so the panel refuses dot segments and only
accepts webhook deliveries from that route (tagged with X-Panel-Hook).

Domains: a route can point at a compose service's container port
("web:8080"). The panel picks a free 127.0.0.1 port and panelctl publishes
it through a generated .panel-ports.yaml override, so compose files need no
ports: section. Existing host:port upstreams keep working.

Logs stream live over server-sent events, with service and text filters.
A sampler keeps an hour of CPU and memory per container for the new
Monitoring tab. The Terminal tab opens `podman exec` in a container over a
WebSocket, using xterm.js bundled by the Nix package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
2026-09-27 19:05:19 +00:00

348 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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); answers `needs_deploy` when a newly published port needs a deploy |
| POST | `/apps/<name>/deploy` | Queue a deployment (compose up + caddy reload) — see *Deployments* |
| POST | `/apps/<name>/restart` | Queue a 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: queue a sync (fetch branch, hard-reset checkout to it, deploy) |
| POST | `/apps/<name>/env` | Replace environment variables: `{"vars": [...], "inject": true, "deploy": false}` (`deploy` queues a deployment) |
| POST | `/apps/<name>/volume-clear` | Stop the app and empty its default data folder |
| POST | `/apps/<name>/autodeploy` | Auto deploy on push: `{"enabled": true, "register": true, "regenerate": false}` |
| POST | `/compose/inspect` | `{"content": "..."}` → services of a compose file and the container ports they mention |
Write operations are serialised per app. While one runs, another write to the
same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`.
Deploys, restarts and syncs are the exception: they are queued (see below).
### Deployments
`deploy`, `restart` and `repo-pull` run in the background, one at a time per
app, and answer `202` at once with the queued deployment:
```json
{"ok": true, "deployment": {"id": 12, "app": "blog", "kind": "sync", "trigger": "manual", "title": "Sync from git", "status": "queued", ...}}
```
A newer request replaces one that is still queued (that one ends as
`cancelled`, "superseded by #13"). Send `{"wait": true}` to block until it
ends; the answer then has `ok`, the `deployment` and its log in `stdout` /
`stderr`, like the old synchronous API.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/apps/<name>/deployments?limit=N` | History, newest first (the last 50 are kept, with logs) |
| GET | `/deployments/<id>` | One deployment |
| GET | `/deployments/<id>/log` | Its full log |
| GET | `/deployments/<id>/stream?offset=N` | Server-sent events: `status`, `log` (`{text, offset}`) while it runs, then `done` |
| POST | `/deployments/<id>/cancel` | Cancel a queued or running deployment |
| POST | `/deployments/<id>/redeploy` | Deploy that deployment again: its commit for git apps, its saved compose file otherwise (a rollback) |
A deployment has `status` (`queued`, `running`, `success`, `failed`,
`cancelled`), `kind` (`deploy`, `sync`, `restart`), `trigger` (`manual`,
`webhook`, `rollback`), `commit_sha` / `commit_subject` / `commit_author`
for git apps, `error`, and `created` / `started` / `finished` / `duration`.
### Webhooks (auto deploy)
`POST /hooks/<name>` deploys an app whose auto deploy is switched on. It is
routed past Authelia by the NixOS module and authenticated with the app's
secret instead: an `X-Forgejo-Signature` / `X-Gitea-Signature` /
`X-Hub-Signature-256` HMAC of the body, or the secret itself as
`X-Gitlab-Token`, `X-Panel-Token` or `?token=`. For git apps a push to another
branch is ignored; everything else queues a sync.
`GET /apps/<name>/autodeploy` returns `enabled`, the webhook `url` and
`secret`, the `branch`, whether the panel registered the webhook on Forgejo
(`forgejo.hook_id`) and the `last` delivery. Enabling it with a Forgejo token
stored adds the webhook to the repository (the token needs write access to it).
### Logs, metrics and terminal
| Method | Path | Description |
|--------|------|-------------|
| GET | `/apps/<name>/logs/stream?tail=N&service=S` | Server-sent events: `lines` (`{lines: [...]}`) as the containers write them, `end` when they stop |
| GET | `/apps/<name>/stats` | Per container: current CPU / memory / network / block IO and an hour of `[time, cpu %, memory bytes]` history |
| GET | `/apps/<name>/services` | Compose services with the container ports they mention |
| GET (WebSocket) | `/apps/<name>/terminal?container=C` | Shell in a container (`podman exec`). Send `{"type": "input", "data": "..."}` and `{"type": "resize", "cols", "rows"}`; output arrives as binary frames |
### Route targets
A route points at the container port it serves instead of an upstream:
```json
{"domain": "blog.reudy.net", "target": "web:2368"}
```
`target` is a port (`2368`, for a compose file with one service),
`service:port`, or `host:port` for something outside the app
(`127.0.0.1:8081`, same as the older `upstream` field). For service targets
the panel picks a free port in 18000–19999 and `panelctl` publishes the
container port on `127.0.0.1:<port>` through a generated
`.panel-ports.yaml` compose override, so compose files need no `ports:`.
Routes keep their port when they are saved again. In `/status` such routes
have `service` and `port` next to the `upstream` Caddy uses.
### Create app (git repository, with environment variables)
```json
{
"name": "blog",
"routes": [{"domain": "blog.reudy.net", "target": "web:2368"}],
"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.
### 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": "",
"autodeploy": false,
"busy": null,
"queued": false,
"last_deployment": {"id": 7, "status": "success", "title": "Deploy", "finished": 1790460640, "...": "..."},
"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", "target": "80"}
],
"auth": true
}
```
### Create app (multiple routes, different services)
```json
{
"name": "myapp",
"routes": [
{"domain": "app.reudy.net", "target": "web:3000"},
{"domain": "api.app.reudy.net", "target": "api:8080"}
],
"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 .
```