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

@@ -37,7 +37,11 @@ impl std::fmt::Display for AuthError {
/// Validate Proxy-Authorization header.
///
/// 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
/// the value from [`DynamicConfig`](crate::runtime::DynamicConfig) (which
@@ -45,7 +49,6 @@ impl std::fmt::Display for AuthError {
pub fn validate_proxy_auth(
proxy_auth_header: Option<&str>,
config: &Config,
node_id: &str,
timestamp_tolerance: u64,
) -> Result<(), AuthError> {
let header = proxy_auth_header.ok_or(AuthError::MissingHeader)?;
@@ -88,11 +91,10 @@ pub fn validate_proxy_auth(
return Err(AuthError::TimestampExpired);
}
// Recompute signature
let payload = format!("{}\n{}", timestamp_str, node_id);
// Recompute signature: HMAC-SHA256(key, timestamp)
let mut mac =
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_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()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let payload = format!("{}\n{}", now, node_id);
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 cred = format!("hmac:{}.{}", now, sig);
let encoded = base64::engine::general_purpose::STANDARD.encode(cred);
@@ -148,28 +149,15 @@ mod tests {
#[test]
fn test_valid_auth() {
let config = make_config();
let header = make_valid_auth(&config, "node-1");
assert!(
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)
));
let header = make_valid_auth(&config);
assert!(validate_proxy_auth(Some(&header), &config, config.timestamp_tolerance).is_ok());
}
#[test]
fn test_missing_header() {
let config = make_config();
assert!(matches!(
validate_proxy_auth(None, &config, "node-1", config.timestamp_tolerance),
validate_proxy_auth(None, &config, config.timestamp_tolerance),
Err(AuthError::MissingHeader)
));
}
@@ -181,7 +169,7 @@ mod tests {
let header = format!("Basic {}", encoded);
let config = make_config();
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)
));
}

View File

