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:

  1. An HttpOnly maand_totp_session cookie from POST /api/auth/totp/verify, or
  2. 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

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):

Cannot (edit locally + push):

UI surfaces:

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.