mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: aether-proxy TLS 双栈支持与自签名证书自动生成
- aether-proxy 新增 TLS 模块:自签名证书生成、TLS acceptor 构建、证书 SHA-256 指纹计算 - 代理服务器支持 HTTP+TLS 双栈模式,通过 peek 首字节区分 TLS ClientHello 与普通 HTTP - 注册与心跳上报 tls_enabled 和 tls_cert_fingerprint 字段 - Python 侧 httpx 代理适配:TLS 代理使用 httpx.Proxy + CERT_NONE ssl_context - ProxyNode 模型新增 tls_enabled/tls_cert_fingerprint 字段及对应迁移
This commit is contained in:
104
aether-proxy/Cargo.lock
generated
104
aether-proxy/Cargo.lock
generated
@@ -17,13 +17,18 @@ dependencies = [
|
|||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
"rcgen",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pemfile",
|
||||||
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -121,6 +126,28 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-lc-rs"
|
||||||
|
version = "1.15.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-sys",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-lc-sys"
|
||||||
|
version = "0.37.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cmake",
|
||||||
|
"dunce",
|
||||||
|
"fs_extra",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -197,6 +224,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
|
checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -252,6 +281,15 @@ version = "0.7.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmake"
|
||||||
|
version = "0.1.57"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -471,6 +509,12 @@ dependencies = [
|
|||||||
"litrs",
|
"litrs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dunce"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -592,6 +636,12 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs_extra"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -1018,6 +1068,16 @@ version = "1.0.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jobserver"
|
||||||
|
version = "0.1.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.85"
|
version = "0.3.85"
|
||||||
@@ -1360,6 +1420,16 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem"
|
||||||
|
version = "3.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -1624,6 +1694,19 @@ dependencies = [
|
|||||||
"unicode-width",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rcgen"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
||||||
|
dependencies = [
|
||||||
|
"pem",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"time",
|
||||||
|
"yasna",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -1757,6 +1840,8 @@ version = "0.23.36"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-webpki",
|
"rustls-webpki",
|
||||||
@@ -1764,6 +1849,15 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pemfile"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
@@ -1779,6 +1873,7 @@ version = "0.103.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
@@ -2989,6 +3084,15 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yasna"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
|
||||||
|
dependencies = [
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ bytes = "1"
|
|||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
tokio-rustls = "0.26"
|
||||||
|
rustls = "0.23"
|
||||||
|
rustls-pki-types = "1"
|
||||||
|
rustls-pemfile = "2"
|
||||||
|
rcgen = "0.13"
|
||||||
ratatui = "0.30"
|
ratatui = "0.30"
|
||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ pub struct Config {
|
|||||||
/// Output logs as JSON
|
/// Output logs as JSON
|
||||||
#[arg(long, env = "AETHER_PROXY_LOG_JSON", default_value_t = false)]
|
#[arg(long, env = "AETHER_PROXY_LOG_JSON", default_value_t = false)]
|
||||||
pub log_json: bool,
|
pub log_json: bool,
|
||||||
|
|
||||||
|
/// Enable TLS encryption (dual-stack: accepts both HTTP and TLS on same port)
|
||||||
|
#[arg(long, env = "AETHER_PROXY_ENABLE_TLS", default_value_t = true)]
|
||||||
|
pub enable_tls: bool,
|
||||||
|
|
||||||
|
/// Path to TLS certificate PEM file
|
||||||
|
#[arg(long, env = "AETHER_PROXY_TLS_CERT", default_value = "aether-proxy-cert.pem")]
|
||||||
|
pub tls_cert: String,
|
||||||
|
|
||||||
|
/// Path to TLS private key PEM file
|
||||||
|
#[arg(long, env = "AETHER_PROXY_TLS_KEY", default_value = "aether-proxy-key.pem")]
|
||||||
|
pub tls_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -92,6 +104,12 @@ pub struct ConfigFile {
|
|||||||
pub log_level: Option<String>,
|
pub log_level: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub log_json: Option<bool>,
|
pub log_json: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enable_tls: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tls_cert: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tls_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConfigFile {
|
impl ConfigFile {
|
||||||
@@ -133,6 +151,9 @@ impl ConfigFile {
|
|||||||
set!("AETHER_PROXY_TIMESTAMP_TOLERANCE", self.timestamp_tolerance);
|
set!("AETHER_PROXY_TIMESTAMP_TOLERANCE", self.timestamp_tolerance);
|
||||||
set!("AETHER_PROXY_LOG_LEVEL", self.log_level);
|
set!("AETHER_PROXY_LOG_LEVEL", self.log_level);
|
||||||
set!("AETHER_PROXY_LOG_JSON", self.log_json);
|
set!("AETHER_PROXY_LOG_JSON", self.log_json);
|
||||||
|
set!("AETHER_PROXY_ENABLE_TLS", self.enable_tls);
|
||||||
|
set!("AETHER_PROXY_TLS_CERT", self.tls_cert);
|
||||||
|
set!("AETHER_PROXY_TLS_KEY", self.tls_key);
|
||||||
|
|
||||||
// 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 {
|
||||||
|
|||||||
@@ -73,7 +73,26 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Register with Aether
|
// Register with Aether
|
||||||
let aether_client = Arc::new(AetherClient::new(&config));
|
let aether_client = Arc::new(AetherClient::new(&config));
|
||||||
let node_id = aether_client.register(&config, &public_ip).await?;
|
|
||||||
|
// Initialize TLS if enabled
|
||||||
|
let (tls_acceptor, tls_fingerprint) = if config.enable_tls {
|
||||||
|
let cert_path = std::path::PathBuf::from(&config.tls_cert);
|
||||||
|
let key_path = std::path::PathBuf::from(&config.tls_key);
|
||||||
|
|
||||||
|
proxy::tls::ensure_self_signed_cert(&cert_path, &key_path)?;
|
||||||
|
let acceptor = proxy::tls::build_tls_acceptor(&cert_path, &key_path)?;
|
||||||
|
let fingerprint = proxy::tls::cert_sha256_fingerprint(&cert_path)?;
|
||||||
|
|
||||||
|
info!(fingerprint = %fingerprint, "TLS enabled");
|
||||||
|
(Some(acceptor), Some(fingerprint))
|
||||||
|
} else {
|
||||||
|
info!("TLS disabled");
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let node_id = aether_client
|
||||||
|
.register(&config, &public_ip, config.enable_tls, tls_fingerprint.as_deref())
|
||||||
|
.await?;
|
||||||
|
|
||||||
info!(node_id = %node_id, "node registered");
|
info!(node_id = %node_id, "node registered");
|
||||||
|
|
||||||
@@ -94,9 +113,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let config = Arc::clone(&config);
|
let config = Arc::clone(&config);
|
||||||
let dynamic = Arc::clone(&dynamic);
|
let dynamic = Arc::clone(&dynamic);
|
||||||
let public_ip = public_ip.clone();
|
let public_ip = public_ip.clone();
|
||||||
|
let fingerprint = tls_fingerprint.clone();
|
||||||
let rx = shutdown_rx.clone();
|
let rx = shutdown_rx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
registration::heartbeat::run(client, node_id, config, public_ip, dynamic, rx).await;
|
registration::heartbeat::run(client, node_id, config, public_ip, fingerprint, dynamic, rx).await;
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,8 +126,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let node_id = Arc::clone(&node_id);
|
let node_id = Arc::clone(&node_id);
|
||||||
let dynamic = Arc::clone(&dynamic);
|
let dynamic = Arc::clone(&dynamic);
|
||||||
let rx = shutdown_rx.clone();
|
let rx = shutdown_rx.clone();
|
||||||
|
let tls = tls_acceptor.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = proxy::server::run(config, node_id, dynamic, rx).await {
|
if let Err(e) = proxy::server::run(config, node_id, dynamic, tls, rx).await {
|
||||||
error!(error = %e, "proxy server error");
|
error!(error = %e, "proxy server error");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ pub mod connect;
|
|||||||
pub mod plain;
|
pub mod plain;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod target_filter;
|
pub mod target_filter;
|
||||||
|
pub mod tls;
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ use hyper::body::Incoming;
|
|||||||
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};
|
||||||
|
use hyper::rt::{Read, Write};
|
||||||
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 tokio_rustls::TlsAcceptor;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::proxy::{connect, plain};
|
use crate::proxy::{connect, plain, tls};
|
||||||
use crate::runtime::SharedDynamicConfig;
|
use crate::runtime::SharedDynamicConfig;
|
||||||
|
|
||||||
/// Start the proxy server.
|
/// Start the proxy server.
|
||||||
@@ -20,15 +22,25 @@ use crate::runtime::SharedDynamicConfig;
|
|||||||
/// 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
|
/// - Other HTTP requests -> plain forward proxy handler
|
||||||
|
///
|
||||||
|
/// When `tls_acceptor` is provided, the server operates in dual-stack mode:
|
||||||
|
/// it peeks at the first byte of each connection to distinguish TLS ClientHello
|
||||||
|
/// (0x16) from plain HTTP, and handles both on the same port.
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
config: Arc<Config>,
|
config: Arc<Config>,
|
||||||
node_id: Arc<RwLock<String>>,
|
node_id: Arc<RwLock<String>>,
|
||||||
dynamic: SharedDynamicConfig,
|
dynamic: SharedDynamicConfig,
|
||||||
|
tls_acceptor: Option<TlsAcceptor>,
|
||||||
mut shutdown_rx: watch::Receiver<bool>,
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], config.listen_port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], config.listen_port));
|
||||||
let listener = TcpListener::bind(addr).await?;
|
let listener = TcpListener::bind(addr).await?;
|
||||||
info!(addr = %addr, "proxy server listening");
|
|
||||||
|
if tls_acceptor.is_some() {
|
||||||
|
info!(addr = %addr, "proxy server listening (HTTP+TLS dual-stack)");
|
||||||
|
} else {
|
||||||
|
info!(addr = %addr, "proxy server listening (HTTP only)");
|
||||||
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -46,10 +58,63 @@ pub async fn run(
|
|||||||
let config = Arc::clone(&config);
|
let config = Arc::clone(&config);
|
||||||
let node_id = Arc::clone(&node_id);
|
let node_id = Arc::clone(&node_id);
|
||||||
let dynamic = Arc::clone(&dynamic);
|
let dynamic = Arc::clone(&dynamic);
|
||||||
|
let tls_acceptor = tls_acceptor.clone();
|
||||||
|
|
||||||
tokio::task::spawn(async move {
|
tokio::task::spawn(async move {
|
||||||
let io = TokioIo::new(stream);
|
// Dual-stack: peek first byte to decide TLS vs plain HTTP
|
||||||
|
if let Some(acceptor) = &tls_acceptor {
|
||||||
|
if tls::is_tls_client_hello(&stream).await {
|
||||||
|
match acceptor.accept(stream).await {
|
||||||
|
Ok(tls_stream) => {
|
||||||
|
debug!(peer = %peer_addr, "TLS handshake ok");
|
||||||
|
serve_connection(
|
||||||
|
TokioIo::new(tls_stream),
|
||||||
|
peer_addr,
|
||||||
|
config,
|
||||||
|
node_id,
|
||||||
|
dynamic,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!(peer = %peer_addr, error = %e, "TLS handshake failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain HTTP
|
||||||
|
serve_connection(
|
||||||
|
TokioIo::new(stream),
|
||||||
|
peer_addr,
|
||||||
|
config,
|
||||||
|
node_id,
|
||||||
|
dynamic,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
info!("proxy server shutting down");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve a single HTTP/1.1 connection (works over both plain TCP and TLS).
|
||||||
|
async fn serve_connection<I>(
|
||||||
|
io: I,
|
||||||
|
peer_addr: SocketAddr,
|
||||||
|
config: Arc<Config>,
|
||||||
|
node_id: Arc<RwLock<String>>,
|
||||||
|
dynamic: SharedDynamicConfig,
|
||||||
|
) where
|
||||||
|
I: Read + Write + Unpin + Send + 'static,
|
||||||
|
{
|
||||||
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 node_id = Arc::clone(&node_id);
|
||||||
@@ -106,14 +171,4 @@ pub async fn run(
|
|||||||
debug!(peer = %peer_addr, error = %e, "connection error");
|
debug!(peer = %peer_addr, error = %e, "connection error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
_ = shutdown_rx.changed() => {
|
|
||||||
info!("proxy server shutting down");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|||||||
116
aether-proxy/src/proxy/tls.rs
Normal file
116
aether-proxy/src/proxy/tls.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io::BufReader;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rcgen::{CertificateParams, KeyPair};
|
||||||
|
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tokio_rustls::TlsAcceptor;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
/// Generate a self-signed certificate if the files do not already exist.
|
||||||
|
///
|
||||||
|
/// The certificate includes SANs: `localhost` and `aether-proxy`.
|
||||||
|
/// The private key file is set to mode 0600 on unix.
|
||||||
|
pub fn ensure_self_signed_cert(cert_path: &Path, key_path: &Path) -> anyhow::Result<()> {
|
||||||
|
if cert_path.exists() && key_path.exists() {
|
||||||
|
info!(
|
||||||
|
cert = %cert_path.display(),
|
||||||
|
key = %key_path.display(),
|
||||||
|
"using existing TLS certificate"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("generating self-signed TLS certificate");
|
||||||
|
|
||||||
|
let mut params = CertificateParams::new(vec![
|
||||||
|
"localhost".into(),
|
||||||
|
"aether-proxy".into(),
|
||||||
|
])?;
|
||||||
|
params.distinguished_name = rcgen::DistinguishedName::new();
|
||||||
|
params
|
||||||
|
.distinguished_name
|
||||||
|
.push(rcgen::DnType::CommonName, "aether-proxy");
|
||||||
|
|
||||||
|
let key_pair = KeyPair::generate()?;
|
||||||
|
let cert = params.self_signed(&key_pair)?;
|
||||||
|
|
||||||
|
let cert_pem = cert.pem();
|
||||||
|
let key_pem = key_pair.serialize_pem();
|
||||||
|
|
||||||
|
fs::write(cert_path, &cert_pem)?;
|
||||||
|
fs::write(key_path, &key_pem)?;
|
||||||
|
|
||||||
|
// Set key file permissions to 0600 on unix
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let perms = fs::Permissions::from_mode(0o600);
|
||||||
|
fs::set_permissions(key_path, perms)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
cert = %cert_path.display(),
|
||||||
|
key = %key_path.display(),
|
||||||
|
"self-signed TLS certificate generated"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `TlsAcceptor` from PEM certificate and key files.
|
||||||
|
pub fn build_tls_acceptor(cert_path: &Path, key_path: &Path) -> anyhow::Result<TlsAcceptor> {
|
||||||
|
let cert_file = fs::File::open(cert_path)?;
|
||||||
|
let key_file = fs::File::open(key_path)?;
|
||||||
|
|
||||||
|
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
if certs.is_empty() {
|
||||||
|
anyhow::bail!("no certificates found in {}", cert_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let key: PrivateKeyDer<'static> =
|
||||||
|
rustls_pemfile::private_key(&mut BufReader::new(key_file))?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no private key found in {}", key_path.display()))?;
|
||||||
|
|
||||||
|
let config = rustls::ServerConfig::builder()
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(certs, key)?;
|
||||||
|
|
||||||
|
Ok(TlsAcceptor::from(Arc::new(config)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the SHA-256 fingerprint of the first certificate in a PEM file.
|
||||||
|
///
|
||||||
|
/// Returns the hex-encoded fingerprint (lowercase, no separators).
|
||||||
|
pub fn cert_sha256_fingerprint(cert_path: &Path) -> anyhow::Result<String> {
|
||||||
|
let cert_file = fs::File::open(cert_path)?;
|
||||||
|
let certs: Vec<CertificateDer<'static>> =
|
||||||
|
rustls_pemfile::certs(&mut BufReader::new(cert_file))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
let cert = certs
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no certificates found in {}", cert_path.display()))?;
|
||||||
|
|
||||||
|
let digest = Sha256::digest(cert.as_ref());
|
||||||
|
Ok(hex::encode(digest))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peek at the first byte of a TCP stream to determine if it is a TLS ClientHello.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the first byte is 0x16 (TLS record type: Handshake).
|
||||||
|
pub async fn is_tls_client_hello(stream: &tokio::net::TcpStream) -> bool {
|
||||||
|
let mut buf = [0u8; 1];
|
||||||
|
match stream.peek(&mut buf).await {
|
||||||
|
Ok(1) => buf[0] == 0x16,
|
||||||
|
Ok(_) => false,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "failed to peek first byte");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ struct RegisterRequest {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
region: Option<String>,
|
region: Option<String>,
|
||||||
heartbeat_interval: u64,
|
heartbeat_interval: u64,
|
||||||
|
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
tls_enabled: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
tls_cert_fingerprint: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -113,6 +117,8 @@ impl AetherClient {
|
|||||||
&self,
|
&self,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
public_ip: &str,
|
public_ip: &str,
|
||||||
|
tls_enabled: bool,
|
||||||
|
tls_cert_fingerprint: Option<&str>,
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
let url = format!("{}/api/admin/proxy-nodes/register", self.base_url);
|
let url = format!("{}/api/admin/proxy-nodes/register", self.base_url);
|
||||||
let body = RegisterRequest {
|
let body = RegisterRequest {
|
||||||
@@ -121,6 +127,8 @@ impl AetherClient {
|
|||||||
port: config.listen_port,
|
port: config.listen_port,
|
||||||
region: config.node_region.clone(),
|
region: config.node_region.clone(),
|
||||||
heartbeat_interval: config.heartbeat_interval,
|
heartbeat_interval: config.heartbeat_interval,
|
||||||
|
tls_enabled,
|
||||||
|
tls_cert_fingerprint: tls_cert_fingerprint.map(|s| s.to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub async fn run(
|
|||||||
node_id: Arc<RwLock<String>>,
|
node_id: Arc<RwLock<String>>,
|
||||||
config: Arc<Config>,
|
config: Arc<Config>,
|
||||||
public_ip: String,
|
public_ip: String,
|
||||||
|
tls_fingerprint: Option<String>,
|
||||||
dynamic: SharedDynamicConfig,
|
dynamic: SharedDynamicConfig,
|
||||||
mut shutdown_rx: watch::Receiver<bool>,
|
mut shutdown_rx: watch::Receiver<bool>,
|
||||||
) {
|
) {
|
||||||
@@ -59,7 +60,12 @@ pub async fn run(
|
|||||||
old_node_id = %current_node_id,
|
old_node_id = %current_node_id,
|
||||||
"node not found, re-registering"
|
"node not found, re-registering"
|
||||||
);
|
);
|
||||||
match client.register(&config, &public_ip).await {
|
match client.register(
|
||||||
|
&config,
|
||||||
|
&public_ip,
|
||||||
|
config.enable_tls,
|
||||||
|
tls_fingerprint.as_deref(),
|
||||||
|
).await {
|
||||||
Ok(new_id) => {
|
Ok(new_id) => {
|
||||||
info!(
|
info!(
|
||||||
old_node_id = %current_node_id,
|
old_node_id = %current_node_id,
|
||||||
|
|||||||
@@ -150,6 +150,14 @@ impl App {
|
|||||||
required: true,
|
required: true,
|
||||||
help: "HMAC 时间戳容差窗口 (秒)",
|
help: "HMAC 时间戳容差窗口 (秒)",
|
||||||
},
|
},
|
||||||
|
Field {
|
||||||
|
label: "Enable TLS",
|
||||||
|
key: "enable_tls",
|
||||||
|
value: "true".into(),
|
||||||
|
kind: FieldKind::Bool,
|
||||||
|
required: true,
|
||||||
|
help: "启用 TLS 加密 (双栈模式, 同时接受 HTTP 和 TLS)",
|
||||||
|
},
|
||||||
Field {
|
Field {
|
||||||
label: "Log Level",
|
label: "Log Level",
|
||||||
key: "log_level",
|
key: "log_level",
|
||||||
@@ -204,6 +212,7 @@ impl App {
|
|||||||
"timestamp_tolerance" => cfg.timestamp_tolerance.map(|v| v.to_string()),
|
"timestamp_tolerance" => cfg.timestamp_tolerance.map(|v| v.to_string()),
|
||||||
"log_level" => cfg.log_level.clone(),
|
"log_level" => cfg.log_level.clone(),
|
||||||
"log_json" => cfg.log_json.map(|v| v.to_string()),
|
"log_json" => cfg.log_json.map(|v| v.to_string()),
|
||||||
|
"enable_tls" => cfg.enable_tls.map(|v| v.to_string()),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let Some(v) = val {
|
if let Some(v) = val {
|
||||||
@@ -238,6 +247,9 @@ impl App {
|
|||||||
timestamp_tolerance: get("timestamp_tolerance").and_then(|v| v.parse().ok()),
|
timestamp_tolerance: get("timestamp_tolerance").and_then(|v| v.parse().ok()),
|
||||||
log_level: get("log_level"),
|
log_level: get("log_level"),
|
||||||
log_json: get("log_json").and_then(|v| v.parse().ok()),
|
log_json: get("log_json").and_then(|v| v.parse().ok()),
|
||||||
|
enable_tls: get("enable_tls").and_then(|v| v.parse().ok()),
|
||||||
|
tls_cert: None,
|
||||||
|
tls_key: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Add tls_enabled and tls_cert_fingerprint to proxy_nodes
|
||||||
|
|
||||||
|
Revision ID: 4b5c6d7e8f9a
|
||||||
|
Revises: 3aff3ffc4a0e
|
||||||
|
Create Date: 2026-02-07 18:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "4b5c6d7e8f9a"
|
||||||
|
down_revision: str | None = "3aff3ffc4a0e"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists("proxy_nodes", "tls_enabled"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"tls_enabled",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="false",
|
||||||
|
comment="是否启用 TLS 加密",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not column_exists("proxy_nodes", "tls_cert_fingerprint"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"tls_cert_fingerprint",
|
||||||
|
sa.String(128),
|
||||||
|
nullable=True,
|
||||||
|
comment="TLS 证书 SHA-256 指纹(hex)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists("proxy_nodes", "tls_cert_fingerprint"):
|
||||||
|
op.drop_column("proxy_nodes", "tls_cert_fingerprint")
|
||||||
|
if column_exists("proxy_nodes", "tls_enabled"):
|
||||||
|
op.drop_column("proxy_nodes", "tls_enabled")
|
||||||
@@ -50,6 +50,8 @@ def _node_to_dict(node: ProxyNode) -> dict[str, Any]:
|
|||||||
"active_connections": node.active_connections,
|
"active_connections": node.active_connections,
|
||||||
"total_requests": node.total_requests,
|
"total_requests": node.total_requests,
|
||||||
"avg_latency_ms": node.avg_latency_ms,
|
"avg_latency_ms": node.avg_latency_ms,
|
||||||
|
"tls_enabled": bool(node.tls_enabled),
|
||||||
|
"tls_cert_fingerprint": node.tls_cert_fingerprint,
|
||||||
"remote_config": node.remote_config,
|
"remote_config": node.remote_config,
|
||||||
"config_version": node.config_version,
|
"config_version": node.config_version,
|
||||||
"created_at": node.created_at,
|
"created_at": node.created_at,
|
||||||
@@ -75,6 +77,12 @@ class ProxyNodeRegisterRequest(BaseModel):
|
|||||||
total_requests: int | None = Field(None, ge=0, description="累计请求数")
|
total_requests: int | None = Field(None, ge=0, description="累计请求数")
|
||||||
avg_latency_ms: float | None = Field(None, ge=0, description="平均延迟(毫秒)")
|
avg_latency_ms: float | None = Field(None, ge=0, description="平均延迟(毫秒)")
|
||||||
|
|
||||||
|
# TLS
|
||||||
|
tls_enabled: bool = Field(False, description="是否启用 TLS 加密")
|
||||||
|
tls_cert_fingerprint: str | None = Field(
|
||||||
|
None, max_length=128, description="TLS 证书 SHA-256 指纹"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("ip")
|
@field_validator("ip")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_ip(cls, v: str) -> str:
|
def validate_ip(cls, v: str) -> str:
|
||||||
@@ -275,6 +283,8 @@ class AdminRegisterProxyNodeAdapter(AdminApiAdapter):
|
|||||||
node.status = ProxyNodeStatus.ONLINE
|
node.status = ProxyNodeStatus.ONLINE
|
||||||
node.last_heartbeat_at = now
|
node.last_heartbeat_at = now
|
||||||
node.heartbeat_interval = req.heartbeat_interval
|
node.heartbeat_interval = req.heartbeat_interval
|
||||||
|
node.tls_enabled = req.tls_enabled
|
||||||
|
node.tls_cert_fingerprint = req.tls_cert_fingerprint
|
||||||
if req.active_connections is not None:
|
if req.active_connections is not None:
|
||||||
node.active_connections = req.active_connections
|
node.active_connections = req.active_connections
|
||||||
if req.total_requests is not None:
|
if req.total_requests is not None:
|
||||||
@@ -295,6 +305,8 @@ class AdminRegisterProxyNodeAdapter(AdminApiAdapter):
|
|||||||
active_connections=req.active_connections or 0,
|
active_connections=req.active_connections or 0,
|
||||||
total_requests=req.total_requests or 0,
|
total_requests=req.total_requests or 0,
|
||||||
avg_latency_ms=req.avg_latency_ms,
|
avg_latency_ms=req.avg_latency_ms,
|
||||||
|
tls_enabled=req.tls_enabled,
|
||||||
|
tls_cert_fingerprint=req.tls_cert_fingerprint,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
@@ -496,7 +508,9 @@ def _build_test_proxy_url(node: ProxyNode) -> str:
|
|||||||
# aether-proxy: 使用 HMAC 认证构建代理 URL
|
# aether-proxy: 使用 HMAC 认证构建代理 URL
|
||||||
from src.clients.http_client import _build_hmac_proxy_url
|
from src.clients.http_client import _build_hmac_proxy_url
|
||||||
|
|
||||||
return _build_hmac_proxy_url(node.ip, node.port, node.id)
|
return _build_hmac_proxy_url(
|
||||||
|
node.ip, node.port, node.id, tls_enabled=bool(node.tls_enabled)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -634,9 +648,14 @@ class AdminTestProxyNodeAdapter(AdminApiAdapter):
|
|||||||
test_url = "https://1.1.1.1/cdn-cgi/trace"
|
test_url = "https://1.1.1.1/cdn-cgi/trace"
|
||||||
start = _time.monotonic()
|
start = _time.monotonic()
|
||||||
|
|
||||||
|
# TLS 代理需要 proxy_ssl_context
|
||||||
|
from src.clients.http_client import _make_proxy_param
|
||||||
|
|
||||||
|
proxy_param = _make_proxy_param(proxy_url)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
proxy=proxy_url,
|
proxy=proxy_param,
|
||||||
timeout=httpx.Timeout(15.0, connect=10.0),
|
timeout=httpx.Timeout(15.0, connect=10.0),
|
||||||
) as client:
|
) as client:
|
||||||
response = await client.get(test_url)
|
response = await client.get(test_url)
|
||||||
|
|||||||
@@ -73,7 +73,12 @@ def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
|
|||||||
"password": node.proxy_password,
|
"password": node.proxy_password,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
value = {"ip": node.ip, "port": node.port}
|
value = {
|
||||||
|
"ip": node.ip,
|
||||||
|
"port": node.port,
|
||||||
|
"tls_enabled": bool(node.tls_enabled),
|
||||||
|
"tls_cert_fingerprint": node.tls_cert_fingerprint,
|
||||||
|
}
|
||||||
|
|
||||||
_proxy_node_cache[node_id] = (value, now + _PROXY_NODE_CACHE_TTL_SECONDS)
|
_proxy_node_cache[node_id] = (value, now + _PROXY_NODE_CACHE_TTL_SECONDS)
|
||||||
return value
|
return value
|
||||||
@@ -81,12 +86,14 @@ def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _build_hmac_proxy_url(ip: str, port: int, node_id: str) -> str:
|
def _build_hmac_proxy_url(ip: str, port: int, node_id: str, *, tls_enabled: bool = False) -> str:
|
||||||
"""
|
"""
|
||||||
构建带 HMAC BasicAuth 的 httpx proxy URL
|
构建带 HMAC BasicAuth 的 httpx proxy URL
|
||||||
|
|
||||||
格式: http://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}\\n{node_id}") 的 hex
|
||||||
|
|
||||||
|
当 tls_enabled=True 时使用 https:// scheme。
|
||||||
"""
|
"""
|
||||||
if not config.proxy_hmac_key:
|
if not config.proxy_hmac_key:
|
||||||
raise ProxyNodeUnavailableError(
|
raise ProxyNodeUnavailableError(
|
||||||
@@ -102,7 +109,8 @@ def _build_hmac_proxy_url(ip: str, port: int, node_id: str) -> str:
|
|||||||
).hexdigest()
|
).hexdigest()
|
||||||
|
|
||||||
host = f"[{ip}]" if ":" in ip else ip
|
host = f"[{ip}]" if ":" in ip else ip
|
||||||
return f"http://hmac:{timestamp}.{signature}@{host}:{int(port)}"
|
scheme = "https" if tls_enabled else "http"
|
||||||
|
return f"{scheme}://hmac:{timestamp}.{signature}@{host}:{int(port)}"
|
||||||
|
|
||||||
|
|
||||||
# 系统默认代理缓存
|
# 系统默认代理缓存
|
||||||
@@ -150,9 +158,11 @@ def get_system_proxy_config() -> dict[str, Any] | None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def resolve_ops_proxy(connector_config: dict[str, Any] | None) -> str | None:
|
def resolve_ops_proxy(
|
||||||
|
connector_config: dict[str, Any] | None,
|
||||||
|
) -> str | httpx.Proxy | None:
|
||||||
"""
|
"""
|
||||||
从 ops connector.config 中解析代理 URL(含系统默认回退)
|
从 ops connector.config 中解析代理参数(含系统默认回退)
|
||||||
|
|
||||||
优先级:
|
优先级:
|
||||||
1. connector_config.proxy_node_id(新格式)
|
1. connector_config.proxy_node_id(新格式)
|
||||||
@@ -163,14 +173,15 @@ def resolve_ops_proxy(connector_config: dict[str, Any] | None) -> str | None:
|
|||||||
connector_config: connector 的 config 字典
|
connector_config: connector 的 config 字典
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
代理 URL 字符串,或 None
|
httpx 可接受的代理参数(str 或 httpx.Proxy),或 None
|
||||||
"""
|
"""
|
||||||
if connector_config:
|
if connector_config:
|
||||||
# 新格式:proxy_node_id → 通过 build_proxy_url 解析
|
# 新格式:proxy_node_id → 通过 build_proxy_url 解析
|
||||||
node_id = connector_config.get("proxy_node_id")
|
node_id = connector_config.get("proxy_node_id")
|
||||||
if isinstance(node_id, str) and node_id.strip():
|
if isinstance(node_id, str) and node_id.strip():
|
||||||
try:
|
try:
|
||||||
return build_proxy_url({"node_id": node_id.strip(), "enabled": True})
|
url = build_proxy_url({"node_id": node_id.strip(), "enabled": True})
|
||||||
|
return _make_proxy_param(url)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("解析 proxy_node_id={} 失败,回退到直连: {}", node_id, exc)
|
logger.warning("解析 proxy_node_id={} 失败,回退到直连: {}", node_id, exc)
|
||||||
return None
|
return None
|
||||||
@@ -184,7 +195,8 @@ def resolve_ops_proxy(connector_config: dict[str, Any] | None) -> str | None:
|
|||||||
system_proxy = get_system_proxy_config()
|
system_proxy = get_system_proxy_config()
|
||||||
if system_proxy:
|
if system_proxy:
|
||||||
try:
|
try:
|
||||||
return build_proxy_url(system_proxy)
|
url = build_proxy_url(system_proxy)
|
||||||
|
return _make_proxy_param(url)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("构建系统默认代理 URL 失败: {}", exc)
|
logger.warning("构建系统默认代理 URL 失败: {}", exc)
|
||||||
return None
|
return None
|
||||||
@@ -281,7 +293,12 @@ def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
|
|||||||
return manual_url
|
return manual_url
|
||||||
|
|
||||||
# aether-proxy 节点:使用 HMAC 认证
|
# aether-proxy 节点:使用 HMAC 认证
|
||||||
return _build_hmac_proxy_url(node_info["ip"], node_info["port"], node_id)
|
return _build_hmac_proxy_url(
|
||||||
|
node_info["ip"],
|
||||||
|
node_info["port"],
|
||||||
|
node_id,
|
||||||
|
tls_enabled=node_info.get("tls_enabled", False),
|
||||||
|
)
|
||||||
|
|
||||||
proxy_url: str | None = proxy_config.get("url")
|
proxy_url: str | None = proxy_config.get("url")
|
||||||
if not proxy_url:
|
if not proxy_url:
|
||||||
@@ -308,6 +325,26 @@ def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
|
|||||||
return proxy_url
|
return proxy_url
|
||||||
|
|
||||||
|
|
||||||
|
def _make_proxy_param(proxy_url: str | None) -> str | httpx.Proxy | None:
|
||||||
|
"""
|
||||||
|
根据代理 URL 返回 httpx 可接受的 proxy 参数。
|
||||||
|
|
||||||
|
对于 https:// scheme 的代理 URL(TLS aether-proxy 节点),返回 httpx.Proxy
|
||||||
|
并附带 proxy_ssl_context(CERT_NONE,因为使用自签名证书)。
|
||||||
|
其他情况返回普通 URL 字符串。
|
||||||
|
"""
|
||||||
|
if not proxy_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# https:// 代理需要 ssl_context(自签名证书场景)
|
||||||
|
if proxy_url.startswith("https://"):
|
||||||
|
from src.utils.ssl_utils import get_proxy_ssl_context
|
||||||
|
|
||||||
|
return httpx.Proxy(url=proxy_url, ssl_context=get_proxy_ssl_context())
|
||||||
|
|
||||||
|
return proxy_url
|
||||||
|
|
||||||
|
|
||||||
class HTTPClientPool:
|
class HTTPClientPool:
|
||||||
"""
|
"""
|
||||||
全局HTTP客户端池单例
|
全局HTTP客户端池单例
|
||||||
@@ -521,8 +558,9 @@ class HTTPClientPool:
|
|||||||
|
|
||||||
# 添加代理配置
|
# 添加代理配置
|
||||||
proxy_url = build_proxy_url(proxy_config) if proxy_config else None
|
proxy_url = build_proxy_url(proxy_config) if proxy_config else None
|
||||||
if proxy_url:
|
proxy_param = _make_proxy_param(proxy_url)
|
||||||
client_config["proxy"] = proxy_url
|
if proxy_param:
|
||||||
|
client_config["proxy"] = proxy_param
|
||||||
|
|
||||||
client = httpx.AsyncClient(**client_config) # type: ignore[arg-type]
|
client = httpx.AsyncClient(**client_config) # type: ignore[arg-type]
|
||||||
cls._proxy_clients[cache_key] = (client, time.time())
|
cls._proxy_clients[cache_key] = (client, time.time())
|
||||||
@@ -629,10 +667,15 @@ class HTTPClientPool:
|
|||||||
pool=config.http_pool_timeout,
|
pool=config.http_pool_timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 无特定代理时,回退到系统默认代理(与 get_proxy_client 行为一致)
|
||||||
|
if proxy_config is None:
|
||||||
|
proxy_config = get_system_proxy_config()
|
||||||
|
|
||||||
# 添加代理配置
|
# 添加代理配置
|
||||||
proxy_url = build_proxy_url(proxy_config) if proxy_config else None
|
proxy_url = build_proxy_url(proxy_config) if proxy_config else None
|
||||||
if proxy_url:
|
proxy_param = _make_proxy_param(proxy_url)
|
||||||
client_config["proxy"] = proxy_url
|
if proxy_param:
|
||||||
|
client_config["proxy"] = proxy_param
|
||||||
logger.debug(f"创建带代理的HTTP客户端(一次性): {proxy_config.get('url', 'unknown')}")
|
logger.debug(f"创建带代理的HTTP客户端(一次性): {proxy_config.get('url', 'unknown')}")
|
||||||
|
|
||||||
client_config.update(kwargs)
|
client_config.update(kwargs)
|
||||||
|
|||||||
@@ -842,6 +842,12 @@ class ProxyNode(Base):
|
|||||||
total_requests = Column(BigInteger, default=0, nullable=False)
|
total_requests = Column(BigInteger, default=0, nullable=False)
|
||||||
avg_latency_ms = Column(Float, nullable=True)
|
avg_latency_ms = Column(Float, nullable=True)
|
||||||
|
|
||||||
|
# TLS 加密
|
||||||
|
tls_enabled = Column(Boolean, default=False, nullable=False, comment="是否启用 TLS 加密")
|
||||||
|
tls_cert_fingerprint = Column(
|
||||||
|
String(128), nullable=True, comment="TLS 证书 SHA-256 指纹(hex)"
|
||||||
|
)
|
||||||
|
|
||||||
# 管理端远程配置(通过心跳下发给 aether-proxy)
|
# 管理端远程配置(通过心跳下发给 aether-proxy)
|
||||||
remote_config = Column(
|
remote_config = Column(
|
||||||
JSON,
|
JSON,
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ def _parse_session_user_id(cookie_input: str) -> tuple[str | None, str | None]:
|
|||||||
|
|
||||||
|
|
||||||
async def _get_acw_cookie(
|
async def _get_acw_cookie(
|
||||||
base_url: str, timeout: float = 10, proxy: str | None = None
|
base_url: str, timeout: float = 10, proxy: str | httpx.Proxy | None = None
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""
|
"""
|
||||||
获取 acw_sc__v2 Cookie
|
获取 acw_sc__v2 Cookie
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class ProviderConnector(ABC):
|
|||||||
# 代理配置(支持 proxy_node_id 和旧的 proxy URL)
|
# 代理配置(支持 proxy_node_id 和旧的 proxy URL)
|
||||||
from src.clients.http_client import resolve_ops_proxy
|
from src.clients.http_client import resolve_ops_proxy
|
||||||
|
|
||||||
self._proxy: str | None = resolve_ops_proxy(self.config)
|
self._proxy: str | httpx.Proxy | None = resolve_ops_proxy(self.config)
|
||||||
|
|
||||||
# HTTP 客户端配置
|
# HTTP 客户端配置
|
||||||
self._timeout = self.config.get("timeout", 30)
|
self._timeout = self.config.get("timeout", 30)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ SSL 工具函数
|
|||||||
|
|
||||||
import ssl
|
import ssl
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import certifi
|
import certifi
|
||||||
|
|
||||||
@@ -12,6 +14,8 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
_SSL_CONTEXT = ssl.create_default_context()
|
_SSL_CONTEXT = ssl.create_default_context()
|
||||||
|
|
||||||
|
_PROXY_SSL_CONTEXT: ssl.SSLContext | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_ssl_context() -> ssl.SSLContext:
|
def get_ssl_context() -> ssl.SSLContext:
|
||||||
"""
|
"""
|
||||||
@@ -24,3 +28,30 @@ def get_ssl_context() -> ssl.SSLContext:
|
|||||||
ssl.SSLContext: SSL 上下文
|
ssl.SSLContext: SSL 上下文
|
||||||
"""
|
"""
|
||||||
return _SSL_CONTEXT
|
return _SSL_CONTEXT
|
||||||
|
|
||||||
|
|
||||||
|
def get_proxy_ssl_context(expected_fingerprint: str | None = None) -> ssl.SSLContext:
|
||||||
|
"""
|
||||||
|
获取用于代理连接的 SSL 上下文(连接 aether-proxy TLS 端口)
|
||||||
|
|
||||||
|
当前使用 CERT_NONE(不验证证书),因为 aether-proxy 使用自签名证书。
|
||||||
|
expected_fingerprint 参数预留供未来实现指纹校验。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expected_fingerprint: 预期的证书 SHA-256 指纹(hex,预留参数)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ssl.SSLContext: 代理专用 SSL 上下文
|
||||||
|
"""
|
||||||
|
global _PROXY_SSL_CONTEXT
|
||||||
|
|
||||||
|
if expected_fingerprint:
|
||||||
|
logger.warning("TLS 证书指纹校验尚未实现, fingerprint={} 被忽略", expected_fingerprint)
|
||||||
|
|
||||||
|
if _PROXY_SSL_CONTEXT is None:
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
_PROXY_SSL_CONTEXT = ctx
|
||||||
|
# TODO: 实现基于 expected_fingerprint 的证书指纹校验
|
||||||
|
return _PROXY_SSL_CONTEXT
|
||||||
|
|||||||
Reference in New Issue
Block a user