use std::sync::Once;
use std::time::Duration;
pub mod clients;
pub mod config;
pub mod credentials;
pub mod discovery;
pub mod factory;
pub mod monitor;
#[cfg(feature = "proxy-negotiate")]
pub mod negotiate;
pub mod no_proxy;
pub mod resolve;
pub mod shape_memo;
pub mod state;
pub mod tls;
pub use clients::{ClientHandle, EgressClients, StagedClients};
pub use config::{
EgressConfig, EgressWarning, EnvSource, ProcessEnv, ProxyAuth, ProxyMode, ProxySource,
ProxyToml, PROXY_TOML_KEYS,
};
pub use credentials::{
authority_key, credential_authority, mask_userinfo, resolve_password, PasswordSource,
ProxyCredentialFile, ProxyCredentialStore, PROXY_CREDENTIALS_FILE,
};
pub use discovery::{
discover, pac_route_for, CandidateAttempt, CandidateOutcome, CandidateProbe, Context,
Discovered, HealthProbe, PacAnswer, PacBinding, PacEvaluator, PacFacility, Route,
};
pub use factory::{build_blocking_client, build_client, build_client_with, Consumer, Timeouts};
pub use monitor::{run_egress_monitor, SelfHeal, HEAL_BACKOFF_INITIAL, HEAL_BACKOFF_MAX};
pub use no_proxy::{NoProxyMatcher, HARD_BYPASS};
pub use resolve::{resolve_auth, ProbeOutcome, ResolvedAuth};
pub use shape_memo::{emit_if_changed as emit_proxy_shape_if_changed, ProxyShape};
pub use state::{
mask_text, AuthScheme, EgressReporter, EgressSnapshot, EgressState, EgressStatus, LastError,
ProxyType, FAILURE_THRESHOLD, IDLE_PROBE_SECS,
};
pub use tls::CaSource;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyCandidate {
pub url: String,
pub source: ProxySource,
}
pub trait ProxyResolver: Send + Sync {
fn candidates(&self, target: &str) -> Vec<ProxyCandidate>;
}
static CRYPTO_INIT: Once = Once::new();
pub fn init_crypto() {
CRYPTO_INIT.call_once(|| {
if rustls::crypto::ring::default_provider()
.install_default()
.is_err()
{
tracing::warn!(
"rustls CryptoProvider already installed; ring is not the process default"
);
}
});
}
#[allow(clippy::disallowed_methods)] pub fn client_builder() -> reqwest::ClientBuilder {
init_crypto();
reqwest::Client::builder()
}
#[allow(clippy::disallowed_methods)] pub fn blocking_client_builder() -> reqwest::blocking::ClientBuilder {
init_crypto();
reqwest::blocking::Client::builder()
}
#[allow(clippy::disallowed_methods)] pub fn client() -> reqwest::Client {
init_crypto();
reqwest::Client::new()
}
#[allow(clippy::disallowed_methods)] pub fn blocking_client() -> reqwest::blocking::Client {
init_crypto();
reqwest::blocking::Client::new()
}
pub const STREAM_BUFFERED_AFTER: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamVerdict {
Streaming,
Buffered,
Skipped,
Unclassified,
}
impl StreamVerdict {
pub fn as_str(self) -> &'static str {
match self {
Self::Streaming => "streaming",
Self::Buffered => "buffered",
Self::Skipped => "skipped",
Self::Unclassified => "unclassified",
}
}
}
#[derive(Debug, Clone)]
pub struct StreamProbe {
pub verdict: StreamVerdict,
pub code: Option<&'static str>,
pub first_byte_ms: Option<u64>,
pub detail: Option<String>,
}
impl StreamProbe {
fn verdict_only(verdict: StreamVerdict) -> Self {
Self {
verdict,
code: None,
first_byte_ms: None,
detail: None,
}
}
fn unclassified(detail: impl Into<String>) -> Self {
Self {
verdict: StreamVerdict::Unclassified,
code: None,
first_byte_ms: None,
detail: Some(detail.into()),
}
}
}
pub async fn stream_probe(client: &reqwest::Client, target: Option<&str>) -> StreamProbe {
stream_probe_within(client, target, STREAM_BUFFERED_AFTER).await
}
pub(crate) async fn stream_probe_within(
client: &reqwest::Client,
target: Option<&str>,
buffered_after: Duration,
) -> StreamProbe {
let Some(target) = target else {
return StreamProbe::verdict_only(StreamVerdict::Skipped);
};
let started = std::time::Instant::now();
let mut response = match client
.get(target)
.header(reqwest::header::ACCEPT, "text/event-stream")
.send()
.await
{
Ok(r) => r,
Err(e) => return StreamProbe::unclassified(state::mask_text(&e.to_string())),
};
let status = response.status();
if !status.is_success() {
return StreamProbe::unclassified(format!("upstream answered {status}"));
}
let deadline = tokio::time::Instant::now() + buffered_after;
loop {
match tokio::time::timeout_at(deadline, response.chunk()).await {
Ok(Ok(Some(chunk))) if !chunk.is_empty() => {
return StreamProbe {
verdict: StreamVerdict::Streaming,
code: None,
first_byte_ms: Some(
started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64
),
detail: None,
};
}
Ok(Ok(Some(_))) => continue,
Ok(Ok(None)) => {
return StreamProbe::unclassified("upstream closed the body with no bytes")
}
Ok(Err(e)) => return StreamProbe::unclassified(state::mask_text(&e.to_string())),
Err(_elapsed) => {
return StreamProbe {
verdict: StreamVerdict::Buffered,
code: Some(crate::core::error::ERR_STREAM_BUFFERED),
first_byte_ms: None,
detail: Some(format!(
"headers arrived but no body byte within {}s",
buffered_after.as_secs_f32()
)),
};
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn masking_removes_the_password_and_keeps_everything_else() {
assert_eq!(
mask_userinfo("http://alice:s3cr3t@proxy.corp:8080"),
"http://alice:*****@proxy.corp:8080"
);
assert_eq!(
mask_userinfo("http://alice:p@ss@proxy.corp:8080"),
"http://alice:*****@proxy.corp:8080"
);
assert_eq!(
mask_userinfo("http://alice@proxy.corp:8080"),
"http://alice@proxy.corp:8080"
);
assert_eq!(
mask_userinfo("http://proxy.corp:8080"),
"http://proxy.corp:8080"
);
assert_eq!(mask_userinfo(""), "");
assert_eq!(mask_userinfo("pac:wpad"), "pac:wpad");
assert_eq!(
mask_userinfo("http://alice:s3cr3t@proxy.corp:8080/path"),
"http://alice:*****@proxy.corp:8080/path"
);
}
#[tokio::test]
async fn a_real_handshake_reaches_certificate_verification() {
use std::sync::Arc;
use tokio::net::TcpListener;
init_crypto();
let issued = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
.expect("generate self-signed cert");
let server_config = tokio_rustls::rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(
vec![issued.cert.der().clone()],
rustls::pki_types::PrivateKeyDer::Pkcs8(issued.signing_key.serialize_der().into()),
)
.expect("server config");
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let port = listener.local_addr().expect("local_addr").port();
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config));
let server = tokio::spawn(async move {
let Ok((stream, _)) = listener.accept().await else {
return false;
};
let _ = acceptor.accept(stream).await;
true
});
let client = client_builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.expect("build client");
let err = client
.get(format!("https://127.0.0.1:{port}/"))
.send()
.await
.expect_err("a self-signed leaf must not verify");
assert!(
server.await.expect("server task"),
"no connection reached the TLS server -- this test proved nothing"
);
assert!(
err.is_connect() || err.is_request(),
"expected a certificate failure, got: {err}"
);
}
fn stream_client() -> reqwest::Client {
build_client(Consumer::Boundary, &EgressConfig::direct()).expect("stream client")
}
async fn spawn_buffering_upstream(hold: Duration) -> u16 {
use tokio::io::AsyncWriteExt;
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("bind");
let port = listener.local_addr().expect("addr").port();
tokio::spawn(async move {
if let Ok((mut stream, _)) = listener.accept().await {
let mut scratch = [0u8; 1024];
let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut scratch).await;
let head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\
Transfer-Encoding: chunked\r\n\r\n";
let _ = stream.write_all(head.as_bytes()).await;
let _ = stream.flush().await;
tokio::time::sleep(hold).await;
let _ = stream.write_all(b"0\r\n\r\n").await;
}
});
port
}
#[test]
fn the_buffering_threshold_is_ten_seconds_and_takes_no_knob() {
assert_eq!(STREAM_BUFFERED_AFTER, Duration::from_secs(10));
}
#[tokio::test]
async fn no_target_is_skipped_without_touching_the_network() {
let probe = stream_probe(&stream_client(), None).await;
assert_eq!(probe.verdict, StreamVerdict::Skipped);
assert_eq!(probe.code, None);
assert_eq!(probe.verdict.as_str(), "skipped");
}
#[tokio::test]
async fn a_trickling_upstream_is_streaming() {
let upstream = crate::boundary::mock::spawn_trickle_sse(3, Duration::from_millis(20)).await;
let probe = stream_probe_within(
&stream_client(),
Some(&format!("http://127.0.0.1:{}/v1/messages", upstream.port)),
Duration::from_secs(10),
)
.await;
assert_eq!(
probe.verdict,
StreamVerdict::Streaming,
"first byte arrived well inside the window: {:?}",
probe.detail
);
assert!(probe.first_byte_ms.is_some());
assert_eq!(probe.code, None);
}
#[tokio::test]
async fn an_upstream_that_holds_the_body_is_buffered() {
let threshold = Duration::from_millis(200);
let port = spawn_buffering_upstream(Duration::from_secs(5)).await;
let probe = stream_probe_within(
&stream_client(),
Some(&format!("http://127.0.0.1:{port}/v1/messages")),
threshold,
)
.await;
assert_eq!(probe.verdict, StreamVerdict::Buffered);
assert_eq!(probe.code, Some(crate::core::error::ERR_STREAM_BUFFERED));
assert_eq!(probe.verdict.as_str(), "buffered");
}
#[tokio::test]
async fn an_unreachable_target_is_unclassified_not_buffered() {
let probe = stream_probe(&stream_client(), Some("http://127.0.0.1:1/v1/messages")).await;
assert_eq!(probe.verdict, StreamVerdict::Unclassified);
assert_eq!(probe.code, None);
assert!(probe.detail.is_some(), "a verdict must say why");
}
#[test]
fn init_crypto_is_idempotent() {
init_crypto();
init_crypto();
init_crypto();
assert!(
rustls::crypto::CryptoProvider::get_default().is_some(),
"no process-default CryptoProvider after init_crypto()"
);
}
}