Skip to main content

clawft_kernel/workload_runtime/
seed_http.rs

1//! HTTP transport and credential storage for the Cognitum Seed adapter.
2//!
3//! The per-Seed bearer token lives in the operator secret store
4//! ([`SeedCredentials`], persisted by [`super::seed_creds::FileCredentials`]);
5//! it is only ever placed in an `Authorization` header and never appears
6//! in errors, logs or chain payloads.
7
8use 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/// HTTP method subset the Seed API uses.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Method {
20    /// GET.
21    Get,
22    /// POST.
23    Post,
24    /// PUT.
25    Put,
26    /// DELETE.
27    Delete,
28}
29
30/// Largest response body accepted from a Seed.
31pub const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
32
33/// Sends requests to one Seed.
34#[async_trait]
35pub trait SeedTransport: Send + Sync {
36    /// Send `method path` with an optional JSON body; returns status and
37    /// parsed JSON (`Value::Null` for an empty or non-JSON body).
38    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    /// How this link authenticates the Seed. Fails closed.
48    fn link_security(&self) -> LinkSecurity {
49        LinkSecurity::Unpinned
50    }
51}
52
53/// Validate a Seed base URL: `http(s)://host[:port]`, nothing else.
54pub 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
72/// Validate an API path: `/api/v1/...` with safe characters only.
73pub 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
89/// reqwest-backed transport.
90pub struct HttpSeedTransport {
91    base: String,
92    client: reqwest::Client,
93    pinned: bool,
94    lab_opt_in: bool,
95}
96
97impl HttpSeedTransport {
98    /// Transport for `base_url`.
99    ///
100    /// An `https://` Seed is trusted per `tls`: WebPKI, or exactly the
101    /// operator-pinned certificate or key ([`SeedTls::PinnedSha256`],
102    /// [`SeedTls::PinnedSpki`]; a Seed's own certificate is self-signed). Certificate checking is never switched
103    /// off. A pin on an `http://` base is refused (it would protect nothing
104    /// while looking as if it did).
105    pub fn new(base_url: &str, tls: SeedTls) -> Result<Self, RuntimeError> {
106        let base = validate_base_url(base_url)?;
107        // A Seed is reached directly (USB link-local, LAN or tailnet), never
108        // through a proxy: a system or environment proxy cannot route
109        // `169.254.0.0/16` and would also see the bearer token. Idle
110        // connections are not pooled: the Seed closes a keep-alive
111        // connection after a few seconds, and reusing the stale one fails
112        // with "error sending request".
113        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    /// Operator opt-in for a USB or lab link that cannot be pinned (plain
141    /// http, or https verified only by WebPKI). Off by default: without it
142    /// a Seed on this link cannot be bound or placed on.
143    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        // Errors are rendered without the request, which carries the token.
186        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        // Read chunk by chunk so the cap bounds memory (a body without a
199        // Content-Length, or one that lies about it, is cut off at the cap).
200        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
217/// Where per-Seed tokens live.
218pub trait SeedCredentials: Send + Sync {
219    /// Token for `node_id`.
220    fn get(&self, node_id: &str) -> Result<SecretString, RuntimeError>;
221    /// Store a token for `node_id`.
222    fn put(&self, node_id: &str, token: SecretString) -> Result<(), RuntimeError>;
223}