MaidCafe is the backend for MaidKit-managed hosts. It contains two separate runtime modes:
- Cloud: a PostgreSQL-backed control plane for daemon registration, credentials, metrics, and notifications.
- Daemon: a local named-webhook runner for managed hosts. It can run fully offline or publish metrics and selected webhook notifications to the cloud over ordinary HTTP(S) POST requests.
The daemon has no database, queue, NATS connection, Solar authentication client, or inbound cloud-control channel. Cloud-to-daemon control is intentionally not implemented.
- Gin HTTP server with recovery middleware and request validation.
- PostgreSQL persistence through GORM and startup
AutoMigrate. - Solar Network bearer-token authentication for user routes.
- Workspace membership checks through the Solar workspace service
(
DyWorkspaceService): every daemon belongs to a workspace, and any member of that workspace can manage its daemons and notifications. - Daemon registration with one-time randomly generated credentials.
- bcrypt storage for daemon credentials; plaintext secrets are never persisted.
- Secret rotation that invalidates the previous secret.
- Soft daemon deletion: disables the daemon, removes metrics, and keeps the row as an audit record.
- Daemon metrics ingestion with server-side receive timestamps.
- Notification persistence with bounded kind, title, body, and metadata fields.
- Optional NATS/event-bus fan-out using the event type
maidcafe.notification.v1. - Durable notification listing with unread filtering, daemon filtering, limits, cursor pagination, and idempotent read acknowledgement.
- Workspace quota exposure: the effective plan quota (
max_daemons,polling_interval_seconds,metrics_retention_days) is served to workspace members (GET /api/workspaces/:id/quota) and to daemons for self-tuning (GET /api/daemons/:id/quota). - Unauthenticated health endpoint:
GET /health
{"ok":true,"mode":"cloud"}
- Gin HTTP routing with recovery middleware.
- Loopback default listen address:
127.0.0.1:8747. - Named, immutable webhook configuration.
- Bearer-secret authentication with constant-time comparison.
- Disabled and unknown webhooks return
404. - Request bodies are opaque stdin bytes. They are never parsed, templated, or appended to command arguments.
- Commands run directly with
exec.CommandContext; the daemon never invokessh -c(script actions apply their working directory with acdline prepended to the rendered body instead). - Static configured arguments only.
- Absolute command paths and controlled working directory.
- Per-hook
cwd(absolute working directory),env(KEY=VALUEassignments),user(run as another account), anddisplayName(optional human-readable label;namestays the API slug and is what audit records and the/api/v1/actions/:nameroute use). userruns are delegated tosudo -H -u <user>, so the daemon process itself stays unprivileged. Environment assignments are passed as command-lineVAR=valueentries (sudo applies them on top of its reset environment) and the working directory is applied without sudo-D(which default sudoers rules reject): script bodies carry acdline prepended by the executor, and plain commands inherit the daemon-set working directory, since sudo does not reset it. The executed command is always the configured absolute path, never a shell wrapper, so the sudoers rule matches it. The sudoers rule granting the daemon the right to run MaidKit-deployed scripts as the configured users is installed by MaidKit; hand-configured entries must provide their own rule.- Script actions that run as another user render their substituted body next
to the deployed script under a hidden
.rundirectory (0755, created on demand), so the target account can read and execute them; the daemon user needs write access to the scripts directory for that to work. - Request body size limits.
- Maximum concurrent execution limit with
429on exhaustion. - Script timeout handling with
504responses. - Non-zero exit handling with
502responses. - Bounded stdout/stderr capture in JSON responses.
- Atomic success/failure execution counters.
- Scheduled jobs (
daemon.jobsDirfragments): cron expressions or@everydescriptors targeting any configured action or native operation, run through the same executor (audit, concurrency, timeout) with optionaljob.failurenotifications. - Container log tracking: running containers are tailed incrementally
(
logs --since) on the logs cadence, appended to per-container JSONL files (rotated at 512 KiB, one generation, pruned bymetricsRetentionDays) and pushed to SSElogssubscribers;GET /api/v1/containers/:id/logsreturns the tail window. - Hot reload: the daemon watches its config file and the action/alarm/job
fragment directories,
systemctl reload/SIGHUP re-reads them, andPATCH /api/v1/configpatches a safe subset of[daemon]keys — in every case the reloadable components (hooks, jobs, alarms, runtimes, watched processes, intervals, limits, cloud endpoint) swap without a restart. Transport, listen address, storage paths and the metrics secret remain restart-required. - Public health endpoint that exposes only daemon mode and ID:
GET /health
{"ok":true,"mode":"daemon","id":"..."}
Set daemon.transport = "stdio" to run without a listening HTTP port. MaidKit
starts /usr/local/bin/maidcafe-daemon over SSH and exchanges newline-delimited
JSON on stdin/stdout:
{"type":"request","id":"1","action":"health"}
{"type":"request","id":"2","action":"metrics"}
{"type":"request","id":"3","action":"action","name":"backup","body":{"job":"incremental"}}The daemon emits a ready event, periodic metrics events, and one response
per request. SSH authentication is the transport boundary for actions, so
daemon.actions entries use fixed absolute commands and fixed argument lists
without webhook secrets. MaidKit's server detail page installs this mode and
lets the user define those action presets.
- Publishes metrics on every
metricsIntervaltick. - Publishes configured action metadata on every metrics tick.
- When
logsUploadEnabled = true, batches captured container log lines toPOST /api/daemons/:id/logs; failed uploads remain in a bounded daemon-local buffer and workspacemetrics_retention_daysprunes cloud rows. - Publishes daemon-side log alert notifications through the same notification pipeline as metric alarms.
- Uses request deadlines and disables cross-host redirect following.
- Drops failed publishing attempts without failing local execution.
- Does not retain an unbounded retry queue.
Cloud publishing is disabled when either setting is empty. Log upload is
separately opt-in even when cloud publishing is configured. HTTPS is required,
except for HTTP development URLs using localhost or 127.0.0.1.
- Container status upload: when
statusUploadEnabled = true, the daemon publishes the managed container set's status (state, image, compose project) to the cloud on the metrics tick, so managed hosts can be inspected centrally from one place. An optional managed allowlist (managedContainers,managedComposes) scopes both log upload and status upload to a curated set; an empty allowlist uploads every container. Status carries no secrets.
User routes require a Solar bearer token. Daemon ingestion routes require the registered daemon secret instead; a daemon secret cannot access user routes.
| Method | Route | Purpose |
|---|---|---|
POST |
/api/daemons |
Create a daemon in a workspace; returns the one-time secret |
GET |
/api/daemons?workspace_id= |
List daemons in a workspace |
GET |
/api/daemons/:id |
Read a workspace daemon |
PATCH |
/api/daemons/:id |
Change name or enabled state |
POST |
/api/daemons/:id/rotate-secret |
Rotate the one-time secret |
DELETE |
/api/daemons/:id |
Disable daemon and delete its metrics |
POST |
/api/daemons/:id/metrics |
Ingest daemon metrics |
POST |
/api/daemons/:id/logs |
Ingest a bounded batch of container log lines |
POST |
/api/daemons/:id/notifications |
Create a daemon notification |
Create a daemon inside a workspace you belong to:
curl -X POST http://localhost:8080/api/daemons \
-H 'Authorization: Bearer <solar-token>' \
-H 'Content-Type: application/json' \
-d '{"workspace_id":"<workspace-id>","name":"managed-host-01"}'The response contains id, workspace_id, name, and secret. Store the
secret in the managed host configuration. It is not returned by list or read
endpoints.
| Method | Route | Purpose |
|---|---|---|
GET |
/api/daemons/:id/logs?container_id=&limit= |
List uploaded logs for workspace members |
Uploaded rows follow the workspace's metrics_retention_days quota and are
bounded to 500 lines per request, with each line capped at 4096 bytes.
| GET | /api/notifications?workspace_id= | List workspace notifications |
| POST | /api/notifications/:id/read | Mark a workspace notification read |
GET /api/notifications requires workspace_id and additionally supports
and an RFC3339 before cursor.
Full walkthrough for configuring and invoking actions and webhooks, including
the cloud relay, lives in docs/webhooks.md.
Webhook requests carry an HMAC signature over the body, keyed by the webhook's own secret:
POST /api/v1/webhooks/:name
X-MaidCafe-Signature: <hex HMAC-SHA256 of the raw body keyed by the webhook secret>
Example:
SECRET='replace-with-local-webhook-secret'
BODY='{"job":"incremental"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -X POST http://127.0.0.1:8747/api/v1/webhooks/backup \
-H "X-MaidCafe-Signature: $SIG" \
--data-binary "$BODY"Successful execution returns 200; non-zero exit returns 502; timeout returns
504; oversized bodies return 413; exhausted concurrency returns 429.
Metrics and configured actions use the daemon metrics secret:
GET /health
GET /api/v1/metrics
POST /api/v1/actions/:name
GET /api/v1/audit?limit=N
Authorization: Bearer <metrics-secret>
Every execution — HTTP webhooks, actions, and cloud-relayed webhooks — is
appended to daemon.auditPath (default /var/lib/maidcafe/audit.jsonl) as
one JSON line per run: timestamp, name (API slug), optional display_name,
source (http | stdio | relay), ok, exit_code, duration_ms, and a
truncated failure reason. The file rotates at 1 MiB keeping one generation
(audit.jsonl.1). Logging is best-effort: an unwritable path disables it with
a warning and never affects execution. GET /api/v1/audit?limit=N returns the
newest entries (default 50, max 500) newest first, authenticated with the
metrics secret.
GET /api/v1/stream serves a Server-Sent Events stream of realtime daemon
state over HTTP, authenticated with the same metrics secret:
GET /api/v1/stream?events=metric,containers,images,processes,systemd
Authorization: Bearer <metrics-secret>
eventsis a comma-separated whitelist of event types; omitted means all. Unknown names return400.processesLimitoverrides how many processesprocessesframes carry for this subscriber:0requests the complete process table (the default for this parameter is the daemon's configuredprocessesLimit). Valid values are0or1..10000; anything else returns400. Each subscriber is capped independently, so one client can ask for everything while another keeps the default slice.- Every frame is standard SSE (
event:/data:lines terminated by a blank line); a: pingcomment is emitted every 15s while idle. - The first frame is always
hello, carrying the stream version, daemon version, and the configured collection intervals in seconds so clients can detect staleness:
{"stream":"v1","version":"0.1.0","intervals":{"metric":1,"containers":5,"images":60,"processes":10,"systemd":30}}metricframes are the same payload asGET /api/v1/metrics, delivered everystreamInterval(default1s) while at least one client subscribes.containersframes (everycontainersInterval, default5s) report the podman/docker container list, including the compose project extracted from container labels.imagesframes (everyimagesInterval, default60s) report the podman/docker image list (id,tags,size,created,digest).processesframes (everyprocessesInterval, default10s) report the topprocessesLimit(default50, valid1..500) CPU consumers.systemdframes (everysystemdInterval, default30s) report the merged systemd unit list, including enabled-but-inactive units.- Container and image listing retries through
sudo -nwhen the direct query fails or returns nothing and the daemon is not root, so root-owned containers stay visible to a non-root daemon (e.g. the systemdmaidcafeuser) when passwordless sudo is available. The retry is never interactive. A failed direct query keeps its own error; an empty direct listing whose elevated retry also fails is reported as an error rather than a misleading empty list, so invisible root-owned containers never look like "no containers". - Setting a collector interval to
0disables that collector. Collection is gated on active subscribers and never persists or writes to disk; metrics persistence and cloud publishing stay onmetricsInterval.
The same state the stream pushes is also available as one-shot responses, so clients can paint first data from the daemon instead of an SSH fallback. They reuse the stream collectors' probe cache and rate limits:
GET /api/v1/containers— same payload as thecontainersevent: aruntimeslist covering every runtime found on the host (podman first), each withruntime,available,errorandcontainers.GET /api/v1/images— same payload as theimagesevent: aruntimeslist, each withruntime,available,errorandimages.GET /api/v1/processes— same payload as theprocessesevent.?limit=Noverrides the daemon's configuredprocessesLimit:0returns the complete process table (no cap), a positive value keeps the top N CPU consumers. Valid values are0or1..10000; anything else returns400.GET /api/v1/systemd— same payload as thesystemdevent.
All four are authenticated with the same metrics secret and cost one collection on demand; repeated calls are rate-limited by the shared probe cache.
Webhook secrets are separate from the metrics secret. The daemon does not provide an HTTP configuration API; MaidKit updates the managed TOML configuration over SSH and restarts the service when needed.
The daemon also executes typed mutations directly — container lifecycle,
process kill, systemd unit actions and compose project actions — mirroring
what MaidKit's SSH layer can do, so a managed host can be operated through the
daemon (locally over HTTP, over the SSH stdio pipe, or remotely through the
cloud relay) without a workstation SSH session. Unlike script actions, native
ops never interpolate caller input into a shell: targets are validated against
the same patterns MaidKit enforces client-side, and commands run directly with
exec.CommandContext (no sh -c). Root-owned resources are reached with a
sudo -n retry mirroring the collectors; under the shipped systemd unit's
NoNewPrivileges that retry is inert, so such ops fail with a clear error and
MaidKit falls back to SSH.
POST /api/v1/containers/:id/:action action = start|stop|restart|pause|unpause|kill|remove
POST /api/v1/processes/:pid/kill
POST /api/v1/systemd/:unit/:action action = start|stop|restart|reload|enable|disable
POST /api/v1/compose/:project/:action action = up|stop|restart|pull|recreate
All four are authenticated with the metrics secret and a body signature, like
the actions route. POST /api/v1/containers/:id/remove accepts {"force": true} (mapped to rm -f); POST /api/v1/compose/:project/:action requires
{"directory": "<absolute path>"} — compose resolves its file from the
working directory, so the path must hold the compose file. Container ops
resolve the runtime with the shared probe (podman first) and fall back to the
other runtime when the container is not found there. Every run is appended to
the audit log under its slug (container.restart, process.kill, …).
The slugs are also valid through the cloud relay (POST /api/daemons/:id/webhook-requests with name: container.restart and the
identity in the body: {"id": "…"}, {"pid": 123}, {"unit": "…"},
{"project": "…", "directory": "…"}) and over the SSH stdio pipe (request
action = the slug, same body). They are reported to the cloud on every
metrics tick, so the cloud page lists them as invocable, and the slugs are
reserved — a webhook or action may not use one, keeping the relay name space
unambiguous.
Jobs run a configured action or native operation on a schedule. Each job is a
<slug>.toml fragment under daemon.jobsDir (default
/etc/maidcafe/jobs), merged at load like action and alarm fragments, or an
inline [[daemon.jobs]] entry:
name = "nightly-backup"
schedule = "0 3 * * *" # five-field cron, or "@every 30s", "@hourly"...
action = "backup" # a configured action name or native op slug
body = { job = "incremental" }
enabled = true
notifyOnFailure = truescheduleaccepts standard five-field cron expressions and robfig/cron descriptors (@every 30s,@hourly,@daily).actiontargets a configured action or a native operation slug (container.restart,process.kill, …);bodyis the JSON object passed to it (script template variables or native-op identity parameters).- Runs go through the same executor as manual invocations: audit log entries
with
source: "job"andinvoked_by: "job:<name>", the shared concurrency slot and per-run timeout. Overlapping runs are skipped, never stacked. notifyOnFailurepublishes ajob.failurenotification when a run fails.
-
The daemon tails every running container on
daemon.logsInterval(default30s,0disables) with<runtime> logs --since <cursor> --timestamps <id>(initial run backfills the last 200 lines), reusing the runtime probe andsudo -nretry. New lines are: -
appended to per-container JSONL files under
daemon.logsDir(default/var/lib/maidcafe/logs), rotated at 512 KiB with one generation and pruned bymetricsRetentionDays; -
pushed to SSE
logssubscribers as{"container": "<id>", "lines": [{"ts": ..., "line": ...}]}frames; -
served by
GET /api/v1/containers/:id/logs?lines=N(1–1000, default 200) as the tail window, oldest first; -
uploaded to the cloud only when
logsUploadEnabled = true, batched everylogsUploadInterval(default30s) up tologsUploadBatchLines(default 100), through the daemon-secret endpointPOST /api/daemons/:id/logs. Failed uploads remain queued in a bounded local buffer and retry; old cloud rows follow the workspacemetrics_retention_daysquota.
Log alerts are daemon-side regex fragments under daemon.logAlertsDir
(default /etc/maidcafe/log-alerts):
name = "errors"
pattern = "(?i)\\berror\\b|\\bfail(ed|ure)?\\b"
container = "web" # omit to match every container
title = "Web container error"
cooldownSeconds = 300
enabled = trueEach matching line publishes daemon.log_alert through the existing cloud
notification pipeline. Alerts are local and regex-based; the raw line is
included as the bounded notification body, with alert/container/timestamp
metadata. Cooldowns are tracked independently per alert and container.
Three triggers re-read the configuration: file changes (the config file and
the action/alarm/job fragment directories are watched, debounced), SIGHUP
(systemctl reload maidcafe-daemon — the shipped unit carries
ExecReload=/bin/kill -HUP $MAINPID), and the config API. A reload validates
the result and swaps the reloadable components atomically: webhook/action
tables, jobs, alarms, runtime groups, watched processes, collector intervals,
timeouts, body limits, concurrency, and the cloud endpoint. A failed load or
validation keeps the previous state; the daemon never restarts itself.
GET /api/v1/config returns the merged configuration with secrets redacted
(metrics/cloud secrets and webhook secrets are never exposed) plus the
fragment contents. PATCH /api/v1/config patches a safe subset of [daemon]
keys — intervals, processesLimit, scriptTimeout, maxBodyBytes,
maxConcurrentRuns, runtimes, watchedProcesses, cloudUrl,
cloudSecret — into config.toml (preserving every other line verbatim),
then hot-reloads; a failed reload restores the previous file. Unsupported or
invalid keys are rejected with 400 before any write.
Restart-required settings — transport, listen, metricsSecret, storage
paths, audit path — are read-only over the API, and patching them is
rejected. The daemon writes its own config file, so the systemd unit grants
it ReadWritePaths=/etc/maidcafe; the config API needs the daemon user to be
able to write that directory (a hand-rolled install must allow it too).
Example response:
{
"ok": true,
"name": "backup",
"exit_code": 0,
"stdout": "...",
"stderr": ""
}Use config.cloud.example.toml for cloud mode and
config.daemon.example.toml for daemon mode.
Configuration is typed TOML loaded through Viper and can also be selected with
CONFIG_PATH.
Cloud requires:
database.dsnauth.targetworkspace.targethttp.port(default8080)
Daemon requires:
daemon.id- Valid positive durations and resource limits.
- Webhook names matching
[A-Za-z0-9._-]+. - Absolute webhook command paths.
- Unique webhook names.
Daemon cloud publishing is optional. An empty cloud URL and secret are valid.
go run ./cmd/cloud --config config.cloud.example.toml
go run ./cmd/daemon --config config.daemon.example.tomlUseful Make targets:
make build
make build-cloud
make build-daemon
make test
make tidyThe Docker image builds cloud mode only and uses a non-root distroless runtime image:
docker build -t maidcafe-cloud .
docker run --rm \
-p 8080:8080 \
-v "$PWD/config.cloud.toml:/etc/maidcafe/config.toml:ro" \
-e CONFIG_PATH=/etc/maidcafe/config.toml \
maidcafe-cloudFor PostgreSQL plus the cloud service, copy
docker-compose.example.yml, then replace the
example database credentials and Solar auth target.
The daemon is intended to run directly on managed hosts under systemd, not in the cloud Docker image.
Create a service account:
sudo useradd --system --home /var/lib/maidcafe --create-home maidcafeBuild and install:
make build-daemon
sudo install -o root -g root -m 0755 bin/maidcafe-daemon /usr/local/bin/maidcafe-daemon
sudo install -d -o root -g maidcafe -m 0750 /etc/maidcafe
sudo install -o root -g maidcafe -m 0640 config.daemon.toml /etc/maidcafe/config.toml
sudo install -o root -g root -m 0644 deploy/maidcafe-daemon.service \
/etc/systemd/system/maidcafe-daemon.service
sudo systemctl daemon-reload
sudo systemctl enable --now maidcafe-daemonThe unit runs as maidcafe, restarts after failure, and applies systemd
hardening. Webhook commands must be readable and executable by that account.
Actions that run as another account (user = "..." in [[daemon.actions]]
or [[daemon.webhooks]]) are executed through sudo and render their
substituted scripts under /etc/maidcafe/actions/run. For those, the unit
needs NoNewPrivileges=false (sudo's setuid bit) and ReadWritePaths=/etc/maidcafe/actions,
and the daemon account needs a sudoers rule such as:
sudo install -o root -g root -m 0440 /dev/stdin /etc/sudoers.d/maidcafe-actions <<'EOF'
maidcafe ALL=(deploy) NOPASSWD: /etc/maidcafe/actions/run/*, /etc/maidcafe/actions/*
EOFThe two specs matter: user-mode script actions render their substituted body
under /etc/maidcafe/actions/run/, and sudoers wildcards do not cross /.
The same rule must exist for the SSH user that runs the daemon in stdio
transport mode. MaidKit deploys all of this automatically when an action
selects a run-as user.
GitHub Actions is defined in .github/workflows/build.yml:
- Pull requests run tests and builds and build the cloud image without pushing.
masterpushes publish the cloud image to GHCR.- Version tags publish a versioned cloud image.
- Manual workflow runs are also supported. Run them from a version tag for a
stable release or from
masterfor a rolling release. - Pull requests validate the daemon build matrix without publishing daemon artifacts.
- Version tags without a leading
v(for example1.2.3) publish compressed daemon-binary-only archives for Linux, macOS, and Windows onamd64andarm64to the stable channel. - Every commit pushed to
masterpublishes only Linuxamd64andarm64daemon archives to the rolling channel. Its version is the first six characters of the commit SHA.
Set the repository variable PACKAGE_OWNER to the GHCR owner used by the image
name. For daemon artifact publishing, create a DistributionCenter product and
product-scoped upload key, then set:
DISTRIBUTION_API_BASE_URLrepository variableDISTRIBUTION_PRODUCT_IDrepository variableDISTRIBUTION_UPLOAD_KEYrepository secret
DistributionCenter returns a daemon artifact's download URL by matching the release channel, platform, and architecture. The archive contains only the compressed daemon binary; MaidKit supplies configuration and service-manager integration on the target host.
Webhook request bodies are data delivered to process stdin. They are not shell
syntax. Commands and arguments come only from validated static configuration.
Daemon secrets are sent only in the Authorization header, never in query
parameters or cookies.