feat(proxy): 支持配置 private 目标地址放行

This commit is contained in:
fawney19
2026-04-15 00:31:36 +08:00
parent a4e7ac1df6
commit 05fbbac493
8 changed files with 128 additions and 22 deletions

View File

@@ -76,6 +76,7 @@ sudo aether-proxy uninstall
| `--node-region` | `AETHER_PROXY_NODE_REGION` | 自动检测 | 地区标识 | | `--node-region` | `AETHER_PROXY_NODE_REGION` | 自动检测 | 地区标识 |
| `--heartbeat-interval` | `AETHER_PROXY_HEARTBEAT_INTERVAL` | `5` | 心跳间隔(秒) | | `--heartbeat-interval` | `AETHER_PROXY_HEARTBEAT_INTERVAL` | `5` | 心跳间隔(秒) |
| `--allowed-ports` | `AETHER_PROXY_ALLOWED_PORTS` | `80,443,8080,8443` | 允许代理的目标端口 | | `--allowed-ports` | `AETHER_PROXY_ALLOWED_PORTS` | `80,443,8080,8443` | 允许代理的目标端口 |
| `--allow-private-targets` | `AETHER_PROXY_ALLOW_PRIVATE_TARGETS` | `true` | 允许 private/reserved 目标地址,通过后仍受 `allowed_ports` 限制;设为 `false` 可恢复严格拦截 |
#### Tunnel 连接 #### Tunnel 连接
@@ -121,6 +122,7 @@ sudo aether-proxy uninstall
| 参数 | 环境变量 | 默认值 | 说明 | | 参数 | 环境变量 | 默认值 | 说明 |
|------|----------|--------|------| |------|----------|--------|------|
| `--allow-private-targets` | `AETHER_PROXY_ALLOW_PRIVATE_TARGETS` | `true` | 默认允许 private/reserved 目标地址;设为 `false` 可恢复拦截,且仅影响重启后的进程 |
| `--dns-cache-ttl-secs` | `AETHER_PROXY_DNS_CACHE_TTL_SECS` | `60` | DNS 缓存 TTL | | `--dns-cache-ttl-secs` | `AETHER_PROXY_DNS_CACHE_TTL_SECS` | `60` | DNS 缓存 TTL |
| `--dns-cache-capacity` | `AETHER_PROXY_DNS_CACHE_CAPACITY` | `1024` | DNS 缓存容量(条目数) | | `--dns-cache-capacity` | `AETHER_PROXY_DNS_CACHE_CAPACITY` | `1024` | DNS 缓存容量(条目数) |

View File

