Compare commits

..

13 Commits

Author SHA1 Message Date
ospab cdfd2babc0 chore: release v0.4.2-beta.4 on beta 2026-07-21 18:18:01 +03:00
ospab 2092e22a7c fix(cli): missing sha2::Digest import broke the CI build (v0.4.2-beta.3)
Sha256::digest() is a trait method (from digest::Digest, re-exported as
sha2::Digest), not an inherent one - fully-qualifying the call
(sha2::Sha256::digest(...)) doesn't exempt it from Rust's requirement that
the trait itself be in scope for method resolution. Whatever made this
resolve locally without the explicit import didn't reproduce on the CI
runner's dependency resolution, breaking `cargo check` and killing the
whole v0.4.2-beta.3 matrix before a single platform job even started.
Added the import; harmless even where it isn't strictly needed.
2026-07-21 18:17:49 +03:00
ospab 5278f58903 chore: release v0.4.2-beta.3 on beta 2026-07-21 18:12:30 +03:00
ospab 340819745a chore: update Cargo.lock for ostp's new direct sha2 dependency 2026-07-21 18:10:23 +03:00
ospab e31c4b2268 fix(protocol): don't abandon slow start over a single isolated packet loss
Root cause of "connection takes 20-30s, sometimes 1-2 minutes, to reach
stable throughput" (trickle of KB for a while, then a sudden jump to full
speed): on_loss during SlowStart unconditionally halved cwnd AND
permanently switched to ProbeBandwidth's linear (+1 MTU/RTT) growth on the
very FIRST loss. Real mobile/Wi-Fi links have a non-zero background loss
rate from ordinary wireless noise and handover blips that has nothing to
do with congestion; on such a link the first RTT or two of slow start would
hit a loss, get knocked into linear growth from a still-small window, and
take an enormous number of RTTs to claw back up to full speed - directly
contradicting the module's own stated BBR-inspired design intent, since
real BBR is deliberately loss-tolerant during startup instead of treating
any loss as a hard congestion signal.

Fix: track losses within a short (500ms) window and only pay the full
exit-slow-start-and-halve cost once SLOW_START_LOSS_TOLERANCE (3) losses
land within it - sustained loss is still treated as real congestion.  A
single isolated loss now takes a mild, temporary haircut (cwnd *= 0.8) but
stays in slow start, so exponential growth continues instead of being
abandoned over a one-off dropped packet.
2026-07-21 18:08:04 +03:00
ospab e46c863ef0 fix(client): stop leaking a socket+task per direct-bypassed SOCKS5 UDP flow
Same class of bug as dbf923f (which fixed the TUN-mode UDP NAT path):
handle_udp_associate's direct-bypass branch spawned spawn_direct_udp_reader
holding its own Arc<UdpSocket> clone with no way to know when the
UDP-associate session it belonged to had ended. Every SOCKS5 UDP session
that ever bypassed traffic direct (an excluded IP/domain) leaked one
socket + one reader task for the rest of the process's life.

Wired a oneshot cancellation channel per spawned reader, held by
handle_udp_associate itself: the channel closes automatically (no explicit
signal needed) the instant that function returns, on every exit path,
telling the reader loop to stop via tokio::select! against the cancel
future.
2026-07-21 18:07:49 +03:00
ospab cddd623ad0 fix(client): coalesce bursty NetworkChanged events on mobile handoff
Root cause of "constantly disconnects on mobile, have to reconnect
manually": Android's ConnectivityManager fires onLost(old) + onAvailable(new)
within milliseconds of each other during a real Wi-Fi<->cellular handoff,
and each one queues its own BridgeCommand::NetworkChanged. Each reconnect is
a full sequential handshake (up to ~1.2s x 4 attempts x mux_sessions) run
synchronously inside the bridge's select-loop iteration - so without
coalescing, the FIRST queued NetworkChanged often starts reconnecting before
the OS has actually finished switching networks, races the dying interface,
and only fails after burning its full attempt budget. Only THEN does the
SECOND (correct) NetworkChanged get to run its own reconnect. A sub-second
handoff was turning into several extra seconds of outage on every
occurrence, and multiple back-to-back handoffs (common walking in/out of
Wi-Fi range) compounded this every time.

Fix: on NetworkChanged, drain any additional same-kind events already
queued before starting the reconnect, so a burst collapses into one attempt
using the freshest signal. A different command found while draining isn't
dropped - it's dispatched immediately (recursing into handle_bridge_cmd)
so nothing queued behind the burst gets lost or reordered incorrectly.
2026-07-21 17:57:39 +03:00
ospab 9a891310f9 fix(cli): setup wizard used a fake password hash, locking admins out of their own panel
The Server+Panel setup wizard's panel-password hashing was a placeholder:
std::collections::hash_map::DefaultHasher (SipHash, not cryptographic, and
not even a 256-bit output - only the first 8 of 32 bytes were real, the
rest zero-padded), left in by the comment "sha2 is not a direct dep of
ostp/Cargo.toml, so we use std's hasher as a placeholder digest here."

