Compare commits

...

4 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
27 changed files with 572 additions and 817 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:
* **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.
* **Git**: For version control.

View File

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

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
# Требования: Rust 1.75+
cargo build --release
# Кросс-компиляция для 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.
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 авторизация).
3. Выполняется эфемерный обмен ключами Curve25519 для создания симметричных ключей передачи данных.
Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер строго контролирует окно синхронизации (±30 секунд).
Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер контролирует окно синхронизации (±300 секунд) с учётом дрейфа часов и смены сети при роуминге.
---

View File

@ -9,7 +9,7 @@ anyhow.workspace = true
bytes.workspace = true
tokio.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"
ostp-core = { path = "../ostp-core" }
ostp-tun = { path = "../ostp-tun" }

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 logging;
pub mod ipc_crypto;

View File

@ -74,16 +74,20 @@ 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) {
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()
.with_target(true)
.with_line_number(true)
.with_line_number(false)
.with_thread_ids(false)
.with_thread_names(false)
.with_ansi(false)
.with_timer(timer.clone())
.with_writer(file_writer);
let stderr_layer = tracing_subscriber::fmt::layer()
.with_target(true)
.with_timer(timer)
.with_writer(std::io::stderr);
let _ = tracing_subscriber::registry()
@ -107,6 +111,7 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
// Fallback: stderr only
let stderr_layer = tracing_subscriber::fmt::layer()
.with_target(true)
.with_timer(tracing_subscriber::fmt::time::UtcTime::rfc_3339())
.with_writer(std::io::stderr);
let _ = tracing_subscriber::registry()
.with(EnvFilter::new(level))

View File

@ -13,7 +13,7 @@ pub async fn run_client_core(
mut shutdown_rx_ext: watch::Receiver<bool>,
_config_rx: Option<watch::Receiver<ClientConfig>>,
) -> 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 balancer = Arc::new(Balancer::new(&config));

View File

@ -1,230 +1 @@
/// DNS tunnel transport — dnstt-style implementation.
///
/// Protocol (client → server, embedded in DNS query domain name):
/// Base32([client_id: 8][msg_id: 2 BE][total_frags: 1][frag_idx: 1][payload: ≤MAX_CHUNK])
/// Split into DNS labels of max 63 chars, suffixed with base_domain.
///
/// Poll query: payload is empty (total_frags=1, frag_idx=0, len=0).
///
/// Protocol (server → client, in TXT rdata):
/// Concatenated length-prefixed OSTP packets: [len: 2 BE][data ...]...
///
/// Polling: adaptive 500ms → 10s, like dnstt. Resets to 500ms on real data.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use rand::Rng;
use tokio::net::UdpSocket;
use tokio::sync::{mpsc, Mutex};
use crate::transport::Transport;
use rand::RngCore;
use ostp_core::dns::{base32_encode, DnsPacket, DnsRecordType};
/// Max raw payload bytes we put into one DNS query.
/// Calculation: FQDN ≤ 253 chars. Domain suffix ~30 chars max.
/// Remaining: ~220 chars for base32 labels. 220/8*5 = 137 bytes raw.
/// Header = 12 bytes → payload ≤ 120 bytes (conservative, works for any domain ≤ 40 chars).
const MAX_CHUNK_PAYLOAD: usize = 120;
const CLIENT_ID_LEN: usize = 8;
const INIT_POLL_DELAY: Duration = Duration::from_millis(500);
const MAX_POLL_DELAY: Duration = Duration::from_secs(10);
const POLL_DELAY_MULTIPLIER: f64 = 2.0;
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>(256);
let (transport_tx, app_rx) = mpsc::channel::<Bytes>(256);
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);
// Generate random ClientID for this tunnel session
let mut client_id = [0u8; CLIENT_ID_LEN];
rand::thread_rng().fill_bytes(&mut client_id);
let client_id = Arc::new(client_id);
tracing::info!("DNS transport: domain={} resolver={} client_id={}",
domain, resolver_addr,
hex::encode(client_id.as_slice()));
// ── Send task ─────────────────────────────────────────────────────────────
let sock_send = socket.clone();
let cid_send = client_id.clone();
let domain_send = domain.clone();
tokio::spawn(async move {
let mut rx = transport_rx;
let mut msg_id: u16 = 0;
let mut poll_delay = INIT_POLL_DELAY;
loop {
let data: Option<Bytes> = tokio::select! {
data = rx.recv() => data,
_ = tokio::time::sleep(poll_delay) => {
poll_delay = Duration::from_secs_f64(
(poll_delay.as_secs_f64() * POLL_DELAY_MULTIPLIER)
.min(MAX_POLL_DELAY.as_secs_f64())
);
// Send poll (empty payload)
Some(Bytes::new())
}
};
let data = match data {
Some(d) => d,
None => {
tracing::debug!("DNS send task: channel closed, exiting");
break;
}
};
if data.is_empty() {
// Poll query — one empty chunk
if let Err(e) = send_chunk(&sock_send, &cid_send, msg_id, 1, 0, &[], &domain_send).await {
tracing::warn!("DNS poll send error: {}", e);
}
} else {
// Real OSTP packet — fragment into chunks
poll_delay = INIT_POLL_DELAY; // reset on real data
let data_slice = data.as_ref();
let total_chunks = data_slice.chunks(MAX_CHUNK_PAYLOAD).count();
let total_u8 = total_chunks.min(255) as u8;
for (idx, chunk) in data_slice.chunks(MAX_CHUNK_PAYLOAD).enumerate() {
if let Err(e) = send_chunk(
&sock_send, &cid_send,
msg_id, total_u8, idx as u8,
chunk, &domain_send,
).await {
tracing::warn!("DNS chunk send error (idx={}): {}", idx, e);
break;
}
// Brief inter-fragment delay to avoid flooding the resolver
if total_chunks > 1 {
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
msg_id = msg_id.wrapping_add(1);
}
}
});
// ── Receive task ──────────────────────────────────────────────────────────
let sock_recv = socket.clone();
let tx_recv = transport_tx.clone();
let domain_recv = domain.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 65535];
// Reassembly buffers: msg_id → (total, Vec<Option<chunk>>)
let reassembly: HashMap<u16, (u8, Vec<Option<Vec<u8>>>)> = HashMap::new();
loop {
match sock_recv.recv(&mut buf).await {
Ok(n) => {
let Some(pkt) = DnsPacket::decode(&buf[..n]) else { continue };
// Only process DNS responses
if pkt.flags & 0x8000 == 0 { continue; }
for answer in pkt.answers {
if answer.rtype != DnsRecordType::TXT && answer.rtype != DnsRecordType::NULL {
continue;
}
let rdata = answer.rdata;
// Parse length-prefixed OSTP packets packed in rdata:
// [len_hi: 1][len_lo: 1][data: len]...
let mut pos = 0;
while pos + 2 <= rdata.len() {
let pkt_len = u16::from_be_bytes([rdata[pos], rdata[pos + 1]]) as usize;
pos += 2;
if pkt_len == 0 { continue; }
if pos + pkt_len > rdata.len() {
tracing::debug!("DNS recv: truncated packet in rdata");
break;
}
let payload = Bytes::copy_from_slice(&rdata[pos..pos + pkt_len]);
pos += pkt_len;
if tx_recv.send(payload).await.is_err() {
return; // app closed
}
}
}
// Also check for responses packed in the server's extra DNS answer rdata
// that use our fragmentation scheme (server→client fragments)
// This is handled above via the length-prefix protocol.
let _ = &reassembly; // Keep for future upstream fragmentation support
let _ = &domain_recv;
}
Err(e) => {
tracing::warn!("DNS transport recv error: {}", e);
break;
}
}
}
});
Ok(Transport::Dns {
tx: app_tx,
rx: Arc::new(Mutex::new(app_rx)),
})
}
/// Build and send one DNS TXT query with a framed chunk.
///
/// Frame format (before base32 encoding):
/// [client_id: 8][msg_id: 2 BE][total_frags: 1][frag_idx: 1][payload: 0120]
async fn send_chunk(
socket: &UdpSocket,
client_id: &[u8; CLIENT_ID_LEN],
msg_id: u16,
total_frags: u8,
frag_idx: u8,
payload: &[u8],
base_domain: &str,
) -> std::io::Result<()> {
// Build frame
let mut frame = Vec::with_capacity(CLIENT_ID_LEN + 4 + payload.len());
frame.extend_from_slice(client_id);
frame.extend_from_slice(&msg_id.to_be_bytes());
frame.push(total_frags);
frame.push(frag_idx);
frame.extend_from_slice(payload);
// Base32-encode
let encoded = base32_encode(&frame);
// Split into 63-char labels and append domain
let mut fqdn = String::with_capacity(encoded.len() + base_domain.len() + 10);
let mut start = 0;
while start < encoded.len() {
let end = (start + 63).min(encoded.len());
fqdn.push_str(&encoded[start..end]);
fqdn.push('.');
start = end;
}
fqdn.push_str(base_domain);
// Build DNS TXT query with random ID
let id: u16 = rand::thread_rng().gen();
let pkt = DnsPacket::new_query(id, &fqdn, DnsRecordType::TXT);
let wire = pkt.encode();
tracing::trace!("DNS send chunk: msg_id={} frag={}/{} payload={}B fqdn_len={}",
msg_id, frag_idx + 1, total_frags, payload.len(), fqdn.len());
socket.send(&wire).await?;
Ok(())
}
// Left empty by request

