Initial public release: Ospab Stealth Transport Protocol v0.1.0

This commit is contained in:
ospab 2026-05-14 21:41:41 +03:00
commit 1ebf01cc65
61 changed files with 8551 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@ -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

1223
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

27
Cargo.toml Normal file
View File

@ -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"

74
LICENSE Normal file
View File

@ -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.

129
README.md Normal file
View File

@ -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).

129
README.ru.md Normal file
View File

@ -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).

70
docs/en/architecture.md Normal file
View File

@ -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.

81
docs/en/client.md Normal file
View File

@ -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`).

93
docs/en/ieee_spec.md Normal file
View File

@ -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.

13
docs/en/integrations.md Normal file
View File

@ -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.

51
docs/en/obfuscation.md Normal file
View File

@ -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.

205
docs/en/rfc_ostp.txt Normal file
View File

@ -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.

41
docs/en/server.md Normal file
View File

@ -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.

70
docs/ru/architecture.md Normal file
View File

@ -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-соединения внутри туннеля не прерываются.

81
docs/ru/client.md Normal file
View File

@ -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`).

13
docs/ru/integrations.md Normal file
View File

@ -0,0 +1,13 @@
# Нативные интеграции
## Кроссплатформенная разработка
Ядро протокола OSTP разработано так, чтобы быть полностью независимым от платформы и единообразно работать в различных операционных системах. Для взаимодействия с сетевыми стеками конкретных хостов созданы интеграционные слои, оборачивающие базовую асинхронную среду выполнения.
## Мобильный SDK
Для обеспечения работы на мобильных платформах кодовая база включает специализированный слой интеграции через Native Development Kit (NDK).
- **Экспорт через C-ABI**: Базовые функции протокола экспортируются через строго типизированный бинарный интерфейс приложений C (C-ABI). Это обеспечивает совместимость со стандартными механизмами вызова внешних функций, необходимыми для высокоуровневых языков, таких как Java, Kotlin или Dart.
- **Изолированные среды выполнения**: Нативный модуль инициализирует и управляет собственной многопоточной асинхронной средой выполнения внутри памяти процесса хоста. Такое архитектурное решение предотвращает влияние тяжелых операций сетевого ввода-вывода на главный поток пользовательского интерфейса мобильного приложения.
- **Мосты телеметрии**: Между средами устанавливаются безопасные для памяти каналы связи, позволяющие хост-приложению эффективно опрашивать телеметрию соединения и извлекать эксплуатационные журналы без риска ошибок конкурентности или утечек памяти.
## Системные интерфейсы
В десктопных средах специализированные модули управляют взаимодействием с подсистемой маршрутизации операционной системы. В зависимости от режима работы, слой интеграции безопасно управляет системными реестрами маршрутизации или связывается напрямую с адаптерами виртуальных сетевых драйверов, обеспечивая прозрачное и бесшовное перенаправление трафика.

51
docs/ru/obfuscation.md Normal file
View File