api.rs's handle_login computes the REAL SHA256 hex digest of the submitted
password and compares it against config.json's stored password_hash. Since
the wizard's placeholder never produces the same value as real SHA256 of
the same password, anyone who set up a panel through this wizard could
never actually log into it with the password it just showed them - a
complete functional break of the wizard-driven admin flow, not a corner
case.

Added sha2 as a direct ostp dependency and replaced the placeholder with
the exact same format!("{:x}", Sha256::digest(..)) api.rs's login check
uses.
2026-07-18 18:14:19 +03:00
ospab d9686c9344 fix(ci): cap lints when installing cross, so its own code can't fail our build
The mipsel-unknown-linux-musl job in v0.4.2-beta.2 failed at "Install cross":
cross-rs's own source uses a macro-at-end-of-block pattern (eyre::bail!())
that trips rustc's semicolon_in_expressions_from_macros lint on current
toolchains. `cargo install` compiles the installed package as the "local"
crate, so Cargo's usual automatic lint-capping for dependencies doesn't
apply to cross's own code - and other cross-built targets in the same run
(armv7, aarch64-linux, i686-linux) succeeded, so this reads as a race
against cross-rs's unpinned `main` branch history (no --rev/--tag) rather
than a deterministic break.

RUSTFLAGS="--cap-lints=warn" is the standard mechanism for exactly this
situation - building a third-party tool against a newer compiler than its
own lint config assumed - without touching our own build's lint levels.
More robust than pinning to one historical commit, which just relocates
the same risk to whenever that pin is next updated.
2026-07-18 17:59:02 +03:00
ospab dbf923fb16 fix(client): stop leaking a socket+task per bypassed UDP flow
start_udp_bypass_session (the TUN-mode path for UDP from apps/IPs the user
has excluded from the tunnel) spawned a separate task to read from the
physical-interface-bound socket, holding its own Arc<UdpSocket> clone.
Nothing ever cancelled that task when the outer function returned (e.g.
once session_rx closed) - it just kept running, and its socket clone kept
the OS fd alive, for the lifetime of the process. Every distinct bypassed
UDP flow (any excluded app's DNS query, game session, etc.) leaked one
socket and one task permanently.

The sibling function right below it, start_udp_session, already does this
correctly: one tokio::select! loop combining both directions in a single
task that exits (and drops the socket) as soon as either side closes.
Rewrote start_udp_bypass_session to match that pattern instead of
spawning a detached reader task.
2026-07-18 17:46:14 +03:00
ospab 51b947e6ff fix(server): rate-limit the (currently unwired) open UDP DNS listener
DnsServer::run_local_udp_listener binds 0.0.0.0 and answers every UDP
datagram by resolving it and replying to the packet's (unverified,
spoofable) source address - a textbook DNS reflection/amplification
primitive. An attacker spoofing a victim's IP as the query source turns
any server with this listener running into a free amplifier against that
victim, with zero authentication gating it (unlike the main OSTP port,
there's no Noise handshake here).

Nothing in the codebase currently calls this function - the live DNS path
is router.route_dns(), reached only through the authenticated OSTP tunnel
relay (relay.rs). But the doc comment describes this as an intended,
not-yet-wired entry point for clients that point their OS resolver
directly at the server, so it's a real latent risk for whoever connects it
without realizing the implication. Added a global (not per-source-IP -
per-IP limiting doesn't help against a reflection attack, since the
attacker never sees the replies and can spread queries across arbitrary
spoofed sources) token bucket capping total replies/sec, so connecting
this later can't silently reintroduce unbounded amplification.
2026-07-18 17:42:49 +03:00
ospab f01ed4ec25 fix(server): constant-time comparison for Management API secrets
check_token() and handle_login() compared bearer tokens, session tokens,
and the password hash with plain ==, which short-circuits on the first
differing byte - a textbook remote timing side-channel against exactly
the long-lived secrets these gates exist to protect. Added subtle (already
in the dependency tree transitively via chacha20poly1305) as a direct
dependency and route every secret comparison through a small secure_eq()
wrapper over ConstantTimeEq. Username comparison in handle_login is left
as-is: it isn't treated as a secret in this threat model (one fixed admin
username), matching standard practice of only constant-timing the
password/token side of an auth check.

Added tests for secure_eq() itself (equal, different, different-length,
empty) alongside the existing check_token coverage.
2026-07-18 17:38:09 +03:00
ospab c2a1a53b4d fix(server): audit-log API endpoints had no auth check at all
GET/POST/DELETE /api/audit were the only three handlers in the whole
Management API that never called check_token() - every other endpoint
(status, users, rules, config) does. Concretely, with the panel's
credentials configured, an unauthenticated request could still:
  - read the full audit log (GET)
  - inject arbitrary forged entries, e.g. fake "success" events to cover
    tracks (POST)
  - wipe the entire audit log (DELETE) - the exact mechanism meant to
    detect and investigate unauthorized actions, erasable with zero auth

