use std::borrow::Cow;
use std::fmt::Debug;
use std::time::Duration;
use super::http_targets::HomeserverFeatures;
use crate::{cross_log, errors::BuildError};
pub(crate) const DEFAULT_MAX_ERROR_BODY_BYTES: usize = 4096;
const DEFAULT_USER_AGENT: &str = concat!("pubky.org", "@", env!("CARGO_PKG_VERSION"),);
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, Default)]
struct NativeHttpConfig {
request_timeout: Option<Duration>,
read_timeout: Option<Duration>,
pool_max_idle_per_host: Option<usize>,
extra_trust_anchors: Vec<rustls::pki_types::TrustAnchor<'static>>,
}
#[derive(Debug, Clone)]
#[must_use]
#[derive(Default)]
pub struct PubkyHttpClientBuilder {
pkarr: pkarr::ClientBuilder,
user_agent_extra: Option<String>,
max_error_body_bytes: Option<usize>,
#[cfg(not(target_arch = "wasm32"))]
native_http: NativeHttpConfig,
#[cfg(target_arch = "wasm32")]
testnet_host: Option<String>,
}
impl PubkyHttpClientBuilder {
#[cfg(test)]
pub(crate) fn isolated_pkarr_test(&mut self) -> &mut Self {
self.pkarr
.no_default_network()
.bootstrap(&["127.0.0.1:1"])
.dht_report_policy(pkarr::dht::ReportPolicy::testnet());
self
}
pub fn testnet(&mut self) -> &mut Self {
self.testnet_with_host("localhost")
}
#[cfg_attr(
target_arch = "wasm32",
allow(
clippy::semicolon_outside_block,
clippy::unnecessary_operation,
clippy::semicolon_if_nothing_returned,
reason = "WASM-only block preserves conditional assignment formatting"
)
)]
pub fn testnet_with_host(&mut self, host: &str) -> &mut Self {
cross_log!(info, "Configuring testnet builders for host {host}");
#[cfg(not(target_arch = "wasm32"))]
self.pkarr
.bootstrap(&[format!(
"{}:{}",
host,
pubky_common::constants::testnet_ports::BOOTSTRAP
)])
.dht_report_policy(pkarr::dht::ReportPolicy::testnet());
self.pkarr
.relays(&[format!(
"http://{}:{}",
host,
pubky_common::constants::testnet_ports::PKARR_RELAY
)])
.expect("relays urls infallible");
#[cfg(target_arch = "wasm32")]
{
self.testnet_host = Some(host.to_string());
}
self
}
pub fn pkarr<F>(&mut self, f: F) -> &mut Self
where
F: FnOnce(&mut pkarr::ClientBuilder) -> &mut pkarr::ClientBuilder,
{
f(&mut self.pkarr);
self
}
pub fn user_agent_extra<S: Into<String>>(&mut self, extra: S) -> &mut Self {
self.user_agent_extra = Some(extra.into());
self
}
pub fn max_error_body_bytes(&mut self, limit: usize) -> &mut Self {
self.max_error_body_bytes = Some(limit);
self
}
pub fn build(&self) -> Result<PubkyHttpClient, BuildError> {
let pkarr = self.pkarr.build()?;
let user_agent = self
.user_agent_extra
.as_deref()
.map(str::trim)
.filter(|extra| !extra.is_empty())
.map_or_else(
|| Cow::Borrowed(DEFAULT_USER_AGENT),
|extra| Cow::Owned(format!("{DEFAULT_USER_AGENT} {extra}")),
);
#[cfg(not(target_arch = "wasm32"))]
cross_log!(
info,
"Building PubkyHttpClient (request_timeout: {:?}, read_timeout: {:?}, user_agent: {}, pool_max_idle_per_host: {:?})",
self.native_http.request_timeout,
self.native_http.read_timeout,
user_agent,
self.native_http.pool_max_idle_per_host
);
#[cfg(target_arch = "wasm32")]
cross_log!(
info,
"Building PubkyHttpClient (user_agent: {})",
user_agent
);
#[cfg(not(target_arch = "wasm32"))]
let mut http_builder =
reqwest::ClientBuilder::from(pkarr.clone()).user_agent(user_agent.as_ref());
#[cfg(target_arch = "wasm32")]
let http_builder = reqwest::Client::builder().user_agent(user_agent.as_ref());
#[cfg(not(target_arch = "wasm32"))]
let mut icann_http_builder = reqwest::Client::builder()
.user_agent(user_agent.as_ref())
.tls_backend_preconfigured(icann_tls_config_without_revocation_check(
&self.native_http.extra_trust_anchors,
));
#[cfg(not(target_arch = "wasm32"))]
if let Some(timeout) = self.native_http.request_timeout {
http_builder = http_builder.timeout(timeout);
icann_http_builder = icann_http_builder.timeout(timeout);
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(timeout) = self.native_http.read_timeout {
http_builder = http_builder.read_timeout(timeout);
icann_http_builder = icann_http_builder.read_timeout(timeout);
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(max) = self.native_http.pool_max_idle_per_host {
http_builder = http_builder.pool_max_idle_per_host(max);
icann_http_builder = icann_http_builder.pool_max_idle_per_host(max);
}
#[cfg(not(target_arch = "wasm32"))]
let features = HomeserverFeatures::new(self.native_http.request_timeout);
#[cfg(target_arch = "wasm32")]
let features = HomeserverFeatures::default();
Ok(PubkyHttpClient {
pkarr,
http: http_builder.build()?,
features,
max_error_body_bytes: self
.max_error_body_bytes
.unwrap_or(DEFAULT_MAX_ERROR_BODY_BYTES),
#[cfg(not(target_arch = "wasm32"))]
icann_http: icann_http_builder.build()?,
#[cfg(not(target_arch = "wasm32"))]
transport: super::http_targets::native::TransportResolver::new(),
#[cfg(target_arch = "wasm32")]
testnet_host: self.testnet_host.clone(),
})
}
}
#[cfg(not(target_arch = "wasm32"))]
impl PubkyHttpClientBuilder {
pub fn add_root_certificates_pem(&mut self, pem: &[u8]) -> Result<&mut Self, BuildError> {
use rustls::pki_types::pem::PemObject;
let mut roots = rustls::RootCertStore::empty();
for certificate in rustls::pki_types::CertificateDer::pem_slice_iter(pem) {
roots.add(certificate?)?;
}
if roots.is_empty() {
return Err(BuildError::EmptyRootCertificates);
}
self.native_http.extra_trust_anchors.extend(roots.roots);
Ok(self)
}
pub fn request_timeout(&mut self, timeout: Duration) -> &mut Self {
self.native_http.request_timeout = Some(timeout);
self
}
pub fn read_timeout(&mut self, timeout: Duration) -> &mut Self {
self.native_http.read_timeout = Some(timeout);
self
}
pub fn pool_max_idle_per_host(&mut self, max: usize) -> &mut Self {
self.native_http.pool_max_idle_per_host = Some(max);
self
}
}
#[cfg(target_arch = "wasm32")]
impl PubkyHttpClientBuilder {
#[deprecated(
note = "HTTP request timeout is not supported on WASM and has never had any effect; use `.pkarr(|p| p.relay_request_timeout(..))` for Pkarr relay timeouts"
)]
pub fn request_timeout(&mut self, _timeout: Duration) -> &mut Self {
self
}
#[deprecated(note = "HTTP read timeout is not supported on WASM")]
pub fn read_timeout(&mut self, _timeout: Duration) -> &mut Self {
self
}
}
#[cfg(not(target_arch = "wasm32"))]
fn icann_tls_config_without_revocation_check(
extra_roots: &[rustls::pki_types::TrustAnchor<'static>],
) -> rustls::ClientConfig {
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
root_store.extend(extra_roots.iter().cloned());
let mut tls_config = rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(
rustls::crypto::aws_lc_rs::default_provider(),
))
.with_safe_default_protocol_versions()
.expect("aws-lc-rs provides safe default protocol versions")
.with_root_certificates(root_store)
.with_no_client_auth();
tls_config.alpn_protocols = vec![b"http/1.1".to_vec()];
tls_config
}
#[derive(Clone, Debug)]
pub struct PubkyHttpClient {
pub(crate) http: reqwest::Client,
pub(crate) pkarr: pkarr::Client,
pub(crate) features: HomeserverFeatures,
pub(crate) max_error_body_bytes: usize,
#[cfg(not(target_arch = "wasm32"))]
pub(crate) icann_http: reqwest::Client,
#[cfg(not(target_arch = "wasm32"))]
pub(crate) transport: super::http_targets::native::TransportResolver,
#[cfg(target_arch = "wasm32")]
pub(crate) testnet_host: Option<String>,
}
#[doc(hidden)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedFetch {
#[doc(hidden)]
pub pubky_host_header: Option<String>,
#[doc(hidden)]
pub is_pubky_target: bool,
}
impl PubkyHttpClient {
pub fn new() -> Result<Self, BuildError> {
cross_log!(
info,
"Constructing PubkyHttpClient with default configuration"
);
Self::builder().build()
}
pub fn builder() -> PubkyHttpClientBuilder {
PubkyHttpClientBuilder::default()
}
pub fn testnet() -> Result<Self, BuildError> {
cross_log!(
info,
"Constructing PubkyHttpClient configured for local testnet"
);
let mut builder = Self::builder();
builder.testnet();
builder.build()
}
#[must_use]
pub const fn pkarr(&self) -> &pkarr::Client {
&self.pkarr
}
}
#[cfg(test)]
mod test {
use httpmock::MockServer;
use reqwest::{Method, StatusCode};
#[cfg(not(target_arch = "wasm32"))]
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
use super::*;
#[cfg(not(target_arch = "wasm32"))]
const TEST_CA: &[u8] = include_bytes!("test_certs/ca.pem");
#[cfg(not(target_arch = "wasm32"))]
async fn stalling_http_server() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buffer = [0; 1024];
loop {
let read = stream.read(&mut buffer).await.unwrap();
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
if request.ends_with(b"\r\n\r\n") {
break;
}
}
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\nConnection: close\r\n\r\n")
.await
.unwrap();
tokio::time::sleep(Duration::from_secs(1)).await;
});
format!("http://{address}")
}
#[tokio::test]
async fn test_fetch() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method("GET").path("/health");
then.status(200).body("ok");
});
let client = PubkyHttpClient::new().unwrap();
let response = client
.request(Method::GET, &server.url("/health"))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
mock.assert();
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn native_timeouts_are_opt_in() {
let config = NativeHttpConfig::default();
assert_eq!(config.request_timeout, None);
assert_eq!(config.read_timeout, None);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn native_timeout_setters_store_values() {
let request_timeout = Duration::from_secs(30);
let read_timeout = Duration::from_secs(10);
let mut builder = PubkyHttpClient::builder();
builder
.request_timeout(request_timeout)
.read_timeout(read_timeout);
assert_eq!(builder.native_http.request_timeout, Some(request_timeout));
assert_eq!(builder.native_http.read_timeout, Some(read_timeout));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn invalid_root_certificate_bundle_does_not_change_builder() {
let mut builder = PubkyHttpClient::builder();
assert!(matches!(
builder.add_root_certificates_pem(b"no certificates"),
Err(BuildError::EmptyRootCertificates)
));
assert!(builder.native_http.extra_trust_anchors.is_empty());
assert!(matches!(
builder.add_root_certificates_pem(
b"-----BEGIN CERTIFICATE-----\ninvalid!\n-----END CERTIFICATE-----\n"
),
Err(BuildError::RootCertificatePem(_))
));
assert!(builder.native_http.extra_trust_anchors.is_empty());
assert!(matches!(
builder.add_root_certificates_pem(
b"-----BEGIN CERTIFICATE-----\nAA==\n-----END CERTIFICATE-----\n"
),
Err(BuildError::RootCertificate(_))
));
assert!(builder.native_http.extra_trust_anchors.is_empty());
builder.add_root_certificates_pem(TEST_CA).unwrap();
assert_eq!(builder.native_http.extra_trust_anchors.len(), 1);
}
#[cfg(not(target_arch = "wasm32"))]
async fn https_server_with_test_certificate() -> url::Url {
use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
use tokio_rustls::TlsAcceptor;
let server_cert =
CertificateDer::from_pem_slice(include_bytes!("test_certs/server.pem")).unwrap();
let server_key =
PrivateKeyDer::from_pem_slice(include_bytes!("test_certs/server.key")).unwrap();
let server_config = rustls::ServerConfig::builder_with_provider(std::sync::Arc::new(
rustls::crypto::aws_lc_rs::default_provider(),
))
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(vec![server_cert], server_key)
.unwrap();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut stream = TlsAcceptor::from(std::sync::Arc::new(server_config))
.accept(stream)
.await
.unwrap();
let mut request = Vec::new();
let mut buffer = [0; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let read = stream.read(&mut buffer).await.unwrap();
assert!(read > 0, "request ended before its headers");
request.extend_from_slice(&buffer[..read]);
}
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
.await
.unwrap();
});
url::Url::parse(&format!("https://localhost:{}/", address.port())).unwrap()
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn extra_root_certificate_trusts_icann_https_server() {
let url = https_server_with_test_certificate().await;
let mut builder = PubkyHttpClient::builder();
builder.add_root_certificates_pem(TEST_CA).unwrap();
let client = builder.build().unwrap();
let response = client
.request_async(Method::GET, url)
.await
.unwrap()
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text().await.unwrap(), "ok");
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn read_timeout_applies_to_all_native_http_clients() {
let client = PubkyHttpClient::builder()
.read_timeout(Duration::from_millis(50))
.build()
.unwrap();
for http in [&client.http, &client.icann_http] {
let response = http.get(stalling_http_server().await).send().await.unwrap();
let error = tokio::time::timeout(Duration::from_secs(1), response.bytes())
.await
.expect("read should finish with reqwest's timeout")
.expect_err("stalled response body should time out");
assert!(error.is_timeout());
}
}
}