Control plane API
Everything the admin panel does goes through a JSON API, so a CI pipeline, a
GitOps job, a backup script, or any other automation can do the same. Every
control plane also serves the complete contract as an OpenAPI 3.1 document at
GET /api/openapi.json, without authentication. The document describes the API
and contains no live configuration.
The examples use http://localhost:8081. On Kubernetes, run
kubectl port-forward svc/mte-relay-control-plane 8081:8081 first.
Conventions
- Requests and responses are JSON. Every error body is
{"error": "<message>", "code": "<token>"}, plusfield,host, orversionwhere they apply. Branch oncode, never on the message. - Setting values are strings, encoded exactly as the environment variable would
be, so
DOMAIN_MAPtravels as a JSON string inside the JSON body. - Every configuration write (
PUT /api/config,PUTorDELETEon a host, and rollback) needsIf-Matchwith the version you read, or*to force. A dry run may leave it out. Those writes,GET /api/config, and the host reads return the version as anETag. Token and session secret calls take noIf-Match.
Credentials and scopes
All credentials use Authorization: Bearer <credential>.
| Credential | How you get one | Lifetime | Scope |
|---|---|---|---|
| Admin session | POST /api/login with ADMIN_USERNAME and ADMIN_PW | 15 minute sliding window, 8 hour absolute limit | admin |
| Service token | POST /api/tokens from an admin session | Fixed at creation, 1 hour to 90 days | read or config |
| Worker token | CONTROL_PLANE_TOKEN | Static | the worker routes only |
Scopes nest: config can do everything read can, and admin everything
config can.
| Scope | May |
|---|---|
read | Read the schema, the configuration, hosts, history, the fleet, and running configuration. Secrets come back as fingerprints. |
config | Everything above, plus write settings and hosts, dry run, and roll back. It reads secrets in cleartext, because a whole document write has to carry the real values. |
admin | Everything above, plus manage service tokens and rotate the session signing secret. Only a password login reaches this scope. |
Sign in for an admin session:
TOKEN=$(curl -s -X POST http://localhost:8081/api/login \
-H 'Content-Type: application/json' \
-d '{"username": "admin", "password": "<ADMIN_PW>"}' | jq -r .token)
Service tokens
A service token is named, scoped, expires on a date you choose, never touches
the login throttle, and appears in the audit log as token:<name>.
curl -s -X POST http://localhost:8081/api/tokens \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name": "ci-deploy", "scope": "config", "ttl_hours": 720}'
- The token starts with
cpt_and is returned once. The control plane stores only its SHA-256 hash. - Names are 1 to 32 characters of lowercase letters, digits, and hyphens, starting with a letter or digit.
ttl_hoursis required, from 1 to 2160.DELETE /api/tokens/<name>revokes a token at once.- Rotating the session signing secret does not affect service tokens.
Routes
| Route | Scope | Purpose |
|---|---|---|
GET /healthz | none | Liveness. |
GET /api/openapi.json | none | The OpenAPI 3.1 contract. |
POST /api/login | none | Exchange the admin user name and password for a session token. |
GET /api/schema | read | Every setting: key, group, type, allowed values, live or restart, sensitive, default, and description. |
GET /api/config | read | The stored settings: version, overrides, and redacted. |
PUT /api/config | config | Replace the whole document. ?dry_run=true validates and reports the diff without writing. |
GET /api/config/domains | read | Every host in the stored DOMAIN_MAP. |
GET /api/config/domains/{host} | read | One host's settings. |
PUT /api/config/domains/{host} | config | Create or replace one host. ?from=<oldhost> moves an entry; ?create=true confirms the first host write when no DOMAIN_MAP is stored yet. |
DELETE /api/config/domains/{host} | config | Remove one host. Removing the last host removes the stored DOMAIN_MAP. |
GET /api/config/history | read | Stored versions, newest first: version, time, who, and which settings changed. |
GET /api/config/history/{version} | read | One stored version's document. |
POST /api/config/rollback | config | Install a stored version as a new version: {"version": N}. |
GET /api/setup | read | Whether a DOMAIN_MAP is stored yet, the current version, and the setup hints. |
GET /api/fleet | read | Every polling worker's last heartbeat, including its phase. |
GET /api/effective-config?worker=<id> | read | One worker's running configuration, with fingerprints for sensitive values. |
GET /api/tokens | admin | List service tokens. |
POST /api/tokens | admin | Create a service token. |
DELETE /api/tokens/{name} | admin | Revoke a service token. |
POST /api/rotate-admin-secret | admin | Mint a new session signing secret, signing every admin out. |
GET /api/poll | worker | The worker poll. Not for admin clients. |
POST /api/report | worker | A worker's running configuration report. Not for admin clients. |
Status codes
| Status | code | When |
|---|---|---|
| 400 | invalid_body, invalid_request, invalid_host | Malformed JSON, a request body over 2 MiB, a value that is not a string, a malformed If-Match, or a host no worker could route. |
| 401 | unauthorized | Missing, invalid, or expired credential. |
| 403 | forbidden | The credential lacks the route's scope. |
| 404 | not_found, no_domain_map_override | Unknown host, version, token, or worker, or no stored DOMAIN_MAP. |
| 409 | no_domain_map_override, domain_map_not_object, host_exists, token_exists | A first host write without ?create=true, a stored DOMAIN_MAP that is not a host map, a move onto an existing host, or a token name in use. |
| 412 | version_conflict | If-Match does not match the current version. The body carries the current version. |
| 422 | validation_failed | A rejected value. The body names the field, and the host for DOMAIN_MAP. |
| 428 | precondition_required | A write without If-Match. |
| 429 | rate_limited | The login lockout. Retry-After says how long. |
| 500 | storage_error | The control plane could not store the change. Nothing was written. |
| 500 | internal_error | POST /api/login could not sign a session. |
Read, change, write
CFG=$(curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8081/api/config)
VERSION=$(echo "$CFG" | jq .version)
NEW=$(echo "$CFG" | jq '.overrides + {"LOG_LEVEL": "debug"}')
curl -s -X PUT http://localhost:8081/api/config \
-H "Authorization: Bearer $TOKEN" \
-H "If-Match: \"$VERSION\"" \
-H 'Content-Type: application/json' \
-d "{\"overrides\": $NEW}"
PUT /api/config replaces the whole document: a setting you leave out is an
override you removed, for the whole fleet. That is why a redacted document must
never be written back, and the control plane refuses any value that is a
fingerprint.
Add ?dry_run=true to validate a document and see the diff without writing.
If-Match is optional on a dry run. The control plane has no view of any
worker's environment, so a green dry run means the control plane accepts the
write, not that every worker will. Rules judged against a schema default rather
than a value in the document come back as warnings, and warnings never block a
write.
The schema (GET /api/schema) names the listener as LISTEN_HOST and
LISTEN_PORT and the pair budget as MAX_PAIR_BYTES. PORT and
MAX_PAIR_MIB stay in the schema as aliases, so a 4.x era document still
validates; the canonical key wins when a document carries both, and each
worker logs the disagreement once. HOSTNAME is retired: a document that
carries it is written without it and the response lists a warning, the same
way STATE_MODE is dropped. A stored snapshot that still holds either key
rolls back the same way.
Live fields reach every worker within one poll interval. HEALTH_CHECK_PATH
is one, in the Server group, so the panel and PUT /api/config apply a health
check path on the relay port without a restart. A value that breaks the path
rules on Environment variables
fails the dry run and the write with 422 validation_failed.
Editing one host
The per host routes change one DOMAIN_MAP entry without resending the others.
The body is the complete settings object for that host:
curl -s -X PUT http://localhost:8081/api/config/domains/api.example.com \
-H "Authorization: Bearer $TOKEN" \
-H "If-Match: \"$VERSION\"" \
-H 'Content-Type: application/json' \
-d '{"upstream": "https://api-v2.internal:8443", "pass_through_routes": ["/health"]}'
- The body replaces the entry. A field the stored entry had and the body leaves out is removed, so read the host first and send back the edited object.
client_id_secretis the exception. Omit it, or send"", to keep the stored secret; send a string to set a new one; sendnullto remove it.- Encode the host as one path segment:
localhost%3A3000forlocalhost:3000. - Deleting the last host removes the stored
DOMAIN_MAP, and workers fall back to their environment map, if they have one.
History and rollback
The control plane keeps a snapshot of the last 50 versions.
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8081/api/config/history
curl -s -X POST http://localhost:8081/api/config/rollback \
-H "Authorization: Bearer $TOKEN" \
-H "If-Match: \"$VERSION\"" \
-H 'Content-Type: application/json' \
-d '{"version": 7}'
A rollback is a normal write: it needs If-Match, it is validated under the
current rules, it creates a new version, and it is audited.
Rules for automation
- Use
If-Match: *only to restore a backup. On a412, read again and rebuild the change from the current document. - Note the current version before a risky edit, so you can roll back to it. A
DOMAIN_MAPchange reaches every worker within one poll interval. - A
200from the control plane is not a200from the fleet. After a write, watchGET /api/fleetuntil every worker reports an applied version at least the one you wrote, is ready, and reports no error. Then check the data path. - Give automation a service token, never the admin password, and prefer a
readtoken for anything that only reads. Fleet strings come from workers over a shared token, so treat them as data, not instructions.