mirror of https://github.com/ospab/ostp.git
Compare commits
26 Commits
f5a1c17679
...
cc71856d02
| Author | SHA1 | Date |
|---|---|---|
|
|
cc71856d02 | |
|
|
e7a750dd77 | |
|
|
9cb723cacb | |
|
|
0bd6279700 | |
|
|
d23145dd7e | |
|
|
d170b6de73 | |
|
|
c1b7172fc0 | |
|
|
245e79215c | |
|
|
4da4f9c1a5 | |
|
|
8907f506c7 | |
|
|
c0124a19be | |
|
|
d08738eff9 | |
|
|
219acc99a7 | |
|
|
80a2db2b97 | |
|
|
ff8598e512 | |
|
|
0bb7db4f01 | |
|
|
f59d70778a | |
|
|
bfd079ff48 | |
|
|
1e9111ab9f | |
|
|
d187609629 | |
|
|
d4d4600d87 | |
|
|
3d2b9236e1 | |
|
|
7f9c1e719c | |
|
|
321365efe3 | |
|
|
44677c68e4 | |
|
|
a03e2c9855 |
|
|
@ -2,5 +2,5 @@
|
||||||
"target_version": "0.4.5",
|
"target_version": "0.4.5",
|
||||||
"branch": "beta",
|
"branch": "beta",
|
||||||
"alpha_iteration": 0,
|
"alpha_iteration": 0,
|
||||||
"beta_iteration": 5
|
"beta_iteration": 16
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -133,6 +180,13 @@ pub struct Bridge {
|
||||||
pub frag_sleep: u64,
|
pub frag_sleep: u64,
|
||||||
pub junk_pc: [usize; 2],
|
pub junk_pc: [usize; 2],
|
||||||
pub junk_ps: [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 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>>,
|
||||||
|
|
@ -184,6 +238,11 @@ impl Bridge {
|
||||||
frag_sleep: config.transport.frag_sleep,
|
frag_sleep: config.transport.frag_sleep,
|
||||||
junk_pc: config.transport.junk_pc,
|
junk_pc: config.transport.junk_pc,
|
||||||
junk_ps: config.transport.junk_ps,
|
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,
|
mtu: config.ostp.mtu,
|
||||||
kill_switch: config.kill_switch,
|
kill_switch: config.kill_switch,
|
||||||
reload_tx: None,
|
reload_tx: None,
|
||||||
|
|
@ -1106,15 +1165,80 @@ impl Bridge {
|
||||||
let mut success = false;
|
let mut success = false;
|
||||||
|
|
||||||
let is_uot = matches!(socket, crate::transport::Transport::Uot { .. });
|
let is_uot = matches!(socket, crate::transport::Transport::Uot { .. });
|
||||||
let (attempt_limit, attempt_timeout_ms) = if is_uot { (1, 8000) } else { (4, 1200) };
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
for attempt in 0..attempt_limit {
|
for attempt in 0..attempt_limit {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
tx.send(UiEvent::Log(format!("Handshake attempt {} lost. Retransmitting...", attempt))).await.ok();
|
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() {
|
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);
|
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 {
|
match timeout(Duration::from_millis(attempt_timeout_ms), socket.recv(&mut buf)).await {
|
||||||
Ok(Ok(n)) => {
|
Ok(Ok(n)) => {
|
||||||
|
|
@ -1205,6 +1329,11 @@ impl Bridge {
|
||||||
self.frag_sleep = cfg.transport.frag_sleep;
|
self.frag_sleep = cfg.transport.frag_sleep;
|
||||||
self.junk_pc = cfg.transport.junk_pc;
|
self.junk_pc = cfg.transport.junk_pc;
|
||||||
self.junk_ps = cfg.transport.junk_ps;
|
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.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;
|
||||||
|
|
|
||||||
|
|
@ -92,13 +92,35 @@ pub struct TransportConfig {
|
||||||
/// [min, max] junk packet size in bytes
|
/// [min, max] junk packet size in bytes
|
||||||
#[serde(default = "default_junk_size")]
|
#[serde(default = "default_junk_size")]
|
||||||
pub junk_ps: [usize; 2],
|
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_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 }
|
||||||
fn default_junk_count() -> [usize; 2] { [2, 5] }
|
fn default_junk_count() -> [usize; 2] { [2, 5] }
|
||||||
fn default_junk_size() -> [usize; 2] { [100, 1000] }
|
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 {
|
impl Default for TransportConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
|
@ -109,6 +131,10 @@ impl Default for TransportConfig {
|
||||||
frag_sleep: default_frag_sleep(),
|
frag_sleep: default_frag_sleep(),
|
||||||
junk_pc: default_junk_count(),
|
junk_pc: default_junk_count(),
|
||||||
junk_ps: default_junk_size(),
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -194,6 +220,10 @@ struct RawTransportSection {
|
||||||
frag_sleep: Option<u64>,
|
frag_sleep: Option<u64>,
|
||||||
junk_pc: Option<[usize; 2]>,
|
junk_pc: Option<[usize; 2]>,
|
||||||
junk_ps: 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)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -271,6 +301,10 @@ impl ClientConfig {
|
||||||
frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or_else(default_frag_sleep),
|
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_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),
|
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 {
|
exclusions: ExclusionConfig {
|
||||||
domains: exclusions.domains.unwrap_or_default(),
|
domains: exclusions.domains.unwrap_or_default(),
|
||||||
|
|
@ -349,11 +383,18 @@ impl UnifiedConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppMode::Relay(cfg) => {
|
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() {
|
if cfg.upstream_tcp.is_empty() {
|
||||||
anyhow::bail!("Relay configuration must specify upstream_tcp address.");
|
anyhow::bail!("Relay configuration must specify upstream_tcp (the next hop's TCP/UoT address).");
|
||||||
}
|
}
|
||||||
if cfg.upstream_api_url.is_empty() {
|
if cfg.upstream_udp.is_empty() {
|
||||||
anyhow::bail!("Relay configuration must specify upstream_api_url.");
|
anyhow::bail!("Relay configuration must specify upstream_udp (the next hop's UDP address).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -396,6 +437,7 @@ impl UserConfig {
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
pub listen: ListenConfig,
|
pub listen: ListenConfig,
|
||||||
|
pub bind_ip: Option<String>,
|
||||||
pub access_keys: Vec<UserConfig>,
|
pub access_keys: Vec<UserConfig>,
|
||||||
pub debug: Option<bool>,
|
pub debug: Option<bool>,
|
||||||
pub outbound: Option<OutboundConfig>,
|
pub outbound: Option<OutboundConfig>,
|
||||||
|
|
@ -511,6 +553,13 @@ pub struct OutboundConfig {
|
||||||
pub protocol: String,
|
pub protocol: String,
|
||||||
pub address: String,
|
pub address: String,
|
||||||
pub port: u16,
|
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)]
|
#[serde(default)]
|
||||||
pub rules: Vec<OutboundRule>,
|
pub rules: Vec<OutboundRule>,
|
||||||
pub default_action: Option<String>,
|
pub default_action: Option<String>,
|
||||||
|
|
@ -522,6 +571,10 @@ pub struct OutboundRule {
|
||||||
pub ip_cidr: Option<Vec<String>>,
|
pub ip_cidr: Option<Vec<String>>,
|
||||||
pub protocol: Option<String>,
|
pub protocol: Option<String>,
|
||||||
pub action: 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)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
|
@ -536,3 +589,76 @@ pub struct MuxConfig {
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
pub sessions: Option<usize>,
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -353,9 +353,60 @@ 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)
|
(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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -549,6 +600,31 @@ mod tests {
|
||||||
assert!(report.notes.iter().any(|n| n.contains("api.token")));
|
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]
|
#[test]
|
||||||
fn detect_kind_falls_back_to_structural_sniffing_without_mode_tag() {
|
fn detect_kind_falls_back_to_structural_sniffing_without_mode_tag() {
|
||||||
assert_eq!(detect_kind(&json!({"access_key": "x", "server": "y"})), Some(ConfigKind::Client));
|
assert_eq!(detect_kind(&json!({"access_key": "x", "server": "y"})), Some(ConfigKind::Client));
|
||||||
|
|
@ -556,4 +632,78 @@ mod tests {
|
||||||
assert_eq!(detect_kind(&json!({"upstream_tcp": "x", "upstream_api_url": "y"})), Some(ConfigKind::Relay));
|
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));
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,4 +53,29 @@ impl Transport {
|
||||||
Self::Uot { .. } => Ok("0.0.0.0:0".parse().unwrap()),
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,307 @@
|
||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod proxy;
|
mod proxy;
|
||||||
|
mod ip_reasm;
|
||||||
pub mod native_handler;
|
pub mod native_handler;
|
||||||
|
|
||||||
mod udp_nat;
|
mod udp_nat;
|
||||||
|
|
|
||||||
|
|
@ -123,12 +123,18 @@ pub async fn run_native_tunnel(
|
||||||
let (mut tun_read, mut tun_write) = tokio::io::split(dev);
|
let (mut tun_read, mut tun_write) = tokio::io::split(dev);
|
||||||
|
|
||||||
let mut tun_to_stack = tokio::spawn(async move {
|
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];
|
let mut buf = vec![0u8; 65536];
|
||||||
loop {
|
loop {
|
||||||
match tun_read.read(&mut buf).await {
|
match tun_read.read(&mut buf).await {
|
||||||
Ok(0) => break,
|
Ok(0) => break,
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
let frame = buf[..n].to_vec();
|
let Some(frame) = reasm.process(&buf[..n]) else {
|
||||||
|
continue; // fragment buffered; nothing to forward yet
|
||||||
|
};
|
||||||
if let Err(e) = stack_sink.send(frame).await {
|
if let Err(e) = stack_sink.send(frame).await {
|
||||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||||
break;
|
break;
|
||||||
|
|
@ -471,6 +477,9 @@ pub async fn run_native_tunnel_from_fd(
|
||||||
let (mut stack_sink, mut stack_stream) = stack.split();
|
let (mut stack_sink, mut stack_stream) = stack.split();
|
||||||
|
|
||||||
let _tun_to_stack = tokio::spawn(async move {
|
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];
|
let mut buf = vec![0u8; 65536];
|
||||||
loop {
|
loop {
|
||||||
let mut guard = match tun_stream.readable().await {
|
let mut guard = match tun_stream.readable().await {
|
||||||
|
|
@ -502,7 +511,9 @@ pub async fn run_native_tunnel_from_fd(
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
let frame = buf[..n].to_vec();
|
let Some(frame) = reasm.process(&buf[..n]) else {
|
||||||
|
continue; // fragment buffered; nothing to forward yet
|
||||||
|
};
|
||||||
if let Err(e) = stack_sink.send(frame).await {
|
if let Err(e) = stack_sink.send(frame).await {
|
||||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<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
|
<application
|
||||||
android:label="ostp_client"
|
android:label="ostp_client"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|
|
||||||
|
|
@ -152,5 +152,24 @@ class MainActivity : FlutterActivity() {
|
||||||
intent.putExtra("configJson", pendingConfigJson)
|
intent.putExtra("configJson", pendingConfigJson)
|
||||||
}
|
}
|
||||||
androidx.core.content.ContextCompat.startForegroundService(this, intent)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class OstpProfile {
|
||||||
int junkPcMax;
|
int junkPcMax;
|
||||||
int junkPsMin;
|
int junkPsMin;
|
||||||
int junkPsMax;
|
int junkPsMax;
|
||||||
|
bool ttlDesync; // TTL-desync decoys (UDP), auto-calibrated in the engine
|
||||||
|
|
||||||
OstpProfile({
|
OstpProfile({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -38,6 +39,7 @@ class OstpProfile {
|
||||||
this.junkPcMax = 5,
|
this.junkPcMax = 5,
|
||||||
this.junkPsMin = 100,
|
this.junkPsMin = 100,
|
||||||
this.junkPsMax = 1000,
|
this.junkPsMax = 1000,
|
||||||
|
this.ttlDesync = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
@ -55,6 +57,7 @@ class OstpProfile {
|
||||||
'junkPcMax': junkPcMax,
|
'junkPcMax': junkPcMax,
|
||||||
'junkPsMin': junkPsMin,
|
'junkPsMin': junkPsMin,
|
||||||
'junkPsMax': junkPsMax,
|
'junkPsMax': junkPsMax,
|
||||||
|
'ttlDesync': ttlDesync,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +76,7 @@ class OstpProfile {
|
||||||
junkPcMax: json['junkPcMax'] as int? ?? 5,
|
junkPcMax: json['junkPcMax'] as int? ?? 5,
|
||||||
junkPsMin: json['junkPsMin'] as int? ?? 100,
|
junkPsMin: json['junkPsMin'] as int? ?? 100,
|
||||||
junkPsMax: json['junkPsMax'] as int? ?? 1000,
|
junkPsMax: json['junkPsMax'] as int? ?? 1000,
|
||||||
|
ttlDesync: json['ttlDesync'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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/connection_state_enum.dart';
|
||||||
import '../models/ostp_profile.dart';
|
import '../models/ostp_profile.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
|
@ -55,10 +56,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
String _pingText = '-- ms';
|
String _pingText = '-- ms';
|
||||||
Color _pingColor = Colors.white54;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
|
_loadVersion();
|
||||||
_pulseController = AnimationController(
|
_pulseController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
|
|
@ -71,6 +77,17 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
_startPolling();
|
_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 {
|
Future<void> _checkInitialState() async {
|
||||||
try {
|
try {
|
||||||
final isRunning = await platform.invokeMethod('isRunning');
|
final isRunning = await platform.invokeMethod('isRunning');
|
||||||
|
|
@ -138,6 +155,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
"frag_sleep": p?.fragSleep ?? 2,
|
"frag_sleep": p?.fragSleep ?? 2,
|
||||||
"junk_pc": [p?.junkPcMin ?? 2, p?.junkPcMax ?? 5],
|
"junk_pc": [p?.junkPcMin ?? 2, p?.junkPcMax ?? 5],
|
||||||
"junk_ps": [p?.junkPsMin ?? 100, p?.junkPsMax ?? 1000],
|
"junk_ps": [p?.junkPsMin ?? 100, p?.junkPsMax ?? 1000],
|
||||||
|
"ttl_desync": p?.ttlDesync ?? false,
|
||||||
|
"ttl_desync_auto": true,
|
||||||
},
|
},
|
||||||
"multiplex": {
|
"multiplex": {
|
||||||
"enabled": muxEnabled,
|
"enabled": muxEnabled,
|
||||||
|
|
@ -515,6 +534,17 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
_buildTopBar(theme),
|
_buildTopBar(theme),
|
||||||
Expanded(child: _buildStage(theme)),
|
Expanded(child: _buildStage(theme)),
|
||||||
_buildMetricsBar(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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
final junkPsMaxCtrl = TextEditingController(text: (profile?.junkPsMax ?? 1000).toString());
|
final junkPsMaxCtrl = TextEditingController(text: (profile?.junkPsMax ?? 1000).toString());
|
||||||
String transportMode = profile?.transportMode ?? 'udp';
|
String transportMode = profile?.transportMode ?? 'udp';
|
||||||
bool tcpFragmentation = profile?.tcpFragmentation ?? false;
|
bool tcpFragmentation = profile?.tcpFragmentation ?? false;
|
||||||
|
bool ttlDesync = profile?.ttlDesync ?? false;
|
||||||
bool obscureKey = true;
|
bool obscureKey = true;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
|
|
@ -301,6 +302,13 @@ 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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -348,6 +356,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
junkPcMax: int.tryParse(junkPcMaxCtrl.text) ?? 5,
|
junkPcMax: int.tryParse(junkPcMaxCtrl.text) ?? 5,
|
||||||
junkPsMin: int.tryParse(junkPsMinCtrl.text) ?? 100,
|
junkPsMin: int.tryParse(junkPsMinCtrl.text) ?? 100,
|
||||||
junkPsMax: int.tryParse(junkPsMaxCtrl.text) ?? 1000,
|
junkPsMax: int.tryParse(junkPsMaxCtrl.text) ?? 1000,
|
||||||
|
ttlDesync: ttlDesync,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
profile.name = nameCtrl.text.trim().isNotEmpty ? nameCtrl.text.trim() : server;
|
profile.name = nameCtrl.text.trim().isNotEmpty ? nameCtrl.text.trim() : server;
|
||||||
|
|
@ -361,6 +370,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
profile.junkPcMax = int.tryParse(junkPcMaxCtrl.text) ?? 5;
|
profile.junkPcMax = int.tryParse(junkPcMaxCtrl.text) ?? 5;
|
||||||
profile.junkPsMin = int.tryParse(junkPsMinCtrl.text) ?? 100;
|
profile.junkPsMin = int.tryParse(junkPsMinCtrl.text) ?? 100;
|
||||||
profile.junkPsMax = int.tryParse(junkPsMaxCtrl.text) ?? 1000;
|
profile.junkPsMax = int.tryParse(junkPsMaxCtrl.text) ?? 1000;
|
||||||
|
profile.ttlDesync = ttlDesync;
|
||||||
}
|
}
|
||||||
_saveProfiles();
|
_saveProfiles();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.4.5+36
|
version: 0.4.5+47
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.4
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,10 @@ struct TransportConfigRaw {
|
||||||
frag_sleep: Option<u64>,
|
frag_sleep: Option<u64>,
|
||||||
junk_pc: Option<[usize; 2]>,
|
junk_pc: Option<[usize; 2]>,
|
||||||
junk_ps: 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)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
|
@ -239,6 +243,10 @@ 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),
|
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_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]),
|
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 {
|
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(),
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ const translations = {
|
||||||
label_kill_switch: 'Kill Switch',
|
label_kill_switch: 'Kill Switch',
|
||||||
kill_switch_hint: 'Block non-VPN traffic when tunnel drops',
|
kill_switch_hint: 'Block non-VPN traffic when tunnel drops',
|
||||||
label_transport: 'Transport Protocol',
|
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_mtu: 'MTU Size',
|
||||||
label_transport: 'Transport Protocol',
|
label_transport: 'Transport Protocol',
|
||||||
label_sni: 'Stealth SNI (Fake Host)',
|
label_sni: 'Stealth SNI (Fake Host)',
|
||||||
|
|
@ -93,6 +95,8 @@ const translations = {
|
||||||
label_kill_switch: 'Kill Switch',
|
label_kill_switch: 'Kill Switch',
|
||||||
kill_switch_hint: 'Блокировать трафик вне VPN при обрыве связи',
|
kill_switch_hint: 'Блокировать трафик вне VPN при обрыве связи',
|
||||||
label_transport: 'Транспортный протокол',
|
label_transport: 'Транспортный протокол',
|
||||||
|
label_ttl_desync: 'TTL-десинхронизация',
|
||||||
|
hint_ttl_desync: 'Пакеты-приманки, умирающие до сервера (UDP, авто-настройка)',
|
||||||
label_mtu: 'Размер MTU',
|
label_mtu: 'Размер MTU',
|
||||||
label_transport: 'Транспортный протокол',
|
label_transport: 'Транспортный протокол',
|
||||||
label_sni: 'Маскировочный SNI',
|
label_sni: 'Маскировочный SNI',
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,17 @@
|
||||||
</div>
|
</div>
|
||||||
</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> <!-- client-settings-card -->
|
||||||
|
|
||||||
<div class="app-version" id="app-version">OSTP GUI</div>
|
<div class="app-version" id="app-version">OSTP GUI</div>
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,7 @@ const btnJunkDone = $('btn-junk-done');
|
||||||
|
|
||||||
const inTcpFrag = $('cs-tcp-frag');
|
const inTcpFrag = $('cs-tcp-frag');
|
||||||
const btnFragSettings = $('btn-frag-settings');
|
const btnFragSettings = $('btn-frag-settings');
|
||||||
|
const inTtlDesync = $('cs-ttl-desync');
|
||||||
const fragModal = $('frag-modal');
|
const fragModal = $('frag-modal');
|
||||||
const inFragChunk = $('cs-frag-chunk');
|
const inFragChunk = $('cs-frag-chunk');
|
||||||
const inFragSleep = $('cs-frag-sleep');
|
const inFragSleep = $('cs-frag-sleep');
|
||||||
|
|
@ -318,7 +319,9 @@ function buildConfig() {
|
||||||
frag_chunk: s.tcpFrag ? (s.fragChunk || 2) : (active.frag_chunk || 2),
|
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),
|
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_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])
|
junk_ps: s.junkEnabled ? [s.junkPsMin || 100, s.junkPsMax || 1000] : (active.junk_ps || [100, 1000]),
|
||||||
|
ttl_desync: !!s.ttlDesync,
|
||||||
|
ttl_desync_auto: true
|
||||||
},
|
},
|
||||||
tun: {
|
tun: {
|
||||||
enable: !!s.tun,
|
enable: !!s.tun,
|
||||||
|
|
@ -657,6 +660,7 @@ function loadSettingsIntoForm() {
|
||||||
inTcpFrag.checked = !!s.tcpFrag;
|
inTcpFrag.checked = !!s.tcpFrag;
|
||||||
inFragChunk.value = s.fragChunk || 2;
|
inFragChunk.value = s.fragChunk || 2;
|
||||||
inFragSleep.value = !isNaN(parseInt(s.fragSleep)) ? s.fragSleep : 2;
|
inFragSleep.value = !isNaN(parseInt(s.fragSleep)) ? s.fragSleep : 2;
|
||||||
|
if (inTtlDesync) inTtlDesync.checked = !!s.ttlDesync;
|
||||||
updateClientVisibility();
|
updateClientVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -692,6 +696,7 @@ function collectAndSaveSettings() {
|
||||||
tcpFrag: inTcpFrag.checked,
|
tcpFrag: inTcpFrag.checked,
|
||||||
fragChunk: parseInt(inFragChunk.value) || 2,
|
fragChunk: parseInt(inFragChunk.value) || 2,
|
||||||
fragSleep: !isNaN(parseInt(inFragSleep.value)) ? parseInt(inFragSleep.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.
|
// Cheap and local: safe to run on every debounced keystroke.
|
||||||
saveClientSettings(s);
|
saveClientSettings(s);
|
||||||
|
|
@ -715,7 +720,7 @@ function collectAndSaveSettings() {
|
||||||
const tunnelRelevant = JSON.stringify([
|
const tunnelRelevant = JSON.stringify([
|
||||||
s.tun, s.killSwitch, s.mux, s.muxSessions, s.mtu, s.dns, s.socks,
|
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.exDomains, s.exIps, s.exProcs, s.junkEnabled, s.junkPcMin, s.junkPcMax,
|
||||||
s.junkPsMin, s.junkPsMax, s.tcpFrag, s.fragChunk, s.fragSleep,
|
s.junkPsMin, s.junkPsMax, s.tcpFrag, s.fragChunk, s.fragSleep, s.ttlDesync,
|
||||||
]);
|
]);
|
||||||
if (tunnelRelevant !== lastAppliedTunnelConfig) {
|
if (tunnelRelevant !== lastAppliedTunnelConfig) {
|
||||||
clearTimeout(hotReloadTimer);
|
clearTimeout(hotReloadTimer);
|
||||||
|
|
@ -901,7 +906,8 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||||
wintunModal.addEventListener('click', e => { if (e.target === wintunModal) wintunModal.classList.add('hidden'); });
|
wintunModal.addEventListener('click', e => { if (e.target === wintunModal) wintunModal.classList.add('hidden'); });
|
||||||
|
|
||||||
// Client settings — wire all inputs
|
// Client settings — wire all inputs
|
||||||
[inTun, inKillSwitch, inMux, inAutoconnect, inLaunchStartup, inDebug, inShowRtt, inShowSpeed, inJunkEnabled, inTcpFrag]
|
[inTun, inKillSwitch, inMux, inAutoconnect, inLaunchStartup, inDebug, inShowRtt, inShowSpeed, inJunkEnabled, inTcpFrag, inTtlDesync]
|
||||||
|
.filter(Boolean)
|
||||||
.forEach(el => el.addEventListener('change', collectAndSaveSettings));
|
.forEach(el => el.addEventListener('change', collectAndSaveSettings));
|
||||||
[inMuxSessions, inMtu, inDns, inSocks, inExDomains, inExIps, inExProcs, inJunkPcMin, inJunkPcMax, inJunkPsMin, inJunkPsMax, inFragChunk, inFragSleep]
|
[inMuxSessions, inMtu, inDns, inSocks, inExDomains, inExIps, inExProcs, inJunkPcMin, inJunkPcMax, inJunkPsMin, inJunkPsMax, inFragChunk, inFragSleep]
|
||||||
.forEach(el => {
|
.forEach(el => {
|
||||||
|
|
|
||||||
|
|
@ -878,7 +878,7 @@ mod tests {
|
||||||
config_path: None,
|
config_path: None,
|
||||||
dns_server: crate::dns::DnsServer::new(Default::default()),
|
dns_server: crate::dns::DnsServer::new(Default::default()),
|
||||||
audit_logs: Arc::new(RwLock::new(Vec::new())),
|
audit_logs: Arc::new(RwLock::new(Vec::new())),
|
||||||
router: Arc::new(crate::router::Router::new(None, crate::dns::DnsServer::new(Default::default()), false)),
|
router: Arc::new(crate::router::Router::new(None, None, crate::dns::DnsServer::new(Default::default()), false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ pub(crate) struct RemoteState {
|
||||||
pub async fn run_server(
|
pub async fn run_server(
|
||||||
bind_addrs: Vec<String>,
|
bind_addrs: Vec<String>,
|
||||||
server_public_ip: Option<String>,
|
server_public_ip: Option<String>,
|
||||||
|
bind_ip: Option<String>,
|
||||||
access_keys: Vec<(String, crate::api::UserMeta)>,
|
access_keys: Vec<(String, crate::api::UserMeta)>,
|
||||||
outbound: Option<OutboundConfig>,
|
outbound: Option<OutboundConfig>,
|
||||||
api_config: Option<ApiConfig>,
|
api_config: Option<ApiConfig>,
|
||||||
|
|
@ -255,6 +256,7 @@ pub async fn run_server(
|
||||||
// Initialize Router
|
// Initialize Router
|
||||||
let router = std::sync::Arc::new(router::Router::new(
|
let router = std::sync::Arc::new(router::Router::new(
|
||||||
outbound.clone(),
|
outbound.clone(),
|
||||||
|
bind_ip,
|
||||||
dns_server.clone(),
|
dns_server.clone(),
|
||||||
debug,
|
debug,
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,13 @@ pub struct OutboundRule {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub protocol: Option<String>,
|
pub protocol: Option<String>,
|
||||||
pub action: OutboundAction,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -28,6 +35,10 @@ pub struct OutboundConfig {
|
||||||
pub protocol: String,
|
pub protocol: String,
|
||||||
pub address: String,
|
pub address: String,
|
||||||
pub port: u16,
|
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 rules: Vec<OutboundRule>,
|
||||||
pub default_action: OutboundAction,
|
pub default_action: OutboundAction,
|
||||||
}
|
}
|
||||||
|
|
@ -37,12 +48,15 @@ pub struct OutboundConfig {
|
||||||
pub async fn connect_target(
|
pub async fn connect_target(
|
||||||
target: &str,
|
target: &str,
|
||||||
outbound: Option<&OutboundConfig>,
|
outbound: Option<&OutboundConfig>,
|
||||||
|
bind_ip: Option<&str>,
|
||||||
debug: bool,
|
debug: bool,
|
||||||
) -> Result<TcpStream> {
|
) -> Result<TcpStream> {
|
||||||
let connect_timeout = Duration::from_secs(10);
|
let connect_timeout = Duration::from_secs(10);
|
||||||
if let Some(outbound) = outbound {
|
if let Some(outbound) = outbound {
|
||||||
if outbound.enabled {
|
if outbound.enabled {
|
||||||
let action = select_outbound_action(target, "tcp", outbound, debug).await;
|
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);
|
||||||
if action == OutboundAction::Block {
|
if action == OutboundAction::Block {
|
||||||
return Err(anyhow::anyhow!("blocked by outbound rule: {}", target));
|
return Err(anyhow::anyhow!("blocked by outbound rule: {}", target));
|
||||||
}
|
}
|
||||||
|
|
@ -51,8 +65,8 @@ pub async fn connect_target(
|
||||||
// Case-insensitive: a config saying "SOCKS5" means the same thing
|
// Case-insensitive: a config saying "SOCKS5" means the same thing
|
||||||
// as "socks5", and silently treating it as unknown is a trap.
|
// as "socks5", and silently treating it as unknown is a trap.
|
||||||
return match outbound.protocol.to_ascii_lowercase().as_str() {
|
return match outbound.protocol.to_ascii_lowercase().as_str() {
|
||||||
"socks5" => connect_via_socks5(&proxy_addr, target).await,
|
"socks5" => connect_via_socks5(&proxy_addr, target, eff_bind, &outbound.username, &outbound.password).await,
|
||||||
"http" => connect_via_http(&proxy_addr, target).await,
|
"http" => connect_via_http(&proxy_addr, target, eff_bind).await,
|
||||||
// FAIL CLOSED. This used to fall through to a direct
|
// FAIL CLOSED. This used to fall through to a direct
|
||||||
// connection, so any unrecognised protocol string — a typo,
|
// connection, so any unrecognised protocol string — a typo,
|
||||||
// a case difference, an empty value — silently sent ALL TCP
|
// a case difference, an empty value — silently sent ALL TCP
|
||||||
|
|
@ -67,10 +81,14 @@ 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).await
|
connect_direct(target, connect_timeout, bind_ip).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-candidate-address connect attempt, tried in turn (see `connect_direct`
|
/// Per-candidate-address connect attempt, tried in turn (see `connect_direct`
|
||||||
|
|
@ -92,7 +110,23 @@ const PER_ADDR_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
/// attempted: every dual-stack destination (i.e. most popular sites) never
|
/// attempted: every dual-stack destination (i.e. most popular sites) never
|
||||||
/// loads, while IPv4-only destinations work fine - exactly the "traffic
|
/// loads, while IPv4-only destinations work fine - exactly the "traffic
|
||||||
/// counter moves but sites don't open" symptom this fixes.
|
/// counter moves but sites don't open" symptom this fixes.
|
||||||
async fn connect_direct(target: &str, connect_timeout: Duration) -> Result<TcpStream> {
|
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> {
|
||||||
tokio::time::timeout(connect_timeout, async {
|
tokio::time::timeout(connect_timeout, async {
|
||||||
let mut addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(target)
|
let mut addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(target)
|
||||||
.await
|
.await
|
||||||
|
|
@ -105,7 +139,7 @@ async fn connect_direct(target: &str, connect_timeout: Duration) -> Result<TcpSt
|
||||||
|
|
||||||
let mut last_err = None;
|
let mut last_err = None;
|
||||||
for addr in addrs {
|
for addr in addrs {
|
||||||
match tokio::time::timeout(PER_ADDR_CONNECT_TIMEOUT, TcpStream::connect(addr)).await {
|
match tokio::time::timeout(PER_ADDR_CONNECT_TIMEOUT, connect_tcp_with_bind(addr, bind_ip)).await {
|
||||||
Ok(Ok(stream)) => return Ok(stream),
|
Ok(Ok(stream)) => return Ok(stream),
|
||||||
Ok(Err(e)) => last_err = Some(anyhow::anyhow!("{}: {}", addr, e)),
|
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())),
|
Err(_) => last_err = Some(anyhow::anyhow!("{}: connect timeout ({}s)", addr, PER_ADDR_CONNECT_TIMEOUT.as_secs())),
|
||||||
|
|
@ -130,39 +164,35 @@ pub async fn select_outbound_action(
|
||||||
protocol: &str,
|
protocol: &str,
|
||||||
outbound: &OutboundConfig,
|
outbound: &OutboundConfig,
|
||||||
debug: bool,
|
debug: bool,
|
||||||
) -> OutboundAction {
|
) -> (OutboundAction, Option<String>) {
|
||||||
let (host, port) = match split_host_port(target) {
|
let (host, port) = match split_host_port(target) {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => return outbound.default_action,
|
None => return (outbound.default_action, None),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut matched = None;
|
// 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;
|
||||||
for rule in &outbound.rules {
|
for rule in &outbound.rules {
|
||||||
if let Some(ref rule_proto) = rule.protocol {
|
if let Some(ref rule_proto) = rule.protocol {
|
||||||
if !rule_proto.is_empty() && rule_proto.to_lowercase() != protocol {
|
if !rule_proto.is_empty() && rule_proto.to_lowercase() != protocol {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if rule.domain_suffix.is_empty() && rule.ip_cidr.is_empty() {
|
let hit = (rule.domain_suffix.is_empty() && rule.ip_cidr.is_empty())
|
||||||
// Protocol-only rule match
|
|| match_domain_rule(&host, &rule.domain_suffix)
|
||||||
matched = Some(rule.action);
|
|| match_ip_rule(&host, port, &rule.ip_cidr).await;
|
||||||
break;
|
if hit {
|
||||||
}
|
matched = Some((rule.action, rule.send_from.clone()));
|
||||||
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let action = matched.unwrap_or(outbound.default_action);
|
let (action, send_from) = matched.unwrap_or((outbound.default_action, None));
|
||||||
if debug {
|
if debug {
|
||||||
tracing::debug!("Outbound routing: target={target} action={action:?}");
|
tracing::debug!("Outbound routing: target={target} action={action:?} send_from={send_from:?}");
|
||||||
}
|
}
|
||||||
action
|
(action, send_from)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn match_domain_rule(host: &str, suffixes: &[String]) -> bool {
|
fn match_domain_rule(host: &str, suffixes: &[String]) -> bool {
|
||||||
|
|
@ -193,16 +223,72 @@ async fn match_ip_rule(host: &str, _port: u16, cidrs: &[String]) -> bool {
|
||||||
|
|
||||||
// ── SOCKS5 / HTTP CONNECT upstream proxy ─────────────────────────────────────
|
// ── SOCKS5 / HTTP CONNECT upstream proxy ─────────────────────────────────────
|
||||||
|
|
||||||
async fn connect_via_socks5(proxy_addr: &str, target: &str) -> Result<TcpStream> {
|
/// 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,
|
||||||
|
{
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
let mut stream = TcpStream::connect(proxy_addr).await?;
|
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?;
|
stream.write_all(&[0x05, 0x01, 0x00]).await?;
|
||||||
|
}
|
||||||
let mut reply = [0u8; 2];
|
let mut reply = [0u8; 2];
|
||||||
stream.read_exact(&mut reply).await?;
|
stream.read_exact(&mut reply).await?;
|
||||||
if reply != [0x05, 0x00] {
|
if reply[0] != 0x05 {
|
||||||
anyhow::bail!("SOCKS5 auth not accepted");
|
anyhow::bail!("SOCKS5: unexpected version 0x{:02x} in method reply", reply[0]);
|
||||||
}
|
}
|
||||||
|
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 (host, port) = split_host_port(target).ok_or_else(|| anyhow::anyhow!("invalid target"))?;
|
||||||
let mut req = Vec::new();
|
let mut req = Vec::new();
|
||||||
|
|
@ -250,10 +336,15 @@ async fn connect_via_socks5(proxy_addr: &str, target: &str) -> Result<TcpStream>
|
||||||
Ok(stream)
|
Ok(stream)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn connect_via_http(proxy_addr: &str, target: &str) -> Result<TcpStream> {
|
async fn connect_via_http(proxy_addr: &str, target: &str, bind_ip: Option<&str>) -> Result<TcpStream> {
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
let mut stream = TcpStream::connect(proxy_addr).await?;
|
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 request = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
|
let request = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
|
||||||
stream.write_all(request.as_bytes()).await?;
|
stream.write_all(request.as_bytes()).await?;
|
||||||
|
|
||||||
|
|
@ -372,19 +463,21 @@ impl UdpProxySocket {
|
||||||
pub async fn connect_udp_target(
|
pub async fn connect_udp_target(
|
||||||
target: &str,
|
target: &str,
|
||||||
outbound: Option<&OutboundConfig>,
|
outbound: Option<&OutboundConfig>,
|
||||||
|
bind_ip: Option<&str>,
|
||||||
debug: bool,
|
debug: bool,
|
||||||
server_udp: std::sync::Arc<tokio::net::UdpSocket>,
|
server_udp: std::sync::Arc<tokio::net::UdpSocket>,
|
||||||
) -> Result<UdpProxySocket> {
|
) -> Result<UdpProxySocket> {
|
||||||
if let Some(outbound) = outbound {
|
if let Some(outbound) = outbound {
|
||||||
if outbound.enabled {
|
if outbound.enabled {
|
||||||
let action = select_outbound_action(target, "udp", outbound, debug).await;
|
let (action, rule_src) = select_outbound_action(target, "udp", outbound, debug).await;
|
||||||
|
let eff_bind = rule_src.as_deref().or(bind_ip);
|
||||||
if action == OutboundAction::Block {
|
if action == OutboundAction::Block {
|
||||||
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
|
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
|
||||||
}
|
}
|
||||||
if action == OutboundAction::Proxy {
|
if action == OutboundAction::Proxy {
|
||||||
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
|
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
|
||||||
if outbound.protocol.eq_ignore_ascii_case("socks5") {
|
if outbound.protocol.eq_ignore_ascii_case("socks5") {
|
||||||
return connect_udp_via_socks5(&proxy_addr, server_udp).await;
|
return connect_udp_via_socks5(&proxy_addr, server_udp, eff_bind, &outbound.username, &outbound.password).await;
|
||||||
}
|
}
|
||||||
// FAIL CLOSED. HTTP CONNECT genuinely cannot carry UDP — but the
|
// FAIL CLOSED. HTTP CONNECT genuinely cannot carry UDP — but the
|
||||||
// answer to that is not to send the datagrams in the clear. The
|
// answer to that is not to send the datagrams in the clear. The
|
||||||
|
|
@ -408,16 +501,19 @@ pub async fn connect_udp_target(
|
||||||
pub async fn connect_udp_via_socks5(
|
pub async fn connect_udp_via_socks5(
|
||||||
proxy_addr: &str,
|
proxy_addr: &str,
|
||||||
server_udp: std::sync::Arc<tokio::net::UdpSocket>,
|
server_udp: std::sync::Arc<tokio::net::UdpSocket>,
|
||||||
|
bind_ip: Option<&str>,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
) -> Result<UdpProxySocket> {
|
) -> Result<UdpProxySocket> {
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
let mut stream = TcpStream::connect(proxy_addr).await?;
|
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(proxy_addr).await?.collect();
|
||||||
stream.write_all(&[0x05, 0x01, 0x00]).await?;
|
let mut stream = if let Some(addr) = addrs.into_iter().next() {
|
||||||
let mut reply = [0u8; 2];
|
connect_tcp_with_bind(addr, bind_ip).await?
|
||||||
stream.read_exact(&mut reply).await?;
|
} else {
|
||||||
if reply != [0x05, 0x00] {
|
anyhow::bail!("could not resolve proxy address");
|
||||||
anyhow::bail!("SOCKS5 auth not accepted");
|
};
|
||||||
}
|
socks5_negotiate_auth(&mut stream, username, password).await?;
|
||||||
|
|
||||||
// Send UDP Associate request
|
// Send UDP Associate request
|
||||||
let local_addr = server_udp.local_addr()?;
|
let local_addr = server_udp.local_addr()?;
|
||||||
|
|
@ -637,7 +733,7 @@ mod tests {
|
||||||
let _ = listener.accept().await;
|
let _ = listener.accept().await;
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = connect_direct(&addr.to_string(), Duration::from_secs(2)).await;
|
let result = connect_direct(&addr.to_string(), Duration::from_secs(2), None).await;
|
||||||
assert!(result.is_ok(), "expected connect_direct to reach a live local listener: {:?}", result.err());
|
assert!(result.is_ok(), "expected connect_direct to reach a live local listener: {:?}", result.err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -650,7 +746,7 @@ mod tests {
|
||||||
drop(listener);
|
drop(listener);
|
||||||
|
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let result = connect_direct(&addr.to_string(), Duration::from_secs(5)).await;
|
let result = connect_direct(&addr.to_string(), Duration::from_secs(5), None).await;
|
||||||
assert!(result.is_err(), "connecting to a closed port should fail");
|
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");
|
assert!(start.elapsed() < Duration::from_secs(4), "a refused connection must not wait out the full timeout");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -155,10 +155,16 @@ pub async fn handle_relay_message(
|
||||||
if router.debug {
|
if router.debug {
|
||||||
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay UDP ASSOCIATE stream_id={stream_id}")));
|
let _ = ui_event_tx.send(UiEvent::Log(format!("Relay UDP ASSOCIATE stream_id={stream_id}")));
|
||||||
}
|
}
|
||||||
let udp_bind_result = match UdpSocket::bind("[::]:0").await {
|
|
||||||
|
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),
|
Ok(s) => Ok(s),
|
||||||
Err(_) => UdpSocket::bind("0.0.0.0:0").await,
|
Err(_) => tokio::net::UdpSocket::bind("0.0.0.0:0").await,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let server_udp = match udp_bind_result {
|
let server_udp = match udp_bind_result {
|
||||||
Ok(s) => std::sync::Arc::new(s),
|
Ok(s) => std::sync::Arc::new(s),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,16 @@ use crate::dns::DnsServer;
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Router {
|
pub struct Router {
|
||||||
pub outbound_cfg: Arc<RwLock<Option<OutboundConfig>>>,
|
pub outbound_cfg: Arc<RwLock<Option<OutboundConfig>>>,
|
||||||
|
pub bind_ip: Option<String>,
|
||||||
pub dns_server: Arc<DnsServer>,
|
pub dns_server: Arc<DnsServer>,
|
||||||
pub debug: bool,
|
pub debug: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Router {
|
impl Router {
|
||||||
pub fn new(outbound_cfg: Option<OutboundConfig>, dns_server: Arc<DnsServer>, debug: bool) -> Self {
|
pub fn new(outbound_cfg: Option<OutboundConfig>, bind_ip: Option<String>, dns_server: Arc<DnsServer>, debug: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
outbound_cfg: Arc::new(RwLock::new(outbound_cfg)),
|
outbound_cfg: Arc::new(RwLock::new(outbound_cfg)),
|
||||||
|
bind_ip,
|
||||||
dns_server,
|
dns_server,
|
||||||
debug,
|
debug,
|
||||||
}
|
}
|
||||||
|
|
@ -26,7 +28,7 @@ impl Router {
|
||||||
let lock = self.outbound_cfg.read().unwrap();
|
let lock = self.outbound_cfg.read().unwrap();
|
||||||
lock.clone()
|
lock.clone()
|
||||||
};
|
};
|
||||||
connect_target(target, cfg.as_ref(), self.debug).await
|
connect_target(target, cfg.as_ref(), self.bind_ip.as_deref(), self.debug).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// UDP Target Routing
|
/// UDP Target Routing
|
||||||
|
|
@ -35,7 +37,7 @@ impl Router {
|
||||||
let lock = self.outbound_cfg.read().unwrap();
|
let lock = self.outbound_cfg.read().unwrap();
|
||||||
lock.clone()
|
lock.clone()
|
||||||
};
|
};
|
||||||
crate::outbound::connect_udp_target(target, cfg.as_ref(), self.debug, server_udp).await
|
crate::outbound::connect_udp_target(target, cfg.as_ref(), self.bind_ip.as_deref(), self.debug, server_udp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Establish a UDP session router that can dynamically route packets
|
/// Establish a UDP session router that can dynamically route packets
|
||||||
|
|
@ -50,7 +52,7 @@ impl Router {
|
||||||
if c.enabled {
|
if c.enabled {
|
||||||
if c.protocol == "socks5" {
|
if c.protocol == "socks5" {
|
||||||
let proxy_addr = format!("{}:{}", c.address, c.port);
|
let proxy_addr = format!("{}:{}", c.address, c.port);
|
||||||
match crate::outbound::connect_udp_via_socks5(&proxy_addr, server_udp.clone()).await {
|
match crate::outbound::connect_udp_via_socks5(&proxy_addr, server_udp.clone(), self.bind_ip.as_deref(), &c.username, &c.password).await {
|
||||||
Ok(p) => proxy = Some(Arc::new(p)),
|
Ok(p) => proxy = Some(Arc::new(p)),
|
||||||
// Warn unconditionally, not only under `debug`. Every UDP
|
// Warn unconditionally, not only under `debug`. Every UDP
|
||||||
// flow the rules want proxied is now dropped instead of
|
// flow the rules want proxied is now dropped instead of
|
||||||
|
|
@ -99,7 +101,7 @@ impl UdpSessionRouter {
|
||||||
pub async fn send_to(&self, data: &[u8], target: &str) -> Result<usize> {
|
pub async fn send_to(&self, data: &[u8], target: &str) -> Result<usize> {
|
||||||
if let Some(cfg) = &self.cfg {
|
if let Some(cfg) = &self.cfg {
|
||||||
if cfg.enabled {
|
if cfg.enabled {
|
||||||
let action = crate::outbound::select_outbound_action(target, "udp", cfg, self.debug).await;
|
let (action, _rule_src) = crate::outbound::select_outbound_action(target, "udp", cfg, self.debug).await;
|
||||||
if action == crate::outbound::OutboundAction::Block {
|
if action == crate::outbound::OutboundAction::Block {
|
||||||
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
|
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,18 +99,34 @@ pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
|
||||||
|
|
||||||
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
|
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
|
||||||
|
|
||||||
// A freshly created WinTun adapter can take several seconds to appear in
|
// Take the interface index straight from the adapter WinTun just created,
|
||||||
// GetAdaptersAddresses (it only shows up once it has an operational IPv4
|
// via the tun crate. The old code looked it up by FriendlyName == "ostp_tun"
|
||||||
// binding). The default route via the TUN is what actually captures
|
// through GetAdaptersAddresses — but WinTun does NOT set the FriendlyName to
|
||||||
// traffic, so this lookup is critical — give it a generous window (~15s).
|
// the adapter name, so that match never succeeded: on every single connect
|
||||||
let mut tun_index = None;
|
// 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
|
||||||
|
}
|
||||||
|
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 {
|
for _ in 0..75 {
|
||||||
if let Some(idx) = windows_route::sys::get_interface_index("ostp_tun") {
|
if let Some(i) = windows_route::sys::get_interface_index("ostp_tun") {
|
||||||
tun_index = Some(idx);
|
idx = Some(i);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
}
|
}
|
||||||
|
idx
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(idx) = tun_index {
|
if let Some(idx) = tun_index {
|
||||||
match windows_route::sys::add_ipv4_route(
|
match windows_route::sys::add_ipv4_route(
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ pub mod sys {
|
||||||
use winapi::shared::minwindef::{DWORD, ULONG};
|
use winapi::shared::minwindef::{DWORD, ULONG};
|
||||||
use winapi::shared::winerror::{ERROR_INSUFFICIENT_BUFFER, NO_ERROR};
|
use winapi::shared::winerror::{ERROR_INSUFFICIENT_BUFFER, NO_ERROR};
|
||||||
use winapi::um::iphlpapi::{
|
use winapi::um::iphlpapi::{
|
||||||
CreateIpForwardEntry, DeleteIpForwardEntry, GetAdaptersAddresses, GetIpForwardTable,
|
DeleteIpForwardEntry, GetAdaptersAddresses, GetIpForwardTable,
|
||||||
};
|
};
|
||||||
use winapi::um::iptypes::{
|
use winapi::um::iptypes::{
|
||||||
GAA_FLAG_SKIP_ANYCAST, GAA_FLAG_SKIP_DNS_SERVER, GAA_FLAG_SKIP_MULTICAST, IP_ADAPTER_ADDRESSES,
|
GAA_FLAG_SKIP_ANYCAST, GAA_FLAG_SKIP_DNS_SERVER, GAA_FLAG_SKIP_MULTICAST, IP_ADAPTER_ADDRESSES,
|
||||||
|
|
@ -88,20 +88,47 @@ pub mod sys {
|
||||||
if_index: u32,
|
if_index: u32,
|
||||||
metric: u32,
|
metric: u32,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut row: MIB_IPFORWARDROW = unsafe { mem::zeroed() };
|
// Installed through route.exe rather than CreateIpForwardEntry.
|
||||||
row.dwForwardDest = ipv4_to_dword(dest);
|
//
|
||||||
row.dwForwardMask = ipv4_to_dword(mask);
|
// The legacy CreateIpForwardEntry API was failing here with error 160
|
||||||
row.dwForwardNextHop = ipv4_to_dword(nexthop);
|
// (ERROR_BAD_ARGUMENTS) on every single route — server-IP bypass and TUN
|
||||||
row.dwForwardIfIndex = if_index;
|
// default route alike — which left the server IP routed INTO the tunnel
|
||||||
row.ForwardType = if nexthop == Ipv4Addr::UNSPECIFIED || dest == nexthop { 3 } else { 4 };
|
// (a loop that froze the link for seconds under load) and the default
|
||||||
row.ForwardProto = 3; // MIB_IPPROTO_NETMGMT
|
// route uninstalled. route.exe resolves the interface and validates the
|
||||||
row.dwForwardMetric1 = metric;
|
// 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 ret = unsafe { CreateIpForwardEntry(&mut row) };
|
// route add <dest> mask <mask> <gateway> metric <m> if <ifindex>
|
||||||
if ret == NO_ERROR {
|
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() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(format!("CreateIpForwardEntry failed: {}", ret))
|
// 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 }
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
126
ostp/src/main.rs
126
ostp/src/main.rs
|
|
@ -314,15 +314,90 @@ fn detect_local_public_ip() -> Option<String> {
|
||||||
None
|
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 {
|
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 config_dir = config_path.parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||||
let cache_path = config_dir.join(".ostp_public_ip");
|
let cache_path = config_dir.join(".ostp_public_ip");
|
||||||
|
|
||||||
if cache_path.exists() {
|
if cache_path.exists() {
|
||||||
if let Ok(cached) = std::fs::read_to_string(&cache_path) {
|
if let Ok(cached) = std::fs::read_to_string(&cache_path) {
|
||||||
let ip = cached.trim().to_string();
|
// The file may hold several addresses (one per line); the first
|
||||||
if !ip.is_empty() {
|
// non-empty line is the primary that links advertise.
|
||||||
return ip;
|
if let Some(ip) = cached.lines().map(|l| l.trim()).find(|l| !l.is_empty()) {
|
||||||
|
return ip.to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -637,9 +712,14 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||||
}
|
}
|
||||||
wizard_ok(&format!("Generated {} key(s)", key_count));
|
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");
|
wizard_step(3, TOTAL, "Service registration");
|
||||||
// intentional: step text then daemon call below
|
// intentional: step text then daemon call below
|
||||||
let server_json = serde_json::json!({
|
let mut server_json = serde_json::json!({
|
||||||
"mode": "server",
|
"mode": "server",
|
||||||
"log_level": "info",
|
"log_level": "info",
|
||||||
"listen": listen,
|
"listen": listen,
|
||||||
|
|
@ -649,6 +729,8 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||||
"protocol": "socks5",
|
"protocol": "socks5",
|
||||||
"address": "127.0.0.1",
|
"address": "127.0.0.1",
|
||||||
"port": 9050,
|
"port": 9050,
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
"default_action": "proxy",
|
"default_action": "proxy",
|
||||||
"rules": []
|
"rules": []
|
||||||
},
|
},
|
||||||
|
|
@ -662,6 +744,9 @@ 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" },
|
"fallback": { "enabled": false, "listen": "0.0.0.0:443", "target": "127.0.0.1:8080" },
|
||||||
"debug": false
|
"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)?;
|
let actual_path = wizard_save_config(config_path, &server_json)?;
|
||||||
|
|
||||||
|
|
@ -739,9 +824,13 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||||
<sha2::Sha256 as sha2::Digest>::digest(password.as_bytes())
|
<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");
|
wizard_step(4, TOTAL, "Saving configuration");
|
||||||
let panel_bind = format!("0.0.0.0:{}", panel_port);
|
let panel_bind = format!("0.0.0.0:{}", panel_port);
|
||||||
let server_json = serde_json::json!({
|
let mut server_json = serde_json::json!({
|
||||||
"mode": "server",
|
"mode": "server",
|
||||||
"log_level": "info",
|
"log_level": "info",
|
||||||
"listen": listen,
|
"listen": listen,
|
||||||
|
|
@ -751,6 +840,8 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||||
"protocol": "socks5",
|
"protocol": "socks5",
|
||||||
"address": "127.0.0.1",
|
"address": "127.0.0.1",
|
||||||
"port": 9050,
|
"port": 9050,
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
"default_action": "proxy",
|
"default_action": "proxy",
|
||||||
"rules": []
|
"rules": []
|
||||||
},
|
},
|
||||||
|
|
@ -764,6 +855,9 @@ 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" },
|
"fallback": { "enabled": false, "listen": "0.0.0.0:443", "target": "127.0.0.1:8080" },
|
||||||
"debug": false
|
"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)?;
|
let actual_path = wizard_save_config(config_path, &server_json)?;
|
||||||
|
|
||||||
|
|
@ -1358,6 +1452,8 @@ async fn run_app() -> Result<()> {
|
||||||
protocol: o.protocol,
|
protocol: o.protocol,
|
||||||
address: o.address,
|
address: o.address,
|
||||||
port: o.port,
|
port: o.port,
|
||||||
|
username: o.username,
|
||||||
|
password: o.password,
|
||||||
rules: o
|
rules: o
|
||||||
.rules
|
.rules
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -1366,6 +1462,7 @@ async fn run_app() -> Result<()> {
|
||||||
ip_cidr: r.ip_cidr.unwrap_or_default(),
|
ip_cidr: r.ip_cidr.unwrap_or_default(),
|
||||||
protocol: r.protocol,
|
protocol: r.protocol,
|
||||||
action: parse_outbound_action(r.action),
|
action: parse_outbound_action(r.action),
|
||||||
|
send_from: r.send_from,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
default_action: parse_outbound_action(o.default_action),
|
default_action: parse_outbound_action(o.default_action),
|
||||||
|
|
@ -1401,8 +1498,9 @@ async fn run_app() -> Result<()> {
|
||||||
.map(serde_json::from_value)
|
.map(serde_json::from_value)
|
||||||
.transpose()
|
.transpose()
|
||||||
.map_err(|e| anyhow!("Invalid 'dns' section in server config: {e}"))?;
|
.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
|
// Pass all listen addresses for multi-listener support
|
||||||
ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, dns_cfg, Some(args.config)).await?;
|
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?;
|
||||||
}
|
}
|
||||||
AppMode::Client(client_cfg) => {
|
AppMode::Client(client_cfg) => {
|
||||||
println!("{}", include_str!("../../docs/banner.txt").blue().bold());
|
println!("{}", include_str!("../../docs/banner.txt").blue().bold());
|
||||||
|
|
@ -1550,7 +1648,7 @@ fn cmd_migrate(config_path: &std::path::Path) -> Result<()> {
|
||||||
let kind = ostp_client::migrate::detect_kind(&parsed)
|
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))?;
|
.ok_or_else(|| anyhow!("Could not determine whether {:?} is a client, server, or relay config.", config_path))?;
|
||||||
|
|
||||||
let (migrated, report) = match kind {
|
let (mut migrated, mut report) = match kind {
|
||||||
ostp_client::migrate::ConfigKind::Client => {
|
ostp_client::migrate::ConfigKind::Client => {
|
||||||
let (mut v, r) = ostp_client::migrate::migrate_client_json(parsed);
|
let (mut v, r) = ostp_client::migrate::migrate_client_json(parsed);
|
||||||
if v.get("mode").is_none() { v["mode"] = serde_json::json!("client"); }
|
if v.get("mode").is_none() { v["mode"] = serde_json::json!("client"); }
|
||||||
|
|
@ -1567,6 +1665,16 @@ 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 {
|
if !report.changed {
|
||||||
println!("{} Config is already up to date, nothing to migrate.", "[ostp]".green().bold());
|
println!("{} Config is already up to date, nothing to migrate.", "[ostp]".green().bold());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -1713,6 +1821,10 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
|
||||||
frag_sleep: 2,
|
frag_sleep: 2,
|
||||||
junk_pc: [2, 5],
|
junk_pc: [2, 5],
|
||||||
junk_ps: [100, 1000],
|
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()),
|
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