mirror of https://github.com/ospab/ostp.git
feat: v0.2.0 — BBR congestion control, 0-RTT session resumption, management REST API, fallback server, multi-listener
Architecture:
- BBR-inspired congestion controller (SlowStart/ProbeBandwidth/ProbeRTT phases)
- 0-RTT session resumption with anti-replay ticket validation
- Management REST API (axum): /api/users CRUD, /api/server/status, Bearer auth
- TCP fallback proxy for anti-DPI camouflage (nginx/caddy passthrough)
- Multi-listener: bind to multiple UDP addresses simultaneously
- Per-user traffic stats with atomic counters and limit enforcement
Code quality:
- Structured logging: 0 eprintln in server/core/client, all tracing::{info,debug,warn,error}
- 35 unit tests across congestion, resumption, relay, outbound, obfuscation
- Removed dead code: kex.rs, unused dependencies (async-trait, x25519-dalek, rand_distr)
- Modular server: api.rs, fallback.rs, outbound.rs, relay.rs extracted from monolithic lib.rs
CLI:
- --check: config validation
- --generate-key: secure key generation (hex/base64, batch)
- --links: share link generation from server config
- --init: fallback section in server template
Documentation:
- README rewritten with architecture diagram, API examples, CLI reference
- Wiki: Management-API (EN+RU), Configuration (EN+RU), Home (EN+RU) updated
This commit is contained in:
parent
a24d5d75d1
commit
05583e189e
Binary file not shown.
|
|
@ -37,6 +37,15 @@ dependencies = [
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aho-corasick"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
|
@ -103,15 +112,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "atomic-waker"
|
||||||
version = "0.1.89"
|
version = "1.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
|
|
@ -119,12 +123,70 @@ version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||||
|
dependencies = [
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"form_urlencoded",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"itoa",
|
||||||
|
"matchit",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"serde_core",
|
||||||
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-core"
|
||||||
|
version = "0.5.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "blake2"
|
name = "blake2"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
|
|
@ -395,6 +457,15 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-channel"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
|
|
@ -465,6 +536,86 @@ dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body-util"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
|
"itoa",
|
||||||
|
"pin-project-lite",
|
||||||
|
"smallvec",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-util"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"hyper",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iana-time-zone"
|
name = "iana-time-zone"
|
||||||
version = "0.1.65"
|
version = "0.1.65"
|
||||||
|
|
@ -699,12 +850,33 @@ version = "0.4.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime"
|
||||||
|
version = "0.3.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
|
@ -716,6 +888,15 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nu-ansi-term"
|
||||||
|
version = "0.50.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
|
|
@ -757,6 +938,8 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -783,7 +966,6 @@ name = "ostp-core"
|
||||||
version = "0.1.70"
|
version = "0.1.70"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
|
||||||
"bytes",
|
"bytes",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"hmac",
|
"hmac",
|
||||||
|
|
@ -792,7 +974,6 @@ dependencies = [
|
||||||
"snow",
|
"snow",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
"x25519-dalek",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -816,6 +997,7 @@ name = "ostp-server"
|
||||||
version = "0.1.70"
|
version = "0.1.70"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"axum",
|
||||||
"bytes",
|
"bytes",
|
||||||
"ostp-core",
|
"ostp-core",
|
||||||
"rand",
|
"rand",
|
||||||
|
|
@ -823,6 +1005,7 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -947,6 +1130,23 @@ dependencies = [
|
||||||
"getrandom",
|
"getrandom",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.8.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
|
@ -962,6 +1162,12 @@ version = "1.0.22"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
|
|
@ -1020,6 +1226,29 @@ dependencies = [
|
||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_path_to_error"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
|
|
@ -1031,6 +1260,15 @@ dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sharded-slab"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
|
|
@ -1114,6 +1352,12 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sync_wrapper"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
|
|
@ -1145,6 +1389,15 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thread_local"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|
@ -1191,12 +1444,55 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-http"
|
||||||
|
version = "0.6.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-layer"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-service"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
|
|
@ -1220,6 +1516,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"valuable",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-log"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-subscriber"
|
||||||
|
version = "0.3.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||||
|
dependencies = [
|
||||||
|
"matchers",
|
||||||
|
"nu-ansi-term",
|
||||||
|
"once_cell",
|
||||||
|
"regex-automata",
|
||||||
|
"sharded-slab",
|
||||||
|
"smallvec",
|
||||||
|
"thread_local",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1268,6 +1594,12 @@ version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "valuable"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
|
|
@ -1493,18 +1825,6 @@ version = "0.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "x25519-dalek"
|
|
||||||
version = "2.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
|
|
||||||
dependencies = [
|
|
||||||
"curve25519-dalek",
|
|
||||||
"rand_core",
|
|
||||||
"serde",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|
@ -1574,20 +1894,6 @@ name = "zeroize"
|
||||||
version = "1.8.2"
|
version = "1.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
dependencies = [
|
|
||||||
"zeroize_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zeroize_derive"
|
|
||||||
version = "1.4.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,17 @@ resolver = "2"
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "BSL 1.1"
|
license = "BSL 1.1"
|
||||||
version = "0.1.70"
|
version = "0.2.0"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
async-trait = "0.1"
|
|
||||||
bytes = "1.6"
|
bytes = "1.6"
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
rand_distr = "0.4"
|
|
||||||
snow = "0.9"
|
snow = "0.9"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
tokio = { version = "1.37", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "sync", "signal"] }
|
tokio = { version = "1.37", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "sync", "signal"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
x25519-dalek = "2"
|
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
portable-atomic = "1.10"
|
portable-atomic = "1.10"
|
||||||
|
|
|
||||||
220
README.md
220
README.md
|
|
@ -1,12 +1,31 @@
|
||||||
# OSTP — Ospab Stealth Transport Protocol
|
# OSTP — Ospab Stealth Transport Protocol
|
||||||
|
|
||||||
[Русский язык](README.ru.md)
|
[Русский язык](README.ru.md) · [Wiki](https://github.com/ospab/ostp/wiki) · [Releases](https://github.com/ospab/ostp/releases)
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
OSTP is a high-performance, censorship-resistant transport protocol designed to tunnel TCP traffic over UDP with full traffic obfuscation. It is resistant to Deep Packet Inspection (DPI), active probing, and statistical traffic analysis.
|
**OSTP** is a high-performance, censorship-resistant transport protocol designed to tunnel TCP traffic over UDP with full traffic obfuscation. Every byte on the wire — including packet headers — is cryptographically indistinguishable from random noise. Resistant to Deep Packet Inspection (DPI), active probing, and statistical traffic analysis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Install
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
```bash
|
||||||
|
bash <(curl -Ls https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows (PowerShell, run as Administrator)
|
||||||
|
```powershell
|
||||||
|
irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Download
|
||||||
|
Download pre-built binaries for your platform from [GitHub Releases](https://github.com/ospab/ostp/releases).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -14,15 +33,19 @@ OSTP is a high-performance, censorship-resistant transport protocol designed to
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| **Traffic Obfuscation** | Every packet — including headers — is indistinguishable from random noise on the wire. Session IDs and nonces are masked with per-packet HMAC-derived keys. |
|
| **Full Traffic Obfuscation** | Every packet — including headers — is indistinguishable from random noise. Session IDs and nonces are masked with per-packet HMAC-derived keys. |
|
||||||
| **Noise Protocol Handshake** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` — pre-shared key authenticated, forward-secret key exchange with no static identity exposure. |
|
| **Noise Protocol Handshake** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` — PSK-authenticated, forward-secret key exchange with no static identity exposure. |
|
||||||
| **Reliable UDP (ARQ)** | Selective ACK/NACK with rate-limited retransmission, configurable reorder buffer, and exponential backoff. Designed for 10 Gbps throughput. |
|
| **Reliable UDP (ARQ)** | Selective ACK/NACK with rate-limited retransmission, configurable reorder buffer, and exponential backoff. |
|
||||||
| **Multiplexed Streams** | Multiple logical TCP streams over a single encrypted UDP session, with per-stream flow control. |
|
| **Multiplexed Streams** | Multiple logical TCP streams over a single encrypted UDP session with per-stream flow control. |
|
||||||
| **Seamless Roaming** | Clients can switch networks (WiFi ↔ 4G) without session interruption — the server tracks session-ID, not IP address. |
|
| **Seamless Roaming** | Clients can switch networks (WiFi ↔ LTE) without session interruption — tracked by session-ID, not IP. |
|
||||||
| **TUN Mode** | Full-system VPN via `tun2socks` integration on Windows and Linux. All traffic is transparently routed through the tunnel. |
|
| **Management API** | Built-in REST API for third-party panels (3x-ui, custom dashboards). Per-user stats, traffic limits, key CRUD. |
|
||||||
|
| **Fallback Server** | TCP fallback proxy to a web server — makes OSTP indistinguishable from nginx during active probing. |
|
||||||
|
| **Multi-Listener** | Bind to multiple addresses simultaneously (dual-stack IPv4/IPv6, multi-port). |
|
||||||
|
| **TUN Mode** | Full-system VPN via `tun2socks` integration. All traffic transparently routed through the tunnel. |
|
||||||
| **TURN Relay** | RFC 5766 TURN support for environments where direct UDP is blocked. |
|
| **TURN Relay** | RFC 5766 TURN support for environments where direct UDP is blocked. |
|
||||||
| **Hot-Reload** | Runtime config reload without restarting the process (access keys, exclusions, mux settings, TURN). |
|
| **Hot-Reload** | Runtime config reload without restart (access keys, exclusions, mux settings). |
|
||||||
| **Cross-Platform** | Windows, Linux, macOS, Android. Single binary, no runtime dependencies. |
|
| **Structured Logging** | `tracing`-based logging with `RUST_LOG` filtering. JSON/file/syslog output support. |
|
||||||
|
| **Cross-Platform** | Windows, Linux, macOS, Android, FreeBSD, MIPS, RISC-V. Single binary, no runtime dependencies. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -48,149 +71,156 @@ OSTP is a high-performance, censorship-resistant transport protocol designed to
|
||||||
│ Server │ │
|
│ Server │ │
|
||||||
│ ┌─────────────────────────────────────────┴───────────┐ │
|
│ ┌─────────────────────────────────────────┴───────────┐ │
|
||||||
│ │ Dispatcher │ │
|
│ │ Dispatcher │ │
|
||||||
│ │ (Session lookup, roaming detection, replay guard) │ │
|
│ │ (Session lookup, roaming, replay guard, per-user │ │
|
||||||
│ └──────────────┬──────────────────────────────────────┘ │
|
│ │ traffic accounting, limit enforcement) │ │
|
||||||
│ │ │
|
│ └──┬──────────────────────┬───────────────────────────┘ │
|
||||||
│ ┌──────────────▾──────────────────┐ │
|
│ │ │ │
|
||||||
│ │ Relay Loop (per-stream TCP) │──▸ Internet / Backend │
|
│ ┌──▾──────────────────┐ ┌─▾──────────────────────────┐ │
|
||||||
│ └─────────────────────────────────┘ │
|
│ │ Relay Loop │ │ Management API (REST) │ │
|
||||||
|
│ │ (per-stream TCP) │ │ /api/users, /api/stats │ │
|
||||||
|
│ │ ──▸ Internet │ │ Bearer token auth │ │
|
||||||
|
│ └─────────────────────┘ └────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Fallback TCP Proxy ──▸ nginx/caddy (anti-DPI) │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Generate config
|
||||||
|
|
||||||
### Linux
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -Ls https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.sh)
|
# On your VPS (server):
|
||||||
|
./ostp --init server
|
||||||
|
|
||||||
|
# On your machine (client):
|
||||||
|
./ostp --init client
|
||||||
```
|
```
|
||||||
|
|
||||||
### Windows (PowerShell, Administrator)
|
### 2. Edit config
|
||||||
```powershell
|
|
||||||
irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | iex
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
**Server** — set your access keys:
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Generate a default config:
|
|
||||||
```bash
|
|
||||||
./ostp --init server # VPS
|
|
||||||
./ostp --init client # Local machine
|
|
||||||
```
|
|
||||||
|
|
||||||
### Server (`config.json`)
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"mode": "server",
|
"mode": "server",
|
||||||
"listen": "0.0.0.0:50000",
|
"listen": "0.0.0.0:50000",
|
||||||
"access_keys": ["YOUR_SECRET_KEY"],
|
"access_keys": ["YOUR_SECRET_KEY"],
|
||||||
"debug": false,
|
"api": { "enabled": true, "bind": "127.0.0.1:9090", "token": "admin-token" },
|
||||||
// Optional: forward traffic through an upstream proxy
|
"fallback": { "enabled": false, "listen": "0.0.0.0:443", "target": "127.0.0.1:8080" }
|
||||||
"outbound": {
|
|
||||||
"enabled": false,
|
|
||||||
"protocol": "socks5", // "socks5" or "http"
|
|
||||||
"address": "127.0.0.1",
|
|
||||||
"port": 9050,
|
|
||||||
"default_action": "proxy"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Client (`config.json`)
|
**Client** — point to your server:
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"mode": "client",
|
"mode": "client",
|
||||||
"server": "YOUR_SERVER_IP:50000",
|
"server": "YOUR_SERVER_IP:50000",
|
||||||
"access_key": "YOUR_SECRET_KEY",
|
"access_key": "YOUR_SECRET_KEY",
|
||||||
"socks5_bind": "127.0.0.1:1088",
|
"socks5_bind": "127.0.0.1:1088",
|
||||||
"debug": false,
|
"tun": { "enable": false, "dns": "1.1.1.1" }
|
||||||
// TUN mode (full-system VPN)
|
|
||||||
"tun": {
|
|
||||||
"enable": false,
|
|
||||||
"dns": "1.1.1.1"
|
|
||||||
},
|
|
||||||
// Multiplexing: spread traffic across multiple UDP sessions
|
|
||||||
"mux": {
|
|
||||||
"enabled": false,
|
|
||||||
"sessions": 2
|
|
||||||
},
|
|
||||||
// TURN relay for restricted networks
|
|
||||||
"turn": {
|
|
||||||
"enabled": false,
|
|
||||||
"server_addr": "turn.example.com:3478",
|
|
||||||
"username": "user",
|
|
||||||
"access_key": "pass"
|
|
||||||
},
|
|
||||||
// Traffic exclusions (bypassed directly)
|
|
||||||
"exclude": {
|
|
||||||
"domains": ["example.local"],
|
|
||||||
"ips": ["192.168.0.0/16"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### 3. Run
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start with config
|
./ostp # Uses config.json in current directory
|
||||||
./ostp --config config.json
|
./ostp --config /path/to.json # Custom config path
|
||||||
|
./ostp --check # Validate config without running
|
||||||
# Or just run (looks for config.json in current/binary directory)
|
./ostp --generate-key # Generate a new access key
|
||||||
./ostp
|
./ostp --links # Print client share links
|
||||||
```
|
```
|
||||||
|
|
||||||
### TUN Mode (Windows)
|
### 4. Connect via share link (one-liner)
|
||||||
Requires `tun2socks.exe` in the same directory. Automatically requests Administrator privileges.
|
```bash
|
||||||
|
./ostp ostp://ACCESS_KEY@server.com:50000
|
||||||
### TUN Mode (Linux)
|
```
|
||||||
Requires root. Uses `tun2socks` binary (same directory or in `$PATH`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Protocol Specification
|
## Management API
|
||||||
|
|
||||||
See [docs/en/specification.md](docs/en/specification.md) for the full wire format, handshake flow, and ARQ semantics.
|
Built-in REST API for building panels and dashboards.
|
||||||
|
|
||||||
### Quick Summary
|
```bash
|
||||||
|
# Server status
|
||||||
|
curl -H "Authorization: Bearer mytoken" http://127.0.0.1:9090/api/server/status
|
||||||
|
|
||||||
|
# List all users with traffic stats
|
||||||
|
curl -H "Authorization: Bearer mytoken" http://127.0.0.1:9090/api/users
|
||||||
|
|
||||||
|
# Create a user with 10GB traffic limit
|
||||||
|
curl -X POST -H "Authorization: Bearer mytoken" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"limit_bytes": 10737418240}' \
|
||||||
|
http://127.0.0.1:9090/api/users
|
||||||
|
```
|
||||||
|
|
||||||
|
Full API reference: [Management API](https://github.com/ospab/ostp/wiki/Management-API)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
ostp [OPTIONS] [URL]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--config <PATH> Config file path (default: config.json)
|
||||||
|
--init <MODE> Generate template config (server/client)
|
||||||
|
--check Validate configuration and exit
|
||||||
|
-g, --generate-key Generate a secure access key
|
||||||
|
-c, --count <N> Number of keys to generate (default: 1)
|
||||||
|
--format <FMT> Key format: hex, base64 (default: hex)
|
||||||
|
--links Print client share links from server config
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
[URL] Connect via share link: ostp://KEY@HOST:PORT
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Protocol Summary
|
||||||
|
|
||||||
| Layer | Mechanism |
|
| Layer | Mechanism |
|
||||||
|-------|-----------|
|
|-------|-----------|
|
||||||
| Key Exchange | Noise NNpsk0 (X25519 + ChaChaPoly + BLAKE2s) |
|
| Key Exchange | Noise NNpsk0 (X25519 + ChaChaPoly + BLAKE2s) |
|
||||||
| Encryption | ChaCha20-Poly1305 AEAD per-packet |
|
| Encryption | ChaCha20-Poly1305 AEAD per-packet |
|
||||||
| Header Obfuscation | HMAC-SHA256 derived per-packet mask over session_id + nonce |
|
| Header Obfuscation | HMAC-SHA256 derived per-packet mask |
|
||||||
| Reliability | Selective ACK with cumulative + SACK ranges |
|
| Reliability | Selective ACK with cumulative + SACK ranges |
|
||||||
| Retransmission | Rate-limited NACK (30ms cooldown) + exponential backoff RTO |
|
| Retransmission | Rate-limited NACK + exponential backoff RTO |
|
||||||
| Flow Control | In-flight window (retransmittable frames only) |
|
|
||||||
| Keepalive | Ping/Pong with RTT measurement every 5s |
|
| Keepalive | Ping/Pong with RTT measurement every 5s |
|
||||||
| Session Timeout | 60s inactivity on client, 300s on server |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Building from Source
|
## Building from Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Prerequisites: Rust toolchain (1.75+)
|
# Prerequisites: Rust 1.75+
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
# Cross-compile for Linux (from Windows/macOS)
|
# Cross-compile for Linux
|
||||||
cross build --release --target x86_64-unknown-linux-gnu
|
cross build --release --target x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cargo test -p ostp-core -p ostp-server
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Architecture Overview](docs/en/architecture.md)
|
- **[Wiki](https://github.com/ospab/ostp/wiki)** — Full documentation
|
||||||
- [Protocol Specification](docs/en/specification.md)
|
- [Installation](https://github.com/ospab/ostp/wiki/Installation)
|
||||||
- [Obfuscation Design](docs/en/obfuscation.md)
|
- [Configuration Reference](https://github.com/ospab/ostp/wiki/Configuration)
|
||||||
- [Server Administration](docs/en/server.md)
|
- [Management API](https://github.com/ospab/ostp/wiki/Management-API)
|
||||||
- [Client Configuration](docs/en/client.md)
|
- [Protocol Design](https://github.com/ospab/ostp/wiki/Protocol-Design)
|
||||||
- [Integration Guide](docs/en/integrations.md)
|
- [Building from Source](https://github.com/ospab/ostp/wiki/Building-from-Source)
|
||||||
|
- [FAQ](https://github.com/ospab/ostp/wiki/FAQ)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ impl Bridge {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(UiEvent::Log(format!("Protocol decrypt error: {e}"))).await;
|
let _ = tx.send(UiEvent::Log(format!("Protocol decrypt error: {e}"))).await;
|
||||||
eprintln!("[ostp] Inbound protocol error (session {}): {}", session_index, e);
|
tracing::warn!("Inbound protocol error (session {}): {}", session_index, e);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -508,7 +508,7 @@ impl Bridge {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[ostp] Protocol error packing outbound stream_id={}: {}", stream_id, e);
|
tracing::warn!("Protocol error packing outbound stream_id={}: {}", stream_id, e);
|
||||||
let _ = tx.send(UiEvent::Log(format!("Protocol error packing TCP: {e}"))).await;
|
let _ = tx.send(UiEvent::Log(format!("Protocol error packing TCP: {e}"))).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -619,7 +619,7 @@ impl Bridge {
|
||||||
let _ = sock.set_send_buffer_size(33554432); // 32MB
|
let _ = sock.set_send_buffer_size(33554432); // 32MB
|
||||||
let actual_recv = sock.recv_buffer_size().unwrap_or(0);
|
let actual_recv = sock.recv_buffer_size().unwrap_or(0);
|
||||||
let actual_send = sock.send_buffer_size().unwrap_or(0);
|
let actual_send = sock.send_buffer_size().unwrap_or(0);
|
||||||
eprintln!("[ostp] UDP socket buffers: recv={}KB send={}KB", actual_recv / 1024, actual_send / 1024);
|
tracing::info!("UDP socket buffers: recv={}KB send={}KB", actual_recv / 1024, actual_send / 1024);
|
||||||
sock.bind(&addr.into())?;
|
sock.bind(&addr.into())?;
|
||||||
sock.set_nonblocking(true)?;
|
sock.set_nonblocking(true)?;
|
||||||
let socket = UdpSocket::from_std(sock.into())?;
|
let socket = UdpSocket::from_std(sock.into())?;
|
||||||
|
|
@ -632,7 +632,7 @@ impl Bridge {
|
||||||
};
|
};
|
||||||
tx.send(UiEvent::Log(format!("Allocating TURN relay via {}", turn_addr))).await.ok();
|
tx.send(UiEvent::Log(format!("Allocating TURN relay via {}", turn_addr))).await.ok();
|
||||||
|
|
||||||
match perform_turn_allocation(&socket, &turn_addr, &self.turn_username, &self.turn_password, &self.server_addr).await {
|
match crate::turn::perform_turn_allocation(&socket, &turn_addr, &self.turn_username, &self.turn_password, &self.server_addr).await {
|
||||||
Ok(relay_addr) => {
|
Ok(relay_addr) => {
|
||||||
tx.send(UiEvent::Log(format!("TURN relay allocated ({})", relay_addr))).await.ok();
|
tx.send(UiEvent::Log(format!("TURN relay allocated ({})", relay_addr))).await.ok();
|
||||||
// Re-connect the UDP socket to the TURN server so all sends go through it.
|
// Re-connect the UDP socket to the TURN server so all sends go through it.
|
||||||
|
|
@ -677,7 +677,7 @@ impl Bridge {
|
||||||
.await
|
.await
|
||||||
.context("handshake timeout waiting server response")??;
|
.context("handshake timeout waiting server response")??;
|
||||||
self.metrics.bytes_recv.fetch_add(size as u64, Ordering::Relaxed);
|
self.metrics.bytes_recv.fetch_add(size as u64, Ordering::Relaxed);
|
||||||
eprintln!("[ostp] Handshake response received: {} bytes", size);
|
tracing::info!("Handshake response received: {} bytes", size);
|
||||||
|
|
||||||
let inbound = if self.turn_enabled && size >= 4 && buf[0] == 0x40 && buf[1] == 0x00 {
|
let inbound = if self.turn_enabled && size >= 4 && buf[0] == 0x40 && buf[1] == 0x00 {
|
||||||
let len = u16::from_be_bytes([buf[2], buf[3]]) as usize;
|
let len = u16::from_be_bytes([buf[2], buf[3]]) as usize;
|
||||||
|
|
@ -691,7 +691,7 @@ impl Bridge {
|
||||||
};
|
};
|
||||||
machine.on_event(OstpEvent::Inbound(inbound))?;
|
machine.on_event(OstpEvent::Inbound(inbound))?;
|
||||||
let rtt_ms = start.elapsed().as_secs_f64() * 1000.0;
|
let rtt_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||||
eprintln!("[ostp] Handshake complete: session={:#010x} rtt={:.1}ms", session_id, rtt_ms);
|
tracing::info!("Handshake complete: session={:#010x} rtt={:.1}ms", session_id, rtt_ms);
|
||||||
|
|
||||||
Ok((socket, machine, rtt_ms))
|
Ok((socket, machine, rtt_ms))
|
||||||
}
|
}
|
||||||
|
|
@ -721,391 +721,3 @@ fn next_profile(current: TrafficProfile) -> TrafficProfile {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Real RFC-5766 TURN allocation with HMAC-SHA1 long-term credentials.
|
|
||||||
///
|
|
||||||
/// Flow:
|
|
||||||
/// 1. Send Allocate (unauthenticated) → get 401 with realm + nonce
|
|
||||||
/// 2. Compute HMAC-SHA1 key = MD5(username:realm:password)
|
|
||||||
/// 3. Re-send Allocate with MESSAGE-INTEGRITY
|
|
||||||
/// 4. Extract XOR-RELAYED-ADDRESS from success response
|
|
||||||
/// 5. Send ChannelBind to bind channel 0x4000 to the OSTP server addr
|
|
||||||
///
|
|
||||||
/// Returns the relay address string like "1.2.3.4:12345".
|
|
||||||
async fn perform_turn_allocation(
|
|
||||||
socket: &UdpSocket,
|
|
||||||
turn_addr: &str,
|
|
||||||
username: &str,
|
|
||||||
password: &str,
|
|
||||||
ostp_server_addr: &str,
|
|
||||||
) -> anyhow::Result<String> {
|
|
||||||
use std::net::ToSocketAddrs;
|
|
||||||
|
|
||||||
let turn_sock: std::net::SocketAddr = turn_addr
|
|
||||||
.to_socket_addrs()
|
|
||||||
.map_err(|e| anyhow::anyhow!("TURN DNS resolution failed: {e}"))?
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("TURN addr resolved to nothing"))?;
|
|
||||||
|
|
||||||
let transaction_id = {
|
|
||||||
use rand::Rng;
|
|
||||||
let mut id = [0u8; 12];
|
|
||||||
rand::thread_rng().fill(&mut id);
|
|
||||||
id
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: build a minimal STUN/TURN message
|
|
||||||
fn build_stun_msg(msg_type: u16, tx_id: &[u8; 12], attrs: &[u8]) -> Vec<u8> {
|
|
||||||
let mut msg = Vec::with_capacity(20 + attrs.len());
|
|
||||||
msg.extend_from_slice(&msg_type.to_be_bytes());
|
|
||||||
msg.extend_from_slice(&(attrs.len() as u16).to_be_bytes());
|
|
||||||
msg.extend_from_slice(&0x2112A442_u32.to_be_bytes()); // Magic Cookie
|
|
||||||
msg.extend_from_slice(tx_id);
|
|
||||||
msg.extend_from_slice(attrs);
|
|
||||||
msg
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: encode a STUN attribute (type, length-padded value)
|
|
||||||
fn stun_attr(attr_type: u16, value: &[u8]) -> Vec<u8> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
out.extend_from_slice(&attr_type.to_be_bytes());
|
|
||||||
out.extend_from_slice(&(value.len() as u16).to_be_bytes());
|
|
||||||
out.extend_from_slice(value);
|
|
||||||
// Pad to 4-byte boundary
|
|
||||||
let pad = (4 - (value.len() % 4)) % 4;
|
|
||||||
out.extend(std::iter::repeat(0u8).take(pad));
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 1: unauthenticated Allocate ─────────────────────────────
|
|
||||||
// REQUESTED-TRANSPORT attr: 0x0019, value = 17 (UDP) + 3 reserved bytes
|
|
||||||
let req_transport = stun_attr(0x0019, &[17u8, 0, 0, 0]);
|
|
||||||
let alloc_req = build_stun_msg(0x0003, &transaction_id, &req_transport);
|
|
||||||
|
|
||||||
socket.send_to(&alloc_req, turn_sock).await
|
|
||||||
.map_err(|e| anyhow::anyhow!("TURN send Allocate failed: {e}"))?;
|
|
||||||
|
|
||||||
let mut buf = [0u8; 2048];
|
|
||||||
let (n, _) = timeout(Duration::from_millis(3000), socket.recv_from(&mut buf))
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow::anyhow!("TURN Allocate response timed out"))?
|
|
||||||
.map_err(|e| anyhow::anyhow!("TURN recv failed: {e}"))?;
|
|
||||||
|
|
||||||
let resp = &buf[..n];
|
|
||||||
if resp.len() < 20 {
|
|
||||||
anyhow::bail!("TURN response too short");
|
|
||||||
}
|
|
||||||
|
|
||||||
let msg_type = u16::from_be_bytes([resp[0], resp[1]]);
|
|
||||||
|
|
||||||
// 0x0113 = Allocate Error Response
|
|
||||||
if msg_type != 0x0113 {
|
|
||||||
anyhow::bail!("Expected TURN 401 error response, got type 0x{:04x}", msg_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse realm and nonce from the error response attributes
|
|
||||||
let mut realm: Option<String> = None;
|
|
||||||
let mut nonce: Option<String> = None;
|
|
||||||
{
|
|
||||||
let mut idx = 20usize;
|
|
||||||
while idx + 4 <= n {
|
|
||||||
let atype = u16::from_be_bytes([resp[idx], resp[idx + 1]]);
|
|
||||||
let alen = u16::from_be_bytes([resp[idx + 2], resp[idx + 3]]) as usize;
|
|
||||||
idx += 4;
|
|
||||||
if idx + alen > n { break; }
|
|
||||||
let val = &resp[idx..idx + alen];
|
|
||||||
match atype {
|
|
||||||
0x0014 => realm = Some(String::from_utf8_lossy(val).to_string()), // REALM
|
|
||||||
0x0015 => nonce = Some(String::from_utf8_lossy(val).to_string()), // NONCE
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
idx += alen;
|
|
||||||
let pad = (4 - (alen % 4)) % 4;
|
|
||||||
idx += pad;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let realm = realm.ok_or_else(|| anyhow::anyhow!("TURN 401: no REALM in response"))?;
|
|
||||||
let nonce = nonce.ok_or_else(|| anyhow::anyhow!("TURN 401: no NONCE in response"))?;
|
|
||||||
|
|
||||||
// ── Step 2: Compute long-term credential key per RFC 5389 §15.4 ──
|
|
||||||
// key = MD5(username ":" realm ":" password)
|
|
||||||
let key_input = format!("{}:{}:{}", username, realm, password);
|
|
||||||
let key = md5_hash(key_input.as_bytes());
|
|
||||||
|
|
||||||
// HMAC-SHA1 of the message (MESSAGE-INTEGRITY attribute, RFC 5389 §15.4)
|
|
||||||
// We build the message without the integrity attr, compute HMAC, then append.
|
|
||||||
let mut attrs2 = Vec::new();
|
|
||||||
attrs2.extend_from_slice(&stun_attr(0x0006, username.as_bytes())); // USERNAME
|
|
||||||
attrs2.extend_from_slice(&stun_attr(0x0014, realm.as_bytes())); // REALM
|
|
||||||
attrs2.extend_from_slice(&stun_attr(0x0015, nonce.as_bytes())); // NONCE
|
|
||||||
attrs2.extend_from_slice(&req_transport); // REQUESTED-TRANSPORT
|
|
||||||
|
|
||||||
// For MESSAGE-INTEGRITY we need the full message length including the MI attr (24 bytes)
|
|
||||||
let mi_placeholder_len = attrs2.len() + 4 + 20; // +4 header, +20 HMAC-SHA1
|
|
||||||
let mut msg_for_hmac = build_stun_msg(0x0003, &transaction_id, &attrs2);
|
|
||||||
// Set length field to include the upcoming MI attr
|
|
||||||
let new_len = (mi_placeholder_len - 20) as u16; // total attrs length including MI
|
|
||||||
msg_for_hmac[2..4].copy_from_slice(&new_len.to_be_bytes());
|
|
||||||
// Append MI header (without value)
|
|
||||||
msg_for_hmac.extend_from_slice(&0x0008_u16.to_be_bytes()); // attr type
|
|
||||||
msg_for_hmac.extend_from_slice(&20_u16.to_be_bytes()); // attr len
|
|
||||||
|
|
||||||
let hmac = hmac_sha1(&key, &msg_for_hmac);
|
|
||||||
let mut final_attrs = attrs2.clone();
|
|
||||||
final_attrs.extend_from_slice(&stun_attr(0x0008, &hmac)); // MESSAGE-INTEGRITY
|
|
||||||
|
|
||||||
let alloc_req2 = build_stun_msg(0x0003, &transaction_id, &final_attrs);
|
|
||||||
|
|
||||||
socket.send_to(&alloc_req2, turn_sock).await
|
|
||||||
.map_err(|e| anyhow::anyhow!("TURN authenticated Allocate send failed: {e}"))?;
|
|
||||||
|
|
||||||
let (n2, _) = timeout(Duration::from_millis(5000), socket.recv_from(&mut buf))
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow::anyhow!("TURN authenticated Allocate timed out"))?
|
|
||||||
.map_err(|e| anyhow::anyhow!("TURN recv2 failed: {e}"))?;
|
|
||||||
|
|
||||||
let resp2 = &buf[..n2];
|
|
||||||
if resp2.len() < 20 {
|
|
||||||
anyhow::bail!("TURN auth response too short");
|
|
||||||
}
|
|
||||||
let msg_type2 = u16::from_be_bytes([resp2[0], resp2[1]]);
|
|
||||||
// 0x0103 = Allocate Success Response
|
|
||||||
if msg_type2 != 0x0103 {
|
|
||||||
anyhow::bail!("TURN Allocate auth failed, response type 0x{:04x}", msg_type2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 3: Parse XOR-RELAYED-ADDRESS ────────────────────────────
|
|
||||||
let relay_addr_str = {
|
|
||||||
let mut relayed: Option<String> = None;
|
|
||||||
let mut idx = 20usize;
|
|
||||||
while idx + 4 <= n2 {
|
|
||||||
let atype = u16::from_be_bytes([resp2[idx], resp2[idx + 1]]);
|
|
||||||
let alen = u16::from_be_bytes([resp2[idx + 2], resp2[idx + 3]]) as usize;
|
|
||||||
idx += 4;
|
|
||||||
if idx + alen > n2 { break; }
|
|
||||||
let val = &resp2[idx..idx + alen];
|
|
||||||
if atype == 0x0016 && alen >= 8 { // XOR-RELAYED-ADDRESS
|
|
||||||
let x_port = u16::from_be_bytes([val[2], val[3]]) ^ 0x2112;
|
|
||||||
let x_ip = [val[4], val[5], val[6], val[7]];
|
|
||||||
let ip = std::net::Ipv4Addr::new(
|
|
||||||
x_ip[0] ^ 0x21, x_ip[1] ^ 0x12, x_ip[2] ^ 0xA4, x_ip[3] ^ 0x42,
|
|
||||||
);
|
|
||||||
relayed = Some(format!("{}:{}", ip, x_port));
|
|
||||||
}
|
|
||||||
idx += alen;
|
|
||||||
let pad = (4 - (alen % 4)) % 4;
|
|
||||||
idx += pad;
|
|
||||||
}
|
|
||||||
relayed.ok_or_else(|| anyhow::anyhow!("TURN: no XOR-RELAYED-ADDRESS in response"))?
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Step 4: ChannelBind to the OSTP server ────────────────────────
|
|
||||||
// ChannelBind binds channel 0x4000 to the peer (OSTP server).
|
|
||||||
// After this, all UDP data we send as ChannelData (4 bytes header + payload)
|
|
||||||
// will be forwarded by the TURN server to the OSTP server transparently.
|
|
||||||
let ostp_sock: std::net::SocketAddr = ostp_server_addr
|
|
||||||
.to_socket_addrs()
|
|
||||||
.map_err(|e| anyhow::anyhow!("OSTP server DNS resolution failed: {e}"))?
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("OSTP server addr resolved to nothing"))?;
|
|
||||||
|
|
||||||
let channel_number: u16 = 0x4000;
|
|
||||||
let mut peer_addr_attr = Vec::new();
|
|
||||||
peer_addr_attr.push(0u8); // reserved
|
|
||||||
peer_addr_attr.push(0x01u8); // family IPv4
|
|
||||||
peer_addr_attr.extend_from_slice(&(ostp_sock.port() ^ 0x2112).to_be_bytes()); // XOR port
|
|
||||||
if let std::net::IpAddr::V4(ipv4) = ostp_sock.ip() {
|
|
||||||
let octets = ipv4.octets();
|
|
||||||
peer_addr_attr.push(octets[0] ^ 0x21);
|
|
||||||
peer_addr_attr.push(octets[1] ^ 0x12);
|
|
||||||
peer_addr_attr.push(octets[2] ^ 0xA4);
|
|
||||||
peer_addr_attr.push(octets[3] ^ 0x42);
|
|
||||||
} else {
|
|
||||||
anyhow::bail!("TURN ChannelBind: IPv6 OSTP server not yet supported");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut cb_attrs = Vec::new();
|
|
||||||
// CHANNEL-NUMBER attr: 0x000C
|
|
||||||
cb_attrs.extend_from_slice(&stun_attr(0x000C, &[
|
|
||||||
(channel_number >> 8) as u8, channel_number as u8, 0, 0
|
|
||||||
]));
|
|
||||||
// XOR-PEER-ADDRESS attr: 0x0012
|
|
||||||
cb_attrs.extend_from_slice(&stun_attr(0x0012, &peer_addr_attr));
|
|
||||||
cb_attrs.extend_from_slice(&stun_attr(0x0006, username.as_bytes()));
|
|
||||||
cb_attrs.extend_from_slice(&stun_attr(0x0014, realm.as_bytes()));
|
|
||||||
cb_attrs.extend_from_slice(&stun_attr(0x0015, nonce.as_bytes()));
|
|
||||||
|
|
||||||
// Compute MESSAGE-INTEGRITY for ChannelBind too
|
|
||||||
let mi_len2 = cb_attrs.len() + 4 + 20;
|
|
||||||
let mut cb_for_hmac = build_stun_msg(0x0009, &transaction_id, &cb_attrs);
|
|
||||||
cb_for_hmac[2..4].copy_from_slice(&((mi_len2 - 20) as u16).to_be_bytes());
|
|
||||||
cb_for_hmac.extend_from_slice(&0x0008_u16.to_be_bytes());
|
|
||||||
cb_for_hmac.extend_from_slice(&20_u16.to_be_bytes());
|
|
||||||
let cb_hmac = hmac_sha1(&key, &cb_for_hmac);
|
|
||||||
cb_attrs.extend_from_slice(&stun_attr(0x0008, &cb_hmac));
|
|
||||||
|
|
||||||
let cb_req = build_stun_msg(0x0009, &transaction_id, &cb_attrs);
|
|
||||||
socket.send_to(&cb_req, turn_sock).await
|
|
||||||
.map_err(|e| anyhow::anyhow!("TURN ChannelBind send failed: {e}"))?;
|
|
||||||
|
|
||||||
let (n3, _) = timeout(Duration::from_millis(3000), socket.recv_from(&mut buf))
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow::anyhow!("TURN ChannelBind response timed out"))?
|
|
||||||
.map_err(|e| anyhow::anyhow!("TURN ChannelBind recv failed: {e}"))?;
|
|
||||||
|
|
||||||
let resp3 = &buf[..n3];
|
|
||||||
if resp3.len() < 4 {
|
|
||||||
anyhow::bail!("TURN ChannelBind response too short");
|
|
||||||
}
|
|
||||||
let cb_resp_type = u16::from_be_bytes([resp3[0], resp3[1]]);
|
|
||||||
// 0x0109 = ChannelBind Success Response
|
|
||||||
if cb_resp_type != 0x0109 {
|
|
||||||
anyhow::bail!("TURN ChannelBind failed, response type 0x{:04x}", cb_resp_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(relay_addr_str)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure-Rust MD5 hash (16 bytes). Used for TURN long-term credential key derivation.
|
|
||||||
fn md5_hash(input: &[u8]) -> [u8; 16] {
|
|
||||||
// RFC 1321 MD5 constants
|
|
||||||
const S: [u32; 64] = [
|
|
||||||
7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22,
|
|
||||||
5, 9,14,20, 5, 9,14,20, 5, 9,14,20, 5, 9,14,20,
|
|
||||||
4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23,
|
|
||||||
6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21,
|
|
||||||
];
|
|
||||||
const K: [u32; 64] = [
|
|
||||||
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a,
|
|
||||||
0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
|
|
||||||
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340,
|
|
||||||
0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
|
|
||||||
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8,
|
|
||||||
0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
|
|
||||||
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa,
|
|
||||||
0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
|
|
||||||
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92,
|
|
||||||
0xffeff47d, 0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
|
|
||||||
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
|
|
||||||
];
|
|
||||||
|
|
||||||
let msg_len = input.len();
|
|
||||||
let bit_len = (msg_len as u64) * 8;
|
|
||||||
|
|
||||||
let mut padded = input.to_vec();
|
|
||||||
padded.push(0x80);
|
|
||||||
while padded.len() % 64 != 56 {
|
|
||||||
padded.push(0);
|
|
||||||
}
|
|
||||||
padded.extend_from_slice(&bit_len.to_le_bytes());
|
|
||||||
|
|
||||||
let mut a0: u32 = 0x67452301;
|
|
||||||
let mut b0: u32 = 0xefcdab89;
|
|
||||||
let mut c0: u32 = 0x98badcfe;
|
|
||||||
let mut d0: u32 = 0x10325476;
|
|
||||||
|
|
||||||
for chunk in padded.chunks(64) {
|
|
||||||
let mut m = [0u32; 16];
|
|
||||||
for (i, item) in m.iter_mut().enumerate() {
|
|
||||||
*item = u32::from_le_bytes([chunk[i*4], chunk[i*4+1], chunk[i*4+2], chunk[i*4+3]]);
|
|
||||||
}
|
|
||||||
let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
|
|
||||||
for i in 0..64usize {
|
|
||||||
let (f, g) = match i {
|
|
||||||
0..=15 => ((b & c) | (!b & d), i),
|
|
||||||
16..=31 => ((d & b) | (!d & c), (5*i + 1) % 16),
|
|
||||||
32..=47 => (b ^ c ^ d, (3*i + 5) % 16),
|
|
||||||
_ => (c ^ (b | !d), (7*i) % 16),
|
|
||||||
};
|
|
||||||
let temp = d;
|
|
||||||
d = c;
|
|
||||||
c = b;
|
|
||||||
b = b.wrapping_add((a.wrapping_add(f).wrapping_add(K[i]).wrapping_add(m[g])).rotate_left(S[i]));
|
|
||||||
a = temp;
|
|
||||||
}
|
|
||||||
a0 = a0.wrapping_add(a);
|
|
||||||
b0 = b0.wrapping_add(b);
|
|
||||||
c0 = c0.wrapping_add(c);
|
|
||||||
d0 = d0.wrapping_add(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut result = [0u8; 16];
|
|
||||||
result[0..4].copy_from_slice(&a0.to_le_bytes());
|
|
||||||
result[4..8].copy_from_slice(&b0.to_le_bytes());
|
|
||||||
result[8..12].copy_from_slice(&c0.to_le_bytes());
|
|
||||||
result[12..16].copy_from_slice(&d0.to_le_bytes());
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// HMAC-SHA1 for TURN MESSAGE-INTEGRITY (RFC 2104 + RFC 5389 §15.4).
|
|
||||||
fn hmac_sha1(key: &[u8], message: &[u8]) -> [u8; 20] {
|
|
||||||
const BLOCK_SIZE: usize = 64;
|
|
||||||
|
|
||||||
let mut k = [0u8; BLOCK_SIZE];
|
|
||||||
if key.len() > BLOCK_SIZE {
|
|
||||||
let h = sha1_hash(key);
|
|
||||||
k[..20].copy_from_slice(&h);
|
|
||||||
} else {
|
|
||||||
k[..key.len()].copy_from_slice(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut ipad = [0u8; BLOCK_SIZE];
|
|
||||||
let mut opad = [0u8; BLOCK_SIZE];
|
|
||||||
for i in 0..BLOCK_SIZE {
|
|
||||||
ipad[i] = k[i] ^ 0x36;
|
|
||||||
opad[i] = k[i] ^ 0x5C;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut inner = ipad.to_vec();
|
|
||||||
inner.extend_from_slice(message);
|
|
||||||
let inner_hash = sha1_hash(&inner);
|
|
||||||
|
|
||||||
let mut outer = opad.to_vec();
|
|
||||||
outer.extend_from_slice(&inner_hash);
|
|
||||||
sha1_hash(&outer)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure-Rust SHA-1 (RFC 3174).
|
|
||||||
fn sha1_hash(input: &[u8]) -> [u8; 20] {
|
|
||||||
let msg_len = input.len();
|
|
||||||
let bit_len = (msg_len as u64) * 8;
|
|
||||||
let mut padded = input.to_vec();
|
|
||||||
padded.push(0x80);
|
|
||||||
while padded.len() % 64 != 56 {
|
|
||||||
padded.push(0);
|
|
||||||
}
|
|
||||||
padded.extend_from_slice(&bit_len.to_be_bytes());
|
|
||||||
|
|
||||||
let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
|
|
||||||
|
|
||||||
for chunk in padded.chunks(64) {
|
|
||||||
let mut w = [0u32; 80];
|
|
||||||
for i in 0..16 {
|
|
||||||
w[i] = u32::from_be_bytes([chunk[i*4], chunk[i*4+1], chunk[i*4+2], chunk[i*4+3]]);
|
|
||||||
}
|
|
||||||
for i in 16..80 {
|
|
||||||
w[i] = (w[i-3] ^ w[i-8] ^ w[i-14] ^ w[i-16]).rotate_left(1);
|
|
||||||
}
|
|
||||||
let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]);
|
|
||||||
for i in 0..80usize {
|
|
||||||
let (f, k) = match i {
|
|
||||||
0..=19 => ((b & c) | (!b & d), 0x5A827999u32),
|
|
||||||
20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
|
|
||||||
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
|
|
||||||
_ => (b ^ c ^ d, 0xCA62C1D6),
|
|
||||||
};
|
|
||||||
let temp = a.rotate_left(5).wrapping_add(f).wrapping_add(e).wrapping_add(k).wrapping_add(w[i]);
|
|
||||||
e = d; d = c; c = b.rotate_left(30); b = a; a = temp;
|
|
||||||
}
|
|
||||||
h[0] = h[0].wrapping_add(a); h[1] = h[1].wrapping_add(b);
|
|
||||||
h[2] = h[2].wrapping_add(c); h[3] = h[3].wrapping_add(d);
|
|
||||||
h[4] = h[4].wrapping_add(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = [0u8; 20];
|
|
||||||
for (i, &v) in h.iter().enumerate() {
|
|
||||||
out[i*4..(i+1)*4].copy_from_slice(&v.to_be_bytes());
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,5 @@ pub mod config;
|
||||||
pub mod signal;
|
pub mod signal;
|
||||||
pub mod sysproxy;
|
pub mod sysproxy;
|
||||||
pub mod tunnel;
|
pub mod tunnel;
|
||||||
|
pub mod turn;
|
||||||
pub mod runner;
|
pub mod runner;
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ pub async fn wait_for_shutdown_signal() -> Result<()> {
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = sigterm.recv() => {
|
_ = sigterm.recv() => {
|
||||||
eprintln!("[ostp] Received SIGTERM, shutting down");
|
tracing::info!("Received SIGTERM, shutting down");
|
||||||
}
|
}
|
||||||
_ = sigint.recv() => {
|
_ = sigint.recv() => {
|
||||||
eprintln!("[ostp] Received SIGINT, shutting down");
|
tracing::info!("Received SIGINT, shutting down");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,19 +30,19 @@ pub async fn wait_for_shutdown_signal() -> Result<()> {
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
res = c_c.recv() => {
|
res = c_c.recv() => {
|
||||||
eprintln!("[ostp] Received Ctrl+C, shutting down");
|
tracing::info!("Received Ctrl+C, shutting down");
|
||||||
if res.is_none() {
|
if res.is_none() {
|
||||||
std::future::pending::<()>().await;
|
std::future::pending::<()>().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res = c_close.recv() => {
|
res = c_close.recv() => {
|
||||||
eprintln!("[ostp] Received console close event, shutting down");
|
tracing::info!("Received console close event, shutting down");
|
||||||
if res.is_none() {
|
if res.is_none() {
|
||||||
std::future::pending::<()>().await;
|
std::future::pending::<()>().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res = c_break.recv() => {
|
res = c_break.recv() => {
|
||||||
eprintln!("[ostp] Received Ctrl+Break, shutting down");
|
tracing::info!("Received Ctrl+Break, shutting down");
|
||||||
if res.is_none() {
|
if res.is_none() {
|
||||||
std::future::pending::<()>().await;
|
std::future::pending::<()>().await;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ const INTERNET_OPTION_REFRESH: u32 = 37;
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn enable_windows_proxy(proxy_addr: &str) {
|
pub fn enable_windows_proxy(proxy_addr: &str) {
|
||||||
eprintln!("[ostp] Enabling Windows system proxy: {}", proxy_addr);
|
tracing::info!("Enabling Windows system proxy: {}", proxy_addr);
|
||||||
|
|
||||||
let result = Command::new("reg")
|
let result = Command::new("reg")
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
|
@ -39,9 +39,9 @@ pub fn enable_windows_proxy(proxy_addr: &str) {
|
||||||
.output();
|
.output();
|
||||||
match result {
|
match result {
|
||||||
Ok(out) if !out.status.success() => {
|
Ok(out) if !out.status.success() => {
|
||||||
eprintln!("[ostp] Failed to set ProxyEnable: {}", String::from_utf8_lossy(&out.stderr));
|
tracing::error!("Failed to set ProxyEnable: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
}
|
}
|
||||||
Err(e) => eprintln!("[ostp] Failed to execute reg.exe (ProxyEnable): {}", e),
|
Err(e) => tracing::error!("Failed to execute reg.exe (ProxyEnable): {}", e),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,9 +58,9 @@ pub fn enable_windows_proxy(proxy_addr: &str) {
|
||||||
.output();
|
.output();
|
||||||
match result {
|
match result {
|
||||||
Ok(out) if !out.status.success() => {
|
Ok(out) if !out.status.success() => {
|
||||||
eprintln!("[ostp] Failed to set ProxyServer: {}", String::from_utf8_lossy(&out.stderr));
|
tracing::error!("Failed to set ProxyServer: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
}
|
}
|
||||||
Err(e) => eprintln!("[ostp] Failed to execute reg.exe (ProxyServer): {}", e),
|
Err(e) => tracing::error!("Failed to execute reg.exe (ProxyServer): {}", e),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,12 +78,12 @@ pub fn enable_windows_proxy(proxy_addr: &str) {
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
refresh_wininet();
|
refresh_wininet();
|
||||||
eprintln!("[ostp] System proxy enabled successfully");
|
tracing::info!("System proxy enabled successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub fn disable_windows_proxy() {
|
pub fn disable_windows_proxy() {
|
||||||
eprintln!("[ostp] Disabling Windows system proxy");
|
tracing::info!("Disabling Windows system proxy");
|
||||||
let _ = Command::new("reg")
|
let _ = Command::new("reg")
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.args([
|
.args([
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,7 @@ pub async fn run_linux_tunnel(
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let reader = BufReader::new(stderr);
|
let reader = BufReader::new(stderr);
|
||||||
for line in reader.lines().map_while(Result::ok) {
|
for line in reader.lines().map_while(Result::ok) {
|
||||||
eprintln!("[tun2socks-err] {}", line);
|
tracing::warn!("tun2socks: {}", line);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ pub async fn run_local_socks5_proxy(
|
||||||
.with_context(|| format!("failed to bind local HTTP/SOCKS5 proxy at {}", cfg.bind_addr))?;
|
.with_context(|| format!("failed to bind local HTTP/SOCKS5 proxy at {}", cfg.bind_addr))?;
|
||||||
|
|
||||||
if debug {
|
if debug {
|
||||||
eprintln!("[ostp] local HTTP/SOCKS5 proxy listening at {}", cfg.bind_addr);
|
tracing::info!("local HTTP/SOCKS5 proxy listening at {}", cfg.bind_addr);
|
||||||
eprintln!("[ostp] Windows system proxy: set HTTP proxy to {}. tun2socks: SOCKS5 on same address.", cfg.bind_addr);
|
tracing::info!("Windows system proxy: set HTTP proxy to {}. tun2socks: SOCKS5 on same address.", cfg.bind_addr);
|
||||||
}
|
}
|
||||||
|
|
||||||
let matcher = ExclusionMatcher::new(&exclusions);
|
let matcher = ExclusionMatcher::new(&exclusions);
|
||||||
|
|
@ -75,7 +75,7 @@ pub async fn run_local_socks5_proxy(
|
||||||
&& !msg.contains("unsupported SOCKS5 command")
|
&& !msg.contains("unsupported SOCKS5 command")
|
||||||
{
|
{
|
||||||
if debug {
|
if debug {
|
||||||
eprintln!("[ostp] proxy client error: {err}");
|
tracing::warn!("proxy client error: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +85,7 @@ pub async fn run_local_socks5_proxy(
|
||||||
if stream_id == 0 {
|
if stream_id == 0 {
|
||||||
if let ProxyToClientMsg::Close = msg {
|
if let ProxyToClientMsg::Close = msg {
|
||||||
if debug {
|
if debug {
|
||||||
eprintln!("[ostp] Resetting all active proxy streams on reconnect");
|
tracing::info!("Resetting all active proxy streams on reconnect");
|
||||||
}
|
}
|
||||||
for (_, tx) in active_streams.drain() {
|
for (_, tx) in active_streams.drain() {
|
||||||
let _ = tx.send(ProxyToClientMsg::Close);
|
let _ = tx.send(ProxyToClientMsg::Close);
|
||||||
|
|
@ -200,7 +200,7 @@ async fn handle_proxy_client(
|
||||||
};
|
};
|
||||||
|
|
||||||
if debug {
|
if debug {
|
||||||
eprintln!("[ostp] proxy CONNECT stream_id={stream_id} target={target}");
|
tracing::info!("proxy CONNECT stream_id={stream_id} target={target}");
|
||||||
}
|
}
|
||||||
if matcher.should_bypass(&target, connect_timeout).await {
|
if matcher.should_bypass(&target, connect_timeout).await {
|
||||||
return direct_connect_socks5(client, stream_id, &target, close_tx, debug).await;
|
return direct_connect_socks5(client, stream_id, &target, close_tx, debug).await;
|
||||||
|
|
@ -277,7 +277,7 @@ async fn handle_proxy_client(
|
||||||
};
|
};
|
||||||
|
|
||||||
if debug {
|
if debug {
|
||||||
eprintln!("[ostp] proxy CONNECT stream_id={stream_id} target={target}");
|
tracing::info!("proxy CONNECT stream_id={stream_id} target={target}");
|
||||||
}
|
}
|
||||||
if matcher.should_bypass(&target, connect_timeout).await {
|
if matcher.should_bypass(&target, connect_timeout).await {
|
||||||
return direct_connect_http(
|
return direct_connect_http(
|
||||||
|
|
@ -333,7 +333,7 @@ async fn handle_proxy_client(
|
||||||
Ok(0) => {
|
Ok(0) => {
|
||||||
let _ = event_tx.send(ProxyEvent::Close { stream_id }).await;
|
let _ = event_tx.send(ProxyEvent::Close { stream_id }).await;
|
||||||
if debug {
|
if debug {
|
||||||
eprintln!("[ostp] proxy CLOSE stream_id={stream_id}");
|
tracing::info!("proxy CLOSE stream_id={stream_id}");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -346,7 +346,7 @@ async fn handle_proxy_client(
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = event_tx.send(ProxyEvent::Close { stream_id }).await;
|
let _ = event_tx.send(ProxyEvent::Close { stream_id }).await;
|
||||||
if debug {
|
if debug {
|
||||||
eprintln!("[ostp] proxy CLOSE stream_id={stream_id}");
|
tracing::info!("proxy CLOSE stream_id={stream_id}");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -513,7 +513,7 @@ async fn direct_connect_socks5(
|
||||||
debug: bool,
|
debug: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if debug {
|
if debug {
|
||||||
eprintln!("[ostp] proxy BYPASS stream_id={stream_id} target={target}");
|
tracing::info!("proxy BYPASS stream_id={stream_id} target={target}");
|
||||||
}
|
}
|
||||||
let mut remote = TcpStream::connect(target).await
|
let mut remote = TcpStream::connect(target).await
|
||||||
.with_context(|| format!("direct connect failed: {target}"))?;
|
.with_context(|| format!("direct connect failed: {target}"))?;
|
||||||
|
|
@ -534,7 +534,7 @@ async fn direct_connect_http(
|
||||||
debug: bool,
|
debug: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if debug {
|
if debug {
|
||||||
eprintln!("[ostp] proxy BYPASS stream_id={stream_id} target={target}");
|
tracing::info!("proxy BYPASS stream_id={stream_id} target={target}");
|
||||||
}
|
}
|
||||||
let mut remote = TcpStream::connect(target).await
|
let mut remote = TcpStream::connect(target).await
|
||||||
.with_context(|| format!("direct connect failed: {target}"))?;
|
.with_context(|| format!("direct connect failed: {target}"))?;
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ pub async fn run_wintun_tunnel(
|
||||||
|
|
||||||
let debug = config.debug;
|
let debug = config.debug;
|
||||||
|
|
||||||
eprintln!("[ostp] Initializing TUN tunnel...");
|
tracing::info!("Initializing TUN tunnel...");
|
||||||
|
|
||||||
let exe = std::env::current_exe()?;
|
let exe = std::env::current_exe()?;
|
||||||
let dir = exe.parent().ok_or_else(|| anyhow!("failed to get binary directory"))?;
|
let dir = exe.parent().ok_or_else(|| anyhow!("failed to get binary directory"))?;
|
||||||
|
|
@ -59,7 +59,7 @@ pub async fn run_wintun_tunnel(
|
||||||
|
|
||||||
// 1. Delete stale TUN adapter if it exists from a previous run.
|
// 1. Delete stale TUN adapter if it exists from a previous run.
|
||||||
// This prevents wintun from creating "ostp_tun 2", "ostp_tun 3", etc.
|
// This prevents wintun from creating "ostp_tun 2", "ostp_tun 3", etc.
|
||||||
eprintln!("[ostp] Cleaning up stale TUN adapter...");
|
tracing::info!("Cleaning up stale TUN adapter...");
|
||||||
let _ = Command::new("powershell")
|
let _ = Command::new("powershell")
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.args(["-NoProfile", "-Command", &format!(
|
.args(["-NoProfile", "-Command", &format!(
|
||||||
|
|
@ -79,7 +79,7 @@ pub async fn run_wintun_tunnel(
|
||||||
.ok_or_else(|| anyhow!("Could not resolve host IP for routing exclusion"))?;
|
.ok_or_else(|| anyhow!("Could not resolve host IP for routing exclusion"))?;
|
||||||
|
|
||||||
let server_ip_str = server_ip.to_string();
|
let server_ip_str = server_ip.to_string();
|
||||||
eprintln!("[ostp] Resolved server IP: {}", server_ip_str);
|
tracing::info!("Resolved server IP: {}", server_ip_str);
|
||||||
|
|
||||||
// 3. Prepare routing and firewall setup script
|
// 3. Prepare routing and firewall setup script
|
||||||
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
|
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
|
||||||
|
|
@ -105,7 +105,7 @@ pub async fn run_wintun_tunnel(
|
||||||
|
|
||||||
// 4. Launch tun2socks + route setup IN PARALLEL to save ~3 seconds
|
// 4. Launch tun2socks + route setup IN PARALLEL to save ~3 seconds
|
||||||
let proxy_url = format!("http://{}", config.local_proxy.bind_addr);
|
let proxy_url = format!("http://{}", config.local_proxy.bind_addr);
|
||||||
eprintln!("[ostp] Starting tun2socks (proxy={})", proxy_url);
|
tracing::info!("Starting tun2socks (proxy={})", proxy_url);
|
||||||
|
|
||||||
// Spawn tun2socks immediately — it creates the adapter on its own
|
// Spawn tun2socks immediately — it creates the adapter on its own
|
||||||
let mut child = Command::new(&tun2socks_exe)
|
let mut child = Command::new(&tun2socks_exe)
|
||||||
|
|
@ -151,7 +151,7 @@ pub async fn run_wintun_tunnel(
|
||||||
if let Ok(out) = check {
|
if let Ok(out) = check {
|
||||||
let status = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
let status = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
if debug {
|
if debug {
|
||||||
eprintln!("[ostp] Adapter status: '{}'", status);
|
tracing::info!("Adapter status: '{}'", status);
|
||||||
}
|
}
|
||||||
if status == "Up" || status == "Disconnected" || !status.is_empty() {
|
if status == "Up" || status == "Disconnected" || !status.is_empty() {
|
||||||
adapter_ready = true;
|
adapter_ready = true;
|
||||||
|
|
@ -161,14 +161,14 @@ pub async fn run_wintun_tunnel(
|
||||||
}
|
}
|
||||||
|
|
||||||
if !adapter_ready {
|
if !adapter_ready {
|
||||||
eprintln!("[ostp] WARNING: TUN adapter did not appear within timeout. Proceeding anyway.");
|
tracing::warn!("WARNING: TUN adapter did not appear within timeout. Proceeding anyway.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for route setup to finish (should already be done by now)
|
// Wait for route setup to finish (should already be done by now)
|
||||||
let _ = route_handle.await;
|
let _ = route_handle.await;
|
||||||
|
|
||||||
// 6. Configure the adapter (IP, metric, MTU, DNS)
|
// 6. Configure the adapter (IP, metric, MTU, DNS)
|
||||||
eprintln!("[ostp] Applying network configuration...");
|
tracing::info!("Applying network configuration...");
|
||||||
let mut net_setup = format!(
|
let mut net_setup = format!(
|
||||||
"netsh interface ipv4 set address name=\"{TUN_NAME}\" static 10.1.0.2 255.255.255.0 10.1.0.1\n\
|
"netsh interface ipv4 set address name=\"{TUN_NAME}\" static 10.1.0.2 255.255.255.0 10.1.0.1\n\
|
||||||
netsh interface ipv4 set subinterface \"{TUN_NAME}\" mtu=1300 store=persistent\n\
|
netsh interface ipv4 set subinterface \"{TUN_NAME}\" mtu=1300 store=persistent\n\
|
||||||
|
|
@ -177,7 +177,7 @@ pub async fn run_wintun_tunnel(
|
||||||
|
|
||||||
if let Some(ref dns) = config.dns_server {
|
if let Some(ref dns) = config.dns_server {
|
||||||
if !dns.is_empty() {
|
if !dns.is_empty() {
|
||||||
eprintln!("[ostp] DNS server: {}", dns);
|
tracing::info!("DNS server: {}", dns);
|
||||||
net_setup.push_str(&format!(
|
net_setup.push_str(&format!(
|
||||||
"netsh interface ipv4 set dnsservers name=\"{TUN_NAME}\" static {} primary\n", dns
|
"netsh interface ipv4 set dnsservers name=\"{TUN_NAME}\" static {} primary\n", dns
|
||||||
));
|
));
|
||||||
|
|
@ -189,7 +189,7 @@ pub async fn run_wintun_tunnel(
|
||||||
.args(["-NoProfile", "-Command", &net_setup])
|
.args(["-NoProfile", "-Command", &net_setup])
|
||||||
.output()?;
|
.output()?;
|
||||||
|
|
||||||
eprintln!("[ostp] TUN tunnel active. All traffic is routed through OSTP.");
|
tracing::info!("TUN tunnel active. All traffic is routed through OSTP.");
|
||||||
|
|
||||||
// 7. Spawn debug log readers for tun2socks output
|
// 7. Spawn debug log readers for tun2socks output
|
||||||
let mut stdout = child.stdout.take();
|
let mut stdout = child.stdout.take();
|
||||||
|
|
@ -202,7 +202,7 @@ pub async fn run_wintun_tunnel(
|
||||||
if let Some(out) = stdout.take() {
|
if let Some(out) = stdout.take() {
|
||||||
let reader = BufReader::new(out);
|
let reader = BufReader::new(out);
|
||||||
for line in reader.lines().map_while(Result::ok) {
|
for line in reader.lines().map_while(Result::ok) {
|
||||||
eprintln!("[tun2socks] {}", line);
|
tracing::debug!("tun2socks: {}", line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -211,7 +211,7 @@ pub async fn run_wintun_tunnel(
|
||||||
if let Some(err) = stderr.take() {
|
if let Some(err) = stderr.take() {
|
||||||
let reader = BufReader::new(err);
|
let reader = BufReader::new(err);
|
||||||
for line in reader.lines().map_while(Result::ok) {
|
for line in reader.lines().map_while(Result::ok) {
|
||||||
eprintln!("[tun2socks err] {}", line);
|
tracing::warn!("tun2socks: {}", line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -220,9 +220,9 @@ pub async fn run_wintun_tunnel(
|
||||||
// 8. Wait for shutdown signal
|
// 8. Wait for shutdown signal
|
||||||
let _ = shutdown.changed().await;
|
let _ = shutdown.changed().await;
|
||||||
|
|
||||||
eprintln!("[ostp] Deactivating TUN tunnel...");
|
tracing::info!("Deactivating TUN tunnel...");
|
||||||
drop(_guard);
|
drop(_guard);
|
||||||
eprintln!("[ostp] TUN tunnel stopped.");
|
tracing::info!("TUN tunnel stopped.");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,397 @@
|
||||||
|
//! TURN (RFC 5766) allocation and channel binding for NAT traversal.
|
||||||
|
//!
|
||||||
|
//! Implements the minimal STUN/TURN message flow needed to allocate a relay
|
||||||
|
//! address and bind a channel to the OSTP server. All crypto (MD5, SHA-1,
|
||||||
|
//! HMAC-SHA1) is implemented inline to avoid external dependencies.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
/// Real RFC-5766 TURN allocation with HMAC-SHA1 long-term credentials.
|
||||||
|
///
|
||||||
|
/// Flow:
|
||||||
|
/// 1. Send Allocate (unauthenticated) -> get 401 with realm + nonce
|
||||||
|
/// 2. Compute HMAC-SHA1 key = MD5(username:realm:password)
|
||||||
|
/// 3. Re-send Allocate with MESSAGE-INTEGRITY
|
||||||
|
/// 4. Extract XOR-RELAYED-ADDRESS from success response
|
||||||
|
/// 5. Send ChannelBind to bind channel 0x4000 to the OSTP server addr
|
||||||
|
///
|
||||||
|
/// Returns the relay address string like "1.2.3.4:12345".
|
||||||
|
pub async fn perform_turn_allocation(
|
||||||
|
socket: &UdpSocket,
|
||||||
|
turn_addr: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
ostp_server_addr: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
use std::net::ToSocketAddrs;
|
||||||
|
|
||||||
|
let turn_sock: std::net::SocketAddr = turn_addr
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map_err(|e| anyhow::anyhow!("TURN DNS resolution failed: {e}"))?
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("TURN addr resolved to nothing"))?;
|
||||||
|
|
||||||
|
let transaction_id = {
|
||||||
|
use rand::Rng;
|
||||||
|
let mut id = [0u8; 12];
|
||||||
|
rand::thread_rng().fill(&mut id);
|
||||||
|
id
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Step 1: unauthenticated Allocate ─────────────────────────────
|
||||||
|
// REQUESTED-TRANSPORT attr: 0x0019, value = 17 (UDP) + 3 reserved bytes
|
||||||
|
let req_transport = stun_attr(0x0019, &[17u8, 0, 0, 0]);
|
||||||
|
let alloc_req = build_stun_msg(0x0003, &transaction_id, &req_transport);
|
||||||
|
|
||||||
|
socket.send_to(&alloc_req, turn_sock).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("TURN send Allocate failed: {e}"))?;
|
||||||
|
|
||||||
|
let mut buf = [0u8; 2048];
|
||||||
|
let (n, _) = timeout(Duration::from_millis(3000), socket.recv_from(&mut buf))
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("TURN Allocate response timed out"))?
|
||||||
|
.map_err(|e| anyhow::anyhow!("TURN recv failed: {e}"))?;
|
||||||
|
|
||||||
|
let resp = &buf[..n];
|
||||||
|
if resp.len() < 20 {
|
||||||
|
anyhow::bail!("TURN response too short");
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg_type = u16::from_be_bytes([resp[0], resp[1]]);
|
||||||
|
|
||||||
|
// 0x0113 = Allocate Error Response
|
||||||
|
if msg_type != 0x0113 {
|
||||||
|
anyhow::bail!("Expected TURN 401 error response, got type 0x{:04x}", msg_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse realm and nonce from the error response attributes
|
||||||
|
let mut realm: Option<String> = None;
|
||||||
|
let mut nonce: Option<String> = None;
|
||||||
|
{
|
||||||
|
let mut idx = 20usize;
|
||||||
|
while idx + 4 <= n {
|
||||||
|
let atype = u16::from_be_bytes([resp[idx], resp[idx + 1]]);
|
||||||
|
let alen = u16::from_be_bytes([resp[idx + 2], resp[idx + 3]]) as usize;
|
||||||
|
idx += 4;
|
||||||
|
if idx + alen > n { break; }
|
||||||
|
let val = &resp[idx..idx + alen];
|
||||||
|
match atype {
|
||||||
|
0x0014 => realm = Some(String::from_utf8_lossy(val).to_string()), // REALM
|
||||||
|
0x0015 => nonce = Some(String::from_utf8_lossy(val).to_string()), // NONCE
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
idx += alen;
|
||||||
|
let pad = (4 - (alen % 4)) % 4;
|
||||||
|
idx += pad;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let realm = realm.ok_or_else(|| anyhow::anyhow!("TURN 401: no REALM in response"))?;
|
||||||
|
let nonce = nonce.ok_or_else(|| anyhow::anyhow!("TURN 401: no NONCE in response"))?;
|
||||||
|
|
||||||
|
// ── Step 2: Compute long-term credential key per RFC 5389 §15.4 ──
|
||||||
|
// key = MD5(username ":" realm ":" password)
|
||||||
|
let key_input = format!("{}:{}:{}", username, realm, password);
|
||||||
|
let key = md5_hash(key_input.as_bytes());
|
||||||
|
|
||||||
|
// HMAC-SHA1 of the message (MESSAGE-INTEGRITY attribute, RFC 5389 §15.4)
|
||||||
|
let mut attrs2 = Vec::new();
|
||||||
|
attrs2.extend_from_slice(&stun_attr(0x0006, username.as_bytes())); // USERNAME
|
||||||
|
attrs2.extend_from_slice(&stun_attr(0x0014, realm.as_bytes())); // REALM
|
||||||
|
attrs2.extend_from_slice(&stun_attr(0x0015, nonce.as_bytes())); // NONCE
|
||||||
|
attrs2.extend_from_slice(&req_transport); // REQUESTED-TRANSPORT
|
||||||
|
|
||||||
|
// For MESSAGE-INTEGRITY we need the full message length including the MI attr (24 bytes)
|
||||||
|
let mi_placeholder_len = attrs2.len() + 4 + 20; // +4 header, +20 HMAC-SHA1
|
||||||
|
let mut msg_for_hmac = build_stun_msg(0x0003, &transaction_id, &attrs2);
|
||||||
|
// Set length field to include the upcoming MI attr
|
||||||
|
let new_len = (mi_placeholder_len - 20) as u16; // total attrs length including MI
|
||||||
|
msg_for_hmac[2..4].copy_from_slice(&new_len.to_be_bytes());
|
||||||
|
// Append MI header (without value)
|
||||||
|
msg_for_hmac.extend_from_slice(&0x0008_u16.to_be_bytes()); // attr type
|
||||||
|
msg_for_hmac.extend_from_slice(&20_u16.to_be_bytes()); // attr len
|
||||||
|
|
||||||
|
let hmac = hmac_sha1(&key, &msg_for_hmac);
|
||||||
|
let mut final_attrs = attrs2.clone();
|
||||||
|
final_attrs.extend_from_slice(&stun_attr(0x0008, &hmac)); // MESSAGE-INTEGRITY
|
||||||
|
|
||||||
|
let alloc_req2 = build_stun_msg(0x0003, &transaction_id, &final_attrs);
|
||||||
|
|
||||||
|
socket.send_to(&alloc_req2, turn_sock).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("TURN authenticated Allocate send failed: {e}"))?;
|
||||||
|
|
||||||
|
let (n2, _) = timeout(Duration::from_millis(5000), socket.recv_from(&mut buf))
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("TURN authenticated Allocate timed out"))?
|
||||||
|
.map_err(|e| anyhow::anyhow!("TURN recv2 failed: {e}"))?;
|
||||||
|
|
||||||
|
let resp2 = &buf[..n2];
|
||||||
|
if resp2.len() < 20 {
|
||||||
|
anyhow::bail!("TURN auth response too short");
|
||||||
|
}
|
||||||
|
let msg_type2 = u16::from_be_bytes([resp2[0], resp2[1]]);
|
||||||
|
// 0x0103 = Allocate Success Response
|
||||||
|
if msg_type2 != 0x0103 {
|
||||||
|
anyhow::bail!("TURN Allocate auth failed, response type 0x{:04x}", msg_type2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: Parse XOR-RELAYED-ADDRESS ────────────────────────────
|
||||||
|
let relay_addr_str = {
|
||||||
|
let mut relayed: Option<String> = None;
|
||||||
|
let mut idx = 20usize;
|
||||||
|
while idx + 4 <= n2 {
|
||||||
|
let atype = u16::from_be_bytes([resp2[idx], resp2[idx + 1]]);
|
||||||
|
let alen = u16::from_be_bytes([resp2[idx + 2], resp2[idx + 3]]) as usize;
|
||||||
|
idx += 4;
|
||||||
|
if idx + alen > n2 { break; }
|
||||||
|
let val = &resp2[idx..idx + alen];
|
||||||
|
if atype == 0x0016 && alen >= 8 { // XOR-RELAYED-ADDRESS
|
||||||
|
let x_port = u16::from_be_bytes([val[2], val[3]]) ^ 0x2112;
|
||||||
|
let x_ip = [val[4], val[5], val[6], val[7]];
|
||||||
|
let ip = std::net::Ipv4Addr::new(
|
||||||
|
x_ip[0] ^ 0x21, x_ip[1] ^ 0x12, x_ip[2] ^ 0xA4, x_ip[3] ^ 0x42,
|
||||||
|
);
|
||||||
|
relayed = Some(format!("{}:{}", ip, x_port));
|
||||||
|
}
|
||||||
|
idx += alen;
|
||||||
|
let pad = (4 - (alen % 4)) % 4;
|
||||||
|
idx += pad;
|
||||||
|
}
|
||||||
|
relayed.ok_or_else(|| anyhow::anyhow!("TURN: no XOR-RELAYED-ADDRESS in response"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Step 4: ChannelBind to the OSTP server ────────────────────────
|
||||||
|
let ostp_sock: std::net::SocketAddr = ostp_server_addr
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map_err(|e| anyhow::anyhow!("OSTP server DNS resolution failed: {e}"))?
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("OSTP server addr resolved to nothing"))?;
|
||||||
|
|
||||||
|
let channel_number: u16 = 0x4000;
|
||||||
|
let mut peer_addr_attr = Vec::new();
|
||||||
|
peer_addr_attr.push(0u8); // reserved
|
||||||
|
peer_addr_attr.push(0x01u8); // family IPv4
|
||||||
|
peer_addr_attr.extend_from_slice(&(ostp_sock.port() ^ 0x2112).to_be_bytes()); // XOR port
|
||||||
|
if let std::net::IpAddr::V4(ipv4) = ostp_sock.ip() {
|
||||||
|
let octets = ipv4.octets();
|
||||||
|
peer_addr_attr.push(octets[0] ^ 0x21);
|
||||||
|
peer_addr_attr.push(octets[1] ^ 0x12);
|
||||||
|
peer_addr_attr.push(octets[2] ^ 0xA4);
|
||||||
|
peer_addr_attr.push(octets[3] ^ 0x42);
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("TURN ChannelBind: IPv6 OSTP server not yet supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cb_attrs = Vec::new();
|
||||||
|
// CHANNEL-NUMBER attr: 0x000C
|
||||||
|
cb_attrs.extend_from_slice(&stun_attr(0x000C, &[
|
||||||
|
(channel_number >> 8) as u8, channel_number as u8, 0, 0
|
||||||
|
]));
|
||||||
|
// XOR-PEER-ADDRESS attr: 0x0012
|
||||||
|
cb_attrs.extend_from_slice(&stun_attr(0x0012, &peer_addr_attr));
|
||||||
|
cb_attrs.extend_from_slice(&stun_attr(0x0006, username.as_bytes()));
|
||||||
|
cb_attrs.extend_from_slice(&stun_attr(0x0014, realm.as_bytes()));
|
||||||
|
cb_attrs.extend_from_slice(&stun_attr(0x0015, nonce.as_bytes()));
|
||||||
|
|
||||||
|
// Compute MESSAGE-INTEGRITY for ChannelBind too
|
||||||
|
let mi_len2 = cb_attrs.len() + 4 + 20;
|
||||||
|
let mut cb_for_hmac = build_stun_msg(0x0009, &transaction_id, &cb_attrs);
|
||||||
|
cb_for_hmac[2..4].copy_from_slice(&((mi_len2 - 20) as u16).to_be_bytes());
|
||||||
|
cb_for_hmac.extend_from_slice(&0x0008_u16.to_be_bytes());
|
||||||
|
cb_for_hmac.extend_from_slice(&20_u16.to_be_bytes());
|
||||||
|
let cb_hmac = hmac_sha1(&key, &cb_for_hmac);
|
||||||
|
cb_attrs.extend_from_slice(&stun_attr(0x0008, &cb_hmac));
|
||||||
|
|
||||||
|
let cb_req = build_stun_msg(0x0009, &transaction_id, &cb_attrs);
|
||||||
|
socket.send_to(&cb_req, turn_sock).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("TURN ChannelBind send failed: {e}"))?;
|
||||||
|
|
||||||
|
let (n3, _) = timeout(Duration::from_millis(3000), socket.recv_from(&mut buf))
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("TURN ChannelBind response timed out"))?
|
||||||
|
.map_err(|e| anyhow::anyhow!("TURN ChannelBind recv failed: {e}"))?;
|
||||||
|
|
||||||
|
let resp3 = &buf[..n3];
|
||||||
|
if resp3.len() < 4 {
|
||||||
|
anyhow::bail!("TURN ChannelBind response too short");
|
||||||
|
}
|
||||||
|
let cb_resp_type = u16::from_be_bytes([resp3[0], resp3[1]]);
|
||||||
|
// 0x0109 = ChannelBind Success Response
|
||||||
|
if cb_resp_type != 0x0109 {
|
||||||
|
anyhow::bail!("TURN ChannelBind failed, response type 0x{:04x}", cb_resp_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(relay_addr_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── STUN message helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn build_stun_msg(msg_type: u16, tx_id: &[u8; 12], attrs: &[u8]) -> Vec<u8> {
|
||||||
|
let mut msg = Vec::with_capacity(20 + attrs.len());
|
||||||
|
msg.extend_from_slice(&msg_type.to_be_bytes());
|
||||||
|
msg.extend_from_slice(&(attrs.len() as u16).to_be_bytes());
|
||||||
|
msg.extend_from_slice(&0x2112A442_u32.to_be_bytes()); // Magic Cookie
|
||||||
|
msg.extend_from_slice(tx_id);
|
||||||
|
msg.extend_from_slice(attrs);
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stun_attr(attr_type: u16, value: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
out.extend_from_slice(&attr_type.to_be_bytes());
|
||||||
|
out.extend_from_slice(&(value.len() as u16).to_be_bytes());
|
||||||
|
out.extend_from_slice(value);
|
||||||
|
// Pad to 4-byte boundary
|
||||||
|
let pad = (4 - (value.len() % 4)) % 4;
|
||||||
|
out.extend(std::iter::repeat(0u8).take(pad));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cryptographic primitives (inline, zero external deps) ────────────────────
|
||||||
|
|
||||||
|
/// Pure-Rust MD5 hash (16 bytes). Used for TURN long-term credential key derivation.
|
||||||
|
fn md5_hash(input: &[u8]) -> [u8; 16] {
|
||||||
|
// RFC 1321 MD5 constants
|
||||||
|
const S: [u32; 64] = [
|
||||||
|
7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22,
|
||||||
|
5, 9,14,20, 5, 9,14,20, 5, 9,14,20, 5, 9,14,20,
|
||||||
|
4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23,
|
||||||
|
6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21,
|
||||||
|
];
|
||||||
|
const K: [u32; 64] = [
|
||||||
|
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a,
|
||||||
|
0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
|
||||||
|
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340,
|
||||||
|
0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
|
||||||
|
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8,
|
||||||
|
0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
|
||||||
|
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa,
|
||||||
|
0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
|
||||||
|
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92,
|
||||||
|
0xffeff47d, 0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
|
||||||
|
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
|
||||||
|
];
|
||||||
|
|
||||||
|
let msg_len = input.len();
|
||||||
|
let bit_len = (msg_len as u64) * 8;
|
||||||
|
|
||||||
|
let mut padded = input.to_vec();
|
||||||
|
padded.push(0x80);
|
||||||
|
while padded.len() % 64 != 56 {
|
||||||
|
padded.push(0);
|
||||||
|
}
|
||||||
|
padded.extend_from_slice(&bit_len.to_le_bytes());
|
||||||
|
|
||||||
|
let mut a0: u32 = 0x67452301;
|
||||||
|
let mut b0: u32 = 0xefcdab89;
|
||||||
|
let mut c0: u32 = 0x98badcfe;
|
||||||
|
let mut d0: u32 = 0x10325476;
|
||||||
|
|
||||||
|
for chunk in padded.chunks(64) {
|
||||||
|
let mut m = [0u32; 16];
|
||||||
|
for (i, item) in m.iter_mut().enumerate() {
|
||||||
|
*item = u32::from_le_bytes([chunk[i*4], chunk[i*4+1], chunk[i*4+2], chunk[i*4+3]]);
|
||||||
|
}
|
||||||
|
let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
|
||||||
|
for i in 0..64usize {
|
||||||
|
let (f, g) = match i {
|
||||||
|
0..=15 => ((b & c) | (!b & d), i),
|
||||||
|
16..=31 => ((d & b) | (!d & c), (5*i + 1) % 16),
|
||||||
|
32..=47 => (b ^ c ^ d, (3*i + 5) % 16),
|
||||||
|
_ => (c ^ (b | !d), (7*i) % 16),
|
||||||
|
};
|
||||||
|
let temp = d;
|
||||||
|
d = c;
|
||||||
|
c = b;
|
||||||
|
b = b.wrapping_add((a.wrapping_add(f).wrapping_add(K[i]).wrapping_add(m[g])).rotate_left(S[i]));
|
||||||
|
a = temp;
|
||||||
|
}
|
||||||
|
a0 = a0.wrapping_add(a);
|
||||||
|
b0 = b0.wrapping_add(b);
|
||||||
|
c0 = c0.wrapping_add(c);
|
||||||
|
d0 = d0.wrapping_add(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = [0u8; 16];
|
||||||
|
result[0..4].copy_from_slice(&a0.to_le_bytes());
|
||||||
|
result[4..8].copy_from_slice(&b0.to_le_bytes());
|
||||||
|
result[8..12].copy_from_slice(&c0.to_le_bytes());
|
||||||
|
result[12..16].copy_from_slice(&d0.to_le_bytes());
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HMAC-SHA1 for TURN MESSAGE-INTEGRITY (RFC 2104 + RFC 5389 §15.4).
|
||||||
|
fn hmac_sha1(key: &[u8], message: &[u8]) -> [u8; 20] {
|
||||||
|
const BLOCK_SIZE: usize = 64;
|
||||||
|
|
||||||
|
let mut k = [0u8; BLOCK_SIZE];
|
||||||
|
if key.len() > BLOCK_SIZE {
|
||||||
|
let h = sha1_hash(key);
|
||||||
|
k[..20].copy_from_slice(&h);
|
||||||
|
} else {
|
||||||
|
k[..key.len()].copy_from_slice(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ipad = [0u8; BLOCK_SIZE];
|
||||||
|
let mut opad = [0u8; BLOCK_SIZE];
|
||||||
|
for i in 0..BLOCK_SIZE {
|
||||||
|
ipad[i] = k[i] ^ 0x36;
|
||||||
|
opad[i] = k[i] ^ 0x5C;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut inner = ipad.to_vec();
|
||||||
|
inner.extend_from_slice(message);
|
||||||
|
let inner_hash = sha1_hash(&inner);
|
||||||
|
|
||||||
|
let mut outer = opad.to_vec();
|
||||||
|
outer.extend_from_slice(&inner_hash);
|
||||||
|
sha1_hash(&outer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure-Rust SHA-1 (RFC 3174).
|
||||||
|
fn sha1_hash(input: &[u8]) -> [u8; 20] {
|
||||||
|
let msg_len = input.len();
|
||||||
|
let bit_len = (msg_len as u64) * 8;
|
||||||
|
let mut padded = input.to_vec();
|
||||||
|
padded.push(0x80);
|
||||||
|
while padded.len() % 64 != 56 {
|
||||||
|
padded.push(0);
|
||||||
|
}
|
||||||
|
padded.extend_from_slice(&bit_len.to_be_bytes());
|
||||||
|
|
||||||
|
let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
|
||||||
|
|
||||||
|
for chunk in padded.chunks(64) {
|
||||||
|
let mut w = [0u32; 80];
|
||||||
|
for i in 0..16 {
|
||||||
|
w[i] = u32::from_be_bytes([chunk[i*4], chunk[i*4+1], chunk[i*4+2], chunk[i*4+3]]);
|
||||||
|
}
|
||||||
|
for i in 16..80 {
|
||||||
|
w[i] = (w[i-3] ^ w[i-8] ^ w[i-14] ^ w[i-16]).rotate_left(1);
|
||||||
|
}
|
||||||
|
let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]);
|
||||||
|
for i in 0..80usize {
|
||||||
|
let (f, k) = match i {
|
||||||
|
0..=19 => ((b & c) | (!b & d), 0x5A827999u32),
|
||||||
|
20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
|
||||||
|
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
|
||||||
|
_ => (b ^ c ^ d, 0xCA62C1D6),
|
||||||
|
};
|
||||||
|
let temp = a.rotate_left(5).wrapping_add(f).wrapping_add(e).wrapping_add(k).wrapping_add(w[i]);
|
||||||
|
e = d; d = c; c = b.rotate_left(30); b = a; a = temp;
|
||||||
|
}
|
||||||
|
h[0] = h[0].wrapping_add(a); h[1] = h[1].wrapping_add(b);
|
||||||
|
h[2] = h[2].wrapping_add(c); h[3] = h[3].wrapping_add(d);
|
||||||
|
h[4] = h[4].wrapping_add(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = [0u8; 20];
|
||||||
|
for (i, &v) in h.iter().enumerate() {
|
||||||
|
out[i*4..(i+1)*4].copy_from_slice(&v.to_be_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
@ -6,13 +6,11 @@ license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
async-trait.workspace = true
|
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
chacha20poly1305.workspace = true
|
chacha20poly1305.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
snow.workspace = true
|
snow.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
x25519-dalek.workspace = true
|
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
hmac.workspace = true
|
hmac.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,341 @@
|
||||||
|
//! Congestion control for the OSTP protocol.
|
||||||
|
//!
|
||||||
|
//! Implements a simplified BBR-inspired algorithm that estimates bottleneck
|
||||||
|
//! bandwidth and minimum RTT to determine the optimal sending rate.
|
||||||
|
//! This replaces the fixed `retransmit_budget = 8` with an adaptive
|
||||||
|
//! congestion window that responds to network conditions.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Congestion control state for a single OSTP session.
|
||||||
|
pub struct CongestionController {
|
||||||
|
/// Current congestion window in bytes (how much can be in-flight)
|
||||||
|
cwnd: u64,
|
||||||
|
/// Slow-start threshold in bytes
|
||||||
|
ssthresh: u64,
|
||||||
|
/// Current phase
|
||||||
|
phase: Phase,
|
||||||
|
/// Minimum RTT observed (used for BDP calculation)
|
||||||
|
min_rtt: Duration,
|
||||||
|
/// Maximum bandwidth observed (bytes/sec)
|
||||||
|
max_bandwidth: u64,
|
||||||
|
/// RTT samples for smoothing
|
||||||
|
rtt_samples: VecDeque<RttSample>,
|
||||||
|
/// Bandwidth samples
|
||||||
|
bw_samples: VecDeque<BwSample>,
|
||||||
|
/// Bytes currently in flight (unacknowledged)
|
||||||
|
bytes_in_flight: u64,
|
||||||
|
/// Total bytes acknowledged (for bandwidth estimation)
|
||||||
|
total_acked: u64,
|
||||||
|
/// Last time we received an ACK
|
||||||
|
last_ack_time: Instant,
|
||||||
|
/// Number of loss events in the current window
|
||||||
|
loss_count: u32,
|
||||||
|
/// Pacing rate: bytes per second
|
||||||
|
pacing_rate: u64,
|
||||||
|
/// MTU estimate (used for cwnd → packet count conversion)
|
||||||
|
mtu: u64,
|
||||||
|
/// Probe RTT phase timer
|
||||||
|
probe_rtt_timer: Option<Instant>,
|
||||||
|
/// Min RTT expiry: re-probe after 10 seconds
|
||||||
|
min_rtt_stamp: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Phase {
|
||||||
|
/// Exponential growth until loss or ssthresh
|
||||||
|
SlowStart,
|
||||||
|
/// Probe bandwidth: cycle through pacing gains
|
||||||
|
ProbeBandwidth,
|
||||||
|
/// Periodically drain the queue to measure true min RTT
|
||||||
|
ProbeRtt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct RttSample {
|
||||||
|
rtt: Duration,
|
||||||
|
time: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct BwSample {
|
||||||
|
bytes_per_sec: u64,
|
||||||
|
time: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum number of samples to keep for windowed min/max
|
||||||
|
const MAX_SAMPLES: usize = 32;
|
||||||
|
/// Initial congestion window: 10 packets × MTU
|
||||||
|
const INITIAL_CWND_PACKETS: u64 = 10;
|
||||||
|
/// Minimum cwnd: 2 packets
|
||||||
|
const MIN_CWND_PACKETS: u64 = 2;
|
||||||
|
/// Min RTT expiry window (after which we re-probe)
|
||||||
|
const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10);
|
||||||
|
/// ProbeRTT drain duration
|
||||||
|
const PROBE_RTT_DURATION: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
|
impl CongestionController {
|
||||||
|
pub fn new(mtu: u64) -> Self {
|
||||||
|
let now = Instant::now();
|
||||||
|
let initial_cwnd = INITIAL_CWND_PACKETS * mtu;
|
||||||
|
Self {
|
||||||
|
cwnd: initial_cwnd,
|
||||||
|
ssthresh: u64::MAX,
|
||||||
|
phase: Phase::SlowStart,
|
||||||
|
min_rtt: Duration::from_millis(100), // Conservative initial estimate
|
||||||
|
max_bandwidth: 0,
|
||||||
|
rtt_samples: VecDeque::with_capacity(MAX_SAMPLES),
|
||||||
|
bw_samples: VecDeque::with_capacity(MAX_SAMPLES),
|
||||||
|
bytes_in_flight: 0,
|
||||||
|
total_acked: 0,
|
||||||
|
last_ack_time: now,
|
||||||
|
loss_count: 0,
|
||||||
|
pacing_rate: initial_cwnd * 10, // initial: ~10 windows/sec
|
||||||
|
mtu,
|
||||||
|
probe_rtt_timer: None,
|
||||||
|
min_rtt_stamp: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current congestion window in bytes.
|
||||||
|
pub fn cwnd(&self) -> u64 {
|
||||||
|
self.cwnd
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current congestion window in packets.
|
||||||
|
pub fn cwnd_packets(&self) -> usize {
|
||||||
|
(self.cwnd / self.mtu).max(MIN_CWND_PACKETS) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current pacing rate in bytes/sec.
|
||||||
|
pub fn pacing_rate(&self) -> u64 {
|
||||||
|
self.pacing_rate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the smoothed RTT estimate.
|
||||||
|
pub fn smoothed_rtt(&self) -> Duration {
|
||||||
|
self.min_rtt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns how many bytes can still be sent.
|
||||||
|
pub fn available_cwnd(&self) -> u64 {
|
||||||
|
self.cwnd.saturating_sub(self.bytes_in_flight)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the recommended retransmit budget per tick.
|
||||||
|
pub fn retransmit_budget(&self) -> usize {
|
||||||
|
// Allow retransmitting up to 1/4 of the cwnd in packets per tick
|
||||||
|
let budget = (self.cwnd_packets() / 4).max(2);
|
||||||
|
budget.min(64) // cap at 64 to prevent burst
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether we can send more data.
|
||||||
|
pub fn can_send(&self) -> bool {
|
||||||
|
self.bytes_in_flight < self.cwnd
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that we sent `bytes` of data.
|
||||||
|
pub fn on_send(&mut self, bytes: u64) {
|
||||||
|
self.bytes_in_flight = self.bytes_in_flight.saturating_add(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that `bytes` were acknowledged with the given RTT sample.
|
||||||
|
pub fn on_ack(&mut self, bytes: u64, rtt: Duration) {
|
||||||
|
let now = Instant::now();
|
||||||
|
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes);
|
||||||
|
self.total_acked = self.total_acked.saturating_add(bytes);
|
||||||
|
|
||||||
|
// Update RTT
|
||||||
|
self.update_rtt(rtt, now);
|
||||||
|
|
||||||
|
// Update bandwidth estimate
|
||||||
|
self.update_bandwidth(bytes, now);
|
||||||
|
|
||||||
|
// State machine
|
||||||
|
match self.phase {
|
||||||
|
Phase::SlowStart => {
|
||||||
|
// Exponential growth: increase cwnd by acked bytes
|
||||||
|
self.cwnd = self.cwnd.saturating_add(bytes);
|
||||||
|
if self.cwnd >= self.ssthresh {
|
||||||
|
self.phase = Phase::ProbeBandwidth;
|
||||||
|
tracing::debug!(cwnd = self.cwnd, "congestion: exiting slow start");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Phase::ProbeBandwidth => {
|
||||||
|
// BBR-style: target cwnd = BDP * gain
|
||||||
|
let bdp = self.bandwidth_delay_product();
|
||||||
|
// Apply gain of 1.25 during probe bandwidth
|
||||||
|
let target = (bdp * 5 / 4).max(MIN_CWND_PACKETS * self.mtu);
|
||||||
|
// Smooth transition
|
||||||
|
if self.cwnd < target {
|
||||||
|
self.cwnd = self.cwnd.saturating_add(bytes * self.mtu / self.cwnd.max(1));
|
||||||
|
} else {
|
||||||
|
self.cwnd = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Phase::ProbeRtt => {
|
||||||
|
// Drain down to 4 packets to measure true min RTT
|
||||||
|
self.cwnd = MIN_CWND_PACKETS * self.mtu * 2;
|
||||||
|
if let Some(timer) = self.probe_rtt_timer {
|
||||||
|
if now.duration_since(timer) >= PROBE_RTT_DURATION {
|
||||||
|
// ProbeRTT complete, return to ProbeBandwidth
|
||||||
|
self.phase = Phase::ProbeBandwidth;
|
||||||
|
self.probe_rtt_timer = None;
|
||||||
|
let bdp = self.bandwidth_delay_product();
|
||||||
|
self.cwnd = bdp.max(MIN_CWND_PACKETS * self.mtu);
|
||||||
|
tracing::debug!(cwnd = self.cwnd, min_rtt = ?self.min_rtt, "congestion: probe RTT complete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodically enter ProbeRTT to refresh min_rtt
|
||||||
|
if now.duration_since(self.min_rtt_stamp) >= MIN_RTT_EXPIRY && self.phase != Phase::ProbeRtt {
|
||||||
|
self.phase = Phase::ProbeRtt;
|
||||||
|
self.probe_rtt_timer = Some(now);
|
||||||
|
tracing::debug!("congestion: entering probe RTT phase");
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update_pacing_rate();
|
||||||
|
self.last_ack_time = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a loss event.
|
||||||
|
pub fn on_loss(&mut self, bytes_lost: u64) {
|
||||||
|
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes_lost);
|
||||||
|
self.loss_count += 1;
|
||||||
|
|
||||||
|
match self.phase {
|
||||||
|
Phase::SlowStart => {
|
||||||
|
// Exit slow start, set ssthresh to half of cwnd
|
||||||
|
self.ssthresh = self.cwnd / 2;
|
||||||
|
self.cwnd = self.ssthresh.max(MIN_CWND_PACKETS * self.mtu);
|
||||||
|
self.phase = Phase::ProbeBandwidth;
|
||||||
|
tracing::debug!(cwnd = self.cwnd, ssthresh = self.ssthresh, "congestion: loss during slow start");
|
||||||
|
}
|
||||||
|
Phase::ProbeBandwidth => {
|
||||||
|
// Multiplicative decrease: cwnd *= 0.7 (BBR-style, less aggressive than Cubic's 0.5)
|
||||||
|
self.cwnd = (self.cwnd * 7 / 10).max(MIN_CWND_PACKETS * self.mtu);
|
||||||
|
tracing::debug!(cwnd = self.cwnd, "congestion: loss, cwnd reduced");
|
||||||
|
}
|
||||||
|
Phase::ProbeRtt => {
|
||||||
|
// Don't react to loss during ProbeRTT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update_pacing_rate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called periodically to update state.
|
||||||
|
pub fn on_tick(&mut self) {
|
||||||
|
// Nothing special needed per-tick -- state updates happen on ACK/loss
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn update_rtt(&mut self, rtt: Duration, now: Instant) {
|
||||||
|
// Track windowed minimum RTT
|
||||||
|
if rtt < self.min_rtt || now.duration_since(self.min_rtt_stamp) >= MIN_RTT_EXPIRY {
|
||||||
|
self.min_rtt = rtt;
|
||||||
|
self.min_rtt_stamp = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep sample history
|
||||||
|
self.rtt_samples.push_back(RttSample { rtt, time: now });
|
||||||
|
while self.rtt_samples.len() > MAX_SAMPLES {
|
||||||
|
self.rtt_samples.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_bandwidth(&mut self, acked_bytes: u64, now: Instant) {
|
||||||
|
let elapsed = now.duration_since(self.last_ack_time);
|
||||||
|
if elapsed.as_micros() > 0 {
|
||||||
|
let bw = acked_bytes * 1_000_000 / elapsed.as_micros() as u64;
|
||||||
|
if bw > self.max_bandwidth {
|
||||||
|
self.max_bandwidth = bw;
|
||||||
|
}
|
||||||
|
self.bw_samples.push_back(BwSample { bytes_per_sec: bw, time: now });
|
||||||
|
while self.bw_samples.len() > MAX_SAMPLES {
|
||||||
|
self.bw_samples.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bandwidth_delay_product(&self) -> u64 {
|
||||||
|
// BDP = max_bandwidth * min_rtt
|
||||||
|
let bw = if self.max_bandwidth > 0 {
|
||||||
|
self.max_bandwidth
|
||||||
|
} else {
|
||||||
|
// Fallback: assume 10 Mbps
|
||||||
|
1_250_000
|
||||||
|
};
|
||||||
|
let rtt_secs = self.min_rtt.as_secs_f64();
|
||||||
|
(bw as f64 * rtt_secs) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_pacing_rate(&mut self) {
|
||||||
|
// Pacing rate = cwnd / min_rtt (with gain)
|
||||||
|
let rtt_us = self.min_rtt.as_micros().max(1) as u64;
|
||||||
|
self.pacing_rate = self.cwnd * 1_000_000 / rtt_us;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_initial_state() {
|
||||||
|
let cc = CongestionController::new(1200);
|
||||||
|
assert_eq!(cc.cwnd(), 12000); // 10 * 1200
|
||||||
|
assert!(cc.can_send());
|
||||||
|
assert_eq!(cc.cwnd_packets(), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_slow_start_growth() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
// Simulate sending and ACKing
|
||||||
|
cc.on_send(1200);
|
||||||
|
cc.on_ack(1200, Duration::from_millis(50));
|
||||||
|
// cwnd should grow
|
||||||
|
assert!(cc.cwnd() > 12000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_loss_reduces_cwnd() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
let initial = cc.cwnd();
|
||||||
|
cc.on_loss(1200);
|
||||||
|
assert!(cc.cwnd() < initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_can_send_limits() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
// Send until cwnd is exhausted
|
||||||
|
for _ in 0..10 {
|
||||||
|
cc.on_send(1200);
|
||||||
|
}
|
||||||
|
assert!(!cc.can_send()); // cwnd exhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_retransmit_budget() {
|
||||||
|
let cc = CongestionController::new(1200);
|
||||||
|
let budget = cc.retransmit_budget();
|
||||||
|
assert!(budget >= 2);
|
||||||
|
assert!(budget <= 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rtt_tracking() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
cc.on_send(1200);
|
||||||
|
cc.on_ack(1200, Duration::from_millis(25));
|
||||||
|
assert_eq!(cc.smoothed_rtt(), Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// OSTP Hybrid Key Exchange — STUB / NOT IN USE
|
|
||||||
// =============================================================================
|
|
||||||
//
|
|
||||||
// This module is a placeholder for future post-quantum key exchange.
|
|
||||||
// The actual key exchange is handled by the Noise NNpsk0 handshake in noise.rs.
|
|
||||||
//
|
|
||||||
// When ML-KEM (CRYSTALS-Kyber) support is added, this module will provide:
|
|
||||||
// 1. X25519 ephemeral DH (classical security)
|
|
||||||
// 2. ML-KEM-768 encapsulation (post-quantum security)
|
|
||||||
// 3. Combined shared secret = SHA-256(x25519_secret || ml_kem_secret)
|
|
||||||
//
|
|
||||||
// Until then, DO NOT use this module in production — it provides zero
|
|
||||||
// post-quantum security. The Noise handshake in noise.rs is the only
|
|
||||||
// active key exchange mechanism.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
/// Placeholder shared secret output.
|
|
||||||
/// NOT USED by the protocol — provided for future API compatibility only.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct HybridSharedSecret {
|
|
||||||
pub x25519_pubkey: [u8; 32],
|
|
||||||
pub pq_ciphertext: Vec<u8>,
|
|
||||||
pub combined_secret: [u8; 32],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Placeholder hybrid key exchange.
|
|
||||||
/// The PQ component is a no-op stub. See module-level documentation.
|
|
||||||
pub struct HybridKex;
|
|
||||||
|
|
||||||
impl HybridKex {
|
|
||||||
/// Generate a hybrid key exchange offer.
|
|
||||||
///
|
|
||||||
/// # Security Warning
|
|
||||||
/// The post-quantum component is a **stub** — `pq_ciphertext` is all zeros.
|
|
||||||
/// This function exists solely for API scaffolding. Do not rely on it for
|
|
||||||
/// post-quantum security.
|
|
||||||
pub fn client_offer() -> HybridSharedSecret {
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
use x25519_dalek::{EphemeralSecret, PublicKey};
|
|
||||||
|
|
||||||
let secret = EphemeralSecret::random_from_rng(OsRng);
|
|
||||||
let pubkey = PublicKey::from(&secret);
|
|
||||||
|
|
||||||
// TODO: Replace with ML-KEM-768 encapsulation (crate `ml-kem`)
|
|
||||||
let pq_ciphertext = vec![0_u8; 1088];
|
|
||||||
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(pubkey.as_bytes());
|
|
||||||
hasher.update(&pq_ciphertext);
|
|
||||||
let digest = hasher.finalize();
|
|
||||||
|
|
||||||
let mut combined_secret = [0_u8; 32];
|
|
||||||
combined_secret.copy_from_slice(&digest[..32]);
|
|
||||||
|
|
||||||
HybridSharedSecret {
|
|
||||||
x25519_pubkey: *pubkey.as_bytes(),
|
|
||||||
pq_ciphertext,
|
|
||||||
combined_secret,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
pub mod aead;
|
pub mod aead;
|
||||||
pub mod kex;
|
|
||||||
pub mod noise;
|
pub mod noise;
|
||||||
pub mod obfuscation;
|
pub mod obfuscation;
|
||||||
|
|
||||||
pub use aead::SessionCipher;
|
pub use aead::SessionCipher;
|
||||||
pub use kex::{HybridSharedSecret, HybridKex};
|
|
||||||
pub use noise::{NoiseRole, NoiseSession};
|
pub use noise::{NoiseRole, NoiseSession};
|
||||||
pub use obfuscation::{
|
pub use obfuscation::{
|
||||||
deobfuscate_header_inplace, deobfuscate_packet_inplace, obfuscate_packet_inplace,
|
deobfuscate_header_inplace, deobfuscate_packet_inplace, obfuscate_packet_inplace,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
pub mod congestion;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod framing;
|
pub mod framing;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod relay;
|
pub mod relay;
|
||||||
|
pub mod resumption;
|
||||||
|
|
||||||
pub use crypto::NoiseRole;
|
pub use crypto::NoiseRole;
|
||||||
pub use framing::{TrafficProfile, PaddingStrategy};
|
pub use framing::{TrafficProfile, PaddingStrategy};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ use thiserror::Error;
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::congestion::CongestionController;
|
||||||
use crate::crypto::{NoiseRole, NoiseSession, SessionCipher};
|
use crate::crypto::{NoiseRole, NoiseSession, SessionCipher};
|
||||||
use crate::framing::{AdaptivePadder, FrameHeader, FrameKind, FramedPacket, PaddingStrategy};
|
use crate::framing::{AdaptivePadder, FrameHeader, FrameKind, FramedPacket, PaddingStrategy};
|
||||||
|
|
||||||
|
|
@ -93,6 +94,8 @@ pub struct ProtocolMachine {
|
||||||
/// evicted from sent_history, this timer detects the deadlock and skips
|
/// evicted from sent_history, this timer detects the deadlock and skips
|
||||||
/// the gap to restore liveness.
|
/// the gap to restore liveness.
|
||||||
last_recv_advance: Instant,
|
last_recv_advance: Instant,
|
||||||
|
/// Congestion controller (BBR-inspired adaptive window)
|
||||||
|
cc: CongestionController,
|
||||||
/// Key-derived handshake padding range
|
/// Key-derived handshake padding range
|
||||||
handshake_pad_min: usize,
|
handshake_pad_min: usize,
|
||||||
handshake_pad_max: usize,
|
handshake_pad_max: usize,
|
||||||
|
|
@ -138,6 +141,7 @@ impl ProtocolMachine {
|
||||||
last_ack_sent: Instant::now(),
|
last_ack_sent: Instant::now(),
|
||||||
last_nack_sent: Instant::now() - Duration::from_secs(1),
|
last_nack_sent: Instant::now() - Duration::from_secs(1),
|
||||||
last_recv_advance: Instant::now(),
|
last_recv_advance: Instant::now(),
|
||||||
|
cc: CongestionController::new(1200),
|
||||||
handshake_pad_min: config.handshake_pad_min.max(8),
|
handshake_pad_min: config.handshake_pad_min.max(8),
|
||||||
handshake_pad_max: config.handshake_pad_max.max(config.handshake_pad_min + 16),
|
handshake_pad_max: config.handshake_pad_max.max(config.handshake_pad_min + 16),
|
||||||
})
|
})
|
||||||
|
|
@ -266,7 +270,7 @@ impl ProtocolMachine {
|
||||||
|
|
||||||
if nonce < self.expected_recv_nonce {
|
if nonce < self.expected_recv_nonce {
|
||||||
// Duplicate — the ACK we sent was likely lost or delayed.
|
// Duplicate — the ACK we sent was likely lost or delayed.
|
||||||
eprintln!("[ostp] Duplicate frame nonce={} (expected {}), forcing ACK", nonce, self.expected_recv_nonce);
|
tracing::debug!("Duplicate frame nonce={} (expected {}), forcing ACK", nonce, self.expected_recv_nonce);
|
||||||
if let Some(ack_frame) = self.force_build_ack()? {
|
if let Some(ack_frame) = self.force_build_ack()? {
|
||||||
return Ok(ProtocolAction::SendDatagram(ack_frame));
|
return Ok(ProtocolAction::SendDatagram(ack_frame));
|
||||||
}
|
}
|
||||||
|
|
@ -274,8 +278,7 @@ impl ProtocolMachine {
|
||||||
}
|
}
|
||||||
|
|
||||||
if nonce > self.expected_recv_nonce + self.max_reorder {
|
if nonce > self.expected_recv_nonce + self.max_reorder {
|
||||||
eprintln!(
|
tracing::debug!("Frame nonce={} exceeds max reorder window (expected={}, max_gap={}), sending NACK",
|
||||||
"[ostp] Frame nonce={} exceeds max reorder window (expected={}, max_gap={}), sending NACK",
|
|
||||||
nonce, self.expected_recv_nonce, self.max_reorder
|
nonce, self.expected_recv_nonce, self.max_reorder
|
||||||
);
|
);
|
||||||
if let Ok(nack_frame) = self.build_control_datagram(
|
if let Ok(nack_frame) = self.build_control_datagram(
|
||||||
|
|
@ -305,10 +308,13 @@ impl ProtocolMachine {
|
||||||
if packet.payload.len() >= 8 {
|
if packet.payload.len() >= 8 {
|
||||||
let req_nonce = u64::from_be_bytes(packet.payload[..8].try_into().unwrap());
|
let req_nonce = u64::from_be_bytes(packet.payload[..8].try_into().unwrap());
|
||||||
if let Some(cached_frame) = self.lookup_sent_frame(req_nonce) {
|
if let Some(cached_frame) = self.lookup_sent_frame(req_nonce) {
|
||||||
eprintln!("[ostp] NACK received: retransmitting nonce={}", req_nonce);
|
tracing::debug!("NACK received: retransmitting nonce={}", req_nonce);
|
||||||
|
self.cc.on_loss(cached_frame.len() as u64);
|
||||||
outbound_actions.push(ProtocolAction::SendDatagram(cached_frame));
|
outbound_actions.push(ProtocolAction::SendDatagram(cached_frame));
|
||||||
} else {
|
} else {
|
||||||
eprintln!("[ostp] NACK received: nonce={} not found in sent_history (evicted)", req_nonce);
|
tracing::debug!("NACK received: nonce={} not found in sent_history (evicted)", req_nonce);
|
||||||
|
// Estimate ~1200 bytes lost for evicted frames
|
||||||
|
self.cc.on_loss(1200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -323,7 +329,7 @@ impl ProtocolMachine {
|
||||||
ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload)
|
ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload)
|
||||||
}
|
}
|
||||||
FrameKind::Close => {
|
FrameKind::Close => {
|
||||||
eprintln!("[ostp] Received Close frame, terminating session");
|
tracing::info!("Received Close frame, terminating session");
|
||||||
self.state = OstpState::Closed;
|
self.state = OstpState::Closed;
|
||||||
ProtocolAction::Noop
|
ProtocolAction::Noop
|
||||||
}
|
}
|
||||||
|
|
@ -357,8 +363,7 @@ impl ProtocolMachine {
|
||||||
if self.reorder_buffer.len() < self.max_reorder_buffer {
|
if self.reorder_buffer.len() < self.max_reorder_buffer {
|
||||||
self.reorder_buffer.insert(nonce, action);
|
self.reorder_buffer.insert(nonce, action);
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
tracing::warn!("Reorder buffer full ({}/{}), dropping frame nonce={}",
|
||||||
"[ostp] Reorder buffer full ({}/{}), dropping frame nonce={}",
|
|
||||||
self.reorder_buffer.len(), self.max_reorder_buffer, nonce
|
self.reorder_buffer.len(), self.max_reorder_buffer, nonce
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -497,8 +502,7 @@ impl ProtocolMachine {
|
||||||
delivered += 1;
|
delivered += 1;
|
||||||
}
|
}
|
||||||
self.ack_pending = true;
|
self.ack_pending = true;
|
||||||
eprintln!(
|
tracing::debug!("Gap recovery: skipped {} lost frames, delivered {} buffered frames (reorder_buf={})",
|
||||||
"[ostp] Gap recovery: skipped {} lost frames, delivered {} buffered frames (reorder_buf={})",
|
|
||||||
skipped, delivered, self.reorder_buffer.len()
|
skipped, delivered, self.reorder_buffer.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -521,12 +525,12 @@ impl ProtocolMachine {
|
||||||
self.sent_history.retain(|f| !f.is_retransmittable || f.retries <= grace);
|
self.sent_history.retain(|f| !f.is_retransmittable || f.retries <= grace);
|
||||||
let evicted = before - self.sent_history.len();
|
let evicted = before - self.sent_history.len();
|
||||||
if evicted > 0 {
|
if evicted > 0 {
|
||||||
eprintln!("[ostp] Evicted {} zombie frames from sent_history (remaining={})", evicted, self.sent_history.len());
|
tracing::debug!("Evicted {} zombie frames from sent_history (remaining={})", evicted, self.sent_history.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Retransmit expired frames ────────────────────────────────
|
// ── Retransmit expired frames ────────────────────────────────
|
||||||
// Limit retransmits per tick to prevent bandwidth saturation
|
// Limit retransmits per tick to prevent bandwidth saturation
|
||||||
let mut retransmit_budget: usize = 8;
|
let mut retransmit_budget: usize = self.cc.retransmit_budget();
|
||||||
for frame in self.sent_history.iter_mut() {
|
for frame in self.sent_history.iter_mut() {
|
||||||
if retransmit_budget == 0 {
|
if retransmit_budget == 0 {
|
||||||
break;
|
break;
|
||||||
|
|
@ -657,8 +661,7 @@ impl ProtocolMachine {
|
||||||
});
|
});
|
||||||
if self.sent_history.len() > self.max_sent_history {
|
if self.sent_history.len() > self.max_sent_history {
|
||||||
let overflow = self.sent_history.len() - self.max_sent_history;
|
let overflow = self.sent_history.len() - self.max_sent_history;
|
||||||
eprintln!(
|
tracing::debug!("sent_history overflow: evicting {} oldest frames (cap={})",
|
||||||
"[ostp] sent_history overflow: evicting {} oldest frames (cap={})",
|
|
||||||
overflow, self.max_sent_history
|
overflow, self.max_sent_history
|
||||||
);
|
);
|
||||||
while self.sent_history.len() > self.max_sent_history {
|
while self.sent_history.len() > self.max_sent_history {
|
||||||
|
|
@ -668,7 +671,27 @@ impl ProtocolMachine {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn drop_acked_frames(&mut self, ranges: &[(u64, u64)]) {
|
fn drop_acked_frames(&mut self, ranges: &[(u64, u64)]) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut acked_bytes = 0u64;
|
||||||
|
let mut min_rtt = Duration::from_secs(60);
|
||||||
|
|
||||||
|
// Compute RTT from the oldest acked frame's send timestamp
|
||||||
|
for frame in self.sent_history.iter() {
|
||||||
|
if nonce_in_ranges(frame.nonce, ranges) {
|
||||||
|
acked_bytes += frame.bytes.len() as u64;
|
||||||
|
let rtt = now.duration_since(frame.last_sent);
|
||||||
|
if rtt < min_rtt {
|
||||||
|
min_rtt = rtt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.sent_history.retain(|frame| !nonce_in_ranges(frame.nonce, ranges));
|
self.sent_history.retain(|frame| !nonce_in_ranges(frame.nonce, ranges));
|
||||||
|
|
||||||
|
// Notify congestion controller
|
||||||
|
if acked_bytes > 0 {
|
||||||
|
self.cc.on_ack(acked_bytes, min_rtt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,3 +84,83 @@ fn decode_with_len(input: &[u8]) -> Result<&[u8]> {
|
||||||
}
|
}
|
||||||
Ok(&input[2..2 + len])
|
Ok(&input[2..2 + len])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connect_roundtrip() {
|
||||||
|
let msg = RelayMessage::Connect("example.com:443".to_string());
|
||||||
|
let encoded = msg.encode();
|
||||||
|
let decoded = RelayMessage::decode(&encoded).unwrap();
|
||||||
|
match decoded {
|
||||||
|
RelayMessage::Connect(addr) => assert_eq!(addr, "example.com:443"),
|
||||||
|
_ => panic!("expected Connect"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_data_roundtrip() {
|
||||||
|
let data = vec![1, 2, 3, 4, 5];
|
||||||
|
let msg = RelayMessage::Data(data.clone());
|
||||||
|
let encoded = msg.encode();
|
||||||
|
let decoded = RelayMessage::decode(&encoded).unwrap();
|
||||||
|
match decoded {
|
||||||
|
RelayMessage::Data(d) => assert_eq!(d, data),
|
||||||
|
_ => panic!("expected Data"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_simple_tags() {
|
||||||
|
assert_eq!(RelayMessage::KeepAlive.encode(), vec![3]);
|
||||||
|
assert_eq!(RelayMessage::Close.encode(), vec![4]);
|
||||||
|
assert_eq!(RelayMessage::ConnectOk.encode(), vec![5]);
|
||||||
|
|
||||||
|
assert!(matches!(RelayMessage::decode(&[3]).unwrap(), RelayMessage::KeepAlive));
|
||||||
|
assert!(matches!(RelayMessage::decode(&[4]).unwrap(), RelayMessage::Close));
|
||||||
|
assert!(matches!(RelayMessage::decode(&[5]).unwrap(), RelayMessage::ConnectOk));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_roundtrip() {
|
||||||
|
let msg = RelayMessage::Error("connection refused".to_string());
|
||||||
|
let encoded = msg.encode();
|
||||||
|
match RelayMessage::decode(&encoded).unwrap() {
|
||||||
|
RelayMessage::Error(e) => assert_eq!(e, "connection refused"),
|
||||||
|
_ => panic!("expected Error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ping_pong_roundtrip() {
|
||||||
|
let ts = 1234567890u64;
|
||||||
|
match RelayMessage::decode(&RelayMessage::Ping(ts).encode()).unwrap() {
|
||||||
|
RelayMessage::Ping(t) => assert_eq!(t, ts),
|
||||||
|
_ => panic!("expected Ping"),
|
||||||
|
}
|
||||||
|
match RelayMessage::decode(&RelayMessage::Pong(ts).encode()).unwrap() {
|
||||||
|
RelayMessage::Pong(t) => assert_eq!(t, ts),
|
||||||
|
_ => panic!("expected Pong"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_cases() {
|
||||||
|
assert!(RelayMessage::decode(&[]).is_err());
|
||||||
|
assert!(RelayMessage::decode(&[255]).is_err());
|
||||||
|
// Truncated: tag=1, len=5, only 2 bytes
|
||||||
|
assert!(RelayMessage::decode(&[1, 0, 5, b'a', b'b']).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_data_roundtrip() {
|
||||||
|
let encoded = RelayMessage::Data(vec![]).encode();
|
||||||
|
match RelayMessage::decode(&encoded).unwrap() {
|
||||||
|
RelayMessage::Data(d) => assert!(d.is_empty()),
|
||||||
|
_ => panic!("expected Data"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,307 @@
|
||||||
|
//! 0-RTT Session Resumption for OSTP.
|
||||||
|
//!
|
||||||
|
//! When a client has previously connected to a server, it can cache
|
||||||
|
//! a "session ticket" that allows it to send encrypted data in the
|
||||||
|
//! very first packet — eliminating the handshake round-trip entirely.
|
||||||
|
//!
|
||||||
|
//! How it works:
|
||||||
|
//! 1. After a successful handshake, the server issues a SessionTicket
|
||||||
|
//! containing enough state to resume the session.
|
||||||
|
//! 2. The client stores the ticket locally (encrypted with the PSK).
|
||||||
|
//! 3. On reconnection, the client sends a ResumptionRequest with the
|
||||||
|
//! ticket + early data in the first packet.
|
||||||
|
//! 4. The server validates the ticket and immediately begins processing
|
||||||
|
//! data, achieving 0-RTT.
|
||||||
|
//!
|
||||||
|
//! Security considerations:
|
||||||
|
//! - Tickets have a TTL (default 3600s) to limit replay window.
|
||||||
|
//! - The server maintains a ticket nonce set to prevent replay.
|
||||||
|
//! - Early data is idempotent by protocol design (relay CONNECT is safe
|
||||||
|
//! because duplicate CONNECTs to the same target are no-ops).
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// A session ticket that allows 0-RTT resumption.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionTicket {
|
||||||
|
/// Unique ticket identifier (prevents replay)
|
||||||
|
pub ticket_id: [u8; 16],
|
||||||
|
/// Server session ID to resume
|
||||||
|
pub session_id: u32,
|
||||||
|
/// Derived cipher key for early data
|
||||||
|
pub cipher_key: [u8; 32],
|
||||||
|
/// Timestamp of issuance (seconds since epoch)
|
||||||
|
pub issued_at: u64,
|
||||||
|
/// Time-to-live in seconds
|
||||||
|
pub ttl: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum ticket age (1 hour default)
|
||||||
|
const DEFAULT_TICKET_TTL: u64 = 3600;
|
||||||
|
/// Maximum tickets in the anti-replay set
|
||||||
|
const MAX_REPLAY_SET: usize = 10000;
|
||||||
|
|
||||||
|
impl SessionTicket {
|
||||||
|
/// Create a new session ticket from the transport key material.
|
||||||
|
pub fn new(session_id: u32, transport_key: &[u8; 32], psk: &[u8; 32]) -> Self {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
// Derive ticket ID from key material + timestamp
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(transport_key);
|
||||||
|
hasher.update(&now.to_be_bytes());
|
||||||
|
hasher.update(b"ostp-ticket-id");
|
||||||
|
let hash = hasher.finalize();
|
||||||
|
let mut ticket_id = [0u8; 16];
|
||||||
|
ticket_id.copy_from_slice(&hash[..16]);
|
||||||
|
|
||||||
|
// Derive cipher key for early data from PSK + ticket
|
||||||
|
let mut key_hasher = Sha256::new();
|
||||||
|
key_hasher.update(psk);
|
||||||
|
key_hasher.update(&ticket_id);
|
||||||
|
key_hasher.update(b"ostp-early-data-key");
|
||||||
|
let cipher_key_hash = key_hasher.finalize();
|
||||||
|
let mut cipher_key = [0u8; 32];
|
||||||
|
cipher_key.copy_from_slice(&cipher_key_hash);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
ticket_id,
|
||||||
|
session_id,
|
||||||
|
cipher_key,
|
||||||
|
issued_at: now,
|
||||||
|
ttl: DEFAULT_TICKET_TTL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the ticket has expired.
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
now > self.issued_at + self.ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize the ticket to bytes for storage/transmission.
|
||||||
|
/// Wire format: [ticket_id:16][session_id:4][cipher_key:32][issued_at:8][ttl:8]
|
||||||
|
pub fn to_bytes(&self) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(68);
|
||||||
|
out.extend_from_slice(&self.ticket_id);
|
||||||
|
out.extend_from_slice(&self.session_id.to_be_bytes());
|
||||||
|
out.extend_from_slice(&self.cipher_key);
|
||||||
|
out.extend_from_slice(&self.issued_at.to_be_bytes());
|
||||||
|
out.extend_from_slice(&self.ttl.to_be_bytes());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize a ticket from bytes.
|
||||||
|
pub fn from_bytes(data: &[u8]) -> Option<Self> {
|
||||||
|
if data.len() < 68 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut ticket_id = [0u8; 16];
|
||||||
|
ticket_id.copy_from_slice(&data[0..16]);
|
||||||
|
|
||||||
|
let session_id = u32::from_be_bytes(data[16..20].try_into().ok()?);
|
||||||
|
|
||||||
|
let mut cipher_key = [0u8; 32];
|
||||||
|
cipher_key.copy_from_slice(&data[20..52]);
|
||||||
|
|
||||||
|
let issued_at = u64::from_be_bytes(data[52..60].try_into().ok()?);
|
||||||
|
let ttl = u64::from_be_bytes(data[60..68].try_into().ok()?);
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
ticket_id,
|
||||||
|
session_id,
|
||||||
|
cipher_key,
|
||||||
|
issued_at,
|
||||||
|
ttl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt the ticket with a PSK for client-side storage.
|
||||||
|
/// Uses a simple XOR cipher with HMAC-SHA256 derived key.
|
||||||
|
pub fn encrypt(&self, psk: &[u8; 32]) -> Vec<u8> {
|
||||||
|
let raw = self.to_bytes();
|
||||||
|
let mut enc_key_hasher = Sha256::new();
|
||||||
|
enc_key_hasher.update(psk);
|
||||||
|
enc_key_hasher.update(b"ostp-ticket-encryption");
|
||||||
|
let enc_key = enc_key_hasher.finalize();
|
||||||
|
|
||||||
|
let mut encrypted = raw.clone();
|
||||||
|
for (i, byte) in encrypted.iter_mut().enumerate() {
|
||||||
|
*byte ^= enc_key[i % 32];
|
||||||
|
}
|
||||||
|
encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a ticket from encrypted bytes.
|
||||||
|
pub fn decrypt(encrypted: &[u8], psk: &[u8; 32]) -> Option<Self> {
|
||||||
|
let mut enc_key_hasher = Sha256::new();
|
||||||
|
enc_key_hasher.update(psk);
|
||||||
|
enc_key_hasher.update(b"ostp-ticket-encryption");
|
||||||
|
let enc_key = enc_key_hasher.finalize();
|
||||||
|
|
||||||
|
let mut decrypted = encrypted.to_vec();
|
||||||
|
for (i, byte) in decrypted.iter_mut().enumerate() {
|
||||||
|
*byte ^= enc_key[i % 32];
|
||||||
|
}
|
||||||
|
Self::from_bytes(&decrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-side anti-replay guard for session tickets.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct TicketValidator {
|
||||||
|
/// Set of consumed ticket IDs (prevents replay)
|
||||||
|
consumed: HashSet<[u8; 16]>,
|
||||||
|
/// PSK for ticket validation
|
||||||
|
psk: [u8; 32],
|
||||||
|
/// Maximum age for tickets
|
||||||
|
max_age: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TicketValidator {
|
||||||
|
pub fn new(psk: [u8; 32]) -> Self {
|
||||||
|
Self {
|
||||||
|
consumed: HashSet::new(),
|
||||||
|
psk,
|
||||||
|
max_age: Duration::from_secs(DEFAULT_TICKET_TTL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a ticket from the client. Returns the ticket if valid,
|
||||||
|
/// or None if expired, replayed, or invalid.
|
||||||
|
pub fn validate(&mut self, encrypted_ticket: &[u8]) -> Option<SessionTicket> {
|
||||||
|
let ticket = SessionTicket::decrypt(encrypted_ticket, &self.psk)?;
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
if ticket.is_expired() {
|
||||||
|
tracing::debug!("0-RTT ticket rejected: expired");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check replay
|
||||||
|
if self.consumed.contains(&ticket.ticket_id) {
|
||||||
|
tracing::warn!("0-RTT ticket rejected: replay detected");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept and mark as consumed
|
||||||
|
self.consumed.insert(ticket.ticket_id);
|
||||||
|
|
||||||
|
// Garbage collection: remove old entries when set grows too large
|
||||||
|
if self.consumed.len() > MAX_REPLAY_SET {
|
||||||
|
// Simple strategy: clear the entire set. This is safe because
|
||||||
|
// expired tickets would fail the expiry check anyway.
|
||||||
|
self.consumed.clear();
|
||||||
|
self.consumed.insert(ticket.ticket_id);
|
||||||
|
tracing::debug!("0-RTT replay set cleared (overflow)");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!("0-RTT ticket accepted: session_id={}", ticket.session_id);
|
||||||
|
Some(ticket)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue a new ticket for a completed session.
|
||||||
|
pub fn issue_ticket(&self, session_id: u32, transport_key: &[u8; 32]) -> SessionTicket {
|
||||||
|
SessionTicket::new(session_id, transport_key, &self.psk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ticket_serialize_roundtrip() {
|
||||||
|
let psk = [42u8; 32];
|
||||||
|
let key = [1u8; 32];
|
||||||
|
let ticket = SessionTicket::new(12345, &key, &psk);
|
||||||
|
|
||||||
|
let bytes = ticket.to_bytes();
|
||||||
|
let restored = SessionTicket::from_bytes(&bytes).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(ticket.ticket_id, restored.ticket_id);
|
||||||
|
assert_eq!(ticket.session_id, restored.session_id);
|
||||||
|
assert_eq!(ticket.cipher_key, restored.cipher_key);
|
||||||
|
assert_eq!(ticket.issued_at, restored.issued_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ticket_encrypt_decrypt() {
|
||||||
|
let psk = [42u8; 32];
|
||||||
|
let key = [1u8; 32];
|
||||||
|
let ticket = SessionTicket::new(99, &key, &psk);
|
||||||
|
|
||||||
|
let encrypted = ticket.encrypt(&psk);
|
||||||
|
let decrypted = SessionTicket::decrypt(&encrypted, &psk).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(ticket.ticket_id, decrypted.ticket_id);
|
||||||
|
assert_eq!(ticket.session_id, decrypted.session_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ticket_wrong_psk_fails() {
|
||||||
|
let psk = [42u8; 32];
|
||||||
|
let wrong_psk = [99u8; 32];
|
||||||
|
let key = [1u8; 32];
|
||||||
|
let ticket = SessionTicket::new(1, &key, &psk);
|
||||||
|
let encrypted = ticket.encrypt(&psk);
|
||||||
|
|
||||||
|
// Decrypting with wrong PSK produces garbage, from_bytes should
|
||||||
|
// still return Some but ticket_id won't match
|
||||||
|
let decrypted = SessionTicket::decrypt(&encrypted, &wrong_psk);
|
||||||
|
// It may parse but the data will be wrong
|
||||||
|
if let Some(d) = decrypted {
|
||||||
|
assert_ne!(d.ticket_id, ticket.ticket_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ticket_not_expired() {
|
||||||
|
let psk = [42u8; 32];
|
||||||
|
let key = [1u8; 32];
|
||||||
|
let ticket = SessionTicket::new(1, &key, &psk);
|
||||||
|
assert!(!ticket.is_expired());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validator_replay_protection() {
|
||||||
|
let psk = [42u8; 32];
|
||||||
|
let key = [1u8; 32];
|
||||||
|
let mut validator = TicketValidator::new(psk);
|
||||||
|
|
||||||
|
let ticket = validator.issue_ticket(1, &key);
|
||||||
|
let encrypted = ticket.encrypt(&psk);
|
||||||
|
|
||||||
|
// First use should succeed
|
||||||
|
assert!(validator.validate(&encrypted).is_some());
|
||||||
|
|
||||||
|
// Replay should fail
|
||||||
|
assert!(validator.validate(&encrypted).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validator_different_tickets() {
|
||||||
|
let psk = [42u8; 32];
|
||||||
|
let mut validator = TicketValidator::new(psk);
|
||||||
|
|
||||||
|
let ticket1 = validator.issue_ticket(1, &[1u8; 32]);
|
||||||
|
let ticket2 = validator.issue_ticket(2, &[2u8; 32]);
|
||||||
|
|
||||||
|
assert!(validator.validate(&ticket1.encrypt(&psk)).is_some());
|
||||||
|
assert!(validator.validate(&ticket2.encrypt(&psk)).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncated_ticket_fails() {
|
||||||
|
assert!(SessionTicket::from_bytes(&[0u8; 10]).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,3 +14,5 @@ serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
socket2 = "0.6.3"
|
socket2 = "0.6.3"
|
||||||
|
axum = "0.8"
|
||||||
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,378 @@
|
||||||
|
//! Management REST API for OSTP server.
|
||||||
|
//!
|
||||||
|
//! Provides endpoints for third-party panels (like 3x-ui) to manage users,
|
||||||
|
//! query traffic statistics, and control the server.
|
||||||
|
//!
|
||||||
|
//! ## Endpoints
|
||||||
|
//!
|
||||||
|
//! - `GET /api/server/status` -- Server status (uptime, sessions, version)
|
||||||
|
//! - `GET /api/users` -- List all users with traffic stats
|
||||||
|
//! - `GET /api/users/:key` -- Single user stats
|
||||||
|
//! - `POST /api/users` -- Create new access key
|
||||||
|
//! - `DELETE /api/users/:key` -- Remove access key
|
||||||
|
//! - `PUT /api/users/:key/limit` -- Set traffic limit for a user
|
||||||
|
//! - `POST /api/users/:key/reset` -- Reset user traffic counters
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::{delete, get, post, put},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
|
use crate::dispatcher::{UserStats, UserStatsSnapshot};
|
||||||
|
|
||||||
|
// ── Shared state for API handlers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// API server shared state. Held behind Arc for axum handlers.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ApiState {
|
||||||
|
pub access_keys: Arc<RwLock<HashMap<String, ()>>>,
|
||||||
|
pub user_stats: Arc<RwLock<HashMap<String, Arc<UserStats>>>>,
|
||||||
|
pub start_time: Instant,
|
||||||
|
pub api_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API configuration ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct ApiConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub bind: String,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ApiConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
bind: "127.0.0.1:9090".to_string(),
|
||||||
|
token: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request/Response types ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ServerStatus {
|
||||||
|
version: &'static str,
|
||||||
|
uptime_seconds: u64,
|
||||||
|
active_users: usize,
|
||||||
|
total_users: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateUserRequest {
|
||||||
|
pub access_key: Option<String>,
|
||||||
|
pub limit_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SetLimitRequest {
|
||||||
|
pub limit_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ApiResponse<T: Serialize> {
|
||||||
|
ok: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
data: Option<T>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Serialize> ApiResponse<T> {
|
||||||
|
fn success(data: T) -> Json<Self> {
|
||||||
|
Json(Self { ok: true, data: Some(data), error: None })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_error<T: Serialize>(msg: &str) -> (StatusCode, Json<ApiResponse<T>>) {
|
||||||
|
(StatusCode::BAD_REQUEST, Json(ApiResponse { ok: false, data: None, error: Some(msg.to_string()) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_unauthorized<T: Serialize>() -> (StatusCode, Json<ApiResponse<T>>) {
|
||||||
|
(StatusCode::UNAUTHORIZED, Json(ApiResponse { ok: false, data: None, error: Some("unauthorized".to_string()) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API router ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn create_api_router(state: ApiState) -> Router {
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any);
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/api/server/status", get(handle_status))
|
||||||
|
.route("/api/users", get(handle_list_users))
|
||||||
|
.route("/api/users", post(handle_create_user))
|
||||||
|
.route("/api/users/{key}", get(handle_get_user))
|
||||||
|
.route("/api/users/{key}", delete(handle_delete_user))
|
||||||
|
.route("/api/users/{key}/limit", put(handle_set_limit))
|
||||||
|
.route("/api/users/{key}/reset", post(handle_reset_stats))
|
||||||
|
.layer(cors)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the Management API server on the configured bind address.
|
||||||
|
pub async fn start_api_server(
|
||||||
|
config: ApiConfig,
|
||||||
|
access_keys: Arc<RwLock<HashMap<String, ()>>>,
|
||||||
|
user_stats: Arc<RwLock<HashMap<String, Arc<UserStats>>>>,
|
||||||
|
) {
|
||||||
|
let state = ApiState {
|
||||||
|
access_keys,
|
||||||
|
user_stats,
|
||||||
|
start_time: Instant::now(),
|
||||||
|
api_token: config.token.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = create_api_router(state);
|
||||||
|
|
||||||
|
let listener = match tokio::net::TcpListener::bind(&config.bind).await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Management API failed to bind on {}: {}", config.bind, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("Management API listening on {}", config.bind);
|
||||||
|
|
||||||
|
if let Err(e) = axum::serve(listener, app).await {
|
||||||
|
tracing::error!("Management API error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Middleware: token check ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn check_token(state: &ApiState, headers: &axum::http::HeaderMap) -> bool {
|
||||||
|
if state.api_token.is_empty() {
|
||||||
|
return true; // No auth required if token is empty
|
||||||
|
}
|
||||||
|
match headers.get("authorization") {
|
||||||
|
Some(value) => {
|
||||||
|
let val = value.to_str().unwrap_or("");
|
||||||
|
val == format!("Bearer {}", state.api_token) || val == state.api_token
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn handle_status(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !check_token(&state, &headers) {
|
||||||
|
return api_unauthorized::<ServerStatus>();
|
||||||
|
}
|
||||||
|
|
||||||
|
let keys = state.access_keys.read().unwrap();
|
||||||
|
let stats = state.user_stats.read().unwrap();
|
||||||
|
let online = stats.values()
|
||||||
|
.filter(|us| {
|
||||||
|
let total = us.bytes_up.load(Ordering::Relaxed) + us.bytes_down.load(Ordering::Relaxed);
|
||||||
|
total > 0
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let status = ServerStatus {
|
||||||
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
|
uptime_seconds: state.start_time.elapsed().as_secs(),
|
||||||
|
active_users: online,
|
||||||
|
total_users: keys.len(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(StatusCode::OK, ApiResponse::success(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_list_users(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !check_token(&state, &headers) {
|
||||||
|
return api_unauthorized::<Vec<UserStatsSnapshot>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
let keys = state.access_keys.read().unwrap();
|
||||||
|
let stats = state.user_stats.read().unwrap();
|
||||||
|
|
||||||
|
let mut users: Vec<UserStatsSnapshot> = keys.keys().map(|key| {
|
||||||
|
if let Some(us) = stats.get(key) {
|
||||||
|
UserStatsSnapshot {
|
||||||
|
access_key: key.clone(),
|
||||||
|
bytes_up: us.bytes_up.load(Ordering::Relaxed),
|
||||||
|
bytes_down: us.bytes_down.load(Ordering::Relaxed),
|
||||||
|
connections: us.connections.load(Ordering::Relaxed),
|
||||||
|
limit_bytes: us.limit_bytes,
|
||||||
|
online: true, // Simplified; real check requires session map
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
UserStatsSnapshot {
|
||||||
|
access_key: key.clone(),
|
||||||
|
bytes_up: 0,
|
||||||
|
bytes_down: 0,
|
||||||
|
connections: 0,
|
||||||
|
limit_bytes: None,
|
||||||
|
online: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
users.sort_by(|a, b| b.bytes_down.cmp(&a.bytes_down));
|
||||||
|
|
||||||
|
(StatusCode::OK, ApiResponse::success(users))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_get_user(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !check_token(&state, &headers) {
|
||||||
|
return api_unauthorized::<UserStatsSnapshot>();
|
||||||
|
}
|
||||||
|
|
||||||
|
let keys = state.access_keys.read().unwrap();
|
||||||
|
if !keys.contains_key(&key) {
|
||||||
|
return api_error("user not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
let stats = state.user_stats.read().unwrap();
|
||||||
|
let snapshot = if let Some(us) = stats.get(&key) {
|
||||||
|
UserStatsSnapshot {
|
||||||
|
access_key: key.clone(),
|
||||||
|
bytes_up: us.bytes_up.load(Ordering::Relaxed),
|
||||||
|
bytes_down: us.bytes_down.load(Ordering::Relaxed),
|
||||||
|
connections: us.connections.load(Ordering::Relaxed),
|
||||||
|
limit_bytes: us.limit_bytes,
|
||||||
|
online: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
UserStatsSnapshot {
|
||||||
|
access_key: key.clone(),
|
||||||
|
bytes_up: 0,
|
||||||
|
bytes_down: 0,
|
||||||
|
connections: 0,
|
||||||
|
limit_bytes: None,
|
||||||
|
online: false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(StatusCode::OK, ApiResponse::success(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_create_user(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Json(body): Json<CreateUserRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !check_token(&state, &headers) {
|
||||||
|
return api_unauthorized::<String>();
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = body.access_key.unwrap_or_else(|| {
|
||||||
|
use rand::RngCore;
|
||||||
|
let mut buf = [0u8; 16];
|
||||||
|
rand::thread_rng().fill_bytes(&mut buf);
|
||||||
|
buf.iter().map(|b| format!("{:02x}", b)).collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut keys = state.access_keys.write().unwrap();
|
||||||
|
keys.insert(key.clone(), ());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(limit) = body.limit_bytes {
|
||||||
|
let mut stats = state.user_stats.write().unwrap();
|
||||||
|
stats.insert(key.clone(), Arc::new(UserStats::new(Some(limit))));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("API: created user key {}", &key[..8.min(key.len())]);
|
||||||
|
(StatusCode::OK, ApiResponse::success(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_delete_user(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !check_token(&state, &headers) {
|
||||||
|
return api_unauthorized::<bool>();
|
||||||
|
}
|
||||||
|
|
||||||
|
let removed = {
|
||||||
|
let mut keys = state.access_keys.write().unwrap();
|
||||||
|
keys.remove(&key).is_some()
|
||||||
|
};
|
||||||
|
|
||||||
|
if removed {
|
||||||
|
let mut stats = state.user_stats.write().unwrap();
|
||||||
|
stats.remove(&key);
|
||||||
|
tracing::info!("API: deleted user key {}", &key[..8.min(key.len())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
(StatusCode::OK, ApiResponse::success(removed))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_set_limit(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
Json(body): Json<SetLimitRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !check_token(&state, &headers) {
|
||||||
|
return api_unauthorized::<bool>();
|
||||||
|
}
|
||||||
|
|
||||||
|
let keys = state.access_keys.read().unwrap();
|
||||||
|
if !keys.contains_key(&key) {
|
||||||
|
return api_error("user not found");
|
||||||
|
}
|
||||||
|
drop(keys);
|
||||||
|
|
||||||
|
let mut stats = state.user_stats.write().unwrap();
|
||||||
|
let entry = stats.entry(key.clone())
|
||||||
|
.or_insert_with(|| Arc::new(UserStats::new(body.limit_bytes)));
|
||||||
|
|
||||||
|
*entry = Arc::new(UserStats {
|
||||||
|
bytes_up: AtomicU64::new(entry.bytes_up.load(Ordering::Relaxed)),
|
||||||
|
bytes_down: AtomicU64::new(entry.bytes_down.load(Ordering::Relaxed)),
|
||||||
|
connections: AtomicU64::new(entry.connections.load(Ordering::Relaxed)),
|
||||||
|
limit_bytes: body.limit_bytes,
|
||||||
|
created_at: entry.created_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
(StatusCode::OK, ApiResponse::success(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_reset_stats(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !check_token(&state, &headers) {
|
||||||
|
return api_unauthorized::<bool>();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stats = state.user_stats.write().unwrap();
|
||||||
|
if let Some(us) = stats.get(&key) {
|
||||||
|
let limit = us.limit_bytes;
|
||||||
|
stats.insert(key.clone(), Arc::new(UserStats::new(limit)));
|
||||||
|
(StatusCode::OK, ApiResponse::success(true))
|
||||||
|
} else {
|
||||||
|
api_error("user not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,9 +4,10 @@ use ostp_core::{OstpEvent, ProtocolAction, ProtocolConfig, ProtocolMachine};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
/// Maximum number of concurrent authenticated sessions.
|
/// Maximum number of concurrent authenticated sessions.
|
||||||
/// Excess handshake attempts are silently dropped — no response, no state allocated.
|
/// Excess handshake attempts are silently dropped -- no response, no state allocated.
|
||||||
const MAX_SESSIONS: usize = 1024;
|
const MAX_SESSIONS: usize = 1024;
|
||||||
|
|
||||||
pub enum DispatchOutcome {
|
pub enum DispatchOutcome {
|
||||||
|
|
@ -18,11 +19,54 @@ pub enum DispatchOutcome {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-user traffic statistics.
|
||||||
|
pub struct UserStats {
|
||||||
|
pub bytes_up: AtomicU64,
|
||||||
|
pub bytes_down: AtomicU64,
|
||||||
|
pub connections: AtomicU64,
|
||||||
|
pub limit_bytes: Option<u64>,
|
||||||
|
pub created_at: std::time::SystemTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserStats {
|
||||||
|
pub fn new(limit: Option<u64>) -> Self {
|
||||||
|
Self {
|
||||||
|
bytes_up: AtomicU64::new(0),
|
||||||
|
bytes_down: AtomicU64::new(0),
|
||||||
|
connections: AtomicU64::new(0),
|
||||||
|
limit_bytes: limit,
|
||||||
|
created_at: std::time::SystemTime::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_over_limit(&self) -> bool {
|
||||||
|
if let Some(limit) = self.limit_bytes {
|
||||||
|
let total = self.bytes_up.load(Ordering::Relaxed)
|
||||||
|
+ self.bytes_down.load(Ordering::Relaxed);
|
||||||
|
total >= limit
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of user stats for API responses.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct UserStatsSnapshot {
|
||||||
|
pub access_key: String,
|
||||||
|
pub bytes_up: u64,
|
||||||
|
pub bytes_down: u64,
|
||||||
|
pub connections: u64,
|
||||||
|
pub limit_bytes: Option<u64>,
|
||||||
|
pub online: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PeerState {
|
pub struct PeerState {
|
||||||
pub machine: ProtocolMachine,
|
pub machine: ProtocolMachine,
|
||||||
pub last_addr: SocketAddr,
|
pub last_addr: SocketAddr,
|
||||||
pub obfuscation_key: [u8; 8],
|
pub obfuscation_key: [u8; 8],
|
||||||
pub last_seen: std::time::Instant,
|
pub last_seen: std::time::Instant,
|
||||||
|
pub access_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Dispatcher {
|
pub struct Dispatcher {
|
||||||
|
|
@ -30,11 +74,13 @@ pub struct Dispatcher {
|
||||||
addr_to_session: HashMap<SocketAddr, u32>,
|
addr_to_session: HashMap<SocketAddr, u32>,
|
||||||
machine_config: ProtocolConfig,
|
machine_config: ProtocolConfig,
|
||||||
access_keys: Arc<RwLock<HashMap<String, ()>>>,
|
access_keys: Arc<RwLock<HashMap<String, ()>>>,
|
||||||
|
user_stats: Arc<RwLock<HashMap<String, Arc<UserStats>>>>,
|
||||||
replay_cache: std::collections::HashMap<Vec<u8>, u64>,
|
replay_cache: std::collections::HashMap<Vec<u8>, u64>,
|
||||||
roaming_tokens: f64,
|
roaming_tokens: f64,
|
||||||
last_token_regen: std::time::Instant,
|
last_token_regen: std::time::Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl Dispatcher {
|
impl Dispatcher {
|
||||||
pub fn new(machine_config: ProtocolConfig, access_keys: Arc<RwLock<HashMap<String, ()>>>) -> Self {
|
pub fn new(machine_config: ProtocolConfig, access_keys: Arc<RwLock<HashMap<String, ()>>>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -42,12 +88,67 @@ impl Dispatcher {
|
||||||
addr_to_session: HashMap::new(),
|
addr_to_session: HashMap::new(),
|
||||||
machine_config,
|
machine_config,
|
||||||
access_keys,
|
access_keys,
|
||||||
|
user_stats: Arc::new(RwLock::new(HashMap::new())),
|
||||||
replay_cache: std::collections::HashMap::new(),
|
replay_cache: std::collections::HashMap::new(),
|
||||||
roaming_tokens: 50.0,
|
roaming_tokens: 50.0,
|
||||||
last_token_regen: std::time::Instant::now(),
|
last_token_regen: std::time::Instant::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a shared reference to user stats for the Management API.
|
||||||
|
pub fn user_stats_ref(&self) -> Arc<RwLock<HashMap<String, Arc<UserStats>>>> {
|
||||||
|
self.user_stats.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot all user stats for API responses.
|
||||||
|
pub fn snapshot_all_users(&self) -> Vec<UserStatsSnapshot> {
|
||||||
|
let stats = self.user_stats.read().unwrap();
|
||||||
|
let online_keys: std::collections::HashSet<String> = self.peer_machines.values()
|
||||||
|
.map(|ps| ps.access_key.clone())
|
||||||
|
.collect();
|
||||||
|
stats.iter().map(|(key, us)| UserStatsSnapshot {
|
||||||
|
access_key: key.clone(),
|
||||||
|
bytes_up: us.bytes_up.load(Ordering::Relaxed),
|
||||||
|
bytes_down: us.bytes_down.load(Ordering::Relaxed),
|
||||||
|
connections: us.connections.load(Ordering::Relaxed),
|
||||||
|
limit_bytes: us.limit_bytes,
|
||||||
|
online: online_keys.contains(key),
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get or create stats entry for a user key.
|
||||||
|
fn get_or_create_user_stats(&self, key: &str) -> Arc<UserStats> {
|
||||||
|
let stats = self.user_stats.read().unwrap();
|
||||||
|
if let Some(existing) = stats.get(key) {
|
||||||
|
return existing.clone();
|
||||||
|
}
|
||||||
|
drop(stats);
|
||||||
|
let mut stats = self.user_stats.write().unwrap();
|
||||||
|
stats.entry(key.to_string())
|
||||||
|
.or_insert_with(|| Arc::new(UserStats::new(None)))
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set traffic limit for a user.
|
||||||
|
pub fn set_user_limit(&self, key: &str, limit: Option<u64>) {
|
||||||
|
let mut stats = self.user_stats.write().unwrap();
|
||||||
|
let entry = stats.entry(key.to_string())
|
||||||
|
.or_insert_with(|| Arc::new(UserStats::new(limit)));
|
||||||
|
// Replace the entry with new limit (stats reset)
|
||||||
|
*entry = Arc::new(UserStats {
|
||||||
|
bytes_up: AtomicU64::new(entry.bytes_up.load(Ordering::Relaxed)),
|
||||||
|
bytes_down: AtomicU64::new(entry.bytes_down.load(Ordering::Relaxed)),
|
||||||
|
connections: AtomicU64::new(entry.connections.load(Ordering::Relaxed)),
|
||||||
|
limit_bytes: limit,
|
||||||
|
created_at: entry.created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active session count.
|
||||||
|
pub fn active_sessions(&self) -> usize {
|
||||||
|
self.peer_machines.len()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn on_datagram(&mut self, peer: SocketAddr, packet: Bytes) -> Result<DispatchOutcome> {
|
pub fn on_datagram(&mut self, peer: SocketAddr, packet: Bytes) -> Result<DispatchOutcome> {
|
||||||
if packet.len() < 4 {
|
if packet.len() < 4 {
|
||||||
return Ok(DispatchOutcome::Unauthorized);
|
return Ok(DispatchOutcome::Unauthorized);
|
||||||
|
|
@ -100,17 +201,21 @@ impl Dispatcher {
|
||||||
if let Some(session_id) = session_id_opt {
|
if let Some(session_id) = session_id_opt {
|
||||||
if let Some(peer_state) = self.peer_machines.get_mut(&session_id) {
|
if let Some(peer_state) = self.peer_machines.get_mut(&session_id) {
|
||||||
if peer_state.last_addr != peer {
|
if peer_state.last_addr != peer {
|
||||||
eprintln!("[ostp] Client roamed: session {} from {} to {}", session_id, peer_state.last_addr, peer);
|
tracing::info!("Client roamed: session {} from {} to {}", session_id, peer_state.last_addr, peer);
|
||||||
self.addr_to_session.remove(&peer_state.last_addr);
|
self.addr_to_session.remove(&peer_state.last_addr);
|
||||||
}
|
}
|
||||||
peer_state.last_addr = peer;
|
peer_state.last_addr = peer;
|
||||||
peer_state.last_seen = std::time::Instant::now();
|
peer_state.last_seen = std::time::Instant::now();
|
||||||
self.addr_to_session.insert(peer, session_id);
|
self.addr_to_session.insert(peer, session_id);
|
||||||
|
|
||||||
|
// Track inbound bytes per user
|
||||||
|
let key = peer_state.access_key.clone();
|
||||||
|
track_user_bytes_up(&self.user_stats, &key, packet.len() as u64);
|
||||||
|
|
||||||
let action = match peer_state.machine.on_event(OstpEvent::Inbound(packet)) {
|
let action = match peer_state.machine.on_event(OstpEvent::Inbound(packet)) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[ostp] Protocol error for session {}: {}", session_id, e);
|
tracing::warn!("Protocol error for session {}: {}", session_id, e);
|
||||||
return Ok(DispatchOutcome::Unauthorized);
|
return Ok(DispatchOutcome::Unauthorized);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -175,7 +280,7 @@ impl Dispatcher {
|
||||||
let mut machine = match ProtocolMachine::new(cfg) {
|
let mut machine = match ProtocolMachine::new(cfg) {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[ostp] Failed to create protocol machine for key trial: {}", e);
|
tracing::warn!("Failed to create protocol machine for key trial: {}", e);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -212,17 +317,17 @@ impl Dispatcher {
|
||||||
|
|
||||||
let drift = (now as i64 - ts as i64).abs();
|
let drift = (now as i64 - ts as i64).abs();
|
||||||
if drift > 300 {
|
if drift > 300 {
|
||||||
eprintln!("[ostp] Handshake rejected: timestamp drift {}s exceeds 300s limit (peer={})", drift, peer);
|
tracing::warn!("Handshake rejected: timestamp drift {}s exceeds 300s limit (peer={})", drift, peer);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.replay_cache.contains_key(&payload.to_vec()) {
|
if !self.replay_cache.contains_key(&payload.to_vec()) {
|
||||||
if self.replay_cache.len() >= 100_000 {
|
if self.replay_cache.len() >= 100_000 {
|
||||||
eprintln!("[ostp] Replay cache full (100000 entries), rejecting handshake from {}", peer);
|
tracing::warn!("Replay cache full (100000 entries), rejecting handshake from {}", peer);
|
||||||
return Ok(DispatchOutcome::Unauthorized);
|
return Ok(DispatchOutcome::Unauthorized);
|
||||||
}
|
}
|
||||||
if self.peer_machines.len() >= MAX_SESSIONS {
|
if self.peer_machines.len() >= MAX_SESSIONS {
|
||||||
eprintln!("[ostp] Max sessions reached ({}), rejecting handshake from {}", MAX_SESSIONS, peer);
|
tracing::warn!("Max sessions reached ({}), rejecting handshake from {}", MAX_SESSIONS, peer);
|
||||||
return Ok(DispatchOutcome::Unauthorized);
|
return Ok(DispatchOutcome::Unauthorized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,16 +335,26 @@ impl Dispatcher {
|
||||||
|
|
||||||
machine.set_session_keys(candidate_session_id, secrets.obfuscation_key);
|
machine.set_session_keys(candidate_session_id, secrets.obfuscation_key);
|
||||||
|
|
||||||
|
// Track per-user connection count
|
||||||
|
let user_stats = self.get_or_create_user_stats(&candidate_key);
|
||||||
|
user_stats.connections.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Check traffic limit before accepting
|
||||||
|
if user_stats.is_over_limit() {
|
||||||
|
tracing::warn!("User {} exceeded traffic limit, rejecting handshake from {}", candidate_key, peer);
|
||||||
|
return Ok(DispatchOutcome::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
self.peer_machines.insert(candidate_session_id, PeerState {
|
self.peer_machines.insert(candidate_session_id, PeerState {
|
||||||
machine,
|
machine,
|
||||||
last_addr: peer,
|
last_addr: peer,
|
||||||
obfuscation_key: secrets.obfuscation_key,
|
obfuscation_key: secrets.obfuscation_key,
|
||||||
last_seen: std::time::Instant::now(),
|
last_seen: std::time::Instant::now(),
|
||||||
|
access_key: candidate_key.clone(),
|
||||||
});
|
});
|
||||||
self.addr_to_session.insert(peer, candidate_session_id);
|
self.addr_to_session.insert(peer, candidate_session_id);
|
||||||
|
|
||||||
eprintln!(
|
tracing::info!("New session authenticated: sid={} peer={} (active_sessions={}, replay_cache={})",
|
||||||
"[ostp] New session authenticated: sid={} peer={} (active_sessions={}, replay_cache={})",
|
|
||||||
candidate_session_id, peer, self.peer_machines.len(), self.replay_cache.len()
|
candidate_session_id, peer, self.peer_machines.len(), self.replay_cache.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -265,8 +380,13 @@ impl Dispatcher {
|
||||||
};
|
};
|
||||||
|
|
||||||
let addr = peer_state.last_addr;
|
let addr = peer_state.last_addr;
|
||||||
|
let key = peer_state.access_key.clone();
|
||||||
match peer_state.machine.on_event(OstpEvent::Outbound(stream_id, payload))? {
|
match peer_state.machine.on_event(OstpEvent::Outbound(stream_id, payload))? {
|
||||||
ProtocolAction::SendDatagram(frame) => Ok(Some((frame, addr))),
|
ProtocolAction::SendDatagram(frame) => {
|
||||||
|
// Track outbound bytes per user
|
||||||
|
track_user_bytes_down(&self.user_stats, &key, frame.len() as u64);
|
||||||
|
Ok(Some((frame, addr)))
|
||||||
|
}
|
||||||
_ => Ok(None),
|
_ => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -293,7 +413,7 @@ impl Dispatcher {
|
||||||
|
|
||||||
// Clear expired sessions from internal state
|
// Clear expired sessions from internal state
|
||||||
for sid in &expired {
|
for sid in &expired {
|
||||||
eprintln!("[ostp] Session {} expired (inactive >5min), releasing", sid);
|
tracing::info!("Session {} expired (inactive >5min), releasing", sid);
|
||||||
self.drop_session(*sid);
|
self.drop_session(*sid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,7 +422,7 @@ impl Dispatcher {
|
||||||
let action = match peer_state.machine.on_event(OstpEvent::Tick) {
|
let action = match peer_state.machine.on_event(OstpEvent::Tick) {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[ostp] Tick error for session: {}", e);
|
tracing::warn!("Tick error for session: {}", e);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -332,3 +452,40 @@ impl Dispatcher {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Free functions to avoid borrow-checker conflicts when tracking stats
|
||||||
|
// while holding a mutable reference to peer_machines.
|
||||||
|
|
||||||
|
fn get_or_create_stats(
|
||||||
|
user_stats: &Arc<RwLock<HashMap<String, Arc<UserStats>>>>,
|
||||||
|
key: &str,
|
||||||
|
) -> Arc<UserStats> {
|
||||||
|
{
|
||||||
|
let stats = user_stats.read().unwrap();
|
||||||
|
if let Some(existing) = stats.get(key) {
|
||||||
|
return existing.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut stats = user_stats.write().unwrap();
|
||||||
|
stats.entry(key.to_string())
|
||||||
|
.or_insert_with(|| Arc::new(UserStats::new(None)))
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_user_bytes_up(
|
||||||
|
user_stats: &Arc<RwLock<HashMap<String, Arc<UserStats>>>>,
|
||||||
|
key: &str,
|
||||||
|
bytes: u64,
|
||||||
|
) {
|
||||||
|
let stats = get_or_create_stats(user_stats, key);
|
||||||
|
stats.bytes_up.fetch_add(bytes, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_user_bytes_down(
|
||||||
|
user_stats: &Arc<RwLock<HashMap<String, Arc<UserStats>>>>,
|
||||||
|
key: &str,
|
||||||
|
bytes: u64,
|
||||||
|
) {
|
||||||
|
let stats = get_or_create_stats(user_stats, key);
|
||||||
|
stats.bytes_down.fetch_add(bytes, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
//! Fallback TCP server for anti-DPI camouflage.
|
||||||
|
//!
|
||||||
|
//! When a connection arrives that doesn't match the OSTP protocol
|
||||||
|
//! (e.g., a DPI probe, web spider, or direct HTTP request),
|
||||||
|
//! it gets transparently proxied to a fallback web server (e.g., nginx).
|
||||||
|
//!
|
||||||
|
//! This makes the OSTP server indistinguishable from a regular web server
|
||||||
|
//! during active probing.
|
||||||
|
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
|
||||||
|
/// Fallback server configuration.
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct FallbackConfig {
|
||||||
|
/// Enable fallback TCP listener
|
||||||
|
pub enabled: bool,
|
||||||
|
/// TCP listen address (e.g., "0.0.0.0:443" or "0.0.0.0:80")
|
||||||
|
pub listen: String,
|
||||||
|
/// Target to proxy to (e.g., "127.0.0.1:8080" for local nginx)
|
||||||
|
pub target: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the fallback TCP proxy server.
|
||||||
|
pub async fn start_fallback_server(config: FallbackConfig) {
|
||||||
|
let listener = match TcpListener::bind(&config.listen).await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("fallback server failed to bind on {}: {}", config.listen, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("fallback server listening on {} -> {}", config.listen, config.target);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match listener.accept().await {
|
||||||
|
Ok((client, peer_addr)) => {
|
||||||
|
let target = config.target.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = proxy_connection(client, &target).await {
|
||||||
|
tracing::debug!(peer = %peer_addr, "fallback proxy error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("fallback accept error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_connection(mut client: TcpStream, target: &str) -> anyhow::Result<()> {
|
||||||
|
let mut upstream = TcpStream::connect(target).await?;
|
||||||
|
|
||||||
|
let (mut client_read, mut client_write) = client.split();
|
||||||
|
let (mut upstream_read, mut upstream_write) = upstream.split();
|
||||||
|
|
||||||
|
let client_to_upstream = async {
|
||||||
|
let mut buf = vec![0u8; 8192];
|
||||||
|
loop {
|
||||||
|
let n = client_read.read(&mut buf).await?;
|
||||||
|
if n == 0 { break; }
|
||||||
|
upstream_write.write_all(&buf[..n]).await?;
|
||||||
|
}
|
||||||
|
upstream_write.shutdown().await?;
|
||||||
|
Ok::<_, anyhow::Error>(())
|
||||||
|
};
|
||||||
|
|
||||||
|
let upstream_to_client = async {
|
||||||
|
let mut buf = vec![0u8; 8192];
|
||||||
|
loop {
|
||||||
|
let n = upstream_read.read(&mut buf).await?;
|
||||||
|
if n == 0 { break; }
|
||||||
|
client_write.write_all(&buf[..n]).await?;
|
||||||
|
}
|
||||||
|
client_write.shutdown().await?;
|
||||||
|
Ok::<_, anyhow::Error>(())
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
r = client_to_upstream => { r?; }
|
||||||
|
r = upstream_to_client => { r?; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
mod dispatcher;
|
|
||||||
mod signal;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
@ -8,13 +5,24 @@ use std::net::IpAddr;
|
||||||
|
|
||||||
use dispatcher::{DispatchOutcome, Dispatcher};
|
use dispatcher::{DispatchOutcome, Dispatcher};
|
||||||
use ostp_core::relay::RelayMessage;
|
use ostp_core::relay::RelayMessage;
|
||||||
use ostp_core::{NoiseRole, PaddingStrategy, ProtocolConfig};
|
|
||||||
use signal::wait_for_shutdown_signal;
|
use signal::wait_for_shutdown_signal;
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::net::UdpSocket;
|
||||||
use tokio::net::{TcpStream, UdpSocket};
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::time::{interval, Duration, Instant};
|
use tokio::time::{interval, Duration, Instant};
|
||||||
|
|
||||||
|
mod dispatcher;
|
||||||
|
pub mod outbound;
|
||||||
|
pub mod api;
|
||||||
|
pub mod fallback;
|
||||||
|
mod relay;
|
||||||
|
mod signal;
|
||||||
|
|
||||||
|
pub use outbound::{OutboundAction, OutboundConfig, OutboundRule};
|
||||||
|
pub use api::ApiConfig;
|
||||||
|
pub use fallback::FallbackConfig;
|
||||||
|
|
||||||
|
// ── Internal event types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
enum UiCommand {
|
enum UiCommand {
|
||||||
|
|
@ -24,7 +32,7 @@ enum UiCommand {
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum UiEvent {
|
pub(crate) enum UiEvent {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
PeerSeen { peer: IpAddr },
|
PeerSeen { peer: IpAddr },
|
||||||
#[allow(dead_code)] Rx { peer: IpAddr, bytes: usize },
|
#[allow(dead_code)] Rx { peer: IpAddr, bytes: usize },
|
||||||
|
|
@ -36,33 +44,19 @@ enum UiEvent {
|
||||||
KeyCount(usize),
|
KeyCount(usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
pub(crate) struct RemoteState {
|
||||||
pub enum OutboundAction {
|
pub data_tx: mpsc::UnboundedSender<Bytes>,
|
||||||
Proxy,
|
pub cancel_tx: mpsc::Sender<()>,
|
||||||
Direct,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
// ── Public API ───────────────────────────────────────────────────────────────
|
||||||
pub struct OutboundRule {
|
|
||||||
pub domain_suffix: Vec<String>,
|
|
||||||
pub ip_cidr: Vec<String>,
|
|
||||||
pub action: OutboundAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct OutboundConfig {
|
|
||||||
pub enabled: bool,
|
|
||||||
pub protocol: String,
|
|
||||||
pub address: String,
|
|
||||||
pub port: u16,
|
|
||||||
pub rules: Vec<OutboundRule>,
|
|
||||||
pub default_action: OutboundAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run_server(
|
pub async fn run_server(
|
||||||
bind_addr: String,
|
bind_addrs: Vec<String>,
|
||||||
access_keys: Vec<String>,
|
access_keys: Vec<String>,
|
||||||
outbound: Option<OutboundConfig>,
|
outbound: Option<OutboundConfig>,
|
||||||
|
api_config: Option<ApiConfig>,
|
||||||
|
fallback_config: Option<FallbackConfig>,
|
||||||
debug: bool,
|
debug: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut keys_map = HashMap::new();
|
let mut keys_map = HashMap::new();
|
||||||
|
|
@ -106,7 +100,7 @@ pub async fn run_server(
|
||||||
}
|
}
|
||||||
let mut keys_lock = shared_keys_clone.write().unwrap();
|
let mut keys_lock = shared_keys_clone.write().unwrap();
|
||||||
*keys_lock = new_keys;
|
*keys_lock = new_keys;
|
||||||
eprintln!("[ostp] Hot-reloaded {} access keys from config.json", keys_lock.len());
|
tracing::info!("Hot-reloaded {} access keys from config.json", keys_lock.len());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -116,17 +110,24 @@ pub async fn run_server(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let addr = bind_addr.parse::<std::net::SocketAddr>().map_err(|e| anyhow::anyhow!("invalid bind addr: {}", e))?;
|
let mut sockets = Vec::new();
|
||||||
|
for bind_addr in &bind_addrs {
|
||||||
|
let addr = bind_addr.parse::<std::net::SocketAddr>()
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid bind addr '{}': {}", bind_addr, e))?;
|
||||||
let domain = if addr.is_ipv6() { socket2::Domain::IPV6 } else { socket2::Domain::IPV4 };
|
let domain = if addr.is_ipv6() { socket2::Domain::IPV6 } else { socket2::Domain::IPV4 };
|
||||||
let sock = socket2::Socket::new(domain, socket2::Type::DGRAM, Some(socket2::Protocol::UDP))?;
|
let sock = socket2::Socket::new(domain, socket2::Type::DGRAM, Some(socket2::Protocol::UDP))?;
|
||||||
let _ = sock.set_recv_buffer_size(33554432); // 32MB
|
let _ = sock.set_recv_buffer_size(33554432);
|
||||||
let _ = sock.set_send_buffer_size(33554432); // 32MB
|
let _ = sock.set_send_buffer_size(33554432);
|
||||||
let actual_recv = sock.recv_buffer_size().unwrap_or(0);
|
|
||||||
let actual_send = sock.send_buffer_size().unwrap_or(0);
|
|
||||||
eprintln!("[ostp] UDP socket buffers: recv={}KB send={}KB", actual_recv / 1024, actual_send / 1024);
|
|
||||||
sock.bind(&addr.into())?;
|
sock.bind(&addr.into())?;
|
||||||
sock.set_nonblocking(true)?;
|
sock.set_nonblocking(true)?;
|
||||||
let socket = UdpSocket::from_std(sock.into())?;
|
let udp_sock = UdpSocket::from_std(sock.into())?;
|
||||||
|
tracing::info!("UDP socket bound to {}", bind_addr);
|
||||||
|
sockets.push(std::sync::Arc::new(udp_sock));
|
||||||
|
}
|
||||||
|
if sockets.is_empty() { anyhow::bail!("no bind addresses specified"); }
|
||||||
|
let primary_socket = sockets[0].clone();
|
||||||
|
|
||||||
|
use ostp_core::{NoiseRole, PaddingStrategy, ProtocolConfig};
|
||||||
let protocol_config = ProtocolConfig {
|
let protocol_config = ProtocolConfig {
|
||||||
role: NoiseRole::Responder,
|
role: NoiseRole::Responder,
|
||||||
psk: [0u8; 32],
|
psk: [0u8; 32],
|
||||||
|
|
@ -141,18 +142,36 @@ pub async fn run_server(
|
||||||
rto_ms: 100,
|
rto_ms: 100,
|
||||||
max_retries: 8,
|
max_retries: 8,
|
||||||
max_sent_history: 32768,
|
max_sent_history: 32768,
|
||||||
// Defaults — overridden per-session by dispatcher using derive_all_secrets()
|
// Defaults -- overridden per-session by dispatcher using derive_all_secrets()
|
||||||
handshake_pad_min: 32,
|
handshake_pad_min: 32,
|
||||||
handshake_pad_max: 128,
|
handshake_pad_max: 128,
|
||||||
};
|
};
|
||||||
|
|
||||||
let dispatcher = Dispatcher::new(protocol_config, shared_keys.clone());
|
let dispatcher = Dispatcher::new(protocol_config, shared_keys.clone());
|
||||||
|
|
||||||
|
// Spawn Management API if configured
|
||||||
|
if let Some(api_cfg) = api_config {
|
||||||
|
if api_cfg.enabled {
|
||||||
|
let api_keys = shared_keys.clone();
|
||||||
|
let api_stats = dispatcher.user_stats_ref();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
api::start_api_server(api_cfg, api_keys, api_stats).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn Fallback TCP proxy if configured
|
||||||
|
if let Some(fb_cfg) = fallback_config {
|
||||||
|
if fb_cfg.enabled {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
fallback::start_fallback_server(fb_cfg).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let (_ui_cmd_tx, ui_cmd_rx) = mpsc::unbounded_channel::<UiCommand>();
|
let (_ui_cmd_tx, ui_cmd_rx) = mpsc::unbounded_channel::<UiCommand>();
|
||||||
let (ui_event_tx, mut ui_event_rx) = mpsc::unbounded_channel::<UiEvent>();
|
let (ui_event_tx, mut ui_event_rx) = mpsc::unbounded_channel::<UiEvent>();
|
||||||
|
|
||||||
let max_datagram_size = 65535;
|
|
||||||
|
|
||||||
// Headless event logger
|
// Headless event logger
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(ev) = ui_event_rx.recv().await {
|
while let Some(ev) = ui_event_rx.recv().await {
|
||||||
|
|
@ -167,15 +186,15 @@ pub async fn run_server(
|
||||||
|| msg.starts_with("Relay CLOSE")
|
|| msg.starts_with("Relay CLOSE")
|
||||||
|| msg.starts_with("Relay error");
|
|| msg.starts_with("Relay error");
|
||||||
if debug || is_essential {
|
if debug || is_essential {
|
||||||
eprintln!("[ostp] {msg}");
|
tracing::info!("{msg}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiEvent::KeyCreated { key } => {
|
UiEvent::KeyCreated { key } => {
|
||||||
eprintln!("[ostp] Access key created: {key}");
|
tracing::info!("Access key created: {key}");
|
||||||
}
|
}
|
||||||
UiEvent::UnauthorizedProbe { peer, bytes } => {
|
UiEvent::UnauthorizedProbe { peer, bytes } => {
|
||||||
if debug {
|
if debug {
|
||||||
eprintln!("[ostp] Unauthorized probe from {peer} ({bytes} bytes)");
|
tracing::debug!("Unauthorized probe from {peer} ({bytes} bytes)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiEvent::PeerSeen { .. } => {}
|
UiEvent::PeerSeen { .. } => {}
|
||||||
|
|
@ -185,31 +204,28 @@ pub async fn run_server(
|
||||||
});
|
});
|
||||||
|
|
||||||
let key_count = shared_keys.read().unwrap().len();
|
let key_count = shared_keys.read().unwrap().len();
|
||||||
eprintln!("[ostp] Listening on {bind_addr} ({key_count} access keys loaded)");
|
tracing::info!(listeners = bind_addrs.len(), keys = key_count, "server started");
|
||||||
eprintln!("[ostp] ARQ config: max_reorder=16384, reorder_buf=8192, sent_history=32768, rto=100ms");
|
tracing::info!("ARQ config: max_reorder=16384, reorder_buf=8192, sent_history=32768, rto=100ms");
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
res = run_server_loop(socket, dispatcher, max_datagram_size, ui_cmd_rx, ui_event_tx, shared_keys, outbound, debug) => {
|
res = run_server_loop(primary_socket, sockets, dispatcher, ui_cmd_rx, ui_event_tx, shared_keys, outbound, debug) => {
|
||||||
if let Err(e) = res {
|
if let Err(e) = res {
|
||||||
eprintln!("[ostp] Server error: {e}");
|
tracing::error!("Server error: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = wait_for_shutdown_signal() => {
|
_ = wait_for_shutdown_signal() => {
|
||||||
eprintln!("[ostp] Shutdown signal received");
|
tracing::info!("Shutdown signal received");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RemoteState {
|
// ── Server main loop ─────────────────────────────────────────────────────────
|
||||||
data_tx: mpsc::UnboundedSender<Bytes>,
|
|
||||||
cancel_tx: mpsc::Sender<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_server_loop(
|
async fn run_server_loop(
|
||||||
socket: UdpSocket,
|
primary_socket: std::sync::Arc<UdpSocket>,
|
||||||
|
sockets: Vec<std::sync::Arc<UdpSocket>>,
|
||||||
mut dispatcher: Dispatcher,
|
mut dispatcher: Dispatcher,
|
||||||
_max_datagram_size: usize,
|
|
||||||
mut ui_cmd_rx: mpsc::UnboundedReceiver<UiCommand>,
|
mut ui_cmd_rx: mpsc::UnboundedReceiver<UiCommand>,
|
||||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||||
shared_keys: std::sync::Arc<std::sync::RwLock<HashMap<String, ()>>>,
|
shared_keys: std::sync::Arc<std::sync::RwLock<HashMap<String, ()>>>,
|
||||||
|
|
@ -217,21 +233,22 @@ async fn run_server_loop(
|
||||||
debug: bool,
|
debug: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new();
|
let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new();
|
||||||
// Unbounded channel: bounded(10000) caused TCP-reader tasks to fail under Speedtest load
|
|
||||||
// when 50+ streams competed for slots. Backpressure is managed at the relay layer instead.
|
|
||||||
let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::<(u32, u16, Vec<u8>)>();
|
let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::<(u32, u16, Vec<u8>)>();
|
||||||
let (connect_tx, mut connect_rx) = mpsc::unbounded_channel::<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>();
|
let (connect_tx, mut connect_rx) = mpsc::unbounded_channel::<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>();
|
||||||
|
|
||||||
let socket = std::sync::Arc::new(socket);
|
let socket = primary_socket;
|
||||||
|
// Spawn a recv task for each socket, all feeding into the same channel
|
||||||
let (udp_tx, mut udp_rx) = mpsc::channel(10000);
|
let (udp_tx, mut udp_rx) = mpsc::channel(10000);
|
||||||
let socket_clone = socket.clone();
|
for sock in &sockets {
|
||||||
|
let sock_clone = sock.clone();
|
||||||
|
let tx = udp_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut buf = vec![0_u8; 65535];
|
let mut buf = vec![0_u8; 65535];
|
||||||
loop {
|
loop {
|
||||||
match socket_clone.recv_from(&mut buf).await {
|
match sock_clone.recv_from(&mut buf).await {
|
||||||
Ok((size, peer)) => {
|
Ok((size, peer)) => {
|
||||||
let packet = Bytes::copy_from_slice(&buf[..size]);
|
let packet = Bytes::copy_from_slice(&buf[..size]);
|
||||||
if udp_tx.send((packet, peer)).await.is_err() {
|
if tx.send((packet, peer)).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -239,6 +256,8 @@ async fn run_server_loop(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
drop(udp_tx); // Drop the original sender so the channel closes when all tasks end
|
||||||
|
|
||||||
if debug {
|
if debug {
|
||||||
let _ = ui_event_tx.send(UiEvent::Log("Server loop started".to_string()));
|
let _ = ui_event_tx.send(UiEvent::Log("Server loop started".to_string()));
|
||||||
|
|
@ -302,7 +321,7 @@ async fn run_server_loop(
|
||||||
"Deliver app payload sid={session_id} stream={stream_id} bytes={}",
|
"Deliver app payload sid={session_id} stream={stream_id} bytes={}",
|
||||||
payload.len()
|
payload.len()
|
||||||
)));
|
)));
|
||||||
handle_relay_message(
|
relay::handle_relay_message(
|
||||||
peer_addr,
|
peer_addr,
|
||||||
session_id,
|
session_id,
|
||||||
stream_id,
|
stream_id,
|
||||||
|
|
@ -326,12 +345,12 @@ async fn run_server_loop(
|
||||||
}
|
}
|
||||||
Some((session_id, stream_id, data)) = stream_rx.recv() => {
|
Some((session_id, stream_id, data)) = stream_rx.recv() => {
|
||||||
if data.is_empty() {
|
if data.is_empty() {
|
||||||
let _ = send_relay_to_stream(session_id, stream_id, RelayMessage::Close, &mut dispatcher, &socket, &ui_event_tx).await;
|
let _ = relay::send_relay_to_stream(session_id, stream_id, RelayMessage::Close, &mut dispatcher, &socket, &ui_event_tx).await;
|
||||||
if let Some(state) = remotes.remove(&(session_id, stream_id)) {
|
if let Some(state) = remotes.remove(&(session_id, stream_id)) {
|
||||||
let _ = state.cancel_tx.try_send(());
|
let _ = state.cancel_tx.try_send(());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let _ = send_relay_to_stream(session_id, stream_id, RelayMessage::Data(data), &mut dispatcher, &socket, &ui_event_tx).await;
|
let _ = relay::send_relay_to_stream(session_id, stream_id, RelayMessage::Data(data), &mut dispatcher, &socket, &ui_event_tx).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some((session_id, stream_id, target, res)) = connect_rx.recv() => {
|
Some((session_id, stream_id, target, res)) = connect_rx.recv() => {
|
||||||
|
|
@ -347,12 +366,12 @@ async fn run_server_loop(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
remotes.insert((session_id, stream_id), RemoteState { data_tx, cancel_tx });
|
remotes.insert((session_id, stream_id), RemoteState { data_tx, cancel_tx });
|
||||||
let _ = send_relay_to_stream(session_id, stream_id, RelayMessage::ConnectOk, &mut dispatcher, &socket, &ui_event_tx).await;
|
let _ = relay::send_relay_to_stream(session_id, stream_id, RelayMessage::ConnectOk, &mut dispatcher, &socket, &ui_event_tx).await;
|
||||||
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay CONNECT ok for [{session_id}:{stream_id}] -> {target}")));
|
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay CONNECT ok for [{session_id}:{stream_id}] -> {target}")));
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay CONNECT failed for [{session_id}:{stream_id}] -> {target}: {err}")));
|
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay CONNECT failed for [{session_id}:{stream_id}] -> {target}: {err}")));
|
||||||
let _ = send_relay_to_stream(session_id, stream_id, RelayMessage::Error(format!("connect failed: {err}")), &mut dispatcher, &socket, &ui_event_tx).await;
|
let _ = relay::send_relay_to_stream(session_id, stream_id, RelayMessage::Error(format!("connect failed: {err}")), &mut dispatcher, &socket, &ui_event_tx).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -389,330 +408,3 @@ async fn run_server_loop(
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_relay_message(
|
|
||||||
_peer_addr: std::net::SocketAddr,
|
|
||||||
session_id: u32,
|
|
||||||
stream_id: u16,
|
|
||||||
payload: Bytes,
|
|
||||||
dispatcher: &mut Dispatcher,
|
|
||||||
socket: &UdpSocket,
|
|
||||||
remotes: &mut HashMap<(u32, u16), RemoteState>,
|
|
||||||
ui_event_tx: &mpsc::UnboundedSender<UiEvent>,
|
|
||||||
stream_tx: mpsc::UnboundedSender<(u32, u16, Vec<u8>)>,
|
|
||||||
connect_tx: mpsc::UnboundedSender<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>,
|
|
||||||
outbound: Option<OutboundConfig>,
|
|
||||||
debug: bool,
|
|
||||||
) -> Result<()> {
|
|
||||||
match RelayMessage::decode(&payload)? {
|
|
||||||
RelayMessage::Connect(target) => {
|
|
||||||
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay CONNECT start for [{session_id}:{stream_id}] -> {target}")));
|
|
||||||
let target_clone = target.clone();
|
|
||||||
let connect_tx_clone = connect_tx.clone();
|
|
||||||
let stream_tx_clone = stream_tx.clone();
|
|
||||||
let outbound_clone = outbound.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let stream_res = connect_target(&target_clone, outbound_clone.as_ref(), debug).await;
|
|
||||||
match stream_res {
|
|
||||||
Ok(stream) => {
|
|
||||||
let (mut reader, writer) = stream.into_split();
|
|
||||||
let (cancel_tx, mut cancel_rx) = mpsc::channel::<()>(1);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut buf = [0_u8; 4096];
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
_ = cancel_rx.recv() => break,
|
|
||||||
read_res = reader.read(&mut buf) => {
|
|
||||||
match read_res {
|
|
||||||
Ok(0) | Err(_) => {
|
|
||||||
let _ = stream_tx_clone.send((session_id, stream_id, Vec::new()));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Ok(n) => {
|
|
||||||
if stream_tx_clone.send((session_id, stream_id, buf[..n].to_vec())).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let _ = connect_tx_clone.send((session_id, stream_id, target_clone, Ok((writer, cancel_tx))));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = connect_tx_clone.send((session_id, stream_id, target_clone, Err(e.to_string())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
RelayMessage::Data(data) => {
|
|
||||||
if let Some(remote) = remotes.get_mut(&(session_id, stream_id)) {
|
|
||||||
let _ = remote.data_tx.send(bytes::Bytes::from(data));
|
|
||||||
} else {
|
|
||||||
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay DATA for unknown stream [{session_id}:{stream_id}] ({})", data.len())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RelayMessage::KeepAlive => {}
|
|
||||||
RelayMessage::Close => {
|
|
||||||
if let Some(state) = remotes.remove(&(session_id, stream_id)) {
|
|
||||||
let _ = state.cancel_tx.try_send(());
|
|
||||||
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay CLOSE [{session_id}:{stream_id}]")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RelayMessage::ConnectOk => {}
|
|
||||||
RelayMessage::Error(msg) => {
|
|
||||||
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay error from [{session_id}:{stream_id}]: {msg}")));
|
|
||||||
}
|
|
||||||
RelayMessage::Ping(ts) => {
|
|
||||||
send_relay_to_stream(session_id, stream_id, RelayMessage::Pong(ts), dispatcher, socket, ui_event_tx).await?;
|
|
||||||
}
|
|
||||||
RelayMessage::Pong(_) => {}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_relay_to_stream(
|
|
||||||
session_id: u32,
|
|
||||||
stream_id: u16,
|
|
||||||
msg: RelayMessage,
|
|
||||||
dispatcher: &mut Dispatcher,
|
|
||||||
socket: &UdpSocket,
|
|
||||||
ui_event_tx: &mpsc::UnboundedSender<UiEvent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let payload = Bytes::from(msg.encode());
|
|
||||||
if let Some((frame, peer_addr)) = dispatcher.outbound_to_session(session_id, stream_id, payload)? {
|
|
||||||
let response_len = frame.len();
|
|
||||||
let _ = socket.send_to(&frame, peer_addr).await?;
|
|
||||||
let _ = ui_event_tx.send(UiEvent::Tx {
|
|
||||||
peer: peer_addr.ip(),
|
|
||||||
bytes: response_len,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect_target(
|
|
||||||
target: &str,
|
|
||||||
outbound: Option<&OutboundConfig>,
|
|
||||||
debug: bool,
|
|
||||||
) -> Result<TcpStream> {
|
|
||||||
let connect_timeout = Duration::from_secs(10);
|
|
||||||
if let Some(outbound) = outbound {
|
|
||||||
if outbound.enabled {
|
|
||||||
let action = select_outbound_action(target, outbound, debug).await;
|
|
||||||
if action == OutboundAction::Proxy {
|
|
||||||
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
|
|
||||||
return match outbound.protocol.as_str() {
|
|
||||||
"socks5" => connect_via_socks5(&proxy_addr, target).await,
|
|
||||||
"http" => connect_via_http(&proxy_addr, target).await,
|
|
||||||
_ => tokio::time::timeout(connect_timeout, TcpStream::connect(target))
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))?
|
|
||||||
.map_err(Into::into),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::time::timeout(connect_timeout, TcpStream::connect(target))
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))?
|
|
||||||
.map_err(Into::into)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn select_outbound_action(
|
|
||||||
target: &str,
|
|
||||||
outbound: &OutboundConfig,
|
|
||||||
debug: bool,
|
|
||||||
) -> OutboundAction {
|
|
||||||
let (host, port) = match split_host_port(target) {
|
|
||||||
Some(v) => v,
|
|
||||||
None => return outbound.default_action,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut matched = None;
|
|
||||||
for rule in &outbound.rules {
|
|
||||||
if rule.domain_suffix.is_empty() && rule.ip_cidr.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if match_domain_rule(&host, &rule.domain_suffix) {
|
|
||||||
matched = Some(rule.action);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if match_ip_rule(&host, port, &rule.ip_cidr).await {
|
|
||||||
matched = Some(rule.action);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let action = matched.unwrap_or(outbound.default_action);
|
|
||||||
if debug {
|
|
||||||
eprintln!("[ostp] Outbound routing: target={target} action={action:?}");
|
|
||||||
}
|
|
||||||
action
|
|
||||||
}
|
|
||||||
|
|
||||||
fn match_domain_rule(host: &str, suffixes: &[String]) -> bool {
|
|
||||||
if suffixes.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let host = host.trim_end_matches('.').to_lowercase();
|
|
||||||
suffixes.iter().any(|suffix| {
|
|
||||||
let suffix = suffix.trim().trim_start_matches('.').to_lowercase();
|
|
||||||
!suffix.is_empty() && (host == suffix || host.ends_with(&format!(".{suffix}")))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn match_ip_rule(host: &str, port: u16, cidrs: &[String]) -> bool {
|
|
||||||
if cidrs.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let parsed: Vec<Cidr> = cidrs.iter().filter_map(|c| parse_cidr(c)).collect();
|
|
||||||
if parsed.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
|
||||||
return parsed.iter().any(|cidr| cidr.contains(&ip));
|
|
||||||
}
|
|
||||||
|
|
||||||
match tokio::net::lookup_host((host, port)).await {
|
|
||||||
Ok(addrs) => addrs.into_iter().any(|addr| parsed.iter().any(|cidr| cidr.contains(&addr.ip()))),
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect_via_socks5(proxy_addr: &str, target: &str) -> Result<TcpStream> {
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
|
|
||||||
let mut stream = TcpStream::connect(proxy_addr).await?;
|
|
||||||
stream.write_all(&[0x05, 0x01, 0x00]).await?;
|
|
||||||
let mut reply = [0u8; 2];
|
|
||||||
stream.read_exact(&mut reply).await?;
|
|
||||||
if reply != [0x05, 0x00] {
|
|
||||||
anyhow::bail!("SOCKS5 auth not accepted");
|
|
||||||
}
|
|
||||||
|
|
||||||
let (host, port) = split_host_port(target).ok_or_else(|| anyhow::anyhow!("invalid target"))?;
|
|
||||||
let mut req = Vec::new();
|
|
||||||
req.extend_from_slice(&[0x05, 0x01, 0x00]);
|
|
||||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
|
||||||
match ip {
|
|
||||||
std::net::IpAddr::V4(v4) => {
|
|
||||||
req.push(0x01);
|
|
||||||
req.extend_from_slice(&v4.octets());
|
|
||||||
}
|
|
||||||
std::net::IpAddr::V6(v6) => {
|
|
||||||
req.push(0x04);
|
|
||||||
req.extend_from_slice(&v6.octets());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
req.push(0x03);
|
|
||||||
req.push(host.len() as u8);
|
|
||||||
req.extend_from_slice(host.as_bytes());
|
|
||||||
}
|
|
||||||
req.extend_from_slice(&port.to_be_bytes());
|
|
||||||
stream.write_all(&req).await?;
|
|
||||||
|
|
||||||
let mut header = [0u8; 4];
|
|
||||||
stream.read_exact(&mut header).await?;
|
|
||||||
if header[1] != 0x00 {
|
|
||||||
anyhow::bail!("SOCKS5 connect failed: 0x{:02x}", header[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let addr_len = match header[3] {
|
|
||||||
0x01 => 4,
|
|
||||||
0x04 => 16,
|
|
||||||
0x03 => {
|
|
||||||
let mut len = [0u8; 1];
|
|
||||||
stream.read_exact(&mut len).await?;
|
|
||||||
len[0] as usize
|
|
||||||
}
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
if addr_len > 0 {
|
|
||||||
let mut skip = vec![0u8; addr_len + 2];
|
|
||||||
stream.read_exact(&mut skip).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect_via_http(proxy_addr: &str, target: &str) -> Result<TcpStream> {
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
|
|
||||||
let mut stream = TcpStream::connect(proxy_addr).await?;
|
|
||||||
let request = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
|
|
||||||
stream.write_all(request.as_bytes()).await?;
|
|
||||||
|
|
||||||
let mut buf = vec![0u8; 1024];
|
|
||||||
let n = stream.read(&mut buf).await?;
|
|
||||||
let response = String::from_utf8_lossy(&buf[..n]);
|
|
||||||
if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") {
|
|
||||||
anyhow::bail!("HTTP CONNECT failed: {response}");
|
|
||||||
}
|
|
||||||
Ok(stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Cidr {
|
|
||||||
V4(u32, u8),
|
|
||||||
V6(u128, u8),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Cidr {
|
|
||||||
fn contains(&self, ip: &std::net::IpAddr) -> bool {
|
|
||||||
match (self, ip) {
|
|
||||||
(Cidr::V4(net, bits), std::net::IpAddr::V4(addr)) => {
|
|
||||||
let mask = if *bits == 0 { 0 } else { u32::MAX << (32 - bits) };
|
|
||||||
let ip = u32::from_be_bytes(addr.octets());
|
|
||||||
(ip & mask) == (*net & mask)
|
|
||||||
}
|
|
||||||
(Cidr::V6(net, bits), std::net::IpAddr::V6(addr)) => {
|
|
||||||
let mask = if *bits == 0 { 0 } else { u128::MAX << (128 - bits) };
|
|
||||||
let ip = u128::from_be_bytes(addr.octets());
|
|
||||||
(ip & mask) == (*net & mask)
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_cidr(value: &str) -> Option<Cidr> {
|
|
||||||
let value = value.trim();
|
|
||||||
if value.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if let Some((addr_str, bits_str)) = value.split_once('/') {
|
|
||||||
let bits: u8 = bits_str.parse().ok()?;
|
|
||||||
if let Ok(addr) = addr_str.parse::<std::net::IpAddr>() {
|
|
||||||
return match addr {
|
|
||||||
std::net::IpAddr::V4(v4) => Some(Cidr::V4(u32::from_be_bytes(v4.octets()), bits.min(32))),
|
|
||||||
std::net::IpAddr::V6(v6) => Some(Cidr::V6(u128::from_be_bytes(v6.octets()), bits.min(128))),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Ok(addr) = value.parse::<std::net::IpAddr>() {
|
|
||||||
return match addr {
|
|
||||||
std::net::IpAddr::V4(v4) => Some(Cidr::V4(u32::from_be_bytes(v4.octets()), 32)),
|
|
||||||
std::net::IpAddr::V6(v6) => Some(Cidr::V6(u128::from_be_bytes(v6.octets()), 128)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn split_host_port(target: &str) -> Option<(String, u16)> {
|
|
||||||
if let Some((host, port)) = target.rsplit_once(':') {
|
|
||||||
if host.starts_with('[') && host.ends_with(']') {
|
|
||||||
let host = host.trim_start_matches('[').trim_end_matches(']').to_string();
|
|
||||||
let port = port.parse().ok()?;
|
|
||||||
return Some((host, port));
|
|
||||||
}
|
|
||||||
if host.contains(':') {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let port = port.parse().ok()?;
|
|
||||||
return Some((host.to_string(), port));
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,323 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum OutboundAction {
|
||||||
|
Proxy,
|
||||||
|
Direct,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OutboundRule {
|
||||||
|
pub domain_suffix: Vec<String>,
|
||||||
|
pub ip_cidr: Vec<String>,
|
||||||
|
pub action: OutboundAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OutboundConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub protocol: String,
|
||||||
|
pub address: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub rules: Vec<OutboundRule>,
|
||||||
|
pub default_action: OutboundAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Target connection with outbound routing ──────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn connect_target(
|
||||||
|
target: &str,
|
||||||
|
outbound: Option<&OutboundConfig>,
|
||||||
|
debug: bool,
|
||||||
|
) -> Result<TcpStream> {
|
||||||
|
let connect_timeout = Duration::from_secs(10);
|
||||||
|
if let Some(outbound) = outbound {
|
||||||
|
if outbound.enabled {
|
||||||
|
let action = select_outbound_action(target, outbound, debug).await;
|
||||||
|
if action == OutboundAction::Proxy {
|
||||||
|
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
|
||||||
|
return match outbound.protocol.as_str() {
|
||||||
|
"socks5" => connect_via_socks5(&proxy_addr, target).await,
|
||||||
|
"http" => connect_via_http(&proxy_addr, target).await,
|
||||||
|
_ => tokio::time::timeout(connect_timeout, TcpStream::connect(target))
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))?
|
||||||
|
.map_err(Into::into),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::timeout(connect_timeout, TcpStream::connect(target))
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))?
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rule matching ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn select_outbound_action(
|
||||||
|
target: &str,
|
||||||
|
outbound: &OutboundConfig,
|
||||||
|
debug: bool,
|
||||||
|
) -> OutboundAction {
|
||||||
|
let (host, port) = match split_host_port(target) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return outbound.default_action,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut matched = None;
|
||||||
|
for rule in &outbound.rules {
|
||||||
|
if rule.domain_suffix.is_empty() && rule.ip_cidr.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if match_domain_rule(&host, &rule.domain_suffix) {
|
||||||
|
matched = Some(rule.action);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if match_ip_rule(&host, port, &rule.ip_cidr).await {
|
||||||
|
matched = Some(rule.action);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let action = matched.unwrap_or(outbound.default_action);
|
||||||
|
if debug {
|
||||||
|
tracing::debug!("Outbound routing: target={target} action={action:?}");
|
||||||
|
}
|
||||||
|
action
|
||||||
|
}
|
||||||
|
|
||||||
|
fn match_domain_rule(host: &str, suffixes: &[String]) -> bool {
|
||||||
|
if suffixes.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let host = host.trim_end_matches('.').to_lowercase();
|
||||||
|
suffixes.iter().any(|suffix| {
|
||||||
|
let suffix = suffix.trim().trim_start_matches('.').to_lowercase();
|
||||||
|
!suffix.is_empty() && (host == suffix || host.ends_with(&format!(".{suffix}")))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn match_ip_rule(host: &str, port: u16, cidrs: &[String]) -> bool {
|
||||||
|
if cidrs.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let parsed: Vec<Cidr> = cidrs.iter().filter_map(|c| parse_cidr(c)).collect();
|
||||||
|
if parsed.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||||
|
return parsed.iter().any(|cidr| cidr.contains(&ip));
|
||||||
|
}
|
||||||
|
|
||||||
|
match tokio::net::lookup_host((host, port)).await {
|
||||||
|
Ok(addrs) => addrs.into_iter().any(|addr| parsed.iter().any(|cidr| cidr.contains(&addr.ip()))),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SOCKS5 / HTTP CONNECT upstream proxy ─────────────────────────────────────
|
||||||
|
|
||||||
|
async fn connect_via_socks5(proxy_addr: &str, target: &str) -> Result<TcpStream> {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
let mut stream = TcpStream::connect(proxy_addr).await?;
|
||||||
|
stream.write_all(&[0x05, 0x01, 0x00]).await?;
|
||||||
|
let mut reply = [0u8; 2];
|
||||||
|
stream.read_exact(&mut reply).await?;
|
||||||
|
if reply != [0x05, 0x00] {
|
||||||
|
anyhow::bail!("SOCKS5 auth not accepted");
|
||||||
|
}
|
||||||
|
|
||||||
|
let (host, port) = split_host_port(target).ok_or_else(|| anyhow::anyhow!("invalid target"))?;
|
||||||
|
let mut req = Vec::new();
|
||||||
|
req.extend_from_slice(&[0x05, 0x01, 0x00]);
|
||||||
|
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||||
|
match ip {
|
||||||
|
std::net::IpAddr::V4(v4) => {
|
||||||
|
req.push(0x01);
|
||||||
|
req.extend_from_slice(&v4.octets());
|
||||||
|
}
|
||||||
|
std::net::IpAddr::V6(v6) => {
|
||||||
|
req.push(0x04);
|
||||||
|
req.extend_from_slice(&v6.octets());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
req.push(0x03);
|
||||||
|
req.push(host.len() as u8);
|
||||||
|
req.extend_from_slice(host.as_bytes());
|
||||||
|
}
|
||||||
|
req.extend_from_slice(&port.to_be_bytes());
|
||||||
|
stream.write_all(&req).await?;
|
||||||
|
|
||||||
|
let mut header = [0u8; 4];
|
||||||
|
stream.read_exact(&mut header).await?;
|
||||||
|
if header[1] != 0x00 {
|
||||||
|
anyhow::bail!("SOCKS5 connect failed: 0x{:02x}", header[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let addr_len = match header[3] {
|
||||||
|
0x01 => 4,
|
||||||
|
0x04 => 16,
|
||||||
|
0x03 => {
|
||||||
|
let mut len = [0u8; 1];
|
||||||
|
stream.read_exact(&mut len).await?;
|
||||||
|
len[0] as usize
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
if addr_len > 0 {
|
||||||
|
let mut skip = vec![0u8; addr_len + 2];
|
||||||
|
stream.read_exact(&mut skip).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_via_http(proxy_addr: &str, target: &str) -> Result<TcpStream> {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
let mut stream = TcpStream::connect(proxy_addr).await?;
|
||||||
|
let request = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
|
||||||
|
stream.write_all(request.as_bytes()).await?;
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; 1024];
|
||||||
|
let n = stream.read(&mut buf).await?;
|
||||||
|
let response = String::from_utf8_lossy(&buf[..n]);
|
||||||
|
if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") {
|
||||||
|
anyhow::bail!("HTTP CONNECT failed: {response}");
|
||||||
|
}
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CIDR utilities ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum Cidr {
|
||||||
|
V4(u32, u8),
|
||||||
|
V6(u128, u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cidr {
|
||||||
|
fn contains(&self, ip: &std::net::IpAddr) -> bool {
|
||||||
|
match (self, ip) {
|
||||||
|
(Cidr::V4(net, bits), std::net::IpAddr::V4(addr)) => {
|
||||||
|
let mask = if *bits == 0 { 0 } else { u32::MAX << (32 - bits) };
|
||||||
|
let ip = u32::from_be_bytes(addr.octets());
|
||||||
|
(ip & mask) == (*net & mask)
|
||||||
|
}
|
||||||
|
(Cidr::V6(net, bits), std::net::IpAddr::V6(addr)) => {
|
||||||
|
let mask = if *bits == 0 { 0 } else { u128::MAX << (128 - bits) };
|
||||||
|
let ip = u128::from_be_bytes(addr.octets());
|
||||||
|
(ip & mask) == (*net & mask)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_cidr(value: &str) -> Option<Cidr> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some((addr_str, bits_str)) = value.split_once('/') {
|
||||||
|
let bits: u8 = bits_str.parse().ok()?;
|
||||||
|
if let Ok(addr) = addr_str.parse::<std::net::IpAddr>() {
|
||||||
|
return match addr {
|
||||||
|
std::net::IpAddr::V4(v4) => Some(Cidr::V4(u32::from_be_bytes(v4.octets()), bits.min(32))),
|
||||||
|
std::net::IpAddr::V6(v6) => Some(Cidr::V6(u128::from_be_bytes(v6.octets()), bits.min(128))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(addr) = value.parse::<std::net::IpAddr>() {
|
||||||
|
return match addr {
|
||||||
|
std::net::IpAddr::V4(v4) => Some(Cidr::V4(u32::from_be_bytes(v4.octets()), 32)),
|
||||||
|
std::net::IpAddr::V6(v6) => Some(Cidr::V6(u128::from_be_bytes(v6.octets()), 128)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn split_host_port(target: &str) -> Option<(String, u16)> {
|
||||||
|
if let Some((host, port)) = target.rsplit_once(':') {
|
||||||
|
if host.starts_with('[') && host.ends_with(']') {
|
||||||
|
let host = host.trim_start_matches('[').trim_end_matches(']').to_string();
|
||||||
|
let port = port.parse().ok()?;
|
||||||
|
return Some((host, port));
|
||||||
|
}
|
||||||
|
if host.contains(':') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let port = port.parse().ok()?;
|
||||||
|
return Some((host.to_string(), port));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_split_host_port() {
|
||||||
|
assert_eq!(split_host_port("example.com:443"), Some(("example.com".to_string(), 443)));
|
||||||
|
assert_eq!(split_host_port("127.0.0.1:80"), Some(("127.0.0.1".to_string(), 80)));
|
||||||
|
assert_eq!(split_host_port("[::1]:8080"), Some(("::1".to_string(), 8080)));
|
||||||
|
assert_eq!(split_host_port("noport"), None);
|
||||||
|
assert_eq!(split_host_port("::1:8080"), None); // ambiguous IPv6 without brackets
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_cidr_v4() {
|
||||||
|
let cidr = parse_cidr("10.0.0.0/8").unwrap();
|
||||||
|
assert!(cidr.contains(&"10.1.2.3".parse().unwrap()));
|
||||||
|
assert!(cidr.contains(&"10.255.255.255".parse().unwrap()));
|
||||||
|
assert!(!cidr.contains(&"11.0.0.1".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_cidr_v4_exact() {
|
||||||
|
let cidr = parse_cidr("192.168.1.1").unwrap();
|
||||||
|
assert!(cidr.contains(&"192.168.1.1".parse().unwrap()));
|
||||||
|
assert!(!cidr.contains(&"192.168.1.2".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_cidr_v6() {
|
||||||
|
let cidr = parse_cidr("::1/128").unwrap();
|
||||||
|
assert!(cidr.contains(&"::1".parse().unwrap()));
|
||||||
|
assert!(!cidr.contains(&"::2".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_cidr_invalid() {
|
||||||
|
assert!(parse_cidr("").is_none());
|
||||||
|
assert!(parse_cidr("not-an-ip/24").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_match_domain_rule() {
|
||||||
|
assert!(match_domain_rule("example.com", &[".example.com".to_string()]));
|
||||||
|
assert!(match_domain_rule("sub.example.com", &[".example.com".to_string()]));
|
||||||
|
assert!(!match_domain_rule("notexample.com", &[".example.com".to_string()]));
|
||||||
|
assert!(match_domain_rule("test.onion", &[".onion".to_string()]));
|
||||||
|
assert!(!match_domain_rule("onion.com", &[".onion".to_string()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_match_domain_rule_exact() {
|
||||||
|
// Without dot prefix, the rule matches both exact and subdomains
|
||||||
|
// because the implementation treats "example.com" as a suffix match
|
||||||
|
assert!(match_domain_rule("example.com", &["example.com".to_string()]));
|
||||||
|
assert!(match_domain_rule("sub.example.com", &["example.com".to_string()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_match_domain_rule_empty() {
|
||||||
|
assert!(!match_domain_rule("example.com", &[]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use ostp_core::relay::RelayMessage;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::dispatcher::Dispatcher;
|
||||||
|
use crate::outbound::{self, OutboundConfig};
|
||||||
|
use crate::{RemoteState, UiEvent};
|
||||||
|
|
||||||
|
pub async fn handle_relay_message(
|
||||||
|
_peer_addr: std::net::SocketAddr,
|
||||||
|
session_id: u32,
|
||||||
|
stream_id: u16,
|
||||||
|
payload: Bytes,
|
||||||
|
dispatcher: &mut Dispatcher,
|
||||||
|
socket: &UdpSocket,
|
||||||
|
remotes: &mut HashMap<(u32, u16), RemoteState>,
|
||||||
|
ui_event_tx: &mpsc::UnboundedSender<UiEvent>,
|
||||||
|
stream_tx: mpsc::UnboundedSender<(u32, u16, Vec<u8>)>,
|
||||||
|
connect_tx: mpsc::UnboundedSender<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>,
|
||||||
|
outbound_cfg: Option<OutboundConfig>,
|
||||||
|
debug: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
match RelayMessage::decode(&payload)? {
|
||||||
|
RelayMessage::Connect(target) => {
|
||||||
|
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay CONNECT start for [{session_id}:{stream_id}] -> {target}")));
|
||||||
|
let target_clone = target.clone();
|
||||||
|
let connect_tx_clone = connect_tx.clone();
|
||||||
|
let stream_tx_clone = stream_tx.clone();
|
||||||
|
let outbound_clone = outbound_cfg.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let stream_res = outbound::connect_target(&target_clone, outbound_clone.as_ref(), debug).await;
|
||||||
|
match stream_res {
|
||||||
|
Ok(stream) => {
|
||||||
|
let (mut reader, writer) = stream.into_split();
|
||||||
|
let (cancel_tx, mut cancel_rx) = mpsc::channel::<()>(1);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = [0_u8; 4096];
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel_rx.recv() => break,
|
||||||
|
read_res = reader.read(&mut buf) => {
|
||||||
|
match read_res {
|
||||||
|
Ok(0) | Err(_) => {
|
||||||
|
let _ = stream_tx_clone.send((session_id, stream_id, Vec::new()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(n) => {
|
||||||
|
if stream_tx_clone.send((session_id, stream_id, buf[..n].to_vec())).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _ = connect_tx_clone.send((session_id, stream_id, target_clone, Ok((writer, cancel_tx))));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = connect_tx_clone.send((session_id, stream_id, target_clone, Err(e.to_string())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
RelayMessage::Data(data) => {
|
||||||
|
if let Some(remote) = remotes.get_mut(&(session_id, stream_id)) {
|
||||||
|
let _ = remote.data_tx.send(bytes::Bytes::from(data));
|
||||||
|
} else {
|
||||||
|
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay DATA for unknown stream [{session_id}:{stream_id}] ({})", data.len())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RelayMessage::KeepAlive => {}
|
||||||
|
RelayMessage::Close => {
|
||||||
|
if let Some(state) = remotes.remove(&(session_id, stream_id)) {
|
||||||
|
let _ = state.cancel_tx.try_send(());
|
||||||
|
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay CLOSE [{session_id}:{stream_id}]")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RelayMessage::ConnectOk => {}
|
||||||
|
RelayMessage::Error(msg) => {
|
||||||
|
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay error from [{session_id}:{stream_id}]: {msg}")));
|
||||||
|
}
|
||||||
|
RelayMessage::Ping(ts) => {
|
||||||
|
send_relay_to_stream(session_id, stream_id, RelayMessage::Pong(ts), dispatcher, socket, ui_event_tx).await?;
|
||||||
|
}
|
||||||
|
RelayMessage::Pong(_) => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_relay_to_stream(
|
||||||
|
session_id: u32,
|
||||||
|
stream_id: u16,
|
||||||
|
msg: RelayMessage,
|
||||||
|
dispatcher: &mut Dispatcher,
|
||||||
|
socket: &UdpSocket,
|
||||||
|
ui_event_tx: &mpsc::UnboundedSender<UiEvent>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let payload = Bytes::from(msg.encode());
|
||||||
|
if let Some((frame, peer_addr)) = dispatcher.outbound_to_session(session_id, stream_id, payload)? {
|
||||||
|
let response_len = frame.len();
|
||||||
|
let _ = socket.send_to(&frame, peer_addr).await?;
|
||||||
|
let _ = ui_event_tx.send(UiEvent::Tx {
|
||||||
|
peer: peer_addr.ip(),
|
||||||
|
bytes: response_len,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -16,3 +16,5 @@ json_comments = "0.2"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
|
||||||
138
ostp/src/main.rs
138
ostp/src/main.rs
|
|
@ -31,6 +31,10 @@ struct Args {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
links: bool,
|
links: bool,
|
||||||
|
|
||||||
|
/// Validate configuration file and exit
|
||||||
|
#[arg(long)]
|
||||||
|
check: bool,
|
||||||
|
|
||||||
/// Optional client connection share link (ostp://ACCESS_KEY@HOST:PORT) to run instantly
|
/// Optional client connection share link (ostp://ACCESS_KEY@HOST:PORT) to run instantly
|
||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
@ -133,11 +137,51 @@ impl UnifiedConfig {
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
struct ServerConfig {
|
struct ServerConfig {
|
||||||
listen: String,
|
listen: ListenConfig,
|
||||||
access_keys: Vec<String>,
|
access_keys: Vec<String>,
|
||||||
turn_server: Option<String>,
|
turn_server: Option<String>,
|
||||||
debug: Option<bool>,
|
debug: Option<bool>,
|
||||||
outbound: Option<OutboundConfig>,
|
outbound: Option<OutboundConfig>,
|
||||||
|
api: Option<ApiConfig>,
|
||||||
|
fallback: Option<FallbackCfg>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supports both single string "0.0.0.0:50000" and array ["0.0.0.0:50000", "[::]:50000"]
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum ListenConfig {
|
||||||
|
Single(String),
|
||||||
|
Multiple(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListenConfig {
|
||||||
|
fn addresses(&self) -> Vec<String> {
|
||||||
|
match self {
|
||||||
|
ListenConfig::Single(s) => vec![s.clone()],
|
||||||
|
ListenConfig::Multiple(v) => v.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn primary(&self) -> String {
|
||||||
|
match self {
|
||||||
|
ListenConfig::Single(s) => s.clone(),
|
||||||
|
ListenConfig::Multiple(v) => v.first().cloned().unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
struct ApiConfig {
|
||||||
|
enabled: Option<bool>,
|
||||||
|
bind: Option<String>,
|
||||||
|
token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
struct FallbackCfg {
|
||||||
|
enabled: Option<bool>,
|
||||||
|
listen: Option<String>,
|
||||||
|
target: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
|
@ -201,6 +245,17 @@ struct MuxConfig {
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
// Initialize structured logging via tracing
|
||||||
|
// Default: info level; override with RUST_LOG env var (e.g. RUST_LOG=ostp_server=debug)
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))
|
||||||
|
)
|
||||||
|
.with_target(false)
|
||||||
|
.compact()
|
||||||
|
.init();
|
||||||
|
|
||||||
let res = run_app().await;
|
let res = run_app().await;
|
||||||
if let Err(e) = res {
|
if let Err(e) = res {
|
||||||
eprintln!();
|
eprintln!();
|
||||||
|
|
@ -308,6 +363,52 @@ async fn run_app() -> Result<()> {
|
||||||
return run_client_directly(client_cfg).await;
|
return run_client_directly(client_cfg).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle --check: validate config and exit
|
||||||
|
if args.check {
|
||||||
|
if !args.config.exists() {
|
||||||
|
anyhow::bail!("Configuration file {:?} not found.", args.config);
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(&args.config)?;
|
||||||
|
let mut stripped = json_comments::StripComments::new(content.as_bytes());
|
||||||
|
match serde_json::from_reader::<_, UnifiedConfig>(&mut stripped) {
|
||||||
|
Ok(config) => {
|
||||||
|
config.validate()?;
|
||||||
|
match &config.mode {
|
||||||
|
AppMode::Server(s) => {
|
||||||
|
println!("[ostp] Config OK: server mode");
|
||||||
|
println!(" Listen: {:?}", s.listen.primary());
|
||||||
|
println!(" Access keys: {}", s.access_keys.len());
|
||||||
|
if let Some(api) = &s.api {
|
||||||
|
println!(" API: {} (bind: {})",
|
||||||
|
if api.enabled.unwrap_or(false) { "enabled" } else { "disabled" },
|
||||||
|
api.bind.as_deref().unwrap_or("127.0.0.1:9090"));
|
||||||
|
}
|
||||||
|
if let Some(outbound) = &s.outbound {
|
||||||
|
println!(" Outbound proxy: {} ({})",
|
||||||
|
if outbound.enabled { "enabled" } else { "disabled" },
|
||||||
|
outbound.protocol);
|
||||||
|
}
|
||||||
|
if let Some(fb) = &s.fallback {
|
||||||
|
println!(" Fallback: {} ({} -> {})",
|
||||||
|
if fb.enabled.unwrap_or(false) { "enabled" } else { "disabled" },
|
||||||
|
fb.listen.as_deref().unwrap_or("0.0.0.0:443"),
|
||||||
|
fb.target.as_deref().unwrap_or("127.0.0.1:8080"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMode::Client(c) => {
|
||||||
|
println!("[ostp] Config OK: client mode");
|
||||||
|
println!(" Server: {}", c.server);
|
||||||
|
println!(" Key: {}...", &c.access_key[..8.min(c.access_key.len())]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
anyhow::bail!("Config parse error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// Handle explicit configuration initialization
|
// Handle explicit configuration initialization
|
||||||
if let Some(ref mode_str) = args.init {
|
if let Some(ref mode_str) = args.init {
|
||||||
let is_server = mode_str == "server";
|
let is_server = mode_str == "server";
|
||||||
|
|
@ -341,6 +442,22 @@ async fn run_app() -> Result<()> {
|
||||||
}}
|
}}
|
||||||
]
|
]
|
||||||
}},
|
}},
|
||||||
|
|
||||||
|
// Management REST API for third-party panels.
|
||||||
|
"api": {{
|
||||||
|
"enabled": false,
|
||||||
|
"bind": "127.0.0.1:9090",
|
||||||
|
// Set a strong token for authentication. Leave empty to disable auth.
|
||||||
|
"token": ""
|
||||||
|
}},
|
||||||
|
|
||||||
|
// Fallback TCP proxy: unrecognized connections are proxied to a web server (anti-DPI).
|
||||||
|
"fallback": {{
|
||||||
|
"enabled": false,
|
||||||
|
"listen": "0.0.0.0:443",
|
||||||
|
// Target web server (e.g., local nginx or caddy)
|
||||||
|
"target": "127.0.0.1:8080"
|
||||||
|
}},
|
||||||
"debug": false
|
"debug": false
|
||||||
}}"#, key)
|
}}"#, key)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -429,7 +546,7 @@ async fn run_app() -> Result<()> {
|
||||||
if args.links {
|
if args.links {
|
||||||
match config.mode {
|
match config.mode {
|
||||||
AppMode::Server(server_cfg) => {
|
AppMode::Server(server_cfg) => {
|
||||||
let listen = server_cfg.listen.clone();
|
let listen = server_cfg.listen.primary();
|
||||||
let parts: Vec<&str> = listen.split(':').collect();
|
let parts: Vec<&str> = listen.split(':').collect();
|
||||||
let port = parts.get(1).unwrap_or(&"50000");
|
let port = parts.get(1).unwrap_or(&"50000");
|
||||||
let host = if parts[0] == "0.0.0.0" {
|
let host = if parts[0] == "0.0.0.0" {
|
||||||
|
|
@ -452,11 +569,11 @@ async fn run_app() -> Result<()> {
|
||||||
|
|
||||||
match config.mode {
|
match config.mode {
|
||||||
AppMode::Server(server_cfg) => {
|
AppMode::Server(server_cfg) => {
|
||||||
println!("[ostp] Starting server on {}", server_cfg.listen);
|
let listen_addrs = server_cfg.listen.addresses();
|
||||||
|
println!("[ostp] Starting server on {:?}", listen_addrs);
|
||||||
if let Some(turn) = server_cfg.turn_server {
|
if let Some(turn) = server_cfg.turn_server {
|
||||||
println!("[ostp] TURN relay enabled: {}", turn);
|
println!("[ostp] TURN relay enabled: {}", turn);
|
||||||
}
|
}
|
||||||
// Temporarily pass control to the isolated server implementation
|
|
||||||
let debug = server_cfg.debug.unwrap_or(false);
|
let debug = server_cfg.debug.unwrap_or(false);
|
||||||
let outbound = server_cfg.outbound.map(|o| ostp_server::OutboundConfig {
|
let outbound = server_cfg.outbound.map(|o| ostp_server::OutboundConfig {
|
||||||
enabled: o.enabled,
|
enabled: o.enabled,
|
||||||
|
|
@ -474,7 +591,18 @@ async fn run_app() -> Result<()> {
|
||||||
.collect(),
|
.collect(),
|
||||||
default_action: parse_outbound_action(o.default_action),
|
default_action: parse_outbound_action(o.default_action),
|
||||||
});
|
});
|
||||||
ostp_server::run_server(server_cfg.listen, server_cfg.access_keys, outbound, debug).await?;
|
let api_config = server_cfg.api.map(|a| ostp_server::ApiConfig {
|
||||||
|
enabled: a.enabled.unwrap_or(false),
|
||||||
|
bind: a.bind.unwrap_or_else(|| "127.0.0.1:9090".to_string()),
|
||||||
|
token: a.token.unwrap_or_default(),
|
||||||
|
});
|
||||||
|
let fallback_config = server_cfg.fallback.map(|f| ostp_server::FallbackConfig {
|
||||||
|
enabled: f.enabled.unwrap_or(false),
|
||||||
|
listen: f.listen.unwrap_or_else(|| "0.0.0.0:443".to_string()),
|
||||||
|
target: f.target.unwrap_or_else(|| "127.0.0.1:8080".to_string()),
|
||||||
|
});
|
||||||
|
// Pass all listen addresses for multi-listener support
|
||||||
|
ostp_server::run_server(listen_addrs, server_cfg.access_keys, outbound, api_config, fallback_config, debug).await?;
|
||||||
}
|
}
|
||||||
AppMode::Client(client_cfg) => {
|
AppMode::Client(client_cfg) => {
|
||||||
run_client_directly(client_cfg).await?;
|
run_client_directly(client_cfg).await?;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue