Skip to content

Control Plane Client

The internal/api package provides the Go client for communicating with the Plexsphere control plane. It handles HTTPS request/response calls, SSE event streaming, automatic reconnection with exponential backoff, and event dispatching.

Config

Config holds connection parameters passed to the client constructor. No file I/O occurs in this package — config loading is the caller's responsibility.

FieldTypeDefaultDescription
BaseURLstringControl plane API base URL (required)
TLSInsecureSkipVerifyboolfalseDisable TLS certificate verification
ConnectTimeouttime.Duration10sTCP connection timeout
RequestTimeouttime.Duration30sFull HTTP request/response timeout
SSEIdleTimeouttime.Duration90sMax idle time before SSE reconnect (now honored)
SSEReprobeIntervaltime.Duration10mHow often pull-only delivery re-probes a descoped SSE endpoint
go
cfg := api.Config{
    BaseURL:               "https://api.plexsphere.com",
    TLSInsecureSkipVerify: false,
}
cfg.ApplyDefaults() // sets zero-valued timeouts to defaults
if err := cfg.Validate(); err != nil {
    log.Fatal(err)
}

ControlPlane

ControlPlane is the core HTTP client. It manages authentication, JSON serialization, request/response compression, and error mapping.

Constructor

go
func NewControlPlane(cfg Config, version string, logger *slog.Logger) (*ControlPlane, error)
  • Applies config defaults and validates
  • Configures TLS, connect timeout, request timeout
  • Sets User-Agent: plexd/{version} on all requests
  • Gzip-compresses request bodies larger than 1 KiB on the three observability ingest operations only — they are the ones whose contract accepts Content-Encoding: gzip. Every other handler decodes the body as it arrives, so a compressed one is read as JSON and refused with 400, naming the gzip magic byte as invalid character '\x1f'.
  • Transparently decompresses gzip responses

Authentication

go
bearer, err := identity.BearerToken()
// ...
client.SetAuthToken(bearer)

Thread-safe via sync.RWMutex. The token is injected as Authorization: Bearer {token} on every request. After registration the registrar arms the client with the NSK bearer envelope (nsk_<env>_<base64url(node_id || nsk)>, see the registration reference) — never the raw NSK, which the control plane refuses with 401.

The registrar is the sole owner of this credential: it arms the client on both of its paths (resumed identity and fresh registration), and no other production code calls SetAuthToken. One client is shared by every post-registration consumer — heartbeat, SSE, reconciler, key rotator, peer exchange, actions, node API, and the platform reporters — so a later assignment does not degrade one caller, it mutes all of them at once. Callers that need a credential re-armed re-register; they do not set the token themselves.

API Methods

All methods accept a context.Context for cancellation and return typed responses.

MethodHTTPPathRequest TypeResponse Type
RegisterPOST/v1/registerRegisterRequest*RegisterResponse
HeartbeatPOST/v1/nodes/{node_id}/heartbeatHeartbeatRequest*HeartbeatResponse
FetchStateGET/v1/nodes/{node_id}/state*NodeStateSnapshot
ConnectSSEGET/v1/nodes/{node_id}/events*http.Response
RotateKeysPOST/v1/keys/rotateKeyRotateRequest*KeyRotateResponse
UpdateCapabilitiesPUT/v1/nodes/{node_id}/capabilitiesCapabilityManifestRequest
ReportEndpointPUT/v1/nodes/{node_id}/endpointEndpointRequest*EndpointResponse
FetchSecretGET/v1/nodes/{node_id}/secrets/{name} (optional ?version=N)*SecretEnvelope
PutStateReportPUT/v1/nodes/{node_id}/state/reports/{key}NodeStateReportRequest*NodeStateReportResponse
DeleteStateReportDELETE/v1/nodes/{node_id}/state/reports/{key}— (204 No Content)
ExecutionCallbackPOST/v1/nodes/{node_id}/executions/{execution_id}ExecutionCallbackRequest*ExecutionCallbackResponse
UploadExecutionOutputPUTpresigned URL (no bearer token)[]byte
ReportMetricsPOST/v1/nodes/{node_id}/metrics[]MetricSample (JSON array)*IngestReceipt
ReportLogsPOST/v1/nodes/{node_id}/logs[]LogLine (NDJSON)*IngestReceipt
ReportAuditPOST/v1/nodes/{node_id}/audit[]AuditEvent (NDJSON)*IngestReceipt
ReportSessionActivityPOST/v1/nodes/{node_id}/sessions/{session_id}SessionActivityRequest— (204 No Content)
ReportIntegrityViolationsPOST/v1/nodes/{node_id}/integrity-violationsIntegrityViolationsRequest

Generic Helpers

go
func (c *ControlPlane) PostJSON(ctx context.Context, path string, body any, result any) error
func (c *ControlPlane) GetJSON(ctx context.Context, path string, result any) error

Error Types

HTTP errors are mapped to structured *APIError values supporting errors.Is and errors.As.

