mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
588
aether-proxy/Cargo.lock
generated
588
aether-proxy/Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
200
aether-proxy/src/app.rs
Normal 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(¤t_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 => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
79
aether-proxy/src/hardware.rs
Normal file
79
aether-proxy/src/hardware.rs
Normal 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
|
||||||
|
}
|
||||||
@@ -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(¤t_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
86
aether-proxy/src/net.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
221
aether-proxy/src/proxy/delegate.rs
Normal file
221
aether-proxy/src/proxy/delegate.rs
Normal 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()
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
¤t_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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(¤t_node_id, None, None, None).await {
|
match state
|
||||||
|
.aether_client
|
||||||
|
.heartbeat(¤t_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)) => {}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
4
aether-proxy/src/setup/mod.rs
Normal file
4
aether-proxy/src/setup/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub(crate) mod service;
|
||||||
|
mod tui;
|
||||||
|
|
||||||
|
pub use self::tui::run;
|
||||||
253
aether-proxy/src/setup/service.rs
Normal file
253
aether-proxy/src/setup/service.rs
Normal 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(())
|
||||||
|
}
|
||||||
@@ -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
30
aether-proxy/src/state.rs
Normal 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>,
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
51
frontend/src/views/admin/components/HardwareTooltip.vue
Normal file
51
frontend/src/views/admin/components/HardwareTooltip.vue
Normal 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>
|
||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 的代理 URL(TLS aether-proxy 节点),返回 httpx.Proxy
|
|
||||||
并附带 proxy_ssl_context(CERT_NONE,因为使用自签名证书)。
|
|
||||||
其他情况返回普通 URL 字符串。
|
|
||||||
"""
|
|
||||||
if not proxy_url:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# https:// 代理需要 ssl_context(自签名证书场景)
|
|
||||||
if proxy_url.startswith("https://"):
|
|
||||||
from src.utils.ssl_utils import get_proxy_ssl_context
|
|
||||||
|
|
||||||
return httpx.Proxy(url=proxy_url, ssl_context=get_proxy_ssl_context())
|
|
||||||
|
|
||||||
return proxy_url
|
|
||||||
|
|
||||||
|
|
||||||
class HTTPClientPool:
|
class HTTPClientPool:
|
||||||
"""
|
"""
|
||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
673
src/services/proxy_node/resolver.py
Normal file
673
src/services/proxy_node/resolver.py
Normal 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 session(create_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:
|
||||||
|
"""将用户名密码注入代理 URL(URL 编码处理特殊字符)"""
|
||||||
|
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 的代理 URL(TLS aether-proxy 节点),返回 httpx.Proxy
|
||||||
|
并附带 proxy_ssl_context(CERT_NONE,因为使用自签名证书)。
|
||||||
|
其他情况返回普通 URL 字符串。
|
||||||
|
"""
|
||||||
|
if not proxy_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# https:// 代理需要 ssl_context(自签名证书场景)
|
||||||
|
if proxy_url.startswith("https://"):
|
||||||
|
from src.utils.ssl_utils import get_proxy_ssl_context
|
||||||
|
|
||||||
|
return httpx.Proxy(url=proxy_url, ssl_context=get_proxy_ssl_context())
|
||||||
|
|
||||||
|
return proxy_url
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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;代发分支始终携带 timeout(proxy 协议需要)。
|
||||||
|
"""
|
||||||
|
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
|
||||||
477
src/services/proxy_node/service.py
Normal file
477
src/services/proxy_node/service.py
Normal 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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(用于链路追踪展示)
|
||||||
|
|||||||
Reference in New Issue
Block a user