Skip to main content

uptrakit_wire/
messages.rs

1use serde::{Deserialize, Serialize};
2
3use super::capabilities::ErrorPayload;
4use super::payloads::{
5    AccessInvalidatedPayload, ApprovedPayload, BatchUpdateResultPayload,
6    BroadcastAdminEventPayload, CaBundleUpdatedPayload, CertificatePayload, CheckVersionsPayload,
7    DeleteServiceConfigPayload, DisconnectingPayload, DiscoverSoftwarePayload,
8    DiscoveryResultsPayload, EnrollPayload, EnrolledPayload, ExecuteBatchUpdatePayload,
9    ExecuteUpdatePayload, HostConnectivityUpdatedPayload, PingPayload, PongPayload,
10    RegisterPayload, RejectedPayload, ReportHostsPayload, ReportPluginConfigPayload,
11    ReportPluginConfigResponsePayload, RequestCaRotationPayload, RequestCertRenewalPayload,
12    RequestCrlRenewalPayload, ServerRestartingPayload, ServiceConfigAckPayload,
13    ServiceConfigDeliveryPayload, ServiceConfigUpdatedPayload, ServiceCredentialsPayload,
14    ServiceHostBatchUpdateTriggerPayload, ServiceSettingsPayload, ServiceUpdateTriggerPayload,
15    SetUpdateFreezePayload, SoftwareStatesChangedPayload, SoftwareStatesPayload,
16    StdinAttentionPayload, StoreServiceConfigPayload, TestPluginConfigPayload,
17    TestPluginConfigResultPayload, TokenRevokedPayload, UpdateOutputPayload, UpdateResultPayload,
18    UpdateStartedPayload, UpdateStdinDataPayload, VersionCheckResultsPayload,
19    WorkloadClaimAnnouncementPayload, WorkloadClaimPayload, WorkloadClaimResultPayload,
20    WorkloadClaimSyncRequestPayload, WorkloadClaimSyncResponsePayload, WorkloadReleasePayload,
21};
22use super::surfaces;
23
24/// Messages sent from a service (agent or MQTT) to the controller.
25///
26/// ## Forward compatibility
27///
28/// The `Unknown` variant is a catch-all for message types introduced in newer
29/// service builds that an older controller does not yet recognise. When
30/// encountered, the controller logs a warning and continues without closing the
31/// connection, allowing rolling upgrades where services and controllers are not
32/// updated simultaneously.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35#[non_exhaustive]
36#[serde(tag = "type", rename_all = "snake_case")]
37pub enum ServiceMessage {
38    // -- Shared enrollment + lifecycle --
39    Ping(PingPayload),
40    Enroll(EnrollPayload),
41    RequestCertificate(super::payloads::RequestCertificatePayload),
42    RenewCertificate(super::payloads::RenewCertificatePayload),
43    Disconnecting(DisconnectingPayload),
44    // -- Agent-specific --
45    ReportHosts(ReportHostsPayload),
46    VersionCheckResults(VersionCheckResultsPayload),
47    UpdateStarted(UpdateStartedPayload),
48    UpdateOutput(UpdateOutputPayload),
49    UpdateResult(UpdateResultPayload),
50    BatchUpdateResult(BatchUpdateResultPayload),
51    DiscoveryResults(DiscoveryResultsPayload),
52    /// Agent → Controller: the update process appears to be waiting for stdin input.
53    ///
54    /// Sent when the agent detects that the process has produced no output for
55    /// a sustained period while still running (heuristic: ~10 seconds of silence).
56    /// The controller broadcasts this to interactive session subscribers and may
57    /// trigger notifications.
58    StdinAttention(StdinAttentionPayload),
59    ServiceTriggerUpdate(ServiceUpdateTriggerPayload),
60    /// Service → Controller: trigger a batch update of all outdated software items on a host.
61    ///
62    /// Sent when a Home Assistant user presses "Install" on a host update entity.
63    ServiceTriggerHostBatchUpdate(ServiceHostBatchUpdateTriggerPayload),
64    // -- Capability declaration --
65    /// Service declares its capabilities immediately on connect.
66    ///
67    /// Sent from `on_connected` before `ServiceSettings` is processed.
68    /// The controller uses this to establish session-level capability flags
69    /// without relying on DB-stored values (which may be absent on first connect).
70    Register(RegisterPayload),
71    // -- Plugin config reporting --
72    /// Service reports a plugin configuration to the controller.
73    ///
74    /// Sent by agents that detect infrastructure (e.g. PVE nodes) during
75    /// bootstrap. The controller creates or returns an existing plugin config
76    /// matching `(tenant_id, plugin_type, name)` and responds with
77    /// `ReportPluginConfigResponse`.
78    ReportPluginConfig(ReportPluginConfigPayload),
79    // -- Surfaces --
80    /// Service declares its surfaces after connecting.
81    ///
82    /// Sent once after connection setup by services that participate in the
83    /// surface contract.
84    #[cfg_attr(feature = "schema", schemars(with = "serde_json::Value"))]
85    SurfaceRegistration(surfaces::SurfaceRegistration),
86    /// Response to a proxied surface action invocation.
87    ///
88    /// Sent by the service after processing a `SurfaceActionRequest` from the
89    /// controller.
90    #[cfg_attr(feature = "schema", schemars(with = "serde_json::Value"))]
91    SurfaceActionResponse(surfaces::SurfaceActionResponse),
92    /// Service requests a surface action invocation from the controller.
93    ///
94    /// Enables services to call surface actions via the wire protocol and
95    /// receive the correlated `ControllerMessage::SurfaceActionResponse`.
96    #[cfg_attr(feature = "schema", schemars(with = "serde_json::Value"))]
97    SurfaceActionRequest(surfaces::SurfaceActionRequest),
98    // -- Service config store --
99    /// Service → Controller: upsert a config entry in the controller DB.
100    ///
101    /// The controller encrypts sensitive values at rest, ACKs, and broadcasts
102    /// `ServiceConfigUpdated` to all connected instances of the same service app.
103    StoreServiceConfig(StoreServiceConfigPayload),
104    /// Service → Controller: delete a config entry from the controller DB.
105    ///
106    /// The controller deletes, ACKs, and broadcasts `ServiceConfigUpdated`.
107    DeleteServiceConfig(DeleteServiceConfigPayload),
108    // -- Workload claim protocol --
109    /// Service → Controller: request exclusive ownership of config keys.
110    ///
111    /// Sent after `ServiceConfigDelivery` is processed and whenever the
112    /// desired config set changes. Uses full replacement semantics.
113    /// Requires the `WorkloadClaims` capability.
114    WorkloadClaim(WorkloadClaimPayload),
115    /// Service → Controller: voluntarily release config keys.
116    ///
117    /// Sent when a service no longer wants to serve certain configs.
118    /// Requires the `WorkloadClaims` capability.
119    WorkloadRelease(WorkloadReleasePayload),
120    /// Agent -> Controller: result of a plugin configuration test.
121    ///
122    /// Sent after the agent completes a config test request. The controller
123    /// uses `request_id` to correlate with the pending REST API request.
124    TestPluginConfigResult(TestPluginConfigResultPayload),
125    /// Service -> Controller: forwarded semantic audit event.
126    ///
127    /// The controller re-validates the event and silently drops invalid or
128    /// non-forwardable payloads without closing the connection.
129    AuditEvent(super::payloads::AuditEventPayload),
130    /// Unknown message type from a newer service build.
131    ///
132    /// Deserialized when the `type` tag does not match any known variant.
133    /// The payload is discarded. The receiver should log a warning and
134    /// continue processing other messages.
135    #[serde(other)]
136    #[cfg_attr(feature = "schema", schemars(skip))]
137    Unknown,
138}
139
140/// Messages sent from the controller to a service (agent or MQTT).
141///
142/// ## Forward compatibility
143///
144/// The `Unknown` variant is a catch-all for message types introduced in newer
145/// controller builds that an older service does not yet recognise. When
146/// encountered, the service logs a warning and continues without closing the
147/// connection, allowing rolling upgrades where services and controllers are not
148/// updated simultaneously.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
151#[non_exhaustive]
152#[serde(tag = "type", rename_all = "snake_case")]
153pub enum ControllerMessage {
154    // -- Shared --
155    Pong(PongPayload),
156    Enrolled(EnrolledPayload),
157    Approved(ApprovedPayload),
158    Rejected(RejectedPayload),
159    Certificate(CertificatePayload),
160    Error(ErrorPayload),
161    ServiceSettings(ServiceSettingsPayload),
162    CaBundleUpdated(CaBundleUpdatedPayload),
163    RequestCertRenewal(RequestCertRenewalPayload),
164    ServerRestarting(ServerRestartingPayload),
165    // -- Agent-specific --
166    CheckVersions(CheckVersionsPayload),
167    ExecuteUpdate(Box<ExecuteUpdatePayload>),
168    ExecuteBatchUpdate(Box<ExecuteBatchUpdatePayload>),
169    DiscoverSoftware(DiscoverSoftwarePayload),
170    SetUpdateFreeze(SetUpdateFreezePayload),
171    /// Controller → Agent: forward stdin data or a signal to the running update process.
172    ///
173    /// Only sent to agents that advertise the `InteractiveUpdates` capability
174    /// and have an in-flight interactive update matching the `update_history_id`.
175    ///
176    /// **Security**: session-targeted, NEVER published to NATS.
177    UpdateStdinData(UpdateStdinDataPayload),
178    /// Controller → Services: reset all tenant-scoped data.
179    ///
180    /// Broadcast to services with the `ResetData` capability after the
181    /// controller has cleared the database. Services should truncate their
182    /// local data stores (e.g. SSH host list, Proxmox state).
183    ResetData,
184    SoftwareStates(SoftwareStatesPayload),
185    /// Agent connectivity changed for one or more hosts.
186    ///
187    /// Published to NATS with `target_capability = "update_tracking"` by the controller
188    /// that owns the agent WebSocket connection (on connect and disconnect). The MQTT
189    /// service updates its per-tenant connectivity cache and publishes the
190    /// `{prefix}/hosts/{h}/connectivity/state` retained topic.
191    ///
192    /// **Safe to publish via NATS** — contains no credential material.
193    HostConnectivityUpdated(HostConnectivityUpdatedPayload),
194    // -- Surfaces --
195    /// Proxied surface action invocation from the controller to a service.
196    ///
197    /// Sent to services participating in the surface contract. The service
198    /// should process the action and respond with `SurfaceActionResponse`.
199    #[cfg_attr(feature = "schema", schemars(with = "serde_json::Value"))]
200    SurfaceActionRequest(surfaces::SurfaceActionRequest),
201    /// Cancellation of an in-flight proxied surface action request.
202    ///
203    /// Session-targeted and never published to NATS.
204    #[cfg_attr(feature = "schema", schemars(with = "serde_json::Value"))]
205    SurfaceActionCancel(surfaces::SurfaceActionCancel),
206    /// Response to a service-initiated surface action invocation.
207    ///
208    /// Sent by the controller after processing a
209    /// `ServiceMessage::SurfaceActionRequest`.
210    #[cfg_attr(feature = "schema", schemars(with = "serde_json::Value"))]
211    SurfaceActionResponse(surfaces::SurfaceActionResponse),
212    // -- Plugin config reporting --
213    /// Response to a `ReportPluginConfig` request from a service.
214    ///
215    /// Contains the plugin config ID if the operation succeeded, or an error
216    /// message if it failed. Idempotent: returns the existing config ID if a
217    /// matching `(tenant_id, plugin_type, name)` already exists.
218    ReportPluginConfigResponse(ReportPluginConfigResponsePayload),
219    // -- Infrastructure credential delivery --
220    /// Infrastructure credentials for services that advertise credential
221    /// capabilities. Fields are populated based on the service's capability set:
222    ///   - `database_access` → `db_url` is set
223    ///   - `nats_access` → `nats_url` is set (if controller has NATS)
224    ///   - `master_key_access` → `master_key_hex` is set (if encryption enabled)
225    ///
226    /// **Security**: NEVER published to NATS. Delivered locally via WebSocket only,
227    /// following the same pattern as MQTT credential messages.
228    ServiceCredentials(ServiceCredentialsPayload),
229    // -- Service config store --
230    /// Controller → Service: initial delivery of all stored config entries.
231    ///
232    /// Sent once after authentication (after credential delivery if applicable).
233    /// **Security**: contains decrypted sensitive values — NEVER published to NATS.
234    ServiceConfigDelivery(ServiceConfigDeliveryPayload),
235    /// Controller → Service: acknowledgment of a store or delete operation.
236    ///
237    /// **Security**: NEVER published to NATS — session-targeted.
238    ServiceConfigAck(ServiceConfigAckPayload),
239    /// Controller → Service: incremental update pushed to all instances of the
240    /// same `service_app_name` when any instance modifies a config entry.
241    ///
242    /// **Security**: may contain decrypted sensitive values — NEVER published to NATS.
243    ServiceConfigUpdated(ServiceConfigUpdatedPayload),
244    /// Request from an external component (e.g. scheduler) for the controller to
245    /// perform CA certificate rotation. Published via NATS to the controller subject;
246    /// handled by triggering `ca_rotation_trigger.notify_one()`.
247    RequestCaRotation(RequestCaRotationPayload),
248    /// Request all controller instances to rebuild the CRL immediately.
249    ///
250    /// Published via NATS to the controller subject by any controller that
251    /// revokes a certificate or by the `CrlRenewal` scheduled task.
252    /// Receiving controllers fire `revocation_notify.notify_one()` so that
253    /// `CrlManager::run()` rebuilds and hot-reloads the TLS configuration.
254    RequestCrlRenewal(RequestCrlRenewalPayload),
255    /// Signal that software states have changed for a tenant.
256    ///
257    /// Published to the `controller` NATS subject by the external scheduler
258    /// after a version-check run completes. The receiving controller loads
259    /// the states from the database and pushes them to update-tracking services.
260    SoftwareStatesChanged(SoftwareStatesChangedPayload),
261    /// Token revocation event published by the originating controller to the
262    /// "controller" NATS subject so that all other instances update their
263    /// in-memory denylist caches without a per-request DB query.
264    ///
265    /// A message carries either a JTI-level revocation (when `jti` and `exp`
266    /// are set) or a user-level revocation (when `user_id`, `iat_cutoff`, and
267    /// `purge_after` are set). Both kinds may be present in a single message
268    /// (e.g. when revoking a specific token *and* all prior tokens for a user).
269    ///
270    /// **Safe to publish via NATS** — contains no credential material.
271    TokenRevoked(TokenRevokedPayload),
272    /// Cross-controller access-cache invalidation published by the controller
273    /// that mutated access grants or role assignments. Controller→controller
274    /// over NATS only — never sent over the service WebSocket. Receivers
275    /// flush their whole access cache; the ID lists are diagnostic and
276    /// forward-compat only (no granular invalidation promise).
277    ///
278    /// **Safe to publish via NATS** — contains no credential material.
279    AccessInvalidated(AccessInvalidatedPayload),
280    /// Cross-controller admin event broadcast.
281    ///
282    /// Published via NATS to the `controller` subject by any controller
283    /// instance when it emits an `AdminEvent` to local SSE subscribers.
284    /// Receiving controller instances decode the payload and re-broadcast
285    /// to their own local SSE subscribers using `send_local` /
286    /// `send_global_local` (without re-publishing to NATS to avoid loops).
287    ///
288    /// **Safe to publish via NATS** — contains no credential material.
289    BroadcastAdminEvent(BroadcastAdminEventPayload),
290    // -- Workload claim protocol --
291    /// Controller → Service: grant/reject response for a workload claim.
292    ///
293    /// Sent in response to `WorkloadClaim`, unsolicited for proactive
294    /// re-grants when previously rejected keys become available, or for
295    /// revocations during cross-controller conflict resolution.
296    ///
297    /// **Session-targeted**: NEVER published to NATS.
298    WorkloadClaimResult(WorkloadClaimResultPayload),
299    /// Controller → NATS: announce claim state changes for cross-controller sync.
300    ///
301    /// Published to the `controller` NATS subject after granting or releasing
302    /// claims. Other controllers update their global claim registry from this.
303    ///
304    /// **Safe to publish via NATS** — contains no credential material.
305    WorkloadClaimAnnouncement(WorkloadClaimAnnouncementPayload),
306    /// Controller → NATS: request full claim state from all active controllers.
307    ///
308    /// Published on controller startup. Each active controller responds with
309    /// `WorkloadClaimSyncResponse`.
310    ///
311    /// **NATS-only** (controller-to-controller).
312    WorkloadClaimSyncRequest(WorkloadClaimSyncRequestPayload),
313    /// Controller → NATS: respond with full local claim state.
314    ///
315    /// Sent in response to `WorkloadClaimSyncRequest`.
316    ///
317    /// **NATS-only** (controller-to-controller).
318    WorkloadClaimSyncResponse(WorkloadClaimSyncResponsePayload),
319    /// Controller -> Agent: test a plugin configuration on a specific host.
320    ///
321    /// Sent when a user invokes the config test API endpoint for an agent-side
322    /// plugin. The agent executes the test and responds with
323    /// `ServiceMessage::TestPluginConfigResult`.
324    ///
325    /// **Security**: session-targeted, NEVER published to NATS.
326    TestPluginConfig(TestPluginConfigPayload),
327    /// Unknown message type from a newer controller build.
328    ///
329    /// Deserialized when the `type` tag does not match any known variant.
330    /// The payload is discarded. The receiver should log a warning and
331    /// continue processing other messages.
332    ///
333    /// **Security**: Never published to NATS — we cannot re-publish a message
334    /// whose payload has been discarded.
335    #[serde(other)]
336    #[cfg_attr(feature = "schema", schemars(skip))]
337    Unknown,
338}
339
340impl ControllerMessage {
341    /// Returns `true` if this message may be published to NATS JetStream.
342    ///
343    /// Credential-bearing variants (`ServiceCredentials`) and session-targeted
344    /// variants (`SurfaceActionRequest`, `SurfaceActionCancel`,
345    /// `SurfaceActionResponse`)
346    /// must **never** be published to NATS — they are delivered exclusively
347    /// over authenticated WebSocket connections. All other variants are safe
348    /// to broadcast via NATS.
349    ///
350    /// This is the authoritative gate used by [`NatsConnection::publish`].
351    pub fn is_nats_publishable(&self) -> bool {
352        !matches!(
353            self,
354            ControllerMessage::ServiceCredentials(_)
355                | ControllerMessage::SurfaceActionRequest(_)
356                | ControllerMessage::SurfaceActionCancel(_)
357                | ControllerMessage::SurfaceActionResponse(_)
358                | ControllerMessage::UpdateStdinData(_)
359                | ControllerMessage::ResetData
360                | ControllerMessage::ServiceConfigDelivery(_)
361                | ControllerMessage::ServiceConfigAck(_)
362                | ControllerMessage::ServiceConfigUpdated(_)
363                | ControllerMessage::WorkloadClaimResult(_)
364                | ControllerMessage::TestPluginConfig(_)
365                | ControllerMessage::Unknown
366        )
367    }
368}