@ -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`), интегрируемый в структуру пакета до момента активации шифрующего каскада:
- **Стратегия заполнения буферов**: Механизм анализирует текущую длину выборки телеметрии и производит масштабирование до типичных кратных длин промышленных сетей передачи данных и буферов потоковых агрегаторов.
- **Изоляция выравнивания**: Данные заполнения помещаются внутрь защищенной области кадра. Внешние анализаторы топологии сети не способны определить внутренние границы между телеметрической нагрузкой и служебными полями выравнивания, видя только монолитный блок данных.

41
docs/ru/server.md Normal file
View File

@ -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.

18
ostp-client/Cargo.toml Normal file
View File

@ -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"

110
ostp-client/src/app.rs Normal file
View File

@ -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<u64>,
pub outgoing_history: Vec<u64>,
pub logs: VecDeque<String>,
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<u64>, value: u64) {
if !history.is_empty() {
history.remove(0);
}
history.push(value);
}

977
ostp-client/src/bridge.rs Normal file
View File

@ -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<UdpSocket>,
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<BridgeMetrics>,
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<BridgeMetrics>) -> Result<Self> {
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<UiEvent>,
mut bridge_rx: mpsc::Receiver<BridgeCommand>,
mut shutdown: watch::Receiver<bool>,
mut proxy_rx: mpsc::Receiver<ProxyEvent>,
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<Vec<SessionState>> = None;
let mut udp_rx_opt: Option<mpsc::Receiver<(usize, Bytes)>> = None;
let mut _proxy_guard: Option<crate::sysproxy::WindowsProxyGuard> = 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<UiEvent>) {
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<UiEvent>,
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(&timestamp.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<String> {
use std::net::ToSocketAddrs;
let turn_sock: std::net::SocketAddr = turn_addr
.to_socket_addrs()
.map_err(|e| anyhow::anyhow!("TURN DNS resolution failed: {e}"))?
.next()
.ok_or_else(|| anyhow::anyhow!("TURN addr resolved to nothing"))?;
let transaction_id = {
use rand::Rng;
let mut id = [0u8; 12];
rand::thread_rng().fill(&mut id);
id
};
// Helper: build a minimal STUN/TURN message
fn build_stun_msg(msg_type: u16, tx_id: &[u8; 12], attrs: &[u8]) -> Vec<u8> {
let mut msg = Vec::with_capacity(20 + attrs.len());
msg.extend_from_slice(&msg_type.to_be_bytes());
msg.extend_from_slice(&(attrs.len() as u16).to_be_bytes());
msg.extend_from_slice(&0x2112A442_u32.to_be_bytes()); // Magic Cookie
msg.extend_from_slice(tx_id);
msg.extend_from_slice(attrs);
msg
}
// Helper: encode a STUN attribute (type, length-padded value)
fn stun_attr(attr_type: u16, value: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&attr_type.to_be_bytes());
out.extend_from_slice(&(value.len() as u16).to_be_bytes());
out.extend_from_slice(value);
// Pad to 4-byte boundary
let pad = (4 - (value.len() % 4)) % 4;
out.extend(std::iter::repeat(0u8).take(pad));
out
}
// ── Step 1: unauthenticated Allocate ─────────────────────────────
// REQUESTED-TRANSPORT attr: 0x0019, value = 17 (UDP) + 3 reserved bytes
let req_transport = stun_attr(0x0019, &[17u8, 0, 0, 0]);
let alloc_req = build_stun_msg(0x0003, &transaction_id, &req_transport);
socket.send_to(&alloc_req, turn_sock).await
.map_err(|e| anyhow::anyhow!("TURN send Allocate failed: {e}"))?;
let mut buf = [0u8; 2048];
let (n, _) = timeout(Duration::from_millis(3000), socket.recv_from(&mut buf))
.await
.map_err(|_| anyhow::anyhow!("TURN Allocate response timed out"))?
.map_err(|e| anyhow::anyhow!("TURN recv failed: {e}"))?;
let resp = &buf[..n];
if resp.len() < 20 {
anyhow::bail!("TURN response too short");
}
let msg_type = u16::from_be_bytes([resp[0], resp[1]]);
// 0x0113 = Allocate Error Response
if msg_type != 0x0113 {
anyhow::bail!("Expected TURN 401 error response, got type 0x{:04x}", msg_type);
}
// Parse realm and nonce from the error response attributes
let mut realm: Option<String> = None;
let mut nonce: Option<String> = None;
{
let mut idx = 20usize;
while idx + 4 <= n {
let atype = u16::from_be_bytes([resp[idx], resp[idx + 1]]);
let alen = u16::from_be_bytes([resp[idx + 2], resp[idx + 3]]) as usize;
idx += 4;
if idx + alen > n { break; }
let val = &resp[idx..idx + alen];
match atype {
0x0014 => realm = Some(String::from_utf8_lossy(val).to_string()), // REALM
0x0015 => nonce = Some(String::from_utf8_lossy(val).to_string()), // NONCE
_ => {}
}
idx += alen;
let pad = (4 - (alen % 4)) % 4;
idx += pad;
}
}
let realm = realm.ok_or_else(|| anyhow::anyhow!("TURN 401: no REALM in response"))?;
let nonce = nonce.ok_or_else(|| anyhow::anyhow!("TURN 401: no NONCE in response"))?;
// ── Step 2: Compute long-term credential key per RFC 5389 §15.4 ──
// key = MD5(username ":" realm ":" password)
let key_input = format!("{}:{}:{}", username, realm, password);
let key = md5_hash(key_input.as_bytes());
// HMAC-SHA1 of the message (MESSAGE-INTEGRITY attribute, RFC 5389 §15.4)
// We build the message without the integrity attr, compute HMAC, then append.
let mut attrs2 = Vec::new();
attrs2.extend_from_slice(&stun_attr(0x0006, username.as_bytes())); // USERNAME
attrs2.extend_from_slice(&stun_attr(0x0014, realm.as_bytes())); // REALM
attrs2.extend_from_slice(&stun_attr(0x0015, nonce.as_bytes())); // NONCE
attrs2.extend_from_slice(&req_transport); // REQUESTED-TRANSPORT
// For MESSAGE-INTEGRITY we need the full message length including the MI attr (24 bytes)
let mi_placeholder_len = attrs2.len() + 4 + 20; // +4 header, +20 HMAC-SHA1
let mut msg_for_hmac = build_stun_msg(0x0003, &transaction_id, &attrs2);
// Set length field to include the upcoming MI attr
let new_len = (mi_placeholder_len - 20) as u16; // total attrs length including MI
msg_for_hmac[2..4].copy_from_slice(&new_len.to_be_bytes());
// Append MI header (without value)
msg_for_hmac.extend_from_slice(&0x0008_u16.to_be_bytes()); // attr type
msg_for_hmac.extend_from_slice(&20_u16.to_be_bytes()); // attr len
let hmac = hmac_sha1(&key, &msg_for_hmac);
let mut final_attrs = attrs2.clone();
final_attrs.extend_from_slice(&stun_attr(0x0008, &hmac)); // MESSAGE-INTEGRITY
let alloc_req2 = build_stun_msg(0x0003, &transaction_id, &final_attrs);
socket.send_to(&alloc_req2, turn_sock).await
.map_err(|e| anyhow::anyhow!("TURN authenticated Allocate send failed: {e}"))?;
let (n2, _) = timeout(Duration::from_millis(5000), socket.recv_from(&mut buf))
.await
.map_err(|_| anyhow::anyhow!("TURN authenticated Allocate timed out"))?
.map_err(|e| anyhow::anyhow!("TURN recv2 failed: {e}"))?;
let resp2 = &buf[..n2];
if resp2.len() < 20 {
anyhow::bail!("TURN auth response too short");
}
let msg_type2 = u16::from_be_bytes([resp2[0], resp2[1]]);
// 0x0103 = Allocate Success Response
if msg_type2 != 0x0103 {
anyhow::bail!("TURN Allocate auth failed, response type 0x{:04x}", msg_type2);
}
// ── Step 3: Parse XOR-RELAYED-ADDRESS ────────────────────────────
let relay_addr_str = {
let mut relayed: Option<String> = None;
let mut idx = 20usize;
while idx + 4 <= n2 {
let atype = u16::from_be_bytes([resp2[idx], resp2[idx + 1]]);
let alen = u16::from_be_bytes([resp2[idx + 2], resp2[idx + 3]]) as usize;
idx += 4;
if idx + alen > n2 { break; }
let val = &resp2[idx..idx + alen];
if atype == 0x0016 && alen >= 8 { // XOR-RELAYED-ADDRESS
let x_port = u16::from_be_bytes([val[2], val[3]]) ^ 0x2112;
let x_ip = [val[4], val[5], val[6], val[7]];
let ip = std::net::Ipv4Addr::new(
x_ip[0] ^ 0x21, x_ip[1] ^ 0x12, x_ip[2] ^ 0xA4, x_ip[3] ^ 0x42,
);
relayed = Some(format!("{}:{}", ip, x_port));
}
idx += alen;
let pad = (4 - (alen % 4)) % 4;
idx += pad;
}
relayed.ok_or_else(|| anyhow::anyhow!("TURN: no XOR-RELAYED-ADDRESS in response"))?
};
// ── Step 4: ChannelBind to the OSTP server ────────────────────────
// ChannelBind binds channel 0x4000 to the peer (OSTP server).
// After this, all UDP data we send as ChannelData (4 bytes header + payload)
// will be forwarded by the TURN server to the OSTP server transparently.
let ostp_sock: std::net::SocketAddr = ostp_server_addr
.to_socket_addrs()
.map_err(|e| anyhow::anyhow!("OSTP server DNS resolution failed: {e}"))?
.next()
.ok_or_else(|| anyhow::anyhow!("OSTP server addr resolved to nothing"))?;
let channel_number: u16 = 0x4000;
let mut peer_addr_attr = Vec::new();
peer_addr_attr.push(0u8); // reserved
peer_addr_attr.push(0x01u8); // family IPv4
peer_addr_attr.extend_from_slice(&(ostp_sock.port() ^ 0x2112).to_be_bytes()); // XOR port
if let std::net::IpAddr::V4(ipv4) = ostp_sock.ip() {
let octets = ipv4.octets();
peer_addr_attr.push(octets[0] ^ 0x21);
peer_addr_attr.push(octets[1] ^ 0x12);
peer_addr_attr.push(octets[2] ^ 0xA4);
peer_addr_attr.push(octets[3] ^ 0x42);
} else {
anyhow::bail!("TURN ChannelBind: IPv6 OSTP server not yet supported");
}
let mut cb_attrs = Vec::new();
// CHANNEL-NUMBER attr: 0x000C
cb_attrs.extend_from_slice(&stun_attr(0x000C, &[
(channel_number >> 8) as u8, channel_number as u8, 0, 0
]));
// XOR-PEER-ADDRESS attr: 0x0012
cb_attrs.extend_from_slice(&stun_attr(0x0012, &peer_addr_attr));
cb_attrs.extend_from_slice(&stun_attr(0x0006, username.as_bytes()));
cb_attrs.extend_from_slice(&stun_attr(0x0014, realm.as_bytes()));
cb_attrs.extend_from_slice(&stun_attr(0x0015, nonce.as_bytes()));
// Compute MESSAGE-INTEGRITY for ChannelBind too
let mi_len2 = cb_attrs.len() + 4 + 20;
let mut cb_for_hmac = build_stun_msg(0x0009, &transaction_id, &cb_attrs);
cb_for_hmac[2..4].copy_from_slice(&((mi_len2 - 20) as u16).to_be_bytes());
cb_for_hmac.extend_from_slice(&0x0008_u16.to_be_bytes());
cb_for_hmac.extend_from_slice(&20_u16.to_be_bytes());
let cb_hmac = hmac_sha1(&key, &cb_for_hmac);
cb_attrs.extend_from_slice(&stun_attr(0x0008, &cb_hmac));
let cb_req = build_stun_msg(0x0009, &transaction_id, &cb_attrs);
socket.send_to(&cb_req, turn_sock).await
.map_err(|e| anyhow::anyhow!("TURN ChannelBind send failed: {e}"))?;
let (n3, _) = timeout(Duration::from_millis(3000), socket.recv_from(&mut buf))
.await
.map_err(|_| anyhow::anyhow!("TURN ChannelBind response timed out"))?
.map_err(|e| anyhow::anyhow!("TURN ChannelBind recv failed: {e}"))?;
let resp3 = &buf[..n3];
if resp3.len() < 4 {
anyhow::bail!("TURN ChannelBind response too short");
}
let cb_resp_type = u16::from_be_bytes([resp3[0], resp3[1]]);
// 0x0109 = ChannelBind Success Response
if cb_resp_type != 0x0109 {
anyhow::bail!("TURN ChannelBind failed, response type 0x{:04x}", cb_resp_type);
}
Ok(relay_addr_str)
}
/// Pure-Rust MD5 hash (16 bytes). Used for TURN long-term credential key derivation.
fn md5_hash(input: &[u8]) -> [u8; 16] {
// RFC 1321 MD5 constants
const S: [u32; 64] = [
7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22,
5, 9,14,20, 5, 9,14,20, 5, 9,14,20, 5, 9,14,20,
4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23,
6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21,
];
const K: [u32; 64] = [
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a,
0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340,
0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8,
0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa,
0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92,
0xffeff47d, 0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
];
let msg_len = input.len();
let bit_len = (msg_len as u64) * 8;
let mut padded = input.to_vec();
padded.push(0x80);
while padded.len() % 64 != 56 {
padded.push(0);
}
padded.extend_from_slice(&bit_len.to_le_bytes());
let mut a0: u32 = 0x67452301;
let mut b0: u32 = 0xefcdab89;
let mut c0: u32 = 0x98badcfe;
let mut d0: u32 = 0x10325476;
for chunk in padded.chunks(64) {
let mut m = [0u32; 16];
for (i, item) in m.iter_mut().enumerate() {
*item = u32::from_le_bytes([chunk[i*4], chunk[i*4+1], chunk[i*4+2], chunk[i*4+3]]);
}
let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
for i in 0..64usize {
let (f, g) = match i {
0..=15 => ((b & c) | (!b & d), i),
16..=31 => ((d & b) | (!d & c), (5*i + 1) % 16),
32..=47 => (b ^ c ^ d, (3*i + 5) % 16),
_ => (c ^ (b | !d), (7*i) % 16),
};
let temp = d;
d = c;
c = b;
b = b.wrapping_add((a.wrapping_add(f).wrapping_add(K[i]).wrapping_add(m[g])).rotate_left(S[i]));
a = temp;
}
a0 = a0.wrapping_add(a);
b0 = b0.wrapping_add(b);
c0 = c0.wrapping_add(c);
d0 = d0.wrapping_add(d);
}
let mut result = [0u8; 16];
result[0..4].copy_from_slice(&a0.to_le_bytes());
result[4..8].copy_from_slice(&b0.to_le_bytes());
result[8..12].copy_from_slice(&c0.to_le_bytes());
result[12..16].copy_from_slice(&d0.to_le_bytes());
result
}
/// HMAC-SHA1 for TURN MESSAGE-INTEGRITY (RFC 2104 + RFC 5389 §15.4).
fn hmac_sha1(key: &[u8], message: &[u8]) -> [u8; 20] {
const BLOCK_SIZE: usize = 64;
let mut k = [0u8; BLOCK_SIZE];
if key.len() > BLOCK_SIZE {
let h = sha1_hash(key);
k[..20].copy_from_slice(&h);
} else {
k[..key.len()].copy_from_slice(key);
}
let mut ipad = [0u8; BLOCK_SIZE];
let mut opad = [0u8; BLOCK_SIZE];
for i in 0..BLOCK_SIZE {
ipad[i] = k[i] ^ 0x36;
opad[i] = k[i] ^ 0x5C;
}
let mut inner = ipad.to_vec();
inner.extend_from_slice(message);
let inner_hash = sha1_hash(&inner);
let mut outer = opad.to_vec();
outer.extend_from_slice(&inner_hash);
sha1_hash(&outer)
}
/// Pure-Rust SHA-1 (RFC 3174).
fn sha1_hash(input: &[u8]) -> [u8; 20] {
let msg_len = input.len();
let bit_len = (msg_len as u64) * 8;
let mut padded = input.to_vec();
padded.push(0x80);
while padded.len() % 64 != 56 {
padded.push(0);
}
padded.extend_from_slice(&bit_len.to_be_bytes());
let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
for chunk in padded.chunks(64) {
let mut w = [0u32; 80];
for i in 0..16 {
w[i] = u32::from_be_bytes([chunk[i*4], chunk[i*4+1], chunk[i*4+2], chunk[i*4+3]]);
}
for i in 16..80 {
w[i] = (w[i-3] ^ w[i-8] ^ w[i-14] ^ w[i-16]).rotate_left(1);
}
let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]);
for i in 0..80usize {
let (f, k) = match i {
0..=19 => ((b & c) | (!b & d), 0x5A827999u32),
20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
_ => (b ^ c ^ d, 0xCA62C1D6),
};
let temp = a.rotate_left(5).wrapping_add(f).wrapping_add(e).wrapping_add(k).wrapping_add(w[i]);
e = d; d = c; c = b.rotate_left(30); b = a; a = temp;
}
h[0] = h[0].wrapping_add(a); h[1] = h[1].wrapping_add(b);
h[2] = h[2].wrapping_add(c); h[3] = h[3].wrapping_add(d);
h[4] = h[4].wrapping_add(e);
}
let mut out = [0u8; 20];
for (i, &v) in h.iter().enumerate() {
out[i*4..(i+1)*4].copy_from_slice(&v.to_be_bytes());
}
out
}

203
ostp-client/src/config.rs Normal file
View File

@ -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<String>,
#[serde(default)]
pub ips: Vec<String>,
#[serde(default)]
pub processes: Vec<String>,
}
#[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<bool>,
server: Option<String>,
access_key: Option<String>,
socks5_bind: Option<String>,
tun: Option<RawTunSection>,
exclude: Option<RawExcludeSection>,
mux: Option<RawMuxSection>,
}
#[derive(Debug, Deserialize)]
struct RawTunSection {
enable: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct RawExcludeSection {
domains: Option<Vec<String>>,
ips: Option<Vec<String>>,
processes: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct RawMuxSection {
enabled: Option<bool>,
sessions: Option<usize>,
}
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<Self> {
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),
},
})
}
}

7
ostp-client/src/lib.rs Normal file
View File

@ -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;

202
ostp-client/src/runner.rs Normal file
View File

@ -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")
}

22
ostp-client/src/signal.rs Normal file
View File

@ -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(())
}

113
ostp-client/src/sysproxy.rs Normal file
View File

@ -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();
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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<ratatui::text::Line<'_>> = 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);
}
}

View File

@ -0,0 +1,4 @@
pub mod controls;
pub mod dashboard;
pub mod logs;
pub mod traffic;

View File

@ -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]);
}
}

318
ostp-client/src/tui/mod.rs Normal file
View File

@ -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<UiEvent>,
cmd_tx: mpsc::Sender<BridgeCommand>,
) -> Result<TuiExit> {
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<CrosstermBackend<io::Stdout>>,
mut ui_rx: mpsc::Receiver<UiEvent>,
cmd_tx: mpsc::Sender<BridgeCommand>,
) -> Result<TuiExit> {
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]
}

View File

@ -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<bool>,
proxy_events_tx: mpsc::Sender<ProxyEvent>,
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
}

View File

@ -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<bool>,
proxy_events_tx: mpsc::Sender<ProxyEvent>,
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<u16, mpsc::Sender<ProxyToClientMsg>> = 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<ProxyEvent>,
mut rx: mpsc::Receiver<ProxyToClientMsg>,
close_tx: mpsc::Sender<u16>,
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<String>,
cidrs: Vec<Cidr>,
}
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::<std::net::IpAddr>() {
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<Cidr> {
let value = value.trim();
if value.is_empty() {
return None;
}
if let Some((addr_str, bits_str)) = value.split_once('/') {
let bits: u8 = bits_str.parse().ok()?;
if let Ok(addr) = addr_str.parse::<std::net::IpAddr>() {
return match addr {
std::net::IpAddr::V4(v4) => Some(Cidr::V4(u32::from_be_bytes(v4.octets()), bits.min(32))),
std::net::IpAddr::V6(v6) => Some(Cidr::V6(u128::from_be_bytes(v6.octets()), bits.min(128))),
};
}
}
if let Ok(addr) = value.parse::<std::net::IpAddr>() {
return match addr {
std::net::IpAddr::V4(v4) => Some(Cidr::V4(u32::from_be_bytes(v4.octets()), 32)),
std::net::IpAddr::V6(v6) => Some(Cidr::V6(u128::from_be_bytes(v6.octets()), 128)),
};
}
None
}
fn split_host_port(target: &str) -> Option<(String, u16)> {
if let Some((host, port)) = target.rsplit_once(':') {
if host.starts_with('[') && host.ends_with(']') {
let host = host.trim_start_matches('[').trim_end_matches(']').to_string();
let port = port.parse().ok()?;
return Some((host, port));
}
if host.contains(':') {
return None;
}
let port = port.parse().ok()?;
return Some((host.to_string(), port));
}
None
}
async fn direct_connect_socks5(
mut client: TcpStream,
stream_id: u16,
target: &str,
close_tx: mpsc::Sender<u16>,
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<u8>,
close_tx: mpsc::Sender<u16>,
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(())
}

View File

@ -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(())
}

View File

@ -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<bool>,
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<bool>,
_debug: bool,
) -> Result<()> {
Err(anyhow!("Wintun is only supported on Windows!"))
}

17
ostp-core/Cargo.toml Normal file
View File

@ -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

View File

@ -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<Vec<u8>, 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<Vec<u8>, 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
}

View File

@ -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<u8>,
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()
}
}

View File

@ -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};

View File

@ -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<Self, ProtocolError> {
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<usize, ProtocolError> {
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<usize, ProtocolError> {
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<Self, ProtocolError> {
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),
}
}
}

View File

@ -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];
}
}
}

View File

@ -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<u8> for FrameKind {
type Error = ProtocolError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
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<Self, ProtocolError> {
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<Self, ProtocolError> {
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,
})
}
}

View File

@ -0,0 +1,5 @@
pub mod frame;
pub mod padding;
pub use frame::{FrameHeader, FrameKind, FramedPacket};
pub use padding::{AdaptivePadder, PaddingStrategy, TrafficProfile};

View File

@ -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<u8> {
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
}
}

8
ostp-core/src/lib.rs Normal file
View File

@ -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};

565
ostp-core/src/protocol.rs Normal file
View File

@ -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<u8>,
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<Bytes>), // Passed from client's handshake, Optional response to send
Multiple(Vec<ProtocolAction>),
Noop,
}
pub struct ProtocolMachine {
role: NoiseRole,
state: OstpState,
noise: NoiseSession,
send_cipher: Option<SessionCipher>,
recv_cipher: Option<SessionCipher>,
send_nonce: u64,
expected_recv_nonce: u64,
reorder_buffer: BTreeMap<u64, ProtocolAction>,
sent_history: VecDeque<SentFrame>,
session_id: u32,
handshake_payload: Vec<u8>,
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<Self, ProtocolError> {
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<ProtocolAction, ProtocolError> {
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<ProtocolAction, ProtocolError> {
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<Bytes, ProtocolError> {
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<Bytes, ProtocolError> {
self.build_datagram(stream_id, kind, payload, true)
}
fn build_control_datagram(&mut self, stream_id: u16, kind: FrameKind, payload: Bytes) -> Result<Bytes, ProtocolError> {
self.build_datagram(stream_id, kind, payload, false)
}
fn build_datagram(&mut self, stream_id: u16, kind: FrameKind, payload: Bytes, track: bool) -> Result<Bytes, ProtocolError> {
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<ProtocolAction, ProtocolError> {
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<Option<Bytes>, 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<u64> = 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<Bytes> {
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<Vec<(u64, u64)>, 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),
}
}

86
ostp-core/src/relay.rs Normal file
View File

@ -0,0 +1,86 @@
use anyhow::{anyhow, Result};
#[derive(Debug, Clone)]
pub enum RelayMessage {
Connect(String),
Data(Vec<u8>),
KeepAlive,
Close,
ConnectOk,
Error(String),
Ping(u64),
Pong(u64),
}
impl RelayMessage {
pub fn encode(&self) -> Vec<u8> {
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<Self> {
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<u8> {
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])
}

19
ostp-jni/Cargo.toml Normal file
View File

@ -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"

331
ostp-jni/OstpClientSdk.kt Normal file
View File

@ -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>(TunnelState.Idle)
/** Observe the current tunnel state. Safe to collect from any coroutine. */
val state: StateFlow<TunnelState> = _state.asStateFlow()
/** Whether the tunnel is currently active (Connected state). */
val isConnected: Boolean get() = _state.value is TunnelState.Connected
private val _logs = MutableSharedFlow<String>(extraBufferCapacity = 512)
/** Observe log messages from the native layer in real-time. */
val logs: SharedFlow<String> = _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<String> {
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 }
}
}
}
}

204
ostp-jni/src/lib.rs Normal file
View File

@ -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<Runtime>,
shutdown_tx: Option<watch::Sender<bool>>,
metrics: Option<Arc<BridgeMetrics>>,
}
lazy_static! {
static ref STATE: Mutex<SdkState> = Mutex::new(SdkState {
runtime: None,
shutdown_tx: None,
metrics: None,
});
static ref LOGS: Mutex<VecDeque<String>> = 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<String> = 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()
}

15
ostp-server/Cargo.toml Normal file
View File

@ -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

View File

@ -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<Bytes>,
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<u32, PeerState>,
addr_to_session: HashMap<SocketAddr, u32>,
machine_config: ProtocolConfig,
access_keys: Arc<RwLock<HashMap<String, ()>>>,
replay_cache: std::collections::HashMap<Vec<u8>, u64>,
roaming_tokens: f64,
last_token_regen: std::time::Instant,
}
impl Dispatcher {
pub fn new(machine_config: ProtocolConfig, access_keys: Arc<RwLock<HashMap<String, ()>>>) -> 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<DispatchOutcome> {
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<Bytes>,
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<String> = 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<Option<(Bytes, SocketAddr)>> {
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<u32>) {
// 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);
}
}
}

670
ostp-server/src/lib.rs Normal file
View File

@ -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<String>,
pub ip_cidr: Vec<String>,
pub action: OutboundAction,
}
#[derive(Debug, Clone)]
pub struct OutboundConfig {
pub enabled: bool,
pub protocol: String,
pub address: String,
pub port: u16,
pub rules: Vec<OutboundRule>,
pub default_action: OutboundAction,
}
pub async fn run_server(
bind_addr: String,
access_keys: Vec<String>,
outbound: Option<OutboundConfig>,
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<String>,
}
if let Ok(cfg) = serde_json::from_str::<ServerReloadConfig>(&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::<UiCommand>();
let (ui_event_tx, mut ui_event_rx) = mpsc::unbounded_channel::<UiEvent>();
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<UiCommand>,
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
shared_keys: std::sync::Arc<std::sync::RwLock<HashMap<String, ()>>>,
outbound: Option<OutboundConfig>,
debug: bool,
) -> Result<()> {
let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new();
let (stream_tx, mut stream_rx) = mpsc::channel::<(u32, u16, Vec<u8>)>(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<IpAddr, Instant> = HashMap::new();
let mut peer_available: HashMap<IpAddr, bool> = 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<UiEvent>,
stream_tx: mpsc::Sender<(u32, u16, Vec<u8>)>,
outbound: Option<OutboundConfig>,
debug: bool,
) -> Result<()> {
match RelayMessage::decode(&payload)? {
RelayMessage::Connect(target) => {
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay CONNECT start for [{session_id}:{stream_id}] -> {target}")));
let 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<UiEvent>,
) -> Result<()> {
let payload = Bytes::from(msg.encode());
if let Some((frame, peer_addr)) = dispatcher.outbound_to_session(session_id, stream_id, payload)? {
let response_len = frame.len();
let _ = socket.send_to(&frame, peer_addr).await?;
let _ = ui_event_tx.send(UiEvent::Tx {
peer: peer_addr.ip(),
bytes: response_len,
});
}
Ok(())
}
async fn connect_target(
target: &str,
outbound: Option<&OutboundConfig>,
debug: bool,
) -> Result<TcpStream> {
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<Cidr> = cidrs.iter().filter_map(|c| parse_cidr(c)).collect();
if parsed.is_empty() {
return false;
}
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return parsed.iter().any(|cidr| cidr.contains(&ip));
}
match tokio::net::lookup_host((host, port)).await {
Ok(addrs) => addrs.into_iter().any(|addr| parsed.iter().any(|cidr| cidr.contains(&addr.ip()))),
Err(_) => false,
}
}
async fn connect_via_socks5(proxy_addr: &str, target: &str) -> Result<TcpStream> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut stream = TcpStream::connect(proxy_addr).await?;
stream.write_all(&[0x05, 0x01, 0x00]).await?;
let mut reply = [0u8; 2];
stream.read_exact(&mut reply).await?;
if reply != [0x05, 0x00] {
anyhow::bail!("SOCKS5 auth not accepted");
}
let (host, port) = split_host_port(target).ok_or_else(|| anyhow::anyhow!("invalid target"))?;
let mut req = Vec::new();
req.extend_from_slice(&[0x05, 0x01, 0x00]);
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
match ip {
std::net::IpAddr::V4(v4) => {
req.push(0x01);
req.extend_from_slice(&v4.octets());
}
std::net::IpAddr::V6(v6) => {
req.push(0x04);
req.extend_from_slice(&v6.octets());
}
}
} else {
req.push(0x03);
req.push(host.len() as u8);
req.extend_from_slice(host.as_bytes());
}
req.extend_from_slice(&port.to_be_bytes());
stream.write_all(&req).await?;
let mut header = [0u8; 4];
stream.read_exact(&mut header).await?;
if header[1] != 0x00 {
anyhow::bail!("SOCKS5 connect failed: 0x{:02x}", header[1]);
}
let addr_len = match header[3] {
0x01 => 4,
0x04 => 16,
0x03 => {
let mut len = [0u8; 1];
stream.read_exact(&mut len).await?;
len[0] as usize
}
_ => 0,
};
if addr_len > 0 {
let mut skip = vec![0u8; addr_len + 2];
stream.read_exact(&mut skip).await?;
}
Ok(stream)
}
async fn connect_via_http(proxy_addr: &str, target: &str) -> Result<TcpStream> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut stream = TcpStream::connect(proxy_addr).await?;
let request = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
stream.write_all(request.as_bytes()).await?;
let mut buf = vec![0u8; 1024];
let n = stream.read(&mut buf).await?;
let response = String::from_utf8_lossy(&buf[..n]);
if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") {
anyhow::bail!("HTTP CONNECT failed: {response}");
}
Ok(stream)
}
enum Cidr {
V4(u32, u8),
V6(u128, u8),
}
impl Cidr {
fn contains(&self, ip: &std::net::IpAddr) -> bool {
match (self, ip) {
(Cidr::V4(net, bits), std::net::IpAddr::V4(addr)) => {
let mask = if *bits == 0 { 0 } else { u32::MAX << (32 - bits) };
let ip = u32::from_be_bytes(addr.octets());
(ip & mask) == (*net & mask)
}
(Cidr::V6(net, bits), std::net::IpAddr::V6(addr)) => {
let mask = if *bits == 0 { 0 } else { u128::MAX << (128 - bits) };
let ip = u128::from_be_bytes(addr.octets());
(ip & mask) == (*net & mask)
}
_ => false,
}
}
}
fn parse_cidr(value: &str) -> Option<Cidr> {
let value = value.trim();
if value.is_empty() {
return None;
}
if let Some((addr_str, bits_str)) = value.split_once('/') {
let bits: u8 = bits_str.parse().ok()?;
if let Ok(addr) = addr_str.parse::<std::net::IpAddr>() {
return match addr {
std::net::IpAddr::V4(v4) => Some(Cidr::V4(u32::from_be_bytes(v4.octets()), bits.min(32))),
std::net::IpAddr::V6(v6) => Some(Cidr::V6(u128::from_be_bytes(v6.octets()), bits.min(128))),
};
}
}
if let Ok(addr) = value.parse::<std::net::IpAddr>() {
return match addr {
std::net::IpAddr::V4(v4) => Some(Cidr::V4(u32::from_be_bytes(v4.octets()), 32)),
std::net::IpAddr::V6(v6) => Some(Cidr::V6(u128::from_be_bytes(v6.octets()), 128)),
};
}
None
}
fn split_host_port(target: &str) -> Option<(String, u16)> {
if let Some((host, port)) = target.rsplit_once(':') {
if host.starts_with('[') && host.ends_with(']') {
let host = host.trim_start_matches('[').trim_end_matches(']').to_string();
let port = port.parse().ok()?;
return Some((host, port));
}
if host.contains(':') {
return None;
}
let port = port.parse().ok()?;
return Some((host.to_string(), port));
}
None
}

22
ostp-server/src/signal.rs Normal file
View File

@ -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(())
}

29
ostp-server/src/tui.rs Normal file
View File

@ -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<UiEvent>,
_ui_cmd_tx: mpsc::UnboundedSender<UiCommand>,
_initial_key_count: usize,
_peer_idle_timeout: std::time::Duration,
) -> anyhow::Result<()> {
Ok(())
}

1
ostp/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

16
ostp/Cargo.toml Normal file
View File

@ -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

273
ostp/src/main.rs Normal file
View File

@ -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<String>,
/// 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<String>) -> 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<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct ServerConfig {
listen: String,
access_keys: Vec<String>,
turn_server: Option<String>,
debug: Option<bool>,
outbound: Option<OutboundConfig>,
}
#[derive(Debug, Deserialize, Serialize)]
struct ClientConfig {
server: String,
access_key: String,
socks5_bind: Option<String>,
tun: Option<TunConfig>,
turn: Option<TurnConfigRaw>,
debug: Option<bool>,
exclude: Option<ExcludeConfig>,
mux: Option<MuxConfig>,
}
#[derive(Debug, Deserialize, Serialize)]
struct TunConfig {
enable: bool,
wintun_path: Option<String>,
ipv4_address: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct TurnConfigRaw {
enabled: bool,
server_addr: String,
username: Option<String>,
access_key: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct OutboundConfig {
enabled: bool,
protocol: String,
address: String,
port: u16,
#[serde(default)]
rules: Vec<OutboundRule>,
default_action: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct OutboundRule {
domain_suffix: Option<Vec<String>>,
ip_cidr: Option<Vec<String>>,
action: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct ExcludeConfig {
domains: Option<Vec<String>>,
ips: Option<Vec<String>>,
processes: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Serialize)]
struct MuxConfig {
enabled: Option<bool>,
sessions: Option<usize>,
}
#[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(())
}

78
scripts/build.ps1 Normal file
View File

@ -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"
}
}

137
scripts/install.sh Normal file
View File

@ -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 <<EOF > /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 "--------------------------------------------------------"