mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
fix(dns): unify provider resolution and bound SMTP and tunnel egress
Share provider DNS policy across WebSocket and connection probes, handle bracketed IPv6 literals, and preserve bounded address sets for outbound clients. Bound SMTP DNS and TCP setup with multi-address fallback. Add opt-in trusted proxy DNS for tunnel upstreams while retaining default IP ACLs and origin isolation. Document DNS policy boundaries and verify 809 gateway, tunnel, and HTTP regression tests.
This commit is contained in:
@@ -126,11 +126,16 @@ pub async fn run(mut config: Config, servers: Vec<ServerEntry>) -> anyhow::Resul
|
||||
if let Ok(proxy) = crate::egress_proxy::UpstreamProxyConfig::parse(proxy_url) {
|
||||
info!(
|
||||
upstream_proxy_url = %proxy.redacted_url(),
|
||||
upstream_proxy_remote_dns = config.upstream_proxy_remote_dns,
|
||||
"provider upstream egress proxy configured"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if config.upstream_proxy_remote_dns {
|
||||
warn!("provider hostname DNS resolution and destination IP access controls are delegated to the trusted upstream proxy");
|
||||
}
|
||||
|
||||
// Resolve public IP (best-effort for region info)
|
||||
let public_ip = match &config.public_ip {
|
||||
Some(ip) => ip.clone(),
|
||||
@@ -1420,6 +1425,7 @@ mod tests {
|
||||
upstream_tcp_keepalive_secs: 60,
|
||||
upstream_tcp_nodelay: true,
|
||||
upstream_proxy_url: None,
|
||||
upstream_proxy_remote_dns: false,
|
||||
legacy_redirect_replay_budget_bytes_ignored: None,
|
||||
emit_proxy_timing_header: true,
|
||||
log_level: "info".to_string(),
|
||||
|
||||
@@ -480,6 +480,14 @@ pub struct Config {
|
||||
#[arg(long, env = "AETHER_TUNNEL_UPSTREAM_PROXY_URL")]
|
||||
pub upstream_proxy_url: Option<String>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_TUNNEL_UPSTREAM_PROXY_REMOTE_DNS",
|
||||
default_value_t = false,
|
||||
help = "Trust an HTTP or SOCKS5h upstream proxy to resolve hostnames and enforce destination IP access controls"
|
||||
)]
|
||||
pub upstream_proxy_remote_dns: bool,
|
||||
|
||||
/// Accepted only so older launch commands and environments keep working.
|
||||
/// Redirect request bodies are always replayed without a cumulative size limit.
|
||||
#[arg(
|
||||
@@ -820,6 +828,16 @@ impl Config {
|
||||
crate::egress_proxy::UpstreamProxyConfig::parse(proxy_url)
|
||||
.map_err(|err| anyhow::anyhow!("upstream_proxy_url invalid: {err}"))?;
|
||||
}
|
||||
if self.upstream_proxy_remote_dns {
|
||||
let proxy_url = normalized_proxy_url(&self.upstream_proxy_url).ok_or_else(|| {
|
||||
anyhow::anyhow!("upstream_proxy_remote_dns requires upstream_proxy_url")
|
||||
})?;
|
||||
let proxy = crate::egress_proxy::UpstreamProxyConfig::parse(proxy_url)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
if !proxy.supports_remote_target_dns() {
|
||||
anyhow::bail!("upstream_proxy_remote_dns requires an http:// or socks5h:// proxy");
|
||||
}
|
||||
}
|
||||
if matches!(self.max_in_flight_streams, Some(0)) {
|
||||
anyhow::bail!("max_in_flight_streams must be > 0");
|
||||
}
|
||||
@@ -1087,6 +1105,8 @@ pub struct ConfigFile {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_proxy_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_proxy_remote_dns: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub emit_proxy_timing_header: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub log_level: Option<String>,
|
||||
@@ -1306,6 +1326,10 @@ impl ConfigFile {
|
||||
self.upstream_tcp_nodelay
|
||||
);
|
||||
set!("AETHER_TUNNEL_UPSTREAM_PROXY_URL", self.upstream_proxy_url);
|
||||
set!(
|
||||
"AETHER_TUNNEL_UPSTREAM_PROXY_REMOTE_DNS",
|
||||
self.upstream_proxy_remote_dns
|
||||
);
|
||||
set!(
|
||||
"AETHER_TUNNEL_EMIT_PROXY_TIMING_HEADER",
|
||||
self.emit_proxy_timing_header
|
||||
@@ -1670,6 +1694,57 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::hardware::HardwareInfo;
|
||||
|
||||
#[test]
|
||||
fn proxy_remote_dns_requires_explicit_trust_and_a_remote_dns_proxy() {
|
||||
let mut config = Config::parse_from([
|
||||
"aether-tunnel",
|
||||
"--aether-url",
|
||||
"https://example.com",
|
||||
"--management-token",
|
||||
"ae_test",
|
||||
"--node-name",
|
||||
"tunnel-test",
|
||||
]);
|
||||
assert!(!config.upstream_proxy_remote_dns);
|
||||
let argument = Config::command()
|
||||
.get_arguments()
|
||||
.find(|argument| argument.get_id() == "upstream_proxy_remote_dns")
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(
|
||||
argument.get_env().unwrap(),
|
||||
"AETHER_TUNNEL_UPSTREAM_PROXY_REMOTE_DNS"
|
||||
);
|
||||
|
||||
config.upstream_proxy_remote_dns = true;
|
||||
for proxy in [None, Some(" "), Some("socks5://127.0.0.1:1080")] {
|
||||
config.upstream_proxy_url = proxy.map(str::to_string);
|
||||
assert!(config
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("upstream_proxy_remote_dns"));
|
||||
}
|
||||
for proxy in ["http://127.0.0.1:8080", "socks5h://127.0.0.1:1080"] {
|
||||
config.upstream_proxy_url = Some(proxy.to_string());
|
||||
config
|
||||
.validate()
|
||||
.expect("explicit remote DNS configuration should validate");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_file_round_trips_proxy_remote_dns() {
|
||||
let config = parse_config_file_content(
|
||||
"upstream_proxy_url = \"socks5h://127.0.0.1:1080\"\nupstream_proxy_remote_dns = true",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(config.upstream_proxy_remote_dns, Some(true));
|
||||
let round_trip: ConfigFile = toml::from_str(&toml::to_string(&config).unwrap()).unwrap();
|
||||
assert_eq!(round_trip.upstream_proxy_remote_dns, Some(true));
|
||||
assert_eq!(ConfigFile::default().upstream_proxy_remote_dns, None);
|
||||
}
|
||||
|
||||
fn config_save_test_dir(label: &str) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"aether-tunnel-config-{label}-{}",
|
||||
|
||||
@@ -142,6 +142,13 @@ impl UpstreamProxyConfig {
|
||||
self.scheme == UpstreamProxyScheme::Socks5h
|
||||
}
|
||||
|
||||
pub(crate) fn supports_remote_target_dns(&self) -> bool {
|
||||
matches!(
|
||||
self.scheme,
|
||||
UpstreamProxyScheme::Http | UpstreamProxyScheme::Socks5h
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn basic_auth_header(&self) -> Option<String> {
|
||||
let username = self.username()?;
|
||||
let mut credentials = String::with_capacity(
|
||||
@@ -445,7 +452,7 @@ pub(crate) async fn socks5_target_address(
|
||||
remote_dns: bool,
|
||||
) -> io::Result<Vec<u8>> {
|
||||
let mut request = vec![0x05, 0x01, 0x00];
|
||||
if let Ok(ip) = target_host.parse::<IpAddr>() {
|
||||
if let Some(ip) = aether_http::parse_ip_literal_host(target_host) {
|
||||
push_socks5_ip_address(&mut request, ip);
|
||||
} else if remote_dns {
|
||||
let host = target_host.as_bytes();
|
||||
@@ -524,6 +531,19 @@ fn non_empty_url_part(value: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn socks_proxy_encodes_bracketed_ipv6_as_an_ip_for_both_dns_modes() {
|
||||
for remote_dns in [false, true] {
|
||||
let expected = socks5_target_address("::1", 443, remote_dns).await.unwrap();
|
||||
let actual = socks5_target_address("[::1]", 443, remote_dns)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(&actual[..4], &[5, 1, 0, 4]);
|
||||
assert_eq!(&actual[20..], &443u16.to_be_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_http_proxy_with_default_port() {
|
||||
let proxy = UpstreamProxyConfig::parse("http://proxy.example").expect("proxy should parse");
|
||||
|
||||
@@ -214,6 +214,14 @@ impl App {
|
||||
required: false,
|
||||
help: "Heartbeat interval in seconds; default is 5",
|
||||
},
|
||||
Field {
|
||||
label: "Proxy Remote DNS",
|
||||
key: "upstream_proxy_remote_dns",
|
||||
value: "false".into(),
|
||||
kind: FieldKind::Bool,
|
||||
required: false,
|
||||
help: "Trust HTTP/SOCKS5h egress proxy to resolve provider hosts and enforce destination IP ACLs; restart required",
|
||||
},
|
||||
],
|
||||
selected: 0,
|
||||
mode: Mode::Normal,
|
||||
@@ -288,6 +296,9 @@ impl App {
|
||||
"allow_private_targets" => cfg.allow_private_targets.map(|v| v.to_string()),
|
||||
"heartbeat_interval" => cfg.heartbeat_interval.map(|v| v.to_string()),
|
||||
"upstream_proxy_url" => cfg.upstream_proxy_url.clone(),
|
||||
"upstream_proxy_remote_dns" => {
|
||||
cfg.upstream_proxy_remote_dns.map(|value| value.to_string())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(v) = val {
|
||||
@@ -396,11 +407,23 @@ impl App {
|
||||
let get_tab = |tab: &ServerTab, key: &str| -> Option<String> { Self::get_tab(tab, key) };
|
||||
|
||||
let save_logs_to_file = self.toggle_enabled("save_logs_to_file");
|
||||
let upstream_proxy_url = self.parse_optional_upstream_proxy_url()?;
|
||||
let upstream_proxy_remote_dns = self.toggle_enabled("upstream_proxy_remote_dns");
|
||||
if upstream_proxy_remote_dns {
|
||||
let proxy_url = upstream_proxy_url
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Proxy Remote DNS requires an egress proxy"))?;
|
||||
let proxy = UpstreamProxyConfig::parse(proxy_url).map_err(anyhow::Error::msg)?;
|
||||
if !proxy.supports_remote_target_dns() {
|
||||
anyhow::bail!("Proxy Remote DNS requires an http:// or socks5h:// proxy");
|
||||
}
|
||||
}
|
||||
let mut cfg = ConfigFile {
|
||||
log_level: get_global("log_level"),
|
||||
allow_private_targets: Some(self.toggle_enabled("allow_private_targets")),
|
||||
heartbeat_interval: self.parse_optional_heartbeat_interval()?,
|
||||
upstream_proxy_url: self.parse_optional_upstream_proxy_url()?,
|
||||
upstream_proxy_url,
|
||||
upstream_proxy_remote_dns: Some(upstream_proxy_remote_dns),
|
||||
log_destination: Some(if save_logs_to_file {
|
||||
TunnelLogDestinationArg::Both
|
||||
} else {
|
||||
@@ -1086,6 +1109,37 @@ mod tests {
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_remote_dns_toggle_round_trips_and_requires_a_trusted_proxy() {
|
||||
let mut app = sample_app();
|
||||
assert_eq!(
|
||||
app.to_config().unwrap().upstream_proxy_remote_dns,
|
||||
Some(false)
|
||||
);
|
||||
set_global_field(&mut app, "upstream_proxy_remote_dns", "true");
|
||||
assert!(app
|
||||
.to_config()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("requires an egress proxy"));
|
||||
set_global_field(&mut app, "upstream_proxy_url", "socks5://127.0.0.1:1080");
|
||||
assert!(app
|
||||
.to_config()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("socks5h://"));
|
||||
|
||||
for proxy in ["http://127.0.0.1:8080", "socks5h://127.0.0.1:1080"] {
|
||||
set_global_field(&mut app, "upstream_proxy_url", proxy);
|
||||
let config = app.to_config().unwrap();
|
||||
let mut restored = sample_app();
|
||||
restored.apply_config(&config);
|
||||
let round_trip = restored.to_config().unwrap();
|
||||
assert_eq!(round_trip.upstream_proxy_remote_dns, Some(true));
|
||||
assert_eq!(round_trip.upstream_proxy_url.as_deref(), Some(proxy));
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_temp_config_path(name: &str) -> PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
||||
@@ -226,21 +226,34 @@ pub async fn validate_target(
|
||||
allow_private: bool,
|
||||
dns_cache: &DnsCache,
|
||||
) -> Result<Vec<SocketAddr>, FilterError> {
|
||||
// Port whitelist check
|
||||
if let Some(address) = validate_target_literal(host, port, allowed_ports, allow_private)? {
|
||||
return Ok(vec![address]);
|
||||
}
|
||||
|
||||
resolve_public_addrs(host, port, allow_private, dns_cache).await
|
||||
}
|
||||
|
||||
pub(crate) fn validate_target_literal(
|
||||
host: &str,
|
||||
port: u16,
|
||||
allowed_ports: &HashSet<u16>,
|
||||
allow_private: bool,
|
||||
) -> Result<Option<SocketAddr>, FilterError> {
|
||||
if !allowed_ports.contains(&port) {
|
||||
return Err(FilterError::PortNotAllowed(port));
|
||||
}
|
||||
|
||||
// Try parsing as IP directly (no DNS needed)
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
if let Some(ip) = aether_http::parse_ip_literal_host(host) {
|
||||
if !allow_private && is_private_ip(&ip) {
|
||||
return Err(FilterError::PrivateIp(ip));
|
||||
}
|
||||
return Ok(vec![SocketAddr::new(ip, port)]);
|
||||
return Ok(Some(SocketAddr::new(ip, port)));
|
||||
}
|
||||
|
||||
// Resolve and return the exact addresses authorized for this request.
|
||||
resolve_public_addrs(host, port, allow_private, dns_cache).await
|
||||
if !allow_private && host.trim_end_matches('.').eq_ignore_ascii_case("localhost") {
|
||||
return Err(FilterError::NoPublicAddrs(host.to_string()));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -737,6 +737,7 @@ mod tests {
|
||||
upstream_tcp_keepalive_secs: 60,
|
||||
upstream_tcp_nodelay: true,
|
||||
upstream_proxy_url: None,
|
||||
upstream_proxy_remote_dns: false,
|
||||
legacy_redirect_replay_budget_bytes_ignored: None,
|
||||
emit_proxy_timing_header: true,
|
||||
log_level: "info".to_string(),
|
||||
|
||||
@@ -1276,6 +1276,40 @@ fn resolve_redirect<B>(
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_upstream_target(
|
||||
current_url: &url::Url,
|
||||
allowed_ports: &std::collections::HashSet<u16>,
|
||||
allow_private_targets: bool,
|
||||
proxy_remote_dns: bool,
|
||||
dns_cache: &target_filter::DnsCache,
|
||||
) -> Result<upstream_client::ValidatedUpstreamTarget, String> {
|
||||
validate_tunnel_upstream_url(current_url, allow_private_targets).map_err(str::to_string)?;
|
||||
let host = current_url
|
||||
.host_str()
|
||||
.ok_or_else(|| "missing host in URL".to_string())?;
|
||||
let port = current_url
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| "missing port in URL".to_string())?;
|
||||
let addresses = if proxy_remote_dns {
|
||||
match target_filter::validate_target_literal(
|
||||
host,
|
||||
port,
|
||||
allowed_ports,
|
||||
allow_private_targets,
|
||||
)
|
||||
.map_err(|_| "upstream target blocked".to_string())?
|
||||
{
|
||||
Some(address) => vec![address],
|
||||
None => return upstream_client::ValidatedUpstreamTarget::proxy_resolved(current_url),
|
||||
}
|
||||
} else {
|
||||
target_filter::validate_target(host, port, allowed_ports, allow_private_targets, dns_cache)
|
||||
.await
|
||||
.map_err(|_| "upstream target blocked".to_string())?
|
||||
};
|
||||
upstream_client::ValidatedUpstreamTarget::new(current_url, addresses)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn execute_upstream_request(
|
||||
state: &AppState,
|
||||
@@ -1288,24 +1322,19 @@ async fn execute_upstream_request(
|
||||
timeout: Duration,
|
||||
http1_only: bool,
|
||||
) -> Result<UpstreamResponseContext, String> {
|
||||
let host = current_url
|
||||
.host_str()
|
||||
.ok_or_else(|| "missing host in URL".to_string())?;
|
||||
let port = current_url.port_or_known_default().unwrap_or(443);
|
||||
|
||||
let dns_start = Instant::now();
|
||||
let validated_addrs = {
|
||||
let validated_target = {
|
||||
let allowed_ports = Arc::clone(&server.dynamic.load().allowed_ports);
|
||||
match target_filter::validate_target(
|
||||
host,
|
||||
port,
|
||||
match resolve_upstream_target(
|
||||
current_url,
|
||||
&allowed_ports,
|
||||
state.config.allow_private_targets,
|
||||
state.config.upstream_proxy_remote_dns,
|
||||
&state.dns_cache,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(addrs) => addrs,
|
||||
Ok(target) => target,
|
||||
Err(_error) => {
|
||||
server.metrics.dns_failures.fetch_add(1, Ordering::Release);
|
||||
// Keep the detailed filter error out of the tunnel response;
|
||||
@@ -1317,9 +1346,6 @@ async fn execute_upstream_request(
|
||||
};
|
||||
let dns_ms = dns_start.elapsed().as_millis() as u64;
|
||||
|
||||
let validated_target =
|
||||
upstream_client::ValidatedUpstreamTarget::new(current_url, validated_addrs)?;
|
||||
|
||||
let client_key = upstream_client::upstream_client_pool_key(
|
||||
meta.provider_id.as_deref(),
|
||||
meta.endpoint_id.as_deref(),
|
||||
@@ -2326,6 +2352,89 @@ fn build_prefixed_request_body(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[tokio::test]
|
||||
async fn remote_dns_target_resolution_skips_local_dns_and_keeps_literal_acl() {
|
||||
let cache = target_filter::DnsCache::new(Duration::from_secs(60), 16);
|
||||
let ports = [80, 443].into_iter().collect();
|
||||
let url = url::Url::parse("https://remote-dns-test.invalid/path").unwrap();
|
||||
let target = resolve_upstream_target(&url, &ports, false, true, &cache)
|
||||
.await
|
||||
.expect("trusted proxy should receive an unresolved hostname");
|
||||
assert!(target.uses_proxy_dns());
|
||||
assert!(cache.get("remote-dns-test.invalid", 443).await.is_none());
|
||||
|
||||
for address in ["https://8.8.8.8/", "https://[2606:4700:4700::1111]/"] {
|
||||
let target = resolve_upstream_target(
|
||||
&url::Url::parse(address).unwrap(),
|
||||
&ports,
|
||||
false,
|
||||
true,
|
||||
&cache,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!target.uses_proxy_dns(), "IP literals must remain pinned");
|
||||
}
|
||||
|
||||
for address in [
|
||||
"https://127.0.0.1/",
|
||||
"https://10.0.0.1/",
|
||||
"https://198.18.0.1/",
|
||||
"https://[::1]/",
|
||||
"https://[::ffff:127.0.0.1]/",
|
||||
"https://localhost/",
|
||||
"https://LOCALHOST./",
|
||||
"https://remote-dns-test.invalid:25/",
|
||||
"https://user:[email protected]/",
|
||||
"https://remote-dns-test.invalid/#fragment",
|
||||
"ftp://remote-dns-test.invalid/",
|
||||
] {
|
||||
assert!(
|
||||
resolve_upstream_target(
|
||||
&url::Url::parse(address).unwrap(),
|
||||
&ports,
|
||||
false,
|
||||
true,
|
||||
&cache,
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"target should remain blocked: {address}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn strict_dns_targets_stay_pinned_and_separate_from_remote_dns_targets() {
|
||||
let cache = target_filter::DnsCache::new(Duration::from_secs(60), 16);
|
||||
let ports = [443].into_iter().collect();
|
||||
let url = url::Url::parse("https://remote-dns-test.invalid/").unwrap();
|
||||
cache
|
||||
.insert(
|
||||
"remote-dns-test.invalid",
|
||||
443,
|
||||
Arc::new(vec!["8.8.8.8:443".parse().unwrap()]),
|
||||
)
|
||||
.await;
|
||||
let strict = resolve_upstream_target(&url, &ports, false, false, &cache)
|
||||
.await
|
||||
.unwrap();
|
||||
let remote = resolve_upstream_target(&url, &ports, false, true, &cache)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!strict.uses_proxy_dns());
|
||||
assert!(remote.uses_proxy_dns());
|
||||
assert_ne!(strict, remote);
|
||||
|
||||
let private_url = url::Url::parse("http://[::1]/").unwrap();
|
||||
let private_ports = [80].into_iter().collect();
|
||||
let explicitly_allowed =
|
||||
resolve_upstream_target(&private_url, &private_ports, true, true, &cache)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!explicitly_allowed.uses_proxy_dns());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn window_updates_wait_for_capacity_instead_of_disappearing() {
|
||||
let (high_tx, mut high_rx) = aether_runtime::bounded_queue(1);
|
||||
@@ -3820,6 +3929,7 @@ mod tests {
|
||||
upstream_tcp_keepalive_secs: 60,
|
||||
upstream_tcp_nodelay: true,
|
||||
upstream_proxy_url: None,
|
||||
upstream_proxy_remote_dns: false,
|
||||
legacy_redirect_replay_budget_bytes_ignored: None,
|
||||
emit_proxy_timing_header: true,
|
||||
log_level: "info".to_string(),
|
||||
|
||||
@@ -34,7 +34,8 @@ use tower_service::Service;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::egress_proxy::{
|
||||
connect_validated_target_via_proxy, ProxyConnectOptions, UpstreamProxyConfig,
|
||||
connect_target_via_proxy, connect_validated_target_via_proxy, ProxyConnectOptions,
|
||||
UpstreamProxyConfig,
|
||||
};
|
||||
use crate::target_filter::DnsCache;
|
||||
|
||||
@@ -66,11 +67,42 @@ pub struct ValidatedUpstreamTarget {
|
||||
scheme: String,
|
||||
host: String,
|
||||
port: u16,
|
||||
addrs: Vec<SocketAddr>,
|
||||
resolution: UpstreamTargetResolution,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
enum UpstreamTargetResolution {
|
||||
Pinned(Vec<SocketAddr>),
|
||||
ProxyDns,
|
||||
}
|
||||
|
||||
impl ValidatedUpstreamTarget {
|
||||
pub fn new(target_url: &url::Url, mut addrs: Vec<SocketAddr>) -> Result<Self, String> {
|
||||
if addrs.is_empty() {
|
||||
return Err("validated upstream target has no addresses".to_string());
|
||||
}
|
||||
addrs.sort_unstable();
|
||||
addrs.dedup();
|
||||
Self::with_resolution(target_url, UpstreamTargetResolution::Pinned(addrs))
|
||||
}
|
||||
|
||||
pub(crate) fn proxy_resolved(target_url: &url::Url) -> Result<Self, String> {
|
||||
if !matches!(target_url.host(), Some(url::Host::Domain(_))) {
|
||||
return Err("IP literal targets must use pinned addresses".to_string());
|
||||
}
|
||||
Self::with_resolution(target_url, UpstreamTargetResolution::ProxyDns)
|
||||
}
|
||||
|
||||
fn with_resolution(
|
||||
target_url: &url::Url,
|
||||
resolution: UpstreamTargetResolution,
|
||||
) -> Result<Self, String> {
|
||||
if !target_url.username().is_empty()
|
||||
|| target_url.password().is_some()
|
||||
|| target_url.fragment().is_some()
|
||||
{
|
||||
return Err("upstream target must not contain credentials or a fragment".to_string());
|
||||
}
|
||||
let scheme = target_url.scheme().to_ascii_lowercase();
|
||||
if !matches!(scheme.as_str(), "http" | "https") {
|
||||
return Err(format!("unsupported upstream scheme {scheme}"));
|
||||
@@ -86,19 +118,16 @@ impl ValidatedUpstreamTarget {
|
||||
let port = target_url
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| "missing port in upstream URL".to_string())?;
|
||||
if addrs.is_empty() {
|
||||
return Err("validated upstream target has no addresses".to_string());
|
||||
if let UpstreamTargetResolution::Pinned(addrs) = &resolution {
|
||||
if addrs.iter().any(|addr| addr.port() != port) {
|
||||
return Err("validated upstream target address has the wrong port".to_string());
|
||||
}
|
||||
}
|
||||
if addrs.iter().any(|addr| addr.port() != port) {
|
||||
return Err("validated upstream target address has the wrong port".to_string());
|
||||
}
|
||||
addrs.sort_unstable();
|
||||
addrs.dedup();
|
||||
Ok(Self {
|
||||
scheme,
|
||||
host,
|
||||
port,
|
||||
addrs,
|
||||
resolution,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,8 +148,8 @@ impl ValidatedUpstreamTarget {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn addrs(&self) -> &[SocketAddr] {
|
||||
&self.addrs
|
||||
pub(crate) fn uses_proxy_dns(&self) -> bool {
|
||||
matches!(self.resolution, UpstreamTargetResolution::ProxyDns)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,9 +362,14 @@ impl Service<Name> for PinnedResolver {
|
||||
"DNS request does not match the validated upstream host",
|
||||
));
|
||||
}
|
||||
Ok(ValidatedAddrs {
|
||||
inner: target.addrs.into_iter(),
|
||||
})
|
||||
match target.resolution {
|
||||
UpstreamTargetResolution::Pinned(addrs) => Ok(ValidatedAddrs {
|
||||
inner: addrs.into_iter(),
|
||||
}),
|
||||
UpstreamTargetResolution::ProxyDns => Err(io::Error::other(
|
||||
"proxy-resolved target must not fall back to local DNS",
|
||||
)),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -376,16 +410,25 @@ impl Service<Uri> for InstrumentedConnector {
|
||||
};
|
||||
let connect_start = std::time::Instant::now();
|
||||
return Box::pin(async move {
|
||||
connect_via_proxy(
|
||||
dst,
|
||||
scheme,
|
||||
tls_config,
|
||||
proxy,
|
||||
validated_target,
|
||||
options,
|
||||
connect_start,
|
||||
tokio::time::timeout(
|
||||
options.connect_timeout,
|
||||
connect_via_proxy(
|
||||
dst,
|
||||
scheme,
|
||||
tls_config,
|
||||
proxy,
|
||||
validated_target,
|
||||
options,
|
||||
connect_start,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
Box::new(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"upstream proxy connection timed out",
|
||||
)) as BoxError
|
||||
})?
|
||||
});
|
||||
}
|
||||
let connecting = self.http.call(dst.clone());
|
||||
@@ -441,20 +484,40 @@ async fn connect_via_proxy(
|
||||
connect_start: std::time::Instant,
|
||||
) -> Result<TimedConn, BoxError> {
|
||||
let scheme = scheme.ok_or_else(|| io::Error::other("missing scheme"))?;
|
||||
let mut last_error = None;
|
||||
let mut connected = None;
|
||||
for target_addr in validated_target.addrs().iter().copied() {
|
||||
match connect_validated_target_via_proxy(&proxy, target_addr, options).await {
|
||||
Ok(tcp) => {
|
||||
connected = Some(tcp);
|
||||
break;
|
||||
let tcp = match &validated_target.resolution {
|
||||
UpstreamTargetResolution::ProxyDns => {
|
||||
if !proxy.supports_remote_target_dns() {
|
||||
return Err(
|
||||
io::Error::other("upstream proxy does not support remote target DNS").into(),
|
||||
);
|
||||
}
|
||||
Err(error) => last_error = Some(error),
|
||||
connect_target_via_proxy(
|
||||
&proxy,
|
||||
&validated_target.host,
|
||||
validated_target.port,
|
||||
options,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
let tcp = connected.ok_or_else(|| {
|
||||
last_error.unwrap_or_else(|| io::Error::other("validated upstream target has no addresses"))
|
||||
})?;
|
||||
UpstreamTargetResolution::Pinned(addrs) => {
|
||||
let mut last_error = None;
|
||||
let mut connected = None;
|
||||
for target_addr in addrs.iter().copied() {
|
||||
match connect_validated_target_via_proxy(&proxy, target_addr, options).await {
|
||||
Ok(tcp) => {
|
||||
connected = Some(tcp);
|
||||
break;
|
||||
}
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
connected.ok_or_else(|| {
|
||||
last_error.unwrap_or_else(|| {
|
||||
io::Error::other("validated upstream target has no addresses")
|
||||
})
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
let connect_ms = connect_start.elapsed().as_millis() as u64;
|
||||
|
||||
@@ -512,6 +575,24 @@ fn build_upstream_client_with_protocol(
|
||||
http1_only: bool,
|
||||
h2c_prior_knowledge: bool,
|
||||
) -> Result<UpstreamClient, String> {
|
||||
let proxy = config
|
||||
.upstream_proxy_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(UpstreamProxyConfig::parse)
|
||||
.transpose()?;
|
||||
if validated_target.uses_proxy_dns()
|
||||
&& (!config.upstream_proxy_remote_dns
|
||||
|| !proxy
|
||||
.as_ref()
|
||||
.is_some_and(UpstreamProxyConfig::supports_remote_target_dns))
|
||||
{
|
||||
return Err(
|
||||
"proxy-resolved upstream requires explicit remote DNS and an HTTP or SOCKS5h proxy"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let mut http = HttpConnector::new_with_resolver(PinnedResolver::new(validated_target.clone()));
|
||||
http.enforce_http(false);
|
||||
http.set_connect_timeout(Some(Duration::from_secs(
|
||||
@@ -530,13 +611,7 @@ fn build_upstream_client_with_protocol(
|
||||
http,
|
||||
tls_config: build_tls_config(http1_only),
|
||||
validated_target,
|
||||
proxy: config
|
||||
.upstream_proxy_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(UpstreamProxyConfig::parse)
|
||||
.transpose()?,
|
||||
proxy,
|
||||
connect_timeout: Duration::from_secs(config.upstream_connect_timeout_secs),
|
||||
tcp_nodelay: config.upstream_tcp_nodelay,
|
||||
tcp_keepalive: (config.upstream_tcp_keepalive_secs > 0)
|
||||
@@ -1035,6 +1110,239 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trusted_http_proxy_resolves_hostname_without_local_dns() {
|
||||
let (proxy_url, connect_rx, request_rx) = spawn_http_proxy().await;
|
||||
let client = remote_dns_client(&proxy_url, "http://remote-dns-test.invalid/");
|
||||
let request = hyper::Request::builder()
|
||||
.uri("http://remote-dns-test.invalid/remote-dns")
|
||||
.body(full_request_body(Bytes::new()))
|
||||
.unwrap();
|
||||
let response = tokio::time::timeout(Duration::from_secs(5), client.request(request))
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("proxy should resolve the target without local DNS");
|
||||
assert_eq!(response.status(), hyper::StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.into_body().collect().await.unwrap().to_bytes(),
|
||||
"ok"
|
||||
);
|
||||
assert!(connect_rx
|
||||
.await
|
||||
.unwrap()
|
||||
.starts_with("CONNECT remote-dns-test.invalid:80 HTTP/1.1\r\n"));
|
||||
let request = request_rx.await.unwrap().to_ascii_lowercase();
|
||||
assert!(request.starts_with("get /remote-dns http/1.1\r\n"));
|
||||
assert!(request.contains("\r\nhost: remote-dns-test.invalid\r\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trusted_socks5h_proxy_receives_hostname_not_a_locally_resolved_ip() {
|
||||
let (proxy_url, target_rx, request_rx) = spawn_remote_dns_socks_proxy().await;
|
||||
let client = remote_dns_client(&proxy_url, "http://remote-dns-test.invalid/");
|
||||
let request = hyper::Request::builder()
|
||||
.uri("http://remote-dns-test.invalid/remote-dns")
|
||||
.body(full_request_body(Bytes::new()))
|
||||
.unwrap();
|
||||
let response = tokio::time::timeout(Duration::from_secs(5), client.request(request))
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("SOCKS proxy should receive the unresolved target");
|
||||
assert_eq!(response.status(), hyper::StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.into_body().collect().await.unwrap().to_bytes(),
|
||||
"ok"
|
||||
);
|
||||
assert_eq!(
|
||||
target_rx.await.unwrap(),
|
||||
("remote-dns-test.invalid".to_string(), 80)
|
||||
);
|
||||
assert!(request_rx
|
||||
.await
|
||||
.unwrap()
|
||||
.to_ascii_lowercase()
|
||||
.contains("\r\nhost: remote-dns-test.invalid\r\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_dns_https_preserves_hostname_for_connect_and_sni() {
|
||||
let (proxy_url, connect_rx) = spawn_connect_only_http_proxy().await;
|
||||
let client = remote_dns_client(&proxy_url, "https://remote-dns-test.invalid/");
|
||||
let uri: Uri = "https://remote-dns-test.invalid/secure".parse().unwrap();
|
||||
let request = hyper::Request::builder()
|
||||
.uri(uri.clone())
|
||||
.body(full_request_body(Bytes::new()))
|
||||
.unwrap();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), client.request(request))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(connect_rx
|
||||
.await
|
||||
.unwrap()
|
||||
.starts_with("CONNECT remote-dns-test.invalid:443 HTTP/1.1\r\n"));
|
||||
match resolve_server_name(&uri).unwrap() {
|
||||
ServerName::DnsName(name) => assert_eq!(name.as_ref(), "remote-dns-test.invalid"),
|
||||
other => panic!("expected hostname for TLS verification, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_dns_targets_cannot_fall_back_to_local_dns_or_change_origin() {
|
||||
let url = url::Url::parse("https://remote-dns-test.invalid/").unwrap();
|
||||
let target = ValidatedUpstreamTarget::proxy_resolved(&url).unwrap();
|
||||
let mut resolver = PinnedResolver::new(target.clone());
|
||||
let error = resolver
|
||||
.call("remote-dns-test.invalid".parse().unwrap())
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("must not fall back to local DNS"));
|
||||
for uri in [
|
||||
"http://remote-dns-test.invalid/",
|
||||
"https://another-target.invalid/",
|
||||
"https://remote-dns-test.invalid:8443/",
|
||||
] {
|
||||
assert!(target.ensure_matches_uri(&uri.parse().unwrap()).is_err());
|
||||
}
|
||||
for url in ["http://127.0.0.1/", "https://[::1]/"] {
|
||||
assert!(
|
||||
ValidatedUpstreamTarget::proxy_resolved(&url::Url::parse(url).unwrap()).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_dns_clients_require_opt_in_and_do_not_share_pinned_pool_entries() {
|
||||
let mut config = remote_dns_config("http://127.0.0.1:8080");
|
||||
let url = url::Url::parse("https://remote-dns-test.invalid/").unwrap();
|
||||
let remote = ValidatedUpstreamTarget::proxy_resolved(&url).unwrap();
|
||||
config.upstream_proxy_remote_dns = false;
|
||||
assert!(build_upstream_client_with_protocol(&config, remote.clone(), true, false).is_err());
|
||||
config.upstream_proxy_remote_dns = true;
|
||||
for proxy in [None, Some("socks5://127.0.0.1:1080")] {
|
||||
config.upstream_proxy_url = proxy.map(str::to_string);
|
||||
assert!(
|
||||
build_upstream_client_with_protocol(&config, remote.clone(), true, false).is_err()
|
||||
);
|
||||
}
|
||||
config.upstream_proxy_url = Some("http://127.0.0.1:8080".to_string());
|
||||
let pinned =
|
||||
ValidatedUpstreamTarget::new(&url, vec!["8.8.8.8:443".parse().unwrap()]).unwrap();
|
||||
let remote_key = upstream_client_pool_key(None, None, None, None, false, remote);
|
||||
let pinned_key = upstream_client_pool_key(None, None, None, None, false, pinned);
|
||||
assert_ne!(remote_key, pinned_key);
|
||||
let pool = UpstreamClientPool::new(
|
||||
Arc::new(config),
|
||||
Arc::new(DnsCache::new(Duration::from_secs(60), 16)),
|
||||
);
|
||||
pool.get_or_build(remote_key).unwrap();
|
||||
pool.get_or_build(pinned_key).unwrap();
|
||||
assert_eq!(pool.clients.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_dns_proxy_connect_timeout_covers_connect_and_tls_handshakes() {
|
||||
for tls in [false, true] {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let proxy_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
read_http_headers(&mut stream).await;
|
||||
if tls {
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
std::future::pending::<()>().await;
|
||||
drop(stream);
|
||||
});
|
||||
let target_url = if tls {
|
||||
"https://remote-dns-test.invalid/"
|
||||
} else {
|
||||
"http://remote-dns-test.invalid/"
|
||||
};
|
||||
let client = remote_dns_client(&proxy_url, target_url);
|
||||
let request = hyper::Request::builder()
|
||||
.uri(target_url)
|
||||
.body(full_request_body(Bytes::new()))
|
||||
.unwrap();
|
||||
let result =
|
||||
tokio::time::timeout(Duration::from_secs(5), client.request(request)).await;
|
||||
server.abort();
|
||||
let error = result
|
||||
.expect("configured connect timeout must include proxy and TLS handshakes")
|
||||
.unwrap_err();
|
||||
assert!(error.is_connect());
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_dns_config(proxy_url: &str) -> Config {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
Config::parse_from([
|
||||
"aether-tunnel",
|
||||
"--aether-url",
|
||||
"https://example.com",
|
||||
"--management-token",
|
||||
"ae_test",
|
||||
"--node-name",
|
||||
"tunnel-test",
|
||||
"--upstream-proxy-url",
|
||||
proxy_url,
|
||||
"--upstream-proxy-remote-dns",
|
||||
"--upstream-connect-timeout-secs",
|
||||
"1",
|
||||
])
|
||||
}
|
||||
|
||||
fn remote_dns_client(proxy_url: &str, target_url: &str) -> UpstreamClient {
|
||||
let config = remote_dns_config(proxy_url);
|
||||
let target =
|
||||
ValidatedUpstreamTarget::proxy_resolved(&url::Url::parse(target_url).unwrap()).unwrap();
|
||||
build_upstream_client_with_protocol(&config, target, true, false).unwrap()
|
||||
}
|
||||
|
||||
async fn spawn_remote_dns_socks_proxy() -> (
|
||||
String,
|
||||
tokio::sync::oneshot::Receiver<(String, u16)>,
|
||||
tokio::sync::oneshot::Receiver<String>,
|
||||
) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let proxy_url = format!("socks5h://{}", listener.local_addr().unwrap());
|
||||
let (target_tx, target_rx) = tokio::sync::oneshot::channel();
|
||||
let (request_tx, request_rx) = tokio::sync::oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut greeting = [0u8; 3];
|
||||
stream.read_exact(&mut greeting).await.unwrap();
|
||||
assert_eq!(greeting, [0x05, 0x01, 0x00]);
|
||||
stream.write_all(&[0x05, 0x00]).await.unwrap();
|
||||
let mut header = [0u8; 5];
|
||||
stream.read_exact(&mut header).await.unwrap();
|
||||
assert_eq!(&header[..4], &[0x05, 0x01, 0x00, 0x03]);
|
||||
let mut hostname = vec![0; header[4] as usize];
|
||||
stream.read_exact(&mut hostname).await.unwrap();
|
||||
let port = stream.read_u16().await.unwrap();
|
||||
target_tx
|
||||
.send((String::from_utf8(hostname).unwrap(), port))
|
||||
.unwrap();
|
||||
stream
|
||||
.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
.await
|
||||
.unwrap();
|
||||
request_tx
|
||||
.send(read_http_headers(&mut stream).await)
|
||||
.unwrap();
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
(proxy_url, target_rx, request_rx)
|
||||
}
|
||||
|
||||
fn proxied_client(
|
||||
proxy_url: &str,
|
||||
target_url: &str,
|
||||
|
||||
Reference in New Issue
Block a user