clawft_kernel/workload_runtime/
seed_http.rs1use std::time::Duration;
9
10use async_trait::async_trait;
11use clawft_types::secret::SecretString;
12use serde_json::Value;
13
14use super::seed_tls::SeedTls;
15use super::types::{LinkSecurity, RuntimeError};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Method {
20 Get,
22 Post,
24 Put,
26 Delete,
28}
29
30pub const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
32
33#[async_trait]
35pub trait SeedTransport: Send + Sync {
36 async fn request(
39 &self,
40 method: Method,
41 path: &str,
42 body: Option<&Value>,
43 token: &SecretString,
44 timeout: Duration,
45 ) -> Result<(u16, Value), RuntimeError>;
46
47 fn link_security(&self) -> LinkSecurity {
49 LinkSecurity::Unpinned
50 }
51}
52
53pub fn validate_base_url(url: &str) -> Result<String, RuntimeError> {
55 let bad = || RuntimeError::InvalidConfig("seed base URL must be http(s)://host[:port]".into());
56 let rest = url
57 .strip_prefix("https://")
58 .or_else(|| url.strip_prefix("http://"))
59 .ok_or_else(bad)?;
60 let host = rest.trim_end_matches('/');
61 if host.is_empty()
62 || host.len() > 253
63 || !host
64 .bytes()
65 .all(|b| b.is_ascii_alphanumeric() || b".-:[]".contains(&b))
66 {
67 return Err(bad());
68 }
69 Ok(url.trim_end_matches('/').to_string())
70}
71
72pub fn validate_path(path: &str) -> Result<(), RuntimeError> {
74 let ok = path.starts_with("/api/v1/")
75 && path.len() <= 256
76 && !path.contains("..")
77 && path
78 .bytes()
79 .all(|b| b.is_ascii_alphanumeric() || b"/-_.?=&".contains(&b));
80 if ok {
81 Ok(())
82 } else {
83 Err(RuntimeError::InvalidConfig(format!(
84 "unsafe seed API path {path:?}"
85 )))
86 }
87}
88
89pub struct HttpSeedTransport {
91 base: String,
92 client: reqwest::Client,
93 pinned: bool,
94 lab_opt_in: bool,
95}
96
97impl HttpSeedTransport {
98 pub fn new(base_url: &str, tls: SeedTls) -> Result<Self, RuntimeError> {
106 let base = validate_base_url(base_url)?;
107 let mut builder = reqwest::Client::builder()
114 .redirect(reqwest::redirect::Policy::none())
115 .no_proxy()
116 .pool_max_idle_per_host(0);
117 let pinned = matches!(tls, SeedTls::PinnedSha256(_) | SeedTls::PinnedSpki(_));
118 if pinned {
119 if !base.starts_with("https://") {
120 return Err(RuntimeError::InvalidConfig(
121 "a seed TLS pin needs an https:// base URL".into(),
122 ));
123 }
124 let cfg = tls
125 .client_config()
126 .ok_or_else(|| RuntimeError::Backend("tls config for the pinned seed".into()))?;
127 builder = builder.use_preconfigured_tls(cfg);
128 }
129 let client = builder
130 .build()
131 .map_err(|e| RuntimeError::Backend(format!("http client: {e}")))?;
132 Ok(Self {
133 base,
134 client,
135 pinned,
136 lab_opt_in: false,
137 })
138 }
139
140 pub fn allow_unpinned_lab_link(mut self) -> Self {
144 tracing::warn!(base = %self.base, "unpinned Seed lab link enabled by operator opt-in");
145 self.lab_opt_in = true;
146 self
147 }
148}
149
150#[async_trait]
151impl SeedTransport for HttpSeedTransport {
152 fn link_security(&self) -> LinkSecurity {
153 if self.pinned {
154 LinkSecurity::Pinned
155 } else if self.lab_opt_in {
156 LinkSecurity::LabOptIn
157 } else {
158 LinkSecurity::Unpinned
159 }
160 }
161
162 async fn request(
163 &self,
164 method: Method,
165 path: &str,
166 body: Option<&Value>,
167 token: &SecretString,
168 timeout: Duration,
169 ) -> Result<(u16, Value), RuntimeError> {
170 validate_path(path)?;
171 let url = format!("{}{path}", self.base);
172 let mut req = match method {
173 Method::Get => self.client.get(&url),
174 Method::Post => self.client.post(&url),
175 Method::Put => self.client.put(&url),
176 Method::Delete => self.client.delete(&url),
177 }
178 .timeout(timeout);
179 if !token.is_empty() {
180 req = req.bearer_auth(token.expose());
181 }
182 if let Some(b) = body {
183 req = req.json(b);
184 }
185 let resp = req
187 .send()
188 .await
189 .map_err(|e| RuntimeError::Backend(format!("seed {path}: {}", e.without_url())))?;
190 let status = resp.status().as_u16();
191 let too_large = || RuntimeError::Backend(format!("seed {path}: response too large"));
192 if resp
193 .content_length()
194 .is_some_and(|n| n > MAX_RESPONSE_BYTES as u64)
195 {
196 return Err(too_large());
197 }
198 let mut resp = resp;
201 let mut bytes = Vec::new();
202 while let Some(chunk) = resp
203 .chunk()
204 .await
205 .map_err(|e| RuntimeError::Backend(format!("seed {path}: {}", e.without_url())))?
206 {
207 if bytes.len() + chunk.len() > MAX_RESPONSE_BYTES {
208 return Err(too_large());
209 }
210 bytes.extend_from_slice(&chunk);
211 }
212 let v = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
213 Ok((status, v))
214 }
215}
216
217pub trait SeedCredentials: Send + Sync {
219 fn get(&self, node_id: &str) -> Result<SecretString, RuntimeError>;
221 fn put(&self, node_id: &str, token: SecretString) -> Result<(), RuntimeError>;
223}