refactor(proxy): 简化 HMAC 认证、移除明文 HTTP 代理并优化 setup 流程

- HMAC 签名移除 node_id,仅使用 timestamp,消除重注册时的认证竞态问题
- 删除 plain HTTP forward proxy(plain.rs),仅保留 CONNECT 隧道和 delegate,非支持方法返回 405
- 提取共享 BoxBody 类型和 empty_box_body() 到 proxy/mod.rs
- CLI 解析重构为 clap 原生 subcommand,启用 subcommand_negates_reqs
- setup 向导返回 SetupOutcome 枚举,支持保存后自动启动 proxy
- setup TUI 增加未保存变更的退出确认(pending_quit)
- validate_target 改为 async,使用 tokio::net::lookup_host 避免阻塞 DNS
- 显式初始化 rustls ring CryptoProvider
- ConfigFile 新增 inject_env_override() 用于 setup 后重载配置
- Python 端 HMAC 签名同步移除 node_id,缓存时间桶从 120s 调整为 240s
This commit is contained in:
fawney19
2026-02-08 16:10:13 +08:00
parent f07cae540e
commit 183e7c60e1
15 changed files with 261 additions and 357 deletions

View File

@@ -26,7 +26,7 @@ hex = "0.4"
anyhow = "1" anyhow = "1"
toml = "0.8" toml = "0.8"
tokio-rustls = "0.26" tokio-rustls = "0.26"
rustls = "0.23" rustls = { version = "0.23", features = ["ring"] }
rustls-pki-types = "1" rustls-pki-types = "1"
rustls-pemfile = "2" rustls-pemfile = "2"
rcgen = "0.13" rcgen = "0.13"

View File

