mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +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:
@@ -170,12 +170,13 @@ Linux/macOS 可运行 `sudo aether-tunnel upgrade [version]`。自更新只接
|
||||
|
||||
| 参数 | 环境变量 | 默认值 | 说明 |
|
||||
|------|----------|--------|------|
|
||||
| `--upstream-connect-timeout-secs` | `AETHER_TUNNEL_UPSTREAM_CONNECT_TIMEOUT_SECS` | `30` | 上游建连超时(秒) |
|
||||
| `--upstream-connect-timeout-secs` | `AETHER_TUNNEL_UPSTREAM_CONNECT_TIMEOUT` | `30` | 上游建连超时(秒) |
|
||||
| `--upstream-pool-max-idle-per-host` | `AETHER_TUNNEL_UPSTREAM_POOL_MAX_IDLE_PER_HOST` | `64` | 每 Host 最大空闲连接数 |
|
||||
| `--upstream-pool-idle-timeout-secs` | `AETHER_TUNNEL_UPSTREAM_POOL_IDLE_TIMEOUT_SECS` | `300` | 连接池空闲超时(秒) |
|
||||
| `--upstream-tcp-keepalive-secs` | `AETHER_TUNNEL_UPSTREAM_TCP_KEEPALIVE_SECS` | `60` | TCP keepalive(秒,0 关闭) |
|
||||
| `--upstream-pool-idle-timeout-secs` | `AETHER_TUNNEL_UPSTREAM_POOL_IDLE_TIMEOUT` | `300` | 连接池空闲超时(秒) |
|
||||
| `--upstream-tcp-keepalive-secs` | `AETHER_TUNNEL_UPSTREAM_TCP_KEEPALIVE` | `60` | TCP keepalive(秒,0 关闭) |
|
||||
| `--upstream-tcp-nodelay` | `AETHER_TUNNEL_UPSTREAM_TCP_NODELAY` | `true` | 启用 TCP_NODELAY |
|
||||
| `--upstream-proxy-url` | `AETHER_TUNNEL_UPSTREAM_PROXY_URL` | 空 | 仅 provider 上游请求使用的出口代理 |
|
||||
| `--upstream-proxy-remote-dns` | `AETHER_TUNNEL_UPSTREAM_PROXY_REMOTE_DNS` | `false` | 显式信任 HTTP/SOCKS5h 代理解析供应商域名并执行目标 IP 访问控制;需重启 |
|
||||
|
||||
启用 `follow_redirects` 后,同源 307/308 会在请求体不超过 5 MiB 时重放。首个上游请求始终流式传输;超过重放预算时不会拒绝或截断原请求,而是将 307/308 响应原样返回给调用方。
|
||||
|
||||
@@ -185,14 +186,38 @@ Linux/macOS 可运行 `sudo aether-tunnel upgrade [version]`。自更新只接
|
||||
upstream_proxy_url = "socks5h://microwarp:1080"
|
||||
```
|
||||
|
||||
默认仍由隧道本机解析供应商域名、执行端口/IP ACL,再把已校验的 IP 交给代理;仅配置
|
||||
`socks5h://` 不会跳过本地 DNS。这保留现有的防 DNS 重绑定及内网访问边界。
|
||||
|
||||
如果隧道本机 DNS 不可用、被污染或返回不可路由的 Fake-IP,可显式委托**受信任且配置了
|
||||
目的地址访问控制的代理**解析域名。在 TOML 顶层(第一个 `[[servers]]` 之前)配置:
|
||||
|
||||
```toml
|
||||
upstream_proxy_url = "socks5h://microwarp:1080"
|
||||
upstream_proxy_remote_dns = true
|
||||
```
|
||||
|
||||
也可启用环境变量 `AETHER_TUNNEL_UPSTREAM_PROXY_REMOTE_DNS=true`、CLI 参数
|
||||
`--upstream-proxy-remote-dns` 或 setup 中的 `Proxy Remote DNS` 开关,保存后重启。
|
||||
该模式仅支持 `http://` 和 `socks5h://`,不支持本地解析语义的 `socks5://`;未配置代理时
|
||||
启动会报错。域名原样交给 HTTP CONNECT/SOCKS5h,HTTP Host 和 TLS SNI/证书校验仍使用
|
||||
原域名,不会在失败时偷偷回退到本地 DNS。
|
||||
|
||||
**安全边界:**普通 HTTP CONNECT/SOCKS5 不能让隧道校验代理最终解析出的目标 IP,因此
|
||||
启用该模式代表把域名目标的 IP ACL 委托给代理,而不只是换一个 DNS 服务器。隧道仍检查
|
||||
端口、URL 凭据/fragment、`localhost` 和 IP 字面地址;默认继续拒绝私网/保留 IP 字面地址。
|
||||
这不需要打开 `allow_private_targets`。代理本身的域名仍需本地解析;如果本地 DNS 完全
|
||||
不可用,使用代理 IP 地址或修复本地解析。代理 DNS、TCP、CONNECT/SOCKS 和 TLS 握手共同
|
||||
受 `upstream_connect_timeout_secs` 限制。
|
||||
|
||||
如果需要让 Aether 管理 API 和 WebSocket tunnel 也走代理,使用 `aether_outbound_proxy_url`。
|
||||
|
||||
#### Aether API 客户端
|
||||
|
||||
| 参数 | 环境变量 | 默认值 | 说明 |
|
||||
|------|----------|--------|------|
|
||||
| `--aether-request-timeout-secs` | `AETHER_TUNNEL_AETHER_REQUEST_TIMEOUT_SECS` | `10` | 请求总超时(秒) |
|
||||
| `--aether-connect-timeout-secs` | `AETHER_TUNNEL_AETHER_CONNECT_TIMEOUT_SECS` | `10` | 建连超时(秒) |
|
||||
| `--aether-request-timeout-secs` | `AETHER_TUNNEL_AETHER_REQUEST_TIMEOUT` | `10` | 请求总超时(秒) |
|
||||
| `--aether-connect-timeout-secs` | `AETHER_TUNNEL_AETHER_CONNECT_TIMEOUT` | `10` | 建连超时(秒) |
|
||||
| `--aether-outbound-proxy-url` | `AETHER_TUNNEL_AETHER_OUTBOUND_PROXY_URL` | 空 | Aether 注册、心跳和 WebSocket tunnel 回连使用的出口代理(默认不走代理) |
|
||||
| `--aether-retry-max-attempts` | `AETHER_TUNNEL_AETHER_RETRY_MAX_ATTEMPTS` | `3` | 最大重试次数 |
|
||||
|
||||
@@ -201,7 +226,7 @@ upstream_proxy_url = "socks5h://microwarp:1080"
|
||||
| 参数 | 环境变量 | 默认值 | 说明 |
|
||||
|------|----------|--------|------|
|
||||
| `--allow-private-targets` | `AETHER_TUNNEL_ALLOW_PRIVATE_TARGETS` | `false` | 默认拦截 private/reserved 目标地址;仅在明确需要访问内网服务时设为 `true`,且仅影响重启后的进程 |
|
||||
| `--dns-cache-ttl-secs` | `AETHER_TUNNEL_DNS_CACHE_TTL_SECS` | `60` | DNS 缓存 TTL(秒) |
|
||||
| `--dns-cache-ttl-secs` | `AETHER_TUNNEL_DNS_CACHE_TTL` | `60` | DNS 缓存 TTL(秒) |
|
||||
| `--dns-cache-capacity` | `AETHER_TUNNEL_DNS_CACHE_CAPACITY` | `1024` | DNS 缓存容量(条目数) |
|
||||
|
||||
#### 日志
|
||||
|
||||
@@ -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