mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 04:30:20 +08:00
feat: delegate 协议改为 header 元数据 + gzip 压缩 body 直传
- delegate wire format 从 JSON body 改为 HTTP headers 传递元数据 (X-Delegate-Method/Url/Headers),上游请求体 gzip 压缩后直传, 减少跨国代理传输耗时 - Rust 侧新增 gzip 解压(含 decompression bomb 防护 50MB 上限) - 升级模块从 GitHub API asset 下载改为公开 release URL 直链, GITHUB_TOKEN 变为可选 - 前端适配新 timing 字段,展示压缩率与 wire_size/body_size - CI release notes 改用 generate_release_notes 自动生成
This commit is contained in:
Generated
+1
-1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aether-proxy"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -7,7 +8,6 @@ use futures_util::TryStreamExt;
|
||||
use http_body_util::{BodyExt, Full, Limited, StreamBody};
|
||||
use hyper::body::{Frame, Incoming};
|
||||
use hyper::{Request, Response};
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, warn};
|
||||
use url::Url;
|
||||
|
||||
@@ -16,22 +16,20 @@ use crate::auth;
|
||||
use crate::config::Config;
|
||||
use crate::proxy::target_filter::{self, DnsCache};
|
||||
|
||||
/// Delegation request payload sent by Aether.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DelegateRequest {
|
||||
method: String,
|
||||
url: String,
|
||||
headers: HashMap<String, String>,
|
||||
body: Option<String>,
|
||||
/// Accepted but not used on the proxy side — Aether controls timeouts.
|
||||
#[allow(dead_code)]
|
||||
timeout: Option<u64>,
|
||||
}
|
||||
|
||||
/// Handle delegation requests: Aether sends a full request description,
|
||||
/// and the proxy issues the actual upstream HTTP call using its own TLS stack.
|
||||
///
|
||||
/// Endpoint: POST /_aether/delegate
|
||||
///
|
||||
/// Wire format: metadata in HTTP headers, upstream body sent directly
|
||||
/// as HTTP body (optionally gzip-compressed via `Content-Encoding: gzip`).
|
||||
///
|
||||
/// Headers:
|
||||
/// X-Delegate-Method: POST
|
||||
/// X-Delegate-Url: https://api.anthropic.com/v1/messages
|
||||
/// X-Delegate-Headers: base64-encoded JSON {"Authorization": "Bearer ...", ...}
|
||||
/// X-Delegate-Timeout: 30 (accepted but not used — Aether controls timeouts)
|
||||
/// Content-Encoding: gzip (optional, indicates body is gzip-compressed)
|
||||
pub async fn handle_delegate(
|
||||
req: Request<Incoming>,
|
||||
config: Arc<Config>,
|
||||
@@ -42,7 +40,7 @@ pub async fn handle_delegate(
|
||||
) -> Response<BoxBody> {
|
||||
let total_start = Instant::now();
|
||||
|
||||
// Authenticate via Authorization header (same HMAC scheme as Proxy-Authorization)
|
||||
// ── Auth ──
|
||||
let auth_header = req
|
||||
.headers()
|
||||
.get("authorization")
|
||||
@@ -54,34 +52,74 @@ pub async fn handle_delegate(
|
||||
}
|
||||
let auth_ms = total_start.elapsed().as_millis() as u64;
|
||||
|
||||
// Read and parse request body (limit to 10 MB to prevent OOM)
|
||||
const MAX_BODY: usize = 10 * 1024 * 1024;
|
||||
let body_read_start = Instant::now();
|
||||
let body_bytes = match Limited::new(req.into_body(), MAX_BODY).collect().await {
|
||||
Ok(collected) => collected.to_bytes(),
|
||||
Err(e) => {
|
||||
warn!(error = %e, "failed to read delegate request body");
|
||||
return error_response(413, "payload_too_large", "request body exceeds 10MB limit");
|
||||
// ── Parse metadata from headers ──
|
||||
let meta_start = Instant::now();
|
||||
|
||||
let method_str = match req
|
||||
.headers()
|
||||
.get("x-delegate-method")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
Some(m) => m.to_string(),
|
||||
None => {
|
||||
warn!("delegate missing X-Delegate-Method");
|
||||
return error_response(400, "bad_request", "missing X-Delegate-Method header");
|
||||
}
|
||||
};
|
||||
let body_read_ms = body_read_start.elapsed().as_millis() as u64;
|
||||
let body_size = body_bytes.len() as u64;
|
||||
|
||||
let body_parse_start = Instant::now();
|
||||
let delegate_req: DelegateRequest = match serde_json::from_slice(&body_bytes) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "invalid delegate request JSON");
|
||||
return error_response(400, "bad_request", &format!("invalid JSON: {}", e));
|
||||
let target_url = match req
|
||||
.headers()
|
||||
.get("x-delegate-url")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
Some(u) => u.to_string(),
|
||||
None => {
|
||||
warn!("delegate missing X-Delegate-Url");
|
||||
return error_response(400, "bad_request", "missing X-Delegate-Url header");
|
||||
}
|
||||
};
|
||||
let body_parse_ms = body_parse_start.elapsed().as_millis() as u64;
|
||||
|
||||
// Target filter: validate the upstream URL against allowed ports and private IP checks
|
||||
let parsed_url = match Url::parse(&delegate_req.url) {
|
||||
let upstream_headers: HashMap<String, String> = match req
|
||||
.headers()
|
||||
.get("x-delegate-headers")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
Some(b64) => {
|
||||
match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) {
|
||||
Ok(decoded) => match serde_json::from_slice(&decoded) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "delegate invalid X-Delegate-Headers JSON");
|
||||
return error_response(
|
||||
400,
|
||||
"bad_request",
|
||||
"invalid X-Delegate-Headers JSON",
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!(error = %e, "delegate invalid X-Delegate-Headers base64");
|
||||
return error_response(400, "bad_request", "invalid X-Delegate-Headers base64");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => HashMap::new(),
|
||||
};
|
||||
|
||||
let is_gzip = req
|
||||
.headers()
|
||||
.get("content-encoding")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|v| v.eq_ignore_ascii_case("gzip"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let meta_ms = meta_start.elapsed().as_millis() as u64;
|
||||
|
||||
// ── Target validation ──
|
||||
let parsed_url = match Url::parse(&target_url) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
warn!(url = %delegate_req.url, error = %e, "invalid delegate target URL");
|
||||
warn!(url = %target_url, error = %e, "delegate invalid target URL");
|
||||
return error_response(400, "bad_request", &format!("invalid URL: {}", e));
|
||||
}
|
||||
};
|
||||
@@ -89,7 +127,7 @@ pub async fn handle_delegate(
|
||||
let host = match parsed_url.host_str() {
|
||||
Some(h) => h.to_string(),
|
||||
None => {
|
||||
warn!(url = %delegate_req.url, "delegate target URL missing host");
|
||||
warn!(url = %target_url, "delegate target URL missing host");
|
||||
return error_response(400, "bad_request", "URL missing host");
|
||||
}
|
||||
};
|
||||
@@ -103,51 +141,91 @@ pub async fn handle_delegate(
|
||||
}
|
||||
let dns_ms = dns_start.elapsed().as_millis() as u64;
|
||||
|
||||
debug!(
|
||||
method = %delegate_req.method,
|
||||
url = %delegate_req.url,
|
||||
"delegate request"
|
||||
);
|
||||
debug!(method = %method_str, url = %target_url, is_gzip, "delegate request");
|
||||
|
||||
// Build upstream request
|
||||
let method = match delegate_req.method.parse::<reqwest::Method>() {
|
||||
// ── Read body + decompress if gzip ──
|
||||
let body_read_start = Instant::now();
|
||||
const MAX_BODY: usize = 10 * 1024 * 1024;
|
||||
let body_bytes = match Limited::new(req.into_body(), MAX_BODY).collect().await {
|
||||
Ok(collected) => collected.to_bytes(),
|
||||
Err(e) => {
|
||||
warn!(error = %e, "delegate failed to read request body");
|
||||
return error_response(413, "payload_too_large", "request body exceeds 10MB limit");
|
||||
}
|
||||
};
|
||||
let body_read_ms = body_read_start.elapsed().as_millis() as u64;
|
||||
let wire_size = body_bytes.len() as u64;
|
||||
|
||||
let decompress_start = Instant::now();
|
||||
const MAX_DECOMPRESSED: usize = 50 * 1024 * 1024; // 50 MB
|
||||
let upstream_body: Option<Vec<u8>> = if body_bytes.is_empty() {
|
||||
None
|
||||
} else if is_gzip {
|
||||
let decoder = flate2::read::GzDecoder::new(&body_bytes[..]);
|
||||
let mut decompressed = Vec::with_capacity((body_bytes.len() * 4).min(MAX_DECOMPRESSED));
|
||||
match decoder
|
||||
.take(MAX_DECOMPRESSED as u64 + 1)
|
||||
.read_to_end(&mut decompressed)
|
||||
{
|
||||
Ok(n) if n > MAX_DECOMPRESSED => {
|
||||
warn!(
|
||||
wire_size = body_bytes.len(),
|
||||
decompressed_size = n,
|
||||
"delegate decompressed body exceeds limit"
|
||||
);
|
||||
return error_response(
|
||||
413,
|
||||
"payload_too_large",
|
||||
"decompressed body exceeds 50MB limit",
|
||||
);
|
||||
}
|
||||
Ok(_) => Some(decompressed),
|
||||
Err(e) => {
|
||||
warn!(error = %e, "delegate gzip decompression failed");
|
||||
return error_response(400, "bad_request", "gzip decompression failed");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Some(body_bytes.to_vec())
|
||||
};
|
||||
let decompress_ms = decompress_start.elapsed().as_millis() as u64;
|
||||
let body_size = upstream_body.as_ref().map(|b| b.len() as u64).unwrap_or(0);
|
||||
|
||||
// ── Build upstream request ──
|
||||
let method = match method_str.parse::<reqwest::Method>() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
warn!(error = %e, method = %delegate_req.method, "invalid HTTP method");
|
||||
warn!(error = %e, method = %method_str, "delegate invalid HTTP method");
|
||||
return error_response(400, "bad_request", &format!("invalid method: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let mut upstream_req = http_client.request(method, &delegate_req.url);
|
||||
|
||||
// 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
|
||||
// first-byte / idle timeouts on its own side via asyncio.
|
||||
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 &delegate_req.headers {
|
||||
for (name, value) in &upstream_headers {
|
||||
if name.eq_ignore_ascii_case("host") {
|
||||
continue;
|
||||
}
|
||||
upstream_req = upstream_req.header(name.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
// Set body
|
||||
if let Some(body) = delegate_req.body {
|
||||
if let Some(body) = upstream_body {
|
||||
upstream_req = upstream_req.body(body);
|
||||
}
|
||||
|
||||
// Send upstream request
|
||||
// ── 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
|
||||
// first-byte / idle timeouts on its own side via asyncio.
|
||||
let upstream_start = Instant::now();
|
||||
let upstream_resp = match upstream_req.send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
warn!(url = %delegate_req.url, error = %e, "delegate upstream request failed");
|
||||
// Sanitize: strip URL details from error message to avoid leaking
|
||||
// API keys or paths that may appear in query strings / paths.
|
||||
warn!(url = %target_url, error = %e, "delegate upstream request failed");
|
||||
let safe_detail = sanitize_upstream_error(&e.to_string());
|
||||
if e.is_timeout() {
|
||||
return error_response(504, "upstream_timeout", &safe_detail);
|
||||
@@ -157,33 +235,34 @@ pub async fn handle_delegate(
|
||||
};
|
||||
let upstream_ms = upstream_start.elapsed().as_millis() as u64;
|
||||
|
||||
// Build response: pass through upstream status + headers, stream body back
|
||||
// ── Build response ──
|
||||
let status = upstream_resp.status().as_u16();
|
||||
let upstream_headers = upstream_resp.headers().clone();
|
||||
|
||||
let resp_headers = upstream_resp.headers().clone();
|
||||
let total_ms = total_start.elapsed().as_millis() as u64;
|
||||
|
||||
debug!(
|
||||
url = %delegate_req.url,
|
||||
url = %target_url,
|
||||
status,
|
||||
dns_ms,
|
||||
upstream_ms,
|
||||
total_ms,
|
||||
wire_size,
|
||||
body_size,
|
||||
"delegate upstream response"
|
||||
);
|
||||
|
||||
// Inject proxy timing header for Aether to parse
|
||||
let timing = serde_json::json!({
|
||||
"auth_ms": auth_ms,
|
||||
"meta_ms": meta_ms,
|
||||
"body_read_ms": body_read_ms,
|
||||
"body_parse_ms": body_parse_ms,
|
||||
"decompress_ms": decompress_ms,
|
||||
"wire_size": wire_size,
|
||||
"body_size": body_size,
|
||||
"dns_ms": dns_ms,
|
||||
"upstream_ms": upstream_ms,
|
||||
"total_ms": total_ms,
|
||||
});
|
||||
|
||||
// Stream the response body
|
||||
let body_stream = upstream_resp
|
||||
.bytes_stream()
|
||||
.map_ok(Frame::data)
|
||||
@@ -192,7 +271,7 @@ pub async fn handle_delegate(
|
||||
let stream_body: BoxBody = StreamBody::new(body_stream).boxed();
|
||||
|
||||
let mut builder = Response::builder().status(status);
|
||||
for (name, value) in upstream_headers.iter() {
|
||||
for (name, value) in resp_headers.iter() {
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
builder = builder.header("X-Proxy-Timing", timing.to_string());
|
||||
|
||||
@@ -17,13 +17,6 @@ const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
struct GithubRelease {
|
||||
tag_name: String,
|
||||
name: String,
|
||||
assets: Vec<GithubAsset>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GithubAsset {
|
||||
name: String,
|
||||
id: u64,
|
||||
}
|
||||
|
||||
// ── Platform detection ───────────────────────────────────────────────────────
|
||||
@@ -49,17 +42,15 @@ fn detect_platform() -> &'static str {
|
||||
// ── GitHub HTTP client ───────────────────────────────────────────────────────
|
||||
|
||||
fn build_github_client() -> anyhow::Result<reqwest::Client> {
|
||||
let token = std::env::var("GITHUB_TOKEN").map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"GITHUB_TOKEN is required (private repo).\n Set it via: export GITHUB_TOKEN=ghp_xxx"
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token))?,
|
||||
);
|
||||
|
||||
if let Ok(token) = std::env::var("GITHUB_TOKEN") {
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token))?,
|
||||
);
|
||||
}
|
||||
|
||||
headers.insert(
|
||||
reqwest::header::ACCEPT,
|
||||
reqwest::header::HeaderValue::from_static("application/vnd.github+json"),
|
||||
@@ -119,16 +110,18 @@ async fn fetch_release(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download & verify ────────────────────────────────────────────────────────
|
||||
// ── Download via GitHub release direct links ─────────────────────────────────
|
||||
|
||||
async fn download_asset_bytes(
|
||||
/// Download a release asset via the public direct download URL:
|
||||
/// `https://github.com/{repo}/releases/download/{tag}/{filename}`
|
||||
async fn download_release_file(
|
||||
client: &reqwest::Client,
|
||||
asset: &GithubAsset,
|
||||
tag: &str,
|
||||
filename: &str,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
// Use GitHub API asset endpoint for reliable private repo downloads
|
||||
let url = format!(
|
||||
"{}/repos/{}/releases/assets/{}",
|
||||
GITHUB_API_BASE, GITHUB_REPO, asset.id
|
||||
"https://github.com/{}/releases/download/{}/{}",
|
||||
GITHUB_REPO, tag, filename
|
||||
);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
@@ -138,7 +131,7 @@ async fn download_asset_bytes(
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!(
|
||||
"download failed for '{}' (HTTP {})",
|
||||
asset.name,
|
||||
filename,
|
||||
resp.status(),
|
||||
);
|
||||
}
|
||||
@@ -161,28 +154,16 @@ fn parse_checksum(sums_text: &str, filename: &str) -> anyhow::Result<String> {
|
||||
|
||||
async fn download_and_verify(
|
||||
client: &reqwest::Client,
|
||||
release: &GithubRelease,
|
||||
tag: &str,
|
||||
platform: &str,
|
||||
dest: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let archive_name = format!("aether-proxy-{}.tar.gz", platform);
|
||||
|
||||
let archive_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == archive_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("asset '{}' not found in release", archive_name))?;
|
||||
|
||||
let checksum_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == "SHA256SUMS.txt")
|
||||
.ok_or_else(|| anyhow::anyhow!("SHA256SUMS.txt not found in release"))?;
|
||||
|
||||
eprintln!(" Downloading {}...", archive_name);
|
||||
let (archive_bytes, checksum_bytes) = tokio::try_join!(
|
||||
download_asset_bytes(client, archive_asset),
|
||||
download_asset_bytes(client, checksum_asset),
|
||||
download_release_file(client, tag, &archive_name),
|
||||
download_release_file(client, tag, "SHA256SUMS.txt"),
|
||||
)?;
|
||||
let checksum_text = String::from_utf8(checksum_bytes)?;
|
||||
|
||||
@@ -348,7 +329,7 @@ pub async fn cmd_upgrade(version: Option<String>) -> anyhow::Result<()> {
|
||||
eprintln!(" Upgrading: {} -> {}", CURRENT_VERSION, target_semver);
|
||||
eprintln!();
|
||||
|
||||
if let Err(e) = download_and_verify(&client, &release, platform, &temp_path).await {
|
||||
if let Err(e) = download_and_verify(&client, target_tag, platform, &temp_path).await {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user