clawft_kernel/licence/
http_transport.rs1use 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
33pub const DEFAULT_MAX_ARTIFACT_BYTES: u64 = 64 * 1024 * 1024;
35
36#[derive(Debug, Clone)]
38pub struct TransportLimits {
39 pub connect_timeout: Duration,
41 pub call_timeout: Duration,
43 pub artifact_timeout: Duration,
46 pub max_body: usize,
48 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#[derive(Debug, Clone)]
66pub struct LicenceLinkConfig {
67 pub url: String,
70 pub tls: SeedTls,
72 pub allow_unpinned_lab_link: bool,
74 pub limits: TransportLimits,
76}
77
78pub 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 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 pub fn link_security(&self) -> LinkSecurity {
121 self.security
122 }
123
124 pub fn base(&self) -> &str {
126 &self.base
127 }
128}
129
130fn 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}