@@ -139,10 +139,21 @@ impl ConfigFile {
/// Only sets variables that are **not** already present in the
/// environment, preserving the precedence: CLI > env > config file.
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 {
($env:expr, $val:expr) => {
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());
}
}
@@ -165,7 +176,7 @@ impl ConfigFile {
// allowed_ports needs special handling (comma-separated)
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
.iter()
.map(|p| p.to_string())

View File

@@ -11,42 +11,49 @@ mod state;
use std::path::PathBuf;
use clap::Parser;
use clap::{CommandFactory, FromArgMatches, Parser};
use config::Config;
/// Default config file name.
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]
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)
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)
// Load config file as env-var defaults (before clap parsing)
let config_file_path =
std::env::var("AETHER_PROXY_CONFIG").unwrap_or_else(|_| DEFAULT_CONFIG.to_string());
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
let config = match Config::try_parse() {
Ok(c) => c,
// Parse CLI (subcommands + config args in one pass)
match build_command().try_get_matches() {
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) => {
if e.kind() == clap::error::ErrorKind::MissingRequiredArgument {
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).
// 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() {

View File

@@ -16,7 +16,6 @@ use crate::proxy::target_filter;
pub async fn handle_connect(
req: Request<Incoming>,
config: Arc<Config>,
node_id: &str,
allowed_ports: &HashSet<u16>,
timestamp_tolerance: u64,
) -> Response<http_body_util::Empty<bytes::Bytes>> {
@@ -27,7 +26,7 @@ pub async fn handle_connect(
.and_then(|v| v.to_str().ok());
// 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");
return proxy_auth_required(&e.to_string());
}
@@ -45,7 +44,7 @@ pub async fn handle_connect(
let port = authority.port_u16().unwrap_or(443);
// 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,
Err(e) => {
warn!(host = %host, port, error = %e, "CONNECT target rejected");

View File

@@ -10,9 +10,9 @@ use serde::Deserialize;
use tracing::{debug, warn};
use url::Url;
use super::BoxBody;
use crate::auth;
use crate::config::Config;
use crate::proxy::plain::BoxBody;
use crate::proxy::target_filter;
/// Delegation request payload sent by Aether.
@@ -34,7 +34,6 @@ struct DelegateRequest {
pub async fn handle_delegate(
req: Request<Incoming>,
config: Arc<Config>,
node_id: &str,
allowed_ports: &HashSet<u16>,
timestamp_tolerance: u64,
http_client: &reqwest::Client,
@@ -45,7 +44,7 @@ pub async fn handle_delegate(
.get("authorization")
.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");
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);
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");
return error_response(403, "target_not_allowed", &e.to_string());
}
@@ -165,7 +164,7 @@ pub async fn handle_delegate(
builder
.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 ─────────────────────────────────────────────────────────────
@@ -195,12 +194,6 @@ fn sanitize_upstream_error(msg: &str) -> String {
// ── 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> {
let body = serde_json::json!({
"error": error,

View File

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

View File

@@ -1,5 +1,5 @@
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.
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.
///
/// 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.
pub fn validate_target(
pub async fn validate_target(
host: &str,
port: u16,
allowed_ports: &HashSet<u16>,
@@ -93,7 +96,7 @@ pub fn validate_target(
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 is_private_ip(&ip) {
return Err(FilterError::PrivateIp(ip));
@@ -101,10 +104,10 @@ pub fn validate_target(
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 addrs: Vec<SocketAddr> = addr_str
.to_socket_addrs()
let addrs: Vec<SocketAddr> = tokio::net::lookup_host(&addr_str)
.await
.map_err(|_| FilterError::DnsResolutionFailed(host.to_string()))?
.collect();
@@ -157,21 +160,21 @@ mod tests {
))));
}
#[test]
fn test_port_not_allowed() {
let result = validate_target("8.8.8.8", 22, &ports());
#[tokio::test]
async fn test_port_not_allowed() {
let result = validate_target("8.8.8.8", 22, &ports()).await;
assert!(matches!(result, Err(FilterError::PortNotAllowed(22))));
}
#[test]
fn test_private_ip_blocked() {
let result = validate_target("127.0.0.1", 80, &ports());
#[tokio::test]
async fn test_private_ip_blocked() {
let result = validate_target("127.0.0.1", 80, &ports()).await;
assert!(matches!(result, Err(FilterError::PrivateIp(_))));
}
#[test]
fn test_public_ip_allowed() {
let result = validate_target("8.8.8.8", 443, &ports());
#[tokio::test]
async fn test_public_ip_allowed() {
let result = validate_target("8.8.8.8", 443, &ports()).await;
assert!(result.is_ok());
}
}

View File

@@ -44,10 +44,12 @@ pub type SharedDynamicConfig = Arc<RwLock<DynamicConfig>>;
// ── Log-level hot-reload ─────────────────────────────────────────────────────
/// 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`).
pub fn set_log_reloader(f: Box<dyn Fn(&str) + Send + Sync>) {
pub fn set_log_reloader(f: LogReloader) {
let _ = LOG_RELOADER.set(f);
}

View File

@@ -2,4 +2,4 @@ pub(crate) mod service;
mod tui;
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!(" Commands:");
eprintln!(" sudo systemctl status {} # status", SERVICE_NAME);
eprintln!(" sudo systemctl restart {} # restart", SERVICE_NAME);
eprintln!(" sudo journalctl -u {} -f # logs", SERVICE_NAME);
eprintln!(" aether-proxy status # service status");
eprintln!(" aether-proxy logs # tail logs");
eprintln!(" sudo aether-proxy restart # restart");
eprintln!(" sudo aether-proxy stop # stop");
eprintln!(" sudo aether-proxy uninstall # remove service");
eprintln!();
Ok(())

View File

@@ -21,6 +21,16 @@ use ratatui::Terminal;
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).
const LABEL_WIDTH: usize = 22;
@@ -63,6 +73,7 @@ struct App {
message: Option<(String, Instant, bool)>, // (text, when, is_error)
scroll_offset: usize,
saved_once: bool,
pending_quit: bool, // true after first q/Esc with unsaved changes
}
impl App {
@@ -148,6 +159,7 @@ impl App {
message: None,
scroll_offset: 0,
saved_once: false,
pending_quit: false,
}
}
@@ -235,9 +247,9 @@ impl App {
/// Returns `true` when the app should exit.
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 when.elapsed() > Duration::from_secs(4) {
if !self.pending_quit && when.elapsed() > Duration::from_secs(4) {
self.message = None;
}
}
@@ -252,8 +264,29 @@ impl App {
}
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 {
KeyCode::Char('q') | KeyCode::Esc => return true,
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Err(e) = self.save() {
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 ──────────────────────────────────────────────────────────────
pub fn run(config_path: PathBuf) -> anyhow::Result<()> {
pub fn run(config_path: PathBuf) -> anyhow::Result<SetupOutcome> {
// Setup terminal
terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
@@ -584,46 +617,42 @@ pub fn run(config_path: PathBuf) -> anyhow::Result<()> {
result?;
// Post-TUI message
if app.saved_once {
eprintln!();
eprintln!(" Config saved to {}", config_path.display());
eprintln!();
// ── Post-TUI: decide outcome ─────────────────────────────────────
let wants_service = app
.fields
.iter()
.find(|f| f.key == "install_service")
.map(|f| f.value == "true")
.unwrap_or(false);
if !app.saved_once {
return Ok(SetupOutcome::Cancelled);
}
if wants_service {
match super::service::install_service(&config_path) {
Ok(()) => {}
Err(e) => {
eprintln!(" Service install failed: {}", e);
eprintln!();
}
eprintln!();
eprintln!(" Config saved to {}", config_path.display());
eprintln!();
let wants_service = app
.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
if super::service::is_installed() {
if let Err(e) = super::service::uninstall_service() {
eprintln!(" Service uninstall failed: {}", e);
eprintln!();
}
}
} else {
// Uninstall service if it was previously installed but toggled off
if super::service::is_installed() {
if let Err(e) = super::service::uninstall_service() {
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(