use std::time::Duration;
use crate::core::error::{OlError, ERR_DIRECT_FORBIDDEN, ERR_PROXY_SCHEME_UNSUPPORTED};
use super::config::{EgressConfig, ProxyAuth, ProxyMode};
use super::{blocking_client_builder, client_builder, tls};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Consumer {
CloudWorker,
PolicyPoller,
Alerts,
Auth,
StatusProbe,
UpdateCheck,
UpdateDownload,
Telemetry,
Boundary,
}
struct Preset {
connect: Option<Duration>,
total: Option<Duration>,
pool_idle: Option<usize>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Timeouts {
pub connect: Option<Duration>,
pub total: Option<Duration>,
}
impl Timeouts {
pub fn total(d: Duration) -> Self {
Self {
connect: None,
total: Some(d),
}
}
}
impl Consumer {
fn preset(self) -> Preset {
match self {
Self::Boundary => Preset {
connect: Some(Duration::from_secs(10)),
total: None,
pool_idle: Some(8),
},
Self::CloudWorker => Preset {
connect: None,
total: None,
pool_idle: Some(4),
},
Self::Auth => Preset {
connect: None,
total: Some(Duration::from_secs(10)),
pool_idle: None,
},
Self::StatusProbe => Preset {
connect: None,
total: Some(Duration::from_secs(3)),
pool_idle: None,
},
Self::UpdateCheck => Preset {
connect: None,
total: Some(Duration::from_secs(5)),
pool_idle: None,
},
Self::PolicyPoller | Self::Alerts | Self::UpdateDownload | Self::Telemetry => Preset {
connect: None,
total: None,
pool_idle: None,
},
}
}
}
pub(super) fn effective_route(cfg: &EgressConfig) -> Result<Option<String>, OlError> {
if cfg.mode == ProxyMode::Direct {
return Ok(None);
}
match cfg.url.as_deref() {
Some(u) => Ok(Some(u.to_string())),
None if cfg.allow_direct => Ok(None),
None => Err(OlError::new(
ERR_DIRECT_FORBIDDEN,
"no proxy is configured, and [proxy] allow_direct = false forbids going direct",
)
.with_suggestion(
"Set [proxy] url, or allow a direct connection with allow_direct = true.",
)),
}
}
fn proxy_url_with_auth(cfg: &EgressConfig, route: &str) -> String {
if !matches!(cfg.auth, ProxyAuth::Basic | ProxyAuth::Auto) {
return route.to_string();
}
let (Some(user), Some(pass)) = (cfg.username.as_deref(), cfg.proxy_password()) else {
return route.to_string();
};
match route.split_once("://") {
Some((scheme, rest)) => format!(
"{scheme}://{}:{}@{rest}",
percent_encode_userinfo(user),
percent_encode_userinfo(pass)
),
None => route.to_string(),
}
}
fn percent_encode_userinfo(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char);
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn build_proxy(cfg: &EgressConfig) -> Result<Option<reqwest::Proxy>, OlError> {
if let Some(binding) = super::discovery::pac_binding(cfg) {
if !cfg.allow_direct {
return Err(OlError::new(
ERR_DIRECT_FORBIDDEN,
"a PAC-sourced proxy cannot guarantee allow_direct = false: a PAC may answer DIRECT, and a failed evaluation has no route to fall back to",
)
.with_suggestion(
"Set [proxy] url to the proxy the PAC returns for this host, or set allow_direct = true.",
));
}
let matcher = cfg.no_proxy.clone();
return Ok(Some(reqwest::Proxy::custom(move |dst| {
let host = dst.host_str()?;
let port = dst.port_or_known_default()?;
if matcher.matches(host, port) {
return None;
}
super::discovery::pac_route_for(dst, &binding)
.ok()
.flatten()
})));
}
let Some(route) = effective_route(cfg)? else {
return Ok(None);
};
let target = proxy_url_with_auth(cfg, &route);
let matcher = cfg.no_proxy.clone();
Ok(Some(reqwest::Proxy::custom(move |dst| {
let host = dst.host_str()?;
let port = dst.port_or_known_default()?;
if matcher.matches(host, port) {
return None;
}
target.parse::<reqwest::Url>().ok()
})))
}
fn check_scheme(cfg: &EgressConfig) -> Result<(), OlError> {
if cfg.auth == ProxyAuth::Negotiate && !cfg!(feature = "proxy-negotiate") {
return Err(OlError::new(
ERR_PROXY_SCHEME_UNSUPPORTED,
"[proxy] auth = negotiate needs a build with the proxy-negotiate feature",
)
.with_suggestion(
"Release binaries ship the feature; a local build needs \
--features proxy-negotiate.",
));
}
Ok(())
}
fn build_failed(consumer: Consumer, e: reqwest::Error) -> OlError {
OlError::new(
ERR_PROXY_SCHEME_UNSUPPORTED,
format!("could not build the {consumer:?} http client: {e}"),
)
.with_suggestion("Check [proxy] url and ca_bundle in config.toml.")
}
pub fn build_client(consumer: Consumer, cfg: &EgressConfig) -> Result<reqwest::Client, OlError> {
build_client_with(consumer, cfg, Timeouts::default())
}
pub fn build_client_with(
consumer: Consumer,
cfg: &EgressConfig,
timeouts: Timeouts,
) -> Result<reqwest::Client, OlError> {
check_scheme(cfg)?;
let p = consumer.preset();
let mut b = client_builder();
if let Some(d) = timeouts.connect.or(p.connect) {
b = b.connect_timeout(d);
}
if let Some(d) = timeouts.total.or(p.total) {
b = b.timeout(d);
}
if let Some(n) = p.pool_idle {
b = b.pool_max_idle_per_host(n);
}
if cfg.http1_only {
b = b.http1_only();
}
b = tls::apply(b, cfg)?;
b = b.no_proxy();
if let Some(proxy) = build_proxy(cfg)? {
b = b.proxy(proxy);
}
b.build().map_err(|e| build_failed(consumer, e))
}
pub fn build_blocking_client(
consumer: Consumer,
cfg: &EgressConfig,
) -> Result<reqwest::blocking::Client, OlError> {
check_scheme(cfg)?;
let p = consumer.preset();
let mut b = blocking_client_builder();
if let Some(d) = p.connect {
b = b.connect_timeout(d);
}
if let Some(d) = p.total {
b = b.timeout(d);
}
if let Some(n) = p.pool_idle {
b = b.pool_max_idle_per_host(n);
}
if cfg.http1_only {
b = b.http1_only();
}
b = tls::apply_blocking(b, cfg)?;
b = b.no_proxy();
if let Some(proxy) = build_proxy(cfg)? {
b = b.proxy(proxy);
}
b.build().map_err(|e| build_failed(consumer, e))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::egress::config::ProxyToml;
use std::collections::HashMap;
struct MapEnv(HashMap<String, String>);
impl super::super::config::EnvSource for MapEnv {
fn var(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
}
fn empty_env() -> MapEnv {
MapEnv(HashMap::new())
}
fn cfg_with(url: Option<&str>) -> EgressConfig {
let toml = ProxyToml {
url: url.map(str::to_string),
..Default::default()
};
EgressConfig::resolve(Some(&toml), &empty_env(), 7443, 7444).expect("resolve")
}
#[test]
fn every_consumer_builds() {
let cfg = cfg_with(Some("http://proxy.test:8080"));
for c in [
Consumer::CloudWorker,
Consumer::PolicyPoller,
Consumer::Alerts,
Consumer::Auth,
Consumer::UpdateCheck,
Consumer::UpdateDownload,
Consumer::Telemetry,
Consumer::Boundary,
] {
build_client(c, &cfg).unwrap_or_else(|e| panic!("{c:?} failed to build: {e:?}"));
}
build_blocking_client(Consumer::StatusProbe, &cfg).expect("status probe builds");
}
#[test]
fn direct_config_builds_every_consumer_too() {
let cfg = EgressConfig::direct();
for c in [Consumer::Boundary, Consumer::CloudWorker, Consumer::Auth] {
build_client(c, &cfg).unwrap_or_else(|e| panic!("{c:?} failed to build: {e:?}"));
}
}
#[test]
fn the_boundary_preset_keeps_its_header_only_posture() {
let p = Consumer::Boundary.preset();
assert_eq!(p.connect, Some(Duration::from_secs(10)));
assert_eq!(p.total, None, "the boundary must have NO total timeout");
assert_eq!(p.pool_idle, Some(8));
}
#[test]
fn presets_match_the_inventory() {
assert_eq!(Consumer::Auth.preset().total, Some(Duration::from_secs(10)));
assert_eq!(
Consumer::StatusProbe.preset().total,
Some(Duration::from_secs(3))
);
assert_eq!(
Consumer::UpdateCheck.preset().total,
Some(Duration::from_secs(5))
);
assert_eq!(Consumer::CloudWorker.preset().pool_idle, Some(4));
}
#[test]
fn caller_deadlines_override_the_preset_and_nothing_else() {
let cfg = cfg_with(Some("http://proxy.test:8080"));
build_client_with(
Consumer::PolicyPoller,
&cfg,
Timeouts::total(Duration::from_secs(30)),
)
.expect("poller builds with a caller deadline");
assert_eq!(Timeouts::total(Duration::from_secs(30)).connect, None);
assert_eq!(Timeouts::default().total, None);
}
#[test]
fn direct_mode_routes_nowhere() {
let cfg = EgressConfig::direct();
assert_eq!(effective_route(&cfg).expect("route"), None);
}
#[test]
fn allow_direct_false_without_a_proxy_is_refused() {
let toml = ProxyToml {
allow_direct: Some(false),
..Default::default()
};
let cfg = EgressConfig::resolve(Some(&toml), &empty_env(), 7443, 7444).expect("resolve");
let err = effective_route(&cfg).expect_err("must refuse");
assert_eq!(err.code, ERR_DIRECT_FORBIDDEN);
}
#[test]
fn basic_credentials_are_embedded_and_encoded() {
let mut cfg = cfg_with(Some("http://proxy.test:8080"));
cfg.username = Some("dom\\alice".into());
cfg.env_password = Some("p@ss word".into());
let url = proxy_url_with_auth(&cfg, "http://proxy.test:8080");
assert_eq!(url, "http://dom%5Calice:p%40ss%20word@proxy.test:8080");
}
#[test]
fn negotiate_never_embeds_basic_credentials() {
let mut cfg = cfg_with(Some("http://proxy.test:8080"));
cfg.username = Some("alice".into());
cfg.env_password = Some("secret".into());
cfg.auth = ProxyAuth::Negotiate;
assert_eq!(
proxy_url_with_auth(&cfg, "http://proxy.test:8080"),
"http://proxy.test:8080"
);
}
#[test]
fn auth_none_never_embeds_credentials() {
let mut cfg = cfg_with(Some("http://proxy.test:8080"));
cfg.username = Some("alice".into());
cfg.env_password = Some("secret".into());
cfg.auth = ProxyAuth::None;
assert_eq!(
proxy_url_with_auth(&cfg, "http://proxy.test:8080"),
"http://proxy.test:8080"
);
}
#[test]
fn a_pac_sourced_config_installs_a_proxy_closure_instead_of_going_direct() {
let toml = ProxyToml {
source: Some("pac".to_string()),
pac_url: Some("http://wpad.corp/proxy.pac".to_string()),
..Default::default()
};
let cfg = EgressConfig::resolve(Some(&toml), &empty_env(), 7443, 7444).expect("resolve");
assert!(cfg.url.is_none(), "a PAC source must persist no url");
let proxy = build_proxy(&cfg).expect("build");
if crate::core::egress::discovery::native_pac_facility().is_some() {
assert!(
proxy.is_some(),
"a PAC-capable host must get a per-destination closure"
);
} else {
assert!(proxy.is_none());
}
build_client(Consumer::CloudWorker, &cfg).expect("a PAC config still builds a client");
}
#[test]
fn pac_with_allow_direct_false_is_refused_rather_than_leaked() {
let toml = ProxyToml {
source: Some("pac".to_string()),
pac_url: Some("http://wpad.corp/proxy.pac".to_string()),
allow_direct: Some(false),
..Default::default()
};
let cfg = EgressConfig::resolve(Some(&toml), &empty_env(), 7443, 7444).expect("resolve");
let err = build_proxy(&cfg);
if crate::core::egress::discovery::native_pac_facility().is_some() {
let err = err.expect_err("the combination must be refused, not silently allowed");
assert_eq!(err.code, ERR_DIRECT_FORBIDDEN);
assert!(err.suggestion.is_some(), "a refusal owes a remedy");
} else {
assert_eq!(
err.expect_err("no proxy and no direct is still refused")
.code,
ERR_DIRECT_FORBIDDEN
);
}
}
#[test]
fn a_manual_source_is_never_treated_as_a_pac_binding() {
let mut cfg = cfg_with(Some("http://proxy.test:8080"));
cfg.source = Some(crate::core::egress::ProxySource::Manual);
cfg.pac_url = Some("http://wpad.corp/proxy.pac".to_string());
assert!(
crate::core::egress::discovery::pac_binding(&cfg).is_none(),
"a stray pac_url must not hijack a static route"
);
}
#[test]
fn negotiate_without_the_feature_is_named_not_silently_downgraded() {
let mut cfg = cfg_with(Some("http://proxy.test:8080"));
cfg.auth = ProxyAuth::Negotiate;
if cfg!(feature = "proxy-negotiate") {
assert!(check_scheme(&cfg).is_ok());
} else {
let err = check_scheme(&cfg).expect_err("must refuse");
assert_eq!(err.code, ERR_PROXY_SCHEME_UNSUPPORTED);
}
}
}