Skip to content

API Types ​

All request/response types for the control plane API endpoints, organized by endpoint group. All types use JSON struct tags matching the API specification.

Registration ​

POST /v1/register ​

Unauthenticated (security: []): the bootstrap token travels in the request body, not an Authorization header. Success is 201 Created; errors use RFC 9457 application/problem+json. See Control Plane API Endpoints for the wire examples and error taxonomy.

RegisterRequest

FieldTypeJSON TagConstraints
ProjectIDstring"project_id"Required. Platform project UUID.
ResourceHandlestring"resource_handle"Required. Platform Resource handle.
BootstrapTokenstring"bootstrap_token"Required. Format psb_<env>_<project>_<kind>_<random>, matching ^psb_[a-z]+_[a-z2-7]+_(node|bridge)_[a-z2-7]{20,}$.
Noncestring"nonce"Required. Fresh UUIDv4 generated per registration attempt (server-side replay protection).
PublicKeystring"public_key"Required. Curve25519 public key as 44-char standard base64, matching ^[A-Za-z0-9+/]{43}=$.
RequestedResourceIDstring"requested_resource_id,omitempty"Optional. Resource ID override when substrate naming differs from the handle.

hostname, metadata, and capabilities are no longer part of registration. Capabilities are published after registration via PUT /v1/nodes/{node_id}/capabilities.

RegisterResponse (201 Created)

FieldTypeJSON TagDescription
NodeIDstring"node_id"Assigned node identifier (UUID)
MeshIPstring"mesh_ip"Assigned mesh IP address
SigningPublicKeystring"signing_public_key"Control plane signing public key
SigningKeyIDstring"signing_key_id"Signing key id for rotation-aware signature verification (e.g. did:web:plexsphere.com#key-2026-04)
NSKstring"nsk"Node secret key, returned exactly once: 44-char standard-padded base64 that decodes to the 32-byte AES-256-GCM key. Sent verbatim as the bearer credential; decoded before use as a key
PeerSnapshot[]RegisterPeer"peer_snapshot"Initial peer snapshot
DomainMeshCIDRstring"domain_mesh_cidr"Domain mesh address range (e.g. 100.64.0.0/10)

RegisterPeer

FieldTypeJSON TagDescription
NodeIDstring"node_id"Peer node ID
MeshIPstring"mesh_ip"Peer mesh IP address
PublicKeystring"public_key"Peer Curve25519 public key
FallbackEndpointstring"fallback_endpoint,omitempty"Optional fallback WireGuard endpoint

RegisterPeer is deliberately narrow: it carries no psk, allowed_ips, or endpoint. The reconciliation peer shape is SnapshotPeer (see State), used by GET /v1/nodes/{node_id}/state.

Peer

Peer is the internal WireGuard programming shape. The wireguard package builds it from a SnapshotPeer (see State) and never reads it off the wire: the state pull carries SnapshotPeer, and plexd never decodes the payloads of the SSE peer_* events.

FieldTypeJSON TagDescription
IDstring"id"Peer node ID
PublicKeystring"public_key"WireGuard public key
MeshIPstring"mesh_ip"Mesh IP address
Endpointstring"endpoint"First entry of Endpoints, or empty when the peer has no endpoint to dial
Endpoints[]string"endpoints,omitempty"The peer's dial order: the endpoints the WireGuard manager tries one after another until a handshake completes (see Dial order)
AllowedIPs[]string"allowed_ips"mesh_ip/32, derived locally
PSKstring"psk"Edge PSK fetched for the peer's current public key, standard base64; empty when plexd holds none

Heartbeat ​

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

HeartbeatRequest

FieldTypeJSON TagDescription
ClientNowtime.Time"client_now"RFC 3339 UTC send time, stamped fresh per request; the server rejects a skew above 60s
BinaryChecksumstring"binary_checksum"SHA-256 of the running binary (64-char hex or 44-char base64 of 32 bytes)
BinaryVersionstring"binary_version"Build version (dev when unset); never empty
NATSummarymap[string]any"nat_summary"Always a JSON object: {} before discovery, else {"endpoint", "nat_type"}

nat_summary is a map[string]any rather than a nil-able pointer so it always marshals to an object: a nil map would emit null, which the control plane rejects with malformed_heartbeat_request.

The in-memory NAT discovery result that feeds nat_summary is nat.DiscoveryResult; it is never serialized on its own.

HeartbeatResponse

FieldTypeJSON TagDescription
AcceptedAttime.Time"accepted_at"Server receive time (used for skew estimation)
Reconcilebool"reconcile"Whether to trigger reconciliation
RotateKeysbool"rotate_keys"Whether to rotate keys

State ​

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

NodeStateSnapshot

The desired-state envelope. Every block key is always present on the wire: a null value means "block not populated", never "field absent", so the differ distinguishes a nil pointer from a populated block. None of the ten fields carry omitempty.

FieldTypeJSON TagDescription
Peers[]SnapshotPeer"peers"Desired peers; [] when empty, node_id ascending, self excluded
Reachabilityjson.RawMessage"reachability"The control plane's verdict about this node, carried opaquely and decoded leniently by the reachability observer
Policy*PolicySnapshot"policy"Merged network policy block
Bridge*BridgeSnapshot"bridge"Bridge subtrees
State*NodeStateBlock"state"Node state buckets
Reports*NodeStateBlock"reports"Mirrors state today (forward-compat split)
Executions[]NodeStateExecution"executions"Pending action dispatches; [] when empty, never null
Sessions*[]NodeStateSession"sessions"Live mediated-access sessions; [] when empty, never null on the wire. A pointer, like the block fields above: a nil value is a block the control plane did not populate, which is not a teardown signal
Secrets*[]NodeStateSecret"secrets"The node's secret inventory, ordered by name ascending; [] when the node may read nothing, never null on the wire. A pointer, like Sessions: a nil value (the wire's null, or a pull from a control plane that predates the block and omits it) keeps the node API's secret index, and [] empties it
SigningKey*NodeStateSigningKey"signing_key"The key the node's Domain signs envelopes and session tokens with. A nil value (the wire's null, or a pull from a control plane that predates the block and omits it) means "keep the key you hold", never "drop it"

