mirror of https://github.com/ospab/ostp.git
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:
parent
d187609629
commit
1e9111ab9f
|
|
@ -75,6 +75,53 @@ impl Drop for SessionState {
|
||||||
|
|
||||||
/// Spawn the per-session receiver loop that reads inbound datagrams from the
|
/// Spawn the per-session receiver loop that reads inbound datagrams from the
|
||||||
/// transport and forwards them to the bridge, returning an AbortHandle so 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(×tamp.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
|
/// task is torn down when its `SessionState` is dropped. Consolidates the three
|
||||||
/// previously-duplicated inline copies (initial connect, network-change, and
|
/// previously-duplicated inline copies (initial connect, network-change, and
|
||||||
/// keepalive reconnect).
|
/// keepalive reconnect).
|
||||||
|
|
@ -136,6 +183,10 @@ pub struct Bridge {
|
||||||
pub ttl_desync: bool,
|
pub ttl_desync: bool,
|
||||||
pub ttl_desync_ttl: u8,
|
pub ttl_desync_ttl: u8,
|
||||||
pub ttl_desync_count: 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 mtu: usize,
|
||||||
pub kill_switch: bool,
|
pub kill_switch: bool,
|
||||||
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
|
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
|
||||||
|
|
@ -190,6 +241,8 @@ impl Bridge {
|
||||||
ttl_desync: config.transport.ttl_desync,
|
ttl_desync: config.transport.ttl_desync,
|
||||||
ttl_desync_ttl: config.transport.ttl_desync_ttl,
|
ttl_desync_ttl: config.transport.ttl_desync_ttl,
|
||||||
ttl_desync_count: config.transport.ttl_desync_count,
|
ttl_desync_count: config.transport.ttl_desync_count,
|
||||||
|
ttl_desync_auto: config.transport.ttl_desync_auto,
|
||||||
|
ttl_desync_measured: None,
|
||||||
mtu: config.ostp.mtu,
|
mtu: config.ostp.mtu,
|
||||||
kill_switch: config.kill_switch,
|
kill_switch: config.kill_switch,
|
||||||
reload_tx: None,
|
reload_tx: None,
|
||||||
|
|
@ -1120,6 +1173,31 @@ impl Bridge {
|
||||||
// Each carries the key's junk marker, so any decoy that does reach
|
// Each carries the key's junk marker, so any decoy that does reach
|
||||||
// the server is dropped there silently.
|
// the server is dropped there silently.
|
||||||
if self.ttl_desync && !is_uot && self.ttl_desync_count > 0 {
|
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(
|
let marker = ostp_core::crypto::derive_junk_marker(
|
||||||
&self.access_key,
|
&self.access_key,
|
||||||
ostp_core::crypto::current_junk_window(),
|
ostp_core::crypto::current_junk_window(),
|
||||||
|
|
@ -1139,7 +1217,7 @@ impl Bridge {
|
||||||
})
|
})
|
||||||
.collect()
|
.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 {
|
for attempt in 0..attempt_limit {
|
||||||
|
|
@ -1242,6 +1320,8 @@ impl Bridge {
|
||||||
self.ttl_desync = cfg.transport.ttl_desync;
|
self.ttl_desync = cfg.transport.ttl_desync;
|
||||||
self.ttl_desync_ttl = cfg.transport.ttl_desync_ttl;
|
self.ttl_desync_ttl = cfg.transport.ttl_desync_ttl;
|
||||||
self.ttl_desync_count = cfg.transport.ttl_desync_count;
|
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.mtu = cfg.ostp.mtu;
|
||||||
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec;
|
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec;
|
||||||
self.kill_switch = cfg.kill_switch;
|
self.kill_switch = cfg.kill_switch;
|
||||||
|
|
|
||||||
|
|
@ -105,8 +105,15 @@ pub struct TransportConfig {
|
||||||
/// How many decoy datagrams to send per handshake.
|
/// How many decoy datagrams to send per handshake.
|
||||||
#[serde(default = "default_ttl_desync_count")]
|
#[serde(default = "default_ttl_desync_count")]
|
||||||
pub ttl_desync_count: u8,
|
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_transport_mode() -> String { "udp".to_string() }
|
||||||
fn default_frag_chunk() -> usize { 2 }
|
fn default_frag_chunk() -> usize { 2 }
|
||||||
fn default_frag_sleep() -> u64 { 2 }
|
fn default_frag_sleep() -> u64 { 2 }
|
||||||
|
|
@ -127,6 +134,7 @@ impl Default for TransportConfig {
|
||||||
ttl_desync: false,
|
ttl_desync: false,
|
||||||
ttl_desync_ttl: default_ttl_desync_ttl(),
|
ttl_desync_ttl: default_ttl_desync_ttl(),
|
||||||
ttl_desync_count: default_ttl_desync_count(),
|
ttl_desync_count: default_ttl_desync_count(),
|
||||||
|
ttl_desync_auto: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +223,7 @@ struct RawTransportSection {
|
||||||
ttl_desync: Option<bool>,
|
ttl_desync: Option<bool>,
|
||||||
ttl_desync_ttl: Option<u8>,
|
ttl_desync_ttl: Option<u8>,
|
||||||
ttl_desync_count: Option<u8>,
|
ttl_desync_count: Option<u8>,
|
||||||
|
ttl_desync_auto: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[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: 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_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_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 {
|
exclusions: ExclusionConfig {
|
||||||
domains: exclusions.domains.unwrap_or_default(),
|
domains: exclusions.domains.unwrap_or_default(),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ pub mod migrate;
|
||||||
pub mod signal;
|
pub mod signal;
|
||||||
pub mod sysproxy;
|
pub mod sysproxy;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
pub mod ttl_probe;
|
||||||
pub mod tunnel;
|
pub mod tunnel;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -64,6 +64,7 @@ struct TransportConfigRaw {
|
||||||
ttl_desync: Option<bool>,
|
ttl_desync: Option<bool>,
|
||||||
ttl_desync_ttl: Option<u8>,
|
ttl_desync_ttl: Option<u8>,
|
||||||
ttl_desync_count: Option<u8>,
|
ttl_desync_count: Option<u8>,
|
||||||
|
ttl_desync_auto: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[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: 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_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_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 {
|
exclusions: ostp_client::config::ExclusionConfig {
|
||||||
domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(),
|
domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(),
|
||||||
|
|
|
||||||
|
|
@ -1716,6 +1716,7 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
|
||||||
ttl_desync: false,
|
ttl_desync: false,
|
||||||
ttl_desync_ttl: 8,
|
ttl_desync_ttl: 8,
|
||||||
ttl_desync_count: 2,
|
ttl_desync_count: 2,
|
||||||
|
ttl_desync_auto: true,
|
||||||
},
|
},
|
||||||
dns_server: client_cfg.tun.as_ref().and_then(|t| t.dns.clone()),
|
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),
|
kill_switch: client_cfg.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue