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

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

View File

@@ -284,6 +284,14 @@ pub struct Config {
)]
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
#[arg(
long,
@@ -808,6 +816,8 @@ pub struct ConfigFile {
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_ports: Option<Vec<u16>>,
#[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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aether_connect_timeout_secs: Option<u64>,
@@ -950,6 +960,10 @@ impl ConfigFile {
set!("AETHER_PROXY_NODE_NAME", node_name);
set!("AETHER_PROXY_NODE_REGION", self.node_region);
set!("AETHER_PROXY_HEARTBEAT_INTERVAL", self.heartbeat_interval);
set!(
"AETHER_PROXY_ALLOW_PRIVATE_TARGETS",
self.allow_private_targets
);
set!(
"AETHER_PROXY_AETHER_REQUEST_TIMEOUT",
self.aether_request_timeout_secs
@@ -1182,6 +1196,12 @@ mod tests {
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]
fn config_file_rejects_removed_tunnel_seconds_keys() {
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());
}
#[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]
fn tunnel_fast_recovery_defaults_use_millisecond_values() {
let config = Config::parse_from([

View File

@@ -166,6 +166,15 @@ impl App {
required: false,
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 {
label: "Heartbeat Interval",
key: "heartbeat_interval",
@@ -254,6 +263,7 @@ impl App {
)
.to_string()
}),
"allow_private_targets" => cfg.allow_private_targets.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(),
_ => None,
@@ -362,6 +372,7 @@ impl App {
let save_logs_to_file = self.toggle_enabled("save_logs_to_file");
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()?,
redirect_replay_budget_bytes: self.parse_optional_redirect_replay_budget()?,
log_destination: Some(if save_logs_to_file {
@@ -1089,10 +1100,12 @@ mod tests {
#[test]
fn to_config_persists_optional_heartbeat_interval() {
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, "redirect_replay_budget_bytes", "6m");
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.redirect_replay_budget_bytes.as_deref(), Some("6M"));
}
@@ -1131,7 +1144,12 @@ mod tests {
let config_path = unique_temp_config_path("ctrl-s");
let mut app = sample_app();
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.edit_buffer = "45".to_string();
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.
/// Returns an error if no public addresses remain after filtering.
/// Results are cached in `dns_cache`. Private/reserved IPs are filtered out
/// unless `allow_private` is enabled. Returns an error if filtering removes
/// every resolved address.
pub async fn resolve_public_addrs(
host: &str,
port: u16,
allow_private: bool,
dns_cache: &DnsCache,
) -> Result<Vec<SocketAddr>, FilterError> {
// Cache hit
@@ -235,11 +237,15 @@ pub async fn resolve_public_addrs(
return Err(FilterError::DnsResolutionFailed(host.to_string()));
}
// Filter out private/reserved addresses
let public: Vec<SocketAddr> = resolved
.into_iter()
.filter(|addr| !is_private_ip(&addr.ip()))
.collect();
// Filter out private/reserved addresses unless explicitly allowed.
let public: Vec<SocketAddr> = if allow_private {
resolved
} else {
resolved
.into_iter()
.filter(|addr| !is_private_ip(&addr.ip()))
.collect()
};
if public.is_empty() {
return Err(FilterError::NoPublicAddrs(host.to_string()));
@@ -260,6 +266,7 @@ pub async fn validate_target(
host: &str,
port: u16,
allowed_ports: &HashSet<u16>,
allow_private: bool,
dns_cache: &DnsCache,
) -> Result<Vec<SocketAddr>, FilterError> {
// Port whitelist check
@@ -269,14 +276,14 @@ pub async fn validate_target(
// Try parsing as IP directly (no DNS needed)
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 Ok(vec![SocketAddr::new(ip, port)]);
}
// 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)]
@@ -333,27 +340,54 @@ mod tests {
#[tokio::test]
async fn test_port_not_allowed() {
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))));
}
#[tokio::test]
async fn test_private_ip_blocked() {
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(_))));
}
#[tokio::test]
async fn test_public_ip_allowed() {
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());
let addrs = result.unwrap();
assert_eq!(addrs.len(), 1);
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]
async fn test_cache_stores_multiple_addrs() {
let cache = cache();

View File

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

View File

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

View File

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