codex_helper_core/fleet/
registry.rs1use 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}