Compare commits

..

No commits in common. "cc71856d0234f9769a574ff36e3d4aa6f9dbae67" and "f5a1c1767974621a723913dffbc2e035b949f78d" have entirely different histories.

28 changed files with 93 additions and 1300 deletions

View File

@ -2,5 +2,5 @@
"target_version": "0.4.5",
"branch": "beta",
"alpha_iteration": 0,
"beta_iteration": 16
"beta_iteration": 5
}

View File

@ -75,53 +75,6 @@ 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).
@ -180,13 +133,6 @@ pub struct Bridge {
pub frag_sleep: u64,
pub junk_pc: [usize; 2],
pub junk_ps: [usize; 2],
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>>,
@ -238,11 +184,6 @@ impl Bridge {
frag_sleep: config.transport.frag_sleep,
junk_pc: config.transport.junk_pc,
junk_ps: config.transport.junk_ps,
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,
@ -1165,79 +1106,14 @@ impl Bridge {
let mut success = false;
let is_uot = matches!(socket, crate::transport::Transport::Uot { .. });
// UDP: more, shorter retransmit windows over the SAME total budget
// (6×800ms ≈ the old 4×1200ms), so a lost handshake on a mobile link
// recovers faster and there are more chances before giving up.
let (attempt_limit, attempt_timeout_ms) = if is_uot { (1, 8000) } else { (6, 800) };
// TTL-desync (UDP only, opt-in): fire decoy datagrams that reach an
// on-path DPI box but expire before the server, so the box classifies
// the flow on the decoys rather than the real handshake that follows.
// 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(),
);
let decoys: Vec<bytes::Bytes> = {
let mut rng = rand::thread_rng();
let [min_s, max_s] = self.junk_ps;
let min_s = min_s.max(4);
let max_s = max_s.max(min_s);
(0..self.ttl_desync_count)
.map(|_| {
let len = rng.gen_range(min_s..=max_s);
let mut b = vec![0u8; len];
rng.fill(&mut b[..]);
b[..4].copy_from_slice(&marker);
bytes::Bytes::from(b)
})
.collect()
};
socket.send_ttl_decoys(&decoys, effective_ttl).await;
}
let (attempt_limit, attempt_timeout_ms) = if is_uot { (1, 8000) } else { (4, 1200) };
for attempt in 0..attempt_limit {
if attempt > 0 {
tx.send(UiEvent::Log(format!("Handshake attempt {} lost. Retransmitting...", attempt))).await.ok();
}
// Send the handshake twice on UDP: a single dropped datagram
// otherwise costs a whole retransmit window, which on a lossy
// mobile link is the gap between a sub-second connect and a
// multi-second one. The duplicate is harmless — whichever copy
// arrives first is processed, and the server's anti-replay drops
// the other. UoT rides reliable TCP, so one send there.
let sends = if is_uot { 1 } else { 2 };
for _ in 0..sends {
if send_datagram(&socket, &handshake_frame, self.transport_mode == "udp").await.is_ok() {
self.metrics.bytes_sent.fetch_add(handshake_frame.len() as u64, Ordering::Relaxed);
}
if send_datagram(&socket, &handshake_frame, self.transport_mode == "udp").await.is_ok() {
self.metrics.bytes_sent.fetch_add(handshake_frame.len() as u64, Ordering::Relaxed);
}
match timeout(Duration::from_millis(attempt_timeout_ms), socket.recv(&mut buf)).await {
@ -1329,11 +1205,6 @@ impl Bridge {
self.frag_sleep = cfg.transport.frag_sleep;
self.junk_pc = cfg.transport.junk_pc;
self.junk_ps = cfg.transport.junk_ps;
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

@ -92,35 +92,13 @@ pub struct TransportConfig {
/// [min, max] junk packet size in bytes
#[serde(default = "default_junk_size")]
pub junk_ps: [usize; 2],
/// TTL-desync (UDP only): before the handshake, send decoy datagrams with a
/// lowered IP TTL so they reach an on-path DPI box but expire before the
/// server, poisoning the box's classification of the flow. Off by default —
/// it needs the TTL calibrated to the network, and the wrong value is inert.
#[serde(default)]
pub ttl_desync: bool,
/// TTL the decoy datagrams are sent with. Set it to one or two hops past the
/// injector distance the prober reports, so decoys die just beyond the DPI.
#[serde(default = "default_ttl_desync_ttl")]
pub ttl_desync_ttl: u8,
/// 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 }
fn default_junk_count() -> [usize; 2] { [2, 5] }
fn default_junk_size() -> [usize; 2] { [100, 1000] }
fn default_ttl_desync_ttl() -> u8 { 8 }
fn default_ttl_desync_count() -> u8 { 2 }
impl Default for TransportConfig {
fn default() -> Self {
@ -131,10 +109,6 @@ impl Default for TransportConfig {
frag_sleep: default_frag_sleep(),
junk_pc: default_junk_count(),
junk_ps: default_junk_size(),
ttl_desync: false,
ttl_desync_ttl: default_ttl_desync_ttl(),
ttl_desync_count: default_ttl_desync_count(),
ttl_desync_auto: true,
}
}
}
@ -220,10 +194,6 @@ struct RawTransportSection {
frag_sleep: Option<u64>,
junk_pc: Option<[usize; 2]>,
junk_ps: Option<[usize; 2]>,
ttl_desync: Option<bool>,
ttl_desync_ttl: Option<u8>,
ttl_desync_count: Option<u8>,
ttl_desync_auto: Option<bool>,
}
#[derive(Debug, Deserialize)]
@ -301,10 +271,6 @@ impl ClientConfig {
frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or_else(default_frag_sleep),
junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or_else(default_junk_count),
junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or_else(default_junk_size),
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(),
@ -383,18 +349,11 @@ impl UnifiedConfig {
}
}
AppMode::Relay(cfg) => {
// The relay forwards to a fixed next hop on both carriers, so it
// needs both upstream addresses. It does NOT need upstream_api_url:
// that field belonged to the old design where the relay
// authenticated clients itself, which it no longer does. Requiring
// it here was the bug that made every generated relay config
// (wizard and template alike write no api_url) fail to load with
// "must specify upstream_api_url" — a relay that could never start.
if cfg.upstream_tcp.is_empty() {
anyhow::bail!("Relay configuration must specify upstream_tcp (the next hop's TCP/UoT address).");
anyhow::bail!("Relay configuration must specify upstream_tcp address.");
}
if cfg.upstream_udp.is_empty() {
anyhow::bail!("Relay configuration must specify upstream_udp (the next hop's UDP address).");
if cfg.upstream_api_url.is_empty() {
anyhow::bail!("Relay configuration must specify upstream_api_url.");
}
}
}
@ -437,7 +396,6 @@ impl UserConfig {
#[derive(Debug, Deserialize, Serialize)]
pub struct ServerConfig {
pub listen: ListenConfig,
pub bind_ip: Option<String>,
pub access_keys: Vec<UserConfig>,
pub debug: Option<bool>,
pub outbound: Option<OutboundConfig>,
@ -553,13 +511,6 @@ pub struct OutboundConfig {
pub protocol: String,
pub address: String,
pub port: u16,
/// SOCKS5 username, for an upstream proxy that requires authentication
/// (e.g. a residential-proxy service). Empty/absent = no-auth SOCKS5.
#[serde(default)]
pub username: String,
/// SOCKS5 password (paired with `username`).
#[serde(default)]
pub password: String,
#[serde(default)]
pub rules: Vec<OutboundRule>,
pub default_action: Option<String>,
@ -571,10 +522,6 @@ pub struct OutboundRule {
pub ip_cidr: Option<Vec<String>>,
pub protocol: Option<String>,
pub action: Option<String>,
/// Local source IP to egress from when this rule matches (overrides the
/// server's global `bind_ip` for this rule). Lets one destination leave via
/// one address and another via a different one.
pub send_from: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
@ -589,76 +536,3 @@ pub struct MuxConfig {
pub enabled: Option<bool>,
pub sessions: Option<usize>,
}
#[cfg(test)]
mod tests {
use super::*;
/// Loads a config.json exactly as the daemon does: parse the JSON into the
/// canonical `UnifiedConfig`, then validate. This is the real drift-catcher —
/// if the wizard/template and the validator ever disagree on required fields,
/// this fails instead of a user's relay refusing to start.
fn load(json: &str) -> Result<UnifiedConfig> {
let cfg: UnifiedConfig = serde_json::from_str(json)?;
cfg.validate()?;
Ok(cfg)
}
/// Regression: the relay used to authenticate clients and so its config
/// carried `upstream_api_url`. The relay is a transparent pipe now and both
/// the wizard and the `init` template write NO api_url — yet validation kept
/// demanding it, so every generated relay config failed to load with
/// "must specify upstream_api_url". A relay that could never start.
#[test]
fn relay_config_without_api_url_loads() {
// Byte-for-byte the shape the wizard (main.rs) emits.
let json = r#"{
"mode": "relay",
"listen": "0.0.0.0:50000",
"upstream_tcp": "203.0.113.10:50000",
"upstream_udp": "203.0.113.10:50000",
"debug": false
}"#;
load(json).expect("a transparent-relay config must load without upstream_api_url");
}
/// A relay still needs somewhere to forward to on both carriers, so an
/// incomplete relay config must fail loudly at load, not connect-to-empty
/// per session at runtime.
#[test]
fn relay_config_missing_upstream_udp_is_rejected() {
let json = r#"{
"mode": "relay",
"listen": "0.0.0.0:50000",
"upstream_tcp": "203.0.113.10:50000",
"upstream_udp": "",
"debug": false
}"#;
assert!(load(json).is_err(), "a relay with no UDP upstream must be rejected");
}
/// A deprecated api_url left in an OLD config must not break loading — it is
/// ignored, not required and not forbidden.
#[test]
fn relay_config_with_leftover_api_url_still_loads() {
let json = r#"{
"mode": "relay",
"listen": "0.0.0.0:50000",
"upstream_tcp": "203.0.113.10:50000",
"upstream_udp": "203.0.113.10:50000",
"upstream_api_url": "http://old.example:8080",
"debug": false
}"#;
load(json).expect("a stale api_url must be tolerated, not rejected");
}
/// The minimal client and server shapes the template emits must also load,
/// so this test guards all three modes against generator/validator drift.
#[test]
fn minimal_client_and_server_configs_load() {
load(r#"{"mode":"client","server":"127.0.0.1:50000","access_key":"k"}"#)
.expect("minimal client config must load");
load(r#"{"mode":"server","listen":"0.0.0.0:50000","access_keys":["k"]}"#)
.expect("minimal server config must load");
}
}

View File

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

View File

@ -353,60 +353,9 @@ pub fn migrate_server_json(json: Value) -> (Value, MigrationReport) {
}
}
// Backfill the SOCKS5 credential fields on `outbound`, added after some
// server configs already existed. These are plain strings that default to
// "" (no-auth), so making them explicit is safe and concise. The optional
// `bind_ip` (top level) and per-rule `send_from` are deliberately NOT
// backfilled: absent means "use the default source", which is correct — and
// a placeholder would either be stripped (null) or, worse, parse as an
// invalid source IP ("").
if let Some(outbound) = obj.get_mut("outbound").and_then(|o| o.as_object_mut()) {
for key in ["username", "password"] {
if !outbound.contains_key(key) {
report.note(format!("Added outbound.{key} = \"\" (missing default)"));
outbound.insert(key.to_string(), json!(""));
}
}
}
(out, report)
}
/// Final normalization pass applied to every migrated config: strip null-valued
/// keys at every nesting level. A JSON null means "unset", so it is pure noise —
/// removing it is the "concise" part of the migration, and it never loses real
/// data (a set value is never null). Key ORDER is already canonical for free:
/// serde_json serializes object keys in sorted order, so any write of a migrated
/// config comes out stably ordered no matter how disordered the input was.
///
/// Returns whether it removed anything, so the caller folds it into the
/// "was this already up to date?" decision.
pub fn normalize(value: &mut Value) -> bool {
let before = value.clone();
strip_nulls(value);
*value != before
}
/// Recursively drop keys whose value is JSON null, descending into nested
/// objects and array elements. Empty objects and arrays are kept — an explicit
/// `rules: []` or `exclude: {}` carries intent; only nulls are noise.
fn strip_nulls(value: &mut Value) {
match value {
Value::Object(map) => {
map.retain(|_, v| !v.is_null());
for v in map.values_mut() {
strip_nulls(v);
}
}
Value::Array(arr) => {
for v in arr.iter_mut() {
strip_nulls(v);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -600,31 +549,6 @@ mod tests {
assert!(report.notes.iter().any(|n| n.contains("api.token")));
}
/// A server config whose `outbound` predates the SOCKS5 credential fields
/// must get them backfilled — this is exactly the "migrate said nothing to
/// migrate but the new fields were missing" gap. The optional bind_ip /
/// send_from must NOT be injected (absent = correct).
#[test]
fn server_migrate_backfills_outbound_credentials() {
let old = json!({
"listen": "0.0.0.0:50000",
"access_keys": ["k1"],
"api": { "enabled": true, "bind": "0.0.0.0:9090", "webpath": "", "username": "", "password_hash": "" },
"outbound": {
"enabled": false, "protocol": "socks5", "address": "127.0.0.1", "port": 40000,
"default_action": "proxy",
"rules": [{ "action": "proxy", "domain_suffix": [".onion"] }]
}
});
let (new, report) = migrate_server_json(old);
assert!(report.changed, "adding the missing credential fields is a change");
assert_eq!(new["outbound"]["username"], "");
assert_eq!(new["outbound"]["password"], "");
// Optional fields are left absent, not injected.
assert!(new.get("bind_ip").is_none());
assert!(new["outbound"]["rules"][0].get("send_from").is_none());
}
#[test]
fn detect_kind_falls_back_to_structural_sniffing_without_mode_tag() {
assert_eq!(detect_kind(&json!({"access_key": "x", "server": "y"})), Some(ConfigKind::Client));
@ -632,78 +556,4 @@ mod tests {
assert_eq!(detect_kind(&json!({"upstream_tcp": "x", "upstream_api_url": "y"})), Some(ConfigKind::Relay));
assert_eq!(detect_kind(&json!({"mode": "client", "server": "x"})), Some(ConfigKind::Client));
}
// ── normalization (concise + no data loss + canonical) ──────────────────
#[test]
fn normalize_strips_nulls_but_keeps_real_data_and_empty_collections() {
let mut v = json!({
"mode": "client",
"server": "1.2.3.4:50000",
"access_key": "k",
"socks5_bind": null, // unset → removed
"tun": { "enable": true, "dns": null }, // nested null → removed
"exclude": { "domains": [], "ips": null }, // empty [] kept, null removed
"mux": { "enabled": false, "sessions": 1 },
});
let changed = normalize(&mut v);
assert!(changed, "stripping nulls is a change");
assert!(v.get("socks5_bind").is_none(), "top-level null must be gone");
assert!(v["tun"].get("dns").is_none(), "nested null must be gone");
assert!(v["exclude"].get("ips").is_none(), "nested null must be gone");
assert_eq!(v["exclude"]["domains"], json!([]), "an explicit empty array is intent, kept");
assert_eq!(v["server"], json!("1.2.3.4:50000"), "real data untouched");
assert_eq!(v["tun"]["enable"], json!(true));
}
#[test]
fn normalize_is_idempotent() {
let mut v = json!({ "mode": "server", "listen": "0.0.0.0:50000", "access_keys": ["k"], "debug": null });
assert!(normalize(&mut v), "first pass removes the null");
let once = v.clone();
assert!(!normalize(&mut v), "second pass changes nothing");
assert_eq!(v, once);
}
#[test]
fn normalize_never_drops_unknown_fields() {
// A field the schema has never heard of must survive — no data loss ever.
let mut v = json!({ "mode": "client", "server": "s", "access_key": "k", "some_future_field": {"a": 1} });
normalize(&mut v);
assert_eq!(v["some_future_field"], json!({"a": 1}), "unknown data must be preserved verbatim");
}
/// Forcing function: the configs the tool itself generates (init/setup
/// templates, current shape) must already be canonical — running the full
/// migrate pipeline over them must report NO change. If someone adds a field
/// to a template or the schema without teaching the migrator, this fails
/// instead of a user silently ending up with a config that `ostp migrate`
/// keeps trying to "fix". Covers all three kinds.
#[test]
fn generated_configs_are_already_canonical() {
// These mirror the exact shapes emitted by `ostp init` / the wizard.
let client = json!({
"mode": "client", "server": "127.0.0.1:50000", "access_key": "k",
"socks5_bind": "127.0.0.1:1088",
"transport": { "mode": "udp", "tcp_fragmentation": false },
"debug": false,
});
let server = json!({
"mode": "server", "listen": "0.0.0.0:50000", "access_keys": ["k"],
"outbound": { "enabled": false, "protocol": "socks5", "address": "127.0.0.1",
"port": 9050, "username": "", "password": "", "default_action": "proxy", "rules": [] },
"debug": false,
});
let relay = json!({
"mode": "relay", "listen": "0.0.0.0:50000",
"upstream_tcp": "1.2.3.4:50000", "upstream_udp": "1.2.3.4:50000", "debug": false,
});
for (name, cfg) in [("client", client), ("server", server), ("relay", relay)] {
let mut v = cfg.clone();
let changed = normalize(&mut v);
assert!(!changed, "the generated {name} template must be canonical (no nulls to strip)");
assert_eq!(v, cfg, "normalizing the {name} template must not alter it");
}
}
}

View File

@ -53,29 +53,4 @@ impl Transport {
Self::Uot { .. } => Ok("0.0.0.0:0".parse().unwrap()),
}
}
/// TTL-desync: send `decoys` as datagrams with the IP TTL lowered to `ttl`,
/// then restore the socket's original TTL. The decoys are meant to reach an
/// on-path DPI box and expire before the server — poisoning the box's view
/// of the flow (it classifies on the decoy) while the server never sees
/// them. Calibrate `ttl` to the injector hop distance the prober reports.
///
/// UDP only: this manipulates individual datagrams' TTL. On UoT the carrier
/// is one TCP stream, so a socket-level TTL change would apply to the real
/// traffic too — proper TCP desync needs injected packets (a driver), which
/// this deliberately does not attempt. No-op there.
pub async fn send_ttl_decoys(&self, decoys: &[Bytes], ttl: u8) {
let Self::Udp(sock) = self else { return };
if decoys.is_empty() {
return;
}
let restore = sock.ttl().unwrap_or(128);
if sock.set_ttl(ttl as u32).is_err() {
return;
}
for d in decoys {
let _ = sock.send(d).await;
}
let _ = sock.set_ttl(restore);
}
}

View File

@ -1,100 +0,0 @@
//! 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

@ -1,307 +0,0 @@
//! IPv4 fragment reassembly for the TUN → netstack path.
//!
//! Why this exists: the userspace netstack (netstack-smoltcp) parses each IP
//! packet it receives and, for UDP, runs `UdpPacket::new_checked` on the IP
//! payload. An IP *fragment* passes the IP-level check but fails the UDP one —
//! the UDP length field describes the whole datagram while the fragment carries
//! only a slice — so the netstack drops it with `wire::Error` and the datagram
//! never reaches the tunnel. Large UDP datagrams (game traffic, e.g. Roblox
//! sending >MTU packets that the OS fragments on the way to the TUN) therefore
//! vanish entirely, and the app times out.
//!
//! smoltcp 0.2.2 does no reassembly of its own, so we do it here, between the
//! TUN read and the netstack: fragments are buffered by (src, dst, id, proto),
//! and only a fully reassembled datagram is handed on. Non-fragmented packets
//! pass straight through untouched.
use std::collections::{BTreeMap, HashMap};
use std::time::{Duration, Instant};
/// A fragment group is discarded if not completed within this window, matching
/// the usual IP reassembly timeout. Prevents a lost tail fragment from pinning
/// memory forever.
const REASM_TIMEOUT: Duration = Duration::from_secs(3);
/// Cap on concurrently tracked fragment groups, so a flood of first-fragments
/// with no tail cannot grow memory without bound.
const MAX_GROUPS: usize = 4096;
/// A reassembled IPv4 datagram cannot exceed this (total-length is 16-bit).
const MAX_DATAGRAM: usize = 65_535;
type Key = (u32, u32, u16, u8); // src, dst, identification, protocol
struct Group {
/// fragment_offset (bytes) → that fragment's IP payload.
parts: BTreeMap<usize, Vec<u8>>,
/// IP header of the offset-0 fragment, reused for the reassembled packet.
header: Option<Vec<u8>>,
/// Total payload length, known once the last fragment (MF=0) is seen.
total_len: Option<usize>,
first_seen: Instant,
}
pub struct Reassembler {
groups: HashMap<Key, Group>,
last_sweep: Instant,
}
impl Reassembler {
pub fn new() -> Self {
Self { groups: HashMap::new(), last_sweep: Instant::now() }
}
/// Feed one frame read from the TUN. Returns the packet(s) to forward to the
/// netstack: the frame itself when it is not a fragment, a single fully
/// reassembled datagram when this frame completes one, or nothing when the
/// frame was buffered as an incomplete fragment.
pub fn process(&mut self, frame: &[u8]) -> Option<Vec<u8>> {
self.maybe_sweep();
let Some(v4) = Ipv4View::parse(frame) else {
// Not a parseable IPv4 packet (e.g. IPv6) — pass through unchanged;
// reassembly is not our job for it.
return Some(frame.to_vec());
};
// A packet is fragmented iff MF is set or it carries a non-zero offset.
if !v4.more_fragments && v4.frag_offset == 0 {
return Some(frame.to_vec());
}
let key = (v4.src, v4.dst, v4.id, v4.protocol);
let now = Instant::now();
if self.groups.len() >= MAX_GROUPS && !self.groups.contains_key(&key) {
// Under pressure, drop the oldest incomplete group to make room
// rather than refusing the new one outright.
if let Some(oldest) = self
.groups
.iter()
.min_by_key(|(_, g)| g.first_seen)
.map(|(k, _)| *k)
{
self.groups.remove(&oldest);
}
}
let group = self.groups.entry(key).or_insert_with(|| Group {
parts: BTreeMap::new(),
header: None,
total_len: None,
first_seen: now,
});
// Ignore a payload that would push the datagram past the legal maximum.
if v4.frag_offset + v4.payload.len() > MAX_DATAGRAM {
self.groups.remove(&key);
return None;
}
group.parts.insert(v4.frag_offset, v4.payload.to_vec());
if v4.frag_offset == 0 {
group.header = Some(v4.header.to_vec());
}
if !v4.more_fragments {
// The last fragment fixes the total length.
group.total_len = Some(v4.frag_offset + v4.payload.len());
}
// Complete? Walk fragments from offset 0 and require they tile the whole
// datagram with no hole. Overlaps are tolerated as long as coverage is
// contiguous (BTreeMap keeps them offset-ordered).
let (Some(total), Some(header)) = (group.total_len, group.header.clone()) else {
return None;
};
let mut expected = 0usize;
for (&off, part) in &group.parts {
if off > expected {
return None; // hole before this fragment
}
let end = off + part.len();
if end > expected {
expected = end;
}
}
if expected < total {
return None; // not fully covered yet
}
// Reassemble: header + payload bytes [0, total), then fix the header so
// it describes a single unfragmented datagram.
let mut payload = vec![0u8; total];
for (&off, part) in &group.parts {
let end = (off + part.len()).min(total);
if off < total {
payload[off..end].copy_from_slice(&part[..end - off]);
}
}
self.groups.remove(&key);
Some(build_reassembled(&header, &payload))
}
fn maybe_sweep(&mut self) {
let now = Instant::now();
if now.duration_since(self.last_sweep) < Duration::from_secs(1) {
return;
}
self.last_sweep = now;
self.groups.retain(|_, g| now.duration_since(g.first_seen) < REASM_TIMEOUT);
}
}
/// A read-only view over an IPv4 header and its payload.
struct Ipv4View<'a> {
header: &'a [u8],
payload: &'a [u8],
src: u32,
dst: u32,
id: u16,
protocol: u8,
more_fragments: bool,
frag_offset: usize,
}
impl<'a> Ipv4View<'a> {
fn parse(frame: &'a [u8]) -> Option<Self> {
if frame.len() < 20 {
return None;
}
if frame[0] >> 4 != 4 {
return None; // not IPv4
}
let ihl = ((frame[0] & 0x0f) as usize) * 4;
if ihl < 20 || frame.len() < ihl {
return None;
}
let total_len = u16::from_be_bytes([frame[2], frame[3]]) as usize;
// Trust the smaller of declared length and what we actually read.
let total_len = total_len.min(frame.len()).max(ihl);
let id = u16::from_be_bytes([frame[4], frame[5]]);
let flags_frag = u16::from_be_bytes([frame[6], frame[7]]);
let more_fragments = flags_frag & 0x2000 != 0;
let frag_offset = ((flags_frag & 0x1fff) as usize) * 8;
let protocol = frame[9];
let src = u32::from_be_bytes([frame[12], frame[13], frame[14], frame[15]]);
let dst = u32::from_be_bytes([frame[16], frame[17], frame[18], frame[19]]);
Some(Ipv4View {
header: &frame[..ihl],
payload: &frame[ihl..total_len],
src,
dst,
id,
protocol,
more_fragments,
frag_offset,
})
}
}
/// Stitch the offset-0 header onto a full payload, clearing the fragment fields
/// and fixing total-length and header checksum so the netstack sees one clean
/// datagram.
fn build_reassembled(header0: &[u8], payload: &[u8]) -> Vec<u8> {
let ihl = header0.len();
let mut out = Vec::with_capacity(ihl + payload.len());
out.extend_from_slice(header0);
out.extend_from_slice(payload);
let total = (ihl + payload.len()) as u16;
out[2..4].copy_from_slice(&total.to_be_bytes());
// Clear flags (except keep DF? no — a reassembled datagram is not a
// fragment and DF is irrelevant here) and the fragment offset.
out[6] = 0;
out[7] = 0;
// Recompute the IPv4 header checksum over the (possibly options-bearing)
// header only.
out[10] = 0;
out[11] = 0;
let cksum = ipv4_checksum(&out[..ihl]);
out[10..12].copy_from_slice(&cksum.to_be_bytes());
out
}
fn ipv4_checksum(header: &[u8]) -> u16 {
let mut sum: u32 = 0;
let mut i = 0;
while i + 1 < header.len() {
sum += u16::from_be_bytes([header[i], header[i + 1]]) as u32;
i += 2;
}
if i < header.len() {
sum += (header[i] as u32) << 8;
}
while sum >> 16 != 0 {
sum = (sum & 0xffff) + (sum >> 16);
}
!(sum as u16)
}
#[cfg(test)]
mod tests {
use super::*;
// Build a minimal IPv4 header for tests. `mf` = more-fragments, `offset`
// in bytes (must be /8), `payload_len` fills total_length.
fn ipv4(id: u16, mf: bool, offset: usize, payload: &[u8]) -> Vec<u8> {
let total = 20 + payload.len();
let mut h = vec![0u8; 20];
h[0] = 0x45; // v4, ihl 5
h[2..4].copy_from_slice(&(total as u16).to_be_bytes());
h[4..6].copy_from_slice(&id.to_be_bytes());
let flags_frag = (if mf { 0x2000u16 } else { 0 }) | ((offset / 8) as u16 & 0x1fff);
h[6..8].copy_from_slice(&flags_frag.to_be_bytes());
h[9] = 17; // UDP
h[12..16].copy_from_slice(&[10, 1, 0, 2]);
h[16..20].copy_from_slice(&[13, 249, 8, 109]);
h.extend_from_slice(payload);
h
}
#[test]
fn passes_non_fragmented_through() {
let mut r = Reassembler::new();
let pkt = ipv4(1, false, 0, &[1, 2, 3, 4]);
assert_eq!(r.process(&pkt), Some(pkt));
}
#[test]
fn reassembles_two_fragments() {
let mut r = Reassembler::new();
// 16 bytes of "UDP" payload split as 8 + 8.
let first = ipv4(42, true, 0, &[0, 1, 2, 3, 4, 5, 6, 7]);
let second = ipv4(42, false, 8, &[8, 9, 10, 11, 12, 13, 14, 15]);
assert_eq!(r.process(&first), None, "first fragment must be buffered");
let whole = r.process(&second).expect("second fragment completes it");
// Header says unfragmented, total length 36, payload is the full 16.
assert_eq!(whole[0] >> 4, 4);
assert_eq!(u16::from_be_bytes([whole[2], whole[3]]), 36);
assert_eq!(whole[6] & 0x20, 0, "MF must be cleared");
assert_eq!(u16::from_be_bytes([whole[6], whole[7]]) & 0x1fff, 0, "offset cleared");
assert_eq!(&whole[20..], &(0u8..16).collect::<Vec<_>>()[..]);
// A correctly checksummed header sums to zero when the check field is
// included in the computation.
assert_eq!(ipv4_checksum(&whole[..20]), 0, "header checksum must verify");
}
#[test]
fn out_of_order_fragments_reassemble() {
let mut r = Reassembler::new();
let first = ipv4(7, true, 0, &[0, 1, 2, 3, 4, 5, 6, 7]);
let last = ipv4(7, false, 16, &[16, 17, 18, 19]);
let mid = ipv4(7, true, 8, &[8, 9, 10, 11, 12, 13, 14, 15]);
assert_eq!(r.process(&last), None);
assert_eq!(r.process(&first), None);
let whole = r.process(&mid).expect("last piece completes it");
assert_eq!(&whole[20..], &(0u8..20).collect::<Vec<_>>()[..]);
}
#[test]
fn incomplete_group_yields_nothing() {
let mut r = Reassembler::new();
let first = ipv4(9, true, 0, &[0; 8]);
// Tail never arrives.
assert_eq!(r.process(&first), None);
}
}

View File

@ -1,5 +1,4 @@
mod proxy;
mod ip_reasm;
pub mod native_handler;
mod udp_nat;

View File

@ -123,18 +123,12 @@ pub async fn run_native_tunnel(
let (mut tun_read, mut tun_write) = tokio::io::split(dev);
let mut tun_to_stack = tokio::spawn(async move {
// Reassemble IPv4 fragments before the netstack sees them: smoltcp drops
// UDP fragments with a wire::Error, which silently kills any >MTU UDP
// datagram (game traffic in particular). See ip_reasm for the details.
let mut reasm = super::ip_reasm::Reassembler::new();
let mut buf = vec![0u8; 65536];
loop {
match tun_read.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
let Some(frame) = reasm.process(&buf[..n]) else {
continue; // fragment buffered; nothing to forward yet
};
let frame = buf[..n].to_vec();
if let Err(e) = stack_sink.send(frame).await {
if e.kind() == std::io::ErrorKind::BrokenPipe {
break;
@ -477,9 +471,6 @@ pub async fn run_native_tunnel_from_fd(
let (mut stack_sink, mut stack_stream) = stack.split();
let _tun_to_stack = tokio::spawn(async move {
// See the Windows path above: reassemble IPv4 fragments so smoltcp does
// not drop >MTU UDP datagrams.
let mut reasm = super::ip_reasm::Reassembler::new();
let mut buf = vec![0u8; 65536];
loop {
let mut guard = match tun_stream.readable().await {
@ -511,9 +502,7 @@ pub async fn run_native_tunnel_from_fd(
Err(_) => continue,
};
let Some(frame) = reasm.process(&buf[..n]) else {
continue; // fragment buffered; nothing to forward yet
};
let frame = buf[..n].to_vec();
if let Err(e) = stack_sink.send(frame).await {
if e.kind() == std::io::ErrorKind::BrokenPipe {
break;

View File

@ -7,10 +7,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Without a battery-optimization exemption, Doze freezes the VPN service
when the screen is off: the connection drops and the in-process
reconnect logic never runs because the whole process is suspended. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<application
android:label="ostp_client"
android:name="${applicationName}"

View File

@ -152,24 +152,5 @@ class MainActivity : FlutterActivity() {
intent.putExtra("configJson", pendingConfigJson)
}
androidx.core.content.ContextCompat.startForegroundService(this, intent)
// Ask to be exempt from battery optimization so Doze does not freeze the
// service (which drops the tunnel AND stops the in-process reconnect from
// ever running). Only prompts if not already exempt; runs after the VPN
// consent so the two system dialogs do not stack.
requestBatteryExemptionIfNeeded()
}
private fun requestBatteryExemptionIfNeeded() {
try {
val pm = getSystemService(android.content.Context.POWER_SERVICE) as android.os.PowerManager
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
val intent = Intent(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.data = android.net.Uri.parse("package:$packageName")
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
} catch (e: Throwable) {
android.util.Log.e("MainActivity", "Battery exemption request failed", e)
}
}
}

View File

@ -23,7 +23,6 @@ class OstpProfile {
int junkPcMax;
int junkPsMin;
int junkPsMax;
bool ttlDesync; // TTL-desync decoys (UDP), auto-calibrated in the engine
OstpProfile({
required this.id,
@ -39,7 +38,6 @@ class OstpProfile {
this.junkPcMax = 5,
this.junkPsMin = 100,
this.junkPsMax = 1000,
this.ttlDesync = false,
});
Map<String, dynamic> toJson() {
@ -57,7 +55,6 @@ class OstpProfile {
'junkPcMax': junkPcMax,
'junkPsMin': junkPsMin,
'junkPsMax': junkPsMax,
'ttlDesync': ttlDesync,
};
}
@ -76,7 +73,6 @@ class OstpProfile {
junkPcMax: json['junkPcMax'] as int? ?? 5,
junkPsMin: json['junkPsMin'] as int? ?? 100,
junkPsMax: json['junkPsMax'] as int? ?? 1000,
ttlDesync: json['ttlDesync'] as bool? ?? false,
);
}
}

View File

@ -4,7 +4,6 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../models/connection_state_enum.dart';
import '../models/ostp_profile.dart';
import 'settings_screen.dart';
@ -56,15 +55,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
String _pingText = '-- ms';
Color _pingColor = Colors.white54;
// App version, shown at the bottom of the home screen. Read from the build
// (pubspec) via package_info rather than hardcoded, so it never drifts.
String _version = '';
@override
void initState() {
super.initState();
_loadSettings();
_loadVersion();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
@ -77,17 +71,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_startPolling();
}
Future<void> _loadVersion() async {
try {
final info = await PackageInfo.fromPlatform();
if (mounted) {
setState(() => _version = 'v${info.version} (${info.buildNumber})');
}
} catch (_) {
// Non-fatal: just leave the version line blank if it can't be read.
}
}
Future<void> _checkInitialState() async {
try {
final isRunning = await platform.invokeMethod('isRunning');
@ -155,8 +138,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
"frag_sleep": p?.fragSleep ?? 2,
"junk_pc": [p?.junkPcMin ?? 2, p?.junkPcMax ?? 5],
"junk_ps": [p?.junkPsMin ?? 100, p?.junkPsMax ?? 1000],
"ttl_desync": p?.ttlDesync ?? false,
"ttl_desync_auto": true,
},
"multiplex": {
"enabled": muxEnabled,
@ -534,17 +515,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_buildTopBar(theme),
Expanded(child: _buildStage(theme)),
_buildMetricsBar(theme),
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 10),
child: Text(
_version.isEmpty ? 'OSTP' : 'OSTP · $_version',
style: TextStyle(
fontSize: 11,
letterSpacing: 0.5,
color: Colors.white.withOpacity(0.28),
),
),
),
],
),
),

View File

@ -224,7 +224,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
final junkPsMaxCtrl = TextEditingController(text: (profile?.junkPsMax ?? 1000).toString());
String transportMode = profile?.transportMode ?? 'udp';
bool tcpFragmentation = profile?.tcpFragmentation ?? false;
bool ttlDesync = profile?.ttlDesync ?? false;
bool obscureKey = true;
showDialog(
@ -302,13 +301,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
],
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('TTL Desync', style: TextStyle(fontSize: 14)),
subtitle: const Text('Decoy packets that die before the server (UDP, auto-tuned)', style: TextStyle(fontSize: 12, color: Colors.white54)),
value: ttlDesync,
onChanged: (v) => setDialogState(() => ttlDesync = v),
),
],
),
),
@ -356,7 +348,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
junkPcMax: int.tryParse(junkPcMaxCtrl.text) ?? 5,
junkPsMin: int.tryParse(junkPsMinCtrl.text) ?? 100,
junkPsMax: int.tryParse(junkPsMaxCtrl.text) ?? 1000,
ttlDesync: ttlDesync,
));
} else {
profile.name = nameCtrl.text.trim().isNotEmpty ? nameCtrl.text.trim() : server;
@ -370,7 +361,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
profile.junkPcMax = int.tryParse(junkPcMaxCtrl.text) ?? 5;
profile.junkPsMin = int.tryParse(junkPsMinCtrl.text) ?? 100;
profile.junkPsMax = int.tryParse(junkPsMaxCtrl.text) ?? 1000;
profile.ttlDesync = ttlDesync;
}
_saveProfiles();
});

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
# 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.
version: 0.4.5+47
version: 0.4.5+36
environment:
sdk: ^3.11.4

View File

@ -61,10 +61,6 @@ struct TransportConfigRaw {
frag_sleep: Option<u64>,
junk_pc: Option<[usize; 2]>,
junk_ps: Option<[usize; 2]>,
ttl_desync: Option<bool>,
ttl_desync_ttl: Option<u8>,
ttl_desync_count: Option<u8>,
ttl_desync_auto: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
@ -243,10 +239,6 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi
frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or(2),
junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or([2, 5]),
junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or([100, 1000]),
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

@ -28,8 +28,6 @@ const translations = {
label_kill_switch: 'Kill Switch',
kill_switch_hint: 'Block non-VPN traffic when tunnel drops',
label_transport: 'Transport Protocol',
label_ttl_desync: 'TTL Desync',
hint_ttl_desync: 'Decoy packets that die before the server (UDP, auto-tuned)',
label_mtu: 'MTU Size',
label_transport: 'Transport Protocol',
label_sni: 'Stealth SNI (Fake Host)',
@ -95,8 +93,6 @@ const translations = {
label_kill_switch: 'Kill Switch',
kill_switch_hint: 'Блокировать трафик вне VPN при обрыве связи',
label_transport: 'Транспортный протокол',
label_ttl_desync: 'TTL-десинхронизация',
hint_ttl_desync: 'Пакеты-приманки, умирающие до сервера (UDP, авто-настройка)',
label_mtu: 'Размер MTU',
label_transport: 'Транспортный протокол',
label_sni: 'Маскировочный SNI',

View File

@ -336,17 +336,6 @@
</div>
</div>
<div class="toggle-row" style="border-top:none;">
<div class="toggle-text">
<span class="toggle-name" data-i18n="label_ttl_desync">TTL Desync</span>
<span class="toggle-hint" data-i18n="hint_ttl_desync">Decoy packets that die before the server (UDP, auto-tuned)</span>
</div>
<label class="toggle">
<input type="checkbox" id="cs-ttl-desync" />
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
</div>
</div> <!-- client-settings-card -->
<div class="app-version" id="app-version">OSTP GUI</div>

View File

@ -161,7 +161,6 @@ const btnJunkDone = $('btn-junk-done');
const inTcpFrag = $('cs-tcp-frag');
const btnFragSettings = $('btn-frag-settings');
const inTtlDesync = $('cs-ttl-desync');
const fragModal = $('frag-modal');
const inFragChunk = $('cs-frag-chunk');
const inFragSleep = $('cs-frag-sleep');
@ -319,9 +318,7 @@ function buildConfig() {
frag_chunk: s.tcpFrag ? (s.fragChunk || 2) : (active.frag_chunk || 2),
frag_sleep: s.tcpFrag ? (!isNaN(parseInt(s.fragSleep)) ? s.fragSleep : 2) : (active.frag_sleep !== undefined ? active.frag_sleep : 2),
junk_pc: s.junkEnabled ? [s.junkPcMin || 2, s.junkPcMax || 5] : (active.junk_pc || [2, 5]),
junk_ps: s.junkEnabled ? [s.junkPsMin || 100, s.junkPsMax || 1000] : (active.junk_ps || [100, 1000]),
ttl_desync: !!s.ttlDesync,
ttl_desync_auto: true
junk_ps: s.junkEnabled ? [s.junkPsMin || 100, s.junkPsMax || 1000] : (active.junk_ps || [100, 1000])
},
tun: {
enable: !!s.tun,
@ -660,7 +657,6 @@ function loadSettingsIntoForm() {
inTcpFrag.checked = !!s.tcpFrag;
inFragChunk.value = s.fragChunk || 2;
inFragSleep.value = !isNaN(parseInt(s.fragSleep)) ? s.fragSleep : 2;
if (inTtlDesync) inTtlDesync.checked = !!s.ttlDesync;
updateClientVisibility();
}
@ -696,7 +692,6 @@ function collectAndSaveSettings() {
tcpFrag: inTcpFrag.checked,
fragChunk: parseInt(inFragChunk.value) || 2,
fragSleep: !isNaN(parseInt(inFragSleep.value)) ? parseInt(inFragSleep.value) : 2,
ttlDesync: inTtlDesync ? inTtlDesync.checked : false,
};
// Cheap and local: safe to run on every debounced keystroke.
saveClientSettings(s);
@ -720,7 +715,7 @@ function collectAndSaveSettings() {
const tunnelRelevant = JSON.stringify([
s.tun, s.killSwitch, s.mux, s.muxSessions, s.mtu, s.dns, s.socks,
s.exDomains, s.exIps, s.exProcs, s.junkEnabled, s.junkPcMin, s.junkPcMax,
s.junkPsMin, s.junkPsMax, s.tcpFrag, s.fragChunk, s.fragSleep, s.ttlDesync,
s.junkPsMin, s.junkPsMax, s.tcpFrag, s.fragChunk, s.fragSleep,
]);
if (tunnelRelevant !== lastAppliedTunnelConfig) {
clearTimeout(hotReloadTimer);
@ -906,8 +901,7 @@ window.addEventListener('DOMContentLoaded', async () => {
wintunModal.addEventListener('click', e => { if (e.target === wintunModal) wintunModal.classList.add('hidden'); });
// Client settings — wire all inputs
[inTun, inKillSwitch, inMux, inAutoconnect, inLaunchStartup, inDebug, inShowRtt, inShowSpeed, inJunkEnabled, inTcpFrag, inTtlDesync]
.filter(Boolean)
[inTun, inKillSwitch, inMux, inAutoconnect, inLaunchStartup, inDebug, inShowRtt, inShowSpeed, inJunkEnabled, inTcpFrag]
.forEach(el => el.addEventListener('change', collectAndSaveSettings));
[inMuxSessions, inMtu, inDns, inSocks, inExDomains, inExIps, inExProcs, inJunkPcMin, inJunkPcMax, inJunkPsMin, inJunkPsMax, inFragChunk, inFragSleep]
.forEach(el => {

View File

@ -878,7 +878,7 @@ mod tests {
config_path: None,
dns_server: crate::dns::DnsServer::new(Default::default()),
audit_logs: Arc::new(RwLock::new(Vec::new())),
router: Arc::new(crate::router::Router::new(None, None, crate::dns::DnsServer::new(Default::default()), false)),
router: Arc::new(crate::router::Router::new(None, crate::dns::DnsServer::new(Default::default()), false)),
}
}

View File

@ -70,7 +70,6 @@ pub(crate) struct RemoteState {
pub async fn run_server(
bind_addrs: Vec<String>,
server_public_ip: Option<String>,
bind_ip: Option<String>,
access_keys: Vec<(String, crate::api::UserMeta)>,
outbound: Option<OutboundConfig>,
api_config: Option<ApiConfig>,
@ -256,7 +255,6 @@ pub async fn run_server(
// Initialize Router
let router = std::sync::Arc::new(router::Router::new(
outbound.clone(),
bind_ip,
dns_server.clone(),
debug,
));

View File

@ -20,13 +20,6 @@ pub struct OutboundRule {
#[serde(default)]
pub protocol: Option<String>,
pub action: OutboundAction,
/// Local source IP to egress from when this rule matches. Overrides the
/// server's global `bind_ip` for this rule only, so different destinations
/// can leave the machine from different addresses — e.g. one clean IP
/// direct to a picky site, another via the proxy for everything else. None
/// falls back to the global `bind_ip`.
#[serde(default)]
pub send_from: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -35,10 +28,6 @@ pub struct OutboundConfig {
pub protocol: String,
pub address: String,
pub port: u16,
/// SOCKS5 credentials for an upstream proxy that requires auth (e.g. a
/// residential-proxy service). Empty = no-auth SOCKS5.
pub username: String,
pub password: String,
pub rules: Vec<OutboundRule>,
pub default_action: OutboundAction,
}
@ -48,15 +37,12 @@ pub struct OutboundConfig {
pub async fn connect_target(
target: &str,
outbound: Option<&OutboundConfig>,
bind_ip: Option<&str>,
debug: bool,
) -> Result<TcpStream> {
let connect_timeout = Duration::from_secs(10);
if let Some(outbound) = outbound {
if outbound.enabled {
let (action, rule_src) = select_outbound_action(target, "tcp", outbound, debug).await;
// Per-rule source wins; otherwise the server's global bind_ip.
let eff_bind = rule_src.as_deref().or(bind_ip);
let action = select_outbound_action(target, "tcp", outbound, debug).await;
if action == OutboundAction::Block {
return Err(anyhow::anyhow!("blocked by outbound rule: {}", target));
}
@ -65,8 +51,8 @@ pub async fn connect_target(
// Case-insensitive: a config saying "SOCKS5" means the same thing
// as "socks5", and silently treating it as unknown is a trap.
return match outbound.protocol.to_ascii_lowercase().as_str() {
"socks5" => connect_via_socks5(&proxy_addr, target, eff_bind, &outbound.username, &outbound.password).await,
"http" => connect_via_http(&proxy_addr, target, eff_bind).await,
"socks5" => connect_via_socks5(&proxy_addr, target).await,
"http" => connect_via_http(&proxy_addr, target).await,
// FAIL CLOSED. This used to fall through to a direct
// connection, so any unrecognised protocol string — a typo,
// a case difference, an empty value — silently sent ALL TCP
@ -81,14 +67,10 @@ pub async fn connect_target(
)),
};
}
// action == Direct: egress directly, but still honour this rule's
// send_from (falling back to the global bind_ip) — that is the whole
// point of "direct from THIS ip to that destination".
return connect_direct(target, connect_timeout, eff_bind).await;
}
}
connect_direct(target, connect_timeout, bind_ip).await
connect_direct(target, connect_timeout).await
}
/// Per-candidate-address connect attempt, tried in turn (see `connect_direct`
@ -110,23 +92,7 @@ const PER_ADDR_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
/// attempted: every dual-stack destination (i.e. most popular sites) never
/// loads, while IPv4-only destinations work fine - exactly the "traffic
/// counter moves but sites don't open" symptom this fixes.
async fn connect_tcp_with_bind(addr: std::net::SocketAddr, bind_ip: Option<&str>) -> Result<TcpStream> {
if let Some(ip_str) = bind_ip {
let ip: std::net::IpAddr = ip_str.parse().map_err(|e| anyhow::anyhow!("invalid bind_ip: {}", e))?;
let socket = match addr {
std::net::SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4()?,
std::net::SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6()?,
};
if (addr.is_ipv4() && ip.is_ipv4()) || (addr.is_ipv6() && ip.is_ipv6()) {
socket.bind(std::net::SocketAddr::new(ip, 0))?;
}
Ok(socket.connect(addr).await?)
} else {
Ok(TcpStream::connect(addr).await?)
}
}
async fn connect_direct(target: &str, connect_timeout: Duration, bind_ip: Option<&str>) -> Result<TcpStream> {
async fn connect_direct(target: &str, connect_timeout: Duration) -> Result<TcpStream> {
tokio::time::timeout(connect_timeout, async {
let mut addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(target)
.await
@ -139,7 +105,7 @@ async fn connect_direct(target: &str, connect_timeout: Duration, bind_ip: Option
let mut last_err = None;
for addr in addrs {
match tokio::time::timeout(PER_ADDR_CONNECT_TIMEOUT, connect_tcp_with_bind(addr, bind_ip)).await {
match tokio::time::timeout(PER_ADDR_CONNECT_TIMEOUT, TcpStream::connect(addr)).await {
Ok(Ok(stream)) => return Ok(stream),
Ok(Err(e)) => last_err = Some(anyhow::anyhow!("{}: {}", addr, e)),
Err(_) => last_err = Some(anyhow::anyhow!("{}: connect timeout ({}s)", addr, PER_ADDR_CONNECT_TIMEOUT.as_secs())),
@ -164,35 +130,39 @@ pub async fn select_outbound_action(
protocol: &str,
outbound: &OutboundConfig,
debug: bool,
) -> (OutboundAction, Option<String>) {
) -> OutboundAction {
let (host, port) = match split_host_port(target) {
Some(v) => v,
None => return (outbound.default_action, None),
None => return outbound.default_action,
};
// Capture the matched rule's action AND its per-rule source IP together, so
// the caller egresses from the address that rule asked for.
let mut matched: Option<(OutboundAction, Option<String>)> = None;
let mut matched = None;
for rule in &outbound.rules {
if let Some(ref rule_proto) = rule.protocol {
if !rule_proto.is_empty() && rule_proto.to_lowercase() != protocol {
continue;
}
}
let hit = (rule.domain_suffix.is_empty() && rule.ip_cidr.is_empty())
|| match_domain_rule(&host, &rule.domain_suffix)
|| match_ip_rule(&host, port, &rule.ip_cidr).await;
if hit {
matched = Some((rule.action, rule.send_from.clone()));
if rule.domain_suffix.is_empty() && rule.ip_cidr.is_empty() {
// Protocol-only rule match
matched = Some(rule.action);
break;
}
if match_domain_rule(&host, &rule.domain_suffix) {
matched = Some(rule.action);
break;
}
if match_ip_rule(&host, port, &rule.ip_cidr).await {
matched = Some(rule.action);
break;
}
}
let (action, send_from) = matched.unwrap_or((outbound.default_action, None));
let action = matched.unwrap_or(outbound.default_action);
if debug {
tracing::debug!("Outbound routing: target={target} action={action:?} send_from={send_from:?}");
tracing::debug!("Outbound routing: target={target} action={action:?}");
}
(action, send_from)
action
}
fn match_domain_rule(host: &str, suffixes: &[String]) -> bool {
@ -223,72 +193,16 @@ async fn match_ip_rule(host: &str, _port: u16, cidrs: &[String]) -> bool {
// ── SOCKS5 / HTTP CONNECT upstream proxy ─────────────────────────────────────
/// SOCKS5 method negotiation plus RFC 1929 username/password auth when
/// credentials are supplied. Shared by the TCP-connect and UDP-associate paths
/// so both authenticate identically to an upstream that requires it.
async fn socks5_negotiate_auth<S>(stream: &mut S, username: &str, password: &str) -> Result<()>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
async fn connect_via_socks5(proxy_addr: &str, target: &str) -> Result<TcpStream> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let use_auth = !username.is_empty();
if use_auth && (username.len() > 255 || password.len() > 255) {
anyhow::bail!("SOCKS5 username/password must each be at most 255 bytes");
}
// Offer username/password (0x02) alongside no-auth (0x00) when we have
// credentials, so a residential-proxy service that demands auth is satisfied
// while a plain proxy still works.
if use_auth {
stream.write_all(&[0x05, 0x02, 0x00, 0x02]).await?;
} else {
stream.write_all(&[0x05, 0x01, 0x00]).await?;
}
let mut stream = TcpStream::connect(proxy_addr).await?;
stream.write_all(&[0x05, 0x01, 0x00]).await?;
let mut reply = [0u8; 2];
stream.read_exact(&mut reply).await?;
if reply[0] != 0x05 {
anyhow::bail!("SOCKS5: unexpected version 0x{:02x} in method reply", reply[0]);
if reply != [0x05, 0x00] {
anyhow::bail!("SOCKS5 auth not accepted");
}
match reply[1] {
0x00 => {} // no authentication required
0x02 => {
let mut auth = vec![0x01u8];
auth.push(username.len() as u8);
auth.extend_from_slice(username.as_bytes());
auth.push(password.len() as u8);
auth.extend_from_slice(password.as_bytes());
stream.write_all(&auth).await?;
let mut ar = [0u8; 2];
stream.read_exact(&mut ar).await?;
if ar[1] != 0x00 {
anyhow::bail!("SOCKS5 username/password auth rejected (status 0x{:02x})", ar[1]);
}
}
0xFF => anyhow::bail!(
"SOCKS5 proxy rejected all offered auth methods — it likely requires \
credentials; set outbound.username / outbound.password"
),
other => anyhow::bail!("SOCKS5 proxy chose unsupported auth method 0x{:02x}", other),
}
Ok(())
}
async fn connect_via_socks5(
proxy_addr: &str,
target: &str,
bind_ip: Option<&str>,
username: &str,
password: &str,
) -> Result<TcpStream> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(proxy_addr).await?.collect();
let mut stream = if let Some(addr) = addrs.into_iter().next() {
connect_tcp_with_bind(addr, bind_ip).await?
} else {
anyhow::bail!("could not resolve proxy address");
};
socks5_negotiate_auth(&mut stream, username, password).await?;
let (host, port) = split_host_port(target).ok_or_else(|| anyhow::anyhow!("invalid target"))?;
let mut req = Vec::new();
@ -336,15 +250,10 @@ async fn connect_via_socks5(
Ok(stream)
}
async fn connect_via_http(proxy_addr: &str, target: &str, bind_ip: Option<&str>) -> Result<TcpStream> {
async fn connect_via_http(proxy_addr: &str, target: &str) -> Result<TcpStream> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(proxy_addr).await?.collect();
let mut stream = if let Some(addr) = addrs.into_iter().next() {
connect_tcp_with_bind(addr, bind_ip).await?
} else {
anyhow::bail!("could not resolve proxy address");
};
let mut stream = TcpStream::connect(proxy_addr).await?;
let request = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
stream.write_all(request.as_bytes()).await?;
@ -463,21 +372,19 @@ impl UdpProxySocket {
pub async fn connect_udp_target(
target: &str,
outbound: Option<&OutboundConfig>,
bind_ip: Option<&str>,
debug: bool,
server_udp: std::sync::Arc<tokio::net::UdpSocket>,
) -> Result<UdpProxySocket> {
if let Some(outbound) = outbound {
if outbound.enabled {
let (action, rule_src) = select_outbound_action(target, "udp", outbound, debug).await;
let eff_bind = rule_src.as_deref().or(bind_ip);
let action = select_outbound_action(target, "udp", outbound, debug).await;
if action == OutboundAction::Block {
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
}
if action == OutboundAction::Proxy {
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
if outbound.protocol.eq_ignore_ascii_case("socks5") {
return connect_udp_via_socks5(&proxy_addr, server_udp, eff_bind, &outbound.username, &outbound.password).await;
return connect_udp_via_socks5(&proxy_addr, server_udp).await;
}
// FAIL CLOSED. HTTP CONNECT genuinely cannot carry UDP — but the
// answer to that is not to send the datagrams in the clear. The
@ -501,19 +408,16 @@ pub async fn connect_udp_target(
pub async fn connect_udp_via_socks5(
proxy_addr: &str,
server_udp: std::sync::Arc<tokio::net::UdpSocket>,
bind_ip: Option<&str>,
username: &str,
password: &str,
) -> Result<UdpProxySocket> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(proxy_addr).await?.collect();
let mut stream = if let Some(addr) = addrs.into_iter().next() {
connect_tcp_with_bind(addr, bind_ip).await?
} else {
anyhow::bail!("could not resolve proxy address");
};
socks5_negotiate_auth(&mut stream, username, password).await?;
let mut stream = TcpStream::connect(proxy_addr).await?;
stream.write_all(&[0x05, 0x01, 0x00]).await?;
let mut reply = [0u8; 2];
stream.read_exact(&mut reply).await?;
if reply != [0x05, 0x00] {
anyhow::bail!("SOCKS5 auth not accepted");
}
// Send UDP Associate request
let local_addr = server_udp.local_addr()?;
@ -733,7 +637,7 @@ mod tests {
let _ = listener.accept().await;
});
let result = connect_direct(&addr.to_string(), Duration::from_secs(2), None).await;
let result = connect_direct(&addr.to_string(), Duration::from_secs(2)).await;
assert!(result.is_ok(), "expected connect_direct to reach a live local listener: {:?}", result.err());
}
@ -746,7 +650,7 @@ mod tests {
drop(listener);
let start = std::time::Instant::now();
let result = connect_direct(&addr.to_string(), Duration::from_secs(5), None).await;
let result = connect_direct(&addr.to_string(), Duration::from_secs(5)).await;
assert!(result.is_err(), "connecting to a closed port should fail");
assert!(start.elapsed() < Duration::from_secs(4), "a refused connection must not wait out the full timeout");
}

View File

@ -155,16 +155,10 @@ pub async fn handle_relay_message(
if router.debug {
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay UDP ASSOCIATE stream_id={stream_id}")));
}
let udp_bind_result = if let Some(ref bind_ip) = router.bind_ip {
tokio::net::UdpSocket::bind(format!("{}:0", bind_ip)).await
} else {
match tokio::net::UdpSocket::bind("[::]:0").await {
Ok(s) => Ok(s),
Err(_) => tokio::net::UdpSocket::bind("0.0.0.0:0").await,
}
let udp_bind_result = match UdpSocket::bind("[::]:0").await {
Ok(s) => Ok(s),
Err(_) => UdpSocket::bind("0.0.0.0:0").await,
};
let server_udp = match udp_bind_result {
Ok(s) => std::sync::Arc::new(s),
Err(e) => {

View File

@ -7,16 +7,14 @@ use crate::dns::DnsServer;
#[derive(Clone)]
pub struct Router {
pub outbound_cfg: Arc<RwLock<Option<OutboundConfig>>>,
pub bind_ip: Option<String>,
pub dns_server: Arc<DnsServer>,
pub debug: bool,
}
impl Router {
pub fn new(outbound_cfg: Option<OutboundConfig>, bind_ip: Option<String>, dns_server: Arc<DnsServer>, debug: bool) -> Self {
pub fn new(outbound_cfg: Option<OutboundConfig>, dns_server: Arc<DnsServer>, debug: bool) -> Self {
Self {
outbound_cfg: Arc::new(RwLock::new(outbound_cfg)),
bind_ip,
dns_server,
debug,
}
@ -28,7 +26,7 @@ impl Router {
let lock = self.outbound_cfg.read().unwrap();
lock.clone()
};
connect_target(target, cfg.as_ref(), self.bind_ip.as_deref(), self.debug).await
connect_target(target, cfg.as_ref(), self.debug).await
}
/// UDP Target Routing
@ -37,7 +35,7 @@ impl Router {
let lock = self.outbound_cfg.read().unwrap();
lock.clone()
};
crate::outbound::connect_udp_target(target, cfg.as_ref(), self.bind_ip.as_deref(), self.debug, server_udp).await
crate::outbound::connect_udp_target(target, cfg.as_ref(), self.debug, server_udp).await
}
/// Establish a UDP session router that can dynamically route packets
@ -52,7 +50,7 @@ impl Router {
if c.enabled {
if c.protocol == "socks5" {
let proxy_addr = format!("{}:{}", c.address, c.port);
match crate::outbound::connect_udp_via_socks5(&proxy_addr, server_udp.clone(), self.bind_ip.as_deref(), &c.username, &c.password).await {
match crate::outbound::connect_udp_via_socks5(&proxy_addr, server_udp.clone()).await {
Ok(p) => proxy = Some(Arc::new(p)),
// Warn unconditionally, not only under `debug`. Every UDP
// flow the rules want proxied is now dropped instead of
@ -101,7 +99,7 @@ impl UdpSessionRouter {
pub async fn send_to(&self, data: &[u8], target: &str) -> Result<usize> {
if let Some(cfg) = &self.cfg {
if cfg.enabled {
let (action, _rule_src) = crate::outbound::select_outbound_action(target, "udp", cfg, self.debug).await;
let action = crate::outbound::select_outbound_action(target, "udp", cfg, self.debug).await;
if action == crate::outbound::OutboundAction::Block {
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
}

View File

@ -99,34 +99,18 @@ pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
// Take the interface index straight from the adapter WinTun just created,
// via the tun crate. The old code looked it up by FriendlyName == "ostp_tun"
// through GetAdaptersAddresses — but WinTun does NOT set the FriendlyName to
// the adapter name, so that match never succeeded: on every single connect
// it spun the full 15s and then gave up with "traffic will NOT be captured",
// leaving the default route (and, above, the server-IP bypass) uninstalled.
// get_adapter_index() is instant and correct.
use tun::AbstractDevice;
let tun_index = match dev.tun_index() {
Ok(idx) if idx > 0 => Some(idx as u32),
Ok(idx) => {
tracing::error!("WinTun reported a non-positive interface index ({idx})");
None
// A freshly created WinTun adapter can take several seconds to appear in
// GetAdaptersAddresses (it only shows up once it has an operational IPv4
// binding). The default route via the TUN is what actually captures
// traffic, so this lookup is critical — give it a generous window (~15s).
let mut tun_index = None;
for _ in 0..75 {
if let Some(idx) = windows_route::sys::get_interface_index("ostp_tun") {
tun_index = Some(idx);
break;
}
Err(e) => {
// Fall back to the old name lookup rather than fail outright.
tracing::warn!("Could not read TUN index from the adapter ({e}); falling back to name lookup");
let mut idx = None;
for _ in 0..75 {
if let Some(i) = windows_route::sys::get_interface_index("ostp_tun") {
idx = Some(i);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
idx
}
};
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
if let Some(idx) = tun_index {
match windows_route::sys::add_ipv4_route(

View File

@ -17,7 +17,7 @@ pub mod sys {
use winapi::shared::minwindef::{DWORD, ULONG};
use winapi::shared::winerror::{ERROR_INSUFFICIENT_BUFFER, NO_ERROR};
use winapi::um::iphlpapi::{
DeleteIpForwardEntry, GetAdaptersAddresses, GetIpForwardTable,
CreateIpForwardEntry, DeleteIpForwardEntry, GetAdaptersAddresses, GetIpForwardTable,
};
use winapi::um::iptypes::{
GAA_FLAG_SKIP_ANYCAST, GAA_FLAG_SKIP_DNS_SERVER, GAA_FLAG_SKIP_MULTICAST, IP_ADAPTER_ADDRESSES,
@ -88,47 +88,20 @@ pub mod sys {
if_index: u32,
metric: u32,
) -> Result<(), String> {
// Installed through route.exe rather than CreateIpForwardEntry.
//
// The legacy CreateIpForwardEntry API was failing here with error 160
// (ERROR_BAD_ARGUMENTS) on every single route — server-IP bypass and TUN
// default route alike — which left the server IP routed INTO the tunnel
// (a loop that froze the link for seconds under load) and the default
// route uninstalled. route.exe resolves the interface and validates the
// gateway itself, and is what the teardown path already uses, so it
// succeeds where the hand-built MIB_IPFORWARDROW did not.
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut row: MIB_IPFORWARDROW = unsafe { mem::zeroed() };
row.dwForwardDest = ipv4_to_dword(dest);
row.dwForwardMask = ipv4_to_dword(mask);
row.dwForwardNextHop = ipv4_to_dword(nexthop);
row.dwForwardIfIndex = if_index;
row.ForwardType = if nexthop == Ipv4Addr::UNSPECIFIED || dest == nexthop { 3 } else { 4 };
row.ForwardProto = 3; // MIB_IPPROTO_NETMGMT
row.dwForwardMetric1 = metric;
// route add <dest> mask <mask> <gateway> metric <m> if <ifindex>
let out = Command::new("route")
.args([
"add",
&dest.to_string(),
"mask",
&mask.to_string(),
&nexthop.to_string(),
"metric",
&metric.to_string(),
"if",
&if_index.to_string(),
])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("could not run route.exe: {e}"))?;
if out.status.success() {
let ret = unsafe { CreateIpForwardEntry(&mut row) };
if ret == NO_ERROR {
Ok(())
} else {
// route.exe prints its diagnostics to stdout, not stderr.
let msg = String::from_utf8_lossy(&out.stdout);
let msg = msg.trim();
Err(format!(
"route add failed (exit {:?}): {}",
out.status.code(),
if msg.is_empty() { "no output" } else { msg }
))
Err(format!("CreateIpForwardEntry failed: {}", ret))
}
}

View File

@ -314,90 +314,15 @@ fn detect_local_public_ip() -> Option<String> {
None
}
/// All global (non-private) IPv4 addresses the machine holds. A VPS with a
/// second address — bought to escape a burned IP — shows up here as more than
/// one entry, which is what the multi-address egress feature (bind_ip /
/// send_from) routes over.
fn detect_all_public_ipv4() -> Vec<String> {
let mut ips = Vec::new();
#[cfg(not(target_os = "windows"))]
{
if let Ok(out) = std::process::Command::new("ip")
.args(["-4", "addr", "show", "scope", "global"])
.output()
{
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines() {
if let Some(idx) = line.find("inet ") {
let substr = &line[idx + 5..];
let ip = substr
.split(|c: char| c == '/' || c.is_whitespace())
.next()
.unwrap_or("");
if !ip.is_empty() && !is_private_ip(ip) && !ips.contains(&ip.to_string()) {
ips.push(ip.to_string());
}
}
}
}
}
ips
}
/// Detect the machine's public IPv4 address(es), record them one per line in
/// `.ostp_public_ip`, and — when there is more than one — offer to set up
/// multi-address egress. Called from the server setup wizard.
///
/// Returns `Some(primary_ip)` to be used as the config's global `bind_ip` when
/// the operator opts into multi-address egress; `None` when there is a single
/// address or they decline (leave egress on the OS default). The file is always
/// written regardless, so the addresses are on hand for editing send_from later.
fn setup_public_ips(config_dir: &std::path::Path) -> Option<String> {
let cache_path = config_dir.join(".ostp_public_ip");
let detected = detect_all_public_ipv4();
if detected.is_empty() {
return None;
}
// Record every detected address, one per line, so it is available later for
// configuring bind_ip / send_from. The first line stays the primary.
let _ = std::fs::write(&cache_path, detected.join("\n") + "\n");
if detected.len() == 1 {
println!(" {} Detected public IP: {}", "[ostp]".green().bold(), detected[0].cyan());
return None;
}
println!(" {} Detected {} public IPs (saved to {}):", "[ostp]".green().bold(), detected.len(), cache_path.display());
for ip in &detected {
println!("{}", ip.cyan());
}
let multi = wizard_yn(
"Set up multi-address egress (route different destinations out of different IPs)?",
true,
);
if !multi {
return None;
}
println!(
" Global source set to {}. Per-destination sources go in outbound rules, e.g.\n \
{{ \"domain_suffix\": [\"youtube.com\"], \"action\": \"direct\", \"send_from\": \"{}\" }}",
detected[0].cyan(),
detected.last().unwrap()
);
detected.into_iter().next()
}
fn get_or_ask_public_ip(config_path: &std::path::Path) -> String {
let config_dir = config_path.parent().unwrap_or_else(|| std::path::Path::new("."));
let cache_path = config_dir.join(".ostp_public_ip");
if cache_path.exists() {
if let Ok(cached) = std::fs::read_to_string(&cache_path) {
// The file may hold several addresses (one per line); the first
// non-empty line is the primary that links advertise.
if let Some(ip) = cached.lines().map(|l| l.trim()).find(|l| !l.is_empty()) {
return ip.to_string();
let ip = cached.trim().to_string();
if !ip.is_empty() {
return ip;
}
}
}
@ -712,14 +637,9 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
}
wizard_ok(&format!("Generated {} key(s)", key_count));
// Auto-detect the machine's public IPs and, when there is more than
// one, offer multi-address egress (records them in .ostp_public_ip).
let config_dir = config_path.parent().unwrap_or_else(|| std::path::Path::new("."));
let bind_ip = setup_public_ips(config_dir);
wizard_step(3, TOTAL, "Service registration");
// intentional: step text then daemon call below
let mut server_json = serde_json::json!({
let server_json = serde_json::json!({
"mode": "server",
"log_level": "info",
"listen": listen,
@ -729,8 +649,6 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
"protocol": "socks5",
"address": "127.0.0.1",
"port": 9050,
"username": "",
"password": "",
"default_action": "proxy",
"rules": []
},
@ -744,9 +662,6 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
"fallback": { "enabled": false, "listen": "0.0.0.0:443", "target": "127.0.0.1:8080" },
"debug": false
});
if let Some(ip) = &bind_ip {
server_json["bind_ip"] = serde_json::json!(ip);
}
let actual_path = wizard_save_config(config_path, &server_json)?;
@ -824,13 +739,9 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
<sha2::Sha256 as sha2::Digest>::digest(password.as_bytes())
);
// Auto-detect public IPs and offer multi-address egress.
let config_dir = config_path.parent().unwrap_or_else(|| std::path::Path::new("."));
let bind_ip = setup_public_ips(config_dir);
wizard_step(4, TOTAL, "Saving configuration");
let panel_bind = format!("0.0.0.0:{}", panel_port);
let mut server_json = serde_json::json!({
let server_json = serde_json::json!({
"mode": "server",
"log_level": "info",
"listen": listen,
@ -840,8 +751,6 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
"protocol": "socks5",
"address": "127.0.0.1",
"port": 9050,
"username": "",
"password": "",
"default_action": "proxy",
"rules": []
},
@ -855,9 +764,6 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
"fallback": { "enabled": false, "listen": "0.0.0.0:443", "target": "127.0.0.1:8080" },
"debug": false
});
if let Some(ip) = &bind_ip {
server_json["bind_ip"] = serde_json::json!(ip);
}
let actual_path = wizard_save_config(config_path, &server_json)?;
@ -1452,8 +1358,6 @@ async fn run_app() -> Result<()> {
protocol: o.protocol,
address: o.address,
port: o.port,
username: o.username,
password: o.password,
rules: o
.rules
.into_iter()
@ -1462,7 +1366,6 @@ async fn run_app() -> Result<()> {
ip_cidr: r.ip_cidr.unwrap_or_default(),
protocol: r.protocol,
action: parse_outbound_action(r.action),
send_from: r.send_from,
})
.collect(),
default_action: parse_outbound_action(o.default_action),
@ -1498,9 +1401,8 @@ async fn run_app() -> Result<()> {
.map(serde_json::from_value)
.transpose()
.map_err(|e| anyhow!("Invalid 'dns' section in server config: {e}"))?;
let bind_ip = server_cfg.bind_ip;
// Pass all listen addresses for multi-listener support
ostp_server::run_server(listen_addrs, Some(host), bind_ip, access_keys_meta, outbound, api_config, fallback_config, debug, dns_cfg, Some(args.config)).await?;
ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, dns_cfg, Some(args.config)).await?;
}
AppMode::Client(client_cfg) => {
println!("{}", include_str!("../../docs/banner.txt").blue().bold());
@ -1648,7 +1550,7 @@ fn cmd_migrate(config_path: &std::path::Path) -> Result<()> {
let kind = ostp_client::migrate::detect_kind(&parsed)
.ok_or_else(|| anyhow!("Could not determine whether {:?} is a client, server, or relay config.", config_path))?;
let (mut migrated, mut report) = match kind {
let (migrated, report) = match kind {
ostp_client::migrate::ConfigKind::Client => {
let (mut v, r) = ostp_client::migrate::migrate_client_json(parsed);
if v.get("mode").is_none() { v["mode"] = serde_json::json!("client"); }
@ -1665,16 +1567,6 @@ fn cmd_migrate(config_path: &std::path::Path) -> Result<()> {
}
};
// Uniform final pass for every kind: strip null "unset" keys so the written
// config is concise. Key order is already canonical (serde_json sorts keys
// on write), so together with the kind-specific rules above this turns any
// disordered, noisy config.json into a clean canonical one — without losing
// any real data.
if ostp_client::migrate::normalize(&mut migrated) {
report.changed = true;
report.notes.push("Removed unset (null) keys and wrote the config in canonical order.".to_string());
}
if !report.changed {
println!("{} Config is already up to date, nothing to migrate.", "[ostp]".green().bold());
return Ok(());
@ -1821,10 +1713,6 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
frag_sleep: 2,
junk_pc: [2, 5],
junk_ps: [100, 1000],
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),