feat: delegate 客户端替换为 hyper 原生实现,新增代理管理功能

aether-proxy:
- 用 hyper-util Client + 自定义 InstrumentedConnector 替换 reqwest delegate 客户端
- 支持 HTTP/HTTPS 自动 TLS,ALPN h2 协商,connect/tls 分阶段计时
- ConnectTiming 通过 hyper extensions 传递,上游响应细分 connect_ms/tls_ms/ttfb_ms
- upgrade 命令在非 root 下跳过 systemd restart 并提示手动操作

后端:
- 新增 /admin/proxy-nodes/test-url 接口,支持直接测试代理 URL 连通性
- 新增 /admin/proxy-nodes/hmac-key 接口,获取 HMAC Key 供部署使用
- 提取 _test_proxy_connectivity 公共函数,消除 test_node 中的重复代码
- candidate_resolver 在 extra_data 中输出 needs_conversion/provider_api_format
- stats_aggregator 小时聚合增加 IntegrityError 冲突重试

前端:
- 请求时间线组件展示代理 timing 细分(DNS/连接/TLS/TTFB/上游处理)
- 请求时间线增加格式转换分界标记和 conversion badge
- ProxyNodes 页面新增代理 URL 测试和 HMAC Key 复制功能
- HardwareTooltip 从 Popover 改为 Tooltip 组件
This commit is contained in:
fawney19
2026-02-11 23:24:59 +08:00
parent 943ca79951
commit 1c2a8119c0
16 changed files with 813 additions and 196 deletions

View File

