refactor: 代理节点架构重构与功能增强

aether-proxy:
- 重构 main.rs,拆分为 app/state/hardware/net 模块
- setup.rs 拆分为 setup/tui.rs + setup/service.rs,支持 systemd 服务管理子命令
- 新增 delegate 端点,支持后端通过代理节点转发请求而非传统 CONNECT 代理
- 注册时上报硬件信息(CPU/内存/fd_limit)和估算最大并发数
- 心跳上报活跃连接数,支持远程下发 node_name 配置
- HTTP 转发时剥离 X-Forwarded-* 等敏感头部
- 切换到 rustls-tls,降低日志级别减少噪音

后端:
- 从 http_client.py 提取代理相关逻辑至 proxy_node/resolver.py
- 从 routes.py 提取业务逻辑至 proxy_node/service.py
- handler 支持 delegate 模式(通过代理节点 HTTP 端点转发而非 CONNECT 隧道)
- ProxyNode 模型新增 hardware_info 和 estimated_max_concurrency 字段

前端:
- 新增 HardwareTooltip 组件展示节点硬件信息
- 远程配置支持下发 node_name
This commit is contained in:
fawney19
2026-02-08 13:33:08 +08:00
parent 254d30d32d
commit 519ad67eb1
44 changed files with 3339 additions and 1709 deletions

588
aether-proxy/Cargo.lock generated
View File

@@ -11,11 +11,13 @@ dependencies = [
"bytes", "bytes",
"clap", "clap",
"crossterm 0.28.1", "crossterm 0.28.1",
"futures-util",
"hex", "hex",
"hmac", "hmac",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-util", "hyper-util",
"libc",
"ratatui", "ratatui",
"rcgen", "rcgen",
"reqwest", "reqwest",
@@ -26,12 +28,14 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"subtle", "subtle",
"sysinfo",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"toml", "toml",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url",
] ]
[[package]] [[package]]
@@ -319,16 +323,6 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -344,6 +338,31 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]] [[package]]
name = "crossterm" name = "crossterm"
version = "0.28.1" version = "0.28.1"
@@ -521,15 +540,6 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -565,12 +575,6 @@ dependencies = [
"regex", "regex",
] ]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]] [[package]]
name = "filedescriptor" name = "filedescriptor"
version = "0.8.3" version = "0.8.3"
@@ -612,21 +616,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -657,6 +646,23 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.31" version = "0.3.31"
@@ -676,7 +682,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"pin-utils", "pin-utils",
"slab", "slab",
@@ -699,8 +709,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -710,28 +722,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"wasip2", "wasip2",
] "wasm-bindgen",
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
] ]
[[package]] [[package]]
@@ -821,7 +816,6 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -848,22 +842,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
] "webpki-roots",
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
] ]
[[package]] [[package]]
@@ -884,11 +863,9 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -1174,6 +1151,12 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "mac_address" name = "mac_address"
version = "1.1.8" version = "1.1.8"
@@ -1214,12 +1197,6 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@@ -1238,23 +1215,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.29.0" version = "0.29.0"
@@ -1278,6 +1238,15 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "ntapi"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1334,60 +1303,6 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-src"
version = "300.5.5+3.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "ordered-float" name = "ordered-float"
version = "4.6.0" version = "4.6.0"
@@ -1506,7 +1421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [ dependencies = [
"phf_shared", "phf_shared",
"rand", "rand 0.8.5",
] ]
[[package]] [[package]]
@@ -1543,12 +1458,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.13.1" version = "1.13.1"
@@ -1570,6 +1479,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -1579,6 +1497,61 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.44" version = "1.0.44"
@@ -1600,7 +1573,27 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [ dependencies = [
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@@ -1609,6 +1602,15 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "ratatui" name = "ratatui"
version = "0.30.0" version = "0.30.0"
@@ -1694,6 +1696,26 @@ dependencies = [
"unicode-width", "unicode-width",
] ]
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]] [[package]]
name = "rcgen" name = "rcgen"
version = "0.13.2" version = "0.13.2"
@@ -1753,36 +1775,37 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"encoding_rs",
"futures-core", "futures-core",
"h2", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls", "hyper-rustls",
"hyper-tls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime",
"native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-native-tls", "tokio-rustls",
"tokio-util",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams",
"web-sys", "web-sys",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -1799,6 +1822,12 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -1843,6 +1872,7 @@ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"log", "log",
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
"subtle", "subtle",
@@ -1864,6 +1894,7 @@ version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -1891,44 +1922,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.27" version = "1.0.27"
@@ -2172,37 +2171,17 @@ dependencies = [
] ]
[[package]] [[package]]
name = "system-configuration" name = "sysinfo"
version = "0.7.0" version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [ dependencies = [
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
] "memchr",
"ntapi",
[[package]] "rayon",
name = "tempfile" "windows",
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.3",
"windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2348,6 +2327,21 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "tinyvec"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.49.0" version = "1.49.0"
@@ -2376,16 +2370,6 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]] [[package]]
name = "tokio-rustls" name = "tokio-rustls"
version = "0.26.4" version = "0.26.4"
@@ -2664,12 +2648,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@@ -2768,6 +2746,19 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.85" version = "0.3.85"
@@ -2778,6 +2769,25 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "wezterm-bidi" name = "wezterm-bidi"
version = "0.2.3" version = "0.2.3"
@@ -2872,39 +2882,63 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
"windows-core",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-implement"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "windows-interface"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [ dependencies = [
"windows-link", "windows-targets 0.52.6",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
] ]
[[package]] [[package]]
@@ -3116,6 +3150,26 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zerocopy"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.6" version = "0.1.6"

View File

@@ -9,7 +9,8 @@ tokio = { version = "1", features = ["full"] }
hyper = { version = "1", features = ["http1", "server"] } hyper = { version = "1", features = ["http1", "server"] }
hyper-util = { version = "0.1", features = ["tokio", "http1", "server"] } hyper-util = { version = "0.1", features = ["tokio", "http1", "server"] }
http-body-util = "0.1" http-body-util = "0.1"
reqwest = { version = "0.12", features = ["json", "native-tls-vendored"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
futures-util = "0.3"
hmac = "0.12" hmac = "0.12"
sha2 = "0.10" sha2 = "0.10"
subtle = "2" subtle = "2"
@@ -31,6 +32,9 @@ rustls-pemfile = "2"
rcgen = "0.13" rcgen = "0.13"
ratatui = "0.30" ratatui = "0.30"
crossterm = "0.28" crossterm = "0.28"
url = "2"
sysinfo = "0.32"
libc = "0.2"
[profile.release] [profile.release]
lto = true lto = true

View File

@@ -2,23 +2,70 @@
Aether 正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。 Aether 正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。
## 下载预编译二进制 ## 安装
### 下载预编译二进制
在 [GitHub Releases](../../releases) 页面下载对应平台的预编译文件,无需安装 Rust 环境。 在 [GitHub Releases](../../releases) 页面下载对应平台的预编译文件,无需安装 Rust 环境。
| 平台 | 文件 |
|------|------| ## 快速开始
| Linux x86_64 | `aether-proxy-linux-amd64.tar.gz` |
| Linux ARM64 | `aether-proxy-linux-arm64.tar.gz` |
| macOS Intel | `aether-proxy-macos-amd64.tar.gz` |
| macOS Apple Silicon | `aether-proxy-macos-arm64.tar.gz` |
| Windows x86_64 | `aether-proxy-windows-amd64.zip` |
```bash ```bash
# 下载 & 解压 (以 Linux amd64 为例) # 1. 首次安装配置TUI 向导,勾选 Install Service 随系统启动服务)
tar xzf aether-proxy-linux-amd64.tar.gz sudo ./aether-proxy setup
chmod +x aether-proxy
# 2. 日常管理 (勾选 Install Service 作为系统服务的情况下)
aether-proxy status # 看状态
aether-proxy logs # 看日志
sudo aether-proxy start # 启动服务
sudo aether-proxy stop # 停止服务
sudo aether-proxy restart # 重启服务
# 3. 重新配置(改完自动重启服务)
sudo aether-proxy setup
# 4. 彻底卸载
sudo aether-proxy uninstall
``` ```
保存后配置写入 `aether-proxy.toml`,如果启用了 Install Service将自动注册并启动 systemd 服务。
### 直接运行
如果不需要安装为系统服务,可以直接运行。缺少必填参数时会自动进入 setup 向导:
```bash
./aether-proxy
```
## 配置
配置按以下优先级加载(高优先级覆盖低优先级):
1. CLI 参数
2. 环境变量(`AETHER_PROXY_*`
3. 配置文件(`aether-proxy.toml`,或通过 `AETHER_PROXY_CONFIG` 指定路径)
### 参数一览
| 参数 | 环境变量 | 默认值 | 说明 |
|------|----------|--------|------|
| `--aether-url` | `AETHER_PROXY_AETHER_URL` | **必填** | Aether 服务器地址 |
| `--management-token` | `AETHER_PROXY_MANAGEMENT_TOKEN` | **必填** | 管理员 Token`ae_xxx` 格式) |
| `--hmac-key` | `AETHER_PROXY_HMAC_KEY` | **必填** | HMAC 密钥,需与 Aether 端一致 |
| `--listen-port` | `AETHER_PROXY_LISTEN_PORT` | `18080` | 监听端口 |
| `--public-ip` | `AETHER_PROXY_PUBLIC_IP` | 自动检测 | 公网 IP |
| `--node-name` | `AETHER_PROXY_NODE_NAME` | `proxy-01` | 节点名称标识 |
| `--node-region` | `AETHER_PROXY_NODE_REGION` | 自动检测 | 地区标识 |
| `--heartbeat-interval` | `AETHER_PROXY_HEARTBEAT_INTERVAL` | `30` | 心跳间隔(秒) |
| `--allowed-ports` | `AETHER_PROXY_ALLOWED_PORTS` | `80,443,8080,8443` | 允许代理的目标端口 |
| `--timestamp-tolerance` | `AETHER_PROXY_TIMESTAMP_TOLERANCE` | `300` | HMAC 时间戳容差(秒) |
| `--log-level` | `AETHER_PROXY_LOG_LEVEL` | `info` | 日志级别 |
| `--log-json` | `AETHER_PROXY_LOG_JSON` | `false` | JSON 格式日志 |
| `--enable-tls` | `AETHER_PROXY_ENABLE_TLS` | `true` | 启用 TLS |
| `--tls-cert` | `AETHER_PROXY_TLS_CERT` | `aether-proxy-cert.pem` | TLS 证书路径 |
| `--tls-key` | `AETHER_PROXY_TLS_KEY` | `aether-proxy-key.pem` | TLS 私钥路径 |
## 发布新版本 ## 发布新版本
@@ -28,150 +75,3 @@ chmod +x aether-proxy
git tag proxy-v0.1.0 git tag proxy-v0.1.0
git push origin proxy-v0.1.0 git push origin proxy-v0.1.0
``` ```
也可以在 GitHub → Actions → **Build aether-proxy Binaries** → Run workflow 手动触发编译(不会创建 Release但可以在 Artifacts 中下载)。
## 从源码编译
```bash
# 需要 Rust 工具链
cargo build --release
# 产物: target/release/aether-proxy
```
## Docker 部署
```bash
docker build -t aether-proxy .
docker run -d \
--name aether-proxy \
-p 18080:18080 \
--env-file .env \
--restart unless-stopped \
aether-proxy
```
## 配置
复制 `.env.example``.env` 并填写:
```bash
cp .env.example .env
```
### 必填
| 变量 | 说明 |
|------|------|
| `AETHER_PROXY_AETHER_URL` | Aether 服务器地址,如 `https://aether.example.com` |
| `AETHER_PROXY_MANAGEMENT_TOKEN` | 管理员 Token`ae_xxx` 格式,必须属于 ADMIN 用户) |
| `AETHER_PROXY_HMAC_KEY` | HMAC 密钥,**必须与 Aether 端的 `PROXY_HMAC_KEY` 一致** |
### 可选
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `AETHER_PROXY_LISTEN_PORT` | `18080` | 监听端口 |
| `AETHER_PROXY_PUBLIC_IP` | 自动检测 | 公网 IP留空则自动获取 |
| `AETHER_PROXY_NODE_NAME` | `proxy-01` | 节点名称标识 |
| `AETHER_PROXY_NODE_REGION` | - | 地区标识,如 `ap-northeast-1` |
| `AETHER_PROXY_HEARTBEAT_INTERVAL` | `30` | 心跳间隔(秒) |
| `AETHER_PROXY_ALLOWED_PORTS` | `80,443,8080,8443` | 允许代理的目标端口 |
| `AETHER_PROXY_TIMESTAMP_TOLERANCE` | `300` | HMAC 时间戳容差(秒) |
| `AETHER_PROXY_LOG_LEVEL` | `info` | 日志级别trace/debug/info/warn/error |
| `AETHER_PROXY_LOG_JSON` | `false` | 是否输出 JSON 格式日志 |
## 运行
直接运行二进制即可,支持环境变量或 CLI 参数:
```bash
# 使用 .env 文件 (需要先 export)
export $(grep -v '^#' .env | xargs)
./aether-proxy
# 或直接传参
./aether-proxy \
--aether-url https://aether.example.com \
--management-token ae_xxx \
--hmac-key your-hmac-key
```
### 后台运行
**方式一systemd推荐开机自启 + 自动重启)**
创建 `/etc/systemd/system/aether-proxy.service`
```ini
[Unit]
Description=Aether Proxy
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/aether-proxy
EnvironmentFile=/opt/aether-proxy/.env
ExecStart=/opt/aether-proxy/aether-proxy
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
```bash
# 把二进制和 .env 放到 /opt/aether-proxy/
sudo mkdir -p /opt/aether-proxy
sudo cp aether-proxy .env /opt/aether-proxy/
# 启用并启动
sudo systemctl daemon-reload
sudo systemctl enable --now aether-proxy
# 常用命令
sudo systemctl status aether-proxy # 查看状态
sudo systemctl restart aether-proxy # 重启
sudo journalctl -u aether-proxy -f # 查看日志
```
**方式二nohup简单快速**
```bash
export $(grep -v '^#' .env | xargs)
nohup ./aether-proxy > aether-proxy.log 2>&1 &
# 查看日志
tail -f aether-proxy.log
# 停止
kill $(pgrep aether-proxy)
```
**方式三screen / tmux**
```bash
screen -S aether-proxy
export $(grep -v '^#' .env | xargs)
./aether-proxy
# Ctrl+A D 脱离会话
screen -r aether-proxy # 重新连接
```
## 工作流程
1. **启动** → 自动检测公网 IP如未配置
2. **注册** → 向 Aether 发送注册请求 (`POST /api/admin/proxy-nodes/register`)
3. **心跳** → 定时上报节点状态(默认 30 秒)
4. **代理** → 监听端口,接收并转发 Aether 发来的请求
5. **关闭** → 收到 SIGTERM/SIGINT 后优雅退出,向 Aether 发送注销请求
## 安全特性
- **HMAC-SHA256 认证**:所有代理请求必须携带合法签名
- **时间戳防重放**:默认 5 分钟窗口
- **私有 IP 拦截**阻止访问内网地址10.x、172.16.x、192.168.x、127.x 等)
- **端口白名单**:仅允许配置的目标端口
- **DNS rebinding 防护**:解析后的 IP 也会检查是否为内网地址

200
aether-proxy/src/app.rs Normal file
View File

@@ -0,0 +1,200 @@
//! Application lifecycle: initialization, task orchestration, and shutdown.
//!
//! Extracted from `main.rs` to keep the entry point minimal and consolidate
//! the startup sequence, tracing init, and graceful shutdown logic.
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, RwLock};
use tokio::signal;
use tokio::sync::watch;
use tracing::{error, info};
use crate::config::Config;
use crate::net;
use crate::registration::client::AetherClient;
use crate::runtime::{self, DynamicConfig};
use crate::state::AppState;
use crate::{hardware, proxy};
/// Run the full application lifecycle after config has been parsed.
pub async fn run(mut config: Config) -> anyhow::Result<()> {
init_tracing(&config);
info!(
version = env!("CARGO_PKG_VERSION"),
port = config.listen_port,
node_name = %config.node_name,
"aether-proxy starting"
);
// Resolve public IP
let public_ip = match &config.public_ip {
Some(ip) => ip.clone(),
None => net::detect_public_ip().await?,
};
info!(public_ip = %public_ip, "using public IP");
// Auto-detect region if not configured
if config.node_region.is_none() {
if let Some(region) = net::detect_region(&public_ip).await {
config.node_region = Some(region);
}
}
// 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)
};
// Collect hardware info (once at startup)
let hw_info = hardware::collect();
// Register with Aether
let aether_client = Arc::new(AetherClient::new(&config));
let node_id = aether_client
.register(
&config,
&public_ip,
config.enable_tls,
tls_fingerprint.as_deref(),
Some(&hw_info),
)
.await?;
info!(node_id = %node_id, "node registered");
// Build DynamicConfig before moving config into Arc
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 delegate_client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.pool_max_idle_per_host(20)
.pool_idle_timeout(std::time::Duration::from_secs(90))
.build()
.expect("failed to create delegate HTTP client");
// Build shared application state
let state = Arc::new(AppState {
config: Arc::new(config),
node_id: Arc::new(RwLock::new(node_id)),
dynamic,
aether_client,
hardware_info: Arc::new(hw_info),
public_ip,
tls_fingerprint,
tls_acceptor,
delegate_client,
active_connections: Arc::new(AtomicU64::new(0)),
});
// Shutdown signal channel
let (shutdown_tx, shutdown_rx) = watch::channel(false);
// Start heartbeat task
let heartbeat_handle = {
let state = Arc::clone(&state);
let rx = shutdown_rx.clone();
tokio::spawn(async move {
crate::registration::heartbeat::run(&state, rx).await;
})
};
// Start proxy server
let server_handle = {
let state = Arc::clone(&state);
let rx = shutdown_rx.clone();
tokio::spawn(async move {
if let Err(e) = proxy::server::run(&state, rx).await {
error!(error = %e, "proxy server error");
}
})
};
// Wait for shutdown signal (SIGTERM or SIGINT)
wait_for_shutdown().await;
info!("shutdown signal received, cleaning up...");
// Signal all tasks to stop
let _ = shutdown_tx.send(true);
// Graceful unregister (best-effort)
let current_node_id = state.node_id.read().unwrap().clone();
if let Err(e) = state.aether_client.unregister(&current_node_id).await {
error!(error = %e, "unregister failed during shutdown");
}
// Wait for tasks to finish
let _ = tokio::join!(heartbeat_handle, server_handle);
info!("aether-proxy stopped");
Ok(())
}
fn init_tracing(config: &Config) {
use tracing_subscriber::prelude::*;
use tracing_subscriber::{reload, EnvFilter};
let filter = EnvFilter::try_new(&config.log_level).unwrap_or_else(|_| EnvFilter::new("info"));
let (filter_layer, reload_handle) = reload::Layer::new(filter);
// Register log-level hot-reloader
runtime::set_log_reloader(Box::new(move |level: &str| {
if let Ok(new_filter) = EnvFilter::try_new(level) {
let _ = reload_handle.modify(|f| *f = new_filter);
}
}));
if config.log_json {
tracing_subscriber::registry()
.with(filter_layer)
.with(tracing_subscriber::fmt::layer().json())
.init();
} else {
tracing_subscriber::registry()
.with(filter_layer)
.with(tracing_subscriber::fmt::layer())
.init();
}
}
async fn wait_for_shutdown() {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}

View File

@@ -24,7 +24,9 @@ impl std::fmt::Display for AuthError {
Self::MissingHeader => write!(f, "missing Proxy-Authorization header"), Self::MissingHeader => write!(f, "missing Proxy-Authorization header"),
Self::InvalidBasicAuth => write!(f, "invalid Basic auth encoding"), Self::InvalidBasicAuth => write!(f, "invalid Basic auth encoding"),
Self::InvalidUsername => write!(f, "username must be 'hmac'"), Self::InvalidUsername => write!(f, "username must be 'hmac'"),
Self::InvalidPasswordFormat => write!(f, "password format must be 'timestamp.signature'"), Self::InvalidPasswordFormat => {
write!(f, "password format must be 'timestamp.signature'")
}
Self::TimestampParseError => write!(f, "invalid timestamp"), Self::TimestampParseError => write!(f, "invalid timestamp"),
Self::TimestampExpired => write!(f, "timestamp outside tolerance window"), Self::TimestampExpired => write!(f, "timestamp outside tolerance window"),
Self::SignatureMismatch => write!(f, "HMAC signature mismatch"), Self::SignatureMismatch => write!(f, "HMAC signature mismatch"),
@@ -60,9 +62,7 @@ pub fn validate_proxy_auth(
let decoded = String::from_utf8(decoded_bytes).map_err(|_| AuthError::InvalidBasicAuth)?; let decoded = String::from_utf8(decoded_bytes).map_err(|_| AuthError::InvalidBasicAuth)?;
// format: hmac:{timestamp}.{signature} // format: hmac:{timestamp}.{signature}
let (username, password) = decoded let (username, password) = decoded.split_once(':').ok_or(AuthError::InvalidBasicAuth)?;
.split_once(':')
.ok_or(AuthError::InvalidBasicAuth)?;
if username != "hmac" { if username != "hmac" {
return Err(AuthError::InvalidUsername); return Err(AuthError::InvalidUsername);
@@ -82,11 +82,7 @@ pub fn validate_proxy_auth(
.expect("system clock before epoch") .expect("system clock before epoch")
.as_secs(); .as_secs();
let diff = if now > timestamp { let diff = now.abs_diff(timestamp);
now - timestamp
} else {
timestamp - now
};
if diff > timestamp_tolerance { if diff > timestamp_tolerance {
return Err(AuthError::TimestampExpired); return Err(AuthError::TimestampExpired);
@@ -129,6 +125,9 @@ mod tests {
timestamp_tolerance: 300, timestamp_tolerance: 300,
log_level: "info".to_string(), log_level: "info".to_string(),
log_json: false, log_json: false,
enable_tls: false,
tls_cert: String::new(),
tls_key: String::new(),
} }
} }
@@ -138,8 +137,7 @@ mod tests {
.unwrap() .unwrap()
.as_secs(); .as_secs();
let payload = format!("{}\n{}", now, node_id); let payload = format!("{}\n{}", now, node_id);
let mut mac = let mut mac = HmacSha256::new_from_slice(config.hmac_key.as_bytes()).unwrap();
HmacSha256::new_from_slice(config.hmac_key.as_bytes()).unwrap();
mac.update(payload.as_bytes()); mac.update(payload.as_bytes());
let sig = hex::encode(mac.finalize().into_bytes()); let sig = hex::encode(mac.finalize().into_bytes());
let cred = format!("hmac:{}.{}", now, sig); let cred = format!("hmac:{}.{}", now, sig);
@@ -151,7 +149,10 @@ mod tests {
fn test_valid_auth() { fn test_valid_auth() {
let config = make_config(); let config = make_config();
let header = make_valid_auth(&config, "node-1"); let header = make_valid_auth(&config, "node-1");
assert!(validate_proxy_auth(Some(&header), &config, "node-1", config.timestamp_tolerance).is_ok()); assert!(
validate_proxy_auth(Some(&header), &config, "node-1", config.timestamp_tolerance)
.is_ok()
);
} }
#[test] #[test]

View File

@@ -64,11 +64,19 @@ pub struct Config {
pub enable_tls: bool, pub enable_tls: bool,
/// Path to TLS certificate PEM file /// Path to TLS certificate PEM file
#[arg(long, env = "AETHER_PROXY_TLS_CERT", default_value = "aether-proxy-cert.pem")] #[arg(
long,
env = "AETHER_PROXY_TLS_CERT",
default_value = "aether-proxy-cert.pem"
)]
pub tls_cert: String, pub tls_cert: String,
/// Path to TLS private key PEM file /// Path to TLS private key PEM file
#[arg(long, env = "AETHER_PROXY_TLS_KEY", default_value = "aether-proxy-key.pem")] #[arg(
long,
env = "AETHER_PROXY_TLS_KEY",
default_value = "aether-proxy-key.pem"
)]
pub tls_key: String, pub tls_key: String,
} }

View File

@@ -0,0 +1,79 @@
use serde::Serialize;
use sysinfo::System;
use tracing::info;
/// Hardware information collected at startup.
///
/// The struct is `Serialize`-able so it can be sent directly as the
/// `hardware_info` JSON bag in the registration request. New fields
/// can be added without database schema migrations.
#[derive(Debug, Clone, Serialize)]
pub struct HardwareInfo {
pub cpu_cores: u32,
pub total_memory_mb: u64,
pub os_info: String,
pub fd_limit: u64,
#[serde(skip)]
pub estimated_max_concurrency: u64,
}
/// Collect hardware information and estimate max concurrency.
///
/// Should be called once at startup -- hardware does not change at runtime.
pub fn collect() -> HardwareInfo {
let sys = System::new_all();
let cpu_cores = sys.cpus().len() as u32;
let total_memory_mb = sys.total_memory() / (1024 * 1024);
let os_info = format!(
"{} {}",
System::name().unwrap_or_else(|| "Unknown".into()),
System::os_version().unwrap_or_default(),
)
.trim()
.to_string();
// Estimate max concurrent connections:
// - Each tokio async task uses ~8-16 KB stack + heap buffers
// - OS file descriptor limit is often the real bottleneck
// - Conservative formula: min(fd_limit - 100, ram_mb * 40, cpu_cores * 2000)
let fd_limit = get_fd_limit();
let by_fd = fd_limit.saturating_sub(100);
let by_ram = total_memory_mb.saturating_mul(40);
let by_cpu = (cpu_cores as u64).saturating_mul(2000);
let estimated_max_concurrency = by_fd.min(by_ram).min(by_cpu);
info!(
cpu_cores,
total_memory_mb,
os_info = %os_info,
fd_limit,
estimated_max_concurrency,
"hardware info collected"
);
HardwareInfo {
cpu_cores,
total_memory_mb,
os_info,
fd_limit,
estimated_max_concurrency,
}
}
/// Read the soft file-descriptor limit (RLIMIT_NOFILE).
fn get_fd_limit() -> u64 {
#[cfg(unix)]
{
let mut rlim = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
let ret = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut rlim) };
if ret == 0 {
return rlim.rlim_cur;
}
}
// Fallback for non-unix or error
1024
}

View File

@@ -1,21 +1,19 @@
mod app;
mod auth; mod auth;
mod config; mod config;
mod hardware;
mod net;
mod proxy; mod proxy;
mod registration; mod registration;
mod runtime; mod runtime;
mod setup; mod setup;
mod state;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use clap::Parser; use clap::Parser;
use tokio::signal;
use tokio::sync::watch;
use tracing::{error, info};
use config::Config; use config::Config;
use registration::client::{detect_public_ip, AetherClient};
use runtime::DynamicConfig;
/// Default config file name. /// Default config file name.
const DEFAULT_CONFIG: &str = "aether-proxy.toml"; const DEFAULT_CONFIG: &str = "aether-proxy.toml";
@@ -24,186 +22,54 @@ const DEFAULT_CONFIG: &str = "aether-proxy.toml";
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
// ── Handle `setup` subcommand before clap parsing ──────────────────── // Handle subcommands before clap parsing (these don't need Config)
if args.len() > 1 && args[1] == "setup" { if args.len() > 1 {
let path = args match args[1].as_str() {
.get(2) "setup" => {
.map(PathBuf::from) let path = args
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG)); .get(2)
return setup::run(path); .map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG));
return setup::run(path);
}
"start" => return setup::service::cmd_start(),
"status" => return setup::service::cmd_status(),
"logs" => return setup::service::cmd_logs(),
"restart" => return setup::service::cmd_restart(),
"stop" => return setup::service::cmd_stop(),
"uninstall" => return setup::service::cmd_uninstall(),
_ => {} // fall through to clap (--help, --version, config args)
}
} }
// ── Load config file as env-var defaults (before clap) ─────────────── // Load config file as env-var defaults (before clap)
let config_file_path = std::env::var("AETHER_PROXY_CONFIG") let config_file_path =
.unwrap_or_else(|_| DEFAULT_CONFIG.to_string()); std::env::var("AETHER_PROXY_CONFIG").unwrap_or_else(|_| DEFAULT_CONFIG.to_string());
if std::path::Path::new(&config_file_path).exists() { if std::path::Path::new(&config_file_path).exists() {
if let Ok(file_cfg) = config::ConfigFile::load(std::path::Path::new(&config_file_path)) { if let Ok(file_cfg) = config::ConfigFile::load(std::path::Path::new(&config_file_path)) {
file_cfg.inject_env(); file_cfg.inject_env();
} }
} }
// ── Parse config; fall back to setup TUI if required args are missing // Parse config; fall back to setup TUI if required args are missing
let config = match Config::try_parse() { let config = match Config::try_parse() {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
if e.kind() == clap::error::ErrorKind::MissingRequiredArgument { if e.kind() == clap::error::ErrorKind::MissingRequiredArgument {
eprintln!("缺少必要配置,启动交互式配置向导...\n"); eprintln!("Missing required config, launching setup wizard...\n");
return setup::run(PathBuf::from(&config_file_path)); return setup::run(PathBuf::from(&config_file_path));
} }
e.exit(); e.exit();
} }
}; };
// Initialize tracing (with hot-reload support) // Warn if systemd service is already running (would cause port conflict)
init_tracing(&config); if setup::service::is_service_active() {
eprintln!("Warning: systemd service is already running.");
info!( eprintln!("Use `aether-proxy stop` to stop it first, or manage via subcommands:");
version = env!("CARGO_PKG_VERSION"), eprintln!(" aether-proxy status / logs / restart / stop");
port = config.listen_port, std::process::exit(1);
node_name = %config.node_name,
"aether-proxy starting"
);
// Resolve public IP
let public_ip = match &config.public_ip {
Some(ip) => ip.clone(),
None => detect_public_ip().await?,
};
info!(public_ip = %public_ip, "using public IP");
// Register with Aether
let aether_client = Arc::new(AetherClient::new(&config));
// 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");
let node_id = Arc::new(RwLock::new(node_id));
// Dynamic config (hot-reloadable via heartbeat)
let dynamic = Arc::new(RwLock::new(DynamicConfig::from_config(&config)));
// Shutdown signal channel
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let config = Arc::new(config);
// Start heartbeat task
let heartbeat_handle = {
let client = Arc::clone(&aether_client);
let node_id = Arc::clone(&node_id);
let config = Arc::clone(&config);
let dynamic = Arc::clone(&dynamic);
let public_ip = public_ip.clone();
let fingerprint = tls_fingerprint.clone();
let rx = shutdown_rx.clone();
tokio::spawn(async move {
registration::heartbeat::run(client, node_id, config, public_ip, fingerprint, dynamic, rx).await;
})
};
// Start proxy server
let server_handle = {
let config = Arc::clone(&config);
let node_id = Arc::clone(&node_id);
let dynamic = Arc::clone(&dynamic);
let rx = shutdown_rx.clone();
let tls = tls_acceptor.clone();
tokio::spawn(async move {
if let Err(e) = proxy::server::run(config, node_id, dynamic, tls, rx).await {
error!(error = %e, "proxy server error");
}
})
};
// Wait for shutdown signal (SIGTERM or SIGINT)
wait_for_shutdown().await;
info!("shutdown signal received, cleaning up...");
// Signal all tasks to stop
let _ = shutdown_tx.send(true);
// Graceful unregister (best-effort)
let current_node_id = node_id.read().unwrap().clone();
if let Err(e) = aether_client.unregister(&current_node_id).await {
error!(error = %e, "unregister failed during shutdown");
} }
// Wait for tasks to finish app::run(config).await
let _ = tokio::join!(heartbeat_handle, server_handle);
info!("aether-proxy stopped");
Ok(())
}
fn init_tracing(config: &Config) {
use tracing_subscriber::prelude::*;
use tracing_subscriber::{reload, EnvFilter};
let filter =
EnvFilter::try_new(&config.log_level).unwrap_or_else(|_| EnvFilter::new("info"));
let (filter_layer, reload_handle) = reload::Layer::new(filter);
// Register log-level hot-reloader
runtime::set_log_reloader(Box::new(move |level: &str| {
if let Ok(new_filter) = EnvFilter::try_new(level) {
let _ = reload_handle.modify(|f| *f = new_filter);
}
}));
if config.log_json {
tracing_subscriber::registry()
.with(filter_layer)
.with(tracing_subscriber::fmt::layer().json())
.init();
} else {
tracing_subscriber::registry()
.with(filter_layer)
.with(tracing_subscriber::fmt::layer())
.init();
}
}
async fn wait_for_shutdown() {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
} }

86
aether-proxy/src/net.rs Normal file
View File

@@ -0,0 +1,86 @@
//! Network utility functions (public IP detection, region detection).
//!
//! These are standalone helpers not tied to any specific client or service.
use reqwest::Client;
use tracing::{debug, info};
/// Auto-detect public IP by querying external services.
pub async fn detect_public_ip() -> anyhow::Result<String> {
let endpoints = [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
];
let client = Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()?;
for endpoint in &endpoints {
match client.get(*endpoint).send().await {
Ok(resp) if resp.status().is_success() => {
let ip = resp.text().await?.trim().to_string();
if !ip.is_empty() {
info!(ip = %ip, source = %endpoint, "detected public IP");
return Ok(ip);
}
}
Ok(resp) => {
debug!(endpoint = %endpoint, status = %resp.status(), "IP detection failed");
}
Err(e) => {
debug!(endpoint = %endpoint, error = %e, "IP detection failed");
}
}
}
anyhow::bail!("failed to detect public IP from any source; use --public-ip")
}
/// Auto-detect geographic region from a public IP address.
///
/// Uses multiple providers with HTTPS preferred. Falls back to ip-api.com
/// over plain HTTP (their free tier doesn't support HTTPS).
/// This is best-effort and non-sensitive -- region detection should never
/// block startup.
pub async fn detect_region(ip: &str) -> Option<String> {
// Try HTTPS provider first
let https_url = format!("https://ipinfo.io/{}/country", ip);
let client = Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.ok()?;
// Try ipinfo.io (HTTPS, returns plain text country code)
if let Ok(resp) = client.get(&https_url).send().await {
if resp.status().is_success() {
if let Ok(text) = resp.text().await {
let code = text.trim();
if !code.is_empty() && code.len() <= 3 {
info!(region = %code, ip = %ip, source = "ipinfo.io", "detected region");
return Some(code.to_string());
}
}
}
}
// Fallback: ip-api.com (HTTP only on free tier, non-sensitive data)
let http_url = format!("http://ip-api.com/json/{}?fields=countryCode", ip);
match client.get(&http_url).send().await {
Ok(resp) if resp.status().is_success() => {
let body: serde_json::Value = resp.json().await.ok()?;
let code = body.get("countryCode")?.as_str()?;
if code.is_empty() {
return None;
}
info!(region = %code, ip = %ip, source = "ip-api.com", "detected region");
Some(code.to_string())
}
_ => {
debug!(ip = %ip, "region detection failed");
None
}
}
}

View File

@@ -4,7 +4,7 @@ use std::sync::Arc;
use hyper::body::Incoming; use hyper::body::Incoming;
use hyper::{Request, Response}; use hyper::{Request, Response};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tracing::{debug, info, warn}; use tracing::{debug, warn};
use crate::auth; use crate::auth;
use crate::config::Config; use crate::config::Config;
@@ -53,7 +53,7 @@ pub async fn handle_connect(
} }
}; };
info!(target = %target_addr, "CONNECT tunnel establishing"); debug!(target = %target_addr, "CONNECT tunnel establishing");
// Connect to target // Connect to target
let target_stream = match TcpStream::connect(target_addr).await { let target_stream = match TcpStream::connect(target_addr).await {
@@ -65,28 +65,29 @@ pub async fn handle_connect(
}; };
// Respond 200 and upgrade connection to raw TCP tunnel // Respond 200 and upgrade connection to raw TCP tunnel
let target_display = target_addr.to_string();
tokio::task::spawn(async move { tokio::task::spawn(async move {
match hyper::upgrade::on(req).await { match hyper::upgrade::on(req).await {
Ok(upgraded) => { Ok(upgraded) => {
let mut upgraded = let mut upgraded = hyper_util::rt::TokioIo::new(upgraded);
hyper_util::rt::TokioIo::new(upgraded);
let mut target = target_stream; let mut target = target_stream;
match tokio::io::copy_bidirectional(&mut upgraded, &mut target).await { match tokio::io::copy_bidirectional(&mut upgraded, &mut target).await {
Ok((from_client, from_target)) => { Ok((from_client, from_target)) => {
info!( debug!(
target = %target_display,
from_client, from_client,
from_target, from_target,
"CONNECT tunnel closed" "CONNECT tunnel closed"
); );
} }
Err(e) => { Err(e) => {
debug!(error = %e, "CONNECT tunnel error"); debug!(target = %target_display, error = %e, "CONNECT tunnel error");
} }
} }
} }
Err(e) => { Err(e) => {
warn!(error = %e, "CONNECT upgrade failed"); warn!(target = %target_display, error = %e, "CONNECT upgrade failed");
} }
} }
}); });

View File

@@ -0,0 +1,221 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
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;
use crate::auth;
use crate::config::Config;
use crate::proxy::plain::BoxBody;
use crate::proxy::target_filter;
/// 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
pub async fn handle_delegate(
req: Request<Incoming>,
config: Arc<Config>,
node_id: &str,
allowed_ports: &HashSet<u16>,
timestamp_tolerance: u64,
http_client: &reqwest::Client,
) -> Response<BoxBody> {
// Authenticate via Authorization header (same HMAC scheme as Proxy-Authorization)
let auth_header = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok());
if let Err(e) = auth::validate_proxy_auth(auth_header, &config, node_id, timestamp_tolerance) {
warn!(error = %e, "delegate auth failed");
return error_response(401, "authentication_failed", &e.to_string());
}
// Read and parse request body (limit to 10 MB to prevent OOM)
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, "failed to read delegate request body");
return error_response(413, "payload_too_large", "request body exceeds 10MB limit");
}
};
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));
}
};
// Target filter: validate the upstream URL against allowed ports and private IP checks
let parsed_url = match Url::parse(&delegate_req.url) {
Ok(u) => u,
Err(e) => {
warn!(url = %delegate_req.url, error = %e, "invalid delegate target URL");
return error_response(400, "bad_request", &format!("invalid URL: {}", e));
}
};
let host = match parsed_url.host_str() {
Some(h) => h.to_string(),
None => {
warn!(url = %delegate_req.url, "delegate target URL missing host");
return error_response(400, "bad_request", "URL missing host");
}
};
let port = parsed_url.port_or_known_default().unwrap_or(443);
if let Err(e) = target_filter::validate_target(&host, port, allowed_ports) {
warn!(host = %host, port, error = %e, "delegate target rejected");
return error_response(403, "target_not_allowed", &e.to_string());
}
debug!(
method = %delegate_req.method,
url = %delegate_req.url,
"delegate request"
);
// Build upstream request
let method = match delegate_req.method.parse::<reqwest::Method>() {
Ok(m) => m,
Err(e) => {
warn!(error = %e, method = %delegate_req.method, "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 30s connect_timeout for connection establishment, and Aether controls
// first-byte / idle timeouts on its own side via asyncio.
// 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 {
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 {
upstream_req = upstream_req.body(body);
}
// Send upstream request
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.
let safe_detail = sanitize_upstream_error(&e.to_string());
if e.is_timeout() {
return error_response(504, "upstream_timeout", &safe_detail);
}
return error_response(502, "upstream_connection_failed", &safe_detail);
}
};
// Build response: pass through upstream status + headers, stream body back
let status = upstream_resp.status().as_u16();
let upstream_headers = upstream_resp.headers().clone();
debug!(url = %delegate_req.url, status, "delegate upstream response");
// Stream the response body
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 = StreamBody::new(body_stream).boxed();
let mut builder = Response::builder().status(status);
for (name, value) in upstream_headers.iter() {
builder = builder.header(name, value);
}
builder
.body(stream_body)
.unwrap_or_else(|_| Response::builder().status(500).body(empty_box()).unwrap())
}
// ── Sanitisation ─────────────────────────────────────────────────────────────
/// Strip full URLs from error messages to prevent leaking upstream API keys,
/// paths, or query parameters in the delegate error response.
///
/// Replaces `https://api.example.com/v1/chat?key=xxx` with `api.example.com`.
fn sanitize_upstream_error(msg: &str) -> String {
// Simple regex-free approach: find "https://..." or "http://..." spans and
// replace them with just the host portion.
let mut result = msg.to_string();
for scheme in &["https://", "http://"] {
while let Some(start) = result.find(scheme) {
let after_scheme = start + scheme.len();
// Host ends at '/', '?', '#', ' ', or end of string
let host_end = result[after_scheme..]
.find(['/', '?', '#', ' '])
.map(|i| after_scheme + i)
.unwrap_or(result.len());
let host = &result[after_scheme..host_end];
result = format!("{}{}{}", &result[..start], host, &result[host_end..]);
}
}
result
}
// ── Error response helpers ───────────────────────────────────────────────────
fn empty_box() -> BoxBody {
Full::new(bytes::Bytes::new())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
.boxed()
}
fn error_response(status: u16, error: &str, detail: &str) -> Response<BoxBody> {
let body = serde_json::json!({
"error": error,
"detail": detail,
});
let body_bytes = bytes::Bytes::from(body.to_string());
Response::builder()
.status(status)
.header("Content-Type", "application/json")
.header("X-Delegate-Error", "true")
.body(
Full::new(body_bytes)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { match e {} })
.boxed(),
)
.unwrap()
}

