Skip to main content

clawft_kernel/licence/
http_transport.rs

1//! The production steward to `weft-licence` transport (ADR-106 sections 4
2//! and 7, phase 3).
3//!
4//! One signed request in, one status and body out, over HTTP:
5//!
6//! - **Link rules.** A link is built only when it is pinned (`https://` with
7//!   a certificate or SubjectPublicKeyInfo pin) or when the operator set the
8//!   explicit lab opt-in for it (`allow_unpinned_lab_link`, read from the
9//!   runtime dir). `weft-licence` serves plain HTTP on the USB link-local and
10//!   tailnet interfaces only, so until it serves TLS (W4) a real deployment
11//!   runs on that opt-in, and the confidentiality of the bytes comes from the
12//!   link (WireGuard or the cable). Integrity never depends on it: requests
13//!   and grants are signed and the bytes are checked against signed hashes.
14//! - **Bounds.** Connect, call and artifact timeouts; every response body is
15//!   read chunk by chunk under a cap (256 KiB, or the artifact cap for the
16//!   artifact path), so a lying or endless body cannot grow memory.
17//! - **Fail closed.** No redirects, no proxy, no pooled idle connections, and
18//!   only `/licence/v1/` paths with safe characters. Every failure is a
19//!   [`LicenceClientError::Transport`]; nothing is retried here.
20
21use std::time::Duration;
22
23use async_trait::async_trait;
24
25use super::client::{
26    ARTIFACT_PATH, LicenceClientError, LicenceResponse, LicenceTransport, MAX_RESPONSE_BODY,
27};
28use super::request::LicenceRequest;
29use crate::workload_runtime::LinkSecurity;
30use crate::workload_runtime::seed_http::validate_base_url;
31use crate::workload_runtime::seed_tls::SeedTls;
32
33/// Default largest artifact accepted (the `weft-licence` default limit).
34pub const DEFAULT_MAX_ARTIFACT_BYTES: u64 = 64 * 1024 * 1024;
35
36/// Timeouts and caps of one link.
37#[derive(Debug, Clone)]
38pub struct TransportLimits {
39    /// TCP and TLS connect.
40    pub connect_timeout: Duration,
41    /// Whole call, for every path but the artifact one.
42    pub call_timeout: Duration,
43    /// Whole call for `GET /licence/v1/artifact/..` (64 MiB at the Seed's
44    /// 4 MiB/s takes 16 s).
45    pub artifact_timeout: Duration,
46    /// Largest body read for every path but the artifact one.
47    pub max_body: usize,
48    /// Largest artifact body read.
49    pub max_artifact: u64,
50}
51
52impl Default for TransportLimits {
53    fn default() -> Self {
54        Self {
55            connect_timeout: Duration::from_secs(5),
56            call_timeout: Duration::from_secs(30),
57            artifact_timeout: Duration::from_secs(120),
58            max_body: MAX_RESPONSE_BODY,
59            max_artifact: DEFAULT_MAX_ARTIFACT_BYTES,
60        }
61    }
62}
63
64/// Where `weft-licence` is and how the link is trusted.
65#[derive(Debug, Clone)]
66pub struct LicenceLinkConfig {
67    /// `http(s)://host[:port]`, nothing else (address the Seed by IP: it
68    /// checks the `Host` header against its listen addresses).
69    pub url: String,
70    /// TLS trust for an `https://` link; a pin makes the link `Pinned`.
71    pub tls: SeedTls,
72    /// The operator's explicit opt-in for an unpinned (plain http) link.
73    pub allow_unpinned_lab_link: bool,
74    /// Timeouts and caps.
75    pub limits: TransportLimits,
76}
77
78/// [`LicenceTransport`] over HTTP.
79pub struct HttpLicenceTransport {
80    base: String,
81    client: reqwest::Client,
82    security: LinkSecurity,
83    limits: TransportLimits,
84}
85
86fn link_err(e: impl std::fmt::Display) -> LicenceClientError {
87    LicenceClientError::Transport(e.to_string())
88}
89
90impl HttpLicenceTransport {
91    /// A transport for `cfg`. Refused unless the link is pinned or opted in.
92    pub fn new(cfg: LicenceLinkConfig) -> Result<Self, LicenceClientError> {
93        let base = validate_base_url(&cfg.url).map_err(link_err)?;
94        let pinned = matches!(cfg.tls, SeedTls::PinnedSha256(_) | SeedTls::PinnedSpki(_));
95        let mut builder = reqwest::Client::builder()
96            .redirect(reqwest::redirect::Policy::none())
97            .no_proxy()
98            .pool_max_idle_per_host(0)
99            .connect_timeout(cfg.limits.connect_timeout);
100        if pinned {
101            if !base.starts_with("https://") {
102                return Err(link_err("a weft-licence TLS pin needs an https:// URL"));
103            }
104            let tls = cfg.tls.client_config().ok_or_else(|| link_err("tls config for the pinned link"))?;
105            builder = builder.use_preconfigured_tls(tls);
106        }
107        let security = if pinned {
108            LinkSecurity::Pinned
109        } else if cfg.allow_unpinned_lab_link {
110            LinkSecurity::LabOptIn
111        } else {
112            LinkSecurity::Unpinned
113        };
114        security.require_pinned("weft-licence").map_err(link_err)?;
115        let client = builder.build().map_err(|e| link_err(format!("http client: {e}")))?;
116        Ok(Self { base, client, security, limits: cfg.limits })
117    }
118
119    /// How this link authenticates `weft-licence`.
120    pub fn link_security(&self) -> LinkSecurity {
121        self.security
122    }
123
124    /// The base URL.
125    pub fn base(&self) -> &str {
126        &self.base
127    }
128}
129
130/// Only `weft-licence` paths, short, with no traversal and safe characters.
131fn valid_path(path: &str) -> bool {
132    path.starts_with("/licence/v1/")
133        && path.len() <= 256
134        && !path.contains("..")
135        && path.bytes().all(|b| b.is_ascii_alphanumeric() || b"/-_.?=&".contains(&b))
136}
137
138#[async_trait]
139impl LicenceTransport for HttpLicenceTransport {
140    async fn call(&self, req: LicenceRequest) -> Result<LicenceResponse, LicenceClientError> {
141        if !valid_path(&req.path) {
142            return Err(link_err("refused an unsafe weft-licence path"));
143        }
144        let artifact = req.path.starts_with(ARTIFACT_PATH);
145        let (timeout, cap) = if artifact {
146            (self.limits.artifact_timeout, usize::try_from(self.limits.max_artifact).unwrap_or(usize::MAX))
147        } else {
148            (self.limits.call_timeout, self.limits.max_body)
149        };
150        let url = format!("{}{}", self.base, req.path);
151        let mut b = match req.method.as_str() {
152            "GET" => self.client.get(&url),
153            "POST" => self.client.post(&url),
154            other => return Err(link_err(format!("method {other} is not used by weft-licence"))),
155        }
156        .timeout(timeout);
157        if let Some(a) = &req.auth {
158            b = b
159                .header("x-licence-node", &a.node)
160                .header("x-licence-ts", a.timestamp_ms.to_string())
161                .header("x-licence-nonce", &a.nonce)
162                .header("x-licence-sig", &a.signature);
163        }
164        if req.method == "POST" {
165            b = b.header("content-type", "application/json").body(req.body);
166        }
167        let mut resp = b.send().await.map_err(|e| link_err(format!("weft-licence: {}", e.without_url())))?;
168        let status = resp.status().as_u16();
169        let too_large = || link_err("weft-licence: response too large");
170        if resp.content_length().is_some_and(|n| n > cap as u64) {
171            return Err(too_large());
172        }
173        let mut body = Vec::new();
174        while let Some(chunk) =
175            resp.chunk().await.map_err(|e| link_err(format!("weft-licence: {}", e.without_url())))?
176        {
177            if body.len() + chunk.len() > cap {
178                return Err(too_large());
179            }
180            body.extend_from_slice(&chunk);
181        }
182        Ok(LicenceResponse { status, body })
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::valid_path;
189
190    #[test]
191    fn only_weft_licence_paths_with_safe_characters_are_sent() {
192        assert!(valid_path("/licence/v1/checkout"));
193        assert!(valid_path("/licence/v1/grants?since=3"));
194        assert!(valid_path(&format!("/licence/v1/artifact/{}", "a".repeat(64))));
195        for p in ["/api/v1/identity", "/licence/v1/../x", "/licence/v1/a b", "/licence/v1/%2e", "licence/v1/x"] {
196            assert!(!valid_path(p), "{p}");
197        }
198        assert!(!valid_path(&format!("/licence/v1/{}", "a".repeat(300))));
199    }
200}