Compare commits

..

24 Commits

Author SHA1 Message Date
ospab 5f9682663e Suppress dead_code warnings in ostp-gui lib
Log::message is deserialized from the IPC stream but not acted on
(informational variant, GUI shows it via the tray). HelperState::port
is stored for potential reconnection but not read back after initial
connection. Both are correctly annotated with #[allow(dead_code)].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:41:22 +03:00
ospab ee38b15402 Fix tun-helper IPC encryption mismatch and unify log format
tun-helper: the GUI encrypts all IPC commands with ChaCha20Poly1305 and
sends them as hex, but the helper was reading plain JSON — every command
was silently dropped and the tunnel core was never started. Fix by:
- Moving IpcCrypto + derive_key into ostp-client/src/ipc_crypto.rs as a
  shared module so GUI and helper always use identical crypto logic.
- Rewriting tun-helper/src/main.rs to hex-decode and decrypt every
  incoming line before JSON-parsing, and to encrypt + hex-encode every
  outgoing HelperMsg before sending.
- Replacing the custom log_to_file() helper with tracing::info/warn/error
  so all helper output goes through the standard tracing pipeline.
- Adding tracing and hex to ostp-tun-helper Cargo.toml; dropping chrono
  (no longer needed after removing log_to_file).

logging: unify output format across all OSTP binaries to match the
standard tracing-subscriber style:
  2026-06-21T19:11:18.643226Z  INFO ostp_server: message
- Enable the `time` feature in tracing-subscriber and set UTC RFC-3339
  timer on both file and stderr layers in init_tracing.
- Remove with_line_number(true) — line numbers are not part of the
  desired format and bloat the target field.
- Replace println! in runner.rs with tracing::info!.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:38:29 +03:00
ospab 47d44fa072 Fix Closing state, replace sent_history VecDeque with BTreeMap, clean up dead code
- protocol: Closing+Inbound no longer force-transitions to Closed after
  one packet; handle_inbound now owns the transition when it receives a
  Close frame, preventing data loss on in-flight packets during teardown.
  Add Tick handling for Closing state so the Close frame is retransmitted.
- protocol: replace sent_history VecDeque<SentFrame> with BTreeMap<u64,
  SentFrame>; NACK lookup is now O(log n) instead of O(n) linear scan.
- protocol: remove unused _mtu field; drop VecDeque import.
- congestion: remove no-op on_tick method (was never called).
- dispatcher: remove broad #[allow(dead_code)] on impl block; annotate
  three genuinely unused methods individually. Fix comment "100000
  entries" → "50000" and log "inactive >5min" → ">10min" (real timeout
  is 600 s). Remove unused mut on stream variable in ostp client.
- docs: correct timestamp window ±30 s → ±300 s in EN and RU specs to
  match the actual drift > 300 check in dispatcher.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:09:56 +03:00
ospab d031b15679 Integrate dnstt into ostp-core and update build dependencies
Rewrite DNS transport on both client and server sides with embedded
dnstt binaries compiled from Go source via build.rs. Add Go 1.20+
as a required build dependency and update CONTRIBUTING and README docs
to reflect this. Extend relay and lib with dnstt-aware session handling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:08:59 +03:00
ospab b31da29b2d Fix DNS roaming by using a stable fake peer IP derived from ClientID 2026-06-20 19:25:38 +03:00
ospab 10c1772271 Fix DNS server responses 2026-06-20 19:15:01 +03:00
ospab 3ced4a19b6 Rewrite DNS transport with dnstt-style fragmentation, ClientID, polling and reassembly 2026-06-20 18:45:23 +03:00
ospab 6987ac5344 Fallback to server parameter for DNS resolver if not specified 2026-06-20 00:07:52 +03:00
ospab d65af355f1 Fix handshake timeouts in OSTP outbounds and remove test_parse 2026-06-19 23:57:35 +03:00
ospab 23c4d38ee4 Make --import and --url patch existing configuration instead of overwriting 2026-06-19 23:45:33 +03:00
ospab b7a31af911 Add DNS Tunneling example to client init config 2026-06-19 23:21:39 +03:00
ospab 76bf1c9a98 fix(cli): evaluate CARGO_PKG_VERSION in parse_ostp_link to prevent false migrations 2026-06-19 19:24:37 +03:00
ospab fc339b3643 feat(server): log reasons for dropped packets 2026-06-19 19:14:46 +03:00
ospab 6eb7b369a0 fix(client): wait for handshake response in dial_tcp before sending data 2026-06-19 19:06:51 +03:00
ospab 01d7d19b11 Restore Session import for Windows compatibility and fix Flutter build 2026-06-19 18:24:51 +03:00
ospab 0953b83e3c CI/CD: release version v0.3.12 2026-06-19 17:53:16 +03:00
ospab 8a0b633bb1 Fix compiler warnings and errors 2026-06-19 17:51:58 +03:00
ospab 72077bbd0c CI/CD: release version v0.3.11 2026-06-19 17:36:16 +03:00
ospab 0cd189fb84 Prober now auto-reads DNS domain from config 2026-06-19 17:34:37 +03:00
ospab 87694c6218 Add update version targeting and fix dns prober 2026-06-19 17:31:43 +03:00
ospab 916a21eeec Fix type mismatch error in make_transport 2026-06-19 16:19:51 +03:00
ospab f8f27d366d Fix empty handshake payload and dummy keys in ostp outbound client 2026-06-19 16:11:37 +03:00
ospab ce9f11a35e Fix ReloadUser missing rename for 'key' resulting in all keys being dropped 2026-06-19 15:54:55 +03:00
ospab 7fadc8d28d Fix hot-reloader clearing access keys due to modular config migration 2026-06-19 15:44:55 +03:00
50 changed files with 1736 additions and 986 deletions

BIN
.gitignore vendored

Binary file not shown.

View File

@ -22,6 +22,7 @@ By contributing to this project, you agree to abide by our code of conduct and l
To build and test OSTP locally, you will need: To build and test OSTP locally, you will need:
* **Rust Toolchain**: Install via [rustup](https://rustup.rs/) (stable channel). * **Rust Toolchain**: Install via [rustup](https://rustup.rs/) (stable channel).
* **Go 1.20+**: Required to compile the embedded `dnstt` tunnel binaries.
* **Node.js (18+) & npm**: Required to compile Tauri GUI resources. * **Node.js (18+) & npm**: Required to compile Tauri GUI resources.
* **Git**: For version control. * **Git**: For version control.

View File

@ -22,6 +22,7 @@
Для локальной сборки и тестирования OSTP вам понадобятся: Для локальной сборки и тестирования OSTP вам понадобятся:
* **Rust Toolchain**: Установите через [rustup](https://rustup.rs/) (stable канал). * **Rust Toolchain**: Установите через [rustup](https://rustup.rs/) (stable канал).
* **Go 1.20+**: Необходимо для сборки встроенного DNS-туннеля dnstt.
* **Node.js (18+) и npm**: Необходимы для сборки интерфейса Tauri. * **Node.js (18+) и npm**: Необходимы для сборки интерфейса Tauri.
* **Git**: Для контроля версий. * **Git**: Для контроля версий.

36
Cargo.lock generated
View File

@ -388,6 +388,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clipboard-win"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fdf5e01086b6be750428ba4a40619f847eb2e95756eee84b18e06e5f0b50342"
dependencies = [
"lazy-bytes-cast",
"winapi",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@ -1252,6 +1262,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dbbfed4e59ba9750e15ba154fdfd9329cee16ff3df539c2666b70f58cc32105" checksum = "9dbbfed4e59ba9750e15ba154fdfd9329cee16ff3df539c2666b70f58cc32105"
[[package]]
name = "lazy-bytes-cast"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10257499f089cd156ad82d0a9cd57d9501fa2c989068992a97eb3c27836f206b"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -1431,16 +1447,18 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "ostp" name = "ostp"
version = "0.3.10" version = "0.3.12"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
"clap", "clap",
"clipboard-win",
"colored", "colored",
"json_comments", "json_comments",
"ostp-client", "ostp-client",
"ostp-core", "ostp-core",
"ostp-server", "ostp-server",
"pico-args",
"rand 0.8.5", "rand 0.8.5",
"reqwest", "reqwest",
"serde", "serde",
@ -1453,7 +1471,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-client" name = "ostp-client"
version = "0.3.10" version = "0.3.12"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1488,7 +1506,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-core" name = "ostp-core"
version = "0.3.10" version = "0.3.12"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"byteorder", "byteorder",
@ -1525,7 +1543,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-server" name = "ostp-server"
version = "0.3.10" version = "0.3.12"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@ -1558,7 +1576,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-tun" name = "ostp-tun"
version = "0.3.10" version = "0.3.12"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc", "libc",
@ -1570,7 +1588,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-tun-helper" name = "ostp-tun-helper"
version = "0.3.10" version = "0.3.12"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -1594,6 +1612,12 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"

View File

@ -12,7 +12,7 @@ resolver = "2"
[workspace.package] [workspace.package]
edition = "2021" edition = "2021"
license = "BSL 1.1" license = "BSL 1.1"
version = "0.3.10" version = "0.3.12"
[workspace.dependencies] [workspace.dependencies]
anyhow = "1.0" anyhow = "1.0"

View File

@ -142,8 +142,13 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
## Сборка из исходников ## Сборка из исходников
### Зависимости для сборки
- Rust 1.70+
- Go 1.20+ (необходимо для сборки встроенного DNS-туннеля dnstt)
> **Благодарности:** Этот проект использует [dnstt](https://www.bamsoftware.com/software/dnstt/) от Bamsoftware для обеспечения устойчивого туннелирования поверх DNS. Бинарники dnstt автоматически компилируются и встраиваются в ядро OSTP.
```bash ```bash
# Требования: Rust 1.75+
cargo build --release cargo build --release
# Кросс-компиляция для Linux # Кросс-компиляция для Linux

View File

@ -94,7 +94,7 @@ OSTP executes a Noise Protocol Framework exchange utilizing the `Noise_NNpsk0_25
2. The PSK is integrated into the state at pattern position zero, authorizing and encrypting the very first handshaking datagram. 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. 3. Ephemeral Curve25519 key exchange is evaluated to synthesize autonomous symmetric keys for subsequent read/write channels.
The initial handshake payload includes a Unix timestamp to mitigate replay attacks. The server enforces a strict ±30-second synchronization window. The initial handshake payload includes a Unix timestamp to mitigate replay attacks. The server enforces a ±300-second synchronization window to accommodate clock drift and mobile roaming scenarios.
--- ---

View File

@ -94,7 +94,7 @@ OSTP использует Noise Protocol Framework с паттерном `Noise_
2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия (Zero-RTT авторизация). 2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия (Zero-RTT авторизация).
3. Выполняется эфемерный обмен ключами Curve25519 для создания симметричных ключей передачи данных. 3. Выполняется эфемерный обмен ключами Curve25519 для создания симметричных ключей передачи данных.
Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер строго контролирует окно синхронизации (±30 секунд). Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер контролирует окно синхронизации (±300 секунд) с учётом дрейфа часов и смены сети при роуминге.
--- ---

View File

@ -9,7 +9,7 @@ anyhow.workspace = true
bytes.workspace = true bytes.workspace = true
tokio.workspace = true tokio.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "time"] }
tracing-appender = "0.2" tracing-appender = "0.2"
ostp-core = { path = "../ostp-core" } ostp-core = { path = "../ostp-core" }
ostp-tun = { path = "../ostp-tun" } ostp-tun = { path = "../ostp-tun" }

View File

@ -50,6 +50,8 @@ pub enum InboundConfig {
protocol: String, // "socks" or "http" protocol: String, // "socks" or "http"
listen: String, listen: String,
port: u16, port: u16,
#[serde(default)]
set_system_proxy: bool,
}, },
} }
@ -172,18 +174,15 @@ impl ClientConfig {
.with_context(|| format!("failed to parse JSON from {}", path.display()))?; .with_context(|| format!("failed to parse JSON from {}", path.display()))?;
let (migrated_json, was_migrated) = Self::migrate_json(raw_json); let (migrated_json, was_migrated) = Self::migrate_json(raw_json);
if was_migrated { if was_migrated {
tracing::info!("Config was migrated to v0.3.1. Saving to {}", path.display()); tracing::warn!(
let serialized = serde_json::to_string_pretty(&migrated_json)?; "Config at {} is in an outdated format. Run 'ostp --migrate' to upgrade it.",
let header = "// OSTP Configuration v0.3.1\n// DO NOT EDIT THIS COMMENT - Migrator relies on it\n"; path.display()
let final_content = format!("{}{}", header, serialized); );
std::fs::write(&path, final_content)
.with_context(|| format!("failed to save migrated config to {}", path.display()))?;
} }
let config: ClientConfig = serde_json::from_value(migrated_json) let config: ClientConfig = serde_json::from_value(migrated_json)
.with_context(|| format!("failed to deserialize migrated config from {}", path.display()))?; .with_context(|| format!("failed to deserialize config from {}", path.display()))?;
Ok(config) Ok(config)
} }
@ -191,8 +190,20 @@ impl ClientConfig {
/// Migrates old monolithic JSON to the new modular format. /// Migrates old monolithic JSON to the new modular format.
/// Returns the migrated JSON value and a boolean indicating if a migration occurred. /// Returns the migrated JSON value and a boolean indicating if a migration occurred.
pub fn migrate_json(json: serde_json::Value) -> (serde_json::Value, bool) { pub fn migrate_json(json: serde_json::Value) -> (serde_json::Value, bool) {
let is_migrated = json.get("version").and_then(|v| v.as_str()) == Some(env!("CARGO_PKG_VERSION")); // Consider the config already migrated if:
if is_migrated { // 1. Version matches exactly, OR
// 2. The JSON already has the new modular format (inbounds + outbounds arrays)
let has_version = json.get("version").and_then(|v| v.as_str()) == Some(env!("CARGO_PKG_VERSION"));
let has_new_format = json.get("inbounds").and_then(|v| v.as_array()).is_some()
&& json.get("outbounds").and_then(|v| v.as_array()).is_some();
if has_version || has_new_format {
// If format is already new but version is old, just bump the version
if has_new_format && !has_version {
let mut updated = json.clone();
updated["version"] = serde_json::json!(env!("CARGO_PKG_VERSION"));
return (updated, false);
}
return (json, false); return (json, false);
} }

View File

@ -0,0 +1,41 @@
use anyhow::{anyhow, Result};
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
use chacha20poly1305::aead::{Aead, KeyInit};
use sha2::{Sha256, Digest};
/// Symmetric IPC channel encryption for the tun-helper ↔ GUI pipe.
///
/// Both sides derive the same key from the per-launch random token, so no
/// secret is ever passed on the command line. The zero nonce is safe here
/// because each session uses a fresh random token, making key reuse impossible.
#[derive(Clone)]
pub struct IpcCrypto {
cipher: ChaCha20Poly1305,
}
impl IpcCrypto {
pub fn new(key: &[u8; 32]) -> Self {
let cipher = ChaCha20Poly1305::new_from_slice(key)
.expect("32-byte key is always valid for ChaCha20Poly1305");
Self { cipher }
}
pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
let nonce = Nonce::from_slice(&[0u8; 12]);
self.cipher.encrypt(nonce, plaintext)
.map_err(|e| anyhow!("IPC encrypt: {}", e))
}
pub fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>> {
let nonce = Nonce::from_slice(&[0u8; 12]);
self.cipher.decrypt(nonce, ciphertext)
.map_err(|e| anyhow!("IPC decrypt: {}", e))
}
}
/// Derive a 32-byte key from the per-session random token.
pub fn derive_key(token: &str) -> [u8; 32] {
let mut key = [0u8; 32];
key.copy_from_slice(&Sha256::digest(token.as_bytes()));
key
}

View File

@ -9,3 +9,4 @@ pub mod tunnel;
pub mod runner; pub mod runner;
pub mod logging; pub mod logging;
pub mod ipc_crypto;

View File

@ -73,17 +73,21 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
if let Ok(file) = OpenOptions::new().create(true).append(true).open(&path) { if let Ok(file) = OpenOptions::new().create(true).append(true).open(&path) {
let (file_writer, guard) = tracing_appender::non_blocking(file); let (file_writer, guard) = tracing_appender::non_blocking(file);
let timer = tracing_subscriber::fmt::time::UtcTime::rfc_3339();
let fmt_layer = tracing_subscriber::fmt::layer() let fmt_layer = tracing_subscriber::fmt::layer()
.with_target(true) .with_target(true)
.with_line_number(true) .with_line_number(false)
.with_thread_ids(false) .with_thread_ids(false)
.with_thread_names(false) .with_thread_names(false)
.with_ansi(false) .with_ansi(false)
.with_timer(timer.clone())
.with_writer(file_writer); .with_writer(file_writer);
let stderr_layer = tracing_subscriber::fmt::layer() let stderr_layer = tracing_subscriber::fmt::layer()
.with_target(true) .with_target(true)
.with_timer(timer)
.with_writer(std::io::stderr); .with_writer(std::io::stderr);
let _ = tracing_subscriber::registry() let _ = tracing_subscriber::registry()
@ -107,6 +111,7 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
// Fallback: stderr only // Fallback: stderr only
let stderr_layer = tracing_subscriber::fmt::layer() let stderr_layer = tracing_subscriber::fmt::layer()
.with_target(true) .with_target(true)
.with_timer(tracing_subscriber::fmt::time::UtcTime::rfc_3339())
.with_writer(std::io::stderr); .with_writer(std::io::stderr);
let _ = tracing_subscriber::registry() let _ = tracing_subscriber::registry()
.with(EnvFilter::new(level)) .with(EnvFilter::new(level))

View File

@ -13,7 +13,7 @@ pub async fn run_client_core(
mut shutdown_rx_ext: watch::Receiver<bool>, mut shutdown_rx_ext: watch::Receiver<bool>,
_config_rx: Option<watch::Receiver<ClientConfig>>, _config_rx: Option<watch::Receiver<ClientConfig>>,
) -> Result<()> { ) -> Result<()> {
println!("[ostp] Starting run_client_core with multi-server architecture"); tracing::info!("starting client core");
let router = Arc::new(Router::new(config.routing.clone())); let router = Arc::new(Router::new(config.routing.clone()));
let balancer = Arc::new(Balancer::new(&config)); let balancer = Arc::new(Balancer::new(&config));

View File

@ -1,104 +1 @@
use std::sync::Arc; // Left empty by request
use std::time::Duration;
use bytes::Bytes;
use tokio::net::UdpSocket;
use tokio::sync::{mpsc, Mutex};
use rand::Rng;
pub use ostp_core::dns::{
DnsPacket, DnsRecordType, encode_payload_to_domain,
decode_domain_to_payload,
};
use crate::transport::Transport;
pub async fn start_dns_transport(domain: String, resolver: String, _pubkey: Option<String>) -> std::io::Result<Transport> {
let (app_tx, transport_rx) = mpsc::channel::<Bytes>(100);
let (transport_tx, app_rx) = mpsc::channel::<Bytes>(100);
let resolver_addr = if resolver.contains(':') {
resolver.clone()
} else {
format!("{}:53", resolver)
};
let socket = UdpSocket::bind("0.0.0.0:0").await?;
socket.connect(&resolver_addr).await?;
let socket = Arc::new(socket);
let sock_rx = socket.clone();
let sock_tx = socket;
let base_domain = domain.clone();
// Send task (reads from app, encodes into DNS TXT, sends to UDP socket)
tokio::spawn(async move {
let mut rx = transport_rx;
loop {
let data_opt = tokio::select! {
res = rx.recv() => res,
_ = tokio::time::sleep(Duration::from_secs(2)) => Some(Bytes::new()),
};
let data = match data_opt {
Some(d) => d,
None => break, // App closed
};
// Encode data to base32 domain
let fqdn = encode_payload_to_domain(&data, &base_domain);
let id: u16 = rand::thread_rng().gen();
// Randomly choose TXT or NULL for diversity (as requested)
let qtype = if rand::thread_rng().gen_bool(0.5) {
DnsRecordType::TXT
} else {
DnsRecordType::NULL
};
let packet = DnsPacket::new_query(id, &fqdn, qtype);
let encoded = packet.encode();
if let Err(e) = sock_tx.send(&encoded).await {
tracing::warn!("DNS transport send error: {}", e);
break;
}
}
});
// Receive task (reads from UDP socket, decodes DNS answer, sends to app)
let _base_domain_rx = domain.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 65535];
loop {
match sock_rx.recv(&mut buf).await {
Ok(n) => {
if let Some(packet) = DnsPacket::decode(&buf[..n]) {
for answer in packet.answers {
if answer.rtype == DnsRecordType::TXT || answer.rtype == DnsRecordType::NULL {
// If it's a TXT record, the response might be base32 encoded payload?
// Actually, dnstt puts the payload in the TXT/NULL record data.
// We'll just assume the rdata is the raw payload, or base32 encoded if it was sent as such.
// Let's just pass the raw data (TXT strings are decoded in DnsPacket::decode)
// Wait, dnstt server responds with raw bytes in NULL, and base32/chunked strings in TXT.
// Our `DnsPacket::decode` already handles extracting TXT string bytes or NULL raw bytes into `rdata`.
// Let's just send `rdata` to the app.
if transport_tx.send(Bytes::from(answer.rdata)).await.is_err() {
return; // App closed
}
}
}
}
}
Err(e) => {
tracing::warn!("DNS transport recv error: {}", e);
break;
}
}
}
});
Ok(Transport::Dns {
tx: app_tx,
rx: Arc::new(Mutex::new(app_rx)),
})
}

View File

@ -1,4 +1,3 @@
pub mod dns;
use std::sync::Arc; use std::sync::Arc;
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
use bytes::Bytes; use bytes::Bytes;
@ -10,9 +9,10 @@ pub enum Transport {
tx: tokio::sync::mpsc::Sender<Bytes>, tx: tokio::sync::mpsc::Sender<Bytes>,
rx: Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Bytes>>>, rx: Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Bytes>>>,
}, },
Dns { Dnstt {
tx: tokio::sync::mpsc::Sender<Bytes>, tx: tokio::sync::mpsc::Sender<Bytes>,
rx: Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Bytes>>>, rx: Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Bytes>>>,
_guard: Arc<tokio::sync::Mutex<ostp_core::dnstt::DnsttProcess>>,
} }
} }
@ -20,7 +20,7 @@ impl Transport {
pub async fn send(&self, frame: &Bytes) -> std::io::Result<usize> { pub async fn send(&self, frame: &Bytes) -> std::io::Result<usize> {
match self { match self {
Self::Udp(sock) => sock.send(frame).await, Self::Udp(sock) => sock.send(frame).await,
Self::Uot { tx, .. } | Self::Dns { tx, .. } => { Self::Uot { tx, .. } | Self::Dnstt { tx, .. } => {
tx.send(frame.clone()).await.map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "channel closed"))?; tx.send(frame.clone()).await.map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "channel closed"))?;
Ok(frame.len()) Ok(frame.len())
} }
@ -30,31 +30,40 @@ impl Transport {
pub async fn send_to(&self, frame: &Bytes, target: std::net::SocketAddr) -> std::io::Result<usize> { pub async fn send_to(&self, frame: &Bytes, target: std::net::SocketAddr) -> std::io::Result<usize> {
match self { match self {
Self::Udp(sock) => sock.send_to(frame, target).await, Self::Udp(sock) => sock.send_to(frame, target).await,
Self::Uot { .. } | Self::Dns { .. } => self.send(frame).await, Self::Uot { .. } | Self::Dnstt { .. } => self.send(frame).await,
} }
} }
pub async fn recv(&self, buf: &mut [u8]) -> std::io::Result<usize> { pub async fn recv(&self, buf: &mut [u8]) -> std::io::Result<usize> {
match self { match self {
Self::Udp(sock) => sock.recv(buf).await, Self::Udp(sock) => sock.recv(buf).await,
Self::Uot { rx, .. } | Self::Dns { rx, .. } => { Self::Uot { rx, .. } | Self::Dnstt { rx, .. } => {
let mut rx = rx.lock().await; let mut rx = rx.lock().await;
match rx.recv().await { if let Some(frame) = rx.recv().await {
Some(bytes) => { let len = frame.len().min(buf.len());
let len = bytes.len().min(buf.len()); buf[..len].copy_from_slice(&frame[..len]);
buf[..len].copy_from_slice(&bytes[..len]); Ok(len)
Ok(len) } else {
} Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "channel closed"))
None => Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "channel closed")),
} }
} }
} }
} }
pub async fn recv_from(&self, buf: &mut [u8]) -> std::io::Result<(usize, std::net::SocketAddr)> {
match self {
Self::Udp(sock) => sock.recv_from(buf).await,
Self::Uot { .. } | Self::Dnstt { .. } => {
let n = self.recv(buf).await?;
Ok((n, "127.0.0.1:0".parse().unwrap()))
}
}
}
pub fn local_addr(&self) -> std::io::Result<std::net::SocketAddr> { pub fn local_addr(&self) -> std::io::Result<std::net::SocketAddr> {
match self { match self {
Self::Udp(sock) => sock.local_addr(), Self::Udp(sock) => sock.local_addr(),
Self::Uot { .. } | Self::Dns { .. } => Ok("0.0.0.0:0".parse().unwrap()), Self::Uot { .. } | Self::Dnstt { .. } => Ok("0.0.0.0:0".parse().unwrap()),
} }
} }
} }

View File

@ -59,6 +59,7 @@ impl Balancer {
/// Fetches the config for a concrete outbound /// Fetches the config for a concrete outbound
pub fn get_concrete_outbound(&self, tag: &str) -> Option<&OutboundConfig> { pub fn get_concrete_outbound(&self, tag: &str) -> Option<&OutboundConfig> {
let resolved_tag = self.resolve_outbound(tag); let resolved_tag = self.resolve_outbound(tag);
tracing::debug!("Balancer: tag '{}' resolved to '{}'", tag, resolved_tag);
self.outbounds.get(&resolved_tag) self.outbounds.get(&resolved_tag)
} }
} }

View File

@ -14,13 +14,20 @@ pub async fn run_socks_inbound(
outbound_manager: Arc<OutboundManager>, outbound_manager: Arc<OutboundManager>,
mut shutdown: watch::Receiver<bool>, mut shutdown: watch::Receiver<bool>,
) -> Result<()> { ) -> Result<()> {
let InboundConfig::LocalProxy { tag, protocol, listen, port } = inbound_config else { let InboundConfig::LocalProxy { tag, protocol, listen, port, set_system_proxy } = inbound_config else {
return Err(anyhow!("Invalid config for LocalProxy inbound")); return Err(anyhow!("Invalid config for LocalProxy inbound"));
}; };
let bind_addr = format!("{}:{}", listen, port); let bind_addr = format!("{}:{}", listen, port);
tracing::info!("Starting {} proxy inbound on {} (tag: {})", protocol, bind_addr, tag); tracing::info!("Starting {} proxy inbound on {} (tag: {})", protocol, bind_addr, tag);
let _proxy_guard = if set_system_proxy {
let proxy_host = if listen == "0.0.0.0" { "127.0.0.1" } else { &listen };
Some(crate::sysproxy::SystemProxyGuard::enable(&format!("{}:{}", proxy_host, port)))
} else {
None
};
let listener = TcpListener::bind(&bind_addr).await?; let listener = TcpListener::bind(&bind_addr).await?;
loop { loop {

View File

@ -1,6 +1,7 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use std::sync::Arc; use std::sync::Arc;
use crate::config::{ClientConfig, InboundConfig}; use crate::config::{ClientConfig, InboundConfig};
#[allow(unused_imports)]
use crate::tunnel::router::{Router, Session}; use crate::tunnel::router::{Router, Session};
use crate::tunnel::outbounds::OutboundManager; use crate::tunnel::outbounds::OutboundManager;
use tokio::sync::watch; use tokio::sync::watch;

View File

@ -66,7 +66,7 @@ pub fn bind_socket_to_interface(socket: &tokio::net::TcpSocket, _is_ipv6: bool,
Ok(()) Ok(())
} }
pub async fn dial_tcp(target_host: &str, target_port: u16, phys_if_idx: Option<u32>) -> Result<TcpStream> { pub async fn dial_tcp(target_host: &str, target_port: u16, _phys_if_idx: Option<u32>) -> Result<TcpStream> {
let addrs = tokio::net::lookup_host((target_host, target_port)).await?.collect::<Vec<_>>(); let addrs = tokio::net::lookup_host((target_host, target_port)).await?.collect::<Vec<_>>();
if addrs.is_empty() { if addrs.is_empty() {
return Err(anyhow!("Could not resolve target host: {}", target_host)); return Err(anyhow!("Could not resolve target host: {}", target_host));
@ -79,7 +79,7 @@ pub async fn dial_tcp(target_host: &str, target_port: u16, phys_if_idx: Option<u
}; };
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
if let Some(idx) = phys_if_idx { if let Some(idx) = _phys_if_idx {
if let Err(e) = bind_socket_to_interface(&socket, target_addr.is_ipv6(), idx) { if let Err(e) = bind_socket_to_interface(&socket, target_addr.is_ipv6(), idx) {
tracing::warn!("DIRECT: Failed to bind to physical interface {}: {}", idx, e); tracing::warn!("DIRECT: Failed to bind to physical interface {}: {}", idx, e);
} }

View File

@ -11,7 +11,7 @@ pub mod socks;
pub struct OutboundManager { pub struct OutboundManager {
balancer: Arc<Balancer>, balancer: Arc<Balancer>,
phys_if_index: Option<u32>, phys_if_index: Option<u32>,
phys_if_name: Option<String>, _phys_if_name: Option<String>,
} }
impl OutboundManager { impl OutboundManager {
@ -23,7 +23,7 @@ impl OutboundManager {
Self { Self {
balancer, balancer,
phys_if_index, phys_if_index,
phys_if_name, _phys_if_name: phys_if_name,
} }
} }
@ -39,7 +39,7 @@ impl OutboundManager {
block::dial_tcp(target_host, target_port).await block::dial_tcp(target_host, target_port).await
} }
OutboundConfig::Ostp { server, port, access_key, transport, multiplex, .. } => { OutboundConfig::Ostp { server, port, access_key, transport, multiplex, .. } => {
ostp::dial_tcp(server, *port, access_key, transport, multiplex).await ostp::dial_tcp(target_host, target_port, server, *port, access_key, transport, multiplex).await
} }
OutboundConfig::Socks { server, port, .. } => { OutboundConfig::Socks { server, port, .. } => {
socks::dial_tcp(target_host, target_port, server, *port).await socks::dial_tcp(target_host, target_port, server, *port).await

View File

@ -5,71 +5,211 @@ use crate::config::{TransportConfig, MultiplexConfig};
use ostp_core::{OstpEvent, ProtocolAction, ProtocolConfig, ProtocolMachine}; use ostp_core::{OstpEvent, ProtocolAction, ProtocolConfig, ProtocolMachine};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// Build the handshake payload the server expects:
/// [timestamp_u64_be (8 bytes)] [session_id_u32_be (4 bytes)] [access_key bytes]
fn build_handshake_payload(session_id: u32, access_key: &str) -> Vec<u8> {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut payload = Vec::with_capacity(12 + access_key.len());
payload.extend_from_slice(&ts.to_be_bytes());
payload.extend_from_slice(&session_id.to_be_bytes());
payload.extend_from_slice(access_key.as_bytes());
payload
}
/// Build a correctly configured ProtocolConfig for an outgoing OSTP connection.
fn make_initiator_config(
session_id: u32,
access_key: &str,
transport_cfg: &TransportConfig,
) -> ProtocolConfig {
let secrets = ostp_core::crypto::derive_all_secrets(access_key.as_bytes());
let payload = build_handshake_payload(session_id, access_key);
let mtu = match transport_cfg.r#type.as_str() {
"dns" => 1100,
_ => 1350,
};
// For DNS transport: use larger ack_delay and rto to match DNS round-trip latency
// (each DNS query + reply takes 300-800ms end-to-end through Cloudflare).
// For UDP: minimize ack_delay to 1ms (ACK asap) and let CC drive the RTO.
let (ack_delay_ms, rto_ms) = match transport_cfg.r#type.as_str() {
"dns" => (50, 1500),
_ => (1, 200),
};
ProtocolConfig {
role: ostp_core::NoiseRole::Initiator,
psk: secrets.psk,
session_id,
handshake_payload: payload,
max_padding: 256,
padding_strategy: ostp_core::framing::PaddingStrategy::Adaptive,
obfuscation_key: secrets.obfuscation_key,
max_reorder: 16384,
max_reorder_buffer: 8192,
ack_delay_ms,
rto_ms,
max_retries: 8,
max_sent_history: 32768,
handshake_pad_min: secrets.handshake_pad_min,
handshake_pad_max: secrets.handshake_pad_max,
mtu,
}
}
fn random_session_id() -> u32 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
std::time::Instant::now().hash(&mut h);
std::thread::current().id().hash(&mut h);
h.finish() as u32
}
pub async fn dial_tcp( pub async fn dial_tcp(
target_host: &str,
target_port: u16,
server: &str, server: &str,
port: u16, port: u16,
access_key: &str, access_key: &str,
transport_cfg: &TransportConfig, transport_cfg: &TransportConfig,
_multiplex: &MultiplexConfig, _multiplex: &MultiplexConfig,
) -> Result<TcpStream> { ) -> Result<TcpStream> {
tracing::info!("Dialing OSTP server {}:{} for target {}:{}", server, port, target_host, target_port);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let local_addr = listener.local_addr()?; let local_addr = listener.local_addr()?;
let client_stream = tokio::net::TcpStream::connect(local_addr).await?; let client_stream = tokio::net::TcpStream::connect(local_addr).await?;
let (mut server_stream, _) = listener.accept().await?; let (mut server_stream, _) = listener.accept().await?;
let transport = match transport_cfg.r#type.as_str() { let transport = make_transport(transport_cfg, server, port).await?;
"dns" => {
let domain = transport_cfg.domain.clone().unwrap_or_else(|| "tunnel.example.com".to_string()); let session_id = random_session_id();
let resolver = transport_cfg.resolver.clone().unwrap_or_else(|| "8.8.8.8".to_string()); let config = make_initiator_config(session_id, access_key, transport_cfg);
crate::transport::dns::start_dns_transport(domain, resolver, transport_cfg.pubkey.clone()).await?
}
// Fallback to UDP for now if unknown
_ => {
let udp = tokio::net::UdpSocket::bind("0.0.0.0:0").await?;
udp.connect((server, port)).await?;
crate::transport::Transport::Udp(std::sync::Arc::new(udp))
}
};
let mut psk = [0u8; 32];
let key_bytes = access_key.as_bytes();
let len = key_bytes.len().min(32);
psk[..len].copy_from_slice(&key_bytes[..len]);
let config = ProtocolConfig {
role: ostp_core::NoiseRole::Initiator,
psk,
session_id: 1,
handshake_payload: vec![],
max_padding: 0,
padding_strategy: ostp_core::framing::PaddingStrategy::Fixed(0),
obfuscation_key: [0; 8],
max_reorder: 16384,
max_reorder_buffer: 8192,
ack_delay_ms: 10,
rto_ms: 100,
max_retries: 5,
max_sent_history: 32768,
handshake_pad_min: 8,
handshake_pad_max: 24,
mtu: 1400,
};
let mut machine = ProtocolMachine::new(config).unwrap(); let mut machine = ProtocolMachine::new(config).unwrap();
let target_host_str = target_host.to_string();
let server_str = server.to_string();
// Spawn bridge task
tokio::spawn(async move {
// Send initial handshake
if let Ok(action) = machine.on_event(OstpEvent::Start) {
handle_action(action, &transport, &mut server_stream).await;
}
// Spawn bridge task // Wait for handshake response (server sends HandshakePayload back)
tokio::spawn(async move { let mut buf = [0u8; 8192];
if let Ok(action) = machine.on_event(OstpEvent::Start) { let mut handshake_success = false;
match tokio::time::timeout(
std::time::Duration::from_millis(15000),
transport.recv(&mut buf),
).await {
Ok(Ok(n)) => {
if let Ok(action) = machine.on_event(OstpEvent::Inbound(bytes::Bytes::copy_from_slice(&buf[..n]))) {
handle_action(action, &transport, &mut server_stream).await;
handshake_success = true;
}
}
_ => {
tracing::warn!("OSTP handshake timeout for {}:{}", server_str, port);
return;
}
}
if !handshake_success {
tracing::warn!("TCP handshake failed or protocol machine error");
return;
}
// Send connection request
let connect_msg = ostp_core::relay::RelayMessage::Connect(format!("{}:{}", target_host_str, target_port));
let connect_encoded = connect_msg.encode();
if let Ok(action) = machine.on_event(OstpEvent::Outbound(1, bytes::Bytes::from(connect_encoded))) {
handle_action(action, &transport, &mut server_stream).await; handle_action(action, &transport, &mut server_stream).await;
} }
// ── Wait for ConnectOk before forwarding any data ─────────────────
// This is critical: if we enter the data loop immediately, the TLS
// ClientHello arrives at the server before it has established the
// outbound TCP connection, causing it to drop the packet as
// "Relay DATA for unknown stream".
// The kernel will buffer incoming data from server_stream while we wait.
let mut connect_ok = false;
match tokio::time::timeout(
std::time::Duration::from_secs(30),
async {
let mut wait_buf = [0u8; 8192];
loop {
tokio::select! {
Ok(n) = transport.recv(&mut wait_buf) => {
if let Ok(action) = machine.on_event(OstpEvent::Inbound(
bytes::Bytes::copy_from_slice(&wait_buf[..n]),
)) {
// Check for ConnectOk or Error before dispatching
let result = check_connect_result(&action);
handle_action(action, &transport, &mut server_stream).await;
match result {
Some(true) => return true,
Some(false) => return false,
None => {}
}
}
}
_ = tokio::time::sleep(std::time::Duration::from_millis(10)) => {
if let Ok(action) = machine.on_event(OstpEvent::Tick) {
handle_action(action, &transport, &mut server_stream).await;
}
}
}
}
},
)
.await
{
Ok(true) => {
tracing::debug!("ConnectOk received for {}:{}, starting data forwarding", target_host_str, target_port);
connect_ok = true;
}
Ok(false) => {
tracing::warn!("Server refused connection to {}:{}", target_host_str, target_port);
}
Err(_) => {
tracing::warn!("ConnectOk timeout for {}:{}", target_host_str, target_port);
}
}
if !connect_ok {
return;
}
// ── Main bidirectional data forwarding loop ───────────────────────
// Backpressure: we track how many frames are in-flight vs the congestion
// window. When the window is full we stop reading from the TCP stream
// (the kernel buffers it) until the remote ACKs enough frames.
// This prevents overrunning the sender's sent_history and collapsing cwnd.
let mut buf = [0u8; 65535]; let mut buf = [0u8; 65535];
let mut udp_buf = [0u8; 65535]; let mut udp_buf = [0u8; 65535];
loop { loop {
// Compute adaptive tick interval:
// - If there is a pending ACK: tick = ack_delay (flush it quickly)
// - Otherwise: tick = rto/4 (check retransmits without busy-spinning)
// Floor at 1ms, ceiling at 50ms.
let tick_ms = (machine.rto().as_millis() / 4).clamp(1, 50) as u64;
let can_send = machine.in_flight_count() < machine.cwnd_packets().max(4);
tokio::select! { tokio::select! {
Ok(n) = server_stream.read(&mut buf) => { // Only read from the application TCP stream when cwnd allows
Ok(n) = server_stream.read(&mut buf), if can_send => {
if n == 0 { break; } if n == 0 { break; }
if let Ok(action) = machine.on_event(OstpEvent::Outbound(1, bytes::Bytes::copy_from_slice(&buf[..n]))) { let data_msg = ostp_core::relay::RelayMessage::Data(buf[..n].to_vec());
let encoded = data_msg.encode();
if let Ok(action) = machine.on_event(OstpEvent::Outbound(1, bytes::Bytes::from(encoded))) {
handle_action(action, &transport, &mut server_stream).await; handle_action(action, &transport, &mut server_stream).await;
} }
} }
@ -78,7 +218,7 @@ pub async fn dial_tcp(
handle_action(action, &transport, &mut server_stream).await; handle_action(action, &transport, &mut server_stream).await;
} }
} }
_ = tokio::time::sleep(std::time::Duration::from_millis(10)) => { _ = tokio::time::sleep(std::time::Duration::from_millis(tick_ms)) => {
if let Ok(action) = machine.on_event(OstpEvent::Tick) { if let Ok(action) = machine.on_event(OstpEvent::Tick) {
handle_action(action, &transport, &mut server_stream).await; handle_action(action, &transport, &mut server_stream).await;
} }
@ -87,6 +227,7 @@ pub async fn dial_tcp(
} }
}); });
Ok(client_stream) Ok(client_stream)
} }
@ -100,64 +241,55 @@ pub async fn handle_udp(
transport_cfg: &TransportConfig, transport_cfg: &TransportConfig,
_multiplex: &MultiplexConfig, _multiplex: &MultiplexConfig,
) -> Result<()> { ) -> Result<()> {
let transport = match transport_cfg.r#type.as_str() { let transport = make_transport(transport_cfg, server, port).await?;
"dns" => {
let domain = transport_cfg.domain.clone().unwrap_or_else(|| "tunnel.example.com".to_string()); // Derive session_id from client source addr for stable per-flow sessions
let resolver = transport_cfg.resolver.clone().unwrap_or_else(|| "8.8.8.8".to_string()); let ip_bytes = match client_src.ip() {
crate::transport::dns::start_dns_transport(domain, resolver, transport_cfg.pubkey.clone()).await? std::net::IpAddr::V4(v4) => {
let o = v4.octets();
u32::from_be_bytes(o)
} }
_ => { std::net::IpAddr::V6(v6) => {
let udp = tokio::net::UdpSocket::bind("0.0.0.0:0").await?; let o = v6.octets();
udp.connect((server, port)).await?; u32::from_be_bytes([o[12], o[13], o[14], o[15]])
crate::transport::Transport::Udp(std::sync::Arc::new(udp))
} }
}; };
let session_id = ip_bytes ^ (client_src.port() as u32);
let mut psk = [0u8; 32]; let config = make_initiator_config(session_id, access_key, transport_cfg);
let key_bytes = access_key.as_bytes();
let len = key_bytes.len().min(32);
psk[..len].copy_from_slice(&key_bytes[..len]);
let config = ProtocolConfig {
role: ostp_core::NoiseRole::Initiator,
psk,
session_id: u32::from_ne_bytes([
client_src.ip().to_string().as_bytes().get(0).copied().unwrap_or(0),
client_src.ip().to_string().as_bytes().get(1).copied().unwrap_or(0),
client_src.ip().to_string().as_bytes().get(2).copied().unwrap_or(0),
client_src.ip().to_string().as_bytes().get(3).copied().unwrap_or(0),
]),
handshake_payload: vec![],
max_padding: 0,
padding_strategy: ostp_core::framing::PaddingStrategy::Fixed(0),
obfuscation_key: [0; 8],
max_reorder: 4096,
max_reorder_buffer: 2048,
ack_delay_ms: 50,
rto_ms: 200,
max_retries: 3,
max_sent_history: 8192,
handshake_pad_min: 8,
handshake_pad_max: 24,
mtu: 1400,
};
let mut machine = ProtocolMachine::new(config)?; let mut machine = ProtocolMachine::new(config)?;
// Send initial packet with UDP payload // Send handshake first
if let Ok(action) = machine.on_event(OstpEvent::Start) { if let Ok(action) = machine.on_event(OstpEvent::Start) {
handle_udp_action(action, &transport).await; handle_udp_action(action, &transport).await;
} }
// Send the actual UDP payload // Wait for handshake response (server sends HandshakePayload back)
let relay_msg = ostp_core::relay::RelayMessage::Connect(format!("{}:{}", target_dst.ip(), target_dst.port())); let mut buf = [0u8; 8192];
let encoded = relay_msg.encode(); match tokio::time::timeout(
std::time::Duration::from_millis(15000),
transport.recv(&mut buf),
).await {
Ok(Ok(n)) => {
let _ = machine.on_event(OstpEvent::Inbound(bytes::Bytes::copy_from_slice(&buf[..n])));
}
_ => {
tracing::warn!("OSTP handshake timeout for {}:{}", server, port);
return Ok(());
}
}
// Send relay UdpAssociate + data
let assoc_msg = ostp_core::relay::RelayMessage::UdpAssociate;
let encoded = assoc_msg.encode();
if let Ok(action) = machine.on_event(OstpEvent::Outbound(1, bytes::Bytes::from(encoded))) { if let Ok(action) = machine.on_event(OstpEvent::Outbound(1, bytes::Bytes::from(encoded))) {
handle_udp_action(action, &transport).await; handle_udp_action(action, &transport).await;
} }
// Send data packet let data_msg = ostp_core::relay::RelayMessage::UdpData(
let data_msg = ostp_core::relay::RelayMessage::Data(payload.to_vec()); format!("{}:{}", target_dst.ip(), target_dst.port()),
payload.to_vec()
);
let encoded = data_msg.encode(); let encoded = data_msg.encode();
if let Ok(action) = machine.on_event(OstpEvent::Outbound(1, bytes::Bytes::from(encoded))) { if let Ok(action) = machine.on_event(OstpEvent::Outbound(1, bytes::Bytes::from(encoded))) {
handle_udp_action(action, &transport).await; handle_udp_action(action, &transport).await;
@ -165,13 +297,15 @@ pub async fn handle_udp(
// Keep-alive for a short time to receive response // Keep-alive for a short time to receive response
for _ in 0..5 { for _ in 0..5 {
let mut buf = [0u8; 8192];
match tokio::time::timeout( match tokio::time::timeout(
std::time::Duration::from_millis(100), std::time::Duration::from_millis(100),
transport.recv(&mut buf) transport.recv(&mut buf),
).await { ).await {
Ok(Ok(n)) => { Ok(Ok(n)) => {
let _ = machine.on_event(OstpEvent::Inbound(bytes::Bytes::copy_from_slice(&buf[..n]))); if let Ok(action) = machine.on_event(OstpEvent::Inbound(bytes::Bytes::copy_from_slice(&buf[..n]))) {
// Just process incoming UDP response internally
let _ = action;
}
} }
_ => break, _ => break,
} }
@ -180,6 +314,76 @@ pub async fn handle_udp(
Ok(()) Ok(())
} }
async fn make_transport(
transport_cfg: &TransportConfig,
server: &str,
port: u16,
) -> Result<crate::transport::Transport> {
let debug = tracing::enabled!(tracing::Level::DEBUG);
match transport_cfg.r#type.as_str() {
"dns" => {
let domain = transport_cfg.domain.clone()
.unwrap_or_else(|| "tunnel.example.com".to_string());
let pubkey = transport_cfg.pubkey.clone()
.unwrap_or_else(|| "".to_string());
let resolver = transport_cfg.resolver.clone()
.unwrap_or_else(|| server.to_string());
let resolver_with_port = if resolver.contains(':') {
resolver.clone()
} else {
format!("{}:53", resolver)
};
let (local_port, process) = ostp_core::dnstt::spawn_client(&pubkey, &domain, &resolver_with_port, debug)?;
// Wait for dnstt-client to start its local TCP listener
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// Connect TCP to the local dnstt-client port
let stream = tokio::net::TcpStream::connect(("127.0.0.1", local_port)).await?;
let (mut rh, mut wh) = stream.into_split();
let (tx_send, mut tx_recv) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
let (rx_send, rx_recv) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
// Writer task
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
while let Some(data) = tx_recv.recv().await {
let len = data.len() as u16;
if wh.write_u16(len).await.is_err() { break; }
if wh.write_all(&data).await.is_err() { break; }
}
});
// Reader task
tokio::spawn(async move {
use tokio::io::AsyncReadExt;
loop {
let len = match rh.read_u16().await {
Ok(l) => l,
Err(_) => break,
};
let mut buf = vec![0u8; len as usize];
if rh.read_exact(&mut buf).await.is_err() { break; }
if rx_send.send(bytes::Bytes::from(buf)).await.is_err() { break; }
}
});
Ok(crate::transport::Transport::Dnstt {
tx: tx_send,
rx: std::sync::Arc::new(tokio::sync::Mutex::new(rx_recv)),
_guard: std::sync::Arc::new(tokio::sync::Mutex::new(process)),
})
}
_ => {
let udp = tokio::net::UdpSocket::bind("0.0.0.0:0").await?;
udp.connect((server, port)).await?;
Ok(crate::transport::Transport::Udp(std::sync::Arc::new(udp)))
}
}
}
async fn handle_udp_action(action: ProtocolAction, transport: &crate::transport::Transport) { async fn handle_udp_action(action: ProtocolAction, transport: &crate::transport::Transport) {
match action { match action {
ProtocolAction::SendDatagram(data) => { ProtocolAction::SendDatagram(data) => {
@ -202,17 +406,53 @@ async fn handle_action(action: ProtocolAction, transport: &crate::transport::Tra
let _ = transport.send(&data).await; let _ = transport.send(&data).await;
} }
ProtocolAction::DeliverApp(_stream_id, payload) => { ProtocolAction::DeliverApp(_stream_id, payload) => {
let _ = server_stream.write_all(&payload).await; if let Ok(msg) = ostp_core::relay::RelayMessage::decode(&payload) {
match msg {
ostp_core::relay::RelayMessage::Data(data) => {
let _ = server_stream.write_all(&data).await;
}
ostp_core::relay::RelayMessage::ConnectOk => {
tracing::debug!("TCP Connection established successfully");
}
ostp_core::relay::RelayMessage::Error(err) => {
tracing::warn!("Server returned TCP connection error: {}", err);
}
_ => {}
}
}
} }
ProtocolAction::Multiple(actions) => { ProtocolAction::Multiple(actions) => {
for a in actions { for a in actions {
match a { Box::pin(handle_action(a, transport, server_stream)).await;
ProtocolAction::SendDatagram(data) => { let _ = transport.send(&data).await; }
ProtocolAction::DeliverApp(_stream_id, payload) => { let _ = server_stream.write_all(&payload).await; }
_ => {}
}
} }
} }
_ => {} _ => {}
} }
} }
/// Inspect a ProtocolAction for ConnectOk / Error relay messages.
/// Returns Some(true) on ConnectOk, Some(false) on Error, None if neither.
/// Works recursively through Multiple actions.
fn check_connect_result(action: &ProtocolAction) -> Option<bool> {
match action {
ProtocolAction::DeliverApp(_stream_id, payload) => {
if let Ok(msg) = ostp_core::relay::RelayMessage::decode(payload) {
match msg {
ostp_core::relay::RelayMessage::ConnectOk => return Some(true),
ostp_core::relay::RelayMessage::Error(_) => return Some(false),
_ => {}
}
}
None
}
ProtocolAction::Multiple(actions) => {
for a in actions {
if let Some(result) = check_connect_result(a) {
return Some(result);
}
}
None
}
_ => None,
}
}

View File

@ -126,7 +126,6 @@ pub fn get_process_name_from_port(port: u16) -> Option<String> {
use std::fs; use std::fs;
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
let mut target_inode = None;
let hex_port = format!("{:04X}", port); let hex_port = format!("{:04X}", port);
let check_net_file = |path: &str| -> Option<u64> { let check_net_file = |path: &str| -> Option<u64> {
@ -146,12 +145,11 @@ pub fn get_process_name_from_port(port: u16) -> Option<String> {
None None
}; };
target_inode = check_net_file("/proc/net/tcp") let target_inode = check_net_file("/proc/net/tcp")
.or_else(|| check_net_file("/proc/net/tcp6")) .or_else(|| check_net_file("/proc/net/tcp6"))
.or_else(|| check_net_file("/proc/net/udp")) .or_else(|| check_net_file("/proc/net/udp"))
.or_else(|| check_net_file("/proc/net/udp6")); .or_else(|| check_net_file("/proc/net/udp6"))?;
let target_inode = target_inode?;
let socket_str = format!("socket:[{}]", target_inode); let socket_str = format!("socket:[{}]", target_inode);
for entry in fs::read_dir("/proc").ok()?.filter_map(Result::ok) { for entry in fs::read_dir("/proc").ok()?.filter_map(Result::ok) {

View File

@ -4,6 +4,12 @@
//! bandwidth and minimum RTT to determine the optimal sending rate. //! bandwidth and minimum RTT to determine the optimal sending rate.
//! This replaces the fixed `retransmit_budget = 8` with an adaptive //! This replaces the fixed `retransmit_budget = 8` with an adaptive
//! congestion window that responds to network conditions. //! congestion window that responds to network conditions.
//!
//! RTO calculation follows RFC 6298:
//! SRTT = (1 - α) * SRTT + α * RTT (α = 1/8)
//! RTTVAR = (1 - β) * RTTVAR + β * |SRTT - RTT| (β = 1/4)
//! RTO = SRTT + 4 * RTTVAR
//! clamped to [RTO_MIN, RTO_MAX]
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@ -15,8 +21,14 @@ pub struct CongestionController {
ssthresh: u64, ssthresh: u64,
/// Current phase /// Current phase
phase: Phase, phase: Phase,
/// Minimum RTT observed /// Minimum RTT observed (for BBR-style bandwidth estimation)
min_rtt: Duration, min_rtt: Duration,
/// Smoothed RTT (RFC 6298 SRTT)
srtt: Duration,
/// RTT variance (RFC 6298 RTTVAR)
rttvar: Duration,
/// Whether we have received a first RTT sample
rtt_initialized: bool,
/// Bytes currently in flight (unacknowledged) /// Bytes currently in flight (unacknowledged)
bytes_in_flight: u64, bytes_in_flight: u64,
/// Total bytes acknowledged (for bandwidth estimation) /// Total bytes acknowledged (for bandwidth estimation)
@ -37,31 +49,43 @@ pub struct CongestionController {
enum Phase { enum Phase {
/// Exponential growth until loss or ssthresh /// Exponential growth until loss or ssthresh
SlowStart, SlowStart,
/// Probe bandwidth: cycle through pacing gains /// Probe bandwidth: additive increase
ProbeBandwidth, ProbeBandwidth,
} }
/// Initial congestion window: 10 packets × MTU /// Initial congestion window: 32 packets × MTU (IW10 is too conservative for modern links)
const INITIAL_CWND_PACKETS: u64 = 10; const INITIAL_CWND_PACKETS: u64 = 32;
/// Minimum cwnd: 2 packets /// Minimum cwnd: 2 packets
const MIN_CWND_PACKETS: u64 = 2; const MIN_CWND_PACKETS: u64 = 2;
/// Min RTT expiry window (after which we re-probe) /// Min RTT expiry window (after which we re-probe)
const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10); const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10);
/// Minimum RTO (RFC 6298: 1s in TCP; we use 50ms since we own the protocol)
const RTO_MIN: Duration = Duration::from_millis(50);
/// Maximum RTO
const RTO_MAX: Duration = Duration::from_secs(16);
/// Initial RTT estimate — 30 ms is reasonable for a well-connected VPN server.
/// Will be replaced by first real measurement within milliseconds.
const INITIAL_RTT: Duration = Duration::from_millis(30);
impl CongestionController { impl CongestionController {
pub fn new(mtu: u64) -> Self { pub fn new(mtu: u64) -> Self {
let now = Instant::now(); let now = Instant::now();
let initial_cwnd = INITIAL_CWND_PACKETS * mtu; let initial_cwnd = INITIAL_CWND_PACKETS * mtu;
// Initial pacing: deliver cwnd in ~2 RTTs to fill the pipe quickly
let initial_pacing = initial_cwnd * 1_000_000 / INITIAL_RTT.as_micros().max(1) as u64;
Self { Self {
cwnd: initial_cwnd, cwnd: initial_cwnd,
ssthresh: u64::MAX, ssthresh: u64::MAX,
phase: Phase::SlowStart, phase: Phase::SlowStart,
min_rtt: Duration::from_millis(100), // Conservative initial estimate min_rtt: INITIAL_RTT,
srtt: INITIAL_RTT,
rttvar: INITIAL_RTT / 2,
rtt_initialized: false,
bytes_in_flight: 0, bytes_in_flight: 0,
total_acked: 0, total_acked: 0,
last_ack_time: now, last_ack_time: now,
loss_count: 0, loss_count: 0,
pacing_rate: initial_cwnd * 10, // initial: ~10 windows/sec pacing_rate: initial_pacing,
mtu, mtu,
min_rtt_stamp: now, min_rtt_stamp: now,
} }
@ -82,9 +106,20 @@ impl CongestionController {
self.pacing_rate self.pacing_rate
} }
/// Returns the smoothed RTT estimate. /// Returns the smoothed RTT estimate (SRTT).
pub fn smoothed_rtt(&self) -> Duration { pub fn smoothed_rtt(&self) -> Duration {
self.min_rtt self.srtt
}
/// Returns the adaptive RTO computed per RFC 6298:
/// RTO = SRTT + 4 * RTTVAR, clamped to [RTO_MIN, RTO_MAX].
///
/// This replaces the static `rto_ms` field in ProtocolMachine so that
/// retransmit timers automatically track changing network conditions.
pub fn rto(&self) -> Duration {
let rttvar4 = self.rttvar.saturating_mul(4);
let rto = self.srtt.saturating_add(rttvar4);
rto.clamp(RTO_MIN, RTO_MAX)
} }
/// Returns how many bytes can still be sent. /// Returns how many bytes can still be sent.
@ -115,16 +150,13 @@ impl CongestionController {
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes); self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes);
self.total_acked = self.total_acked.saturating_add(bytes); self.total_acked = self.total_acked.saturating_add(bytes);
// Update RTT // Update RTT measurements
self.update_rtt(rtt, now); self.update_rtt(rtt, now);
// Update bandwidth estimate
self.update_bandwidth(bytes, now);
// State machine // State machine
match self.phase { match self.phase {
Phase::SlowStart => { Phase::SlowStart => {
// Exponential growth: increase cwnd by acked bytes // Exponential growth: increase cwnd by acked bytes (doubles per RTT)
self.cwnd = self.cwnd.saturating_add(bytes); self.cwnd = self.cwnd.saturating_add(bytes);
if self.cwnd >= self.ssthresh { if self.cwnd >= self.ssthresh {
self.phase = Phase::ProbeBandwidth; self.phase = Phase::ProbeBandwidth;
@ -164,32 +196,49 @@ impl CongestionController {
self.update_pacing_rate(); self.update_pacing_rate();
} }
/// Called periodically to update state.
pub fn on_tick(&mut self) {
// Nothing special needed per-tick -- state updates happen on ACK/loss
}
// ── Private ────────────────────────────────────────────────────────────── // ── Private ──────────────────────────────────────────────────────────────
fn update_rtt(&mut self, rtt: Duration, now: Instant) { fn update_rtt(&mut self, rtt: Duration, now: Instant) {
// Track windowed minimum RTT // Update windowed minimum RTT (for pacing)
if rtt < self.min_rtt || now.duration_since(self.min_rtt_stamp) >= MIN_RTT_EXPIRY { if rtt < self.min_rtt || now.duration_since(self.min_rtt_stamp) >= MIN_RTT_EXPIRY {
self.min_rtt = rtt; self.min_rtt = rtt;
self.min_rtt_stamp = now; self.min_rtt_stamp = now;
} }
}
fn update_bandwidth(&mut self, _acked_bytes: u64, now: Instant) { // Update SRTT and RTTVAR per RFC 6298
let elapsed = now.duration_since(self.last_ack_time); if !self.rtt_initialized {
if elapsed.as_micros() > 0 { // First measurement: initialize directly
// Removed bw_samples tracking self.srtt = rtt;
self.rttvar = rtt / 2;
self.rtt_initialized = true;
} else {
// RTTVAR = (3/4) * RTTVAR + (1/4) * |SRTT - R|
let diff = if rtt > self.srtt {
rtt - self.srtt
} else {
self.srtt - rtt
};
// Integer-safe: RTTVAR = RTTVAR - RTTVAR/4 + diff/4
self.rttvar = self.rttvar
.saturating_sub(self.rttvar / 4)
.saturating_add(diff / 4);
// SRTT = (7/8) * SRTT + (1/8) * R
self.srtt = self.srtt
.saturating_sub(self.srtt / 8)
.saturating_add(rtt / 8);
} }
tracing::trace!(
srtt_ms = self.srtt.as_millis(),
rttvar_ms = self.rttvar.as_millis(),
rto_ms = self.rto().as_millis(),
"congestion: RTT updated"
);
} }
fn update_pacing_rate(&mut self) { fn update_pacing_rate(&mut self) {
// Pacing rate = cwnd / min_rtt (with gain) // Pacing rate = cwnd / min_rtt (delivery rate target)
let rtt_us = self.min_rtt.as_micros().max(1) as u64; let rtt_us = self.min_rtt.as_micros().max(1) as u64;
self.pacing_rate = self.cwnd * 1_000_000 / rtt_us; self.pacing_rate = self.cwnd * 1_000_000 / rtt_us;
} }
@ -202,19 +251,18 @@ mod tests {
#[test] #[test]
fn test_initial_state() { fn test_initial_state() {
let cc = CongestionController::new(1200); let cc = CongestionController::new(1200);
assert_eq!(cc.cwnd(), 12000); // 10 * 1200 assert_eq!(cc.cwnd(), 32 * 1200); // 32 * 1200
assert!(cc.can_send()); assert!(cc.can_send());
assert_eq!(cc.cwnd_packets(), 10); assert_eq!(cc.cwnd_packets(), 32);
} }
#[test] #[test]
fn test_slow_start_growth() { fn test_slow_start_growth() {
let mut cc = CongestionController::new(1200); let mut cc = CongestionController::new(1200);
// Simulate sending and ACKing let initial = cc.cwnd();
cc.on_send(1200); cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(50)); cc.on_ack(1200, Duration::from_millis(50));
// cwnd should grow assert!(cc.cwnd() > initial);
assert!(cc.cwnd() > 12000);
} }
#[test] #[test]
@ -229,7 +277,7 @@ mod tests {
fn test_can_send_limits() { fn test_can_send_limits() {
let mut cc = CongestionController::new(1200); let mut cc = CongestionController::new(1200);
// Send until cwnd is exhausted // Send until cwnd is exhausted
for _ in 0..10 { for _ in 0..32 {
cc.on_send(1200); cc.on_send(1200);
} }
assert!(!cc.can_send()); // cwnd exhausted assert!(!cc.can_send()); // cwnd exhausted
@ -244,10 +292,46 @@ mod tests {
} }
#[test] #[test]
fn test_rtt_tracking() { fn test_rtt_tracking_first_sample() {
let mut cc = CongestionController::new(1200); let mut cc = CongestionController::new(1200);
cc.on_send(1200); cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(25)); cc.on_ack(1200, Duration::from_millis(25));
// After first sample: SRTT = 25ms, RTTVAR = 12ms
assert_eq!(cc.smoothed_rtt(), Duration::from_millis(25)); assert_eq!(cc.smoothed_rtt(), Duration::from_millis(25));
} }
#[test]
fn test_rto_rfc6298() {
let mut cc = CongestionController::new(1200);
// After first sample with RTT=50ms: SRTT=50ms, RTTVAR=25ms, RTO=150ms
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(50));
let rto = cc.rto();
// RTO = 50 + 4*25 = 150ms; clamped to [50ms, 16s]
assert!(rto >= RTO_MIN);
assert!(rto <= RTO_MAX);
assert_eq!(rto, Duration::from_millis(150));
}
#[test]
fn test_rto_clamp_min() {
let cc = CongestionController::new(1200);
// Even with no RTT samples, RTO should not go below RTO_MIN
assert!(cc.rto() >= RTO_MIN);
}
#[test]
fn test_rto_adapts_after_multiple_samples() {
let mut cc = CongestionController::new(1200);
// Feed several consistent RTT samples
for _ in 0..8 {
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(20));
}
// After convergence, RTTVAR should be small → RTO close to SRTT + small margin
let rto = cc.rto();
// Should be well below 100ms (the old hardcoded default)
assert!(rto < Duration::from_millis(200));
assert!(rto >= RTO_MIN);
}
} }

View File

@ -1,5 +1,5 @@
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
use std::io::{Cursor, Read, Write}; use std::io::{Cursor, Read};
const BASE32_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; const BASE32_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
@ -136,13 +136,17 @@ impl DnsPacket {
qtype: rtype.clone(), qtype: rtype.clone(),
qclass: 1, // IN qclass: 1, // IN
}], }],
answers: vec![DnsAnswer { answers: if rdata.is_empty() {
name: name.to_string(), vec![]
rtype, } else {
rclass: 1, vec![DnsAnswer {
ttl: 0, // No caching name: name.to_string(),
rdata, rtype,
}], rclass: 1,
ttl: 0, // No caching
rdata,
}]
},
} }
} }

View File

@ -6,6 +6,7 @@ pub mod relay;
pub mod resumption; pub mod resumption;
pub mod dns; pub mod dns;
pub mod dns_prober; pub mod dns_prober;
pub mod dnstt;
pub use crypto::NoiseRole; pub use crypto::NoiseRole;
pub use framing::{TrafficProfile, PaddingStrategy}; pub use framing::{TrafficProfile, PaddingStrategy};

View File

@ -2,7 +2,7 @@ use bytes::Bytes;
use rand::Rng; use rand::Rng;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use thiserror::Error; use thiserror::Error;
use std::collections::{BTreeMap, VecDeque}; use std::collections::BTreeMap;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::congestion::CongestionController; use crate::congestion::CongestionController;
@ -75,7 +75,7 @@ pub struct ProtocolMachine {
send_nonce: u64, send_nonce: u64,
expected_recv_nonce: u64, expected_recv_nonce: u64,
reorder_buffer: BTreeMap<u64, ProtocolAction>, reorder_buffer: BTreeMap<u64, ProtocolAction>,
sent_history: VecDeque<SentFrame>, sent_history: BTreeMap<u64, SentFrame>,
session_id: u32, session_id: u32,
handshake_payload: Vec<u8>, handshake_payload: Vec<u8>,
padder: AdaptivePadder, padder: AdaptivePadder,
@ -83,7 +83,8 @@ pub struct ProtocolMachine {
max_reorder: u64, max_reorder: u64,
max_reorder_buffer: usize, max_reorder_buffer: usize,
ack_delay: Duration, ack_delay: Duration,
rto: Duration, /// Initial/fallback RTO from config (overridden by cc.rto() after first RTT sample)
rto_initial: Duration,
max_retries: u8, max_retries: u8,
max_sent_history: usize, max_sent_history: usize,
ack_pending: bool, ack_pending: bool,
@ -100,11 +101,11 @@ pub struct ProtocolMachine {
/// Key-derived handshake padding range /// Key-derived handshake padding range
handshake_pad_min: usize, handshake_pad_min: usize,
handshake_pad_max: usize, handshake_pad_max: usize,
_mtu: usize,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct SentFrame { struct SentFrame {
#[allow(dead_code)] // mirrored in BTreeMap key; kept for Debug output
nonce: u64, nonce: u64,
bytes: Bytes, bytes: Bytes,
last_sent: Instant, last_sent: Instant,
@ -128,7 +129,7 @@ impl ProtocolMachine {
send_nonce: 0, send_nonce: 0,
expected_recv_nonce: 0, expected_recv_nonce: 0,
reorder_buffer: BTreeMap::new(), reorder_buffer: BTreeMap::new(),
sent_history: VecDeque::with_capacity(config.max_sent_history.max(1)), sent_history: BTreeMap::new(),
session_id: config.session_id, session_id: config.session_id,
handshake_payload: config.handshake_payload, handshake_payload: config.handshake_payload,
padder: AdaptivePadder::new(config.mtu, config.max_padding, config.padding_strategy), padder: AdaptivePadder::new(config.mtu, config.max_padding, config.padding_strategy),
@ -136,7 +137,7 @@ impl ProtocolMachine {
max_reorder: config.max_reorder.max(1), max_reorder: config.max_reorder.max(1),
max_reorder_buffer: config.max_reorder_buffer.max(1), max_reorder_buffer: config.max_reorder_buffer.max(1),
ack_delay: Duration::from_millis(config.ack_delay_ms.max(1)), ack_delay: Duration::from_millis(config.ack_delay_ms.max(1)),
rto: Duration::from_millis(config.rto_ms.max(1)), rto_initial: Duration::from_millis(config.rto_ms.max(1)),
max_retries: config.max_retries.max(1), max_retries: config.max_retries.max(1),
max_sent_history: config.max_sent_history.max(1), max_sent_history: config.max_sent_history.max(1),
ack_pending: false, ack_pending: false,
@ -146,20 +147,25 @@ impl ProtocolMachine {
cc: CongestionController::new(config.mtu as u64), cc: CongestionController::new(config.mtu as u64),
handshake_pad_min: config.handshake_pad_min.max(8), handshake_pad_min: config.handshake_pad_min.max(8),
handshake_pad_max: config.handshake_pad_max.max(config.handshake_pad_min + 16), handshake_pad_max: config.handshake_pad_max.max(config.handshake_pad_min + 16),
_mtu: config.mtu,
}) })
} }
pub fn in_flight_count(&self) -> usize { pub fn in_flight_count(&self) -> usize {
// COUNT ONLY retransmittable Data frames — control frames (Ack/Nack) must not // COUNT ONLY retransmittable Data frames — control frames (Ack/Nack) must not
// contribute to this counter or they will trigger false backpressure. // contribute to this counter or they will trigger false backpressure.
self.sent_history.iter().filter(|f| f.is_retransmittable).count() self.sent_history.values().filter(|f| f.is_retransmittable).count()
} }
pub fn cwnd_packets(&self) -> usize { pub fn cwnd_packets(&self) -> usize {
self.cc.cwnd_packets() as usize self.cc.cwnd_packets() as usize
} }
/// Returns the current adaptive RTO (from congestion controller after first RTT sample,
/// falls back to the config-specified initial value before any ACK is received).
pub fn rto(&self) -> Duration {
self.cc.rto()
}
pub fn on_send(&mut self, bytes: u64) { pub fn on_send(&mut self, bytes: u64) {
self.cc.on_send(bytes); self.cc.on_send(bytes);
} }
@ -207,13 +213,12 @@ impl ProtocolMachine {
.map(ProtocolAction::SendDatagram) .map(ProtocolAction::SendDatagram)
} }
(OstpState::Closing, OstpEvent::Inbound(raw)) => { (OstpState::Closing, OstpEvent::Inbound(raw)) => {
// Process final in-flight packets to prevent data loss during teardown. // The remote may still have data or ACKs in transit.
// The remote may still have data or ACKs in transit when we initiated Close. // handle_inbound transitions to Closed when it receives a Close frame.
let result = self.handle_inbound(raw); self.handle_inbound(raw)
self.state = OstpState::Closed;
result
} }
(OstpState::Established, OstpEvent::Tick) => self.handle_tick(), (OstpState::Established, OstpEvent::Tick) => self.handle_tick(),
(OstpState::Closing, OstpEvent::Tick) => self.handle_tick(),
(OstpState::Closed, _) => Ok(ProtocolAction::Noop), (OstpState::Closed, _) => Ok(ProtocolAction::Noop),
(_, OstpEvent::Close) => { (_, OstpEvent::Close) => {
self.state = OstpState::Closed; self.state = OstpState::Closed;
@ -392,18 +397,26 @@ impl ProtocolMachine {
self.last_recv_advance = Instant::now(); self.last_recv_advance = Instant::now();
} else { } else {
// Gap detected // Gap detected
if self.reorder_buffer.len() < self.max_reorder_buffer { if self.reorder_buffer.len() >= self.max_reorder_buffer {
self.reorder_buffer.insert(nonce, action); tracing::warn!("Reorder buffer full ({}/{}), dropping new frame nonce={} to wait for recovery of nonce={}",
} else { self.reorder_buffer.len(), self.max_reorder_buffer, nonce, self.expected_recv_nonce
tracing::warn!("Reorder buffer full ({}/{}), dropping frame nonce={}",
self.reorder_buffer.len(), self.max_reorder_buffer, nonce
); );
} }
// Rate-limited NACK: send at most once per 30ms to prevent retransmit storms. if nonce >= self.expected_recv_nonce {
// Under high load with natural UDP reordering, sending a NACK per packet if self.reorder_buffer.len() < self.max_reorder_buffer {
// causes exponential retransmit explosion that saturates the channel. self.reorder_buffer.insert(nonce, action);
let nack_cooldown = Duration::from_millis(30); } else {
tracing::warn!("Reorder buffer still full after gap recovery, dropping frame nonce={}", nonce);
}
} else {
tracing::debug!("Frame nonce={} arrived too late after gap recovery, dropping", nonce);
}
// Rate-limited NACK: send at most once per (rto/2) to prevent retransmit storms.
// Using rto/2 means we send a NACK before the sender's timer fires, prompting
// fast retransmit without flooding. Floor at 10ms to handle very low-RTT links.
let nack_cooldown = (self.cc.rto() / 2).max(Duration::from_millis(10));
if self.last_nack_sent.elapsed() >= nack_cooldown { if self.last_nack_sent.elapsed() >= nack_cooldown {
self.last_nack_sent = Instant::now(); self.last_nack_sent = Instant::now();
let nack_payload = self.expected_recv_nonce.to_be_bytes(); let nack_payload = self.expected_recv_nonce.to_be_bytes();
@ -511,68 +524,45 @@ impl ProtocolMachine {
fn handle_tick(&mut self) -> Result<ProtocolAction, ProtocolError> { fn handle_tick(&mut self) -> Result<ProtocolAction, ProtocolError> {
let mut actions = Vec::new(); let mut actions = Vec::new();
// ── Gap Recovery ──────────────────────────────────────────────
// If expected_recv_nonce hasn't advanced for 500ms+ and there
// are buffered frames waiting, the sender likely evicted the lost
// frame from sent_history. Skip the gap to restore data flow.
// This trades a small amount of data loss for connection liveness.
if !self.reorder_buffer.is_empty()
&& self.last_recv_advance.elapsed() > Duration::from_millis(500)
{
if let Some(&first_buffered) = self.reorder_buffer.keys().next() {
let skipped = first_buffered.saturating_sub(self.expected_recv_nonce);
self.expected_recv_nonce = first_buffered;
self.last_recv_advance = Instant::now();
let mut delivered = 0u64;
while let Some(buffered_action) = self.reorder_buffer.remove(&self.expected_recv_nonce) {
actions.push(buffered_action);
self.expected_recv_nonce = self.expected_recv_nonce.saturating_add(1);
delivered += 1;
}
self.ack_pending = true;
tracing::debug!("Gap recovery: skipped {} lost frames, delivered {} buffered frames (reorder_buf={})",
skipped, delivered, self.reorder_buffer.len()
);
}
}
// ── Pending ACK flush ───────────────────────────────────────── // ── Pending ACK flush ─────────────────────────────────────────
if let Some(ack_frame) = self.build_ack_if_due()? { if let Some(ack_frame) = self.build_ack_if_due()? {
actions.push(ProtocolAction::SendDatagram(ack_frame)); actions.push(ProtocolAction::SendDatagram(ack_frame));
} }
let now = Instant::now(); let now = Instant::now();
let base_rto_ms = self.rto.as_millis().max(1) as u64; // Use the adaptive RTO from the congestion controller (RFC 6298 SRTT + 4*RTTVAR).
// Falls back to rto_initial before the first ACK is received.
let base_rto = self.cc.rto().max(self.rto_initial);
let base_rto_ms = base_rto.as_millis().max(1) as u64;
// ── Zombie frame eviction ──────────────────────────────────── // ── Zombie frame eviction ────────────────────────────────────
// Evict frames that exceeded max_retries + 2 grace retries. // Evict frames that exceeded max_retries + 2 grace retries.
// Shorter grace period than before (was +4) to free memory faster
// after high-throughput bursts.
let grace = self.max_retries.saturating_add(2); let grace = self.max_retries.saturating_add(2);
let before = self.sent_history.len(); let before = self.sent_history.len();
self.sent_history.retain(|f| !f.is_retransmittable || f.retries <= grace); self.sent_history.retain(|_, f| !f.is_retransmittable || f.retries <= grace);
let evicted = before - self.sent_history.len(); let evicted = before - self.sent_history.len();
if evicted > 0 { if evicted > 0 {
tracing::debug!("Evicted {} zombie frames from sent_history (remaining={})", evicted, self.sent_history.len()); tracing::debug!("Evicted {} zombie frames from sent_history (remaining={})", evicted, self.sent_history.len());
} }
// ── Retransmit expired frames ──────────────────────────────── // ── Retransmit expired frames ────────────────────────────────
// Limit retransmits per tick to prevent bandwidth saturation // Backoff starts from retry #0 (immediately effective):
// effective_rto = base_rto * 2^retries, capped at 2^6 = 64×
// This ensures we do not flood with retransmits on the first few losses
// while still recovering quickly on a transient single loss.
let mut retransmit_budget: usize = self.cc.retransmit_budget(); let mut retransmit_budget: usize = self.cc.retransmit_budget();
for frame in self.sent_history.iter_mut() { for frame in self.sent_history.values_mut() {
if !frame.is_retransmittable { if !frame.is_retransmittable {
continue; continue;
} }
let retry_over = frame.retries.saturating_sub(self.max_retries); let backoff_factor = 1u64 << (frame.retries as u64).min(6);
let backoff_factor = 1u64 << retry_over.min(6);
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor)); let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor));
if now.duration_since(frame.last_sent) >= effective_rto { if now.duration_since(frame.last_sent) >= effective_rto {
frame.last_sent = now; frame.last_sent = now;
frame.retries = frame.retries.saturating_add(1); frame.retries = frame.retries.saturating_add(1);
if retransmit_budget > 0 { if retransmit_budget > 0 {
actions.push(ProtocolAction::SendDatagram(frame.bytes.clone())); actions.push(ProtocolAction::SendDatagram(frame.bytes.clone()));
retransmit_budget -= 1; retransmit_budget -= 1;
@ -672,7 +662,7 @@ impl ProtocolMachine {
} }
fn lookup_sent_frame(&mut self, nonce: u64) -> Option<Bytes> { 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) { if let Some(frame) = self.sent_history.get_mut(&nonce) {
frame.last_sent = Instant::now(); frame.last_sent = Instant::now();
frame.retries = frame.retries.saturating_add(1); frame.retries = frame.retries.saturating_add(1);
return Some(frame.bytes.clone()); return Some(frame.bytes.clone());
@ -684,7 +674,7 @@ impl ProtocolMachine {
if is_retransmittable { if is_retransmittable {
self.cc.on_send(bytes.len() as u64); self.cc.on_send(bytes.len() as u64);
} }
self.sent_history.push_back(SentFrame { self.sent_history.insert(nonce, SentFrame {
nonce, nonce,
bytes, bytes,
last_sent: Instant::now(), last_sent: Instant::now(),
@ -697,7 +687,7 @@ impl ProtocolMachine {
overflow, self.max_sent_history overflow, self.max_sent_history
); );
while self.sent_history.len() > self.max_sent_history { while self.sent_history.len() > self.max_sent_history {
self.sent_history.pop_front(); self.sent_history.pop_first();
} }
} }
} }
@ -708,8 +698,8 @@ impl ProtocolMachine {
let mut min_rtt = Duration::from_secs(60); let mut min_rtt = Duration::from_secs(60);
// Compute RTT from the oldest acked frame's send timestamp // Compute RTT from the oldest acked frame's send timestamp
for frame in self.sent_history.iter() { for (&nonce, frame) in &self.sent_history {
if nonce_in_ranges(frame.nonce, ranges) { if nonce_in_ranges(nonce, ranges) {
acked_bytes += frame.bytes.len() as u64; acked_bytes += frame.bytes.len() as u64;
let rtt = now.duration_since(frame.last_sent); let rtt = now.duration_since(frame.last_sent);
if rtt < min_rtt { if rtt < min_rtt {
@ -718,7 +708,7 @@ impl ProtocolMachine {
} }
} }
self.sent_history.retain(|frame| !nonce_in_ranges(frame.nonce, ranges)); self.sent_history.retain(|&nonce, _| !nonce_in_ranges(nonce, ranges));
// Notify congestion controller // Notify congestion controller
if acked_bytes > 0 { if acked_bytes > 0 {

View File

@ -527,8 +527,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (_dnsDomainCtrl.text.trim().isNotEmpty) { if (_dnsDomainCtrl.text.trim().isNotEmpty) {
queryParams.add('domain=${Uri.encodeComponent(_dnsDomainCtrl.text.trim())}'); queryParams.add('domain=${Uri.encodeComponent(_dnsDomainCtrl.text.trim())}');
} }
if (_dnsRegion != 'Global') { final resolver = _dnsRegionCtrl.text.trim();
queryParams.add('region=${Uri.encodeComponent(_dnsRegion)}'); if (resolver.isNotEmpty && resolver != '1.1.1.1') {
queryParams.add('resolver=${Uri.encodeComponent(resolver)}');
} }
if (_pbkCtrl.text.trim().isNotEmpty) { if (_pbkCtrl.text.trim().isNotEmpty) {
queryParams.add('pbk=${Uri.encodeComponent(_pbkCtrl.text.trim())}'); queryParams.add('pbk=${Uri.encodeComponent(_pbkCtrl.text.trim())}');

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.3.9+22 version: 0.3.12+25
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.4

View File

@ -2665,7 +2665,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-client" name = "ostp-client"
version = "0.3.9" version = "0.3.12"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",
@ -2700,7 +2700,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-core" name = "ostp-core"
version = "0.3.9" version = "0.3.12"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"byteorder", "byteorder",
@ -2742,7 +2742,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-tun" name = "ostp-tun"
version = "0.3.9" version = "0.3.12"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc", "libc",

View File

@ -1,41 +1,3 @@
use anyhow::{anyhow, Result}; // Re-export the shared IPC crypto from ostp-client so that GUI and tun-helper
use chacha20poly1305::{ChaCha20Poly1305, Nonce}; // always use identical encrypt/decrypt logic.
use chacha20poly1305::aead::{Aead, KeyInit}; pub use ostp_client::ipc_crypto::{derive_key, IpcCrypto};
use sha2::{Sha256, Digest};
pub struct IpcCrypto {
cipher: ChaCha20Poly1305,
nonce: [u8; 12],
}
impl IpcCrypto {
pub fn new(key: &[u8; 32]) -> Self {
let cipher = ChaCha20Poly1305::new_from_slice(key)
.expect("valid key size");
let nonce = [0u8; 12];
Self { cipher, nonce }
}
pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
let nonce = Nonce::from_slice(&self.nonce);
let ciphertext = self.cipher.encrypt(nonce, plaintext)
.map_err(|e| anyhow!("Encryption failed: {}", e))?;
Ok(ciphertext)
}
pub fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>> {
let nonce = Nonce::from_slice(&self.nonce);
let plaintext = self.cipher.decrypt(nonce, ciphertext)
.map_err(|e| anyhow!("Decryption failed: {}", e))?;
Ok(plaintext)
}
}
pub fn derive_key(token: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let result = hasher.finalize();
let mut key = [0u8; 32];
key.copy_from_slice(&result);
key
}

View File

@ -40,7 +40,7 @@ struct UIMetrics {
#[serde(tag = "type", rename_all = "lowercase")] #[serde(tag = "type", rename_all = "lowercase")]
enum HelperMsg { enum HelperMsg {
Status { value: u8 }, Status { value: u8 },
Log { message: String }, Log { #[allow(dead_code)] message: String },
Metrics { bytes_sent: u64, bytes_recv: u64, rtt_ms: u32 }, Metrics { bytes_sent: u64, bytes_recv: u64, rtt_ms: u32 },
Error { message: String }, Error { message: String },
} }
@ -59,6 +59,7 @@ struct HelperState {
pipe_state: Arc<Mutex<HelperPipeState>>, pipe_state: Arc<Mutex<HelperPipeState>>,
cmd_tx: tokio::sync::mpsc::Sender<String>, cmd_tx: tokio::sync::mpsc::Sender<String>,
token: String, token: String,
#[allow(dead_code)]
port: u16, port: u16,
} }

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ostp-gui", "productName": "ostp-gui",
"version": "0.3.9", "version": "0.3.12",
"identifier": "com.ospab.ostp", "identifier": "com.ospab.ostp",
"build": { "build": {
"frontendDist": "../src" "frontendDist": "../src"

View File

@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{api::ApiConfig, fallback::FallbackConfig, outbound::OutboundConfig, dns::DnsConfig}; use crate::{fallback::FallbackConfig, dns::DnsConfig};
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(tag = "protocol", rename_all = "snake_case")] #[serde(tag = "protocol", rename_all = "snake_case")]

View File

@ -12,12 +12,13 @@ use portable_atomic::AtomicU64;
// const MAX_SESSIONS removed because dynamic limit is used // const MAX_SESSIONS removed because dynamic limit is used
pub enum DispatchOutcome { pub enum DispatchOutcome {
Unauthorized,
Accepted { Accepted {
responses: Vec<Bytes>, responses: Vec<Bytes>,
app_payloads: Vec<(u32, u16, Bytes)>, // session_id, stream_id, payload app_payloads: Vec<(u32, u16, Bytes)>, // session_id, stream_id, payload
peer_addr: SocketAddr, peer_addr: SocketAddr,
}, },
Unauthorized(String),
Ignored,
} }
/// Per-user traffic statistics. /// Per-user traffic statistics.
@ -83,7 +84,6 @@ pub struct Dispatcher {
last_token_regen: std::time::Instant, last_token_regen: std::time::Instant,
} }
#[allow(dead_code)]
impl Dispatcher { impl Dispatcher {
pub fn new(machine_config: ProtocolConfig, access_keys: Arc<RwLock<HashMap<String, crate::api::UserMeta>>>) -> Self { pub fn new(machine_config: ProtocolConfig, access_keys: Arc<RwLock<HashMap<String, crate::api::UserMeta>>>) -> Self {
let mut initial_stats = HashMap::new(); let mut initial_stats = HashMap::new();
@ -108,6 +108,7 @@ impl Dispatcher {
} }
/// Snapshot all user stats for API responses. /// Snapshot all user stats for API responses.
#[allow(dead_code)]
pub fn snapshot_all_users(&self) -> Vec<UserStatsSnapshot> { pub fn snapshot_all_users(&self) -> Vec<UserStatsSnapshot> {
let stats = self.user_stats.read().unwrap_or_else(|e| e.into_inner()); let stats = self.user_stats.read().unwrap_or_else(|e| e.into_inner());
let mut online_keys: HashMap<String, std::time::Instant> = HashMap::new(); let mut online_keys: HashMap<String, std::time::Instant> = HashMap::new();
@ -161,6 +162,7 @@ impl Dispatcher {
} }
/// Set traffic limit for a user. /// Set traffic limit for a user.
#[allow(dead_code)]
pub fn set_user_limit(&self, key: &str, limit: Option<u64>) { pub fn set_user_limit(&self, key: &str, limit: Option<u64>) {
let mut stats = self.user_stats.write().unwrap_or_else(|e| e.into_inner()); let mut stats = self.user_stats.write().unwrap_or_else(|e| e.into_inner());
let entry = stats.entry(key.to_string()) let entry = stats.entry(key.to_string())
@ -176,13 +178,14 @@ impl Dispatcher {
} }
/// Active session count. /// Active session count.
#[allow(dead_code)]
pub fn active_sessions(&self) -> usize { pub fn active_sessions(&self) -> usize {
self.peer_machines.len() self.peer_machines.len()
} }
pub fn on_datagram(&mut self, peer: SocketAddr, packet: Bytes) -> Result<DispatchOutcome> { pub fn on_datagram(&mut self, peer: SocketAddr, packet: Bytes) -> Result<DispatchOutcome> {
if packet.len() < 4 { if packet.len() < 4 {
return Ok(DispatchOutcome::Unauthorized); return Ok(DispatchOutcome::Unauthorized("packet too short".to_string()));
} }
let mut session_id_opt = None; let mut session_id_opt = None;
@ -239,7 +242,7 @@ impl Dispatcher {
tracing::info!("Dropping session {} for key {} (valid={}, over_limit={})", tracing::info!("Dropping session {} for key {} (valid={}, over_limit={})",
session_id, access_key, key_valid, user_stats.is_over_limit()); session_id, access_key, key_valid, user_stats.is_over_limit());
self.drop_session(session_id); self.drop_session(session_id);
return Ok(DispatchOutcome::Unauthorized); return Ok(DispatchOutcome::Unauthorized("key invalid or over limit".to_string()));
} }
} }
@ -260,8 +263,7 @@ impl Dispatcher {
let action = match peer_state.machine.on_event(OstpEvent::Inbound(packet)) { let action = match peer_state.machine.on_event(OstpEvent::Inbound(packet)) {
Ok(a) => a, Ok(a) => a,
Err(e) => { Err(e) => {
tracing::warn!("Protocol error for session {}: {}", session_id, e); return Ok(DispatchOutcome::Unauthorized(format!("protocol error: {}", e)));
return Ok(DispatchOutcome::Unauthorized);
} }
}; };
@ -303,13 +305,17 @@ impl Dispatcher {
// Not an existing session — try each registered access key's derived obfuscation key // Not an existing session — try each registered access key's derived obfuscation key
let keys_snapshot: Vec<String> = self.access_keys.read().unwrap_or_else(|e| e.into_inner()).keys().cloned().collect(); let keys_snapshot: Vec<String> = self.access_keys.read().unwrap_or_else(|e| e.into_inner()).keys().cloned().collect();
let mut failed_trials = Vec::new();
for candidate_key in keys_snapshot { for candidate_key in keys_snapshot {
let secrets = ostp_core::crypto::derive_all_secrets(candidate_key.as_bytes()); let secrets = ostp_core::crypto::derive_all_secrets(candidate_key.as_bytes());
// Decode the session_id using this key's obfuscation // Decode the session_id using this key's obfuscation
// The handshake mask is derived from the Noise payload at bytes [6..], // The handshake mask is derived from the Noise payload at bytes [6..],
// so we must deobfuscate the full packet, not just the header. // so we must deobfuscate the full packet, not just the header.
if packet.len() < 7 { continue; } if packet.len() < 7 {
failed_trials.push(format!("key {}: packet too short", candidate_key));
continue;
}
let mut trial = packet.to_vec(); let mut trial = packet.to_vec();
ostp_core::crypto::deobfuscate_packet_inplace(&mut trial, &secrets.obfuscation_key, true); ostp_core::crypto::deobfuscate_packet_inplace(&mut trial, &secrets.obfuscation_key, true);
let candidate_session_id = u32::from_be_bytes([trial[0], trial[1], trial[2], trial[3]]); let candidate_session_id = u32::from_be_bytes([trial[0], trial[1], trial[2], trial[3]]);
@ -331,7 +337,10 @@ impl Dispatcher {
}; };
let action = match machine.on_event(OstpEvent::Inbound(packet.clone())) { let action = match machine.on_event(OstpEvent::Inbound(packet.clone())) {
Ok(a) => a, Ok(a) => a,
Err(_) => continue, Err(e) => {
failed_trials.push(format!("key {}: crypto err: {}", candidate_key, e));
continue;
}
}; };
if let ProtocolAction::HandshakePayload(payload, response_opt) = action { if let ProtocolAction::HandshakePayload(payload, response_opt) = action {
@ -345,6 +354,7 @@ impl Dispatcher {
let sid_from_payload = u32::from_be_bytes(sid_bytes); let sid_from_payload = u32::from_be_bytes(sid_bytes);
if sid_from_payload != candidate_session_id { if sid_from_payload != candidate_session_id {
failed_trials.push(format!("key {}: sid mismatch", candidate_key));
continue; continue;
} }
@ -352,6 +362,7 @@ impl Dispatcher {
if let Ok(key_from_payload) = std::str::from_utf8(key_bytes) { 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 // The key embedded in the payload must match the candidate key we decoded with
if key_from_payload != candidate_key { if key_from_payload != candidate_key {
failed_trials.push(format!("key {}: embedded key mismatch", candidate_key));
continue; continue;
} }
@ -362,19 +373,25 @@ impl Dispatcher {
let drift = (now as i64 - ts as i64).abs(); let drift = (now as i64 - ts as i64).abs();
if drift > 300 { if drift > 300 {
tracing::warn!("Handshake rejected: timestamp drift {}s exceeds 300s limit (peer={})", drift, peer); let reason = format!("timestamp drift {}s exceeds 300s limit", drift);
tracing::warn!("Handshake rejected for {}: {}", peer, reason);
failed_trials.push(format!("key {}: {}", candidate_key, reason));
continue; continue;
} }
if !self.replay_cache.contains_key(&payload.to_vec()) { if self.replay_cache.contains_key(&payload.to_vec()) {
if self.replay_cache.len() >= 50_000 { tracing::debug!("Replay detected from {}, ignoring", peer);
tracing::warn!("Replay cache full (100000 entries), rejecting handshake from {}", peer); return Ok(DispatchOutcome::Ignored);
return Ok(DispatchOutcome::Unauthorized); }
}
self.replay_cache.insert(payload.to_vec(), ts); if self.replay_cache.len() >= 50_000 {
tracing::warn!("Replay cache full (50000 entries), rejecting handshake from {}", peer);
return Ok(DispatchOutcome::Unauthorized("replay cache full".to_string()));
}
machine.set_session_keys(candidate_session_id, secrets.obfuscation_key); self.replay_cache.insert(payload.to_vec(), ts);
machine.set_session_keys(candidate_session_id, secrets.obfuscation_key);
// Track per-user connection count // Track per-user connection count
let user_stats = self.get_or_create_user_stats(&candidate_key); let user_stats = self.get_or_create_user_stats(&candidate_key);
@ -383,7 +400,7 @@ impl Dispatcher {
// Check traffic limit before accepting // Check traffic limit before accepting
if user_stats.is_over_limit() { if user_stats.is_over_limit() {
tracing::warn!("User {} exceeded traffic limit, rejecting handshake from {}", candidate_key, peer); tracing::warn!("User {} exceeded traffic limit, rejecting handshake from {}", candidate_key, peer);
return Ok(DispatchOutcome::Unauthorized); return Ok(DispatchOutcome::Unauthorized("user over traffic limit".to_string()));
} }
self.peer_machines.insert(candidate_session_id, PeerState { self.peer_machines.insert(candidate_session_id, PeerState {
@ -404,32 +421,49 @@ impl Dispatcher {
app_payloads: Vec::new(), app_payloads: Vec::new(),
peer_addr: peer, peer_addr: peer,
}); });
}
} }
} }
} }
} }
Ok(DispatchOutcome::Unauthorized) let reason = if failed_trials.is_empty() {
"no valid handshake payload found".to_string()
} else {
format!("all key trials failed: {}", failed_trials.join(", "))
};
Ok(DispatchOutcome::Unauthorized(reason))
} }
pub fn outbound_to_session(&mut self, session_id: u32, stream_id: u16, payload: Bytes) -> Result<Option<(Bytes, SocketAddr)>> { pub fn outbound_to_session(&mut self, session_id: u32, stream_id: u16, payload: Bytes) -> Result<Vec<(Bytes, SocketAddr)>> {
let peer_state = if let Some(existing) = self.peer_machines.get_mut(&session_id) { let peer_state = if let Some(existing) = self.peer_machines.get_mut(&session_id) {
existing existing
} else { } else {
return Ok(None); return Ok(Vec::new());
}; };
let addr = peer_state.last_addr; let addr = peer_state.last_addr;
let key = peer_state.access_key.clone(); let key = peer_state.access_key.clone();
match peer_state.machine.on_event(OstpEvent::Outbound(stream_id, payload))? { let action = peer_state.machine.on_event(OstpEvent::Outbound(stream_id, payload))?;
ProtocolAction::SendDatagram(frame) => {
// Track outbound bytes per user let mut frames = Vec::new();
track_user_bytes_down(&self.user_stats, &self.access_keys, &key, frame.len() as u64); let mut queue = vec![action];
Ok(Some((frame, addr))) while let Some(current) = queue.pop() {
match current {
ProtocolAction::Multiple(list) => {
for item in list {
queue.push(item);
}
}
ProtocolAction::SendDatagram(frame) => {
track_user_bytes_down(&self.user_stats, &self.access_keys, &key, frame.len() as u64);
frames.push((frame, addr));
}
_ => {}
} }
_ => Ok(None),
} }
Ok(frames)
} }
pub fn on_tick(&mut self) -> (Vec<(Bytes, SocketAddr)>, Vec<u32>) { pub fn on_tick(&mut self) -> (Vec<(Bytes, SocketAddr)>, Vec<u32>) {
@ -443,7 +477,7 @@ impl Dispatcher {
let mut frames = Vec::new(); let mut frames = Vec::new();
let mut expired = Vec::new(); let mut expired = Vec::new();
let now = std::time::Instant::now(); let now = std::time::Instant::now();
let timeout_dur = std::time::Duration::from_secs(600); // 10 minute session timeout (mobile NAT can be up to 5-10min) let timeout_dur = std::time::Duration::from_secs(600); // 10-minute session timeout (mobile NAT mappings can live 510 min)
// Gather expired or invalid sessions // Gather expired or invalid sessions
for (&sid, peer_state) in &self.peer_machines { for (&sid, peer_state) in &self.peer_machines {
@ -461,7 +495,7 @@ impl Dispatcher {
let key_valid = self.access_keys.read().unwrap_or_else(|e| e.into_inner()).contains_key(&ps.access_key); let key_valid = self.access_keys.read().unwrap_or_else(|e| e.into_inner()).contains_key(&ps.access_key);
let user_stats = self.get_or_create_user_stats(&ps.access_key); let user_stats = self.get_or_create_user_stats(&ps.access_key);
if now.duration_since(ps.last_seen) > timeout_dur { if now.duration_since(ps.last_seen) > timeout_dur {
"inactive >5min" "inactive >10min"
} else if !key_valid { } else if !key_valid {
"key deleted" "key deleted"
} else if user_stats.is_over_limit() { } else if user_stats.is_over_limit() {

View File

@ -1,15 +1,23 @@
use anyhow::Result; use anyhow::Result;
use bytes::Bytes; use bytes::Bytes;
use std::collections::HashMap; use std::collections::{HashMap, VecDeque};
use std::net::IpAddr; use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use dispatcher::{DispatchOutcome, Dispatcher}; use dispatcher::{DispatchOutcome, Dispatcher};
use ostp_core::relay::RelayMessage; use ostp_core::relay::RelayMessage;
use signal::wait_for_shutdown_signal; use signal::wait_for_shutdown_signal;
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
use tokio::sync::mpsc; use tokio::sync::{mpsc, RwLock};
use tokio::time::{interval, Duration, Instant}; use tokio::time::{interval, Duration, Instant};
use std::sync::OnceLock;
pub fn dns_queue() -> &'static Arc<RwLock<HashMap<SocketAddr, VecDeque<Bytes>>>> {
static DNS_QUEUE: OnceLock<Arc<RwLock<HashMap<SocketAddr, VecDeque<Bytes>>>>> = OnceLock::new();
DNS_QUEUE.get_or_init(|| Arc::new(RwLock::new(HashMap::new())))
}
mod dispatcher; mod dispatcher;
pub mod outbound; pub mod outbound;
pub mod fallback; pub mod fallback;
@ -45,7 +53,7 @@ pub(crate) enum UiEvent {
PeerSeen { peer: IpAddr }, PeerSeen { peer: IpAddr },
#[allow(dead_code)] Rx { peer: IpAddr, bytes: usize }, #[allow(dead_code)] Rx { peer: IpAddr, bytes: usize },
#[allow(dead_code)] Tx { peer: IpAddr, bytes: usize }, #[allow(dead_code)] Tx { peer: IpAddr, bytes: usize },
UnauthorizedProbe { peer: IpAddr, bytes: usize }, UnauthorizedProbe { peer: IpAddr, bytes: usize, reason: String },
KeyCreated { key: String }, KeyCreated { key: String },
Log(String), Log(String),
#[allow(dead_code)] #[allow(dead_code)]
@ -120,6 +128,29 @@ pub async fn run_server(
let dispatcher = Dispatcher::new(protocol_config, shared_keys.clone()); let dispatcher = Dispatcher::new(protocol_config, shared_keys.clone());
// Launch dnstt-server if configured
let _dnstt_guard = if let Some(dns) = &dns_transport {
let pub_ip = server_public_ip.clone().unwrap_or_else(|| {
let p = config_path.as_ref()
.and_then(|p| p.parent())
.unwrap_or_else(|| std::path::Path::new("."))
.join(".ostp_public_ip");
std::fs::read_to_string(p).unwrap_or_else(|_| "127.0.0.1".to_string()).trim().to_string()
});
match ostp_core::dnstt::spawn_server(&pub_ip, 50000, &dns.privkey, debug) {
Ok(guard) => {
tracing::info!("dnstt-server initialized on {}:53 with pubkey: {}", pub_ip, dns.pubkey);
Some(guard)
}
Err(e) => {
tracing::error!("Failed to initialize dnstt-server: {}", e);
None
}
}
} else {
None
};
// Background config hot-reloader for access keys // Background config hot-reloader for access keys
let shared_keys_clone = shared_keys.clone(); let shared_keys_clone = shared_keys.clone();
let user_stats_clone = dispatcher.user_stats_ref(); let user_stats_clone = dispatcher.user_stats_ref();
@ -159,7 +190,12 @@ pub async fn run_server(
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
#[serde(untagged)] #[serde(untagged)]
enum ReloadUser { enum ReloadUser {
Detailed { access_key: String, name: Option<String>, limit_bytes: Option<u64> }, Detailed {
#[serde(rename = "key")]
access_key: String,
name: Option<String>,
limit_bytes: Option<u64>
},
KeyOnly(String), KeyOnly(String),
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@ -167,6 +203,8 @@ pub async fn run_server(
mode: String, mode: String,
#[serde(default)] #[serde(default)]
access_keys: Vec<ReloadUser>, access_keys: Vec<ReloadUser>,
#[serde(default)]
inbounds: Vec<serde_json::Value>,
} }
let mut stripped = json_comments::StripComments::new(content.as_bytes()); let mut stripped = json_comments::StripComments::new(content.as_bytes());
@ -181,7 +219,23 @@ pub async fn run_server(
Ok(cfg) => { Ok(cfg) => {
if cfg.mode == "server" { if cfg.mode == "server" {
let mut new_keys = HashMap::new(); let mut new_keys = HashMap::new();
for uc in cfg.access_keys { let mut all_users = cfg.access_keys;
for inbound in cfg.inbounds {
if let Some(protocol) = inbound.get("protocol").and_then(|p| p.as_str()) {
if protocol == "ostp" {
if let Some(users) = inbound.get("users").and_then(|u| u.as_array()) {
for u in users {
if let Ok(ru) = serde_json::from_value::<ReloadUser>(u.clone()) {
all_users.push(ru);
}
}
}
}
}
}
for uc in all_users {
let (k, m) = match uc { let (k, m) = match uc {
ReloadUser::Detailed { access_key, name, limit_bytes } => (access_key, crate::api::UserMeta { name, limit_bytes }), ReloadUser::Detailed { access_key, name, limit_bytes } => (access_key, crate::api::UserMeta { name, limit_bytes }),
ReloadUser::KeyOnly(k) => (k, crate::api::UserMeta { name: None, limit_bytes: None }), ReloadUser::KeyOnly(k) => (k, crate::api::UserMeta { name: None, limit_bytes: None }),
@ -305,10 +359,9 @@ pub async fn run_server(
UiEvent::KeyCreated { key } => { UiEvent::KeyCreated { key } => {
tracing::info!("Access key created: {key}"); tracing::info!("Access key created: {key}");
} }
UiEvent::UnauthorizedProbe { peer, bytes } => { UiEvent::UnauthorizedProbe { peer, bytes, reason } => {
if debug { // Make it a warn so it's always visible outside debug mode!
tracing::debug!("Unauthorized probe from {peer} ({bytes} bytes)"); tracing::warn!("Unauthorized probe from {peer} ({bytes} bytes): {reason}");
}
} }
UiEvent::PeerSeen { .. } => {} UiEvent::PeerSeen { .. } => {}
_ => {} _ => {}
@ -433,17 +486,9 @@ async fn run_server_loop(
if let Some(dns_cfg) = dns_transport { if let Some(dns_cfg) = dns_transport {
if dns_cfg.enabled { if dns_cfg.enabled {
let dns_udp_tx = udp_tx.clone(); // DNS transport is now handled entirely by dnstt-server launched at startup.
let dns_tcp_map = tcp_map.clone(); // We just trace it here.
let dns_ui_tx = ui_event_tx.clone(); tracing::info!("DNS Transport via dnstt is enabled");
tokio::spawn(async move {
crate::transport::dns::start_dns_transport_server(
dns_cfg,
dns_udp_tx,
dns_tcp_map,
dns_ui_tx,
).await;
});
} }
} }
@ -553,8 +598,8 @@ async fn handle_udp_packet(
) -> Result<()> { ) -> Result<()> {
let size = packet.len(); let size = packet.len();
match dispatcher.on_datagram(peer, packet) { match dispatcher.on_datagram(peer, packet) {
Ok(DispatchOutcome::Unauthorized) => { Ok(DispatchOutcome::Unauthorized(reason)) => {
let _ = ui_event_tx.send(UiEvent::UnauthorizedProbe { peer: peer.ip(), bytes: size }); let _ = ui_event_tx.send(UiEvent::UnauthorizedProbe { peer: peer.ip(), bytes: size, reason });
} }
Ok(DispatchOutcome::Accepted { responses, app_payloads, peer_addr }) => { Ok(DispatchOutcome::Accepted { responses, app_payloads, peer_addr }) => {
let peer_ip = peer_addr.ip(); let peer_ip = peer_addr.ip();
@ -563,7 +608,11 @@ async fn handle_udp_packet(
if !peer_available.get(&peer_ip).copied().unwrap_or(false) { if !peer_available.get(&peer_ip).copied().unwrap_or(false) {
peer_available.insert(peer_ip, true); peer_available.insert(peer_ip, true);
let is_tcp = tcp_map.read().await.contains_key(&peer_addr); let is_tcp = tcp_map.read().await.contains_key(&peer_addr);
let proto = if is_tcp { "TCP (UoT)" } else { "UDP" }; let is_dns = match peer_ip {
std::net::IpAddr::V4(v4) => v4.octets()[0] == 10 && v4.octets()[1] == 255,
_ => false,
};
let proto = if is_dns { "DNS-tunnel" } else if is_tcp { "TCP (UoT)" } else { "UDP" };
let _ = ui_event_tx.send(UiEvent::Log(format!("Client {peer_ip} connected via {proto}"))); let _ = ui_event_tx.send(UiEvent::Log(format!("Client {peer_ip} connected via {proto}")));
} }
@ -587,7 +636,21 @@ async fn handle_udp_packet(
} }
} }
if !sent_tcp { if !sent_tcp {
let _ = socket.send_to(&resp, peer_addr).await?; // Check if this is a DNS tunnel virtual IP (10.255.x.x)
let is_dns_ip = match peer_addr.ip() {
std::net::IpAddr::V4(v4) => v4.octets()[0] == 10 && v4.octets()[1] == 255,
_ => false,
};
if is_dns_ip {
// Queue the packet for the next DNS poll query
let mut dq = crate::dns_queue().write().await;
let queue = dq.entry(peer_addr).or_insert_with(std::collections::VecDeque::new);
if queue.len() < 256 {
queue.push_back(resp);
}
} else {
let _ = socket.send_to(&resp, peer_addr).await?;
}
} }
let _ = ui_event_tx.send(UiEvent::Tx { peer: peer_ip, bytes: resp_len }); let _ = ui_event_tx.send(UiEvent::Tx { peer: peer_ip, bytes: resp_len });
} }
@ -614,6 +677,9 @@ async fn handle_udp_packet(
).await?; ).await?;
} }
} }
Ok(DispatchOutcome::Ignored) => {
// Handshake replay, safely ignored
}
Err(err) => { Err(err) => {
let _ = ui_event_tx.send(UiEvent::Log(format!("Protocol error for {peer}: {err}"))); let _ = ui_event_tx.send(UiEvent::Log(format!("Protocol error for {peer}: {err}")));
} }
@ -650,7 +716,19 @@ async fn handle_tick(
} }
} }
if !sent_tcp { if !sent_tcp {
let _ = socket.send_to(&frame, peer_addr).await?; let is_dns_ip = match peer_addr.ip() {
std::net::IpAddr::V4(v4) => v4.octets()[0] == 10 && v4.octets()[1] == 255,
_ => false,
};
if is_dns_ip {
let mut dq = crate::dns_queue().write().await;
let queue = dq.entry(peer_addr).or_insert_with(std::collections::VecDeque::new);
if queue.len() < 256 {
queue.push_back(frame);
}
} else {
let _ = socket.send_to(&frame, peer_addr).await;
}
} }
} }
for sid in dropped_sessions { for sid in dropped_sessions {

View File

@ -247,18 +247,58 @@ pub async fn send_relay_to_stream(
tcp_map: &std::sync::Arc<tokio::sync::RwLock<HashMap<std::net::SocketAddr, tokio::sync::mpsc::Sender<Bytes>>>>, tcp_map: &std::sync::Arc<tokio::sync::RwLock<HashMap<std::net::SocketAddr, tokio::sync::mpsc::Sender<Bytes>>>>,
) -> Result<()> { ) -> Result<()> {
let payload = Bytes::from(msg.encode()); let payload = Bytes::from(msg.encode());
if let Some((frame, peer_addr)) = dispatcher.outbound_to_session(session_id, stream_id, payload)? { for (frame, peer_addr) in dispatcher.outbound_to_session(session_id, stream_id, payload)? {
let response_len = frame.len(); let response_len = frame.len();
let mut sent_tcp = false; let mut sent_tcp = false;
{ {
let map = tcp_map.read().await; let map = tcp_map.read().await;
if let Some(tx) = map.get(&peer_addr) { if let Some(tx) = map.get(&peer_addr) {
let _ = tx.try_send(frame.clone()); // Use a bounded async send with a generous timeout instead of try_send.
sent_tcp = true; // try_send silently drops frames when the channel is full (common with
// bursty traffic), causing spurious retransmits and throughput collapse.
// 200ms matches roughly one RTO — if we can't deliver in that window
// the receiver is definitely stalled and we should log it.
let tx = tx.clone();
let frame_clone = frame.clone();
match tokio::time::timeout(
std::time::Duration::from_millis(200),
tx.send(frame_clone),
).await {
Ok(Ok(())) => { sent_tcp = true; }
Ok(Err(_)) => {
tracing::warn!(
"relay: TCP channel closed for peer={}, frame dropped (session={}, stream={})",
peer_addr, session_id, stream_id
);
sent_tcp = true; // channel gone, don't fall through to UDP
}
Err(_timeout) => {
tracing::warn!(
"relay: TCP channel full / timeout for peer={}, falling back to UDP (session={}, stream={})",
peer_addr, session_id, stream_id
);
// sent_tcp stays false → will fall through to UDP send below
}
}
} }
} }
if !sent_tcp { if !sent_tcp {
let _ = socket.send_to(&frame, peer_addr).await?; let is_dns_ip = match peer_addr.ip() {
std::net::IpAddr::V4(v4) => v4.octets()[0] == 10 && v4.octets()[1] == 255,
_ => false,
};
if is_dns_ip {
// DNS virtual IP — queue for next poll
let mut dq = crate::dns_queue().write().await;
let queue = dq.entry(peer_addr).or_insert_with(std::collections::VecDeque::new);
if queue.len() < 256 {
queue.push_back(frame);
} else {
tracing::warn!("relay: dns_queue full for peer={}, frame dropped", peer_addr);
}
} else {
let _ = socket.send_to(&frame, peer_addr).await;
}
} }
let _ = ui_event_tx.send(UiEvent::Tx { let _ = ui_event_tx.send(UiEvent::Tx {
peer: peer_addr.ip(), peer: peer_addr.ip(),
@ -267,3 +307,4 @@ pub async fn send_relay_to_stream(
} }
Ok(()) Ok(())
} }

View File

@ -1,113 +1 @@
use std::sync::Arc; // Left empty by request
use tokio::net::UdpSocket;
use tokio::sync::{mpsc, RwLock};
use std::collections::HashMap;
use std::net::SocketAddr;
use bytes::Bytes;
use tokio::time::Duration;
use ostp_core::dns::{DnsPacket, DnsRecordType, decode_domain_to_payload, encode_payload_to_domain};
use crate::config::DnsTransportConfig;
use crate::UiEvent;
pub(crate) async fn start_dns_transport_server(
config: DnsTransportConfig,
udp_tx: mpsc::Sender<(Bytes, SocketAddr)>,
tcp_map: Arc<RwLock<HashMap<SocketAddr, mpsc::Sender<Bytes>>>>,
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
) {
let listen_addr = if config.listen.contains(':') {
config.listen.clone()
} else {
format!("0.0.0.0:{}", config.listen)
};
let socket = match UdpSocket::bind(&listen_addr).await {
Ok(s) => Arc::new(s),
Err(e) => {
tracing::error!("DNS Transport failed to bind to {}: {}", listen_addr, e);
let _ = ui_event_tx.send(UiEvent::Log(format!("DNS Transport failed to bind: {}", e)));
return;
}
};
tracing::info!("DNS Transport listening on {}", listen_addr);
let _ = ui_event_tx.send(UiEvent::Log(format!("DNS Transport listening on {}", listen_addr)));
let mut buf = vec![0u8; 65535];
loop {
match socket.recv_from(&mut buf).await {
Ok((size, peer)) => {
let packet_bytes = buf[..size].to_vec();
let udp_tx = udp_tx.clone();
let tcp_map = tcp_map.clone();
let socket = socket.clone();
let base_domain = config.domain.clone();
tokio::spawn(async move {
if let Some(dns_req) = DnsPacket::decode(&packet_bytes) {
if dns_req.questions.is_empty() { return; }
let query = &dns_req.questions[0];
// Check if it's our target domain and it's a TXT or NULL query
if (query.qtype == DnsRecordType::TXT || query.qtype == DnsRecordType::NULL) && query.name.ends_with(&base_domain) {
// Decode base32 payload
if let Some(payload) = decode_domain_to_payload(&query.name, &base_domain) {
let (resp_tx, mut resp_rx) = mpsc::channel::<Bytes>(10);
// Insert into tcp_map so Dispatcher routes responses to us
tcp_map.write().await.insert(peer, resp_tx);
// Send payload to dispatcher
if udp_tx.send((Bytes::from(payload), peer)).await.is_ok() {
// Wait up to 50ms for any responses
let mut responses = Vec::new();
while let Ok(Some(resp)) = tokio::time::timeout(Duration::from_millis(50), resp_rx.recv()).await {
responses.push(resp);
if responses.len() >= 3 { break; }
}
// Remove from tcp_map
tcp_map.write().await.remove(&peer);
// Build DNS Answer
let mut dns_resp = DnsPacket::new_response(dns_req.id, &query.name, query.qtype.clone(), vec![]);
dns_resp.answers.clear(); // We'll add our own
if !responses.is_empty() {
for r in responses {
dns_resp.answers.push(ostp_core::dns::DnsAnswer {
name: query.name.clone(),
rtype: query.qtype.clone(),
rclass: 1,
ttl: 0,
rdata: r.to_vec(),
});
}
} else {
// Empty response
dns_resp.answers.push(ostp_core::dns::DnsAnswer {
name: query.name.clone(),
rtype: query.qtype.clone(),
rclass: 1,
ttl: 0,
rdata: vec![],
});
}
let resp_encoded = dns_resp.encode();
let _ = socket.send_to(&resp_encoded, peer).await;
}
}
}
}
});
}
Err(e) => {
tracing::warn!("DNS Transport recv error: {}", e);
}
}
}
}

View File

@ -1,2 +1 @@
pub mod uot; pub mod uot;
pub mod dns;

View File

@ -12,7 +12,7 @@ pub enum UiEvent {
PeerSeen { peer: IpAddr }, PeerSeen { peer: IpAddr },
Rx { peer: IpAddr, bytes: usize }, Rx { peer: IpAddr, bytes: usize },
Tx { peer: IpAddr, bytes: usize }, Tx { peer: IpAddr, bytes: usize },
UnauthorizedProbe { peer: IpAddr, bytes: usize }, UnauthorizedProbe { peer: IpAddr, bytes: usize, reason: String },
KeyCreated { key: String }, KeyCreated { key: String },
Log(String), Log(String),
KeyCount(usize), KeyCount(usize),

View File

@ -11,10 +11,11 @@ path = "src/main.rs"
ostp-client = { path = "../ostp-client" } ostp-client = { path = "../ostp-client" }
tokio = { workspace = true } tokio = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
portable-atomic = { workspace = true } portable-atomic = { workspace = true }
chrono = "0.4" hex = "0.4"
[build-dependencies] [build-dependencies]
# no extra build deps needed; manifest is embedded via build.rs # no extra build deps needed; manifest is embedded via build.rs

View File

@ -2,30 +2,16 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use anyhow::Result; use anyhow::Result;
use hex;
use ostp_client::ipc_crypto::{derive_key, IpcCrypto};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use std::io::Write as _;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{watch, Mutex};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::{watch, Mutex};
use portable_atomic::Ordering; use portable_atomic::Ordering;
fn log_to_file(msg: &str) {
let msg = msg.to_string();
tokio::task::spawn_blocking(move || {
let path = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("ostp-helper.log")))
.unwrap_or_else(|| std::path::PathBuf::from("ostp-helper.log"));
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(file, "[{}] {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), msg);
}
});
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(tag = "cmd", rename_all = "lowercase")] #[serde(tag = "cmd", rename_all = "lowercase")]
enum GuiCmd { enum GuiCmd {
@ -72,22 +58,22 @@ async fn main() -> Result<()> {
let path = &args[i + 1]; let path = &args[i + 1];
if let Ok(content) = std::fs::read_to_string(path) { if let Ok(content) = std::fs::read_to_string(path) {
expected_token = content.trim().to_string(); expected_token = content.trim().to_string();
let _ = std::fs::remove_file(path); // securely delete after reading let _ = std::fs::remove_file(path);
} }
} }
} }
log_to_file("Helper started (TCP mode)"); tracing::info!("helper started (TCP mode)");
if expected_token.is_empty() { if expected_token.is_empty() {
log_to_file("FATAL: Auth token is required for security (--token-file or OSTP_TUN_TOKEN)."); tracing::error!("auth token is required (--token-file or OSTP_TUN_TOKEN)");
return Err(anyhow::anyhow!("Auth token is required")); return Err(anyhow::anyhow!("auth token is required"));
} }
if let Err(e) = run_server(expected_token, port).await { if let Err(e) = run_server(expected_token, port).await {
log_to_file(&format!("Fatal error: {}", e)); tracing::error!("fatal: {}", e);
} }
log_to_file("Helper exiting"); tracing::info!("helper exiting");
Ok(()) Ok(())
} }
@ -98,24 +84,26 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
metrics: None, metrics: None,
})); }));
let ipc_key = derive_key(&expected_token);
let crypto = IpcCrypto::new(&ipc_key);
let bind_addr = format!("127.0.0.1:{}", port); let bind_addr = format!("127.0.0.1:{}", port);
log_to_file(&format!("Attempting to bind to {}", bind_addr)); tracing::info!("binding to {}", bind_addr);
let listener = TcpListener::bind(&bind_addr).await.map_err(|e| { let listener = TcpListener::bind(&bind_addr).await.map_err(|e| {
log_to_file(&format!("Bind failed: {}", e)); tracing::error!("bind failed: {}", e);
e e
})?; })?;
log_to_file("Listening successfully"); tracing::info!("listening, waiting for GUI connection");
// Wait for GUI to connect (60 second timeout)
let (socket, _) = match tokio::time::timeout(Duration::from_secs(60), listener.accept()).await { let (socket, _) = match tokio::time::timeout(Duration::from_secs(60), listener.accept()).await {
Ok(Ok(s)) => s, Ok(Ok(s)) => s,
_ => { _ => {
log_to_file("No connection from GUI within 60s, exiting"); tracing::warn!("no connection from GUI within 60s, exiting");
return Ok(()); return Ok(());
} }
}; };
log_to_file("GUI connected via TCP"); tracing::info!("GUI connected");
let (reader_half, writer_half) = tokio::io::split(socket); let (reader_half, writer_half) = tokio::io::split(socket);
let writer = Arc::new(Mutex::new(writer_half)); let writer = Arc::new(Mutex::new(writer_half));
@ -123,12 +111,20 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
let send_msg = { let send_msg = {
let writer = writer.clone(); let writer = writer.clone();
let crypto = crypto.clone();
move |msg: HelperMsg| { move |msg: HelperMsg| {
let writer = writer.clone(); let writer = writer.clone();
let crypto = crypto.clone();
let json = serde_json::to_string(&msg).unwrap_or_default(); let json = serde_json::to_string(&msg).unwrap_or_default();
tokio::spawn(async move { tokio::spawn(async move {
let mut w = writer.lock().await; match crypto.encrypt(json.as_bytes()) {
let _ = w.write_all(format!("{}\n", json).as_bytes()).await; Ok(enc) => {
let line = format!("{}\n", hex::encode(&enc));
let mut w = writer.lock().await;
let _ = w.write_all(line.as_bytes()).await;
}
Err(e) => tracing::error!("send_msg encrypt failed: {}", e),
}
}); });
} }
}; };
@ -138,7 +134,7 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
line.clear(); line.clear();
let n = reader.read_line(&mut line).await.unwrap_or(0); let n = reader.read_line(&mut line).await.unwrap_or(0);
if n == 0 { if n == 0 {
log_to_file("GUI disconnected, stopping tunnel"); tracing::info!("GUI disconnected, stopping tunnel");
let mut st = state.lock().await; let mut st = state.lock().await;
if let Some(tx) = st.shutdown_tx.take() { if let Some(tx) = st.shutdown_tx.take() {
let _ = tx.send(true); let _ = tx.send(true);
@ -149,10 +145,23 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
let trimmed = line.trim(); let trimmed = line.trim();
if trimmed.is_empty() { continue; } if trimmed.is_empty() { continue; }
let cmd: GuiCmd = match serde_json::from_str(trimmed) { // Decrypt the hex-encoded encrypted command from the GUI
let decrypted_json = match hex::decode(trimmed)
.ok()
.and_then(|enc| crypto.decrypt(&enc).ok())
.and_then(|dec| String::from_utf8(dec).ok())
{
Some(s) => s,
None => {
tracing::warn!("received undecodable command, ignoring");
continue;
}
};
let cmd: GuiCmd = match serde_json::from_str(&decrypted_json) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
send_msg(HelperMsg::Error { message: format!("Bad command: {}", e) }); send_msg(HelperMsg::Error { message: format!("bad command: {}", e) });
continue; continue;
} }
}; };
@ -160,11 +169,11 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
match cmd { match cmd {
GuiCmd::Start { config, token } => { GuiCmd::Start { config, token } => {
if token != expected_token { if token != expected_token {
log_to_file("Received START command with invalid token"); tracing::warn!("START command with invalid token");
send_msg(HelperMsg::Error { message: "Invalid authorization token".to_string() }); send_msg(HelperMsg::Error { message: "invalid authorization token".to_string() });
continue; continue;
} }
log_to_file("Received START command"); tracing::info!("received START command");
{ {
let mut st = state.lock().await; let mut st = state.lock().await;
if let Some(tx) = st.shutdown_tx.take() { if let Some(tx) = st.shutdown_tx.take() {
@ -176,8 +185,8 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
let cfg: ostp_client::config::ClientConfig = match serde_json::from_str(&config) { let cfg: ostp_client::config::ClientConfig = match serde_json::from_str(&config) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
log_to_file(&format!("Config parse error: {}", e)); tracing::error!("config parse error: {}", e);
send_msg(HelperMsg::Error { message: format!("Config parse error: {}", e) }); send_msg(HelperMsg::Error { message: format!("config parse error: {}", e) });
continue; continue;
} }
}; };
@ -201,21 +210,26 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
let metrics_for_runner = metrics.clone(); let metrics_for_runner = metrics.clone();
let writer_for_err = writer.clone(); let writer_for_err = writer.clone();
let crypto_for_err = crypto.clone();
let shutdown_rx_for_core = shutdown_rx.clone(); let shutdown_rx_for_core = shutdown_rx.clone();
tokio::spawn(async move { tokio::spawn(async move {
log_to_file("Starting tunnel core..."); tracing::info!("starting tunnel core");
match ostp_client::runner::run_client_core(cfg, metrics_for_runner, shutdown_rx_for_core, Some(config_rx)).await { match ostp_client::runner::run_client_core(cfg, metrics_for_runner, shutdown_rx_for_core, Some(config_rx)).await {
Ok(_) => { log_to_file("Tunnel core stopped normally"); } Ok(_) => tracing::info!("tunnel core stopped normally"),
Err(e) => { Err(e) => {
log_to_file(&format!("Tunnel core error: {}", e)); tracing::error!("tunnel core error: {}", e);
let json = serde_json::to_string(&HelperMsg::Error { message: e.to_string() }).unwrap_or_default(); let json = serde_json::to_string(&HelperMsg::Error { message: e.to_string() })
let mut w = writer_for_err.lock().await; .unwrap_or_default();
let _ = w.write_all(format!("{}\n", json).as_bytes()).await; if let Ok(enc) = crypto_for_err.encrypt(json.as_bytes()) {
let mut w = writer_for_err.lock().await;
let _ = w.write_all(format!("{}\n", hex::encode(&enc)).as_bytes()).await;
}
} }
} }
}); });
let writer_tick = writer.clone(); let writer_tick = writer.clone();
let crypto_tick = crypto.clone();
let metrics_tick = metrics.clone(); let metrics_tick = metrics.clone();
let mut shutdown_rx_tick = shutdown_rx.clone(); let mut shutdown_rx_tick = shutdown_rx.clone();
tokio::spawn(async move { tokio::spawn(async move {
@ -227,21 +241,28 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
if *shutdown_rx_tick.borrow() { break; } if *shutdown_rx_tick.borrow() { break; }
} }
} }
let cs = metrics_tick.connection_state.load(Ordering::Relaxed); let cs = metrics_tick.connection_state.load(Ordering::Relaxed);
let sent = metrics_tick.bytes_sent.load(Ordering::Relaxed); let sent = metrics_tick.bytes_sent.load(Ordering::Relaxed);
let recv = metrics_tick.bytes_recv.load(Ordering::Relaxed); let recv = metrics_tick.bytes_recv.load(Ordering::Relaxed);
let rtt = metrics_tick.rtt_ms.load(Ordering::Relaxed); let rtt = metrics_tick.rtt_ms.load(Ordering::Relaxed);
let mut w = writer_tick.lock().await; let mut msgs: Vec<HelperMsg> = Vec::new();
if cs != last_state { if cs != last_state {
last_state = cs; last_state = cs;
let json = serde_json::to_string(&HelperMsg::Status { value: cs }).unwrap_or_default(); msgs.push(HelperMsg::Status { value: cs });
if w.write_all(format!("{}\n", json).as_bytes()).await.is_err() { break; } }
msgs.push(HelperMsg::Metrics { bytes_sent: sent, bytes_recv: recv, rtt_ms: rtt });
let mut w = writer_tick.lock().await;
for msg in msgs {
let json = serde_json::to_string(&msg).unwrap_or_default();
if let Ok(enc) = crypto_tick.encrypt(json.as_bytes()) {
if w.write_all(format!("{}\n", hex::encode(&enc)).as_bytes()).await.is_err() {
return;
}
}
} }
let json = serde_json::to_string(&HelperMsg::Metrics { bytes_sent: sent, bytes_recv: recv, rtt_ms: rtt }).unwrap_or_default();
if w.write_all(format!("{}\n", json).as_bytes()).await.is_err() { break; }
drop(w); drop(w);
} }
}); });
@ -250,15 +271,15 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
} }
GuiCmd::Reload { config, token } => { GuiCmd::Reload { config, token } => {
if token != expected_token { if token != expected_token {
send_msg(HelperMsg::Error { message: "Invalid authorization token".to_string() }); send_msg(HelperMsg::Error { message: "invalid authorization token".to_string() });
continue; continue;
} }
log_to_file("Received RELOAD command"); tracing::info!("received RELOAD command");
let cfg: ostp_client::config::ClientConfig = match serde_json::from_str(&config) { let cfg: ostp_client::config::ClientConfig = match serde_json::from_str(&config) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
send_msg(HelperMsg::Error { message: format!("Config parse error during reload: {}", e) }); send_msg(HelperMsg::Error { message: format!("config parse error during reload: {}", e) });
continue; continue;
} }
}; };
@ -267,7 +288,7 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
let st = state.lock().await; let st = state.lock().await;
if let Some(tx) = &st.config_tx { if let Some(tx) = &st.config_tx {
let _ = tx.send(cfg); let _ = tx.send(cfg);
log_to_file("Config sent to running core for seamless hot-reload"); tracing::info!("config sent to running core for hot-reload");
} }
} }
@ -275,11 +296,11 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
} }
GuiCmd::Stop { token } => { GuiCmd::Stop { token } => {
if token != expected_token { if token != expected_token {
log_to_file("Received STOP command with invalid token"); tracing::warn!("STOP command with invalid token");
send_msg(HelperMsg::Error { message: "Invalid authorization token".to_string() }); send_msg(HelperMsg::Error { message: "invalid authorization token".to_string() });
continue; continue;
} }
log_to_file("Received STOP command"); tracing::info!("received STOP command");
let mut st = state.lock().await; let mut st = state.lock().await;
if let Some(tx) = st.shutdown_tx.take() { if let Some(tx) = st.shutdown_tx.take() {
let _ = tx.send(true); let _ = tx.send(true);

View File

@ -12,6 +12,8 @@ struct LinuxRouteGuard {
impl Drop for LinuxRouteGuard { impl Drop for LinuxRouteGuard {
fn drop(&mut self) { fn drop(&mut self) {
let _ = Command::new("ip").args(["route", "del", "0.0.0.0/1", "dev", "ostp_tun"]).output();
let _ = Command::new("ip").args(["route", "del", "128.0.0.0/1", "dev", "ostp_tun"]).output();
let _ = Command::new("ip").args(["route", "del", "default", "dev", "ostp_tun"]).output(); let _ = Command::new("ip").args(["route", "del", "default", "dev", "ostp_tun"]).output();
let _ = Command::new("ip").args(["route", "del", &format!("{}/32", self.server_ip)]).output(); let _ = Command::new("ip").args(["route", "del", &format!("{}/32", self.server_ip)]).output();
for route in &self.bypass_routes { for route in &self.bypass_routes {
@ -38,10 +40,6 @@ pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
.mtu(opts.mtu) .mtu(opts.mtu)
.up(); .up();
tun_cfg.platform_config(|cfg| {
cfg.packet_information(false);
});
let dev = tun::create(&tun_cfg).map_err(|e| anyhow!("Failed to create TUN device: {}", e))?; let dev = tun::create(&tun_cfg).map_err(|e| anyhow!("Failed to create TUN device: {}", e))?;
let dev = tun::AsyncDevice::new(dev).map_err(|e| anyhow!("TUN device async failed: {}", e))?; let dev = tun::AsyncDevice::new(dev).map_err(|e| anyhow!("TUN device async failed: {}", e))?;
tracing::info!("TUN device 'ostp_tun' created."); tracing::info!("TUN device 'ostp_tun' created.");
@ -74,7 +72,9 @@ pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
bypass_routes.push(route); bypass_routes.push(route);
} }
let _ = Command::new("ip").args(["route", "add", "default", "dev", "ostp_tun"]).output(); // Override default route gracefully by adding more specific /1 routes
let _ = Command::new("ip").args(["route", "add", "0.0.0.0/1", "dev", "ostp_tun"]).output();
let _ = Command::new("ip").args(["route", "add", "128.0.0.0/1", "dev", "ostp_tun"]).output();
if opts.kill_switch { if opts.kill_switch {
tracing::info!("Kill Switch: deleting original default route to prevent leakage."); tracing::info!("Kill Switch: deleting original default route to prevent leakage.");

View File

@ -21,3 +21,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
ostp-core = { path = "../ostp-core" } ostp-core = { path = "../ostp-core" }
colored = "2.1" colored = "2.1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
pico-args = "0.5.0"
clipboard-win = "3.1.1"

View File

@ -98,19 +98,69 @@ const PUBLIC_DNS_SERVERS: &[(&str, &str)] = &[
("Pishgaman", "5.160.25.25"), ("Pishgaman", "5.160.25.25"),
]; ];
pub async fn run_prober() { pub async fn run_prober(config_path: &std::path::Path) {
println!("Starting DNS resolver prober to find the fastest server for DNS Transport..."); let mut target_domain = String::new();
if config_path.exists() {
if let Ok(content) = std::fs::read_to_string(config_path) {
let mut stripped = json_comments::StripComments::new(content.as_bytes());
if let Ok(json_val) = serde_json::from_reader::<_, serde_json::Value>(&mut stripped) {
// Check if it's a server config
if let Some(inbounds) = json_val.get("inbounds").and_then(|i| i.as_array()) {
for inbound in inbounds {
if inbound.get("protocol").and_then(|p| p.as_str()) == Some("dns") {
if let Some(domain) = inbound.get("domain").and_then(|d| d.as_str()) {
target_domain = domain.to_string();
break;
}
}
}
}
// Check if it's a client config
if target_domain.is_empty() {
if let Some(outbounds) = json_val.get("outbounds").and_then(|o| o.as_array()) {
for outbound in outbounds {
if let Some(transport) = outbound.get("transport") {
if transport.get("type").and_then(|t| t.as_str()) == Some("dns") {
if let Some(domain) = transport.get("domain").and_then(|d| d.as_str()) {
target_domain = domain.to_string();
break;
}
}
}
}
}
}
}
}
}
if target_domain.is_empty() {
println!("Could not find DNS Tunnel configuration in config.json.");
println!("Enter your OSTP DNS Tunnel domain (e.g., tunnel.example.com):");
std::io::stdin().read_line(&mut target_domain).unwrap();
target_domain = target_domain.trim().to_string();
} else {
println!("Found DNS Tunnel domain in config.json: {}", target_domain);
}
if target_domain.is_empty() {
println!("Domain cannot be empty. Exiting prober.");
return;
}
println!("\nStarting DNS resolver prober for domain: {}", target_domain);
println!("{:<15} | {:<15} | {:<10}", "Name", "IP Address", "Latency"); println!("{:<15} | {:<15} | {:<10}", "Name", "IP Address", "Latency");
println!("{:-<15}-+-{:-<15}-+-{:-<10}", "", "", ""); println!("{:-<15}-+-{:-<15}-+-{:-<10}", "", "", "");
let mut best_server = "8.8.8.8"; let mut best_server = "8.8.8.8";
let mut best_latency = Duration::from_secs(10); let mut best_latency = Duration::from_secs(10);
// We send a random TXT query to test DNS resolution time // Send a real OSTP ping packet encoded as a domain
let payload = b"PING";
let encoded_domain = ostp_core::dns::encode_payload_to_domain(payload, &target_domain);
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let id: u16 = rng.gen();
let packet = DnsPacket::new_query(id, "example.com", DnsRecordType::TXT);
let payload = packet.encode();
for (name, ip) in PUBLIC_DNS_SERVERS { for (name, ip) in PUBLIC_DNS_SERVERS {
let sock = match tokio::net::UdpSocket::bind("0.0.0.0:0").await { let sock = match tokio::net::UdpSocket::bind("0.0.0.0:0").await {
@ -122,8 +172,12 @@ pub async fn run_prober() {
continue; continue;
} }
let id: u16 = rng.gen();
let packet = DnsPacket::new_query(id, &encoded_domain, DnsRecordType::TXT);
let payload_bytes = packet.encode();
let start = Instant::now(); let start = Instant::now();
if sock.send(&payload).await.is_ok() { if sock.send(&payload_bytes).await.is_ok() {
let mut buf = [0u8; 512]; let mut buf = [0u8; 512];
match tokio::time::timeout(Duration::from_secs(2), sock.recv(&mut buf)).await { match tokio::time::timeout(Duration::from_secs(2), sock.recv(&mut buf)).await {
Ok(Ok(_)) => { Ok(Ok(_)) => {

View File

@ -55,6 +55,10 @@ struct Args {
#[arg(long, help_heading = "Common Commands")] #[arg(long, help_heading = "Common Commands")]
update: bool, update: bool,
/// Specify a target version for the update command (e.g., -v 0.2.98)
#[arg(short = 'v', long = "version", help_heading = "Common Commands")]
target_version: Option<String>,
/// Import a share link (ostp://...) into the configuration file and exit /// Import a share link (ostp://...) into the configuration file and exit
#[arg(long, help_heading = "Client Commands")] #[arg(long, help_heading = "Client Commands")]
import: Option<String>, import: Option<String>,
@ -76,6 +80,83 @@ struct Args {
prober: bool, prober: bool,
} }
fn patch_existing_client_config(config_path: &std::path::Path, new_client_inner: serde_json::Value) -> serde_json::Value {
let unified_new = serde_json::to_value(UnifiedConfig {
mode: AppMode::Client(new_client_inner.clone()),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
log: Some(serde_json::json!({ "level": "info" })),
}).unwrap();
if !config_path.exists() {
return unified_new;
}
let content = match std::fs::read_to_string(config_path) {
Ok(c) => c,
Err(_) => return unified_new,
};
let mut stripped = json_comments::StripComments::new(content.as_bytes());
let mut existing: serde_json::Value = match serde_json::from_reader(&mut stripped) {
Ok(v) => v,
Err(_) => return unified_new,
};
if existing.get("mode").and_then(|m| m.as_str()) != Some("client") {
return unified_new;
}
let mut new_proxy = None;
if let Some(outbounds) = new_client_inner.get("outbounds").and_then(|o| o.as_array()) {
for ob in outbounds {
if ob.get("tag").and_then(|t| t.as_str()) == Some("proxy") {
new_proxy = Some(ob.clone());
break;
}
}
}
if let Some(new_proxy) = new_proxy {
if let Some(existing_outbounds) = existing.get_mut("outbounds").and_then(|o| o.as_array_mut()) {
let mut replaced = false;
for ob in existing_outbounds.iter_mut() {
if ob.get("tag").and_then(|t| t.as_str()) == Some("proxy") {
*ob = new_proxy.clone();
replaced = true;
break;
}
}
if !replaced {
existing_outbounds.insert(0, new_proxy);
}
} else {
existing["outbounds"] = serde_json::json!([new_proxy]);
}
}
if let Some(new_inbounds) = new_client_inner.get("inbounds").and_then(|i| i.as_array()) {
for new_ib in new_inbounds {
if new_ib.get("type").and_then(|t| t.as_str()) == Some("tun") {
if let Some(auto_route) = new_ib.get("auto_route").and_then(|a| a.as_bool()) {
if auto_route {
if let Some(existing_inbounds) = existing.get_mut("inbounds").and_then(|i| i.as_array_mut()) {
for existing_ib in existing_inbounds.iter_mut() {
if existing_ib.get("type").and_then(|t| t.as_str()) == Some("tun") {
existing_ib["auto_route"] = serde_json::json!(true);
}
}
}
}
}
}
}
}
existing["version"] = serde_json::json!(env!("CARGO_PKG_VERSION"));
existing
}
fn parse_ostp_link(link: &str) -> Result<serde_json::Value> { fn parse_ostp_link(link: &str) -> Result<serde_json::Value> {
let parsed = url::Url::parse(link) let parsed = url::Url::parse(link)
.map_err(|e| anyhow!("Failed to parse share link URL: {e}"))?; .map_err(|e| anyhow!("Failed to parse share link URL: {e}"))?;
@ -127,7 +208,7 @@ fn parse_ostp_link(link: &str) -> Result<serde_json::Value> {
} }
Ok(serde_json::json!({ Ok(serde_json::json!({
"version": "{}", "version": env!("CARGO_PKG_VERSION"),
"log": { "log": {
"level": "info" "level": "info"
}, },
@ -282,6 +363,7 @@ impl UserConfig {
} }
} }
#[allow(dead_code)]
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
struct OutboundConfig { struct OutboundConfig {
enabled: bool, enabled: bool,
@ -293,6 +375,7 @@ struct OutboundConfig {
default_action: Option<String>, default_action: Option<String>,
} }
#[allow(dead_code)]
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
struct OutboundRule { struct OutboundRule {
domain_suffix: Option<Vec<String>>, domain_suffix: Option<Vec<String>>,
@ -301,6 +384,7 @@ struct OutboundRule {
action: Option<String>, action: Option<String>,
} }
#[allow(dead_code)]
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
struct TransportConfigRaw { struct TransportConfigRaw {
mode: Option<String>, mode: Option<String>,
@ -356,6 +440,7 @@ impl ListenConfig {
} }
} }
#[allow(dead_code)]
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
struct ApiConfig { struct ApiConfig {
enabled: Option<bool>, enabled: Option<bool>,
@ -366,6 +451,7 @@ struct ApiConfig {
password_hash: Option<String>, password_hash: Option<String>,
} }
#[allow(dead_code)]
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
struct FallbackCfg { struct FallbackCfg {
enabled: Option<bool>, enabled: Option<bool>,
@ -700,7 +786,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
let client_json = serde_json::json!({ let client_json = serde_json::json!({
"mode": "client", "mode": "client",
"version": "{}", "version": env!("CARGO_PKG_VERSION"),
"log": { "log": {
"level": "info" "level": "info"
}, },
@ -918,7 +1004,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
}; };
wizard_step(4, TOTAL, "Saving configuration"); wizard_step(4, TOTAL, "Saving configuration");
let panel_bind = format!("0.0.0.0:{}", panel_port); let _panel_bind = format!("0.0.0.0:{}", panel_port);
let server_json = serde_json::json!({ let server_json = serde_json::json!({
"mode": "server", "mode": "server",
"version": "{}", "version": "{}",
@ -1120,18 +1206,7 @@ async fn run_app() -> Result<()> {
return cmd_migrate(&args.config); return cmd_migrate(&args.config);
} }
if args.config.exists() && !args.uninstall && !args.update {
if let Ok(config_content) = fs::read_to_string(&args.config) {
let mut stripped = json_comments::StripComments::new(config_content.as_bytes());
if let Ok(raw_json) = serde_json::from_reader::<_, serde_json::Value>(&mut stripped) {
if raw_json.get("version").and_then(|v| v.as_str()) != Some(env!("CARGO_PKG_VERSION")) {
println!("{} Outdated configuration format detected.", "[ostp]".yellow().bold());
println!("{} Please run '{}' to update your configuration to the latest modular format.", "[ostp]".yellow().bold(), "ostp --migrate".green());
std::process::exit(1);
}
}
}
}
// ── Setup wizard: explicit flag or first-time (no config) ──────── // ── Setup wizard: explicit flag or first-time (no config) ────────
if args.setup { if args.setup {
@ -1226,12 +1301,8 @@ async fn run_app() -> Result<()> {
println!("{} Importing configuration from share link...", "[ostp]".cyan().bold()); println!("{} Importing configuration from share link...", "[ostp]".cyan().bold());
let client_cfg = parse_ostp_link(&import_url) let client_cfg = parse_ostp_link(&import_url)
.map_err(|e| anyhow!("Share Link Error: {e}"))?; .map_err(|e| anyhow!("Share Link Error: {e}"))?;
let unified = UnifiedConfig { let patched = patch_existing_client_config(&args.config, client_cfg);
mode: AppMode::Client(client_cfg), let content = serde_json::to_string_pretty(&patched)?;
version: Some("0.3.1".to_string()),
log: Some(serde_json::json!({ "level": "info" })),
};
let content = serde_json::to_string_pretty(&unified)?;
if let Some(parent) = args.config.parent() { if let Some(parent) = args.config.parent() {
if !parent.as_os_str().is_empty() { if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
@ -1303,7 +1374,8 @@ async fn run_app() -> Result<()> {
client_cfg["log"]["level"] = serde_json::json!("debug"); client_cfg["log"]["level"] = serde_json::json!("debug");
} }
return run_client_directly(client_cfg).await; let patched = patch_existing_client_config(&args.config, client_cfg);
return run_client_directly(patched).await;
} }
// Handle --check: validate config and exit // Handle --check: validate config and exit
@ -1320,7 +1392,7 @@ async fn run_app() -> Result<()> {
AppMode::Server(s) => { AppMode::Server(s) => {
println!("{} Config OK: server mode", "[ostp]".green().bold()); println!("{} Config OK: server mode", "[ostp]".green().bold());
let mut keys_count = 0; let mut keys_count = 0;
let mut has_outbound = false; let mut _has_outbound = false;
for inbound in &s.inbounds { for inbound in &s.inbounds {
match inbound { match inbound {
ostp_server::config::ServerInbound::Ostp { listen, port, users, fallback, .. } => { ostp_server::config::ServerInbound::Ostp { listen, port, users, fallback, .. } => {
@ -1344,7 +1416,7 @@ async fn run_app() -> Result<()> {
for ob in &s.outbounds { for ob in &s.outbounds {
if let ostp_server::config::ServerOutbound::Socks { server, port, .. } = ob { if let ostp_server::config::ServerOutbound::Socks { server, port, .. } = ob {
println!(" Outbound Proxy: SOCKS5 {}:{}", server.cyan(), port.to_string().cyan()); println!(" Outbound Proxy: SOCKS5 {}:{}", server.cyan(), port.to_string().cyan());
has_outbound = true; _has_outbound = true;
} }
} }
if let Some(dns) = &s.dns { if let Some(dns) = &s.dns {
@ -1392,8 +1464,15 @@ async fn run_app() -> Result<()> {
if let Some(ref mode_str) = args.init { if let Some(ref mode_str) = args.init {
let is_server = mode_str == "server"; let is_server = mode_str == "server";
let key = generate_secure_key("hex"); let key = generate_secure_key("hex");
let dns_pub = generate_secure_key("base64");
let dns_priv = generate_secure_key("base64"); let (dns_priv, dns_pub) = if is_server {
ostp_core::dnstt::generate_keypair().unwrap_or_else(|e| {
tracing::warn!("Failed to generate dnstt keys: {}. Using placeholders.", e);
("YOUR_PRIVKEY".to_string(), "YOUR_PUBKEY".to_string())
})
} else {
("".to_string(), "".to_string())
};
let content = if is_server { let content = if is_server {
format!(r#"{{ format!(r#"{{
// OSTP Server Configuration // OSTP Server Configuration
@ -1542,6 +1621,25 @@ async fn run_app() -> Result<()> {
"sessions": 1 "sessions": 1
}} }}
}}, }},
{{
// DNS Tunneling connection to the remote OSTP server
// NOTE: DNS Tunneling is very slow and should be used only when UDP/TCP are blocked.
// Read the manual here: https://github.com/ospab/ostp/wiki/DNS-Tunneling
"type": "ostp",
"tag": "proxy-dns",
"server": "1.1.1.1",
"port": 53,
"access_key": "{key}",
"transport": {{
"type": "dns",
"domain": "tunnel.yourdomain.com",
"pubkey": "SERVER_PUBLIC_KEY_HERE"
}},
"multiplex": {{
"enabled": true,
"sessions": 5
}}
}},
{{ {{
"type": "direct", "type": "direct",
"tag": "direct" "tag": "direct"
@ -1603,7 +1701,7 @@ async fn run_app() -> Result<()> {
} }
if args.prober { if args.prober {
dns_prober::run_prober().await; dns_prober::run_prober(&args.config).await;
return Ok(()); return Ok(());
} }
@ -1626,29 +1724,28 @@ async fn run_app() -> Result<()> {
let mut raw_json: serde_json::Value = serde_json::from_reader(&mut stripped) let mut raw_json: serde_json::Value = serde_json::from_reader(&mut stripped)
.map_err(|e| anyhow!("Failed to parse config as JSON: {}", e))?; .map_err(|e| anyhow!("Failed to parse config as JSON: {}", e))?;
let is_migrated = raw_json.get("version").and_then(|v| v.as_str()) == Some(env!("CARGO_PKG_VERSION"));
if !is_migrated { // Hard stop if config is not in current format — user must run --migrate explicitly
let is_server = raw_json.get("listen").is_some() || raw_json.get("access_keys").is_some(); {
if is_server { let has_new_format = raw_json.get("inbounds").and_then(|v| v.as_array()).is_some()
raw_json["mode"] = serde_json::json!("server"); && raw_json.get("outbounds").and_then(|v| v.as_array()).is_some();
raw_json["version"] = serde_json::json!(env!("CARGO_PKG_VERSION")); let version_ok = raw_json.get("version").and_then(|v| v.as_str()) == Some(env!("CARGO_PKG_VERSION"));
if let Some(log) = raw_json.get("log_level") { if !has_new_format {
raw_json["log"] = serde_json::json!({ "level": log.clone() }); eprintln!();
} eprintln!("{} Your configuration file is in an outdated format.", "[ostp]".yellow().bold());
} else { eprintln!("{} Run the following command to upgrade it:", "[ostp]".yellow().bold());
let (migrated, _) = ostp_client::config::ClientConfig::migrate_json(raw_json); eprintln!();
raw_json = migrated; eprintln!(" {}", "ostp --migrate".green().bold());
raw_json["mode"] = serde_json::json!("client"); eprintln!();
std::process::exit(1);
}
if !version_ok {
// New format but wrong version — silently fix just the version field in memory (no write)
raw_json["version"] = serde_json::json!(env!("CARGO_PKG_VERSION"));
} }
// Save migrated config back
let serialized = serde_json::to_string_pretty(&raw_json)?;
let header = "// OSTP Configuration v0.3.1\n// DO NOT EDIT THIS COMMENT - Migrator relies on it\n";
let final_content = format!("{}{}", header, serialized);
let _ = fs::write(&args.config, final_content);
println!("{} Configuration automatically migrated to v0.3.1", "[ostp]".cyan().bold());
} }
let config: UnifiedConfig = serde_json::from_value(raw_json) let config: UnifiedConfig = serde_json::from_value(raw_json)
.map_err(|e| anyhow!("Failed to parse config: {}", e))?; .map_err(|e| anyhow!("Failed to parse config: {}", e))?;
@ -1940,145 +2037,528 @@ fn cmd_migrate(config_path: &std::path::Path) -> Result<()> {
let config_content = fs::read_to_string(config_path)?; let config_content = fs::read_to_string(config_path)?;
let mut stripped = json_comments::StripComments::new(config_content.as_bytes()); let mut stripped = json_comments::StripComments::new(config_content.as_bytes());
let mut raw_json: serde_json::Value = serde_json::from_reader(&mut stripped) let old: serde_json::Value = serde_json::from_reader(&mut stripped)
.map_err(|e| anyhow!("Failed to parse config as JSON: {}", e))?; .map_err(|e| anyhow!("Failed to parse config as JSON: {}", e))?;
let is_migrated = raw_json.get("version").and_then(|v| v.as_str()) == Some(env!("CARGO_PKG_VERSION")); // --- Determine config type ---
if is_migrated { let mode = old.get("mode").and_then(|m| m.as_str()).unwrap_or("");
println!("{} Configuration is already up to date (v0.3.5)", "[ostp]".cyan().bold()); let is_server = mode == "server"
return Ok(()); || old.get("listen").is_some()
} || old.get("access_keys").is_some()
|| old.get("inbounds").and_then(|v| v.as_array()).map(|arr| {
arr.iter().any(|i| {
i.get("protocol").and_then(|p| p.as_str()) == Some("ostp")
|| i.get("type").and_then(|t| t.as_str()) == Some("ostp")
})
}).unwrap_or(false);
let is_relay = mode == "relay" || old.get("upstream_tcp").is_some();
let _is_client = !is_server && !is_relay;
// --- Helper: extract log level ---
let log_level = old.get("log").and_then(|l| l.get("level")).and_then(|v| v.as_str())
.or_else(|| old.get("log_level").and_then(|v| v.as_str()))
.unwrap_or("info");
// --- Backup original ---
let bak_path = config_path.with_extension("json.bak");
fs::copy(config_path, &bak_path)?;
println!("{} Original config backed up to {:?}", "[ostp]".cyan().bold(), bak_path);
let new_content: String;
let is_server = raw_json.get("listen").is_some() || raw_json.get("access_keys").is_some() || raw_json.get("mode").and_then(|m| m.as_str()) == Some("server");
if is_server { if is_server {
raw_json["mode"] = serde_json::json!("server"); println!("{} Detected: Server configuration", "[ostp]".cyan().bold());
raw_json["version"] = serde_json::json!(env!("CARGO_PKG_VERSION"));
if let Some(log) = raw_json.get("log_level") {
raw_json["log"] = serde_json::json!({ "level": log.clone() });
}
let mut inbounds = Vec::new();
let mut outbounds = Vec::new();
let mut routing = serde_json::json!({
"rules": [],
"default_outbound": "direct"
});
// Migrate Ostp inbound // --- Extract server data ---
let listen = raw_json.get("listen").and_then(|l| l.as_str()).unwrap_or("0.0.0.0:50000"); // Listen host:port
let parts: Vec<&str> = listen.split(':').collect(); let (listen_host, listen_port) = extract_server_listen(&old);
let host = parts.get(0).unwrap_or(&"0.0.0.0");
let port: u16 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(50000);
let mut users = Vec::new();
if let Some(keys) = raw_json.get("access_keys").and_then(|a| a.as_array()) {
for k in keys {
users.push(serde_json::json!({
"key": k.as_str().unwrap_or("")
}));
}
}
let mut ostp_inbound = serde_json::json!({
"protocol": "ostp",
"tag": "ostp-in",
"listen": host,
"port": port,
"users": users
});
if let Some(fallback) = raw_json.get("fallback") {
ostp_inbound["fallback"] = fallback.clone();
}
inbounds.push(ostp_inbound);
// Migrate Api inbound // Access keys — support old flat list and new inbounds format
if let Some(api) = raw_json.get("api") { let users_json = extract_server_users(&old);
let mut api_inbound = api.clone();
api_inbound["protocol"] = serde_json::json!("api");
api_inbound["tag"] = serde_json::json!("api-in");
let bind = api.get("bind").and_then(|b| b.as_str()).unwrap_or("127.0.0.1:9090");
let parts: Vec<&str> = bind.split(':').collect();
api_inbound["listen"] = serde_json::json!(parts.get(0).unwrap_or(&"127.0.0.1"));
api_inbound["port"] = serde_json::json!(parts.get(1).and_then(|p| p.parse::<u16>().ok()).unwrap_or(9090));
inbounds.push(api_inbound);
}
// Migrate Outbound // Fallback
outbounds.push(serde_json::json!({ let (fallback_enabled, fallback_listen, fallback_target) = extract_server_fallback(&old);
"protocol": "direct",
"tag": "direct"
}));
outbounds.push(serde_json::json!({
"protocol": "block",
"tag": "block"
}));
if let Some(ob) = raw_json.get("outbound") {
if ob.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false) {
let tag = "socks5-legacy";
let mut socks = serde_json::json!({
"protocol": "socks5",
"tag": tag,
"server": ob.get("address").and_then(|a| a.as_str()).unwrap_or("127.0.0.1"),
"port": ob.get("port").and_then(|p| p.as_u64()).unwrap_or(9050)
});
outbounds.push(socks);
if let Some(rules) = ob.get("rules").and_then(|r| r.as_array()) {
let mut new_rules = Vec::new();
for rule in rules {
let mut new_rule = rule.clone();
new_rule["outbound"] = serde_json::json!(tag);
new_rules.push(new_rule);
}
routing["rules"] = serde_json::json!(new_rules);
}
let default_action = ob.get("default_action").and_then(|a| a.as_str()).unwrap_or("proxy");
if default_action == "proxy" {
routing["default_outbound"] = serde_json::json!(tag);
} else if default_action == "block" {
routing["default_outbound"] = serde_json::json!("block");
}
}
}
// DNS migrate // API
if let Some(dns) = raw_json.get("dns_transport") { let (api_listen, api_port, api_token, api_webpath, api_username, api_pass_hash) =
let mut dns_inbound = dns.clone(); extract_server_api(&old);
dns_inbound["protocol"] = serde_json::json!("dns");
dns_inbound["tag"] = serde_json::json!("dns-tunnel"); // DNS transport
inbounds.push(dns_inbound); let (dns_listen, dns_domain, dns_pubkey, dns_privkey) = extract_server_dns(&old);
}
// Routing rules (preserve if present)
let routing_rules_str = extract_routing_rules_str(&old);
let default_outbound = old.get("routing").and_then(|r| r.get("default_outbound"))
.and_then(|v| v.as_str()).unwrap_or("direct");
let users_str = users_json.iter()
.map(|k| format!(
r#" {{
"key": "{}"
}}
"#, k))
.collect::<Vec<_>>()
.join(",\n");
let users_str = if users_str.is_empty() {
format!(r#" {{
"key": "{}"
}}
"#, generate_secure_key("hex"))
} else { users_str };
new_content = format!(r#"{{
// OSTP Server Configuration
"version": "{ver}",
"mode": "server",
"log": {{
// Log levels: trace, debug, info, warn, error
"level": "{log_level}"
}},
"inbounds": [
{{
// Primary OSTP protocol listener
"protocol": "ostp",
"tag": "ostp-in",
"listen": "{listen_host}",
"port": {listen_port},
"users": [
{users_str} ],
"fallback": {{
// Fallback protection: redirects unauthorized probes to a real website
"enabled": {fallback_enabled},
"listen": "{fallback_listen}",
"target": "{fallback_target}"
}}
}},
{{
// Web Administration API
"protocol": "api",
"tag": "api-in",
"listen": "{api_listen}",
"port": {api_port},
"token": "{api_token}",
"webpath": "{api_webpath}",
"username": "{api_username}",
"password_hash": "{api_pass_hash}"
}},
{{
// DNS Tunnel Inbound
// [WARNING] This is a last-resort transport via public DNS.
// It requires a dedicated registered domain with NS records pointing to this server.
// Full setup guide: https://github.com/ospab/ostp/wiki/DNS-Tunneling
"protocol": "dns",
"tag": "dns-tunnel",
"listen": "{dns_listen}",
"domain": "{dns_domain}",
"pubkey": "{dns_pubkey}",
"privkey": "{dns_privkey}"
}}
],
"outbounds": [
{{
// Example local SOCKS5 proxy (e.g. for Tor network)
"protocol": "socks5",
"tag": "socks5-local",
"server": "127.0.0.1",
"port": 9050
}},
{{
// Default direct internet access
"protocol": "direct",
"tag": "direct"
}},
{{
// Blackhole for blocked connections
"protocol": "block",
"tag": "block"
}}
],
"routing": {{
// Rule-based routing of client traffic
"rules": [{routing_rules}],
// If no rules match, use the default outbound
"default_outbound": "{default_outbound}"
}},
"debug": false
}}
"#,
ver = env!("CARGO_PKG_VERSION"),
log_level = log_level,
listen_host = listen_host,
listen_port = listen_port,
users_str = users_str,
fallback_enabled = fallback_enabled,
fallback_listen = fallback_listen,
fallback_target = fallback_target,
api_listen = api_listen,
api_port = api_port,
api_token = api_token,
api_webpath = api_webpath,
api_username = api_username,
api_pass_hash = api_pass_hash,
dns_listen = dns_listen,
dns_domain = dns_domain,
dns_pubkey = dns_pubkey,
dns_privkey = dns_privkey,
routing_rules = routing_rules_str,
default_outbound = default_outbound,
);
} else if is_relay {
println!("{} Detected: Relay configuration", "[ostp]".cyan().bold());
let upstream_tcp = old.get("upstream_tcp").and_then(|v| v.as_str()).unwrap_or("TARGET_SERVER_IP:50000");
let upstream_udp = old.get("upstream_udp").and_then(|v| v.as_str()).unwrap_or(upstream_tcp);
let api_url = old.get("upstream_api_url").and_then(|v| v.as_str()).unwrap_or("http://TARGET_SERVER_IP:9090");
let api_token = old.get("upstream_api_token").and_then(|v| v.as_str()).unwrap_or("");
let sync_interval = old.get("sync_interval_secs").and_then(|v| v.as_u64()).unwrap_or(30);
let listen = old.get("listen").and_then(|v| v.as_str()).unwrap_or("0.0.0.0:50000");
new_content = format!(r#"{{
// OSTP Relay Configuration
"version": "{ver}",
"mode": "relay",
"log": {{
// Log levels: trace, debug, info, warn, error
"level": "{log_level}"
}},
// Local port for the relay to listen on
"listen": "{listen}",
// Upstream server details
"upstream_tcp": "{upstream_tcp}",
"upstream_udp": "{upstream_udp}",
// Upstream Control Panel API for automatic key synchronization
"upstream_api_url": "{api_url}",
"upstream_api_token": "{api_token}",
"sync_interval_secs": {sync_interval},
"debug": false
}}
"#,
ver = env!("CARGO_PKG_VERSION"),
log_level = log_level,
listen = listen,
upstream_tcp = upstream_tcp,
upstream_udp = upstream_udp,
api_url = api_url,
api_token = api_token,
sync_interval = sync_interval,
);
raw_json["inbounds"] = serde_json::json!(inbounds);
raw_json["outbounds"] = serde_json::json!(outbounds);
raw_json["routing"] = routing;
// Remove legacy fields
let obj = raw_json.as_object_mut().unwrap();
obj.remove("listen");
obj.remove("access_keys");
obj.remove("fallback");
obj.remove("api");
obj.remove("outbound");
obj.remove("log_level");
obj.remove("dns_transport");
println!("{} Detected Server configuration.", "[ostp]".cyan().bold());
} else { } else {
println!("{} Detected Client configuration.", "[ostp]".cyan().bold()); println!("{} Detected: Client configuration", "[ostp]".cyan().bold());
let (migrated, _) = ostp_client::config::ClientConfig::migrate_json(raw_json.clone());
raw_json = migrated; // Extract client data
raw_json["mode"] = serde_json::json!("client"); let (server_ip, server_port, access_key, transport_type) = extract_client_server(&old);
raw_json["version"] = serde_json::json!(env!("CARGO_PKG_VERSION")); let (socks_listen, socks_port) = extract_client_socks(&old);
let tun_enabled = extract_client_tun(&old);
let mux_enabled = old.get("mux").and_then(|m| m.get("enabled")).and_then(|v| v.as_bool())
.or_else(|| old.get("outbounds").and_then(|o| o.as_array()).and_then(|arr| {
arr.iter().find(|o| o.get("type").and_then(|t| t.as_str()) == Some("ostp"))
.and_then(|o| o.get("multiplex")).and_then(|m| m.get("enabled")).and_then(|v| v.as_bool())
}))
.unwrap_or(false);
let mux_sessions = old.get("mux").and_then(|m| m.get("sessions")).and_then(|v| v.as_u64())
.or_else(|| old.get("outbounds").and_then(|o| o.as_array()).and_then(|arr| {
arr.iter().find(|o| o.get("type").and_then(|t| t.as_str()) == Some("ostp"))
.and_then(|o| o.get("multiplex")).and_then(|m| m.get("sessions")).and_then(|v| v.as_u64())
}))
.unwrap_or(1);
let routing_rules_str = extract_routing_rules_str(&old);
let default_outbound = old.get("routing").and_then(|r| r.get("default_outbound"))
.and_then(|v| v.as_str()).unwrap_or("proxy");
let tun_block = if tun_enabled {
r#" {{
// Virtual network interface for transparent proxying
"type": "tun",
"tag": "tun-in",
"auto_route": true,
"mtu": 1140
}},
"#
} else {
r#" // Uncomment below to enable TUN (VPN) mode:
// {{ "type": "tun", "tag": "tun-in", "auto_route": true, "mtu": 1140 }},
"#
};
new_content = format!(r#"{{
// OSTP Client Configuration
"version": "{ver}",
"mode": "client",
"log": {{
"level": "{log_level}"
}},
"inbounds": [
{tun_block} {{
// Local SOCKS5 proxy server for browser configuration
"type": "local_proxy",
"tag": "socks-in",
"protocol": "socks",
"listen": "{socks_listen}",
"port": {socks_port}
}}
],
"outbounds": [
{{
// Connection to the remote OSTP server
"type": "ostp",
"tag": "proxy",
"server": "{server_ip}",
"port": {server_port},
"access_key": "{access_key}",
"transport": {{
"type": "{transport_type}"
}},
"multiplex": {{
"enabled": {mux_enabled},
"sessions": {mux_sessions}
}}
}},
{{
"type": "direct",
"tag": "direct"
}},
{{
"type": "block",
"tag": "block"
}}
],
"routing": {{
"rules": [{routing_rules}],
"default_outbound": "{default_outbound}"
}}
}}
"#,
ver = env!("CARGO_PKG_VERSION"),
log_level = log_level,
tun_block = tun_block,
socks_listen = socks_listen,
socks_port = socks_port,
server_ip = server_ip,
server_port = server_port,
access_key = access_key,
transport_type = transport_type,
mux_enabled = mux_enabled,
mux_sessions = mux_sessions,
routing_rules = routing_rules_str,
default_outbound = default_outbound,
);
} }
let serialized = serde_json::to_string_pretty(&raw_json)?; fs::write(config_path, &new_content)?;
let final_content = format!("{}", serialized); println!("{} Configuration successfully migrated to v{}!", "[ostp]".green().bold(), env!("CARGO_PKG_VERSION"));
fs::write(config_path, final_content)?; println!("{} Backup saved at {:?}", "[ostp]".dimmed(), bak_path);
println!("{} Successfully migrated configuration to v0.3.5!", "[ostp]".green().bold());
Ok(()) Ok(())
} }
// ---------------------------------------------------------------------------
// Migration helper extractors
// ---------------------------------------------------------------------------
/// Extract listen host and port for server from old or new format
fn extract_server_listen(old: &serde_json::Value) -> (String, u16) {
// New format: inbounds[type=ostp].listen + port
if let Some(arr) = old.get("inbounds").and_then(|v| v.as_array()) {
for inbound in arr {
let proto = inbound.get("protocol").or(inbound.get("type")).and_then(|v| v.as_str()).unwrap_or("");
if proto == "ostp" {
let h = inbound.get("listen").and_then(|v| v.as_str()).unwrap_or("0.0.0.0").to_string();
let p = inbound.get("port").and_then(|v| v.as_u64()).unwrap_or(50000) as u16;
return (h, p);
}
}
}
// Old format: "listen": "0.0.0.0:50000"
if let Some(s) = old.get("listen").and_then(|v| v.as_str()) {
let parts: Vec<&str> = s.split(':').collect();
let h = parts.get(0).unwrap_or(&"0.0.0.0").to_string();
let p = parts.get(1).and_then(|x| x.parse().ok()).unwrap_or(50000);
return (h, p);
}
("0.0.0.0".to_string(), 50000)
}
/// Extract access keys as list of strings
fn extract_server_users(old: &serde_json::Value) -> Vec<String> {
// New format: inbounds[type=ostp].users[].key
if let Some(arr) = old.get("inbounds").and_then(|v| v.as_array()) {
for inbound in arr {
let proto = inbound.get("protocol").or(inbound.get("type")).and_then(|v| v.as_str()).unwrap_or("");
if proto == "ostp" {
if let Some(users) = inbound.get("users").and_then(|v| v.as_array()) {
return users.iter().filter_map(|u| {
u.get("key").and_then(|k| k.as_str()).map(|s| s.to_string())
.or_else(|| u.as_str().map(|s| s.to_string()))
}).collect();
}
}
}
}
// Old flat format: "access_keys": ["key1", "key2"]
if let Some(keys) = old.get("access_keys").and_then(|v| v.as_array()) {
return keys.iter().filter_map(|k| k.as_str().map(|s| s.to_string())).collect();
}
vec![]
}
/// Extract fallback config
fn extract_server_fallback(old: &serde_json::Value) -> (bool, String, String) {
// New format: inbounds[type=ostp].fallback
if let Some(arr) = old.get("inbounds").and_then(|v| v.as_array()) {
for inbound in arr {
let proto = inbound.get("protocol").or(inbound.get("type")).and_then(|v| v.as_str()).unwrap_or("");
if proto == "ostp" {
if let Some(fb) = inbound.get("fallback") {
let enabled = fb.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
let listen = fb.get("listen").and_then(|v| v.as_str()).unwrap_or("0.0.0.0:443").to_string();
let target = fb.get("target").and_then(|v| v.as_str()).unwrap_or("127.0.0.1:8080").to_string();
return (enabled, listen, target);
}
}
}
}
// Old flat format
if let Some(fb) = old.get("fallback") {
let enabled = fb.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
let listen = fb.get("listen").and_then(|v| v.as_str()).unwrap_or("0.0.0.0:443").to_string();
let target = fb.get("target").and_then(|v| v.as_str()).unwrap_or("127.0.0.1:8080").to_string();
return (enabled, listen, target);
}
(false, "0.0.0.0:443".to_string(), "127.0.0.1:8080".to_string())
}
/// Extract API config
fn extract_server_api(old: &serde_json::Value) -> (String, u16, String, String, String, String) {
let default_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string();
// New format: inbounds[protocol=api]
if let Some(arr) = old.get("inbounds").and_then(|v| v.as_array()) {
for inbound in arr {
let proto = inbound.get("protocol").or(inbound.get("type")).and_then(|v| v.as_str()).unwrap_or("");
if proto == "api" {
let listen = inbound.get("listen").and_then(|v| v.as_str()).unwrap_or("127.0.0.1").to_string();
let port = inbound.get("port").and_then(|v| v.as_u64()).unwrap_or(9090) as u16;
let token = inbound.get("token").and_then(|v| v.as_str()).unwrap_or("YOUR_SECRET_TOKEN").to_string();
let webpath = inbound.get("webpath").and_then(|v| v.as_str()).unwrap_or("/admin").to_string();
let username = inbound.get("username").and_then(|v| v.as_str()).unwrap_or("admin").to_string();
let pass = inbound.get("password_hash").and_then(|v| v.as_str()).unwrap_or(&default_hash).to_string();
return (listen, port, token, webpath, username, pass);
}
}
}
// Old format: "api": { "bind": "127.0.0.1:9090", ... }
if let Some(api) = old.get("api") {
let bind = api.get("bind").and_then(|v| v.as_str()).unwrap_or("127.0.0.1:9090");
let parts: Vec<&str> = bind.split(':').collect();
let listen = parts.get(0).unwrap_or(&"127.0.0.1").to_string();
let port = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(9090);
let token = api.get("token").and_then(|v| v.as_str()).unwrap_or("YOUR_SECRET_TOKEN").to_string();
let webpath = api.get("webpath").and_then(|v| v.as_str()).unwrap_or("/admin").to_string();
let username = api.get("username").and_then(|v| v.as_str()).unwrap_or("admin").to_string();
let pass = api.get("password_hash").and_then(|v| v.as_str()).unwrap_or(&default_hash).to_string();
return (listen, port, token, webpath, username, pass);
}
("127.0.0.1".to_string(), 9090, "YOUR_SECRET_TOKEN".to_string(), "/admin".to_string(), "admin".to_string(), default_hash)
}
/// Extract DNS transport config
fn extract_server_dns(old: &serde_json::Value) -> (String, String, String, String) {
// New format: inbounds[protocol=dns]
if let Some(arr) = old.get("inbounds").and_then(|v| v.as_array()) {
for inbound in arr {
let proto = inbound.get("protocol").or(inbound.get("type")).and_then(|v| v.as_str()).unwrap_or("");
if proto == "dns" {
let listen = inbound.get("listen").and_then(|v| v.as_str()).unwrap_or("0.0.0.0:53").to_string();
let domain = inbound.get("domain").and_then(|v| v.as_str()).unwrap_or("tunnel.example.com").to_string();
let pubkey = inbound.get("pubkey").and_then(|v| v.as_str()).unwrap_or("").to_string();
let privkey = inbound.get("privkey").and_then(|v| v.as_str()).unwrap_or("").to_string();
return (listen, domain, pubkey, privkey);
}
}
}
// Old flat format: "dns_transport": {...}
if let Some(dns) = old.get("dns_transport") {
let listen = dns.get("listen").and_then(|v| v.as_str()).unwrap_or("0.0.0.0:53").to_string();
let domain = dns.get("domain").and_then(|v| v.as_str()).unwrap_or("tunnel.example.com").to_string();
let pubkey = dns.get("pubkey").and_then(|v| v.as_str()).unwrap_or("").to_string();
let privkey = dns.get("privkey").and_then(|v| v.as_str()).unwrap_or("").to_string();
return (listen, domain, pubkey, privkey);
}
let new_pub = generate_secure_key("base64");
let new_priv = generate_secure_key("base64");
("0.0.0.0:53".to_string(), "tunnel.example.com".to_string(), new_pub, new_priv)
}
/// Extract routing rules as a formatted JSON string for embedding in template
fn extract_routing_rules_str(old: &serde_json::Value) -> String {
if let Some(rules) = old.get("routing").and_then(|r| r.get("rules")).and_then(|v| v.as_array()) {
if !rules.is_empty() {
let parts: Vec<String> = rules.iter()
.filter_map(|r| serde_json::to_string_pretty(r).ok())
.collect();
return format!("\n {}\n ", parts.join(",\n "));
}
}
String::new()
}
/// Extract client server address, port, key, transport
fn extract_client_server(old: &serde_json::Value) -> (String, u16, String, String) {
// New format: outbounds[type=ostp]
if let Some(arr) = old.get("outbounds").and_then(|v| v.as_array()) {
for ob in arr {
let t = ob.get("type").and_then(|v| v.as_str()).unwrap_or("");
if t == "ostp" {
let server = ob.get("server").and_then(|v| v.as_str()).unwrap_or("YOUR_SERVER_IP").to_string();
let port = ob.get("port").and_then(|v| v.as_u64()).unwrap_or(50000) as u16;
let key = ob.get("access_key").and_then(|v| v.as_str()).unwrap_or("").to_string();
let transport = ob.get("transport").and_then(|t| t.get("type")).and_then(|v| v.as_str()).unwrap_or("udp").to_string();
return (server, port, key, transport);
}
}
}
// Old flat format
let server_full = old.get("server").and_then(|v| v.as_str()).unwrap_or("YOUR_SERVER_IP:50000");
let parts: Vec<&str> = server_full.split(':').collect();
let server = parts.get(0).unwrap_or(&"YOUR_SERVER_IP").to_string();
let port = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(50000);
let key = old.get("access_key").and_then(|v| v.as_str()).unwrap_or("").to_string();
let transport = old.get("transport").and_then(|t| t.get("mode").or(t.get("type"))).and_then(|v| v.as_str()).unwrap_or("udp").to_string();
(server, port, key, transport)
}
/// Extract client SOCKS listen address and port
fn extract_client_socks(old: &serde_json::Value) -> (String, u16) {
// New format: inbounds[type=local_proxy]
if let Some(arr) = old.get("inbounds").and_then(|v| v.as_array()) {
for inbound in arr {
let t = inbound.get("type").and_then(|v| v.as_str()).unwrap_or("");
if t == "local_proxy" {
let listen = inbound.get("listen").and_then(|v| v.as_str()).unwrap_or("127.0.0.1").to_string();
let port = inbound.get("port").and_then(|v| v.as_u64()).unwrap_or(1088) as u16;
return (listen, port);
}
}
}
// Old flat format
let bind = old.get("socks5_bind").and_then(|v| v.as_str()).unwrap_or("127.0.0.1:1088");
let parts: Vec<&str> = bind.split(':').collect();
let listen = parts.get(0).unwrap_or(&"127.0.0.1").to_string();
let port = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(1088);
(listen, port)
}
/// Check if TUN is enabled in old config
fn extract_client_tun(old: &serde_json::Value) -> bool {
// New format: inbounds[type=tun]
if let Some(arr) = old.get("inbounds").and_then(|v| v.as_array()) {
for inbound in arr {
let t = inbound.get("type").and_then(|v| v.as_str()).unwrap_or("");
if t == "tun" {
return inbound.get("auto_route").and_then(|v| v.as_bool()).unwrap_or(true);
}
}
}
// Old flat format
old.get("tun").and_then(|t| t.get("enable")).and_then(|v| v.as_bool()).unwrap_or(false)
}

View File

@ -85,10 +85,33 @@ esac
echo "Platform: linux/$ARCH" echo "Platform: linux/$ARCH"
# ── Parse arguments ────────────────────────────────────────────────────
TARGET_VERSION=""
while [[ $# -gt 0 ]]; do
case $1 in
-v|--version)
TARGET_VERSION="$2"
shift 2
;;
*)
shift
;;
esac
done
# ── Download binary ────────────────────────────────────────────────── # ── Download binary ──────────────────────────────────────────────────
echo "Fetching latest release..." if [ -n "$TARGET_VERSION" ]; then
LATEST_RELEASE=$(curl -s "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') LATEST_RELEASE="$TARGET_VERSION"
# Ensure it starts with 'v' if it's supposed to
if [[ ! "$LATEST_RELEASE" =~ ^v ]]; then
LATEST_RELEASE="v$LATEST_RELEASE"
fi
echo "Fetching requested release $LATEST_RELEASE..."
else
echo "Fetching latest release..."
LATEST_RELEASE=$(curl -s "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
fi
if [ -z "$LATEST_RELEASE" ] || [[ "$LATEST_RELEASE" == *"null"* ]]; then if [ -z "$LATEST_RELEASE" ] || [[ "$LATEST_RELEASE" == *"null"* ]]; then
echo "[notice] Could not determine latest release automatically." echo "[notice] Could not determine latest release automatically."

View File

@ -1,62 +0,0 @@
{
// OSTP Server Configuration
"mode": "server",
"log_level": "info",
// The address and port the server listens on for incoming OSTP connections.
"listen": "0.0.0.0:50000",
// List of valid keys. Clients must use one of these to connect.
"access_keys": [
"a1d8795a93553c08b4e89b017a16ca52"
],
// Optional proxy for outbound traffic.
"outbound": {
"enabled": false,
"protocol": "socks5",
"address": "127.0.0.1",
"port": 9050,
// default_action: 'proxy' (all through proxy) or 'direct' (bypass proxy by default).
"default_action": "proxy",
"rules": [
{
"domain_suffix": [".onion"],
"action": "proxy"
}
]
},
// Web control panel & Management API
"api": {
"enabled": false,
"bind": "0.0.0.0:9090",
// Static API token for Relay servers (optional)
"token": "",
// Secret URL path to hide panel from scanners (e.g. "mySecret123")
"webpath": "",
// Login credentials for web panel (password stored as SHA256 hash)
"username": "",
"password_hash": ""
},
// Fallback TCP proxy: unrecognized connections are proxied to a web server (anti-DPI).
"fallback": {
"enabled": false,
"listen": "0.0.0.0:443",
// Target web server (e.g., local nginx or caddy)
"target": "127.0.0.1:8080"
},
// Reality (XTLS) / UoT Masquerade parameters
"reality": {
"enabled": false,
"dest": "www.microsoft.com:443",
"private_key": "6FVg53jUBTt-dJ52F1Zu1RBCcW1gr9K84WdynBb7i80",
"pbk": "c9QjERoaqFGoKBd-9ZpNzj51E8B93fcnEQT_cohEk2E",
"sid": "960223edfa174fc5",
"sni_list": ["www.microsoft.com"]
},
"debug": false,
}

View File

@ -1,53 +0,0 @@
{
// OSTP Configuration v0.3.1
// DO NOT EDIT THIS COMMENT - Migrator relies on it
"version": "0.3.1",
"mode": "client",
"log": {
"level": "info"
},
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"auto_route": true,
"mtu": 1140
},
{
"type": "local_proxy",
"tag": "socks-in",
"protocol": "socks",
"listen": "127.0.0.1",
"port": 1088
}
],
"outbounds": [
{
"type": "ostp",
"tag": "proxy",
"server": "YOUR_SERVER_IP",
"port": 50000,
"access_key": "170756347f1562a4b260f8f4b419009a",
"transport": {
"type": "udp"
}
},
{
"type": "direct",
"tag": "direct"
},
{
"type": "block",
"tag": "block"
}
],
"routing": {
"rules": [
{
"domain_suffix": ["localhost"],
"outbound": "direct"
}
],
"default_outbound": "proxy"
}
}

View File

@ -1,42 +0,0 @@
{
// OSTP Configuration v0.3.1
// DO NOT EDIT THIS COMMENT - Migrator relies on it
"version": "0.3.1",
"mode": "server",
"log": {
"level": "info"
},
// The address and port the server listens on for incoming OSTP connections.
"listen": "0.0.0.0:50000",
// List of valid keys. Clients must use one of these to connect.
"access_keys": [
"1369293f64ed6382d96cd2c1fa2ee4ee"
],
// Optional proxy for outbound traffic.
"outbound": {
"enabled": false,
"protocol": "socks5",
"address": "127.0.0.1",
"port": 9050,
"default_action": "proxy",
"rules": [
{
"domain_suffix": [".onion"],
"action": "proxy"
}
]
},
// Fallback TCP proxy: unrecognized connections are proxied to a web server (anti-DPI).
"fallback": {
"enabled": false,
"listen": "0.0.0.0:443",
// Target web server (e.g., local nginx or caddy)
"target": "127.0.0.1:8080"
},
"debug": false
}