feat: ProxyNode 代理节点管理系统与 OpenAI Responses API 解析增强

ProxyNode 系统:新增 aether-proxy(Rust)海外 VPS 代理组件,后端实现节点注册/心跳/
HMAC 认证/健康检测调度器/模块化集成,前端新增代理节点管理页面。ProxyConfig 支持
node_id 模式,http_client 支持 HMAC 签名代理 URL 构建与 TTL 缓存。

OpenAI CLI 解析器:适配 Responses API 格式,支持 input_tokens/output_tokens 提取、
output[].content[].text 文本解析、response.completed 流式事件 usage 嵌套结构。
This commit is contained in:
fawney19
2026-02-07 12:24:42 +08:00
parent 62f852b851
commit 1180634269
40 changed files with 4761 additions and 11 deletions

View File

@@ -0,0 +1,134 @@
use std::collections::HashSet;
use std::sync::Arc;
use hyper::body::Incoming;
use hyper::{Request, Response};
use tokio::net::TcpStream;
use tracing::{debug, warn};
use crate::auth;
use crate::config::Config;
use crate::proxy::target_filter;
/// Handle HTTP CONNECT tunnel requests.
///
/// Flow: validate auth -> check target filter -> TCP connect -> 200 -> bidirectional copy
pub async fn handle_connect(
req: Request<Incoming>,
config: Arc<Config>,
node_id: &str,
allowed_ports: &HashSet<u16>,
) -> Response<http_body_util::Empty<bytes::Bytes>> {
// 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) {
warn!(error = %e, "CONNECT auth failed");
return proxy_auth_required(&e.to_string());
}
// Parse target host:port from CONNECT URI
let authority = match req.uri().authority() {
Some(auth) => auth.clone(),
None => {
warn!("CONNECT request missing authority");
return bad_request("missing target authority");
}
};
let host = authority.host().to_string();
let port = authority.port_u16().unwrap_or(443);
// Target filter: private IP + port whitelist
let target_addr = match target_filter::validate_target(&host, port, allowed_ports) {
Ok(addr) => addr,
Err(e) => {
warn!(host = %host, port, error = %e, "CONNECT target rejected");
return forbidden(&e.to_string());
}
};
debug!(target = %target_addr, "CONNECT tunnel establishing");
// Connect to target
let target_stream = match TcpStream::connect(target_addr).await {
Ok(s) => s,
Err(e) => {
warn!(target = %target_addr, error = %e, "CONNECT target connection failed");
return bad_gateway(&e.to_string());
}
};
// Respond 200 and upgrade connection to raw TCP tunnel
tokio::task::spawn(async move {
match hyper::upgrade::on(req).await {
Ok(upgraded) => {
let mut upgraded =
hyper_util::rt::TokioIo::new(upgraded);
let mut target = target_stream;
match tokio::io::copy_bidirectional(&mut upgraded, &mut target).await {
Ok((from_client, from_target)) => {
debug!(
from_client,
from_target,
"CONNECT tunnel closed"
);
}
Err(e) => {
debug!(error = %e, "CONNECT tunnel error");
}
}
}
Err(e) => {
warn!(error = %e, "CONNECT upgrade failed");
}
}
});
Response::builder()
.status(200)
.body(http_body_util::Empty::new())
.unwrap()
}
fn proxy_auth_required(msg: &str) -> Response<http_body_util::Empty<bytes::Bytes>> {
Response::builder()
.status(407)
.header("Proxy-Authenticate", "HMAC-SHA256")
.header("Content-Length", "0")
.header("X-Error", msg)
.body(http_body_util::Empty::new())
.unwrap()
}
fn forbidden(msg: &str) -> Response<http_body_util::Empty<bytes::Bytes>> {
Response::builder()
.status(403)
.header("Content-Length", "0")
.header("X-Error", msg)
.body(http_body_util::Empty::new())
.unwrap()
}
fn bad_request(msg: &str) -> Response<http_body_util::Empty<bytes::Bytes>> {
Response::builder()
.status(400)
.header("Content-Length", "0")
.header("X-Error", msg)
.body(http_body_util::Empty::new())
.unwrap()
}
fn bad_gateway(msg: &str) -> Response<http_body_util::Empty<bytes::Bytes>> {
Response::builder()
.status(502)
.header("Content-Length", "0")
.header("X-Error", msg)
.body(http_body_util::Empty::new())
.unwrap()
}

View File

@@ -0,0 +1,4 @@
pub mod connect;
pub mod plain;
pub mod server;
pub mod target_filter;

View File

