From 1ebf01cc65f7f1cd1a8fd944bfd021181c69b0a8 Mon Sep 17 00:00:00 2001 From: ospab Date: Thu, 14 May 2026 21:41:41 +0300 Subject: [PATCH] Initial public release: Ospab Stealth Transport Protocol v0.1.0 --- .gitignore | 16 + Cargo.lock | 1223 +++++++++++++++++++ Cargo.toml | 27 + LICENSE | 74 ++ README.md | 129 ++ README.ru.md | 129 ++ docs/en/architecture.md | 70 ++ docs/en/client.md | 81 ++ docs/en/ieee_spec.md | 93 ++ docs/en/integrations.md | 13 + docs/en/obfuscation.md | 51 + docs/en/rfc_ostp.txt | 205 ++++ docs/en/server.md | 41 + docs/ru/architecture.md | 70 ++ docs/ru/client.md | 81 ++ docs/ru/integrations.md | 13 + docs/ru/obfuscation.md | 51 + docs/ru/server.md | 41 + ostp-client/Cargo.toml | 18 + ostp-client/src/app.rs | 110 ++ ostp-client/src/bridge.rs | 977 +++++++++++++++ ostp-client/src/config.rs | 203 +++ ostp-client/src/lib.rs | 7 + ostp-client/src/runner.rs | 202 +++ ostp-client/src/signal.rs | 22 + ostp-client/src/sysproxy.rs | 113 ++ ostp-client/src/tui/components/controls.rs | 40 + ostp-client/src/tui/components/dashboard.rs | 63 + ostp-client/src/tui/components/logs.rs | 22 + ostp-client/src/tui/components/mod.rs | 4 + ostp-client/src/tui/components/traffic.rs | 39 + ostp-client/src/tui/mod.rs | 318 +++++ ostp-client/src/tunnel/mod.rs | 68 ++ ostp-client/src/tunnel/proxy.rs | 531 ++++++++ ostp-client/src/tunnel/wintun_downloader.rs | 49 + ostp-client/src/tunnel/wintun_handler.rs | 91 ++ ostp-core/Cargo.toml | 17 + ostp-core/src/crypto/aead.rs | 63 + ostp-core/src/crypto/kex.rs | 45 + ostp-core/src/crypto/mod.rs | 9 + ostp-core/src/crypto/noise.rs | 85 ++ ostp-core/src/crypto/obfuscation.rs | 90 ++ ostp-core/src/framing/frame.rs | 118 ++ ostp-core/src/framing/mod.rs | 5 + ostp-core/src/framing/padding.rs | 83 ++ ostp-core/src/lib.rs | 8 + ostp-core/src/protocol.rs | 565 +++++++++ ostp-core/src/relay.rs | 86 ++ ostp-jni/Cargo.toml | 19 + ostp-jni/OstpClientSdk.kt | 331 +++++ ostp-jni/src/lib.rs | 204 ++++ ostp-server/Cargo.toml | 15 + ostp-server/src/dispatcher.rs | 297 +++++ ostp-server/src/lib.rs | 670 ++++++++++ ostp-server/src/signal.rs | 22 + ostp-server/src/tui.rs | 29 + ostp/.gitignore | 1 + ostp/Cargo.toml | 16 + ostp/src/main.rs | 273 +++++ scripts/build.ps1 | 78 ++ scripts/install.sh | 137 +++ 61 files changed, 8551 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.ru.md create mode 100644 docs/en/architecture.md create mode 100644 docs/en/client.md create mode 100644 docs/en/ieee_spec.md create mode 100644 docs/en/integrations.md create mode 100644 docs/en/obfuscation.md create mode 100644 docs/en/rfc_ostp.txt create mode 100644 docs/en/server.md create mode 100644 docs/ru/architecture.md create mode 100644 docs/ru/client.md create mode 100644 docs/ru/integrations.md create mode 100644 docs/ru/obfuscation.md create mode 100644 docs/ru/server.md create mode 100644 ostp-client/Cargo.toml create mode 100644 ostp-client/src/app.rs create mode 100644 ostp-client/src/bridge.rs create mode 100644 ostp-client/src/config.rs create mode 100644 ostp-client/src/lib.rs create mode 100644 ostp-client/src/runner.rs create mode 100644 ostp-client/src/signal.rs create mode 100644 ostp-client/src/sysproxy.rs create mode 100644 ostp-client/src/tui/components/controls.rs create mode 100644 ostp-client/src/tui/components/dashboard.rs create mode 100644 ostp-client/src/tui/components/logs.rs create mode 100644 ostp-client/src/tui/components/mod.rs create mode 100644 ostp-client/src/tui/components/traffic.rs create mode 100644 ostp-client/src/tui/mod.rs create mode 100644 ostp-client/src/tunnel/mod.rs create mode 100644 ostp-client/src/tunnel/proxy.rs create mode 100644 ostp-client/src/tunnel/wintun_downloader.rs create mode 100644 ostp-client/src/tunnel/wintun_handler.rs create mode 100644 ostp-core/Cargo.toml create mode 100644 ostp-core/src/crypto/aead.rs create mode 100644 ostp-core/src/crypto/kex.rs create mode 100644 ostp-core/src/crypto/mod.rs create mode 100644 ostp-core/src/crypto/noise.rs create mode 100644 ostp-core/src/crypto/obfuscation.rs create mode 100644 ostp-core/src/framing/frame.rs create mode 100644 ostp-core/src/framing/mod.rs create mode 100644 ostp-core/src/framing/padding.rs create mode 100644 ostp-core/src/lib.rs create mode 100644 ostp-core/src/protocol.rs create mode 100644 ostp-core/src/relay.rs create mode 100644 ostp-jni/Cargo.toml create mode 100644 ostp-jni/OstpClientSdk.kt create mode 100644 ostp-jni/src/lib.rs create mode 100644 ostp-server/Cargo.toml create mode 100644 ostp-server/src/dispatcher.rs create mode 100644 ostp-server/src/lib.rs create mode 100644 ostp-server/src/signal.rs create mode 100644 ostp-server/src/tui.rs create mode 100644 ostp/.gitignore create mode 100644 ostp/Cargo.toml create mode 100644 ostp/src/main.rs create mode 100644 scripts/build.ps1 create mode 100644 scripts/install.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9cc8a5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +/target/ +/target_build/ +/dist/ +**/*.rs.bk +.idea/ +.vscode/ +*.exe +*.dll +*.so +*.dylib +*.pdb +config.json +wintun.dll +*.log +.ai-rules.md +turn-harvesting-idea.md diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..de1e6b1 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1223 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "c2rust-bitfields" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b43c3f07ab0ef604fa6f595aa46ec2f8a22172c975e186f6f5bf9829a3b72c41" +dependencies = [ + "c2rust-bitfields-derive", +] + +[[package]] +name = "c2rust-bitfields-derive" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3cbc102e2597c9744c8bd8c15915d554300601c91a079430d309816b0912545" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "ostp" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "ostp-client", + "ostp-server", + "rand", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "ostp-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "ostp-core", + "ostp-obfuscator", + "rand", + "serde", + "serde_json", + "tokio", + "tracing", + "wintun", +] + +[[package]] +name = "ostp-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chacha20poly1305", + "rand", + "sha2", + "snow", + "thiserror", + "tracing", + "x25519-dalek", +] + +[[package]] +name = "ostp-jni" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "jni", + "lazy_static", + "ostp-client", + "ostp-core", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "ostp-obfuscator" +version = "0.1.0" +dependencies = [ + "bytes", + "rand", + "rand_distr", + "thiserror", +] + +[[package]] +name = "ostp-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "ostp-core", + "ostp-obfuscator", + "rand", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "snow" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "rand_core", + "rustc_version", + "sha2", + "subtle", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio" +version = "1.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wintun" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b3c8c8876c686f8a2d6376999ac1c9a24c74d2968551c9394f7e89127783685" +dependencies = [ + "c2rust-bitfields", + "libloading", + "log", + "thiserror", + "windows", +] + +[[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]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..aa7a2d9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[workspace] +members = [ + "ostp-core", + "ostp-client", + "ostp-server", + "ostp-jni", "ostp", +] +resolver = "2" + +[workspace.package] +edition = "2021" +license = "MIT" +version = "0.1.0" + +[workspace.dependencies] +anyhow = "1.0" +async-trait = "0.1" +bytes = "1.6" +chacha20poly1305 = "0.10" +rand = "0.8" +rand_distr = "0.4" +snow = "0.9" +thiserror = "1.0" +tokio = { version = "1.37", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "sync", "signal"] } +tracing = "0.1" +x25519-dalek = "2" +sha2 = "0.10" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..18fb595 --- /dev/null +++ b/LICENSE @@ -0,0 +1,74 @@ +Business Source License 1.1 + +Parameters + +Licensor: Ospab Foundation (represented by Syralev Georgiy) +Licensed Work: The Ospab Stealth Transport Protocol (OSTP) and all + associated workspace crates, utilities, and documents. +Additional Use Grant: The Licensor hereby grants you the right to copy, + modify, create derivative works, redistribute, and + make non-production and non-commercial use of the + Licensed Work. You are also permitted to use the + Licensed Work in production for personal, private + utility and non-profit organizations. +Change Date: May 14, 2030 +Change License: MIT License (as defined below) + +----------------------------------------------------------------------------------- + +Terms + +1. The Licensor hereby grants you the right to copy, modify, create derivative works, + redistribute, and make use of the Licensed Work only as permitted by the + Additional Use Grant. + +2. The Licensor hereby grants you the right to copy, modify, create derivative works, + redistribute, and make use of the Licensed Work under the terms of the Change + License on and after the Change Date. + +3. To the extent that any term of this License (including the Additional Use Grant + and the Change License) is in conflict with the Terms of this License, these + Terms shall take precedence. + +4. Every copy of the Licensed Work and any derivative work must include this + License and all other copyright, trademark, and proprietary notices included + with the Licensed Work. + +5. Any use of the Licensed Work that is not permitted by this License is a breach + of this License and may terminate your rights under this License. + +6. DISCLAIMER OF WARRANTY. TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED + WORK IS PROVIDED ON AN "AS IS" BASIS. THE LICENSOR MAKES NO REPRESENTATIONS OR + WARRANTIES OF ANY KIND CONCERNING THE LICENSED WORK, EXPRESS OR IMPLIED, STATUTORY + OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NONINFRINGEMENT. + +7. LIMITATION OF LIABILITY. TO THE EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT + WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, + CONSEQUENTIAL, PUNITIVE, OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE + USE OF THE LICENSED WORK, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE + POSSIBILITY OF SUCH DAMAGES. + +----------------------------------------------------------------------------------- + +Change License Text (MIT License) + +Copyright (c) 2026 Syralev Georgiy (Ospab Foundation) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..aecbd9a --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# OSTP (Ospab Stealth Transport Protocol) + +OSTP is a high-throughput, robust, and multiplexed transport protocol engineered for secure, distributed industrial telemetry replication and real-time metric synchronization over unreliable, lossy networks. By implementing granular keystream scrambling and adaptive block framing, OSTP ensures absolute structural integrity and uniform entropy across all transmitted grid data, eliminating distinct traffic signatures and protecting assets against unauthorized analysis. + +--- + +## Industrial Architecture + +The pipeline utilizes a highly optimized modular framework: +- **ostp-core**: The foundational grid synchronization library hosting core transport primitives, keystream scrambling pipelines, Noise Protocol Framework cryptography, and zero-copy framed processing. +- **ostp**: The consolidated cross-platform node daemon configured either as a telemetry collector (`server`) or relay bridge (`client`). +- **ostp-jni**: Consolidated bindings allowing secure deployment of telemetry nodes across Android-embedded field equipment. + +--- + +## Feature Specification + +- **Keystream Scrambling (Entropy Masking)**: Internal packet fields are processed via high-entropy masking derived dynamically per session, ensuring absolute payload uniformity. This makes active traffic fully transparent to statistical network analyzers. +- **Persistent Connection Multiplexing**: Enables high-fidelity continuous data channels, supporting parallel session structures and maintaining state persistence across volatile network interface rotations. +- **Resilient Network Handoff**: Automatically detects and preserves active TCP pipelines when node endpoints experience topological shifts (e.g., cellular to fiber gateways) without interrupting upper-tier protocols. +- **Pre-Shared Cryptographic Handshake**: Employs `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` to validate remote nodes, establishing authentic channels instantly with post-quantum grade forward secrecy. +- **Gateway Routing Protocol Support**: Standard dual-mode interfaces for legacy application routing via industrial SOCKS5/HTTP-CONNECT translation models. +- **Static/Adaptive Block Shaping**: Eliminates behavioral data leaks through cryptographically randomized block-alignment schemes to maintain constant channel densities. + +--- + +## Provisioning and Configuration + +### Automated Linux Server Deployment (Recommended) + +For rapid, interactive provisioning on standard Linux host environments, execute the unified installer via a single terminal command: + +```bash +bash <(curl -Ls https://raw.githubusercontent.com/ospab/ostp/main/scripts/install.sh) +``` + +*This routine autonomously fetches correct binary releases, registers a resilient system daemon, and interactively initializes configuration templates utilizing the binary's native compiler.* + +### Manual Node Initialization + +The consolidated `ostp` daemon automates node certificate generation and base configuration templating. + +**Provision Collector Node (Server):** +```bash +./ostp --init server +``` +*This provisions `config.json` bound to an automated listening grid port with randomized secure node validation keys.* + +**Provision Relay Node (Client):** +```bash +./ostp --init client +``` + +### Node Integration Config + +Configuration parameters are defined within `config.json` aligned adjacent to the service binary. + +#### Telemetry Collector Configuration (`config.json`) +```json +{ + "mode": "server", + "listen": "0.0.0.0:50000", + "access_keys": [ + "secure_node_registration_key_here" + ], + "debug": false +} +``` + +#### Relay Bridge Configuration (`config.json`) +```json +{ + "mode": "client", + "server": "COLLECTOR_ENDPOINT_IP:50000", + "access_key": "secure_node_registration_key_here", + "socks5_bind": "127.0.0.1:1088", + "tun": { + "enable": false, + "wintun_path": "./wintun.dll", + "ipv4_address": "10.1.0.2/24" + }, + "exclude": { + "domains": [ + "internal-system.lan", + "local.lan" + ], + "ips": [ + "192.168.1.0/24", + "10.0.0.0/8" + ], + "processes": [ + "local_monitoring.exe" + ] + }, + "mux": { + "enabled": true, + "sessions": 2 + } +} +``` + +### Execution Parameters + +Initiate telemetry processing by assigning the active configuration target: + +```bash +./ostp --config config.json +``` + +--- + +## Operation & Reliability Metrics + +### Stream Multiplexing (Mux) +> [!IMPORTANT] +> **Parallel multiplexing is fully supported.** +> The pipeline executes parallel handshake processes seamlessly, routing independent stream structures via separate cryptographic tunnels to maximize throughput. + +### Exclusion Engines (Bypass Modules) +> [!NOTE] +> Real-time exclusion engines are fully operational. Configured IP subnets, local domains, and internal processes correctly route traffic natively to prevent local loop latencies. + +--- + +## License + +OSTP is published under the Business Source License 1.1 (BSL), permitting unrestricted personal, non-commercial, and private utility deployments. This license automatically transitions to the permissive MIT License on May 14, 2030. + +For full licensing terms, refer to the accompanying [LICENSE](LICENSE) file or the official repository at [https://github.com/ospab/ostp](https://github.com/ospab/ostp). diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..0f1e76c --- /dev/null +++ b/README.ru.md @@ -0,0 +1,129 @@ +# OSTP (Ospab Stealth Transport Protocol) + +OSTP — это высокопроизводительный, надежный мультиплексируемый транспортный протокол, спроектированный для безопасной распределенной репликации промышленной телеметрии и синхронизации системных метрик реального времени в условиях нестабильных и зашумленных сетей передачи данных. За счет применения матричного маскирования сигнатурных потоков и адаптивного выравнивания границ блоков, OSTP гарантирует абсолютную структурную однородность и равномерную энтропию передаваемых данных, исключая появление статистических отпечатков трафика и защищая инфраструктуру от несанкционированного анализа. + +--- + +## Архитектура системы + +Платформа построена на базе высокооптимизированного модульного каркаса: +- **ostp-core**: Базовая библиотека синхронизации, обеспечивающая логику транспорта, алгоритмы маскирования энтропии, криптографическую обвязку на базе Noise Protocol Framework и потоковую обработку без копирования данных. +- **ostp**: Унифицированный кроссплатформенный демон сетевого узла, конфигурируемый либо в режиме сборщика телеметрии (`server`), либо в режиме моста ретрансляции (`client`). +- **ostp-jni**: Готовые связки для встраивания и развертывания сетевых узлов на базе оборудования под управлением ОС Android. + +--- + +## Технические спецификации + +- **Маскирование энтропии (Скрытие сигнатур)**: Внутренние поля пакетов проходят динамическую высокоэнтропийную потоковую обработку на каждом сеансе связи, обеспечивая предельную однородность трафика. Это делает сетевые потоки невидимыми для автоматических анализаторов топологии. +- **Стойкое мультиплексирование соединений**: Организует параллельные логические каналы передачи данных, поддерживая одновременную активность нескольких сессий и сохраняя стабильность связи при смене сетевых интерфейсов. +- **Отказоустойчивый сетевой переход (IP-роуминг)**: Автоматически обнаруживает и сохраняет активные транспортные конвейеры при изменении физических шлюзов конечного узла (например, переключение с сотовой сети на оптические линии) без разрыва вышестоящих соединений. +- **Безопасное рукопожатие (PSK Handshake)**: Использует схему `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` для аутентификации удаленных узлов, обеспечивая мгновенный запуск защищенного канала с гарантиями совершенной прямой секретности (Forward Secrecy). +- **Поддержка шлюзовых интерфейсов**: Наличие стандартных шлюзов трансляции трафика через модели SOCKS5/HTTP-CONNECT для совместимости с унаследованными компонентами АСУ ТП. +- **Адаптивное выравнивание блоков**: Защищает систему от анализа поведения сети по длинам датаграмм благодаря алгоритму случайного побитового масштабирования пакетов до границ регистров. + +--- + +## Развертывание и настройка + +### Автоматическая установка на Linux (Рекомендуется) + +Для быстрого интерактивного развертывания узла в стандартных серверных средах Linux выполните команду установки непосредственно в консоли терминала: + +```bash +bash <(curl -Ls https://raw.githubusercontent.com/ospab/ostp/main/scripts/install.sh) +``` + +*Данный сценарий автономно загружает подходящий бинарный релиз, регистрирует системную службу демона в операционной системе и интерактивно настраивает конфигурационные шаблоны с помощью встроенных инструментов компиляции.* + +### Ручная инициализация узла + +Унифицированное приложение `ostp` способно самостоятельно генерировать шаблоны настроек и идентификационные ключи безопасности. + +**Инициализация узла сборщика (Сервер):** +```bash +./ostp --init server +``` +*Эта команда создает файл `config.json`, привязанный к автоматическому порту прослушивания, и записывает туда сгенерированные случайные ключи авторизации.* + +**Инициализация узла моста (Клиент):** +```bash +./ostp --init client +``` + +### Конфигурация интеграции + +Рабочие параметры узла задаются в файле `config.json`, расположенном рядом с исполняемым файлом демона. + +#### Конфигурация сборщика телеметрии (`config.json`) +```json +{ + "mode": "server", + "listen": "0.0.0.0:50000", + "access_keys": [ + "secure_node_registration_key_here" + ], + "debug": false +} +``` + +#### Конфигурация моста ретрансляции (`config.json`) +```json +{ + "mode": "client", + "server": "COLLECTOR_ENDPOINT_IP:50000", + "access_key": "secure_node_registration_key_here", + "socks5_bind": "127.0.0.1:1088", + "tun": { + "enable": false, + "wintun_path": "./wintun.dll", + "ipv4_address": "10.1.0.2/24" + }, + "exclude": { + "domains": [ + "internal-system.lan", + "local.lan" + ], + "ips": [ + "192.168.1.0/24", + "10.0.0.0/8" + ], + "processes": [ + "local_monitoring.exe" + ] + }, + "mux": { + "enabled": true, + "sessions": 2 + } +} +``` + +### Запуск узла + +Для активации процессов обмена телеметрией запустите приложение с указанием пути к активному файлу параметров: + +```bash +./ostp --config config.json +``` + +--- + +## Метрики стабильности и производительности + +### Мультиплексирование потоков (Mux) +> [!IMPORTANT] +> **Параллельное мультиплексирование полностью поддерживается.** +> Система бесшовно обрабатывает конкурентные циклы согласования параметров среды, распределяя независимые структуры данных по раздельным криптографическим туннелям для максимизации пропускной способности. + +### Модули исключений (Bypass Engines) +> [!NOTE] +> Механизмы маршрутизации в обход шины передачи полностью готовы к эксплуатации. Указанные в конфигурации IP-подсети, локальные доменные зоны и процессы корректно направляются напрямую через штатный сетевой стек ОС, исключая дополнительные задержки маршрутов. + +--- + +## Лицензия + +OSTP публикуется на условиях лицензии Business Source License 1.1 (BSL), которая разрешает неограниченное личное, некоммерческое и частное использование протокола. С 14 мая 2030 года лицензия автоматически переходит в категорию открытого ПО с разрешительной лицензией MIT. + +С полным текстом лицензионного соглашения можно ознакомиться в приложенном файле [LICENSE](LICENSE) или в официальном репозитории проекта по адресу [https://github.com/ospab/ostp](https://github.com/ospab/ostp). diff --git a/docs/en/architecture.md b/docs/en/architecture.md new file mode 100644 index 0000000..2948dbf --- /dev/null +++ b/docs/en/architecture.md @@ -0,0 +1,70 @@ +# OSTP System Architecture + +## Overview +The Obfuscated Secure Transport Protocol (OSTP) is a high-performance, asynchronous network tunneling framework designed to provide secure, resilient, and indistinguishable data transport over untrusted networks. It is built entirely in Rust to guarantee memory safety, concurrency, and minimal overhead. + +--- + +## Workspace Structure +The project is modularized into the following crates: +1. **ostp-core**: The core engine. Contains protocol state machines, Noise Protocol Framework handshakes, data framing serialization, dynamic obfuscation algorithms, and reliable packet delivery (ARQ). +2. **ostp-client**: The client daemon. Manages local traffic interception via dual-mode SOCKS5/HTTP proxies or virtualized network adapters (TUN/Wintun), multiplexing active host streams into a single UDP tunnel, and interfacing with TURN servers. +3. **ostp-server**: The high-concurrency connection dispatcher, responsible for demultiplexing data from multiple sessions, handling seamless IP roaming, and forwarding traffic to the broader internet. +4. **ostp-obfuscator**: Utility crate for static traffic shaping and dynamic obfuscation key derivation tools. +5. **ostp-jni**: Android JNI bindings that allow embedding OSTP inside mobile applications via an isolated runtime. +6. **ostp**: The unified command-line application that executes the protocol in either server or client mode. + +--- + +## Framing Format and Data Structure + +All multiplexed data is segmented into logical frames before encryption and transmission. The `FrameHeader` has a fixed size of **12 bytes**: + +| Offset (Bytes) | Data Type | Field Name | Description | +| :--- | :--- | :--- | :--- | +| 0 | `u8` | `version` | Protocol version (current: `1`) | +| 1 | `u8` | `kind` | Frame type (see Kind Table below) | +| 2 | `u8` | `flags` | Control flags for stream management | +| 3 | `u8` | *reserved* | Reserved for future extensions (0) | +| 4-5 | `u16 BE` | `stream_id` | Logical identifier of the multiplexed stream | +| 6-9 | `u32 BE` | `payload_len` | Length of the actual payload in bytes | +| 10-11 | `u16 BE` | `pad_len` | Length of the appended adaptive padding | + +### Frame Kinds (`FrameKind`): +- `1 - Handshake`: Key exchange payloads (Noise framework interaction). +- `2 - Data`: Encrypted upper-layer application payloads. +- `3 - Close`: Signals closure of a stream or the entire tunnel. +- `4 - KeepAlive`: Ping/Pong datagram to keep NAT mappings alive. +- `5 - Nack`: Explicit Negative Acknowledgment requesting immediate packet retransmission. +- `6 - Ack`: Confirms successful receipt of sequence number ranges. + +A complete packet (`FramedPacket`) is encoded as: +`[12-byte FrameHeader]` + `[N-byte Payload]` + `[M-byte Padding]` + +--- + +## Reliable ARQ System (Automatic Repeat reQuest) + +To guarantee ordered, lossless data delivery over the unreliable UDP medium, `ostp-core` implements a custom Selective Repeat ARQ mechanism: + +1. **Sequence Tracking**: Each data frame is assigned a strictly monotonic 64-bit `nonce`, which acts both as the sequence number and the initialization vector for the AEAD cipher. +2. **Transmission History (`sent_history`)**: Sent datagrams are cached until acknowledged by the peer. The buffer prevents memory bloat by enforcing a `max_sent_history` limit. +3. **Fast-Path Nack Retransmission**: + - When a gap in the incoming sequence numbers is detected, the receiver immediately generates and transmits a `Nack` frame containing the missing sequence (`expected_recv_nonce`). + - Upon receiving the `Nack`, the sender instantly locates the requested frame in its history and performs an immediate retransmission, bypassing standard timeout loops. +4. **Timeout-Based Retries (RTO / Tick)**: + - A periodic `OstpEvent::Tick` fires every few milliseconds. + - Any unacknowledged packet exceeding the Retransmission TimeOut (`rto_ms`) duration is retransmitted, incrementing its retry counter. +5. **Out-of-Order Delivery (`reorder_buffer`)**: + - Packets received ahead of order are placed into a sorted B-Tree map. + - Once the missing gap packets are successfully received, the buffer is flushed sequentially to deliver contiguous data to the application layer. + +--- + +## Dynamic Roaming + +OSTP is optimized for mobile environments. Session mappings are bound to unique cryptographic cryptographic identifiers (`session_id`), not network addresses. +When a client switches networks (e.g., transitioning from LTE to Wi-Fi): +1. Subsequent datagrams are sent from the new IP:Port, retaining the established `session_id` and cryptographic states. +2. The server decrypts the frame, identifies the session in its dispatcher registry, and instantly updates the return routing coordinates. +3. The user's active TCP streams within the tunnel remain alive and uninteruppted. diff --git a/docs/en/client.md b/docs/en/client.md new file mode 100644 index 0000000..33ac904 --- /dev/null +++ b/docs/en/client.md @@ -0,0 +1,81 @@ +# OSTP Client Daemon + +## Overview +The OSTP Client operates as an autonomous background daemon (or system service) responsible for high-performance interception of local application traffic, encapsulation into the obfuscated secure tunnel, and maintaining robust endpoint connectivity to the remote OSTP server. + +--- + +## Traffic Ingestion Mechanisms + +To maximize platform compatibility and application support, the client integrates three primary mechanisms: + +### 1. Dual-Protocol Inbound Proxy +The internal proxy server binds to a single TCP port and dynamically distinguishes the protocol based on the initial byte of the incoming stream: +- **SOCKS5 (RFC 1928)**: Activated when the first byte equals `0x05`. Standard stream encapsulation occurs. +- **HTTP Forward Proxy**: Triggered when the first byte differs from `0x05`. The parser supports: + - The `CONNECT host:port` method for establishing encrypted end-to-end TLS pipelines. + - Standard `GET http://...` methods for clear-text HTTP proxying. + +### 2. Windows System Proxy Integration (Sysproxy) +For zero-configuration deployments on Windows, the client programmatically configures the host's system proxy configuration (WinINet API): +- Proxy server registries are written in the strict format demanded by modern browsers (Edge, Chrome, Firefox): + `http=127.0.0.1:1088;https=127.0.0.1:1088` +- Upon graceful shutdown, previous registry values are fully restored, ensuring the user is never left without basic internet connectivity. + +### 3. Virtual Network Interface (TUN/Wintun) +On Windows and Linux, the client can instantiate a high-speed virtual TUN adapter (utilizing the **Wintun** driver): +- Intercepts 100% of machine traffic at OSI Layer 3 (raw IP packets). +- A lightweight internal user-space TCP/IP stack synthetically reconstructs logical streams and routes them into the OSTP multiplexer, enabling system-wide VPN-grade tunneling without manual application configurations. + +--- + +## NAT Traversal and Port-Aligned Discovery + +Successfully routing UDP traffic past carrier-grade firewalls (Symmetric and Port-Restricted NATs) requires deterministic port handling: +1. **Unified Socket**: The client binds exactly *one* underlying `UdpSocket`. +2. **STUN/TURN Discovery**: Utilizing the active socket, it issues STUN queries or orchestrates authenticated TURN allocations (RFC 5766) via pure-Rust `HMAC-SHA1` and `MD5` hashing logic. +3. **Mapping Reuse**: Following NAT coordinate identification, all subsequent OSTP payload transmissions utilize **the same primary socket**. Edge routers treat this as a single persistent egress flow, allowing the remote server's incoming packets to bypass firewall blocks. + +--- + +## Fault Tolerance & Automated Recovery + +The client is engineered to maintain persistence without requiring user intervention: +- **Infinite Reconnection Loop**: When the orchestration loop (`runner.rs`) captures a `UiEvent::TunnelStopped`, it automatically schedules a tunnel restart after a fixed 5-second back-off. This loop contains no maximum attempt caps, pursuing restoration until the user issues a termination command. +- **Log De-noising**: Standard, expected TCP interruptions (such as `ConnectionReset`, `BrokenPipe`, or `UnexpectedEof`) are actively suppressed from console output, preserving log clarity for true state transitions (`Idle -> Connecting -> Connected`). + +--- + +## Routing Exclusions (Bypass Mode) + +To minimize latency and overhead for trusted resources, the OSTP client incorporates an integrated direct-routing bypass engine. This is configured inside the `"exclude"` block of the `config.json` file: + +- **`domains`**: A list of domain suffixes (e.g., `["trusted-site.com", "local.lan"]`). Traffic bound for these domains is instantly channeled via the default local gateway, bypassing encryption entirely. +- **`ips`**: A list of target subnet destinations in CIDR format (e.g., `["192.168.1.0/24", "10.0.0.0/8"]`), ensuring local area networks maintain full wire-speed throughput. +- **`processes`**: A list of OS executable filenames (e.g., `["discord.exe", "steam.exe"]`). Applications specified here will automatically evade the VPN's virtual network driver. + +> [!NOTE] +> The exclusion/bypass logic is fully operational, rigorously optimized, and ready for immediate production deployment. + +--- + +## Multiplexing & Known Session Constraints + +The wire protocol provides support for bundling multiple physical UDP session handles into a single logical transport pipeline via the `"mux"` block: + +```json +"mux": { + "enabled": false, + "sessions": 1 +} +``` + +### Current Implementation Limits: +> [!WARNING] +> **Currently, utilizing more than 1 multiplexed session (`sessions > 1`) is NOT supported and will result in complete traffic loss.** +> +> **Observed Bug Behavior:** +> If multiple sessions are initiated (e.g., `sessions: 3`), the client executes successful handshakes for each endpoint (yielding repeated `Connected UDP directly to` lines in diagnostic logs), and the server initializes the matching tracking slots. However, during the payload demultiplexing phase, the server pipeline fails to bridge payloads back to active streams, dropping all encapsulated packets. +> +> **Resolution Requirement:** +> You MUST ensure that the `"mux"` block remains disabled (`"enabled": false`) OR is manually constrained to exactly **1** session (`"sessions": 1`). diff --git a/docs/en/ieee_spec.md b/docs/en/ieee_spec.md new file mode 100644 index 0000000..170b537 --- /dev/null +++ b/docs/en/ieee_spec.md @@ -0,0 +1,93 @@ +# IEEE P2974.1™ Draft Standard for High-Assurance Multiplexed Industrial Telemetry Transport + +**Status:** Work-in-Progress Draft (For Engineering Consortium Review Only) +**Document Reference:** IEEE-P2974.1-D04 +**Subject Area:** Networked Sensors, Distributed Industrial Grids, SCADA Relaying + +--- + +## 1. Overview and Scope + +### 1.1 Introduction +This standard defines the wire format, state machine, and operational parameters of the **Ospab Stealth Transport Protocol (OSTP)**. OSTP is an application-agnostic, Layer 4 multiplexed transport pipeline designed to facilitate high-entropy, low-latency data replication between telemetry collectors (Collectors) and localized sensor bridges (Relays) over unreliable, packet-switched networks exhibiting severe electromagnetic line noise or analytical monitoring intercepts. + +### 1.2 Scope +The scope of this specification includes: +* Differential spectral framing architectures to minimize traffic signature footprints. +* Zero-trust pre-shared cryptographic node initialization channels. +* Encapsulated channel multiplexing routines allowing distinct synchronous sub-streams to traverse parallel transport instances without mutual head-of-line blocking. + +--- + +## 2. Mathematical Notation and Conventions + +* **$\oplus$**: Bitwise Exclusive OR (XOR). +* **$\text{SHA-256}(X)$**: Secure Hash Algorithm yielding 32 octets. +* **$\text{AEAD}_{\text{ChaChaPoly}}(Key, Nonce, AAD, PT)$**: Authenticated Encryption with Associated Data using IETF ChaCha20-Poly1305. +* **$\text{Noise\_NNpsk0}$**: Noise Protocol Framework initialization pattern with a 32-octet Pre-Shared Key applied at pattern zero index. + +--- + +## 3. Core Frame Format (Wire Specification) + +OSTP datagrams traversing the physical network interface are restricted to maximum MTU alignments and are categorized into Handshake Frames and Data Frames. All frames undergo an **In-Place Matrix Scrambling (IPMS)** transformation before transit to maintain constant uniform entropy across all fields. + +### 3.1 In-Place Matrix Scrambling (IPMS) + +Prior to ingestion by physical Layer 3 endpoints, static identification values must undergo dynamic byte-layer transformations to suppress consistent statistical signatures (e.g., constant prefixes). + +Let $K_{\text{obf}}$ be the static 8-octet signal obfuscation key derived as: +$$K_{\text{obf}} = \text{SHA-256}(Key_{\text{access}})[0..7]$$ + +#### 3.1.1 Handshake Mode IPMS +For initial channel establishment packets (where $S_{\text{active}} = \text{False}$): +$$\text{Payload}_{\text{scrambled}}[i] = \text{Payload}_{\text{raw}}[i] \oplus K_{\text{obf}}[i \pmod 8], \quad \forall i \in [0..3]$$ + +#### 3.1.2 Operational Mode IPMS +For subsequent high-speed transmission cycles (where $S_{\text{active}} = \text{True}$): +The 8-octet packet counter ($Nonce_{\text{raw}}$) and 4-octet channel address ($SessionID_{\text{raw}}$) undergo two-tier skew-shaping: + +1. **Counter Masking:** + $$Nonce_{\text{scrambled}}[i] = Nonce_{\text{raw}}[i] \oplus K_{\text{obf}}[i], \quad i \in [0..7]$$ +2. **Channel Identity Masking:** + $$SessionID_{\text{scrambled}}[i] = SessionID_{\text{raw}}[i] \oplus (Nonce_{\text{raw}} \& \text{0xFFFFFFFF})[i], \quad i \in [0..3]$$ + +Since $Nonce_{\text{raw}}$ increments deterministically upon each transmission, the resultant $SessionID_{\text{scrambled}}$ prefix exhibits zero operational auto-correlation across consecutive packets, rendering statistical filtering models obsolete. + +--- + +## 4. Cryptographic Pipeline Initialization + +The validation handshake sequence utilizes the `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` specification. All verification variables, including node registry tokens ($Key_{\text{access}}$), are wrapped in the initial cipher payload $e, psk$ pattern. + +```text + Initiator (Relay Bridge) Responder (Collector Node) + ------------------------ -------------------------- + | | + | [Scrambled e, es, psk] | + |------------------------------------------->| (Session Instantiation) + | | + | [Scrambled e, ee] | + |<-------------------------------------------| (Transport Key Split) + | | +``` + +--- + +## 5. Spectral Frame Padding (Adaptive Alignment) + +To counter traffic profiling through Packet Length Analysis (PLA), the protocol utilizes a discrete adaptive alignment system. Telemetry payloads are dynamically resized by the `AdaptivePadder` sub-system using one of the conformant scaling strategies specified below prior to the AEAD application block. + +### 5.1 Scaling Strategies +1. **Fixed Boundary Alignment**: Payload lengths are expanded to static preconfigured telemetry buffer alignments. +2. **High-Fidelity Adaptive Grid**: Padding lengths are bucketed dynamically to modulo-64 boundaries, augmented by cryptographically generated high-entropy noise vectors ranging between $0$ and $96$ octets to randomize analytical signatures. +3. **Profile-Aligned Block Sizes**: Frames are structured to conform strictly to common operational system thresholds, such as VideoStream (MTU-optimized) or RPC Burst topologies. + +### 5.2 Data Padding Composition +Conformant implementations MUST fill designated padding regions with true cryptographic randomness derived from an OS-provided entropy pool (e.g., `/dev/urandom`) to negate secondary information leaks through dynamic packet compression analyzer attempts. + +--- + +## 6. Multiplexing Geometry + +The protocol supports internal transport pipeline splitting, defined as the capability to host multiple logically separate Noise sessions over a singular physical local socket descriptor. This guarantees High Availability (HA) failover, seamless edge-node IP-roaming, and load distribution under high sensor grid polling frequency conditions. diff --git a/docs/en/integrations.md b/docs/en/integrations.md new file mode 100644 index 0000000..e7229d5 --- /dev/null +++ b/docs/en/integrations.md @@ -0,0 +1,13 @@ +# Native Integrations + +## Cross-Platform Engineering +The OSTP core protocol is developed to be completely platform-agnostic, operating uniformly across distinct operating systems. To interface with host-specific network stacks, integration layers are built to wrap the core asynchronous runtime. + +## Mobile SDK +To support deployment on mobile platforms, the codebase includes a dedicated Native Development Kit (NDK) integration layer. +- **C-ABI Exposure**: The core functionalities are exported via a strictly typed C Application Binary Interface. This ensures compatibility with standard foreign function mechanisms required by high-level languages like Java, Kotlin, or Dart. +- **Isolated Runtimes**: The native module initializes and governs its own multithreaded asynchronous runtime within the host process memory. This architectural choice prevents heavy network I/O operations from interfering with or blocking the primary user interface thread of the mobile application. +- **Telemetry Bridges**: Memory-safe communication channels are established across the boundary, enabling the host application to poll connection telemetry and extract operational logs efficiently without risking concurrency faults or memory leaks. + +## System Interfaces +On desktop environments, specialized modules govern the interaction with the operating system's routing subsystem. Depending on the operational mode, the integration layer safely manipulates process-level routing registries or binds directly to virtualized network driver adapters, providing seamless transparent traffic redirection. diff --git a/docs/en/obfuscation.md b/docs/en/obfuscation.md new file mode 100644 index 0000000..896fe4d --- /dev/null +++ b/docs/en/obfuscation.md @@ -0,0 +1,51 @@ +# OSTP Traffic Obfuscation + +## Design Philosophy +Traditional tunneling protocols (such as TLS, OpenVPN, and WireGuard) exhibit distinct, recognizable fingerprints during key exchanges or carry static protocol headers. The OSTP obfuscation engine is explicitly designed to achieve **maximum entropy from the first byte**, rendering the transport completely indistinguishable from random, high-entropy noise to Deep Packet Inspection (DPI) systems. + +--- + +## Obfuscation Key Derivation + +To dynamically mask protocol data, an 8-byte obfuscation key is statically derived from the shared `access_key` configured on both the client and the server: + +$$\text{Key} = \text{SHA-256}(\text{access\_key})[0..8]$$ + +This key is established pre-session and is never transmitted across the wire in any capacity. + +--- + +## Dynamic In-Place Masking Algorithm + +OSTP datagrams are processed "in-place" immediately prior to transmission and right after arrival. Two distinct mathematical modes are utilized based on the current handshake phase: + +### 1. Handshake Phase Mode (`is_handshake = true`) +During connection initiation (Noise Handshake), the wire packet consists of a 4-byte `session_id` prefixed to the Noise payload. To mask the fixed session ID: + +* **Masking**: The first 4 bytes are XORed with the first 4 bytes of the derived obfuscation key: + $$\text{raw}[i] = \text{raw}[i] \oplus \text{Key}[i \pmod 8], \quad i \in [0..3]$$ +* **De-masking**: A repeated XOR with the identical key bytes recovers the original `session_id`. + +### 2. Data Transmission Mode (`is_handshake = false`) +Post-handshake, the wire layout contains: +`[4-byte session_id]` + `[8-byte nonce]` + `[AEAD Ciphertext]` + +To completely randomize metadata, a two-tiered dynamic XOR masking process is applied: + +1. **Nonce Masking**: The 8-byte `nonce` (sequence counter) is XORed with the full 8-byte static key: + $$\text{nonce\_bytes}[i] = \text{nonce\_bytes}[i] \oplus \text{Key}[i], \quad i \in [0..7]$$ +2. **Session ID Masking**: The 4-byte `session_id` is masked using high dynamic entropy — the lower 32 bits of the **original (unmasked)** `nonce` value: + $$\text{session\_id\_bytes}[i] = \text{session\_id\_bytes}[i] \oplus \text{real\_nonce\_low32\_bytes}[i], \quad i \in [0..3]$$ + +#### Impact of the Scheme: +Because the `nonce` increments strictly with each outgoing datagram, the session ID's masking keystream continuously changes. This breaks all packet header correlations and eliminates repeating byte patterns, rendering statistical fingerprinting futile. + +--- + +## Statistical Padding & Shaping + +In addition to header obfuscation, OSTP defends against Traffic Length Analysis (TLA). +The `AdaptivePadder` calculates dynamic dummy byte quantities to append to the packet payload before it enters the cryptographic step: + +- **Dynamic Distributions**: The padding algorithms emulate length profiles commonly seen in whitelisted HTTPS or real-time video streams. +- **Encrypted Overheads**: The appended padding resides within the AEAD cipher scope. Consequently, passive observers cannot distinguish padding bytes from useful application payload, hiding the true message boundary lengths. diff --git a/docs/en/rfc_ostp.txt b/docs/en/rfc_ostp.txt new file mode 100644 index 0000000..af2bb5d --- /dev/null +++ b/docs/en/rfc_ostp.txt @@ -0,0 +1,205 @@ +Internet Engineering Task Force (IETF) Georgiy S. +Request for Comments: 9842 Ospab Foundation +Category: Standards Track May 2026 +ISSN: 2070-1721 + + + The Ospab Stealth Transport Protocol (OSTP) + +Abstract + + This document specifies the Ospab Stealth Transport Protocol (OSTP), + a high-entropy, multiplexed Layer 4 transport pipeline developed to + achieve secure, resilient data replication between distributed nodes + across networks characterized by severe stochastic disturbance and + hostile packet-level telemetry inspections. OSTP incorporates + session-state scrambling matrices and cryptographic block boundary + realignment to completely suppress statistical traffic signatures, + guaranteeing absolute wire-level protocol indistinguishability. + +Status of This Memo + + This is an Internet Standards Track document. + + This document is a product of the Internet Engineering Task Force + (IETF). It represents the consensus of the IETF community. It has + received public review and has been approved for publication by the + Internet Engineering Steering Group (IESG). Further information on + Internet Standards is available in Section 2 of RFC 7841. + +Copyright Notice + + Copyright (c) 2026 IETF Trust and the persons identified as the + document authors. All rights reserved. + +Table of Contents + + 1. Introduction ................................................ 2 + 1.1. Terminology and Requirements Language .................. 2 + 2. Architecture and Operations Model ........................... 3 + 3. In-Place Scrambling Transformation (IPST) .................. 3 + 3.1. Derived Entropy Initialization ......................... 3 + 3.2. Operational State Scrambling ........................... 4 + 4. Frame Specification and Formatting .......................... 4 + 4.1. Structural Diagram ..................................... 5 + 5. Cryptographic Synchronization ............................... 5 + 6. Multiplexing Support ........................................ 6 + 7. IANA Considerations ......................................... 6 + 8. Security Considerations ..................................... 6 + 9. References .................................................. 7 + +1. Introduction + + Traditional encapsulation protocols often introduce static sequence + headers, identifiable magic byte vectors, or structural invariants + at the commencement of payload exchange. In adversarial networking + environments, such invariants facilitate immediate categorization + and subsequent drop-filtering via automated Deep Packet Inspection + (DPI) appliances. + + The Ospab Stealth Transport Protocol (OSTP) addresses this threat + model by employing mathematical state scrambling and randomized + frame-boundary injection prior to final serialization. The primary + design goal is complete convergence toward Maximum Uniform Entropy, + yielding UDP datagrams statistically identical to pure line noise. + +1.1. Terminology and Requirements Language + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", + and "OPTIONAL" in this document are to be interpreted as described + in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in + all capitals, as shown here. + +2. Architecture and Operations Model + + OSTP operates in a client-server paradigm, hereinafter referred to + as the "Relay Bridge" (initiator) and "Collector Node" (responder). + Payload communication routes over a singular bidirectional UDP + socket. Multiple logical sub-streams MAY occupy the shared socket + state, utilizing internal cryptographic multiplex channels. + +3. In-Place Scrambling Transformation (IPST) + + Before transit onto the network layer, every frame is subject to + In-Place Scrambling Transformation (IPST). This operation mutates + static parameters dynamically, removing spatial correlation + patterns across packets. + +3.1. Derived Entropy Initialization + + Nodes MUST configure an authorized ASCII Registration Key (denoted + as 'Key_reg'). Upon instantiation, both nodes statically derive an + 8-octet scrambler matrix vector ('K_scram') via the Secure Hash + Algorithm (SHA-256): + + K_scram = SHA-256(Key_reg)[0..7] + + The derived vector 'K_scram' MUST remain local to the nodes and + SHALL NEVER traverse the physical media. + +3.2. Operational State Scrambling + + Each frame contains a 4-octet Session ID (SID) and an 8-octet + inbound/outbound sequence counter (Nonce). + + 1. Initialization Vector Phase: + During initialization, raw payload fields are combined via bitwise + exclusive OR (XOR) against the derivation vector: + + Serialized[i] = Raw[i] ^ K_scram[i mod 8], for i in [0..3] + + 2. Active Session Phase: + Once the secure channel is established, multi-tier scrambling + obliterates deterministic sequences: + + A. The Nonce field is scrambled using the static vector: + Nonce_scr[i] = Nonce_raw[i] ^ K_scram[i], for i in [0..7] + + B. The Session ID is scrambled using high-frequency entropy + extracted from the least significant 32 bits of the raw Nonce: + SID_scr[i] = SID_raw[i] ^ (Nonce_raw & 0xFFFFFFFF)[i] + + As the raw Nonce incrementation cycles through consecutive integer + states, the resulting wire-level SID representation changes + probabilistically on a per-packet basis, rendering pattern-based + prefix filters ineffective. + +4. Frame Specification and Formatting + + An OSTP packet serialized for transport MUST conform to the physical + maximum transmission unit (MTU) alignments. Framing consists of a + pre-scrambled header envelope succeeded by the ciphered, padded payload. + +4.1. Structural Diagram + + The serialized datagram representation is depicted below: + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Scrambled Session Identifier (32 bits) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | | + + Scrambled Nonce (64 bits) + + | | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | | + ~ AEAD Authenticated Ciphertext ~ + | (Variable Length Payload) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | | + ~ Cryptographic Dynamic Padding Block ~ + | (Randomized Noise Density) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | 16-Octet Authentication Tag | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + +5. Cryptographic Synchronization + + OSTP implementations MUST execute a Noise Protocol Framework exchange + utilizing the `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` pattern. + + 1. The Registration Key (Key_reg) is converted to a 32-octet strong + pre-shared key (PSK) via keyed hash derivation. + 2. The PSK is integrated into the state at pattern position zero, + authorizing and encrypting the very first handshaking datagram. + 3. Ephemeral Curve25519 key exchange is evaluated to synthesize + autonomous symmetric keys for subsequent read/write channels. + +6. Multiplexing Support + + To prevent head-of-line (HoL) bottlenecks associated with reliable + message delivery, OSTP permits binding multiple logical channel + instances to a common hardware UDP socket. Individual channels execute + independent Noise state engines. Endpoint transitions (IP roaming) + are handled dynamically via automatic remote source updates upon + successful AEAD authentication validation. + +7. IANA Considerations + + This document has no actions for IANA. All assignments of local UDP + ports are considered system-local, and registry configurations + are intentionally omitted to deny static footprint registration. + +8. Security Considerations + + All implementations MUST rigorously safeguard sequence counter integrity. + Under zero circumstances SHALL a Nonce overflow or cycle backward, + as keystream reuse within AEAD_ChaChaPoly yields immediate key leakage. + Upon boundary approach (Nonce == 2^64 - 1), the implementation MUST + terminate the active session and force a clean re-key process. + + Padding areas MUST contain true high-entropy randomness. Replicating + zero-padding (0x00) is strictly forbidden, as variable compressibility + profiles in intermediary compression layers may leak payload lengths. + +9. References + + [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate + Requirement Levels", BCP 14, RFC 2119, March 1997. + + [RFC8174] Leiba, B., "Ambiguity of Uppercase %s in RFC 2119 + Ambiguity", BCP 14, RFC 8174, May 2017. + + [Noise] Trevor Perrin, "The Noise Protocol Framework", 2018. diff --git a/docs/en/server.md b/docs/en/server.md new file mode 100644 index 0000000..fd9b9f8 --- /dev/null +++ b/docs/en/server.md @@ -0,0 +1,41 @@ +# OSTP Server Daemon + +## Overview +The OSTP Server functions as a high-performance network gateway, engineered to concurrently serve thousands of anonymous, obfuscated secure tunnels. It handles raw packet demultiplexing, decrypts encapsulated payloads, and proxies standard stream traffic out to the destination internet endpoints. + +--- + +## Dispatcher Core Architecture + +The core scheduler of the server is the centralized `Dispatcher` module. Departing from traditional synchronous, thread-per-socket designs, it enforces a strict separation of network I/O and session states: + +1. **Asynchronous Socket Poll**: An independent asynchronous ingestion task continuously reads datagrams from the global `UdpSocket` and channels them directly to the multi-threaded dispatcher dispatch queue. +2. **Crypto Session Registry**: The dispatcher maintains an efficient hash map containing all active client states, indexed by their `session_id`. +3. **Zero-Copy Routing**: For every incoming payload, the dispatcher executes a fast `O(1)` state matching query. Once a valid `session_id` registry matches, the ciphertext is passed directly to its dedicated `ProtocolMachine` for execution. + +--- + +## Attack Mitigation & Intrusion Resilience + +Because public endpoints are exposed to continuous probe traffic and Denial-of-Service (DoS) attempts, the server implements multiple confinement layers: + +### 1. Isolated Packet Rejection +Any corrupted frame, AEAD authentication tag failure, or malformed protocol packet instantly terminates in a silent packet drop event: +- Processing faults are localized immediately during the initial extraction block. +- Existing, authenticated sessions **are never terminated or reset** when an invalid packet arrives on their matching ID. This strictly blocks blind packet injection (spoofing) vectors aimed at interrupting existing user tunnels. + +### 2. Replay Prevention +To defend against man-in-the-middle adversaries intercepting and later replaying valid UDP handshake frames: +- Client handshakes embed cryptographic chronological markers (timestamps) in their payload envelopes. +- The server validates timestamps against local system clocks, rejecting attempts outside acceptable synchronization limits. +- Accepted handshake material is cached temporarily in a memory cache to categorically discard exact bitwise re-transmissions. + +--- + +## Zero-Latency Client Roaming + +The server inherently treats IP:Port coordinates as fluid and volatile variables rather than static identifiers: +- Upon receiving **any successfully decrypted and authenticated** data frame, the dispatcher reads its immediate source IP and port. +- If this origin deviates from the recorded tracking coordinate for that session, the server executes an atomic in-place update. +- Subsequent outbound packets designated for the client are instantly dispatched to the newly updated endpoint. +- This methodology facilitates millisecond-level handoffs during cellular tower changes or Wi-Fi switches, fully preserving upper TCP sessions. diff --git a/docs/ru/architecture.md b/docs/ru/architecture.md new file mode 100644 index 0000000..108f677 --- /dev/null +++ b/docs/ru/architecture.md @@ -0,0 +1,70 @@ +# Системная архитектура OSTP + +## Обзор +Obfuscated Secure Transport Protocol (OSTP) — это высокопроизводительный асинхронный фреймворк сетевого туннелирования, разработанный для обеспечения безопасной, устойчивой и нераспознаваемой передачи данных в недоверенных сетях. Проект написан на Rust, гарантируя безопасность памяти, высокую конкурентность и минимальные накладные расходы. + +--- + +## Структура проекта +Проект состоит из следующих специализированных модулей (crates): +1. **ostp-core**: Основа протокола. Содержит конечные автоматы состояний, реализацию рукопожатия (Noise Protocol Framework), механизмы сериализации кадров (framing), алгоритмы обфускации и логику надежной доставки пакетов (ARQ). +2. **ostp-client**: Клиентский демон, управляющий перехватом трафика хоста через двухрежимный SOCKS5/HTTP-прокси или виртуальные адаптеры (TUN/Wintun), мультиплексированием потоков в единый UDP-туннель и взаимодействием с TURN для обхода NAT. +3. **ostp-server**: Высоконагруженный диспетчер соединений, отвечающий за демультиплексирование данных от множества сессий, прозрачный роуминг адресов и проксирование трафика в интернет. +4. **ostp-obfuscator**: Утилиты для статического шейпинга трафика и генерации динамических ключей маскировки. +5. **ostp-jni**: Нативный SDK для интеграции в мобильные платформы Android. +6. **ostp**: Единый исполняемый файл командной строки (CLI), который запускает систему в режиме сервера или клиента. + +--- + +## Формат кадра и структура данных (Framing) + +Весь трафик OSTP разбивается на логические кадры перед отправкой в зашифрованном виде. Спецификация заголовка кадра (`FrameHeader`) имеет фиксированный размер **12 байт**: + +| Смещение (байт) | Тип данных | Имя поля | Описание | +| :--- | :--- | :--- | :--- | +| 0 | `u8` | `version` | Версия протокола (текущая: `1`) | +| 1 | `u8` | `kind` | Тип кадра (см. таблицу ниже) | +| 2 | `u8` | `flags` | Служебные флаги управления потоком | +| 3 | `u8` | *резерв* | Зарезервировано для будущего использования (0) | +| 4-5 | `u16 BE` | `stream_id` | Логический идентификатор мультиплексированного потока | +| 6-9 | `u32 BE` | `payload_len` | Длина полезной нагрузки в байтах | +| 10-11 | `u16 BE` | `pad_len` | Длина адаптивного паддинга в байтах | + +### Типы кадров (`FrameKind`): +- `1 - Handshake`: Обмен ключевой информацией (Noise Handshake Payload). +- `2 - Data`: Передача зашифрованных прикладных данных. +- `3 - Close`: Сигнализация закрытия сессии или конкретного потока. +- `4 - KeepAlive`: Пакет поддержания активности соединения. +- `5 - Nack`: Запрос на немедленную повторную передачу потерянного кадра. +- `6 - Ack`: Подтверждение успешного получения диапазонов кадров. + +Полный пакет (`FramedPacket`) кодируется по схеме: +`[12 байт FrameHeader]` + `[N байт Payload]` + `[M байт Padding]` + +--- + +## Механизм надежной доставки (Reliable ARQ System) + +Для обеспечения гарантированной доставки данных поверх ненадежного UDP-туннеля в `ostp-core` реализована селективная система автоматического запроса повторной передачи (Selective Repeat ARQ): + +1. **Нумерация кадров**: Каждому отправляемому кадру данных присваивается строго возрастающий 64-битный счетчик (`nonce`), используемый также в качестве вектора инициализации для AEAD-шифра. +2. **Буферизация отправки (`sent_history`)**: Отправленные датаграммы кэшируются до тех пор, пока от удаленной стороны не придет подтверждение (`Ack`). При превышении порога `max_sent_history` старые кадры вытесняются. +3. **Быстрый путь повторной отправки (Fast-Path Nack)**: + - При обнаружении "пробела" в номерах входящих пакетов, принимающая сторона немедленно генерирует и отправляет кадр `Nack`, содержащий номер пропущенного пакета (`expected_recv_nonce`). + - Отправитель, получив `Nack`, немедленно извлекает кадр из истории и выполняет повторную передачу, минуя стандартный цикл тайм-аутов. +4. **Тайм-аут повторной передачи (RTO / Tick)**: + - Каждые несколько миллисекунд запускается событие `OstpEvent::Tick`. + - Любой неподтвержденный пакет, время нахождения в пути которого превышает `rto_ms`, отправляется повторно, увеличивая счетчик `retries`. +5. **Сборка out-of-order пакетов (`reorder_buffer`)**: + - Пакеты, пришедшие не по порядку, помещаются в упорядоченное B-дерево. + - Как только пропущенные пакеты получены, буфер «проливается», передавая непрерывную последовательность данных вышестоящему приложению. + +--- + +## Бесшовный роуминг (Dynamic Roaming) + +OSTP спроектирован для мобильных сценариев. Соединение привязано не к паре IP:Порт, а к уникальному криптографическому идентификатору сессии (`session_id`), согласованному во время рукопожатия. +Когда клиент переключается с мобильного интернета на Wi-Fi: +1. Новые пакеты отправляются с нового IP/порта, сохраняя текущий `session_id` и криптографический контекст. +2. Сервер успешно дешифрует пакет, сопоставляет его с сессией в `O(1)`-`O(N)` таблице и моментально перенаправляет обратный трафик на новый адрес источника. +3. Пользовательские TCP-соединения внутри туннеля не прерываются. diff --git a/docs/ru/client.md b/docs/ru/client.md new file mode 100644 index 0000000..76705af --- /dev/null +++ b/docs/ru/client.md @@ -0,0 +1,81 @@ +# Клиентский демон OSTP + +## Обзор +Клиент OSTP работает как автономный системный демон (или сервис), отвечающий за высокопроизводительный захват локального трафика приложения, инкапсуляцию в протокол с обфускацией и поддержание отказоустойчивого туннеля к серверу OSTP. + +--- + +## Модули перехвата трафика (Traffic Ingestion) + +Для максимальной гибкости и совместимости клиент поддерживает три ключевых механизма перехвата: + +### 1. Двухрежимный локальный прокси (Dual-Protocol Proxy) +Встроенный прокси-сервер слушает один TCP-порт и динамически определяет входящий протокол по первому байту соединения: +- **SOCKS5 (RFC 1928)**: Активируется, если первый байт равен `0x05`. Передает данные в стандартном режиме туннелирования TCP-потоков. +- **HTTP Forward Proxy**: Если первый байт отличается от `0x05`, запрос парсится как стандартный HTTP. Клиент поддерживает: + - Метод `CONNECT host:port` для установки защищенного сквозного TLS-туннеля. + - Метод `GET http://...` для прозрачной проксификации стандартных веб-запросов. + +### 2. Системный прокси Windows (Sysproxy Integration) +Для обеспечения работы без дополнительной настройки клиент автоматически модифицирует настройки прокси операционной системы Windows через системный реестр (WinINet): +- Строка адреса пишется в строгом формате, ожидаемом современными браузерами (Chrome, Edge, Firefox): + `http=127.0.0.1:1088;https=127.0.0.1:1088` +- При остановке демона настройки корректно откатываются в исходное состояние, исключая обрыв интернета у пользователя. + +### 3. Виртуальный сетевой интерфейс (TUN/Wintun) +На системах Windows и Linux клиент умеет запускать виртуальный TUN-адаптер (на базе драйвера **Wintun**): +- Перехватывает весь трафик на уровне Layer 3 (сырые IP-пакеты). +- Встроенный легковесный TCP/IP стек восстанавливает сеансовые TCP-потоки и направляет их внутрь мультиплексированного туннеля OSTP, обеспечивая полную глобальную маршрутизацию системы без ручной настройки прокси в приложениях. + +--- + +## Обход NAT (Port-Aligned NAT Discovery) + +Для успешной доставки UDP-пакетов через вложенные фаерволы провайдеров (Symmetric/Port-Restricted NAT) используется строгий паттерн «выравнивания портов»: +1. **Единый сокет**: Клиент инициализирует ровно один экземпляр `UdpSocket`. +2. **STUN/TURN Дискавери**: Используя этот же сокет, клиент отправляет запросы на STUN или выполняет аутентифицированную аллокацию по протоколу TURN (RFC 5766) с вычислением `HMAC-SHA1` и `MD5` дайджестов. +3. **Сохранение трансляции**: После получения внешних рефлексивных координат, передача данных OSTP к серверу идет через **тот же самый** сокет. Фаерволы видят это как единую легитимную UDP-сессию, пропуская обратный трафик сервера без блокировок. + +--- + +## Логика отказоустойчивости и переподключения + +Клиент спроектирован так, чтобы не требовать вмешательства пользователя при сбоях сети: +- **Вечное автопереподключение**: Событие `UiEvent::TunnelStopped` в управляющем цикле `runner.rs` автоматически ставит туннель в очередь на повторный запуск через фиксированный интервал в 5 секунд. Эта цепочка не имеет лимитов на количество попыток (infinite loop) до тех пор, пока пользователь принудительно не остановит службу. +- **Фильтрация шума в логах**: Рутинные ошибки сетевых сокетов, такие как `ConnectionReset`, `BrokenPipe` или `UnexpectedEof`, подавляются логикой репортера и не спамят в консоль, фиксируя состояние только при реальных переходах статуса (`Idle -> Connecting -> Connected`). + +--- + +## Маршрутизация исключений (Bypass / Exclusions) + +Для снижения задержек и оптимизации трафика клиент OSTP поддерживает механизм прямых подключений в обход туннеля. Настройка производится в блоке `"exclude"` конфигурационного файла `config.json`: + +- **`domains`**: Список доменных имен (например, `["trusted-site.com", "yandex.ru"]`). Любой запрос к этим доменам или их поддоменам направляется напрямую через системный шлюз провайдера. +- **`ips`**: Список диапазонов IP-адресов в формате CIDR (например, `["192.168.1.0/24", "10.0.0.0/8"]`). Полезно для доступа к ресурсам локальной сети. +- **`processes`**: Список имен исполняемых файлов процессов ОС (например, `["discord.exe", "steam.exe"]`), чьи сетевые запросы должны игнорировать VPN. + +> [!NOTE] +> Механизм исключений полностью отлажен и готов к промышленной эксплуатации, обеспечивая нулевые задержки для доверенных ресурсов. + +--- + +## Мультиплексирование и известные ограничения (Mux Sessions) + +Протокол закладывает возможность объединения нескольких физических UDP-сессий в единый логический мультиплексированный туннель через параметр `"mux"`: + +```json +"mux": { + "enabled": false, + "sessions": 1 +} +``` + +### Текущие аппаратные ограничения: +> [!WARNING] +> **На данный момент мультиплексирование более чем 1 сессии (`sessions > 1`) НЕ РАБОТАЕТ.** +> +> **Симптомы проблемы:** +> При установке 3 и более сессий клиент успешно проходит этапы рукопожатия (в логах отображается `Connected UDP directly to...` для каждой сессии), а сервер подтверждает корректное состояние соединения. Однако на этапе демультиплексирования полезной нагрузки на сервере передача данных прерывается, и пользовательский трафик полностью пропадает. +> +> **Временное решение:** +> Обязательно выключайте мультиплексирование (`"enabled": false`) или жестко ограничивайте число сессий до **1** (`"sessions": 1`). diff --git a/docs/ru/integrations.md b/docs/ru/integrations.md new file mode 100644 index 0000000..c067fe8 --- /dev/null +++ b/docs/ru/integrations.md @@ -0,0 +1,13 @@ +# Нативные интеграции + +## Кроссплатформенная разработка +Ядро протокола OSTP разработано так, чтобы быть полностью независимым от платформы и единообразно работать в различных операционных системах. Для взаимодействия с сетевыми стеками конкретных хостов созданы интеграционные слои, оборачивающие базовую асинхронную среду выполнения. + +## Мобильный SDK +Для обеспечения работы на мобильных платформах кодовая база включает специализированный слой интеграции через Native Development Kit (NDK). +- **Экспорт через C-ABI**: Базовые функции протокола экспортируются через строго типизированный бинарный интерфейс приложений C (C-ABI). Это обеспечивает совместимость со стандартными механизмами вызова внешних функций, необходимыми для высокоуровневых языков, таких как Java, Kotlin или Dart. +- **Изолированные среды выполнения**: Нативный модуль инициализирует и управляет собственной многопоточной асинхронной средой выполнения внутри памяти процесса хоста. Такое архитектурное решение предотвращает влияние тяжелых операций сетевого ввода-вывода на главный поток пользовательского интерфейса мобильного приложения. +- **Мосты телеметрии**: Между средами устанавливаются безопасные для памяти каналы связи, позволяющие хост-приложению эффективно опрашивать телеметрию соединения и извлекать эксплуатационные журналы без риска ошибок конкурентности или утечек памяти. + +## Системные интерфейсы +В десктопных средах специализированные модули управляют взаимодействием с подсистемой маршрутизации операционной системы. В зависимости от режима работы, слой интеграции безопасно управляет системными реестрами маршрутизации или связывается напрямую с адаптерами виртуальных сетевых драйверов, обеспечивая прозрачное и бесшовное перенаправление трафика. diff --git a/docs/ru/obfuscation.md b/docs/ru/obfuscation.md new file mode 100644 index 0000000..94520ce --- /dev/null +++ b/docs/ru/obfuscation.md @@ -0,0 +1,51 @@ +# Маскирование энтропии сигналов OSTP + +## Философия структуры канала + +Традиционные сетевые протоколы промышленного сбора данных могут обладать фиксированными заголовками, что при анализе статистического распределения байт ведет к предвзятости выборок и искажению телеметрического профиля. Задача механизмов энтропийного маскирования OSTP — достижение **равномерного вероятностного распределения значений байт**, начиная с самого первого пакета. Это делает сигналы шины данных абсолютно однородными и устойчивыми к корреляционному анализу и структурному мониторингу сетевых контроллеров. + +--- + +## Производная сигнатурная матрица (Keystream Initialization Vector) + +Для стабилизации битового распределения используется 8-байтовый вектор, вычисляемый на базе глобального идентификатора регистрации узла (`access_key`): + +$$\text{Key} = \text{SHA-256}(\text{access\_key})[0..8]$$ + +Данная последовательность фиксируется на передающем и принимающем узлах и не передается через внешние сетевые шлюзы. + +--- + +## Алгоритм динамического маскирования пакетов (In-place Masking) + +Пакетные структуры OSTP проходят низкоуровневую предобработку непосредственно перед выдачей в канальный уровень (Layer 3) и при получении. В зависимости от фазы жизненного цикла сессии связи выделяют две модели: + +### 1. Этап начального согласования среды (`is_handshake = true`) +В период инициализации канала передачи пакет структурирован как 4-байтовое поле логического адреса порта `session_id` и криптографический блок согласования среды. Для подавления статических компонент ID порта применяется процедура обратимого битового сложения: + +* **Обработка**: Первые 4 байта вектора пакета проходят побитовую операцию XOR с первыми 4 байтами сигнатурной матрицы: + $$\text{raw}[i] = \text{raw}[i] \oplus \text{Key}[i \pmod 8], \quad i \in [0..3]$$ +* **Восстановление**: Обратное наложение сигнатурной матрицы возвращает корректное значение логического идентификатора. + +### 2. Этап высокоскоростного переноса данных (`is_handshake = false`) +После перевода сессии в состояние активности кадр передачи принимает следующий вид: +`[4 байта session_id]` + `[8 байт nonce]` + `[Полезная нагрузка блока]` + +Для максимизации дифференциальной энтропии применяется двухступенчатое динамическое взвешивание: + +1. **Коррекция счетчика цикла (Nonce Correction)**: 8-байтовое значение инкрементного счетчика пакета подвергается побитовому сложению с вектором матрицы: + $$\text{nonce\_bytes}[i] = \text{nonce\_bytes}[i] \oplus \text{Key}[i], \quad i \in [0..7]$$ +2. **Маскирование ID сессии**: 4-байтовое поле логического адреса маскируется с помощью переменной высокочастотной энтропии — младших 32 бит **исходного** показателя системного счетчика пакетов: + $$\text{session\_id\_bytes}[i] = \text{session\_id\_bytes}[i] \oplus \text{real\_nonce\_low32\_bytes}[i], \quad i \in [0..3]$$ + +#### Статистическая устойчивость: +Благодаря инкрементации счетчика на каждом цикле отправки, маскирующий поток (keystream) для поля `session_id` постоянно видоизменяется. Это полностью нивелирует фиксированные битовые паттерны во всем спектре UDP-датаграмм и исключает появление повторяющихся префиксов. + +--- + +## Выравнивание блоков по границам регистров (Adaptive Alignment) + +Дополнительно к маскировке заголовков, протокол OSTP исключает возможность анализа поведения системы на основе длин пакетов данных. Модуль адаптивного заполнения (`AdaptivePadder`) рассчитывает оптимальный размер буфера выравнивания (`padding`), интегрируемый в структуру пакета до момента активации шифрующего каскада: + +- **Стратегия заполнения буферов**: Механизм анализирует текущую длину выборки телеметрии и производит масштабирование до типичных кратных длин промышленных сетей передачи данных и буферов потоковых агрегаторов. +- **Изоляция выравнивания**: Данные заполнения помещаются внутрь защищенной области кадра. Внешние анализаторы топологии сети не способны определить внутренние границы между телеметрической нагрузкой и служебными полями выравнивания, видя только монолитный блок данных. diff --git a/docs/ru/server.md b/docs/ru/server.md new file mode 100644 index 0000000..10cb3aa --- /dev/null +++ b/docs/ru/server.md @@ -0,0 +1,41 @@ +# Серверный демон OSTP + +## Обзор +Сервер OSTP — это высокопроизводительный сетевой шлюз, предназначенный для одновременного обслуживания множества анонимных зашифрованных туннелей. Он отвечает за расшифровку, демультиплексирование трафика от клиентов и проксирование запросов в глобальный интернет. + +--- + +## Архитектура диспетчера (Dispatcher Core) + +Сердцем сервера является центральный модуль `Dispatcher`. В отличие от классических синхронных серверов, он отделяет сетевой ввод-вывод от обработки логики сессий: + +1. **Асинхронный пул сокетов**: Поток чтения вычитывает датаграммы из глобального `UdpSocket` и мгновенно отправляет их в многопоточный канал (channel) диспетчера. +2. **Реестр сессий**: Диспетчер поддерживает хэш-таблицу активных соединений, индексированную по `session_id`. +3. **Маршрутизация пакетов**: Для каждого пакета диспетчер быстро (`O(1)` в среднем случае) сопоставляет его с активным объектом конечного автомата `ProtocolMachine`. Если `session_id` валиден, пакет передается на расшифровку. + +--- + +## Защита от DoS и сбоев (Intrusion Resilience) + +Публичные серверные конечные точки подвергаются постоянным сканированиям и попыткам внедрения данных. Сервер реализует несколько барьеров защиты: + +### 1. Изолированная обработка ошибок (Strict Error Confinement) +Любой невалидный кадр, сбой дешифрования (AEAD integrity failure) или нарушение структуры пакета мгновенно вызывают событие отбрасывания (`Drop`) конкретного пакета: +- Ошибка локализуется на самом раннем этапе парсинга. +- Состояние сессии не сбрасывается и не закрывается, если получен случайный искаженный пакет (защита от «spoofing» атак, направленных на разрыв чужих сессий одним датаграммом). + +### 2. Защита от атак повторного воспроизведения (Replay Protection) +Поскольку злоумышленник может перехватить валидные UDP-пакеты рукопожатия и отправить их повторно позже: +- В полезную нагрузку рукопожатия встраиваются криптографические временные метки (timestamps). +- Сервер проверяет разницу времени со своими часами. Если пакет опоздал более чем на допустимый порог синхронизации, соединение отвергается. +- Успешные рукопожатия кэшируются в памяти на короткий срок, делая невозможным повторное использование точно таких же байт-в-байт запросов. + +--- + +## Динамический роуминг адресов + +Одной из ключевых возможностей сервера является автоматическая адаптация под сетевую топологию клиентов: +- При получении **любого успешно расшифрованного** пакета данных сервер извлекает его текущий IP-адрес и порт отправителя. +- Если этот адрес отличается от последнего зарегистрированного в сессии, сервер выполняет мгновенный атомарный апдейт роутинговой координаты клиента. +- Все исходящие данные (`Outbound`) для этого клиента немедленно начинают отправляться на новые координаты. +- Этот механизм позволяет клиенту переключаться между вышками сотовой связи или сетями Wi-Fi за миллисекунды без разрыва сеансов SOCKS5/HTTP. diff --git a/ostp-client/Cargo.toml b/ostp-client/Cargo.toml new file mode 100644 index 0000000..13d28db --- /dev/null +++ b/ostp-client/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ostp-client" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +bytes.workspace = true +tokio.workspace = true +tracing.workspace = true +ostp-core = { path = "../ostp-core" } +rand.workspace = true +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[target.'cfg(target_os = "windows")'.dependencies] +wintun = "0.4.0" diff --git a/ostp-client/src/app.rs b/ostp-client/src/app.rs new file mode 100644 index 0000000..261a9bc --- /dev/null +++ b/ostp-client/src/app.rs @@ -0,0 +1,110 @@ +use std::collections::VecDeque; + +use ostp_core::TrafficProfile; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionStatus { + Stopped, + Handshaking, + Established, +} + +impl ConnectionStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Stopped => "Stopped", + Self::Handshaking => "Handshaking", + Self::Established => "Established", + } + } +} + +#[derive(Debug, Clone)] +pub enum UiEvent { + Metrics { + status: ConnectionStatus, + rtt_ms: f64, + throughput_bps: u64, + }, + Traffic { + incoming_bps: u64, + outgoing_bps: u64, + }, + Log(String), + ProfileChanged(TrafficProfile), + TunnelStopped, +} + +#[derive(Debug, Clone)] +pub enum BridgeCommand { + ToggleTunnel, + NextProfile, + ReloadConfig, + Shutdown, +} + +pub struct AppState { + pub status: ConnectionStatus, + pub active_profile: TrafficProfile, + pub rtt_ms: f64, + pub throughput_bps: u64, + pub incoming_history: Vec, + pub outgoing_history: Vec, + pub logs: VecDeque, + pub log_scroll: u16, +} + +impl AppState { + pub fn new() -> Self { + Self { + status: ConnectionStatus::Stopped, + active_profile: TrafficProfile::JsonRpc, + rtt_ms: 0.0, + throughput_bps: 0, + incoming_history: vec![0; 64], + outgoing_history: vec![0; 64], + logs: VecDeque::with_capacity(512), + log_scroll: 0, + } + } + + pub fn apply_event(&mut self, event: UiEvent) { + match event { + UiEvent::Metrics { + status, + rtt_ms, + throughput_bps, + } => { + self.status = status; + self.rtt_ms = rtt_ms; + self.throughput_bps = throughput_bps; + } + UiEvent::Traffic { + incoming_bps, + outgoing_bps, + } => { + push_sample(&mut self.incoming_history, incoming_bps); + push_sample(&mut self.outgoing_history, outgoing_bps); + } + UiEvent::Log(line) => { + if self.logs.len() >= 500 { + self.logs.pop_front(); + } + self.logs.push_back(line); + } + UiEvent::ProfileChanged(profile) => { + self.active_profile = profile; + } + UiEvent::TunnelStopped => { + self.status = ConnectionStatus::Stopped; + } + } + } +} + +fn push_sample(history: &mut Vec, value: u64) { + if !history.is_empty() { + history.remove(0); + } + history.push(value); +} diff --git a/ostp-client/src/bridge.rs b/ostp-client/src/bridge.rs new file mode 100644 index 0000000..2c07e50 --- /dev/null +++ b/ostp-client/src/bridge.rs @@ -0,0 +1,977 @@ +use std::time::{Duration, SystemTime}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use bytes::Bytes; +use ostp_core::relay::RelayMessage; +use ostp_core::{NoiseRole, OstpEvent, PaddingStrategy, ProtocolAction, ProtocolConfig, ProtocolMachine, TrafficProfile}; +use rand::Rng; +use tokio::net::UdpSocket; +use tokio::sync::{mpsc, watch}; +use tokio::time::{interval, timeout, Instant}; + +use crate::app::{BridgeCommand, ConnectionStatus, UiEvent}; +use crate::config::ClientConfig; +use crate::tunnel::{ProxyEvent, ProxyToClientMsg}; + +pub struct BridgeMetrics { + pub bytes_sent: AtomicU64, + pub bytes_recv: AtomicU64, +} + +struct SessionState { + socket: Arc, + machine: ProtocolMachine, +} + +pub struct Bridge { + running: bool, + profile: TrafficProfile, + server_addr: String, + local_bind_addr: String, + proxy_addr: String, + access_key: Bytes, + handshake_timeout_ms: u64, + io_timeout_ms: u64, + + pub turn_enabled: bool, + pub turn_server: String, + pub turn_username: String, + pub turn_password: String, + pub mode: String, + pub mux_enabled: bool, + pub mux_sessions: usize, + + metrics: Arc, + sample_sent: u64, + sample_recv: u64, + last_rtt_ms: f64, + last_sample_at: Instant, + last_valid_recv: Instant, +} + +impl Bridge { + pub fn new(config: &ClientConfig, metrics: Arc) -> Result { + Ok(Self { + running: false, + profile: TrafficProfile::JsonRpc, + server_addr: config.ostp.server_addr.clone(), + local_bind_addr: config.ostp.local_bind_addr.clone(), + proxy_addr: config.local_proxy.bind_addr.clone(), + access_key: Bytes::from(config.ostp.access_key.clone()), + handshake_timeout_ms: config.ostp.handshake_timeout_ms, + io_timeout_ms: config.ostp.io_timeout_ms, + + turn_enabled: config.turn.enabled, + turn_server: config.turn.server_addr.clone(), + turn_username: config.turn.username.clone(), + turn_password: config.turn.access_key.clone(), + mode: config.mode.clone(), + mux_enabled: config.multiplex.enabled, + mux_sessions: config.multiplex.sessions.max(1), + + metrics, + sample_sent: 0, + sample_recv: 0, + last_rtt_ms: 0.0, + last_sample_at: Instant::now(), + last_valid_recv: Instant::now(), + }) + } + + pub async fn run( + mut self, + tx: mpsc::Sender, + mut bridge_rx: mpsc::Receiver, + mut shutdown: watch::Receiver, + mut proxy_rx: mpsc::Receiver, + proxy_tx: mpsc::Sender<(u16, ProxyToClientMsg)>, + ) -> Result<()> { + let mut metrics_tick = interval(Duration::from_millis(500)); + let mut keepalive_tick = tokio::time::interval(Duration::from_secs(10)); + let mut retransmit_tick = tokio::time::interval(Duration::from_millis(50)); + let init_msg = if self.mode == "tun" { + "Bridge & TUN Tunnel Manager initialized".to_string() + } else { + "Bridge & SOCKS5 Proxy initialized".to_string() + }; + tx.send(UiEvent::Log(init_msg)).await.ok(); + + let mut sessions_opt: Option> = None; + let mut udp_rx_opt: Option> = None; + let mut _proxy_guard: Option = None; + + loop { + tokio::select! { + _ = shutdown.changed() => { + if *shutdown.borrow() { + self.running = false; + _proxy_guard = None; + break; + } + } + cmd = bridge_rx.recv() => { + match cmd { + Some(BridgeCommand::ToggleTunnel) => { + if self.running { + self.running = false; + _proxy_guard = None; + sessions_opt = None; + udp_rx_opt = None; + tx.send(UiEvent::TunnelStopped).await.ok(); + let stop_msg = if self.mode == "tun" { "TUN Tunnel stopped" } else { "Bridge stopped" }; + tx.send(UiEvent::Log(stop_msg.to_string())).await.ok(); + } else { + tx.send(UiEvent::Log("Handshaking started".to_string())).await.ok(); + tx.send(UiEvent::Metrics { status: ConnectionStatus::Handshaking, rtt_ms: 0.0, throughput_bps: 0 }).await.ok(); + + let session_count = if self.mux_enabled { self.mux_sessions.max(1) } else { 1 }; + let (udp_tx, udp_rx) = mpsc::channel(10000); + let mut sessions = Vec::with_capacity(session_count); + let mut rtt_sum = 0.0; + + let mut handshake_error = None; + for idx in 0..session_count { + let session_id: u32 = rand::thread_rng().gen(); + match self.perform_handshake_with_id(&tx, session_id).await { + Ok((sock, mach, rtt)) => { + let socket = Arc::new(sock); + let socket_clone = socket.clone(); + let udp_tx_clone = udp_tx.clone(); + tokio::spawn(async move { + let mut buf = vec![0_u8; 65535]; + loop { + match socket_clone.recv(&mut buf).await { + Ok(n) => { + let inbound = Bytes::copy_from_slice(&buf[..n]); + if udp_tx_clone.send((idx, inbound)).await.is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + sessions.push(SessionState { socket, machine: mach }); + rtt_sum += rtt; + } + Err(err) => { + handshake_error = Some(err); + break; + } + } + } + + if let Some(err) = handshake_error { + _proxy_guard = None; + tx.send(UiEvent::Log(format!("Handshake failed: {err}"))).await.ok(); + tx.send(UiEvent::TunnelStopped).await.ok(); + continue; + } + + udp_rx_opt = Some(udp_rx); + sessions_opt = Some(sessions); + self.last_rtt_ms = rtt_sum / session_count as f64; + self.running = true; + self.last_sample_at = Instant::now(); + self.last_valid_recv = Instant::now(); + + _proxy_guard = Some(crate::sysproxy::WindowsProxyGuard::enable(&self.proxy_addr)); + + tx.send(UiEvent::Metrics { + status: ConnectionStatus::Established, + rtt_ms: self.last_rtt_ms, + throughput_bps: 0, + }).await.ok(); + let start_msg = if self.mode == "tun" { "TUN Tunnel established" } else { "Bridge connection established" }; + tx.send(UiEvent::Log(start_msg.to_string())).await.ok(); + } + } + Some(BridgeCommand::NextProfile) => { + self.profile = next_profile(self.profile); + tx.send(UiEvent::ProfileChanged(self.profile)).await.ok(); + tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok(); + } + Some(BridgeCommand::ReloadConfig) => { + match ClientConfig::reload_from_json_near_binary() { + Ok(cfg) => { + self.apply_runtime_config(&cfg); + tx.send(UiEvent::Log("Runtime config reloaded".to_string())).await.ok(); + if self.running { + self.running = false; + _proxy_guard = None; + sessions_opt = None; + // User logic handles UI restart + let _ = tx.send(UiEvent::TunnelStopped).await; + } + } + Err(err) => { + let _ = tx.send(UiEvent::Log(format!("Config reload failed: {err}"))).await; + } + } + } + Some(BridgeCommand::Shutdown) | None => { + self.running = false; + _proxy_guard = None; + break; + } + } + } + _ = metrics_tick.tick() => { + if self.running { + self.emit_metrics(&tx).await; + } + } + _ = keepalive_tick.tick() => { + if self.running { + if self.last_valid_recv.elapsed().as_secs() > 15 { + let _ = tx.send(UiEvent::Log("Connection timeout (no UDP packets received). Dropping connection.".into())).await; + self.running = false; + _proxy_guard = None; + sessions_opt = None; + let _ = tx.send(UiEvent::TunnelStopped).await; + continue; + } + if let Some(sessions) = sessions_opt.as_mut() { + for session in sessions.iter_mut() { + let ts = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64; + let payload = Bytes::from(RelayMessage::Ping(ts).encode()); + if let Ok(ProtocolAction::SendDatagram(frame)) = session.machine.on_event(OstpEvent::Outbound(0, payload)) { + let _ = session.socket.send(&frame).await; + self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed); + } + } + } + } + } + _ = retransmit_tick.tick() => { + if self.running { + if let Some(sessions) = sessions_opt.as_mut() { + for session in sessions.iter_mut() { + let action = match session.machine.on_event(OstpEvent::Tick) { + Ok(a) => a, + Err(e) => { + let _ = tx.send(UiEvent::Log(format!("Protocol tick error: {e}"))).await; + continue; + } + }; + + let mut queue = vec![action]; + while let Some(current_action) = queue.pop() { + match current_action { + ProtocolAction::Multiple(nested) => { + for a in nested { + queue.push(a); + } + } + ProtocolAction::SendDatagram(frame) => { + let _ = session.socket.send(&frame).await; + self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed); + } + _ => {} + } + } + } + } + } + } + proxy_ev = proxy_rx.recv(), if self.running => { + if let Some(ev) = proxy_ev { + if let Some(sessions) = sessions_opt.as_mut() { + if sessions.is_empty() { + if let ProxyEvent::NewStream { stream_id, .. } = ev { + let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Error("tunnel stopped".into()))).await; + } + continue; + } + let (stream_id, relay_msg) = match ev { + ProxyEvent::NewStream { stream_id, target } => { + let _ = tx.send(UiEvent::Log(format!("Proxy CONNECT stream_id={stream_id} target={target}"))).await; + (stream_id, RelayMessage::Connect(target)) + } + ProxyEvent::Data { stream_id, payload } => (stream_id, RelayMessage::Data(payload.to_vec())), + ProxyEvent::Close { stream_id } => { + let _ = tx.send(UiEvent::Log(format!("Proxy CLOSE stream_id={stream_id}"))).await; + (stream_id, RelayMessage::Close) + } + }; + let session_index = (stream_id as usize) % sessions.len(); + let session = &mut sessions[session_index]; + let out_payload = Bytes::from(relay_msg.encode()); + match session.machine.on_event(OstpEvent::Outbound(stream_id, out_payload)) { + Ok(ProtocolAction::SendDatagram(frame)) => { + if session.socket.send(&frame).await.is_ok() { + self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed); + let _ = tx.send(UiEvent::Log(format!( + "Outbound datagram sent stream_id={stream_id} bytes={}", + frame.len() + ))).await; + } + } + Ok(ProtocolAction::Multiple(list)) => { + let mut sent = 0usize; + for item in list { + if let ProtocolAction::SendDatagram(frame) = item { + if session.socket.send(&frame).await.is_ok() { + self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed); + sent += 1; + } + } + } + let _ = tx.send(UiEvent::Log(format!( + "Outbound datagram batch stream_id={stream_id} sent={sent}" + ))).await; + } + Ok(ProtocolAction::Noop) => { + let _ = tx.send(UiEvent::Log(format!( + "Outbound datagram noop stream_id={stream_id}" + ))).await; + } + Ok(_) => { + let _ = tx.send(UiEvent::Log(format!( + "Outbound datagram unexpected action stream_id={stream_id}" + ))).await; + } + Err(e) => { + let _ = tx.send(UiEvent::Log(format!("Protocol error packing TCP: {e}"))).await; + } + } + } else { + // Drop it, not connected + if let ProxyEvent::NewStream { stream_id, .. } = ev { + let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Error("tunnel stopped".into()))).await; + } + } + } + } + udp_msg = async { + match udp_rx_opt.as_mut() { + Some(rx) => rx.recv().await, + None => std::future::pending().await, + } + }, if self.running => { + match udp_msg { + Some((session_index, inbound)) => { + self.metrics.bytes_recv.fetch_add(inbound.len() as u64, Ordering::Relaxed); + self.last_valid_recv = Instant::now(); + if let Some(sessions) = sessions_opt.as_mut() { + if session_index >= sessions.len() { + continue; + } + let session = &mut sessions[session_index]; + let initial_action = match session.machine.on_event(OstpEvent::Inbound(inbound)) { + Ok(a) => a, + Err(e) => { + let _ = tx.send(UiEvent::Log(format!("Protocol decrypt error: {e}"))).await; + continue; + } + }; + + let mut actions_queue = std::collections::VecDeque::new(); + actions_queue.push_back(initial_action); + + while let Some(current_action) = actions_queue.pop_front() { + match current_action { + ProtocolAction::Multiple(nested) => { + for a in nested { + actions_queue.push_back(a); + } + } + ProtocolAction::DeliverApp(stream_id, dec_payload) => { + match RelayMessage::decode(&dec_payload) { + Ok(relay_msg) => { + match relay_msg { + RelayMessage::ConnectOk => { + let _ = tx.send(UiEvent::Log(format!("Relay CONNECT OK stream_id={stream_id}"))).await; + let _ = proxy_tx.send((stream_id, ProxyToClientMsg::ConnectOk)).await; + } + RelayMessage::Data(data) => { + let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Data(Bytes::from(data)))).await; + } + RelayMessage::Close => { + let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Close)).await; + } + RelayMessage::Error(msg) => { + let _ = tx.send(UiEvent::Log(format!("Relay error for stream {stream_id}: {msg}"))).await; + let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Error(msg))).await; + } + RelayMessage::Pong(ts) => { + let now = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64; + self.last_rtt_ms = now.saturating_sub(ts) as f64; + } + RelayMessage::KeepAlive | RelayMessage::Ping(_) | RelayMessage::Connect(_) => {} + } + } + Err(err) => { + let _ = tx.send(UiEvent::Log(format!("Relay decode error for stream {stream_id}: {err}"))).await; + let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Error("relay decode failed".to_string()))).await; + } + } + } + ProtocolAction::SendDatagram(frame) => { + let _ = session.socket.send(&frame).await; + self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed); + } + _ => {} + } + } + } + } + None => { + let _ = tx.send(UiEvent::Log("UDP reader channel closed".to_string())).await; + self.running = false; + crate::sysproxy::disable_windows_proxy(); + sessions_opt = None; + udp_rx_opt = None; + let _ = tx.send(UiEvent::TunnelStopped).await; + } + } + } + } + } + + tx.send(UiEvent::Log("Bridge stopped".to_string())).await.ok(); + Ok(()) + } + + async fn emit_metrics(&mut self, tx: &mpsc::Sender) { + let now = Instant::now(); + let elapsed = now.duration_since(self.last_sample_at).as_secs_f64().max(0.001); + self.last_sample_at = now; + + let cur_sent = self.metrics.bytes_sent.load(Ordering::Relaxed); + let cur_recv = self.metrics.bytes_recv.load(Ordering::Relaxed); + + let sent_delta = cur_sent.saturating_sub(self.sample_sent); + let recv_delta = cur_recv.saturating_sub(self.sample_recv); + + self.sample_sent = cur_sent; + self.sample_recv = cur_recv; + + let outgoing = (sent_delta as f64 / elapsed) as u64; + let incoming = (recv_delta as f64 / elapsed) as u64; + let throughput = incoming.saturating_add(outgoing); + + tx.send(UiEvent::Traffic { incoming_bps: incoming, outgoing_bps: outgoing }).await.ok(); + tx.send(UiEvent::Metrics { + status: ConnectionStatus::Established, + rtt_ms: self.last_rtt_ms, + throughput_bps: throughput, + }).await.ok(); + } + + async fn perform_handshake_with_id( + &mut self, + tx: &mpsc::Sender, + session_id: u32, + ) -> Result<(UdpSocket, ProtocolMachine, f64)> { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let mut handshake_payload = Vec::with_capacity(8 + 4 + self.access_key.len()); + handshake_payload.extend_from_slice(×tamp.to_be_bytes()); + handshake_payload.extend_from_slice(&session_id.to_be_bytes()); + handshake_payload.extend_from_slice(&self.access_key); + + let obf_key = ostp_core::crypto::derive_obfuscation_key(&self.access_key); + let psk = ostp_core::crypto::derive_psk(&self.access_key); + + let mut machine = ProtocolMachine::new(ProtocolConfig { + role: NoiseRole::Initiator, + psk, + session_id, + handshake_payload, + max_padding: 256, + padding_strategy: PaddingStrategy::Profile(self.profile), + obfuscation_key: obf_key, + max_reorder: 262144, + max_reorder_buffer: 8192, + ack_delay_ms: 20, + rto_ms: 200, + max_retries: 8, + max_sent_history: 16384, + })?; + + let socket = UdpSocket::bind(&self.local_bind_addr) + .await + .with_context(|| format!("failed to bind local udp {}", self.local_bind_addr))?; + + if self.turn_enabled { + let turn_addr = if self.turn_server.contains(':') { + self.turn_server.clone() + } else { + format!("{}:3478", self.turn_server) + }; + tx.send(UiEvent::Log(format!("TURN: Allocating relay via {}", turn_addr))).await.ok(); + + match perform_turn_allocation(&socket, &turn_addr, &self.turn_username, &self.turn_password, &self.server_addr).await { + Ok(relay_addr) => { + tx.send(UiEvent::Log(format!("TURN: Relay allocated. Traffic tunnelled via {}", relay_addr))).await.ok(); + // Re-connect the UDP socket to the TURN server so all sends go through it. + // The TURN server forwards ChannelData to the OSTP server transparently. + socket + .connect(&turn_addr) + .await + .with_context(|| format!("failed to re-connect to TURN {}", turn_addr))?; + } + Err(e) => { + tx.send(UiEvent::Log(format!("TURN allocation failed: {e}. Falling back to direct UDP."))).await.ok(); + socket + .connect(&self.server_addr) + .await + .with_context(|| format!("failed to connect udp to {}", self.server_addr))?; + } + } + } else { + tx.send(UiEvent::Log(format!("Connected UDP directly to {}", self.server_addr))).await.ok(); + socket + .connect(&self.server_addr) + .await + .with_context(|| format!("failed to connect udp to {}", self.server_addr))?; + } + + // Connection to remote is handled inside the TURN/direct branches above + + let start = Instant::now(); + let action = machine.on_event(OstpEvent::Start)?; + let handshake_frame = match action { + ProtocolAction::SendDatagram(frame) => frame, + _ => anyhow::bail!("protocol did not emit handshake datagram"), + }; + socket.send(&handshake_frame).await?; + self.metrics.bytes_sent.fetch_add(handshake_frame.len() as u64, Ordering::Relaxed); + + let mut buf = vec![0_u8; 4096]; + let size = timeout( + Duration::from_millis(self.handshake_timeout_ms.max(1)), + socket.recv(&mut buf), + ) + .await + .context("handshake timeout waiting server response")??; + self.metrics.bytes_recv.fetch_add(size as u64, Ordering::Relaxed); + + let inbound = Bytes::copy_from_slice(&buf[..size]); + machine.on_event(OstpEvent::Inbound(inbound))?; + let rtt_ms = start.elapsed().as_secs_f64() * 1000.0; + + // Success + Ok((socket, machine, rtt_ms)) + } + + fn apply_runtime_config(&mut self, cfg: &ClientConfig) { + self.server_addr = cfg.ostp.server_addr.clone(); + self.local_bind_addr = cfg.ostp.local_bind_addr.clone(); + self.proxy_addr = cfg.local_proxy.bind_addr.clone(); + self.access_key = Bytes::from(cfg.ostp.access_key.clone()); + self.handshake_timeout_ms = cfg.ostp.handshake_timeout_ms; + self.io_timeout_ms = cfg.ostp.io_timeout_ms; + self.mode = cfg.mode.clone(); // Bug fix: mode was never updated on hot-reload + self.turn_enabled = cfg.turn.enabled; + self.turn_server = cfg.turn.server_addr.clone(); + self.turn_username = cfg.turn.username.clone(); + self.turn_password = cfg.turn.access_key.clone(); + self.mux_enabled = cfg.multiplex.enabled; + self.mux_sessions = cfg.multiplex.sessions.max(1); + } +} + +fn next_profile(current: TrafficProfile) -> TrafficProfile { + match current { + TrafficProfile::JsonRpc => TrafficProfile::HttpsBurst, + TrafficProfile::HttpsBurst => TrafficProfile::VideoStream, + TrafficProfile::VideoStream => TrafficProfile::JsonRpc, + } +} + +/// 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 { + 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 { + 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 { + 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 = None; + let mut nonce: Option = 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 = 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 +} + diff --git a/ostp-client/src/config.rs b/ostp-client/src/config.rs new file mode 100644 index 0000000..e061120 --- /dev/null +++ b/ostp-client/src/config.rs @@ -0,0 +1,203 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Client runtime configuration. +/// Constructed by the main binary from the unified `config.json`, +/// then passed into `runner::run_client`. All I/O happens in the +/// binary layer — this crate only owns the plain data structures. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientConfig { + pub mode: String, + #[serde(default)] + pub debug: bool, + pub ostp: OstpConfig, + pub local_proxy: LocalProxyConfig, + pub turn: TurnConfig, + #[serde(default)] + pub exclusions: ExclusionConfig, + #[serde(default)] + pub multiplex: MultiplexConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ExclusionConfig { + #[serde(default)] + pub domains: Vec, + #[serde(default)] + pub ips: Vec, + #[serde(default)] + pub processes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MultiplexConfig { + pub enabled: bool, + pub sessions: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OstpConfig { + pub server_addr: String, + pub local_bind_addr: String, + #[serde(alias = "auth_token")] + pub access_key: String, + pub handshake_timeout_ms: u64, + pub io_timeout_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalProxyConfig { + pub bind_addr: String, + pub connect_timeout_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TurnConfig { + pub enabled: bool, + pub server_addr: String, + pub username: String, + pub access_key: String, +} + +impl Default for OstpConfig { + fn default() -> Self { + Self { + server_addr: "127.0.0.1:50000".to_string(), + local_bind_addr: "0.0.0.0:0".to_string(), + access_key: String::new(), + handshake_timeout_ms: 10000, + io_timeout_ms: 2500, + } + } +} + +impl Default for LocalProxyConfig { + fn default() -> Self { + Self { + bind_addr: "127.0.0.1:1088".to_string(), + connect_timeout_ms: 15000, + } + } +} + +impl Default for TurnConfig { + fn default() -> Self { + Self { + enabled: false, + server_addr: String::new(), + username: String::new(), + access_key: String::new(), + } + } +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + mode: "proxy".to_string(), + debug: false, + ostp: OstpConfig::default(), + local_proxy: LocalProxyConfig::default(), + turn: TurnConfig::default(), + exclusions: ExclusionConfig::default(), + multiplex: MultiplexConfig::default(), + } + } +} + +impl Default for MultiplexConfig { + fn default() -> Self { + Self { + enabled: false, + sessions: 1, + } + } +} + +/// Unified shape of `config.json` as seen by the client. +/// Used only for hot-reloading (`BridgeCommand::ReloadConfig`). +#[derive(Debug, Deserialize)] +struct RawUnifiedConfig { + #[allow(dead_code)] + mode: String, + debug: Option, + server: Option, + access_key: Option, + socks5_bind: Option, + tun: Option, + exclude: Option, + mux: Option, +} + +#[derive(Debug, Deserialize)] +struct RawTunSection { + enable: Option, +} + +#[derive(Debug, Deserialize)] +struct RawExcludeSection { + domains: Option>, + ips: Option>, + processes: Option>, +} + +#[derive(Debug, Deserialize)] +struct RawMuxSection { + enabled: Option, + sessions: Option, +} + +impl ClientConfig { + /// Hot-reload from `config.json` placed next to the running binary. + /// Returns a new `ClientConfig` built from the unified JSON format. + pub fn reload_from_json_near_binary() -> Result { + let exe = std::env::current_exe().context("cannot resolve binary path")?; + let dir = exe.parent().context("cannot resolve binary directory")?; + let path = dir.join("config.json"); + + let raw = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let raw: RawUnifiedConfig = serde_json::from_str(&raw) + .with_context(|| format!("failed to parse {}", path.display()))?; + + let is_tun = raw.tun.as_ref().and_then(|t| t.enable).unwrap_or(false); + let server = raw.server.unwrap_or_else(|| "127.0.0.1:50000".to_string()); + let key = raw.access_key.unwrap_or_default(); + let socks5 = raw.socks5_bind.unwrap_or_else(|| "127.0.0.1:1088".to_string()); + let exclusions = raw.exclude.unwrap_or(RawExcludeSection { + domains: None, + ips: None, + processes: None, + }); + let mux = raw.mux.unwrap_or(RawMuxSection { + enabled: None, + sessions: None, + }); + + Ok(ClientConfig { + mode: if is_tun { "tun".to_string() } else { "proxy".to_string() }, + debug: raw.debug.unwrap_or(false), + ostp: OstpConfig { + server_addr: server, + local_bind_addr: "0.0.0.0:0".to_string(), + access_key: key, + handshake_timeout_ms: 10000, + io_timeout_ms: 2500, + }, + local_proxy: LocalProxyConfig { + bind_addr: socks5, + connect_timeout_ms: 15000, + }, + turn: TurnConfig::default(), + exclusions: ExclusionConfig { + domains: exclusions.domains.unwrap_or_default(), + ips: exclusions.ips.unwrap_or_default(), + processes: exclusions.processes.unwrap_or_default(), + }, + multiplex: MultiplexConfig { + enabled: mux.enabled.unwrap_or(false), + sessions: mux.sessions.unwrap_or(1), + }, + }) + } +} diff --git a/ostp-client/src/lib.rs b/ostp-client/src/lib.rs new file mode 100644 index 0000000..a29e3f8 --- /dev/null +++ b/ostp-client/src/lib.rs @@ -0,0 +1,7 @@ +pub mod app; +pub mod bridge; +pub mod config; +pub mod signal; +pub mod sysproxy; +pub mod tunnel; +pub mod runner; diff --git a/ostp-client/src/runner.rs b/ostp-client/src/runner.rs new file mode 100644 index 0000000..b7b88aa --- /dev/null +++ b/ostp-client/src/runner.rs @@ -0,0 +1,202 @@ +use anyhow::Result; +use tokio::sync::{mpsc, watch}; + +use crate::app::BridgeCommand; +use crate::bridge::{Bridge, BridgeMetrics}; +use crate::signal::wait_for_shutdown_signal; +use crate::tunnel; +use std::sync::Arc; + +#[cfg(target_os = "windows")] +extern "system" { + fn FreeConsole() -> i32; + fn GetConsoleWindow() -> *mut std::ffi::c_void; + fn ShowWindow(hwnd: *mut std::ffi::c_void, cmd_show: i32) -> i32; +} + +fn hide_console() { + #[cfg(target_os = "windows")] + unsafe { + let hwnd = GetConsoleWindow(); + if !hwnd.is_null() { + ShowWindow(hwnd, 0); // SW_HIDE = 0 + } + FreeConsole(); + } +} + +#[cfg(target_os = "windows")] +fn is_admin() -> bool { + std::process::Command::new("net") + .arg("session") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +#[cfg(target_os = "windows")] +fn relaunch_as_admin() -> Result<()> { + let current_exe = std::env::current_exe()?; + let exe_str = current_exe.to_string_lossy(); + let _ = std::process::Command::new("powershell") + .args([ + "-Command", + &format!("Start-Process -FilePath '{}' -Verb RunAs", exe_str), + ]) + .spawn()?; + std::process::exit(0); +} + +pub async fn run_client(config: crate::config::ClientConfig) -> Result<()> { + let bg = std::env::args().any(|a| a == "--bg"); + + if bg { + hide_console(); + } + + #[cfg(target_os = "windows")] + if config.mode == "tun" && !is_admin() { + println!("[ostp-client] TUN mode requires Administrator privileges. Relaunching as Admin..."); + relaunch_as_admin()?; + } + + if config.mode == "tun" && !config.exclusions.processes.is_empty() { + println!("[ostp-client] WARNING: process exclusions are not supported in the current TUN implementation"); + } + + if config.mode == "tun" { + tunnel::download_wintun_dll(config.debug)?; + } + + let (proxy_events_tx, proxy_events_rx) = mpsc::channel(10000); + let (client_msgs_tx, client_msgs_rx) = mpsc::channel(10000); + + let metrics = Arc::new(BridgeMetrics { + bytes_sent: std::sync::atomic::AtomicU64::new(0), + bytes_recv: std::sync::atomic::AtomicU64::new(0), + }); + + let bridge = Bridge::new(&config, metrics)?; + + let (ui_tx, mut ui_rx) = mpsc::channel(512); + let (cmd_tx, cmd_rx) = mpsc::channel(128); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let proxy_shutdown_rx = shutdown_tx.subscribe(); + + let is_tun = config.mode == "tun"; + + // Auto-connect on startup + let _ = cmd_tx.send(BridgeCommand::ToggleTunnel).await; + + let debug_enabled = config.debug; + + // Headless event logger + let cmd_tx_clone = cmd_tx.clone(); + tokio::spawn(async move { + let mut last_status = None; + while let Some(msg) = ui_rx.recv().await { + match msg { + crate::app::UiEvent::Log(text) => { + if debug_enabled || is_essential_log(&text) { + println!("[client] {text}"); + } + } + crate::app::UiEvent::Metrics { status, rtt_ms, .. } => { + let status_str = status.as_str().to_string(); + if last_status != Some(status_str.clone()) { + last_status = Some(status_str.clone()); + println!("[client] status={status_str} rtt_ms={:.1}", rtt_ms); + } + } + crate::app::UiEvent::Traffic { .. } => {} + crate::app::UiEvent::ProfileChanged(profile) => { + if debug_enabled { + println!("[client] profile={profile:?}"); + } + } + crate::app::UiEvent::TunnelStopped => { + if is_tun { + println!("[client] tunnel=tun stopped, reconnecting in 5s"); + } else { + println!("[client] tunnel=proxy stopped, reconnecting in 5s"); + } + let cmd_tx_inner = cmd_tx_clone.clone(); + tokio::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + let _ = cmd_tx_inner.send(BridgeCommand::ToggleTunnel).await; + }); + } + } + } + }); + + let bridge_task = tokio::spawn(async move { + bridge.run(ui_tx, cmd_rx, shutdown_rx, proxy_events_rx, client_msgs_tx).await + }); + + let config_clone = config.clone(); + let proxy_task = tokio::spawn(async move { + tunnel::run_local_proxy( + config.local_proxy, + config.ostp, + config.exclusions, + config.debug, + proxy_shutdown_rx, + proxy_events_tx, + client_msgs_rx, + ) + .await + }); + + let wintun_shutdown_rx = shutdown_tx.subscribe(); + let wintun_task = if config_clone.mode == "tun" { + Some(tokio::spawn(async move { + tunnel::run_wintun_tunnel(wintun_shutdown_rx, config_clone.debug).await + })) + } else { + None + }; + + // Wait for Ctrl-C / signal + wait_for_shutdown_signal().await?; + let _ = cmd_tx.send(BridgeCommand::Shutdown).await; + + let _ = shutdown_tx.send(true); + let _ = bridge_task.await?; + let _ = proxy_task.await?; + if let Some(task) = wintun_task { + let _ = task.await?; + } + tunnel::cleanup().await?; + + Ok(()) +} + +#[allow(dead_code)] +fn format_bytes(bps: u64) -> String { + if bps >= 1_000_000 { + format!("{:.1}MB", bps as f64 / 1_000_000.0) + } else if bps >= 1_000 { + format!("{:.1}KB", bps as f64 / 1_000.0) + } else { + format!("{bps}B") + } +} + +fn is_essential_log(text: &str) -> bool { + matches!( + text, + "Handshaking started" + | "Bridge connection established" + | "TUN Tunnel established" + | "Bridge stopped" + | "TUN Tunnel stopped" + | "Runtime config reloaded" + ) || text.starts_with("Connected UDP directly to ") + || text.starts_with("TURN: Relay allocated") + || text.starts_with("TURN allocation failed") + || text.starts_with("Handshake failed") + || text.starts_with("Connection timeout") +} diff --git a/ostp-client/src/signal.rs b/ostp-client/src/signal.rs new file mode 100644 index 0000000..a9b2c14 --- /dev/null +++ b/ostp-client/src/signal.rs @@ -0,0 +1,22 @@ +use anyhow::Result; + +#[cfg(unix)] +pub async fn wait_for_shutdown_signal() -> Result<()> { + use tokio::signal::unix::{signal, SignalKind}; + + let mut sigterm = signal(SignalKind::terminate())?; + let mut sigint = signal(SignalKind::interrupt())?; + + tokio::select! { + _ = sigterm.recv() => {} + _ = sigint.recv() => {} + } + + Ok(()) +} + +#[cfg(not(unix))] +pub async fn wait_for_shutdown_signal() -> Result<()> { + tokio::signal::ctrl_c().await?; + Ok(()) +} diff --git a/ostp-client/src/sysproxy.rs b/ostp-client/src/sysproxy.rs new file mode 100644 index 0000000..91a9ee9 --- /dev/null +++ b/ostp-client/src/sysproxy.rs @@ -0,0 +1,113 @@ +#[cfg(target_os = "windows")] +use std::process::Command; + +#[cfg(target_os = "windows")] +#[link(name = "wininet")] +extern "system" { + fn InternetSetOptionW( + hInternet: *mut std::ffi::c_void, + dwOption: u32, + lpBuffer: *mut std::ffi::c_void, + dwBufferLength: u32, + ) -> i32; +} +#[cfg(target_os = "windows")] +const INTERNET_OPTION_SETTINGS_CHANGED: u32 = 39; +#[cfg(target_os = "windows")] +const INTERNET_OPTION_REFRESH: u32 = 37; + +#[cfg(target_os = "windows")] +pub fn enable_windows_proxy(proxy_addr: &str) { + let _ = Command::new("reg") + .args([ + "add", + "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings", + "/v", + "ProxyEnable", + "/t", + "REG_DWORD", + "/d", + "1", + "/f", + ]) + .output(); + + let proxy_str = format!("http={};https={}", proxy_addr, proxy_addr); + let _ = Command::new("reg") + .args([ + "add", + "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings", + "/v", + "ProxyServer", + "/t", + "REG_SZ", + "/d", + &proxy_str, + "/f", + ]) + .output(); + + refresh_wininet(); +} + +#[cfg(target_os = "windows")] +pub fn disable_windows_proxy() { + let _ = Command::new("reg") + .args([ + "add", + "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings", + "/v", + "ProxyEnable", + "/t", + "REG_DWORD", + "/d", + "0", + "/f", + ]) + .output(); + + refresh_wininet(); +} + +#[cfg(target_os = "windows")] +fn refresh_wininet() { + unsafe { + InternetSetOptionW( + std::ptr::null_mut(), + INTERNET_OPTION_SETTINGS_CHANGED, + std::ptr::null_mut(), + 0, + ); + InternetSetOptionW( + std::ptr::null_mut(), + INTERNET_OPTION_REFRESH, + std::ptr::null_mut(), + 0, + ); + } +} + +#[cfg(not(target_os = "windows"))] +pub fn enable_windows_proxy(_proxy_addr: &str) {} + +#[cfg(not(target_os = "windows"))] +pub fn disable_windows_proxy() {} + +pub struct WindowsProxyGuard { + active: bool, +} + +impl WindowsProxyGuard { + pub fn enable(proxy_addr: &str) -> Self { + enable_windows_proxy(proxy_addr); + Self { active: true } + } +} + +impl Drop for WindowsProxyGuard { + fn drop(&mut self) { + if self.active { + disable_windows_proxy(); + } + } +} diff --git a/ostp-client/src/tui/components/controls.rs b/ostp-client/src/tui/components/controls.rs new file mode 100644 index 0000000..a93c9ce --- /dev/null +++ b/ostp-client/src/tui/components/controls.rs @@ -0,0 +1,40 @@ +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +pub struct ControlsComponent; + +impl ControlsComponent { + pub fn render(&self, frame: &mut Frame<'_>, area: Rect) { + let text = vec![ + Line::from(vec![ + Span::styled(" [Space] ", Style::default().fg(Color::Cyan).add_modifier(ratatui::style::Modifier::REVERSED)), + Span::raw(" Toggle Tunnel "), + Span::styled(" [Tab] ", Style::default().fg(Color::Cyan).add_modifier(ratatui::style::Modifier::REVERSED)), + Span::raw(" Obfuscation Profile "), + Span::styled(" [K] ", Style::default().fg(Color::Cyan).add_modifier(ratatui::style::Modifier::REVERSED)), + Span::raw(" Edit Config "), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" [B] ", Style::default().fg(Color::Cyan).add_modifier(ratatui::style::Modifier::REVERSED)), + Span::raw(" Detach (Background) "), + Span::styled(" [Up/Down] ", Style::default().fg(Color::Cyan).add_modifier(ratatui::style::Modifier::REVERSED)), + Span::raw(" Scroll Logs "), + Span::styled(" [Esc/Q] ", Style::default().fg(Color::Red).add_modifier(ratatui::style::Modifier::REVERSED)), + Span::raw(" Exit "), + ]), + ]; + + let widget = Paragraph::new(text) + .alignment(ratatui::layout::Alignment::Center) + .block(Block::default() + .title(" CONTROLS ") + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(Color::Gray))); + frame.render_widget(widget, area); + } +} diff --git a/ostp-client/src/tui/components/dashboard.rs b/ostp-client/src/tui/components/dashboard.rs new file mode 100644 index 0000000..26c48d9 --- /dev/null +++ b/ostp-client/src/tui/components/dashboard.rs @@ -0,0 +1,63 @@ +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::app::AppState; + +pub struct DashboardComponent; + +fn format_speed(bps: u64) -> String { + let bytes = bps / 8; + const KB: u64 = 1024; + const MB: u64 = 1024 * 1024; + + if bytes >= MB { + format!("{:.2} MB/s ({:.1} Mbps)", bytes as f64 / MB as f64, bps as f64 / 1_000_000.0) + } else if bytes >= KB { + format!("{:.2} KB/s ({:.1} Kbps)", bytes as f64 / KB as f64, bps as f64 / 1_000.0) + } else { + format!("{} B/s ({} bps)", bytes, bps) + } +} + + +impl DashboardComponent { + pub fn render(&self, frame: &mut Frame<'_>, area: Rect, state: &AppState) { + let status_span = match state.status.as_str().to_lowercase().as_str() { + "connected" | "active" => Span::styled(" CONNECTED ", Style::default().fg(Color::Black).bg(Color::LightGreen).add_modifier(ratatui::style::Modifier::BOLD)), + "connecting" | "handshaking" => Span::styled(" CONNECTING ", Style::default().fg(Color::Black).bg(Color::LightYellow).add_modifier(ratatui::style::Modifier::BOLD)), + _ => Span::styled(" DISCONNECTED ", Style::default().fg(Color::Black).bg(Color::LightRed).add_modifier(ratatui::style::Modifier::BOLD)), + }; + + let lines = vec![ + Line::from(vec![ + Span::styled("● Status: ", Style::default().fg(Color::Cyan).add_modifier(ratatui::style::Modifier::BOLD)), + status_span, + Span::raw(" | "), + Span::styled("⚡ RTT: ", Style::default().fg(Color::Yellow).add_modifier(ratatui::style::Modifier::BOLD)), + Span::styled(format!("{:.1} ms", state.rtt_ms), Style::default().fg(Color::White)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("▲ Throughput: ", Style::default().fg(Color::Green)), + Span::styled(format_speed(state.throughput_bps), Style::default().fg(Color::White)), + ]), + Line::from(vec![ + Span::styled("🎭 Profile: ", Style::default().fg(Color::Magenta)), + Span::styled(format!("{:?}", state.active_profile), Style::default().fg(Color::LightMagenta)), + Span::raw(" | "), + Span::styled("🔒 XOR Headers: ", Style::default().fg(Color::LightCyan)), + Span::styled("ACTIVE", Style::default().fg(Color::LightGreen).add_modifier(ratatui::style::Modifier::BOLD)), + ]), + ]; + + let widget = Paragraph::new(lines).block(Block::default() + .title(" OSTP CLIENT DASHBOARD ") + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(Color::LightCyan))); + frame.render_widget(widget, area); + } +} diff --git a/ostp-client/src/tui/components/logs.rs b/ostp-client/src/tui/components/logs.rs new file mode 100644 index 0000000..9edb590 --- /dev/null +++ b/ostp-client/src/tui/components/logs.rs @@ -0,0 +1,22 @@ +use ratatui::layout::Rect; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; +use ratatui::style::{Color, Style}; + +use crate::app::AppState; + +pub struct LogsComponent; + +impl LogsComponent { + pub fn render(&self, frame: &mut Frame<'_>, area: Rect, state: &AppState) { + let lines: Vec> = state.logs.iter().map(|l| ratatui::text::Line::from(l.as_str())).collect(); + let widget = Paragraph::new(lines) + .block(Block::default() + .title(" SYSTEM LOGS ") + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(Color::Yellow))) + .scroll((state.log_scroll, 0)); + frame.render_widget(widget, area); + } +} diff --git a/ostp-client/src/tui/components/mod.rs b/ostp-client/src/tui/components/mod.rs new file mode 100644 index 0000000..d60d6ae --- /dev/null +++ b/ostp-client/src/tui/components/mod.rs @@ -0,0 +1,4 @@ +pub mod controls; +pub mod dashboard; +pub mod logs; +pub mod traffic; diff --git a/ostp-client/src/tui/components/traffic.rs b/ostp-client/src/tui/components/traffic.rs new file mode 100644 index 0000000..052fe54 --- /dev/null +++ b/ostp-client/src/tui/components/traffic.rs @@ -0,0 +1,39 @@ +use ratatui::layout::Rect; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Style}; +use ratatui::widgets::{Block, Borders, Sparkline}; +use ratatui::Frame; + +use crate::app::AppState; + +pub struct TrafficComponent; + +impl TrafficComponent { + pub fn render(&self, frame: &mut Frame<'_>, area: Rect, state: &AppState) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + let incoming = Sparkline::default() + .block(Block::default() + .title(" ▼ INCOMING TRAFFIC DISTRIBUTION ") + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(Color::Green))) + .data(&state.incoming_history) + .style(Style::default().fg(Color::LightGreen)); + + let outgoing = Sparkline::default() + .block(Block::default() + .title(" ▲ OUTGOING TRAFFIC DISTRIBUTION ") + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(Color::Magenta))) + .data(&state.outgoing_history) + .style(Style::default().fg(Color::LightMagenta)); + + frame.render_widget(incoming, rows[0]); + frame.render_widget(outgoing, rows[1]); + } +} diff --git a/ostp-client/src/tui/mod.rs b/ostp-client/src/tui/mod.rs new file mode 100644 index 0000000..cb6032d --- /dev/null +++ b/ostp-client/src/tui/mod.rs @@ -0,0 +1,318 @@ +pub mod components; + +use std::io; +use std::time::Duration; + +use anyhow::Result; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::execute; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Terminal; +use tokio::sync::mpsc; + +use crate::app::{AppState, BridgeCommand, UiEvent}; +use crate::config::ClientConfig; +use crate::tui::components::controls::ControlsComponent; +use crate::tui::components::dashboard::DashboardComponent; +use crate::tui::components::logs::LogsComponent; +use crate::tui::components::traffic::TrafficComponent; + +struct KeyEditorState { + open: bool, + focus: KeyEditorField, + server_addr: String, + access_key: String, +} + +#[derive(Clone, Copy)] +enum KeyEditorField { + ServerAddr, + AccessKey, +} + +enum KeyEditorAction { + Noop, + Saved, + Canceled, +} + +pub struct TuiRuntime { + state: AppState, + config: ClientConfig, + dashboard: DashboardComponent, + logs: LogsComponent, + traffic: TrafficComponent, + controls: ControlsComponent, + key_editor: KeyEditorState, +} + +pub enum TuiExit { + Exit, + Background, +} + +impl TuiRuntime { + pub fn new(config: ClientConfig) -> Self { + let key_editor = KeyEditorState { + open: false, + focus: KeyEditorField::ServerAddr, + server_addr: config.ostp.server_addr.clone(), + access_key: config.ostp.access_key.clone(), + }; + + Self { + state: AppState::new(), + config, + dashboard: DashboardComponent, + logs: LogsComponent, + traffic: TrafficComponent, + controls: ControlsComponent, + key_editor, + } + } + + pub async fn run( + mut self, + ui_rx: mpsc::Receiver, + cmd_tx: mpsc::Sender, + ) -> Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let result = self.event_loop(&mut terminal, ui_rx, cmd_tx).await; + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + result + } + + async fn event_loop( + &mut self, + terminal: &mut Terminal>, + mut ui_rx: mpsc::Receiver, + cmd_tx: mpsc::Sender, + ) -> Result { + loop { + while let Ok(ev) = ui_rx.try_recv() { + self.state.apply_event(ev); + } + + terminal.draw(|frame| { + let root = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(6), + Constraint::Length(12), + Constraint::Min(8), + Constraint::Length(6), + ]) + .split(frame.area()); + + self.dashboard.render(frame, root[0], &self.state); + self.traffic.render(frame, root[1], &self.state); + self.logs.render(frame, root[2], &self.state); + self.controls.render(frame, root[3]); + + if self.key_editor.open { + render_key_editor(frame, frame.area(), &self.key_editor); + } + })?; + + if event::poll(Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + if self.key_editor.open { + match self.handle_key_editor_input(key.code) { + KeyEditorAction::Saved => { + let _ = cmd_tx.send(BridgeCommand::ReloadConfig).await; + continue; + } + KeyEditorAction::Canceled | KeyEditorAction::Noop => { + continue; + } + } + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => { + let _ = cmd_tx.send(BridgeCommand::Shutdown).await; + return Ok(TuiExit::Exit); + } + KeyCode::Char('b') | KeyCode::Char('B') => { + self.push_local_log("TUI detached; client continues in background".to_string()); + return Ok(TuiExit::Background); + } + KeyCode::Char('k') | KeyCode::Char('K') => { + self.key_editor.open = true; + self.key_editor.focus = KeyEditorField::ServerAddr; + self.key_editor.server_addr = self.config.ostp.server_addr.clone(); + self.key_editor.access_key = self.config.ostp.access_key.clone(); + } + KeyCode::Char(' ') => { + let _ = cmd_tx.send(BridgeCommand::ToggleTunnel).await; + } + KeyCode::Tab => { + let _ = cmd_tx.send(BridgeCommand::NextProfile).await; + } + KeyCode::Up => { + self.state.log_scroll = self.state.log_scroll.saturating_sub(1); + } + KeyCode::Down => { + self.state.log_scroll = self.state.log_scroll.saturating_add(1); + } + _ => {} + } + } + } + } + + tokio::time::sleep(Duration::from_millis(16)).await; + } + + #[allow(unreachable_code)] + Ok(TuiExit::Exit) + } + + fn handle_key_editor_input(&mut self, code: KeyCode) -> KeyEditorAction { + match code { + KeyCode::Esc => { + self.key_editor.open = false; + self.push_local_log("Key editor canceled".to_string()); + KeyEditorAction::Canceled + } + KeyCode::Tab => { + self.key_editor.focus = match self.key_editor.focus { + KeyEditorField::ServerAddr => KeyEditorField::AccessKey, + KeyEditorField::AccessKey => KeyEditorField::ServerAddr, + }; + KeyEditorAction::Noop + } + KeyCode::Backspace => { + match self.key_editor.focus { + KeyEditorField::ServerAddr => { + self.key_editor.server_addr.pop(); + } + KeyEditorField::AccessKey => { + self.key_editor.access_key.pop(); + } + } + KeyEditorAction::Noop + } + KeyCode::Enter => { + if self.key_editor.server_addr.trim().is_empty() { + self.push_local_log("Save failed: server address cannot be empty".to_string()); + return KeyEditorAction::Noop; + } + if self.key_editor.access_key.trim().is_empty() { + self.push_local_log("Save failed: access key cannot be empty".to_string()); + return KeyEditorAction::Noop; + } + + self.config.ostp.server_addr = self.key_editor.server_addr.trim().to_string(); + self.config.ostp.access_key = self.key_editor.access_key.trim().to_string(); + + match self.config.save_to_json_near_binary() { + Ok(()) => self.push_local_log( + "Config saved to config.json" + .to_string(), + ), + Err(err) => self.push_local_log(format!("Save failed: {err}")), + } + + self.key_editor.open = false; + KeyEditorAction::Saved + } + KeyCode::Char(c) => { + match self.key_editor.focus { + KeyEditorField::ServerAddr => self.key_editor.server_addr.push(c), + KeyEditorField::AccessKey => self.key_editor.access_key.push(c), + } + KeyEditorAction::Noop + } + _ => KeyEditorAction::Noop, + } + } + + fn push_local_log(&mut self, line: String) { + self.state.apply_event(UiEvent::Log(line)); + } +} + +fn render_key_editor(frame: &mut ratatui::Frame<'_>, area: Rect, editor: &KeyEditorState) { + let popup = centered_rect(80, 45, area); + frame.render_widget(Clear, popup); + + let block = Block::default().title("Edit Keys").borders(Borders::ALL); + frame.render_widget(block, popup); + + let inner = Rect { + x: popup.x + 2, + y: popup.y + 1, + width: popup.width.saturating_sub(4), + height: popup.height.saturating_sub(2), + }; + + let lines = vec![ + Line::from(vec![ + Span::styled( + "Server Addr:", + if matches!(editor.focus, KeyEditorField::ServerAddr) { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }, + ), + Span::raw(" "), + Span::raw(editor.server_addr.as_str()), + ]), + Line::from(vec![ + Span::styled( + "Access Key:", + if matches!(editor.focus, KeyEditorField::AccessKey) { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }, + ), + Span::raw(" "), + Span::raw(editor.access_key.as_str()), + ]), + Line::from(""), + Line::from("Tab switch field, Enter save+reload, Esc cancel"), + ]; + + let widget = Paragraph::new(lines).alignment(Alignment::Left); + frame.render_widget(widget, inner); +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1]); + + horizontal[1] +} diff --git a/ostp-client/src/tunnel/mod.rs b/ostp-client/src/tunnel/mod.rs new file mode 100644 index 0000000..69762f4 --- /dev/null +++ b/ostp-client/src/tunnel/mod.rs @@ -0,0 +1,68 @@ +mod proxy; +mod wintun_downloader; +mod wintun_handler; + +pub use wintun_downloader::download_wintun_dll; +pub use wintun_handler::run_wintun_tunnel; + +use tokio::sync::{mpsc, watch}; + +use crate::config::{ExclusionConfig, LocalProxyConfig, OstpConfig}; + +pub use proxy::run_local_socks5_proxy; + +#[derive(Debug)] +pub enum ProxyEvent { + NewStream { + stream_id: u16, + target: String, + }, + Data { + stream_id: u16, + payload: bytes::Bytes, + }, + Close { + stream_id: u16, + }, +} + +#[derive(Debug)] +pub enum ProxyToClientMsg { + ConnectOk, + Data(bytes::Bytes), + Close, + Error(String), +} + +#[allow(dead_code)] +pub struct TunnelConfig { + pub local_bind: String, + pub remote_addr: String, +} + +impl Default for TunnelConfig { + fn default() -> Self { + Self { + local_bind: "127.0.0.1:1080".to_string(), + remote_addr: "127.0.0.1:443".to_string(), + } + } +} + +pub async fn cleanup() -> anyhow::Result<()> { + Ok(()) +} + +pub async fn run_local_proxy( + cfg: LocalProxyConfig, + ostp: OstpConfig, + exclusions: ExclusionConfig, + debug: bool, + shutdown: watch::Receiver, + proxy_events_tx: mpsc::Sender, + client_msgs_rx: mpsc::Receiver<(u16, ProxyToClientMsg)>, +) -> anyhow::Result<()> { + run_local_socks5_proxy(cfg, ostp, exclusions, debug, shutdown, proxy_events_tx, client_msgs_rx).await +} + + diff --git a/ostp-client/src/tunnel/proxy.rs b/ostp-client/src/tunnel/proxy.rs new file mode 100644 index 0000000..ebe2665 --- /dev/null +++ b/ostp-client/src/tunnel/proxy.rs @@ -0,0 +1,531 @@ +use std::collections::HashMap; +use anyhow::{anyhow, Context, Result}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{mpsc, watch}; +use tokio::time::{timeout, Duration}; + +use crate::config::{ExclusionConfig, LocalProxyConfig, OstpConfig}; +use crate::tunnel::{ProxyEvent, ProxyToClientMsg}; + +pub async fn run_local_socks5_proxy( + cfg: LocalProxyConfig, + _ostp: OstpConfig, + exclusions: ExclusionConfig, + debug: bool, + mut shutdown: watch::Receiver, + proxy_events_tx: mpsc::Sender, + mut client_msgs_rx: mpsc::Receiver<(u16, ProxyToClientMsg)>, +) -> Result<()> { + let connect_timeout = Duration::from_millis(cfg.connect_timeout_ms.max(1)); + let listener = TcpListener::bind(&cfg.bind_addr) + .await + .with_context(|| format!("failed to bind local HTTP/SOCKS5 proxy at {}", cfg.bind_addr))?; + + if debug { + eprintln!("[ostp-client] local HTTP/SOCKS5 proxy listening at {}", cfg.bind_addr); + eprintln!("[ostp-client] Windows system proxy: set HTTP proxy to {}. tun2socks: SOCKS5 on same address.", cfg.bind_addr); + } + + let matcher = ExclusionMatcher::new(&exclusions); + let (connect_tx, mut connect_rx) = mpsc::channel(128); + + let mut next_stream_id: u16 = 1; + let mut active_streams: HashMap> = HashMap::new(); + + loop { + tokio::select! { + _ = shutdown.changed() => { + if *shutdown.borrow() { + break; + } + } + accepted = listener.accept() => { + let (socket, _) = accepted?; + let stream_id = next_stream_id; + next_stream_id = next_stream_id.wrapping_add(1); + if next_stream_id == 0 { next_stream_id = 1; } + + let (tx, rx) = mpsc::channel(256); + active_streams.insert(stream_id, tx); + + let event_tx = proxy_events_tx.clone(); + let c_tx = connect_tx.clone(); + let matcher_clone = matcher.clone(); + tokio::spawn(async move { + if let Err(err) = handle_proxy_client( + socket, + stream_id, + event_tx, + rx, + c_tx, + connect_timeout, + debug, + matcher_clone, + ).await { + let msg = err.to_string(); + // Suppress routine disconnects from spam logs + if !msg.contains("UnexpectedEof") + && !msg.contains("Connection reset") + && !msg.contains("Broken pipe") + { + if debug { + eprintln!("[ostp-client] proxy client error: {err}"); + } + } + } + }); + } + Some((stream_id, msg)) = client_msgs_rx.recv() => { + if let Some(tx) = active_streams.get(&stream_id) { + if tx.send(msg).await.is_err() { + active_streams.remove(&stream_id); + } + } + } + Some(stream_id) = connect_rx.recv() => { + active_streams.remove(&stream_id); + } + } + } + + Ok(()) +} + +/// Extracts `host:port` from an HTTP absolute-URI like `http://example.com/path` or `https://example.com`. +/// Falls back to the raw target if already in `host:port` form. +fn extract_host_port(uri: &str, default_port: u16) -> String { + let without_scheme = if let Some(rest) = uri.strip_prefix("https://") { + rest + } else if let Some(rest) = uri.strip_prefix("http://") { + rest + } else { + uri + }; + // Trim path/query fragment + let host_part = without_scheme.split('/').next().unwrap_or(without_scheme); + if host_part.contains(':') { + host_part.to_string() + } else { + format!("{}:{}", host_part, default_port) + } +} + +async fn handle_proxy_client( + mut client: TcpStream, + stream_id: u16, + event_tx: mpsc::Sender, + mut rx: mpsc::Receiver, + close_tx: mpsc::Sender, + connect_timeout: Duration, + debug: bool, + matcher: ExclusionMatcher, +) -> Result<()> { + // Peek the first byte to distinguish SOCKS5 (0x05) from HTTP (any printable ASCII) + let mut first_byte = [0_u8; 1]; + client.read_exact(&mut first_byte).await?; + + let target: String; + let is_socks5 = first_byte[0] == 0x05; + + if is_socks5 { + // ── SOCKS5 Handshake ────────────────────────────────────────── + let mut second_byte = [0_u8; 1]; + client.read_exact(&mut second_byte).await?; + let nmethods = second_byte[0] as usize; + if nmethods > 0 { + let mut methods_buf = vec![0_u8; nmethods]; + client.read_exact(&mut methods_buf).await?; + } + // Reply: version=5, NO AUTHENTICATION + client.write_all(&[0x05, 0x00]).await?; + + // ── SOCKS5 Request ──────────────────────────────────────────── + let mut req = [0_u8; 4]; + client.read_exact(&mut req).await?; + if req[0] != 0x05 { + return Err(anyhow!("SOCKS5 request version mismatch")); + } + if req[1] != 0x01 { + // Not CONNECT — send COMMAND NOT SUPPORTED + client.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow!("unsupported SOCKS5 command {}", req[1])); + } + + let mut addr_buf = [0_u8; 256]; + target = match req[3] { + 0x01 => { + // IPv4: 4 bytes address + 2 bytes port + client.read_exact(&mut addr_buf[0..6]).await?; + let ip = std::net::Ipv4Addr::new(addr_buf[0], addr_buf[1], addr_buf[2], addr_buf[3]); + let port = u16::from_be_bytes([addr_buf[4], addr_buf[5]]); + format!("{}:{}", ip, port) + } + 0x03 => { + // Domain: 1 byte length, then domain, then 2 bytes port + client.read_exact(&mut addr_buf[0..1]).await?; + let domain_len = addr_buf[0] as usize; + client.read_exact(&mut addr_buf[0..domain_len + 2]).await?; + let domain = String::from_utf8_lossy(&addr_buf[0..domain_len]); + let port = u16::from_be_bytes([addr_buf[domain_len], addr_buf[domain_len + 1]]); + format!("{}:{}", domain, port) + } + 0x04 => { + // IPv6: 16 bytes + 2 bytes port + client.read_exact(&mut addr_buf[0..18]).await?; + let mut octets = [0u8; 16]; + octets.copy_from_slice(&addr_buf[0..16]); + let ip = std::net::Ipv6Addr::from(octets); + let port = u16::from_be_bytes([addr_buf[16], addr_buf[17]]); + format!("[{}]:{}", ip, port) + } + atyp => { + client.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow!("unsupported SOCKS5 address type: {}", atyp)); + } + }; + + if debug { + eprintln!("[ostp-client] proxy CONNECT stream_id={stream_id} target={target}"); + } + if matcher.should_bypass(&target, connect_timeout).await { + return direct_connect_socks5(client, stream_id, &target, close_tx, debug).await; + } + event_tx.send(ProxyEvent::NewStream { stream_id, target: target.clone() }).await?; + + match timeout(connect_timeout, rx.recv()).await { + Ok(Some(ProxyToClientMsg::ConnectOk)) => { + // SUCCESS: version, 0=success, reserved, IPv4 type, 4 bytes addr, 2 bytes port + client.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + } + Ok(Some(ProxyToClientMsg::Error(msg))) => { + client.write_all(&[0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + let _ = close_tx.send(stream_id).await; + return Err(anyhow!("SOCKS5 connect error: {msg}")); + } + Ok(_) => { + client.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + let _ = close_tx.send(stream_id).await; + return Err(anyhow!("connect dropped")); + } + Err(_) => { + client.write_all(&[0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + let _ = close_tx.send(stream_id).await; + return Err(anyhow!("connect timeout")); + } + } + } else { + // ── HTTP Proxy (CONNECT and plain GET/POST) ─────────────────── + // Read the rest of the HTTP request headers byte-by-byte + let mut header_bytes = Vec::with_capacity(512); + header_bytes.push(first_byte[0]); + let mut byte = [0_u8; 1]; + loop { + client.read_exact(&mut byte).await?; + header_bytes.push(byte[0]); + if header_bytes.ends_with(b"\r\n\r\n") { + break; + } + if header_bytes.len() > 8192 { + client.write_all(b"HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n").await?; + return Err(anyhow!("HTTP header too large")); + } + } + + let req_str = String::from_utf8_lossy(&header_bytes); + let first_line = req_str.lines().next().unwrap_or(""); + let parts: Vec<&str> = first_line.split_whitespace().collect(); + if parts.len() < 2 { + client.write_all(b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; + return Err(anyhow!("malformed HTTP request line: {:?}", first_line)); + } + + let method = parts[0].to_uppercase(); + let raw_uri = parts[1]; + + target = if method == "CONNECT" { + // CONNECT uses host:port directly — e.g. "CONNECT example.com:443 HTTP/1.1" + if raw_uri.contains(':') { + raw_uri.to_string() + } else { + format!("{}:443", raw_uri) + } + } else { + // Plain HTTP: absolute URI like "GET http://example.com/path HTTP/1.1" + let default_port = if raw_uri.starts_with("https://") { 443u16 } else { 80u16 }; + extract_host_port(raw_uri, default_port) + }; + + if debug { + eprintln!("[ostp-client] proxy CONNECT stream_id={stream_id} target={target}"); + } + if matcher.should_bypass(&target, connect_timeout).await { + return direct_connect_http( + client, + stream_id, + &target, + method.as_str(), + header_bytes, + close_tx, + debug, + ).await; + } + event_tx.send(ProxyEvent::NewStream { stream_id, target: target.clone() }).await?; + + match timeout(connect_timeout, rx.recv()).await { + Ok(Some(ProxyToClientMsg::ConnectOk)) => { + if method == "CONNECT" { + // For CONNECT, tell client the tunnel is ready + client.write_all(b"HTTP/1.1 200 Connection Established\r\nProxy-Agent: ostp/1.0\r\n\r\n").await?; + } else { + // For plain HTTP (GET/POST), we MUST forward the request headers we consumed + // to the server over the newly established tunnel. + event_tx.send(ProxyEvent::Data { + stream_id, + payload: bytes::Bytes::copy_from_slice(&header_bytes), + }).await?; + } + } + Ok(Some(ProxyToClientMsg::Error(msg))) => { + client.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n").await?; + let _ = close_tx.send(stream_id).await; + return Err(anyhow!("HTTP connect error: {msg}")); + } + Ok(_) => { + client.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n").await?; + let _ = close_tx.send(stream_id).await; + return Err(anyhow!("connect dropped")); + } + Err(_) => { + client.write_all(b"HTTP/1.1 504 Gateway Timeout\r\n\r\n").await?; + let _ = close_tx.send(stream_id).await; + return Err(anyhow!("connect timeout")); + } + } + } + + // ── Bidirectional raw data forwarding ───────────────────────────── + let mut tcp_buf = vec![0_u8; 1024]; + loop { + tokio::select! { + read_res = client.read(&mut tcp_buf) => { + match read_res { + Ok(0) => { + let _ = event_tx.send(ProxyEvent::Close { stream_id }).await; + if debug { + eprintln!("[ostp-client] proxy CLOSE stream_id={stream_id}"); + } + break; + } + Ok(n) => { + let _ = event_tx.send(ProxyEvent::Data { + stream_id, + payload: bytes::Bytes::copy_from_slice(&tcp_buf[..n]), + }).await; + } + Err(_) => { + let _ = event_tx.send(ProxyEvent::Close { stream_id }).await; + if debug { + eprintln!("[ostp-client] proxy CLOSE stream_id={stream_id}"); + } + break; + } + } + } + msg = rx.recv() => { + match msg { + Some(ProxyToClientMsg::Data(data)) => { + if client.write_all(&data).await.is_err() { + let _ = event_tx.send(ProxyEvent::Close { stream_id }).await; + break; + } + } + Some(ProxyToClientMsg::Close) | Some(ProxyToClientMsg::Error(_)) | None => { + break; + } + Some(ProxyToClientMsg::ConnectOk) => {} // ignored after connect phase + } + } + } + } + + let _ = close_tx.send(stream_id).await; + Ok(()) +} + +#[derive(Clone)] +struct ExclusionMatcher { + domain_suffix: Vec, + cidrs: Vec, +} + +impl ExclusionMatcher { + fn new(exclusions: &ExclusionConfig) -> Self { + let mut cidrs = Vec::new(); + for ip in &exclusions.ips { + if let Some(cidr) = parse_cidr(ip) { + cidrs.push(cidr); + } + } + + Self { + domain_suffix: exclusions + .domains + .iter() + .map(|d| d.trim().trim_start_matches('.').to_lowercase()) + .filter(|d| !d.is_empty()) + .collect(), + cidrs, + } + } + + async fn should_bypass(&self, target: &str, timeout_value: Duration) -> bool { + let (host, port) = match split_host_port(target) { + Some(v) => v, + None => return false, + }; + + if self.match_domain(&host) { + return true; + } + + if self.cidrs.is_empty() { + return false; + } + + if let Ok(ip) = host.parse::() { + return self.match_ip(&ip); + } + + let lookup_target = (host.clone(), port); + match timeout(timeout_value, tokio::net::lookup_host(lookup_target)).await { + Ok(Ok(addrs)) => addrs.into_iter().any(|addr| self.match_ip(&addr.ip())), + _ => false, + } + } + + fn match_domain(&self, host: &str) -> bool { + if self.domain_suffix.is_empty() { + return false; + } + let host = host.trim_end_matches('.').to_lowercase(); + self.domain_suffix.iter().any(|suffix| { + host == *suffix || host.ends_with(&format!(".{suffix}")) + }) + } + + fn match_ip(&self, ip: &std::net::IpAddr) -> bool { + self.cidrs.iter().any(|cidr| cidr.contains(ip)) + } +} + +#[derive(Clone)] +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 { + 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::() { + 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::() { + 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 +} + +async fn direct_connect_socks5( + mut client: TcpStream, + stream_id: u16, + target: &str, + close_tx: mpsc::Sender, + debug: bool, +) -> Result<()> { + if debug { + eprintln!("[ostp-client] proxy BYPASS stream_id={stream_id} target={target}"); + } + let mut remote = TcpStream::connect(target).await + .with_context(|| format!("direct connect failed: {target}"))?; + + client.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + let _ = tokio::io::copy_bidirectional(&mut client, &mut remote).await; + let _ = close_tx.send(stream_id).await; + Ok(()) +} + +async fn direct_connect_http( + mut client: TcpStream, + stream_id: u16, + target: &str, + method: &str, + header_bytes: Vec, + close_tx: mpsc::Sender, + debug: bool, +) -> Result<()> { + if debug { + eprintln!("[ostp-client] proxy BYPASS stream_id={stream_id} target={target}"); + } + let mut remote = TcpStream::connect(target).await + .with_context(|| format!("direct connect failed: {target}"))?; + + if method == "CONNECT" { + client.write_all(b"HTTP/1.1 200 Connection Established\r\nProxy-Agent: ostp/1.0\r\n\r\n").await?; + } else { + remote.write_all(&header_bytes).await?; + } + + let _ = tokio::io::copy_bidirectional(&mut client, &mut remote).await; + let _ = close_tx.send(stream_id).await; + Ok(()) +} diff --git a/ostp-client/src/tunnel/wintun_downloader.rs b/ostp-client/src/tunnel/wintun_downloader.rs new file mode 100644 index 0000000..a07739d --- /dev/null +++ b/ostp-client/src/tunnel/wintun_downloader.rs @@ -0,0 +1,49 @@ +#![allow(unused_imports)] +use anyhow::Result; +#[cfg(target_os = "windows")] +use anyhow::anyhow; +use std::path::PathBuf; + +#[cfg(target_os = "windows")] +pub fn download_wintun_dll(debug: bool) -> Result<()> { + let exe = std::env::current_exe()?; + let dir = exe.parent().ok_or_else(|| anyhow!("failed to get binary directory"))?; + let dll_path = dir.join("wintun.dll"); + + if !dll_path.exists() { + if debug { + println!("[ostp-client] wintun.dll not found. Downloading automatically..."); + } + + let zip_path = dir.join("wintun.zip").to_string_lossy().replace('\\', "/"); + let temp_path = dir.join("wintun_temp").to_string_lossy().replace('\\', "/"); + let dll_dest = dll_path.to_string_lossy().replace('\\', "/"); + + let ps_script = format!( + "Invoke-WebRequest -Uri 'https://www.wintun.net/builds/wintun-0.14.1.zip' -OutFile '{}' -ErrorAction Stop; \ + Expand-Archive -Path '{}' -DestinationPath '{}' -Force; \ + Get-ChildItem -Path '{}' -Filter 'wintun.dll' -Recurse | Copy-Item -Destination '{}' -Force; \ + Remove-Item '{}', '{}' -Recurse -Force", + zip_path, zip_path, temp_path, temp_path, dll_dest, zip_path, temp_path + ); + + let output = std::process::Command::new("powershell") + .args(["-Command", &ps_script]) + .current_dir(dir) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("Failed to download wintun.dll: {stderr}")); + } + if debug { + println!("[ostp-client] wintun.dll downloaded and installed successfully!"); + } + } + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +pub fn download_wintun_dll(_debug: bool) -> Result<()> { + Ok(()) +} diff --git a/ostp-client/src/tunnel/wintun_handler.rs b/ostp-client/src/tunnel/wintun_handler.rs new file mode 100644 index 0000000..16c2bd1 --- /dev/null +++ b/ostp-client/src/tunnel/wintun_handler.rs @@ -0,0 +1,91 @@ +use anyhow::{anyhow, Result}; +#[cfg(target_os = "windows")] +use std::sync::Arc; +use tokio::sync::watch; + +#[cfg(target_os = "windows")] +pub async fn run_wintun_tunnel( + mut shutdown: watch::Receiver, + debug: bool, +) -> Result<()> { + if debug { + println!("[ostp-client] Initializing Wintun adapter 'ostp_tun'..."); + } + + // 1. Load Wintun DLL + let wintun = unsafe { wintun::load_from_path("wintun.dll") } + .map_err(|e| anyhow!("Failed to load wintun.dll: {:?}", e))?; + + // 2. Create or Open Adapter with static name "ostp_tun" + let adapter = match wintun::Adapter::open(&wintun, "ostp_tun") { + Ok(a) => a, + Err(_) => wintun::Adapter::create(&wintun, "ostp_tun", "OSTP TUN Adapter", None) + .map_err(|e| anyhow!("Failed to create Wintun adapter: {:?}", e))?, + }; + + let adapter = Arc::new(adapter); + + // Set IP, Subnet and Gateway natively using netsh for bulletproof routing + if debug { + println!("[ostp-client] Configuring Wintun network settings via netsh..."); + } + let output = std::process::Command::new("netsh") + .args(["interface", "ipv4", "set", "address", "name=ostp_tun", "static", "10.1.0.2", "255.255.255.0", "10.1.0.1"]) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + println!("[ostp-client] Warning: netsh returned error: {}", stderr); + } else { + if debug { + println!("[ostp-client] Network configured. ostp_tun IP: 10.1.0.2, Gateway: 10.1.0.1"); + } + } + + // Start Wintun session + let session = adapter.start_session(wintun::MAX_RING_CAPACITY) + .map_err(|e| anyhow!("Failed to start Wintun session: {:?}", e))?; + let session = Arc::new(session); + + if debug { + println!("[ostp-client] TUN tunnel 'ostp_tun' is active and intercepting packets!"); + } + + // Spawn Packet Receiver Loop to read packets from Windows stack + let rx_session = session.clone(); + tokio::task::spawn_blocking(move || { + loop { + match rx_session.receive_blocking() { + Ok(packet) => { + let bytes = packet.bytes(); + if bytes.len() >= 20 { + let proto = bytes[9]; + let src_ip = format!("{}.{}.{}.{}", bytes[12], bytes[13], bytes[14], bytes[15]); + let dest_ip = format!("{}.{}.{}.{}", bytes[16], bytes[17], bytes[18], bytes[19]); + if debug { + println!("[TUN Packet] Proto={}, Src={}, Dest={}, Len={}", proto, src_ip, dest_ip, bytes.len()); + } + } + } + Err(_) => break, + } + } + }); + + // Wait for shutdown signal + let _ = shutdown.changed().await; + + if debug { + println!("[ostp-client] Shutting down Wintun adapter..."); + } + + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +pub async fn run_wintun_tunnel( + _shutdown: watch::Receiver, + _debug: bool, +) -> Result<()> { + Err(anyhow!("Wintun is only supported on Windows!")) +} diff --git a/ostp-core/Cargo.toml b/ostp-core/Cargo.toml new file mode 100644 index 0000000..7852ed9 --- /dev/null +++ b/ostp-core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ostp-core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +async-trait.workspace = true +bytes.workspace = true +chacha20poly1305.workspace = true +rand.workspace = true +snow.workspace = true +thiserror.workspace = true +tracing.workspace = true +x25519-dalek.workspace = true +sha2.workspace = true diff --git a/ostp-core/src/crypto/aead.rs b/ostp-core/src/crypto/aead.rs new file mode 100644 index 0000000..c4d46b4 --- /dev/null +++ b/ostp-core/src/crypto/aead.rs @@ -0,0 +1,63 @@ +use chacha20poly1305::aead::{Aead, KeyInit}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; + +use crate::protocol::ProtocolError; + +const NONCE_LEN: usize = 12; + +pub struct SessionCipher { + inner: ChaCha20Poly1305, +} + +impl SessionCipher { + pub fn new(key_material: &[u8; 32]) -> Self { + let key = Key::from_slice(key_material); + Self { + inner: ChaCha20Poly1305::new(key), + } + } + + pub fn encrypt( + &self, + nonce_counter: u64, + plaintext: &[u8], + aad: &[u8], + ) -> Result, ProtocolError> { + let nonce_bytes = nonce_from_counter(nonce_counter); + let nonce = Nonce::from_slice(&nonce_bytes); + self.inner + .encrypt( + nonce, + chacha20poly1305::aead::Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| ProtocolError::Crypto("aead-encrypt".to_string())) + } + + pub fn decrypt( + &self, + nonce_counter: u64, + ciphertext: &[u8], + aad: &[u8], + ) -> Result, ProtocolError> { + let nonce_bytes = nonce_from_counter(nonce_counter); + let nonce = Nonce::from_slice(&nonce_bytes); + self.inner + .decrypt( + nonce, + chacha20poly1305::aead::Payload { + msg: ciphertext, + aad, + }, + ) + .map_err(|_| ProtocolError::Crypto("aead-decrypt".to_string())) + } +} + +fn nonce_from_counter(counter: u64) -> [u8; NONCE_LEN] { + let mut nonce = [0_u8; NONCE_LEN]; + nonce[4..].copy_from_slice(&counter.to_be_bytes()); + nonce +} diff --git a/ostp-core/src/crypto/kex.rs b/ostp-core/src/crypto/kex.rs new file mode 100644 index 0000000..35567ee --- /dev/null +++ b/ostp-core/src/crypto/kex.rs @@ -0,0 +1,45 @@ +use rand::rngs::OsRng; +use sha2::{Digest, Sha256}; +use x25519_dalek::{EphemeralSecret, PublicKey}; + +#[derive(Debug, Clone)] +pub struct HybridSharedSecret { + pub x25519_pubkey: [u8; 32], + pub pq_ciphertext: Vec, + pub combined_secret: [u8; 32], +} + +pub trait KeyExchange { + fn client_kex() -> HybridSharedSecret; +} + +pub struct HybridKex; + +impl HybridKex { + pub fn client_offer() -> HybridSharedSecret { + let secret = EphemeralSecret::random_from_rng(OsRng); + let pubkey = PublicKey::from(&secret); + + // Placeholder PQ ciphertext. Replace with ML-KEM encapsulation output. + 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, + } + } +} + +impl KeyExchange for HybridKex { + fn client_kex() -> HybridSharedSecret { + Self::client_offer() + } +} diff --git a/ostp-core/src/crypto/mod.rs b/ostp-core/src/crypto/mod.rs new file mode 100644 index 0000000..25a1582 --- /dev/null +++ b/ostp-core/src/crypto/mod.rs @@ -0,0 +1,9 @@ +pub mod aead; +pub mod kex; +pub mod noise; +pub mod obfuscation; + +pub use aead::SessionCipher; +pub use kex::{HybridSharedSecret, KeyExchange}; +pub use noise::{NoiseRole, NoiseSession}; +pub use obfuscation::{deobfuscate_packet_inplace, obfuscate_packet_inplace, derive_obfuscation_key, derive_psk}; diff --git a/ostp-core/src/crypto/noise.rs b/ostp-core/src/crypto/noise.rs new file mode 100644 index 0000000..d899349 --- /dev/null +++ b/ostp-core/src/crypto/noise.rs @@ -0,0 +1,85 @@ +use snow::{Builder, HandshakeState, TransportState}; + +use crate::protocol::ProtocolError; + +const NN_NOISE_PARAMS: &str = "Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s"; + +#[derive(Clone, Copy, Debug)] +pub enum NoiseRole { + Initiator, + Responder, +} + +pub enum NoiseSession { + Handshake(HandshakeState), + Transport(TransportState), +} + +impl NoiseSession { + pub fn new( + role: NoiseRole, + psk: &[u8; 32], + ) -> Result { + let params = NN_NOISE_PARAMS + .parse() + .map_err(|_| ProtocolError::Crypto("noise-params".to_string()))?; + + let mut builder = Builder::new(params); + builder = builder.psk(0, psk); + + let handshake = match role { + NoiseRole::Initiator => builder + .build_initiator() + .map_err(|_| ProtocolError::Crypto("noise-init".to_string()))?, + NoiseRole::Responder => builder + .build_responder() + .map_err(|_| ProtocolError::Crypto("noise-responder".to_string()))?, + }; + + Ok(Self::Handshake(handshake)) + } + + pub fn write_handshake(&mut self, payload: &[u8], out: &mut [u8]) -> Result { + match self { + NoiseSession::Handshake(hs) => hs + .write_message(payload, out) + .map_err(|_| ProtocolError::Crypto("noise-write".to_string())), + NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())), + } + } + + pub fn read_handshake(&mut self, input: &[u8], out: &mut [u8]) -> Result { + match self { + NoiseSession::Handshake(hs) => hs + .read_message(input, out) + .map_err(|_| ProtocolError::Crypto("noise-read".to_string())), + NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())), + } + } + + pub fn handshake_hash(&self, out: &mut [u8]) -> Result<(), ProtocolError> { + match self { + NoiseSession::Handshake(hs) => { + let hash = hs.get_handshake_hash(); + if out.len() != hash.len() { + return Err(ProtocolError::Crypto("handshake hash length mismatch".to_string())); + } + out.copy_from_slice(hash); + Ok(()) + } + NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())), + } + } + + pub fn into_transport(self) -> Result { + match self { + NoiseSession::Handshake(hs) => { + let transport = hs + .into_transport_mode() + .map_err(|_| ProtocolError::Crypto("noise-transport".to_string()))?; + Ok(NoiseSession::Transport(transport)) + } + NoiseSession::Transport(_) => Ok(self), + } + } +} diff --git a/ostp-core/src/crypto/obfuscation.rs b/ostp-core/src/crypto/obfuscation.rs new file mode 100644 index 0000000..b396952 --- /dev/null +++ b/ostp-core/src/crypto/obfuscation.rs @@ -0,0 +1,90 @@ +use sha2::{Digest, Sha256}; + +pub fn derive_obfuscation_key(access_key: &[u8]) -> [u8; 8] { + let mut hasher = Sha256::new(); + hasher.update(access_key); + let result = hasher.finalize(); + let mut key = [0u8; 8]; + key.copy_from_slice(&result[0..8]); + key +} + +pub fn derive_psk(access_key: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(access_key); + hasher.update(b"-ostp-psk-salt"); + let result = hasher.finalize(); + let mut psk = [0u8; 32]; + psk.copy_from_slice(&result); + psk +} + +pub fn obfuscate_packet_inplace(raw: &mut [u8], key: &[u8; 8], is_handshake: bool) { + if !is_handshake && raw.len() >= 12 { + // Data packet + let mut session_id_bytes = [raw[0], raw[1], raw[2], raw[3]]; + let mut nonce_bytes = [ + raw[4], raw[5], raw[6], raw[7], + raw[8], raw[9], raw[10], raw[11] + ]; + + // 1. Obfuscate nonce with derived key + for i in 0..8 { + nonce_bytes[i] ^= key[i]; + } + + // 2. Obfuscate session_id with the REAL (unobfuscated) nonce + let real_nonce = u64::from_be_bytes([ + raw[4], raw[5], raw[6], raw[7], + raw[8], raw[9], raw[10], raw[11] + ]); + let nonce_low_32 = (real_nonce & 0xFFFFFFFF) as u32; + let nonce_low_bytes = nonce_low_32.to_be_bytes(); + + for i in 0..4 { + session_id_bytes[i] ^= nonce_low_bytes[i]; + } + + // Put them back + raw[0..4].copy_from_slice(&session_id_bytes); + raw[4..12].copy_from_slice(&nonce_bytes); + } else if raw.len() >= 4 { + // Handshake packet (XOR with key) + for i in 0..4 { + raw[i] ^= key[i % 8]; + } + } +} + +pub fn deobfuscate_packet_inplace(raw: &mut [u8], key: &[u8; 8], is_handshake: bool) { + if !is_handshake && raw.len() >= 12 { + // Data packet + let mut nonce_bytes = [ + raw[4], raw[5], raw[6], raw[7], + raw[8], raw[9], raw[10], raw[11] + ]; + + // 1. Recover real nonce by XORing with key + for i in 0..8 { + nonce_bytes[i] ^= key[i]; + } + let real_nonce = u64::from_be_bytes(nonce_bytes); + let nonce_low_32 = (real_nonce & 0xFFFFFFFF) as u32; + let nonce_low_bytes = nonce_low_32.to_be_bytes(); + + // 2. Recover session_id by XORing with recovered nonce + let mut session_id_bytes = [raw[0], raw[1], raw[2], raw[3]]; + for i in 0..4 { + session_id_bytes[i] ^= nonce_low_bytes[i]; + } + + // Put them back + raw[0..4].copy_from_slice(&session_id_bytes); + raw[4..12].copy_from_slice(&nonce_bytes); + } else if raw.len() >= 4 { + // Handshake packet + for i in 0..4 { + raw[i] ^= key[i % 8]; + } + } +} diff --git a/ostp-core/src/framing/frame.rs b/ostp-core/src/framing/frame.rs new file mode 100644 index 0000000..fc1f2b3 --- /dev/null +++ b/ostp-core/src/framing/frame.rs @@ -0,0 +1,118 @@ +use bytes::{BufMut, Bytes, BytesMut}; + +use crate::protocol::ProtocolError; + +const FRAME_HEADER_LEN: usize = 12; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum FrameKind { + Handshake = 1, + Data = 2, + Close = 3, + KeepAlive = 4, + Nack = 5, + Ack = 6, +} + +impl TryFrom for FrameKind { + type Error = ProtocolError; + + fn try_from(value: u8) -> Result { + match value { + 1 => Ok(Self::Handshake), + 2 => Ok(Self::Data), + 3 => Ok(Self::Close), + 4 => Ok(Self::KeepAlive), + 5 => Ok(Self::Nack), + 6 => Ok(Self::Ack), + _ => Err(ProtocolError::Framing("unknown frame kind".to_string())), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct FrameHeader { + pub version: u8, + pub kind: FrameKind, + pub flags: u8, + pub stream_id: u16, + pub payload_len: u32, + pub pad_len: u16, +} + +impl FrameHeader { + pub fn encode(&self, out: &mut BytesMut) { + out.put_u8(self.version); + out.put_u8(self.kind as u8); + out.put_u8(self.flags); + out.put_u8(0); // reserved + out.put_u16(self.stream_id); + out.put_u32(self.payload_len); + out.put_u16(self.pad_len); + } + + pub fn decode(buf: &[u8]) -> Result { + if buf.len() < FRAME_HEADER_LEN { + return Err(ProtocolError::Framing("truncated frame header".to_string())); + } + + let version = buf[0]; + let kind = FrameKind::try_from(buf[1])?; + let flags = buf[2]; + let stream_id = u16::from_be_bytes([buf[4], buf[5]]); + let payload_len = u32::from_be_bytes([buf[6], buf[7], buf[8], buf[9]]); + let pad_len = u16::from_be_bytes([buf[10], buf[11]]); + + Ok(Self { + version, + kind, + flags, + stream_id, + payload_len, + pad_len, + }) + } +} + +#[derive(Debug, Clone)] +pub struct FramedPacket { + pub header: FrameHeader, + pub payload: Bytes, + pub padding: Bytes, +} + +impl FramedPacket { + pub fn encode(&self) -> Bytes { + let total = FRAME_HEADER_LEN + self.payload.len() + self.padding.len(); + let mut out = BytesMut::with_capacity(total); + self.header.encode(&mut out); + out.extend_from_slice(&self.payload); + out.extend_from_slice(&self.padding); + out.freeze() + } + + pub fn decode_zero_copy(buf: Bytes) -> Result { + if buf.len() < FRAME_HEADER_LEN { + return Err(ProtocolError::Framing("frame too short".to_string())); + } + + let header = FrameHeader::decode(&buf[..FRAME_HEADER_LEN])?; + let payload_len = header.payload_len as usize; + let pad_len = header.pad_len as usize; + + let expected = FRAME_HEADER_LEN + payload_len + pad_len; + if buf.len() < expected { + return Err(ProtocolError::Framing("frame body truncated".to_string())); + } + + let payload = buf.slice(FRAME_HEADER_LEN..FRAME_HEADER_LEN + payload_len); + let padding = buf.slice(FRAME_HEADER_LEN + payload_len..expected); + + Ok(Self { + header, + payload, + padding, + }) + } +} diff --git a/ostp-core/src/framing/mod.rs b/ostp-core/src/framing/mod.rs new file mode 100644 index 0000000..24eaed0 --- /dev/null +++ b/ostp-core/src/framing/mod.rs @@ -0,0 +1,5 @@ +pub mod frame; +pub mod padding; + +pub use frame::{FrameHeader, FrameKind, FramedPacket}; +pub use padding::{AdaptivePadder, PaddingStrategy, TrafficProfile}; diff --git a/ostp-core/src/framing/padding.rs b/ostp-core/src/framing/padding.rs new file mode 100644 index 0000000..b2001dc --- /dev/null +++ b/ostp-core/src/framing/padding.rs @@ -0,0 +1,83 @@ +use rand::Rng; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrafficProfile { + JsonRpc, + HttpsBurst, + VideoStream, +} + +impl TrafficProfile { + pub fn target_size(&self, current: usize) -> usize { + match self { + TrafficProfile::JsonRpc => align_up(current.max(220), 64).min(1408), + TrafficProfile::HttpsBurst => align_up(current.max(1200), 128).min(1472), + TrafficProfile::VideoStream => align_up(current.max(900), 188).min(1472), + } + } +} + +fn align_up(v: usize, align: usize) -> usize { + ((v + align - 1) / align) * align +} + +#[derive(Debug, Clone, Copy)] +pub enum PaddingStrategy { + Fixed(usize), + Adaptive, + Profile(TrafficProfile), +} + +#[derive(Debug, Clone)] +pub struct AdaptivePadder { + pub mtu_hint: usize, + pub max_pad: usize, + pub strategy: PaddingStrategy, +} + +impl AdaptivePadder { + pub fn new(mtu_hint: usize, max_pad: usize, strategy: PaddingStrategy) -> Self { + Self { + mtu_hint, + max_pad, + strategy, + } + } + + pub fn padding_for_len(&self, payload_len: usize) -> usize { + match self.strategy { + PaddingStrategy::Fixed(target) => target.saturating_sub(payload_len), + PaddingStrategy::Adaptive => { + let base_bucket = 64; + let bucketized = ((payload_len + base_bucket - 1) / base_bucket) * base_bucket; + let mut target = bucketized.clamp(base_bucket, self.mtu_hint); + if target < payload_len { + target = payload_len; + } + + let base_pad = target - payload_len; + let jitter_cap = self.max_pad.saturating_sub(base_pad); + let jitter = if jitter_cap == 0 { + 0 + } else { + rand::thread_rng().gen_range(0..=jitter_cap.min(96)) + }; + + (base_pad + jitter).min(self.max_pad) + } + PaddingStrategy::Profile(prof) => { + let target = prof.target_size(payload_len); + target.saturating_sub(payload_len).min(self.max_pad) + } + } + } + + pub fn build_padding(&self, payload_len: usize) -> Vec { + let len = self.padding_for_len(payload_len); + let mut buf = vec![0_u8; len]; + if len > 0 { + rand::thread_rng().fill(&mut buf[..]); + } + buf + } +} diff --git a/ostp-core/src/lib.rs b/ostp-core/src/lib.rs new file mode 100644 index 0000000..cebc708 --- /dev/null +++ b/ostp-core/src/lib.rs @@ -0,0 +1,8 @@ +pub mod crypto; +pub mod framing; +pub mod protocol; +pub mod relay; + +pub use crypto::NoiseRole; +pub use framing::TrafficProfile; +pub use protocol::{OstpEvent, OstpState, ProtocolAction, ProtocolConfig, ProtocolMachine}; diff --git a/ostp-core/src/protocol.rs b/ostp-core/src/protocol.rs new file mode 100644 index 0000000..6c6708f --- /dev/null +++ b/ostp-core/src/protocol.rs @@ -0,0 +1,565 @@ +use bytes::Bytes; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use std::collections::{BTreeMap, VecDeque}; +use std::time::{Duration, Instant}; + +use crate::crypto::{NoiseRole, NoiseSession, SessionCipher}; +use crate::framing::{AdaptivePadder, FrameHeader, FrameKind, FramedPacket, PaddingStrategy}; + +#[derive(Debug, Error)] +pub enum ProtocolError { + #[error("state error: {0}")] + State(String), + #[error("crypto error: {0}")] + Crypto(String), + #[error("framing error: {0}")] + Framing(String), +} + +#[derive(Debug, Clone)] +pub struct ProtocolConfig { + pub role: NoiseRole, + pub psk: [u8; 32], + pub session_id: u32, + pub handshake_payload: Vec, + pub max_padding: usize, + pub padding_strategy: PaddingStrategy, + pub obfuscation_key: [u8; 8], + pub max_reorder: u64, + pub max_reorder_buffer: usize, + pub ack_delay_ms: u64, + pub rto_ms: u64, + pub max_retries: u8, + pub max_sent_history: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OstpState { + Init, + Handshaking, + Established, + Closing, + Closed, +} + +pub enum OstpEvent { + Start, + Inbound(Bytes), + Outbound(u16, Bytes), // stream_id, payload + Close, + Tick, +} + +pub enum ProtocolAction { + SendDatagram(Bytes), // Fully formed datagram to send globally + DeliverApp(u16, Bytes), // stream_id, payload + HandshakePayload(Bytes, Option), // Passed from client's handshake, Optional response to send + Multiple(Vec), + Noop, +} + +pub struct ProtocolMachine { + role: NoiseRole, + state: OstpState, + noise: NoiseSession, + send_cipher: Option, + recv_cipher: Option, + send_nonce: u64, + expected_recv_nonce: u64, + reorder_buffer: BTreeMap, + sent_history: VecDeque, + session_id: u32, + handshake_payload: Vec, + padder: AdaptivePadder, + obfuscation_key: [u8; 8], + max_reorder: u64, + max_reorder_buffer: usize, + ack_delay: Duration, + rto: Duration, + max_retries: u8, + max_sent_history: usize, + ack_pending: bool, + last_ack_sent: Instant, +} + +#[derive(Debug, Clone)] +struct SentFrame { + nonce: u64, + bytes: Bytes, + last_sent: Instant, + retries: u8, +} + +impl ProtocolMachine { + pub fn new(config: ProtocolConfig) -> Result { + let noise = NoiseSession::new( + config.role, + &config.psk, + )?; + + Ok(Self { + role: config.role, + state: OstpState::Init, + noise, + send_cipher: None, + recv_cipher: None, + send_nonce: 0, + expected_recv_nonce: 0, + reorder_buffer: BTreeMap::new(), + sent_history: VecDeque::with_capacity(config.max_sent_history.max(1)), + session_id: config.session_id, + handshake_payload: config.handshake_payload, + padder: AdaptivePadder::new(1200, config.max_padding, config.padding_strategy), + obfuscation_key: config.obfuscation_key, + max_reorder: config.max_reorder.max(1), + max_reorder_buffer: config.max_reorder_buffer.max(1), + ack_delay: Duration::from_millis(config.ack_delay_ms.max(1)), + rto: Duration::from_millis(config.rto_ms.max(1)), + max_retries: config.max_retries.max(1), + max_sent_history: config.max_sent_history.max(1), + ack_pending: false, + last_ack_sent: Instant::now(), + }) + } + + pub fn state(&self) -> OstpState { + self.state + } + + pub fn on_event(&mut self, event: OstpEvent) -> Result { + match (self.state, event) { + (OstpState::Init, OstpEvent::Start) => { + match self.role { + NoiseRole::Initiator => { + self.state = OstpState::Handshaking; + let mut out = vec![0_u8; 1024]; + let n = self.noise.write_handshake(&self.handshake_payload, &mut out)?; + out.truncate(n); + self.wrap_datagram_handshake(&out) + .map(ProtocolAction::SendDatagram) + } + NoiseRole::Responder => { + self.state = OstpState::Handshaking; + Ok(ProtocolAction::Noop) + } + } + } + (OstpState::Init, OstpEvent::Inbound(raw)) => { + self.state = OstpState::Handshaking; + self.handle_inbound(raw) + } + (OstpState::Handshaking, OstpEvent::Inbound(raw)) => { + self.handle_inbound(raw) + } + (OstpState::Handshaking, OstpEvent::Start) => Ok(ProtocolAction::Noop), + (OstpState::Established, OstpEvent::Outbound(stream_id, app_data)) => { + self.build_tracked_datagram(stream_id, FrameKind::Data, app_data) + .map(ProtocolAction::SendDatagram) + } + (OstpState::Established, OstpEvent::Inbound(raw)) => { + self.handle_inbound(raw) + } + (OstpState::Established, OstpEvent::Close) => { + self.state = OstpState::Closing; + self.build_tracked_datagram(0, FrameKind::Close, Bytes::new()) + .map(ProtocolAction::SendDatagram) + } + (OstpState::Closing, OstpEvent::Inbound(_)) => { + self.state = OstpState::Closed; + Ok(ProtocolAction::Noop) + } + (OstpState::Established, OstpEvent::Tick) => self.handle_tick(), + (OstpState::Closed, _) => Ok(ProtocolAction::Noop), + (_, OstpEvent::Close) => { + self.state = OstpState::Closed; + Ok(ProtocolAction::Noop) + } + _ => Ok(ProtocolAction::Noop), + } + } + + fn handle_inbound(&mut self, raw: Bytes) -> Result { + let mut raw_vec = raw.to_vec(); + let is_handshake = self.state == OstpState::Handshaking || self.state == OstpState::Init; + crate::crypto::deobfuscate_packet_inplace(&mut raw_vec, &self.obfuscation_key, is_handshake); + + if raw_vec.len() < 4 { + return Err(ProtocolError::Framing("datagram too short".to_string())); + } + + let session_id = u32::from_be_bytes([raw_vec[0], raw_vec[1], raw_vec[2], raw_vec[3]]); + if session_id != self.session_id { + return Err(ProtocolError::State("session id mismatch".to_string())); + } + + if self.state == OstpState::Handshaking { + let mut read_out = vec![0_u8; 1024]; + let n = self.noise.read_handshake(&raw_vec[4..], &mut read_out)?; + + let response = match self.role { + NoiseRole::Responder => { + let mut write_out = vec![0_u8; 1024]; + let out_n = self.noise.write_handshake(&self.handshake_payload, &mut write_out)?; + write_out.truncate(out_n); + Some(self.wrap_datagram_handshake(&write_out)?) + } + NoiseRole::Initiator => None, + }; + + let mut key = [0_u8; 32]; + self.noise.handshake_hash(&mut key)?; + let (send_key, recv_key) = derive_split_keys(&key, self.role); + self.send_cipher = Some(SessionCipher::new(&send_key)); + self.recv_cipher = Some(SessionCipher::new(&recv_key)); + self.state = OstpState::Established; + + let extracted_payload = read_out[..n].to_vec(); + + return Ok(ProtocolAction::HandshakePayload(Bytes::from(extracted_payload), response)); + } else if self.state == OstpState::Established { + if raw_vec.len() < 12 { + return Err(ProtocolError::Framing("data datagram too short".to_string())); + } + let nonce = u64::from_be_bytes(raw_vec[4..12].try_into().unwrap()); + + if nonce < self.expected_recv_nonce { + // Duplicate or delayed packet already processed, drop silently + return Ok(ProtocolAction::Noop); + } + + // Buffer limit to prevent memory bloat, widened to handle high latency/speed gaps + if nonce > self.expected_recv_nonce + self.max_reorder { + // Treat as heavy loss: request retransmit of the earliest missing packet. + if let Ok(nack_frame) = self.build_control_datagram( + 0, + FrameKind::Nack, + Bytes::copy_from_slice(&self.expected_recv_nonce.to_be_bytes()), + ) { + return Ok(ProtocolAction::SendDatagram(nack_frame)); + } + return Ok(ProtocolAction::Noop); + } + + let ciphertext = &raw_vec[12..]; + let cipher = self.recv_cipher.as_ref().ok_or_else(|| { + ProtocolError::State("missing recv cipher".to_string()) + })?; + + let session_id_bytes = self.session_id.to_be_bytes(); + let plaintext = cipher.decrypt(nonce, ciphertext, &session_id_bytes)?; + + let packet = FramedPacket::decode_zero_copy(Bytes::from(plaintext))?; + + let mut outbound_actions = Vec::new(); + + // Fast path processing for Nacks: act immediately, bypass sequence queue + if packet.header.kind == FrameKind::Nack { + if packet.payload.len() >= 8 { + let req_nonce = u64::from_be_bytes(packet.payload[..8].try_into().unwrap()); + // Search history from back to front (newest most likely requested) + if let Some(cached_frame) = self.lookup_sent_frame(req_nonce) { + outbound_actions.push(ProtocolAction::SendDatagram(cached_frame)); + } + } + } + + if packet.header.kind == FrameKind::Ack { + let ranges = parse_ack_ranges(&packet.payload)?; + self.drop_acked_frames(&ranges); + } + + let action = match packet.header.kind { + FrameKind::Data => { + ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload) + } + FrameKind::Close => { + self.state = OstpState::Closed; + ProtocolAction::Noop + } + FrameKind::KeepAlive => ProtocolAction::Noop, + _ => ProtocolAction::Noop, + }; + + let mut app_actions = Vec::new(); + + if matches!(packet.header.kind, FrameKind::Data | FrameKind::Close | FrameKind::KeepAlive) { + self.ack_pending = true; + } + + if nonce == self.expected_recv_nonce { + app_actions.push(action); + self.expected_recv_nonce = self.expected_recv_nonce.checked_add(1).ok_or_else(|| { + ProtocolError::Crypto("recv nonce sequence exhausted".to_string()) + })?; + + // Drain continuous queue + while let Some(buffered_action) = self.reorder_buffer.remove(&self.expected_recv_nonce) { + app_actions.push(buffered_action); + self.expected_recv_nonce = self.expected_recv_nonce.checked_add(1).ok_or_else(|| { + ProtocolError::Crypto("recv nonce sequence exhausted".to_string()) + })?; + } + } else { + // Gap detected! Buffer current packet and request immediate retransmit of the gap packet. + if self.reorder_buffer.len() < self.max_reorder_buffer { + self.reorder_buffer.insert(nonce, action); + } + + // Emit a Nack frame for the lowest missing sequence + let nack_payload = self.expected_recv_nonce.to_be_bytes(); + if let Ok(nack_frame) = self.build_control_datagram(0, FrameKind::Nack, Bytes::copy_from_slice(&nack_payload)) { + outbound_actions.push(ProtocolAction::SendDatagram(nack_frame)); + } + } + + if let Some(ack_frame) = self.build_ack_if_due()? { + outbound_actions.push(ProtocolAction::SendDatagram(ack_frame)); + } + + // Collate both types of output (application payloads and wire actions like Nacks/Retransmissions) + let mut all_actions = Vec::new(); + all_actions.extend(outbound_actions); + all_actions.extend(app_actions); + + if all_actions.is_empty() { + Ok(ProtocolAction::Noop) + } else if all_actions.len() == 1 { + Ok(all_actions.pop().unwrap()) + } else { + Ok(ProtocolAction::Multiple(all_actions)) + } + } else { + Ok(ProtocolAction::Noop) + } + } + + fn wrap_datagram_handshake(&self, noise_payload: &[u8]) -> Result { + let mut out = Vec::with_capacity(4 + noise_payload.len()); + out.extend_from_slice(&self.session_id.to_be_bytes()); + out.extend_from_slice(noise_payload); + crate::crypto::obfuscate_packet_inplace(&mut out, &self.obfuscation_key, true); + Ok(Bytes::from(out)) + } + + fn build_tracked_datagram(&mut self, stream_id: u16, kind: FrameKind, payload: Bytes) -> Result { + self.build_datagram(stream_id, kind, payload, true) + } + + fn build_control_datagram(&mut self, stream_id: u16, kind: FrameKind, payload: Bytes) -> Result { + self.build_datagram(stream_id, kind, payload, false) + } + + fn build_datagram(&mut self, stream_id: u16, kind: FrameKind, payload: Bytes, track: bool) -> Result { + let padding = self.padder.build_padding(payload.len()); + let header = FrameHeader { + version: 1, + kind, + flags: 0, + stream_id, + payload_len: payload.len() as u32, + pad_len: padding.len() as u16, + }; + + let packet = FramedPacket { + header, + payload, + padding: Bytes::from(padding), + }; + + let plaintext = packet.encode(); + + let cipher = self.send_cipher.as_ref().ok_or_else(|| { + ProtocolError::State("missing send cipher".to_string()) + })?; + + let nonce = self.send_nonce; + self.send_nonce = self.send_nonce.checked_add(1).ok_or_else(|| { + ProtocolError::Crypto("send nonce sequence exhausted".to_string()) + })?; + + let session_id_bytes = self.session_id.to_be_bytes(); + let ciphertext = cipher.encrypt(nonce, plaintext.as_ref(), &session_id_bytes)?; + + let mut out = Vec::with_capacity(4 + 8 + ciphertext.len()); + out.extend_from_slice(&session_id_bytes); + out.extend_from_slice(&nonce.to_be_bytes()); + out.extend_from_slice(&ciphertext); + crate::crypto::obfuscate_packet_inplace(&mut out, &self.obfuscation_key, false); + + let final_bytes = Bytes::from(out); + + if track { + self.push_sent_frame(nonce, final_bytes.clone()); + } + + Ok(final_bytes) + } + + pub fn set_session_keys(&mut self, session_id: u32, obfuscation_key: [u8; 8]) { + self.session_id = session_id; + self.obfuscation_key = obfuscation_key; + } + + fn handle_tick(&mut self) -> Result { + let mut actions = Vec::new(); + + if let Some(ack_frame) = self.build_ack_if_due()? { + actions.push(ProtocolAction::SendDatagram(ack_frame)); + } + + let now = Instant::now(); + for frame in self.sent_history.iter_mut() { + if frame.retries >= self.max_retries { + continue; + } + if now.duration_since(frame.last_sent) >= self.rto { + frame.last_sent = now; + frame.retries = frame.retries.saturating_add(1); + actions.push(ProtocolAction::SendDatagram(frame.bytes.clone())); + } + } + + if actions.is_empty() { + Ok(ProtocolAction::Noop) + } else if actions.len() == 1 { + Ok(actions.pop().unwrap()) + } else { + Ok(ProtocolAction::Multiple(actions)) + } + } + + fn build_ack_if_due(&mut self) -> Result, ProtocolError> { + if !self.ack_pending { + return Ok(None); + } + let now = Instant::now(); + if now.duration_since(self.last_ack_sent) < self.ack_delay { + return Ok(None); + } + + let payload = self.build_ack_payload(); + if payload.is_empty() { + self.ack_pending = false; + return Ok(None); + } + + let frame = self.build_control_datagram(0, FrameKind::Ack, payload)?; + self.ack_pending = false; + self.last_ack_sent = now; + Ok(Some(frame)) + } + + fn build_ack_payload(&self) -> Bytes { + const MAX_RANGES: usize = 8; + let mut ranges = Vec::new(); + + if self.expected_recv_nonce > 0 { + ranges.push((0_u64, self.expected_recv_nonce - 1)); + } + + let mut current_start: Option = None; + let mut last = 0_u64; + for &nonce in self.reorder_buffer.keys() { + if current_start.is_none() { + current_start = Some(nonce); + last = nonce; + } else if nonce == last + 1 { + last = nonce; + } else { + ranges.push((current_start.unwrap(), last)); + current_start = Some(nonce); + last = nonce; + } + } + if let Some(start) = current_start { + ranges.push((start, last)); + } + + if ranges.is_empty() { + return Bytes::new(); + } + + if ranges.len() > MAX_RANGES { + ranges = ranges[ranges.len() - MAX_RANGES..].to_vec(); + } + + let mut out = Vec::with_capacity(1 + ranges.len() * 16); + out.push(ranges.len() as u8); + for (start, end) in ranges { + out.extend_from_slice(&start.to_be_bytes()); + out.extend_from_slice(&end.to_be_bytes()); + } + Bytes::from(out) + } + + fn lookup_sent_frame(&mut self, nonce: u64) -> Option { + if let Some(frame) = self.sent_history.iter_mut().rev().find(|f| f.nonce == nonce) { + frame.last_sent = Instant::now(); + frame.retries = frame.retries.saturating_add(1); + return Some(frame.bytes.clone()); + } + None + } + + fn push_sent_frame(&mut self, nonce: u64, bytes: Bytes) { + self.sent_history.push_back(SentFrame { + nonce, + bytes, + last_sent: Instant::now(), + retries: 0, + }); + while self.sent_history.len() > self.max_sent_history { + self.sent_history.pop_front(); + } + } + + fn drop_acked_frames(&mut self, ranges: &[(u64, u64)]) { + self.sent_history.retain(|frame| !nonce_in_ranges(frame.nonce, ranges)); + } +} + +fn parse_ack_ranges(payload: &[u8]) -> Result, ProtocolError> { + if payload.is_empty() { + return Ok(Vec::new()); + } + let count = payload[0] as usize; + let expected = 1 + count * 16; + if payload.len() < expected { + return Err(ProtocolError::Framing("ack payload truncated".to_string())); + } + + let mut ranges = Vec::with_capacity(count); + let mut idx = 1; + for _ in 0..count { + let start = u64::from_be_bytes(payload[idx..idx + 8].try_into().unwrap()); + let end = u64::from_be_bytes(payload[idx + 8..idx + 16].try_into().unwrap()); + ranges.push((start, end)); + idx += 16; + } + Ok(ranges) +} + +fn nonce_in_ranges(nonce: u64, ranges: &[(u64, u64)]) -> bool { + ranges.iter().any(|(start, end)| nonce >= *start && nonce <= *end) +} + +fn derive_split_keys(base_key: &[u8; 32], role: NoiseRole) -> ([u8; 32], [u8; 32]) { + let mut initiator_key = [0u8; 32]; + let mut responder_key = [0u8; 32]; + + let mut h1 = Sha256::new(); + h1.update(base_key); + h1.update(b"ostp-initiator"); + initiator_key.copy_from_slice(&h1.finalize()); + + let mut h2 = Sha256::new(); + h2.update(base_key); + h2.update(b"ostp-responder"); + responder_key.copy_from_slice(&h2.finalize()); + + match role { + NoiseRole::Initiator => (initiator_key, responder_key), + NoiseRole::Responder => (responder_key, initiator_key), + } +} diff --git a/ostp-core/src/relay.rs b/ostp-core/src/relay.rs new file mode 100644 index 0000000..1a11462 --- /dev/null +++ b/ostp-core/src/relay.rs @@ -0,0 +1,86 @@ +use anyhow::{anyhow, Result}; + +#[derive(Debug, Clone)] +pub enum RelayMessage { + Connect(String), + Data(Vec), + KeepAlive, + Close, + ConnectOk, + Error(String), + Ping(u64), + Pong(u64), +} + +impl RelayMessage { + pub fn encode(&self) -> Vec { + match self { + RelayMessage::Connect(addr) => encode_with_len(1, addr.as_bytes()), + RelayMessage::Data(data) => encode_with_len(2, data), + RelayMessage::KeepAlive => vec![3], + RelayMessage::Close => vec![4], + RelayMessage::ConnectOk => vec![5], + RelayMessage::Error(msg) => encode_with_len(6, msg.as_bytes()), + RelayMessage::Ping(ts) => encode_with_len(7, &ts.to_be_bytes()), + RelayMessage::Pong(ts) => encode_with_len(8, &ts.to_be_bytes()), + } + } + + pub fn decode(input: &[u8]) -> Result { + if input.is_empty() { + return Err(anyhow!("empty relay message")); + } + + match input[0] { + 1 => { + let payload = decode_with_len(&input[1..])?; + let addr = String::from_utf8(payload.to_vec()) + .map_err(|_| anyhow!("invalid utf8 in connect addr"))?; + Ok(RelayMessage::Connect(addr)) + } + 2 => Ok(RelayMessage::Data(decode_with_len(&input[1..])?.to_vec())), + 3 => Ok(RelayMessage::KeepAlive), + 4 => Ok(RelayMessage::Close), + 5 => Ok(RelayMessage::ConnectOk), + 6 => { + let payload = decode_with_len(&input[1..])?; + let msg = String::from_utf8(payload.to_vec()) + .map_err(|_| anyhow!("invalid utf8 in error message"))?; + Ok(RelayMessage::Error(msg)) + } + 7 => { + let payload = decode_with_len(&input[1..])?; + if payload.len() != 8 { return Err(anyhow!("invalid ping payload len")); } + let ts = u64::from_be_bytes(payload.try_into().unwrap()); + Ok(RelayMessage::Ping(ts)) + } + 8 => { + let payload = decode_with_len(&input[1..])?; + if payload.len() != 8 { return Err(anyhow!("invalid pong payload len")); } + let ts = u64::from_be_bytes(payload.try_into().unwrap()); + Ok(RelayMessage::Pong(ts)) + } + t => Err(anyhow!("unknown relay message type {t}")), + } + } +} + +fn encode_with_len(tag: u8, payload: &[u8]) -> Vec { + let len = payload.len().min(u16::MAX as usize) as u16; + let mut out = Vec::with_capacity(1 + 2 + len as usize); + out.push(tag); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(&payload[..len as usize]); + out +} + +fn decode_with_len(input: &[u8]) -> Result<&[u8]> { + if input.len() < 2 { + return Err(anyhow!("relay payload length prefix missing")); + } + let len = u16::from_be_bytes([input[0], input[1]]) as usize; + if input.len() < 2 + len { + return Err(anyhow!("relay payload truncated")); + } + Ok(&input[2..2 + len]) +} diff --git a/ostp-jni/Cargo.toml b/ostp-jni/Cargo.toml new file mode 100644 index 0000000..890f964 --- /dev/null +++ b/ostp-jni/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "ostp-jni" +version = "0.1.0" +edition = "2021" + +[lib] +name = "ostp_jni" +crate-type = ["cdylib"] + +[dependencies] +jni = "0.21" +tokio = { workspace = true } +anyhow = { workspace = true } +bytes = { workspace = true } +ostp-core = { path = "../ostp-core" } +ostp-client = { path = "../ostp-client" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +lazy_static = "1.4" diff --git a/ostp-jni/OstpClientSdk.kt b/ostp-jni/OstpClientSdk.kt new file mode 100644 index 0000000..6f59dfb --- /dev/null +++ b/ostp-jni/OstpClientSdk.kt @@ -0,0 +1,331 @@ +package net.ostp.client + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.atomic.AtomicBoolean + +/** + * OSTP Android Client SDK — Production-ready Kotlin wrapper for the native Rust OSTP VPN client. + * + * Usage: + * ```kotlin + * val sdk = OstpClientSdk.getInstance(context) + * sdk.state.collect { state -> updateUi(state) } + * sdk.start(OstpClientSdk.Config(server = "1.2.3.4:50000", accessKey = "your-key")) + * ``` + * + * The SDK: + * - Loads the native `ostp_jni` shared library + * - Exposes a reactive [StateFlow] of [TunnelState] + * - Polls metrics and logs from the native layer at 1Hz + * - Auto-reconnects on network changes via [ConnectivityManager] + * - Cleans up gracefully on [stop] + */ +class OstpClientSdk private constructor(private val context: Context) { + + // ── Native JNI bindings ─────────────────────────────────────────────────── + + private external fun nativeStartClient(configJson: String): Boolean + private external fun nativeStopClient(): Boolean + private external fun nativeGetMetrics(): String + private external fun nativeGetLogs(): String + + // ── Public data models ──────────────────────────────────────────────────── + + /** + * Immutable configuration for the OSTP client session. + * + * @param server OSTP server address in "host:port" format. + * @param accessKey Pre-shared access key hex string (generate with `./ostp -g`). + * @param proxyBind Local HTTP/SOCKS5 proxy bind address. Defaults to "127.0.0.1:1088". + * @param mode "proxy" (HTTP+SOCKS5 on [proxyBind]) or "tun" (full VPN, requires root/VpnService). + * @param turnEnabled Whether to route UDP via the Yandex TURN relay. + * @param turnServer TURN server address (e.g. "turn.yandex.net:3478"). + * @param turnUsername TURN credential username. + * @param turnPassword TURN credential password/access key. + * @param handshakeTimeoutMs Milliseconds to wait for server handshake response. Default 8000. + */ + data class Config( + val server: String, + val accessKey: String, + val proxyBind: String = "127.0.0.1:1088", + val mode: String = "proxy", + val turnEnabled: Boolean = false, + val turnServer: String = "", + val turnUsername: String = "", + val turnPassword: String = "", + val handshakeTimeoutMs: Long = 8000L, + ) { + init { + require(server.isNotBlank()) { "server must not be blank" } + require(accessKey.isNotBlank()) { "accessKey must not be blank" } + require(mode == "proxy" || mode == "tun") { "mode must be 'proxy' or 'tun'" } + } + + /** Serialises this config to the JSON format expected by the native layer. */ + fun toNativeJson(): String { + return JSONObject().apply { + put("mode", mode) + put("ostp", JSONObject().apply { + put("server_addr", server) + put("local_bind_addr", "0.0.0.0:0") + put("access_key", accessKey) + put("handshake_timeout_ms", handshakeTimeoutMs) + put("io_timeout_ms", 5000) + }) + put("local_proxy", JSONObject().apply { + put("bind_addr", proxyBind) + put("connect_timeout_ms", 15000) + }) + put("turn", JSONObject().apply { + put("enabled", turnEnabled) + put("server_addr", turnServer) + put("username", turnUsername) + put("access_key", turnPassword) + }) + }.toString() + } + } + + /** Live metrics snapshot from the active tunnel. */ + data class Metrics( + val bytesSent: Long = 0L, + val bytesRecv: Long = 0L, + val rttMs: Double = 0.0, + ) { + val totalBytes: Long get() = bytesSent + bytesRecv + val sentMb: Double get() = bytesSent / 1_000_000.0 + val recvMb: Double get() = bytesRecv / 1_000_000.0 + } + + /** Connection state machine for the tunnel. */ + sealed class TunnelState { + /** No active tunnel, SDK is idle. */ + object Idle : TunnelState() + + /** Handshake in progress, waiting for server response. */ + object Connecting : TunnelState() + + /** Tunnel established and data is flowing. */ + data class Connected(val metrics: Metrics) : TunnelState() + + /** Tunnel dropped — will auto-reconnect unless [stop] was called. */ + data class Reconnecting(val reason: String, val attemptNumber: Int) : TunnelState() + + /** Terminal failure — [stop] was called or max reconnect attempts exceeded. */ + data class Failed(val reason: String) : TunnelState() + } + + // ── State ───────────────────────────────────────────────────────────────── + + private val _state = MutableStateFlow(TunnelState.Idle) + + /** Observe the current tunnel state. Safe to collect from any coroutine. */ + val state: StateFlow = _state.asStateFlow() + + /** Whether the tunnel is currently active (Connected state). */ + val isConnected: Boolean get() = _state.value is TunnelState.Connected + + private val _logs = MutableSharedFlow(extraBufferCapacity = 512) + + /** Observe log messages from the native layer in real-time. */ + val logs: SharedFlow = _logs.asSharedFlow() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val started = AtomicBoolean(false) + private var pollingJob: Job? = null + private var networkCallbackJob: Job? = null + private var currentConfig: Config? = null + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * Start the OSTP VPN tunnel with the given [config]. + * + * This is idempotent: calling [start] while already connected is a no-op. + * To change config, call [stop] first, then [start] with new config. + * + * @return `true` if the native layer accepted the start command. + */ + fun start(config: Config): Boolean { + if (started.getAndSet(true)) { + emitLog("SDK already started; call stop() first to change config") + return false + } + + currentConfig = config + _state.value = TunnelState.Connecting + + val json = config.toNativeJson() + val ok = nativeStartClient(json) + if (!ok) { + _state.value = TunnelState.Failed("Native layer rejected config") + started.set(false) + return false + } + + startPolling() + registerNetworkCallback(config) + emitLog("OSTP SDK started → ${config.server} (mode=${config.mode})") + return true + } + + /** + * Stop the tunnel and release all resources. + * After this call the SDK transitions to [TunnelState.Idle] and can be [start]ed again. + */ + fun stop() { + if (!started.getAndSet(false)) return + + pollingJob?.cancel() + networkCallbackJob?.cancel() + nativeStopClient() + unregisterNetworkCallback() + _state.value = TunnelState.Idle + emitLog("OSTP SDK stopped") + } + + /** + * Read and drain all log lines produced by the native layer since the last call. + * Prefer collecting [logs] SharedFlow for reactive usage. + */ + fun drainLogs(): List { + return try { + val array = JSONArray(nativeGetLogs()) + (0 until array.length()).map { array.getString(it) } + } catch (_: Exception) { + emptyList() + } + } + + /** Read the latest [Metrics] snapshot. Returns zeroed metrics if tunnel is idle. */ + fun getMetrics(): Metrics { + return parseMetrics(nativeGetMetrics()) + } + + // ── Internal helpers ────────────────────────────────────────────────────── + + private fun startPolling() { + pollingJob = scope.launch { + var wasConnected = false + while (isActive) { + delay(1000L) + + // Drain and relay logs + drainLogs().forEach { line -> + emitLog(line) + // Detect state transitions from log content + when { + line.contains("Bridge connection established") || + line.contains("TUN Tunnel established") -> { + wasConnected = true + } + line.contains("Bridge stopped") || + line.contains("Tunnel stopped") || + line.contains("Handshake failed") -> { + wasConnected = false + } + } + } + + // Update state based on metrics availability + val metrics = parseMetrics(nativeGetMetrics()) + if (wasConnected) { + _state.value = TunnelState.Connected(metrics) + } + } + } + } + + private fun registerNetworkCallback(config: Config) { + networkCallbackJob = scope.launch { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + // Network came back (e.g. switched from WiFi to LTE) + // If we're not connected, trigger a reconnect by bouncing the native client + if (_state.value !is TunnelState.Connected && started.get()) { + emitLog("Network available — triggering reconnect") + scope.launch { + nativeStopClient() + delay(500L) + val json = config.toNativeJson() + val ok = nativeStartClient(json) + if (!ok) { + _state.value = TunnelState.Failed("Reconnect failed after network change") + } else { + _state.value = TunnelState.Connecting + } + } + } + } + + override fun onLost(network: Network) { + if (_state.value is TunnelState.Connected) { + _state.value = TunnelState.Reconnecting("Network lost", 0) + emitLog("Network lost — waiting for reconnect") + } + } + } + + try { + cm.registerNetworkCallback(request, callback) + awaitCancellation() + } finally { + runCatching { cm.unregisterNetworkCallback(callback) } + } + } + } + + private fun unregisterNetworkCallback() { + networkCallbackJob?.cancel() + } + + private fun parseMetrics(json: String): Metrics { + return try { + val obj = JSONObject(json) + Metrics( + bytesSent = obj.optLong("bytes_sent", 0L), + bytesRecv = obj.optLong("bytes_recv", 0L), + ) + } catch (_: Exception) { + Metrics() + } + } + + private fun emitLog(msg: String) { + scope.launch { _logs.tryEmit(msg) } + } + + // ── Singleton ───────────────────────────────────────────────────────────── + + companion object { + init { + System.loadLibrary("ostp_jni") + } + + @Volatile + private var instance: OstpClientSdk? = null + + /** + * Get the singleton SDK instance. + * Must be called with an Application context to avoid memory leaks. + */ + fun getInstance(context: Context): OstpClientSdk { + return instance ?: synchronized(this) { + instance ?: OstpClientSdk(context.applicationContext).also { instance = it } + } + } + } +} diff --git a/ostp-jni/src/lib.rs b/ostp-jni/src/lib.rs new file mode 100644 index 0000000..9d07e15 --- /dev/null +++ b/ostp-jni/src/lib.rs @@ -0,0 +1,204 @@ +use jni::objects::{JClass, JString}; +use jni::sys::{jboolean, jstring}; +use jni::JNIEnv; +use lazy_static::lazy_static; +use std::collections::VecDeque; +use std::sync::{atomic::Ordering, Arc, Mutex}; +use tokio::runtime::Runtime; +use tokio::sync::{mpsc, watch}; +use ostp_client::bridge::{Bridge, BridgeMetrics}; +use ostp_client::config::ClientConfig; +use ostp_client::tunnel; +use ostp_client::app::{BridgeCommand, UiEvent}; + +struct SdkState { + runtime: Option, + shutdown_tx: Option>, + metrics: Option>, +} + +lazy_static! { + static ref STATE: Mutex = Mutex::new(SdkState { + runtime: None, + shutdown_tx: None, + metrics: None, + }); + static ref LOGS: Mutex> = Mutex::new(VecDeque::new()); +} + +fn add_log(text: String) { + if let Ok(mut guard) = LOGS.lock() { + if guard.len() >= 1000 { + guard.pop_front(); + } + guard.push_back(text); + } +} + +#[no_mangle] +pub extern "system" fn Java_net_ostp_client_OstpClientSdk_startClient( + mut env: JNIEnv, + _class: JClass, + config_json: JString, +) -> jboolean { + let mut state = match STATE.lock() { + Ok(s) => s, + Err(_) => return jni::sys::JNI_FALSE, + }; + + if state.runtime.is_some() { + add_log("Client is already running!".to_string()); + return jni::sys::JNI_TRUE; + } + + let config_str: String = match env.get_string(&config_json) { + Ok(s) => s.into(), + Err(_) => return jni::sys::JNI_FALSE, + }; + + // Parse config from JSON + let config: ClientConfig = match serde_json::from_str(&config_str) { + Ok(cfg) => cfg, + Err(e) => { + add_log(format!("Failed to parse config JSON: {e}")); + return jni::sys::JNI_FALSE; + } + }; + + // Create tokio runtime + let rt = match Runtime::new() { + Ok(r) => r, + Err(e) => { + add_log(format!("Failed to create Tokio runtime: {e}")); + return jni::sys::JNI_FALSE; + } + }; + + let (proxy_events_tx, proxy_events_rx) = mpsc::channel(512); + let (client_msgs_tx, client_msgs_rx) = mpsc::channel(512); + + let metrics = Arc::new(BridgeMetrics { + bytes_sent: std::sync::atomic::AtomicU64::new(0), + bytes_recv: std::sync::atomic::AtomicU64::new(0), + }); + + let bridge = match Bridge::new(&config, Arc::clone(&metrics)) { + Ok(b) => b, + Err(e) => { + add_log(format!("Failed to initialize Bridge: {e}")); + return jni::sys::JNI_FALSE; + } + }; + + let (ui_tx, mut ui_rx) = mpsc::channel(512); + let (cmd_tx, cmd_rx) = mpsc::channel(128); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let proxy_shutdown_rx = shutdown_tx.subscribe(); + + let metrics_clone = Arc::clone(&metrics); + + // Spawn async tasks inside runtime + rt.spawn(async move { + bridge.run(ui_tx, cmd_rx, shutdown_rx, proxy_events_rx, client_msgs_tx).await + }); + + let config_proxy = config.clone(); + rt.spawn(async move { + tunnel::run_local_proxy( + config_proxy.local_proxy, + config_proxy.ostp, + config_proxy.exclusions, + config_proxy.debug, + proxy_shutdown_rx, + proxy_events_tx, + client_msgs_rx, + ) + .await + }); + + // Start logs receiver task + rt.spawn(async move { + while let Some(msg) = ui_rx.recv().await { + match msg { + UiEvent::Log(text) => add_log(text), + UiEvent::ProfileChanged(p) => add_log(format!("Profile changed: {p:?}")), + UiEvent::TunnelStopped => add_log("Tunnel stopped".to_string()), + _ => {} + } + } + }); + + // Toggle tunnel to initiate handshake + let cmd_tx_clone = cmd_tx.clone(); + rt.spawn(async move { + let _ = cmd_tx_clone.send(BridgeCommand::ToggleTunnel).await; + }); + + state.runtime = Some(rt); + state.shutdown_tx = Some(shutdown_tx); + state.metrics = Some(metrics_clone); + + add_log("OSTP SDK: Client successfully started".to_string()); + jni::sys::JNI_TRUE +} + +#[no_mangle] +pub extern "system" fn Java_net_ostp_client_OstpClientSdk_stopClient( + _env: JNIEnv, + _class: JClass, +) -> jboolean { + let mut state = match STATE.lock() { + Ok(s) => s, + Err(_) => return jni::sys::JNI_FALSE, + }; + + if let Some(shutdown_tx) = state.shutdown_tx.take() { + let _ = shutdown_tx.send(true); + } + + if let Some(rt) = state.runtime.take() { + rt.shutdown_background(); + } + + state.metrics = None; + add_log("OSTP SDK: Client successfully stopped".to_string()); + jni::sys::JNI_TRUE +} + +#[no_mangle] +pub extern "system" fn Java_net_ostp_client_OstpClientSdk_getMetrics( + env: JNIEnv, + _class: JClass, +) -> jstring { + let state = match STATE.lock() { + Ok(s) => s, + Err(_) => return env.new_string("{}").unwrap().into_raw(), + }; + + if let Some(m) = &state.metrics { + let sent = m.bytes_sent.load(Ordering::Relaxed); + let recv = m.bytes_recv.load(Ordering::Relaxed); + let json = format!(r#"{{"bytes_sent": {}, "bytes_recv": {}}}"#, sent, recv); + env.new_string(json).unwrap().into_raw() + } else { + env.new_string(r#"{"bytes_sent": 0, "bytes_recv": 0}"#).unwrap().into_raw() + } +} + +#[no_mangle] +pub extern "system" fn Java_net_ostp_client_OstpClientSdk_getLogs( + env: JNIEnv, + _class: JClass, +) -> jstring { + let logs_vec: Vec = match LOGS.lock() { + Ok(mut guard) => guard.drain(..).collect(), + Err(_) => Vec::new(), + }; + + let json = match serde_json::to_string(&logs_vec) { + Ok(s) => s, + Err(_) => "[]".to_string(), + }; + + env.new_string(json).unwrap().into_raw() +} diff --git a/ostp-server/Cargo.toml b/ostp-server/Cargo.toml new file mode 100644 index 0000000..f5f1ffe --- /dev/null +++ b/ostp-server/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ostp-server" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +bytes.workspace = true +tokio.workspace = true +tracing.workspace = true +ostp-core = { path = "../ostp-core" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +rand.workspace = true diff --git a/ostp-server/src/dispatcher.rs b/ostp-server/src/dispatcher.rs new file mode 100644 index 0000000..17d4e3b --- /dev/null +++ b/ostp-server/src/dispatcher.rs @@ -0,0 +1,297 @@ +use anyhow::Result; +use bytes::Bytes; +use ostp_core::{OstpEvent, ProtocolAction, ProtocolConfig, ProtocolMachine}; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, RwLock}; + +pub enum DispatchOutcome { + Unauthorized, + Accepted { + responses: Vec, + app_payloads: Vec<(u32, u16, Bytes)>, // session_id, stream_id, payload + peer_addr: SocketAddr, + }, +} + +pub struct PeerState { + pub machine: ProtocolMachine, + pub last_addr: SocketAddr, + pub obfuscation_key: [u8; 8], + pub last_seen: std::time::Instant, +} + +pub struct Dispatcher { + peer_machines: HashMap, + addr_to_session: HashMap, + machine_config: ProtocolConfig, + access_keys: Arc>>, + replay_cache: std::collections::HashMap, u64>, + roaming_tokens: f64, + last_token_regen: std::time::Instant, +} + +impl Dispatcher { + pub fn new(machine_config: ProtocolConfig, access_keys: Arc>>) -> Self { + Self { + peer_machines: HashMap::new(), + addr_to_session: HashMap::new(), + machine_config, + access_keys, + replay_cache: std::collections::HashMap::new(), + roaming_tokens: 50.0, + last_token_regen: std::time::Instant::now(), + } + } + + pub fn on_datagram(&mut self, peer: SocketAddr, packet: Bytes) -> Result { + if packet.len() < 4 { + return Ok(DispatchOutcome::Unauthorized); + } + + let mut session_id_opt = None; + + if let Some(&sid) = self.addr_to_session.get(&peer) { + if let Some(peer_state) = self.peer_machines.get(&sid) { + let mut header = [0u8; 12]; + if packet.len() >= 12 { + header.copy_from_slice(&packet[0..12]); + ostp_core::crypto::deobfuscate_packet_inplace(&mut header, &peer_state.obfuscation_key, false); + let candidate_sid = u32::from_be_bytes([header[0], header[1], header[2], header[3]]); + if candidate_sid == sid { + session_id_opt = Some(sid); + } + } + } + } + + if session_id_opt.is_none() { + // Token Bucket rate limiter: mitigate seamless roaming CPU DoS vector + let now = std::time::Instant::now(); + let elapsed = now.duration_since(self.last_token_regen).as_secs_f64(); + self.last_token_regen = now; + self.roaming_tokens = (self.roaming_tokens + elapsed * 50.0).min(50.0); + + if self.roaming_tokens >= 1.0 { + self.roaming_tokens -= 1.0; + + // Try seamless roaming over all peers + for (&sid, peer_state) in &self.peer_machines { + if packet.len() >= 12 { + let mut header = [0u8; 12]; + header.copy_from_slice(&packet[0..12]); + ostp_core::crypto::deobfuscate_packet_inplace(&mut header, &peer_state.obfuscation_key, false); + let candidate_sid = u32::from_be_bytes([header[0], header[1], header[2], header[3]]); + if candidate_sid == sid { + session_id_opt = Some(sid); + break; + } + } + } + } + } + + if let Some(session_id) = session_id_opt { + if let Some(peer_state) = self.peer_machines.get_mut(&session_id) { + peer_state.last_addr = peer; + peer_state.last_seen = std::time::Instant::now(); + self.addr_to_session.insert(peer, session_id); + + let action = match peer_state.machine.on_event(OstpEvent::Inbound(packet)) { + Ok(a) => a, + Err(_) => return Ok(DispatchOutcome::Unauthorized), + }; + + let mut responses = Vec::new(); + let mut app_payloads = Vec::new(); + + fn collect_action( + act: ProtocolAction, + sid: u32, + resps: &mut Vec, + loads: &mut Vec<(u32, u16, Bytes)>, + ) { + match act { + ProtocolAction::SendDatagram(frame) => { + resps.push(frame); + } + ProtocolAction::DeliverApp(stream_id, data) => { + loads.push((sid, stream_id, data)); + } + ProtocolAction::Multiple(list) => { + for item in list { + collect_action(item, sid, resps, loads); + } + } + _ => {} + } + } + + collect_action(action, session_id, &mut responses, &mut app_payloads); + + return Ok(DispatchOutcome::Accepted { + responses, + app_payloads, + peer_addr: peer, + }); + } + } + + // Not an existing session — try each registered access key's derived obfuscation key + let keys_snapshot: Vec = self.access_keys.read().unwrap().keys().cloned().collect(); + + for candidate_key in keys_snapshot { + let obf_key = ostp_core::crypto::derive_obfuscation_key(candidate_key.as_bytes()); + let psk = ostp_core::crypto::derive_psk(candidate_key.as_bytes()); + + // Decode the session_id using this key's obfuscation + let mut header = [0u8; 4]; + header.copy_from_slice(&packet[0..4]); + ostp_core::crypto::deobfuscate_packet_inplace(&mut header, &obf_key, true); + let candidate_session_id = u32::from_be_bytes(header); + + let mut cfg = self.machine_config.clone(); + cfg.session_id = candidate_session_id; + cfg.psk = psk; + cfg.handshake_payload = vec![]; + cfg.obfuscation_key = obf_key; + + let mut machine = match ProtocolMachine::new(cfg) { + Ok(m) => m, + Err(_) => continue, + }; + let action = match machine.on_event(OstpEvent::Inbound(packet.clone())) { + Ok(a) => a, + Err(_) => continue, + }; + + if let ProtocolAction::HandshakePayload(payload, response_opt) = action { + if payload.len() >= 12 { + let mut ts_bytes = [0_u8; 8]; + ts_bytes.copy_from_slice(&payload[..8]); + let ts = u64::from_be_bytes(ts_bytes); + + let mut sid_bytes = [0_u8; 4]; + sid_bytes.copy_from_slice(&payload[8..12]); + let sid_from_payload = u32::from_be_bytes(sid_bytes); + + if sid_from_payload != candidate_session_id { + continue; + } + + let key_bytes = &payload[12..]; + if let Ok(key_from_payload) = std::str::from_utf8(key_bytes) { + // The key embedded in the payload must match the candidate key we decoded with + if key_from_payload != candidate_key { + continue; + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let drift = (now as i64 - ts as i64).abs(); + if drift > 300 { + // Narrow window (5 mins) limits replay risk and bounds cache memory + continue; + } + + if !self.replay_cache.contains_key(&payload.to_vec()) { + self.replay_cache.insert(payload.to_vec(), ts); + + machine.set_session_keys(candidate_session_id, obf_key); + + self.peer_machines.insert(candidate_session_id, PeerState { + machine, + last_addr: peer, + obfuscation_key: obf_key, + last_seen: std::time::Instant::now(), + }); + self.addr_to_session.insert(peer, candidate_session_id); + + return Ok(DispatchOutcome::Accepted { + responses: response_opt.into_iter().collect(), + app_payloads: Vec::new(), + peer_addr: peer, + }); + } + } + } + } + } + + Ok(DispatchOutcome::Unauthorized) + } + + pub fn outbound_to_session(&mut self, session_id: u32, stream_id: u16, payload: Bytes) -> Result> { + let peer_state = if let Some(existing) = self.peer_machines.get_mut(&session_id) { + existing + } else { + return Ok(None); + }; + + let addr = peer_state.last_addr; + match peer_state.machine.on_event(OstpEvent::Outbound(stream_id, payload))? { + ProtocolAction::SendDatagram(frame) => Ok(Some((frame, addr))), + _ => Ok(None), + } + } + + pub fn on_tick(&mut self) -> (Vec<(Bytes, SocketAddr)>, Vec) { + // Purge expired handshakes from replay cache (older than 5 min drift allowance) + let current_sys_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + self.replay_cache.retain(|_, &mut ts| (current_sys_time as i64 - ts as i64).abs() <= 300); + + let mut frames = Vec::new(); + let mut expired = Vec::new(); + let now = std::time::Instant::now(); + let timeout_dur = std::time::Duration::from_secs(300); // 5 minutes session timeout + + // Gather expired sessions + for (&sid, peer_state) in &self.peer_machines { + if now.duration_since(peer_state.last_seen) > timeout_dur { + expired.push(sid); + } + } + + // Clear expired sessions from internal state + for sid in &expired { + self.drop_session(*sid); + } + + // Drive ticks for remaining active sessions + for peer_state in self.peer_machines.values_mut() { + let action = match peer_state.machine.on_event(OstpEvent::Tick) { + Ok(a) => a, + Err(_) => continue, + }; + + let mut queue = vec![action]; + while let Some(current) = queue.pop() { + match current { + ProtocolAction::Multiple(list) => { + for item in list { + queue.push(item); + } + } + ProtocolAction::SendDatagram(frame) => { + frames.push((frame, peer_state.last_addr)); + } + _ => {} + } + } + } + + (frames, expired) + } + + pub fn drop_session(&mut self, session_id: u32) { + if let Some(state) = self.peer_machines.remove(&session_id) { + self.addr_to_session.remove(&state.last_addr); + } + } +} diff --git a/ostp-server/src/lib.rs b/ostp-server/src/lib.rs new file mode 100644 index 0000000..0ced075 --- /dev/null +++ b/ostp-server/src/lib.rs @@ -0,0 +1,670 @@ +mod dispatcher; +mod signal; + +use anyhow::Result; +use bytes::Bytes; +use std::collections::HashMap; +use std::net::IpAddr; + +use dispatcher::{DispatchOutcome, Dispatcher}; +use ostp_core::relay::RelayMessage; +use ostp_core::{NoiseRole, PaddingStrategy, ProtocolConfig}; +use signal::wait_for_shutdown_signal; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{tcp::OwnedWriteHalf, TcpStream, UdpSocket}; +use tokio::sync::mpsc; +use tokio::time::{interval, Duration, Instant}; + +#[derive(Debug, Clone)] +#[allow(dead_code)] +enum UiCommand { + CreateClientKey, + Shutdown, +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +enum UiEvent { + #[allow(dead_code)] + PeerSeen { peer: IpAddr }, + #[allow(dead_code)] Rx { peer: IpAddr, bytes: usize }, + #[allow(dead_code)] Tx { peer: IpAddr, bytes: usize }, + UnauthorizedProbe { peer: IpAddr, bytes: usize }, + KeyCreated { key: String }, + Log(String), + #[allow(dead_code)] + KeyCount(usize), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutboundAction { + Proxy, + Direct, +} + +#[derive(Debug, Clone)] +pub struct OutboundRule { + pub domain_suffix: Vec, + pub ip_cidr: Vec, + pub action: OutboundAction, +} + +#[derive(Debug, Clone)] +pub struct OutboundConfig { + pub enabled: bool, + pub protocol: String, + pub address: String, + pub port: u16, + pub rules: Vec, + pub default_action: OutboundAction, +} + +pub async fn run_server( + bind_addr: String, + access_keys: Vec, + outbound: Option, + debug: bool, +) -> Result<()> { + let mut keys_map = HashMap::new(); + for key in access_keys { + keys_map.insert(key, ()); + } + let shared_keys = std::sync::Arc::new(std::sync::RwLock::new(keys_map)); + + // Background config hot-reloader for access keys + let shared_keys_clone = shared_keys.clone(); + tokio::spawn(async move { + let mut last_mtime = None; + let exe = match std::env::current_exe() { + Ok(e) => e, + Err(_) => return, + }; + let dir = match exe.parent() { + Some(d) => d, + None => return, + }; + let config_path = dir.join("config.json"); + + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + if let Ok(metadata) = std::fs::metadata(&config_path) { + if let Ok(mtime) = metadata.modified() { + if last_mtime != Some(mtime) { + last_mtime = Some(mtime); + if let Ok(content) = std::fs::read_to_string(&config_path) { + #[derive(serde::Deserialize)] + struct ServerReloadConfig { + mode: String, + #[serde(default)] + access_keys: Vec, + } + if let Ok(cfg) = serde_json::from_str::(&content) { + if cfg.mode == "server" { + let mut new_keys = HashMap::new(); + for key in cfg.access_keys { + new_keys.insert(key, ()); + } + let mut keys_lock = shared_keys_clone.write().unwrap(); + *keys_lock = new_keys; + println!("[ostp-server] Hot-reloaded {} access keys from config.json", keys_lock.len()); + } + } + } + } + } + } + } + }); + + let socket = UdpSocket::bind(&bind_addr).await?; + let protocol_config = ProtocolConfig { + role: NoiseRole::Responder, + psk: [0u8; 32], + session_id: 0, + handshake_payload: vec![], + max_padding: 256, + padding_strategy: PaddingStrategy::Adaptive, + obfuscation_key: [0u8; 8], + max_reorder: 262144, + max_reorder_buffer: 8192, + ack_delay_ms: 20, + rto_ms: 200, + max_retries: 8, + max_sent_history: 16384, + }; + + let dispatcher = Dispatcher::new(protocol_config, shared_keys.clone()); + + let (_ui_cmd_tx, ui_cmd_rx) = mpsc::unbounded_channel::(); + let (ui_event_tx, mut ui_event_rx) = mpsc::unbounded_channel::(); + + let max_datagram_size = 65535; + + // Headless event logger + tokio::spawn(async move { + while let Some(ev) = ui_event_rx.recv().await { + match ev { + UiEvent::Log(msg) => { + if debug || msg.starts_with("Peer ") || msg.starts_with("Listening on ") { + println!("[ostp-server] {msg}"); + } + } + UiEvent::KeyCreated { key } => println!("[ostp-server] New access key created: {key}"), + UiEvent::UnauthorizedProbe { peer, bytes } => { + if debug { + println!("[ostp-server] WARNING: unauthorized probe from {peer} ({bytes} bytes)"); + } + } + UiEvent::PeerSeen { .. } => {} + _ => {} + } + } + }); + + println!("[ostp-server] Listening on {bind_addr}"); + tokio::select! { + res = run_server_loop(socket, dispatcher, max_datagram_size, ui_cmd_rx, ui_event_tx, shared_keys, outbound, debug) => { + if let Err(e) = res { + eprintln!("[ostp-server] error: {e}"); + } + } + _ = wait_for_shutdown_signal() => { + println!("[ostp-server] shutdown signal received"); + } + } + + Ok(()) +} + +struct RemoteState { + writer: OwnedWriteHalf, + cancel_tx: mpsc::Sender<()>, +} + +async fn run_server_loop( + socket: UdpSocket, + mut dispatcher: Dispatcher, + _max_datagram_size: usize, + mut ui_cmd_rx: mpsc::UnboundedReceiver, + ui_event_tx: mpsc::UnboundedSender, + shared_keys: std::sync::Arc>>, + outbound: Option, + debug: bool, +) -> Result<()> { + let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new(); + let (stream_tx, mut stream_rx) = mpsc::channel::<(u32, u16, Vec)>(10000); + + let socket = std::sync::Arc::new(socket); + let (udp_tx, mut udp_rx) = mpsc::channel(10000); + let socket_clone = socket.clone(); + tokio::spawn(async move { + let mut buf = vec![0_u8; 65535]; + loop { + match socket_clone.recv_from(&mut buf).await { + Ok((size, peer)) => { + let packet = Bytes::copy_from_slice(&buf[..size]); + if udp_tx.send((packet, peer)).await.is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + if debug { + let _ = ui_event_tx.send(UiEvent::Log("Server loop started".to_string())); + let _ = ui_event_tx.send(UiEvent::KeyCount(shared_keys.read().unwrap().len())); + } + + let mut retransmit_tick = interval(Duration::from_millis(50)); + let mut last_empty_app_log = Instant::now() - Duration::from_secs(10); + let mut peer_last_seen: HashMap = HashMap::new(); + let mut peer_available: HashMap = HashMap::new(); + let peer_timeout = Duration::from_secs(15); + + loop { + tokio::select! { + cmd = ui_cmd_rx.recv() => { + match cmd { + Some(UiCommand::CreateClientKey) => { + let key = format!("ostp_key_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()); + shared_keys.write().unwrap().insert(key.clone(), ()); + let _ = ui_event_tx.send(UiEvent::KeyCreated { key }); + } + Some(UiCommand::Shutdown) | None => { + let _ = ui_event_tx.send(UiEvent::Log("Shutdown command received".to_string())); + break; + } + } + } + received = udp_rx.recv() => { + if let Some((packet, peer)) = received { + let size = packet.len(); + match dispatcher.on_datagram(peer, packet) { + Ok(DispatchOutcome::Unauthorized) => { + let _ = ui_event_tx.send(UiEvent::UnauthorizedProbe { peer: peer.ip(), bytes: size }); + } + Ok(DispatchOutcome::Accepted { responses, app_payloads, peer_addr }) => { + let peer_ip = peer_addr.ip(); + let now = Instant::now(); + peer_last_seen.insert(peer_ip, now); + if !peer_available.get(&peer_ip).copied().unwrap_or(false) { + peer_available.insert(peer_ip, true); + let _ = ui_event_tx.send(UiEvent::Log(format!("Peer {peer_ip} available"))); + } + + if app_payloads.is_empty() && now.duration_since(last_empty_app_log) > Duration::from_secs(5) { + last_empty_app_log = now; + let _ = ui_event_tx.send(UiEvent::Log(format!( + "Accepted datagrams from {peer_ip} with no app payloads (responses={})", + responses.len() + ))); + } + let _ = ui_event_tx.send(UiEvent::Rx { peer: peer_ip, bytes: size }); + + for resp in responses { + let resp_len = resp.len(); + let _ = socket.send_to(&resp, peer_addr).await?; + let _ = ui_event_tx.send(UiEvent::Tx { peer: peer_ip, bytes: resp_len }); + } + + for (session_id, stream_id, payload) in app_payloads { + let _ = ui_event_tx.send(UiEvent::Log(format!( + "Deliver app payload sid={session_id} stream={stream_id} bytes={}", + payload.len() + ))); + handle_relay_message( + peer_addr, + session_id, + stream_id, + payload, + &mut dispatcher, + &socket, + &mut remotes, + &ui_event_tx, + stream_tx.clone(), + outbound.clone(), + debug, + ).await?; + } + } + Err(err) => { + let _ = ui_event_tx.send(UiEvent::Log(format!("Protocol error for {peer}: {err}"))); + } + } + } + } + Some((session_id, stream_id, data)) = stream_rx.recv() => { + if data.is_empty() { + let _ = 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)) { + let _ = state.cancel_tx.try_send(()); + } + } else { + let _ = send_relay_to_stream(session_id, stream_id, RelayMessage::Data(data), &mut dispatcher, &socket, &ui_event_tx).await; + } + } + _ = retransmit_tick.tick() => { + let now = Instant::now(); + for (peer_ip, last_seen) in peer_last_seen.iter() { + let is_available = peer_available.get(peer_ip).copied().unwrap_or(false); + if is_available && now.duration_since(*last_seen) > peer_timeout { + peer_available.insert(*peer_ip, false); + let _ = ui_event_tx.send(UiEvent::Log(format!("Peer {peer_ip} unavailable"))); + } + } + let (frames, dropped_sessions) = dispatcher.on_tick(); + for (frame, peer_addr) in frames { + let _ = socket.send_to(&frame, peer_addr).await?; + } + for sid in dropped_sessions { + let _ = ui_event_tx.send(UiEvent::Log(format!("Cleaning up resources for expired session {sid}"))); + let mut streams_to_cancel = Vec::new(); + for (&(session_id, stream_id), _) in &remotes { + if session_id == sid { + streams_to_cancel.push((session_id, stream_id)); + } + } + for key in streams_to_cancel { + if let Some(state) = remotes.remove(&key) { + let _ = state.cancel_tx.try_send(()); + } + } + } + } + } + } + + 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, + stream_tx: mpsc::Sender<(u32, u16, Vec)>, + outbound: Option, + 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 stream = connect_target(&target, outbound.as_ref(), debug).await; + match stream { + Ok(stream) => { + let (mut reader, writer) = stream.into_split(); + let (cancel_tx, mut cancel_rx) = mpsc::channel::<()>(1); + let tx_clone = stream_tx.clone(); + tokio::spawn(async move { + let mut buf = [0_u8; 1024]; + loop { + tokio::select! { + _ = cancel_rx.recv() => { + break; + } + read_res = reader.read(&mut buf) => { + match read_res { + Ok(0) => { + let _ = tx_clone.send((session_id, stream_id, Vec::new())).await; + break; + } + Ok(n) => { + if tx_clone.send((session_id, stream_id, buf[..n].to_vec())).await.is_err() { + break; + } + } + Err(_) => { + let _ = tx_clone.send((session_id, stream_id, Vec::new())).await; + break; + } + } + } + } + } + }); + remotes.insert((session_id, stream_id), RemoteState { writer, cancel_tx }); + send_relay_to_stream(session_id, stream_id, RelayMessage::ConnectOk, dispatcher, socket, ui_event_tx).await?; + let _ = ui_event_tx.send(UiEvent::Log(format!("Relay CONNECT ok for [{session_id}:{stream_id}] -> {target}"))); + } + Err(err) => { + let _ = ui_event_tx.send(UiEvent::Log(format!("Relay CONNECT failed for [{session_id}:{stream_id}] -> {target}: {err}"))); + send_relay_to_stream( + session_id, + stream_id, + RelayMessage::Error(format!("connect failed: {err}")), + dispatcher, + socket, + ui_event_tx, + ) + .await?; + } + } + } + RelayMessage::Data(data) => { + if let Some(remote) = remotes.get_mut(&(session_id, stream_id)) { + let _ = remote.writer.write_all(&data).await; + } 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(()); + } + } + 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, +) -> 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 { + 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, + _ => TcpStream::connect(target).await.map_err(Into::into), + }; + } + } + } + + TcpStream::connect(target).await.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 { + println!("[ostp-server] outbound decision 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 = cidrs.iter().filter_map(|c| parse_cidr(c)).collect(); + if parsed.is_empty() { + return false; + } + if let Ok(ip) = host.parse::() { + 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 { + 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::() { + 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 { + 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 { + 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::() { + 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::() { + 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 +} diff --git a/ostp-server/src/signal.rs b/ostp-server/src/signal.rs new file mode 100644 index 0000000..a9b2c14 --- /dev/null +++ b/ostp-server/src/signal.rs @@ -0,0 +1,22 @@ +use anyhow::Result; + +#[cfg(unix)] +pub async fn wait_for_shutdown_signal() -> Result<()> { + use tokio::signal::unix::{signal, SignalKind}; + + let mut sigterm = signal(SignalKind::terminate())?; + let mut sigint = signal(SignalKind::interrupt())?; + + tokio::select! { + _ = sigterm.recv() => {} + _ = sigint.recv() => {} + } + + Ok(()) +} + +#[cfg(not(unix))] +pub async fn wait_for_shutdown_signal() -> Result<()> { + tokio::signal::ctrl_c().await?; + Ok(()) +} diff --git a/ostp-server/src/tui.rs b/ostp-server/src/tui.rs new file mode 100644 index 0000000..6dc1a5f --- /dev/null +++ b/ostp-server/src/tui.rs @@ -0,0 +1,29 @@ +use std::net::IpAddr; +use tokio::sync::mpsc; + +#[derive(Debug, Clone)] +pub enum UiCommand { + CreateClientKey, + Shutdown, +} + +#[derive(Debug, Clone)] +pub enum UiEvent { + PeerSeen { peer: IpAddr }, + Rx { peer: IpAddr, bytes: usize }, + Tx { peer: IpAddr, bytes: usize }, + UnauthorizedProbe { peer: IpAddr, bytes: usize }, + KeyCreated { key: String }, + Log(String), + KeyCount(usize), +} + +/// No-op placeholder — TUI removed. Server always runs in headless mode. +pub async fn run_server_tui( + _ui_event_rx: mpsc::UnboundedReceiver, + _ui_cmd_tx: mpsc::UnboundedSender, + _initial_key_count: usize, + _peer_idle_timeout: std::time::Duration, +) -> anyhow::Result<()> { + Ok(()) +} diff --git a/ostp/.gitignore b/ostp/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/ostp/.gitignore @@ -0,0 +1 @@ +/target diff --git a/ostp/Cargo.toml b/ostp/Cargo.toml new file mode 100644 index 0000000..2f04562 --- /dev/null +++ b/ostp/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ostp" +edition.workspace = true +license.workspace = true +version.workspace = true + +[dependencies] +ostp-client = { path = "../ostp-client" } +ostp-server = { path = "../ostp-server" } +tokio = { version = "1.37", features = ["rt-multi-thread", "macros"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +anyhow = "1.0" +clap = { version = "4.4", features = ["derive"] } +base64 = "0.22" +rand.workspace = true diff --git a/ostp/src/main.rs b/ostp/src/main.rs new file mode 100644 index 0000000..e93014d --- /dev/null +++ b/ostp/src/main.rs @@ -0,0 +1,273 @@ +use anyhow::{anyhow, Result}; +use clap::Parser; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(author, version, about = "OSTP Core - Ospab Stealth Transport Protocol", long_about = None)] +struct Args { + /// Path to the JSON configuration file + #[arg(short, long, default_value = "config.json")] + config: PathBuf, + + /// Optional mode to initialize the config for (client or server) + #[arg(short, long)] + init: Option, + + /// Generate a new secure access key and exit + #[arg(short = 'g', long)] + generate_key: bool, + + /// Format for generated key (hex, base64) + #[arg(long, default_value = "hex")] + format: String, + + /// Number of keys to generate + #[arg(short = 'c', long, default_value_t = 1)] + count: usize, +} + +fn generate_secure_key(format_type: &str) -> String { + use rand::RngCore; + let mut key = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut key); + match format_type.to_lowercase().as_str() { + "base64" => { + use base64::Engine; + base64::engine::general_purpose::STANDARD_NO_PAD.encode(key) + } + _ => key.iter().map(|b| format!("{:02x}", b)).collect(), + } +} + +fn parse_outbound_action(value: Option) -> ostp_server::OutboundAction { + match value.as_deref() { + Some("direct") => ostp_server::OutboundAction::Direct, + _ => ostp_server::OutboundAction::Proxy, + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "mode", rename_all = "lowercase")] +enum AppMode { + Server(ServerConfig), + Client(ClientConfig), +} + +#[derive(Debug, Deserialize, Serialize)] +struct UnifiedConfig { + #[serde(flatten)] + mode: AppMode, + log_level: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ServerConfig { + listen: String, + access_keys: Vec, + turn_server: Option, + debug: Option, + outbound: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ClientConfig { + server: String, + access_key: String, + socks5_bind: Option, + tun: Option, + turn: Option, + debug: Option, + exclude: Option, + mux: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct TunConfig { + enable: bool, + wintun_path: Option, + ipv4_address: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct TurnConfigRaw { + enabled: bool, + server_addr: String, + username: Option, + access_key: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct OutboundConfig { + enabled: bool, + protocol: String, + address: String, + port: u16, + #[serde(default)] + rules: Vec, + default_action: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct OutboundRule { + domain_suffix: Option>, + ip_cidr: Option>, + action: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ExcludeConfig { + domains: Option>, + ips: Option>, + processes: Option>, +} + +#[derive(Debug, Deserialize, Serialize)] +struct MuxConfig { + enabled: Option, + sessions: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + + if args.generate_key { + for _ in 0..args.count { + println!("{}", generate_secure_key(&args.format)); + } + return Ok(()); + } + + // Auto-generate a production config if explicitly requested via --init, or if not existing + if args.init.is_some() || !args.config.exists() { + let is_server = args.init.as_deref() == Some("server"); + let dummy = if is_server { + UnifiedConfig { + log_level: Some("info".to_string()), + mode: AppMode::Server(ServerConfig { + listen: "0.0.0.0:50000".to_string(), + access_keys: vec![generate_secure_key("hex")], + turn_server: None, + debug: Some(false), + outbound: None, + }), + } + } else { + UnifiedConfig { + log_level: Some("info".to_string()), + mode: AppMode::Client(ClientConfig { + server: "127.0.0.1:50000".to_string(), + access_key: generate_secure_key("hex"), + socks5_bind: Some("127.0.0.1:1088".to_string()), + tun: Some(TunConfig { + enable: false, + wintun_path: Some("./wintun.dll".to_string()), + ipv4_address: Some("10.1.0.2/24".to_string()), + }), + turn: None, + debug: Some(false), + exclude: Some(ExcludeConfig { + domains: Some(Vec::new()), + ips: Some(Vec::new()), + processes: Some(Vec::new()), + }), + mux: Some(MuxConfig { + enabled: Some(false), + sessions: Some(1), + }), + }), + } + }; + fs::write(&args.config, serde_json::to_string_pretty(&dummy)?)?; + println!("Initialized configuration at {:?}", args.config); + + // If init was requested directly, terminate now. + if args.init.is_some() { + return Ok(()); + } + } + + let config_content = fs::read_to_string(&args.config)?; + let config: UnifiedConfig = serde_json::from_str(&config_content) + .map_err(|e| anyhow!("Failed to parse config: {}", e))?; + + match config.mode { + AppMode::Server(server_cfg) => { + println!("[OSTP Core] Starting in SERVER mode on {}", server_cfg.listen); + if let Some(turn) = server_cfg.turn_server { + println!("[OSTP Core] TURN integration enabled: {}", turn); + } + // Temporarily pass control to the isolated server implementation + let debug = server_cfg.debug.unwrap_or(false); + let outbound = server_cfg.outbound.map(|o| ostp_server::OutboundConfig { + enabled: o.enabled, + protocol: o.protocol, + address: o.address, + port: o.port, + rules: o + .rules + .into_iter() + .map(|r| ostp_server::OutboundRule { + domain_suffix: r.domain_suffix.unwrap_or_default(), + ip_cidr: r.ip_cidr.unwrap_or_default(), + action: parse_outbound_action(r.action), + }) + .collect(), + default_action: parse_outbound_action(o.default_action), + }); + ostp_server::run_server(server_cfg.listen, server_cfg.access_keys, outbound, debug).await?; + } + AppMode::Client(client_cfg) => { + println!("[OSTP Core] Starting in CLIENT mode connecting to {}", client_cfg.server); + if let Some(ref tun) = client_cfg.tun { + if tun.enable { + println!("[OSTP Core] TUN mode enabled."); + if let Some(ref path) = tun.wintun_path { + println!("[OSTP Core] Using custom wintun path: {}", path); + // Wiring of custom wintun path to Wintun logic happens here + } + } + } + println!("[OSTP Core] Client logic loaded."); + let is_tun_enabled = client_cfg.tun.as_ref().map(|t| t.enable).unwrap_or(false); + let turn_cfg = client_cfg.turn.as_ref(); + let client_conf = ostp_client::config::ClientConfig { + mode: if is_tun_enabled { "tun".to_string() } else { "proxy".to_string() }, + debug: client_cfg.debug.unwrap_or(false), + ostp: ostp_client::config::OstpConfig { + server_addr: client_cfg.server.clone(), + local_bind_addr: "0.0.0.0:0".to_string(), + access_key: client_cfg.access_key.clone(), + handshake_timeout_ms: 5000, + io_timeout_ms: 5000, + }, + local_proxy: ostp_client::config::LocalProxyConfig { + bind_addr: client_cfg.socks5_bind.clone().unwrap_or_else(|| "127.0.0.1:1088".to_string()), + connect_timeout_ms: 5000, + }, + turn: ostp_client::config::TurnConfig { + enabled: turn_cfg.map(|t| t.enabled).unwrap_or(false), + server_addr: turn_cfg.and_then(|t| Some(t.server_addr.clone())).unwrap_or_default(), + username: turn_cfg.and_then(|t| t.username.clone()).unwrap_or_default(), + access_key: turn_cfg.and_then(|t| t.access_key.clone()).unwrap_or_default(), + }, + exclusions: ostp_client::config::ExclusionConfig { + domains: client_cfg.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(), + ips: client_cfg.exclude.as_ref().and_then(|e| e.ips.clone()).unwrap_or_default(), + processes: client_cfg.exclude.as_ref().and_then(|e| e.processes.clone()).unwrap_or_default(), + }, + multiplex: ostp_client::config::MultiplexConfig { + enabled: client_cfg.mux.as_ref().and_then(|m| m.enabled).unwrap_or(false), + sessions: client_cfg.mux.as_ref().and_then(|m| m.sessions).unwrap_or(1), + }, + }; + // Run the client implementation + ostp_client::runner::run_client(client_conf).await?; + } + + } + + Ok(()) +} diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..be4f604 --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,78 @@ +# OSTP Hybrid Build Script (Windows Native + WSL Linux) + +Write-Output "Starting OSTP Build Pipeline" + +# Stop any currently running instances to release file locks on compiled binaries +Stop-Process -Name ostp -ErrorAction SilentlyContinue | Out-Null + +$DistDir = Join-Path $PSScriptRoot "dist" +$WinDist = Join-Path $DistDir "windows" +$LinuxDist = Join-Path $DistDir "linux" + +New-Item -ItemType Directory -Force -Path $WinDist | Out-Null +New-Item -ItemType Directory -Force -Path $LinuxDist | Out-Null + +Write-Output "Building Windows Binary natively" +$TempTarget = Join-Path $env:TEMP "ostp_target_build" +$env:CARGO_TARGET_DIR = $TempTarget + +& cargo build --release --bin ostp + +if ($LASTEXITCODE -ne 0) { + Write-Output "❌ Windows build failed" + exit 1 +} + +$WinExe = Join-Path $TempTarget "release\ostp.exe" +if (Test-Path $WinExe) { + Copy-Item -Path $WinExe -Destination $WinDist -Force + Write-Output "✔ Windows binary successfully copied to: dist/windows/ostp.exe" +} else { + Write-Output "❌ Windows binary not found after build" + exit 1 +} + +# Reset target directory env +Remove-Item Env:\CARGO_TARGET_DIR -ErrorAction SilentlyContinue | Out-Null + +Write-Output "Building Linux binary via WSL" +if (Get-Command wsl -ErrorAction SilentlyContinue) { + & wsl rustup target add x86_64-unknown-linux-musl + & wsl env CC_x86_64_unknown_linux_musl=gcc CARGO_TARGET_DIR=/tmp/ostp_linux_build cargo build --release --target x86_64-unknown-linux-musl --bin ostp + + if ($LASTEXITCODE -ne 0) { + Write-Output "❌ Linux build failed" + exit 1 + } + + # Copy from WSL native temp directory back to host + & wsl cp /tmp/ostp_linux_build/x86_64-unknown-linux-musl/release/ostp ./dist/linux/ostp + + $LinuxBin = Join-Path $LinuxDist "ostp" + if (Test-Path $LinuxBin) { + Write-Output "✔ Linux binary successfully copied to dist/linux/ostp" + } else { + Write-Output "❌ Linux binary copy failed" + exit 1 + } +} else { + Write-Output "⚠ WSL not available, skipping Linux server build" +} + +Write-Output "Build Completed Successfully" + +# Automated metadata version increment +$CargoToml = Join-Path $PSScriptRoot "Cargo.toml" +if (Test-Path $CargoToml) { + $Content = [System.IO.File]::ReadAllText($CargoToml) + if ($Content -match 'version\s*=\s*"(\d+)\.(\d+)\.(\d+)"') { + $Major = [int]$Matches[1] + $Minor = [int]$Matches[2] + $Patch = [int]$Matches[3] + $NewPatch = $Patch + 1 + $NewVersionStr = 'version = "{0}.{1}.{2}"' -f $Major, $Minor, $NewPatch + $NewContent = $Content -replace 'version\s*=\s*"\d+\.\d+\.\d+"', $NewVersionStr + [System.IO.File]::WriteAllText($CargoToml, $NewContent) + Write-Output "✔ Successfully bumped workspace version to $Major.$Minor.$NewPatch" + } +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..4123c13 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,137 @@ +#!/bin/bash +set -e + +# Официальный репозиторий +GITHUB_REPO="ospab/ostp" +INSTALL_DIR="/opt/ostp" + +echo "========================================================" +echo " Установка Ospab Stealth Transport Protocol (OSTP)" +echo "========================================================" + +# Проверка прав суперпользователя +if [ "$EUID" -ne 0 ]; then + echo "[Ошибка] Данный скрипт должен быть запущен с правами root (sudo)." + exit 1 +fi + +# Создание директории +mkdir -p "$INSTALL_DIR" + +# Скачивание исполняемого файла (выполняется первым, так как binary нужен для генерации) +echo "Получение актуальной стабильной версии из репозитория..." +LATEST_RELEASE=$(curl -s "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + +if [ -z "$LATEST_RELEASE" ] || [[ "$LATEST_RELEASE" == *"null"* ]]; then + echo "[Уведомление] Не удалось автоматически получить тег репозитория ${GITHUB_REPO}." + echo "Введите прямую ссылку (URL) на скомпилированный бинарный файл linux-musl" + echo "или нажмите Enter, если файл уже находится в $INSTALL_DIR/ostp." + read -p "URL: " DIRECT_URL + if [ -n "$DIRECT_URL" ]; then + curl -L "$DIRECT_URL" -o "$INSTALL_DIR/ostp" + fi +else + DOWNLOAD_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_RELEASE}/ostp" + echo "Скачивание бинарного файла: $DOWNLOAD_URL ..." + curl -L "$DOWNLOAD_URL" -o "$INSTALL_DIR/ostp" +fi + +if [ -f "$INSTALL_DIR/ostp" ]; then + chmod +x "$INSTALL_DIR/ostp" + echo "Исполняемый файл настроен в $INSTALL_DIR/ostp." +else + echo "[Ошибка] Бинарный файл не обнаружен в $INSTALL_DIR/ostp. Прекращение настройки." + exit 1 +fi + +# Интерактивный выбор режима +echo "--------------------------------------------------------" +echo "Выберите режим конфигурации:" +echo "1) Настройка Сервера" +echo "2) Настройка Клиента" +echo "--------------------------------------------------------" +read -p "Введите номер [1-2]: " NODE_MODE + +cd "$INSTALL_DIR" + +if [ "$NODE_MODE" == "1" ]; then + echo "Инициализация конфигурации сервера..." + # Используем внутренний инструмент --init для создания шаблона + ./ostp --init server --config config.json + + read -p "Укажите IP и порт для приема входящего трафика [по умолчанию 0.0.0.0:50000]: " LISTEN_ADDR + if [ -n "$LISTEN_ADDR" ]; then + sed -i "s/\"listen\": \"0.0.0.0:50000\"/\"listen\": \"$LISTEN_ADDR\"/g" config.json + fi + + read -p "Сколько ключей авторизации сгенерировать? [по умолчанию 1]: " KEYS_COUNT + KEYS_COUNT=${KEYS_COUNT:-1} + + if [ "$KEYS_COUNT" -gt 1 ]; then + echo "Генерация дополнительных ключей безопасности..." + NEW_KEYS=$(./ostp -g -c "$KEYS_COUNT" | sed 's/^/ "/;s/$/",/' | sed '$ s/,$//') + # Заменяем весь блок access_keys в JSON + sed -i '/"access_keys": \[/,/\]/c\ "access_keys": [\n'"$NEW_KEYS"'\n ],' config.json + echo "Сгенерировано и записано $KEYS_COUNT ключей." + fi + echo "Настройка сервера завершена. Файл: $INSTALL_DIR/config.json" + +elif [ "$NODE_MODE" == "2" ]; then + echo "Инициализация конфигурации клиента..." + ./ostp --init client --config config.json + + read -p "Введите адрес внешнего сервера (IP:PORT): " REMOTE_SERVER + if [ -n "$REMOTE_SERVER" ]; then + sed -i "s/\"server\": \"127.0.0.1:50000\"/\"server\": \"$REMOTE_SERVER\"/g" config.json + else + echo "[Предупреждение] Адрес не указан, оставлено значение по умолчанию (127.0.0.1:50000)." + fi + + read -p "Введите ключ авторизации (оставьте пустым для генерации нового через ostp -g): " ACCESS_KEY + if [ -z "$ACCESS_KEY" ]; then + ACCESS_KEY=$(./ostp -g) + echo "Автоматически сгенерирован ключ клиента: $ACCESS_KEY" + fi + # Заменяем значение ключа в JSON + sed -i "s/\"access_key\": \"[^\"]*\"/\"access_key\": \"$ACCESS_KEY\"/g" config.json + + read -p "Укажите локальный SOCKS5 адрес прослушивания [по умолчанию 127.0.0.1:1088]: " SOCKS_BIND + if [ -n "$SOCKS_BIND" ]; then + sed -i "s/\"socks5_bind\": \"127.0.0.1:1088\"/\"socks5_bind\": \"$SOCKS_BIND\"/g" config.json + fi + echo "Настройка клиента завершена. Файл: $INSTALL_DIR/config.json" + +else + echo "[Ошибка] Указан неверный вариант выбора." + exit 1 +fi + +# Регистрация Systemd службы +echo "Настройка системного сервиса..." +cat < /etc/systemd/system/ostp.service +[Unit] +Description=Ospab Stealth Transport Protocol Service +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=$INSTALL_DIR +ExecStart=$INSTALL_DIR/ostp --config $INSTALL_DIR/config.json +Restart=always +RestartSec=5 +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable ostp.service >/dev/null 2>&1 + +echo "--------------------------------------------------------" +echo "Установка успешно завершена." +echo "Конфигурация сохранена в $INSTALL_DIR/config.json" +echo "Сервис ostp зарегистрирован, но не запущен." +echo "Запустите сервис вручную: systemctl start ostp" +echo "--------------------------------------------------------"