Skip to main content

codex_helper_core/fleet/
registry.rs

1use std::collections::BTreeMap;
2
3use anyhow::{Context, Result, bail};
4use reqwest::Url;
5use serde::{Deserialize, Serialize};
6
7use crate::control_plane_client::ControlPlaneEndpoint;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
10#[serde(default)]
11pub struct FleetNodeConfig {
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub label: Option<String>,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub admin_url: Option<String>,
16    #[serde(default, skip_serializing_if = "Vec::is_empty")]
17    pub admin_urls: Vec<String>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub admin_token_env: Option<String>,
20    #[serde(default)]
21    pub enabled: bool,
22}
23
24impl FleetNodeConfig {
25    pub fn endpoints(&self) -> Vec<&str> {
26        self.admin_url
27            .as_deref()
28            .into_iter()
29            .chain(self.admin_urls.iter().map(String::as_str))
30            .map(str::trim)
31            .filter(|value| !value.is_empty())
32            .collect()
33    }
34
35    pub fn display_label(&self, id: &str) -> String {
36        self.label
37            .as_deref()
38            .map(str::trim)
39            .filter(|value| !value.is_empty())
40            .unwrap_or(id)
41            .to_string()
42    }
43
44    pub fn validate(&self, node_id: &str) -> Result<()> {
45        let endpoints = self.endpoints();
46        if self.enabled && endpoints.is_empty() {
47            bail!("fleet node '{node_id}' is enabled but has no admin_url or admin_urls");
48        }
49
50        if let Some(env_name) = self.admin_token_env.as_deref() {
51            validate_env_var_name(env_name).with_context(|| {
52                format!("fleet node '{node_id}' admin_token_env must be a valid environment variable name")
53            })?;
54        }
55
56        for endpoint_url in endpoints {
57            let endpoint =
58                ControlPlaneEndpoint::new(endpoint_url, self.admin_token_env.as_deref())?;
59            validate_endpoint_auth(
60                node_id,
61                self.enabled,
62                self.admin_token_env.as_deref(),
63                &endpoint,
64            )?;
65        }
66
67        Ok(())
68    }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
72pub struct FleetRegistryConfig {
73    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
74    pub nodes: BTreeMap<String, FleetNodeConfig>,
75}
76
77impl FleetRegistryConfig {
78    pub fn is_empty(&self) -> bool {
79        self.nodes.is_empty()
80    }
81
82    pub fn enabled_nodes(&self) -> impl Iterator<Item = (&String, &FleetNodeConfig)> {
83        self.nodes
84            .iter()
85            .filter(|(_, node)| node.enabled && !node.endpoints().is_empty())
86    }
87
88    pub fn validate(&self) -> Result<()> {
89        for (node_id, node) in &self.nodes {
90            node.validate(node_id)?;
91        }
92        Ok(())
93    }
94}
95
96fn validate_endpoint_auth(
97    node_id: &str,
98    enabled: bool,
99    admin_token_env: Option<&str>,
100    endpoint: &ControlPlaneEndpoint,
101) -> Result<()> {
102    let url = Url::parse(&endpoint.admin_base_url)
103        .with_context(|| format!("fleet node '{node_id}' has invalid admin_url"))?;
104    if is_loopback_host(&url) {
105        return Ok(());
106    }
107
108    if !enabled {
109        return Ok(());
110    }
111
112    if admin_token_env.is_none() {
113        bail!(
114            "fleet node '{node_id}' points to a non-loopback admin_url but does not set admin_token_env; use HTTPS or a trusted encrypted tunnel and configure admin_token_env"
115        );
116    }
117
118    Ok(())
119}
120
121fn validate_env_var_name(value: &str) -> Result<()> {
122    let value = value.trim();
123    let mut chars = value.chars();
124    let Some(first) = chars.next() else {
125        bail!("environment variable name is empty");
126    };
127    if !(first == '_' || first.is_ascii_uppercase()) {
128        bail!("environment variable name must start with an ASCII uppercase letter or underscore");
129    }
130    if !chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit()) {
131        bail!(
132            "environment variable name must contain only ASCII uppercase letters, digits, or underscores"
133        );
134    }
135    Ok(())
136}
137
138fn is_loopback_host(url: &Url) -> bool {
139    matches!(url.host_str(), Some("localhost"))
140        || url
141            .host_str()
142            .and_then(|host| host.parse::<std::net::IpAddr>().ok())
143            .is_some_and(|addr| addr.is_loopback())
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn fleet_registry_accepts_loopback_admin_url_without_token_env() {
152        let cfg = FleetRegistryConfig {
153            nodes: BTreeMap::from([(
154                "local".to_string(),
155                FleetNodeConfig {
156                    label: Some("Local".to_string()),
157                    admin_url: Some("http://127.0.0.1:4211".to_string()),
158                    admin_urls: Vec::new(),
159                    admin_token_env: None,
160                    enabled: true,
161                },
162            )]),
163        };
164
165        cfg.validate().expect("loopback fleet node should validate");
166    }
167
168    #[test]
169    fn fleet_registry_rejects_remote_http_without_token_env() {
170        let cfg = FleetRegistryConfig {
171            nodes: BTreeMap::from([(
172                "remote".to_string(),
173                FleetNodeConfig {
174                    label: Some("Remote".to_string()),
175                    admin_url: Some("http://nas.example.com:4211".to_string()),
176                    admin_urls: Vec::new(),
177                    admin_token_env: None,
178                    enabled: true,
179                },
180            )]),
181        };
182
183        let err = cfg
184            .validate()
185            .expect_err("remote fleet node should require token auth");
186        assert!(err.to_string().contains("does not set admin_token_env"));
187    }
188
189    #[test]
190    fn fleet_registry_rejects_invalid_admin_token_env_name() {
191        let cfg = FleetRegistryConfig {
192            nodes: BTreeMap::from([(
193                "remote".to_string(),
194                FleetNodeConfig {
195                    label: Some("Remote".to_string()),
196                    admin_url: Some("https://nas.example.com:4211".to_string()),
197                    admin_urls: Vec::new(),
198                    admin_token_env: Some("Bearer abc".to_string()),
199                    enabled: true,
200                },
201            )]),
202        };
203
204        let err = cfg
205            .validate()
206            .expect_err("token env should reject raw secret-like values");
207        assert!(err.to_string().contains("valid environment variable name"));
208    }
209}