mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +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",
|
||||
"clap",
|
||||
"crossterm 0.28.1",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"libc",
|
||||
"ratatui",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
@@ -26,12 +28,14 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"sysinfo",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -319,16 +323,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -344,6 +338,31 @@ dependencies = [
|
||||
"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]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
@@ -521,15 +540,6 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -565,12 +575,6 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
@@ -612,21 +616,6 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -657,6 +646,23 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.31"
|
||||
@@ -676,7 +682,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"slab",
|
||||
@@ -699,8 +709,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -710,28 +722,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -821,7 +816,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -848,22 +842,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -884,11 +863,9 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1174,6 +1151,12 @@ dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac_address"
|
||||
version = "1.1.8"
|
||||
@@ -1214,12 +1197,6 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -1238,23 +1215,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
@@ -1278,6 +1238,15 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1334,60 +1303,6 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "ordered-float"
|
||||
version = "4.6.0"
|
||||
@@ -1506,7 +1421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1543,12 +1458,6 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
@@ -1570,6 +1479,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -1579,6 +1497,61 @@ dependencies = [
|
||||
"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]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
@@ -1600,7 +1573,27 @@ version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
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]]
|
||||
@@ -1609,6 +1602,15 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "ratatui"
|
||||
version = "0.30.0"
|
||||
@@ -1694,6 +1696,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rcgen"
|
||||
version = "0.13.2"
|
||||
@@ -1753,36 +1775,37 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1799,6 +1822,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -1843,6 +1872,7 @@ dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
@@ -1864,6 +1894,7 @@ version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -1891,44 +1922,12 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
@@ -2172,37 +2171,17 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
name = "sysinfo"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
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"
|
||||
checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
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",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"rayon",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2348,6 +2327,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tokio"
|
||||
version = "1.49.0"
|
||||
@@ -2376,16 +2370,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -2664,12 +2648,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -2768,6 +2746,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "web-sys"
|
||||
version = "0.3.85"
|
||||
@@ -2778,6 +2769,25 @@ dependencies = [
|
||||
"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]]
|
||||
name = "wezterm-bidi"
|
||||
version = "0.2.3"
|
||||
@@ -2872,39 +2882,63 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3116,6 +3150,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.6"
|
||||
|
||||
@@ -9,7 +9,8 @@ tokio = { version = "1", features = ["full"] }
|
||||
hyper = { version = "1", features = ["http1", "server"] }
|
||||
hyper-util = { version = "0.1", features = ["tokio", "http1", "server"] }
|
||||
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"
|
||||
sha2 = "0.10"
|
||||
subtle = "2"
|
||||
@@ -31,6 +32,9 @@ rustls-pemfile = "2"
|
||||
rcgen = "0.13"
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.28"
|
||||
url = "2"
|
||||
sysinfo = "0.32"
|
||||
libc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
||||
@@ -2,23 +2,70 @@
|
||||
|
||||
Aether 正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。
|
||||
|
||||
## 下载预编译二进制
|
||||
## 安装
|
||||
|
||||
### 下载预编译二进制
|
||||
|
||||
在 [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
|
||||
# 下载 & 解压 (以 Linux amd64 为例)
|
||||
tar xzf aether-proxy-linux-amd64.tar.gz
|
||||
chmod +x aether-proxy
|
||||
# 1. 首次安装配置(TUI 向导,勾选 Install Service 随系统启动服务)
|
||||
sudo ./aether-proxy setup
|
||||
|
||||
# 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 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::InvalidBasicAuth => write!(f, "invalid Basic auth encoding"),
|
||||
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::TimestampExpired => write!(f, "timestamp outside tolerance window"),
|
||||
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)?;
|
||||
|
||||
// format: hmac:{timestamp}.{signature}
|
||||
let (username, password) = decoded
|
||||
.split_once(':')
|
||||
.ok_or(AuthError::InvalidBasicAuth)?;
|
||||
let (username, password) = decoded.split_once(':').ok_or(AuthError::InvalidBasicAuth)?;
|
||||
|
||||
if username != "hmac" {
|
||||
return Err(AuthError::InvalidUsername);
|
||||
@@ -82,11 +82,7 @@ pub fn validate_proxy_auth(
|
||||
.expect("system clock before epoch")
|
||||
.as_secs();
|
||||
|
||||
let diff = if now > timestamp {
|
||||
now - timestamp
|
||||
} else {
|
||||
timestamp - now
|
||||
};
|
||||
let diff = now.abs_diff(timestamp);
|
||||
|
||||
if diff > timestamp_tolerance {
|
||||
return Err(AuthError::TimestampExpired);
|
||||
@@ -129,6 +125,9 @@ mod tests {
|
||||
timestamp_tolerance: 300,
|
||||
log_level: "info".to_string(),
|
||||
log_json: false,
|
||||
enable_tls: false,
|
||||
tls_cert: String::new(),
|
||||
tls_key: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,8 +137,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let payload = format!("{}\n{}", now, node_id);
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(config.hmac_key.as_bytes()).unwrap();
|
||||
let mut mac = HmacSha256::new_from_slice(config.hmac_key.as_bytes()).unwrap();
|
||||
mac.update(payload.as_bytes());
|
||||
let sig = hex::encode(mac.finalize().into_bytes());
|
||||
let cred = format!("hmac:{}.{}", now, sig);
|
||||
@@ -151,7 +149,10 @@ mod tests {
|
||||
fn test_valid_auth() {
|
||||
let config = make_config();
|
||||
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]
|
||||
|
||||
@@ -64,11 +64,19 @@ pub struct Config {
|
||||
pub enable_tls: bool,
|
||||
|
||||
/// 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,
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
|
||||
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 config;
|
||||
mod hardware;
|
||||
mod net;
|
||||
mod proxy;
|
||||
mod registration;
|
||||
mod runtime;
|
||||
mod setup;
|
||||
mod state;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use clap::Parser;
|
||||
use tokio::signal;
|
||||
use tokio::sync::watch;
|
||||
use tracing::{error, info};
|
||||
|
||||
use config::Config;
|
||||
use registration::client::{detect_public_ip, AetherClient};
|
||||
use runtime::DynamicConfig;
|
||||
|
||||
/// Default config file name.
|
||||
const DEFAULT_CONFIG: &str = "aether-proxy.toml";
|
||||
@@ -24,186 +22,54 @@ const DEFAULT_CONFIG: &str = "aether-proxy.toml";
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// ── Handle `setup` subcommand before clap parsing ────────────────────
|
||||
if args.len() > 1 && args[1] == "setup" {
|
||||
let path = args
|
||||
.get(2)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG));
|
||||
return setup::run(path);
|
||||
// Handle subcommands before clap parsing (these don't need Config)
|
||||
if args.len() > 1 {
|
||||
match args[1].as_str() {
|
||||
"setup" => {
|
||||
let path = args
|
||||
.get(2)
|
||||
.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) ───────────────
|
||||
let config_file_path = std::env::var("AETHER_PROXY_CONFIG")
|
||||
.unwrap_or_else(|_| DEFAULT_CONFIG.to_string());
|
||||
// Load config file as env-var defaults (before clap)
|
||||
let config_file_path =
|
||||
std::env::var("AETHER_PROXY_CONFIG").unwrap_or_else(|_| DEFAULT_CONFIG.to_string());
|
||||
if std::path::Path::new(&config_file_path).exists() {
|
||||
if let Ok(file_cfg) = config::ConfigFile::load(std::path::Path::new(&config_file_path)) {
|
||||
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() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
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));
|
||||
}
|
||||
e.exit();
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize tracing (with hot-reload support)
|
||||
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 => 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");
|
||||
// Warn if systemd service is already running (would cause port conflict)
|
||||
if setup::service::is_service_active() {
|
||||
eprintln!("Warning: systemd service is already running.");
|
||||
eprintln!("Use `aether-proxy stop` to stop it first, or manage via subcommands:");
|
||||
eprintln!(" aether-proxy status / logs / restart / stop");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// 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 => {},
|
||||
}
|
||||
app::run(config).await
|
||||
}
|
||||
|
||||
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::{Request, Response};
|
||||
use tokio::net::TcpStream;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::auth;
|
||||
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
|
||||
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
|
||||
let target_display = target_addr.to_string();
|
||||
tokio::task::spawn(async move {
|
||||
match hyper::upgrade::on(req).await {
|
||||
Ok(upgraded) => {
|
||||
let mut upgraded =
|
||||
hyper_util::rt::TokioIo::new(upgraded);
|
||||
let mut upgraded = hyper_util::rt::TokioIo::new(upgraded);
|
||||
let mut target = target_stream;
|
||||
|
||||
match tokio::io::copy_bidirectional(&mut upgraded, &mut target).await {
|
||||
Ok((from_client, from_target)) => {
|
||||
info!(
|
||||
debug!(
|
||||
target = %target_display,
|
||||
from_client,
|
||||
from_target,
|
||||
"CONNECT tunnel closed"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(error = %e, "CONNECT tunnel error");
|
||||
debug!(target = %target_display, error = %e, "CONNECT tunnel error");
|
||||
}
|
||||
}
|
||||
}
|
||||
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 delegate;
|
||||
pub mod plain;
|
||||
pub mod server;
|
||||
pub mod target_filter;
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::{Request, Response};
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::auth;
|
||||
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)
|
||||
let path_and_query = uri
|
||||
.path_and_query()
|
||||
.map(|pq| pq.as_str())
|
||||
.unwrap_or("/");
|
||||
let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
|
||||
|
||||
let mut builder = Request::builder()
|
||||
.method(req.method())
|
||||
.uri(path_and_query)
|
||||
.version(req.version());
|
||||
|
||||
// Copy headers, skipping proxy-specific ones
|
||||
// Copy headers, skipping proxy-specific and forwarding-related ones
|
||||
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;
|
||||
}
|
||||
builder = builder.header(name, value);
|
||||
@@ -116,7 +122,7 @@ pub async fn handle_plain(
|
||||
|
||||
match sender.send_request(outgoing).await {
|
||||
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
|
||||
let (parts, body) = resp.into_parts();
|
||||
let body: BoxBody = body
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use http_body_util::BodyExt;
|
||||
use hyper::body::Incoming;
|
||||
use hyper::rt::{Read, Write};
|
||||
use hyper::server::conn::http1;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Method, Request};
|
||||
use hyper::rt::{Read, Write};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::watch;
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::proxy::{connect, plain, tls};
|
||||
use crate::runtime::SharedDynamicConfig;
|
||||
use crate::proxy::{connect, delegate, plain, tls};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Start the proxy server.
|
||||
///
|
||||
@@ -23,20 +22,17 @@ use crate::runtime::SharedDynamicConfig;
|
||||
/// - CONNECT requests -> tunnel 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
|
||||
/// (0x16) from plain HTTP, and handles both on the same port.
|
||||
pub async fn run(
|
||||
config: Arc<Config>,
|
||||
node_id: Arc<RwLock<String>>,
|
||||
dynamic: SharedDynamicConfig,
|
||||
tls_acceptor: Option<TlsAcceptor>,
|
||||
state: &Arc<AppState>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> 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?;
|
||||
|
||||
if tls_acceptor.is_some() {
|
||||
if state.tls_acceptor.is_some() {
|
||||
info!(addr = %addr, "proxy server listening (HTTP+TLS dual-stack)");
|
||||
} else {
|
||||
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 node_id = Arc::clone(&node_id);
|
||||
let dynamic = Arc::clone(&dynamic);
|
||||
let tls_acceptor = tls_acceptor.clone();
|
||||
let state = Arc::clone(state);
|
||||
state.active_connections.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
// 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 {
|
||||
match acceptor.accept(stream).await {
|
||||
match acceptor.clone().accept(stream).await {
|
||||
Ok(tls_stream) => {
|
||||
debug!(peer = %peer_addr, "TLS handshake ok");
|
||||
serve_connection(
|
||||
TokioIo::new(tls_stream),
|
||||
peer_addr,
|
||||
config,
|
||||
node_id,
|
||||
dynamic,
|
||||
&state,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -80,6 +72,7 @@ pub async fn run(
|
||||
debug!(peer = %peer_addr, error = %e, "TLS handshake failed");
|
||||
}
|
||||
}
|
||||
state.active_connections.fetch_sub(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -88,11 +81,11 @@ pub async fn run(
|
||||
serve_connection(
|
||||
TokioIo::new(stream),
|
||||
peer_addr,
|
||||
config,
|
||||
node_id,
|
||||
dynamic,
|
||||
&state,
|
||||
)
|
||||
.await;
|
||||
|
||||
state.active_connections.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
_ = 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).
|
||||
async fn serve_connection<I>(
|
||||
io: I,
|
||||
peer_addr: SocketAddr,
|
||||
config: Arc<Config>,
|
||||
node_id: Arc<RwLock<String>>,
|
||||
dynamic: SharedDynamicConfig,
|
||||
) where
|
||||
async fn serve_connection<I>(io: I, peer_addr: SocketAddr, state: &Arc<AppState>)
|
||||
where
|
||||
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 config = Arc::clone(&config);
|
||||
let node_id = Arc::clone(&node_id);
|
||||
let dynamic = Arc::clone(&dynamic);
|
||||
let delegate_client = delegate_client.clone();
|
||||
|
||||
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)
|
||||
let current_node_id = node_id.read().unwrap().clone();
|
||||
@@ -145,6 +142,18 @@ async fn serve_connection<I>(
|
||||
.boxed()
|
||||
});
|
||||
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 {
|
||||
let resp = plain::handle_plain(
|
||||
req,
|
||||
@@ -154,7 +163,6 @@ async fn serve_connection<I>(
|
||||
timestamp_tolerance,
|
||||
)
|
||||
.await;
|
||||
// plain::handle_plain already returns BoxBody (streaming)
|
||||
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");
|
||||
|
||||
let mut params = CertificateParams::new(vec![
|
||||
"localhost".into(),
|
||||
"aether-proxy".into(),
|
||||
])?;
|
||||
let mut params = CertificateParams::new(vec!["localhost".into(), "aether-proxy".into()])?;
|
||||
params.distinguished_name = rcgen::DistinguishedName::new();
|
||||
params
|
||||
.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 key_file = fs::File::open(key_path)?;
|
||||
|
||||
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let certs: Vec<CertificateDer<'static>> =
|
||||
rustls_pemfile::certs(&mut BufReader::new(cert_file)).collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
if certs.is_empty() {
|
||||
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> {
|
||||
let cert_file = fs::File::open(cert_path)?;
|
||||
let certs: Vec<CertificateDer<'static>> =
|
||||
rustls_pemfile::certs(&mut BufReader::new(cert_file))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
rustls_pemfile::certs(&mut BufReader::new(cert_file)).collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let cert = certs
|
||||
.first()
|
||||
|
||||
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::hardware::HardwareInfo;
|
||||
|
||||
/// Heartbeat-specific error that distinguishes "node not found" (needs
|
||||
/// re-registration) from transient / other failures.
|
||||
@@ -35,6 +36,10 @@ struct RegisterRequest {
|
||||
tls_enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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)]
|
||||
@@ -56,6 +61,7 @@ struct HeartbeatRequest {
|
||||
/// Remote configuration pushed by the Aether management backend.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RemoteConfig {
|
||||
pub node_name: Option<String>,
|
||||
pub allowed_ports: Option<Vec<u16>>,
|
||||
pub log_level: Option<String>,
|
||||
pub heartbeat_interval: Option<u64>,
|
||||
@@ -119,6 +125,7 @@ impl AetherClient {
|
||||
public_ip: &str,
|
||||
tls_enabled: bool,
|
||||
tls_cert_fingerprint: Option<&str>,
|
||||
hw: Option<&HardwareInfo>,
|
||||
) -> anyhow::Result<String> {
|
||||
let url = format!("{}/api/admin/proxy-nodes/register", self.base_url);
|
||||
let body = RegisterRequest {
|
||||
@@ -129,6 +136,8 @@ impl AetherClient {
|
||||
heartbeat_interval: config.heartbeat_interval,
|
||||
tls_enabled,
|
||||
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!(
|
||||
@@ -215,10 +224,13 @@ impl AetherClient {
|
||||
config_version,
|
||||
}
|
||||
}
|
||||
Err(_) => HeartbeatResult {
|
||||
remote_config: None,
|
||||
config_version: 0,
|
||||
},
|
||||
Err(e) => {
|
||||
debug!(error = %e, "failed to parse heartbeat response body");
|
||||
HeartbeatResult {
|
||||
remote_config: None,
|
||||
config_version: 0,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::registration::client::{AetherClient, HeartbeatError};
|
||||
use crate::runtime::{self, SharedDynamicConfig};
|
||||
use crate::registration::client::HeartbeatError;
|
||||
use crate::runtime;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 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
|
||||
/// to the [`DynamicConfig`](crate::runtime::DynamicConfig) so the proxy
|
||||
/// picks up changes without a restart.
|
||||
pub async fn run(
|
||||
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>,
|
||||
) {
|
||||
pub async fn run(state: &Arc<AppState>, mut shutdown_rx: watch::Receiver<bool>) {
|
||||
let mut consecutive_failures: u32 = 0;
|
||||
|
||||
// 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::time::sleep(std::time::Duration::from_secs(initial_interval)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
@@ -38,12 +31,17 @@ pub async fn run(
|
||||
}
|
||||
|
||||
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) => {
|
||||
if consecutive_failures > 0 {
|
||||
debug!(
|
||||
info!(
|
||||
previous_failures = consecutive_failures,
|
||||
"heartbeat recovered"
|
||||
);
|
||||
@@ -52,7 +50,7 @@ pub async fn run(
|
||||
|
||||
// Apply remote config if present and version changed
|
||||
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(_)) => {
|
||||
@@ -60,19 +58,24 @@ pub async fn run(
|
||||
old_node_id = %current_node_id,
|
||||
"node not found, re-registering"
|
||||
);
|
||||
match client.register(
|
||||
&config,
|
||||
&public_ip,
|
||||
config.enable_tls,
|
||||
tls_fingerprint.as_deref(),
|
||||
).await {
|
||||
match state
|
||||
.aether_client
|
||||
.register(
|
||||
&state.config,
|
||||
&state.public_ip,
|
||||
state.config.enable_tls,
|
||||
state.tls_fingerprint.as_deref(),
|
||||
Some(&state.hardware_info),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(new_id) => {
|
||||
info!(
|
||||
old_node_id = %current_node_id,
|
||||
new_node_id = %new_id,
|
||||
"re-registered successfully"
|
||||
);
|
||||
*node_id.write().unwrap() = new_id;
|
||||
*state.node_id.write().unwrap() = new_id;
|
||||
consecutive_failures = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -96,7 +99,7 @@ pub async fn run(
|
||||
}
|
||||
|
||||
// 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::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.
|
||||
#[derive(Debug)]
|
||||
pub struct DynamicConfig {
|
||||
pub node_name: String,
|
||||
pub allowed_ports: HashSet<u16>,
|
||||
pub timestamp_tolerance: u64,
|
||||
pub log_level: String,
|
||||
@@ -27,6 +28,7 @@ impl DynamicConfig {
|
||||
/// Initialize from static config (startup defaults).
|
||||
pub fn from_config(config: &Config) -> Self {
|
||||
Self {
|
||||
node_name: config.node_name.clone(),
|
||||
allowed_ports: config.allowed_ports.iter().copied().collect(),
|
||||
timestamp_tolerance: config.timestamp_tolerance,
|
||||
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.
|
||||
pub fn apply_remote_config(
|
||||
dynamic: &SharedDynamicConfig,
|
||||
remote: &super::registration::client::RemoteConfig,
|
||||
remote: &crate::registration::client::RemoteConfig,
|
||||
version: u64,
|
||||
) -> bool {
|
||||
let mut cfg = dynamic.write().unwrap();
|
||||
@@ -65,6 +67,13 @@ pub fn apply_remote_config(
|
||||
|
||||
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 {
|
||||
let new_set: HashSet<u16> = ports.iter().copied().collect();
|
||||
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,
|
||||
Number,
|
||||
Bool,
|
||||
PortList,
|
||||
LogLevel,
|
||||
}
|
||||
|
||||
@@ -102,14 +101,6 @@ impl App {
|
||||
required: true,
|
||||
help: "代理服务监听端口",
|
||||
},
|
||||
Field {
|
||||
label: "Public IP",
|
||||
key: "public_ip",
|
||||
value: String::new(),
|
||||
kind: FieldKind::Text,
|
||||
required: false,
|
||||
help: "节点公网 IP (留空则自动检测)",
|
||||
},
|
||||
Field {
|
||||
label: "Node Name",
|
||||
key: "node_name",
|
||||
@@ -118,53 +109,13 @@ impl App {
|
||||
required: true,
|
||||
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 {
|
||||
label: "Log Level",
|
||||
key: "log_level",
|
||||
value: "info".into(),
|
||||
kind: FieldKind::LogLevel,
|
||||
required: true,
|
||||
help: "日志级别 — Enter 切换: trace / debug / info / warn / error",
|
||||
help: "日志级别 -- Enter 切换: trace / debug / info / warn / error",
|
||||
},
|
||||
Field {
|
||||
label: "Log JSON",
|
||||
@@ -172,7 +123,20 @@ impl App {
|
||||
value: "false".into(),
|
||||
kind: FieldKind::Bool,
|
||||
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,
|
||||
@@ -202,17 +166,9 @@ impl App {
|
||||
"management_token" => cfg.management_token.clone(),
|
||||
"hmac_key" => cfg.hmac_key.clone(),
|
||||
"listen_port" => cfg.listen_port.map(|v| v.to_string()),
|
||||
"public_ip" => cfg.public_ip.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_json" => cfg.log_json.map(|v| v.to_string()),
|
||||
"enable_tls" => cfg.enable_tls.map(|v| v.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(v) = val {
|
||||
@@ -235,19 +191,15 @@ impl App {
|
||||
management_token: get("management_token"),
|
||||
hmac_key: get("hmac_key"),
|
||||
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_region: get("node_region"),
|
||||
heartbeat_interval: get("heartbeat_interval").and_then(|v| v.parse().ok()),
|
||||
allowed_ports: get("allowed_ports").map(|v| {
|
||||
v.split(',')
|
||||
.filter_map(|s| s.trim().parse().ok())
|
||||
.collect()
|
||||
}),
|
||||
timestamp_tolerance: get("timestamp_tolerance").and_then(|v| v.parse().ok()),
|
||||
node_region: None,
|
||||
heartbeat_interval: None,
|
||||
allowed_ports: None,
|
||||
timestamp_tolerance: None,
|
||||
log_level: get("log_level"),
|
||||
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_key: None,
|
||||
}
|
||||
@@ -259,7 +211,7 @@ impl App {
|
||||
self.modified = false;
|
||||
self.saved_once = true;
|
||||
self.message = Some((
|
||||
format!("✓ 已保存到 {}", self.config_path.display()),
|
||||
format!("saved to {}", self.config_path.display()),
|
||||
Instant::now(),
|
||||
false,
|
||||
));
|
||||
@@ -304,7 +256,7 @@ impl App {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return true,
|
||||
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
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') => {
|
||||
@@ -321,16 +273,30 @@ impl App {
|
||||
let field = &self.fields[self.selected];
|
||||
match field.kind {
|
||||
FieldKind::Bool => {
|
||||
let toggled = if field.value == "true" { "false" } else { "true" };
|
||||
self.fields[self.selected].value = toggled.into();
|
||||
self.modified = true;
|
||||
let toggled = if field.value == "true" {
|
||||
"false"
|
||||
} 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 => {
|
||||
const LEVELS: &[&str] =
|
||||
&["trace", "debug", "info", "warn", "error"];
|
||||
const LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
|
||||
let idx = LEVELS.iter().position(|l| *l == field.value).unwrap_or(2);
|
||||
self.fields[self.selected].value =
|
||||
LEVELS[(idx + 1) % LEVELS.len()].into();
|
||||
self.fields[self.selected].value = LEVELS[(idx + 1) % LEVELS.len()].into();
|
||||
self.modified = true;
|
||||
}
|
||||
_ => {
|
||||
@@ -343,7 +309,7 @@ impl App {
|
||||
KeyCode::Tab => {
|
||||
// Quick save shortcut
|
||||
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) {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
// Cancel — discard changes to this field
|
||||
// Cancel -- discard changes to this field
|
||||
self.mode = Mode::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
@@ -363,8 +329,7 @@ impl App {
|
||||
self.modified = true;
|
||||
self.mode = Mode::Normal;
|
||||
} else {
|
||||
self.message =
|
||||
Some(("✗ 格式无效".into(), Instant::now(), true));
|
||||
self.message = Some(("invalid format".into(), Instant::now(), true));
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
@@ -405,12 +370,6 @@ impl App {
|
||||
let buf = &self.edit_buffer;
|
||||
match kind {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -468,7 +427,7 @@ fn render_fields(f: &mut Frame, app: &mut App, area: Rect) {
|
||||
}
|
||||
|
||||
let selected = i == app.selected;
|
||||
let indicator = if selected { " ▸ " } else { " " };
|
||||
let indicator = if selected { " > " } else { " " };
|
||||
|
||||
let label_style = if selected {
|
||||
Style::default()
|
||||
@@ -482,10 +441,7 @@ fn render_fields(f: &mut Frame, app: &mut App, area: Rect) {
|
||||
|
||||
// Value display
|
||||
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 {
|
||||
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) {
|
||||
if field.value.is_empty() {
|
||||
let text = if field.required {
|
||||
"(必填)".into()
|
||||
"(required)".into()
|
||||
} else {
|
||||
"—".into()
|
||||
"-".into()
|
||||
};
|
||||
let color = if field.required {
|
||||
Color::Red
|
||||
@@ -532,14 +488,14 @@ fn field_display(field: &Field) -> (String, Style) {
|
||||
|
||||
match field.kind {
|
||||
FieldKind::Secret => (
|
||||
"•".repeat(field.value.len().min(20)),
|
||||
"*".repeat(field.value.len().min(20)),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
FieldKind::Bool => {
|
||||
if field.value == "true" {
|
||||
("✓ 开启".into(), Style::default().fg(Color::Green))
|
||||
("[x] on".into(), Style::default().fg(Color::Green))
|
||||
} else {
|
||||
("✗ 关闭".into(), Style::default().fg(Color::DarkGray))
|
||||
("[ ] off".into(), Style::default().fg(Color::DarkGray))
|
||||
}
|
||||
}
|
||||
FieldKind::LogLevel => {
|
||||
@@ -561,9 +517,9 @@ fn render_footer(f: &mut Frame, app: &App, area: Rect) {
|
||||
let help = app.fields[app.selected].help;
|
||||
|
||||
let keybindings = if app.mode == Mode::Editing {
|
||||
"Enter 确认 Esc 取消"
|
||||
"Enter confirm Esc cancel"
|
||||
} else {
|
||||
"↑↓ 选择 Enter 编辑 ^S 保存 q 退出"
|
||||
"Up/Down select Enter edit ^S save q quit"
|
||||
};
|
||||
|
||||
let mut status_spans: Vec<Span> = vec![Span::styled(
|
||||
@@ -631,11 +587,40 @@ pub fn run(config_path: PathBuf) -> anyhow::Result<()> {
|
||||
// Post-TUI message
|
||||
if app.saved_once {
|
||||
eprintln!();
|
||||
eprintln!(" 配置已保存到 {}", config_path.display());
|
||||
eprintln!();
|
||||
eprintln!(" 启动方式:");
|
||||
eprintln!(" aether-proxy (自动读取 {})", config_path.display());
|
||||
eprintln!(" Config saved to {}", config_path.display());
|
||||
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(())
|
||||
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>,
|
||||
}
|
||||
Reference in New Issue
Block a user