Five members are consumed on the dispatch seam rather than by the differ. executions is not desired state at all — it is a delivery queue, documented under Executions below. sessions is desired state, but state the tunnel dispatcher converges on itself, documented under Sessions below. reachability is not state to converge on either: the reachability observer decodes it leniently and logs the verdict when it changes. signing_key has nothing to converge on: the first dispatch handler installs and persists it (see NodeStateSigningKey below). secrets is never diffed: the node API's SecretIndexDispatchHandler replaces the secret index from it on every successful pull (see NodeStateSecret below). peers goes to both: the differ drives the WireGuard reconcile handler from it, and the edge PSK store reads it on the dispatch seam.

SnapshotPeer

Carries the peer's direct endpoint and its endpoint_candidates but nopsk or allowed_ips. plexd derives AllowedIPs locally as mesh_ip/32. plexd builds the peer's dial order from the candidates on a subnet this node is also on, then endpoint when it is usable, then the remaining candidates, then fallback_endpoint (the relay target), and keeps the first entry that completes a WireGuard handshake (see Dial order and Endpoint selection). Unless wireguard.edge_psk is false, plexd fetches the pairwise edge PSK per edge from GET /v1/nodes/{node_id}/peers/{peer_node_id}/psk (see Edge PSK).

A direct endpoint or candidate is usable when it parses as an IP address and port with a non-zero port and no zone, its address (an IPv4-mapped address is unmapped first) is not unspecified, loopback, multicast or link-local, and it lies outside the Domain mesh prefix. plexd leaves a refused one out of the order, logging a refused endpoint at Warn and refused candidates at Debug. fallback_endpoint is not validated, because the contract allows a hostname there.

FieldTypeJSON TagDescription
NodeIDstring"node_id"Peer node ID
MeshIPstring"mesh_ip"Peer mesh IP address
PublicKeystring"public_key"Peer WireGuard public key
Endpointstring"endpoint,omitempty"Peer's direct WireGuard endpoint as host:port (an IPv6 host in brackets), the address it last reported through PUT /v1/nodes/{node_id}/endpoint; absent while the peer has no fresh observation
FallbackEndpointstring"fallback_endpoint,omitempty"Optional relay/fallback endpoint
EndpointCandidates[]string"endpoint_candidates,omitempty"The peer's endpoint candidates as host:port strings, at most 8: first its peer-observed endpoints, the addresses at which at least two live nodes of the Domain report a direct handshake with it, then its last accepted, unexpired local candidates (PUT /v1/nodes/{node_id}/endpoint-candidates) in the order it sent them (see State); absent when the peer has none and on every control plane that does not deliver them

ReachabilitySnapshot

The interior of the reachability block. NodeStateSnapshot.Reachability stays a json.RawMessage: decoding it inside FetchState would let a malformed diagnostic block abort the whole state pull, so the decode happens in the reachability observer instead.

State is a plain string and is never validated against the constants below. The verdict vocabulary belongs to the control plane and grows there — never_reported was added after plexd shipped — so a value this build does not know is logged verbatim rather than rejected.

FieldTypeJSON TagDescription
Statestring"state"ReachabilityHealthy (healthy), ReachabilityStale (stale), ReachabilityUnreachable (unreachable), or ReachabilityNeverReported (never_reported); any other value is carried through
LastHeartbeatAt*time.Time"last_heartbeat_at,omitempty"Absent until the control plane accepts the first heartbeat
ChangedAttime.Time"changed_at"When the verdict last changed; always present

PolicySnapshot

The single merged policy block.

FieldTypeJSON TagDescription
RevisionIDstring"revision_id"Policy revision identifier
Fingerprintstring"fingerprint"44-char base64 SHA-256 over the server's canonical rule stream; plexd compares it byte-for-byte and never re-derives it
Rules[]PolicyRule"rules"Ordered firewall rules

PolicyRule

A five-tuple firewall rule. Ports is present iff Protocol is tcp or udp.

FieldTypeJSON TagDescription
Actionstring"action"allow, deny, or log
Protocolstring"protocol"tcp, udp, icmp, or any
SourceCIDRstring"source_cidr"Source CIDR
DestinationCIDRstring"destination_cidr"Destination CIDR
Ports*PortRange"ports,omitempty"Inclusive destination port range

PortRange

A single inclusive destination port range (from <= to).

FieldTypeJSON TagDescription
Fromint"from"Range start port
Toint"to"Range end port

BridgeSnapshot

Carries the four bridge subtrees. Each child is present-but-nullable; there are no base fields on the wire. Inner shapes (RelayConfig, UserAccessConfig, IngressConfig, SiteToSiteConfig) are documented under Bridge Mode.

FieldTypeJSON TagDescription
Relay*RelayConfig"relay"Relay session assignments
UserAccess*UserAccessConfig"user_access"User access configuration
Ingress*IngressConfig"ingress"Public ingress configuration
SiteToSite*SiteToSiteConfig"site_to_site"Site-to-site VPN configuration

NodeStateBlock

A three-bucket state block. Each bucket is a required array (never null when the block is populated), with entries ordered by key ascending.

FieldTypeJSON TagDescription
Metadata[]StateEntry"metadata"Metadata entries
Data[]StateEntry"data"Data entries
Reports[]StateEntry"reports"Report entries

StateEntry

Value is an opaque string; WorkloadTag is absent/empty when the entry is unattributed.