Added the same check_token() gate the rest of the file uses, and fixed
these three handlers' raw .unwrap() on the audit_logs lock to the
poison-recovery pattern (unwrap_or_else(|e| e.into_inner())) used
everywhere else, for consistency.

Added focused unit tests on check_token() itself (missing header, correct/
wrong bearer, raw token, session token, and the documented open-panel
mode when no credentials are configured) - it's the single gate every
sensitive handler depends on, worth pinning down independently of any one
handler.
2026-07-18 17:27:58 +03:00
13 changed files with 329 additions and 62 deletions

View File

@ -284,7 +284,15 @@ jobs:
- name: Install cross (if not cached) - name: Install cross (if not cached)
if: ${{ matrix.use_cross && steps.cross-cache.outputs.cache-hit != 'true' }} if: ${{ matrix.use_cross && steps.cross-cache.outputs.cache-hit != 'true' }}
run: cargo install cross --git https://github.com/cross-rs/cross.git --locked # cross-rs's own source (not ours, not a dependency of ours) uses a
# macro-at-end-of-block pattern that trips rustc's
# semicolon_in_expressions_from_macros lint on current toolchains -
# harmless in cross's actual behavior, but `cargo install` compiles
# the installed package as the "local" crate, so dependency lint
# capping doesn't shield it. --cap-lints=warn is the standard escape
# hatch for building a third-party tool against a newer compiler than
# its own lint config assumed; it doesn't touch our own build.
run: RUSTFLAGS="--cap-lints=warn" cargo install cross --git https://github.com/cross-rs/cross.git --locked
- name: Build (cross) - name: Build (cross)
if: ${{ matrix.use_cross }} if: ${{ matrix.use_cross }}

View File

@ -2,5 +2,5 @@
"target_version": "0.4.2", "target_version": "0.4.2",
"branch": "beta", "branch": "beta",
"alpha_iteration": 0, "alpha_iteration": 0,
"beta_iteration": 2 "beta_iteration": 4
} }

2
Cargo.lock generated
View File

