Release v0.3.18

This commit is contained in:
ospab 2026-06-25 16:55:14 +03:00
parent ad3a8cbe8c
commit 78c7a9e886
21 changed files with 541 additions and 172 deletions

66
Cargo.lock generated
View File

@ -1447,7 +1447,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "ostp"
version = "0.3.16"
version = "0.3.18"
dependencies = [
"anyhow",
"base64",
@ -1467,11 +1467,13 @@ dependencies = [
"tracing",
"tracing-subscriber",
"url",
"windows",
"winres",
]
[[package]]
name = "ostp-client"
version = "0.3.16"
version = "0.3.18"
dependencies = [
"anyhow",
"base64",
@ -1506,7 +1508,7 @@ dependencies = [
[[package]]
name = "ostp-core"
version = "0.3.16"
version = "0.3.18"
dependencies = [
"anyhow",
"byteorder",
@ -1543,7 +1545,7 @@ dependencies = [
[[package]]
name = "ostp-server"
version = "0.3.16"
version = "0.3.18"
dependencies = [
"anyhow",
"axum",
@ -1576,7 +1578,7 @@ dependencies = [
[[package]]
name = "ostp-tun"
version = "0.3.16"
version = "0.3.18"
dependencies = [
"anyhow",
"libc",
@ -1584,11 +1586,12 @@ dependencies = [
"tracing",
"tun",
"winapi",
"windows",
]
[[package]]
name = "ostp-tun-helper"
version = "0.3.16"
version = "0.3.18"
dependencies = [
"anyhow",
"hex",
@ -2912,6 +2915,27 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.62.2"
@ -2925,6 +2949,17 @@ dependencies = [
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@ -2953,6 +2988,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@ -3029,6 +3074,15 @@ dependencies = [
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"

View File

@ -12,7 +12,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
license = "BSL 1.1"
version = "0.3.16"
version = "0.3.18"
[workspace.dependencies]
anyhow = "1.0"

View File

@ -104,8 +104,14 @@ pub struct TransportConfig {
pub resolver: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pubkey: Option<String>,
// Obfuscation
#[serde(default = "default_false")]
pub tcp_fragmentation: bool,
}
fn default_false() -> bool { false }
fn default_transport_mode() -> String { "udp".to_string() }
impl Default for TransportConfig {
@ -115,6 +121,7 @@ impl Default for TransportConfig {
domain: None,
resolver: None,
pubkey: None,
tcp_fragmentation: false,
}
}
}

View File

@ -28,7 +28,7 @@ pub async fn run_client_core(
// TODO: Detect physical interface index for bypassing
let phys_if_for_bypass = None;
let outbound_manager = Arc::new(OutboundManager::new(balancer.clone(), phys_if_for_bypass, None));
let outbound_manager = Arc::new(OutboundManager::new(balancer.clone(), phys_if_for_bypass, None, Some(metrics.clone())));
// When a TUN inbound is present it is the primary one and owns the connected
// state; the SOCKS proxy is then secondary and must not report "connected".

View File

@ -39,8 +39,7 @@ pub async fn run_socks_inbound(
// the TUN inbound owns the connected state — it is set after the device and
// server bypass route are in place — so we must not flip it prematurely.
if is_primary {
metrics.connection_state.store(2, Ordering::Relaxed);
tracing::info!("{} proxy inbound ready on {}, connection state = connected", protocol, bind_addr);
tracing::info!("SOCKS proxy is primary: marking connected");
} else {
tracing::info!("{} proxy inbound ready on {}", protocol, bind_addr);
}

View File

@ -72,6 +72,7 @@ pub async fn run_tun_inbound(
let (mut stack_sink, mut stack_stream) = stack.split();
#[cfg(not(target_os = "android"))]
#[allow(unused_variables)]
let mut _route_guard = None;
@ -187,8 +188,7 @@ pub async fn run_tun_inbound(
// TUN device is up and the default route has been installed inside
// OstpTunInterface::create — the tunnel is now carrying traffic.
metrics.connection_state.store(2, Ordering::Relaxed);
tracing::info!("TUN inbound ready, connection state = connected");
tracing::info!("TUN inbound is primary: marking connected (waiting for bridge)");
// ── TCP Handler ──
let outbound_manager_tcp = outbound_manager.clone();

View File

@ -12,6 +12,7 @@ pub struct OutboundManager {
balancer: Arc<Balancer>,
phys_if_index: Option<u32>,
_phys_if_name: Option<String>,
metrics: Option<Arc<crate::bridge::BridgeMetrics>>,
}
impl OutboundManager {
@ -19,11 +20,13 @@ impl OutboundManager {
balancer: Arc<Balancer>,
phys_if_index: Option<u32>,
phys_if_name: Option<String>,
metrics: Option<Arc<crate::bridge::BridgeMetrics>>,
) -> Self {
Self {
balancer,
phys_if_index,
_phys_if_name: phys_if_name,
metrics,
}
}
@ -39,7 +42,7 @@ impl OutboundManager {
block::dial_tcp(target_host, target_port).await
}
OutboundConfig::Ostp { server, port, access_key, transport, multiplex, .. } => {
ostp::dial_tcp(target_host, target_port, server, *port, access_key, transport, multiplex).await
ostp::dial_tcp(target_host, target_port, server, *port, access_key, transport, multiplex, self.metrics.clone()).await
}
OutboundConfig::Socks { server, port, .. } => {
socks::dial_tcp(target_host, target_port, server, *port).await
@ -66,7 +69,7 @@ impl OutboundManager {
block::handle_udp(client_src, target_dst, payload).await
}
OutboundConfig::Ostp { server, port, access_key, transport, multiplex, .. } => {
ostp::handle_udp(client_src, target_dst, payload, server, *port, access_key, transport, multiplex).await
ostp::handle_udp(client_src, target_dst, payload, server, *port, access_key, transport, multiplex, self.metrics.clone()).await
}
OutboundConfig::Socks { server, port, .. } => {
socks::handle_udp(client_src, target_dst, payload, server, *port).await

View File

@ -45,7 +45,7 @@ fn make_initiator_config(
psk: secrets.psk,
session_id,
handshake_payload: payload,
max_padding: 256,
max_padding: 1024,
padding_strategy: ostp_core::framing::PaddingStrategy::Adaptive,
obfuscation_key: secrets.obfuscation_key,
max_reorder: 16384,
@ -77,6 +77,7 @@ pub async fn dial_tcp(
access_key: &str,
transport_cfg: &TransportConfig,
_multiplex: &MultiplexConfig,
metrics: Option<std::sync::Arc<crate::bridge::BridgeMetrics>>,
) -> Result<TcpStream> {
tracing::info!("Dialing OSTP server {}:{} for target {}:{}", server, port, target_host, target_port);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
@ -122,9 +123,16 @@ pub async fn dial_tcp(
if !handshake_success {
tracing::warn!("TCP handshake failed or protocol machine error");
if let Some(m) = &metrics {
m.connection_state.store(0, std::sync::atomic::Ordering::Relaxed);
}
return;
}
if let Some(m) = &metrics {
m.connection_state.store(2, std::sync::atomic::Ordering::Relaxed);
}
// Send connection request
let connect_msg = ostp_core::relay::RelayMessage::Connect(format!("{}:{}", target_host_str, target_port));
let connect_encoded = connect_msg.encode();
@ -240,6 +248,7 @@ pub async fn handle_udp(
access_key: &str,
transport_cfg: &TransportConfig,
_multiplex: &MultiplexConfig,
metrics: Option<std::sync::Arc<crate::bridge::BridgeMetrics>>,
) -> Result<()> {
let transport = make_transport(transport_cfg, server, port).await?;
@ -259,6 +268,18 @@ pub async fn handle_udp(
let config = make_initiator_config(session_id, access_key, transport_cfg);
let mut machine = ProtocolMachine::new(config)?;
// Send UDP Junk Packets (Amnezia style) to break DPI heuristics
use rand::Rng;
let num_junk = rand::thread_rng().gen_range(2..=5);
for _ in 0..num_junk {
let junk_len = rand::thread_rng().gen_range(100..=1000);
let mut junk = vec![0u8; junk_len];
rand::thread_rng().fill(&mut junk[..]);
let junk_bytes = bytes::Bytes::from(junk);
let _ = transport.send(&junk_bytes).await;
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
// Send handshake first
if let Ok(action) = machine.on_event(OstpEvent::Start) {
handle_udp_action(action, &transport).await;
@ -272,9 +293,15 @@ pub async fn handle_udp(
).await {
Ok(Ok(n)) => {
let _ = machine.on_event(OstpEvent::Inbound(bytes::Bytes::copy_from_slice(&buf[..n])));
if let Some(m) = &metrics {
m.connection_state.store(2, std::sync::atomic::Ordering::Relaxed);
}
}
_ => {
tracing::warn!("OSTP handshake timeout for {}:{}", server, port);
if let Some(m) = &metrics {
m.connection_state.store(0, std::sync::atomic::Ordering::Relaxed);
}
return Ok(());
}
}
@ -376,6 +403,62 @@ async fn make_transport(
_guard: std::sync::Arc::new(tokio::sync::Mutex::new(process)),
})
}
"uot" | "tcp" => {
let stream = tokio::net::TcpStream::connect((server, port)).await?;
let _ = stream.set_nodelay(true);
let (mut rh, mut wh) = stream.into_split();
let (tx_send, mut tx_recv) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
let (rx_send, rx_recv) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
let tcp_fragmentation = transport_cfg.tcp_fragmentation;
// Writer task
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
let mut first_packet = true;
while let Some(data) = tx_recv.recv().await {
let mut len_buf = [0u8; 2];
len_buf.copy_from_slice(&(data.len() as u16).to_be_bytes());
if first_packet && tcp_fragmentation {
first_packet = false;
// Split the length header and first byte of payload
if wh.write_all(&len_buf[0..1]).await.is_err() { break; }
tokio::time::sleep(tokio::time::Duration::from_millis(5)).await;
if wh.write_all(&len_buf[1..2]).await.is_err() { break; }
tokio::time::sleep(tokio::time::Duration::from_millis(5)).await;
// Send data in 1-2 byte chunks for the first packet (handshake)
for chunk in data.chunks(2) {
if wh.write_all(chunk).await.is_err() { break; }
tokio::time::sleep(tokio::time::Duration::from_millis(2)).await;
}
} else {
if wh.write_all(&len_buf).await.is_err() { break; }
if wh.write_all(&data).await.is_err() { break; }
}
}
});
// Reader task
tokio::spawn(async move {
use tokio::io::AsyncReadExt;
loop {
let mut len_buf = [0u8; 2];
if rh.read_exact(&mut len_buf).await.is_err() { break; }
let len = u16::from_be_bytes(len_buf) as usize;
let mut buf = vec![0u8; len];
if rh.read_exact(&mut buf).await.is_err() { break; }
if rx_send.send(bytes::Bytes::from(buf)).await.is_err() { break; }
}
});
Ok(crate::transport::Transport::Uot {
tx: tx_send,
rx: std::sync::Arc::new(tokio::sync::Mutex::new(rx_recv)),
})
}
_ => {
let udp = tokio::net::UdpSocket::bind("0.0.0.0:0").await?;
udp.connect((server, port)).await?;

View File

@ -115,6 +115,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
"mode": transportMode,
"stealth_sni": stealthSni,
"wss": wss,
"tcp_fragmentation": widget.prefs.getBool('tcp_fragmentation') ?? false,
},
"multiplex": {
"enabled": muxEnabled,
@ -203,6 +204,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
"mode": transportMode,
"stealth_sni": stealthSni,
"wss": wss,
"tcp_fragmentation": widget.prefs.getBool('tcp_fragmentation') ?? false,
},
"multiplex": {
"enabled": muxEnabled,
@ -521,6 +523,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
Center(
child: Opacity(
opacity: theme.brightness == Brightness.dark ? 0.05 : 0.06,
child: Platform.isIOS
? ClipRRect(
borderRadius: BorderRadius.circular(32),
child: SvgPicture.asset(
'assets/logo.svg',
width: MediaQuery.of(context).size.width * 0.8,
@ -529,6 +534,17 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
? const ColorFilter.mode(Colors.black, BlendMode.srcIn)
: null,
),
)
: ClipOval(
child: SvgPicture.asset(
'assets/logo.svg',
width: MediaQuery.of(context).size.width * 0.8,
fit: BoxFit.contain,
colorFilter: theme.brightness == Brightness.light
? const ColorFilter.mode(Colors.black, BlendMode.srcIn)
: null,
),
),
),
),

View File

@ -45,15 +45,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
late TextEditingController _muxSessionsCtrl;
bool _isCheckingUpdates = false;
bool _tcpFragmentation = false;
@override
void initState() {
super.initState();
_importCtrl = TextEditingController();
_serverCtrl = TextEditingController(text: widget.prefs.getString('server_addr') ?? '127.0.0.1:443');
_loadSettings();
}
void _loadSettings() {
_serverCtrl = TextEditingController(text: widget.prefs.getString('server_addr') ?? '');
_localBindCtrl = TextEditingController(text: widget.prefs.getString('local_bind') ?? '127.0.0.1:1088');
_keyCtrl = TextEditingController(text: widget.prefs.getString('access_key') ?? '');
_dnsCtrl = TextEditingController(text: widget.prefs.getString('dns_server') ?? '1.1.1.1');
_dnsCtrl = TextEditingController(text: widget.prefs.getString('dns_server') ?? '');
_mtuCtrl = TextEditingController(text: widget.prefs.getString('mtu') ?? '1140');
_transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
_tcpFragmentation = widget.prefs.getBool('tcp_fragmentation') ?? false;
_domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? '');
_ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? '');
_processesCtrl = TextEditingController(text: widget.prefs.getString('ex_processes') ?? '');
@ -61,7 +69,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_dnsRegionCtrl = TextEditingController(text: widget.prefs.getString('dns_region') ?? '1.1.1.1');
_pbkCtrl = TextEditingController(text: widget.prefs.getString('tun_pbk') ?? '');
_sidCtrl = TextEditingController(text: widget.prefs.getString('sid') ?? '');
_transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
_tunStack = widget.prefs.getString('tun_stack') ?? 'ostp';
_debugMode = widget.prefs.getBool('debug_mode') ?? false;
_muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
@ -94,11 +101,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
widget.prefs.setString('access_key', _keyCtrl.text.trim());
widget.prefs.setString('dns_server', _dnsCtrl.text.trim());
widget.prefs.setString('mtu', _mtuCtrl.text.trim());
widget.prefs.setString('transport_mode', _transportMode);
widget.prefs.setBool('tcp_fragmentation', _tcpFragmentation);
widget.prefs.setString('ex_domains', _domainsCtrl.text.trim());
widget.prefs.setString('ex_ips', _ipsCtrl.text.trim());
widget.prefs.setString('ex_processes', _processesCtrl.text.trim());
widget.prefs.setBool('debug_mode', _debugMode);
widget.prefs.setString('transport_mode', _transportMode);
widget.prefs.setString('tun_stack', _tunStack);
widget.prefs.setString('dns_domain', _dnsDomainCtrl.text.trim());
widget.prefs.setString('dns_region', _dnsRegionCtrl.text.trim());
@ -254,6 +262,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
child: const Text('Import', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.black)),
@ -302,10 +311,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
value: 'uot',
groupValue: _transportMode,
title: const Text('UoT (UDP-over-TCP)', style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: const Text('Works reliably on mobile networks and strict firewalls', style: TextStyle(color: Colors.white54, fontSize: 12)),
subtitle: const Text('Reliable on strict networks. Enables TCP DPI bypass.', style: TextStyle(color: Colors.white54, fontSize: 12)),
activeColor: Theme.of(context).colorScheme.primary,
onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }),
),
if (_transportMode == 'uot')
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 8.0, bottom: 8.0),
child: SwitchListTile(
title: const Text('TCP Fragmentation', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
subtitle: const Text('Bypass DPI by chunking handshake (Zapret style)', style: TextStyle(fontSize: 12, color: Colors.white54)),
value: _tcpFragmentation,
activeColor: Theme.of(context).colorScheme.primary,
onChanged: (v) => setState(() { _tcpFragmentation = v; _saveSettings(); }),
),
),
Divider(color: Colors.white.withOpacity(0.05), height: 1),
RadioListTile<String>(
value: 'dns',
@ -339,7 +359,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [
const Icon(Icons.dns, size: 16, color: Colors.orangeAccent),
const SizedBox(width: 8),
const Text('DNS Proxy Settings', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orangeAccent, fontSize: 14)),
const Text('DNS Tunnel Settings', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orangeAccent, fontSize: 14)),
],
),
const SizedBox(height: 4),
@ -585,6 +605,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
label: const Text('Copy Link', style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.3.16+29
version: 0.3.18+30
environment:
sdk: ^3.11.4

View File

@ -2665,7 +2665,7 @@ dependencies = [
[[package]]
name = "ostp-client"
version = "0.3.12"
version = "0.3.17"
dependencies = [
"anyhow",
"base64 0.22.1",
@ -2700,7 +2700,7 @@ dependencies = [
[[package]]
name = "ostp-core"
version = "0.3.12"
version = "0.3.17"
dependencies = [
"anyhow",
"byteorder",
@ -2742,7 +2742,7 @@ dependencies = [
[[package]]
name = "ostp-tun"
version = "0.3.12"
version = "0.3.17"
dependencies = [
"anyhow",
"libc",
@ -2750,6 +2750,7 @@ dependencies = [
"tracing",
"tun",
"winapi",
"windows 0.62.2",
]
[[package]]
@ -3867,7 +3868,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@ -3938,7 +3939,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows",
"windows 0.61.3",
]
[[package]]
@ -4037,7 +4038,7 @@ dependencies = [
"tauri-plugin",
"thiserror 2.0.18",
"url",
"windows",
"windows 0.61.3",
"zbus",
]
@ -4063,7 +4064,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
]
[[package]]
@ -4088,7 +4089,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
"wry",
]
@ -5015,7 +5016,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
@ -5039,7 +5040,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
]
@ -5095,11 +5096,23 @@ version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-collections 0.2.0",
"windows-core 0.61.2",
"windows-future",
"windows-future 0.2.1",
"windows-link 0.1.3",
"windows-numerics",
"windows-numerics 0.2.0",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections 0.3.2",
"windows-core 0.62.2",
"windows-future 0.3.2",
"windows-numerics 0.3.1",
]
[[package]]
@ -5111,6 +5124,15 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.61.2"
@ -5145,7 +5167,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading",
"windows-threading 0.1.0",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
"windows-threading 0.2.1",
]
[[package]]
@ -5192,6 +5225,16 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@ -5295,6 +5338,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-version"
version = "0.1.7"
@ -5580,7 +5632,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ostp-gui",
"version": "0.3.16",
"version": "0.3.18",
"identifier": "com.ospab.ostp",
"build": {
"frontendDist": "../src"

View File

@ -1,31 +1,29 @@
// build.rs for ostp-tun-helper
// Embeds a Windows manifest that requests Administrator privileges.
// This makes Windows show a UAC prompt when the binary is double-clicked
// or launched via ShellExecuteW("runas").
fn main() {
#[cfg(windows)]
#[cfg(target_os = "windows")]
{
let mut res = winres::WindowsResource::new();
res.set_icon("..\\ostp-gui\\src-tauri\\icons\\icon.ico");
res.set("ProductName", "OSTP Core");
res.set("FileDescription", "OSTP Tunnel Helper");
res.set("CompanyName", "Ospab Foundation");
res.set("LegalCopyright", "Copyright (c) 2026 Ospab Foundation");
// This manifest explicitly requests administrator privileges, which triggers
// UAC when the helper is run directly. The GUI launches it as admin anyway,
// but this ensures it always runs elevated.
res.set_manifest(r#"
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls"
version="6.0.0.0" processorArchitecture="*"
publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
</assembly>
"#);
res.compile().expect("failed to compile Windows resources");
if let Err(e) = res.compile() {
println!("cargo:warning=Failed to compile Windows resources: {}", e);
}
}
}

View File

@ -12,6 +12,7 @@ tun = { version = "0.8.9", features = ["async"] }
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3.9", features = ["iphlpapi", "tcpmib", "processthreadsapi", "psapi", "handleapi", "winerror", "minwindef", "winnt", "iptypes", "ws2def"] }
windows = { version = "0.62.2", features = ["Win32_NetworkManagement_WindowsFirewall", "Win32_NetworkManagement_IpHelper", "Win32_System_Com", "Win32_Foundation", "Win32_Security", "Win32_Networking_WinSock", "Win32_NetworkManagement_Ndis"] }
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"

144
ostp-tun/src/sys_win_api.rs Normal file
View File

@ -0,0 +1,144 @@
use anyhow::Result;
use windows::core::{BSTR, GUID};
use windows::Win32::Foundation::{ERROR_SUCCESS, WIN32_ERROR};
use windows::Win32::NetworkManagement::WindowsFirewall::{
INetFwPolicy2, INetFwRule, NetFwPolicy2, NetFwRule, NET_FW_ACTION_ALLOW,
NET_FW_PROFILE2_ALL, NET_FW_RULE_DIR_IN, NET_FW_RULE_DIR_OUT,
};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED,
};
use windows::Win32::NetworkManagement::IpHelper::{
CreateIpForwardEntry, DeleteIpForwardEntry, MIB_IPFORWARDROW,
};
use std::net::Ipv4Addr;
fn init_com() -> Result<()> {
unsafe {
let hr = CoInitializeEx(None, COINIT_MULTITHREADED);
if hr.is_err() && hr.0 != windows::Win32::Foundation::RPC_E_CHANGED_MODE.0 {
return Err(anyhow::anyhow!("CoInitializeEx failed: {}", hr));
}
}
Ok(())
}
pub fn add_firewall_rules(exe_path: &str) -> Result<()> {
init_com()?;
unsafe {
let policy: INetFwPolicy2 = CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER)?;
let rules = policy.Rules()?;
// Rule IN
let rule_in: INetFwRule = CoCreateInstance(&NetFwRule, None, CLSCTX_INPROC_SERVER)?;
rule_in.SetName(&BSTR::from("OSTP Tunnel In"))?;
rule_in.SetApplicationName(&BSTR::from(exe_path))?;
rule_in.SetAction(NET_FW_ACTION_ALLOW)?;
rule_in.SetDirection(NET_FW_RULE_DIR_IN)?;
rule_in.SetProfiles(NET_FW_PROFILE2_ALL.0)?;
rules.Add(&rule_in)?;
// Rule OUT
let rule_out: INetFwRule = CoCreateInstance(&NetFwRule, None, CLSCTX_INPROC_SERVER)?;
rule_out.SetName(&BSTR::from("OSTP Tunnel Out"))?;
rule_out.SetApplicationName(&BSTR::from(exe_path))?;
rule_out.SetAction(NET_FW_ACTION_ALLOW)?;
rule_out.SetDirection(NET_FW_RULE_DIR_OUT)?;
rule_out.SetProfiles(NET_FW_PROFILE2_ALL.0)?;
rules.Add(&rule_out)?;
}
Ok(())
}
pub fn remove_firewall_rules() -> Result<()> {
init_com()?;
unsafe {
let policy: INetFwPolicy2 = CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER)?;
let rules = policy.Rules()?;
let _ = rules.Remove(&BSTR::from("OSTP Tunnel In"));
let _ = rules.Remove(&BSTR::from("OSTP Tunnel Out"));
}
Ok(())
}
// Minimal implementation of Kill Switch (blackhole route to 127.0.0.1) using WinAPI
pub fn set_kill_switch_route(enable: bool) -> Result<()> {
// 0.0.0.0/0 -> 127.0.0.1 with metric 10 and if_index 1 (Loopback)
let mut row: MIB_IPFORWARDROW = unsafe { std::mem::zeroed() };
row.dwForwardDest = 0;
row.dwForwardMask = 0;
row.dwForwardPolicy = 0;
row.dwForwardNextHop = u32::from_ne_bytes(Ipv4Addr::new(127, 0, 0, 1).octets());
row.dwForwardIfIndex = 1; // Loopback interface
row.Anonymous1.dwForwardType = 3; // MIB_IPROUTE_TYPE_INDIRECT
row.Anonymous2.dwForwardProto = 3; // MIB_IPPROTO_NETMGMT
row.dwForwardAge = 0;
row.dwForwardNextHopAS = 0;
row.dwForwardMetric1 = 10;
row.dwForwardMetric2 = !0;
row.dwForwardMetric3 = !0;
row.dwForwardMetric4 = !0;
row.dwForwardMetric5 = !0;
unsafe {
if enable {
let res = WIN32_ERROR(CreateIpForwardEntry(&row));
if res != ERROR_SUCCESS && res != windows::Win32::Foundation::ERROR_OBJECT_ALREADY_EXISTS {
return Err(anyhow::anyhow!("CreateIpForwardEntry failed: {}", res.0));
}
} else {
let res = WIN32_ERROR(DeleteIpForwardEntry(&row));
if res != ERROR_SUCCESS && res != windows::Win32::Foundation::ERROR_NOT_FOUND {
return Err(anyhow::anyhow!("DeleteIpForwardEntry failed: {}", res.0));
}
}
}
Ok(())
}
// DNS setting using WMI (requires wmi crate) or IP Helper API.
// SetInterfaceDnsSettings was added in 1607, let's use it.
pub fn set_dns_servers(adapter_luid: u64, dns: &str) -> Result<()> {
use windows::Win32::NetworkManagement::IpHelper::{
SetInterfaceDnsSettings, DNS_INTERFACE_SETTINGS,
};
use std::os::windows::ffi::OsStrExt;
let dns_wstr: Vec<u16> = std::ffi::OsStr::new(dns)
.encode_wide()
.chain(Some(0))
.collect();
let mut settings = DNS_INTERFACE_SETTINGS {
Version: 1, // DNS_INTERFACE_SETTINGS_VERSION1
Flags: 1, // DNS_SETTING_IPV4
Domain: windows::core::PWSTR::null(),
NameServer: windows::core::PWSTR::from_raw(dns_wstr.as_ptr() as *mut _),
SearchList: windows::core::PWSTR::null(),
RegistrationEnabled: 0,
RegisterAdapterName: 0,
EnableLLMNR: 0,
QueryAdapterName: 0,
ProfileNameServer: windows::core::PWSTR::null(),
};
let luid = windows::Win32::NetworkManagement::Ndis::NET_LUID_LH { Value: adapter_luid };
let guid = GUID::zeroed(); // We can pass zeroed GUID and just use LUID? Wait, SetInterfaceDnsSettings requires GUID.
// Actually, setting DNS via SetInterfaceDnsSettings requires the interface GUID, which we can get from ConvertInterfaceLuidToGuid.
unsafe {
let mut if_guid = GUID::zeroed();
let err = windows::Win32::NetworkManagement::IpHelper::ConvertInterfaceLuidToGuid(&luid, &mut if_guid);
if err != ERROR_SUCCESS {
return Err(anyhow::anyhow!("ConvertInterfaceLuidToGuid failed: {}", err.0));
}
let err = SetInterfaceDnsSettings(if_guid, &settings);
if err != ERROR_SUCCESS {
return Err(anyhow::anyhow!("SetInterfaceDnsSettings failed: {}", err.0));
}
}
Ok(())
}

View File

@ -1,12 +1,14 @@
use crate::{OstpTunInterface, OstpTunOptions};
use anyhow::{anyhow, Result};
use std::process::Command;
use std::os::windows::process::CommandExt;
use tun::AbstractDeviceExt;
pub mod windows_route {
include!("windows_route.rs");
}
#[path = "sys_win_api.rs"]
mod sys_win_api;
struct WindowsRouteGuard {
bypass_routes: Vec<(std::net::Ipv4Addr, std::net::Ipv4Addr, u32)>,
kill_switch: bool,
@ -14,33 +16,14 @@ struct WindowsRouteGuard {
impl Drop for WindowsRouteGuard {
fn drop(&mut self) {
const CREATE_NO_WINDOW: u32 = 0x08000000;
windows_route::sys::remove_bypass_routes(&self.bypass_routes);
tracing::info!("Removed {} bypass routes.", self.bypass_routes.len());
let is_kill_switch = self.kill_switch;
let _ = std::thread::spawn(move || {
let _ = Command::new("netsh")
.creation_flags(CREATE_NO_WINDOW)
.args(["advfirewall", "firewall", "delete", "rule", "name=OSTP Tunnel In"])
.output();
let _ = Command::new("netsh")
.creation_flags(CREATE_NO_WINDOW)
.args(["advfirewall", "firewall", "delete", "rule", "name=OSTP Tunnel Out"])
.output();
let _ = Command::new("netsh")
.creation_flags(CREATE_NO_WINDOW)
.args(["interface", "ipv4", "set", "dnsservers",
"name=ostp_tun", "source=dhcp"])
.output();
if is_kill_switch {
let _ = Command::new("route")
.creation_flags(CREATE_NO_WINDOW)
.args(["delete", "0.0.0.0", "mask", "0.0.0.0", "127.0.0.1"])
.output();
let _ = sys_win_api::remove_firewall_rules();
if self.kill_switch {
let _ = sys_win_api::set_kill_switch_route(false);
}
});
}
}
@ -83,18 +66,8 @@ pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
}
}
// Clean up any stale Wintun adapters matching our name prefix. This prevents
// Wintun from creating "ostp_tun 2" (which violates the strict naming requirement
// and causes the 15-second interface index lookup timeout below).
tracing::info!("Cleaning up any stale 'ostp_tun*' adapters...");
let _ = std::process::Command::new("powershell")
.creation_flags(0x08000000)
.args([
"-NoProfile",
"-Command",
"try { Get-NetAdapter -Name 'ostp_tun*' -ErrorAction Stop | Remove-NetAdapter -Confirm:$false -ErrorAction SilentlyContinue } catch {}"
])
.output();
// No need to call powershell to clean up adapters, WinTun handles it well
// if we don't try to use friendly-name matching.
let mut tun_cfg = tun::Configuration::default();
tun_cfg
@ -136,88 +109,34 @@ pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
}
}
};
let luid = dev.tun_luid();
let dev = tun::AsyncDevice::new(dev).map_err(|e| anyhow!("TUN device async failed: {}", e))?;
tracing::info!("TUN device 'ostp_tun' created.");
let name_owned = "ostp_tun".to_string();
// We rely entirely on the LUID-based route established by the `tun` crate's `.destination()`.
// It is instant and reliable. The fallback polling loop has been removed for instant startup.
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
// A freshly created WinTun adapter can take several seconds to appear in
// GetAdaptersAddresses (it only shows up once it has an operational IPv4
// binding). The default route via the TUN is what actually captures
// traffic. The `tun` crate already added an LUID-based route which works
// instantly, but we add a secondary index-based route for robustness.
// We run this in the background so it doesn't block tunnel startup.
tokio::spawn(async move {
let mut tun_index = None;
for _ in 0..75 {
if let Some(idx) = windows_route::sys::get_interface_index(&name_owned) {
tun_index = Some(idx);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
if let Some(idx) = tun_index {
match windows_route::sys::add_ipv4_route(
std::net::Ipv4Addr::new(0, 0, 0, 0),
std::net::Ipv4Addr::new(0, 0, 0, 0),
std::net::Ipv4Addr::new(10, 1, 0, 1),
idx,
5,
) {
Ok(()) => tracing::info!("Default route via TUN (if_index={idx}, metric=5) added."),
Err(e) => tracing::error!("Failed to add default route via TUN (if_index={idx}): {e} — traffic will NOT be captured."),
}
} else {
tracing::warn!("Could not find '{}' index in routing table after 15s — fallback route not installed.", name_owned);
}
});
let exe1 = current_exe.clone();
let exe2 = current_exe.clone();
let _ = tokio::task::spawn_blocking(move || {
let _ = Command::new("netsh")
.creation_flags(CREATE_NO_WINDOW)
.args(["advfirewall", "firewall", "add", "rule",
"name=OSTP Tunnel In", "dir=in", "action=allow",
&format!("program={}", exe1)])
.output();
let _ = Command::new("netsh")
.creation_flags(CREATE_NO_WINDOW)
.args(["advfirewall", "firewall", "add", "rule",
"name=OSTP Tunnel Out", "dir=out", "action=allow",
&format!("program={}", exe2)])
.output();
let _ = Command::new("netsh")
.creation_flags(CREATE_NO_WINDOW)
.args(["interface", "ipv4", "set", "interface", "name=ostp_tun",
"routerdiscovery=disabled", "dadtransmits=0",
"managedaddress=disabled", "otherstateful=disabled"])
.output();
if let Err(e) = sys_win_api::add_firewall_rules(&exe1) {
tracing::warn!("Failed to add firewall rules via WinAPI: {}", e);
}
});
if let Some(ref dns) = opts.dns_server {
if !dns.is_empty() {
let dns_clone = dns.clone();
let _ = tokio::task::spawn_blocking(move || {
let _ = Command::new("netsh")
.creation_flags(CREATE_NO_WINDOW)
.args(["interface", "ipv4", "set", "dnsservers",
"name=ostp_tun", "static", &dns_clone, "primary"])
.output();
if let Err(e) = sys_win_api::set_dns_servers(luid, &dns_clone) {
tracing::warn!("Failed to set DNS via WinAPI: {}", e);
}
});
}
}
if opts.kill_switch {
tracing::info!("Kill Switch enabled: Adding metric 10 blackhole route to prevent leakage");
let _ = tokio::task::spawn_blocking(move || {
let _ = Command::new("route")
.creation_flags(CREATE_NO_WINDOW)
.args(["add", "0.0.0.0", "mask", "0.0.0.0", "127.0.0.1", "metric", "10", "if", "1"])
.output();
});
let _ = sys_win_api::set_kill_switch_route(true);
}
Ok(OstpTunInterface {

View File

@ -24,3 +24,9 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
pico-args = "0.5.0"
clipboard-win = "3.1.1"
[target."cfg(windows)".build-dependencies]
winres = "0.1.12"
[target."cfg(windows)".dependencies]
windows = { version = "0.62.2", features = ["Win32_Security", "Win32_UI_Shell", "Win32_Foundation"] }

14
ostp/build.rs Normal file
View File

@ -0,0 +1,14 @@
fn main() {
#[cfg(target_os = "windows")]
{
let mut res = winres::WindowsResource::new();
res.set_icon("..\\ostp-gui\\src-tauri\\icons\\icon.ico");
res.set("ProductName", "OSTP Core");
res.set("FileDescription", "OSTP CLI");
res.set("CompanyName", "Ospab Foundation");
res.set("LegalCopyright", "Copyright (c) 2026 Ospab Foundation");
if let Err(e) = res.compile() {
println!("cargo:warning=Failed to compile Windows resources: {}", e);
}
}
}

View File

@ -2030,6 +2030,11 @@ async fn run_client_directly(client_cfg: serde_json::Value) -> Result<()> {
let mode_str = if is_tun_enabled { "tun" } else { "proxy" };
println!("{} Starting client (mode={})", "[ostp]".cyan().bold(), mode_str.yellow());
#[cfg(target_os = "windows")]
if is_tun_enabled {
ensure_elevated_for_tun()?;
}
// Run the client implementation
let (_shutdown_tx, rx) = tokio::sync::watch::channel(false);
let metrics = std::sync::Arc::new(ostp_client::bridge::BridgeMetrics::default());
@ -2039,6 +2044,53 @@ async fn run_client_directly(client_cfg: serde_json::Value) -> Result<()> {
Ok(())
}
#[cfg(target_os = "windows")]
fn ensure_elevated_for_tun() -> Result<()> {
#[link(name = "shell32")]
extern "system" {
fn IsUserAnAdmin() -> i32;
fn ShellExecuteW(h: *mut std::ffi::c_void, op: *const u16, f: *const u16, p: *const u16, d: *const u16, s: i32) -> isize;
}
let is_admin = unsafe { IsUserAnAdmin() != 0 };
if is_admin {
return Ok(());
}
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
let exe = std::env::current_exe()?;
let exe_wstr: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
let verb_wstr: Vec<u16> = OsStr::new("runas").encode_wide().chain(Some(0)).collect();
// Reconstruct arguments
let args: Vec<String> = std::env::args().skip(1).collect();
let params_str = args.iter().map(|s| format!("\"{}\"", s)).collect::<Vec<_>>().join(" ");
let params_wstr: Vec<u16> = OsStr::new(&params_str).encode_wide().chain(Some(0)).collect();
let cwd = std::env::current_dir()?;
let cwd_wstr: Vec<u16> = cwd.as_os_str().encode_wide().chain(Some(0)).collect();
println!("{}", "[ostp] TUN mode requires administrator privileges. Requesting elevation...".yellow());
let ret = unsafe {
ShellExecuteW(
std::ptr::null_mut(),
verb_wstr.as_ptr(),
exe_wstr.as_ptr(),
params_wstr.as_ptr(),
cwd_wstr.as_ptr(),
1, // SW_SHOWNORMAL
)
};
if ret <= 32 {
anyhow::bail!("UAC elevation was denied or failed. Please run as Administrator.");
}
std::process::exit(0);
}
fn cmd_migrate(config_path: &std::path::Path) -> Result<()> {
if !config_path.exists() {
anyhow::bail!("Configuration file not found at {:?}", config_path);