@@ -940,6 +940,7 @@ mod tests {
node_region: None, node_region: None,
heartbeat_interval: 1, heartbeat_interval: 1,
allowed_ports: vec![80, 443], allowed_ports: vec![80, 443],
allow_private_targets: false,
aether_request_timeout_secs: 10, aether_request_timeout_secs: 10,
aether_connect_timeout_secs: 2, aether_connect_timeout_secs: 2,
aether_pool_max_idle_per_host: 8, aether_pool_max_idle_per_host: 8,

View File

@@ -284,6 +284,14 @@ pub struct Config {
)] )]
pub allowed_ports: Vec<u16>, pub allowed_ports: Vec<u16>,
/// Allow private/reserved upstream IP targets. Enabled by default.
#[arg(
long,
env = "AETHER_PROXY_ALLOW_PRIVATE_TARGETS",
default_value_t = true
)]
pub allow_private_targets: bool,
/// Aether API request timeout in seconds /// Aether API request timeout in seconds
#[arg( #[arg(
long, long,
@@ -808,6 +816,8 @@ pub struct ConfigFile {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub allowed_ports: Option<Vec<u16>>, pub allowed_ports: Option<Vec<u16>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub allow_private_targets: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aether_request_timeout_secs: Option<u64>, pub aether_request_timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub aether_connect_timeout_secs: Option<u64>, pub aether_connect_timeout_secs: Option<u64>,
@@ -950,6 +960,10 @@ impl ConfigFile {
set!("AETHER_PROXY_NODE_NAME", node_name); set!("AETHER_PROXY_NODE_NAME", node_name);
set!("AETHER_PROXY_NODE_REGION", self.node_region); set!("AETHER_PROXY_NODE_REGION", self.node_region);
set!("AETHER_PROXY_HEARTBEAT_INTERVAL", self.heartbeat_interval); set!("AETHER_PROXY_HEARTBEAT_INTERVAL", self.heartbeat_interval);
set!(
"AETHER_PROXY_ALLOW_PRIVATE_TARGETS",
self.allow_private_targets
);
set!( set!(
"AETHER_PROXY_AETHER_REQUEST_TIMEOUT", "AETHER_PROXY_AETHER_REQUEST_TIMEOUT",
self.aether_request_timeout_secs self.aether_request_timeout_secs
@@ -1182,6 +1196,12 @@ mod tests {
assert_eq!(stringy.redirect_replay_budget_bytes.as_deref(), Some("6M")); assert_eq!(stringy.redirect_replay_budget_bytes.as_deref(), Some("6M"));
} }
#[test]
fn config_file_deserializes_allow_private_targets() {
let cfg: ConfigFile = toml::from_str("allow_private_targets = true").expect("bool toml");
assert_eq!(cfg.allow_private_targets, Some(true));
}
#[test] #[test]
fn config_file_rejects_removed_tunnel_seconds_keys() { fn config_file_rejects_removed_tunnel_seconds_keys() {
let error = reject_removed_config_keys("tunnel_ping_interval_secs = 5") let error = reject_removed_config_keys("tunnel_ping_interval_secs = 5")
@@ -1224,6 +1244,20 @@ mod tests {
assert!(node_name.get_default_values().is_empty()); assert!(node_name.get_default_values().is_empty());
} }
#[test]
fn cli_defaults_private_targets_to_enabled() {
let config = Config::parse_from([
"aether-proxy",
"--aether-url",
"https://example.com",
"--management-token",
"ae_test",
"--node-name",
"proxy-test",
]);
assert!(config.allow_private_targets);
}
#[test] #[test]
fn tunnel_fast_recovery_defaults_use_millisecond_values() { fn tunnel_fast_recovery_defaults_use_millisecond_values() {
let config = Config::parse_from([ let config = Config::parse_from([

View File

@@ -166,6 +166,15 @@ impl App {
required: false, required: false,
help: "Write pretty .log files with daily rotation and 7-day retention", help: "Write pretty .log files with daily rotation and 7-day retention",
}, },
Field {
label: "Allow Private Targets",
key: "allow_private_targets",
value: "true".into(),
kind: FieldKind::Bool,
required: false,
help:
"Allow proxying private/reserved upstream IPs by default; takes effect after restart",
},
Field { Field {
label: "Heartbeat Interval", label: "Heartbeat Interval",
key: "heartbeat_interval", key: "heartbeat_interval",
@@ -254,6 +263,7 @@ impl App {
) )
.to_string() .to_string()
}), }),
"allow_private_targets" => cfg.allow_private_targets.map(|v| v.to_string()),
"heartbeat_interval" => cfg.heartbeat_interval.map(|v| v.to_string()), "heartbeat_interval" => cfg.heartbeat_interval.map(|v| v.to_string()),
"redirect_replay_budget_bytes" => cfg.redirect_replay_budget_bytes.clone(), "redirect_replay_budget_bytes" => cfg.redirect_replay_budget_bytes.clone(),
_ => None, _ => None,
@@ -362,6 +372,7 @@ impl App {
let save_logs_to_file = self.toggle_enabled("save_logs_to_file"); let save_logs_to_file = self.toggle_enabled("save_logs_to_file");
let mut cfg = ConfigFile { let mut cfg = ConfigFile {
log_level: get_global("log_level"), log_level: get_global("log_level"),
allow_private_targets: Some(self.toggle_enabled("allow_private_targets")),
heartbeat_interval: self.parse_optional_heartbeat_interval()?, heartbeat_interval: self.parse_optional_heartbeat_interval()?,
redirect_replay_budget_bytes: self.parse_optional_redirect_replay_budget()?, redirect_replay_budget_bytes: self.parse_optional_redirect_replay_budget()?,
log_destination: Some(if save_logs_to_file { log_destination: Some(if save_logs_to_file {
@@ -1089,10 +1100,12 @@ mod tests {
#[test] #[test]
fn to_config_persists_optional_heartbeat_interval() { fn to_config_persists_optional_heartbeat_interval() {
let mut app = sample_app(); let mut app = sample_app();
set_global_field(&mut app, "allow_private_targets", "true");
set_global_field(&mut app, "heartbeat_interval", "45"); set_global_field(&mut app, "heartbeat_interval", "45");
set_global_field(&mut app, "redirect_replay_budget_bytes", "6m"); set_global_field(&mut app, "redirect_replay_budget_bytes", "6m");
let cfg = app.to_config().expect("config should serialize"); let cfg = app.to_config().expect("config should serialize");
assert_eq!(cfg.allow_private_targets, Some(true));
assert_eq!(cfg.heartbeat_interval, Some(45)); assert_eq!(cfg.heartbeat_interval, Some(45));
assert_eq!(cfg.redirect_replay_budget_bytes.as_deref(), Some("6M")); assert_eq!(cfg.redirect_replay_budget_bytes.as_deref(), Some("6M"));
} }
@@ -1131,7 +1144,12 @@ mod tests {
let config_path = unique_temp_config_path("ctrl-s"); let config_path = unique_temp_config_path("ctrl-s");
let mut app = sample_app(); let mut app = sample_app();
app.config_path = config_path.clone(); app.config_path = config_path.clone();
app.selected = app.server_field_count() + 3; let heartbeat_idx = app
.global_fields
.iter()
.position(|field| field.key == "heartbeat_interval")
.expect("heartbeat field");
app.selected = app.server_field_count() + heartbeat_idx;
app.mode = Mode::Editing; app.mode = Mode::Editing;
app.edit_buffer = "45".to_string(); app.edit_buffer = "45".to_string();
app.edit_cursor = 2; app.edit_cursor = 2;

View File

@@ -210,13 +210,15 @@ impl DnsCache {
} }
} }
/// Resolve a hostname to public (non-private) socket addresses. /// Resolve a hostname to validated socket addresses.
/// ///
/// Results are cached in `dns_cache`. Private/reserved IPs are filtered out. /// Results are cached in `dns_cache`. Private/reserved IPs are filtered out
/// Returns an error if no public addresses remain after filtering. /// unless `allow_private` is enabled. Returns an error if filtering removes
/// every resolved address.
pub async fn resolve_public_addrs( pub async fn resolve_public_addrs(
host: &str, host: &str,
port: u16, port: u16,
allow_private: bool,
dns_cache: &DnsCache, dns_cache: &DnsCache,
) -> Result<Vec<SocketAddr>, FilterError> { ) -> Result<Vec<SocketAddr>, FilterError> {
// Cache hit // Cache hit
@@ -235,11 +237,15 @@ pub async fn resolve_public_addrs(
return Err(FilterError::DnsResolutionFailed(host.to_string())); return Err(FilterError::DnsResolutionFailed(host.to_string()));
} }
// Filter out private/reserved addresses // Filter out private/reserved addresses unless explicitly allowed.
let public: Vec<SocketAddr> = resolved let public: Vec<SocketAddr> = if allow_private {
.into_iter() resolved
.filter(|addr| !is_private_ip(&addr.ip())) } else {
.collect(); resolved
.into_iter()
.filter(|addr| !is_private_ip(&addr.ip()))
.collect()
};
if public.is_empty() { if public.is_empty() {
return Err(FilterError::NoPublicAddrs(host.to_string())); return Err(FilterError::NoPublicAddrs(host.to_string()));
@@ -260,6 +266,7 @@ pub async fn validate_target(
host: &str, host: &str,
port: u16, port: u16,
allowed_ports: &HashSet<u16>, allowed_ports: &HashSet<u16>,
allow_private: bool,
dns_cache: &DnsCache, dns_cache: &DnsCache,
) -> Result<Vec<SocketAddr>, FilterError> { ) -> Result<Vec<SocketAddr>, FilterError> {
// Port whitelist check // Port whitelist check
@@ -269,14 +276,14 @@ pub async fn validate_target(
// Try parsing as IP directly (no DNS needed) // Try parsing as IP directly (no DNS needed)
if let Ok(ip) = host.parse::<IpAddr>() { if let Ok(ip) = host.parse::<IpAddr>() {
if is_private_ip(&ip) { if !allow_private && is_private_ip(&ip) {
return Err(FilterError::PrivateIp(ip)); return Err(FilterError::PrivateIp(ip));
} }
return Ok(vec![SocketAddr::new(ip, port)]); return Ok(vec![SocketAddr::new(ip, port)]);
} }
// Resolve and validate DNS (populates cache for SafeDnsResolver) // Resolve and validate DNS (populates cache for SafeDnsResolver)
resolve_public_addrs(host, port, dns_cache).await resolve_public_addrs(host, port, allow_private, dns_cache).await
} }
#[cfg(test)] #[cfg(test)]
@@ -333,27 +340,54 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_port_not_allowed() { async fn test_port_not_allowed() {
let cache = cache(); let cache = cache();
let result = validate_target("8.8.8.8", 22, &ports(), &cache).await; let result = validate_target("8.8.8.8", 22, &ports(), false, &cache).await;
assert!(matches!(result, Err(FilterError::PortNotAllowed(22)))); assert!(matches!(result, Err(FilterError::PortNotAllowed(22))));
} }
#[tokio::test] #[tokio::test]
async fn test_private_ip_blocked() { async fn test_private_ip_blocked() {
let cache = cache(); let cache = cache();
let result = validate_target("127.0.0.1", 80, &ports(), &cache).await; let result = validate_target("127.0.0.1", 80, &ports(), false, &cache).await;
assert!(matches!(result, Err(FilterError::PrivateIp(_)))); assert!(matches!(result, Err(FilterError::PrivateIp(_))));
} }
#[tokio::test] #[tokio::test]
async fn test_public_ip_allowed() { async fn test_public_ip_allowed() {
let cache = cache(); let cache = cache();
let result = validate_target("8.8.8.8", 443, &ports(), &cache).await; let result = validate_target("8.8.8.8", 443, &ports(), false, &cache).await;
assert!(result.is_ok()); assert!(result.is_ok());
let addrs = result.unwrap(); let addrs = result.unwrap();
assert_eq!(addrs.len(), 1); assert_eq!(addrs.len(), 1);
assert_eq!(addrs[0].ip(), IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))); assert_eq!(addrs[0].ip(), IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)));
} }
#[tokio::test]
async fn test_private_ip_allowed_when_enabled() {
let cache = cache();
let result = validate_target("127.0.0.1", 80, &ports(), true, &cache).await;
assert!(result.is_ok());
let addrs = result.unwrap();
assert_eq!(
addrs,
vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 80)]
);
}
#[tokio::test]
async fn test_localhost_hostname_blocked_by_default() {
let cache = cache();
let result = validate_target("localhost", 80, &ports(), false, &cache).await;
assert!(matches!(result, Err(FilterError::NoPublicAddrs(_))));
}
#[tokio::test]
async fn test_localhost_hostname_allowed_when_enabled() {
let cache = cache();
let result = validate_target("localhost", 80, &ports(), true, &cache).await;
assert!(result.is_ok());
assert!(!result.unwrap().is_empty());
}
#[tokio::test] #[tokio::test]
async fn test_cache_stores_multiple_addrs() { async fn test_cache_stores_multiple_addrs() {
let cache = cache(); let cache = cache();

View File

@@ -479,6 +479,7 @@ mod tests {
node_region: None, node_region: None,
heartbeat_interval: 1, heartbeat_interval: 1,
allowed_ports: vec![80, 443], allowed_ports: vec![80, 443],
allow_private_targets: false,
aether_request_timeout_secs: 10, aether_request_timeout_secs: 10,
aether_connect_timeout_secs: 2, aether_connect_timeout_secs: 2,
aether_pool_max_idle_per_host: 8, aether_pool_max_idle_per_host: 8,

View File

@@ -381,8 +381,14 @@ async fn execute_upstream_request(
let dns_start = Instant::now(); let dns_start = Instant::now();
{ {
let allowed_ports = Arc::clone(&server.dynamic.load().allowed_ports); let allowed_ports = Arc::clone(&server.dynamic.load().allowed_ports);
if let Err(error) = if let Err(error) = target_filter::validate_target(
target_filter::validate_target(host, port, &allowed_ports, &state.dns_cache).await host,
port,
&allowed_ports,
state.config.allow_private_targets,
&state.dns_cache,
)
.await
{ {
server.metrics.dns_failures.fetch_add(1, Ordering::Release); server.metrics.dns_failures.fetch_add(1, Ordering::Release);
return Err(format!("target blocked: {error}")); return Err(format!("target blocked: {error}"));
@@ -1560,6 +1566,7 @@ mod tests {
node_region: None, node_region: None,
heartbeat_interval: 30, heartbeat_interval: 30,
allowed_ports: vec![80, 443], allowed_ports: vec![80, 443],
allow_private_targets: false,
aether_request_timeout_secs: 10, aether_request_timeout_secs: 10,
aether_connect_timeout_secs: 10, aether_connect_timeout_secs: 10,
aether_pool_max_idle_per_host: 8, aether_pool_max_idle_per_host: 8,

View File

@@ -61,11 +61,15 @@ pub struct RequestTiming {
#[derive(Clone)] #[derive(Clone)]
pub struct ValidatedResolver { pub struct ValidatedResolver {
dns_cache: Arc<DnsCache>, dns_cache: Arc<DnsCache>,
allow_private: bool,
} }
impl ValidatedResolver { impl ValidatedResolver {
pub fn new(dns_cache: Arc<DnsCache>) -> Self { pub fn new(dns_cache: Arc<DnsCache>, allow_private: bool) -> Self {
Self { dns_cache } Self {
dns_cache,
allow_private,
}
} }
} }
@@ -92,6 +96,7 @@ impl Service<Name> for ValidatedResolver {
fn call(&mut self, name: Name) -> Self::Future { fn call(&mut self, name: Name) -> Self::Future {
let dns_cache = Arc::clone(&self.dns_cache); let dns_cache = Arc::clone(&self.dns_cache);
let allow_private = self.allow_private;
let host = name.as_str().to_string(); let host = name.as_str().to_string();
Box::pin(async move { Box::pin(async move {
if let Some(addrs) = dns_cache.get_by_host(&host).await { if let Some(addrs) = dns_cache.get_by_host(&host).await {
@@ -100,9 +105,10 @@ impl Service<Name> for ValidatedResolver {
}); });
} }
let resolved = target_filter::resolve_public_addrs(&host, 0, dns_cache.as_ref()) let resolved =
.await target_filter::resolve_public_addrs(&host, 0, allow_private, dns_cache.as_ref())
.map_err(|err| io::Error::other(err.to_string()))?; .await
.map_err(|err| io::Error::other(err.to_string()))?;
Ok(ValidatedAddrs { Ok(ValidatedAddrs {
inner: resolved.into_iter(), inner: resolved.into_iter(),
}) })
@@ -184,7 +190,10 @@ fn build_upstream_client_with_protocol(
dns_cache: Arc<DnsCache>, dns_cache: Arc<DnsCache>,
http1_only: bool, http1_only: bool,
) -> UpstreamClient { ) -> UpstreamClient {
let mut http = HttpConnector::new_with_resolver(ValidatedResolver::new(dns_cache)); let mut http = HttpConnector::new_with_resolver(ValidatedResolver::new(
dns_cache,
config.allow_private_targets,
));
http.enforce_http(false); http.enforce_http(false);
http.set_connect_timeout(Some(Duration::from_secs( http.set_connect_timeout(Some(Duration::from_secs(
config.upstream_connect_timeout_secs, config.upstream_connect_timeout_secs,