@ -1400,6 +1400,7 @@ dependencies = [
"rlimit", "rlimit",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@ -1496,6 +1497,7 @@ dependencies = [
"sha2", "sha2",
"simple-dns", "simple-dns",
"socket2", "socket2",
"subtle",
"tokio", "tokio",
"tower-http", "tower-http",
"tracing", "tracing",

View File

@ -231,7 +231,7 @@ impl Bridge {
self.handle_inbound_udp(udp_msg, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await; self.handle_inbound_udp(udp_msg, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await;
} }
cmd = bridge_rx.recv() => { cmd = bridge_rx.recv() => {
if !self.handle_bridge_cmd(cmd, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await { if !self.handle_bridge_cmd(cmd, &mut bridge_rx, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await {
break; break;
} }
} }
@ -374,6 +374,7 @@ impl Bridge {
async fn handle_bridge_cmd( async fn handle_bridge_cmd(
&mut self, &mut self,
cmd: Option<BridgeCommand>, cmd: Option<BridgeCommand>,
bridge_rx: &mut mpsc::Receiver<BridgeCommand>,
sessions_opt: &mut Option<Vec<SessionState>>, sessions_opt: &mut Option<Vec<SessionState>>,
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>, udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>, proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
@ -465,6 +466,32 @@ impl Bridge {
tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok(); tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok();
} }
Some(BridgeCommand::NetworkChanged) => { Some(BridgeCommand::NetworkChanged) => {
// A real network handoff (Wi-Fi <-> cellular) commonly fires
// onLost + onAvailable within milliseconds of each other on
// Android, queuing several NetworkChanged commands back to
// back. Each reconnect below is a full sequential handshake
// (up to ~1.2s x 4 attempts x mux_sessions) run synchronously
// in this select-loop iteration, so without coalescing, the
// first attempt often races the OS's own network switch and
// fails on the now-dead interface, then the SECOND queued
// NetworkChanged only starts its own full reconnect after
// that first one finishes - multiplying a sub-second handoff
// into many seconds of extra outage. Drain same-kind repeats
// so a burst collapses into one reconnect on the freshest
// signal; a different command found while draining is
// handled immediately rather than dropped.
while let Ok(next) = bridge_rx.try_recv() {
if !matches!(next, BridgeCommand::NetworkChanged) {
let more = Box::pin(self.handle_bridge_cmd(
Some(next), bridge_rx, sessions_opt, udp_rx_opt, proxy_guard, stream_map, tx, proxy_tx,
)).await;
if !more {
return false;
}
break;
}
}
if self.running { if self.running {
let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await; let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await;
self.metrics.connection_state.store(1, Ordering::Relaxed); self.metrics.connection_state.store(1, Ordering::Relaxed);

View File

@ -361,6 +361,10 @@ async fn handle_udp_associate(
let mut direct_udp_v4: Option<Arc<UdpSocket>> = None; let mut direct_udp_v4: Option<Arc<UdpSocket>> = None;
let mut direct_udp_v6: Option<Arc<UdpSocket>> = None; let mut direct_udp_v6: Option<Arc<UdpSocket>> = None;
// Held only to keep the direct-UDP readers' cancellation senders alive;
// dropping this (on every return path from this function) is what tells
// spawn_direct_udp_reader's tasks to stop. See its doc comment.
let mut direct_udp_cancel_txs: Vec<tokio::sync::oneshot::Sender<()>> = Vec::new();
let mut tcp_buf = [0u8; 1]; let mut tcp_buf = [0u8; 1];
loop { loop {
@ -432,7 +436,9 @@ async fn handle_udp_associate(
match create_udp_socket_bypassing_tun(true, matcher.physical_if_index, &matcher.physical_if_name).await { match create_udp_socket_bypassing_tun(true, matcher.physical_if_index, &matcher.physical_if_name).await {
Ok(s) => { Ok(s) => {
let s_arc = Arc::new(s); let s_arc = Arc::new(s);
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug); let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug, cancel_rx);
direct_udp_cancel_txs.push(cancel_tx);
direct_udp_v6 = Some(s_arc); direct_udp_v6 = Some(s_arc);
} }
Err(e) => { Err(e) => {
@ -446,7 +452,9 @@ async fn handle_udp_associate(
match create_udp_socket_bypassing_tun(false, matcher.physical_if_index, &matcher.physical_if_name).await { match create_udp_socket_bypassing_tun(false, matcher.physical_if_index, &matcher.physical_if_name).await {
Ok(s) => { Ok(s) => {
let s_arc = Arc::new(s); let s_arc = Arc::new(s);
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug); let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug, cancel_rx);
direct_udp_cancel_txs.push(cancel_tx);
direct_udp_v4 = Some(s_arc); direct_udp_v4 = Some(s_arc);
} }
Err(e) => { Err(e) => {
@ -520,11 +528,24 @@ fn spawn_direct_udp_reader(
sock_tx: Arc<UdpSocket>, sock_tx: Arc<UdpSocket>,
client_udp_addr: Arc<std::sync::Mutex<Option<std::net::SocketAddr>>>, client_udp_addr: Arc<std::sync::Mutex<Option<std::net::SocketAddr>>>,
_debug: bool, _debug: bool,
mut cancel_rx: tokio::sync::oneshot::Receiver<()>,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
let mut buf = vec![0u8; 65536]; let mut buf = vec![0u8; 65536];
loop { loop {
match direct_socket.recv_from(&mut buf).await { let recv_result = tokio::select! {
// Fires as soon as the sender half (held by handle_udp_associate
// for exactly this reason) is dropped - which happens the
// instant that function returns, on every exit path, with no
// explicit signaling needed. Without this, a UDP-associate
// session that ever bypassed traffic direct (excluded IP/
// domain) leaked this socket + task for the rest of the
// process's life once the session ended: nothing else ever
// stopped this loop.
_ = &mut cancel_rx => break,
res = direct_socket.recv_from(&mut buf) => res,
};
match recv_result {
Ok((len, target_addr)) => { Ok((len, target_addr)) => {
let client_addr = { let client_addr = {
let guard = client_udp_addr.lock().unwrap(); let guard = client_udp_addr.lock().unwrap();

View File

@ -138,27 +138,34 @@ async fn start_udp_bypass_session(
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name); let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
} }
let socket = Arc::new(socket); // A single select! loop over both directions, rather than spawning a
let socket_rx = socket.clone(); // separate task for the read side, so the whole session - physical
// socket included - is torn down the moment this function returns
// Spawn a task to read from physical socket and send back to smoltcp // (e.g. when session_rx closes). The previous spawned-task version left
let tx_clone = smoltcp_tx.clone(); // that task (and its Arc<UdpSocket> clone, keeping the OS socket fd
tokio::spawn(async move { // alive) running forever after this function returned: nothing ever
// cancelled it, so every bypassed UDP flow (any excluded app/IP in TUN
// mode) leaked one socket + one task for the lifetime of the process.
use futures::SinkExt; use futures::SinkExt;
let mut buf = [0u8; 65536]; let mut buf = [0u8; 65536];
loop { loop {
match socket_rx.recv_from(&mut buf).await { tokio::select! {
outbound = session_rx.recv() => {
match outbound {
Some((payload, dst)) => { socket.send_to(&payload, dst).await?; }
None => break,
}
}
inbound = socket.recv_from(&mut buf) => {
match inbound {
Ok((n, peer)) => { Ok((n, peer)) => {
let mut lock = tx_clone.lock().await; let mut lock = smoltcp_tx.lock().await;
let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await; let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await;
} }
Err(_) => break, Err(_) => break,
} }
} }
}); }
while let Some((payload, dst)) = session_rx.recv().await {
socket.send_to(&payload, dst).await?;
} }
Ok(()) Ok(())

View File

@ -43,6 +43,11 @@ pub struct CongestionController {
mtu: u64, mtu: u64,
/// Min RTT expiry: re-probe after 10 seconds /// Min RTT expiry: re-probe after 10 seconds
min_rtt_stamp: Instant, min_rtt_stamp: Instant,
/// Loss events counted toward SLOW_START_LOSS_TOLERANCE within the
/// current SLOW_START_LOSS_WINDOW (see on_loss's SlowStart arm).
slow_start_losses: u32,
/// Start of the current loss-tolerance window.
slow_start_loss_window_start: Instant,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -67,6 +72,24 @@ const RTO_MAX: Duration = Duration::from_secs(16);
/// Will be replaced by first real measurement within milliseconds. /// Will be replaced by first real measurement within milliseconds.
const INITIAL_RTT: Duration = Duration::from_millis(30); const INITIAL_RTT: Duration = Duration::from_millis(30);
/// Isolated packet loss during slow start (a single dropped frame from
/// wireless noise, a brief LTE handover blip, etc.) is normal on real
/// mobile/Wi-Fi links and does NOT mean the link is congested. The previous
/// behavior exited slow start and halved cwnd on the very FIRST loss, which
/// on any link with a non-zero background loss rate permanently downgrades
/// the session from exponential growth to linear (+1 MTU/RTT) ProbeBandwidth
/// growth within the first few RTTs - turning what should be a sub-second
/// ramp-up into tens of seconds to minutes before throughput opens up
/// (observed as: a trickle of KB/s, then a sudden jump once cwnd finally
/// claws back up). Only treat loss as a real congestion signal - and pay
/// the full slow-start-exit + halving cost - once this many losses land
/// within SLOW_START_LOSS_WINDOW.
const SLOW_START_LOSS_TOLERANCE: u32 = 3;
/// Window within which SLOW_START_LOSS_TOLERANCE losses must land to count
/// as sustained (rather than isolated) loss. Roughly a few RTTs on a
/// well-connected link, generous on a slow one.
const SLOW_START_LOSS_WINDOW: Duration = Duration::from_millis(500);
impl CongestionController { impl CongestionController {
pub fn new(mtu: u64) -> Self { pub fn new(mtu: u64) -> Self {
let now = Instant::now(); let now = Instant::now();
@ -88,6 +111,8 @@ impl CongestionController {
pacing_rate: initial_pacing, pacing_rate: initial_pacing,
mtu, mtu,
min_rtt_stamp: now, min_rtt_stamp: now,
slow_start_losses: 0,
slow_start_loss_window_start: now,
} }
} }
@ -197,11 +222,28 @@ impl CongestionController {
match self.phase { match self.phase {
Phase::SlowStart => { Phase::SlowStart => {
// Exit slow start, set ssthresh to half of cwnd let now = Instant::now();
if now.duration_since(self.slow_start_loss_window_start) > SLOW_START_LOSS_WINDOW {
// Previous window's losses have aged out - this loss starts a fresh count.
self.slow_start_losses = 0;
self.slow_start_loss_window_start = now;
}
self.slow_start_losses += 1;
if self.slow_start_losses >= SLOW_START_LOSS_TOLERANCE {
// Sustained loss within the window: treat as real congestion.
// Exit slow start, set ssthresh to half of cwnd.
self.ssthresh = self.cwnd / 2; self.ssthresh = self.cwnd / 2;
self.cwnd = self.ssthresh.max(MIN_CWND_PACKETS * self.mtu); self.cwnd = self.ssthresh.max(MIN_CWND_PACKETS * self.mtu);
self.phase = Phase::ProbeBandwidth; self.phase = Phase::ProbeBandwidth;
tracing::debug!(cwnd = self.cwnd, ssthresh = self.ssthresh, "congestion: loss during slow start"); tracing::debug!(cwnd = self.cwnd, ssthresh = self.ssthresh, "congestion: sustained loss during slow start, exiting");
} else {
// Isolated loss: likely non-congestive noise. Take a mild,
// temporary haircut but keep exponential growth going -
// don't throw away slow start over a single dropped frame.
self.cwnd = (self.cwnd * 8 / 10).max(MIN_CWND_PACKETS * self.mtu);
tracing::debug!(cwnd = self.cwnd, count = self.slow_start_losses, "congestion: isolated loss during slow start, staying in slow start");
}
} }
Phase::ProbeBandwidth => { Phase::ProbeBandwidth => {
// Multiplicative decrease: cwnd *= 0.7 (BBR-style, less aggressive than Cubic's 0.5) // Multiplicative decrease: cwnd *= 0.7 (BBR-style, less aggressive than Cubic's 0.5)
@ -290,6 +332,50 @@ mod tests {
assert!(cc.cwnd() < initial); assert!(cc.cwnd() < initial);
} }
#[test]
fn test_isolated_slow_start_loss_does_not_exit_slow_start() {
// A single dropped packet (wireless noise, a brief handover blip) is
// normal on real links and must not permanently downgrade the
// session from exponential to linear growth.
let mut cc = CongestionController::new(1200);
cc.on_loss(1200);
assert_eq!(cc.phase, Phase::SlowStart, "one isolated loss must not exit slow start");
// It should still shrink the window somewhat (not ignored entirely),
// just far less punishing than the sustained-congestion case.
let after_one = cc.cwnd();
assert!(after_one < INITIAL_CWND_PACKETS * 1200);
}
#[test]
fn test_sustained_slow_start_loss_exits_slow_start() {
// Losses landing close together (within SLOW_START_LOSS_WINDOW) are
// a real congestion signal and must still trigger the harsher
// exit-slow-start + halve response.
let mut cc = CongestionController::new(1200);
for _ in 0..SLOW_START_LOSS_TOLERANCE {
cc.on_loss(1200);
}
assert_eq!(cc.phase, Phase::ProbeBandwidth, "sustained loss must exit slow start");
}
#[test]
fn test_slow_start_loss_window_resets_after_expiry() {
// Two losses far enough apart (window expired between them) must
// each be treated as isolated, not accumulated toward the sustained-
// loss threshold.
let mut cc = CongestionController::new(1200);
cc.on_loss(1200);
assert_eq!(cc.phase, Phase::SlowStart);
// Simulate the window having expired by resetting its start
// directly (std::thread::sleep in a unit test would be flaky/slow).
cc.slow_start_loss_window_start = Instant::now() - SLOW_START_LOSS_WINDOW - Duration::from_millis(1);
cc.on_loss(1200);
assert_eq!(cc.phase, Phase::SlowStart, "a loss after the window expired must restart the count, not accumulate");
assert_eq!(cc.slow_start_losses, 1);
}
#[test] #[test]
fn test_can_send_limits() { fn test_can_send_limits() {
let mut cc = CongestionController::new(1200); let mut cc = CongestionController::new(1200);

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.4.2+21 version: 0.4.2+23
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.4

View File

@ -31,3 +31,4 @@ hex = "0.4.3"
chacha20poly1305.workspace = true chacha20poly1305.workspace = true
x25519-dalek = { version = "2.0.1", features = ["static_secrets"] } x25519-dalek = { version = "2.0.1", features = ["static_secrets"] }
chrono = "0.4.44" chrono = "0.4.44"
subtle = "2.6"

View File

@ -318,6 +318,18 @@ pub async fn start_api_server(
// ── Middleware: token check ────────────────────────────────────────────────── // ── Middleware: token check ──────────────────────────────────────────────────
/// Constant-time string equality for secrets (tokens, password hashes).
/// Plain `==` short-circuits on the first differing byte, which leaks how
/// many leading bytes an attacker's guess got right through response
/// timing - a classic remote timing side-channel against exactly the kind
/// of long-lived bearer/session secrets compared here. `subtle` is already
/// pulled in transitively (chacha20poly1305 etc.); pinning it as a direct
/// dependency here makes that guarantee explicit for this call site.
fn secure_eq(a: &str, b: &str) -> bool {
use subtle::ConstantTimeEq;
a.as_bytes().ct_eq(b.as_bytes()).into()
}
fn check_token(state: &ApiState, headers: &axum::http::HeaderMap) -> bool { fn check_token(state: &ApiState, headers: &axum::http::HeaderMap) -> bool {
// Both session token (for web UI) and static API token (for relays) are checked // Both session token (for web UI) and static API token (for relays) are checked
let mut allowed = false; let mut allowed = false;
@ -332,19 +344,19 @@ fn check_token(state: &ApiState, headers: &axum::http::HeaderMap) -> bool {
if let Some(token) = val.strip_prefix("Bearer ") { if let Some(token) = val.strip_prefix("Bearer ") {
let current_session = state.session_token.read().unwrap_or_else(|e| e.into_inner()).clone(); let current_session = state.session_token.read().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(session) = current_session { if let Some(session) = current_session {
if token == session { if secure_eq(token, &session) {
allowed = true; allowed = true;
} }
} }
if let Some(ref api_tok) = state.api_token { if let Some(ref api_tok) = state.api_token {
if token == api_tok { if secure_eq(token, api_tok) {
allowed = true; allowed = true;
} }
} }
} else { } else {
if let Some(ref api_tok) = state.api_token { if let Some(ref api_tok) = state.api_token {
if val == api_tok { if secure_eq(val, api_tok) {
allowed = true; allowed = true;
} }
} }
@ -371,7 +383,7 @@ async fn handle_login(
let hash = sha2::Sha256::digest(password.as_bytes()); let hash = sha2::Sha256::digest(password.as_bytes());
let hash_hex = format!("{:x}", hash); let hash_hex = format!("{:x}", hash);
if hash_hex == state.password_hash { if secure_eq(&hash_hex, &state.password_hash) {
let token = uuid::Uuid::new_v4().to_string(); let token = uuid::Uuid::new_v4().to_string();
*state.session_token.write().unwrap_or_else(|e| e.into_inner()) = Some(token.clone()); *state.session_token.write().unwrap_or_else(|e| e.into_inner()) = Some(token.clone());
(StatusCode::OK, ApiResponse::success(LoginResponse { token })) (StatusCode::OK, ApiResponse::success(LoginResponse { token }))
@ -881,15 +893,91 @@ mod tests {
let state = make_test_state(""); let state = make_test_state("");
let _router = create_api_router(state); let _router = create_api_router(state);
} }
#[test]
fn test_secure_eq_matches_and_rejects() {
assert!(secure_eq("same-secret", "same-secret"));
assert!(!secure_eq("same-secret", "different"));
assert!(!secure_eq("short", "much-longer-value"));
assert!(secure_eq("", ""));
} }
async fn handle_get_audit(State(state): State<ApiState>) -> impl IntoResponse { fn headers_with_bearer(token: &str) -> axum::http::HeaderMap {
let logs = state.audit_logs.read().unwrap(); let mut h = axum::http::HeaderMap::new();
ApiResponse::success(logs.clone()) h.insert("authorization", format!("Bearer {token}").parse().unwrap());
h
} }
async fn handle_create_audit(State(state): State<ApiState>, Json(req): Json<CreateAuditLogRequest>) -> impl IntoResponse { // These pin down check_token's behavior directly: it's the single gate
let mut logs = state.audit_logs.write().unwrap(); // every mutating/sensitive handler (including the audit-log ones - see
// the missing-auth fix) relies on, so its logic must be independently
// verified rather than only exercised incidentally through handlers.
#[test]
fn test_check_token_rejects_missing_header_when_configured() {
let state = make_test_state("panel");
assert!(!check_token(&state, &axum::http::HeaderMap::new()));
}
#[test]
fn test_check_token_accepts_matching_api_token_as_bearer() {
let state = make_test_state("panel");
assert!(check_token(&state, &headers_with_bearer("test-token")));
}
#[test]
fn test_check_token_accepts_matching_api_token_raw() {
let state = make_test_state("panel");
let mut h = axum::http::HeaderMap::new();
h.insert("authorization", "test-token".parse().unwrap());
assert!(check_token(&state, &h));
}
#[test]
fn test_check_token_rejects_wrong_token() {
let state = make_test_state("panel");
assert!(!check_token(&state, &headers_with_bearer("wrong-token")));
}
#[test]
fn test_check_token_accepts_matching_session_token() {
let state = make_test_state("panel");
*state.session_token.write().unwrap() = Some("live-session".to_string());
assert!(check_token(&state, &headers_with_bearer("live-session")));
}
#[test]
fn test_check_token_open_when_no_credentials_configured() {
let mut state = make_test_state("panel");
state.api_token = None;
state.username.clear();
state.password_hash.clear();
// Documented "unsafe but possible" open-panel mode: no credentials
// configured at all means every request passes, including with no
// Authorization header.
assert!(check_token(&state, &axum::http::HeaderMap::new()));
}
}
async fn handle_get_audit(
State(state): State<ApiState>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
if !check_token(&state, &headers) {
return api_unauthorized::<Vec<AuditLogEntry>>();
}
let logs = state.audit_logs.read().unwrap_or_else(|e| e.into_inner());
(StatusCode::OK, ApiResponse::success(logs.clone()))
}
async fn handle_create_audit(
State(state): State<ApiState>,
headers: axum::http::HeaderMap,
Json(req): Json<CreateAuditLogRequest>,
) -> impl IntoResponse {
if !check_token(&state, &headers) {
return api_unauthorized::<bool>();
}
let mut logs = state.audit_logs.write().unwrap_or_else(|e| e.into_inner());
let id = format!("{:x}", rand::random::<u64>()); let id = format!("{:x}", rand::random::<u64>());
let now = chrono::Local::now(); let now = chrono::Local::now();
let entry = AuditLogEntry { let entry = AuditLogEntry {
@ -904,7 +992,7 @@ async fn handle_create_audit(State(state): State<ApiState>, Json(req): Json<Crea
logs.truncate(100); logs.truncate(100);
} }
ApiResponse::success(true) (StatusCode::OK, ApiResponse::success(true))
} }
// ── Bulk keys & Router Rules ───────────────────────────────────────────────── // ── Bulk keys & Router Rules ─────────────────────────────────────────────────
@ -1006,10 +1094,16 @@ async fn handle_put_rules(
(StatusCode::OK, ApiResponse::success(true)) (StatusCode::OK, ApiResponse::success(true))
} }
async fn handle_clear_audit(State(state): State<ApiState>) -> impl IntoResponse { async fn handle_clear_audit(
let mut logs = state.audit_logs.write().unwrap(); State(state): State<ApiState>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
if !check_token(&state, &headers) {
return api_unauthorized::<()>();
}
let mut logs = state.audit_logs.write().unwrap_or_else(|e| e.into_inner());
logs.clear(); logs.clear();
ApiResponse::success(()) (StatusCode::OK, ApiResponse::success(()))
} }

View File

@ -276,6 +276,18 @@ impl DnsServer {
/// ///
/// Клиент может явно указать `<server_ip>:<local_port>` как DNS-сервер /// Клиент может явно указать `<server_ip>:<local_port>` как DNS-сервер
/// в настройках — тогда все DNS-запросы туннелируются и резолвятся здесь. /// в настройках — тогда все DNS-запросы туннелируются и резолвятся здесь.
///
/// SECURITY: this socket is bound on 0.0.0.0, reachable directly from the
/// public internet with no authentication (unlike the main OSTP port,
/// there is no Noise handshake gating it). Answering every UDP datagram
/// by resolving and replying to its (unverified, spoofable) source
/// address is a textbook DNS reflection/amplification primitive: an
/// attacker spoofing a victim's IP as the query source turns this server
/// into a free amplifier against that victim. There is currently no
/// caller for this function anywhere in the codebase, but the rate
/// limiter below exists so that connecting it later doesn't silently
/// reintroduce that risk - it bounds how much amplification bandwidth
/// this listener can ever contribute, regardless of query volume.
pub async fn run_local_udp_listener(self: Arc<Self>) { pub async fn run_local_udp_listener(self: Arc<Self>) {
let port = self.config.read().await.local_port; let port = self.config.read().await.local_port;
let bind_addr = format!("0.0.0.0:{port}"); let bind_addr = format!("0.0.0.0:{port}");
@ -289,10 +301,30 @@ impl DnsServer {
}; };
tracing::info!("Built-in DNS server listening on UDP {bind_addr}"); tracing::info!("Built-in DNS server listening on UDP {bind_addr}");
// Global token bucket capping total replies/sec this listener will
// ever send. Deliberately global (not per-source-IP): per-IP limiting
// does nothing against a reflection attack, since the attacker never
// sees the responses and can spread queries across arbitrarily many
// spoofed sources anyway. A global cap bounds this server's total
// contribution to any attack regardless of how the queries are
// distributed.
const MAX_REPLIES_PER_SEC: f64 = 100.0;
let mut tokens: f64 = MAX_REPLIES_PER_SEC;
let mut last_refill = tokio::time::Instant::now();
let mut buf = vec![0u8; 4096]; let mut buf = vec![0u8; 4096];
loop { loop {
match socket.recv_from(&mut buf).await { match socket.recv_from(&mut buf).await {
Ok((n, peer)) => { Ok((n, peer)) => {
let now = tokio::time::Instant::now();
tokens = (tokens + now.duration_since(last_refill).as_secs_f64() * MAX_REPLIES_PER_SEC)
.min(MAX_REPLIES_PER_SEC);
last_refill = now;
if tokens < 1.0 {
continue; // over budget: drop silently, no reply sent
}
tokens -= 1.0;
let query = buf[..n].to_vec(); let query = buf[..n].to_vec();
let srv = self.clone(); let srv = self.clone();
let sock = socket.clone(); let sock = socket.clone();

View File

@ -21,3 +21,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
ostp-core = { path = "../ostp-core" } ostp-core = { path = "../ostp-core" }
colored = "2.1" colored = "2.1"
rlimit = "0.11.0" rlimit = "0.11.0"
sha2.workspace = true

View File

@ -3,6 +3,7 @@ use clap::Parser;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use colored::Colorize; use colored::Colorize;
use sha2::Digest;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about = "OSTP Core - Ospab Stealth Transport Protocol", long_about = None)] #[command(author, version, about = "OSTP Core - Ospab Stealth Transport Protocol", long_about = None)]
@ -720,24 +721,11 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
}) as char }) as char
}).collect(); }).collect();
let password = wizard_prompt("Admin password (blank for random)", &rand_pass); let password = wizard_prompt("Admin password (blank for random)", &rand_pass);
let pass_hash = { // Must match api.rs's handle_login exactly (format!("{:x}", Sha256::digest(..))) -
use std::fmt::Write as _; // this used to be a DefaultHasher (SipHash) placeholder that produced a
let mut hash = String::new(); // differently-shaped digest, so a password set up through this wizard could
let digest: [u8; 32] = { // never actually log into the panel it just configured.
use std::collections::hash_map::DefaultHasher; let pass_hash = format!("{:x}", sha2::Sha256::digest(password.as_bytes()));
use std::hash::{Hash, Hasher};
// Panel password hashing. sha2 is not a direct dep of ostp/Cargo.toml,
// so we use std's hasher as a placeholder digest here.
let mut h = DefaultHasher::new();
password.hash(&mut h);
let v = h.finish();
let mut out = [0u8; 32];
out[..8].copy_from_slice(&v.to_be_bytes());
out
};
for b in digest { let _ = write!(hash, "{:02x}", b); }
hash
};
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);