Skip to main content

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}