Skip to content

Mock Central API Server ​

A fixture-based mock of the Central API for end-to-end testing. Returns static responses that are wire-compatible with internal/api types, enabling CI pipelines to validate plexd's registration, heartbeat, reconciliation, and SSE logic without a production control plane.

Configuration ​

OptionFlagEnvironment VariableDefaultDescription
Listen address-addr—:0TCP address (host:port) to listen on
TLS listen address-tls-addr—:8443TLS address (host:port) for local endpoint handlers

The binary prints MOCKAPI_ADDR=<address> to stdout on startup, which allows test scripts to discover the dynamically assigned port when using :0.

Authentication ​

Every route under /v1/ is served behind a bearer-envelope gate, with two exemptions: POST /v1/register (security: [] in the spec — a node has no credential before it registers) and GET /v1/health (the readiness probe the compose healthcheck dials). The /test/, /releases/ and /exec-output/ surfaces are fixture scaffolding and a presigned upload; none of them carries a bearer.

An authenticated route admits exactly one credential, the NSK bearer envelope the agent assembles after registration:

Authorization: Bearer nsk_<env>_<base64url(node_id_bytes(16) || nsk_bytes(32))>

The payload is unpadded base64url, whose alphabet includes - and _ — the separator parse therefore splits on the first two underscores only. The <env> segment must be non-empty but is not otherwise checked: the gate's authority is the payload, which must name this node (mockNodeID) and carry its secret key (mockNSK). Anything else — no header, a non-Bearer scheme, the raw standard-base64 nsk, a padded or short payload, another node's id or key — is refused with 401 and an application/problem+json body, and increments unauthorized_count.