View File

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

View File

@@ -4,7 +4,7 @@ use std::sync::Arc;
use http_body_util::{BodyExt, Full}; use http_body_util::{BodyExt, Full};
use hyper::body::Incoming; use hyper::body::Incoming;
use hyper::{Request, Response}; use hyper::{Request, Response};
use tracing::{debug, info, warn}; use tracing::{debug, warn};
use crate::auth; use crate::auth;
use crate::config::Config; use crate::config::Config;
@@ -56,22 +56,28 @@ pub async fn handle_plain(
} }
}; };
info!(target = %target_addr, method = %req.method(), "HTTP proxy forwarding"); let method = req.method().clone();
debug!(target = %target_addr, method = %method, "HTTP proxy forwarding");
// Build outgoing request (strip proxy headers, use relative URI) // Build outgoing request (strip proxy headers, use relative URI)
let path_and_query = uri let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("/");
let mut builder = Request::builder() let mut builder = Request::builder()
.method(req.method()) .method(req.method())
.uri(path_and_query) .uri(path_and_query)
.version(req.version()); .version(req.version());
// Copy headers, skipping proxy-specific ones // Copy headers, skipping proxy-specific and forwarding-related ones
for (name, value) in req.headers() { for (name, value) in req.headers() {
if name == "proxy-authorization" || name == "proxy-connection" { if name == "proxy-authorization"
|| name == "proxy-connection"
|| name == "x-forwarded-for"
|| name == "x-forwarded-host"
|| name == "x-forwarded-proto"
|| name == "x-real-ip"
|| name == "forwarded"
|| name == "via"
{
continue; continue;
} }
builder = builder.header(name, value); builder = builder.header(name, value);
@@ -116,7 +122,7 @@ pub async fn handle_plain(
match sender.send_request(outgoing).await { match sender.send_request(outgoing).await {
Ok(resp) => { Ok(resp) => {
info!(target = %target_addr, status = resp.status().as_u16(), "HTTP proxy response"); debug!(target = %target_addr, method = %method, status = resp.status().as_u16(), "HTTP proxy response");
// Stream the response body directly — no buffering // Stream the response body directly — no buffering
let (parts, body) = resp.into_parts(); let (parts, body) = resp.into_parts();
let body: BoxBody = body let body: BoxBody = body

View File

@@ -1,21 +1,20 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::{Arc, RwLock}; use std::sync::atomic::Ordering;
use std::sync::Arc;
use http_body_util::BodyExt; use http_body_util::BodyExt;
use hyper::body::Incoming; use hyper::body::Incoming;
use hyper::rt::{Read, Write};
use hyper::server::conn::http1; use hyper::server::conn::http1;
use hyper::service::service_fn; use hyper::service::service_fn;
use hyper::{Method, Request}; use hyper::{Method, Request};
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::proxy::{connect, delegate, plain, tls};
use crate::proxy::{connect, plain, tls}; use crate::state::AppState;
use crate::runtime::SharedDynamicConfig;
/// Start the proxy server. /// Start the proxy server.
/// ///
@@ -23,20 +22,17 @@ use crate::runtime::SharedDynamicConfig;
/// - 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: /// When TLS is configured, the server operates in dual-stack mode:
/// it peeks at the first byte of each connection to distinguish TLS ClientHello /// it peeks at the first byte of each connection to distinguish TLS ClientHello
/// (0x16) from plain HTTP, and handles both on the same port. /// (0x16) from plain HTTP, and handles both on the same port.
pub async fn run( pub async fn run(
config: Arc<Config>, state: &Arc<AppState>,
node_id: Arc<RwLock<String>>,
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], state.config.listen_port));
let listener = TcpListener::bind(addr).await?; let listener = TcpListener::bind(addr).await?;
if tls_acceptor.is_some() { if state.tls_acceptor.is_some() {
info!(addr = %addr, "proxy server listening (HTTP+TLS dual-stack)"); info!(addr = %addr, "proxy server listening (HTTP+TLS dual-stack)");
} else { } else {
info!(addr = %addr, "proxy server listening (HTTP only)"); info!(addr = %addr, "proxy server listening (HTTP only)");
@@ -53,26 +49,22 @@ pub async fn run(
} }
}; };
info!(peer = %peer_addr, "new connection"); debug!(peer = %peer_addr, "new connection");
let config = Arc::clone(&config); let state = Arc::clone(state);
let node_id = Arc::clone(&node_id); state.active_connections.fetch_add(1, Ordering::Relaxed);
let dynamic = Arc::clone(&dynamic);
let tls_acceptor = tls_acceptor.clone();
tokio::task::spawn(async move { tokio::task::spawn(async move {
// Dual-stack: peek first byte to decide TLS vs plain HTTP // Dual-stack: peek first byte to decide TLS vs plain HTTP
if let Some(acceptor) = &tls_acceptor { if let Some(ref acceptor) = state.tls_acceptor {
if tls::is_tls_client_hello(&stream).await { if tls::is_tls_client_hello(&stream).await {
match acceptor.accept(stream).await { match acceptor.clone().accept(stream).await {
Ok(tls_stream) => { Ok(tls_stream) => {
debug!(peer = %peer_addr, "TLS handshake ok"); debug!(peer = %peer_addr, "TLS handshake ok");
serve_connection( serve_connection(
TokioIo::new(tls_stream), TokioIo::new(tls_stream),
peer_addr, peer_addr,
config, &state,
node_id,
dynamic,
) )
.await; .await;
} }
@@ -80,6 +72,7 @@ pub async fn run(
debug!(peer = %peer_addr, error = %e, "TLS handshake failed"); debug!(peer = %peer_addr, error = %e, "TLS handshake failed");
} }
} }
state.active_connections.fetch_sub(1, Ordering::Relaxed);
return; return;
} }
} }
@@ -88,11 +81,11 @@ pub async fn run(
serve_connection( serve_connection(
TokioIo::new(stream), TokioIo::new(stream),
peer_addr, peer_addr,
config, &state,
node_id,
dynamic,
) )
.await; .await;
state.active_connections.fetch_sub(1, Ordering::Relaxed);
}); });
} }
_ = shutdown_rx.changed() => { _ = shutdown_rx.changed() => {
@@ -106,22 +99,26 @@ pub async fn run(
} }
/// Serve a single HTTP/1.1 connection (works over both plain TCP and TLS). /// Serve a single HTTP/1.1 connection (works over both plain TCP and TLS).
async fn serve_connection<I>( async fn serve_connection<I>(io: I, peer_addr: SocketAddr, state: &Arc<AppState>)
io: I, where
peer_addr: SocketAddr,
config: Arc<Config>,
node_id: Arc<RwLock<String>>,
dynamic: SharedDynamicConfig,
) where
I: Read + Write + Unpin + Send + 'static, I: Read + Write + Unpin + Send + 'static,
{ {
let config = Arc::clone(&state.config);
let node_id = Arc::clone(&state.node_id);
let dynamic = Arc::clone(&state.dynamic);
let delegate_client = state.delegate_client.clone();
let service = service_fn(move |req: Request<Incoming>| { let service = service_fn(move |req: Request<Incoming>| {
let config = Arc::clone(&config); let config = Arc::clone(&config);
let node_id = Arc::clone(&node_id); let node_id = Arc::clone(&node_id);
let dynamic = Arc::clone(&dynamic); let dynamic = Arc::clone(&dynamic);
let delegate_client = delegate_client.clone();
async move { async move {
type BoxBody = http_body_util::combinators::BoxBody<bytes::Bytes, Box<dyn std::error::Error + Send + Sync>>; type BoxBody = http_body_util::combinators::BoxBody<
bytes::Bytes,
Box<dyn std::error::Error + Send + Sync>,
>;
// Snapshot current dynamic values (may be updated by remote config) // Snapshot current dynamic values (may be updated by remote config)
let current_node_id = node_id.read().unwrap().clone(); let current_node_id = node_id.read().unwrap().clone();
@@ -145,6 +142,18 @@ async fn serve_connection<I>(
.boxed() .boxed()
}); });
Ok::<_, hyper::Error>(resp) Ok::<_, hyper::Error>(resp)
} else if req.uri().path() == "/_aether/delegate" && req.method() == hyper::Method::POST
{
let resp = delegate::handle_delegate(
req,
config,
&current_node_id,
&allowed_ports,
timestamp_tolerance,
&delegate_client,
)
.await;
Ok(resp)
} else { } else {
let resp = plain::handle_plain( let resp = plain::handle_plain(
req, req,
@@ -154,7 +163,6 @@ async fn serve_connection<I>(
timestamp_tolerance, timestamp_tolerance,
) )
.await; .await;
// plain::handle_plain already returns BoxBody (streaming)
Ok(resp) Ok(resp)
} }
} }

View File

@@ -25,10 +25,7 @@ pub fn ensure_self_signed_cert(cert_path: &Path, key_path: &Path) -> anyhow::Res
info!("generating self-signed TLS certificate"); info!("generating self-signed TLS certificate");
let mut params = CertificateParams::new(vec![ let mut params = CertificateParams::new(vec!["localhost".into(), "aether-proxy".into()])?;
"localhost".into(),
"aether-proxy".into(),
])?;
params.distinguished_name = rcgen::DistinguishedName::new(); params.distinguished_name = rcgen::DistinguishedName::new();
params params
.distinguished_name .distinguished_name
@@ -65,8 +62,8 @@ pub fn build_tls_acceptor(cert_path: &Path, key_path: &Path) -> anyhow::Result<T
let cert_file = fs::File::open(cert_path)?; let cert_file = fs::File::open(cert_path)?;
let key_file = fs::File::open(key_path)?; let key_file = fs::File::open(key_path)?;
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut BufReader::new(cert_file)) let certs: Vec<CertificateDer<'static>> =
.collect::<Result<Vec<_>, _>>()?; rustls_pemfile::certs(&mut BufReader::new(cert_file)).collect::<Result<Vec<_>, _>>()?;
if certs.is_empty() { if certs.is_empty() {
anyhow::bail!("no certificates found in {}", cert_path.display()); anyhow::bail!("no certificates found in {}", cert_path.display());
@@ -89,8 +86,7 @@ pub fn build_tls_acceptor(cert_path: &Path, key_path: &Path) -> anyhow::Result<T
pub fn cert_sha256_fingerprint(cert_path: &Path) -> anyhow::Result<String> { pub fn cert_sha256_fingerprint(cert_path: &Path) -> anyhow::Result<String> {
let cert_file = fs::File::open(cert_path)?; let cert_file = fs::File::open(cert_path)?;
let certs: Vec<CertificateDer<'static>> = let certs: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut BufReader::new(cert_file)) rustls_pemfile::certs(&mut BufReader::new(cert_file)).collect::<Result<Vec<_>, _>>()?;
.collect::<Result<Vec<_>, _>>()?;
let cert = certs let cert = certs
.first() .first()

View File

@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use crate::config::Config; use crate::config::Config;
use crate::hardware::HardwareInfo;
/// Heartbeat-specific error that distinguishes "node not found" (needs /// Heartbeat-specific error that distinguishes "node not found" (needs
/// re-registration) from transient / other failures. /// re-registration) from transient / other failures.
@@ -35,6 +36,10 @@ struct RegisterRequest {
tls_enabled: bool, tls_enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
tls_cert_fingerprint: Option<String>, tls_cert_fingerprint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
hardware_info: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
estimated_max_concurrency: Option<u64>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -56,6 +61,7 @@ struct HeartbeatRequest {
/// Remote configuration pushed by the Aether management backend. /// Remote configuration pushed by the Aether management backend.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct RemoteConfig { pub struct RemoteConfig {
pub node_name: Option<String>,
pub allowed_ports: Option<Vec<u16>>, pub allowed_ports: Option<Vec<u16>>,
pub log_level: Option<String>, pub log_level: Option<String>,
pub heartbeat_interval: Option<u64>, pub heartbeat_interval: Option<u64>,
@@ -119,6 +125,7 @@ impl AetherClient {
public_ip: &str, public_ip: &str,
tls_enabled: bool, tls_enabled: bool,
tls_cert_fingerprint: Option<&str>, tls_cert_fingerprint: Option<&str>,
hw: Option<&HardwareInfo>,
) -> 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 {
@@ -129,6 +136,8 @@ impl AetherClient {
heartbeat_interval: config.heartbeat_interval, heartbeat_interval: config.heartbeat_interval,
tls_enabled, tls_enabled,
tls_cert_fingerprint: tls_cert_fingerprint.map(|s| s.to_string()), tls_cert_fingerprint: tls_cert_fingerprint.map(|s| s.to_string()),
hardware_info: hw.and_then(|h| serde_json::to_value(h).ok()),
estimated_max_concurrency: hw.map(|h| h.estimated_max_concurrency),
}; };
info!( info!(
@@ -215,10 +224,13 @@ impl AetherClient {
config_version, config_version,
} }
} }
Err(_) => HeartbeatResult { Err(e) => {
remote_config: None, debug!(error = %e, "failed to parse heartbeat response body");
config_version: 0, HeartbeatResult {
}, remote_config: None,
config_version: 0,
}
}
}; };
debug!(node_id = %node_id, config_version = result.config_version, "heartbeat ok"); debug!(node_id = %node_id, config_version = result.config_version, "heartbeat ok");
@@ -260,36 +272,3 @@ impl AetherClient {
} }
} }
} }
/// Auto-detect public IP by querying external services.
pub async fn detect_public_ip() -> anyhow::Result<String> {
let endpoints = [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
];
let client = Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()?;
for endpoint in &endpoints {
match client.get(*endpoint).send().await {
Ok(resp) if resp.status().is_success() => {
let ip = resp.text().await?.trim().to_string();
if !ip.is_empty() {
info!(ip = %ip, source = %endpoint, "detected public IP");
return Ok(ip);
}
}
Ok(resp) => {
debug!(endpoint = %endpoint, status = %resp.status(), "IP detection failed");
}
Err(e) => {
debug!(endpoint = %endpoint, error = %e, "IP detection failed");
}
}
}
anyhow::bail!("failed to detect public IP from any source; use --public-ip")
}

View File

@@ -1,11 +1,12 @@
use std::sync::{Arc, RwLock}; use std::sync::atomic::Ordering;
use std::sync::Arc;
use tokio::sync::watch; use tokio::sync::watch;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use crate::config::Config; use crate::registration::client::HeartbeatError;
use crate::registration::client::{AetherClient, HeartbeatError}; use crate::runtime;
use crate::runtime::{self, SharedDynamicConfig}; use crate::state::AppState;
/// Run periodic heartbeat task until shutdown signal. /// Run periodic heartbeat task until shutdown signal.
/// ///
@@ -16,19 +17,11 @@ use crate::runtime::{self, SharedDynamicConfig};
/// When the heartbeat response includes a `remote_config`, it is applied /// When the heartbeat response includes a `remote_config`, it is applied
/// to the [`DynamicConfig`](crate::runtime::DynamicConfig) so the proxy /// to the [`DynamicConfig`](crate::runtime::DynamicConfig) so the proxy
/// picks up changes without a restart. /// picks up changes without a restart.
pub async fn run( pub async fn run(state: &Arc<AppState>, mut shutdown_rx: watch::Receiver<bool>) {
client: Arc<AetherClient>,
node_id: Arc<RwLock<String>>,
config: Arc<Config>,
public_ip: String,
tls_fingerprint: Option<String>,
dynamic: SharedDynamicConfig,
mut shutdown_rx: watch::Receiver<bool>,
) {
let mut consecutive_failures: u32 = 0; let mut consecutive_failures: u32 = 0;
// Skip the first tick (registration already acts as initial heartbeat) // Skip the first tick (registration already acts as initial heartbeat)
let initial_interval = dynamic.read().unwrap().heartbeat_interval; let initial_interval = state.dynamic.read().unwrap().heartbeat_interval;
tokio::select! { tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(initial_interval)) => {} _ = tokio::time::sleep(std::time::Duration::from_secs(initial_interval)) => {}
_ = shutdown_rx.changed() => { _ = shutdown_rx.changed() => {
@@ -38,12 +31,17 @@ pub async fn run(
} }
loop { loop {
let current_node_id = node_id.read().unwrap().clone(); let current_node_id = state.node_id.read().unwrap().clone();
let active_conns = state.active_connections.load(Ordering::Relaxed) as i64;
match client.heartbeat(&current_node_id, None, None, None).await { match state
.aether_client
.heartbeat(&current_node_id, Some(active_conns), None, None)
.await
{
Ok(result) => { Ok(result) => {
if consecutive_failures > 0 { if consecutive_failures > 0 {
debug!( info!(
previous_failures = consecutive_failures, previous_failures = consecutive_failures,
"heartbeat recovered" "heartbeat recovered"
); );
@@ -52,7 +50,7 @@ pub async fn run(
// Apply remote config if present and version changed // Apply remote config if present and version changed
if let Some(ref remote) = result.remote_config { if let Some(ref remote) = result.remote_config {
runtime::apply_remote_config(&dynamic, remote, result.config_version); runtime::apply_remote_config(&state.dynamic, remote, result.config_version);
} }
} }
Err(HeartbeatError::NodeNotFound(_)) => { Err(HeartbeatError::NodeNotFound(_)) => {
@@ -60,19 +58,24 @@ 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( match state
&config, .aether_client
&public_ip, .register(
config.enable_tls, &state.config,
tls_fingerprint.as_deref(), &state.public_ip,
).await { state.config.enable_tls,
state.tls_fingerprint.as_deref(),
Some(&state.hardware_info),
)
.await
{
Ok(new_id) => { Ok(new_id) => {
info!( info!(
old_node_id = %current_node_id, old_node_id = %current_node_id,
new_node_id = %new_id, new_node_id = %new_id,
"re-registered successfully" "re-registered successfully"
); );
*node_id.write().unwrap() = new_id; *state.node_id.write().unwrap() = new_id;
consecutive_failures = 0; consecutive_failures = 0;
} }
Err(e) => { Err(e) => {
@@ -96,7 +99,7 @@ pub async fn run(
} }
// Read interval from dynamic config (may have been updated remotely) // Read interval from dynamic config (may have been updated remotely)
let interval_secs = dynamic.read().unwrap().heartbeat_interval; let interval_secs = state.dynamic.read().unwrap().heartbeat_interval;
tokio::select! { tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(interval_secs)) => {} _ = tokio::time::sleep(std::time::Duration::from_secs(interval_secs)) => {}

View File

@@ -14,6 +14,7 @@ use crate::config::Config;
/// Configuration that can be changed at runtime without restart. /// Configuration that can be changed at runtime without restart.
#[derive(Debug)] #[derive(Debug)]
pub struct DynamicConfig { pub struct DynamicConfig {
pub node_name: String,
pub allowed_ports: HashSet<u16>, pub allowed_ports: HashSet<u16>,
pub timestamp_tolerance: u64, pub timestamp_tolerance: u64,
pub log_level: String, pub log_level: String,
@@ -27,6 +28,7 @@ impl DynamicConfig {
/// Initialize from static config (startup defaults). /// Initialize from static config (startup defaults).
pub fn from_config(config: &Config) -> Self { pub fn from_config(config: &Config) -> Self {
Self { Self {
node_name: config.node_name.clone(),
allowed_ports: config.allowed_ports.iter().copied().collect(), allowed_ports: config.allowed_ports.iter().copied().collect(),
timestamp_tolerance: config.timestamp_tolerance, timestamp_tolerance: config.timestamp_tolerance,
log_level: config.log_level.clone(), log_level: config.log_level.clone(),
@@ -54,7 +56,7 @@ pub fn set_log_reloader(f: Box<dyn Fn(&str) + Send + Sync>) {
/// Returns `true` if the config was actually changed. /// Returns `true` if the config was actually changed.
pub fn apply_remote_config( pub fn apply_remote_config(
dynamic: &SharedDynamicConfig, dynamic: &SharedDynamicConfig,
remote: &super::registration::client::RemoteConfig, remote: &crate::registration::client::RemoteConfig,
version: u64, version: u64,
) -> bool { ) -> bool {
let mut cfg = dynamic.write().unwrap(); let mut cfg = dynamic.write().unwrap();
@@ -65,6 +67,13 @@ pub fn apply_remote_config(
let mut changed = Vec::new(); let mut changed = Vec::new();
if let Some(ref name) = remote.node_name {
if *name != cfg.node_name {
changed.push(format!("node_name → {}", name));
cfg.node_name = name.clone();
}
}
if let Some(ref ports) = remote.allowed_ports { if let Some(ref ports) = remote.allowed_ports {
let new_set: HashSet<u16> = ports.iter().copied().collect(); let new_set: HashSet<u16> = ports.iter().copied().collect();
if new_set != cfg.allowed_ports { if new_set != cfg.allowed_ports {

View File

@@ -0,0 +1,4 @@
pub(crate) mod service;
mod tui;
pub use self::tui::run;

View File

@@ -0,0 +1,253 @@
//! Systemd service installation for aether-proxy.
//!
//! Called from the setup TUI when the user enables "Install Service".
//! The unit file points to the binary and config at their current
//! absolute paths -- no files are copied.
use std::path::Path;
use std::process::Command;
const UNIT_PATH: &str = "/etc/systemd/system/aether-proxy.service";
const SERVICE_NAME: &str = "aether-proxy";
/// Whether systemd service installation is possible (systemd present + root).
pub fn is_available() -> bool {
is_systemd_available() && is_root()
}
/// Install aether-proxy as a systemd service. Must be run as root.
pub fn install_service(config_path: &Path) -> anyhow::Result<()> {
if !is_systemd_available() {
anyhow::bail!("systemd not available");
}
if !is_root() {
anyhow::bail!("root required, use: sudo aether-proxy setup");
}
let exe_path = std::env::current_exe()?.canonicalize()?;
let exe_str = exe_path
.to_str()
.ok_or_else(|| anyhow::anyhow!("binary path contains invalid UTF-8"))?;
let config_abs = std::fs::canonicalize(config_path)?;
let config_str = config_abs
.to_str()
.ok_or_else(|| anyhow::anyhow!("config path contains invalid UTF-8"))?;
let working_dir = config_abs
.parent()
.unwrap_or_else(|| Path::new("/"))
.to_str()
.unwrap_or("/");
// Stop existing service if running (ignore errors)
if Path::new(UNIT_PATH).exists() {
eprintln!(" Stopping existing service...");
let _ = Command::new("systemctl")
.args(["stop", SERVICE_NAME])
.status();
}
// Write unit file
eprintln!(" Generating systemd unit file...");
eprintln!(" Binary: {}", exe_str);
eprintln!(" Config: {}", config_str);
eprintln!(" WorkDir: {}", working_dir);
let unit_content = format!(
"[Unit]\n\
Description=Aether Proxy\n\
After=network.target\n\
\n\
[Service]\n\
Type=simple\n\
WorkingDirectory={working_dir}\n\
Environment=AETHER_PROXY_CONFIG={config_str}\n\
ExecStart={exe_str}\n\
Restart=on-failure\n\
RestartSec=5\n\
LimitNOFILE=65535\n\
\n\
[Install]\n\
WantedBy=multi-user.target\n",
);
std::fs::write(UNIT_PATH, &unit_content)?;
// Reload and enable
eprintln!(" Enabling and starting service...");
run_cmd("systemctl", &["daemon-reload"])?;
run_cmd("systemctl", &["enable", "--now", SERVICE_NAME])?;
// Verify
eprintln!();
let output = Command::new("systemctl")
.args(["is-active", SERVICE_NAME])
.output()?;
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
if state == "active" {
eprintln!(" Service started successfully!");
} else {
eprintln!(" Service state: {} (check logs)", state);
}
eprintln!();
eprintln!(" Commands:");
eprintln!(" sudo systemctl status {} # status", SERVICE_NAME);
eprintln!(" sudo systemctl restart {} # restart", SERVICE_NAME);
eprintln!(" sudo journalctl -u {} -f # logs", SERVICE_NAME);
eprintln!();
Ok(())
}
fn is_systemd_available() -> bool {
Command::new("systemctl")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn is_root() -> bool {
#[cfg(unix)]
{
unsafe { libc::geteuid() == 0 }
}
#[cfg(not(unix))]
{
false
}
}
/// Whether a systemd unit file is currently installed.
pub fn is_installed() -> bool {
Path::new(UNIT_PATH).exists()
}
/// Remove the systemd service (called from setup TUI when Install Service is toggled off).
pub fn uninstall_service() -> anyhow::Result<()> {
if !Path::new(UNIT_PATH).exists() {
return Ok(());
}
eprintln!(" Stopping and removing existing service...");
let _ = Command::new("systemctl")
.args(["disable", "--now", SERVICE_NAME])
.status();
std::fs::remove_file(UNIT_PATH)?;
eprintln!(" Removed {}", UNIT_PATH);
run_cmd("systemctl", &["daemon-reload"])?;
eprintln!(" Service uninstalled.");
eprintln!();
Ok(())
}
/// Check if the systemd service is currently active.
pub fn is_service_active() -> bool {
std::path::Path::new(UNIT_PATH).exists()
&& Command::new("systemctl")
.args(["is-active", "--quiet", SERVICE_NAME])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
// ── CLI subcommands (systemd wrappers) ──────────────────────────────────────
fn ensure_service_installed() -> anyhow::Result<()> {
if !std::path::Path::new(UNIT_PATH).exists() {
anyhow::bail!("service not installed, run `sudo aether-proxy setup` first");
}
Ok(())
}
fn ensure_root_and_service() -> anyhow::Result<()> {
ensure_service_installed()?;
if !is_root() {
anyhow::bail!("root required, use: sudo aether-proxy <command>");
}
Ok(())
}
/// `aether-proxy status` -- show service status.
pub fn cmd_status() -> anyhow::Result<()> {
ensure_service_installed()?;
let status = Command::new("systemctl")
.args(["status", SERVICE_NAME])
.status()?;
// systemctl status returns non-zero when inactive; that's fine
std::process::exit(status.code().unwrap_or(1));
}
/// `aether-proxy logs` -- tail service logs.
pub fn cmd_logs() -> anyhow::Result<()> {
ensure_service_installed()?;
let status = Command::new("journalctl")
.args(["-u", SERVICE_NAME, "-f", "--no-pager", "-n", "100"])
.status()?;
std::process::exit(status.code().unwrap_or(1));
}
/// `aether-proxy start` -- start the service.
pub fn cmd_start() -> anyhow::Result<()> {
ensure_root_and_service()?;
run_cmd("systemctl", &["start", SERVICE_NAME])?;
eprintln!(" Service started.");
Ok(())
}
/// `aether-proxy restart` -- restart the service.
pub fn cmd_restart() -> anyhow::Result<()> {
ensure_root_and_service()?;
run_cmd("systemctl", &["restart", SERVICE_NAME])?;
eprintln!(" Service restarted.");
Ok(())
}
/// `aether-proxy stop` -- stop the service.
pub fn cmd_stop() -> anyhow::Result<()> {
ensure_root_and_service()?;
run_cmd("systemctl", &["stop", SERVICE_NAME])?;
eprintln!(" Service stopped.");
Ok(())
}
/// `aether-proxy uninstall` -- disable and remove the systemd service.
pub fn cmd_uninstall() -> anyhow::Result<()> {
ensure_root_and_service()?;
eprintln!(" Stopping and disabling service...");
let _ = Command::new("systemctl")
.args(["disable", "--now", SERVICE_NAME])
.status();
if std::path::Path::new(UNIT_PATH).exists() {
std::fs::remove_file(UNIT_PATH)?;
eprintln!(" Removed {}", UNIT_PATH);
}
run_cmd("systemctl", &["daemon-reload"])?;
eprintln!(" Service uninstalled.");
eprintln!();
eprintln!(" Config file and TLS certs are preserved. Remove manually if needed.");
Ok(())
}
fn run_cmd(program: &str, args: &[&str]) -> anyhow::Result<()> {
let display = format!("{} {}", program, args.join(" "));
eprintln!(" > {}", display);
let status = Command::new(program).args(args).status()?;
if !status.success() {
anyhow::bail!("command failed: {}", display);
}
Ok(())
}

View File

@@ -32,7 +32,6 @@ enum FieldKind {
Secret, Secret,
Number, Number,
Bool, Bool,
PortList,
LogLevel, LogLevel,
} }
@@ -102,14 +101,6 @@ impl App {
required: true, required: true,
help: "代理服务监听端口", help: "代理服务监听端口",
}, },
Field {
label: "Public IP",
key: "public_ip",
value: String::new(),
kind: FieldKind::Text,
required: false,
help: "节点公网 IP (留空则自动检测)",
},
Field { Field {
label: "Node Name", label: "Node Name",
key: "node_name", key: "node_name",
@@ -118,53 +109,13 @@ impl App {
required: true, required: true,
help: "节点名称,用于在 Aether 后台识别", help: "节点名称,用于在 Aether 后台识别",
}, },
Field {
label: "Node Region",
key: "node_region",
value: String::new(),
kind: FieldKind::Text,
required: false,
help: "节点区域标识 (如 ap-northeast-1)",
},
Field {
label: "Heartbeat Interval",
key: "heartbeat_interval",
value: "30".into(),
kind: FieldKind::Number,
required: true,
help: "心跳上报间隔 (秒)",
},
Field {
label: "Allowed Ports",
key: "allowed_ports",
value: "80, 443, 8080, 8443".into(),
kind: FieldKind::PortList,
required: true,
help: "允许代理的目标端口,逗号分隔",
},
Field {
label: "Timestamp Tolerance",
key: "timestamp_tolerance",
value: "300".into(),
kind: FieldKind::Number,
required: true,
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",
value: "info".into(), value: "info".into(),
kind: FieldKind::LogLevel, kind: FieldKind::LogLevel,
required: true, required: true,
help: "日志级别 Enter 切换: trace / debug / info / warn / error", help: "日志级别 -- Enter 切换: trace / debug / info / warn / error",
}, },
Field { Field {
label: "Log JSON", label: "Log JSON",
@@ -172,7 +123,20 @@ impl App {
value: "false".into(), value: "false".into(),
kind: FieldKind::Bool, kind: FieldKind::Bool,
required: true, required: true,
help: "是否以 JSON 格式输出日志 Enter 切换", help: "是否以 JSON 格式输出日志 -- Enter 切换",
},
Field {
label: "Install Service",
key: "install_service",
value: if super::service::is_available() {
"true"
} else {
"false"
}
.into(),
kind: FieldKind::Bool,
required: true,
help: "注册为 systemd 开机启动服务 (需要 root 权限) -- Enter 切换",
}, },
], ],
selected: 0, selected: 0,
@@ -202,17 +166,9 @@ impl App {
"management_token" => cfg.management_token.clone(), "management_token" => cfg.management_token.clone(),
"hmac_key" => cfg.hmac_key.clone(), "hmac_key" => cfg.hmac_key.clone(),
"listen_port" => cfg.listen_port.map(|v| v.to_string()), "listen_port" => cfg.listen_port.map(|v| v.to_string()),
"public_ip" => cfg.public_ip.clone(),
"node_name" => cfg.node_name.clone(), "node_name" => cfg.node_name.clone(),
"node_region" => cfg.node_region.clone(),
"heartbeat_interval" => cfg.heartbeat_interval.map(|v| v.to_string()),
"allowed_ports" => cfg.allowed_ports.as_ref().map(|p| {
p.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(", ")
}),
"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 {
@@ -235,19 +191,15 @@ impl App {
management_token: get("management_token"), management_token: get("management_token"),
hmac_key: get("hmac_key"), hmac_key: get("hmac_key"),
listen_port: get("listen_port").and_then(|v| v.parse().ok()), listen_port: get("listen_port").and_then(|v| v.parse().ok()),
public_ip: get("public_ip"), public_ip: None,
node_name: get("node_name"), node_name: get("node_name"),
node_region: get("node_region"), node_region: None,
heartbeat_interval: get("heartbeat_interval").and_then(|v| v.parse().ok()), heartbeat_interval: None,
allowed_ports: get("allowed_ports").map(|v| { allowed_ports: None,
v.split(',') timestamp_tolerance: None,
.filter_map(|s| s.trim().parse().ok())
.collect()
}),
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()), enable_tls: None,
tls_cert: None, tls_cert: None,
tls_key: None, tls_key: None,
} }
@@ -259,7 +211,7 @@ impl App {
self.modified = false; self.modified = false;
self.saved_once = true; self.saved_once = true;
self.message = Some(( self.message = Some((
format!("✓ 已保存到 {}", self.config_path.display()), format!("saved to {}", self.config_path.display()),
Instant::now(), Instant::now(),
false, false,
)); ));
@@ -304,7 +256,7 @@ impl App {
KeyCode::Char('q') | KeyCode::Esc => return true, KeyCode::Char('q') | KeyCode::Esc => return true,
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Err(e) = self.save() { if let Err(e) = self.save() {
self.message = Some((format!(" {}", e), Instant::now(), true)); self.message = Some((format!("error: {}", e), Instant::now(), true));
} }
} }
KeyCode::Up | KeyCode::Char('k') => { KeyCode::Up | KeyCode::Char('k') => {
@@ -321,16 +273,30 @@ impl App {
let field = &self.fields[self.selected]; let field = &self.fields[self.selected];
match field.kind { match field.kind {
FieldKind::Bool => { FieldKind::Bool => {
let toggled = if field.value == "true" { "false" } else { "true" }; let toggled = if field.value == "true" {
self.fields[self.selected].value = toggled.into(); "false"
self.modified = true; } else {
"true"
};
// Block enabling service install without root/systemd
if field.key == "install_service"
&& toggled == "true"
&& !super::service::is_available()
{
self.message = Some((
"requires root with systemd, use: sudo aether-proxy setup".into(),
Instant::now(),
true,
));
} else {
self.fields[self.selected].value = toggled.into();
self.modified = true;
}
} }
FieldKind::LogLevel => { FieldKind::LogLevel => {
const LEVELS: &[&str] = const LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
&["trace", "debug", "info", "warn", "error"];
let idx = LEVELS.iter().position(|l| *l == field.value).unwrap_or(2); let idx = LEVELS.iter().position(|l| *l == field.value).unwrap_or(2);
self.fields[self.selected].value = self.fields[self.selected].value = LEVELS[(idx + 1) % LEVELS.len()].into();
LEVELS[(idx + 1) % LEVELS.len()].into();
self.modified = true; self.modified = true;
} }
_ => { _ => {
@@ -343,7 +309,7 @@ impl App {
KeyCode::Tab => { KeyCode::Tab => {
// Quick save shortcut // Quick save shortcut
if let Err(e) = self.save() { if let Err(e) = self.save() {
self.message = Some((format!(" {}", e), Instant::now(), true)); self.message = Some((format!("error: {}", e), Instant::now(), true));
} }
} }
_ => {} _ => {}
@@ -354,7 +320,7 @@ impl App {
fn handle_edit(&mut self, key: KeyEvent) { fn handle_edit(&mut self, key: KeyEvent) {
match key.code { match key.code {
KeyCode::Esc => { KeyCode::Esc => {
// Cancel discard changes to this field // Cancel -- discard changes to this field
self.mode = Mode::Normal; self.mode = Mode::Normal;
} }
KeyCode::Enter => { KeyCode::Enter => {
@@ -363,8 +329,7 @@ impl App {
self.modified = true; self.modified = true;
self.mode = Mode::Normal; self.mode = Mode::Normal;
} else { } else {
self.message = self.message = Some(("invalid format".into(), Instant::now(), true));
Some(("✗ 格式无效".into(), Instant::now(), true));
} }
} }
KeyCode::Backspace => { KeyCode::Backspace => {
@@ -405,12 +370,6 @@ impl App {
let buf = &self.edit_buffer; let buf = &self.edit_buffer;
match kind { match kind {
FieldKind::Number => buf.is_empty() || buf.parse::<u64>().is_ok(), FieldKind::Number => buf.is_empty() || buf.parse::<u64>().is_ok(),
FieldKind::PortList => {
buf.is_empty()
|| buf
.split(',')
.all(|s| s.trim().is_empty() || s.trim().parse::<u16>().is_ok())
}
_ => true, _ => true,
} }
} }
@@ -468,7 +427,7 @@ fn render_fields(f: &mut Frame, app: &mut App, area: Rect) {
} }
let selected = i == app.selected; let selected = i == app.selected;
let indicator = if selected { " " } else { " " }; let indicator = if selected { " > " } else { " " };
let label_style = if selected { let label_style = if selected {
Style::default() Style::default()
@@ -482,10 +441,7 @@ fn render_fields(f: &mut Frame, app: &mut App, area: Rect) {
// Value display // Value display
let (value_text, value_style) = if app.mode == Mode::Editing && selected { let (value_text, value_style) = if app.mode == Mode::Editing && selected {
( (app.edit_buffer.clone(), Style::default().fg(Color::Yellow))
app.edit_buffer.clone(),
Style::default().fg(Color::Yellow),
)
} else { } else {
field_display(field) field_display(field)
}; };
@@ -518,9 +474,9 @@ fn render_fields(f: &mut Frame, app: &mut App, area: Rect) {
fn field_display(field: &Field) -> (String, Style) { fn field_display(field: &Field) -> (String, Style) {
if field.value.is_empty() { if field.value.is_empty() {
let text = if field.required { let text = if field.required {
"(必填)".into() "(required)".into()
} else { } else {
"".into() "-".into()
}; };
let color = if field.required { let color = if field.required {
Color::Red Color::Red
@@ -532,14 +488,14 @@ fn field_display(field: &Field) -> (String, Style) {
match field.kind { match field.kind {
FieldKind::Secret => ( FieldKind::Secret => (
"".repeat(field.value.len().min(20)), "*".repeat(field.value.len().min(20)),
Style::default().fg(Color::White), Style::default().fg(Color::White),
), ),
FieldKind::Bool => { FieldKind::Bool => {
if field.value == "true" { if field.value == "true" {
("✓ 开启".into(), Style::default().fg(Color::Green)) ("[x] on".into(), Style::default().fg(Color::Green))
} else { } else {
("✗ 关闭".into(), Style::default().fg(Color::DarkGray)) ("[ ] off".into(), Style::default().fg(Color::DarkGray))
} }
} }
FieldKind::LogLevel => { FieldKind::LogLevel => {
@@ -561,9 +517,9 @@ fn render_footer(f: &mut Frame, app: &App, area: Rect) {
let help = app.fields[app.selected].help; let help = app.fields[app.selected].help;
let keybindings = if app.mode == Mode::Editing { let keybindings = if app.mode == Mode::Editing {
"Enter 确认 Esc 取消" "Enter confirm Esc cancel"
} else { } else {
"↑↓ 选择 Enter 编辑 ^S 保存 q 退出" "Up/Down select Enter edit ^S save q quit"
}; };
let mut status_spans: Vec<Span> = vec![Span::styled( let mut status_spans: Vec<Span> = vec![Span::styled(
@@ -631,11 +587,40 @@ pub fn run(config_path: PathBuf) -> anyhow::Result<()> {
// Post-TUI message // Post-TUI message
if app.saved_once { if app.saved_once {
eprintln!(); eprintln!();
eprintln!(" 配置已保存到 {}", config_path.display()); eprintln!(" Config saved to {}", config_path.display());
eprintln!();
eprintln!(" 启动方式:");
eprintln!(" aether-proxy (自动读取 {})", config_path.display());
eprintln!(); eprintln!();
let wants_service = app
.fields
.iter()
.find(|f| f.key == "install_service")
.map(|f| f.value == "true")
.unwrap_or(false);
if wants_service {
match super::service::install_service(&config_path) {
Ok(()) => {}
Err(e) => {
eprintln!(" Service install failed: {}", e);
eprintln!();
}
}
} else {
// Uninstall service if it was previously installed
if super::service::is_installed() {
if let Err(e) = super::service::uninstall_service() {
eprintln!(" Service uninstall failed: {}", e);
eprintln!();
}
}
eprintln!(" Run with:");
eprintln!(
" aether-proxy (auto-reads {})",
config_path.display()
);
eprintln!();
}
} }
Ok(()) Ok(())

30
aether-proxy/src/state.rs Normal file
View File

@@ -0,0 +1,30 @@
//! Shared application state passed to all subsystems.
//!
//! Consolidates the multiple `Arc<...>` parameters that were previously
//! threaded individually through proxy server, heartbeat, and handlers.
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, RwLock};
use tokio_rustls::TlsAcceptor;
use crate::config::Config;
use crate::hardware::HardwareInfo;
use crate::registration::client::AetherClient;
use crate::runtime::SharedDynamicConfig;
/// Central application state shared across all tasks.
pub struct AppState {
pub config: Arc<Config>,
pub node_id: Arc<RwLock<String>>,
pub dynamic: SharedDynamicConfig,
pub aether_client: Arc<AetherClient>,
pub hardware_info: Arc<HardwareInfo>,
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,
/// Active connection count for metrics reporting.
pub active_connections: Arc<AtomicU64>,
}

View File

@@ -0,0 +1,60 @@
"""Add hardware_info and estimated_max_concurrency to proxy_nodes
Revision ID: 5c6d7e8f9a0b
Revises: 4b5c6d7e8f9a
Create Date: 2026-02-08 12: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 = "5c6d7e8f9a0b"
down_revision: str | None = "4b5c6d7e8f9a"
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", "hardware_info"):
op.add_column(
"proxy_nodes",
sa.Column(
"hardware_info",
sa.JSON(),
nullable=True,
comment="硬件信息 (cpu_cores, total_memory_mb, os_info, fd_limit, ...)",
),
)
if not column_exists("proxy_nodes", "estimated_max_concurrency"):
op.add_column(
"proxy_nodes",
sa.Column(
"estimated_max_concurrency",
sa.Integer(),
nullable=True,
comment="基于硬件估算的最大并发连接数",
),
)
def downgrade() -> None:
if column_exists("proxy_nodes", "estimated_max_concurrency"):
op.drop_column("proxy_nodes", "estimated_max_concurrency")
if column_exists("proxy_nodes", "hardware_info"):
op.drop_column("proxy_nodes", "hardware_info")

View File

@@ -1,6 +1,7 @@
import apiClient from './client' import apiClient from './client'
export interface ProxyNodeRemoteConfig { export interface ProxyNodeRemoteConfig {
node_name?: string
allowed_ports?: number[] allowed_ports?: number[]
log_level?: string log_level?: string
heartbeat_interval?: number heartbeat_interval?: number
@@ -19,6 +20,9 @@ export interface ProxyNode {
proxy_url?: string proxy_url?: string
proxy_username?: string proxy_username?: string
proxy_password?: string // 脱敏后的密码 proxy_password?: string // 脱敏后的密码
// 硬件信息aether-proxy 节点)
hardware_info: Record<string, any> | null
estimated_max_concurrency: number | null
// 远程配置aether-proxy 节点) // 远程配置aether-proxy 节点)
remote_config: ProxyNodeRemoteConfig | null remote_config: ProxyNodeRemoteConfig | null
config_version: number config_version: number

View File

@@ -7,7 +7,10 @@
:z-index="70" :z-index="70"
@update:model-value="$emit('update:open', $event)" @update:model-value="$emit('update:open', $event)"
> >
<template v-if="providerId && items.length > 0" #header-actions> <template
v-if="providerId && items.length > 0"
#header-actions
>
<DropdownMenu :modal="false"> <DropdownMenu :modal="false">
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<Button <Button

View File

@@ -107,7 +107,10 @@
> >
<!-- 主区域拖拽 粘贴输入框同一位置切换 --> <!-- 主区域拖拽 粘贴输入框同一位置切换 -->
<div v-if="!importText" class="mt-3"> <div
v-if="!importText"
class="mt-3"
>
<!-- 拖拽模式 --> <!-- 拖拽模式 -->
<div <div
v-if="!showManualInput" v-if="!showManualInput"
@@ -168,7 +171,10 @@
</div> </div>
<!-- 已有内容文件导入后显示文本框 --> <!-- 已有内容文件导入后显示文本框 -->
<div v-if="importText" class="space-y-2"> <div
v-if="importText"
class="space-y-2"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">{{ importFileName || '已粘贴内容' }}</span> <span class="text-xs text-muted-foreground">{{ importFileName || '已粘贴内容' }}</span>
<button <button

View File

@@ -1,6 +1,9 @@
<template> <template>
<div class="space-y-6 pb-8"> <div class="space-y-6 pb-8">
<Card variant="default" class="overflow-hidden"> <Card
variant="default"
class="overflow-hidden"
>
<!-- 标题和筛选器 --> <!-- 标题和筛选器 -->
<div class="px-4 sm:px-6 py-3.5 border-b border-border/60"> <div class="px-4 sm:px-6 py-3.5 border-b border-border/60">
<!-- 移动端 --> <!-- 移动端 -->
@@ -39,10 +42,18 @@
<SelectValue placeholder="状态" /> <SelectValue placeholder="状态" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">全部</SelectItem> <SelectItem value="all">
<SelectItem value="online">在线</SelectItem> 全部
<SelectItem value="unhealthy">异常</SelectItem> </SelectItem>
<SelectItem value="offline">离线</SelectItem> <SelectItem value="online">
在线
</SelectItem>
<SelectItem value="unhealthy">
异常
</SelectItem>
<SelectItem value="offline">
离线
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -69,10 +80,18 @@
<SelectValue placeholder="全部状态" /> <SelectValue placeholder="全部状态" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">全部状态</SelectItem> <SelectItem value="all">
<SelectItem value="online">在线</SelectItem> 全部状态
<SelectItem value="unhealthy">异常</SelectItem> </SelectItem>
<SelectItem value="offline">离线</SelectItem> <SelectItem value="online">
在线
</SelectItem>
<SelectItem value="unhealthy">
异常
</SelectItem>
<SelectItem value="offline">
离线
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<div class="h-4 w-px bg-border" /> <div class="h-4 w-px bg-border" />
@@ -98,15 +117,33 @@
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent"> <TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-[160px] h-12 font-semibold">名称</TableHead> <TableHead class="w-[160px] h-12 font-semibold">
<TableHead class="w-[180px] h-12 font-semibold">地址</TableHead> 名称
<TableHead class="w-[100px] h-12 font-semibold">区域</TableHead> </TableHead>
<TableHead class="w-[90px] h-12 font-semibold text-center">状态</TableHead> <TableHead class="w-[180px] h-12 font-semibold">
<TableHead class="w-[100px] h-12 font-semibold text-center">连接数</TableHead> 地址
<TableHead class="w-[100px] h-12 font-semibold text-center">总请求</TableHead> </TableHead>
<TableHead class="w-[100px] h-12 font-semibold text-center">延迟</TableHead> <TableHead class="w-[100px] h-12 font-semibold">
<TableHead class="w-[160px] h-12 font-semibold">最后心跳</TableHead> 区域
<TableHead class="w-[80px] h-12 font-semibold text-center">操作</TableHead> </TableHead>
<TableHead class="w-[90px] h-12 font-semibold text-center">
状态
</TableHead>
<TableHead class="w-[100px] h-12 font-semibold text-center">
连接数
</TableHead>
<TableHead class="w-[100px] h-12 font-semibold text-center">
总请求
</TableHead>
<TableHead class="w-[100px] h-12 font-semibold text-center">
延迟
</TableHead>
<TableHead class="w-[160px] h-12 font-semibold">
最后心跳
</TableHead>
<TableHead class="w-[80px] h-12 font-semibold text-center">
操作
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -125,6 +162,7 @@
> >
手动 手动
</Badge> </Badge>
<HardwareTooltip :node="node" />
</div> </div>
</TableCell> </TableCell>
<TableCell class="py-4"> <TableCell class="py-4">
@@ -134,7 +172,10 @@
<span class="text-sm text-muted-foreground">{{ node.region || '-' }}</span> <span class="text-sm text-muted-foreground">{{ node.region || '-' }}</span>
</TableCell> </TableCell>
<TableCell class="py-4 text-center"> <TableCell class="py-4 text-center">
<Badge :variant="statusVariant(node.status)" class="font-medium px-2.5 py-0.5 text-xs"> <Badge
:variant="statusVariant(node.status)"
class="font-medium px-2.5 py-0.5 text-xs"
>
{{ statusLabel(node.status) }} {{ statusLabel(node.status) }}
</Badge> </Badge>
</TableCell> </TableCell>
@@ -160,8 +201,14 @@
:disabled="testingNodes.has(node.id)" :disabled="testingNodes.has(node.id)"
@click="handleTest(node)" @click="handleTest(node)"
> >
<Loader2 v-if="testingNodes.has(node.id)" class="h-4 w-4 animate-spin" /> <Loader2
<Activity v-else class="h-4 w-4" /> v-if="testingNodes.has(node.id)"
class="h-4 w-4 animate-spin"
/>
<Activity
v-else
class="h-4 w-4"
/>
</Button> </Button>
<Button <Button
v-if="node.is_manual" v-if="node.is_manual"
@@ -196,7 +243,10 @@
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow v-if="paginatedNodes.length === 0"> <TableRow v-if="paginatedNodes.length === 0">
<TableCell colspan="9" class="py-12 text-center text-muted-foreground text-sm"> <TableCell
colspan="9"
class="py-12 text-center text-muted-foreground text-sm"
>
{{ store.loading ? '加载中...' : '暂无代理节点' }} {{ store.loading ? '加载中...' : '暂无代理节点' }}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -222,10 +272,14 @@
> >
手动 手动
</Badge> </Badge>
<HardwareTooltip :node="node" />
</div> </div>
<code class="text-xs text-muted-foreground">{{ node.is_manual ? (node.proxy_url || `${node.ip}:${node.port}`) : `${node.ip}:${node.port}` }}</code> <code class="text-xs text-muted-foreground">{{ node.is_manual ? (node.proxy_url || `${node.ip}:${node.port}`) : `${node.ip}:${node.port}` }}</code>
</div> </div>
<Badge :variant="statusVariant(node.status)" class="text-xs"> <Badge
:variant="statusVariant(node.status)"
class="text-xs"
>
{{ statusLabel(node.status) }} {{ statusLabel(node.status) }}
</Badge> </Badge>
</div> </div>
@@ -253,8 +307,14 @@
:disabled="testingNodes.has(node.id)" :disabled="testingNodes.has(node.id)"
@click="handleTest(node)" @click="handleTest(node)"
> >
<Loader2 v-if="testingNodes.has(node.id)" class="h-3 w-3 mr-1 animate-spin" /> <Loader2
<Activity v-else class="h-3 w-3 mr-1" /> v-if="testingNodes.has(node.id)"
class="h-3 w-3 mr-1 animate-spin"
/>
<Activity
v-else
class="h-3 w-3 mr-1"
/>
{{ testingNodes.has(node.id) ? '测试中' : '测试' }} {{ testingNodes.has(node.id) ? '测试中' : '测试' }}
</Button> </Button>
<Button <Button
@@ -289,7 +349,10 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="paginatedNodes.length === 0" class="p-8 text-center text-muted-foreground text-sm"> <div
v-if="paginatedNodes.length === 0"
class="p-8 text-center text-muted-foreground text-sm"
>
{{ store.loading ? '加载中...' : '暂无代理节点' }} {{ store.loading ? '加载中...' : '暂无代理节点' }}
</div> </div>
</div> </div>
@@ -390,25 +453,40 @@
size="md" size="md"
@update:model-value="handleConfigDialogClose" @update:model-value="handleConfigDialogClose"
> >
<form class="space-y-4" @submit.prevent> <form
class="space-y-4"
@submit.prevent
>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>允许的端口</Label> <Label>允许的端口</Label>
<Input <Input
v-model="configForm.allowed_ports" v-model="configForm.allowed_ports"
placeholder="80, 443, 8080, 8443" placeholder="80, 443, 8080, 8443"
/> />
<p class="text-xs text-muted-foreground">逗号分隔的目标端口白名单</p> <p class="text-xs text-muted-foreground">
逗号分隔的目标端口白名单
</p>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label>日志级别</Label> <Label>日志级别</Label>
<Select v-model="configForm.log_level"> <Select v-model="configForm.log_level">
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="trace">trace</SelectItem> <SelectItem value="trace">
<SelectItem value="debug">debug</SelectItem> trace
<SelectItem value="info">info</SelectItem> </SelectItem>
<SelectItem value="warn">warn</SelectItem> <SelectItem value="debug">
<SelectItem value="error">error</SelectItem> debug
</SelectItem>
<SelectItem value="info">
info
</SelectItem>
<SelectItem value="warn">
warn
</SelectItem>
<SelectItem value="error">
error
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -432,7 +510,10 @@
/> />
</div> </div>
</div> </div>
<div v-if="configNode" class="text-xs text-muted-foreground"> <div
v-if="configNode"
class="text-xs text-muted-foreground"
>
配置版本: v{{ configNode.config_version }} 配置版本: v{{ configNode.config_version }}
</div> </div>
</form> </form>
@@ -484,6 +565,7 @@ import {
} from '@/components/ui' } from '@/components/ui'
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings } from 'lucide-vue-next' import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings } from 'lucide-vue-next'
import HardwareTooltip from './components/HardwareTooltip.vue'
const { success, error: toastError } = useToast() const { success, error: toastError } = useToast()
const { confirmDanger } = useConfirm() const { confirmDanger } = useConfirm()

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { ProxyNode } from '@/api/proxy-nodes'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui'
import { Cpu } from 'lucide-vue-next'
defineProps<{ node: ProxyNode }>()
function formatMemory(mb: number | null) {
if (mb == null) return '-'
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${mb} MB`
}
function formatNumber(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
</script>
<template>
<TooltipProvider v-if="!node.is_manual && node.hardware_info">
<Tooltip>
<TooltipTrigger as-child>
<Cpu class="h-3.5 w-3.5 text-muted-foreground cursor-default" />
</TooltipTrigger>
<TooltipContent
side="right"
class="text-xs space-y-0.5"
>
<div v-if="node.hardware_info.cpu_cores">
CPU: {{ node.hardware_info.cpu_cores }} cores
</div>
<div v-if="node.hardware_info.total_memory_mb">
RAM: {{ formatMemory(node.hardware_info.total_memory_mb) }}
</div>
<div v-if="node.hardware_info.os_info">
OS: {{ node.hardware_info.os_info }}
</div>
<div v-if="node.estimated_max_concurrency">
Max Concurrency: ~{{ formatNumber(node.estimated_max_concurrency) }}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>

View File

@@ -6,9 +6,7 @@
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress
import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
@@ -18,51 +16,17 @@ from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, NotFoundException from src.core.exceptions import InvalidRequestException
from src.database import get_db from src.database import get_db
from src.models.database import ProxyNode, ProxyNodeStatus, SystemConfig from src.services.proxy_node.service import ProxyNodeService, node_to_dict
router = APIRouter(prefix="/api/admin/proxy-nodes", tags=["Admin - Proxy Nodes"]) router = APIRouter(prefix="/api/admin/proxy-nodes", tags=["Admin - Proxy Nodes"])
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
def _mask_password(password: str | None) -> str | None: # ---------------------------------------------------------------------------
"""脱敏密码仅显示前2位和后2位长度不足 8 时全部遮蔽)""" # Pydantic 请求模型
if not password: # ---------------------------------------------------------------------------
return None
if len(password) < 8:
return "****"
return password[:2] + "****" + password[-2:]
def _node_to_dict(node: ProxyNode) -> dict[str, Any]:
d = {
"id": node.id,
"name": node.name,
"ip": node.ip,
"port": node.port,
"region": node.region,
"status": node.status.value if node.status else None,
"is_manual": bool(node.is_manual),
"registered_by": node.registered_by,
"last_heartbeat_at": node.last_heartbeat_at,
"heartbeat_interval": node.heartbeat_interval,
"active_connections": node.active_connections,
"total_requests": node.total_requests,
"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,
"config_version": node.config_version,
"created_at": node.created_at,
"updated_at": node.updated_at,
}
# 手动节点附带代理配置(密码脱敏)
if node.is_manual:
d["proxy_url"] = node.proxy_url
d["proxy_username"] = node.proxy_username
d["proxy_password"] = _mask_password(node.proxy_password)
return d
class ProxyNodeRegisterRequest(BaseModel): class ProxyNodeRegisterRequest(BaseModel):
@@ -83,6 +47,10 @@ class ProxyNodeRegisterRequest(BaseModel):
None, max_length=128, description="TLS 证书 SHA-256 指纹" None, max_length=128, description="TLS 证书 SHA-256 指纹"
) )
# 硬件信息
hardware_info: dict | None = Field(None, description="硬件信息 JSON")
estimated_max_concurrency: int | None = Field(None, ge=0, description="估算最大并发连接数")
@field_validator("ip") @field_validator("ip")
@classmethod @classmethod
def validate_ip(cls, v: str) -> str: def validate_ip(cls, v: str) -> str:
@@ -110,6 +78,7 @@ class ProxyNodeUnregisterRequest(BaseModel):
class ProxyNodeRemoteConfigRequest(BaseModel): class ProxyNodeRemoteConfigRequest(BaseModel):
"""管理端远程配置 — 通过心跳下发给 aether-proxy""" """管理端远程配置 — 通过心跳下发给 aether-proxy"""
node_name: str | None = Field(None, min_length=1, max_length=100, description="节点名称")
allowed_ports: list[int] | None = Field(None, description="允许代理的目标端口") allowed_ports: list[int] | None = Field(None, description="允许代理的目标端口")
log_level: str | None = Field(None, description="日志级别 (trace/debug/info/warn/error)") log_level: str | None = Field(None, description="日志级别 (trace/debug/info/warn/error)")
heartbeat_interval: int | None = Field(None, ge=5, le=600, description="心跳间隔(秒)") heartbeat_interval: int | None = Field(None, ge=5, le=600, description="心跳间隔(秒)")
@@ -186,6 +155,11 @@ class ManualProxyNodeUpdateRequest(BaseModel):
return v return v
# ---------------------------------------------------------------------------
# 路由端点
# ---------------------------------------------------------------------------
@router.post("/register") @router.post("/register")
async def register_proxy_node(request: Request, db: Session = Depends(get_db)) -> Any: async def register_proxy_node(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminRegisterProxyNodeAdapter() adapter = AdminRegisterProxyNodeAdapter()
@@ -250,6 +224,11 @@ async def update_proxy_node_config(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ---------------------------------------------------------------------------
# 辅助函数
# ---------------------------------------------------------------------------
def _format_validation_error(exc: ValidationError) -> str: def _format_validation_error(exc: ValidationError) -> str:
parts: list[str] = [] parts: list[str] = []
for err in exc.errors(): for err in exc.errors():
@@ -259,6 +238,11 @@ def _format_validation_error(exc: ValidationError) -> str:
return "; ".join(parts) or "输入验证失败" return "; ".join(parts) or "输入验证失败"
# ---------------------------------------------------------------------------
# Adapter 实现
# ---------------------------------------------------------------------------
@dataclass @dataclass
class AdminRegisterProxyNodeAdapter(AdminApiAdapter): class AdminRegisterProxyNodeAdapter(AdminApiAdapter):
name: str = "admin_register_proxy_node" name: str = "admin_register_proxy_node"
@@ -270,50 +254,22 @@ class AdminRegisterProxyNodeAdapter(AdminApiAdapter):
except ValidationError as exc: except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc)) raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
now = datetime.now(timezone.utc) node = ProxyNodeService.register_node(
context.db,
node = ( name=req.name,
context.db.query(ProxyNode) ip=req.ip,
.filter(ProxyNode.ip == req.ip, ProxyNode.port == req.port) port=req.port,
.first() region=req.region,
heartbeat_interval=req.heartbeat_interval,
tls_enabled=req.tls_enabled,
tls_cert_fingerprint=req.tls_cert_fingerprint,
hardware_info=req.hardware_info,
estimated_max_concurrency=req.estimated_max_concurrency,
active_connections=req.active_connections,
total_requests=req.total_requests,
avg_latency_ms=req.avg_latency_ms,
registered_by=context.user.id if context.user else None,
) )
if node:
node.name = req.name
node.region = req.region
node.status = ProxyNodeStatus.ONLINE
node.last_heartbeat_at = now
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:
node.active_connections = req.active_connections
if req.total_requests is not None:
node.total_requests = req.total_requests
if req.avg_latency_ms is not None:
node.avg_latency_ms = req.avg_latency_ms
else:
node = ProxyNode(
id=str(uuid.uuid4()),
name=req.name,
ip=req.ip,
port=req.port,
region=req.region,
status=ProxyNodeStatus.ONLINE,
registered_by=context.user.id if context.user else None,
last_heartbeat_at=now,
heartbeat_interval=req.heartbeat_interval,
active_connections=req.active_connections or 0,
total_requests=req.total_requests or 0,
avg_latency_ms=req.avg_latency_ms,
tls_enabled=req.tls_enabled,
tls_cert_fingerprint=req.tls_cert_fingerprint,
created_at=now,
updated_at=now,
)
context.db.add(node)
context.db.commit()
context.db.refresh(node)
context.add_audit_metadata( context.add_audit_metadata(
action="proxy_node_register", action="proxy_node_register",
@@ -322,7 +278,7 @@ class AdminRegisterProxyNodeAdapter(AdminApiAdapter):
proxy_node_port=node.port, proxy_node_port=node.port,
) )
return {"node_id": node.id, "node": _node_to_dict(node)} return {"node_id": node.id, "node": node_to_dict(node)}
@dataclass @dataclass
@@ -336,31 +292,21 @@ class AdminHeartbeatProxyNodeAdapter(AdminApiAdapter):
except ValidationError as exc: except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc)) raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
node = context.db.query(ProxyNode).filter(ProxyNode.id == req.node_id).first() node = ProxyNodeService.heartbeat(
if not node: context.db,
raise NotFoundException(f"ProxyNode {req.node_id} 不存在", "proxy_node") node_id=req.node_id,
heartbeat_interval=req.heartbeat_interval,
now = datetime.now(timezone.utc) active_connections=req.active_connections,
node.status = ProxyNodeStatus.ONLINE total_requests=req.total_requests,
node.last_heartbeat_at = now avg_latency_ms=req.avg_latency_ms,
if req.heartbeat_interval is not None: )
node.heartbeat_interval = req.heartbeat_interval
if req.active_connections is not None:
node.active_connections = req.active_connections
if req.total_requests is not None:
node.total_requests = req.total_requests
if req.avg_latency_ms is not None:
node.avg_latency_ms = req.avg_latency_ms
context.db.commit()
context.db.refresh(node)
context.add_audit_metadata( context.add_audit_metadata(
action="proxy_node_heartbeat", action="proxy_node_heartbeat",
proxy_node_id=node.id, proxy_node_id=node.id,
) )
return {"message": "heartbeat ok", "node": _node_to_dict(node)} return {"message": "heartbeat ok", "node": node_to_dict(node)}
@dataclass @dataclass
@@ -374,13 +320,7 @@ class AdminUnregisterProxyNodeAdapter(AdminApiAdapter):
except ValidationError as exc: except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc)) raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
node = context.db.query(ProxyNode).filter(ProxyNode.id == req.node_id).first() node = ProxyNodeService.unregister_node(context.db, node_id=req.node_id)
if not node:
raise NotFoundException(f"ProxyNode {req.node_id} 不存在", "proxy_node")
node.status = ProxyNodeStatus.OFFLINE
node.updated_at = datetime.now(timezone.utc)
context.db.commit()
context.add_audit_metadata( context.add_audit_metadata(
action="proxy_node_unregister", action="proxy_node_unregister",
@@ -398,20 +338,11 @@ class AdminListProxyNodesAdapter(AdminApiAdapter):
limit: int = 100 limit: int = 100
async def handle(self, context: ApiRequestContext) -> Any: async def handle(self, context: ApiRequestContext) -> Any:
query = context.db.query(ProxyNode) nodes, total = ProxyNodeService.list_nodes(
if self.status: context.db, status=self.status, skip=self.skip, limit=self.limit
normalized = self.status.strip().lower()
allowed = {"online", "unhealthy", "offline"}
if normalized not in allowed:
raise InvalidRequestException(f"status 必须是以下之一: {sorted(allowed)}", "status")
query = query.filter(ProxyNode.status == ProxyNodeStatus(normalized))
total = query.count()
nodes = (
query.order_by(ProxyNode.updated_at.desc()).offset(self.skip).limit(self.limit).all()
) )
return { return {
"items": [_node_to_dict(n) for n in nodes], "items": [node_to_dict(n) for n in nodes],
"total": total, "total": total,
"skip": self.skip, "skip": self.skip,
"limit": self.limit, "limit": self.limit,
@@ -424,93 +355,21 @@ class AdminDeleteProxyNodeAdapter(AdminApiAdapter):
node_id: str = "" node_id: str = ""
async def handle(self, context: ApiRequestContext) -> Any: async def handle(self, context: ApiRequestContext) -> Any:
node = context.db.query(ProxyNode).filter(ProxyNode.id == self.node_id).first() result = ProxyNodeService.delete_node(context.db, node_id=self.node_id)
if not node:
raise NotFoundException(f"ProxyNode {self.node_id} 不存在", "proxy_node")
context.add_audit_metadata( context.add_audit_metadata(
action="proxy_node_delete", action="proxy_node_delete",
proxy_node_id=node.id, proxy_node_id=self.node_id,
proxy_node_ip=node.ip, **result.get("node_info", {}),
proxy_node_port=node.port,
) )
# 若该节点是系统默认代理,自动清除引用 was_system_proxy = result["cleared_system_proxy"]
was_system_proxy = False msg = "deleted, system default proxy cleared" if was_system_proxy else "deleted"
sys_cfg = ( return {
context.db.query(SystemConfig) "message": msg,
.filter(SystemConfig.key == "system_proxy_node_id") "node_id": self.node_id,
.first() "cleared_system_proxy": was_system_proxy,
) }
if sys_cfg and sys_cfg.value == self.node_id:
sys_cfg.value = None
was_system_proxy = True
context.db.delete(node)
context.db.commit()
if was_system_proxy:
from src.clients.http_client import invalidate_system_proxy_cache
invalidate_system_proxy_cache()
msg = "deleted"
if was_system_proxy:
msg = "deleted, system default proxy cleared"
return {"message": msg, "node_id": self.node_id, "cleared_system_proxy": was_system_proxy}
def _parse_host_port(proxy_url: str) -> tuple[str, int]:
"""从代理 URL 中解析 host 和 port含协议前缀避免唯一约束冲突"""
from urllib.parse import urlparse
parsed = urlparse(proxy_url)
host = parsed.hostname or "manual"
default_ports = {"https": 443, "socks5": 1080}
port = parsed.port or default_ports.get((parsed.scheme or "").lower(), 80)
# 添加协议前缀区分同 host:port 不同协议的场景
scheme = (parsed.scheme or "http").lower()
if scheme != "http":
host = f"{scheme}://{host}"
return host, port
def _sanitize_proxy_error(err: Exception) -> str:
"""去除异常消息中可能包含的代理 URL 凭据(如 HMAC 签名)"""
import re
return re.sub(r"://[^@/]+@", "://***@", str(err))
def _build_test_proxy_url(node: ProxyNode) -> str:
"""为测试连通性构建代理 URL无需节点在线"""
if node.is_manual:
proxy_url = node.proxy_url
if not proxy_url:
raise InvalidRequestException("手动节点缺少 proxy_url")
if node.proxy_username:
from urllib.parse import quote, urlparse
parsed = urlparse(proxy_url)
encoded_username = quote(node.proxy_username, safe="")
encoded_password = quote(node.proxy_password, safe="") if node.proxy_password else ""
host_part = parsed.hostname or "localhost"
if parsed.port:
host_part = f"{host_part}:{parsed.port}"
if encoded_password:
proxy_url = f"{parsed.scheme}://{encoded_username}:{encoded_password}@{host_part}"
else:
proxy_url = f"{parsed.scheme}://{encoded_username}@{host_part}"
if parsed.path:
proxy_url += parsed.path
return proxy_url
else:
# aether-proxy: 使用 HMAC 认证构建代理 URL
from src.clients.http_client import _build_hmac_proxy_url
return _build_hmac_proxy_url(
node.ip, node.port, node.id, tls_enabled=bool(node.tls_enabled)
)
@dataclass @dataclass
@@ -524,48 +383,22 @@ class AdminCreateManualProxyNodeAdapter(AdminApiAdapter):
except ValidationError as exc: except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc)) raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
host, port = _parse_host_port(req.proxy_url) node = ProxyNodeService.create_manual_node(
now = datetime.now(timezone.utc) context.db,
node = ProxyNode(
id=str(uuid.uuid4()),
name=req.name, name=req.name,
ip=host,
port=port,
region=req.region,
is_manual=True,
proxy_url=req.proxy_url, proxy_url=req.proxy_url,
proxy_username=req.username, username=req.username,
proxy_password=req.password, password=req.password,
status=ProxyNodeStatus.ONLINE, region=req.region,
registered_by=context.user.id if context.user else None, registered_by=context.user.id if context.user else None,
last_heartbeat_at=None,
heartbeat_interval=0,
active_connections=0,
total_requests=0,
avg_latency_ms=None,
created_at=now,
updated_at=now,
) )
# 检查是否已存在同地址的节点
existing = (
context.db.query(ProxyNode).filter(ProxyNode.ip == host, ProxyNode.port == port).first()
)
if existing:
raise InvalidRequestException(
f"已存在相同地址的代理节点: {existing.name} ({existing.ip}:{existing.port})"
)
context.db.add(node)
context.db.commit()
context.db.refresh(node)
context.add_audit_metadata( context.add_audit_metadata(
action="proxy_node_manual_create", action="proxy_node_manual_create",
proxy_node_id=node.id, proxy_node_id=node.id,
) )
return {"node_id": node.id, "node": _node_to_dict(node)} return {"node_id": node.id, "node": node_to_dict(node)}
@dataclass @dataclass
@@ -574,53 +407,28 @@ class AdminUpdateManualProxyNodeAdapter(AdminApiAdapter):
node_id: str = "" node_id: str = ""
async def handle(self, context: ApiRequestContext) -> Any: async def handle(self, context: ApiRequestContext) -> Any:
node = context.db.query(ProxyNode).filter(ProxyNode.id == self.node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {self.node_id} 不存在", "proxy_node")
if not node.is_manual:
raise InvalidRequestException("只能编辑手动添加的代理节点")
payload = context.ensure_json_body() payload = context.ensure_json_body()
try: try:
req = ManualProxyNodeUpdateRequest.model_validate(payload) req = ManualProxyNodeUpdateRequest.model_validate(payload)
except ValidationError as exc: except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc)) raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
if req.name is not None: node = ProxyNodeService.update_manual_node(
node.name = req.name context.db,
if req.proxy_url is not None: node_id=self.node_id,
node.proxy_url = req.proxy_url name=req.name,
host, port = _parse_host_port(req.proxy_url) proxy_url=req.proxy_url,
# 检查新地址是否与其他节点冲突 username=req.username,
existing = ( password=req.password,
context.db.query(ProxyNode) region=req.region,
.filter(ProxyNode.ip == host, ProxyNode.port == port, ProxyNode.id != node.id) )
.first()
)
if existing:
raise InvalidRequestException(
f"已存在相同地址的代理节点: {existing.name} ({existing.ip}:{existing.port})"
)
node.ip = host
node.port = port
if req.username is not None:
node.proxy_username = req.username
# password: None=不发送(保留原值), ""=清空, 非空=更新
if req.password is not None:
node.proxy_password = req.password or None
if req.region is not None:
node.region = req.region
node.updated_at = datetime.now(timezone.utc)
context.db.commit()
context.db.refresh(node)
context.add_audit_metadata( context.add_audit_metadata(
action="proxy_node_manual_update", action="proxy_node_manual_update",
proxy_node_id=node.id, proxy_node_id=node.id,
) )
return {"node_id": node.id, "node": _node_to_dict(node)} return {"node_id": node.id, "node": node_to_dict(node)}
@dataclass @dataclass
@@ -631,81 +439,7 @@ class AdminTestProxyNodeAdapter(AdminApiAdapter):
node_id: str = "" node_id: str = ""
async def handle(self, context: ApiRequestContext) -> Any: async def handle(self, context: ApiRequestContext) -> Any:
import time as _time return await ProxyNodeService.test_node(context.db, node_id=self.node_id)
import httpx
node = context.db.query(ProxyNode).filter(ProxyNode.id == self.node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {self.node_id} 不存在", "proxy_node")
# 构建代理 URL
try:
proxy_url = _build_test_proxy_url(node)
except Exception as exc:
return {"success": False, "latency_ms": None, "exit_ip": None, "error": str(exc)}
test_url = "https://1.1.1.1/cdn-cgi/trace"
start = _time.monotonic()
# TLS 代理需要 proxy_ssl_context
from src.clients.http_client import _make_proxy_param
proxy_param = _make_proxy_param(proxy_url)
try:
async with httpx.AsyncClient(
proxy=proxy_param,
timeout=httpx.Timeout(15.0, connect=10.0),
) as client:
response = await client.get(test_url)
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
exit_ip = None
if response.status_code == 200:
for line in response.text.splitlines():
if line.startswith("ip="):
exit_ip = line.split("=", 1)[1].strip()
break
return {
"success": True,
"latency_ms": elapsed_ms,
"exit_ip": exit_ip,
"error": None,
}
except httpx.ProxyError as exc:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": f"代理连接失败: {_sanitize_proxy_error(exc)}",
}
except httpx.ConnectError as exc:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": f"连接失败: {_sanitize_proxy_error(exc)}",
}
except httpx.TimeoutException:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": "连接超时15秒",
}
except Exception as exc:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": _sanitize_proxy_error(exc),
}
@dataclass @dataclass
@@ -716,12 +450,6 @@ class AdminUpdateProxyNodeConfigAdapter(AdminApiAdapter):
node_id: str = "" node_id: str = ""
async def handle(self, context: ApiRequestContext) -> Any: async def handle(self, context: ApiRequestContext) -> Any:
node = context.db.query(ProxyNode).filter(ProxyNode.id == self.node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {self.node_id} 不存在", "proxy_node")
if node.is_manual:
raise InvalidRequestException("手动节点不支持远程配置下发")
payload = context.ensure_json_body() payload = context.ensure_json_body()
try: try:
req = ProxyNodeRemoteConfigRequest.model_validate(payload) req = ProxyNodeRemoteConfigRequest.model_validate(payload)
@@ -729,27 +457,21 @@ class AdminUpdateProxyNodeConfigAdapter(AdminApiAdapter):
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc)) raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
# Build config dict with only the supplied fields # Build config dict with only the supplied fields
config: dict[str, Any] = {} config_updates: dict[str, Any] = {}
if req.node_name is not None:
config_updates["node_name"] = req.node_name
if req.allowed_ports is not None: if req.allowed_ports is not None:
config["allowed_ports"] = req.allowed_ports config_updates["allowed_ports"] = req.allowed_ports
if req.log_level is not None: if req.log_level is not None:
config["log_level"] = req.log_level config_updates["log_level"] = req.log_level
if req.heartbeat_interval is not None: if req.heartbeat_interval is not None:
config["heartbeat_interval"] = req.heartbeat_interval config_updates["heartbeat_interval"] = req.heartbeat_interval
if req.timestamp_tolerance is not None: if req.timestamp_tolerance is not None:
config["timestamp_tolerance"] = req.timestamp_tolerance config_updates["timestamp_tolerance"] = req.timestamp_tolerance
# Merge with existing config (so partial updates are preserved) node = ProxyNodeService.update_node_config(
# Copy to a new dict so SQLAlchemy detects the change on the JSON column context.db, node_id=self.node_id, config_updates=config_updates
existing = dict(node.remote_config) if node.remote_config else {} )
existing.update(config)
node.remote_config = existing
node.config_version = (node.config_version or 0) + 1
node.updated_at = datetime.now(timezone.utc)
context.db.commit()
context.db.refresh(node)
context.add_audit_metadata( context.add_audit_metadata(
action="proxy_node_config_update", action="proxy_node_config_update",
@@ -761,5 +483,5 @@ class AdminUpdateProxyNodeConfigAdapter(AdminApiAdapter):
"node_id": node.id, "node_id": node.id,
"config_version": node.config_version, "config_version": node.config_version,
"remote_config": node.remote_config, "remote_config": node.remote_config,
"node": _node_to_dict(node), "node": node_to_dict(node),
} }

View File

@@ -914,7 +914,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
ctx.selected_base_url = envelope.capture_selected_base_url() if envelope else None ctx.selected_base_url = envelope.capture_selected_base_url() if envelope else None
# 记录代理信息 # 记录代理信息
from src.clients.http_client import get_proxy_label, resolve_proxy_info from src.services.proxy_node.resolver import get_proxy_label, resolve_proxy_info
ctx.proxy_info = resolve_proxy_info(provider.proxy) ctx.proxy_info = resolve_proxy_info(provider.proxy)
proxy_label = get_proxy_label(ctx.proxy_info) proxy_label = get_proxy_label(ctx.proxy_info)
@@ -928,19 +928,23 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# simulate streaming to the client (sync -> stream bridge). # simulate streaming to the client (sync -> stream bridge).
if not upstream_is_stream: if not upstream_is_stream:
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
from src.services.proxy_node.resolver import build_post_kwargs, resolve_delegate_config
request_timeout_sync = provider.request_timeout or config.http_request_timeout request_timeout_sync = provider.request_timeout or config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client( delegate_cfg = resolve_delegate_config(provider.proxy)
proxy_config=provider.proxy, http_client = await HTTPClientPool.get_upstream_client(
delegate_cfg, proxy_config=provider.proxy
) )
try: try:
resp = await http_client.post( _pkw = build_post_kwargs(
url, delegate_cfg,
json=provider_payload, url=url,
headers=provider_headers, headers=provider_headers,
timeout=httpx.Timeout(request_timeout_sync), payload=provider_payload,
timeout=request_timeout_sync,
) )
resp = await http_client.post(**_pkw)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e: except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope: if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e) envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
@@ -971,12 +975,15 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
ctx.provider_request_headers = provider_headers ctx.provider_request_headers = provider_headers
# retry once # retry once
resp = await http_client.post( _pkw = build_post_kwargs(
url, delegate_cfg,
json=provider_payload, url=url,
headers=provider_headers, headers=provider_headers,
timeout=httpx.Timeout(request_timeout_sync), payload=provider_payload,
timeout=request_timeout_sync,
refresh_auth=True,
) )
resp = await http_client.post(**_pkw)
ctx.status_code = resp.status_code ctx.status_code = resp.status_code
ctx.response_headers = dict(resp.headers) ctx.response_headers = dict(resp.headers)
if envelope: if envelope:
@@ -1120,10 +1127,11 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 创建 HTTP 客户端(支持代理配置,从 Provider 读取) # 创建 HTTP 客户端(支持代理配置,从 Provider 读取)
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
from src.services.proxy_node.resolver import build_stream_kwargs, resolve_delegate_config
http_client = HTTPClientPool.create_client_with_proxy( delegate_cfg = resolve_delegate_config(provider.proxy)
proxy_config=provider.proxy, http_client = HTTPClientPool.create_upstream_stream_client(
timeout=timeout_config, delegate_cfg, proxy_config=provider.proxy, timeout=timeout_config
) )
# 用于存储内部函数的结果(必须在函数定义前声明,供 nonlocal 使用) # 用于存储内部函数的结果(必须在函数定义前声明,供 nonlocal 使用)
@@ -1134,9 +1142,18 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
async def _connect_and_prefetch() -> None: async def _connect_and_prefetch() -> None:
"""建立连接并预读首字节(受整体超时控制)""" """建立连接并预读首字节(受整体超时控制)"""
nonlocal byte_iterator, prefetched_chunks, response_ctx nonlocal byte_iterator, prefetched_chunks, response_ctx
response_ctx = http_client.stream( _skw = build_stream_kwargs(
"POST", url, json=provider_payload, headers=provider_headers delegate_cfg,
url=url,
headers=provider_headers,
payload=provider_payload,
timeout=(
provider.request_timeout or config.http_request_timeout
if delegate_cfg
else None
),
) )
response_ctx = http_client.stream(**_skw)
stream_response = await response_ctx.__aenter__() stream_response = await response_ctx.__aenter__()
ctx.status_code = stream_response.status_code ctx.status_code = stream_response.status_code
@@ -1547,7 +1564,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
selected_base_url_cached = envelope.capture_selected_base_url() if envelope else None selected_base_url_cached = envelope.capture_selected_base_url() if envelope else None
# 记录代理信息 # 记录代理信息
from src.clients.http_client import get_proxy_label, resolve_proxy_info from src.services.proxy_node.resolver import get_proxy_label, resolve_proxy_info
sync_proxy_info = resolve_proxy_info(provider.proxy) sync_proxy_info = resolve_proxy_info(provider.proxy)
_proxy_label = get_proxy_label(sync_proxy_info) _proxy_label = get_proxy_label(sync_proxy_info)
@@ -1562,12 +1579,19 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 获取复用的 HTTP 客户端(支持代理配置,从 Provider 读取) # 获取复用的 HTTP 客户端(支持代理配置,从 Provider 读取)
# 注意:使用 get_proxy_client 复用连接池,不再每次创建新客户端 # 注意:使用 get_proxy_client 复用连接池,不再每次创建新客户端
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
from src.services.proxy_node.resolver import (
build_post_kwargs,
build_stream_kwargs,
resolve_delegate_config,
)
# 非流式请求使用 http_request_timeout 作为整体超时 # 非流式请求使用 http_request_timeout 作为整体超时
# 优先使用 Provider 配置,否则使用全局配置 # 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.request_timeout or config.http_request_timeout request_timeout = provider.request_timeout or config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy, delegate_cfg = resolve_delegate_config(provider.proxy)
http_client = await HTTPClientPool.get_upstream_client(
delegate_cfg, proxy_config=provider.proxy
) )
# 注意:不使用 async with因为复用的客户端不应该被关闭 # 注意:不使用 async with因为复用的客户端不应该被关闭
@@ -1575,12 +1599,14 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
resp: httpx.Response | None = None resp: httpx.Response | None = None
if not upstream_is_stream: if not upstream_is_stream:
try: try:
resp = await http_client.post( _pkw = build_post_kwargs(
url, delegate_cfg,
json=provider_payload, url=url,
headers=provider_hdrs, headers=provider_hdrs,
timeout=httpx.Timeout(request_timeout), payload=provider_payload,
timeout=request_timeout,
) )
resp = await http_client.post(**_pkw)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e: except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope: if envelope:
envelope.on_connection_error(base_url=selected_base_url_cached, exc=e) envelope.on_connection_error(base_url=selected_base_url_cached, exc=e)
@@ -1596,13 +1622,14 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
) )
try: try:
async with http_client.stream( _stream_args = build_stream_kwargs(
"POST", delegate_cfg,
url, url=url,
json=provider_payload,
headers=provider_hdrs, headers=provider_hdrs,
timeout=httpx.Timeout(request_timeout), payload=provider_payload,
) as stream_resp: timeout=request_timeout,
)
async with http_client.stream(**_stream_args) as stream_resp:
resp = stream_resp resp = stream_resp
status_code = stream_resp.status_code status_code = stream_resp.status_code

View File

@@ -891,8 +891,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
ctx.selected_base_url = envelope.capture_selected_base_url() if envelope else None ctx.selected_base_url = envelope.capture_selected_base_url() if envelope else None
# 记录代理信息sync-bridge 路径,早于流式路径执行) # 记录代理信息sync-bridge 路径,早于流式路径执行)
from src.clients.http_client import get_proxy_label as _gpl from src.services.proxy_node.resolver import get_proxy_label as _gpl
from src.clients.http_client import resolve_proxy_info as _rpi from src.services.proxy_node.resolver import resolve_proxy_info as _rpi
ctx.proxy_info = _rpi(provider.proxy) ctx.proxy_info = _rpi(provider.proxy)
@@ -900,19 +900,23 @@ class CliMessageHandlerBase(BaseMessageHandler):
# simulate streaming to the client (sync -> stream bridge). # simulate streaming to the client (sync -> stream bridge).
if not upstream_is_stream: if not upstream_is_stream:
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
from src.services.proxy_node.resolver import build_post_kwargs, resolve_delegate_config
request_timeout_sync = provider.request_timeout or config.http_request_timeout request_timeout_sync = provider.request_timeout or config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client( delegate_cfg = resolve_delegate_config(provider.proxy)
proxy_config=provider.proxy, http_client = await HTTPClientPool.get_upstream_client(
delegate_cfg, proxy_config=provider.proxy
) )
try: try:
resp = await http_client.post( _pkw = build_post_kwargs(
url, delegate_cfg,
json=provider_payload, url=url,
headers=provider_headers, headers=provider_headers,
timeout=httpx.Timeout(request_timeout_sync), payload=provider_payload,
timeout=request_timeout_sync,
) )
resp = await http_client.post(**_pkw)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e: except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope: if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e) envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
@@ -943,12 +947,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
ctx.provider_request_headers = provider_headers ctx.provider_request_headers = provider_headers
# retry once # retry once
resp = await http_client.post( _pkw = build_post_kwargs(
url, delegate_cfg,
json=provider_payload, url=url,
headers=provider_headers, headers=provider_headers,
timeout=httpx.Timeout(request_timeout_sync), payload=provider_payload,
timeout=request_timeout_sync,
refresh_auth=True,
) )
resp = await http_client.post(**_pkw)
ctx.status_code = resp.status_code ctx.status_code = resp.status_code
ctx.response_headers = dict(resp.headers) ctx.response_headers = dict(resp.headers)
if envelope: if envelope:
@@ -1091,10 +1098,11 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 创建 HTTP 客户端(支持代理配置,从 Provider 读取) # 创建 HTTP 客户端(支持代理配置,从 Provider 读取)
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
from src.services.proxy_node.resolver import build_stream_kwargs, resolve_delegate_config
http_client = HTTPClientPool.create_client_with_proxy( delegate_cfg = resolve_delegate_config(provider.proxy)
proxy_config=provider.proxy, http_client = HTTPClientPool.create_upstream_stream_client(
timeout=timeout_config, delegate_cfg, proxy_config=provider.proxy, timeout=timeout_config
) )
# 用于存储内部函数的结果(必须在函数定义前声明,供 nonlocal 使用) # 用于存储内部函数的结果(必须在函数定义前声明,供 nonlocal 使用)
@@ -1105,9 +1113,18 @@ class CliMessageHandlerBase(BaseMessageHandler):
async def _connect_and_prefetch() -> None: async def _connect_and_prefetch() -> None:
"""建立连接并预读首字节(受整体超时控制)""" """建立连接并预读首字节(受整体超时控制)"""
nonlocal byte_iterator, prefetched_chunks, response_ctx nonlocal byte_iterator, prefetched_chunks, response_ctx
response_ctx = http_client.stream( _skw = build_stream_kwargs(
"POST", url, json=provider_payload, headers=provider_headers delegate_cfg,
url=url,
headers=provider_headers,
payload=provider_payload,
timeout=(
provider.request_timeout or config.http_request_timeout
if delegate_cfg
else None
),
) )
response_ctx = http_client.stream(**_skw)
stream_response = await response_ctx.__aenter__() stream_response = await response_ctx.__aenter__()
ctx.status_code = stream_response.status_code ctx.status_code = stream_response.status_code
@@ -2962,7 +2979,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
selected_base_url_cached = envelope.capture_selected_base_url() if envelope else None selected_base_url_cached = envelope.capture_selected_base_url() if envelope else None
# 记录代理信息 # 记录代理信息
from src.clients.http_client import get_proxy_label, resolve_proxy_info from src.services.proxy_node.resolver import get_proxy_label, resolve_proxy_info
sync_proxy_info = resolve_proxy_info(provider.proxy) sync_proxy_info = resolve_proxy_info(provider.proxy)
_proxy_label = get_proxy_label(sync_proxy_info) _proxy_label = get_proxy_label(sync_proxy_info)
@@ -2978,12 +2995,19 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 获取复用的 HTTP 客户端(支持代理配置,从 Provider 读取) # 获取复用的 HTTP 客户端(支持代理配置,从 Provider 读取)
# 注意:使用 get_proxy_client 复用连接池,不再每次创建新客户端 # 注意:使用 get_proxy_client 复用连接池,不再每次创建新客户端
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
from src.services.proxy_node.resolver import (
build_post_kwargs,
build_stream_kwargs,
resolve_delegate_config,
)
# 非流式请求使用 http_request_timeout 作为整体超时 # 非流式请求使用 http_request_timeout 作为整体超时
# 优先使用 Provider 配置,否则使用全局配置 # 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.request_timeout or config.http_request_timeout request_timeout = provider.request_timeout or config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy, delegate_cfg = resolve_delegate_config(provider.proxy)
http_client = await HTTPClientPool.get_upstream_client(
delegate_cfg, proxy_config=provider.proxy
) )
# 注意:不使用 async with因为复用的客户端不应该被关闭 # 注意:不使用 async with因为复用的客户端不应该被关闭
@@ -2991,12 +3015,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
resp: httpx.Response | None = None resp: httpx.Response | None = None
if not upstream_is_stream: if not upstream_is_stream:
try: try:
resp = await http_client.post( _pkw = build_post_kwargs(
url, delegate_cfg,
json=provider_payload, url=url,
headers=provider_headers, headers=provider_headers,
timeout=httpx.Timeout(request_timeout), payload=provider_payload,
timeout=request_timeout,
) )
resp = await http_client.post(**_pkw)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e: except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope: if envelope:
envelope.on_connection_error(base_url=selected_base_url_cached, exc=e) envelope.on_connection_error(base_url=selected_base_url_cached, exc=e)
@@ -3013,13 +3039,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
) )
try: try:
async with http_client.stream( _stream_args = build_stream_kwargs(
"POST", delegate_cfg,
url, url=url,
json=provider_payload,
headers=provider_headers, headers=provider_headers,
timeout=httpx.Timeout(request_timeout), payload=provider_payload,
) as stream_resp: timeout=request_timeout,
)
async with http_client.stream(**_stream_args) as stream_resp:
resp = stream_resp resp = stream_resp
status_code = stream_resp.status_code status_code = stream_resp.status_code

View File

@@ -11,398 +11,26 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import hmac
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any from typing import Any
from urllib.parse import quote, urlparse
import httpx import httpx
from src.config import config from src.config import config
from src.core.exceptions import ProxyNodeUnavailableError
from src.core.logger import logger from src.core.logger import logger
from src.services.proxy_node.resolver import (
build_proxy_url,
compute_proxy_cache_key,
get_system_proxy_config,
make_proxy_param,
)
from src.utils.ssl_utils import get_ssl_context from src.utils.ssl_utils import get_ssl_context
# 模块级锁,避免类属性延迟初始化的竞态条件 # 模块级锁,避免类属性延迟初始化的竞态条件
_proxy_clients_lock = asyncio.Lock() _proxy_clients_lock = asyncio.Lock()
_default_client_lock = asyncio.Lock() _default_client_lock = asyncio.Lock()
# ProxyNode 信息缓存(降低高频 DB 查询开销)
_proxy_node_cache: dict[str, tuple[dict[str, Any] | None, float]] = {}
_PROXY_NODE_CACHE_TTL_SECONDS = 60.0
_PROXY_NODE_CACHE_MAX_SIZE = 256
def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
"""
读取 ProxyNode 信息(带内存 TTL 缓存)
Returns:
aether-proxy 节点: {"ip": str, "port": int, "name": str, ...}
手动节点: {"is_manual": True, "name": str, "proxy_url": str, ...}
不存在/非在线: None
"""
now = time.time()
cached = _proxy_node_cache.get(node_id)
if cached:
value, expires_at = cached
if now < expires_at:
return value
# 防止无效 node_id 导致缓存无限膨胀
if len(_proxy_node_cache) >= _PROXY_NODE_CACHE_MAX_SIZE:
_proxy_node_cache.clear()
from src.database import create_session
from src.models.database import ProxyNode, ProxyNodeStatus
db = create_session()
try:
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node or node.status != ProxyNodeStatus.ONLINE:
_proxy_node_cache[node_id] = (None, now + _PROXY_NODE_CACHE_TTL_SECONDS)
return None
if node.is_manual:
value: dict[str, Any] = {
"is_manual": True,
"name": node.name,
"proxy_url": node.proxy_url,
"username": node.proxy_username,
"password": node.proxy_password,
}
else:
value = {
"name": node.name,
"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)
return value
finally:
db.close()
def _build_hmac_proxy_url(ip: str, port: int, node_id: str, *, tls_enabled: bool = False) -> str:
"""
构建带 HMAC BasicAuth 的 httpx proxy URL
格式: http(s)://hmac:{timestamp}.{signature}@{ip}:{port}
signature = HMAC-SHA256(PROXY_HMAC_KEY, "{timestamp}\\n{node_id}") 的 hex
当 tls_enabled=True 时使用 https:// scheme。
"""
if not config.proxy_hmac_key:
logger.error("PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理 (node_id={})", node_id)
raise ProxyNodeUnavailableError(
"PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理", node_id=node_id
)
timestamp = str(int(time.time()))
payload = f"{timestamp}\n{node_id}".encode("utf-8")
signature = hmac.new(
config.proxy_hmac_key.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
host = f"[{ip}]" if ":" in ip else ip
scheme = "https" if tls_enabled else "http"
return f"{scheme}://hmac:{timestamp}.{signature}@{host}:{int(port)}"
# 系统默认代理缓存
_system_proxy_cache: tuple[dict[str, Any] | None, float] | None = None
_SYSTEM_PROXY_CACHE_TTL = 60.0
def invalidate_system_proxy_cache() -> None:
"""手动失效系统代理缓存(在删除节点等操作后调用)"""
global _system_proxy_cache
_system_proxy_cache = None
def get_system_proxy_config() -> dict[str, Any] | None:
"""
获取系统默认代理配置(带 TTL 缓存)
从 system_configs 表中读取 system_proxy_node_id。
返回 {"node_id": "...", "enabled": True} 或 None。
"""
global _system_proxy_cache
now = time.time()
if _system_proxy_cache:
value, expires_at = _system_proxy_cache
if now < expires_at:
return value
from src.database import create_session
from src.services.system.config import SystemConfigService
db = create_session()
try:
node_id = SystemConfigService.get_config(db, "system_proxy_node_id")
if node_id and isinstance(node_id, str) and node_id.strip():
result: dict[str, Any] | None = {"node_id": node_id.strip(), "enabled": True}
else:
result = None
_system_proxy_cache = (result, now + _SYSTEM_PROXY_CACHE_TTL)
return result
except Exception as exc:
logger.warning("获取系统默认代理配置失败: {}", exc)
_system_proxy_cache = (None, now + _SYSTEM_PROXY_CACHE_TTL)
return None
finally:
db.close()
def resolve_ops_proxy(
connector_config: dict[str, Any] | None,
) -> str | httpx.Proxy | None:
"""
从 ops connector.config 中解析代理参数(含系统默认回退)
优先级:
1. connector_config.proxy_node_id新格式
2. connector_config.proxy旧格式 URL 字符串)
3. 系统默认代理节点
Args:
connector_config: connector 的 config 字典
Returns:
httpx 可接受的代理参数str 或 httpx.Proxy或 None
"""
if connector_config:
# 新格式proxy_node_id → 通过 build_proxy_url 解析
node_id = connector_config.get("proxy_node_id")
if isinstance(node_id, str) and node_id.strip():
try:
url = build_proxy_url({"node_id": node_id.strip(), "enabled": True})
return _make_proxy_param(url)
except Exception as exc:
logger.warning("解析 proxy_node_id={} 失败,回退到直连: {}", node_id, exc)
return None
# 旧格式:直接返回 proxy URL 字符串
proxy = connector_config.get("proxy")
if isinstance(proxy, str) and proxy.strip():
return proxy
# 回退:系统默认代理
system_proxy = get_system_proxy_config()
if system_proxy:
try:
url = build_proxy_url(system_proxy)
return _make_proxy_param(url)
except Exception as exc:
logger.warning("构建系统默认代理 URL 失败: {}", exc)
return None
return None
def _compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
"""
计算代理配置的缓存键
Args:
proxy_config: 代理配置字典
Returns:
缓存键字符串,无代理时返回 "__no_proxy__"
"""
if not proxy_config:
return "__no_proxy__"
# enabled=False 时视为无代理(兼容旧数据)
if not proxy_config.get("enabled", True):
return "__no_proxy__"
# ProxyNode 模式:基于 node_id + 时间桶缓存,避免签名随时间变化导致 cache key 爆炸
node_id = proxy_config.get("node_id")
if isinstance(node_id, str) and node_id.strip():
time_bucket = int(time.time() / 120) # 120 秒一个桶
return f"proxy_node:{node_id.strip()}:{time_bucket}"
# 构建代理 URL 作为缓存键的基础
proxy_url = build_proxy_url(proxy_config)
if not proxy_url:
return "__no_proxy__"
# 使用 MD5 哈希来避免过长的键名
return f"proxy:{hashlib.md5(proxy_url.encode()).hexdigest()[:16]}"
def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
"""
根据代理配置构建完整的代理 URL
Args:
proxy_config: 代理配置字典,支持两种模式:
- 手动 URL 模式: {url, username, password, enabled}
- ProxyNode 模式: {node_id, enabled}
Returns:
完整的代理 URL如 socks5://user:pass@host:port
如果 enabled=False 或无配置,返回 None
"""
if not proxy_config:
return None
# 检查 enabled 字段,默认为 True兼容旧数据
if not proxy_config.get("enabled", True):
return None
# ProxyNode 模式aether-proxy 或手动节点)
node_id = proxy_config.get("node_id")
if isinstance(node_id, str) and node_id.strip():
node_id = node_id.strip()
node_info = _get_proxy_node_info(node_id)
if not node_info:
logger.warning("代理节点不可用(离线或不存在): node_id={}", node_id)
raise ProxyNodeUnavailableError(f"代理节点 {node_id} 不可用", node_id=node_id)
# 手动节点:直接使用存储的代理 URL含认证信息
if node_info.get("is_manual"):
manual_url = node_info.get("proxy_url")
if not manual_url:
raise ProxyNodeUnavailableError(
f"手动代理节点 {node_id} 缺少 proxy_url", node_id=node_id
)
username = node_info.get("username")
password = node_info.get("password")
if username:
parsed = urlparse(manual_url)
encoded_username = quote(username, safe="")
encoded_password = quote(password, safe="") if password else ""
# 使用 hostname+port 而非 netloc避免 URL 内嵌凭据导致双重认证
host_part = parsed.hostname or "localhost"
if parsed.port:
host_part = f"{host_part}:{parsed.port}"
if encoded_password:
auth_url = (
f"{parsed.scheme}://{encoded_username}:{encoded_password}@{host_part}"
)
else:
auth_url = f"{parsed.scheme}://{encoded_username}@{host_part}"
if parsed.path:
auth_url += parsed.path
return auth_url
return manual_url
# aether-proxy 节点:使用 HMAC 认证
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")
if not proxy_url:
return None
username = proxy_config.get("username")
password = proxy_config.get("password")
# 只要有用户名就添加认证信息(密码可以为空)
if username:
parsed = urlparse(proxy_url)
# URL 编码用户名和密码,处理特殊字符(如 @, :, /
encoded_username = quote(username, safe="")
encoded_password = quote(password, safe="") if password else ""
# 重新构建带认证的代理 URL
if encoded_password:
auth_proxy = f"{parsed.scheme}://{encoded_username}:{encoded_password}@{parsed.netloc}"
else:
auth_proxy = f"{parsed.scheme}://{encoded_username}@{parsed.netloc}"
if parsed.path:
auth_proxy += parsed.path
return auth_proxy
return proxy_url
def resolve_proxy_info(proxy_config: dict[str, Any] | None) -> dict[str, Any] | None:
"""
解析代理配置的摘要信息(用于日志和 usage 记录)
不构建实际的代理 URL仅返回可读的代理标识信息。
Returns:
{"node_id": "xxx", "node_name": "proxy-01", "source": "provider"} 或
{"url": "socks5://host:port", "source": "provider"} 或
{"node_id": "xxx", "node_name": "...", "source": "system"} 或
None (直连)
"""
source = "provider"
effective_config = proxy_config
# 无 provider 级代理时,尝试系统默认代理
if not effective_config or not effective_config.get("enabled", True):
effective_config = get_system_proxy_config()
source = "system"
if not effective_config or not effective_config.get("enabled", True):
return None
# ProxyNode 模式
node_id = effective_config.get("node_id")
if isinstance(node_id, str) and node_id.strip():
node_id = node_id.strip()
node_info = _get_proxy_node_info(node_id)
node_name = node_info.get("name", "unknown") if node_info else "offline"
return {"node_id": node_id, "node_name": node_name, "source": source}
# 旧格式 URL 模式
proxy_url = effective_config.get("url")
if proxy_url:
# 脱敏:只保留 scheme + host + port
try:
parsed = urlparse(proxy_url)
host_part = parsed.hostname or "unknown"
if parsed.port:
host_part = f"{host_part}:{parsed.port}"
safe_url = f"{parsed.scheme}://{host_part}"
except Exception:
safe_url = "unknown"
return {"url": safe_url, "source": source}
return None
def get_proxy_label(proxy_info: dict[str, Any] | None) -> str:
"""从 proxy_info 中提取简短的代理标签(用于日志)"""
if not proxy_info:
return "direct"
return proxy_info.get("node_name") or proxy_info.get("url") or "unknown"
def _make_proxy_param(proxy_url: str | None) -> str | httpx.Proxy | None:
"""
根据代理 URL 返回 httpx 可接受的 proxy 参数。
对于 https:// scheme 的代理 URLTLS aether-proxy 节点),返回 httpx.Proxy
并附带 proxy_ssl_contextCERT_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:
""" """
@@ -423,6 +51,8 @@ class HTTPClientPool:
_proxy_clients: dict[str, tuple[httpx.AsyncClient, float]] = {} _proxy_clients: dict[str, tuple[httpx.AsyncClient, float]] = {}
# 代理客户端缓存上限(避免内存泄漏) # 代理客户端缓存上限(避免内存泄漏)
_max_proxy_clients: int = 50 _max_proxy_clients: int = 50
# 代发客户端缓存:{tls: client, plain: client}
_delegate_clients: dict[str, httpx.AsyncClient] = {}
def __new__(cls) -> "HTTPClientPool": def __new__(cls) -> "HTTPClientPool":
if cls._instance is None: if cls._instance is None:
@@ -459,10 +89,10 @@ class HTTPClientPool:
follow_redirects=True, # 跟随重定向 follow_redirects=True, # 跟随重定向
) )
logger.info( logger.info(
f"全局HTTP客户端池已初始化: " "全局HTTP客户端池已初始化: max_connections={}, keepalive={}, keepalive_expiry={}s",
f"max_connections={config.http_max_connections}, " config.http_max_connections,
f"keepalive={config.http_keepalive_connections}, " config.http_keepalive_connections,
f"keepalive_expiry={config.http_keepalive_expiry}s" config.http_keepalive_expiry,
) )
return cls._default_client return cls._default_client
@@ -492,10 +122,10 @@ class HTTPClientPool:
follow_redirects=True, # 跟随重定向 follow_redirects=True, # 跟随重定向
) )
logger.info( logger.info(
f"全局HTTP客户端池已初始化: " "全局HTTP客户端池已初始化: max_connections={}, keepalive={}, keepalive_expiry={}s",
f"max_connections={config.http_max_connections}, " config.http_max_connections,
f"keepalive={config.http_keepalive_connections}, " config.http_keepalive_connections,
f"keepalive_expiry={config.http_keepalive_expiry}s" config.http_keepalive_expiry,
) )
return cls._default_client return cls._default_client
@@ -526,7 +156,7 @@ class HTTPClientPool:
default_config.update(kwargs) default_config.update(kwargs)
cls._clients[name] = httpx.AsyncClient(**default_config) # type: ignore[arg-type] cls._clients[name] = httpx.AsyncClient(**default_config) # type: ignore[arg-type]
logger.debug(f"创建命名HTTP客户端: {name}") logger.debug("创建命名HTTP客户端: {}", name)
return cls._clients[name] return cls._clients[name]
@@ -548,9 +178,9 @@ class HTTPClientPool:
# 异步关闭旧客户端 # 异步关闭旧客户端
try: try:
await old_client.aclose() await old_client.aclose()
logger.debug(f"淘汰代理客户端: {oldest_key}") logger.debug("淘汰代理客户端: {}", oldest_key)
except Exception as e: except Exception as e:
logger.warning(f"关闭代理客户端失败: {e}") logger.warning("关闭代理客户端失败: {}", e)
@classmethod @classmethod
async def get_proxy_client( async def get_proxy_client(
@@ -574,7 +204,7 @@ class HTTPClientPool:
if not proxy_config: if not proxy_config:
proxy_config = get_system_proxy_config() proxy_config = get_system_proxy_config()
cache_key = _compute_proxy_cache_key(proxy_config) cache_key = compute_proxy_cache_key(proxy_config)
# 无代理时返回默认客户端 # 无代理时返回默认客户端
if cache_key == "__no_proxy__": if cache_key == "__no_proxy__":
@@ -588,7 +218,7 @@ class HTTPClientPool:
# 健康检查:如果客户端已关闭,移除并重新创建 # 健康检查:如果客户端已关闭,移除并重新创建
if client.is_closed: if client.is_closed:
del cls._proxy_clients[cache_key] del cls._proxy_clients[cache_key]
logger.debug(f"代理客户端已关闭,将重新创建: {cache_key}") logger.debug("代理客户端已关闭,将重新创建: {}", cache_key)
else: else:
# 更新最后使用时间 # 更新最后使用时间
cls._proxy_clients[cache_key] = (client, time.time()) cls._proxy_clients[cache_key] = (client, time.time())
@@ -617,7 +247,7 @@ 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
proxy_param = _make_proxy_param(proxy_url) proxy_param = make_proxy_param(proxy_url)
if proxy_param: if proxy_param:
client_config["proxy"] = proxy_param client_config["proxy"] = proxy_param
@@ -630,7 +260,7 @@ class HTTPClientPool:
proxy_config.get("node_id") or proxy_config.get("url") or "unknown" proxy_config.get("node_id") or proxy_config.get("url") or "unknown"
) )
logger.debug( logger.debug(
f"创建代理客户端(缓存): {proxy_label}, " f"缓存数量: {len(cls._proxy_clients)}" "创建代理客户端(缓存): {}, 缓存数量: {}", proxy_label, len(cls._proxy_clients)
) )
return client return client
@@ -645,7 +275,7 @@ class HTTPClientPool:
for name, client in cls._clients.items(): for name, client in cls._clients.items():
await client.aclose() await client.aclose()
logger.debug(f"命名HTTP客户端已关闭: {name}") logger.debug("命名HTTP客户端已关闭: {}", name)
cls._clients.clear() cls._clients.clear()
@@ -653,11 +283,21 @@ class HTTPClientPool:
for cache_key, (client, _) in cls._proxy_clients.items(): for cache_key, (client, _) in cls._proxy_clients.items():
try: try:
await client.aclose() await client.aclose()
logger.debug(f"代理客户端已关闭: {cache_key}") logger.debug("代理客户端已关闭: {}", cache_key)
except Exception as e: except Exception as e:
logger.warning(f"关闭代理客户端失败: {e}") logger.warning("关闭代理客户端失败: {}", e)
cls._proxy_clients.clear() cls._proxy_clients.clear()
# 关闭代发客户端缓存
for cache_key, client in cls._delegate_clients.items():
try:
await client.aclose()
logger.debug("代发客户端已关闭: {}", cache_key)
except Exception as e:
logger.warning("关闭代发客户端失败: {}", e)
cls._delegate_clients.clear()
logger.info("所有HTTP客户端已关闭") logger.info("所有HTTP客户端已关闭")
@classmethod @classmethod
@@ -732,14 +372,128 @@ 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
proxy_param = _make_proxy_param(proxy_url) proxy_param = make_proxy_param(proxy_url)
if proxy_param: if proxy_param:
client_config["proxy"] = proxy_param client_config["proxy"] = proxy_param
logger.debug(f"创建带代理的HTTP客户端(一次性): {proxy_config.get('url', 'unknown')}") logger.debug("创建带代理的HTTP客户端(一次性): {}", proxy_config.get("url", "unknown"))
client_config.update(kwargs) client_config.update(kwargs)
return httpx.AsyncClient(**client_config) # type: ignore[arg-type] return httpx.AsyncClient(**client_config) # type: ignore[arg-type]
@classmethod
def create_delegate_stream_client(
cls,
delegate_config: dict[str, Any],
timeout: httpx.Timeout | None = None,
) -> httpx.AsyncClient:
"""
创建用于代发流式请求的 httpx 客户端
代发模式下不配置 proxy直接 POST 到 proxy 的 /_aether/delegate 端点。
调用者需要负责关闭返回的客户端。
"""
client_config: dict[str, Any] = {
"http2": False,
"follow_redirects": False,
}
if timeout:
client_config["timeout"] = timeout
else:
client_config["timeout"] = httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout,
write=config.http_write_timeout,
pool=config.http_pool_timeout,
)
if delegate_config.get("tls_enabled"):
from src.utils.ssl_utils import get_proxy_ssl_context
client_config["verify"] = get_proxy_ssl_context()
else:
client_config["verify"] = get_ssl_context()
return httpx.AsyncClient(**client_config)
@classmethod
async def get_delegate_client(
cls,
delegate_config: dict[str, Any],
) -> httpx.AsyncClient:
"""
获取可复用的代发客户端(非流式请求用)
根据 TLS 状态缓存两个客户端tls / plain避免每次请求创建新客户端。
当 tls_enabled=True 时使用 get_proxy_ssl_context()(信任自签名证书)。
"""
cache_key = "tls" if delegate_config.get("tls_enabled") else "plain"
lock = cls._get_proxy_clients_lock()
async with lock:
existing = cls._delegate_clients.get(cache_key)
if existing and not existing.is_closed:
return existing
if cache_key == "tls":
from src.utils.ssl_utils import get_proxy_ssl_context
verify: Any = get_proxy_ssl_context()
else:
verify = get_ssl_context()
client = httpx.AsyncClient(
http2=False,
verify=verify,
follow_redirects=False,
timeout=httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout,
write=config.http_write_timeout,
pool=config.http_pool_timeout,
),
limits=httpx.Limits(
max_connections=config.http_max_connections,
max_keepalive_connections=config.http_keepalive_connections,
keepalive_expiry=config.http_keepalive_expiry,
),
)
cls._delegate_clients[cache_key] = client
logger.debug("创建代发客户端(缓存): {}", cache_key)
return client
@classmethod
async def get_upstream_client(
cls,
delegate_cfg: dict[str, Any] | None,
proxy_config: dict[str, Any] | None = None,
) -> httpx.AsyncClient:
"""
获取可复用的上游请求客户端(自动选择代发或代理模式)
代发模式(delegate_cfg非空):返回代发客户端
直连/代理模式:返回代理客户端(含系统默认代理回退)
"""
if delegate_cfg:
return await cls.get_delegate_client(delegate_cfg)
return await cls.get_proxy_client(proxy_config=proxy_config)
@classmethod
def create_upstream_stream_client(
cls,
delegate_cfg: dict[str, Any] | None,
proxy_config: dict[str, Any] | None = None,
timeout: httpx.Timeout | None = None,
) -> httpx.AsyncClient:
"""
创建上游流式请求客户端(自动选择代发或代理模式)
调用者需负责关闭返回的客户端。
"""
if delegate_cfg:
return cls.create_delegate_stream_client(delegate_cfg, timeout=timeout)
return cls.create_client_with_proxy(proxy_config=proxy_config, timeout=timeout)
@classmethod @classmethod
def get_pool_stats(cls) -> dict[str, Any]: def get_pool_stats(cls) -> dict[str, Any]:
"""获取连接池统计信息""" """获取连接池统计信息"""
@@ -748,6 +502,7 @@ class HTTPClientPool:
"named_clients_count": len(cls._clients), "named_clients_count": len(cls._clients),
"proxy_clients_count": len(cls._proxy_clients), "proxy_clients_count": len(cls._proxy_clients),
"max_proxy_clients": cls._max_proxy_clients, "max_proxy_clients": cls._max_proxy_clients,
"delegate_clients_count": len(cls._delegate_clients),
} }

View File

@@ -7,9 +7,10 @@ from urllib.parse import urlsplit, urlunsplit
import httpx import httpx
import jwt import jwt
from src.clients.http_client import HTTPClientPool, build_proxy_url from src.clients.http_client import HTTPClientPool
from src.core.logger import logger from src.core.logger import logger
from src.core.provider_types import ProviderType from src.core.provider_types import ProviderType
from src.services.proxy_node.resolver import build_proxy_url
_ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token" _ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json" _GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"

View File

@@ -848,6 +848,16 @@ class ProxyNode(Base):
String(128), nullable=True, comment="TLS 证书 SHA-256 指纹hex" String(128), nullable=True, comment="TLS 证书 SHA-256 指纹hex"
) )
# 硬件信息注册时上报JSON 可扩展)
hardware_info = Column(
JSON,
nullable=True,
comment="硬件信息 (cpu_cores, total_memory_mb, os_info, fd_limit, ...)",
)
estimated_max_concurrency = Column(
Integer, nullable=True, comment="基于硬件估算的最大并发连接数"
)
# 管理端远程配置(通过心跳下发给 aether-proxy # 管理端远程配置(通过心跳下发给 aether-proxy
remote_config = Column( remote_config = Column(
JSON, JSON,

View File

@@ -409,7 +409,7 @@ class AnyrouterArchitecture(ProviderArchitecture):
包含 acw_cookie 的配置 包含 acw_cookie 的配置
""" """
# 从 config 获取代理配置(支持 proxy_node_id 和旧的 proxy URL # 从 config 获取代理配置(支持 proxy_node_id 和旧的 proxy URL
from src.clients.http_client import resolve_ops_proxy from src.services.proxy_node.resolver import resolve_ops_proxy
proxy = resolve_ops_proxy(config) proxy = resolve_ops_proxy(config)
acw_cookie = await _get_acw_cookie(base_url, proxy=proxy) acw_cookie = await _get_acw_cookie(base_url, proxy=proxy)

View File

@@ -51,7 +51,7 @@ class ProviderConnector(ABC):
self._last_error: str | None = None self._last_error: str | None = None
# 代理配置(支持 proxy_node_id 和旧的 proxy URL # 代理配置(支持 proxy_node_id 和旧的 proxy URL
from src.clients.http_client import resolve_ops_proxy from src.services.proxy_node.resolver import resolve_ops_proxy
self._proxy: str | httpx.Proxy | None = resolve_ops_proxy(self.config) self._proxy: str | httpx.Proxy | None = resolve_ops_proxy(self.config)

View File

@@ -180,7 +180,7 @@ class NekoCodeArchitecture(ProviderArchitecture):
"timeout": 10, "timeout": 10,
"verify": get_ssl_context(), "verify": get_ssl_context(),
} }
from src.clients.http_client import resolve_ops_proxy from src.services.proxy_node.resolver import resolve_ops_proxy
proxy = resolve_ops_proxy(config) proxy = resolve_ops_proxy(config)
if proxy: if proxy:

View File

@@ -194,7 +194,7 @@ class YesCodeArchitecture(ProviderArchitecture):
cookie_header = _build_cookie_header(cookie_input) cookie_header = _build_cookie_header(cookie_input)
# 获取代理配置(支持 proxy_node_id 和旧的 proxy URL # 获取代理配置(支持 proxy_node_id 和旧的 proxy URL
from src.clients.http_client import resolve_ops_proxy from src.services.proxy_node.resolver import resolve_ops_proxy
proxy = resolve_ops_proxy(config) proxy = resolve_ops_proxy(config)

View File

@@ -917,7 +917,7 @@ class ProviderOpsService:
) )
# 获取代理配置(支持 proxy_node_id 和旧的 proxy URL # 获取代理配置(支持 proxy_node_id 和旧的 proxy URL
from src.clients.http_client import resolve_ops_proxy from src.services.proxy_node.resolver import resolve_ops_proxy
proxy = resolve_ops_proxy(config) proxy = resolve_ops_proxy(config)

View File

@@ -1,5 +1,43 @@
"""Proxy node services.""" """代理节点服务"""
from .health_scheduler import ProxyNodeHealthScheduler, get_proxy_node_health_scheduler from .health_scheduler import ProxyNodeHealthScheduler, get_proxy_node_health_scheduler
from .resolver import (
build_delegate_post_kwargs,
build_delegate_stream_kwargs,
build_hmac_proxy_url,
build_post_kwargs,
build_proxy_url,
build_stream_kwargs,
compute_proxy_cache_key,
get_proxy_label,
get_system_proxy_config,
inject_auth_into_proxy_url,
invalidate_system_proxy_cache,
make_proxy_param,
resolve_delegate_config,
resolve_ops_proxy,
resolve_proxy_info,
)
from .service import ProxyNodeService, node_to_dict
__all__ = ["ProxyNodeHealthScheduler", "get_proxy_node_health_scheduler"] __all__ = [
"ProxyNodeHealthScheduler",
"get_proxy_node_health_scheduler",
"ProxyNodeService",
"node_to_dict",
"build_delegate_post_kwargs",
"build_delegate_stream_kwargs",
"build_hmac_proxy_url",
"build_post_kwargs",
"build_proxy_url",
"build_stream_kwargs",
"compute_proxy_cache_key",
"inject_auth_into_proxy_url",
"make_proxy_param",
"get_proxy_label",
"get_system_proxy_config",
"invalidate_system_proxy_cache",
"resolve_delegate_config",
"resolve_ops_proxy",
"resolve_proxy_info",
]

View File

@@ -0,0 +1,673 @@
"""
代理解析服务
集中管理代理 URL 构建、节点信息缓存、系统默认代理回退、代理信息追踪等逻辑。
供 HTTPClientPool、Handler、Provider Ops 等模块调用。
"""
from __future__ import annotations
import base64
import hashlib
import hmac as _hmac
import time
from typing import Any
from urllib.parse import quote, urlparse
import httpx
from src.config import config
from src.core.exceptions import ProxyNodeUnavailableError
from src.core.logger import logger
# ---------------------------------------------------------------------------
# ProxyNode 信息缓存(降低高频 DB 查询开销)
# ---------------------------------------------------------------------------
_proxy_node_cache: dict[str, tuple[dict[str, Any] | None, float]] = {}
_PROXY_NODE_CACHE_TTL_SECONDS = 60.0
_PROXY_NODE_CACHE_MAX_SIZE = 256
def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
"""
读取 ProxyNode 信息(带内存 TTL 缓存)
NOTE: 使用同步 DB sessioncreate_session在 async 上下文中会短暂阻塞
事件循环。60s TTL 缓存覆盖绝大多数请求,阻塞仅发生在缓存未命中时。
若后续 delegate 模式导致调用频率显著上升,应考虑改为 run_in_executor 包装。
Returns:
aether-proxy 节点: {"ip": str, "port": int, "name": str, ...}
手动节点: {"is_manual": True, "name": str, "proxy_url": str, ...}
不存在/非在线: None
"""
now = time.time()
cached = _proxy_node_cache.get(node_id)
if cached:
value, expires_at = cached
if now < expires_at:
return value
# 防止无效 node_id 导致缓存无限膨胀:淘汰最旧的条目而非全部清除
if len(_proxy_node_cache) >= _PROXY_NODE_CACHE_MAX_SIZE:
# 按过期时间排序,删除最旧的 25%
evict_count = _PROXY_NODE_CACHE_MAX_SIZE // 4
sorted_keys = sorted(_proxy_node_cache, key=lambda k: _proxy_node_cache[k][1])
for k in sorted_keys[:evict_count]:
del _proxy_node_cache[k]
from src.database import create_session
from src.models.database import ProxyNode, ProxyNodeStatus
db = create_session()
try:
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node or node.status != ProxyNodeStatus.ONLINE:
_proxy_node_cache[node_id] = (None, now + _PROXY_NODE_CACHE_TTL_SECONDS)
return None
if node.is_manual:
value: dict[str, Any] = {
"is_manual": True,
"name": node.name,
"proxy_url": node.proxy_url,
"username": node.proxy_username,
"password": node.proxy_password,
}
else:
value = {
"name": node.name,
"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)
return value
finally:
db.close()
# ---------------------------------------------------------------------------
# HMAC 签名
# ---------------------------------------------------------------------------
def build_hmac_proxy_url(ip: str, port: int, node_id: str, *, tls_enabled: bool = False) -> str:
"""
构建带 HMAC BasicAuth 的 httpx proxy URL
格式: http(s)://hmac:{timestamp}.{signature}@{ip}:{port}
signature = HMAC-SHA256(PROXY_HMAC_KEY, "{timestamp}\\n{node_id}") 的 hex
当 tls_enabled=True 时使用 https:// scheme。
"""
if not config.proxy_hmac_key:
logger.error("PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理 (node_id={})", node_id)
raise ProxyNodeUnavailableError(
"PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理", node_id=node_id
)
timestamp = str(int(time.time()))
payload = f"{timestamp}\n{node_id}".encode("utf-8")
signature = _hmac.new(
config.proxy_hmac_key.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
host = f"[{ip}]" if ":" in ip else ip
scheme = "https" if tls_enabled else "http"
return f"{scheme}://hmac:{timestamp}.{signature}@{host}:{int(port)}"
# ---------------------------------------------------------------------------
# 系统默认代理
# ---------------------------------------------------------------------------
_system_proxy_cache: tuple[dict[str, Any] | None, float] | None = None
_SYSTEM_PROXY_CACHE_TTL = 60.0
def invalidate_system_proxy_cache() -> None:
"""手动失效系统代理缓存(在删除节点等操作后调用)"""
global _system_proxy_cache
_system_proxy_cache = None
def get_system_proxy_config() -> dict[str, Any] | None:
"""
获取系统默认代理配置(带 TTL 缓存)
从 system_configs 表中读取 system_proxy_node_id。
返回 {"node_id": "...", "enabled": True} 或 None。
"""
global _system_proxy_cache
now = time.time()
if _system_proxy_cache:
value, expires_at = _system_proxy_cache
if now < expires_at:
return value
from src.database import create_session
from src.services.system.config import SystemConfigService
db = create_session()
try:
node_id = SystemConfigService.get_config(db, "system_proxy_node_id")
if node_id and isinstance(node_id, str) and node_id.strip():
result: dict[str, Any] | None = {"node_id": node_id.strip(), "enabled": True}
else:
result = None
_system_proxy_cache = (result, now + _SYSTEM_PROXY_CACHE_TTL)
return result
except Exception as exc:
logger.warning("获取系统默认代理配置失败: {}", exc)
_system_proxy_cache = (None, now + _SYSTEM_PROXY_CACHE_TTL)
return None
finally:
db.close()
# ---------------------------------------------------------------------------
# 代理 URL 认证注入
# ---------------------------------------------------------------------------
def inject_auth_into_proxy_url(proxy_url: str, username: str, password: str | None = None) -> str:
"""将用户名密码注入代理 URLURL 编码处理特殊字符)"""
parsed = urlparse(proxy_url)
encoded_username = quote(username, safe="")
encoded_password = quote(password, safe="") if password else ""
host_part = parsed.hostname or "localhost"
if parsed.port:
host_part = f"{host_part}:{parsed.port}"
if encoded_password:
auth_url = f"{parsed.scheme}://{encoded_username}:{encoded_password}@{host_part}"
else:
auth_url = f"{parsed.scheme}://{encoded_username}@{host_part}"
if parsed.path:
auth_url += parsed.path
return auth_url
# ---------------------------------------------------------------------------
# TLS 代理参数
# ---------------------------------------------------------------------------
def make_proxy_param(proxy_url: str | None) -> str | httpx.Proxy | None:
"""
根据代理 URL 返回 httpx 可接受的 proxy 参数。
对于 https:// scheme 的代理 URLTLS aether-proxy 节点),返回 httpx.Proxy
并附带 proxy_ssl_contextCERT_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
# ---------------------------------------------------------------------------
# Ops connector 代理解析
# ---------------------------------------------------------------------------
def resolve_ops_proxy(
connector_config: dict[str, Any] | None,
) -> str | httpx.Proxy | None:
"""
从 ops connector.config 中解析代理参数(含系统默认回退)
优先级:
1. connector_config.proxy_node_id新格式
2. connector_config.proxy旧格式 URL 字符串)
3. 系统默认代理节点
Args:
connector_config: connector 的 config 字典
Returns:
httpx 可接受的代理参数str 或 httpx.Proxy或 None
"""
if connector_config:
# 新格式proxy_node_id -> 通过 build_proxy_url 解析
node_id = connector_config.get("proxy_node_id")
if isinstance(node_id, str) and node_id.strip():
try:
url = build_proxy_url({"node_id": node_id.strip(), "enabled": True})
return make_proxy_param(url)
except Exception as exc:
logger.warning("解析 proxy_node_id={} 失败,回退到直连: {}", node_id, exc)
return None
# 旧格式:直接返回 proxy URL 字符串
proxy = connector_config.get("proxy")
if isinstance(proxy, str) and proxy.strip():
return proxy
# 回退:系统默认代理
system_proxy = get_system_proxy_config()
if system_proxy:
try:
url = build_proxy_url(system_proxy)
return make_proxy_param(url)
except Exception as exc:
logger.warning("构建系统默认代理 URL 失败: {}", exc)
return None
return None
# ---------------------------------------------------------------------------
# 代理 URL 构建
# ---------------------------------------------------------------------------
def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
"""
根据代理配置构建完整的代理 URL
Args:
proxy_config: 代理配置字典,支持两种模式:
- 手动 URL 模式: {url, username, password, enabled}
- ProxyNode 模式: {node_id, enabled}
Returns:
完整的代理 URL如 socks5://user:pass@host:port
如果 enabled=False 或无配置,返回 None
"""
if not proxy_config:
return None
# 检查 enabled 字段,默认为 True兼容旧数据
if not proxy_config.get("enabled", True):
return None
# ProxyNode 模式aether-proxy 或手动节点)
node_id = proxy_config.get("node_id")
if isinstance(node_id, str) and node_id.strip():
node_id = node_id.strip()
node_info = _get_proxy_node_info(node_id)
if not node_info:
logger.warning("代理节点不可用(离线或不存在): node_id={}", node_id)
raise ProxyNodeUnavailableError(f"代理节点 {node_id} 不可用", node_id=node_id)
# 手动节点:直接使用存储的代理 URL含认证信息
if node_info.get("is_manual"):
manual_url = node_info.get("proxy_url")
if not manual_url:
raise ProxyNodeUnavailableError(
f"手动代理节点 {node_id} 缺少 proxy_url", node_id=node_id
)
username = node_info.get("username")
password = node_info.get("password")
if username:
return inject_auth_into_proxy_url(manual_url, username, password)
return manual_url
# aether-proxy 节点:使用 HMAC 认证
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")
if not proxy_url:
return None
username = proxy_config.get("username")
password = proxy_config.get("password")
# 只要有用户名就添加认证信息(密码可以为空)
if username:
return inject_auth_into_proxy_url(proxy_url, username, password)
return proxy_url
# ---------------------------------------------------------------------------
# 代理信息追踪(日志/usage
# ---------------------------------------------------------------------------
def resolve_proxy_info(proxy_config: dict[str, Any] | None) -> dict[str, Any] | None:
"""
解析代理配置的摘要信息(用于日志和 usage 记录)
不构建实际的代理 URL仅返回可读的代理标识信息。
Returns:
{"node_id": "xxx", "node_name": "proxy-01", "source": "provider"} 或
{"url": "socks5://host:port", "source": "provider"} 或
{"node_id": "xxx", "node_name": "...", "source": "system"} 或
None (直连)
"""
source = "provider"
effective_config = proxy_config
# 无 provider 级代理时,尝试系统默认代理
if not effective_config or not effective_config.get("enabled", True):
effective_config = get_system_proxy_config()
source = "system"
if not effective_config or not effective_config.get("enabled", True):
return None
# ProxyNode 模式
node_id = effective_config.get("node_id")
if isinstance(node_id, str) and node_id.strip():
node_id = node_id.strip()
node_info = _get_proxy_node_info(node_id)
node_name = node_info.get("name", "unknown") if node_info else "offline"
return {"node_id": node_id, "node_name": node_name, "source": source}
# 旧格式 URL 模式
proxy_url = effective_config.get("url")
if proxy_url:
# 脱敏:只保留 scheme + host + port
try:
parsed = urlparse(proxy_url)
host_part = parsed.hostname or "unknown"
if parsed.port:
host_part = f"{host_part}:{parsed.port}"
safe_url = f"{parsed.scheme}://{host_part}"
except Exception:
safe_url = "unknown"
return {"url": safe_url, "source": source}
return None
def get_proxy_label(proxy_info: dict[str, Any] | None) -> str:
"""从 proxy_info 中提取简短的代理标签(用于日志)"""
if not proxy_info:
return "direct"
return proxy_info.get("node_name") or proxy_info.get("url") or "unknown"
# ---------------------------------------------------------------------------
# 代理缓存键计算(供 HTTPClientPool 使用)
# ---------------------------------------------------------------------------
def compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
"""
计算代理配置的缓存键
Args:
proxy_config: 代理配置字典
Returns:
缓存键字符串,无代理时返回 "__no_proxy__"
"""
if not proxy_config:
return "__no_proxy__"
# enabled=False 时视为无代理(兼容旧数据)
if not proxy_config.get("enabled", True):
return "__no_proxy__"
# ProxyNode 模式:基于 node_id + 时间桶缓存,避免签名随时间变化导致 cache key 爆炸
node_id = proxy_config.get("node_id")
if isinstance(node_id, str) and node_id.strip():
time_bucket = int(time.time() / 120) # 120 秒一个桶
return f"proxy_node:{node_id.strip()}:{time_bucket}"
# 构建代理 URL 作为缓存键的基础
proxy_url = build_proxy_url(proxy_config)
if not proxy_url:
return "__no_proxy__"
# 使用 MD5 哈希来避免过长的键名
return f"proxy:{hashlib.md5(proxy_url.encode()).hexdigest()[:16]}"
# ---------------------------------------------------------------------------
# 代发模式 (Delegate API)
# ---------------------------------------------------------------------------
def _build_hmac_auth_header(node_id: str) -> str:
"""
构建代发请求的 Authorization 头
格式: Basic base64(hmac:{timestamp}.{signature})
签名算法与 build_hmac_proxy_url 相同。
"""
if not config.proxy_hmac_key:
raise ProxyNodeUnavailableError("PROXY_HMAC_KEY 未配置,无法使用代发模式", node_id=node_id)
timestamp = str(int(time.time()))
payload = f"{timestamp}\n{node_id}".encode("utf-8")
signature = _hmac.new(
config.proxy_hmac_key.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
cred = f"hmac:{timestamp}.{signature}"
encoded = base64.b64encode(cred.encode()).decode()
return f"Basic {encoded}"
def resolve_delegate_config(proxy_config: dict[str, Any] | None) -> dict[str, Any] | None:
"""
解析代发配置(仅 aether-proxy 节点支持,手动节点/旧格式 URL 不支持)
无特定代理时自动回退到系统默认代理。
auth_header 延迟生成:通过 ``fresh_auth_header()`` 闭包在每次请求 / 重试时
获取新鲜的 HMAC 签名,避免长生命周期内时间戳过期。
Returns:
{"delegate_url": str, "node_id": str, "tls_enabled": bool,
"auth_header": str, # 首次生成的签名(兼容旧调用)
"fresh_auth_header": Callable} # 延迟生成签名的闭包
或 None
"""
effective_config = proxy_config
if not effective_config or not effective_config.get("enabled", True):
effective_config = get_system_proxy_config()
if not effective_config or not effective_config.get("enabled", True):
return None
node_id = effective_config.get("node_id")
if not isinstance(node_id, str) or not node_id.strip():
return None # 旧格式 URL 模式不支持代发
node_id = node_id.strip()
node_info = _get_proxy_node_info(node_id)
if not node_info or node_info.get("is_manual"):
return None # 手动节点不支持代发
tls_enabled = node_info.get("tls_enabled", False)
host = f"[{node_info['ip']}]" if ":" in node_info["ip"] else node_info["ip"]
scheme = "https" if tls_enabled else "http"
delegate_url = f"{scheme}://{host}:{int(node_info['port'])}/_aether/delegate"
# 闭包捕获 node_id每次调用生成新鲜签名
def _fresh() -> str:
return _build_hmac_auth_header(node_id)
return {
"delegate_url": delegate_url,
"auth_header": _fresh(), # 立即生成一份,兼容旧调用方
"fresh_auth_header": _fresh,
"node_id": node_id,
"tls_enabled": tls_enabled,
}
# ---------------------------------------------------------------------------
# 代发请求参数构建(消除 handler 层重复代码)
# ---------------------------------------------------------------------------
_JSON_CT = "application/json"
def _build_delegate_kwargs_core(
delegate_cfg: dict[str, Any],
*,
url: str,
headers: dict[str, str],
payload: Any,
timeout: float,
refresh_auth: bool = False,
) -> dict[str, Any]:
"""
构建代发请求的核心参数post/stream 共用)
Args:
delegate_cfg: resolve_delegate_config 返回的配置
url: 上游实际 URL
headers: 上游请求头
payload: 上游 JSON body可以为 None
timeout: 上游超时秒数
refresh_auth: 为 True 时重新生成 HMAC 签名(用于 retry
"""
import json as _json
auth = (
delegate_cfg["fresh_auth_header"]()
if refresh_auth
else delegate_cfg.get("auth_header") or delegate_cfg["fresh_auth_header"]()
)
return {
"url": delegate_cfg["delegate_url"],
"json": {
"method": "POST",
"url": url,
"headers": headers,
"body": _json.dumps(payload, ensure_ascii=False) if payload is not None else None,
"timeout": int(timeout),
},
"headers": {"Authorization": auth, "Content-Type": _JSON_CT},
"timeout": httpx.Timeout(timeout + 10),
}
def build_delegate_post_kwargs(
delegate_cfg: dict[str, Any],
*,
url: str,
headers: dict[str, str],
payload: Any,
timeout: float,
refresh_auth: bool = False,
) -> dict[str, Any]:
"""构建代发 POST 请求的 httpx kwargs非流式传给 client.post"""
return _build_delegate_kwargs_core(
delegate_cfg,
url=url,
headers=headers,
payload=payload,
timeout=timeout,
refresh_auth=refresh_auth,
)
def build_delegate_stream_kwargs(
delegate_cfg: dict[str, Any],
*,
url: str,
headers: dict[str, str],
payload: Any,
timeout: float,
refresh_auth: bool = False,
) -> dict[str, Any]:
"""构建代发 stream 请求的 httpx kwargs传给 client.stream"""
kwargs = _build_delegate_kwargs_core(
delegate_cfg,
url=url,
headers=headers,
payload=payload,
timeout=timeout,
refresh_auth=refresh_auth,
)
# stream() 需要显式 method 参数
kwargs["method"] = "POST"
return kwargs
# ---------------------------------------------------------------------------
# 统一上游请求参数构建(消除 handler 层 delegate/直连 分支重复)
# ---------------------------------------------------------------------------
def build_post_kwargs(
delegate_cfg: dict[str, Any] | None,
*,
url: str,
headers: dict[str, str],
payload: Any,
timeout: float,
refresh_auth: bool = False,
) -> dict[str, Any]:
"""
构建上游 POST 请求的 httpx kwargs自动选择代发或直连模式
返回的 dict 可直接传给 ``http_client.post(**kwargs)``。
"""
if delegate_cfg:
return build_delegate_post_kwargs(
delegate_cfg,
url=url,
headers=headers,
payload=payload,
timeout=timeout,
refresh_auth=refresh_auth,
)
return {
"url": url,
"json": payload,
"headers": headers,
"timeout": httpx.Timeout(timeout),
}
def build_stream_kwargs(
delegate_cfg: dict[str, Any] | None,
*,
url: str,
headers: dict[str, str],
payload: Any,
timeout: float | None = None,
) -> dict[str, Any]:
"""
构建上游 stream 请求的 httpx kwargs自动选择代发或直连模式
返回的 dict 可直接传给 ``http_client.stream(**kwargs)``。
当 ``timeout`` 为 None直连模式下由外层 asyncio.wait_for 控制超时),
直连分支不设置 timeout代发分支始终携带 timeoutproxy 协议需要)。
"""
if delegate_cfg:
return build_delegate_stream_kwargs(
delegate_cfg,
url=url,
headers=headers,
payload=payload,
timeout=timeout or 60,
)
kwargs: dict[str, Any] = {
"method": "POST",
"url": url,
"json": payload,
"headers": headers,
}
if timeout is not None:
kwargs["timeout"] = httpx.Timeout(timeout)
return kwargs

View File

@@ -0,0 +1,477 @@
"""
代理节点 CRUD 服务
提供 ProxyNode 的注册、心跳、注销、手动节点管理、连通性测试、远程配置等业务逻辑。
路由层routes.py通过此 service 操作数据库,不再直接编写 DB 查询。
"""
from __future__ import annotations
import re
import uuid
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
import httpx
from sqlalchemy.orm import Session
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.models.database import ProxyNode, ProxyNodeStatus, SystemConfig
from .resolver import (
build_hmac_proxy_url,
inject_auth_into_proxy_url,
invalidate_system_proxy_cache,
make_proxy_param,
)
# ---------------------------------------------------------------------------
# 辅助函数
# ---------------------------------------------------------------------------
def _mask_password(password: str | None) -> str | None:
"""脱敏密码仅显示前2位和后2位长度不足 8 时全部遮蔽)"""
if not password:
return None
if len(password) < 8:
return "****"
return password[:2] + "****" + password[-2:]
def node_to_dict(node: ProxyNode) -> dict[str, Any]:
"""将 ProxyNode 实例序列化为字典(供 API 响应使用)"""
d = {
"id": node.id,
"name": node.name,
"ip": node.ip,
"port": node.port,
"region": node.region,
"status": node.status.value if node.status else None,
"is_manual": bool(node.is_manual),
"registered_by": node.registered_by,
"last_heartbeat_at": node.last_heartbeat_at,
"heartbeat_interval": node.heartbeat_interval,
"active_connections": node.active_connections,
"total_requests": node.total_requests,
"avg_latency_ms": node.avg_latency_ms,
"tls_enabled": bool(node.tls_enabled),
"tls_cert_fingerprint": node.tls_cert_fingerprint,
"hardware_info": node.hardware_info,
"estimated_max_concurrency": node.estimated_max_concurrency,
"remote_config": node.remote_config,
"config_version": node.config_version,
"created_at": node.created_at,
"updated_at": node.updated_at,
}
# 手动节点附带代理配置(密码脱敏)
if node.is_manual:
d["proxy_url"] = node.proxy_url
d["proxy_username"] = node.proxy_username
d["proxy_password"] = _mask_password(node.proxy_password)
return d
def _parse_host_port(proxy_url: str) -> tuple[str, int]:
"""从代理 URL 中解析 host 和 port含协议前缀避免唯一约束冲突"""
parsed = urlparse(proxy_url)
host = parsed.hostname or "manual"
default_ports = {"https": 443, "socks5": 1080}
port = parsed.port or default_ports.get((parsed.scheme or "").lower(), 80)
# 添加协议前缀区分同 host:port 不同协议的场景
scheme = (parsed.scheme or "http").lower()
if scheme != "http":
host = f"{scheme}://{host}"
return host, port
def _sanitize_proxy_error(err: Exception) -> str:
"""去除异常消息中可能包含的代理 URL 凭据(如 HMAC 签名)"""
return re.sub(r"://[^@/]+@", "://***@", str(err))
def _build_test_proxy_url(node: ProxyNode) -> str:
"""为测试连通性构建代理 URL无需节点在线"""
if node.is_manual:
proxy_url = node.proxy_url
if not proxy_url:
raise InvalidRequestException("手动节点缺少 proxy_url")
if node.proxy_username:
proxy_url = inject_auth_into_proxy_url(
proxy_url, node.proxy_username, node.proxy_password
)
return proxy_url
else:
# aether-proxy: 使用 HMAC 认证构建代理 URL
return build_hmac_proxy_url(node.ip, node.port, node.id, tls_enabled=bool(node.tls_enabled))
# ---------------------------------------------------------------------------
# ProxyNodeService
# ---------------------------------------------------------------------------
class ProxyNodeService:
"""代理节点 CRUD 服务"""
@staticmethod
def register_node(
db: Session,
*,
name: str,
ip: str,
port: int,
region: str | None = None,
heartbeat_interval: int = 30,
tls_enabled: bool = False,
tls_cert_fingerprint: str | None = None,
hardware_info: dict[str, Any] | None = None,
estimated_max_concurrency: int | None = None,
active_connections: int | None = None,
total_requests: int | None = None,
avg_latency_ms: float | None = None,
registered_by: str | None = None,
) -> ProxyNode:
"""注册或更新 aether-proxy 节点(按 ip+port upsert"""
now = datetime.now(timezone.utc)
node = db.query(ProxyNode).filter(ProxyNode.ip == ip, ProxyNode.port == port).first()
if node:
node.name = name
node.region = region
node.status = ProxyNodeStatus.ONLINE
node.last_heartbeat_at = now
node.heartbeat_interval = heartbeat_interval
node.tls_enabled = tls_enabled
node.tls_cert_fingerprint = tls_cert_fingerprint
if hardware_info is not None:
node.hardware_info = hardware_info
if estimated_max_concurrency is not None:
node.estimated_max_concurrency = estimated_max_concurrency
if active_connections is not None:
node.active_connections = active_connections
if total_requests is not None:
node.total_requests = total_requests
if avg_latency_ms is not None:
node.avg_latency_ms = avg_latency_ms
else:
node = ProxyNode(
id=str(uuid.uuid4()),
name=name,
ip=ip,
port=port,
region=region,
status=ProxyNodeStatus.ONLINE,
registered_by=registered_by,
last_heartbeat_at=now,
heartbeat_interval=heartbeat_interval,
active_connections=active_connections or 0,
total_requests=total_requests or 0,
avg_latency_ms=avg_latency_ms,
tls_enabled=tls_enabled,
tls_cert_fingerprint=tls_cert_fingerprint,
hardware_info=hardware_info,
estimated_max_concurrency=estimated_max_concurrency,
created_at=now,
updated_at=now,
)
db.add(node)
db.commit()
db.refresh(node)
return node
@staticmethod
def heartbeat(
db: Session,
*,
node_id: str,
heartbeat_interval: int | None = None,
active_connections: int | None = None,
total_requests: int | None = None,
avg_latency_ms: float | None = None,
) -> ProxyNode:
"""处理节点心跳"""
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {node_id} 不存在", "proxy_node")
now = datetime.now(timezone.utc)
node.status = ProxyNodeStatus.ONLINE
node.last_heartbeat_at = now
if heartbeat_interval is not None:
node.heartbeat_interval = heartbeat_interval
if active_connections is not None:
node.active_connections = active_connections
if total_requests is not None:
node.total_requests = total_requests
if avg_latency_ms is not None:
node.avg_latency_ms = avg_latency_ms
db.commit()
db.refresh(node)
return node
@staticmethod
def unregister_node(db: Session, *, node_id: str) -> ProxyNode:
"""注销节点(设置为 OFFLINE"""
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {node_id} 不存在", "proxy_node")
node.status = ProxyNodeStatus.OFFLINE
node.updated_at = datetime.now(timezone.utc)
db.commit()
return node
@staticmethod
def list_nodes(
db: Session,
*,
status: str | None = None,
skip: int = 0,
limit: int = 100,
) -> tuple[list[ProxyNode], int]:
"""列出代理节点(支持按状态筛选和分页)"""
query = db.query(ProxyNode)
if status:
normalized = status.strip().lower()
allowed = {"online", "unhealthy", "offline"}
if normalized not in allowed:
raise InvalidRequestException(f"status 必须是以下之一: {sorted(allowed)}", "status")
query = query.filter(ProxyNode.status == ProxyNodeStatus(normalized))
total = query.count()
nodes = query.order_by(ProxyNode.updated_at.desc()).offset(skip).limit(limit).all()
return nodes, total
@staticmethod
def create_manual_node(
db: Session,
*,
name: str,
proxy_url: str,
username: str | None = None,
password: str | None = None,
region: str | None = None,
registered_by: str | None = None,
) -> ProxyNode:
"""创建手动代理节点"""
host, port = _parse_host_port(proxy_url)
now = datetime.now(timezone.utc)
# 检查是否已存在同地址的节点
existing = db.query(ProxyNode).filter(ProxyNode.ip == host, ProxyNode.port == port).first()
if existing:
raise InvalidRequestException(
f"已存在相同地址的代理节点: {existing.name} ({existing.ip}:{existing.port})"
)
node = ProxyNode(
id=str(uuid.uuid4()),
name=name,
ip=host,
port=port,
region=region,
is_manual=True,
proxy_url=proxy_url,
proxy_username=username,
proxy_password=password,
status=ProxyNodeStatus.ONLINE,
registered_by=registered_by,
last_heartbeat_at=None,
heartbeat_interval=0,
active_connections=0,
total_requests=0,
avg_latency_ms=None,
created_at=now,
updated_at=now,
)
db.add(node)
db.commit()
db.refresh(node)
return node
@staticmethod
def update_manual_node(
db: Session,
*,
node_id: str,
name: str | None = None,
proxy_url: str | None = None,
username: str | None = None,
password: str | None = None,
region: str | None = None,
) -> ProxyNode:
"""更新手动代理节点"""
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {node_id} 不存在", "proxy_node")
if not node.is_manual:
raise InvalidRequestException("只能编辑手动添加的代理节点")
if name is not None:
node.name = name
if proxy_url is not None:
host, port = _parse_host_port(proxy_url)
# 检查新地址是否与其他节点冲突
existing = (
db.query(ProxyNode)
.filter(ProxyNode.ip == host, ProxyNode.port == port, ProxyNode.id != node.id)
.first()
)
if existing:
raise InvalidRequestException(
f"已存在相同地址的代理节点: {existing.name} ({existing.ip}:{existing.port})"
)
node.proxy_url = proxy_url
node.ip = host
node.port = port
if username is not None:
node.proxy_username = username
# password: None=不发送(保留原值), ""=清空, 非空=更新
if password is not None:
node.proxy_password = password or None
if region is not None:
node.region = region
node.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(node)
return node
@staticmethod
def delete_node(db: Session, *, node_id: str) -> dict[str, Any]:
"""
删除代理节点
若该节点是系统默认代理,自动清除引用。
返回 {"node_id": ..., "cleared_system_proxy": bool}
"""
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {node_id} 不存在", "proxy_node")
# 若该节点是系统默认代理,自动清除引用
was_system_proxy = False
sys_cfg = db.query(SystemConfig).filter(SystemConfig.key == "system_proxy_node_id").first()
if sys_cfg and sys_cfg.value == node_id:
sys_cfg.value = None
was_system_proxy = True
node_info = {"proxy_node_ip": node.ip, "proxy_node_port": node.port}
db.delete(node)
db.commit()
if was_system_proxy:
invalidate_system_proxy_cache()
return {
"node_id": node_id,
"node_info": node_info,
"cleared_system_proxy": was_system_proxy,
}
@staticmethod
async def test_node(db: Session, *, node_id: str) -> dict[str, Any]:
"""测试代理节点连通性和延迟"""
import time as _time
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {node_id} 不存在", "proxy_node")
# 构建代理 URL
try:
proxy_url = _build_test_proxy_url(node)
except Exception as exc:
return {"success": False, "latency_ms": None, "exit_ip": None, "error": str(exc)}
test_url = "https://1.1.1.1/cdn-cgi/trace"
start = _time.monotonic()
proxy_param = make_proxy_param(proxy_url)
try:
async with httpx.AsyncClient(
proxy=proxy_param,
timeout=httpx.Timeout(15.0, connect=10.0),
) as client:
response = await client.get(test_url)
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
exit_ip = None
if response.status_code == 200:
for line in response.text.splitlines():
if line.startswith("ip="):
exit_ip = line.split("=", 1)[1].strip()
break
return {
"success": True,
"latency_ms": elapsed_ms,
"exit_ip": exit_ip,
"error": None,
}
except httpx.ProxyError as exc:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": f"代理连接失败: {_sanitize_proxy_error(exc)}",
}
except httpx.ConnectError as exc:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": f"连接失败: {_sanitize_proxy_error(exc)}",
}
except httpx.TimeoutException:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": "连接超时15秒",
}
except Exception as exc:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": _sanitize_proxy_error(exc),
}
@staticmethod
def update_node_config(
db: Session, *, node_id: str, config_updates: dict[str, Any]
) -> ProxyNode:
"""更新 aether-proxy 节点的远程配置(通过下次心跳下发)"""
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {node_id} 不存在", "proxy_node")
if node.is_manual:
raise InvalidRequestException("手动节点不支持远程配置下发")
# node_name is special: it also updates the node.name column directly
if "node_name" in config_updates:
node.name = config_updates["node_name"]
# Merge with existing config (so partial updates are preserved)
# Copy to a new dict so SQLAlchemy detects the change on the JSON column
existing = dict(node.remote_config) if node.remote_config else {}
existing.update(config_updates)
node.remote_config = existing
node.config_version = (node.config_version or 0) + 1
node.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(node)
return node

View File

@@ -180,7 +180,7 @@ class RequestExecutor:
) )
else: else:
# 非流式请求:标记为 success 状态 # 非流式请求:标记为 success 状态
from src.clients.http_client import resolve_proxy_info from src.services.proxy_node.resolver import resolve_proxy_info
_extra: dict[str, Any] = { _extra: dict[str, Any] = {
"is_cached_user": is_cached_user, "is_cached_user": is_cached_user,

View File

@@ -699,7 +699,6 @@ class TaskService:
""" """
import httpx import httpx
from src.clients.http_client import resolve_proxy_info
from src.core.api_format.conversion.exceptions import FormatConversionError from src.core.api_format.conversion.exceptions import FormatConversionError
from src.core.error_utils import extract_error_message from src.core.error_utils import extract_error_message
from src.core.exceptions import ( from src.core.exceptions import (
@@ -709,6 +708,7 @@ class TaskService:
ThinkingSignatureException, ThinkingSignatureException,
UpstreamClientException, UpstreamClientException,
) )
from src.services.proxy_node.resolver import resolve_proxy_info
from src.services.request.executor import ExecutionError from src.services.request.executor import ExecutionError
# 提前解析代理信息,写入候选记录的 extra_data用于链路追踪展示 # 提前解析代理信息,写入候选记录的 extra_data用于链路追踪展示