@@ -0,0 +1,162 @@
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;
/// Handle plain HTTP forward proxy requests (non-CONNECT).
///
/// Flow: validate auth -> check target filter -> forward request -> return response
pub async fn handle_plain(
req: Request<Incoming>,
config: Arc<Config>,
node_id: &str,
allowed_ports: &HashSet<u16>,
) -> Response<Full<bytes::Bytes>> {
// 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) {
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());
}
};
debug!(target = %target_addr, method = %req.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 ones
for (name, value) in req.headers() {
if name == "proxy-authorization" || name == "proxy-connection" {
continue;
}
builder = builder.header(name, value);
}
// Collect the incoming body
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) => {
let (parts, body) = resp.into_parts();
let body_bytes = match body.collect().await {
Ok(collected) => collected.to_bytes(),
Err(e) => {
warn!(error = %e, "failed to read response body");
return bad_gateway("failed to read response body");
}
};
Response::from_parts(parts, Full::new(body_bytes))
}
Err(e) => {
warn!(error = %e, "HTTP proxy request failed");
bad_gateway(&format!("upstream request failed: {}", e))
}
}
}
fn proxy_auth_required(msg: &str) -> Response<Full<bytes::Bytes>> {
Response::builder()
.status(407)
.header("Proxy-Authenticate", "HMAC-SHA256")
.header("X-Error", msg)
.body(Full::new(bytes::Bytes::new()))
.unwrap()
}
fn forbidden(msg: &str) -> Response<Full<bytes::Bytes>> {
Response::builder()
.status(403)
.header("X-Error", msg)
.body(Full::new(bytes::Bytes::new()))
.unwrap()
}
fn bad_request(msg: &str) -> Response<Full<bytes::Bytes>> {
Response::builder()
.status(400)
.header("X-Error", msg)
.body(Full::new(bytes::Bytes::new()))
.unwrap()
}
fn bad_gateway(msg: &str) -> Response<Full<bytes::Bytes>> {
Response::builder()
.status(502)
.header("X-Error", msg)
.body(Full::new(bytes::Bytes::new()))
.unwrap()
}

View File

@@ -0,0 +1,117 @@
use std::collections::HashSet;
use std::net::SocketAddr;
use std::sync::Arc;
use http_body_util::BodyExt;
use hyper::body::Incoming;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request};
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use tokio::sync::watch;
use tracing::{debug, info, warn};
use crate::config::Config;
use crate::proxy::{connect, plain};
/// Start the proxy server.
///
/// Listens for incoming TCP connections and dispatches:
/// - CONNECT requests -> tunnel handler
/// - Other HTTP requests -> plain forward proxy handler
pub async fn run(
config: Arc<Config>,
node_id: Arc<String>,
mut shutdown_rx: watch::Receiver<bool>,
) -> anyhow::Result<()> {
let addr = SocketAddr::from(([0, 0, 0, 0], config.listen_port));
let listener = TcpListener::bind(addr).await?;
info!(addr = %addr, "proxy server listening");
let allowed_ports: Arc<HashSet<u16>> = Arc::new(config.allowed_ports.iter().copied().collect());
loop {
tokio::select! {
result = listener.accept() => {
let (stream, peer_addr) = match result {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "failed to accept connection");
continue;
}
};
debug!(peer = %peer_addr, "new connection");
let config = Arc::clone(&config);
let node_id = Arc::clone(&node_id);
let allowed_ports = Arc::clone(&allowed_ports);
tokio::task::spawn(async move {
let io = TokioIo::new(stream);
let config = config;
let node_id = node_id;
let allowed_ports = allowed_ports;
let service = service_fn(move |req: Request<Incoming>| {
let config = Arc::clone(&config);
let node_id = Arc::clone(&node_id);
let allowed_ports = Arc::clone(&allowed_ports);
async move {
type BoxBody = http_body_util::combinators::BoxBody<bytes::Bytes, Box<dyn std::error::Error + Send + Sync>>;
if req.method() == Method::CONNECT {
let resp = connect::handle_connect(
req,
config,
&node_id,
&allowed_ports,
)
.await;
let resp = resp.map(|_| -> BoxBody {
http_body_util::Empty::new()
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
.boxed()
});
Ok::<_, hyper::Error>(resp)
} else {
let resp = plain::handle_plain(
req,
config,
&node_id,
&allowed_ports,
)
.await;
let resp = resp.map(|body| -> BoxBody {
body.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
.boxed()
});
Ok(resp)
}
}
});
if let Err(e) = http1::Builder::new()
.preserve_header_case(true)
.title_case_headers(false)
.serve_connection(io, service)
.with_upgrades()
.await
{
if !e.to_string().contains("connection closed") {
debug!(peer = %peer_addr, error = %e, "connection error");
}
}
});
}
_ = shutdown_rx.changed() => {
info!("proxy server shutting down");
break;
}
}
}
Ok(())
}