FieldTypeJSON TagDescription
Keystring"key"Entry key
Valuestring"value"Opaque string value
WorkloadTagstring"workload_tag,omitempty"Owning workload, if any

NodeStateSigningKey

The interior of the signing_key block: the Domain signing key as the "Domain signing key" section of plexsphere's agent contract (docs/reference/api/plexd-agent-contract.md in plexsphere/plexsphere) describes it, in the encodings POST /v1/register uses for signing_key_id and signing_public_key. plexd installs the key every successful pull carries into the verifier that checks event envelopes and ssh session tokens (a key whose id or bytes differ from the current pair replaces it and drops any rotation grace entry), and writes a key that differs from the persisted pair to the signing_public_key file and identity.json, so a restart starts from it.

FieldTypeJSON TagDescription
KeyIDstring"key_id"Id the key signs under; plexd rejects an empty one
PublicKeystring"public_key"32-byte Ed25519 public key in standard base64; plexd rejects any other length

NodeStateSecret

One entry of the secrets block: a Secret the node may read, as the "Secret inventory" section of plexsphere's agent contract (docs/reference/api/plexd-agent-contract.md in plexsphere/plexsphere) describes it. The entry carries the Secret's name and current version, never its value; plexd fetches the value from GET /v1/nodes/{node_id}/secrets/{key} (see Secrets). The node API's SecretIndexDispatchHandler maps Name onto SecretRef.Key, keeps Version, and keeps the pull order. It drops an entry whose name is outside the grammar, whose version is below 1, or whose name an earlier kept entry already has, and logs each dropped entry at Warn as secret inventory entry dropped, once until the set of dropped entries changes.

FieldTypeJSON TagDescription
Namestring"name"The Secret's name, matching ^[a-z][a-z0-9_-]{0,62}$; becomes SecretRef.Key in the node API's secret index
Versionint"version"The Secret's current version, at least 1

Supporting types ​

These types are part of the API but are not members of the state snapshot. SigningKeyRotation is the payload of the signing_key_rotated SSE event. SessionRevocation is the payload of the session_revoked SSE event. SecretRef is the entry of the local node API's secret index, fed from the pull's NodeStateSecret entries (the name becomes key). DataEntry is the shape the local node API serves for cached data entries.

SigningKeyRotation

FieldTypeJSON TagDescription
KeyIDstring"key_id"Key id of the new current signing key
PublicKeystring"public_key"Base64 Ed25519 public key of the new current key
PreviousKeyIDstring"previous_key_id"Prior key id kept valid during the grace window
TransitionExpirestime.Time"transition_expires"When the previous key id stops being accepted

SessionRevocation

FieldTypeJSON TagDescription
SessionIDstring"session_id"Id of the session the control plane revoked
JTIstring"jti"The revoked session token's jti, equal to session_id; plexd closes nothing when the two differ or session_id is empty
RevokedAttime.Time"revoked_at"When the control plane revoked the session
RevokeReasonstring"revoke_reason"One of the RevokeReason* values

RevokeReason constants

Carried on SessionRevocation.RevokeReason. The first four are operator-driven, supplied on the explicit revoke call; the last two are set by the control plane's sweeper. The description names the terminated_by plexd reports on the session_ended row of the session the event closes. An empty or unrecognised value is reported as operator_revoke too, with a Warn.

ConstantValueDescription
RevokeReasonOperatorRequestoperator_requestOperator-driven; reported as operator_revoke
RevokeReasonPolicyViolationpolicy_violationOperator-driven; reported as operator_revoke
RevokeReasonIdentityCompromiseidentity_compromiseOperator-driven; reported as operator_revoke
RevokeReasonResourceDecommissionedresource_decommissionedOperator-driven; reported as operator_revoke
RevokeReasonTTLExpiredttl_expiredSweeper, at the session's hard expiry; reported as ttl_expired
RevokeReasonIdleTimeoutidle_timeoutSweeper, at the session's idle timeout; reported as idle_timeout

DataEntry

FieldTypeJSON TagDescription
Keystring"key"Entry key
ContentTypestring"content_type"MIME content type
Payloadjson.RawMessage"payload"Arbitrary JSON payload
Versionint"version"Entry version
UpdatedAttime.Time"updated_at"Last update timestamp

SecretRef

FieldTypeJSON TagDescription
Keystring"key"The Secret's name, from NodeStateSecret.Name
Versionint"version"Secret version

Secrets ​

GET /v1/nodes/{node_id}/secrets/{key} ​

SecretEnvelope

The raw AES-256-GCM envelope served by the endpoint. Unlike every other type on this page it carries no JSON tags: Data is read from the octet-stream body, Version and KID from response headers — the type is never JSON-marshaled or unmarshaled.

FieldTypeSourceDescription
Data[]byteResponse body (application/octet-stream)<12-byte nonce> || <ciphertext + 16-byte GCM tag>
VersionintX-Plexsphere-Secret-Version headerVersion of the returned secret
KIDstringX-Plexsphere-Secret-KID headerKey id of the NSK the envelope was sealed under

Edge PSK ​

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

EdgePSKEnvelope

The pairwise WireGuard PSK for one edge, sealed under the calling node's NSK with AES-256-GCM. Like SecretEnvelope it carries no JSON tags: Data is read from the octet-stream body, KID and Epoch from response headers, and the type is never JSON-marshaled or unmarshaled. FetchEdgePSK refuses a body of any other length than EdgePSKEnvelopeSize and a response that lacks either header. Data is key material and is never logged.

FieldTypeSourceDescription
Data[]byteResponse body (application/octet-stream)<12-byte nonce> || <32-byte PSK + 16-byte GCM tag>, exactly EdgePSKEnvelopeSize bytes
KIDstringX-Plexsphere-PSK-KID headerKey id of the NSK the envelope was sealed under; required, but plexd holds one NSK and selects nothing by it
EpochstringX-Plexsphere-PSK-Epoch headerPSK epoch; required, part of the additional authenticated data and used verbatim

