Skip to main content

soma_fleet/
endpoint.rs

1use std::path::{Component, Path, PathBuf};
2
3use serde::{Deserialize, Deserializer, Serialize};
4
5use crate::{TopologyError, TopologyRevision};
6
7const MAX_ENDPOINT_CHARS: usize = 512;
8const MAX_USER_CHARS: usize = 128;
9
10/// Transport endpoint for one managed host.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(tag = "kind", rename_all = "snake_case")]
13pub enum HostEndpoint {
14    /// Commands execute on the current machine.
15    Local,
16    /// Commands execute through a strict-host-key SSH endpoint.
17    Ssh(SshEndpoint),
18    /// Operations route to an HTTP service endpoint.
19    Http(HttpEndpoint),
20}
21
22impl HostEndpoint {
23    pub(crate) fn revision(&self) -> TopologyRevision {
24        let material = serde_json::to_vec(self).expect("fleet endpoints serialize");
25        TopologyRevision::from_material(material)
26    }
27}
28
29/// Strict-host-key SSH connection material.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
31pub struct SshEndpoint {
32    host: String,
33    port: u16,
34    user: Option<String>,
35    identity_file: Option<PathBuf>,
36    config_file: Option<PathBuf>,
37    known_hosts_file: Option<PathBuf>,
38}
39
40impl SshEndpoint {
41    /// Creates an SSH endpoint using port 22.
42    pub fn new(host: impl Into<String>) -> Result<Self, TopologyError> {
43        let host = host.into();
44        validate_endpoint_text("SSH host", &host)?;
45        Ok(Self {
46            host,
47            port: 22,
48            user: None,
49            identity_file: None,
50            config_file: None,
51            known_hosts_file: None,
52        })
53    }
54
55    /// Sets the SSH port.
56    pub fn with_port(mut self, port: u16) -> Result<Self, TopologyError> {
57        if port == 0 {
58            return Err(TopologyError::InvalidPort);
59        }
60        self.port = port;
61        Ok(self)
62    }
63
64    /// Sets the optional SSH user.
65    pub fn with_user(mut self, user: impl Into<String>) -> Result<Self, TopologyError> {
66        let user = user.into();
67        validate_bounded_text("SSH user", &user, MAX_USER_CHARS)?;
68        self.user = Some(user);
69        Ok(self)
70    }
71
72    /// Sets an absolute identity-file path.
73    pub fn with_identity_file(mut self, path: impl Into<PathBuf>) -> Result<Self, TopologyError> {
74        self.identity_file = Some(validate_absolute_path(path.into())?);
75        Ok(self)
76    }
77
78    /// Sets an absolute SSH config path.
79    pub fn with_config_file(mut self, path: impl Into<PathBuf>) -> Result<Self, TopologyError> {
80        self.config_file = Some(validate_absolute_path(path.into())?);
81        Ok(self)
82    }
83
84    /// Sets an absolute known-hosts path used with strict verification.
85    pub fn with_known_hosts_file(
86        mut self,
87        path: impl Into<PathBuf>,
88    ) -> Result<Self, TopologyError> {
89        self.known_hosts_file = Some(validate_absolute_path(path.into())?);
90        Ok(self)
91    }
92
93    /// Returns the SSH hostname or config alias.
94    #[must_use]
95    pub fn host(&self) -> &str {
96        &self.host
97    }
98
99    /// Returns the SSH port.
100    #[must_use]
101    pub const fn port(&self) -> u16 {
102        self.port
103    }
104
105    /// Returns the optional SSH user.
106    #[must_use]
107    pub fn user(&self) -> Option<&str> {
108        self.user.as_deref()
109    }
110
111    /// Returns the optional identity-file path.
112    #[must_use]
113    pub fn identity_file(&self) -> Option<&Path> {
114        self.identity_file.as_deref()
115    }
116
117    /// Returns the optional config-file path.
118    #[must_use]
119    pub fn config_file(&self) -> Option<&Path> {
120        self.config_file.as_deref()
121    }
122
123    /// Returns the optional strict known-hosts path.
124    #[must_use]
125    pub fn known_hosts_file(&self) -> Option<&Path> {
126        self.known_hosts_file.as_deref()
127    }
128}
129
130impl<'de> Deserialize<'de> for SshEndpoint {
131    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
132    where
133        D: Deserializer<'de>,
134    {
135        let wire = SshEndpointWire::deserialize(deserializer)?;
136        let mut endpoint = Self::new(wire.host).map_err(serde::de::Error::custom)?;
137        endpoint = endpoint
138            .with_port(wire.port)
139            .map_err(serde::de::Error::custom)?;
140        if let Some(user) = wire.user {
141            endpoint = endpoint.with_user(user).map_err(serde::de::Error::custom)?;
142        }
143        if let Some(path) = wire.identity_file {
144            endpoint = endpoint
145                .with_identity_file(path)
146                .map_err(serde::de::Error::custom)?;
147        }
148        if let Some(path) = wire.config_file {
149            endpoint = endpoint
150                .with_config_file(path)
151                .map_err(serde::de::Error::custom)?;
152        }
153        if let Some(path) = wire.known_hosts_file {
154            endpoint = endpoint
155                .with_known_hosts_file(path)
156                .map_err(serde::de::Error::custom)?;
157        }
158        Ok(endpoint)
159    }
160}
161
162#[derive(Deserialize)]
163struct SshEndpointWire {
164    host: String,
165    #[serde(default = "default_ssh_port")]
166    port: u16,
167    #[serde(default)]
168    user: Option<String>,
169    #[serde(default)]
170    identity_file: Option<PathBuf>,
171    #[serde(default)]
172    config_file: Option<PathBuf>,
173    #[serde(default)]
174    known_hosts_file: Option<PathBuf>,
175}
176
177const fn default_ssh_port() -> u16 {
178    22
179}
180
181/// Validated HTTP or HTTPS fleet endpoint.
182#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
183pub struct HttpEndpoint {
184    base_url: String,
185}
186
187impl HttpEndpoint {
188    /// Creates an HTTP endpoint without embedded credentials.
189    pub fn new(base_url: impl Into<String>) -> Result<Self, TopologyError> {
190        let base_url = base_url.into();
191        validate_endpoint_text("HTTP base URL", &base_url)?;
192        let Some((scheme, remainder)) = base_url.split_once("://") else {
193            return Err(TopologyError::InvalidHttpEndpoint);
194        };
195        if !matches!(scheme, "http" | "https") {
196            return Err(TopologyError::InvalidHttpEndpoint);
197        }
198        let authority = remainder.split('/').next().unwrap_or_default();
199        if authority.is_empty() || authority.contains('@') {
200            return Err(TopologyError::InvalidHttpEndpoint);
201        }
202        Ok(Self { base_url })
203    }
204
205    /// Returns the normalized endpoint text.
206    #[must_use]
207    pub fn base_url(&self) -> &str {
208        &self.base_url
209    }
210}
211
212impl<'de> Deserialize<'de> for HttpEndpoint {
213    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
214    where
215        D: Deserializer<'de>,
216    {
217        let wire = HttpEndpointWire::deserialize(deserializer)?;
218        Self::new(wire.base_url).map_err(serde::de::Error::custom)
219    }
220}
221
222#[derive(Deserialize)]
223struct HttpEndpointWire {
224    base_url: String,
225}
226
227fn validate_endpoint_text(field: &'static str, value: &str) -> Result<(), TopologyError> {
228    validate_bounded_text(field, value, MAX_ENDPOINT_CHARS)
229}
230
231fn validate_bounded_text(
232    field: &'static str,
233    value: &str,
234    max_chars: usize,
235) -> Result<(), TopologyError> {
236    let count = value.chars().count();
237    if count == 0 || count > max_chars || value.chars().any(char::is_control) {
238        Err(TopologyError::InvalidEndpointText { field })
239    } else {
240        Ok(())
241    }
242}
243
244fn validate_absolute_path(path: PathBuf) -> Result<PathBuf, TopologyError> {
245    if !path.is_absolute()
246        || path
247            .components()
248            .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
249    {
250        Err(TopologyError::InvalidAbsolutePath(path))
251    } else {
252        Ok(path)
253    }
254}
255
256#[cfg(test)]
257#[path = "endpoint_tests.rs"]
258mod tests;