mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
¤t_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,
|
||||||
¤t_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()
|
||||||
¤t_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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user