use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use super::config::{EgressConfig, ProxyAuth, ProxyMode};
use super::credentials::{self, ProxyCredentialFile};
const PROBE_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
const PROBE_READ_TIMEOUT: Duration = Duration::from_secs(3);
const PROBE_MAX_HEAD: usize = 8 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProbeOutcome {
Skipped,
Open,
Challenged(Vec<String>),
Undetermined,
}
#[derive(Debug, Clone)]
pub struct ResolvedAuth {
pub scheme: ProxyAuth,
pub probe: ProbeOutcome,
pub password_source: Option<credentials::PasswordSource>,
pub warning: Option<String>,
}
pub async fn resolve_auth(
mut cfg: EgressConfig,
api_url: Option<&str>,
credentials_file: Option<&ProxyCredentialFile>,
) -> EgressConfig {
if cfg.mode == ProxyMode::Direct || cfg.url.is_none() {
cfg.resolved = Some(ResolvedAuth {
scheme: if cfg.auth == ProxyAuth::Auto {
ProxyAuth::None
} else {
cfg.auth
},
probe: ProbeOutcome::Skipped,
password_source: None,
warning: None,
});
if cfg.auth == ProxyAuth::Auto {
cfg.auth = ProxyAuth::None;
}
return cfg;
}
let url = cfg.url.clone().unwrap_or_default();
let scheme = url
.split_once("://")
.map(|(s, _)| s.to_ascii_lowercase())
.unwrap_or_default();
let (password, password_source) = match cfg.proxy_authority() {
Some(authority) => {
match credentials::resolve_password(
&authority,
cfg.env_password.as_deref(),
credentials_file,
)
.await
{
Some((secret, source)) => {
use secrecy::ExposeSecret;
(Some(secret.expose_secret().to_string()), Some(source))
}
None => (None, None),
}
}
None => (None, None),
};
cfg.resolved_password = password;
let have_credentials = cfg.proxy_password().is_some() && cfg.username.is_some();
if cfg.auth != ProxyAuth::Auto {
cfg.resolved = Some(ResolvedAuth {
scheme: cfg.auth,
probe: ProbeOutcome::Skipped,
password_source,
warning: None,
});
return cfg;
}
if scheme.starts_with("socks") {
let chosen = if have_credentials {
ProxyAuth::Basic
} else {
ProxyAuth::None
};
cfg.auth = chosen;
cfg.resolved = Some(ResolvedAuth {
scheme: chosen,
probe: ProbeOutcome::Skipped,
password_source,
warning: None,
});
return cfg;
}
let outcome = probe(&url, &scheme, api_url).await;
let (chosen, warning) = decide(&outcome, have_credentials, &cfg).await;
cfg.auth = chosen;
cfg.resolved = Some(ResolvedAuth {
scheme: chosen,
probe: outcome,
password_source,
warning,
});
cfg
}
async fn decide(
outcome: &ProbeOutcome,
have_credentials: bool,
cfg: &EgressConfig,
) -> (ProxyAuth, Option<String>) {
match outcome {
ProbeOutcome::Open => (ProxyAuth::None, None),
ProbeOutcome::Challenged(schemes) => {
let offers_negotiate = schemes.iter().any(|s| s == "negotiate");
let offers_basic = schemes.iter().any(|s| s == "basic");
if offers_negotiate {
match negotiate_viability(cfg).await {
Ok(()) => return (ProxyAuth::Negotiate, None),
Err(why) if offers_basic && have_credentials => {
return (
ProxyAuth::Basic,
Some(format!(
"the proxy offers Negotiate but this host cannot use it \
({why}); falling back to Basic"
)),
)
}
Err(why) => {
return (
ProxyAuth::None,
Some(format!(
"the proxy offers Negotiate and this host cannot use it \
({why}); no usable scheme remains"
)),
)
}
}
}
if offers_basic {
return if have_credentials {
(ProxyAuth::Basic, None)
} else {
(
ProxyAuth::Basic,
Some(
"the proxy asks for Basic and no credential is stored for this \
authority; run 'openlatch proxy set'"
.to_string(),
),
)
};
}
(
ProxyAuth::None,
Some(format!(
"the proxy offers only {} — none of which this client speaks (NTLM is \
deliberately not supported)",
schemes.join(", ")
)),
)
}
ProbeOutcome::Undetermined => (
if have_credentials {
ProxyAuth::Basic
} else {
ProxyAuth::None
},
Some(
"could not determine the proxy's authentication scheme; continuing with \
whatever credentials are configured"
.to_string(),
),
),
ProbeOutcome::Skipped => (ProxyAuth::None, None),
}
}
#[cfg(feature = "proxy-negotiate")]
async fn negotiate_viability(cfg: &EgressConfig) -> Result<(), String> {
use super::negotiate::{platform_provider, StepResult};
let spn = cfg
.spn
.clone()
.or_else(|| {
cfg.proxy_authority().map(|a| {
format!(
"HTTP/{}",
a.rsplit_once(':').map_or(a.clone(), |(h, _)| h.to_string())
)
})
})
.unwrap_or_else(|| "HTTP/localhost".to_string());
tokio::task::spawn_blocking(move || {
let factory = platform_provider().map_err(|e| e.to_string())?;
let mut context = factory.new_provider(&spn).map_err(|e| e.to_string())?;
match context.step(None) {
StepResult::Continue(token) | StepResult::Done(Some(token)) if !token.is_empty() => {
Ok(())
}
StepResult::Done(_) => Err("the security context produced no token".to_string()),
StepResult::Failed(e) => Err(e.to_string()),
StepResult::Continue(_) => {
Err("the security context produced an empty token".to_string())
}
}
})
.await
.unwrap_or_else(|e| Err(format!("the credential probe panicked: {e}")))
}
#[cfg(not(feature = "proxy-negotiate"))]
async fn negotiate_viability(_cfg: &EgressConfig) -> Result<(), String> {
Err("this build does not include the proxy-negotiate feature".to_string())
}
async fn probe(url: &str, scheme: &str, api_url: Option<&str>) -> ProbeOutcome {
let Some((host, port)) = proxy_host_port(url, scheme) else {
return ProbeOutcome::Undetermined;
};
let target = api_url
.and_then(target_authority)
.unwrap_or_else(|| "app.openlatch.ai:443".to_string());
if scheme == "https" && !cfg!(feature = "proxy-negotiate") {
return ProbeOutcome::Undetermined;
}
let Ok(Ok(mut stream)) = tokio::time::timeout(
PROBE_CONNECT_TIMEOUT,
TcpStream::connect((host.as_str(), port)),
)
.await
else {
return ProbeOutcome::Undetermined;
};
#[cfg(feature = "proxy-negotiate")]
if scheme == "https" {
return probe_over_tls(stream, &host, &target).await;
}
match probe_exchange(&mut stream, &target).await {
Some(outcome) => outcome,
None => ProbeOutcome::Undetermined,
}
}
#[cfg(feature = "proxy-negotiate")]
async fn probe_over_tls(stream: TcpStream, host: &str, target: &str) -> ProbeOutcome {
let Ok(setup) = super::negotiate::TlsSetup::new(&EgressConfig::direct()) else {
return ProbeOutcome::Undetermined;
};
let Ok(mut tls) = setup.connect_proxy(host, stream).await else {
return ProbeOutcome::Undetermined;
};
probe_exchange(&mut tls, target)
.await
.unwrap_or(ProbeOutcome::Undetermined)
}
async fn probe_exchange<S>(stream: &mut S, target: &str) -> Option<ProbeOutcome>
where
S: AsyncReadExt + AsyncWriteExt + Unpin,
{
let request =
format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\nProxy-Connection: close\r\n\r\n");
stream.write_all(request.as_bytes()).await.ok()?;
stream.flush().await.ok()?;
let head = tokio::time::timeout(PROBE_READ_TIMEOUT, read_head(stream))
.await
.ok()??;
let mut headers = [httparse::EMPTY_HEADER; 32];
let mut response = httparse::Response::new(&mut headers);
response.parse(&head).ok()?;
let status = response.code?;
Some(match status {
200 => ProbeOutcome::Open,
407 => ProbeOutcome::Challenged(offered_schemes(&head)),
_ => ProbeOutcome::Undetermined,
})
}
async fn read_head<S>(stream: &mut S) -> Option<Vec<u8>>
where
S: AsyncReadExt + Unpin,
{
let mut head = Vec::with_capacity(256);
let mut byte = [0u8; 1];
loop {
match stream.read(&mut byte).await {
Ok(0) | Err(_) => return None,
Ok(_) => {}
}
head.push(byte[0]);
if head.ends_with(b"\r\n\r\n") || head.ends_with(b"\n\n") {
return Some(head);
}
if head.len() >= PROBE_MAX_HEAD {
return None;
}
}
}
fn offered_schemes(head: &[u8]) -> Vec<String> {
String::from_utf8_lossy(head)
.lines()
.skip(1)
.filter_map(|line| {
let (k, v) = line.split_once(':')?;
if !k.trim().eq_ignore_ascii_case("proxy-authenticate") {
return None;
}
v.split_whitespace().next().map(|s| s.to_ascii_lowercase())
})
.collect()
}
fn proxy_host_port(url: &str, scheme: &str) -> Option<(String, u16)> {
let authority = credentials::authority_key(url)?;
let (host, port) = authority.rsplit_once(':')?;
let port = port.parse().ok().or(match scheme {
"https" => Some(443),
"http" => Some(80),
_ => None,
})?;
Some((host.to_string(), port))
}
fn target_authority(api_url: &str) -> Option<String> {
credentials::authority_key(api_url)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::egress::config::{EnvSource, ProxyToml};
use std::collections::HashMap;
struct MapEnv(HashMap<String, String>);
impl EnvSource for MapEnv {
fn var(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
}
fn empty_env() -> MapEnv {
MapEnv(HashMap::new())
}
fn cfg(toml: ProxyToml) -> EgressConfig {
EgressConfig::resolve(Some(&toml), &empty_env(), 7443, 7444).expect("resolve")
}
#[tokio::test]
async fn a_direct_configuration_is_resolved_without_a_probe() {
let resolved = resolve_auth(EgressConfig::direct(), None, None).await;
let auth = resolved.resolved.expect("a resolution is always recorded");
assert_eq!(auth.probe, ProbeOutcome::Skipped);
assert_eq!(auth.scheme, ProxyAuth::None);
assert!(auth.warning.is_none());
}
#[tokio::test]
async fn an_explicit_scheme_skips_the_probe_entirely() {
for (spelling, expected) in [
("basic", ProxyAuth::Basic),
("negotiate", ProxyAuth::Negotiate),
("none", ProxyAuth::None),
] {
let resolved = resolve_auth(
cfg(ProxyToml {
url: Some("http://127.0.0.1:1".into()),
auth: Some(spelling.into()),
..Default::default()
}),
None,
None,
)
.await;
let auth = resolved.resolved.expect("resolution");
assert_eq!(auth.probe, ProbeOutcome::Skipped, "{spelling}");
assert_eq!(auth.scheme, expected, "{spelling}");
}
}
#[tokio::test]
async fn a_socks_route_is_never_probed() {
let resolved = resolve_auth(
cfg(ProxyToml {
url: Some("socks5://127.0.0.1:1".into()),
..Default::default()
}),
None,
None,
)
.await;
let auth = resolved.resolved.expect("resolution");
assert_eq!(auth.probe, ProbeOutcome::Skipped);
assert_ne!(auth.scheme, ProxyAuth::Negotiate);
}
#[tokio::test]
async fn a_dead_proxy_yields_a_warning_and_a_buildable_client() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("bind");
let port = listener.local_addr().expect("addr").port();
drop(listener);
let resolved = resolve_auth(
cfg(ProxyToml {
url: Some(format!("http://127.0.0.1:{port}")),
..Default::default()
}),
Some("https://app.openlatch.ai"),
None,
)
.await;
let auth = resolved.resolved.clone().expect("resolution");
assert_eq!(auth.probe, ProbeOutcome::Undetermined);
assert!(auth.warning.is_some(), "a degradation must be reported");
assert_ne!(resolved.auth, ProxyAuth::Auto, "auto must be concretized");
super::super::build_client(super::super::Consumer::Auth, &resolved)
.expect("a probe failure must never stop a client from being built");
}
#[test]
fn the_probe_target_is_the_configured_api_url_not_a_hard_coded_443() {
assert_eq!(
target_authority("https://app.openlatch.ai").as_deref(),
Some("app.openlatch.ai:443")
);
assert_eq!(
target_authority("http://localhost:8080").as_deref(),
Some("localhost:8080")
);
assert_eq!(
target_authority("https://platform.internal:8443").as_deref(),
Some("platform.internal:8443")
);
}
#[test]
fn the_offered_schemes_are_read_from_every_challenge_header() {
let head = b"HTTP/1.1 407 Proxy Authentication Required\r\n\
Proxy-Authenticate: Negotiate\r\n\
Proxy-Authenticate: NTLM\r\n\
Proxy-Authenticate: Basic realm=\"corp\"\r\n\r\n";
assert_eq!(offered_schemes(head), vec!["negotiate", "ntlm", "basic"]);
}
#[tokio::test]
async fn an_open_proxy_resolves_to_no_credentials() {
let (scheme, warning) = decide(&ProbeOutcome::Open, true, &EgressConfig::direct()).await;
assert_eq!(scheme, ProxyAuth::None);
assert!(warning.is_none());
}
#[tokio::test]
async fn basic_is_chosen_when_offered_and_a_credential_exists() {
let offered = ProbeOutcome::Challenged(vec!["basic".into()]);
let (scheme, warning) = decide(&offered, true, &EgressConfig::direct()).await;
assert_eq!(scheme, ProxyAuth::Basic);
assert!(warning.is_none());
let (scheme, warning) = decide(&offered, false, &EgressConfig::direct()).await;
assert_eq!(scheme, ProxyAuth::Basic);
assert!(warning.is_some_and(|w| w.contains("proxy set")));
}
#[tokio::test]
async fn an_unviable_negotiate_falls_through_to_basic_rather_than_failing() {
let offered = ProbeOutcome::Challenged(vec!["negotiate".into(), "basic".into()]);
let (scheme, warning) = decide(&offered, true, &EgressConfig::direct()).await;
assert!(
matches!(scheme, ProxyAuth::Negotiate | ProxyAuth::Basic),
"expected a usable scheme, got {scheme:?}"
);
if scheme == ProxyAuth::Basic {
assert!(warning.is_some_and(|w| w.contains("falling back to Basic")));
}
}
#[tokio::test]
async fn an_ntlm_only_challenge_resolves_to_no_scheme_and_names_why() {
let offered = ProbeOutcome::Challenged(vec!["ntlm".into()]);
let (scheme, warning) = decide(&offered, true, &EgressConfig::direct()).await;
assert_eq!(scheme, ProxyAuth::None);
let warning = warning.expect("a refusal must be explained");
assert!(warning.contains("ntlm"), "{warning}");
assert!(
warning.contains("NTLM is deliberately not supported"),
"{warning}"
);
}
#[tokio::test]
async fn an_undetermined_probe_uses_whatever_credentials_exist() {
let (with, _) = decide(&ProbeOutcome::Undetermined, true, &EgressConfig::direct()).await;
assert_eq!(with, ProxyAuth::Basic);
let (without, warning) =
decide(&ProbeOutcome::Undetermined, false, &EgressConfig::direct()).await;
assert_eq!(without, ProxyAuth::None);
assert!(warning.is_some());
}
}