diff --git a/Cargo.lock b/Cargo.lock index caca93c..0884f03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,6 +239,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -365,7 +371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -528,8 +534,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", ] [[package]] @@ -620,6 +642,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", ] [[package]] @@ -628,13 +667,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -773,6 +820,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -796,7 +849,7 @@ dependencies = [ "combine", "jni-sys 0.3.1", "log", - "thiserror", + "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] @@ -871,6 +924,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -961,7 +1020,7 @@ dependencies = [ "json_comments", "ostp-client", "ostp-server", - "rand", + "rand 0.8.5", "serde", "serde_json", "snow", @@ -984,7 +1043,7 @@ dependencies = [ "json_comments", "ostp-core", "portable-atomic", - "rand", + "rand 0.8.5", "rustls", "rustls-pki-types", "serde", @@ -1005,10 +1064,10 @@ dependencies = [ "bytes", "chacha20poly1305", "hmac", - "rand", + "rand 0.8.5", "sha2", "snow", - "thiserror", + "thiserror 1.0.69", "tracing", ] @@ -1036,11 +1095,13 @@ dependencies = [ "axum", "base64", "bytes", + "futures-util", "hmac", "ostp-core", "portable-atomic", - "rand", + "rand 0.8.5", "rcgen", + "reqwest", "rustls", "serde", "serde_json", @@ -1150,6 +1211,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -1159,6 +1275,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -1166,8 +1288,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1177,7 +1309,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1186,7 +1328,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -1219,6 +1370,44 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + [[package]] name = "ring" version = "0.17.14" @@ -1227,12 +1416,18 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1263,6 +1458,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -1428,7 +1624,7 @@ dependencies = [ "blake2", "chacha20poly1305", "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "rustc_version", "sha2", "subtle", @@ -1478,6 +1674,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1496,7 +1695,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -1510,6 +1718,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -1548,6 +1767,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.0" @@ -1618,10 +1852,14 @@ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags", "bytes", + "futures-util", "http", + "http-body", "pin-project-lite", + "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -1698,6 +1936,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.19.0" @@ -1772,12 +2016,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.121" @@ -1791,6 +2053,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.121" @@ -1823,6 +2095,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2066,6 +2358,12 @@ dependencies = [ "toml", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" diff --git a/docs/relay-config-example.json b/docs/relay-config-example.json new file mode 100644 index 0000000..2523f71 --- /dev/null +++ b/docs/relay-config-example.json @@ -0,0 +1,35 @@ +{ + // OSTP Relay Node Configuration + // Этот узел принимает соединения от клиентов, проверяет их аутентификацию + // и пробрасывает трафик к целевому серверу. + // + // Архитектура цепочки: + // Клиент -> [Этот Relay] -> [Relay 2] -> ... -> [Target Server] + // + // Ключи синхронизируются напрямую с API Target Server каждые N секунд. + // Relay не знает содержимого трафика — только проверяет HMAC-подпись. + + "mode": "relay", + + // Адрес, на котором relay слушает входящие соединения от клиентов + "listen": "0.0.0.0:50000", + + // Адрес следующего узла в цепочке (другой relay или конечный сервер) — TCP (UoT) + "upstream_tcp": "TARGET_SERVER_IP:50000", + + // Адрес следующего узла в цепочке — UDP + "upstream_udp": "TARGET_SERVER_IP:50000", + + // URL API конечного (целевого) сервера для синхронизации access_keys + // Должен быть доступен с этого relay-сервера (можно через SSH-туннель) + "upstream_api_url": "http://TARGET_SERVER_IP:9090", + + // Bearer-токен для доступа к API целевого сервера + // Должен совпадать с api.token в конфиге target-сервера + "upstream_api_token": "YOUR_API_TOKEN_HERE", + + // Интервал синхронизации ключей в секундах (по умолчанию: 30) + "sync_interval_secs": 30, + + "debug": false +} diff --git a/ostp-server/Cargo.toml b/ostp-server/Cargo.toml index 7a077a3..c9bb720 100644 --- a/ostp-server/Cargo.toml +++ b/ostp-server/Cargo.toml @@ -23,3 +23,5 @@ base64 = "0.22" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "logging", "tls12"] } rcgen = "0.13" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +futures-util = "0.3" diff --git a/ostp-server/src/lib.rs b/ostp-server/src/lib.rs index 622b183..b9eddc6 100644 --- a/ostp-server/src/lib.rs +++ b/ostp-server/src/lib.rs @@ -15,12 +15,14 @@ pub mod outbound; pub mod api; pub mod fallback; pub mod transport; +pub mod relay_node; mod relay; mod signal; pub use outbound::{OutboundAction, OutboundConfig, OutboundRule}; pub use api::ApiConfig; pub use fallback::FallbackConfig; +pub use relay_node::RelayConfig; #[derive(Debug, Clone)] pub struct RealityServerConfig { diff --git a/ostp-server/src/relay_node.rs b/ostp-server/src/relay_node.rs new file mode 100644 index 0000000..fb3f6cb --- /dev/null +++ b/ostp-server/src/relay_node.rs @@ -0,0 +1,392 @@ +//! Authenticated Relay Node +//! +//! Принимает входящие UDP/TCP (UoT) соединения от клиентов, +//! валидирует HMAC-подпись клиента, используя ключи синхронизированные с upstream-сервера, +//! и слепо пробрасывает авторизованный трафик к целевому upstream-серверу. +//! +//! Архитектура цепочек: +//! Клиент -> [Relay 1] -> [Relay 2] -> ... -> [Target Server] +//! Каждый Relay скачивает access_keys напрямую с Target Server API. + +use anyhow::Result; +use bytes::Bytes; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream, UdpSocket}; +use tokio::sync::Mutex; + +/// Конфигурация Relay-узла. +#[derive(Debug, Clone)] +pub struct RelayConfig { + /// Адрес(а) для прослушивания входящих соединений (UDP + TCP). + pub listen_addrs: Vec, + /// Адрес upstream TCP для пересылки (обычно тот же порт, что и у target-сервера). + pub upstream_tcp: String, + /// Адрес upstream UDP. + pub upstream_udp: String, + /// URL API target-сервера для получения access_keys. + /// Пример: "http://127.0.0.1:9090" + pub upstream_api_url: String, + /// Bearer-токен для аутентификации на API target-сервера. + pub upstream_api_token: String, + /// Интервал синхронизации ключей (секунды). + pub sync_interval_secs: u64, +} + +type SharedKeys = Arc>>; + +/// Точка входа Relay-узла. +pub async fn run_relay_node(cfg: RelayConfig) -> Result<()> { + let shared_keys: SharedKeys = Arc::new(RwLock::new(Vec::new())); + + // Первоначальная синхронизация ключей + if let Err(e) = sync_keys(&cfg, &shared_keys).await { + tracing::warn!("Relay: initial key sync failed: {}. Will retry.", e); + } else { + let count = shared_keys.read().unwrap().len(); + tracing::info!("Relay: synced {} access key(s) from upstream API", count); + } + + // Фоновый синхронизатор ключей + let cfg_clone = cfg.clone(); + let keys_clone = shared_keys.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(cfg_clone.sync_interval_secs)).await; + match sync_keys(&cfg_clone, &keys_clone).await { + Ok(count) => tracing::debug!("Relay: refreshed {} access key(s)", count), + Err(e) => tracing::warn!("Relay: key sync error: {}", e), + } + } + }); + + // Запуск UDP relay + { + let cfg_udp = cfg.clone(); + let keys_udp = shared_keys.clone(); + tokio::spawn(async move { + if let Err(e) = run_udp_relay(cfg_udp, keys_udp).await { + tracing::error!("Relay UDP loop error: {}", e); + } + }); + } + + // Запуск TCP (UoT) relay + run_tcp_relay(cfg, shared_keys).await +} + +/// Синхронизация access_keys с upstream API. +async fn sync_keys(cfg: &RelayConfig, shared_keys: &SharedKeys) -> Result { + let url = format!("{}/api/keys", cfg.upstream_api_url.trim_end_matches('/')); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build()?; + + let mut req = client.get(&url); + if !cfg.upstream_api_token.is_empty() { + req = req.header("Authorization", format!("Bearer {}", cfg.upstream_api_token)); + } + + let resp = req.send().await?; + if !resp.status().is_success() { + anyhow::bail!("API returned HTTP {}", resp.status()); + } + + #[derive(serde::Deserialize)] + struct KeysResponse { + keys: Vec, + } + + let body: KeysResponse = resp.json().await?; + let count = body.keys.len(); + { + let mut lock = shared_keys.write().unwrap(); + *lock = body.keys; + } + Ok(count) +} + +/// Проверяет HMAC-подпись клиента по набору ключей. +/// Возвращает true если хотя бы один ключ подходит. +fn verify_hmac(ts_bytes: &[u8; 8], provided_mac: &[u8], keys: &[String]) -> bool { + let client_ts = u64::from_be_bytes(*ts_bytes); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Защита от replay: ±60 секунд + if client_ts > now + 30 || client_ts < now.saturating_sub(60) { + return false; + } + + for key in keys { + if let Ok(mut mac) = Hmac::::new_from_slice(key.as_bytes()) { + mac.update(ts_bytes); + if mac.verify_slice(provided_mac).is_ok() { + return true; + } + } + } + false +} + +// ── UDP Relay ──────────────────────────────────────────────────────────────── + +async fn run_udp_relay(cfg: RelayConfig, shared_keys: SharedKeys) -> Result<()> { + // NAT-таблица: client_addr -> (upstream_socket, last_seen) + let nat_table: Arc, Instant)>>> = + Arc::new(Mutex::new(HashMap::new())); + + for bind_addr in &cfg.listen_addrs { + let sock = UdpSocket::bind(bind_addr).await?; + tracing::info!("Relay UDP listening on {}", bind_addr); + let sock = Arc::new(sock); + let upstream_udp = cfg.upstream_udp.clone(); + let keys = shared_keys.clone(); + let nat = nat_table.clone(); + + tokio::spawn(async move { + let mut buf = vec![0u8; 65535]; + loop { + let (n, peer) = match sock.recv_from(&mut buf).await { + Ok(v) => v, + Err(_) => continue, + }; + + let packet = Bytes::copy_from_slice(&buf[..n]); + + // Быстрая проверка: первый UDP-пакет от нового клиента содержит Noise handshake. + // Мы берём из него первые 8 байт как timestamp + 32 байта MAC. + // Если пакет достаточно длинный, проверяем подпись. + // Для уже авторизованных клиентов (есть в NAT) — пропускаем проверку. + { + let nat_lock = nat.lock().await; + if !nat_lock.contains_key(&peer) { + drop(nat_lock); + + // Пакет должен быть >= 40 байт (8 ts + 32 hmac) для первичной проверки + if packet.len() < 40 { + tracing::debug!("Relay UDP: dropping short packet from {}", peer); + continue; + } + + let ts_bytes: [u8; 8] = packet[0..8].try_into().unwrap(); + let provided_mac = &packet[8..40]; + let keys_guard = keys.read().unwrap(); + + if !verify_hmac(&ts_bytes, provided_mac, &keys_guard) { + tracing::debug!("Relay UDP: unauthorized probe from {}, dropped", peer); + continue; + } + tracing::debug!("Relay UDP: authorized new client {}", peer); + } + } + + // Находим или создаём upstream socket для этого клиента + let upstream_sock = { + let mut nat_lock = nat.lock().await; + if let Some(entry) = nat_lock.get_mut(&peer) { + entry.1 = Instant::now(); + entry.0.clone() + } else { + // Новый upstream socket для этого клиента + let usock = match UdpSocket::bind("0.0.0.0:0").await { + Ok(s) => Arc::new(s), + Err(e) => { + tracing::warn!("Relay UDP: failed to bind upstream socket: {}", e); + continue; + } + }; + if usock.connect(&upstream_udp).await.is_err() { + tracing::warn!("Relay UDP: failed to connect to upstream {}", upstream_udp); + continue; + } + + nat_lock.insert(peer, (usock.clone(), Instant::now())); + + // Задача: читаем ответы от upstream и отправляем клиенту + let usock_rx = usock.clone(); + let client_sock = sock.clone(); + let peer_addr = peer; + tokio::spawn(async move { + let mut rbuf = vec![0u8; 65535]; + loop { + match usock_rx.recv(&mut rbuf).await { + Ok(n) => { + let _ = client_sock.send_to(&rbuf[..n], peer_addr).await; + } + Err(_) => break, + } + } + }); + + usock + } + }; + + // Пересылаем пакет в upstream + let _ = upstream_sock.send(&packet).await; + } + }); + } + + // Периодически чистим устаревшие NAT записи (timeout 120 сек) + loop { + tokio::time::sleep(Duration::from_secs(30)).await; + let mut nat_lock = nat_table.lock().await; + let now = Instant::now(); + nat_lock.retain(|_, (_, last)| now.duration_since(*last) < Duration::from_secs(120)); + } +} + +// ── TCP (UoT) Relay ────────────────────────────────────────────────────────── + +async fn run_tcp_relay(cfg: RelayConfig, shared_keys: SharedKeys) -> Result<()> { + for bind_addr in &cfg.listen_addrs { + let listener = TcpListener::bind(bind_addr).await?; + tracing::info!("Relay TCP (UoT) listening on {}", bind_addr); + + let upstream_tcp = cfg.upstream_tcp.clone(); + let keys = shared_keys.clone(); + + tokio::spawn(async move { + loop { + let (stream, peer_addr) = match listener.accept().await { + Ok(v) => v, + Err(e) => { + tracing::warn!("Relay TCP accept error: {}", e); + continue; + } + }; + + let upstream = upstream_tcp.clone(); + let keys_clone = keys.clone(); + + tokio::spawn(async move { + if let Err(e) = handle_tcp_client(stream, peer_addr, upstream, keys_clone).await { + tracing::debug!("Relay TCP client {} closed: {}", peer_addr, e); + } + }); + } + }); + } + + // Держим поток живым + futures_util::future::pending::<()>().await; + Ok(()) +} + +/// Обработка одного TCP (UoT) соединения. +/// +/// Алгоритм: +/// 1. Читаем HTTP-заголовки (фейковый WebSocket upgrade). +/// 2. Извлекаем HMAC-подпись из Authorization: Bearer. +/// 3. Проверяем подпись по синхронизированным ключам. +/// 4. Если авторизован — открываем соединение к upstream и пайпим потоки. +async fn handle_tcp_client( + mut client: TcpStream, + peer_addr: SocketAddr, + upstream_addr: String, + shared_keys: SharedKeys, +) -> Result<()> { + // Читаем HTTP-заголовки (до \r\n\r\n) + let mut header_buf = vec![0u8; 4096]; + let mut header_len = 0usize; + + loop { + let n = client.read(&mut header_buf[header_len..]).await?; + if n == 0 { + anyhow::bail!("connection closed before handshake"); + } + header_len += n; + if header_buf[..header_len].windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + if header_len >= header_buf.len() { + anyhow::bail!("headers too large"); + } + } + + let headers_str = String::from_utf8_lossy(&header_buf[..header_len]); + + // Быстрая проверка: должен быть GET /stream + if !headers_str.starts_with("GET /stream HTTP/1.1\r\n") { + // Возвращаем 404 как обычный сервер (anti-scan) + let _ = client.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found").await; + anyhow::bail!("invalid request from {}", peer_addr); + } + + // Извлекаем HMAC-подпись + let mut sig_b64 = None; + for line in headers_str.lines() { + let lower = line.to_ascii_lowercase(); + if lower.starts_with("authorization: bearer ") { + sig_b64 = Some(line[22..].trim().to_string()); + } else if lower.starts_with("cookie: ostp_token=") { + sig_b64 = Some(line[19..].trim().to_string()); + } + } + + let sig_b64 = match sig_b64 { + Some(s) => s, + None => { + let _ = client.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found").await; + anyhow::bail!("missing authorization from {}", peer_addr); + } + }; + + let sig_bytes = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD_NO_PAD, + &sig_b64, + ) + .map_err(|_| anyhow::anyhow!("invalid base64 from {}", peer_addr))?; + + if sig_bytes.len() < 40 { + let _ = client.write_all(b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 12\r\nConnection: close\r\n\r\nUnauthorized").await; + anyhow::bail!("signature too short from {}", peer_addr); + } + + let ts_bytes: [u8; 8] = sig_bytes[0..8].try_into().unwrap(); + let provided_mac = &sig_bytes[8..]; + + // Проверяем по синхронизированным ключам + let authorized = { + let keys = shared_keys.read().unwrap(); + verify_hmac(&ts_bytes, provided_mac, &keys) + }; + + if !authorized { + let _ = client.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found").await; + anyhow::bail!("unauthorized client {}", peer_addr); + } + + tracing::info!("Relay TCP: authorized client {}, forwarding to {}", peer_addr, upstream_addr); + + // Подключаемся к upstream + let mut upstream = TcpStream::connect(&upstream_addr).await + .map_err(|e| anyhow::anyhow!("failed to connect to upstream {}: {}", upstream_addr, e))?; + + // Пересылаем upstream заголовки AS-IS (он сам проверит подпись) + upstream.write_all(&header_buf[..header_len]).await?; + + // Пайпим оба потока: client <-> upstream + let (mut cr, mut cw) = client.into_split(); + let (mut ur, mut uw) = upstream.into_split(); + + let c2u = tokio::spawn(async move { + let _ = tokio::io::copy(&mut cr, &mut uw).await; + }); + let u2c = tokio::spawn(async move { + let _ = tokio::io::copy(&mut ur, &mut cw).await; + }); + + let _ = tokio::join!(c2u, u2c); + Ok(()) +} diff --git a/ostp/src/main.rs b/ostp/src/main.rs index e5c1582..442f874 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -145,6 +145,7 @@ fn parse_outbound_action(value: Option) -> ostp_server::OutboundAction { enum AppMode { Server(ServerConfig), Client(ClientConfig), + Relay(RelayServerConfig), } #[derive(Debug, Deserialize, Serialize)] @@ -177,6 +178,14 @@ impl UnifiedConfig { anyhow::bail!("Client configuration must contain an access_key."); } } + AppMode::Relay(cfg) => { + if cfg.upstream_tcp.is_empty() { + anyhow::bail!("Relay configuration must specify upstream_tcp address."); + } + if cfg.upstream_api_url.is_empty() { + anyhow::bail!("Relay configuration must specify upstream_api_url."); + } + } } Ok(()) } @@ -194,6 +203,28 @@ struct ServerConfig { transport: Option, } +/// Конфигурация Relay-узла в config.json +#[derive(Debug, Deserialize, Serialize)] +struct RelayServerConfig { + /// Адрес(а) прослушивания (UDP + TCP UoT) + listen: ListenConfig, + /// Адрес upstream для TCP (UoT) трафика + upstream_tcp: String, + /// Адрес upstream для UDP трафика + upstream_udp: String, + /// URL API целевого сервера для синхронизации ключей + upstream_api_url: String, + /// Bearer-токен для API целевого сервера + #[serde(default)] + upstream_api_token: String, + /// Интервал синхронизации ключей в секундах (по умолчанию 30) + #[serde(default = "default_sync_interval")] + sync_interval_secs: u64, + debug: Option, +} + +fn default_sync_interval() -> u64 { 30 } + /// Supports both single string "0.0.0.0:50000" and array ["0.0.0.0:50000", "[::]:50000"] #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(untagged)] @@ -469,6 +500,13 @@ async fn run_app() -> Result<()> { println!(" Server: {}", c.server); println!(" Key: {}...", &c.access_key[..8.min(c.access_key.len())]); } + AppMode::Relay(r) => { + println!("[ostp] Config OK: relay mode"); + println!(" Listen: {:?}", r.listen.primary()); + println!(" Upstream TCP: {}", r.upstream_tcp); + println!(" Upstream UDP: {}", r.upstream_udp); + println!(" API sync: {}", r.upstream_api_url); + } } } Err(e) => { @@ -698,6 +736,9 @@ async fn run_app() -> Result<()> { AppMode::Client(_) => { anyhow::bail!("The configuration file is in Client mode. The --links flag can only extract keys from a Server configuration."); } + AppMode::Relay(_) => { + anyhow::bail!("The configuration file is in Relay mode. The --links flag only works with Server configuration."); + } } } @@ -757,7 +798,22 @@ async fn run_app() -> Result<()> { AppMode::Client(client_cfg) => { run_client_directly(client_cfg).await?; } - + AppMode::Relay(relay_cfg) => { + let listen_addrs = relay_cfg.listen.addresses(); + println!("[ostp] Starting relay node on {:?}", listen_addrs); + println!("[ostp] Upstream TCP: {}", relay_cfg.upstream_tcp); + println!("[ostp] Upstream UDP: {}", relay_cfg.upstream_udp); + println!("[ostp] Key sync API: {}", relay_cfg.upstream_api_url); + let relay_config = ostp_server::RelayConfig { + listen_addrs, + upstream_tcp: relay_cfg.upstream_tcp, + upstream_udp: relay_cfg.upstream_udp, + upstream_api_url: relay_cfg.upstream_api_url, + upstream_api_token: relay_cfg.upstream_api_token, + sync_interval_secs: relay_cfg.sync_interval_secs, + }; + ostp_server::relay_node::run_relay_node(relay_config).await?; + } } Ok(())