The wireguard package opens Data under the 32-byte NSK with the additional authenticated data plexsphere/mesh/edge-psk/v1|<self node id>|<peer node id>|<epoch>, both node IDs trimmed and lowercased. It refuses an envelope whose epoch sorts below the highest epoch it already accepted for the edge. That floor survives a change of the peer's public key and lives in memory only. See Edge PSKs for the fetch rule and how plexd handles each answer.

Constants

ConstantValueDescription
EdgePSKEnvelopeSize60Exact envelope length: 12-byte nonce, 32-byte PSK, 16-byte GCM tag

Reports ​

PUT / DELETE /v1/nodes/{node_id}/state/reports/{key} ​

Per-key node state reports. PUT upserts the report at {key}; DELETE removes it. {key} follows the report key grammar ^[a-z][a-z0-9._-]{0,127}$, and Value is capped at 4096 bytes. See Control Plane API Endpoints for the wire examples and error taxonomy.

NodeStateReportRequest (PUT body)

FieldTypeJSON TagDescription
Valuestring"value"Opaque report payload (at most 4096 bytes)
WorkloadTagstring"workload_tag,omitempty"Owning workload; absent when unattributed

NodeStateReportResponse (200 OK on PUT)

DELETE returns 204 No Content with no body (or 404 report_not_found when the key has no report).

FieldTypeJSON TagDescription
AcceptedAttime.Time"accepted_at"Server receive time
Keystring"key"Echoes the addressed report key

Executions ​

The executions block of the state snapshot and the single execution callback the node posts back to POST /v1/nodes/{node_id}/executions/{execution_id}.

executions block of GET /v1/nodes/{node_id}/state ​

NodeStateExecution

One pending action dispatch. The block is the delivery channel for action dispatches: it is always present ([] when empty, never null) and ordered by RequestedAt, then ExecutionID. An entry keeps reappearing on every pull until its execution reaches a terminal status through the execution callback, so a consumer must tolerate re-observing the same ExecutionID. The entry carries no callback URL and no hook checksum.

FieldTypeJSON TagDescription
ExecutionIDstring"execution_id"Execution identifier
Actionstring"action"Action name (builtin or hook)
Typestring"type"One of the ActionKind* values
Parametersmap[string]json.RawMessage"parameters"Action parameters, each held as raw JSON so a large integer keeps every digit; nullable, a JSON null decodes to a nil map
Statusstring"status"pending, ack, or started
RequestedAttime.Time"requested_at"When the control plane dispatched the execution
ExpiresAttime.Time"expires_at"Absolute UTC deadline, not a relative timeout

ActionKind constants

ConstantValueDescription
ActionKindBuiltinbuiltinDispatches an action built into plexd
ActionKindHookhookDispatches a hook registered by the node

Pull-only execution status

ConstantValueDescription
ExecutionStatusPendingpendingDispatched and not yet acknowledged; observed only on the pull block and never reported by a node on the callback

The other two statuses an entry can carry, ack and started, are the node-reportable constants listed below.

POST /v1/nodes/{node_id}/executions/{execution_id} ​

A single callback advances an execution through its lifecycle, posted once per transition: ack → started → succeeded | failed | cancelled.

ExecutionCallbackRequest

FieldTypeJSON TagDescription
Statusstring"status"One of the ExecutionStatus* values
ExitCode*int"exit_code,omitempty"Process exit code on a terminal callback (explicit zero)
Errorstring"error,omitempty"Failure reason on a failed terminal
DeclaredOutputBytesint64"declared_output_bytes,omitempty"Captured output length; over 16 KiB drives the presign
Output*ExecutionOutput"output,omitempty"Captured output on a terminal callback

ExecutionOutput

FieldTypeJSON TagDescription
Inlinestring"inline,omitempty"Base64 output body, used only when at most 16 KiB
ObjectKeystring"object_key,omitempty"Object-store key of an uploaded over-ceiling output
SHA256string"sha256,omitempty"Lowercase-hex SHA-256 of the uploaded bytes

ExecutionCallbackResponse

FieldTypeJSON TagDescription
Statusstring"status"The new invocation status
OutputUploadURLstring"output_upload_url,omitempty"Presigned PUT URL, set only on the declaring callback

ExecutionStatus constants

ConstantValueDescription
ExecutionStatusAckackAcknowledges receipt of the action request
ExecutionStatusStartedstartedReports that the action has begun running
ExecutionStatusSucceededsucceededTerminal callback for a successful run
ExecutionStatusFailedfailedTerminal callback for a failed run
ExecutionStatusCancelledcancelledTerminal callback for a cancelled run

Observability ​

The control-plane leg of the three ingest endpoints sends the flattened wire types below and receives an IngestReceipt on 202 Accepted. The internal pipeline and the optional local endpoint keep the richer MetricPoint / LogEntry / AuditEntry shapes; see Metrics Collection, Log Forwarding, and Audit Forwarding.

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

Body is a JSON array of MetricSample.

MetricSample

FieldTypeJSON TagDescription
Groupstring"group"Wire group: node_resources, tunnel_health, peer_latency, or agent_stats
Namestring"name"Sample name
Valuefloat64"value"Sample value
Labelsmap[string]string"labels,omitempty"Dimensions (e.g. peer_id); absent when the sample has none
Timestamptime.Time"timestamp"Sample time

MetricPoint — internal pipeline and local-endpoint format; MetricBatch is a type alias for []MetricPoint

FieldTypeJSON TagDescription
Timestamptime.Time"timestamp"Measurement time
Groupstring"group"Metric group name
PeerIDstring"peer_id,omitempty"Optional peer ID
Datajson.RawMessage"data"Metric data payload

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