View File

@@ -0,0 +1,181 @@
use std::collections::HashSet;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
/// Check if an IP address belongs to a private/reserved network.
fn is_private_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_private_ipv4(v4),
IpAddr::V6(v6) => is_private_ipv6(v6),
}
}
fn is_private_ipv4(ip: &Ipv4Addr) -> bool {
let octets = ip.octets();
// 10.0.0.0/8
if octets[0] == 10 {
return true;
}
// 172.16.0.0/12
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
return true;
}
// 192.168.0.0/16
if octets[0] == 192 && octets[1] == 168 {
return true;
}
// 127.0.0.0/8
if octets[0] == 127 {
return true;
}
// 169.254.0.0/16 (link-local)
if octets[0] == 169 && octets[1] == 254 {
return true;
}
// 0.0.0.0/8
if octets[0] == 0 {
return true;
}
false
}
fn is_private_ipv6(ip: &Ipv6Addr) -> bool {
// ::1 loopback
if ip.is_loopback() {
return true;
}
// :: unspecified
if ip.is_unspecified() {
return true;
}
let segments = ip.segments();
// fc00::/7 (ULA) - first byte is 0xfc or 0xfd
if segments[0] & 0xfe00 == 0xfc00 {
return true;
}
// fe80::/10 (link-local)
if segments[0] & 0xffc0 == 0xfe80 {
return true;
}
// IPv4-mapped IPv6 (::ffff:x.x.x.x) - check the embedded IPv4
if let Some(v4) = ip.to_ipv4_mapped() {
return is_private_ipv4(&v4);
}
false
}
#[derive(Debug)]
pub enum FilterError {
PrivateIp(IpAddr),
PortNotAllowed(u16),
DnsResolutionFailed(String),
AllAddressesPrivate(String),
}
impl std::fmt::Display for FilterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PrivateIp(ip) => write!(f, "target IP {} is in private/reserved range", ip),
Self::PortNotAllowed(port) => write!(f, "port {} not in allowed list", port),
Self::DnsResolutionFailed(host) => write!(f, "DNS resolution failed for {}", host),
Self::AllAddressesPrivate(host) => {
write!(f, "all resolved addresses for {} are private", host)
}
}
}
}
/// Validate that the target host:port is allowed.
///
/// Returns the resolved socket address to connect to.
pub fn validate_target(
host: &str,
port: u16,
allowed_ports: &HashSet<u16>,
) -> Result<SocketAddr, FilterError> {
// Port whitelist check
if !allowed_ports.contains(&port) {
return Err(FilterError::PortNotAllowed(port));
}
// Try parsing as IP directly
if let Ok(ip) = host.parse::<IpAddr>() {
if is_private_ip(&ip) {
return Err(FilterError::PrivateIp(ip));
}
return Ok(SocketAddr::new(ip, port));
}
// DNS resolution with private IP check (DNS rebinding protection)
let addr_str = format!("{}:{}", host, port);
let addrs: Vec<SocketAddr> = addr_str
.to_socket_addrs()
.map_err(|_| FilterError::DnsResolutionFailed(host.to_string()))?
.collect();
if addrs.is_empty() {
return Err(FilterError::DnsResolutionFailed(host.to_string()));
}
// All resolved addresses must be non-private
for addr in &addrs {
if is_private_ip(&addr.ip()) {
return Err(FilterError::PrivateIp(addr.ip()));
}
}
// Return the first valid address
Ok(addrs[0])
}
#[cfg(test)]
mod tests {
use super::*;
fn ports() -> HashSet<u16> {
[80, 443, 8080, 8443].into_iter().collect()
}
#[test]
fn test_private_ipv4() {
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))));
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1))));
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))));
assert!(!is_private_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
assert!(!is_private_ip(&IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))));
}
#[test]
fn test_private_ipv6() {
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::UNSPECIFIED)));
// fc00::1 (ULA)
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::new(
0xfc00, 0, 0, 0, 0, 0, 0, 1
))));
// fe80::1 (link-local)
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::new(
0xfe80, 0, 0, 0, 0, 0, 0, 1
))));
}
#[test]
fn test_port_not_allowed() {
let result = validate_target("8.8.8.8", 22, &ports());
assert!(matches!(result, Err(FilterError::PortNotAllowed(22))));
}
#[test]
fn test_private_ip_blocked() {
let result = validate_target("127.0.0.1", 80, &ports());
assert!(matches!(result, Err(FilterError::PrivateIp(_))));
}
#[test]
fn test_public_ip_allowed() {
let result = validate_target("8.8.8.8", 443, &ports());
assert!(result.is_ok());
}
}