The gate exists because its absence hid a shipped defect: the mock used to admit any credential and check only the node id in the URL, so the whole suite stayed green through two releases in which the agent presented the raw nsk and every deployed node was refused by the real control plane (issues #56, #60). A fixture that accepts anything cannot fail on the one thing every deployed node depends on.

Request validation the fixture enforces ​

The mock refuses what the real handlers refuse, because a fixture that accepts more than the contract hides exactly the defects the suite exists to catch. The first three gates were added after a release shipped past them; the handshake report, the edge PSK route and the endpoint candidates route were gated from the start:

  • PUT /v1/nodes/{id}/capabilities strict-decodes a CapabilityManifestRequest (DisallowUnknownFields) and enforces its invariants: a non-empty binary_version, a binary_checksum that decodes to exactly 32 bytes of standard-padded base64, a canonical SHA256:<base64> fingerprint when present, and declared_hooks that are unique, named, and carry a 32-byte digest. A hex digest decodes to 48 bytes and is refused with binary_checksum_invalid; a gzip-compressed body fails the decode, as it does upstream. The fixture used to decode into whatever shape the agent sent and count it. The two advertised inventories are checked after that, plexd_hooks first, and a violation is answered 422 with the contract's code: plexd_hook_invalid (empty name, non-canonical image_digest, negative timeout_seconds), plexd_hook_duplicate, plexd_hooks_too_many (over 128), builtin_action_invalid (empty action or parameter name, or a parameter declared twice), builtin_action_duplicate, and builtin_actions_too_many (over 128 actions, or over 64 parameters on one). An action whose parameters is null counts as having none.

  • POST /v1/nodes/{id}/audit admits only the contract's closed source enum, auditd and k8s. The fixture used to also admit plexd, so a batch the real ingest gate refuses whole with 400 ingest_batch_malformed was accepted here.

  • POST /v1/nodes/{id}/integrity-violations caps the body at 32 KiB (413 integrity_violations_body_too_large), strict-decodes an IntegrityViolationsRequest, and enforces the batch bounds (integrity_violations_empty, integrity_violations_too_many) plus every per-entry invariant: the closed kind and detected_by enums, a non-empty artifact_id, 32-byte base64 digests for the checksum kinds, canonical SHA256:<base64> fingerprints for the host-key kind, and the per-kind guard that refuses a checksum entry carrying a fingerprint or the reverse. Success answers 200 with an IntegrityViolationsResponse, not 204.

    This one was worse than the other two: the fixture registered the agent's own route (/v1/nodes/{id}/integrity/violations, which the contract does not serve) and decoded the agent's own shape, so the agent's reports agreed with the fixture and with nothing else. A violation never fires during an e2e run, so this handler is the only place the divergence can be caught.

  • POST /v1/nodes/{id}/handshakes caps the body at 1 MiB (413 handshake_report_too_large) and strict-decodes it into mock-local pointer types, so a missing field is told apart from a zero one. It answers in the contract's gate order: 400 malformed_handshake_report for a missing or zero observed_at, a missing or null peers, more than 4096 entries, a peer_node_id that is missing, not a UUID or all zeros, an age that is missing or outside 0..2147483647, or a repeated peer_node_id; then 400 handshake_clock_skew for an observed_at more than 60s from the mock's clock; then 400 handshake_endpoint_unparseable for an endpoint that is missing, has port 0, an unspecified address or a zone. A report naming no fixture peer is still accepted: its entries count as ignored, as upstream. See the route for the full order.

  • GET /v1/nodes/{id}/peers/{peer_node_id}/psk answers 403 node_id_mismatch for an id other than the registered node, then 404 peer_not_found for a peer_node_id that is not one of the two fixture peers or names the node itself. Only a request that passes both gets an envelope. See the route.

  • PUT /v1/nodes/{id}/endpoint-candidates caps the body at 4 KiB (413 endpoint_candidates_body_too_large) and strict-decodes it into mock-local pointer types, so a missing or null member is refused as missing rather than passing as a zero value. It answers in the contract's gate order: 400 malformed_endpoint_candidates for invalid JSON, an unknown field, a missing or null candidates, a missing or zero reported_at, more than 8 entries, or an entry spelled exactly like an earlier one; then 400 endpoint_clock_skew for a reported_at more than 60s from the mock's clock; then 400 endpoint_candidate_unparseable for an entry that fails netip.ParseAddrPort, has port 0 or a zone, or names a loopback, link-local, multicast or unspecified host. An empty array is accepted. See the route.

GET /test/bearer ​

Returns the envelope an authenticated route admits, so a test script that drives one itself does not hardcode a base64 blob and the node id and secret keep a single definition.

Response: 200 OK

json
{ "bearer": "nsk_e2e_AZCouKDAegqKCqCgoKCgowABAgMEBQYHCAkKCwwNDg-AkaKzxNXm9_jp2su8rZ6P" }

Go callers reach the same value through (*Server).BearerEnvelope(). In-process tests that mean to exercise handlers rather than the gate serve the mock behind a shim that stamps this header when a request arrives without one.

Endpoints ​

GET /v1/health ​

Unauthenticated readiness probe (a readiness probe carries no credentials). Answers the control-plane HealthStatus shape — an overall status plus a list of named component checks — so the harness has a stable, contract-faithful liveness endpoint. The e2e suites poll this endpoint to gate on mock-api readiness.

Response: 200 OK

json
{
  "status": "ok",
  "checks": [
    { "name": "mock", "status": "ok", "detail": "" }
  ]
}

Content-Type: application/json

Error: Returns 405 if the HTTP method is not GET.

POST /v1/register ​

Enforces the full POST /v1/register contract (issue #18). Success is 201 Created; every denial is an RFC 9457 application/problem+json body mirroring the platform taxonomy. The bootstrap token's nonce is recorded (the token "consumed") only on the 201 branch — never on an error branch.

Validation order. The handler checks in this sequence, returning at the first failure:

  1. Body decode → 400 (no code) if the body is not valid JSON.
  2. Invariants → 422 (no code): empty bootstrap_token, nonce, or resource_handle; a project_id that is empty, the zero UUID, or not a UUID; any field longer than 4096 characters.
  3. Public key → 400 public_key_invalid if public_key fails ^[A-Za-z0-9+/]{43}=$ or decodes to the all-zero key.
  4. Bootstrap token → 403: malformed token (fails ^psb_[a-z]+_[a-z2-7]+_(node|bridge)_[a-z2-7]{20,}$) has no code; a bridge-kind token yields kind_mismatch; a trailing random segment containing consumed, expired, or revoked yields token_consumed, token_expired, or token_revoked.
  5. Project match → 403 project_mismatch if project_id is not the mock project UUID.
  6. Nonce replay → 403 nonce_collision if (project_id, nonce) was already recorded on a prior 201.
  7. Resource resolution → 404 resource_not_found for the reserved handle unknown-resource.
  8. Allocator / server triggers → magic resource handles (below).
  9. Success → 201 with the fixture below; the nonce is recorded.

Magic triggers. Reserved inputs force specific branches:

TriggerWhereResult
project_id = 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0requestAccepted project; any other UUID → 403 project_mismatch
bridge-kind token (psb_..._bridge_...)bootstrap_token403 kind_mismatch
consumed / expired / revoked in the trailing random segmentbootstrap_token403 token_consumed / token_expired / token_revoked
unknown-resourceresource_handle404 resource_not_found
exhausted-poolresource_handle503 pool_exhausted
exhausted-subrangeresource_handle503 subrange_exhausted
contended-allocatorresource_handle503 allocator_contention
boom-internalresource_handle500 (no code)

Problem format. Error bodies are application/problem+json:

json
{
  "type": "https://api.plexsphere.com/problems/token_expired",
  "title": "Forbidden",
  "status": 403,
  "detail": "bootstrap_token expired",
  "instance": "/v1/register",
  "code": "token_expired"
}

type is https://api.plexsphere.com/problems/<code> when a code is present, otherwise about:blank (and the code member is omitted); title is the HTTP status text; instance is always /v1/register.

Response: 201 Created

json
{
  "node_id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a3",
  "mesh_ip": "10.99.0.1",
  "signing_public_key": "<base64 Ed25519 public key, generated per server start>",
  "signing_key_id": "did:web:plexsphere.com#key-e2e",
  "nsk": "AAECAwQFBgcICQoLDA0OD4CRorPE1eb3+Onay7ytno8=",
  "peer_snapshot": [
    {
      "node_id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0b1",
      "mesh_ip": "10.99.0.2",
      "public_key": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=",
      "fallback_endpoint": "203.0.113.1:51820"
    },
    {
      "node_id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0b2",
      "mesh_ip": "10.99.0.3",
      "public_key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
    }
  ],
  "domain_mesh_cidr": "10.99.0.0/24"
}

The peer_snapshot uses the narrow RegisterPeer shape (no psk, allowed_ips, or endpoint); the second entry omits fallback_endpoint.

nsk is the mock NodeSecretKey in the standard-padded base64 form the register contract specifies (44 characters). plexd decodes it back into the 32-byte AES-256-GCM key it uses to open secret envelopes.

signing_key_id and signing_public_key are the mock's current signing key: did:web:plexsphere.com#key-e2e and the public half of an Ed25519 key pair generated per server start, until POST /test/rotate-signing-key replaces them. A registration after a rotation answers with the new key, and every state pull carries the same key in its signing_key block.

Content-Type: application/json

Counter: Increments registration_count on each call.

Nonce store. Consumed nonces are held in an in-memory map keyed by project_id + "|" + nonce, recorded only on the 201 branch and forgotten when the server restarts.

Error: Denials use application/problem+json as described above. Returns 405 if the HTTP method is not POST.

POST /v1/nodes/{id}/heartbeat ​

Enforces the v1 heartbeat contract: strict JSON decoding (unknown fields rejected), a required nat_summary object, clock-skew tolerance, and checksum/version shape. heartbeat_count increments only when every check passes, so invalid requests are not counted. The success response is the configurable HeartbeatResponse fixture (defaults to reconcile: true, rotate_keys: false) with a fresh accepted_at stamped per call.

Validation order. The handler returns at the first failure:

  1. Body decode → 400 malformed_heartbeat_request if the body is unreadable or strict decoding fails.
  2. nat_summary object → 400 malformed_heartbeat_request if nat_summary is absent, null, or not a JSON object. This is what proves the agent sends {} — not null — before NAT discovery has a result.
  3. Clock skew → 400 clock_skew if client_now is more than 60s from server time in either direction.
  4. Binary checksum → 400 binary_checksum_empty unless binary_checksum is a 64-char lowercase hex digest or the 44-char standard base64 of 32 bytes.
  5. Binary version → 400 binary_version_empty if binary_version is empty.

Response: 200 OK

json
{
  "accepted_at": "2026-07-19T19:32:35Z",
  "reconcile": true,
  "rotate_keys": false
}

Content-Type: application/json (errors use application/problem+json).

Counter: Increments heartbeat_count only when every check passes.

Error: Denials use application/problem+json with the codes above. Returns 405 if the HTTP method is not POST.

POST /v1/keys/rotate ​

Models the v1 key-rotation receipt contract (issue #21). The control plane arms a pending rotation, the node submits its freshly staged public key, and the mock answers a receipt — replaying the stored receipt on an idempotent retry. key_rotate_count advances only on a completing rotation.

Arming. A rotation is armed from three sources, mirroring a control plane that keeps signaling while a rotation is pending:

  • POST /test/configure-heartbeat with rotate_keys: true;
  • each served heartbeat response that carries rotate_keys: true re-arms;
  • an injected rotate_keys SSE event (POST /test/inject-event).

A completing rotation disarms.

Request body: the node identifies itself through its NSK bearer credential, so the body carries no node id.

json
{ "new_public_key": "<44-char standard base64 X25519 public key>" }

Response: 200 OK

json
{
  "rotation_id": "e2e-rotation-0001",
  "kid": "did:web:plexsphere.com#psk-2026-04",
  "wrap_key_version": 1
}

rotation_id is e2e-rotation-%04d, kid is fixed, and wrap_key_version equals the completion count.

Served taxonomy. The handler serves this subset:

StatusProblem codeTrigger
400 Bad Requestmalformed_keys_rotate_requestBody is unreadable or fails strict decoding
413 Payload Too Largekeys_rotate_body_too_largeBody exceeds 4 KiB
422 Unprocessable Entitykeys_rotate_public_key_invalidnew_public_key is not a non-zero 44-char standard base64 X25519 key
422 Unprocessable Entitykeys_rotate_public_key_unchangednew_public_key matches the node's current key and no receipt is stored for it
409 Conflictkeys_rotate_no_pending_rotationNo rotation is armed
200 OK—Idempotent retry: replays the stored receipt without moving the counter
200 OK—Completion: mints and stores a fresh receipt, disarms, and increments key_rotate_count

The mock deliberately does not model 404 keys_rotate_peer_not_found or 403 (ReBAC denial) — server states plexd cannot reach in e2e.

Content-Type: application/json (errors use application/problem+json).

Counter: Increments key_rotate_count only on a completing rotation.

PUT /v1/nodes/{id}/endpoint ​

Enforces the v1 endpoint contract: a 4 KiB body cap, strict JSON decoding, a closed nat_type enum, a closed port_mapping enum, clock-skew tolerance, and a routable ip:port. endpoint_count increments only when every check passes.

Validation order. The handler returns at the first failure:

  1. Body size → 413 endpoint_body_too_large if the body exceeds 4 KiB.
  2. Body decode → 400 malformed_endpoint_request if strict decoding fails.
  3. NAT type → 400 malformed_endpoint_request if nat_type is outside full_cone, restricted, port_restricted, symmetric, unknown.
  4. Port mapping → 400 malformed_endpoint_request if port_mapping is present, not null, and outside preserved, translated. The empty string is refused; null counts as an absent member, as on the control plane.
  5. Clock skew → 400 endpoint_clock_skew if reported_at is more than 60s from server time in either direction.
  6. Routable endpoint → 400 endpoint_unparseable if endpoint is not ip:port with a port in 1–65535 and a non-loopback, non-link-local, non-unspecified host.

Request body:

json
{
  "endpoint": "203.0.113.7:51820",
  "nat_type": "full_cone",
  "port_mapping": "translated",
  "reported_at": "2026-07-19T19:32:35Z"
}

Response: 200 OK

json
{
  "accepted_at": "2026-07-19T19:32:35Z",
  "stale_after": "2026-07-19T19:37:35Z"
}

stale_after is accepted_at plus the configured TTL (default 5m, set via POST /test/configure-endpoint). The response carries no peer endpoints.

Content-Type: application/json (errors use application/problem+json).

Counter: Increments endpoint_count only when every check passes.

Error: Denials use application/problem+json with the codes above. Returns 405 if the HTTP method is not PUT.

PUT /v1/nodes/{id}/endpoint-candidates ​

Enforces the endpoint candidates contract: a 4 KiB body cap, strict JSON decoding, the report's structural invariants, clock-skew tolerance, and a dialable host:port per entry. The body is captured under endpoint-candidates and decoded into mock-local pointer types, so a missing or null member is told apart from a zero one. The bound of 8 entries is the mock's own copy of the contract's, not api.MaxEndpointCandidates, so it checks the contract rather than the agent's reading of it. endpoint_candidates_count increments only when every check passes.

Validation order. The handler returns at the first failure:

  1. Body size → 413 endpoint_candidates_body_too_large if the body exceeds 4 KiB.
  2. Body decode → 400 malformed_endpoint_candidates if the body is not valid JSON or carries an unknown field.
  3. Report shape → 400 malformed_endpoint_candidates if candidates is missing or null, reported_at is missing or zero, candidates holds more than 8 entries, or an entry repeats an earlier one exactly. The detail of a repeat names the entry: candidates[<i>] repeats an earlier entry.
  4. Clock skew → 400 endpoint_clock_skew if reported_at is more than 60s from the mock's clock in either direction.
  5. Entries → 400 endpoint_candidate_unparseable if an entry fails netip.ParseAddrPort, has port 0 or a zone, or its host, with an IPv4-mapped IPv6 address unmapped, is loopback, link-local unicast, link-local multicast, multicast or unspecified. The detail names the entry: candidates[<i>] is not a host:port pair with a non-zero port, a routable address and no zone.

Request body:

json
{
  "candidates": ["10.0.0.5:51820", "[fd00:10::5]:51820"],
  "reported_at": "2026-10-21T10:15:30Z"
}

"candidates": [] is a valid report that withdraws every earlier candidate.

Response: 200 OK

json
{
  "accepted_at": "2026-10-21T10:15:30Z",
  "stale_after": "2026-10-21T10:20:30Z"
}

stale_after is accepted_at plus the TTL POST /test/configure-endpoint sets for the endpoint report (default 5m).

Content-Type: application/json (errors use application/problem+json).

Counter: Increments endpoint_candidates_count only when every check passes.

Error: Denials use application/problem+json with the codes above. A request without the bearer envelope is refused with 401 by the authentication gate. Returns 405 if the HTTP method is not PUT.

POST /v1/nodes/{id}/handshakes ​

Enforces the v1 handshake report contract (PostNodeHandshakes): a 1 MiB body cap, strict JSON decoding, the per-entry invariants, clock-skew tolerance, and a sendable host:port per entry. The body is captured under handshakes and decoded into mock-local pointer types, so a missing field is told apart from a zero one. handshake_report_count increments only when every check passes.

Validation order. The handler returns at the first failure:

  1. Body size → 413 handshake_report_too_large if the body exceeds 1 MiB.
  2. Body decode → 400 malformed_handshake_report if the body is not valid JSON or carries an unknown field.
  3. Report shape → 400 malformed_handshake_report if observed_at is missing or zero, peers is missing or null, peers holds more than 4096 entries, a peer_node_id is missing, does not match the UUID pattern or is all zeros, an age is missing, below 0 or above 2147483647, or a peer_node_id repeats.
  4. Clock skew → 400 handshake_clock_skew if observed_at is more than 60s from the mock's clock.
  5. Endpoints → 400 handshake_endpoint_unparseable if an endpoint is missing, fails netip.ParseAddrPort, has port 0, an unspecified address or a zone. The detail names the entry: peers[<i>].endpoint is not a host:port pair with a non-zero port, a specified address, and no zone.

Request body:

json
{
  "observed_at": "2026-10-21T10:15:30Z",
  "peers": [
    {
      "peer_node_id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0b1",
      "last_handshake_age_seconds": 42,
      "endpoint": "198.51.100.7:51820"
    }
  ]
}

Response: 200 OK

json
{
  "accepted_at": "2026-10-21T10:15:30Z",
  "recorded": 1,
  "ignored": 0
}

recorded counts the entries naming a fixture peer (0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0b1 or 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0b2), ignored every other entry. A report with "peers": [] is answered recorded: 0, ignored: 0, which is what the Docker e2e run expects: the fixture peers never complete a handshake.

Content-Type: application/json (errors use application/problem+json).

Counter: Increments handshake_report_count only when every check passes.

Error: Denials use application/problem+json with the codes above. Returns 405 if the HTTP method is not POST.

GET /v1/nodes/{id}/peers/{peer_node_id}/psk ​

Serves the edge PSKs of the two fixture peers the way the control plane does: each PSK is sealed under the mock NSK (mockNSK, the nsk that registration returns) with the contract's additional authenticated data, and the envelope is the raw response body. edge_psk_count increments only on a 200.

Answer order. The handler returns at the first refusal:

  1. 403 node_id_mismatch if id is not the registered node (0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a3).
  2. 404 peer_not_found if peer_node_id is not a fixture peer (0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0b1 or 0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0b2) or equals id.

Response: 200 OK, a 60-byte application/octet-stream body (<12-byte nonce> || <32-byte PSK + 16-byte GCM tag>, with a fresh random nonce per response) and these headers:

HeaderValue
X-Plexsphere-PSK-KIDmock-nsk-1
X-Plexsphere-PSK-Epochthe fixed epoch below, the same for every envelope
Cache-Controlno-store

The PSK for a peer is the SHA-256 of mock-edge-psk|<peer_node_id>. The envelope is sealed with this additional authenticated data:

text
plexsphere/mesh/edge-psk/v1|0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a3|<peer_node_id>|2026-01-01T00:00:00.000000000Z|2026-01-01T00:00:01.000000000Z

Everything after the peer node id is the epoch from the X-Plexsphere-PSK-Epoch header, which itself contains a |. The epoch is fixed, so a re-fetch is never older than the envelope already in force and plexd's epoch floor never refuses one.

Counter: Increments edge_psk_count only on a 200.

Error: Refusals use application/problem+json with the codes above. A request without the bearer envelope is refused with 401 by the authentication gate. Returns 405 if the HTTP method is not GET.

GET /v1/nodes/{id}/state ​

Returns the active NodeStateSnapshot fixture built by newStateFixture. By default it is an enriched envelope containing two peers, a merged policy with two rules, the reachability projection, all four bridge subtrees, the state and reports blocks (which mirror one another), and empty executions, sessions, and secrets blocks. The active fixture can be replaced at runtime via POST /test/configure-state. The signing_key block is not read from the fixture: every response sets it to the mock's current signing key, the key POST /v1/register hands out, whatever the configured fixture holds. A pull after POST /test/rotate-signing-key therefore hands the node the new key.

Default fixture blocks:

BlockDefault Value
peers2 mesh peers (node_id ascending), no psk/allowed_ips; ...a0b1 carries fallback_endpoint and one endpoint_candidates entry (192.0.2.10:51820) but no direct endpoint, ...a0b2 only a direct endpoint
reachability{"state":"never_reported", ...} — the mock accepts heartbeats but never evaluates them into a projection
policymerged block: 2 rules, fingerprint from policyFingerprint
bridge.relay1 relay session assignment (relay-sess-001)
bridge.user_accessenabled: true, interface wg-access0, 1 peer
bridge.ingressenabled: true, 1 rule (ingress-001, port 443)
bridge.site_to_siteenabled: true, 1 tunnel (s2s-001)
state / reportsmetadata (2 entries), data (2 entries), reports ([])
executions[] — no dispatch queued; phases configure entries explicitly
sessions[] — no session issued; phases configure entries explicitly
secrets[] (no inventory; phases configure entries explicitly)
signing_keythe mock's current signing key (key_id did:web:plexsphere.com#key-e2e until POST /test/rotate-signing-key), set on every response rather than read from the fixture

The two peers give the agent's dial order both shapes. Peer ...a0b1's order is its candidate 192.0.2.10:51820, then its fallback 203.0.113.1:51820. No node holds its key, so no handshake ever completes, and on a device that came up the agent's endpoint selection switches it from the first entry to the second once the first times out. The Docker e2e run asserts that switch.

The fingerprint is produced by the mock-internal policyFingerprint helper — a 44-char base64 SHA-256 over the compact-JSON encoding of the rules slice. This canonicalization is mock-internal: plexd treats the fingerprint as an opaque comparison key and never re-derives it from the rules.

Response: 200 OK

json
{
  "peers": [
    {
      "node_id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0b1",
      "mesh_ip": "10.99.0.2",
      "public_key": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=",
      "fallback_endpoint": "203.0.113.1:51820",
      "endpoint_candidates": ["192.0.2.10:51820"]
    },
    {
      "node_id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0b2",
      "mesh_ip": "10.99.0.3",
      "public_key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
      "endpoint": "198.51.100.20:51820"
    }
  ],
  "reachability": {"state": "never_reported", "changed_at": "2026-01-01T00:00:00Z"},
  "policy": {
    "revision_id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0c1",
    "fingerprint": "<44-char base64 SHA-256>",
    "rules": [
      {
        "action": "allow",
        "protocol": "any",
        "source_cidr": "10.99.0.0/24",
        "destination_cidr": "10.99.0.0/24"
      },
      {
        "action": "allow",
        "protocol": "tcp",
        "source_cidr": "10.99.0.0/24",
        "destination_cidr": "0.0.0.0/0",
        "ports": {"from": 443, "to": 443}
      }
    ]
  },
  "bridge": {
    "relay": {
      "sessions": [
        {
          "session_id": "relay-sess-001",
          "peer_a_id": "peer-001",
          "peer_a_endpoint": "203.0.113.1:51820",
          "peer_b_id": "peer-003",
          "peer_b_endpoint": "203.0.113.3:51820",
          "expires_at": "2099-12-31T23:59:59Z"
        }
      ]
    },
    "user_access": {
      "enabled": true,
      "interface_name": "wg-access0",
      "listen_port": 51821,
      "peers": [
        {
          "public_key": "ua-pub-key-001",
          "allowed_ips": ["10.100.0.1/32"],
          "label": "admin-laptop"
        }
      ]
    },
    "ingress": {
      "enabled": true,
      "rules": [
        {
          "rule_id": "ingress-001",
          "listen_port": 443,
          "target_addr": "10.99.0.2:8443",
          "mode": "tcp"
        }
      ]
    },
    "site_to_site": {
      "enabled": true,
      "tunnels": [
        {
          "tunnel_id": "s2s-001",
          "remote_endpoint": "198.51.100.1:51820",
          "remote_public_key": "s2s-remote-pub-key-001",
          "local_subnets": ["10.99.0.0/24"],
          "remote_subnets": ["172.16.0.0/16"],
          "interface_name": "wg-s2s0",
          "listen_port": 51822
        }
      ]
    }
  },
  "state": {
    "metadata": [
      {"key": "environment", "value": "e2e-test"},
      {"key": "region", "value": "mock-region-1"}
    ],
    "data": [
      {"key": "app/config", "value": "{\"log_level\":\"info\",\"max_conns\":100}"},
      {"key": "certs/ca", "value": "-----BEGIN CERTIFICATE-----\nmock-ca-cert\n-----END CERTIFICATE-----"}
    ],
    "reports": []
  },
  "reports": {
    "metadata": [
      {"key": "environment", "value": "e2e-test"},
      {"key": "region", "value": "mock-region-1"}
    ],
    "data": [
      {"key": "app/config", "value": "{\"log_level\":\"info\",\"max_conns\":100}"},
      {"key": "certs/ca", "value": "-----BEGIN CERTIFICATE-----\nmock-ca-cert\n-----END CERTIFICATE-----"}
    ],
    "reports": []
  },
  "executions": [],
  "sessions": [],
  "secrets": [],
  "signing_key": {"key_id": "did:web:plexsphere.com#key-e2e", "public_key": "<base64 Ed25519 public key, generated per server start>"}
}

Content-Type: application/json

Counter: Increments state_count on each call.

Executions projection: the executions block is not served verbatim from the fixture. Each request projects the configured entries through the live callback state machine:

Configured entryServed as
expires_at not in the future (including a missing expires_at)filtered out
execution has reached a terminal statusdrained — filtered out from the moment it settles
execution is tracked non-terminallyserved with the live status, not the configured one
execution is untrackedserved verbatim

The result is a freshly allocated slice, so the configured entries are never rewritten in place, and it is never nil — the block always serializes as [] rather than null.

Sessions projection: the sessions block is filtered the same way, but there is no status projection — sessions have no callback state machine, so an entry is either served or not:

Configured entryServed as
expires_at in the futureserved verbatim, on every pull for as long as it stands
expires_at not in the future (including a missing expires_at)filtered out

The block is desired state, so redelivery is the point rather than a retry: an entry stands until it is drained. Revocation is expressed by re-posting the fixture without the entry — see POST /test/configure-state below. Like executions, the result is a freshly allocated slice and never nil.

Concurrency: The active fixture is protected by sync.RWMutex. Reads never block other reads. A write via POST /test/configure-state blocks reads briefly during replacement. Readers always see a complete fixture (never a partial update).

GET /v1/nodes/{id}/events ​

Server-Sent Events (SSE) endpoint for the signed event stream. There is no unsolicited initial event: the stream tails from now unless a Last-Event-ID cursor asks to replay buffered envelopes. The connection is held open with periodic keep-alive comments until the client disconnects or the stream is descoped.

Cursor (Last-Event-ID):

Header valueBehavior
absent or emptyTail from now — no replay
non-negative integer NReplay every buffered envelope with sequence > N, in order, then tail live
anything else (non-integer or negative)400 Bad Request, application/problem+json with code: invalid_cursor, no stream opened

Descope: When the events endpoint is descoped (see POST /test/configure-events), it answers 501 Not Implemented, application/problem+json with code: signed_event_bus_not_provisioned, without opening a stream. The mode is re-checked under lock so a descope that races the request never leaves a stream open.

Headers on the 200 stream:

HeaderValue
Content-Typetext/event-stream
Cache-Controlno-cache
Connectionkeep-alive
X-Plexsphere-API-Versionv1

Frame format: each broadcast envelope is delivered as an SSE frame carrying its monotonic stream sequence in the id: line:

id: 1
event: node_state_updated
data: {"id":"evt-inject-001","type":"node_state_updated","scope":"domain:0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a1","key_id":"did:web:plexsphere.com#key-e2e","issued_at":"2026-01-01T00:00:00Z","payload":{"revision":42},"signature":"<base64-ed25519>"}

Sequencing and ring buffer: every broadcast is assigned the next stream sequence (the first is 1) and recorded in a bounded replay ring (capacity 64; the oldest entry is dropped past that). Recording happens regardless of connected clients and regardless of descoped mode, so a later reconnect can replay from a cursor.

Keep-alive: Sends : keep-alive comment every 15 seconds (KeepAliveInterval).

Fan-out: Live clients are registered on the broadcast list. Events injected via POST /test/inject-event are delivered to all registered clients. Each client has a buffered channel (capacity 64); a client whose buffer is full has the event dropped silently to avoid blocking the broadcaster.

Disconnect: The server detects client disconnect via context cancellation (or a descope closing the stream's done channel), removes the client from the broadcast list, and cleans up the goroutine.

Counter: Increments events_request_count on every request, including descoped 501 and 400 cursor answers.

POST /v1/nodes/{id}/executions/{eid} ​

The v1 execution status callback. The mock enforces the node-id guard, a 64 KiB body cap, strict decoding to the five node-reportable statuses (ack, started, succeeded, failed, cancelled), the 16 KiB inline-output ceiling, and the execution state machine.

Legal transitions — the mock tracks each execution's current status (absent means never dispatched) and admits only these advances:

Current stateLegal next status
(never seen)ack
ackstarted
startedsucceeded, failed, cancelled, or started (only when declared_output_bytes > 0)
terminal (succeeded / failed / cancelled)none

The roster is closed and mirrors the control plane: a terminal status is reachable only from started, so ack → failed and ack → cancelled are refused with 409 invalid_state_transition. A node that will not run an action must therefore walk to started before failing it. The started → started self-repeat is the declaring callback that mints the output upload, which the node can only send once the run has finished. A callback that declares an over-ceiling output (started with declared_output_bytes > 0) is answered with a one-time presigned PUT URL in output_upload_url; a terminal callback carrying an object_key is verified against the uploaded bytes (existence, receipt, and sha256).

Every accepted callback increments execution_callback_count and returns 200 with the new status. Denials are RFC 9457 application/problem+json bodies:

StatusProblem codeMeaning
200 OK—Callback accepted; body carries the new status (and output_upload_url on a declaring callback)
400 Bad Requestmalformed_execution_callbackUnreadable/strict-decode failure, status outside the reportable set, non-base64 inline, or an object_key/sha256 that does not match a received upload
403 Forbiddennsk_node_mismatch{id} does not match the mock node identity
409 Conflictexecution_already_terminalCallback on an already-settled invocation
409 Conflictinvalid_state_transitionAny other illegal advance
413 Payload Too Largeinline_output_too_largeoutput.inline decodes to more than 16 KiB

PUT /exec-output/{eid} ​

The one-time presigned output upload minted by a declaring execution callback. The token query parameter identifies the upload; its recorded object key (exec-output/{eid}) must match the path, it must be unused, and the body must fit within the declared size. On success the mock records the bytes (for the terminal callback's sha256 check), increments execution_upload_count, and returns 200.

StatusCondition
200 OKBytes accepted
404 Not FoundNo upload for this token, or the token's key does not match the path
409 ConflictThe upload URL has already been used
413 Payload Too LargeBody exceeds the declared size

The 404 / 409 / 413 problem bodies carry no machine code.

POST /v1/nodes/{id}/sessions/{sid} ​

The v1 session activity record. The mock enforces the node-id guard, a 16 KiB body cap, strict decoding, and the one-of ssh / k8s / tcp contract: exactly one member must be set, and that member must satisfy its per-kind rules — k8s.verb non-empty, tcp.phase one of session_started / session_ended with a valid terminated_by when set, or one of the two ssh shapes the control plane admits:

  • a lifecycle row (phase set) carries no command, exit_code, started_at or completed_at; its phase is session_started without terminated_by, or session_ended without listener_endpoint and with a valid terminated_by when set;
  • a command row carries a command of 1 to 1024 bytes and neither terminated_by nor listener_endpoint.

So {"ssh":{}}, {"ssh":{"command":"ls","phase":"session_started"}}, {"ssh":{"phase":"session_paused"}} and {"ssh":{"phase":"session_ended","listener_endpoint":"10.99.0.1:1"}} are all 400 malformed_session_activity. A record that clears those structural gates goes on to liveness resolution.

Liveness resolves {sid} against the configured sessions block and against a session_id registry that POST /test/configure-state (and PUT /test/state) fills from every configured entry carrying a non-empty session_id and never prunes, so an id drained out of the block stays distinguishable from one that was never configured. The registry also keeps the kind each id was last configured with. A configured entry whose expires_at is in the future is live, and the row's populated member has to be the one its kind calls for (an ssh member for an ssh entry, k8s for k8s, tcp for tcp): a match increments session_activity_count and returns 204 No Content, a mismatch is a 400 malformed_session_activity with the detail activity variant does not match the session's kind, which counts on neither counter (the spec folds the kind mismatch into the structural code). A configured entry whose expires_at has lapsed is expired, an id in the registry but no longer in the block is revoked, and an id in neither is a 404 session_not_found, which increments session_activity_rejected_count.

An expired or revoked session is dead, and like the control plane since plexsphere/plexsphere#931 the mock records one row on it: the session_ended row of an ssh or tcp session, in the member its kind calls for (ssh.phase or tcp.phase set to session_ended). That row is answered 204 No Content and increments session_activity_count, unless the newest lifecycle row the mock accepted for the session (a tcp row, or an ssh row with a phase) is already a session_ended. So a listener's end is recorded once, and a listener bound again after it still gets its own. Every other row on a dead session is a 409 session_expired (expired) or 409 session_already_revoked (revoked) and increments session_activity_rejected_count: a second session_ended, a session_started, an ssh command row, any row on a k8s session, and a row whose member does not match the session's kind, which on a dead session is a 409 rather than a 400. Configuring the id again makes it live again. The body is captured before validation, so GET /test/last-request/session_activity holds the last posted row whether or not it was accepted. The mock accepts configured sessions of any kind, so k8s rows are testable against it even though plexd produces none.

Strict decoding means the accepted tcp fields are exactly those of api.TCPActivity, listener_endpoint among them — a node that reports the address its listener bound on a session_started row is accepted, not 400ed as an unknown field. The value itself is not validated.

StatusProblem codeMeaning
204 No Content—Activity record accepted: a row on a live session, or the one session_ended row a dead ssh or tcp session records
400 Bad Requestmalformed_session_activityUnreadable body, strict-decode failure, a one-of / per-kind violation, or a row whose member does not match a live session's kind
403 Forbiddennsk_node_mismatch{id} does not match the mock node identity
404 Not Foundsession_not_foundNo session resolves for this node and session id
409 Conflictsession_already_revokedThe id was configured earlier and has drained out of the block since, and the row is not the one session_ended row such a session records
409 Conflictsession_expiredThe configured entry's expires_at has lapsed, and the row is not the one session_ended row such a session records

PUT /v1/nodes/{id}/state/reports/{key} ​

The per-key state report upsert. The mock validates {key} against the control-plane grammar ^[a-z][a-z0-9._-]{0,127}$, strict-decodes the api.NodeStateReportRequest body, caps value at 4096 bytes, and upserts the entry into both mirrored reports buckets of the state fixture (state.reports and reports.reports), keeping each sorted by key ascending. Success is 200 OK.

Request body:

json
{ "value": "{\"status\":\"healthy\"}", "workload_tag": "web" }

Response: 200 OK

json
{ "accepted_at": "2026-07-19T19:32:35Z", "key": "status.mesh" }
StatusProblem codeMeaning
200 OK—Report upserted into both mirrored buckets
400 Bad Requestinvalid_reportKey is outside the grammar, the body fails strict decoding, or value exceeds 4096 bytes

Counter: Increments report_put_count on each accepted upsert.

DELETE /v1/nodes/{id}/state/reports/{key} ​

Removes the report stored under {key} from both mirrored reports buckets.

StatusProblem codeMeaning
204 No Content—Report removed
400 Bad Requestinvalid_reportKey is outside the grammar
404 Not Foundreport_not_foundNo report exists for this key

Counter: Increments report_delete_count on each successful delete.

POST /v1/nodes/{id}/metrics ​

The v1 metrics ingest. The mock enforces the shared ingest header gates, strict-decodes a non-empty JSON array of api.MetricSample, and validates each record's group (closed set: node_resources, tunnel_health, peer_latency, agent_stats), non-empty name, and non-zero timestamp. Success is 202 Accepted with an IngestReceipt whose records is the batch length.

Ingest header gates (shared by metrics, logs, and audit, checked in order):

  1. Encoding → 415 ingest_encoding_unsupported if Content-Encoding is anything other than empty, identity, or gzip.
  2. Sent-at → 400 ingest_sent_at_invalid if X-Plexsphere-Sent-At is missing or not an RFC 3339 timestamp.

Response: 202 Accepted

json
{ "accepted_at": "2026-07-19T19:32:35Z", "records": 2 }
StatusProblem codeMeaning
202 Accepted—Batch accepted; body is an IngestReceipt
400 Bad Requestingest_batch_malformedBody is not a non-empty JSON array, or a record has an out-of-set group, empty name, or zero timestamp
400 Bad Requestingest_sent_at_invalidX-Plexsphere-Sent-At missing or not RFC 3339
415 Unsupported Media Typeingest_encoding_unsupportedContent-Encoding other than gzip or identity

Counter: Increments metrics_count on each accepted batch.

POST /v1/nodes/{id}/logs ​

The v1 logs ingest. After the shared header gates, the mock splits the NDJSON body on newlines (skipping blank lines), strict-decodes each line into api.LogLine, and validates the severity (closed set: emerg, alert, crit, err, warning, notice, info, debug), non-empty message, and non-zero timestamp. A batch with no non-blank lines is malformed. Success is 202 Accepted with a receipt whose records is the line count.

StatusProblem codeMeaning
202 Accepted—Batch accepted; body is an IngestReceipt
400 Bad Requestingest_batch_malformedNo non-blank lines, an undecodable line, or a record with an out-of-set severity, empty message, or zero timestamp
400 Bad Requestingest_sent_at_invalidX-Plexsphere-Sent-At missing or not RFC 3339
415 Unsupported Media Typeingest_encoding_unsupportedContent-Encoding other than gzip or identity

Counter: Increments logs_count on each accepted batch.

POST /v1/nodes/{id}/audit ​

The v1 audit ingest, mirroring the logs handler: shared header gates, NDJSON split skipping blank lines, strict decode into api.AuditEvent, and validation of the source (closed set: auditd, k8s), non-empty action, non-empty outcome, and non-zero timestamp. Success is 202 Accepted with a receipt whose records is the line count.

StatusProblem codeMeaning
202 Accepted—Batch accepted; body is an IngestReceipt
400 Bad Requestingest_batch_malformedNo non-blank lines, an undecodable line, or a record with an out-of-set source, empty action, empty outcome, or zero timestamp
400 Bad Requestingest_sent_at_invalidX-Plexsphere-Sent-At missing or not RFC 3339
415 Unsupported Media Typeingest_encoding_unsupportedContent-Encoding other than gzip or identity

Counter: Increments audit_count on each accepted batch.

GET /releases/{tag}/{asset} ​

Plays the GitHub release channel the service.upgrade fetcher pulls from. It lives outside the /v1 namespace, mirroring the real release host rather than the control-plane API. The served assets are byte-identical copies of the internal/upgrade verification fixtures, embedded into the binary, so the e2e upgrade path fetches and verifies exactly what the unit tests do.

Asset matching is arch-agnostic (CI runs amd64, Docker Desktop arm64): {asset} must be plexd-linux-<arch> (the binary) or plexd-linux-<arch>.sigstore.json (the Sigstore bundle); anything else is a 404. The {tag} selects which fixture is served:

TagBinaryBundle
v9.9.9fixture blobvalid Sigstore bundle → verification succeeds
v9.9.8fixture blobgarbage non-JSON bundle → bundle parse fails downstream
anything else404 Not Found404 Not Found

Content-Type: application/octet-stream for the binary, application/json for the bundle.

Fixtures: test/e2e/mockapi/testdata/fixture.bin and fixture.sigstore.json, byte-identical copies of internal/upgrade/testdata/. Regenerate both sets together via make upgrade-fixture (keyless cosign sign-blob); the pinned signing identity in test/e2e/docker/plexd-e2e.yaml changes with the fixtures.

GET /test/assertions ​

Test-only endpoint returning a snapshot of all call counters. Not part of the /v1/ API namespace.

Response: 200 OK

json
{
  "registration_count": 0,
  "heartbeat_count": 0,
  "state_count": 0,
  "key_rotate_count": 0,
  "capabilities_count": 0,
  "endpoint_count": 0,
  "endpoint_candidates_count": 0,
  "secrets_count": 0,
  "secrets_rate_limited_count": 0,
  "report_put_count": 0,
  "report_delete_count": 0,
  "execution_callback_count": 0,
  "execution_upload_count": 0,
  "metrics_count": 0,
  "logs_count": 0,
  "audit_count": 0,
  "session_activity_count": 0,
  "session_activity_rejected_count": 0,
  "integrity_violation_count": 0,
  "handshake_report_count": 0,
  "edge_psk_count": 0,
  "inject_event_count": 0,
  "events_request_count": 0,
  "local_metrics_count": 0,
  "local_logs_count": 0,
  "local_audit_count": 0,
  "unauthorized_count": 0
}

Content-Type: application/json

POST /test/inject-event ​

Broadcasts a signed Envelope to all connected SSE clients and records it in the replay ring. The caller supplies only the identifying fields; the mock stamps the rest and signs the envelope so the agent's verifier accepts it.

Request body: id, type, scope, and payload. Both id and type are required. The mock replaces the scope the caller sends (see below).

json
{
  "id": "evt-inject-001",
  "type": "node_state_updated",
  "scope": "node:n_abc123",
  "payload": {"node_id": "e2e-node-1"}
}

Injecting action_request is possible too, but it delivers no dispatch: like node_state_updated its payload is opaque and it only nudges the agent into an immediate reconcile. The dispatch itself must be configured into the executions block via POST /test/configure-state. session_setup behaves the same way: it carries no session, so the session must be configured into the sessions block the same way.

session_revoked is not opaque: plexd decodes its payload, closes the session it names when that session is live, and only then pulls. The payload must be an object carrying session_id, a jti equal to it, revoked_at and revoke_reason:

json
{
  "id": "evt-e2e-sess-revoke-001",
  "type": "session_revoked",
  "scope": "node",
  "payload": {
    "session_id": "sess-e2e-revoke-001",
    "jti": "sess-e2e-revoke-001",
    "revoked_at": "2026-10-26T12:00:00Z",
    "revoke_reason": "operator_request"
  }
}

revoke_reason decides the terminated_by of the ending row: operator_request, policy_violation, identity_compromise and resource_decommissioned report operator_revoke, ttl_expired reports ttl_expired, idle_timeout reports idle_timeout, and an empty or unrecognised value reports operator_revoke with a Warn. A payload that does not decode (the JSON string "{}", for example), or whose session_id is empty or differs from its jti, closes nothing; the event dispatcher logs event handler failed, and plexd still pulls. The mock does not act on the payload and changes no state of its own: the session stays live in the mock until it is dropped from the sessions block, so the ending row the event produces is accepted as a row on a live session.

Fields the mock stamps: scope (domain:0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a1, replacing whatever the request sent), issued_at (current time), key_id (the current signing key id, did:web:plexsphere.com#key-e2e until POST /test/rotate-signing-key), the stream sequence (the id: frame line), and signature (a valid Ed25519 signature over api.SignedMessage, the SHA-512 digest of the shared canonical form api.CanonicalBytes).

The mock signs the way the "Signed event envelope" section of plexsphere's agent contract (docs/reference/api/plexd-agent-contract.md in plexsphere/plexsphere) specifies: the signature covers the digest, not the canonical bytes. The stamped scope is the Domain of that section's worked example. The canonical form carries only platform or domain:<uuid>, so a free-form scope such as node:n_abc123 could not be signed at all.

Response: 204 No Content

Behavior:

  • The signed envelope is recorded in the replay ring and broadcast to all connected SSE clients in SSE wire format
  • If no SSE clients are connected, the call still records the envelope in the ring (a later reconnect can replay it) and succeeds
  • Non-blocking send: a client whose channel buffer is full has the event dropped
  • A rotate_keys event additionally arms a pending key rotation for POST /v1/keys/rotate
  • Increments inject_event_count on each call
  • Request body is captured and retrievable via GET /test/last-request/inject_event

Error: Returns 400 if the request body is not valid JSON or is missing id or type. Returns 400 with {"error": "mockapi: sign envelope: <cause>"} if the payload is one the canonical form cannot carry, such as a number out of float64 range (1e400); that envelope is neither recorded in the replay ring nor broadcast, and inject_event_count does not move. Returns 405 if the HTTP method is not POST.

POST /test/configure-state ​

Replaces the active NodeStateSnapshot fixture at runtime. Subsequent calls to GET /v1/nodes/{id}/state return the configured state instead of the default. The state_count counter continues to increment regardless of which fixture is active.

Request body: A full api.NodeStateSnapshot JSON object (same schema as the GET /v1/nodes/{id}/state response).

json
{
  "peers": [],
  "reachability": null,
  "policy": null,
  "bridge": null,
  "state": {"metadata": [{"key": "custom", "value": "value"}], "data": [], "reports": []},
  "reports": null,
  "executions": [
    {
      "execution_id": "exec-e2e-001",
      "action": "system.info",
      "type": "builtin",
      "parameters": null,
      "status": "pending",
      "requested_at": "2026-01-01T00:00:00Z",
      "expires_at": "2099-01-01T00:00:00Z"
    }
  ],
  "sessions": [
    {
      "session_id": "sess-e2e-001",
      "jti": "sess-e2e-001",
      "kind": "tcp",
      "target": {"tcp": {"host": "127.0.0.1", "port": 8080}},
      "expires_at": "2099-01-01T00:00:00Z"
    }
  ]
}

Because the call replaces the whole fixture, a test that only wants to queue a dispatch reads the live snapshot, splices its executions array in, and posts the result back — that is what the e2e helper configure_executions does. The sessions key works the same way, spliced by configure_sessions.

Response: 204 No Content

Behavior:

  • The replacement is atomic — concurrent readers never see a partial update
  • The state fixture is protected by sync.RWMutex
  • Any valid NodeStateSnapshot JSON is accepted, including minimal objects with null blocks
  • Decoding is strict: a misspelled or renamed key anywhere in the envelope is a 400, not a 204 that silently stores a zero-valued block
  • Each configured execution seeds the callback state machine (see below)
  • A configured signing_key is stored (and GetState() returns it) but never served: GET /v1/nodes/{id}/state replaces it with the mock's current signing key
  • Request body is captured and retrievable via GET /test/last-request/configure_state

Execution seeding: the configured status of each entry tells the mock what the control plane already holds for that execution, so a phase can stage a mid-flight execution rather than only a fresh one:

Configured statusSeeds
pendingnothing — the execution stays untracked until the node's ack
ack / startedthat status, so the node's next callback is validated against it

Seeding is seed-if-absent: an execution the mock is already tracking is left alone, so re-posting the same fixture can never regress an execution the node has already advanced.

Session issuance and revocation: sessions seed nothing — there is no callback state machine to stage. A configured entry is issued by the mere fact of being served, and only while its expires_at is in the future: an entry with a zero or past expires_at is never served at all, so it cannot be used to stage an expired session. Revocation is a re-post without the entry: because the sessions block is desired state, dropping an entry from the fixture drains it out of the next pull, which is the node's teardown signal. There is no revocation endpoint.

Error: Returns 400 if the request body is not valid JSON. Returns 405 if the HTTP method is not POST.

Go API: The Server also exposes SetState(api.NodeStateSnapshot) and GetState() api.NodeStateSnapshot methods for direct use in Go test code without HTTP.

PUT /test/state ​

Alias for POST /test/configure-state. Same behavior.

Response: 204 No Content

POST /test/configure-endpoint ​

Sets the stale_after TTL applied by PUT /v1/nodes/{id}/endpoint and PUT /v1/nodes/{id}/endpoint-candidates. Lets a test shorten the TTL below the refresh interval to exercise the deadline-driven re-report path.

Request body:

json
{ "ttl_seconds": 90 }

Response: 204 No Content when ttl_seconds is positive.

Behavior:

  • ttl_seconds must be greater than 0; otherwise the handler returns 400 with {"error": "ttl_seconds must be positive"}.
  • The TTL defaults to 5 minutes and is protected by sync.RWMutex.
  • Request body is captured and retrievable via GET /test/last-request/configure_endpoint.

Error: Returns 400 if the body is not valid JSON or ttl_seconds is not positive. Returns 405 if the HTTP method is not POST.

Go API: The Server also exposes SetEndpointTTL(time.Duration) for direct use in Go test code.

POST /test/configure-secrets ​

Arms the dials the secret handler reads: the served current_version, the count of upcoming fetches that answer a 429 (rate_limit_next), and the Retry-After seconds those 429s carry (retry_after_seconds). Lets a test exercise version selection and the armed rate-limit path without a live secret backend.

Request body: strict-decoded — an unknown field is a 400. Each field is applied only when greater than 0, so a partial body leaves the other dials untouched.

json
{ "current_version": 3, "rate_limit_next": 2, "retry_after_seconds": 5 }

Response: 204 No Content.

Behavior:

  • current_version — the version served in X-Plexsphere-Secret-Version and the ceiling a ?version=N selector is checked against (a higher ?version yields 404 secret_version_not_found).
  • rate_limit_next — arms the next N fetches to answer 429 per_node_rate_limited with a Retry-After header, decremented exactly once per fetch so that exactly N fetches are limited; each armed response increments secrets_rate_limited_count.
  • retry_after_seconds — the value written into the Retry-After header of those armed 429s.
  • Request body is captured and retrievable via GET /test/last-request/configure_secrets.

Error: Returns 400 if the body is not valid JSON or contains an unknown field. Returns 405 if the HTTP method is not POST.

POST /test/configure-events ​

Flips the event stream between streaming (the default) and descoped. Descoping makes GET /v1/nodes/{id}/events answer the spec's 501signed_event_bus_not_provisioned and closes every open stream through its done channel; streaming restores normal service. Lets a test exercise the agent's pull-only delivery mode without a live control plane.

Request body: strict-decoded — an unknown field is a 400. mode is a closed enum: streaming or descoped.

json
{ "mode": "descoped" }

Response: 204 No Content.

Behavior:

  • descoped — the events endpoint answers 501 signed_event_bus_not_provisioned, and every currently open stream is closed. The replay ring keeps recording injected envelopes while descoped.
  • streaming — normal service is restored; new connections open streams again.
  • Request body is captured and retrievable via GET /test/last-request/configure_events.

Error: Returns 400 if the body is not valid JSON, contains an unknown field, or carries a mode other than streaming or descoped. Returns 405 if the HTTP method is not POST.

POST /test/session-token ​

Mints the token a mediated ssh session logs in with: a compact JWS signed with the mock's current signing key, the key POST /v1/register hands out as signing_public_key and every state pull carries in signing_key, so plexd and its session helper accept it the way they accept a token the control plane signed with the Domain key.

Request body:

json
{"session_id": "sess-e2e-ssh-root", "user": "root", "allowed_commands": ["uptime"], "ttl_seconds": 600}

allowed_commands is optional.

Response: 200 OK with {"token": "<header>.<claims>.<signature>"}. The header is {"alg":"EdDSA","kid":"<key id>","typ":"at+jwt"}, where kid is the current signing key id (did:web:plexsphere.com#key-e2e until POST /test/rotate-signing-key). The claims are iss plexsphere://domain/e2e, aud resource://e2e, subidentity://e2e, jti equal to session_id, kind ssh, target{"kind":"ssh","user":…,"allowed_commands":…}, and iat, nbf and exp in Unix seconds, with iat and nbf now and exp now plus ttl_seconds.

Error: Returns 400 if the body is not valid JSON, session_id or user is empty, or ttl_seconds is outside 1 to 3600. Returns 405 if the HTTP method is not POST.

POST /test/rotate-signing-key ​

Replaces the mock's Domain signing key with a fresh Ed25519 key pair, named the way the control plane names a key it mints for a Domain. This is what a node enrolled before its Domain had a key of its own meets: the control plane signs with a key the node did not get at registration, and the node learns that key only from the state pull. The Docker e2e run uses it to prove that plexd installs the pulled key, verifies events signed with it, and starts from it after a restart.

Request body: none. The handler does not read the body.

Response: 200 OK

json
{
  "key_id": "signing-domain-0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a1-1767225600000000000",
  "public_key": "<base64 Ed25519 public key>"
}

The answer has the shape of api.NodeStateSigningKey. key_id is signing-domain-0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a1-<unix nanos> and public_key is the 32-byte public key in standard base64.

Behavior:

  • Every later envelope, session token, registration and pull uses the new key: POST /test/inject-event stamps its id and signs with it, POST /test/session-token names it in kid and signs with it, and POST /v1/register and GET /v1/nodes/{id}/state hand it out.
  • The old key is gone at once: the mock keeps no grace window and signs nothing more with it. A node refuses the envelopes signed with the new key (api: verifier: unknown signing key id) until a pull has carried that key.
  • Each call mints another key. The initial key id did:web:plexsphere.com#key-e2e comes back only when the server restarts.
  • A Linux session helper that already pinned the earlier key at /etc/plexd/session-signing-key keeps verifying ssh session tokens against its pin. See Trust anchor.
  • The key is guarded by sync.RWMutex, so a rotation never races a signer or a pull. No counter moves and no request body is captured.

Content-Type: application/json

Error: Returns 500 if generating the key pair fails. Returns 405 if the HTTP method is not POST.

GET /test/last-request/{endpoint} ​

Returns the raw request body captured from the last call to the specified endpoint. Useful for asserting that the client sent the correct payload.

Path parameter: endpoint — the capture key (e.g., register, heartbeat, inject_event).

Response: 200 OK with Content-Type: application/octet-stream and the raw body bytes.

Error: Returns 404 if no request has been captured for the given endpoint.

TLS Listener (Local Endpoints) ​

The server runs a second listener on the TLS address (-tls-addr, default :8443) with a self-signed ECDSA P-256 certificate (DNS names: mock-api, localhost). This listener serves local endpoint handlers that simulate a secondary on-premises HTTPS endpoint with Bearer token authentication.

The TLS certificate is generated at startup by GenerateSelfSignedTLSConfig() and is valid for 24 hours. Clients must use tls_insecure_skip_verify: true since the certificate is self-signed.

The TLS listener serves a separate http.ServeMux (TLSHandler()) with the following routes:

  • POST /local/metrics
  • POST /local/logs
  • POST /local/audit
  • GET /test/assertions (same counters as the HTTP listener)
  • GET /test/last-request/{endpoint} (same capture store as the HTTP listener)

POST /local/metrics ​

Accepts a metrics payload on the local endpoint. Requires Bearer token authentication.

Authentication: Authorization: Bearer e2e-local-bearer-token

Response: 204 No Content

Counter: Increments local_metrics_count on each successful call.

Error: Returns 401 Unauthorized if the Authorization header is missing, malformed, or contains the wrong token.

POST /local/logs ​

Accepts a log payload on the local endpoint. Requires Bearer token authentication.

Authentication: Authorization: Bearer e2e-local-bearer-token

Response: 204 No Content

Counter: Increments local_logs_count on each successful call.

Error: Returns 401 Unauthorized if the Authorization header is missing, malformed, or contains the wrong token.

POST /local/audit ​

Accepts an audit payload on the local endpoint. Requires Bearer token authentication.

Authentication: Authorization: Bearer e2e-local-bearer-token

Response: 204 No Content

Counter: Increments local_audit_count on each successful call.

Error: Returns 401 Unauthorized if the Authorization header is missing, malformed, or contains the wrong token.

Bearer Token Resolution ​

The expected bearer token (e2e-local-bearer-token) is provisioned through the same credential chain that plexd uses in production:

  1. Registration — POST /v1/register returns nsk as 44-char standard-padded base64; plexd decodes it into the 32-byte AES-256-GCM key
  2. Secret fetch — GET /v1/nodes/{id}/secrets/{key} returns the raw AES-256-GCM envelope (<12-byte nonce> || <ciphertext + 16-byte GCM tag>) as an application/octet-stream body, with the version and KID (e2e-nsk-kid-1) in the X-Plexsphere-Secret-Version / X-Plexsphere-Secret-KID headers and Cache-Control: no-store
  3. Decryption — plexd opens the envelope with nodeapi.DecryptSecret(nsk, envelope) to recover the bearer token
  4. Authentication — plexd sends Authorization: Bearer e2e-local-bearer-token on each request to the local endpoint

Call Counters ​

The server tracks API calls using sync/atomic.Int64 counters. Each endpoint increments its counter atomically before writing the response, ensuring accurate counts under concurrent access.

CounterIncremented By
registration_countPOST /v1/register
heartbeat_countPOST /v1/nodes/{id}/heartbeat
state_countGET /v1/nodes/{id}/state
key_rotate_countPOST /v1/keys/rotate (completed rotations only)
capabilities_countPUT /v1/nodes/{id}/capabilities (accepted manifests only)
endpoint_countPUT /v1/nodes/{id}/endpoint
endpoint_candidates_countPUT /v1/nodes/{id}/endpoint-candidates (accepted reports only, 200)
secrets_countGET /v1/nodes/{id}/secrets/{key} (served 200 envelopes only)
secrets_rate_limited_countGET /v1/nodes/{id}/secrets/{key} (armed 429 responses only)
report_put_countPUT /v1/nodes/{id}/state/reports/{key} (accepted upserts only)
report_delete_countDELETE /v1/nodes/{id}/state/reports/{key} (successful deletes only)
execution_callback_countPOST /v1/nodes/{id}/executions/{eid} (accepted callbacks only)
execution_upload_countPUT /exec-output/{eid} (presigned output upload)
metrics_countPOST /v1/nodes/{id}/metrics
logs_countPOST /v1/nodes/{id}/logs
audit_countPOST /v1/nodes/{id}/audit
session_activity_countPOST /v1/nodes/{id}/sessions/{sid} (accepted rows only)
session_activity_rejected_countPOST /v1/nodes/{id}/sessions/{sid} (liveness refusals only: 404 / 409)
integrity_violation_countPOST /v1/nodes/{id}/integrity-violations (accepted batches only, one per request regardless of batch size)
handshake_report_countPOST /v1/nodes/{id}/handshakes (accepted reports only, 200)
edge_psk_countGET /v1/nodes/{id}/peers/{peer_node_id}/psk (served 200 envelopes only)
inject_event_countPOST /test/inject-event
events_request_countGET /v1/nodes/{id}/events (every request, including descoped 501 and 400)
local_metrics_countPOST /local/metrics (TLS)
local_logs_countPOST /local/logs (TLS)
local_audit_countPOST /local/audit (TLS)
unauthorized_countany authenticated route refused by the bearer-envelope gate

Query current values via GET /test/assertions.

Wire Compatibility ​

All responses use the same JSON field names as the types in internal/api:

  • RegisterResponse — internal/api.RegisterResponse
  • RegisterPeer — internal/api.RegisterPeer
  • HeartbeatResponse — internal/api.HeartbeatResponse
  • NodeStateSnapshot — internal/api.NodeStateSnapshot
  • SnapshotPeer — internal/api.SnapshotPeer
  • PolicySnapshot / PolicyRule / PortRange — internal/api.PolicySnapshot / internal/api.PolicyRule / internal/api.PortRange
  • BridgeSnapshot — internal/api.BridgeSnapshot
  • NodeStateBlock / StateEntry — internal/api.NodeStateBlock / internal/api.StateEntry
  • NodeStateSigningKey — internal/api.NodeStateSigningKey
  • Envelope — internal/api.Envelope
  • Peer — internal/api.Peer
  • RelayConfig / RelaySessionAssignment — internal/api.RelayConfig / internal/api.RelaySessionAssignment
  • UserAccessConfig / UserAccessPeer — internal/api.UserAccessConfig / internal/api.UserAccessPeer
  • IngressConfig / IngressRule — internal/api.IngressConfig / internal/api.IngressRule
  • SiteToSiteConfig / SiteToSiteTunnel — internal/api.SiteToSiteConfig / internal/api.SiteToSiteTunnel

Dockerfile ​

Multi-stage build at test/e2e/mockapi/Dockerfile.

StageImagePurpose
Buildergolang:1.26-alpineCompile the mock server binary
Runtimegcr.io/distroless/static-debian12Minimal runtime with no shell

Build ​

From the repository root:

bash
docker build -f test/e2e/mockapi/Dockerfile -t mockapi:latest .

Multi-platform build:

bash
docker buildx build -f test/e2e/mockapi/Dockerfile \
    --platform linux/amd64,linux/arm64 \
    -t mockapi:latest .

Runtime Details ​

PropertyValue
User65534:65534 (nobody)
Exposed ports8080 (HTTP), 8443 (TLS)
Entrypoint/usr/local/bin/mockapi
Default CMD["-addr", ":8080"]

Override the listen address:

bash
docker run -p 9090:9090 mockapi:latest -addr :9090

Build Optimizations ​

  • Module cache layer: go.mod and go.sum are copied and downloaded before the full source, so source-only changes reuse the cached module layer.
  • Static binary: Built with CGO_ENABLED=0 and ldflags -s -w for minimal size.
  • Distroless base: No shell or package manager in the runtime image.

Usage in Tests ​

go
srv := mockapi.New()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()

// Use ts.URL as the base URL for plexd's API client

For TLS local endpoint testing:

go
srv := mockapi.New()
tlsCfg := mockapi.GenerateSelfSignedTLSConfig()
ts := httptest.NewUnstartedServer(srv.TLSHandler())
ts.TLS = tlsCfg
ts.StartTLS()
defer ts.Close()

// srv.NSK() returns the raw 32-byte node secret key (the response encodes it as base64)
// srv.ExpectedBearerToken() returns "e2e-local-bearer-token"

Source ​

  • Server: test/e2e/mockapi/mockapi.go
  • CLI entry point: test/e2e/mockapi/cmd/mockapi/main.go
  • Tests: test/e2e/mockapi/mockapi_test.go
  • Dockerfile: test/e2e/mockapi/Dockerfile