SentinelStatusDescription
ErrBadRequest400Invalid request
ErrUnauthorized401Authentication failure
ErrForbidden403Access denied (permanent)
ErrNotFound404Resource not found (permanent)
ErrConflict409Conflict
ErrPayloadTooLarge413Request body too large
ErrRateLimit429Rate limited (has RetryAfter)
ErrServer5xxServer error (matches any 5xx)
go
resp, err := client.FetchState(ctx, nodeID)
if errors.Is(err, api.ErrUnauthorized) {
    // re-authenticate
} else if errors.Is(err, api.ErrRateLimit) {
    var apiErr *api.APIError
    errors.As(err, &apiErr)
    time.Sleep(apiErr.RetryAfter)
}

IsSessionRevokedOrExpired(err error) bool classifies the answers to ReportSessionActivity. It extracts the *APIError with errors.As, so a wrapped error is classified too, and reports true for a 409 carrying session_already_revoked or session_expired. Everything else is false, including a 400 malformed_session_activity, which is a verdict on the row rather than on the session; a 501 access_session_not_provisioned, which faults the callback endpoint rather than the session, and that endpoint may be provisioned mid-session; and a 404 session_not_found, which is not a terminal state but the sessions block and the session store disagreeing — two separate reads, so a lagging replica or a record written after the block was rendered produces it, and the entry standing in the block is the control plane still asking for the session. All three stay retryable. A true answer is the control plane's verdict that the session has reached a terminal state, and the node reads it as the drain signal: the entry leaves the sessions block on a following pull and the block drain performs the teardown. The answer itself never closes a session.

APIError.CorrelationID carries the correlation_id member of the control plane's problem document; when the document has no id, the client falls back to the X-Correlation-Id header of that same response. Both sources are read only from an application/problem+json response, so an id minted by a proxy or gateway that answered in place of the control plane is not presented as a control-plane id. The header itself is not authenticated, so that gate is the whole guarantee: a tracing proxy that passes the control plane's problem document through untouched and stamps its own X-Correlation-Id on it is indistinguishable, and a fallback id may be that proxy's request id rather than one the control-plane log keys. The field also stays empty for an id longer than 256 bytes, which is dropped rather than truncated because a truncated id keys nothing, and any unprintable rune is stripped — from the message the error string renders alongside the id as well — so a hostile intermediary cannot forge a log line or emit a terminal escape through the error string. APIError.Code, the third response-derived field the error string renders, is guarded by rejection rather than stripping: a code longer than 128 bytes or carrying any unprintable rune is dropped whole, because stripping a rune out of it could collapse a forged code onto a live one the classifiers branch on. A set id is appended to the error string as a trailing (correlation_id=...) segment, so every logged failure line carries it. The id keys the control-plane log, so an operator can quote it there.

SSEManager

SSEManager is the top-level orchestrator that wires together SSE streaming, reconnection, verification, and event dispatching.

Lifecycle

go
logger := slog.Default()
mgr := api.NewSSEManager(client, nil, logger) // nil verifier = NoOpVerifier

// Register handlers before Start
mgr.RegisterHandler("node_state_updated", func(ctx context.Context, env api.Envelope) error {
    // request a reconcile — the state pull is authoritative
    return nil
})
mgr.RegisterHandler("policy_updated", func(ctx context.Context, env api.Envelope) error {
    // handle policy change
    return nil
})

// Start blocks until context cancelled, Shutdown called, or permanent error
ctx, cancel := context.WithCancel(context.Background())
go func() {
    if err := mgr.Start(ctx, nodeID); err != nil {
        log.Printf("SSE manager stopped: %v", err)
    }
}()

// Later: graceful shutdown
mgr.Shutdown()

Methods

MethodDescription
NewSSEManagerCreates manager with client, optional verifier, logger
RegisterHandlerRegisters an event handler by type (call before Start)
Start(ctx, nodeID)Blocking SSE loop with automatic reconnection
Shutdown()Cancels internal context, causes Start to return
SetPollFunc(fn)Overrides the default polling function (FetchState)
SetReconnectIntervalsConfigures backoff base and max intervals
SetPollingFallbackConfigures polling fallback threshold and interval
SetIdleTimeout(d)Sets the SSE idle timeout used for connections opened by Start
SetReprobeInterval(d)Sets how often pull-only mode re-probes the SSE endpoint (non-positive ignored)
SetReconcileTrigger(t)Sets the trigger fired once after every successful SSE connect to cover replay gaps
Mode()Returns the current DeliveryMode (streaming, pull_only, degraded_polling)
SetOnModeChange(fn)Registers a callback invoked on every delivery-mode transition

EventVerifier

Pluggable interface for verifying signed event envelopes. The default NoOpVerifier accepts all events; the production Ed25519Verifier checks the signature.

go
type EventVerifier interface {
    Verify(ctx context.Context, envelope Envelope) error
}

Ed25519Verifier is keyed by signing key id: it is built from the registration-persisted signing_key_id/signing_public_key and selects the verifying key by the envelope's key_id. The current key id is always accepted; a previous key id is accepted only during the rotation grace window (until transition_expires). The signing_key_rotated event installs a new current key via Rotate. See Event Verification for the full envelope shape, canonical form, staleness window, and rotation rules.

EventDispatcher

