Skip to main content

uptrakit_wire/
capabilities.rs

1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5
6/// A protocol capability advertised by a service or controller during connection setup.
7///
8/// Both sides announce their capability sets at the start of each authenticated
9/// connection. Each side independently computes the agreed set as the intersection
10/// of typed variants only — [`Other`](Self::Other) is excluded from intersection.
11///
12/// ## Wire format
13///
14/// Capabilities are serialized as plain strings (snake_case). Unknown strings from
15/// a newer peer become `Other(String)` for forward compatibility.
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
17#[non_exhaustive]
18pub enum Capability {
19    /// Service participates in the graceful-shutdown protocol: sends
20    /// `Disconnecting` before clean exit and honours
21    /// `shutdown_timeout_seconds` from `ServiceSettings`.
22    ///
23    /// Wire string: `graceful_shutdown`.
24    GracefulShutdown,
25    /// Service tracks software update state and host connectivity.
26    ///
27    /// The controller uses this capability to route software-state broadcasts
28    /// and connectivity updates. Also gates MQTT-specific lease coordination
29    /// for MQTT bridge services.
30    ///
31    /// Wire string: `update_tracking`.
32    UpdateTracking,
33    /// Service supports `DiscoverSoftware` → `DiscoveryResults` flow.
34    ///
35    /// The controller gates autodiscovery requests on this capability.
36    ///
37    /// Wire string: `software_discovery`.
38    SoftwareDiscovery,
39    /// Service manages remote hosts over SSH, rather than running locally.
40    ///
41    /// Identifies an SSH-backed agent. Combined with `SoftwareDiscovery`,
42    /// uniquely identifies an SSH agent (vs. a local agent).
43    ///
44    /// Wire string: `ssh_remote`.
45    SshRemote,
46    /// Service supports pre-/post-update lifecycle hook plugins
47    /// (`PluginAssignment` in `ExecuteUpdatePayload`). The controller omits
48    /// hook plugins when absent.
49    ///
50    /// Wire string: `update_hooks`.
51    UpdateHooks,
52    /// Marker: service is an external task scheduler.
53    ///
54    /// Identifies a service that runs scheduled tasks (version checks, cert
55    /// checks, auth cleanup, etc.) externally. The controller uses this to
56    /// detect scheduler presence and disable the embedded scheduler.
57    ///
58    /// Wire string: `scheduler`.
59    Scheduler,
60    /// Service requires direct database access. The controller will include
61    /// `db_url` in [`ServiceCredentialsPayload`](super::payloads::ServiceCredentialsPayload).
62    ///
63    /// Wire string: `database_access`.
64    DatabaseAccess,
65    /// Service requires NATS access. The controller will include `nats_url`
66    /// in [`ServiceCredentialsPayload`](super::payloads::ServiceCredentialsPayload) (if NATS is configured).
67    ///
68    /// Wire string: `nats_access`.
69    NatsAccess,
70    /// Service requires the master encryption key. The controller will include
71    /// `master_key_hex` in [`ServiceCredentialsPayload`](super::payloads::ServiceCredentialsPayload) (if encryption is enabled).
72    ///
73    /// Wire string: `master_key_access`.
74    MasterKeyAccess,
75    /// Service can request CA certificate rotation via [`RequestCaRotationPayload`](super::payloads::RequestCaRotationPayload).
76    /// The controller will accept `RequestCaRotation` messages from services
77    /// with this capability (via NATS or local delivery).
78    ///
79    /// Wire string: `ca_management`.
80    CaManagement,
81    /// Service is a global infrastructure service, not bound to any tenant.
82    ///
83    /// When present in an `EnrollPayload`, the controller routes enrollment to
84    /// the `system_services` table instead of the per-tenant `services` table.
85    ///
86    /// **Credential guard**: any service requesting `DatabaseAccess`,
87    /// `NatsAccess`, `MasterKeyAccess`, or `CaManagement` without also
88    /// advertising `SystemService` will be rejected at enrollment with a 403
89    /// error. This prevents regular tenant agents from claiming infrastructure
90    /// credentials.
91    ///
92    /// Wire string: `system_service`.
93    SystemService,
94    /// Service supports UI surfaces: it will send `SurfaceRegistration` after
95    /// connection and respond to surface action messages.
96    ///
97    /// Wire string: `ui_surfaces`.
98    UiSurfaces,
99    /// Service supports interactive update sessions: PTY allocation, stdin
100    /// forwarding, and signal delivery during update execution.
101    ///
102    /// When present, the controller may set `interactive: true` on
103    /// `ExecuteUpdatePayload` and send `UpdateStdinData` messages to this
104    /// service. The service allocates a PTY for the update process and keeps
105    /// stdin open for forwarding.
106    ///
107    /// Wire string: `interactive_updates`.
108    InteractiveUpdates,
109    /// Service supports the reset-data protocol: truncates local data stores
110    /// when the controller broadcasts a data reset.
111    ///
112    /// Wire string: `reset_data`.
113    ResetData,
114    /// Service participates in the workload claim protocol for exclusive
115    /// config-key ownership.
116    ///
117    /// Services with this capability send `WorkloadClaim` after receiving
118    /// `ServiceConfigDelivery` to request exclusive ownership of config keys.
119    /// The controller responds with `WorkloadClaimResult` and routes
120    /// tenant-scoped messages only to services that hold granted claims.
121    ///
122    /// Wire string: `workload_claims`.
123    WorkloadClaims,
124    /// Unknown capability from a newer peer; never participates in intersection.
125    ///
126    /// Provides forward compatibility: a newer peer may advertise capabilities
127    /// that an older build does not yet recognise. These are preserved on receipt
128    /// but never emitted by the current codebase.
129    Other(String),
130}
131
132impl Capability {
133    /// Returns the snake_case wire string for this capability.
134    pub fn as_str(&self) -> &str {
135        match self {
136            Self::SoftwareDiscovery => "software_discovery",
137            Self::UpdateHooks => "update_hooks",
138            Self::GracefulShutdown => "graceful_shutdown",
139            Self::UpdateTracking => "update_tracking",
140            Self::SshRemote => "ssh_remote",
141            Self::Scheduler => "scheduler",
142            Self::DatabaseAccess => "database_access",
143            Self::NatsAccess => "nats_access",
144            Self::MasterKeyAccess => "master_key_access",
145            Self::CaManagement => "ca_management",
146            Self::SystemService => "system_service",
147            Self::UiSurfaces => "ui_surfaces",
148            Self::InteractiveUpdates => "interactive_updates",
149            Self::ResetData => "reset_data",
150            Self::WorkloadClaims => "workload_claims",
151            Self::Other(s) => s.as_str(),
152        }
153    }
154
155    /// Returns `true` for typed variants; `Other` returns `false`.
156    ///
157    /// Only typed variants participate in capability intersection. `Other` values
158    /// are forwarded-compatibility markers and must not gate behaviour.
159    pub fn is_known(&self) -> bool {
160        !matches!(self, Self::Other(_))
161    }
162}
163
164impl fmt::Display for Capability {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        f.write_str(self.as_str())
167    }
168}
169
170impl FromStr for Capability {
171    type Err = std::convert::Infallible;
172
173    fn from_str(s: &str) -> Result<Self, Self::Err> {
174        Ok(match s {
175            "software_discovery" => Self::SoftwareDiscovery,
176            "update_hooks" => Self::UpdateHooks,
177            "graceful_shutdown" => Self::GracefulShutdown,
178            "update_tracking" => Self::UpdateTracking,
179            "ssh_remote" => Self::SshRemote,
180            "scheduler" => Self::Scheduler,
181            "database_access" => Self::DatabaseAccess,
182            "nats_access" => Self::NatsAccess,
183            "master_key_access" => Self::MasterKeyAccess,
184            "ca_management" => Self::CaManagement,
185            "system_service" => Self::SystemService,
186            "ui_surfaces" => Self::UiSurfaces,
187            "interactive_updates" => Self::InteractiveUpdates,
188            "reset_data" => Self::ResetData,
189            "workload_claims" => Self::WorkloadClaims,
190            other => {
191                tracing::debug!(capability = other, "received unknown capability from peer");
192                Self::Other(other.to_string())
193            }
194        })
195    }
196}
197
198impl Serialize for Capability {
199    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
200        serializer.serialize_str(self.as_str())
201    }
202}
203
204impl<'de> Deserialize<'de> for Capability {
205    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
206        let s = String::deserialize(deserializer)?;
207        Ok(s.parse().unwrap_or(Capability::Other(s)))
208    }
209}
210
211/// Enrollment status returned in the `Enrolled` message.
212///
213/// # Wire forward-compatibility
214///
215/// `Other(String)` is a catch-all for status strings received from a newer
216/// controller that this build does not yet recognise. Serde deserialization
217/// is infallible: an unknown string becomes `Other(...)` rather than a parse
218/// error, allowing older agents to survive rolling upgrades without dropping
219/// the enclosing `Enrolled` message.
220#[non_exhaustive]
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub enum EnrollmentStatus {
223    Pending,
224    Approved,
225    /// An unknown status received from a newer peer.
226    ///
227    /// The inner string is the raw snake_case value as it appeared on the wire.
228    Other(String),
229}
230
231impl EnrollmentStatus {
232    /// Returns the string representation.
233    ///
234    /// For [`EnrollmentStatus::Other`], returns the inner string as-is.
235    pub fn as_str(&self) -> &str {
236        match self {
237            Self::Pending => "pending",
238            Self::Approved => "approved",
239            Self::Other(s) => s.as_str(),
240        }
241    }
242}
243
244impl fmt::Display for EnrollmentStatus {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        f.write_str(self.as_str())
247    }
248}
249
250impl From<String> for EnrollmentStatus {
251    /// Converts a snake_case string to an enrollment status.
252    ///
253    /// Unknown strings map to [`EnrollmentStatus::Other`] rather than failing.
254    fn from(s: String) -> Self {
255        match s.as_str() {
256            "pending" => Self::Pending,
257            "approved" => Self::Approved,
258            _ => {
259                tracing::debug!(status = s, "received unknown enrollment status from peer");
260                Self::Other(s)
261            }
262        }
263    }
264}
265
266impl Serialize for EnrollmentStatus {
267    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
268        serializer.serialize_str(self.as_str())
269    }
270}
271
272impl<'de> Deserialize<'de> for EnrollmentStatus {
273    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
274        String::deserialize(deserializer).map(EnrollmentStatus::from)
275    }
276}
277
278/// Machine-readable error code sent in `ErrorPayload`.
279///
280/// # Wire forward-compatibility
281///
282/// `Other(String)` is a catch-all for error codes received from a newer
283/// controller that this build does not yet recognise. Serde deserialization
284/// is infallible: an unknown string becomes `Other(...)` rather than a parse
285/// error, allowing older agents to survive rolling upgrades without dropping
286/// the enclosing `Error` message.
287#[non_exhaustive]
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub enum ErrorCode {
290    /// Malformed or unexpected message from the service.
291    BadRequest,
292    /// Enrollment attempt failed on the controller side.
293    EnrollmentFailed,
294    /// Service is not approved (pending or rejected).
295    NotApproved,
296    /// Service is not allowed to perform this action.
297    Forbidden,
298    /// Certificate signing or renewal error.
299    CertificateError,
300    /// Unrecoverable server-side error.
301    InternalError,
302    /// Message sequence number mismatch (replay protection).
303    SequenceError,
304    /// An unknown error code received from a newer peer.
305    ///
306    /// The inner string is the raw snake_case value as it appeared on the wire.
307    Other(String),
308}
309
310impl ErrorCode {
311    /// Returns the string representation.
312    ///
313    /// For [`ErrorCode::Other`], returns the inner string as-is.
314    pub fn as_str(&self) -> &str {
315        match self {
316            Self::BadRequest => "bad_request",
317            Self::EnrollmentFailed => "enrollment_failed",
318            Self::NotApproved => "not_approved",
319            Self::Forbidden => "forbidden",
320            Self::CertificateError => "certificate_error",
321            Self::InternalError => "internal_error",
322            Self::SequenceError => "sequence_error",
323            Self::Other(s) => s.as_str(),
324        }
325    }
326}
327
328impl fmt::Display for ErrorCode {
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        f.write_str(self.as_str())
331    }
332}
333
334impl From<String> for ErrorCode {
335    /// Converts a snake_case string to an error code.
336    ///
337    /// Unknown strings map to [`ErrorCode::Other`] rather than failing.
338    fn from(s: String) -> Self {
339        match s.as_str() {
340            "bad_request" => Self::BadRequest,
341            "enrollment_failed" => Self::EnrollmentFailed,
342            "not_approved" => Self::NotApproved,
343            "forbidden" => Self::Forbidden,
344            "certificate_error" => Self::CertificateError,
345            "internal_error" => Self::InternalError,
346            "sequence_error" => Self::SequenceError,
347            _ => {
348                tracing::debug!(error_code = s, "received unknown error code from peer");
349                Self::Other(s)
350            }
351        }
352    }
353}
354
355impl Serialize for ErrorCode {
356    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
357        serializer.serialize_str(self.as_str())
358    }
359}
360
361impl<'de> Deserialize<'de> for ErrorCode {
362    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
363        String::deserialize(deserializer).map(ErrorCode::from)
364    }
365}
366
367/// Payload for error responses.
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct ErrorPayload {
370    pub code: ErrorCode,
371    pub message: String,
372}