use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use hickory_resolver::config::{ResolverConfig, GOOGLE};
use hickory_resolver::net::runtime::TokioRuntimeProvider;
use hickory_resolver::TokioResolver;
use reqwest::Url;
use std::sync::LazyLock;
use tokio::net::lookup_host;
use tracing::debug;
use crate::error::{Result, SeerError};
static FALLBACK_RESOLVER: LazyLock<Option<TokioResolver>> = LazyLock::new(|| {
let mut builder = TokioResolver::builder_with_config(
ResolverConfig::udp_and_tcp(&GOOGLE),
TokioRuntimeProvider::default(),
);
{
crate::dns::apply_standard_opts(builder.options_mut(), Duration::from_secs(5));
}
builder.build().ok()
});
const PRIMARY_RESOLVE_TIMEOUT: Duration = Duration::from_secs(5);
pub fn is_reserved_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_broadcast()
|| v4.is_unspecified()
|| v4.is_documentation()
|| o[0] == 0
|| o[0] >= 240
|| (o[0] == 100 && (o[1] & 0xC0) == 64)
|| (o[0] == 192 && o[1] == 0 && o[2] == 0)
|| (o[0] == 192 && o[1] == 88 && o[2] == 99)
|| (o[0] == 198 && (o[1] == 18 || o[1] == 19))
}
IpAddr::V6(v6) => {
if v6.is_loopback() || v6.is_multicast() || v6.is_unspecified() {
return true;
}
let seg = v6.segments();
if (seg[0] & 0xfe00) == 0xfc00 {
return true;
}
if (seg[0] & 0xffc0) == 0xfe80 {
return true;
}
if (seg[0] & 0xffc0) == 0xfec0 {
return true;
}
if (seg[0] == 0x2001 && seg[1] == 0x0db8) || (seg[0] == 0x3fff && seg[1] & 0xf000 == 0)
{
return true;
}
if seg[0] == 0x2001 && seg[1] == 0x0002 && seg[2] == 0 {
return true;
}
if seg[0] == 0x0100 && seg[1] == 0 && seg[2] == 0 && seg[3] == 0 {
return true;
}
if seg[0] == 0x5f00 {
return true;
}
if seg[0] == 0x2002 {
return true;
}
if seg[0] == 0x0064
&& seg[1] == 0xff9b
&& seg[2] == 0
&& seg[3] == 0
&& seg[4] == 0
&& seg[5] == 0
{
return true;
}
if seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2] == 0x0001 {
return true;
}
if seg[0] == 0x2001 && seg[1] == 0 {
return true;
}
if seg[0] == 0
&& seg[1] == 0
&& seg[2] == 0
&& seg[3] == 0
&& seg[4] == 0xffff
&& seg[5] == 0
{
let embedded = std::net::Ipv4Addr::from(((seg[6] as u32) << 16) | seg[7] as u32);
if is_reserved_ip(IpAddr::V4(embedded)) {
return true;
}
}
if v6
.to_ipv4_mapped()
.is_some_and(|v4| is_reserved_ip(IpAddr::V4(v4)))
{
return true;
}
if seg[0] == 0
&& seg[1] == 0
&& seg[2] == 0
&& seg[3] == 0
&& seg[4] == 0
&& seg[5] == 0
{
let embedded = std::net::Ipv4Addr::from(((seg[6] as u32) << 16) | seg[7] as u32);
if is_reserved_ip(IpAddr::V4(embedded)) {
return true;
}
}
false
}
}
}
pub async fn validate_public_host(host: &str, port: u16) -> Result<()> {
resolve_public_host(host, port).await.map(|_| ())
}
pub async fn resolve_public_host(host: &str, port: u16) -> Result<Vec<SocketAddr>> {
if let Ok(ip) = host.parse::<IpAddr>() {
if is_reserved_ip(ip) {
return Err(SeerError::InvalidInput(format!(
"refusing to connect to reserved address: {}",
ip
)));
}
return Ok(vec![SocketAddr::new(ip, port)]);
}
let addrs: Vec<SocketAddr> =
match tokio::time::timeout(PRIMARY_RESOLVE_TIMEOUT, lookup_host((host, port))).await {
Ok(Ok(iter)) => iter.collect(),
os_failure => {
let os_err = match os_failure {
Ok(Err(e)) => e.to_string(),
_ => format!(
"OS resolver timed out after {}s",
PRIMARY_RESOLVE_TIMEOUT.as_secs()
),
};
debug!(
host = %host,
error = %os_err,
"OS resolver could not resolve host; trying hickory fallback"
);
let Some(resolver) = FALLBACK_RESOLVER.as_ref() else {
return Err(SeerError::DnsError(format!(
"DNS resolution failed for {host}: {os_err} (no fallback resolver available)"
)));
};
match resolver.lookup_ip(host).await {
Ok(resp) => resp.iter().map(|ip| SocketAddr::new(ip, port)).collect(),
Err(fallback_err) => {
return Err(SeerError::DnsError(format!(
"DNS resolution failed for {host}: {os_err} (fallback: {fallback_err})"
)));
}
}
}
};
if addrs.is_empty() {
return Err(SeerError::DnsError(format!(
"no addresses resolved for {host}"
)));
}
for sa in &addrs {
if is_reserved_ip(sa.ip()) {
debug!(
host = %host,
resolved_ip = %sa.ip(),
"refusing host: resolves to a reserved (non-public) address"
);
return Err(SeerError::InvalidInput(format!(
"{host} is not permitted (resolves to a non-public address)"
)));
}
}
Ok(addrs)
}
pub(crate) const USER_AGENT: &str = concat!("Seer/", env!("CARGO_PKG_VERSION"));
static HTTP_TLS: LazyLock<std::result::Result<rustls::ClientConfig, String>> =
LazyLock::new(|| {
let provider = std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let verifier =
rustls_platform_verifier::Verifier::new(provider.clone()).map_err(|e| e.to_string())?;
let mut config = rustls::ClientConfig::builder_with_provider(provider)
.with_protocol_versions(rustls::ALL_VERSIONS)
.map_err(|e| e.to_string())?
.dangerous()
.with_custom_certificate_verifier(std::sync::Arc::new(verifier))
.with_no_client_auth();
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
Ok(config)
});
pub(crate) fn client_builder(timeout: Duration) -> reqwest::ClientBuilder {
let builder = reqwest::Client::builder()
.timeout(timeout)
.redirect(reqwest::redirect::Policy::none())
.no_proxy();
match &*HTTP_TLS {
Ok(tls) => builder.tls_backend_preconfigured(tls.clone()),
Err(e) => {
debug!(error = %e, "shared TLS config unavailable; reqwest builds its own");
builder
}
}
}
pub(crate) fn ipv4_first<T>(addrs: &mut [T], ip: impl Fn(&T) -> IpAddr) {
addrs.sort_by_key(|a| ip(a).is_ipv6());
}
#[derive(Debug)]
pub(crate) enum ConnectError {
TimedOut,
Io(std::io::Error),
}
pub(crate) async fn connect_any<T, F, Fut>(
addrs: &[SocketAddr],
timeout: Duration,
connect: F,
) -> std::result::Result<T, ConnectError>
where
F: Fn(SocketAddr) -> Fut,
Fut: std::future::Future<Output = std::io::Result<T>>,
{
let mut ordered = addrs.to_vec();
ipv4_first(&mut ordered, SocketAddr::ip);
let deadline = tokio::time::Instant::now() + timeout;
let mut last = ConnectError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"no address to connect to",
));
for (i, addr) in ordered.iter().enumerate() {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
let share = remaining / u32::try_from(ordered.len() - i).unwrap_or(u32::MAX);
match tokio::time::timeout(share, connect(*addr)).await {
Ok(Ok(stream)) => return Ok(stream),
Ok(Err(e)) => last = ConnectError::Io(e),
Err(_) => last = ConnectError::TimedOut,
}
}
Err(last)
}
pub(crate) fn url_host(url: &Url) -> Option<String> {
Some(match url.host()? {
url::Host::Domain(d) => d.to_string(),
url::Host::Ipv4(ip) => ip.to_string(),
url::Host::Ipv6(ip) => ip.to_string(),
})
}
pub(crate) async fn validate_http_url(url: &Url) -> Result<Vec<SocketAddr>> {
let scheme = url.scheme();
if scheme != "https" && scheme != "http" {
return Err(SeerError::HttpError(format!(
"unsupported URL scheme: {}",
scheme
)));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(SeerError::HttpError(
"URL credentials are not allowed".to_string(),
));
}
let host = url_host(url).ok_or_else(|| SeerError::HttpError("missing URL host".to_string()))?;
let port = url.port_or_known_default().unwrap_or(443);
if port != 80 && port != 443 {
return Err(SeerError::HttpError(format!(
"non-standard port {} is not allowed",
port
)));
}
resolve_public_host(&host, port)
.await
.map_err(|e| SeerError::HttpError(e.to_string()))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn fallback_resolver_opts_match_the_shared_standard() {
use hickory_resolver::config::{ResolverOpts, ServerOrderingStrategy};
let mut opts = ResolverOpts::default();
crate::dns::apply_standard_opts(&mut opts, Duration::from_secs(5));
assert_eq!(
opts.server_ordering_strategy,
ServerOrderingStrategy::UserProvidedOrder
);
assert_eq!(opts.timeout, Duration::from_secs(5));
assert_eq!(opts.attempts, 2);
}
#[test]
fn rejects_loopback_v4() {
assert!(is_reserved_ip("127.0.0.1".parse().unwrap()));
}
#[test]
fn rejects_metadata_v4() {
assert!(is_reserved_ip("169.254.169.254".parse().unwrap()));
}
#[test]
fn rejects_rfc1918() {
assert!(is_reserved_ip("10.0.0.1".parse().unwrap()));
assert!(is_reserved_ip("172.16.0.1".parse().unwrap()));
assert!(is_reserved_ip("192.168.1.1".parse().unwrap()));
}
#[test]
fn rejects_cgnat() {
assert!(is_reserved_ip("100.64.0.1".parse().unwrap()));
}
#[test]
fn rejects_benchmarking() {
assert!(is_reserved_ip("198.18.0.1".parse().unwrap()));
}
#[test]
fn rejects_ipv6_loopback() {
assert!(is_reserved_ip("::1".parse().unwrap()));
}
#[test]
fn rejects_ipv6_ula() {
assert!(is_reserved_ip("fd00::1".parse().unwrap()));
}
#[test]
fn rejects_ipv6_site_local() {
assert!(is_reserved_ip("fec0::1".parse().unwrap()));
assert!(is_reserved_ip("feff::1".parse().unwrap()));
}
#[test]
fn rejects_ipv4_mapped_loopback() {
assert!(is_reserved_ip("::ffff:127.0.0.1".parse().unwrap()));
}
#[test]
fn rejects_ipv4_mapped_cgnat() {
assert!(is_reserved_ip("::ffff:100.64.0.1".parse().unwrap()));
}
#[test]
fn rejects_class_e_reserved() {
assert!(is_reserved_ip("240.0.0.1".parse().unwrap()));
assert!(is_reserved_ip("250.1.2.3".parse().unwrap()));
}
#[test]
fn rejects_this_network_0_8() {
assert!(is_reserved_ip("0.1.2.3".parse().unwrap()));
}
#[test]
fn rejects_ipv6_nat64_of_metadata() {
assert!(is_reserved_ip("64:ff9b::a9fe:a9fe".parse().unwrap()));
}
#[test]
fn rejects_ipv6_6to4() {
assert!(is_reserved_ip("2002:a9fe:a9fe::".parse().unwrap()));
}
#[test]
fn rejects_teredo_and_siit_translated_forms() {
assert!(is_reserved_ip(
"2001:0:4136:e378:8000:63bf:3fff:fdd2".parse().unwrap()
));
assert!(is_reserved_ip("::ffff:0:a9fe:a9fe".parse().unwrap()));
assert!(is_reserved_ip("::ffff:0:a00:1".parse().unwrap()));
assert!(!is_reserved_ip("::ffff:0:808:808".parse().unwrap()));
assert!(!is_reserved_ip("2001:4860:4860::8888".parse().unwrap()));
}
#[test]
fn rejects_ipv6_nat64_rfc8215_local_use_prefix() {
assert!(is_reserved_ip("64:ff9b:1::a9fe:a9fe".parse().unwrap()));
assert!(is_reserved_ip("64:ff9b:1:ffff::1".parse().unwrap()));
assert!(!is_reserved_ip("64:ff9b:2::1".parse().unwrap()));
}
#[test]
fn rejects_ipv4_compatible_metadata() {
assert!(is_reserved_ip("::a9fe:a9fe".parse().unwrap()));
}
#[test]
fn rejects_ipv6_documentation() {
assert!(is_reserved_ip("2001:db8::1".parse().unwrap()));
}
#[test]
fn allows_ipv4_compatible_public() {
assert!(!is_reserved_ip("::808:808".parse().unwrap()));
}
#[test]
fn rejects_special_purpose_ranges() {
for ip in [
"192.88.99.1", "2001:2::1", "2001:2:0:ffff::1", "3fff::1", "3fff:fff:ffff::1", "100::1", "5f00::1", "5f00:ffff::1",
] {
assert!(is_reserved_ip(ip.parse().unwrap()), "{ip} must be reserved");
}
for ip in [
"192.88.98.1",
"2001:3::1",
"3fff:1000::1",
"100:0:0:1::1",
"5f01::1",
] {
assert!(!is_reserved_ip(ip.parse().unwrap()), "{ip} must be public");
}
}
#[test]
fn ipv4_first_keeps_each_family_in_order() {
let mut addrs: Vec<IpAddr> = ["2001:db8::1", "192.0.2.1", "2001:db8::2", "192.0.2.2"]
.iter()
.map(|a| a.parse().unwrap())
.collect();
ipv4_first(&mut addrs, |ip| *ip);
let text: Vec<String> = addrs.iter().map(IpAddr::to_string).collect();
assert_eq!(
text,
["192.0.2.1", "192.0.2.2", "2001:db8::1", "2001:db8::2"]
);
}
#[tokio::test]
async fn client_builder_ignores_proxy_environment() {
use tokio::net::TcpListener;
let target = TcpListener::bind("127.0.0.1:0").await.unwrap();
let target_port = target.local_addr().unwrap().port();
let proxy = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_url = format!("http://{}", proxy.local_addr().unwrap());
std::env::set_var("HTTPS_PROXY", &proxy_url);
let client = client_builder(Duration::from_secs(2)).build();
std::env::remove_var("HTTPS_PROXY");
let client = client.unwrap();
let accepted = tokio::spawn(async move { target.accept().await.is_ok() });
let _ = client
.get(format!("https://127.0.0.1:{target_port}/"))
.send()
.await;
let direct = tokio::time::timeout(Duration::from_secs(2), accepted).await;
assert!(matches!(direct, Ok(Ok(true))), "must connect to the target");
let via_proxy = tokio::time::timeout(Duration::from_millis(100), proxy.accept()).await;
assert!(via_proxy.is_err(), "must not connect through the proxy");
}
#[tokio::test]
async fn client_offers_http2_over_alpn() {
use tokio::io::AsyncReadExt;
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let hello = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let n = sock.read(&mut buf).await.unwrap();
buf.truncate(n);
buf
});
let client = client_builder(Duration::from_secs(2)).build().unwrap();
let _ = tokio::time::timeout(
Duration::from_secs(2),
client.get(format!("https://127.0.0.1:{port}/")).send(),
)
.await;
let hello = hello.await.unwrap();
let offers = |proto: &[u8]| {
let mut needle = vec![u8::try_from(proto.len()).unwrap()];
needle.extend_from_slice(proto);
hello.windows(needle.len()).any(|w| w == needle.as_slice())
};
assert!(offers(b"h2"), "ClientHello must offer h2");
assert!(offers(b"http/1.1"), "ClientHello must still offer http/1.1");
}
#[test]
fn allows_public_v4() {
assert!(!is_reserved_ip("8.8.8.8".parse().unwrap()));
assert!(!is_reserved_ip("1.1.1.1".parse().unwrap()));
}
#[test]
fn allows_public_v6() {
assert!(!is_reserved_ip("2606:4700:4700::1111".parse().unwrap()));
}
#[tokio::test]
async fn validate_rejects_ip_literal_loopback() {
let err = validate_public_host("127.0.0.1", 80).await.unwrap_err();
assert!(matches!(err, SeerError::InvalidInput(_)));
}
#[tokio::test]
async fn validate_rejects_ip_literal_metadata() {
let err = validate_public_host("169.254.169.254", 80)
.await
.unwrap_err();
assert!(matches!(err, SeerError::InvalidInput(_)));
}
#[tokio::test]
async fn validate_allows_public_ip_literal() {
validate_public_host("8.8.8.8", 53).await.unwrap();
}
#[tokio::test]
async fn validate_http_url_unbrackets_ipv6_literals() {
let url = Url::parse("https://[::1]/").unwrap();
let err = validate_http_url(&url).await.unwrap_err();
assert!(
matches!(err, SeerError::HttpError(ref s) if s.contains("reserved")),
"got: {err:?}"
);
}
#[tokio::test]
async fn validate_http_url_returns_pinnable_addrs_for_public_literal() {
let url = Url::parse("https://8.8.8.8/").unwrap();
let addrs = validate_http_url(&url).await.unwrap();
assert_eq!(addrs, vec!["8.8.8.8:443".parse().unwrap()]);
}
#[tokio::test]
async fn reserved_resolution_error_does_not_leak_resolved_ip() {
let err = validate_public_host("localhost", 80).await.unwrap_err();
assert!(matches!(err, SeerError::InvalidInput(_)));
let msg = err.to_string();
assert!(
!msg.contains("127.0.0.1"),
"must not leak resolved IPv4: {msg}"
);
assert!(!msg.contains("::1"), "must not leak resolved IPv6: {msg}");
assert!(
!msg.contains("resolves to reserved address"),
"must not echo the resolved-address detail: {msg}"
);
}
#[tokio::test]
#[ignore = "requires network — hits Google DNS via hickory fallback"]
async fn validate_rejects_unresolvable_via_fallback() {
let err = validate_public_host("nonexistent.host.invalid.", 443)
.await
.unwrap_err();
assert!(matches!(err, SeerError::DnsError(_)), "got: {err:?}");
let msg = format!("{err}");
assert!(msg.contains("DNS resolution failed"), "got: {msg}");
assert!(msg.contains("fallback"), "got: {msg}");
assert_eq!(err.sanitized_message(), "DNS resolution failed");
}
#[tokio::test]
async fn connect_tries_ipv4_first() {
let v6: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
let v4: SocketAddr = "192.0.2.1:443".parse().unwrap();
let tried = std::sync::Mutex::new(Vec::new());
let got = connect_any(&[v6, v4], Duration::from_secs(5), |addr| {
tried.lock().unwrap().push(addr);
async move { Ok::<_, std::io::Error>(addr) }
})
.await
.unwrap();
assert_eq!(got, v4);
assert_eq!(*tried.lock().unwrap(), [v4]);
}
#[tokio::test]
async fn a_hanging_address_leaves_budget_for_the_next() {
let dead: SocketAddr = "192.0.2.1:443".parse().unwrap();
let live: SocketAddr = "192.0.2.2:443".parse().unwrap();
let got = connect_any(
&[dead, live],
Duration::from_millis(400),
|addr| async move {
if addr == dead {
std::future::pending::<()>().await;
}
Ok::<_, std::io::Error>(addr)
},
)
.await
.unwrap();
assert_eq!(got, live);
let err = connect_any(&[dead, live], Duration::from_secs(5), |_| async {
Err::<(), _>(std::io::Error::from(std::io::ErrorKind::ConnectionRefused))
})
.await
.unwrap_err();
assert!(
matches!(err, ConnectError::Io(ref e) if e.kind() == std::io::ErrorKind::ConnectionRefused),
"got {err:?}"
);
let err = connect_any(&[dead], Duration::from_millis(50), |_| async {
std::future::pending::<std::io::Result<()>>().await
})
.await
.unwrap_err();
assert!(matches!(err, ConnectError::TimedOut), "got {err:?}");
}
#[tokio::test]
async fn shared_tls_config_still_verifies_certificates() {
use crate::tls::test_support::{serve_once, CA_DER, LEAF_DER};
assert!(HTTP_TLS.is_ok(), "{:?}", HTTP_TLS.as_ref().err());
let addr = serve_once(&[LEAF_DER, CA_DER], rustls::ALL_VERSIONS).await;
let client = client_builder(Duration::from_secs(5))
.resolve("chain.test", addr)
.build()
.unwrap();
let err = client
.get(format!("https://chain.test:{}/", addr.port()))
.send()
.await
.expect_err("an untrusted chain must be refused");
let mut chain = Vec::new();
let mut source: Option<&dyn std::error::Error> = Some(&err);
while let Some(e) = source {
chain.push(e.to_string());
source = e.source();
}
assert!(
chain.iter().any(|m| m.contains("invalid peer certificate")),
"{chain:?}"
);
}
}