Routes verified events to registered handlers by the envelope's type.

  • Multiple handlers per event type (invoked sequentially in registration order)
  • Handler errors are logged but do not block subsequent handlers
  • Unhandled event types are logged at debug level and discarded
  • Thread-safe handler registration via sync.RWMutex

Event Type Constants

The SSE event set is organized in two tiers. Payloads of the reconcile-driving types are opaque: the reconciler's state pull is authoritative, so those events only request a reconcile.

Contract — currently emitted by the control plane per the OpenAPI events document:

ConstantValueDispatch target
EventNodeStateUpdatednode_state_updatedTriggerReconcile()
EventPolicyUpdatedpolicy_updatedpolicy.HandlePolicyUpdated → reconcile
EventBridgeConfigUpdatedbridge_config_updatedbridge.HandleBridgeConfigUpdated → reconcile
EventActionRequestaction_requestTriggerReconcile()
EventSessionSetupsession_setupTriggerReconcile()

action_request carries no dispatch of its own: action executions are delivered in the executions block of the state pull, so the event only pulls the next reconcile forward and the resulting pull carries the dispatch. session_setup is the same optimisation for mediated access: the session is delivered in the sessions block of that pull, and the event only pulls it forward.

Documented-coming — named now so the agent can subscribe once the platform's 14-type taxonomy starts emitting them:

ConstantValueDispatch target
EventPeerRegisteredpeer_registeredTriggerReconcile()
EventPeerPSKAssignedpeer_psk_assignedTriggerReconcile()
EventPeerDeregisteredpeer_deregisteredTriggerReconcile()
EventPeerEndpointChangedpeer_endpoint_changedTriggerReconcile()
EventPeerKeyRotatedpeer_key_rotatedTriggerReconcile()
EventRotateKeysrotate_keyskey rotator's RotateNow
EventSigningKeyRotatedsigning_key_rotatedEd25519Verifier.Rotate
EventSessionRevokedsession_revokedTriggerReconcile()

session_revoked carries no teardown of its own: a session leaving the sessions block of the state pull is what closes it, so the event only pulls the observing reconcile forward.

ReconnectEngine

Manages SSE reconnection with exponential backoff and polling fallback.

Backoff Parameters

ParameterDefaultDescription
Base interval1sInitial backoff delay
Multiplier2xExponential growth factor
Max interval60sBackoff cap
Jitter±25%Random variation on each delay
Polling fallback5 minTime before switching to polling
Poll interval60sHow often to poll during fallback

Failure Classification

Error TypeAction
Network / 5xxRetryTransient — exponential backoff
401 UnauthorizedRetryAuth — invoke callback, stop
429 Rate LimitedRespectServer — use Retry-After header
403 / 404PermanentFailure — stop reconnection
501 signed_event_bus_not_provisionedRetryDescoped — switch to pull-only delivery

A 501 carrying signed_event_bus_not_provisioned is classified ahead of the generic 5xx match: it is a durable descope of the event stream, not a transient error, so it is answered by pull-only delivery rather than backoff. A 501 with any other code, a 503 event_stream_unavailable, and network errors stay on the transient path.

Delivery Modes

SSEManager.Mode() reports which channel currently delivers control-plane state, and SetOnModeChange fires on every transition:

ModeMeaning
streamingThe SSE event stream is attached and live.
pull_onlyThe event stream is descoped (RetryDescoped). The engine stops polling entirely; the reconciler's own loop is the delivery channel, and SSE is re-probed once per SSEReprobeInterval (default 10m).
degraded_pollingThe legacy transient fallback: after 5 minutes of failing SSE, the engine polls state every 60s while re-probing SSE.

On a descope the engine enters pull_only immediately, with no backoff and no 5-minute polling-fallback window. A successful re-probe returns to streaming and resets backoff. During pull-only re-probes, auth failures and 403/404 still propagate as permanent; any other error keeps the engine in pull-only at the re-probe cadence.

Reconnect-Triggered Pull and Cursor Reset

The server replays only sequences strictly greater than the client's Last-Event-ID cursor and never backfills an absent cursor. The client covers gaps itself: SetReconcileTrigger fires exactly one TriggerReconcile() after every successful SSE connect (HTTP 200). On a 400 while a cursor was set, the stream clears the cursor so the next connect tails from now — the reconcile pull covers the gap. The cursor is in-memory only, so a restart tails from now and the reconciler's first cycle covers the gap.

State Machine

SSE Parser

W3C-compliant text/event-stream line protocol parser.

  • Handles event:, data:, id:, retry: fields
  • Multi-line data: fields concatenated with \n
  • Comment lines (: prefix) ignored (used as keepalives)
  • Tracks Last-Event-ID for reconnection replay
  • retry: field updates reconnection interval via callback

SSE Stream

SSEStream wraps the parser with HTTP connectivity, envelope parsing, verification, and dispatching.

  • Connects via ControlPlane.ConnectSSE with Accept: text/event-stream
  • Sends Last-Event-ID header on reconnection
  • Parses each data: payload as an Envelope
  • Passes envelope through EventVerifier before dispatching
  • Dispatches on the verified envelope's type, not the frame's event: field
  • Malformed events are logged and skipped without disconnecting