maand server
Run a local ops control plane: HTTP JSON API plus a simple HTML UI for managing multiple maand buckets.
maand server --root /path/to/server-root --token "$MAAND_SERVE_TOKEN"
# optional HTTPS:
# maand server --root /path/to/server-root --token "$MAAND_SERVE_TOKEN" \
# --tls-cert /path/cert.pem --tls-key /path/key.pem
# optional TOTP login 2FA (requires TLS):
# maand server totp-enroll --totp-secret-file ./server-totp.secret
# maand server --root /path/to/server-root --token "$MAAND_SERVE_TOKEN" \
# --tls-cert /path/cert.pem --tls-key /path/key.pem \
# --totp-secret-file ./server-totp.secret
Default listen address is 127.0.0.1:9090 (loopback). A bearer token is required (--token or MAAND_SERVE_TOKEN).
Open http://127.0.0.1:9090/ (or https://… when TLS PEMs are set). The UI
prompts for the API token once and exchanges it for a signed, 12-hour,
HttpOnly, SameSite=Strict browser session (maand_server_session); the bearer
token is not persisted or exposed to JavaScript after login. When TOTP is enabled
(HTTPS only), the UI then prompts for a 6-digit authenticator code and sets a
separate maand_totp_session cookie (default TTL 30m, configurable with
--totp-session-ttl). These are independent lifetimes: renew TOTP without
re-entering the API token while the 12-hour session is still valid.
curl -H "Authorization: Bearer $MAAND_SERVE_TOKEN" http://127.0.0.1:9090/api/buckets
Interactive API docs (Swagger UI): http://127.0.0.1:9090/docs/ (same auth as the API)
OpenAPI 3 spec: http://127.0.0.1:9090/openapi.yaml
Swagger UI and /openapi.yaml are served from the binary and work offline.
Auth and TLS
| Flag / env | Behavior |
|---|---|
--token / MAAND_SERVE_TOKEN |
Required. API clients send Authorization: Bearer …; the UI exchanges it for an HttpOnly maand_server_session cookie. |
--addr |
Default 127.0.0.1:9090. Non-loopback binds (0.0.0.0, :port, …) require TLS. |
--tls-cert + --tls-key |
Required together for HTTPS, for any non-loopback listen address, and whenever TOTP is enabled. |
--totp-secret-file / MAAND_SERVE_TOTP_SECRET_FILE |
Optional. When set, every authenticated request also requires TOTP 2FA (login). Requires --tls-cert / --tls-key. |
--totp-session-ttl |
Lifetime of maand_totp_session after verify (default 30m). |
--totp-issuer |
Label shown in authenticator apps (default maand). |
/api/*, /openapi.yaml, and /docs* require auth. The session-login endpoint,
login shell (/, /index.html), and static UI assets (.js, .css, …) do not.
Unsafe cookie-authenticated requests require a matching same-origin Origin and
X-Maand-CSRF: 1; bearer-header clients are unaffected. Bearer failures are
limited to 20 per direct peer IP per minute.
Responses include security headers (Content-Security-Policy, X-Content-Type-Options, X-Frame-Options, Referrer-Policy; HSTS when TLS is enabled).
TOTP login 2FA (optional)
When --totp-secret-file is set, --tls-cert and --tls-key are required (server command refuses to start without them). A valid bearer token alone is not enough. Every authenticated path (/api/*, /openapi.yaml, /docs*) also requires TOTP — including reads and run_command. There is no separate step-up for dangerous ops. The maand_totp_session cookie is always Secure.
Clients must present:
- An HttpOnly
maand_totp_sessioncookie fromPOST /api/auth/totp/verify, or - A fresh
X-Maand-TOTP: <6-digit>header on each request
Exempt from TOTP (bearer still required for verify): POST /api/auth/totp/verify. Public UI assets (/, .js, .css, …) stay unauthenticated.
Bearer and TOTP failures use separate in-memory limits. Invalid codes sent to
either the verify endpoint or X-Maand-TOTP are limited to 20 per direct peer
IP and 100 globally per minute; missing TOTP does not count. A successful TOTP
clears that IP's TOTP failures only. The global rolling budget is never cleared
by a successful login and expires with the one-minute window. Limits use the
socket peer address, ignore forwarding headers, and reset when the server
restarts. Exhausted limits return HTTP 429.
maand server totp-enroll
Enrollment is CLI-only so a stolen bearer token cannot fetch the secret over HTTP. Enroll does not require TLS; starting the server with the secret does.
maand server totp-enroll --totp-secret-file ./server-totp.secret
maand server totp-enroll --totp-secret-file ./server-totp.secret \
--totp-account ops@prod --totp-issuer maand
# Scan the printed QR with Google Authenticator / Authy / 1Password
maand server --token "$MAAND_SERVE_TOKEN" \
--tls-cert /path/cert.pem --tls-key /path/key.pem \
--totp-secret-file ./server-totp.secret
| Flag / env | Description |
|---|---|
--totp-secret-file / MAAND_SERVE_TOTP_SECRET_FILE |
Path to write (new) or read (existing) the base32 secret (0600). Default for enroll: ./server-totp.secret. |
--totp-issuer |
Issuer label in authenticator apps (default maand) |
--totp-account |
Account label in authenticator apps (default maand-server@<hostname>) |
Prints issuer, account (maand-server@<hostname>), raw base32 secret, otpauth:// URI, and an ASCII QR. Keep the file mode 0600; never commit it.
API usage
# Session cookie (best for many API calls)
curl -c cookies.txt -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"code":"123456"}' https://127.0.0.1:9090/api/auth/totp/verify
curl -b cookies.txt -H "Authorization: Bearer $TOKEN" \
https://127.0.0.1:9090/api/buckets
# Per-request header — needs a FRESH code each call (replay rejected)
curl -H "Authorization: Bearer $TOKEN" -H "X-Maand-TOTP: 123456" \
https://127.0.0.1:9090/api/buckets
Missing/invalid TOTP → 403 {"error":"totp_required"}. Invalid code on verify → 401. TOTP not enabled → verify returns 404.
Notes
- TOTP cannot be enabled without TLS (
--totp-secret-filewithout cert/key fails at startup). - The UI remembers the 30-minute session expiry for the current tab and asks for a fresh authenticator code shortly before it expires. If the server expires the cookie first, the next API request asks the user to renew and retries once.
- Used TOTP codes are rejected within their validity window (replay protection). Prefer the session cookie for scripts making multiple calls.
- One shared secret for all operators; lost phone → re-run
totp-enrollon the host to re-scan. - Keep the server host clock accurate (NTP); large drift makes all codes fail.
- TOTP raises the bar against stolen tokens; it does not stop XSS session-riding on an already-open browser session.
/docs*and/openapi.yamlneed a TOTP session once enabled (browser sends the cookie after verify).- Omit
--totp-secret-filefor token-only auth (unchanged default).
Server root
--root (or MAAND_SERVE_ROOT) is a directory that contains managed buckets. It must not itself be a maand bucket (maand.conf / data/maand.db) or a bucket directory (bucket.json). Prefer a fresh empty directory outside any Git checkout of maand (for example /tmp/maand-server-root). If the path does not exist, the server creates it (MkdirAll).
<server-root>/
{uuid}/
bucket.json # { "project", "name" }
maand.conf
workspace/
data/
…
Each {uuid}/ is a full maand bucket. The directory name must be a valid UUID — that id is the bucket id and matches bucket_id after init. Non-UUID names are rejected on create.
Ownership boundary
The operator's local, Git-managed bucket is authoritative for maand.conf,
workspace/, and templates/. Those paths are read-only through the server API
and UI; maand push and source rollback are the only remote source mutations. The source-file API
only reads regular files in those paths and rejects symlinks.
Server owns bucket.json, data/, logs/, secrets/, and .ssh/. Bucket
create/update/delete, SSH-key upload/removal, worker trust, build, deploy, probes,
job controls (including stop with forget), hooks, GC, snapshots, init/upgrade,
run cancellation, and all read-only catalog views remain available remotely. The
source-file API permanently excludes data/, logs/, secrets/, .ssh/, and
tmp/. Remotely you cannot run worker facts --generate-workers (facts/uptime/sysstat
probes only); regenerate workers.json in the local Git bucket and push.
HTTP timeouts
Fixed server limits (not configurable via flags):
| Timeout | Value | Why |
|---|---|---|
| ReadHeader | 10s | Slowloris / header stall protection |
| Read | 10m | Large push uploads |
| Write | 90s | Response write including run-log long-polls (client timeout up to 60s) |
| Idle | 120s | Keep-alive idle connections |
| MaxHeaderBytes | 1 MiB | Oversized request headers |
API (prefix /api)
Full per-endpoint reference: open /docs/ on a running server instance (or fetch /openapi.yaml with the bearer token).
| Area | Endpoints |
|---|---|
| Auth | POST/DELETE /api/auth/session; POST /api/auth/totp/verify (when TOTP enabled) |
| Buckets | GET/POST/PUT/DELETE /api/buckets… |
| Source settings | GET …/settings/maand, …/settings/bucket, …/settings/job_resources/{selector}, …/files/{path} (read-only); GET …/templates; GET …/workspace/disabled.json |
| Source revisions | GET …/source/revisions; POST …/source/rollback |
| SSH key | GET/PUT/DELETE …/secrets/ssh_key |
| Workers | GET …/workers, POST …/workers/{trust|facts|uptime|sysstat}; worker definitions are pushed source |
| Ship | POST …/push, …/build, …/deploy, …/health_check, …/gc, …/run_command, …/hooks → { "run_id" } |
| Runs | GET …/runs, …/runs/{id}, long-poll …/runs/{id}/logs?after=&timeout=, cancel POST …/runs/{id}/cancel |
| Catalog | GET …/info, …/cat/{workers|jobs|allocations|deployments|hooks|ports|kv|certs} — no prometheus resource (CLI-only: maand cat prometheus) |
| Jobs | files GET …/jobs/{job}/files…; start/stop/restart/status remain operational actions (stop accepts forget query/body) |
Long-running build/deploy/health/gc/hooks/run_command/worker probes/snapshot/job control: poll run status and long-poll logs.
Push user-owned content
From a separate user-owned bucket, run local maand build and then:
MAAND_SERVE_TOKEN=... maand push https://127.0.0.1:9090 --bucket <uuid>
Push replaces maand.conf, workspace/, and templates/, clears tmp/,
preserves the bucket database/logs/secrets/SSH state and .maand/ revisions,
and starts build as one bucket-scoped run. It never deploys. Extraction failure
restores the previous source; build failure keeps the new source as current
while last successful remains the prior good revision. Operators can inspect
Activity, push a fix, or maand push rollback / POST …/source/rollback.
Each accepted push retains an immutable revision and writes durable metadata
plus its terminal build result to logs/runs/<run-id>/maand.log. On server
startup, maand recovers interrupted push artifacts (orphan upload dirs and
incomplete rollback staging) before accepting new traffic. See
push.md.
Exclusivity and cancel
| Rule | Behavior |
|---|---|
| Per-bucket worker | At most one async worker runs per bucket. A second start for that bucket returns 409; unrelated buckets run concurrently in isolated child processes. |
| Worker state | Each worker fixes its bucket globals, KV/key, and hook runtime state for its lifetime. Its stdout and stderr are captured in that run's Activity. |
| Per-bucket flock | Each worker inherits and holds data/maand.lock (advisory flock) through cleanup and forced termination. The CLI acquires the same lock for bucket-scoped commands (init / server excluded). Same-bucket overlap → CLI error bucket busy: another maand operation is in progress, or server 409. |
| Cancel | POST …/runs/{runId}/cancel signals the worker for graceful cleanup, then force-kills it after a bounded wait. Status remains running until the worker exits, then becomes cancelled; an accepted cancel cannot later report success. |
| Run status | running · succeeded · failed · cancelled |
Cancellation first asks the isolated worker to stop so queued phases and worker
probes are skipped, retry sleeps end, and local SSH, rsync, hook, and trust
subprocesses can unwind. After the bounded grace period the server force-kills
the worker. Cancelling local transport does not prove that every remote or
detached descendant stopped: a process that daemonized, used nohup, or created
a new session may survive transport loss. The inherited bucket lock remains held
through graceful cleanup and forced worker termination.
Durable run history
Server stores each accepted run under
<bucket>/logs/runs/<run-id>/. server-run.json contains status,
timestamps, errors/results, and audit entries; activity.log contains the exact
Activity output. The run list and log APIs reload these files when the server starts,
so the UI history and latest Activity output survive process and host restarts.
If the server stops while a run is active, the next startup marks that retained run
as failed with server restarted before run completed; it is never treated as a
still-running operation. Completed history follows the existing
log_run_retention_count and log_run_retention_days pruning settings because
the complete record lives in the normal run directory.
UI
Vanilla HTML/JS embedded in the binary.
Can (remote):
- Create / rename / delete buckets; upload or remove the worker SSH private key; worker trust (including force)
- Init / upgrade schema; snapshot backup/list/restore; build, deploy, health_check, gc
- Run command, hooks (
cli), job start/stop/restart/status (including stop forget), worker facts/uptime/sysstat - Read-only catalog, source files, templates,
disabled.json, deployments, Activity with cancel
Cannot (edit locally + push):
- Mutate
maand.conf,workspace/(includingworkers.jsonanddisabled.json), ortemplates/ - Regenerate
workers.jsonviaworker facts --generate-workers - Author jobs from templates in the UI (scaffolds stay on the local Git bucket)
UI surfaces:
- Projects / buckets
- Workers (read-only definitions plus facts, uptime, sysstat, and trust actions)
- Read-only source configuration plus server-owned SSH key, init/upgrade, snapshot, and bucket controls
- Catalog filters
- Run command — shell on workers (
maand run_command) with workers/labels, concurrency, health check / ignore-failure - Hooks — run
clievent hooks (maand hooks <hook> [job]) from the catalog; Activity streams output - Deployments — ship/operate deployed jobs: tabs Jobs and Rollout
- Jobs — read-only catalog view; templates and source job changes remain in the local Git bucket and arrive through push
- Activity panel — DevTools-style logs, automatic replay of the latest retained run, and Cancel for the active run; one Activity stream may be visible, while the UI blocks starts only for that stream's bucket and allows unrelated buckets to run concurrently
- API token control in the top bar (and TOTP prompt when
--totp-secret-fileis set)
Job authoring (manifests/Makefiles), worker definitions, disabled state, and configuration edits happen in the local Git bucket. The UI can view that source, but cannot mutate it.