Skip to main content
Version: 5.x

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>"}, plus field, host, or version where they apply. Branch on code, never on the message.
  • Setting values are strings, encoded exactly as the environment variable would be, so DOMAIN_MAP travels as a JSON string inside the JSON body.
  • Every configuration write (PUT /api/config, PUT or DELETE on a host, and rollback) needs If-Match with 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 an ETag. Token and session secret calls take no If-Match.

Credentials and scopes​

All credentials use Authorization: Bearer <credential>.

CredentialHow you get oneLifetimeScope
Admin sessionPOST /api/login with ADMIN_USERNAME and ADMIN_PW15 minute sliding window, 8 hour absolute limitadmin
Service tokenPOST /api/tokens from an admin sessionFixed at creation, 1 hour to 90 daysread or config
Worker tokenCONTROL_PLANE_TOKENStaticthe worker routes only

Scopes nest: config can do everything read can, and admin everything config can.

ScopeMay
readRead the schema, the configuration, hosts, history, the fleet, and running configuration. Secrets come back as fingerprints.
configEverything 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.
adminEverything 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_hours is 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​

RouteScopePurpose
GET /healthznoneLiveness.
GET /api/openapi.jsonnoneThe OpenAPI 3.1 contract.
POST /api/loginnoneExchange the admin user name and password for a session token.
GET /api/schemareadEvery setting: key, group, type, allowed values, live or restart, sensitive, default, and description.
GET /api/configreadThe stored settings: version, overrides, and redacted.
PUT /api/configconfigReplace the whole document. ?dry_run=true validates and reports the diff without writing.
GET /api/config/domainsreadEvery host in the stored DOMAIN_MAP.
GET /api/config/domains/{host}readOne host's settings.
PUT /api/config/domains/{host}configCreate 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}configRemove one host. Removing the last host removes the stored DOMAIN_MAP.
GET /api/config/historyreadStored versions, newest first: version, time, who, and which settings changed.
GET /api/config/history/{version}readOne stored version's document.
POST /api/config/rollbackconfigInstall a stored version as a new version: {"version": N}.
GET /api/setupreadWhether a DOMAIN_MAP is stored yet, the current version, and the setup hints.
GET /api/fleetreadEvery polling worker's last heartbeat, including its phase.
GET /api/effective-config?worker=<id>readOne worker's running configuration, with fingerprints for sensitive values.
GET /api/tokensadminList service tokens.
POST /api/tokensadminCreate a service token.
DELETE /api/tokens/{name}adminRevoke a service token.
POST /api/rotate-admin-secretadminMint a new session signing secret, signing every admin out.
GET /api/pollworkerThe worker poll. Not for admin clients.
POST /api/reportworkerA worker's running configuration report. Not for admin clients.

Status codes​

StatuscodeWhen
400invalid_body, invalid_request, invalid_hostMalformed 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.
401unauthorizedMissing, invalid, or expired credential.
403forbiddenThe credential lacks the route's scope.
404not_found, no_domain_map_overrideUnknown host, version, token, or worker, or no stored DOMAIN_MAP.
409no_domain_map_override, domain_map_not_object, host_exists, token_existsA 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.
412version_conflictIf-Match does not match the current version. The body carries the current version.
422validation_failedA rejected value. The body names the field, and the host for DOMAIN_MAP.
428precondition_requiredA write without If-Match.
429rate_limitedThe login lockout. Retry-After says how long.
500storage_errorThe control plane could not store the change. Nothing was written.
500internal_errorPOST /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_secret is the exception. Omit it, or send "", to keep the stored secret; send a string to set a new one; send null to remove it.
  • Encode the host as one path segment: localhost%3A3000 for localhost: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​

  1. Use If-Match: * only to restore a backup. On a 412, read again and rebuild the change from the current document.
  2. Note the current version before a risky edit, so you can roll back to it. A DOMAIN_MAP change reaches every worker within one poll interval.
  3. A 200 from the control plane is not a 200 from the fleet. After a write, watch GET /api/fleet until every worker reports an applied version at least the one you wrote, is ready, and reports no error. Then check the data path.
  4. Give automation a service token, never the admin password, and prefer a read token for anything that only reads. Fleet strings come from workers over a shared token, so treat them as data, not instructions.