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
| Field | Type | JSON Tag | Constraints |
|---|---|---|---|
ProjectID | string | "project_id" | Required. Platform project UUID. |
ResourceHandle | string | "resource_handle" | Required. Platform Resource handle. |
BootstrapToken | string | "bootstrap_token" | Required. Format psb_<env>_<project>_<kind>_<random>, matching ^psb_[a-z]+_[a-z2-7]+_(node|bridge)_[a-z2-7]{20,}$. |
Nonce | string | "nonce" | Required. Fresh UUIDv4 generated per registration attempt (server-side replay protection). |
PublicKey | string | "public_key" | Required. Curve25519 public key as 44-char standard base64, matching ^[A-Za-z0-9+/]{43}=$. |
RequestedResourceID | string | "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)
| Field | Type | JSON Tag | Description |
|---|---|---|---|
NodeID | string | "node_id" | Assigned node identifier (UUID) |
MeshIP | string | "mesh_ip" | Assigned mesh IP address |
SigningPublicKey | string | "signing_public_key" | Control plane signing public key |
SigningKeyID | string | "signing_key_id" | Signing key id for rotation-aware signature verification (e.g. did:web:plexsphere.com#key-2026-04) |
NSK | string | "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 |
DomainMeshCIDR | string | "domain_mesh_cidr" | Domain mesh address range (e.g. 100.64.0.0/10) |
RegisterPeer
| Field | Type | JSON Tag | Description |
|---|---|---|---|
NodeID | string | "node_id" | Peer node ID |
MeshIP | string | "mesh_ip" | Peer mesh IP address |
PublicKey | string | "public_key" | Peer Curve25519 public key |
FallbackEndpoint | string | "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.
| Field | Type | JSON Tag | Description |
|---|---|---|---|
ID | string | "id" | Peer node ID |
PublicKey | string | "public_key" | WireGuard public key |
MeshIP | string | "mesh_ip" | Mesh IP address |
Endpoint | string | "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 |
PSK | string | "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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
ClientNow | time.Time | "client_now" | RFC 3339 UTC send time, stamped fresh per request; the server rejects a skew above 60s |
BinaryChecksum | string | "binary_checksum" | SHA-256 of the running binary (64-char hex or 44-char base64 of 32 bytes) |
BinaryVersion | string | "binary_version" | Build version (dev when unset); never empty |
NATSummary | map[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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
AcceptedAt | time.Time | "accepted_at" | Server receive time (used for skew estimation) |
Reconcile | bool | "reconcile" | Whether to trigger reconciliation |
RotateKeys | bool | "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.
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Peers | []SnapshotPeer | "peers" | Desired peers; [] when empty, node_id ascending, self excluded |
Reachability | json.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.
| Field | Type | JSON Tag | Description |
|---|---|---|---|
NodeID | string | "node_id" | Peer node ID |
MeshIP | string | "mesh_ip" | Peer mesh IP address |
PublicKey | string | "public_key" | Peer WireGuard public key |
Endpoint | string | "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 |
FallbackEndpoint | string | "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.
| Field | Type | JSON Tag | Description |
|---|---|---|---|
State | string | "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 |
ChangedAt | time.Time | "changed_at" | When the verdict last changed; always present |
PolicySnapshot
The single merged policy block.
| Field | Type | JSON Tag | Description |
|---|---|---|---|
RevisionID | string | "revision_id" | Policy revision identifier |
Fingerprint | string | "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.
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Action | string | "action" | allow, deny, or log |
Protocol | string | "protocol" | tcp, udp, icmp, or any |
SourceCIDR | string | "source_cidr" | Source CIDR |
DestinationCIDR | string | "destination_cidr" | Destination CIDR |
Ports | *PortRange | "ports,omitempty" | Inclusive destination port range |
PortRange
A single inclusive destination port range (from <= to).
| Field | Type | JSON Tag | Description |
|---|---|---|---|
From | int | "from" | Range start port |
To | int | "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.
| Field | Type | JSON Tag | Description |
|---|---|---|---|
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.
| Field | Type | JSON Tag | Description |
|---|---|---|---|
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.
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Key | string | "key" | Entry key |
Value | string | "value" | Opaque string value |
WorkloadTag | string | "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.
| Field | Type | JSON Tag | Description |
|---|---|---|---|
KeyID | string | "key_id" | Id the key signs under; plexd rejects an empty one |
PublicKey | string | "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.
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Name | string | "name" | The Secret's name, matching ^[a-z][a-z0-9_-]{0,62}$; becomes SecretRef.Key in the node API's secret index |
Version | int | "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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
KeyID | string | "key_id" | Key id of the new current signing key |
PublicKey | string | "public_key" | Base64 Ed25519 public key of the new current key |
PreviousKeyID | string | "previous_key_id" | Prior key id kept valid during the grace window |
TransitionExpires | time.Time | "transition_expires" | When the previous key id stops being accepted |
SessionRevocation
| Field | Type | JSON Tag | Description |
|---|---|---|---|
SessionID | string | "session_id" | Id of the session the control plane revoked |
JTI | string | "jti" | The revoked session token's jti, equal to session_id; plexd closes nothing when the two differ or session_id is empty |
RevokedAt | time.Time | "revoked_at" | When the control plane revoked the session |
RevokeReason | string | "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.
| Constant | Value | Description |
|---|---|---|
RevokeReasonOperatorRequest | operator_request | Operator-driven; reported as operator_revoke |
RevokeReasonPolicyViolation | policy_violation | Operator-driven; reported as operator_revoke |
RevokeReasonIdentityCompromise | identity_compromise | Operator-driven; reported as operator_revoke |
RevokeReasonResourceDecommissioned | resource_decommissioned | Operator-driven; reported as operator_revoke |
RevokeReasonTTLExpired | ttl_expired | Sweeper, at the session's hard expiry; reported as ttl_expired |
RevokeReasonIdleTimeout | idle_timeout | Sweeper, at the session's idle timeout; reported as idle_timeout |
DataEntry
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Key | string | "key" | Entry key |
ContentType | string | "content_type" | MIME content type |
Payload | json.RawMessage | "payload" | Arbitrary JSON payload |
Version | int | "version" | Entry version |
UpdatedAt | time.Time | "updated_at" | Last update timestamp |
SecretRef
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Key | string | "key" | The Secret's name, from NodeStateSecret.Name |
Version | int | "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.
| Field | Type | Source | Description |
|---|---|---|---|
Data | []byte | Response body (application/octet-stream) | <12-byte nonce> || <ciphertext + 16-byte GCM tag> |
Version | int | X-Plexsphere-Secret-Version header | Version of the returned secret |
KID | string | X-Plexsphere-Secret-KID header | Key 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.
| Field | Type | Source | Description |
|---|---|---|---|
Data | []byte | Response body (application/octet-stream) | <12-byte nonce> || <32-byte PSK + 16-byte GCM tag>, exactly EdgePSKEnvelopeSize bytes |
KID | string | X-Plexsphere-PSK-KID header | Key id of the NSK the envelope was sealed under; required, but plexd holds one NSK and selects nothing by it |
Epoch | string | X-Plexsphere-PSK-Epoch header | PSK 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
| Constant | Value | Description |
|---|---|---|
EdgePSKEnvelopeSize | 60 | Exact 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)
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Value | string | "value" | Opaque report payload (at most 4096 bytes) |
WorkloadTag | string | "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).
| Field | Type | JSON Tag | Description |
|---|---|---|---|
AcceptedAt | time.Time | "accepted_at" | Server receive time |
Key | string | "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.
| Field | Type | JSON Tag | Description |
|---|---|---|---|
ExecutionID | string | "execution_id" | Execution identifier |
Action | string | "action" | Action name (builtin or hook) |
Type | string | "type" | One of the ActionKind* values |
Parameters | map[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 |
Status | string | "status" | pending, ack, or started |
RequestedAt | time.Time | "requested_at" | When the control plane dispatched the execution |
ExpiresAt | time.Time | "expires_at" | Absolute UTC deadline, not a relative timeout |
ActionKind constants
| Constant | Value | Description |
|---|---|---|
ActionKindBuiltin | builtin | Dispatches an action built into plexd |
ActionKindHook | hook | Dispatches a hook registered by the node |
Pull-only execution status
| Constant | Value | Description |
|---|---|---|
ExecutionStatusPending | pending | Dispatched 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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Status | string | "status" | One of the ExecutionStatus* values |
ExitCode | *int | "exit_code,omitempty" | Process exit code on a terminal callback (explicit zero) |
Error | string | "error,omitempty" | Failure reason on a failed terminal |
DeclaredOutputBytes | int64 | "declared_output_bytes,omitempty" | Captured output length; over 16 KiB drives the presign |
Output | *ExecutionOutput | "output,omitempty" | Captured output on a terminal callback |
ExecutionOutput
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Inline | string | "inline,omitempty" | Base64 output body, used only when at most 16 KiB |
ObjectKey | string | "object_key,omitempty" | Object-store key of an uploaded over-ceiling output |
SHA256 | string | "sha256,omitempty" | Lowercase-hex SHA-256 of the uploaded bytes |
ExecutionCallbackResponse
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Status | string | "status" | The new invocation status |
OutputUploadURL | string | "output_upload_url,omitempty" | Presigned PUT URL, set only on the declaring callback |
ExecutionStatus constants
| Constant | Value | Description |
|---|---|---|
ExecutionStatusAck | ack | Acknowledges receipt of the action request |
ExecutionStatusStarted | started | Reports that the action has begun running |
ExecutionStatusSucceeded | succeeded | Terminal callback for a successful run |
ExecutionStatusFailed | failed | Terminal callback for a failed run |
ExecutionStatusCancelled | cancelled | Terminal 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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Group | string | "group" | Wire group: node_resources, tunnel_health, peer_latency, or agent_stats |
Name | string | "name" | Sample name |
Value | float64 | "value" | Sample value |
Labels | map[string]string | "labels,omitempty" | Dimensions (e.g. peer_id); absent when the sample has none |
Timestamp | time.Time | "timestamp" | Sample time |
MetricPoint — internal pipeline and local-endpoint format; MetricBatch is a type alias for []MetricPoint
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Timestamp | time.Time | "timestamp" | Measurement time |
Group | string | "group" | Metric group name |
PeerID | string | "peer_id,omitempty" | Optional peer ID |
Data | json.RawMessage | "data" | Metric data payload |
POST /v1/nodes/{node_id}/logs
Body is NDJSON: one LogLine per line.
LogLine
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Severity | string | "severity" | Syslog severity: emerg, alert, crit, err, warning, notice, info, debug |
Unit | string | "unit,omitempty" | Systemd unit; absent when unknown |
Hostname | string | "hostname,omitempty" | Origin hostname; absent when unknown |
Message | string | "message" | Log message (non-empty) |
Timestamp | time.Time | "timestamp" | Log time |
LogEntry — internal pipeline and local-endpoint format; LogBatch is a type alias for []LogEntry
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Timestamp | time.Time | "timestamp" | Log timestamp |
Source | string | "source" | Log source |
Unit | string | "unit" | Systemd unit |
Message | string | "message" | Log message |
Severity | string | "severity" | Log level |
Hostname | string | "hostname" | Origin hostname |
POST /v1/nodes/{node_id}/audit
Body is NDJSON: one AuditEvent per line.
AuditEvent
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Source | string | "source" | Wire source: auditd, k8s, or plexd |
Action | string | "action" | Action performed (non-empty) |
Outcome | string | "outcome" | Outcome (non-empty) |
Timestamp | time.Time | "timestamp" | Event time |
AuditEntry — internal pipeline and local-endpoint format; AuditBatch is a type alias for []AuditEntry
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Timestamp | time.Time | "timestamp" | Event timestamp |
Source | string | "source" | Audit source |
EventType | string | "event_type" | Audit event type |
Subject | json.RawMessage | "subject" | Who performed it |
Object | json.RawMessage | "object" | What was affected |
Action | string | "action" | Action performed |
Result | string | "result" | Action result |
Hostname | string | "hostname" | Origin hostname |
Raw | string | "raw" | Raw audit record |
Ingest receipt
IngestReceipt (202 Accepted for all three ingest endpoints)
| Field | Type | JSON Tag | Description |
|---|---|---|---|
AcceptedAt | time.Time | "accepted_at" | When the control plane accepted the batch |
Records | int | "records" | Number of records accepted from the batch |
Capabilities
PUT /v1/nodes/{node_id}/capabilities
CapabilityManifestRequest
| Field | Type | JSON Tag | Description |
|---|---|---|---|
BinaryVersion | string | "binary_version" | Agent version, non-empty after trimming |
BinaryChecksum | string | "binary_checksum" | Running binary's SHA-256: 32 bytes, standard-padded base64 |
SSHHostKeyFingerprint | string | "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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Name | string | "name" | Hook identifier, non-empty |
Checksum | string | "checksum" | Hook payload SHA-256: 32 bytes, standard-padded base64 |
DiscoveredPlexdHook — one PlexdHook resource as the manifest reports it (the contract's PlexdHook schema).
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Name | string | "name" | Resource name, unique within its namespace |
ImageDigest | string | "image_digest" | Hook image digest, canonical sha256:<64 lowercase hex> |
Parameters | map[string]string | "parameters,omitempty" | The spec's name/value list folded into a map |
TimeoutSeconds | int64 | "timeout_seconds,omitempty" | Execution timeout; never set, because the CRD has no timeout field |
Sandbox | bool | "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 withbinary_checksum_invalid.integrity.WireChecksumconverts 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.Parametersareomitempty, so an empty list leaves its key off instead of sendingnullwhere 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.
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Version | string | "version" | Binary version |
Checksum | string | "checksum" | Binary checksum |
ActionInfo
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Name | string | "name" | Action name |
Description | string | "description" | Action description |
Parameters | []ActionParam | "parameters" | Action parameters |
ActionParam
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Name | string | "name" | Parameter name |
Type | string | "type" | Parameter type |
Required | bool | "required" | Whether required |
Default | string | "default,omitempty" | Default value for optional parameters |
Description | string | "description" | Parameter description |
HookInfo
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Name | string | "name" | Hook name |
Description | string | "description" | Hook description |
Source | string | "source" | Hook source path |
Checksum | string | "checksum" | Source checksum |
Parameters | []ActionParam | "parameters" | Hook parameters |
Timeout | string | "timeout" | Execution timeout |
Sandbox | string | "sandbox" | Sandbox type |
NAT Endpoint
PUT /v1/nodes/{node_id}/endpoint
EndpointRequest
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Endpoint | string | "endpoint" | Discovered public endpoint (ip:port) |
NATType | string | "nat_type" | Wire NAT type (full_cone, restricted, port_restricted, symmetric, unknown) |
PortMapping | string | "port_mapping,omitempty" | Whether the NAT kept the STUN probe's source port: preserved or translated; omitted when empty |
ReportedAt | time.Time | "reported_at" | RFC 3339 UTC, stamped fresh per attempt |
EndpointResponse
| Field | Type | JSON Tag | Description |
|---|---|---|---|
AcceptedAt | time.Time | "accepted_at" | Server receive time |
StaleAfter | time.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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
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 |
ReportedAt | time.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)
| Field | Type | JSON Tag | Description |
|---|---|---|---|
AcceptedAt | time.Time | "accepted_at" | Server receive time |
StaleAfter | time.Time | "stale_after" | Deadline after which the control plane drops the set; drives the next report |
Constants
| Constant | Value | Description |
|---|---|---|
MaxEndpointCandidates | 8 | Most 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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
ObservedAt | time.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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
PeerNodeID | string | "peer_node_id" | Peer node ID, a non-zero UUID; unique within the report |
LastHandshakeAgeSeconds | int32 | "last_handshake_age_seconds" | Seconds since the latest completed handshake, 0 to 2147483647 |
Endpoint | string | "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)
| Field | Type | JSON Tag | Description |
|---|---|---|---|
AcceptedAt | time.Time | "accepted_at" | Server receive time every age was anchored on |
Recorded | int | "recorded" | Entries stored as the latest observation of their edge |
Ignored | int | "ignored" | Entries naming the node itself, an unknown node, a node of another Domain, or a deregistered peer |
Constants
| Constant | Value | Description |
|---|---|---|
MaxHandshakeReportPeers | 4096 | Most 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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
NewPublicKey | string | "new_public_key" | New Curve25519 public key (44-char standard base64) |
KeyRotateResponse
| Field | Type | JSON Tag | Description |
|---|---|---|---|
RotationID | string | "rotation_id" | Identifier for this rotation |
KID | string | "kid" | Key identifier for the rotated material |
WrapKeyVersion | int | "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.
| Field | Type | JSON Tag | Description |
|---|---|---|---|
SessionID | string | "session_id" | Session identifier |
JTI | string | "jti" | Equals the session id; carried opaquely and never evaluated |
Kind | string | "kind" | One of the SessionKind* values; selects the member of Target |
Target | SessionTarget | "target" | Exactly one member set, matching Kind |
ExpiresAt | time.Time | "expires_at" | Absolute UTC deadline, not a relative timeout |
IdleTimeoutSeconds | int | "idle_timeout_seconds,omitempty" | Idle window in seconds; 0 or absent means no idle window |
SessionTarget
| Field | Type | JSON Tag | Description |
|---|---|---|---|
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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
User | string | "user" | Local account the session logs in as |
AllowedCommands | []string | "allowed_commands,omitempty" | Closed set of command lines the session may run |
SessionTargetK8s
| Field | Type | JSON Tag | Description |
|---|---|---|---|
User | string | "user" | Impersonated Kubernetes user |
ImpersonateGroups | []string | "impersonate_groups,omitempty" | Groups impersonated alongside the user |
SessionTargetTCP
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Host | string | "host" | Host the node forwards the session's connections to |
Port | int | "port" | Port on that host |
SessionKind constants
| Constant | Value | Description |
|---|---|---|
SessionKindSSH | ssh | Mediates an SSH session; Target.SSH is set |
SessionKindK8s | k8s | Mediates a Kubernetes API session; Target.K8s is set |
SessionKindTCP | tcp | Mediates 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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
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.
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Command | string | "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) |
Phase | string | "phase,omitempty" | One of the SSHPhase* values; lifecycle rows only |
ListenerEndpoint | string | "listener_endpoint,omitempty" | Address the ssh listener bound; session_started rows only |
TerminatedBy | string | "terminated_by,omitempty" | One of the TerminatedBy* values; session_ended rows only |
SSHPhase constants
| Constant | Value | Description |
|---|---|---|
SSHPhaseSessionStarted | session_started | The listener of an SSH session is up |
SSHPhaseSessionEnded | session_ended | Close of an SSH session |
K8sActivity
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Verb | string | "verb" | API verb |
ResourceKind | string | "resource_kind,omitempty" | Target resource kind |
Namespace | string | "namespace,omitempty" | Target namespace |
Name | string | "name,omitempty" | Target object name |
StatusCode | int | "status_code,omitempty" | Response status code |
DurationMS | int64 | "duration_ms,omitempty" | Request duration in ms |
TCPActivity
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Phase | string | "phase" | One of the TCPPhase* values |
TargetHost | string | "target_host,omitempty" | Forward target host |
TargetPort | int | "target_port,omitempty" | Forward target port |
ListenerEndpoint | string | "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) |
TerminatedBy | string | "terminated_by,omitempty" | One of the TerminatedBy* values |
TCPPhase constants
| Constant | Value | Description |
|---|---|---|
TCPPhaseSessionStarted | session_started | Opening of a TCP session |
TCPPhaseSessionEnded | session_ended | Close of a TCP session |
TerminatedBy constants
Shared by the session_ended rows of both kinds.
| Constant | Value | Description |
|---|---|---|
TerminatedByTTLExpired | ttl_expired | Session reached its time-to-live |
TerminatedByIdleTimeout | idle_timeout | Idle window elapsed with no byte flow |
TerminatedByPlexdClose | plexd_close | plexd closed the session locally |
TerminatedByOperatorRevoke | operator_revoke | Operator'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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
Kind | IntegrityViolationKind | "kind" | Artifact class the divergence applies to |
DetectedBy | IntegrityDetector | "detected_by" | On-node detector that surfaced it |
ArtifactID | string | "artifact_id" | Affected artifact; non-empty after trimming, at most 4096 bytes |
ObservedChecksum | string | "observed_checksum,omitempty" | Computed SHA-256, 32 raw bytes in standard-padded base64 |
ExpectedChecksum | string | "expected_checksum,omitempty" | Baseline digest, same encoding |
ObservedFingerprint | string | "observed_fingerprint,omitempty" | Observed host-key fingerprint, SHA256:<base64> |
ExpectedFingerprint | string | "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
| Constant | Value | Description |
|---|---|---|
IntegrityKindBinaryChecksum | binary_checksum | The agent's own binary |
IntegrityKindHookChecksum | hook_checksum | A hook script |
IntegrityKindSSHHostKey | ssh_host_key | The mesh SSH host key |
IntegrityDetector constants
| Constant | Value | Description |
|---|---|---|
IntegrityDetectorStartupScan | startup_scan | The 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 |
IntegrityDetectorInotify | inotify | The hooks-directory watcher reacting to a filesystem event |
IntegrityDetectorPreDispatch | pre_dispatch | The 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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
AcceptedAt | time.Time | "accepted_at" | Server-side commit timestamp |
ViolationCount | int | "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
| Field | Type | JSON Tag | Description |
|---|---|---|---|
ID | string | "id" | Unique event identifier (required) |
Type | string | "type" | Event type discriminator (required) |
Scope | string | "scope" | Scope the event applies to |
KeyID | string | "key_id" | Key id that selects the verifying signing key |
IssuedAt | time.Time | "issued_at" | Event timestamp |
Payload | json.RawMessage | "payload" | Event-specific JSON payload |
Signature | string | "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.
| Constant | Value | Tier |
|---|---|---|
EventNodeStateUpdated | node_state_updated | contract |
EventPolicyUpdated | policy_updated | contract |
EventBridgeConfigUpdated | bridge_config_updated | contract |
EventActionRequest | action_request | contract |
EventSessionSetup | session_setup | contract |
EventPeerEndpointChanged | peer_endpoint_changed | contract |
EventNodeSecretsUpdated | node_secrets_updated | contract |
EventSessionRevoked | session_revoked | contract |
EventPeerRegistered | peer_registered | documented-coming |
EventPeerPSKAssigned | peer_psk_assigned | documented-coming |
EventPeerDeregistered | peer_deregistered | documented-coming |
EventPeerKeyRotated | peer_key_rotated | documented-coming |
EventRotateKeys | rotate_keys | documented-coming |
EventSigningKeyRotated | signing_key_rotated | documented-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.