@@ -97,26 +97,7 @@ pub async fn run(mut config: Config) -> anyhow::Result<()> {
let dynamic = Arc::new(RwLock::new(DynamicConfig::from_config(&config)));
// Build delegate HTTP client (for proxy-initiated upstream requests).
// No overall timeout — SSE streams can last indefinitely.
// Connect timeout limits connection establishment; Aether controls
// first-byte / idle timeouts on its own side.
let mut delegate_builder = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(config.delegate_connect_timeout_secs))
.pool_max_idle_per_host(config.delegate_pool_max_idle_per_host)
.pool_idle_timeout(Duration::from_secs(config.delegate_pool_idle_timeout_secs))
.tcp_nodelay(config.delegate_tcp_nodelay);
if config.delegate_tcp_keepalive_secs > 0 {
delegate_builder = delegate_builder.tcp_keepalive(Some(Duration::from_secs(
config.delegate_tcp_keepalive_secs,
)));
} else {
delegate_builder = delegate_builder.tcp_keepalive(None);
}
let delegate_client = delegate_builder
.build()
.expect("failed to create delegate HTTP client");
let delegate_client = proxy::delegate_client::build_delegate_client(&config);
// Build shared application state
let state = Arc::new(AppState {

View File

@@ -1,18 +1,21 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::error::Error as StdError;
use std::sync::Arc;
use std::time::Instant;
use futures_util::{StreamExt, TryStreamExt};
use futures_util::StreamExt;
use http_body_util::{BodyExt, Full, Limited, StreamBody};
use hyper::body::{Frame, Incoming};
use hyper::{Request, Response};
use hyper::header::{HeaderName, HeaderValue};
use hyper::{Method, Request, Response, Uri};
use tracing::{debug, warn};
use url::Url;
use super::BoxBody;
use crate::auth;
use crate::config::Config;
use crate::proxy::delegate_client::{ConnectTiming, DelegateClient};
use crate::proxy::target_filter::{self, DnsCache};
/// Handle delegation requests: Aether sends a full request description,
@@ -35,7 +38,7 @@ pub async fn handle_delegate(
allowed_ports: &HashSet<u16>,
timestamp_tolerance: u64,
dns_cache: &DnsCache,
http_client: &reqwest::Client,
http_client: &DelegateClient,
) -> Response<BoxBody> {
let total_start = Instant::now();
@@ -150,7 +153,7 @@ pub async fn handle_delegate(
debug!(method = %method_str, url = %target_url, is_gzip, "delegate request");
// ── Build upstream request ──
let method = match method_str.parse::<reqwest::Method>() {
let method = match method_str.parse::<Method>() {
Ok(m) => m,
Err(e) => {
warn!(error = %e, method = %method_str, "delegate invalid HTTP method");
@@ -158,34 +161,32 @@ pub async fn handle_delegate(
}
};
let mut upstream_req = http_client.request(method, &target_url);
// Set headers (skip `host` — reqwest sets it from the URL automatically,
// and a duplicate Host header can confuse certain upstreams)
for (name, value) in &upstream_headers {
if name.eq_ignore_ascii_case("host") {
continue;
let uri = match target_url.parse::<Uri>() {
Ok(u) => u,
Err(e) => {
warn!(error = %e, url = %target_url, "delegate invalid target URI");
return error_response(400, "bad_request", &format!("invalid URL: {}", e));
}
upstream_req = upstream_req.header(name.as_str(), value.as_str());
}
};
// ── Stream body passthrough ──
// When body is gzip-compressed, forward it directly to upstream with
// Content-Encoding: gzip header — no collect/decompress needed.
// All major AI API providers (Anthropic, OpenAI, Google) accept gzip request bodies.
let wire_size: u64;
let upstream_body: BoxBody;
if is_gzip {
// Passthrough: stream the gzip body directly to upstream
upstream_req = upstream_req.header("content-encoding", "gzip");
let body_stream = req.into_body();
let byte_stream = http_body_util::BodyStream::new(body_stream).filter_map(|result| async {
match result {
Ok(frame) => frame.into_data().ok().map(Ok),
Err(e) => Some(Err(e)),
}
});
let reqwest_body = reqwest::Body::wrap_stream(byte_stream);
upstream_req = upstream_req.body(reqwest_body);
let body_stream =
http_body_util::BodyStream::new(req.into_body()).filter_map(|result| async {
match result {
Ok(frame) => frame.into_data().ok().map(|data| {
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(Frame::data(data))
}),
Err(e) => Some(Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>)),
}
});
let stream_body = StreamBody::new(body_stream);
upstream_body = BodyExt::boxed(stream_body);
// wire_size will be reported from Content-Length if available, otherwise 0
wire_size = req_content_length;
} else {
@@ -199,41 +200,85 @@ pub async fn handle_delegate(
}
};
wire_size = body_bytes.len() as u64;
if !body_bytes.is_empty() {
upstream_req = upstream_req.body(body_bytes.to_vec());
let body = Full::new(body_bytes)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
.boxed();
upstream_body = body;
}
let mut upstream_req = Request::new(upstream_body);
*upstream_req.method_mut() = method;
*upstream_req.uri_mut() = uri;
{
let headers = upstream_req.headers_mut();
// Set headers (skip `host` — hyper sets it from the URI automatically,
// and a duplicate Host header can confuse certain upstreams)
for (name, value) in &upstream_headers {
if name.eq_ignore_ascii_case("host") {
continue;
}
let header_name = match HeaderName::from_bytes(name.as_bytes()) {
Ok(n) => n,
Err(_) => {
warn!(header = %name, "delegate invalid header name");
return error_response(400, "bad_request", "invalid header name");
}
};
let header_value = match HeaderValue::from_str(value) {
Ok(v) => v,
Err(_) => {
warn!(header = %name, "delegate invalid header value");
return error_response(400, "bad_request", "invalid header value");
}
};
headers.insert(header_name, header_value);
}
if is_gzip {
headers.insert(
hyper::header::CONTENT_ENCODING,
HeaderValue::from_static("gzip"),
);
}
}
// ── Send upstream request ──
// NOTE: We intentionally do NOT set a per-request timeout here.
// reqwest's `.timeout()` caps the *entire* request including body streaming,
// which would truncate long-lived SSE streams. The delegate_client already
// has a configured connect_timeout for connection establishment, and Aether controls
// Connect timeout limits connection establishment; Aether controls
// first-byte / idle timeouts on its own side via asyncio.
let upstream_start = Instant::now();
let upstream_resp = match upstream_req.send().await {
let upstream_resp = match http_client.request(upstream_req).await {
Ok(resp) => resp,
Err(e) => {
warn!(url = %target_url, error = %e, "delegate upstream request failed");
let safe_detail = sanitize_upstream_error(&e.to_string());
if e.is_timeout() {
let safe_detail = sanitize_upstream_error(&root_error_message(&e));
if is_timeout_error(&e) {
return error_response(504, "upstream_timeout", &safe_detail);
}
return error_response(502, "upstream_connection_failed", &safe_detail);
}
};
let upstream_ms = upstream_start.elapsed().as_millis() as u64;
let ttfb_ms = upstream_start.elapsed().as_millis() as u64;
// ── Build response ──
let status = upstream_resp.status().as_u16();
let resp_headers = upstream_resp.headers().clone();
let (connect_ms, tls_ms) = upstream_resp
.extensions()
.get::<ConnectTiming>()
.map(|t| (t.connect_ms, t.tls_ms))
.unwrap_or((0, 0));
let upstream_processing_ms = ttfb_ms.saturating_sub(connect_ms.saturating_add(tls_ms));
let total_ms = total_start.elapsed().as_millis() as u64;
debug!(
url = %target_url,
status,
dns_ms,
upstream_ms,
connect_ms,
tls_ms,
ttfb_ms,
upstream_processing_ms,
total_ms,
wire_size,
is_gzip,
@@ -246,16 +291,18 @@ pub async fn handle_delegate(
"wire_size": wire_size,
"passthrough": is_gzip,
"dns_ms": dns_ms,
"upstream_ms": upstream_ms,
"connect_ms": connect_ms,
"tls_ms": tls_ms,
"ttfb_ms": ttfb_ms,
"upstream_ms": ttfb_ms,
"upstream_processing_ms": upstream_processing_ms,
"total_ms": total_ms,
});
let body_stream = upstream_resp
.bytes_stream()
.map_ok(Frame::data)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) });
let stream_body: BoxBody = BodyExt::boxed(StreamBody::new(body_stream));
let stream_body: BoxBody = upstream_resp
.into_body()
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })
.boxed();
let mut builder = Response::builder().status(status);
for (name, value) in resp_headers.iter() {
@@ -271,6 +318,30 @@ pub async fn handle_delegate(
})
}
fn root_error_message(err: &dyn StdError) -> String {
let mut current = err;
while let Some(source) = current.source() {
current = source;
}
current.to_string()
}
fn is_timeout_error(err: &(dyn StdError + 'static)) -> bool {
if err.is::<tokio::time::error::Elapsed>() {
return true;
}
if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
if io_err.kind() == std::io::ErrorKind::TimedOut {
return true;
}
}
if let Some(source) = err.source() {
// source() returns &(dyn Error + 'static), so this is safe
return is_timeout_error(source);
}
false
}
// ── Sanitisation ─────────────────────────────────────────────────────────────
/// Strip full URLs from error messages to prevent leaking upstream API keys,

View File

@@ -0,0 +1,282 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
use hyper::rt;
use hyper::Uri;
use hyper_util::client::legacy::connect::{Connected, Connection, HttpConnector};
use hyper_util::client::legacy::Client;
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
use rustls::ClientConfig;
use rustls_pki_types::ServerName;
use tokio_rustls::TlsConnector;
use tower_service::Service;
use crate::config::Config;
use crate::proxy::BoxBody;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
type DelegateStream = MaybeHttpsStream<TokioIo<tokio::net::TcpStream>>;
type DelegateConn = TimedConn<DelegateStream>;
pub(crate) type DelegateClient = Client<InstrumentedConnector, BoxBody>;
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct ConnectTiming {
pub connect_ms: u64,
pub tls_ms: u64,
}
pub(crate) fn build_delegate_client(config: &Config) -> DelegateClient {
let mut http = HttpConnector::new();
http.enforce_http(false);
http.set_connect_timeout(Some(Duration::from_secs(
config.delegate_connect_timeout_secs,
)));
http.set_nodelay(config.delegate_tcp_nodelay);
if config.delegate_tcp_keepalive_secs > 0 {
http.set_keepalive(Some(Duration::from_secs(
config.delegate_tcp_keepalive_secs,
)));
} else {
http.set_keepalive(None);
}
let connector = InstrumentedConnector {
http,
tls_config: build_tls_config(),
};
let mut builder = Client::builder(TokioExecutor::new());
builder.pool_max_idle_per_host(config.delegate_pool_max_idle_per_host);
builder.pool_idle_timeout(Duration::from_secs(config.delegate_pool_idle_timeout_secs));
builder.pool_timer(TokioTimer::new());
builder.build::<_, BoxBody>(connector)
}
fn build_tls_config() -> Arc<ClientConfig> {
let root_store =
rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let mut config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
Arc::new(config)
}
#[derive(Clone)]
pub(crate) struct InstrumentedConnector {
http: HttpConnector,
tls_config: Arc<ClientConfig>,
}
impl Service<Uri> for InstrumentedConnector {
type Response = DelegateConn;
type Error = BoxError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, BoxError>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.http.poll_ready(cx).map_err(Into::into)
}
fn call(&mut self, dst: Uri) -> Self::Future {
let scheme = dst.scheme_str().map(|s| s.to_ascii_lowercase());
let tls_config = self.tls_config.clone();
let connecting = self.http.call(dst.clone());
let connect_start = Instant::now();
Box::pin(async move {
match scheme.as_deref() {
Some("http") => {
let tcp = connecting.await.map_err(|e| Box::new(e) as BoxError)?;
let connect_ms = connect_start.elapsed().as_millis() as u64;
Ok(TimedConn::new(
MaybeHttpsStream::Http(tcp),
ConnectTiming {
connect_ms,
tls_ms: 0,
},
))
}
Some("https") => {
let server_name = resolve_server_name(&dst)?;
let tcp = connecting.await.map_err(|e| Box::new(e) as BoxError)?;
let connect_ms = connect_start.elapsed().as_millis() as u64;
let tls_start = Instant::now();
let tls_stream = TlsConnector::from(tls_config)
.connect(server_name, TokioIo::new(tcp))
.await
.map_err(std::io::Error::other)?;
let tls_ms = tls_start.elapsed().as_millis() as u64;
Ok(TimedConn::new(
MaybeHttpsStream::Https(TokioIo::new(tls_stream)),
ConnectTiming { connect_ms, tls_ms },
))
}
Some(other) => {
Err(std::io::Error::other(format!("unsupported scheme {other}")).into())
}
None => Err(std::io::Error::other("missing scheme").into()),
}
})
}
}
fn resolve_server_name(uri: &Uri) -> Result<ServerName<'static>, BoxError> {
let host = uri.host().ok_or("missing host")?;
let host = host.trim_start_matches('[').trim_end_matches(']');
Ok(ServerName::try_from(host.to_string())?)
}
pub(crate) struct TimedConn<T> {
inner: T,
timing: ConnectTiming,
}
impl<T> TimedConn<T> {
fn new(inner: T, timing: ConnectTiming) -> Self {
Self { inner, timing }
}
}
impl<T: Connection> Connection for TimedConn<T> {
fn connected(&self) -> Connected {
self.inner.connected().extra(self.timing)
}
}
impl<T: rt::Read + Unpin> rt::Read for TimedConn<T> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: rt::ReadBufCursor<'_>,
) -> Poll<Result<(), std::io::Error>> {
Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
impl<T: rt::Write + Unpin> rt::Write for TimedConn<T> {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, std::io::Error>> {
Pin::new(&mut self.inner).poll_write(cx, buf)
}
fn poll_flush(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<(), std::io::Error>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<(), std::io::Error>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
fn is_write_vectored(&self) -> bool {
self.inner.is_write_vectored()
}
fn poll_write_vectored(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[std::io::IoSlice<'_>],
) -> Poll<Result<usize, std::io::Error>> {
Pin::new(&mut self.inner).poll_write_vectored(cx, bufs)
}
}
#[allow(clippy::large_enum_variant)]
pub(crate) enum MaybeHttpsStream<T> {
Http(T),
Https(TokioIo<tokio_rustls::client::TlsStream<TokioIo<T>>>),
}
impl<T: rt::Read + rt::Write + Connection + Unpin> Connection for MaybeHttpsStream<T> {
fn connected(&self) -> Connected {
match self {
Self::Http(stream) => stream.connected(),
Self::Https(stream) => {
let (tcp, tls) = stream.inner().get_ref();
if tls.alpn_protocol() == Some(b"h2") {
tcp.inner().connected().negotiated_h2()
} else {
tcp.inner().connected()
}
}
}
}
}
impl<T: rt::Read + rt::Write + Unpin> rt::Read for MaybeHttpsStream<T> {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: rt::ReadBufCursor<'_>,
) -> Poll<Result<(), std::io::Error>> {
match Pin::get_mut(self) {
Self::Http(stream) => Pin::new(stream).poll_read(cx, buf),
Self::Https(stream) => Pin::new(stream).poll_read(cx, buf),
}
}
}
impl<T: rt::Write + rt::Read + Unpin> rt::Write for MaybeHttpsStream<T> {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, std::io::Error>> {
match Pin::get_mut(self) {
Self::Http(stream) => Pin::new(stream).poll_write(cx, buf),
Self::Https(stream) => Pin::new(stream).poll_write(cx, buf),
}
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), std::io::Error>> {
match Pin::get_mut(self) {
Self::Http(stream) => Pin::new(stream).poll_flush(cx),
Self::Https(stream) => Pin::new(stream).poll_flush(cx),
}
}
fn poll_shutdown(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<(), std::io::Error>> {
match Pin::get_mut(self) {
Self::Http(stream) => Pin::new(stream).poll_shutdown(cx),
Self::Https(stream) => Pin::new(stream).poll_shutdown(cx),
}
}
fn is_write_vectored(&self) -> bool {
match self {
Self::Http(stream) => stream.is_write_vectored(),
Self::Https(stream) => stream.is_write_vectored(),
}
}
fn poll_write_vectored(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[std::io::IoSlice<'_>],
) -> Poll<Result<usize, std::io::Error>> {
match Pin::get_mut(self) {
Self::Http(stream) => Pin::new(stream).poll_write_vectored(cx, bufs),
Self::Https(stream) => Pin::new(stream).poll_write_vectored(cx, bufs),
}
}
}

View File

@@ -1,5 +1,6 @@
pub mod connect;
pub mod delegate;
pub mod delegate_client;
pub mod server;
pub mod target_filter;
pub mod tls;

View File

@@ -343,9 +343,15 @@ pub async fn cmd_upgrade(version: Option<String>) -> anyhow::Result<()> {
// Restart systemd service if running
if super::service::is_service_active() {
eprintln!(" Restarting systemd service...");
super::service::run_cmd("systemctl", &["restart", "aether-proxy"])?;
eprintln!(" Service restarted.");
if super::service::is_root() {
eprintln!(" Restarting systemd service...");
super::service::run_cmd("systemctl", &["restart", "aether-proxy"])?;
eprintln!(" Service restarted.");
} else {
eprintln!(" Systemd service is active, but restart requires root.");
eprintln!(" Run: sudo aether-proxy restart");
eprintln!(" Skipping restart.");
}
} else {
eprintln!(" No active systemd service detected, skipping restart.");
}

View File

@@ -12,6 +12,7 @@ use tokio_rustls::TlsAcceptor;
use crate::config::Config;
use crate::hardware::HardwareInfo;
use crate::proxy::delegate_client::DelegateClient;
use crate::proxy::target_filter::DnsCache;
use crate::registration::client::AetherClient;
use crate::runtime::SharedDynamicConfig;
@@ -26,8 +27,8 @@ pub struct AppState {
pub public_ip: String,
pub tls_fingerprint: Option<String>,
pub tls_acceptor: Option<TlsAcceptor>,
/// Shared reqwest client for delegate mode (proxy issues upstream requests directly).
pub delegate_client: reqwest::Client,
/// Shared delegate client for proxy-initiated upstream requests.
pub delegate_client: DelegateClient,
/// Active connection count for metrics reporting.
pub active_connections: Arc<AtomicU64>,
/// Connection concurrency limiter.