Body is NDJSON: one LogLine per line.

LogLine

FieldTypeJSON TagDescription
Severitystring"severity"Syslog severity: emerg, alert, crit, err, warning, notice, info, debug
Unitstring"unit,omitempty"Systemd unit; absent when unknown
Hostnamestring"hostname,omitempty"Origin hostname; absent when unknown
Messagestring"message"Log message (non-empty)
Timestamptime.Time"timestamp"Log time

LogEntry — internal pipeline and local-endpoint format; LogBatch is a type alias for []LogEntry

FieldTypeJSON TagDescription
Timestamptime.Time"timestamp"Log timestamp
Sourcestring"source"Log source
Unitstring"unit"Systemd unit
Messagestring"message"Log message
Severitystring"severity"Log level
Hostnamestring"hostname"Origin hostname

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

Body is NDJSON: one AuditEvent per line.

AuditEvent

FieldTypeJSON TagDescription
Sourcestring"source"Wire source: auditd, k8s, or plexd
Actionstring"action"Action performed (non-empty)
Outcomestring"outcome"Outcome (non-empty)
Timestamptime.Time"timestamp"Event time

AuditEntry — internal pipeline and local-endpoint format; AuditBatch is a type alias for []AuditEntry

FieldTypeJSON TagDescription
Timestamptime.Time"timestamp"Event timestamp
Sourcestring"source"Audit source
EventTypestring"event_type"Audit event type
Subjectjson.RawMessage"subject"Who performed it
Objectjson.RawMessage"object"What was affected
Actionstring"action"Action performed
Resultstring"result"Action result
Hostnamestring"hostname"Origin hostname
Rawstring"raw"Raw audit record

Ingest receipt ​

IngestReceipt (202 Accepted for all three ingest endpoints)

FieldTypeJSON TagDescription
AcceptedAttime.Time"accepted_at"When the control plane accepted the batch
Recordsint"records"Number of records accepted from the batch

Capabilities ​

PUT /v1/nodes/{node_id}/capabilities ​

CapabilityManifestRequest

FieldTypeJSON TagDescription
BinaryVersionstring"binary_version"Agent version, non-empty after trimming
BinaryChecksumstring"binary_checksum"Running binary's SHA-256: 32 bytes, standard-padded base64
SSHHostKeyFingerprintstring"ssh_host_key_fingerprint,omitempty"Optional SHA256:<base64> host-key fingerprint
DeclaredHooks[]DeclaredHook"declared_hooks,omitempty"Optional hook declarations, at most 128, unique names
PlexdHooks[]DiscoveredPlexdHook"plexd_hooks,omitempty"Optional PlexdHook resources discovered at boot, at most 128, unique names
BuiltinActions[]ActionInfo"builtin_actions,omitempty"Optional builtin action inventory sorted by name, at most 128 actions of at most 64 parameters

DeclaredHook

FieldTypeJSON TagDescription
Namestring"name"Hook identifier, non-empty
Checksumstring"checksum"Hook payload SHA-256: 32 bytes, standard-padded base64

