feat(client): auto-calibrate TTL-desync by measuring hops to the server

Adds a tiny built-in probe (ttl_probe) that measures the hop distance to the
server, so the TTL-desync decoys are aimed automatically instead of by a
hand-guessed number — without pulling in the whole ostp-prober and without any
new server endpoint or exposed port.

How it works, and why it needs no prober-server: the OSTP server answers only a
valid handshake and silently drops everything else, so the client sends the real
handshake with a rising IP TTL and watches for the first TTL that draws a reply.
Datagrams whose TTL is too low die on a router before the server and create no
state there; the first responding TTL is the server distance. Decoys are then
stamped at hops-1, so they clear the DPI (which sits far closer than the server)
yet expire before the server. This is inherently key-gated — no key, no valid
handshake, no reply, so an unauthenticated caller learns nothing — and rides the
existing UDP port, which is exactly the "works for key holders, no prober-server,
no ports exposed to the internet" property that was asked for.

Config: transport.ttl_desync_auto (on by default). When on and desync is
enabled, the measured value overrides ttl_desync_ttl; measurement runs once and
is cached, cleared on a config change. On measurement failure it falls back to
the configured fixed TTL rather than skipping desync. The whole path is gated
behind ttl_desync (off by default), so a normal connection never runs it.

The probe logic is unit-tested (measures against a local responder; decoy-TTL
math). The real hop measurement and the desync effect both need a real network
to confirm and cannot be exercised here.
This commit is contained in:
ospab 2026-08-19 19:53:14 +03:00
parent d187609629
commit 1e9111ab9f
6 changed files with 195 additions and 1 deletions

View File