View File

@ -1,4 +1,3 @@
pub mod dns;
use std::sync::Arc;
use tokio::net::UdpSocket;
use bytes::Bytes;
@ -10,9 +9,10 @@ pub enum Transport {
tx: tokio::sync::mpsc::Sender<Bytes>,
rx: Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Bytes>>>,
},
Dns {
Dnstt {
tx: tokio::sync::mpsc::Sender<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> {
match self {
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"))?;
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> {
match self {
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> {
match self {
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;
match rx.recv().await {
Some(bytes) => {
let len = bytes.len().min(buf.len());
buf[..len].copy_from_slice(&bytes[..len]);
Ok(len)
}
None => Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "channel closed")),
if let Some(frame) = rx.recv().await {
let len = frame.len().min(buf.len());
buf[..len].copy_from_slice(&frame[..len]);
Ok(len)
} else {
Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "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> {
match self {
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

@ -32,6 +32,13 @@ fn make_initiator_config(
"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,
@ -43,8 +50,8 @@ fn make_initiator_config(
obfuscation_key: secrets.obfuscation_key,
max_reorder: 16384,
max_reorder_buffer: 8192,
ack_delay_ms: 5,
rto_ms: 100,
ack_delay_ms,
rto_ms,
max_retries: 8,
max_sent_history: 32768,
handshake_pad_min: secrets.handshake_pad_min,
@ -180,12 +187,25 @@ pub async fn dial_tcp(
}
// ── 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 udp_buf = [0u8; 65535];
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! {
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; }
let data_msg = ostp_core::relay::RelayMessage::Data(buf[..n].to_vec());
let encoded = data_msg.encode();
@ -198,7 +218,7 @@ pub async fn dial_tcp(
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) {
handle_action(action, &transport, &mut server_stream).await;
}
@ -299,15 +319,62 @@ async fn make_transport(
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 transport = crate::transport::dns::start_dns_transport(domain, resolver, transport_cfg.pubkey.clone()).await
.map_err(|e| anyhow::anyhow!(e))?;
Ok(transport)
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?;

View File

@ -4,6 +4,12 @@
//! bandwidth and minimum RTT to determine the optimal sending rate.
//! This replaces the fixed `retransmit_budget = 8` with an adaptive
//! congestion window that responds to network conditions.
//!
//! 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};
@ -15,8 +21,14 @@ pub struct CongestionController {
ssthresh: u64,
/// Current phase
phase: Phase,
/// Minimum RTT observed
/// Minimum RTT observed (for BBR-style bandwidth estimation)
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_in_flight: u64,
/// Total bytes acknowledged (for bandwidth estimation)
@ -37,31 +49,43 @@ pub struct CongestionController {
enum Phase {
/// Exponential growth until loss or ssthresh
SlowStart,
/// Probe bandwidth: cycle through pacing gains
/// Probe bandwidth: additive increase
ProbeBandwidth,
}
/// Initial congestion window: 10 packets × MTU
const INITIAL_CWND_PACKETS: u64 = 10;
/// Initial congestion window: 32 packets × MTU (IW10 is too conservative for modern links)
const INITIAL_CWND_PACKETS: u64 = 32;
/// Minimum cwnd: 2 packets
const MIN_CWND_PACKETS: u64 = 2;
/// Min RTT expiry window (after which we re-probe)
const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10);
/// 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 {
pub fn new(mtu: u64) -> Self {
let now = Instant::now();
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 {
cwnd: initial_cwnd,
ssthresh: u64::MAX,
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,
total_acked: 0,
last_ack_time: now,
loss_count: 0,
pacing_rate: initial_cwnd * 10, // initial: ~10 windows/sec
pacing_rate: initial_pacing,
mtu,
min_rtt_stamp: now,
}
@ -82,9 +106,20 @@ impl CongestionController {
self.pacing_rate
}
/// Returns the smoothed RTT estimate.
/// Returns the smoothed RTT estimate (SRTT).
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.
@ -115,16 +150,13 @@ impl CongestionController {
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes);
self.total_acked = self.total_acked.saturating_add(bytes);
// Update RTT
// Update RTT measurements
self.update_rtt(rtt, now);
// Update bandwidth estimate
self.update_bandwidth(bytes, now);
// State machine
match self.phase {
Phase::SlowStart => {
// Exponential growth: increase cwnd by acked bytes
// Exponential growth: increase cwnd by acked bytes (doubles per RTT)
self.cwnd = self.cwnd.saturating_add(bytes);
if self.cwnd >= self.ssthresh {
self.phase = Phase::ProbeBandwidth;
@ -164,32 +196,49 @@ impl CongestionController {
self.update_pacing_rate();
}
/// Called periodically to update state.
pub fn on_tick(&mut self) {
// Nothing special needed per-tick -- state updates happen on ACK/loss
}
// ── Private ──────────────────────────────────────────────────────────────
fn update_rtt(&mut self, rtt: Duration, now: Instant) {
// Track windowed minimum RTT
// Update windowed minimum RTT (for pacing)
if rtt < self.min_rtt || now.duration_since(self.min_rtt_stamp) >= MIN_RTT_EXPIRY {
self.min_rtt = rtt;
self.min_rtt_stamp = now;
}
}
fn update_bandwidth(&mut self, _acked_bytes: u64, now: Instant) {
let elapsed = now.duration_since(self.last_ack_time);
if elapsed.as_micros() > 0 {
// Removed bw_samples tracking
// Update SRTT and RTTVAR per RFC 6298
if !self.rtt_initialized {
// First measurement: initialize directly
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) {
// 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;
self.pacing_rate = self.cwnd * 1_000_000 / rtt_us;
}
@ -202,19 +251,18 @@ mod tests {
#[test]
fn test_initial_state() {
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_eq!(cc.cwnd_packets(), 10);
assert_eq!(cc.cwnd_packets(), 32);
}
#[test]
fn test_slow_start_growth() {
let mut cc = CongestionController::new(1200);
// Simulate sending and ACKing
let initial = cc.cwnd();
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(50));
// cwnd should grow
assert!(cc.cwnd() > 12000);
assert!(cc.cwnd() > initial);
}
#[test]
@ -229,7 +277,7 @@ mod tests {
fn test_can_send_limits() {
let mut cc = CongestionController::new(1200);
// Send until cwnd is exhausted
for _ in 0..10 {
for _ in 0..32 {
cc.on_send(1200);
}
assert!(!cc.can_send()); // cwnd exhausted
@ -244,10 +292,46 @@ mod tests {
}
#[test]
fn test_rtt_tracking() {
fn test_rtt_tracking_first_sample() {
let mut cc = CongestionController::new(1200);
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(25));
// After first sample: SRTT = 25ms, RTTVAR = 12ms
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

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

View File

@ -2,7 +2,7 @@ use bytes::Bytes;
use rand::Rng;
use sha2::{Digest, Sha256};
use thiserror::Error;
use std::collections::{BTreeMap, VecDeque};
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use crate::congestion::CongestionController;
@ -75,7 +75,7 @@ pub struct ProtocolMachine {
send_nonce: u64,
expected_recv_nonce: u64,
reorder_buffer: BTreeMap<u64, ProtocolAction>,
sent_history: VecDeque<SentFrame>,
sent_history: BTreeMap<u64, SentFrame>,
session_id: u32,
handshake_payload: Vec<u8>,
padder: AdaptivePadder,
@ -83,7 +83,8 @@ pub struct ProtocolMachine {
max_reorder: u64,
max_reorder_buffer: usize,
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_sent_history: usize,
ack_pending: bool,
@ -100,11 +101,11 @@ pub struct ProtocolMachine {
/// Key-derived handshake padding range
handshake_pad_min: usize,
handshake_pad_max: usize,
_mtu: usize,
}
#[derive(Debug, Clone)]
struct SentFrame {
#[allow(dead_code)] // mirrored in BTreeMap key; kept for Debug output
nonce: u64,
bytes: Bytes,
last_sent: Instant,
@ -128,7 +129,7 @@ impl ProtocolMachine {
send_nonce: 0,
expected_recv_nonce: 0,
reorder_buffer: BTreeMap::new(),
sent_history: VecDeque::with_capacity(config.max_sent_history.max(1)),
sent_history: BTreeMap::new(),
session_id: config.session_id,
handshake_payload: config.handshake_payload,
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_buffer: config.max_reorder_buffer.max(1),
ack_delay: Duration::from_millis(config.ack_delay_ms.max(1)),
rto: Duration::from_millis(config.rto_ms.max(1)),
rto_initial: Duration::from_millis(config.rto_ms.max(1)),
max_retries: config.max_retries.max(1),
max_sent_history: config.max_sent_history.max(1),
ack_pending: false,
@ -146,20 +147,25 @@ impl ProtocolMachine {
cc: CongestionController::new(config.mtu as u64),
handshake_pad_min: config.handshake_pad_min.max(8),
handshake_pad_max: config.handshake_pad_max.max(config.handshake_pad_min + 16),
_mtu: config.mtu,
})
}
pub fn in_flight_count(&self) -> usize {
// COUNT ONLY retransmittable Data frames — control frames (Ack/Nack) must not
// 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 {
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) {
self.cc.on_send(bytes);
}
@ -207,13 +213,12 @@ impl ProtocolMachine {
.map(ProtocolAction::SendDatagram)
}
(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 when we initiated Close.
let result = self.handle_inbound(raw);
self.state = OstpState::Closed;
result
// The remote may still have data or ACKs in transit.
// handle_inbound transitions to Closed when it receives a Close frame.
self.handle_inbound(raw)
}
(OstpState::Established, OstpEvent::Tick) => self.handle_tick(),
(OstpState::Closing, OstpEvent::Tick) => self.handle_tick(),
(OstpState::Closed, _) => Ok(ProtocolAction::Noop),
(_, OstpEvent::Close) => {
self.state = OstpState::Closed;
@ -408,10 +413,10 @@ impl ProtocolMachine {
tracing::debug!("Frame nonce={} arrived too late after gap recovery, dropping", nonce);
}
// Rate-limited NACK: send at most once per 30ms to prevent retransmit storms.
// Under high load with natural UDP reordering, sending a NACK per packet
// causes exponential retransmit explosion that saturates the channel.
let nack_cooldown = Duration::from_millis(30);
// 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 {
self.last_nack_sent = Instant::now();
let nack_payload = self.expected_recv_nonce.to_be_bytes();
@ -525,30 +530,33 @@ impl ProtocolMachine {
}
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 ────────────────────────────────────
// 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 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();
if evicted > 0 {
tracing::debug!("Evicted {} zombie frames from sent_history (remaining={})", evicted, self.sent_history.len());
}
// ── 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();
for frame in self.sent_history.iter_mut() {
for frame in self.sent_history.values_mut() {
if !frame.is_retransmittable {
continue;
}
let retry_over = frame.retries.saturating_sub(self.max_retries);
let backoff_factor = 1u64 << retry_over.min(6);
let backoff_factor = 1u64 << (frame.retries as u64).min(6);
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor));
if now.duration_since(frame.last_sent) >= effective_rto {
@ -654,7 +662,7 @@ impl ProtocolMachine {
}
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.retries = frame.retries.saturating_add(1);
return Some(frame.bytes.clone());
@ -666,7 +674,7 @@ impl ProtocolMachine {
if is_retransmittable {
self.cc.on_send(bytes.len() as u64);
}
self.sent_history.push_back(SentFrame {
self.sent_history.insert(nonce, SentFrame {
nonce,
bytes,
last_sent: Instant::now(),
@ -679,7 +687,7 @@ impl ProtocolMachine {
overflow, self.max_sent_history
);
while self.sent_history.len() > self.max_sent_history {
self.sent_history.pop_front();
self.sent_history.pop_first();
}
}
}
@ -690,8 +698,8 @@ impl ProtocolMachine {
let mut min_rtt = Duration::from_secs(60);
// Compute RTT from the oldest acked frame's send timestamp
for frame in self.sent_history.iter() {
if nonce_in_ranges(frame.nonce, ranges) {
for (&nonce, frame) in &self.sent_history {
if nonce_in_ranges(nonce, ranges) {
acked_bytes += frame.bytes.len() as u64;
let rtt = now.duration_since(frame.last_sent);
if rtt < min_rtt {
@ -700,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
if acked_bytes > 0 {

View File

@ -1,41 +1,3 @@
use anyhow::{anyhow, Result};
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
use chacha20poly1305::aead::{Aead, KeyInit};
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
}
// Re-export the shared IPC crypto from ostp-client so that GUI and tun-helper
// always use identical encrypt/decrypt logic.
pub use ostp_client::ipc_crypto::{derive_key, IpcCrypto};

View File

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

View File

@ -12,12 +12,13 @@ use portable_atomic::AtomicU64;
// const MAX_SESSIONS removed because dynamic limit is used
pub enum DispatchOutcome {
Unauthorized(String),
Accepted {
responses: Vec<Bytes>,
app_payloads: Vec<(u32, u16, Bytes)>, // session_id, stream_id, payload
peer_addr: SocketAddr,
},
Unauthorized(String),
Ignored,
}
/// Per-user traffic statistics.
@ -83,7 +84,6 @@ pub struct Dispatcher {
last_token_regen: std::time::Instant,
}
#[allow(dead_code)]
impl Dispatcher {
pub fn new(machine_config: ProtocolConfig, access_keys: Arc<RwLock<HashMap<String, crate::api::UserMeta>>>) -> Self {
let mut initial_stats = HashMap::new();
@ -108,6 +108,7 @@ impl Dispatcher {
}
/// Snapshot all user stats for API responses.
#[allow(dead_code)]
pub fn snapshot_all_users(&self) -> Vec<UserStatsSnapshot> {
let stats = self.user_stats.read().unwrap_or_else(|e| e.into_inner());
let mut online_keys: HashMap<String, std::time::Instant> = HashMap::new();
@ -161,6 +162,7 @@ impl Dispatcher {
}
/// Set traffic limit for a user.
#[allow(dead_code)]
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 entry = stats.entry(key.to_string())
@ -176,6 +178,7 @@ impl Dispatcher {
}
/// Active session count.
#[allow(dead_code)]
pub fn active_sessions(&self) -> usize {
self.peer_machines.len()
}
@ -376,15 +379,19 @@ impl Dispatcher {
continue;
}
if !self.replay_cache.contains_key(&payload.to_vec()) {
if self.replay_cache.len() >= 50_000 {
tracing::warn!("Replay cache full (100000 entries), rejecting handshake from {}", peer);
return Ok(DispatchOutcome::Unauthorized("replay cache full".to_string()));
}
if self.replay_cache.contains_key(&payload.to_vec()) {
tracing::debug!("Replay detected from {}, ignoring", peer);
return Ok(DispatchOutcome::Ignored);
}
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
let user_stats = self.get_or_create_user_stats(&candidate_key);
@ -414,7 +421,6 @@ impl Dispatcher {
app_payloads: Vec::new(),
peer_addr: peer,
});
}
}
}
}
@ -429,23 +435,35 @@ impl Dispatcher {
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) {
existing
} else {
return Ok(None);
return Ok(Vec::new());
};
let addr = peer_state.last_addr;
let key = peer_state.access_key.clone();
match peer_state.machine.on_event(OstpEvent::Outbound(stream_id, payload))? {
ProtocolAction::SendDatagram(frame) => {
// Track outbound bytes per user
track_user_bytes_down(&self.user_stats, &self.access_keys, &key, frame.len() as u64);
Ok(Some((frame, addr)))
let action = peer_state.machine.on_event(OstpEvent::Outbound(stream_id, payload))?;
let mut frames = Vec::new();
let mut queue = vec![action];
while let Some(current) = queue.pop() {
match current {
ProtocolAction::Multiple(list) => {
for item in list {
queue.push(item);
}
}
ProtocolAction::SendDatagram(frame) => {
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>) {
@ -459,7 +477,7 @@ impl Dispatcher {
let mut frames = Vec::new();
let mut expired = Vec::new();
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
for (&sid, peer_state) in &self.peer_machines {
@ -477,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 user_stats = self.get_or_create_user_stats(&ps.access_key);
if now.duration_since(ps.last_seen) > timeout_dur {
"inactive >5min"
"inactive >10min"
} else if !key_valid {
"key deleted"
} else if user_stats.is_over_limit() {

View File

@ -1,15 +1,23 @@
use anyhow::Result;
use bytes::Bytes;
use std::collections::HashMap;
use std::net::IpAddr;
use std::collections::{HashMap, VecDeque};
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use dispatcher::{DispatchOutcome, Dispatcher};
use ostp_core::relay::RelayMessage;
use signal::wait_for_shutdown_signal;
use tokio::net::UdpSocket;
use tokio::sync::mpsc;
use tokio::sync::{mpsc, RwLock};
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;
pub mod outbound;
pub mod fallback;
@ -120,6 +128,29 @@ pub async fn run_server(
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
let shared_keys_clone = shared_keys.clone();
let user_stats_clone = dispatcher.user_stats_ref();
@ -455,17 +486,9 @@ async fn run_server_loop(
if let Some(dns_cfg) = dns_transport {
if dns_cfg.enabled {
let dns_udp_tx = udp_tx.clone();
let dns_tcp_map = tcp_map.clone();
let dns_ui_tx = ui_event_tx.clone();
tokio::spawn(async move {
crate::transport::dns::start_dns_transport_server(
dns_cfg,
dns_udp_tx,
dns_tcp_map,
dns_ui_tx,
).await;
});
// DNS transport is now handled entirely by dnstt-server launched at startup.
// We just trace it here.
tracing::info!("DNS Transport via dnstt is enabled");
}
}
@ -585,7 +608,11 @@ async fn handle_udp_packet(
if !peer_available.get(&peer_ip).copied().unwrap_or(false) {
peer_available.insert(peer_ip, true);
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}")));
}
@ -609,7 +636,21 @@ async fn handle_udp_packet(
}
}
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 });
}
@ -636,6 +677,9 @@ async fn handle_udp_packet(
).await?;
}
}
Ok(DispatchOutcome::Ignored) => {
// Handshake replay, safely ignored
}
Err(err) => {
let _ = ui_event_tx.send(UiEvent::Log(format!("Protocol error for {peer}: {err}")));
}
@ -672,7 +716,19 @@ async fn handle_tick(
}
}
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 {

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>>>>,
) -> Result<()> {
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 mut sent_tcp = false;
{
let map = tcp_map.read().await;
if let Some(tx) = map.get(&peer_addr) {
let _ = tx.try_send(frame.clone());
sent_tcp = true;
// Use a bounded async send with a generous timeout instead of try_send.
// 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 {
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 {
peer: peer_addr.ip(),
@ -267,3 +307,4 @@ pub async fn send_relay_to_stream(
}
Ok(())
}

View File

@ -1,346 +1 @@
/// DNS tunnel transport — dnstt-style server implementation.
///
/// Each DNS TXT query from client contains a framed chunk:
/// Base32([client_id: 8][msg_id: 2 BE][total_frags: 1][frag_idx: 1][payload: ≤120])
///
/// Server:
/// 1. Decodes ClientID + fragment from query name
/// 2. Reassembles fragments per (client_id, msg_id)
/// 3. Forwards complete OSTP packet to dispatcher (udp_tx)
/// 4. Waits up to MAX_RESPONSE_DELAY for responses
/// 5. Bundles responses as length-prefixed packets in DNS TXT answer
///
/// Server → client data in TXT rdata: [len_hi][len_lo][data...]...
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use bytes::Bytes;
use tokio::net::UdpSocket;
use tokio::sync::{mpsc, RwLock};
use tokio::time::Duration;
use ostp_core::dns::{base32_decode, DnsPacket, DnsRecordType};
use crate::config::DnsTransportConfig;
use crate::UiEvent;
const CLIENT_ID_LEN: usize = 8;
const HEADER_LEN: usize = CLIENT_ID_LEN + 4; // client_id + msg_id(2) + total(1) + idx(1)
/// How long to wait for downstream OSTP data before sending an empty response.
const MAX_RESPONSE_DELAY: Duration = Duration::from_millis(800);
/// Maximum number of response packets to bundle into one DNS answer.
const MAX_RESPONSE_PACKETS: usize = 8;
/// How long to keep per-client reassembly state without activity.
const CLIENT_EXPIRY: Duration = Duration::from_secs(30);
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
struct ClientId([u8; CLIENT_ID_LEN]);
struct ReassemblyState {
total: u8,
frags: Vec<Option<Vec<u8>>>,
received: u8,
}
impl ReassemblyState {
fn new(total: u8) -> Self {
Self {
total,
frags: vec![None; total as usize],
received: 0,
}
}
fn insert(&mut self, idx: u8, payload: Vec<u8>) -> bool {
let idx = idx as usize;
if idx >= self.frags.len() { return false; }
if self.frags[idx].is_none() {
self.frags[idx] = Some(payload);
self.received += 1;
}
self.received >= self.total
}
fn assemble(self) -> Option<Vec<u8>> {
let mut out = Vec::new();
for frag in self.frags {
out.extend_from_slice(&frag?);
}
Some(out)
}
}
struct ClientState {
/// msg_id → reassembly buffer
reassembly: HashMap<u16, ReassemblyState>,
/// Channel to push pending responses into; DNS handler polls this per-query
#[allow(dead_code)]
resp_tx: mpsc::Sender<Bytes>,
last_seen: std::time::Instant,
}
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)));
// Per-client state: ClientId → ClientState
// Access is serialised by a single Mutex so fragments from the same client
// are always reassembled atomically.
let clients: Arc<tokio::sync::Mutex<HashMap<ClientId, ClientState>>> =
Arc::new(tokio::sync::Mutex::new(HashMap::new()));
// Cleanup task: evict stale client state
{
let clients_gc = clients.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(15)).await;
let mut map = clients_gc.lock().await;
map.retain(|_, v| v.last_seen.elapsed() < CLIENT_EXPIRY);
}
});
}
let base_domain = config.domain.clone();
let mut buf = vec![0u8; 65535];
loop {
let (size, peer) = match socket.recv_from(&mut buf).await {
Ok(v) => v,
Err(e) => {
tracing::warn!("DNS Transport recv error: {}", e);
continue;
}
};
let packet_bytes = buf[..size].to_vec();
let udp_tx = udp_tx.clone();
let tcp_map = tcp_map.clone();
let socket = socket.clone();
let clients = clients.clone();
let base_domain = base_domain.clone();
tokio::spawn(async move {
handle_dns_query(
packet_bytes, peer,
udp_tx, tcp_map, socket, clients, base_domain,
).await;
});
}
}
async fn handle_dns_query(
packet_bytes: Vec<u8>,
peer: SocketAddr,
udp_tx: mpsc::Sender<(Bytes, SocketAddr)>,
tcp_map: Arc<RwLock<HashMap<SocketAddr, mpsc::Sender<Bytes>>>>,
socket: Arc<UdpSocket>,
clients: Arc<tokio::sync::Mutex<HashMap<ClientId, ClientState>>>,
base_domain: String,
) {
let dns_req = match DnsPacket::decode(&packet_bytes) {
Some(p) => p,
None => {
tracing::debug!("DNS: failed to decode packet from {}", peer);
return;
}
};
if dns_req.questions.is_empty() { return; }
let query = &dns_req.questions[0];
if query.qtype != DnsRecordType::TXT && query.qtype != DnsRecordType::NULL {
let resp = build_dns_response(&dns_req, &query.name, query.qtype.clone(), vec![]);
let _ = socket.send_to(&resp, peer).await;
return;
}
if !query.name.ends_with(&base_domain) {
let mut resp = DnsPacket::new_response(dns_req.id, &query.name, query.qtype.clone(), vec![]);
resp.flags = 0x8183; // NXDOMAIN
let _ = socket.send_to(&resp.encode(), peer).await;
return;
}
// Strip base domain and labels separator to get base32 subdomain
let subdomain = {
let name_lower = query.name.to_lowercase();
let suffix = format!(".{}", base_domain.to_lowercase());
let suffix_bare = base_domain.to_lowercase();
let stripped = if name_lower.ends_with(&suffix) {
&query.name[..name_lower.len() - suffix.len()]
} else if name_lower == suffix_bare {
""
} else {
return;
};
// Remove dots (label separators) to get contiguous base32
stripped.replace('.', "")
};
if subdomain.is_empty() {
// Pure poll — no payload
let resp = build_dns_response(&dns_req, &query.name, query.qtype.clone(), vec![]);
let _ = socket.send_to(&resp, peer).await;
return;
}
// Base32-decode
let raw = match base32_decode(&subdomain) {
Some(b) => b,
None => {
tracing::debug!("DNS: base32 decode failed from {}", peer);
return;
}
};
if raw.len() < HEADER_LEN {
tracing::debug!("DNS: frame too short ({} bytes) from {}", raw.len(), peer);
return;
}
// Parse header
let client_id = ClientId(raw[..CLIENT_ID_LEN].try_into().unwrap());
let msg_id = u16::from_be_bytes([raw[8], raw[9]]);
let total_frags = raw[10];
let frag_idx = raw[11];
let payload = raw[HEADER_LEN..].to_vec();
let fake_peer = client_id_to_fake_addr(&client_id);
tracing::trace!("DNS: client={} msg={} frag={}/{} payload={}B",
hex::encode(&client_id.0), msg_id, frag_idx + 1, total_frags, payload.len());
// ── Reassembly ────────────────────────────────────────────────────────────
let complete_packet: Option<Vec<u8>> = {
let mut map = clients.lock().await;
let state = map.entry(client_id).or_insert_with(|| {
let (resp_tx, _) = mpsc::channel(64); // placeholder, will be replaced below
ClientState {
reassembly: HashMap::new(),
resp_tx,
last_seen: std::time::Instant::now(),
}
});
state.last_seen = std::time::Instant::now();
if total_frags == 0 {
// Empty poll — no data
None
} else if total_frags == 1 && payload.is_empty() {
// Poll with empty payload
None
} else {
let asm = state.reassembly
.entry(msg_id)
.or_insert_with(|| ReassemblyState::new(total_frags));
if asm.insert(frag_idx, payload) {
// All fragments received — assemble and remove
let complete = state.reassembly.remove(&msg_id)
.and_then(|s| s.assemble());
complete
} else {
None
}
}
};
// ── Create per-query response channel ────────────────────────────────────
// We use the stable fake_peer as the routing key in tcp_map.
// For each query we create a fresh one-shot channel.
let (resp_tx, mut resp_rx) = mpsc::channel::<Bytes>(MAX_RESPONSE_PACKETS);
tcp_map.write().await.insert(fake_peer, resp_tx.clone());
// ── Forward complete OSTP packet to dispatcher ────────────────────────────
if let Some(ostp_pkt) = complete_packet {
tracing::debug!("DNS: forwarding {}B OSTP packet from client={} to dispatcher",
ostp_pkt.len(), hex::encode(&client_id.0));
let _ = udp_tx.send((Bytes::from(ostp_pkt), fake_peer)).await;
}
// ── Wait for OSTP response(s) ─────────────────────────────────────────────
let mut responses: Vec<Bytes> = Vec::new();
let deadline = tokio::time::sleep(MAX_RESPONSE_DELAY);
tokio::pin!(deadline);
loop {
tokio::select! {
_ = &mut deadline => break,
resp = resp_rx.recv() => {
match resp {
Some(r) => {
responses.push(r);
if responses.len() >= MAX_RESPONSE_PACKETS { break; }
}
None => break,
}
}
}
}
// Only remove if it's still our channel
{
let mut map = tcp_map.write().await;
if let Some(existing_tx) = map.get(&fake_peer) {
if existing_tx.same_channel(&resp_tx) {
map.remove(&fake_peer);
}
}
}
// ── Build DNS TXT response ────────────────────────────────────────────────
// Bundle all response packets as length-prefixed data in TXT rdata:
// [len_hi][len_lo][data...]...
let mut rdata: Vec<u8> = Vec::new();
for r in &responses {
let len = r.len() as u16;
rdata.push((len >> 8) as u8);
rdata.push((len & 0xFF) as u8);
rdata.extend_from_slice(r);
}
tracing::trace!("DNS: responding to {} with {} OSTP packets ({} bytes rdata)",
peer, responses.len(), rdata.len());
let resp = build_dns_response(&dns_req, &query.name, query.qtype.clone(), rdata);
let _ = socket.send_to(&resp, peer).await;
}
/// Build a DNS response packet with the given TXT rdata.
fn build_dns_response(
req: &DnsPacket,
name: &str,
rtype: DnsRecordType,
rdata: Vec<u8>,
) -> Vec<u8> {
let resp = DnsPacket::new_response(req.id, name, rtype, rdata);
resp.encode()
}
fn client_id_to_fake_addr(client_id: &ClientId) -> SocketAddr {
let mut ip_bytes = [10, 255, 0, 0];
ip_bytes[2] = client_id.0[0];
ip_bytes[3] = client_id.0[1];
let port = u16::from_be_bytes([client_id.0[2], client_id.0[3]]);
let port = if port == 0 { 1 } else { port };
SocketAddr::from((ip_bytes, port))
}
// Left empty by request

View File

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

View File

@ -11,10 +11,11 @@ path = "src/main.rs"
ostp-client = { path = "../ostp-client" }
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
portable-atomic = { workspace = true }
chrono = "0.4"
hex = "0.4"
[build-dependencies]
# 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")]
use anyhow::Result;
use hex;
use ostp_client::ipc_crypto::{derive_key, IpcCrypto};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
use std::io::Write as _;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{watch, Mutex};
use tokio::net::TcpListener;
use tokio::sync::{watch, Mutex};
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)]
#[serde(tag = "cmd", rename_all = "lowercase")]
enum GuiCmd {
@ -72,22 +58,22 @@ async fn main() -> Result<()> {
let path = &args[i + 1];
if let Ok(content) = std::fs::read_to_string(path) {
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() {
log_to_file("FATAL: Auth token is required for security (--token-file or OSTP_TUN_TOKEN).");
return Err(anyhow::anyhow!("Auth token is required"));
tracing::error!("auth token is required (--token-file or OSTP_TUN_TOKEN)");
return Err(anyhow::anyhow!("auth token is required"));
}
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(())
}
@ -98,24 +84,26 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
metrics: None,
}));
let ipc_key = derive_key(&expected_token);
let crypto = IpcCrypto::new(&ipc_key);
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| {
log_to_file(&format!("Bind failed: {}", e));
tracing::error!("bind failed: {}", 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 {
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(());
}
};
log_to_file("GUI connected via TCP");
tracing::info!("GUI connected");
let (reader_half, writer_half) = tokio::io::split(socket);
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 writer = writer.clone();
let crypto = crypto.clone();
move |msg: HelperMsg| {
let writer = writer.clone();
let crypto = crypto.clone();
let json = serde_json::to_string(&msg).unwrap_or_default();
tokio::spawn(async move {
let mut w = writer.lock().await;
let _ = w.write_all(format!("{}\n", json).as_bytes()).await;
match crypto.encrypt(json.as_bytes()) {
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();
let n = reader.read_line(&mut line).await.unwrap_or(0);
if n == 0 {
log_to_file("GUI disconnected, stopping tunnel");
tracing::info!("GUI disconnected, stopping tunnel");
let mut st = state.lock().await;
if let Some(tx) = st.shutdown_tx.take() {
let _ = tx.send(true);
@ -149,10 +145,23 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
let trimmed = line.trim();
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,
Err(e) => {
send_msg(HelperMsg::Error { message: format!("Bad command: {}", e) });
send_msg(HelperMsg::Error { message: format!("bad command: {}", e) });
continue;
}
};
@ -160,11 +169,11 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
match cmd {
GuiCmd::Start { config, token } => {
if token != expected_token {
log_to_file("Received START command with invalid token");
send_msg(HelperMsg::Error { message: "Invalid authorization token".to_string() });
tracing::warn!("START command with invalid token");
send_msg(HelperMsg::Error { message: "invalid authorization token".to_string() });
continue;
}
log_to_file("Received START command");
tracing::info!("received START command");
{
let mut st = state.lock().await;
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) {
Ok(c) => c,
Err(e) => {
log_to_file(&format!("Config parse error: {}", e));
send_msg(HelperMsg::Error { message: format!("Config parse error: {}", e) });
tracing::error!("config parse error: {}", e);
send_msg(HelperMsg::Error { message: format!("config parse error: {}", e) });
continue;
}
};
@ -201,21 +210,26 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
let metrics_for_runner = metrics.clone();
let writer_for_err = writer.clone();
let crypto_for_err = crypto.clone();
let shutdown_rx_for_core = shutdown_rx.clone();
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 {
Ok(_) => { log_to_file("Tunnel core stopped normally"); }
Ok(_) => tracing::info!("tunnel core stopped normally"),
Err(e) => {
log_to_file(&format!("Tunnel core error: {}", e));
let json = serde_json::to_string(&HelperMsg::Error { message: e.to_string() }).unwrap_or_default();
let mut w = writer_for_err.lock().await;
let _ = w.write_all(format!("{}\n", json).as_bytes()).await;
tracing::error!("tunnel core error: {}", e);
let json = serde_json::to_string(&HelperMsg::Error { message: e.to_string() })
.unwrap_or_default();
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 crypto_tick = crypto.clone();
let metrics_tick = metrics.clone();
let mut shutdown_rx_tick = shutdown_rx.clone();
tokio::spawn(async move {
@ -231,17 +245,24 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
let cs = metrics_tick.connection_state.load(Ordering::Relaxed);
let sent = metrics_tick.bytes_sent.load(Ordering::Relaxed);
let recv = metrics_tick.bytes_recv.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 {
last_state = cs;
let json = serde_json::to_string(&HelperMsg::Status { value: cs }).unwrap_or_default();
if w.write_all(format!("{}\n", json).as_bytes()).await.is_err() { break; }
msgs.push(HelperMsg::Status { value: cs });
}
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);
}
});
@ -250,15 +271,15 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
}
GuiCmd::Reload { config, 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;
}
log_to_file("Received RELOAD command");
tracing::info!("received RELOAD command");
let cfg: ostp_client::config::ClientConfig = match serde_json::from_str(&config) {
Ok(c) => c,
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;
}
};
@ -267,7 +288,7 @@ async fn run_server(expected_token: String, port: u16) -> Result<()> {
let st = state.lock().await;
if let Some(tx) = &st.config_tx {
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 } => {
if token != expected_token {
log_to_file("Received STOP command with invalid token");
send_msg(HelperMsg::Error { message: "Invalid authorization token".to_string() });
tracing::warn!("STOP command with invalid token");
send_msg(HelperMsg::Error { message: "invalid authorization token".to_string() });
continue;
}
log_to_file("Received STOP command");
tracing::info!("received STOP command");
let mut st = state.lock().await;
if let Some(tx) = st.shutdown_tx.take() {
let _ = tx.send(true);

View File

@ -1464,8 +1464,15 @@ async fn run_app() -> Result<()> {
if let Some(ref mode_str) = args.init {
let is_server = mode_str == "server";
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 {
format!(r#"{{
// OSTP Server Configuration