@@ -37,7 +37,11 @@ impl std::fmt::Display for AuthError {
/// Validate Proxy-Authorization header. /// Validate Proxy-Authorization header.
/// ///
/// Expected format: `Basic base64(hmac:{timestamp}.{signature})` /// Expected format: `Basic base64(hmac:{timestamp}.{signature})`
/// where signature = hex(HMAC-SHA256(hmac_key, "{timestamp}\n{node_id}")) /// where signature = hex(HMAC-SHA256(hmac_key, "{timestamp}"))
///
/// The signature no longer includes `node_id`, eliminating race conditions
/// during re-registration where the Aether server's cached `node_id` could
/// differ from the proxy's freshly assigned `node_id`.
/// ///
/// `timestamp_tolerance` is accepted separately so the caller can supply /// `timestamp_tolerance` is accepted separately so the caller can supply
/// the value from [`DynamicConfig`](crate::runtime::DynamicConfig) (which /// the value from [`DynamicConfig`](crate::runtime::DynamicConfig) (which
@@ -45,7 +49,6 @@ impl std::fmt::Display for AuthError {
pub fn validate_proxy_auth( pub fn validate_proxy_auth(
proxy_auth_header: Option<&str>, proxy_auth_header: Option<&str>,
config: &Config, config: &Config,
node_id: &str,
timestamp_tolerance: u64, timestamp_tolerance: u64,
) -> Result<(), AuthError> { ) -> Result<(), AuthError> {
let header = proxy_auth_header.ok_or(AuthError::MissingHeader)?; let header = proxy_auth_header.ok_or(AuthError::MissingHeader)?;
@@ -88,11 +91,10 @@ pub fn validate_proxy_auth(
return Err(AuthError::TimestampExpired); return Err(AuthError::TimestampExpired);
} }
// Recompute signature // Recompute signature: HMAC-SHA256(key, timestamp)
let payload = format!("{}\n{}", timestamp_str, node_id);
let mut mac = let mut mac =
HmacSha256::new_from_slice(config.hmac_key.as_bytes()).expect("HMAC accepts any key size"); HmacSha256::new_from_slice(config.hmac_key.as_bytes()).expect("HMAC accepts any key size");
mac.update(payload.as_bytes()); mac.update(timestamp_str.as_bytes());
let expected = mac.finalize().into_bytes(); let expected = mac.finalize().into_bytes();
let expected_hex = hex::encode(expected); let expected_hex = hex::encode(expected);
@@ -131,14 +133,13 @@ mod tests {
} }
} }
fn make_valid_auth(config: &Config, node_id: &str) -> String { fn make_valid_auth(config: &Config) -> String {
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap() .unwrap()
.as_secs(); .as_secs();
let payload = format!("{}\n{}", now, node_id);
let mut mac = HmacSha256::new_from_slice(config.hmac_key.as_bytes()).unwrap(); let mut mac = HmacSha256::new_from_slice(config.hmac_key.as_bytes()).unwrap();
mac.update(payload.as_bytes()); mac.update(now.to_string().as_bytes());
let sig = hex::encode(mac.finalize().into_bytes()); let sig = hex::encode(mac.finalize().into_bytes());
let cred = format!("hmac:{}.{}", now, sig); let cred = format!("hmac:{}.{}", now, sig);
let encoded = base64::engine::general_purpose::STANDARD.encode(cred); let encoded = base64::engine::general_purpose::STANDARD.encode(cred);
@@ -148,28 +149,15 @@ mod tests {
#[test] #[test]
fn test_valid_auth() { fn test_valid_auth() {
let config = make_config(); let config = make_config();
let header = make_valid_auth(&config, "node-1"); let header = make_valid_auth(&config);
assert!( assert!(validate_proxy_auth(Some(&header), &config, config.timestamp_tolerance).is_ok());
validate_proxy_auth(Some(&header), &config, "node-1", config.timestamp_tolerance)
.is_ok()
);
}
#[test]
fn test_wrong_node_id() {
let config = make_config();
let header = make_valid_auth(&config, "node-1");
assert!(matches!(
validate_proxy_auth(Some(&header), &config, "node-2", config.timestamp_tolerance),
Err(AuthError::SignatureMismatch)
));
} }
#[test] #[test]
fn test_missing_header() { fn test_missing_header() {
let config = make_config(); let config = make_config();
assert!(matches!( assert!(matches!(
validate_proxy_auth(None, &config, "node-1", config.timestamp_tolerance), validate_proxy_auth(None, &config, config.timestamp_tolerance),
Err(AuthError::MissingHeader) Err(AuthError::MissingHeader)
)); ));
} }
@@ -181,7 +169,7 @@ mod tests {
let header = format!("Basic {}", encoded); let header = format!("Basic {}", encoded);
let config = make_config(); let config = make_config();
assert!(matches!( assert!(matches!(
validate_proxy_auth(Some(&header), &config, "node-1", config.timestamp_tolerance), validate_proxy_auth(Some(&header), &config, config.timestamp_tolerance),
Err(AuthError::InvalidUsername) Err(AuthError::InvalidUsername)
)); ));
} }

View File

@@ -139,10 +139,21 @@ impl ConfigFile {
/// Only sets variables that are **not** already present in the /// Only sets variables that are **not** already present in the
/// environment, preserving the precedence: CLI > env > config file. /// environment, preserving the precedence: CLI > env > config file.
pub fn inject_env(&self) { pub fn inject_env(&self) {
self.inject_env_inner(false);
}
/// Inject values as environment variables, **overriding** any existing
/// values. Used after setup to ensure the freshly-saved config takes
/// effect before re-parsing.
pub fn inject_env_override(&self) {
self.inject_env_inner(true);
}
fn inject_env_inner(&self, force: bool) {
macro_rules! set { macro_rules! set {
($env:expr, $val:expr) => { ($env:expr, $val:expr) => {
if let Some(ref v) = $val { if let Some(ref v) = $val {
if std::env::var($env).is_err() { if force || std::env::var($env).is_err() {
std::env::set_var($env, v.to_string()); std::env::set_var($env, v.to_string());
} }
} }
@@ -165,7 +176,7 @@ impl ConfigFile {
// allowed_ports needs special handling (comma-separated) // allowed_ports needs special handling (comma-separated)
if let Some(ref ports) = self.allowed_ports { if let Some(ref ports) = self.allowed_ports {
if std::env::var("AETHER_PROXY_ALLOWED_PORTS").is_err() { if force || std::env::var("AETHER_PROXY_ALLOWED_PORTS").is_err() {
let s: String = ports let s: String = ports
.iter() .iter()
.map(|p| p.to_string()) .map(|p| p.to_string())

View File

@@ -11,42 +11,49 @@ mod state;
use std::path::PathBuf; use std::path::PathBuf;
use clap::Parser; use clap::{CommandFactory, FromArgMatches, Parser};
use config::Config; use config::Config;
/// Default config file name. /// Default config file name.
const DEFAULT_CONFIG: &str = "aether-proxy.toml"; const DEFAULT_CONFIG: &str = "aether-proxy.toml";
/// Build the full clap command: Config args + discoverable subcommands.
///
/// `subcommand_negates_reqs` lets subcommands bypass the required Config
/// flags so that e.g. `aether-proxy setup` doesn't demand `--aether-url`.
fn build_command() -> clap::Command {
Config::command()
.subcommand(
clap::Command::new("setup")
.about("Interactive setup wizard (TUI)")
.arg(
clap::Arg::new("config_path")
.help("Path to config file")
.default_value(DEFAULT_CONFIG),
),
)
.subcommand(clap::Command::new("start").about("Start the systemd service"))
.subcommand(clap::Command::new("status").about("Show service status"))
.subcommand(clap::Command::new("logs").about("Tail service logs"))
.subcommand(clap::Command::new("restart").about("Restart the systemd service"))
.subcommand(clap::Command::new("stop").about("Stop the systemd service"))
.subcommand(clap::Command::new("uninstall").about("Uninstall the systemd service"))
.subcommand(
clap::Command::new("upgrade")
.about("Self-upgrade from GitHub releases")
.arg(clap::Arg::new("version").help("Target version (e.g. 0.2.0)")),
)
.subcommand_negates_reqs(true)
}
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect(); rustls::crypto::ring::default_provider()
.install_default()
.map_err(|_| anyhow::anyhow!("Failed to install rustls CryptoProvider"))?;
// Handle subcommands before clap parsing (these don't need Config) // Load config file as env-var defaults (before clap parsing)
if args.len() > 1 {
match args[1].as_str() {
"setup" => {
let path = args
.get(2)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG));
return setup::run(path);
}
"start" => return setup::service::cmd_start(),
"status" => return setup::service::cmd_status(),
"logs" => return setup::service::cmd_logs(),
"restart" => return setup::service::cmd_restart(),
"stop" => return setup::service::cmd_stop(),
"uninstall" => return setup::service::cmd_uninstall(),
"upgrade" => {
let version = args.get(2).cloned();
return setup::upgrade::cmd_upgrade(version).await;
}
_ => {} // fall through to clap (--help, --version, config args)
}
}
// Load config file as env-var defaults (before clap)
let config_file_path = let config_file_path =
std::env::var("AETHER_PROXY_CONFIG").unwrap_or_else(|_| DEFAULT_CONFIG.to_string()); std::env::var("AETHER_PROXY_CONFIG").unwrap_or_else(|_| DEFAULT_CONFIG.to_string());
if std::path::Path::new(&config_file_path).exists() { if std::path::Path::new(&config_file_path).exists() {
@@ -55,18 +62,70 @@ async fn main() -> anyhow::Result<()> {
} }
} }
// Parse config; fall back to setup TUI if required args are missing // Parse CLI (subcommands + config args in one pass)
let config = match Config::try_parse() { match build_command().try_get_matches() {
Ok(c) => c, Ok(matches) => match matches.subcommand() {
Some(("setup", sub_m)) => {
let path = sub_m
.get_one::<String>("config_path")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG));
handle_setup_result(setup::run(path)?).await
}
Some(("start", _)) => setup::service::cmd_start(),
Some(("status", _)) => setup::service::cmd_status(),
Some(("logs", _)) => setup::service::cmd_logs(),
Some(("restart", _)) => setup::service::cmd_restart(),
Some(("stop", _)) => setup::service::cmd_stop(),
Some(("uninstall", _)) => setup::service::cmd_uninstall(),
Some(("upgrade", sub_m)) => {
let version = sub_m.get_one::<String>("version").cloned();
setup::upgrade::cmd_upgrade(version).await
}
Some(_) => unreachable!(),
None => {
// No subcommand — run the proxy with parsed config.
let config = Config::from_arg_matches(&matches)?;
run_proxy(config).await
}
},
Err(e) => { Err(e) => {
if e.kind() == clap::error::ErrorKind::MissingRequiredArgument { if e.kind() == clap::error::ErrorKind::MissingRequiredArgument {
eprintln!("Missing required config, launching setup wizard...\n"); eprintln!("Missing required config, launching setup wizard...\n");
return setup::run(PathBuf::from(&config_file_path)); handle_setup_result(setup::run(PathBuf::from(&config_file_path))?).await
} else {
e.exit();
} }
e.exit();
} }
}; }
}
/// Decide what to do after the setup wizard completes.
async fn handle_setup_result(outcome: setup::SetupOutcome) -> anyhow::Result<()> {
match outcome {
setup::SetupOutcome::ServiceInstalled => Ok(()),
setup::SetupOutcome::ReadyToRun(config_path) => {
// Reload config from the file that setup just wrote, overriding
// any stale env vars from a previous config.
match config::ConfigFile::load(&config_path) {
Ok(file_cfg) => file_cfg.inject_env_override(),
Err(e) => anyhow::bail!("failed to reload config after setup: {}", e),
}
// Parse from env-only (argv may still contain "setup" etc.)
let config = Config::try_parse_from(["aether-proxy"])
.map_err(|e| anyhow::anyhow!("config invalid after setup: {}", e))?;
eprintln!(" Starting proxy...\n");
run_proxy(config).await
}
setup::SetupOutcome::Cancelled => {
eprintln!(" Setup cancelled.");
Ok(())
}
}
}
/// Start the proxy server, checking for systemd conflicts first.
async fn run_proxy(config: Config) -> anyhow::Result<()> {
// Warn if systemd service is already running (would cause port conflict). // Warn if systemd service is already running (would cause port conflict).
// Skip this check when we ARE the systemd service (INVOCATION_ID is set by systemd). // Skip this check when we ARE the systemd service (INVOCATION_ID is set by systemd).
if std::env::var_os("INVOCATION_ID").is_none() && setup::service::is_service_active() { if std::env::var_os("INVOCATION_ID").is_none() && setup::service::is_service_active() {

View File

@@ -16,7 +16,6 @@ use crate::proxy::target_filter;
pub async fn handle_connect( pub async fn handle_connect(
req: Request<Incoming>, req: Request<Incoming>,
config: Arc<Config>, config: Arc<Config>,
node_id: &str,
allowed_ports: &HashSet<u16>, allowed_ports: &HashSet<u16>,
timestamp_tolerance: u64, timestamp_tolerance: u64,
) -> Response<http_body_util::Empty<bytes::Bytes>> { ) -> Response<http_body_util::Empty<bytes::Bytes>> {
@@ -27,7 +26,7 @@ pub async fn handle_connect(
.and_then(|v| v.to_str().ok()); .and_then(|v| v.to_str().ok());
// HMAC authentication // HMAC authentication
if let Err(e) = auth::validate_proxy_auth(proxy_auth, &config, node_id, timestamp_tolerance) { if let Err(e) = auth::validate_proxy_auth(proxy_auth, &config, timestamp_tolerance) {
warn!(error = %e, "CONNECT auth failed"); warn!(error = %e, "CONNECT auth failed");
return proxy_auth_required(&e.to_string()); return proxy_auth_required(&e.to_string());
} }
@@ -45,7 +44,7 @@ pub async fn handle_connect(
let port = authority.port_u16().unwrap_or(443); let port = authority.port_u16().unwrap_or(443);
// Target filter: private IP + port whitelist // Target filter: private IP + port whitelist
let target_addr = match target_filter::validate_target(&host, port, allowed_ports) { let target_addr = match target_filter::validate_target(&host, port, allowed_ports).await {
Ok(addr) => addr, Ok(addr) => addr,
Err(e) => { Err(e) => {
warn!(host = %host, port, error = %e, "CONNECT target rejected"); warn!(host = %host, port, error = %e, "CONNECT target rejected");

View File

@@ -10,9 +10,9 @@ use serde::Deserialize;
use tracing::{debug, warn}; use tracing::{debug, warn};
use url::Url; use url::Url;
use super::BoxBody;
use crate::auth; use crate::auth;
use crate::config::Config; use crate::config::Config;
use crate::proxy::plain::BoxBody;
use crate::proxy::target_filter; use crate::proxy::target_filter;
/// Delegation request payload sent by Aether. /// Delegation request payload sent by Aether.
@@ -34,7 +34,6 @@ struct DelegateRequest {
pub async fn handle_delegate( pub async fn handle_delegate(
req: Request<Incoming>, req: Request<Incoming>,
config: Arc<Config>, config: Arc<Config>,
node_id: &str,
allowed_ports: &HashSet<u16>, allowed_ports: &HashSet<u16>,
timestamp_tolerance: u64, timestamp_tolerance: u64,
http_client: &reqwest::Client, http_client: &reqwest::Client,
@@ -45,7 +44,7 @@ pub async fn handle_delegate(
.get("authorization") .get("authorization")
.and_then(|v| v.to_str().ok()); .and_then(|v| v.to_str().ok());
if let Err(e) = auth::validate_proxy_auth(auth_header, &config, node_id, timestamp_tolerance) { if let Err(e) = auth::validate_proxy_auth(auth_header, &config, timestamp_tolerance) {
warn!(error = %e, "delegate auth failed"); warn!(error = %e, "delegate auth failed");
return error_response(401, "authentication_failed", &e.to_string()); return error_response(401, "authentication_failed", &e.to_string());
} }
@@ -87,7 +86,7 @@ pub async fn handle_delegate(
let port = parsed_url.port_or_known_default().unwrap_or(443); let port = parsed_url.port_or_known_default().unwrap_or(443);
if let Err(e) = target_filter::validate_target(&host, port, allowed_ports) { if let Err(e) = target_filter::validate_target(&host, port, allowed_ports).await {
warn!(host = %host, port, error = %e, "delegate target rejected"); warn!(host = %host, port, error = %e, "delegate target rejected");
return error_response(403, "target_not_allowed", &e.to_string()); return error_response(403, "target_not_allowed", &e.to_string());
} }
@@ -165,7 +164,7 @@ pub async fn handle_delegate(
builder builder
.body(stream_body) .body(stream_body)
.unwrap_or_else(|_| Response::builder().status(500).body(empty_box()).unwrap()) .unwrap_or_else(|_| Response::builder().status(500).body(super::empty_box_body()).unwrap())
} }
// ── Sanitisation ───────────────────────────────────────────────────────────── // ── Sanitisation ─────────────────────────────────────────────────────────────
@@ -195,12 +194,6 @@ fn sanitize_upstream_error(msg: &str) -> String {
// ── Error response helpers ─────────────────────────────────────────────────── // ── Error response helpers ───────────────────────────────────────────────────
fn empty_box() -> BoxBody {
Full::new(bytes::Bytes::new())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
.boxed()
}
fn error_response(status: u16, error: &str, detail: &str) -> Response<BoxBody> { fn error_response(status: u16, error: &str, detail: &str) -> Response<BoxBody> {
let body = serde_json::json!({ let body = serde_json::json!({
"error": error, "error": error,

View File

@@ -1,6 +1,18 @@
pub mod connect; pub mod connect;
pub mod delegate; pub mod delegate;
pub mod plain;
pub mod server; pub mod server;
pub mod target_filter; pub mod target_filter;
pub mod tls; pub mod tls;
use http_body_util::BodyExt;
/// Boxed body type used across proxy handlers.
pub type BoxBody =
http_body_util::combinators::BoxBody<bytes::Bytes, Box<dyn std::error::Error + Send + Sync>>;
/// Create an empty [`BoxBody`] (for error responses, 405, etc.).
pub fn empty_box_body() -> BoxBody {
http_body_util::Full::new(bytes::Bytes::new())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
.boxed()
}

View File

@@ -1,179 +0,0 @@
use std::collections::HashSet;
use std::sync::Arc;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper::{Request, Response};
use tracing::{debug, warn};
use crate::auth;
use crate::config::Config;
use crate::proxy::target_filter;
/// Boxed body that unifies `Full` (error responses) and `Incoming` (streamed upstream).
pub type BoxBody =
http_body_util::combinators::BoxBody<bytes::Bytes, Box<dyn std::error::Error + Send + Sync>>;
/// Handle plain HTTP forward proxy requests (non-CONNECT).
///
/// Flow: validate auth -> check target filter -> forward request -> **stream** response
pub async fn handle_plain(
req: Request<Incoming>,
config: Arc<Config>,
node_id: &str,
allowed_ports: &HashSet<u16>,
timestamp_tolerance: u64,
) -> Response<BoxBody> {
// Extract Proxy-Authorization header
let proxy_auth = req
.headers()
.get("proxy-authorization")
.and_then(|v| v.to_str().ok());
// HMAC authentication
if let Err(e) = auth::validate_proxy_auth(proxy_auth, &config, node_id, timestamp_tolerance) {
warn!(error = %e, "HTTP proxy auth failed");
return proxy_auth_required(&e.to_string());
}
// Parse target from absolute URI
let uri = req.uri().clone();
let host = match uri.host() {
Some(h) => h.to_string(),
None => {
warn!(uri = %uri, "HTTP proxy request missing host");
return bad_request("missing host in URI");
}
};
let port = uri.port_u16().unwrap_or(80);
// Target filter
let target_addr = match target_filter::validate_target(&host, port, allowed_ports) {
Ok(addr) => addr,
Err(e) => {
warn!(host = %host, port, error = %e, "HTTP proxy target rejected");
return forbidden(&e.to_string());
}
};
let method = req.method().clone();
debug!(target = %target_addr, method = %method, "HTTP proxy forwarding");
// Build outgoing request (strip proxy headers, use relative URI)
let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
let mut builder = Request::builder()
.method(req.method())
.uri(path_and_query)
.version(req.version());
// Copy headers, skipping proxy-specific and forwarding-related ones
for (name, value) in req.headers() {
if name == "proxy-authorization"
|| name == "proxy-connection"
|| name == "x-forwarded-for"
|| name == "x-forwarded-host"
|| name == "x-forwarded-proto"
|| name == "x-real-ip"
|| name == "forwarded"
|| name == "via"
{
continue;
}
builder = builder.header(name, value);
}
// Collect the incoming request body (client payloads are small)
let body_bytes = match req.into_body().collect().await {
Ok(collected) => collected.to_bytes(),
Err(e) => {
warn!(error = %e, "failed to read request body");
return bad_gateway("failed to read request body");
}
};
// Connect and send via raw TCP + hyper client
let stream = match tokio::net::TcpStream::connect(target_addr).await {
Ok(s) => s,
Err(e) => {
warn!(target = %target_addr, error = %e, "HTTP proxy connection failed");
return bad_gateway(&format!("connection failed: {}", e));
}
};
let io = hyper_util::rt::TokioIo::new(stream);
let (mut sender, conn) = match hyper::client::conn::http1::handshake(io).await {
Ok(pair) => pair,
Err(e) => {
warn!(error = %e, "HTTP handshake failed");
return bad_gateway(&format!("handshake failed: {}", e));
}
};
tokio::task::spawn(async move {
if let Err(e) = conn.await {
debug!(error = %e, "HTTP proxy client connection error");
}
});
let outgoing = builder
.body(Full::new(body_bytes))
.expect("failed to build outgoing request");
match sender.send_request(outgoing).await {
Ok(resp) => {
debug!(target = %target_addr, method = %method, status = resp.status().as_u16(), "HTTP proxy response");
// Stream the response body directly — no buffering
let (parts, body) = resp.into_parts();
let body: BoxBody = body
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })
.boxed();
Response::from_parts(parts, body)
}
Err(e) => {
warn!(error = %e, "HTTP proxy request failed");
bad_gateway(&format!("upstream request failed: {}", e))
}
}
}
// ── Error response helpers ───────────────────────────────────────────────────
fn empty_box() -> BoxBody {
Full::new(bytes::Bytes::new())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
.boxed()
}
fn proxy_auth_required(msg: &str) -> Response<BoxBody> {
Response::builder()
.status(407)
.header("Proxy-Authenticate", "HMAC-SHA256")
.header("X-Error", msg)
.body(empty_box())
.unwrap()
}
fn forbidden(msg: &str) -> Response<BoxBody> {
Response::builder()
.status(403)
.header("X-Error", msg)
.body(empty_box())
.unwrap()
}
fn bad_request(msg: &str) -> Response<BoxBody> {
Response::builder()
.status(400)
.header("X-Error", msg)
.body(empty_box())
.unwrap()
}
fn bad_gateway(msg: &str) -> Response<BoxBody> {
Response::builder()
.status(502)
.header("X-Error", msg)
.body(empty_box())
.unwrap()
}

View File

@@ -7,20 +7,21 @@ use hyper::body::Incoming;
use hyper::rt::{Read, Write}; use hyper::rt::{Read, Write};
use hyper::server::conn::http1; use hyper::server::conn::http1;
use hyper::service::service_fn; use hyper::service::service_fn;
use hyper::{Method, Request}; use hyper::{Method, Request, Response};
use hyper_util::rt::TokioIo; use hyper_util::rt::TokioIo;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::watch; use tokio::sync::watch;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use crate::proxy::{connect, delegate, plain, tls}; use crate::proxy::{connect, delegate, tls, BoxBody};
use crate::state::AppState; use crate::state::AppState;
/// Start the proxy server. /// Start the proxy server.
/// ///
/// Listens for incoming TCP connections and dispatches: /// Listens for incoming TCP connections and dispatches:
/// - CONNECT requests -> tunnel handler /// - CONNECT requests -> tunnel handler
/// - Other HTTP requests -> plain forward proxy handler /// - POST /_aether/delegate -> delegate handler
/// - Other requests -> 405 Method Not Allowed
/// ///
/// When TLS is configured, the server operates in dual-stack mode: /// When TLS is configured, the server operates in dual-stack mode:
/// it peeks at the first byte of each connection to distinguish TLS ClientHello /// it peeks at the first byte of each connection to distinguish TLS ClientHello
@@ -104,38 +105,24 @@ where
I: Read + Write + Unpin + Send + 'static, I: Read + Write + Unpin + Send + 'static,
{ {
let config = Arc::clone(&state.config); let config = Arc::clone(&state.config);
let node_id = Arc::clone(&state.node_id);
let dynamic = Arc::clone(&state.dynamic); let dynamic = Arc::clone(&state.dynamic);
let delegate_client = state.delegate_client.clone(); let delegate_client = state.delegate_client.clone();
let service = service_fn(move |req: Request<Incoming>| { let service = service_fn(move |req: Request<Incoming>| {
let config = Arc::clone(&config); let config = Arc::clone(&config);
let node_id = Arc::clone(&node_id);
let dynamic = Arc::clone(&dynamic); let dynamic = Arc::clone(&dynamic);
let delegate_client = delegate_client.clone(); let delegate_client = delegate_client.clone();
async move { async move {
type BoxBody = http_body_util::combinators::BoxBody<
bytes::Bytes,
Box<dyn std::error::Error + Send + Sync>,
>;
// Snapshot current dynamic values (may be updated by remote config) // Snapshot current dynamic values (may be updated by remote config)
let current_node_id = node_id.read().unwrap().clone();
let (allowed_ports, timestamp_tolerance) = { let (allowed_ports, timestamp_tolerance) = {
let d = dynamic.read().unwrap(); let d = dynamic.read().unwrap();
(d.allowed_ports.clone(), d.timestamp_tolerance) (d.allowed_ports.clone(), d.timestamp_tolerance)
}; };
if req.method() == Method::CONNECT { if req.method() == Method::CONNECT {
let resp = connect::handle_connect( let resp =
req, connect::handle_connect(req, config, &allowed_ports, timestamp_tolerance).await;
config,
&current_node_id,
&allowed_ports,
timestamp_tolerance,
)
.await;
let resp = resp.map(|_| -> BoxBody { let resp = resp.map(|_| -> BoxBody {
http_body_util::Empty::new() http_body_util::Empty::new()
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} }) .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
@@ -147,7 +134,6 @@ where
let resp = delegate::handle_delegate( let resp = delegate::handle_delegate(
req, req,
config, config,
&current_node_id,
&allowed_ports, &allowed_ports,
timestamp_tolerance, timestamp_tolerance,
&delegate_client, &delegate_client,
@@ -155,15 +141,14 @@ where
.await; .await;
Ok(resp) Ok(resp)
} else { } else {
let resp = plain::handle_plain( // Only CONNECT tunnels and /_aether/delegate are supported;
req, // plain HTTP forward proxy was removed (all API traffic is HTTPS).
config, Ok(Response::builder()
&current_node_id, .status(405)
&allowed_ports, .header("Allow", "CONNECT")
timestamp_tolerance, .header("Content-Length", "0")
) .body(crate::proxy::empty_box_body())
.await; .unwrap())
Ok(resp)
} }
} }
}); });

View File

@@ -1,5 +1,5 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
/// Check if an IP address belongs to a private/reserved network. /// Check if an IP address belongs to a private/reserved network.
fn is_private_ip(ip: &IpAddr) -> bool { fn is_private_ip(ip: &IpAddr) -> bool {
@@ -82,8 +82,11 @@ impl std::fmt::Display for FilterError {
/// Validate that the target host:port is allowed. /// Validate that the target host:port is allowed.
/// ///
/// Uses async DNS resolution (via `tokio::net::lookup_host`) to avoid
/// blocking the async runtime on potentially slow DNS lookups.
///
/// Returns the resolved socket address to connect to. /// Returns the resolved socket address to connect to.
pub fn validate_target( pub async fn validate_target(
host: &str, host: &str,
port: u16, port: u16,
allowed_ports: &HashSet<u16>, allowed_ports: &HashSet<u16>,
@@ -93,7 +96,7 @@ pub fn validate_target(
return Err(FilterError::PortNotAllowed(port)); return Err(FilterError::PortNotAllowed(port));
} }
// Try parsing as IP directly // 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 is_private_ip(&ip) {
return Err(FilterError::PrivateIp(ip)); return Err(FilterError::PrivateIp(ip));
@@ -101,10 +104,10 @@ pub fn validate_target(
return Ok(SocketAddr::new(ip, port)); return Ok(SocketAddr::new(ip, port));
} }
// DNS resolution with private IP check (DNS rebinding protection) // Async DNS resolution with private IP check (DNS rebinding protection)
let addr_str = format!("{}:{}", host, port); let addr_str = format!("{}:{}", host, port);
let addrs: Vec<SocketAddr> = addr_str let addrs: Vec<SocketAddr> = tokio::net::lookup_host(&addr_str)
.to_socket_addrs() .await
.map_err(|_| FilterError::DnsResolutionFailed(host.to_string()))? .map_err(|_| FilterError::DnsResolutionFailed(host.to_string()))?
.collect(); .collect();
@@ -157,21 +160,21 @@ mod tests {
)))); ))));
} }
#[test] #[tokio::test]
fn test_port_not_allowed() { async fn test_port_not_allowed() {
let result = validate_target("8.8.8.8", 22, &ports()); let result = validate_target("8.8.8.8", 22, &ports()).await;
assert!(matches!(result, Err(FilterError::PortNotAllowed(22)))); assert!(matches!(result, Err(FilterError::PortNotAllowed(22))));
} }
#[test] #[tokio::test]
fn test_private_ip_blocked() { async fn test_private_ip_blocked() {
let result = validate_target("127.0.0.1", 80, &ports()); let result = validate_target("127.0.0.1", 80, &ports()).await;
assert!(matches!(result, Err(FilterError::PrivateIp(_)))); assert!(matches!(result, Err(FilterError::PrivateIp(_))));
} }
#[test] #[tokio::test]
fn test_public_ip_allowed() { async fn test_public_ip_allowed() {
let result = validate_target("8.8.8.8", 443, &ports()); let result = validate_target("8.8.8.8", 443, &ports()).await;
assert!(result.is_ok()); assert!(result.is_ok());
} }
} }

View File

@@ -44,10 +44,12 @@ pub type SharedDynamicConfig = Arc<RwLock<DynamicConfig>>;
// ── Log-level hot-reload ───────────────────────────────────────────────────── // ── Log-level hot-reload ─────────────────────────────────────────────────────
/// Global log-level reloader function, set during tracing init. /// Global log-level reloader function, set during tracing init.
static LOG_RELOADER: OnceLock<Box<dyn Fn(&str) + Send + Sync>> = OnceLock::new(); type LogReloader = Box<dyn Fn(&str) + Send + Sync>;
static LOG_RELOADER: OnceLock<LogReloader> = OnceLock::new();
/// Register the log-level reload function (called once from `init_tracing`). /// Register the log-level reload function (called once from `init_tracing`).
pub fn set_log_reloader(f: Box<dyn Fn(&str) + Send + Sync>) { pub fn set_log_reloader(f: LogReloader) {
let _ = LOG_RELOADER.set(f); let _ = LOG_RELOADER.set(f);
} }

View File

@@ -2,4 +2,4 @@ pub(crate) mod service;
mod tui; mod tui;
pub(crate) mod upgrade; pub(crate) mod upgrade;
pub use self::tui::run; pub use self::tui::{run, SetupOutcome};

View File

@@ -93,9 +93,11 @@ pub fn install_service(config_path: &Path) -> anyhow::Result<()> {
eprintln!(); eprintln!();
eprintln!(" Commands:"); eprintln!(" Commands:");
eprintln!(" sudo systemctl status {} # status", SERVICE_NAME); eprintln!(" aether-proxy status # service status");
eprintln!(" sudo systemctl restart {} # restart", SERVICE_NAME); eprintln!(" aether-proxy logs # tail logs");
eprintln!(" sudo journalctl -u {} -f # logs", SERVICE_NAME); eprintln!(" sudo aether-proxy restart # restart");
eprintln!(" sudo aether-proxy stop # stop");
eprintln!(" sudo aether-proxy uninstall # remove service");
eprintln!(); eprintln!();
Ok(()) Ok(())

View File

@@ -21,6 +21,16 @@ use ratatui::Terminal;
use crate::config::ConfigFile; use crate::config::ConfigFile;
/// Outcome of the setup wizard, returned to the caller.
pub enum SetupOutcome {
/// Config saved; systemd service installed and started.
ServiceInstalled,
/// Config saved; no service — caller should start the proxy directly.
ReadyToRun(PathBuf),
/// User quit without saving.
Cancelled,
}
/// Column width reserved for the field label (chars). /// Column width reserved for the field label (chars).
const LABEL_WIDTH: usize = 22; const LABEL_WIDTH: usize = 22;
@@ -63,6 +73,7 @@ struct App {
message: Option<(String, Instant, bool)>, // (text, when, is_error) message: Option<(String, Instant, bool)>, // (text, when, is_error)
scroll_offset: usize, scroll_offset: usize,
saved_once: bool, saved_once: bool,
pending_quit: bool, // true after first q/Esc with unsaved changes
} }
impl App { impl App {
@@ -148,6 +159,7 @@ impl App {
message: None, message: None,
scroll_offset: 0, scroll_offset: 0,
saved_once: false, saved_once: false,
pending_quit: false,
} }
} }
@@ -235,9 +247,9 @@ impl App {
/// Returns `true` when the app should exit. /// Returns `true` when the app should exit.
fn handle_key(&mut self, key: KeyEvent) -> bool { fn handle_key(&mut self, key: KeyEvent) -> bool {
// Expire old messages // Expire old messages (but keep quit-confirmation messages alive)
if let Some((_, when, _)) = &self.message { if let Some((_, when, _)) = &self.message {
if when.elapsed() > Duration::from_secs(4) { if !self.pending_quit && when.elapsed() > Duration::from_secs(4) {
self.message = None; self.message = None;
} }
} }
@@ -252,8 +264,29 @@ impl App {
} }
fn handle_normal(&mut self, key: KeyEvent) -> bool { fn handle_normal(&mut self, key: KeyEvent) -> bool {
// ── Quit handling (with unsaved-changes confirmation) ─────────
let is_quit_key = matches!(key.code, KeyCode::Char('q') | KeyCode::Esc);
if is_quit_key {
if !self.modified || self.pending_quit {
return true;
}
self.pending_quit = true;
self.message = Some((
"unsaved changes! q again to discard, ^S to save".into(),
Instant::now(),
true,
));
return false;
}
// Any other key cancels the pending quit
if self.pending_quit {
self.pending_quit = false;
self.message = None;
}
match key.code { match key.code {
KeyCode::Char('q') | KeyCode::Esc => return true,
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Err(e) = self.save() { if let Err(e) = self.save() {
self.message = Some((format!("error: {}", e), Instant::now(), true)); self.message = Some((format!("error: {}", e), Instant::now(), true));
@@ -564,7 +597,7 @@ fn render_footer(f: &mut Frame, app: &App, area: Rect) {
// ── Entry point ────────────────────────────────────────────────────────────── // ── Entry point ──────────────────────────────────────────────────────────────
pub fn run(config_path: PathBuf) -> anyhow::Result<()> { pub fn run(config_path: PathBuf) -> anyhow::Result<SetupOutcome> {
// Setup terminal // Setup terminal
terminal::enable_raw_mode()?; terminal::enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
@@ -584,46 +617,42 @@ pub fn run(config_path: PathBuf) -> anyhow::Result<()> {
result?; result?;
// Post-TUI message // ── Post-TUI: decide outcome ─────────────────────────────────────
if app.saved_once {
eprintln!();
eprintln!(" Config saved to {}", config_path.display());
eprintln!();
let wants_service = app if !app.saved_once {
.fields return Ok(SetupOutcome::Cancelled);
.iter() }
.find(|f| f.key == "install_service")
.map(|f| f.value == "true")
.unwrap_or(false);
if wants_service { eprintln!();
match super::service::install_service(&config_path) { eprintln!(" Config saved to {}", config_path.display());
Ok(()) => {} eprintln!();
Err(e) => {
eprintln!(" Service install failed: {}", e); let wants_service = app
eprintln!(); .fields
} .iter()
.find(|f| f.key == "install_service")
.map(|f| f.value == "true")
.unwrap_or(false);
if wants_service {
match super::service::install_service(&config_path) {
Ok(()) => return Ok(SetupOutcome::ServiceInstalled),
Err(e) => {
eprintln!(" Service install failed: {}", e);
eprintln!(" Starting proxy directly instead.\n");
} }
} else { }
// Uninstall service if it was previously installed } else {
if super::service::is_installed() { // Uninstall service if it was previously installed but toggled off
if let Err(e) = super::service::uninstall_service() { if super::service::is_installed() {
eprintln!(" Service uninstall failed: {}", e); if let Err(e) = super::service::uninstall_service() {
eprintln!(); eprintln!(" Service uninstall failed: {}", e);
} eprintln!();
} }
eprintln!(" Run with:");
eprintln!(
" aether-proxy (auto-reads {})",
config_path.display()
);
eprintln!();
} }
} }
Ok(()) Ok(SetupOutcome::ReadyToRun(config_path))
} }
fn event_loop( fn event_loop(

View File

@@ -94,23 +94,24 @@ def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def build_hmac_proxy_url(ip: str, port: int, node_id: str, *, tls_enabled: bool = False) -> str: def build_hmac_proxy_url(ip: str, port: int, *, tls_enabled: bool = False) -> str:
""" """
构建带 HMAC BasicAuth 的 httpx proxy URL 构建带 HMAC BasicAuth 的 httpx proxy URL
格式: http(s)://hmac:{timestamp}.{signature}@{ip}:{port} 格式: http(s)://hmac:{timestamp}.{signature}@{ip}:{port}
signature = HMAC-SHA256(PROXY_HMAC_KEY, "{timestamp}\\n{node_id}") 的 hex signature = HMAC-SHA256(PROXY_HMAC_KEY, "{timestamp}") 的 hex
签名不再包含 node_id避免 proxy 重新注册后 Aether 端缓存的旧 node_id
与 proxy 端新 node_id 不一致导致的认证失败窗口。
当 tls_enabled=True 时使用 https:// scheme。 当 tls_enabled=True 时使用 https:// scheme。
""" """
if not config.proxy_hmac_key: if not config.proxy_hmac_key:
logger.error("PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理 (node_id={})", node_id) logger.error("PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理")
raise ProxyNodeUnavailableError( raise ProxyNodeUnavailableError("PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理")
"PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理", node_id=node_id
)
timestamp = str(int(time.time())) timestamp = str(int(time.time()))
payload = f"{timestamp}\n{node_id}".encode("utf-8") payload = timestamp.encode("utf-8")
signature = _hmac.new( signature = _hmac.new(
config.proxy_hmac_key.encode("utf-8"), config.proxy_hmac_key.encode("utf-8"),
payload, payload,
@@ -318,7 +319,6 @@ def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
return build_hmac_proxy_url( return build_hmac_proxy_url(
node_info["ip"], node_info["ip"],
node_info["port"], node_info["port"],
node_id,
tls_enabled=node_info.get("tls_enabled", False), tls_enabled=node_info.get("tls_enabled", False),
) )
@@ -421,7 +421,7 @@ def compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
# ProxyNode 模式:基于 node_id + 时间桶缓存,避免签名随时间变化导致 cache key 爆炸 # ProxyNode 模式:基于 node_id + 时间桶缓存,避免签名随时间变化导致 cache key 爆炸
node_id = proxy_config.get("node_id") node_id = proxy_config.get("node_id")
if isinstance(node_id, str) and node_id.strip(): if isinstance(node_id, str) and node_id.strip():
time_bucket = int(time.time() / 120) # 120 秒一个桶 time_bucket = int(time.time() / 240) # 240s bucket, within 300s HMAC tolerance
return f"proxy_node:{node_id.strip()}:{time_bucket}" return f"proxy_node:{node_id.strip()}:{time_bucket}"
# 构建代理 URL 作为缓存键的基础 # 构建代理 URL 作为缓存键的基础
@@ -438,18 +438,18 @@ def compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _build_hmac_auth_header(node_id: str) -> str: def _build_hmac_auth_header() -> str:
""" """
构建代发请求的 Authorization 头 构建代发请求的 Authorization 头
格式: Basic base64(hmac:{timestamp}.{signature}) 格式: Basic base64(hmac:{timestamp}.{signature})
签名算法与 build_hmac_proxy_url 相同。 签名算法与 build_hmac_proxy_url 相同(仅使用 timestamp不含 node_id
""" """
if not config.proxy_hmac_key: if not config.proxy_hmac_key:
raise ProxyNodeUnavailableError("PROXY_HMAC_KEY 未配置,无法使用代发模式", node_id=node_id) raise ProxyNodeUnavailableError("PROXY_HMAC_KEY 未配置,无法使用代发模式")
timestamp = str(int(time.time())) timestamp = str(int(time.time()))
payload = f"{timestamp}\n{node_id}".encode("utf-8") payload = timestamp.encode("utf-8")
signature = _hmac.new( signature = _hmac.new(
config.proxy_hmac_key.encode("utf-8"), config.proxy_hmac_key.encode("utf-8"),
payload, payload,
@@ -497,9 +497,9 @@ def resolve_delegate_config(proxy_config: dict[str, Any] | None) -> dict[str, An
scheme = "https" if tls_enabled else "http" scheme = "https" if tls_enabled else "http"
delegate_url = f"{scheme}://{host}:{int(node_info['port'])}/_aether/delegate" delegate_url = f"{scheme}://{host}:{int(node_info['port'])}/_aether/delegate"
# 闭包捕获 node_id每次调用生成新鲜签名 # 每次调用生成新鲜签名(避免长连接内时间戳过期)
def _fresh() -> str: def _fresh() -> str:
return _build_hmac_auth_header(node_id) return _build_hmac_auth_header()
return { return {
"delegate_url": delegate_url, "delegate_url": delegate_url,