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}