DiscoveredPlexdHook — one PlexdHook resource as the manifest reports it (the contract's PlexdHook schema).

FieldTypeJSON TagDescription
Namestring"name"Resource name, unique within its namespace
ImageDigeststring"image_digest"Hook image digest, canonical sha256:<64 lowercase hex>
Parametersmap[string]string"parameters,omitempty"The spec's name/value list folded into a map
TimeoutSecondsint64"timeout_seconds,omitempty"Execution timeout; never set, because the CRD has no timeout field
Sandboxbool"sandbox,omitempty"true unless the hook is privileged

The handler decodes this body with DisallowUnknownFields, so the manifest carries these fields and nothing else. Three consequences are worth stating outright:

  • The digest is base64, never hex. Both checksum fields are declared format: byte, so a hex digest decodes to 48 bytes and is refused with binary_checksum_invalid. integrity.WireChecksum converts the hex form the integrity package works in into the wire form.
  • One refused entry costs the whole manifest. An inventory entry that breaks an invariant is answered 422, and the binary version and checksum are lost with it. plexd therefore sends only entries it knows are valid: at most 128 per inventory, at most 64 parameters per action, unique names, and a canonical image digest on every PlexdHook.
  • Empty inventories are omitted. Both inventory fields and ActionInfo.Parameters are omitempty, so an empty list leaves its key off instead of sending null where the contract expects an array.

BinaryInfo — the node API's local view of the running binary. The control plane receives the same two values as the manifest's flat fields.

FieldTypeJSON TagDescription
Versionstring"version"Binary version
Checksumstring"checksum"Binary checksum

ActionInfo

FieldTypeJSON TagDescription
Namestring"name"Action name
Descriptionstring"description"Action description
Parameters[]ActionParam"parameters"Action parameters

ActionParam

FieldTypeJSON TagDescription
Namestring"name"Parameter name
Typestring"type"Parameter type
Requiredbool"required"Whether required
Defaultstring"default,omitempty"Default value for optional parameters
Descriptionstring"description"Parameter description

HookInfo

FieldTypeJSON TagDescription
Namestring"name"Hook name
Descriptionstring"description"Hook description
Sourcestring"source"Hook source path
Checksumstring"checksum"Source checksum
Parameters[]ActionParam"parameters"Hook parameters
Timeoutstring"timeout"Execution timeout
Sandboxstring"sandbox"Sandbox type

NAT Endpoint ​

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

EndpointRequest

FieldTypeJSON TagDescription
Endpointstring"endpoint"Discovered public endpoint (ip:port)
NATTypestring"nat_type"Wire NAT type (full_cone, restricted, port_restricted, symmetric, unknown)
PortMappingstring"port_mapping,omitempty"Whether the NAT kept the STUN probe's source port: preserved or translated; omitted when empty
ReportedAttime.Time"reported_at"RFC 3339 UTC, stamped fresh per attempt

EndpointResponse

FieldTypeJSON TagDescription
AcceptedAttime.Time"accepted_at"Server receive time
StaleAftertime.Time"stale_after"Deadline after which the endpoint is stale; drives the re-report cadence

The response does not carry peer endpoints. Each peer's direct endpoint rides the state pull as SnapshotPeer.Endpoint (see State). A peer_endpoint_changed SSE event only triggers a reconcile, and plexd never decodes its payload.

Endpoint Candidates ​

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

The node's local interface addresses, each joined with its WireGuard listen port. Each report replaces the previous set. Peers receive the set as SnapshotPeer.EndpointCandidates (see State). See Control Plane API Endpoints for the field rules and error taxonomy.

EndpointCandidatesRequest

FieldTypeJSON TagDescription
Candidates[]string"candidates"0 to MaxEndpointCandidates (8) distinct host:port strings, an IPv6 host in brackets and without a zone; [] when empty, never null. An empty array withdraws every earlier candidate
ReportedAttime.Time"reported_at"RFC 3339 UTC; the server refuses a value more than 60s from its own time with 400 endpoint_clock_skew

ReportEndpointCandidates sends a nil Candidates as an empty array, on a copy, so the caller's request is left as passed: the control plane answers null with 400 malformed_endpoint_candidates. More than MaxEndpointCandidates entries return an error matching ErrEndpointCandidatesTooMany without sending a request.

EndpointCandidatesResponse (200 OK)

FieldTypeJSON TagDescription
AcceptedAttime.Time"accepted_at"Server receive time
StaleAftertime.Time"stale_after"Deadline after which the control plane drops the set; drives the next report

Constants

ConstantValueDescription
MaxEndpointCandidates8Most candidates one report may carry

Handshakes ​

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

The per-peer WireGuard handshake report the control plane derives mesh edge health from. The handler decodes with unknown fields refused, and every 400 refuses the whole report. See Control Plane API Endpoints for the wire examples and error taxonomy.

HandshakeReportRequest

FieldTypeJSON TagDescription
ObservedAttime.Time"observed_at"Node wall clock when the report was assembled; the server refuses a skew above 60s
Peers[]HandshakeReportPeer"peers"0 to MaxHandshakeReportPeers (4096) entries; [] when empty, never null

ReportHandshakes replaces a nil Peers with an empty slice before it marshals the body, because the control plane answers null with 400 malformed_handshake_report. More than MaxHandshakeReportPeers entries return an error matching ErrHandshakeReportTooMany without sending a request.

HandshakeReportPeer

FieldTypeJSON TagDescription
PeerNodeIDstring"peer_node_id"Peer node ID, a non-zero UUID; unique within the report
LastHandshakeAgeSecondsint32"last_handshake_age_seconds"Seconds since the latest completed handshake, 0 to 2147483647
Endpointstring"endpoint"Endpoint WireGuard uses for the peer, host:port with an IPv6 host in brackets; a non-zero port, a specified address and no zone

HandshakeReportResponse (200 OK)

FieldTypeJSON TagDescription
AcceptedAttime.Time"accepted_at"Server receive time every age was anchored on
Recordedint"recorded"Entries stored as the latest observation of their edge
Ignoredint"ignored"Entries naming the node itself, an unknown node, a node of another Domain, or a deregistered peer

Constants

ConstantValueDescription
MaxHandshakeReportPeers4096Most entries one report may carry (the contract's maxItems)

Key Rotation ​

POST /v1/keys/rotate ​

The server identifies the rotating node from its NSK bearer credential, so the request carries no node id. The response is a receipt, not a peer list. The propagated peer changes arrive via the next state pull. With edge PSKs on, plexd fetches every edge PSK again on that pull from GET /v1/nodes/{node_id}/peers/{peer_node_id}/psk (see Edge PSK).

KeyRotateRequest

FieldTypeJSON TagDescription
NewPublicKeystring"new_public_key"New Curve25519 public key (44-char standard base64)

KeyRotateResponse

FieldTypeJSON TagDescription
RotationIDstring"rotation_id"Identifier for this rotation
KIDstring"kid"Key identifier for the rotated material
WrapKeyVersionint"wrap_key_version"Wrap-key version (monotonic, >= 0)

Sessions ​

The sessions block of the state snapshot and the activity records the node posts back to POST /v1/nodes/{node_id}/sessions/{session_id}.

sessions block of GET /v1/nodes/{node_id}/state ​

NodeStateSession

One live mediated-access session. The block is desired state, not a queue: it is always present on the wire ([] when empty, never null). A response that violates that contract by omitting or nulling the key decodes to a nilSessions pointer, which the tunnel dispatcher leaves alone rather than reading as an empty block — emptiness tears every live session down, so the two cases must stay distinguishable. An entry appears when the control plane issues the session and disappears on revocation or hard expiry — that disappearance is the teardown signal, so there is no revocation callback to answer and no terminal status to report. A revocation the control plane announces in a verified session_revoked event is normally closed by that event first (see Event Types); the disappearance stays the teardown for every other revocation. A live entry is redelivered on every pull, so a consumer must tolerate re-observing the same SessionID.

FieldTypeJSON TagDescription
SessionIDstring"session_id"Session identifier
JTIstring"jti"Equals the session id; carried opaquely and never evaluated
Kindstring"kind"One of the SessionKind* values; selects the member of Target
TargetSessionTarget"target"Exactly one member set, matching Kind
ExpiresAttime.Time"expires_at"Absolute UTC deadline, not a relative timeout
IdleTimeoutSecondsint"idle_timeout_seconds,omitempty"Idle window in seconds; 0 or absent means no idle window

SessionTarget

FieldTypeJSON TagDescription
SSH*SessionTargetSSH"ssh,omitempty"Target of an ssh session
K8s*SessionTargetK8s"k8s,omitempty"Target of a k8s session
TCP*SessionTargetTCP"tcp,omitempty"Target of a tcp session

SessionTargetSSH

FieldTypeJSON TagDescription
Userstring"user"Local account the session logs in as
AllowedCommands[]string"allowed_commands,omitempty"Closed set of command lines the session may run

SessionTargetK8s

FieldTypeJSON TagDescription
Userstring"user"Impersonated Kubernetes user
ImpersonateGroups[]string"impersonate_groups,omitempty"Groups impersonated alongside the user

SessionTargetTCP

FieldTypeJSON TagDescription
Hoststring"host"Host the node forwards the session's connections to
Portint"port"Port on that host

SessionKind constants

ConstantValueDescription
SessionKindSSHsshMediates an SSH session; Target.SSH is set
SessionKindK8sk8sMediates a Kubernetes API session; Target.K8s is set
SessionKindTCPtcpMediates a plain TCP forward; Target.TCP is set

This agent provisions tcp entries, and ssh entries on Linux and macOS; k8s entries, and ssh entries on Windows, are decoded and settled as unsupported. See Secure Access Tunneling for how plexd consumes the block.

POST /v1/nodes/{node_id}/sessions/{session_id} ​

A one-of session activity record: exactly one of ssh, k8s, or tcp is set, selecting the session kind. plexd emits tcp rows for tcp sessions and ssh rows for ssh sessions; the k8s variant is carried by the type and accepted by the server but not emitted. Success is 204 No Content.

SessionActivityRequest

FieldTypeJSON TagDescription
SSH*SSHActivity"ssh,omitempty"SSH session lifecycle or command row
K8s*K8sActivity"k8s,omitempty"Per-request Kubernetes API row
TCP*TCPActivity"tcp,omitempty"TCP session lifecycle row

SSHActivity

A row takes one of two shapes. A command row carries command, optionally with exit_code, started_at and completed_at. A lifecycle row carries phase plus listener_endpoint on session_started or terminated_by on session_ended, and no command fields. plexd never posts an empty command: the server refuses a row that mixes the shapes.

FieldTypeJSON TagDescription
Commandstring"command,omitempty"Command line of an exec (1 to 1024 bytes); command rows only
ExitCode*int"exit_code,omitempty"Command exit code, on the row posted after the command exited
StartedAt*time.Time"started_at,omitempty"RFC 3339 start time (UTC)
CompletedAt*time.Time"completed_at,omitempty"RFC 3339 completion time (UTC)
Phasestring"phase,omitempty"One of the SSHPhase* values; lifecycle rows only
ListenerEndpointstring"listener_endpoint,omitempty"Address the ssh listener bound; session_started rows only
TerminatedBystring"terminated_by,omitempty"One of the TerminatedBy* values; session_ended rows only

SSHPhase constants

ConstantValueDescription
SSHPhaseSessionStartedsession_startedThe listener of an SSH session is up
SSHPhaseSessionEndedsession_endedClose of an SSH session

K8sActivity

FieldTypeJSON TagDescription
Verbstring"verb"API verb
ResourceKindstring"resource_kind,omitempty"Target resource kind
Namespacestring"namespace,omitempty"Target namespace
Namestring"name,omitempty"Target object name
StatusCodeint"status_code,omitempty"Response status code
DurationMSint64"duration_ms,omitempty"Request duration in ms

TCPActivity

FieldTypeJSON TagDescription
Phasestring"phase"One of the TCPPhase* values
TargetHoststring"target_host,omitempty"Forward target host
TargetPortint"target_port,omitempty"Forward target port
ListenerEndpointstring"listener_endpoint,omitempty"Address the node's listener bound; set on session_started rows only
BytesIn*int64"bytes_in,omitempty"Operator→target bytes (explicit 0 on session_ended)
BytesOut*int64"bytes_out,omitempty"Target→operator bytes (explicit 0 on session_ended)
TerminatedBystring"terminated_by,omitempty"One of the TerminatedBy* values

TCPPhase constants

ConstantValueDescription
TCPPhaseSessionStartedsession_startedOpening of a TCP session
TCPPhaseSessionEndedsession_endedClose of a TCP session

TerminatedBy constants

Shared by the session_ended rows of both kinds.

ConstantValueDescription
TerminatedByTTLExpiredttl_expiredSession reached its time-to-live
TerminatedByIdleTimeoutidle_timeoutIdle window elapsed with no byte flow
TerminatedByPlexdCloseplexd_closeplexd closed the session locally
TerminatedByOperatorRevokeoperator_revokeOperator's access was revoked. Reported only for a close caused by a verified session_revoked event; a drained entry without the event is reported as plexd_close, because plexd cannot tell a revocation from a block the control plane failed to serve

Integrity ​

POST /v1/nodes/{node_id}/integrity-violations ​

A batch of the tamper-evidence divergences the agent detected on the node: a mismatched binary checksum, a tampered hook checksum, or a rotated SSH host key. The endpoint takes a batch even for a single finding, so a lone violation travels as a one-entry array rather than as a bare object. Success is 200 OK with a receipt, not 202: the violation rows and the integrity_alert outbox event are committed before the response is written.

The handler decodes with DisallowUnknownFields and caps the body at 32 KiB, so an unknown or renamed field refuses the whole request.

IntegrityViolationsRequest

FieldTypeJSON TagDescription
Violations[]IntegrityViolationReport"violations"1 to MaxIntegrityViolationsPerBatch (128) entries

An empty array is refused with 400 integrity_violations_empty and an oversized one with integrity_violations_too_many. ReportIntegrityViolations checks both bounds locally and returns ErrIntegrityViolationsEmpty or ErrIntegrityViolationsTooMany without sending, since a refused request would take every violation in it down.

IntegrityViolationReport

FieldTypeJSON TagDescription
KindIntegrityViolationKind"kind"Artifact class the divergence applies to
DetectedByIntegrityDetector"detected_by"On-node detector that surfaced it
ArtifactIDstring"artifact_id"Affected artifact; non-empty after trimming, at most 4096 bytes
ObservedChecksumstring"observed_checksum,omitempty"Computed SHA-256, 32 raw bytes in standard-padded base64
ExpectedChecksumstring"expected_checksum,omitempty"Baseline digest, same encoding
ObservedFingerprintstring"observed_fingerprint,omitempty"Observed host-key fingerprint, SHA256:<base64>
ExpectedFingerprintstring"expected_fingerprint,omitempty"Baseline fingerprint, same form

Kind decides which digest pair is legal: a checksum kind carries the checksums and no fingerprint, the host-key kind carries the fingerprints and no checksum. Crossing them is refused with 400 integrity_violation_kind_mismatch, which is why the four digest fields are omitempty — an unset one must be absent from the JSON rather than present and empty.

The checksum fields are declared format: byte, so the hex form the integrity package works in decodes to 48 bytes and is refused with integrity_violation_checksum_invalid. integrity.WireChecksum converts hex into the wire form.

IntegrityViolationKind constants

ConstantValueDescription
IntegrityKindBinaryChecksumbinary_checksumThe agent's own binary
IntegrityKindHookChecksumhook_checksumA hook script
IntegrityKindSSHHostKeyssh_host_keyThe mesh SSH host key

IntegrityDetector constants

ConstantValueDescription
IntegrityDetectorStartupScanstartup_scanThe agent's sweep of the artifacts it holds baselines for. The enum has no value for a periodic re-scan, so the interval-driven sweeps report this one too
IntegrityDetectorInotifyinotifyThe hooks-directory watcher reacting to a filesystem event
IntegrityDetectorPreDispatchpre_dispatchThe check that runs immediately before a hook executes

A value outside either set is refused with 400 integrity_violation_kind_invalid or integrity_violation_detected_by_invalid.

IntegrityViolationsResponse

FieldTypeJSON TagDescription
AcceptedAttime.Time"accepted_at"Server-side commit timestamp
ViolationCountint"violation_count"Rows persisted; matches the input length

SSE Events ​

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

Returns text/event-stream with signed event envelopes.

Envelope

FieldTypeJSON TagDescription
IDstring"id"Unique event identifier (required)
Typestring"type"Event type discriminator (required)
Scopestring"scope"Scope the event applies to
KeyIDstring"key_id"Key id that selects the verifying signing key
IssuedAttime.Time"issued_at"Event timestamp
Payloadjson.RawMessage"payload"Event-specific JSON payload
Signaturestring"signature"Base64 Ed25519 signature over the SHA-512 digest of the canonical form

ParseEnvelope requires id and type. See Event Verification for the canonical form and signature rules.

Event Types ​

The event set is organized in two tiers. Contract types are emitted today; documented-coming types are named for the platform's 14-type taxonomy. Reconcile-driving payloads are opaque — the state pull is authoritative. session_revoked is the exception: its payload names the session to close before the reconcile.

ConstantValueTier
EventNodeStateUpdatednode_state_updatedcontract
EventPolicyUpdatedpolicy_updatedcontract
EventBridgeConfigUpdatedbridge_config_updatedcontract
EventActionRequestaction_requestcontract
EventSessionSetupsession_setupcontract
EventPeerEndpointChangedpeer_endpoint_changedcontract
EventNodeSecretsUpdatednode_secrets_updatedcontract
EventSessionRevokedsession_revokedcontract
EventPeerRegisteredpeer_registereddocumented-coming
EventPeerPSKAssignedpeer_psk_assigneddocumented-coming
EventPeerDeregisteredpeer_deregistereddocumented-coming
EventPeerKeyRotatedpeer_key_rotateddocumented-coming
EventRotateKeysrotate_keysdocumented-coming
EventSigningKeyRotatedsigning_key_rotateddocumented-coming

action_request is a contract type with an opaque payload: action dispatches are delivered in the executions block of the state pull, so the event only triggers a reconcile and the resulting pull carries the dispatch. session_setup is the same shape for mediated access — the session is delivered in the sessions block of that pull.

session_revoked is a contract type whose payload plexd decodes, as a SessionRevocation naming the session the control plane revoked. tunnel.Dispatcher.SessionRevokedHandler closes that session when it is live, reports the terminated_by its revoke_reason maps to (see RevokeReason constants under Supporting types), and only then triggers a reconcile, so the pull cannot drain the session first. A payload that does not decode, or whose session_id is empty or differs from its jti, closes nothing: the handler triggers the reconcile and returns the error. A session leaving the sessions block stays the teardown for a revocation whose event never arrives.

node_secrets_updated is a contract type whose payload carries the node's full secret inventory. plexd treats the payload as opaque and only triggers a reconcile, because the secrets block of the state pull is authoritative. The pull is the only writer of the node API's secret index, so an event that arrives late can never roll the index back.

peer_endpoint_changed is a contract type the control plane emits now, and plexd treats it as a reconcile trigger. Its payload is never decoded: each peer's direct endpoint rides the peers block of the state pull as SnapshotPeer.Endpoint, and the event only brings the next pull forward. peer_psk_assigned is a reconcile trigger too, and it never forces a fetch on its own: plexd fetches an edge PSK from GET /v1/nodes/{node_id}/peers/{peer_node_id}/psk (see Edge PSK) when a peer first appears, when its public key changes and after this node's own key rotation, and a fetch still pending retries on the pull the event brings forward.