@ -75,6 +75,53 @@ impl Drop for SessionState {
/// Spawn the per-session receiver loop that reads inbound datagrams from the
/// transport and forwards them to the bridge, returning an AbortHandle so the
/// Build a fresh, valid handshake datagram for the TTL-desync hop probe. Each
/// call uses a new session id and timestamp so the server's anti-replay does not
/// drop it as a duplicate. Returns an empty vec on the (unexpected) construction
/// error — the probe simply treats that TTL step as unanswered.
fn build_probe_handshake(
secrets: &ostp_core::crypto::DerivedSecrets,
access_key: &[u8],
profile: TrafficProfile,
mtu: usize,
) -> Vec<u8> {
let session_id: u32 = rand::random();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut handshake_payload = Vec::with_capacity(8 + 4 + access_key.len());
handshake_payload.extend_from_slice(&timestamp.to_be_bytes());
handshake_payload.extend_from_slice(&session_id.to_be_bytes());
handshake_payload.extend_from_slice(access_key);
let mut machine = match ProtocolMachine::new(ProtocolConfig {
role: NoiseRole::Initiator,
psk: secrets.psk,
session_id,
handshake_payload,
padding_strategy: PaddingStrategy::Profile(profile),
obfuscation_key: secrets.obfuscation_key,
max_reorder: 16384,
max_reorder_buffer: 8192,
ack_delay_ms: 5,
rto_ms: 100,
max_retries: 8,
max_sent_history: 32768,
handshake_pad_min: secrets.handshake_pad_min,
handshake_pad_max: secrets.handshake_pad_max,
mtu,
max_padding: mtu.saturating_sub(48).max(256),
}) {
Ok(m) => m,
Err(_) => return Vec::new(),
};
match machine.on_event(OstpEvent::Start) {
Ok(ProtocolAction::SendDatagram(frame)) => frame.to_vec(),
_ => Vec::new(),
}
}
/// task is torn down when its `SessionState` is dropped. Consolidates the three
/// previously-duplicated inline copies (initial connect, network-change, and
/// keepalive reconnect).
@ -136,6 +183,10 @@ pub struct Bridge {
pub ttl_desync: bool,
pub ttl_desync_ttl: u8,
pub ttl_desync_count: u8,
pub ttl_desync_auto: bool,
/// Cached result of the hop-distance measurement, so the TTL sweep runs once
/// rather than on every (re)connect. Cleared on a network change.
ttl_desync_measured: Option<u8>,
pub mtu: usize,
pub kill_switch: bool,
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
@ -190,6 +241,8 @@ impl Bridge {
ttl_desync: config.transport.ttl_desync,
ttl_desync_ttl: config.transport.ttl_desync_ttl,
ttl_desync_count: config.transport.ttl_desync_count,
ttl_desync_auto: config.transport.ttl_desync_auto,
ttl_desync_measured: None,
mtu: config.ostp.mtu,
kill_switch: config.kill_switch,
reload_tx: None,
@ -1120,6 +1173,31 @@ impl Bridge {
// Each carries the key's junk marker, so any decoy that does reach
// the server is dropped there silently.
if self.ttl_desync && !is_uot && self.ttl_desync_count > 0 {
// Auto-calibrate the decoy TTL to the hop distance to this server,
// measured once (cached). The server answers only a valid
// handshake, so the sweep is inherently key-gated and needs no
// server-side probe endpoint. On measurement failure we fall back
// to the configured fixed TTL rather than skipping desync.
let effective_ttl = if self.ttl_desync_auto {
if self.ttl_desync_measured.is_none() {
let sec = secrets.clone();
let key = self.access_key.clone();
let profile = self.profile;
let mtu = self.mtu;
let make_hs = move || build_probe_handshake(&sec, &key, profile, mtu);
if let Some(hops) = crate::ttl_probe::measure_hops(target_addr, make_hs).await {
let t = crate::ttl_probe::decoy_ttl_for(hops);
self.ttl_desync_measured = Some(t);
tx.send(UiEvent::Log(format!(
"TTL-desync: server ~{hops} hops, decoy TTL {t}"
))).await.ok();
}
}
self.ttl_desync_measured.unwrap_or(self.ttl_desync_ttl)
} else {
self.ttl_desync_ttl
};
let marker = ostp_core::crypto::derive_junk_marker(
&self.access_key,
ostp_core::crypto::current_junk_window(),
@ -1139,7 +1217,7 @@ impl Bridge {
})
.collect()
};
socket.send_ttl_decoys(&decoys, self.ttl_desync_ttl).await;
socket.send_ttl_decoys(&decoys, effective_ttl).await;
}
for attempt in 0..attempt_limit {
@ -1242,6 +1320,8 @@ impl Bridge {
self.ttl_desync = cfg.transport.ttl_desync;
self.ttl_desync_ttl = cfg.transport.ttl_desync_ttl;
self.ttl_desync_count = cfg.transport.ttl_desync_count;
self.ttl_desync_auto = cfg.transport.ttl_desync_auto;
self.ttl_desync_measured = None; // re-measure after a config change
self.mtu = cfg.ostp.mtu;
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec;
self.kill_switch = cfg.kill_switch;

View File

@ -105,8 +105,15 @@ pub struct TransportConfig {
/// How many decoy datagrams to send per handshake.
#[serde(default = "default_ttl_desync_count")]
pub ttl_desync_count: u8,
/// Auto-calibrate the decoy TTL by measuring the hop distance to the server
/// (see ttl_probe). On by default, so turning desync on "just works"; the
/// measured value overrides ttl_desync_ttl. Turn off to pin ttl_desync_ttl.
#[serde(default = "default_true")]
pub ttl_desync_auto: bool,
}
fn default_true() -> bool { true }
fn default_transport_mode() -> String { "udp".to_string() }
fn default_frag_chunk() -> usize { 2 }
fn default_frag_sleep() -> u64 { 2 }
@ -127,6 +134,7 @@ impl Default for TransportConfig {
ttl_desync: false,
ttl_desync_ttl: default_ttl_desync_ttl(),
ttl_desync_count: default_ttl_desync_count(),
ttl_desync_auto: true,
}
}
}
@ -215,6 +223,7 @@ struct RawTransportSection {
ttl_desync: Option<bool>,
ttl_desync_ttl: Option<u8>,
ttl_desync_count: Option<u8>,
ttl_desync_auto: Option<bool>,
}
#[derive(Debug, Deserialize)]
@ -295,6 +304,7 @@ impl ClientConfig {
ttl_desync: raw.transport.as_ref().and_then(|t| t.ttl_desync).unwrap_or(false),
ttl_desync_ttl: raw.transport.as_ref().and_then(|t| t.ttl_desync_ttl).unwrap_or_else(default_ttl_desync_ttl),
ttl_desync_count: raw.transport.as_ref().and_then(|t| t.ttl_desync_count).unwrap_or_else(default_ttl_desync_count),
ttl_desync_auto: raw.transport.as_ref().and_then(|t| t.ttl_desync_auto).unwrap_or(true),
},
exclusions: ExclusionConfig {
domains: exclusions.domains.unwrap_or_default(),

View File

@ -5,6 +5,7 @@ pub mod migrate;
pub mod signal;
pub mod sysproxy;
pub mod transport;
pub mod ttl_probe;
pub mod tunnel;

View File

@ -0,0 +1,100 @@
//! Lightweight "how many hops to the server" probe, for auto-calibrating the
//! TTL-desync decoys — without pulling in the full ostp-prober, and without any
//! new server code or exposed port.
//!
//! The trick: the OSTP server answers only a *valid* handshake and silently
//! drops everything else. So we send the real handshake datagram with a rising
//! IP TTL and watch for the first TTL that draws a reply. A datagram whose TTL
//! is too low dies on a router before the server and creates no state there;
//! only the TTL that actually reaches the server elicits a response. That first
//! responding TTL is the hop distance to the server.
//!
//! This is inherently key-gated (no key → no valid handshake → no reply, so an
//! unauthenticated caller learns nothing) and rides the existing UDP port, which
//! is exactly the "works for key holders, no prober-server, no extra ports"
//! property we want. Decoys are then sent at `hops - 1`, so they clear the DPI
//! (which sits far closer than the server) yet die before the server.
use std::net::SocketAddr;
use std::time::Duration;
use tokio::net::UdpSocket;
/// Upper bound on the hop sweep. Beyond ~30 the internet does not go, and a
/// server we cannot reach within that many hops is unreachable for other
/// reasons the caller will already be handling.
const MAX_HOPS: u8 = 30;
/// Per-TTL wait for a reply. Kept short so the whole sweep is quick; each TTL is
/// tried a couple of times to ride out isolated loss.
const PER_TTL_TIMEOUT: Duration = Duration::from_millis(400);
const SENDS_PER_TTL: usize = 2;
/// Measure the hop distance to `server` by sweeping the TTL of `handshake`.
///
/// `make_handshake` is called once per TTL step to produce a fresh handshake
/// datagram — fresh because the server's anti-replay cache drops a repeat of the
/// same (session id, timestamp), so each probe must be distinct to be answered.
/// Returns the first TTL that drew a reply, or None if none did within MAX_HOPS.
pub async fn measure_hops<F>(server: SocketAddr, mut make_handshake: F) -> Option<u8>
where
F: FnMut() -> Vec<u8>,
{
let bind: SocketAddr = if server.is_ipv6() {
"[::]:0".parse().ok()?
} else {
"0.0.0.0:0".parse().ok()?
};
let sock = UdpSocket::bind(bind).await.ok()?;
sock.connect(server).await.ok()?;
let mut buf = [0u8; 2048];
for ttl in 1..=MAX_HOPS {
if sock.set_ttl(ttl as u32).is_err() {
continue;
}
for _ in 0..SENDS_PER_TTL {
let frame = make_handshake();
let _ = sock.send(&frame).await;
}
match tokio::time::timeout(PER_TTL_TIMEOUT, sock.recv(&mut buf)).await {
Ok(Ok(n)) if n > 0 => return Some(ttl),
_ => {}
}
}
None
}
/// Given a measured server distance, the TTL to stamp on decoys: one hop short
/// of the server, so they die before it, and clamped to at least 1.
pub fn decoy_ttl_for(server_hops: u8) -> u8 {
server_hops.saturating_sub(1).max(1)
}
#[cfg(test)]
mod tests {
use super::*;
/// Against a local responder, the very first TTL reaches it (localhost is
/// zero routers away), so the sweep returns 1 — exercising the "first reply
/// wins" path end to end over a real socket.
#[tokio::test]
async fn measures_against_a_local_responder() {
let responder = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let addr = responder.local_addr().unwrap();
tokio::spawn(async move {
let mut b = [0u8; 512];
while let Ok((n, from)) = responder.recv_from(&mut b).await {
let _ = responder.send_to(&b[..n], from).await;
}
});
let hops = measure_hops(addr, || b"handshake-probe".to_vec()).await;
assert_eq!(hops, Some(1), "a local responder must answer at the first TTL");
}
#[test]
fn decoy_ttl_is_one_short_of_the_server() {
assert_eq!(decoy_ttl_for(12), 11);
assert_eq!(decoy_ttl_for(1), 1);
assert_eq!(decoy_ttl_for(0), 1);
}
}

View File

@ -64,6 +64,7 @@ struct TransportConfigRaw {
ttl_desync: Option<bool>,
ttl_desync_ttl: Option<u8>,
ttl_desync_count: Option<u8>,
ttl_desync_auto: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
@ -245,6 +246,7 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi
ttl_desync: raw.transport.as_ref().and_then(|t| t.ttl_desync).unwrap_or(false),
ttl_desync_ttl: raw.transport.as_ref().and_then(|t| t.ttl_desync_ttl).unwrap_or(8),
ttl_desync_count: raw.transport.as_ref().and_then(|t| t.ttl_desync_count).unwrap_or(2),
ttl_desync_auto: raw.transport.as_ref().and_then(|t| t.ttl_desync_auto).unwrap_or(true),
},
exclusions: ostp_client::config::ExclusionConfig {
domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(),

View File

@ -1716,6 +1716,7 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
ttl_desync: false,
ttl_desync_ttl: 8,
ttl_desync_count: 2,
ttl_desync_auto: true,
},
dns_server: client_cfg.tun.as_ref().and_then(|t| t.dns.clone()),
kill_switch: client_cfg.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),