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