agentmesh-control-plane

agentmesh-control-plane admits nodes, mints credentials, holds the mesh policy and tracks routers. It is an HTTP server over SQLite or PostgreSQL.

agentmesh-control-plane [flags]                     run the server
agentmesh-control-plane admin ban   --peer <id>     ban a node directly in the database
agentmesh-control-plane admin unban --peer <id>     lift a ban

Flags

FlagDefaultMeaning
--issuerOIDC issuer URL(s), repeatable or comma-separated. At least one of --issuer or --workload-issuer is required. The first issuer is advertised to enrolling nodes on /info.
--workload-issuerWorkload OIDC issuer(s), repeatable or comma-separated (<issuer> or <issuer>=<email-suffix>, such as https://accounts.google.com=.gserviceaccount.com). Automatically added to --issuer. Workload tokens can enroll, refresh, and exchange credentials (/register, /refresh, /token/exchange), and are refused at /user/* and /oauth/authorize. A workload issuer that does not answer its discovery document at start-up does not stop the control plane: it keeps retrying in the background (backoff up to 5 minutes), refuses tokens from that issuer with 401 and a message naming it until it does, and reports it as agentmesh_control_plane_workload_issuer_ready{issuer} = 0. An --issuer that does not answer is still fatal.
--allowed-audiencesagentmesh-audienceAudiences accepted in OIDC tokens, comma-separated.
--oidc-client-idfirst audienceOAuth client ID advertised on /info, for providers where it differs from the audience.
--insecure-skip-tls-verifyfalseSkip TLS verification when fetching issuer metadata and keys. For a cluster issuer served with the cluster CA, or a local development issuer.
--bind-address0.0.0.0:8080HTTP listen address.
--sts-issuer-urlfrom the requestPublic URL of this control plane. It is the issuer of /.well-known/openid-configuration and the iss claim of the JWTs that POST /sts/token mints. Can also be set with AGENTMESH_STS_ISSUER_URL. Without it, the control plane takes the scheme and host of each request and never reads X-Forwarded-* headers, so set it behind a TLS-terminating proxy.
--pprof-addroffServe Go runtime profiles under /debug/pprof/ on this address. Unauthenticated, and a heap profile is a dump of process memory, which holds the signing keys and the admin token: bind it to loopback, never to the API address.
--db-driversqlitesqlite or postgres.
--db-dsncontrol-plane.dbDatabase DSN. A PostgreSQL DSN contains a password, so the next two options are preferred for PostgreSQL.
--db-dsn-pathFile containing the DSN. Overrides --db-dsn. Can also be set with AGENTMESH_DB_DSN.
--admin-token-pathFile containing the bearer token for /admin/* and POST /policies. Can also be set with AGENTMESH_ADMIN_TOKEN. Without a token, the admin API cannot be used.
--auto-approve-enrollmentfalseIssue credentials for valid bootstrap-token enrollments immediately instead of queueing them for approval.
--biscuit-ttl24hLifetime of each credential. If the OIDC token expires sooner, the credential expires with it.
--oidc-session-ttl2160h (90 days)How long a human OIDC enrollment may keep refreshing before the identity must log in again.
--signing-key-pathPEM file with the control plane’s private signing keys (PKCS#8 or SEC 1): Ed25519 for Biscuits, P-256 for the ES256 JWTs behind /sts/token and /jwks. At least one key of each kind is required. The first key of each kind signs, later ones verify for as long as they are listed; a key the control plane generated earlier, or one dropped from the file, stays valid for --key-grace-period. Verifiers learn the set from /keys and /jwks as after any rotation. Turns automatic rotation off; the file is re-read every 30s, so rotation is publishing a new first key and keeping the previous one until every credential it signed has expired. agentmesh_control_plane_operator_signing_keys{algorithm} counts the keys loaded.
--workload-session-ttl48hHow long a workload OIDC enrollment (--workload-issuer) may refresh without presenting a fresh platform JWT in TokenRefreshRequest.jwt.
--key-rotation-interval24hHow often a new signing key is generated. 0 means the default, 24h. To turn rotation off, use --signing-key-path.
--key-grace-period1hHow long a rotated-out key stays accepted. Credentials signed by a retired key cannot be verified or refreshed. Nodes and routers must pull /keys well within this window (agentmesh-node --control-plane-sync-interval, agentmesh-router --control-plane-sync-interval).
--lease-duration15mHow long a router lease lasts without renewal.
--dht-replication3How many routers closest to the key get a copy of each record a member announces, besides the router it announced to. A record on three routers survives two of them down at once; each replica more is a copy more of every record. Members are not concerned: they ask the routers they hold. Published on /info; every router of the mesh runs on it.
--dht-reprovide-interval5mHow often a member re-announces each service it publishes. A provider that stopped without unpublishing stays listed for three intervals, which is how long routers keep a record after its last announcement; the mesh pays its providers divided by the interval in announcements a minute. Published on /info.
--node-retention720h (30 days)How long the record of an enrolled node is kept after its session expires, or, for a node enrolled with a bootstrap token, after its last refresh. Banned nodes are kept forever. Must be positive; records are never kept forever.
--ephemeral-node-retention1h--node-retention for a node enrolled with an ephemeral bootstrap token (BootstrapTokenCreateRequest.ephemeral): how long after its last refresh its record is kept. For members that come and go: a fleet in a scale test, a probe, a CI job.
--mesh-reconnect-interval30sHow often the event publisher re-reads the router leases and dials any router it is not connected to. On each router it runs the mesh handshake with a credential it mints for its own peer under the signing key, with the role mesh:role:control-plane, so the router holds its connection as a member’s.
--log-levelinfodebug, info, warn, error.
--log-formattexttext or json.

HTTP API

Every request and response is a message in api/agentmesh.proto, in one of two encodings. Routes that mesh components call use binary protobuf (application/x-protobuf). Routes for operators and the console use protojson of the same messages, with proto field names; unknown fields are rejected. POST /policies accepts either and answers in the encoding of the request.

Responses that carry credentials are sent with Cache-Control: no-store.

Unauthenticated

RoutePurpose
GET /healthzLiveness.
GET /readyzReadiness. Returns 503 when the database is unreachable.
GET /metricsPrometheus metrics.
GET /infoControlPlaneInfoResponse: the OIDC issuer, client ID and audience, the routers with live leases (routers: peer ID, addresses, the labels attested at the router’s enrollment, and its connections against its limit as of its last lease; router_addresses is the same routers flattened), the list of banned peer IDs, and the directory parameters of the mesh (directory: replication, reprovide_interval, and the record_ttl derived from them). This is what a node needs before it can enroll, what it chooses its routers from, and where it reads its reprovide interval. The routers run their DHT on the same directory parameters.
GET /keysThe current set of signing public keys, signed by every key in the set. A caller accepts the set only if one signature verifies under a key it already trusts.
GET /.well-known/openid-configuration, GET /.well-known/oauth-authorization-serverOIDC Discovery and OAuth 2.1 Authorization Server metadata for outbound STS federation and MCP OAuth 2.1 clients.
GET /jwksJSON Web Key Set (ES256 public keys) for verifying border JWTs minted by POST /sts/token.
GET /oauth/authorize, POST /oauth/tokenOAuth 2.1 Authorization Code + PKCE endpoints (honouring RFC 8707 resource indicators to scope the issued Task Biscuit). Tokens from --workload-issuer are refused at /oauth/authorize with 403.

Enrollment, refresh, and STS

Every request below carries a challenge_unix_ms and a challenge_signature. The enrollee signs mesh:<endpoint>:<peer_id>:<challenge_unix_ms> with its key, and the control plane accepts the signature within five minutes of that instant. This proves that the caller holds the key behind the peer ID it names. Every instant in a response (expire_time and the like) is a google.protobuf.Timestamp, an RFC 3339 string in JSON.

Enrollment is admitted at 10 requests a second for the whole mesh, with a burst of 20. A request over that is answered 429 with a Retry-After header. agentmesh-node waits for it and sends the request again, with jitter, for up to three minutes before it reports the failure, so a fleet of up to about 1500 members that starts at once joins over its first minutes instead of losing the members the limiter turned away. A larger fleet starts in waves; the scalability guide has the arithmetic. A client of your own should retry the same way.

RouteBodyPurpose
POST /registerEnrollRequest (OIDC token, public key, requested role, labels)OIDC enrollment. Returns EnrollResponse: the credential, the control plane public key, router addresses and expiry.
POST /enrollBootstrapEnrollRequest (bootstrap token, public key, requested role, labels)Bootstrap enrollment. Returns BootstrapEnrollResponse with status APPROVED and the credential, or with status PENDING. For a peer that is already approved, a new credential is minted directly.
GET /enroll/status?peer_id=headers X-Mesh-Challenge-Ts, X-Mesh-Challenge-SigPoll a pending enrollment. Any authentication failure answers 401, so the credential is released only to the enrollee.
POST /refreshTokenRefreshRequest (optional jwt), current credential as Authorization: Bearer <base64>Exchange a credential for a new one. When jwt is present and its `iss
POST /token/exchangeTokenExchangeRequest (subject_token, optional task_rule and seal, challenge signature), node credential as bearerStateless JWT-to-Biscuit exchange. Mints a Delegated Session Biscuit bound to the calling node (actor_node, client_peer_id) with zero database writes.
POST /sts/tokenSTSTokenRequest (biscuit, destination, optional audience, challenge signature), node credential as bearerStateless Biscuit-to-JWT minting. Verifies the Biscuit and tar_block chain against egress://<destination> and mints a short-lived ES256 border JWT (sub, act.sub, aud, mesh_roles, mesh_task).
GET /revocationscredential as Authorization: Bearer <base64>RevocationsResponse: revoked root Biscuit revocation IDs (revocation_ids) and banned peer IDs (banned_peer_ids).
POST /routers/leaseRouterLeaseRequest (credential, addresses, telemetry: connected peers, DHT size, open connections against the connection limit)Register or renew a router lease. Requires role("mesh:role:router"). Announced addresses must end in the router’s own peer ID. The load is what /info lists for the router.
GET /policiescredential as Authorization: Bearer <base64>The mesh policy as PolicyConfigGetResponse: the Datalog rules a member adds to its authorizer, one per entry. Operators read the document at GET /admin/policy.
GET /egresscredential as Authorization: Bearer <base64>EgressAssignmentsResponse: the egress destinations whose served_by selects the calling node, by its roles or labels. A node registers and serves what it receives here.
POST /nodes/catalogNodeCatalogReport, credential as bearerA node’s report of the services it publishes, for the console. Display only. Never used for authorization.

Admin

All routes require Authorization: Bearer <admin-token>. Request and response bodies are protojson of the messages named below, with proto field names; unknown fields are rejected.

RoutePurpose
GET /admin/statusAdminStatusResponse: everything the console shows: routers, nodes, enrollment requests, tokens, users, the policy and the node service catalog. Nodes whose session expired are left out; GET /user/nodes?state=expired lists them.
GET /admin/policyThe mesh policy as PolicyConfig, the same document POST /policies takes.
POST /policies, PUT /policiesReplace the mesh policy. The body is PolicyConfig, so a misspelt grant fails instead of being dropped.
GET /admin/bootstrap-tokensBootstrapTokenListResponse: every token, including revoked and expired ones.
POST /admin/bootstrap-tokensMint a token. Body BootstrapTokenCreateRequest: role (required), ttl_hours (default 24), max_usages (default 1), description, autonomous_recovery (default false), ephemeral (default false: nodes enrolled with the token are kept for --ephemeral-node-retention after their last refresh instead of --node-retention). Returns 201 with BootstrapTokenCreateResponse: id, token (shown once), role, expire_time.
DELETE /admin/bootstrap-tokens/{id}Revoke a token. Can be repeated. The token stays in the list with revoke_time set.
GET /admin/enrollmentsEnrollmentRequestListResponse: bootstrap enrollment requests, pending and decided.
POST /admin/enrollments/{id}/approveApprove. Spends a token usage. Checks the token again, and checks the labels against the role’s allowed_labels.
POST /admin/enrollments/{id}/rejectReject.
POST /admin/revokeBody {"peer_id": "..."}. Ban the node and, for an OIDC enrollment, the identity behind it.
POST /admin/nodes/{peer_id}/unbanLift both bans.
POST /admin/nodes/{peer_id}/autonomous-recoveryBody {"enabled": true}. Toggle the flag on an enrolled node.

User

For a human OIDC user acting on their own nodes, authenticated with an ID token from a configured human issuer (tokens matching --workload-issuer are refused with 403). The console uses these routes.

RoutePurpose
GET /user/statusUserStatusResponse: the caller, their enrolled nodes and tokens. For an administrator it also carries the routers and the policy. Nodes whose session expired are left out.
GET /user/nodesNodeListResponse: one page of enrolled nodes, most recent enrollment first. state (admitted, expired or banned, repeatable) and role narrow the list. An expired OIDC enrollment can renew in place, so the console pages state=expired to keep those nodes revocable. An administrator sees every owner’s nodes and may pick one with owner_id; a user sees only their own. Query page_size (default 50, at most 200) and page_token, the next_page_token of the previous page.
POST /user/bootstrap-tokensMint a token owned by the caller; body and response as on the admin route. role defaults to mesh:role:node. Banning the owner disables the tokens they minted.
POST /user/revokeBan one of the caller’s own nodes.

Notes

  • A new control plane grants nothing. With no policy in the database, enrollment succeeds only for identities bound to the requested role, and with no bindings there are none. A policy must be posted first.
  • Bootstrap tokens are spent atomically. max_usages holds under concurrent enrollments, and a pending request is resolved exactly once.
  • An approved peer that enrolls again gets a new credential without approval, as long as the token is valid, the role matches the record and the peer is not banned. This is how a bootstrap node recovers from a retired signing key.
  • Bans propagate in two ways. /info lists banned peer IDs for nodes and routers that start or restart. A signed mesh event reaches the ones that are already running.
  • The rotation deadline is in the database. The first rotation is due one --key-rotation-interval after the first start, each rotation sets the next, and every replica checks the deadline at least once a minute. A control plane that restarts more often than it rotates still rotates on time, and one that was down when a rotation was due rotates on its first check. agentmesh_control_plane_next_key_rotation_timestamp_seconds is the deadline; a value in the past is a rotation that is not happening.
  • Node records are garbage-collected. A node whose session has expired is deleted after --node-retention. A node enrolled with a bootstrap token has no session; its record is deleted once it has not refreshed its credential for --node-retention, or for --ephemeral-node-retention when the token was ephemeral, and a node that comes back after that enrolls again. A node without a persistent data directory enrolls a new identity on every restart, so without this the node table would only grow. agentmesh_control_plane_nodes_deleted_total{reason} counts the deletions: session, silent, ephemeral.
  • The records around an enrollment are garbage-collected with it. The same sweep deletes enrollment requests resolved, or left pending, for --node-retention (for --ephemeral-node-retention when an ephemeral token made them), bootstrap tokens that expired --node-retention ago and that no remaining request refers to, and router leases that expired --node-retention ago. It also drops the service report held in memory for every node that is no longer admitted. agentmesh_control_plane_storage_rows_deleted_total{table} counts these deletions; agentmesh_control_plane_storage_rows{table} is the size of each table, and a table that keeps growing past its retention is a sweep that is failing, which the log says.

Metrics

/metrics exposes agentmesh_control_plane_* counters and gauges: enrolled nodes by role and state, active routers, mesh-connected peers as reported by router leases, rows per store table, service reports held in memory, per-route request counts and latencies, and the Go runtime.

A member whose request on a mesh endpoint (/policies, /egress, /revocations, /token/exchange, /sts/token, …) is answered 401 is counted in agentmesh_control_plane_member_auth_refusals_total{reason} and logged with the same reason: malformed (not a base64 credential), invalid_biscuit (not signed by a key this control plane trusts, or expired), unknown_node (the credential verifies but the store has no enrollment record for the node: the store was reset, or the node was removed), banned, session_expired. unknown_node is the one to look at after a control plane restart on an empty store: every node then refused is a node that needs a bootstrap token (--bootstrap-token-path) to enroll again on its own.