use std::time::Duration;
use async_trait::async_trait;
use super::client::{
ARTIFACT_PATH, LicenceClientError, LicenceResponse, LicenceTransport, MAX_RESPONSE_BODY,
};
use super::request::LicenceRequest;
use crate::workload_runtime::LinkSecurity;
use crate::workload_runtime::seed_http::validate_base_url;
use crate::workload_runtime::seed_tls::SeedTls;
pub const DEFAULT_MAX_ARTIFACT_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct TransportLimits {
pub connect_timeout: Duration,
pub call_timeout: Duration,
pub artifact_timeout: Duration,
pub max_body: usize,
pub max_artifact: u64,
}
impl Default for TransportLimits {
fn default() -> Self {
Self {
connect_timeout: Duration::from_secs(5),
call_timeout: Duration::from_secs(30),
artifact_timeout: Duration::from_secs(120),
max_body: MAX_RESPONSE_BODY,
max_artifact: DEFAULT_MAX_ARTIFACT_BYTES,
}
}
}
#[derive(Debug, Clone)]
pub struct LicenceLinkConfig {
pub url: String,
pub tls: SeedTls,
pub allow_unpinned_lab_link: bool,
pub limits: TransportLimits,
}
pub struct HttpLicenceTransport {
base: String,
client: reqwest::Client,
security: LinkSecurity,
limits: TransportLimits,
}
fn link_err(e: impl std::fmt::Display) -> LicenceClientError {
LicenceClientError::Transport(e.to_string())
}
impl HttpLicenceTransport {
pub fn new(cfg: LicenceLinkConfig) -> Result<Self, LicenceClientError> {
let base = validate_base_url(&cfg.url).map_err(link_err)?;
let pinned = matches!(cfg.tls, SeedTls::PinnedSha256(_) | SeedTls::PinnedSpki(_));
let mut builder = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.pool_max_idle_per_host(0)
.connect_timeout(cfg.limits.connect_timeout);
if pinned {
if !base.starts_with("https://") {
return Err(link_err("a weft-licence TLS pin needs an https:// URL"));
}
let tls = cfg.tls.client_config().ok_or_else(|| link_err("tls config for the pinned link"))?;
builder = builder.use_preconfigured_tls(tls);
}
let security = if pinned {
LinkSecurity::Pinned
} else if cfg.allow_unpinned_lab_link {
LinkSecurity::LabOptIn
} else {
LinkSecurity::Unpinned
};
security.require_pinned("weft-licence").map_err(link_err)?;
let client = builder.build().map_err(|e| link_err(format!("http client: {e}")))?;
Ok(Self { base, client, security, limits: cfg.limits })
}
pub fn link_security(&self) -> LinkSecurity {
self.security
}
pub fn base(&self) -> &str {
&self.base
}
}
fn valid_path(path: &str) -> bool {
path.starts_with("/licence/v1/")
&& path.len() <= 256
&& !path.contains("..")
&& path.bytes().all(|b| b.is_ascii_alphanumeric() || b"/-_.?=&".contains(&b))
}
#[async_trait]
impl LicenceTransport for HttpLicenceTransport {
async fn call(&self, req: LicenceRequest) -> Result<LicenceResponse, LicenceClientError> {
if !valid_path(&req.path) {
return Err(link_err("refused an unsafe weft-licence path"));
}
let artifact = req.path.starts_with(ARTIFACT_PATH);
let (timeout, cap) = if artifact {
(self.limits.artifact_timeout, usize::try_from(self.limits.max_artifact).unwrap_or(usize::MAX))
} else {
(self.limits.call_timeout, self.limits.max_body)
};
let url = format!("{}{}", self.base, req.path);
let mut b = match req.method.as_str() {
"GET" => self.client.get(&url),
"POST" => self.client.post(&url),
other => return Err(link_err(format!("method {other} is not used by weft-licence"))),
}
.timeout(timeout);
if let Some(a) = &req.auth {
b = b
.header("x-licence-node", &a.node)
.header("x-licence-ts", a.timestamp_ms.to_string())
.header("x-licence-nonce", &a.nonce)
.header("x-licence-sig", &a.signature);
}
if req.method == "POST" {
b = b.header("content-type", "application/json").body(req.body);
}
let mut resp = b.send().await.map_err(|e| link_err(format!("weft-licence: {}", e.without_url())))?;
let status = resp.status().as_u16();
let too_large = || link_err("weft-licence: response too large");
if resp.content_length().is_some_and(|n| n > cap as u64) {
return Err(too_large());
}
let mut body = Vec::new();
while let Some(chunk) =
resp.chunk().await.map_err(|e| link_err(format!("weft-licence: {}", e.without_url())))?
{
if body.len() + chunk.len() > cap {
return Err(too_large());
}
body.extend_from_slice(&chunk);
}
Ok(LicenceResponse { status, body })
}
}
#[cfg(test)]
mod tests {
use super::valid_path;
#[test]
fn only_weft_licence_paths_with_safe_characters_are_sent() {
assert!(valid_path("/licence/v1/checkout"));
assert!(valid_path("/licence/v1/grants?since=3"));
assert!(valid_path(&format!("/licence/v1/artifact/{}", "a".repeat(64))));
for p in ["/api/v1/identity", "/licence/v1/../x", "/licence/v1/a b", "/licence/v1/%2e", "licence/v1/x"] {
assert!(!valid_path(p), "{p}");
}
assert!(!valid_path(&format!("/licence/v1/{}", "a".repeat(300))));
}
}