mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(proxy): 支持配置 private 目标地址放行
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user