diff --git a/Cargo.lock b/Cargo.lock index 9142314..035f475 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 2b41f74..479fc9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/ostp-client/src/config.rs b/ostp-client/src/config.rs index a6d1809..1cd26e9 100644 --- a/ostp-client/src/config.rs +++ b/ostp-client/src/config.rs @@ -104,8 +104,14 @@ pub struct TransportConfig { pub resolver: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub pubkey: Option, + + // 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, } } } diff --git a/ostp-client/src/runner.rs b/ostp-client/src/runner.rs index 6e3e36d..df9a8bc 100644 --- a/ostp-client/src/runner.rs +++ b/ostp-client/src/runner.rs @@ -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". diff --git a/ostp-client/src/tunnel/inbounds/local_proxy.rs b/ostp-client/src/tunnel/inbounds/local_proxy.rs index 5c42535..6e2def6 100644 --- a/ostp-client/src/tunnel/inbounds/local_proxy.rs +++ b/ostp-client/src/tunnel/inbounds/local_proxy.rs @@ -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); } diff --git a/ostp-client/src/tunnel/inbounds/tun.rs b/ostp-client/src/tunnel/inbounds/tun.rs index 9ab7cd3..c3809de 100644 --- a/ostp-client/src/tunnel/inbounds/tun.rs +++ b/ostp-client/src/tunnel/inbounds/tun.rs @@ -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(); diff --git a/ostp-client/src/tunnel/outbounds/mod.rs b/ostp-client/src/tunnel/outbounds/mod.rs index 26fcbef..d5e073c 100644 --- a/ostp-client/src/tunnel/outbounds/mod.rs +++ b/ostp-client/src/tunnel/outbounds/mod.rs @@ -12,6 +12,7 @@ pub struct OutboundManager { balancer: Arc, phys_if_index: Option, _phys_if_name: Option, + metrics: Option>, } impl OutboundManager { @@ -19,11 +20,13 @@ impl OutboundManager { balancer: Arc, phys_if_index: Option, phys_if_name: Option, + metrics: Option>, ) -> 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 diff --git a/ostp-client/src/tunnel/outbounds/ostp.rs b/ostp-client/src/tunnel/outbounds/ostp.rs index d168d27..86d0749 100644 --- a/ostp-client/src/tunnel/outbounds/ostp.rs +++ b/ostp-client/src/tunnel/outbounds/ostp.rs @@ -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>, ) -> Result { 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>, ) -> 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::(1024); + let (rx_send, rx_recv) = tokio::sync::mpsc::channel::(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?; diff --git a/ostp-flutter/lib/ui/home_screen.dart b/ostp-flutter/lib/ui/home_screen.dart index 02bd3c3..7ed08a3 100644 --- a/ostp-flutter/lib/ui/home_screen.dart +++ b/ostp-flutter/lib/ui/home_screen.dart @@ -115,6 +115,7 @@ class _HomeScreenState extends State 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 with TickerProviderStateMixin { "mode": transportMode, "stealth_sni": stealthSni, "wss": wss, + "tcp_fragmentation": widget.prefs.getBool('tcp_fragmentation') ?? false, }, "multiplex": { "enabled": muxEnabled, @@ -521,14 +523,28 @@ class _HomeScreenState extends State with TickerProviderStateMixin { Center( child: Opacity( opacity: theme.brightness == Brightness.dark ? 0.05 : 0.06, - 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, - ), + child: Platform.isIOS + ? ClipRRect( + borderRadius: BorderRadius.circular(32), + 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, + ), + ) + : 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, + ), + ), ), ), diff --git a/ostp-flutter/lib/ui/settings_screen.dart b/ostp-flutter/lib/ui/settings_screen.dart index d88fb5f..8615e90 100644 --- a/ostp-flutter/lib/ui/settings_screen.dart +++ b/ostp-flutter/lib/ui/settings_screen.dart @@ -45,15 +45,23 @@ class _SettingsScreenState extends State { 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 { _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 { 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 { 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 { 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( value: 'dns', @@ -339,7 +359,7 @@ class _SettingsScreenState extends State { 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 { 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)), ), diff --git a/ostp-flutter/pubspec.yaml b/ostp-flutter/pubspec.yaml index 1f06d71..d066fdb 100644 --- a/ostp-flutter/pubspec.yaml +++ b/ostp-flutter/pubspec.yaml @@ -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 diff --git a/ostp-gui/package.json b/ostp-gui/package.json index 187f947..84b042f 100644 --- a/ostp-gui/package.json +++ b/ostp-gui/package.json @@ -13,4 +13,4 @@ "devDependencies": { "@tauri-apps/cli": "^2" } -} \ No newline at end of file +} diff --git a/ostp-gui/src-tauri/Cargo.lock b/ostp-gui/src-tauri/Cargo.lock index 7691f72..b4f9620 100644 --- a/ostp-gui/src-tauri/Cargo.lock +++ b/ostp-gui/src-tauri/Cargo.lock @@ -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", diff --git a/ostp-gui/src-tauri/tauri.conf.json b/ostp-gui/src-tauri/tauri.conf.json index 074fd76..724f6f2 100644 --- a/ostp-gui/src-tauri/tauri.conf.json +++ b/ostp-gui/src-tauri/tauri.conf.json @@ -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" @@ -32,4 +32,4 @@ "icons/icon.ico" ] } -} \ No newline at end of file +} diff --git a/ostp-tun-helper/build.rs b/ostp-tun-helper/build.rs index 2426eb1..f4bd462 100644 --- a/ostp-tun-helper/build.rs +++ b/ostp-tun-helper/build.rs @@ -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#" - - - - - - - - - - - - - + + + + + + + "#); - res.compile().expect("failed to compile Windows resources"); + if let Err(e) = res.compile() { + println!("cargo:warning=Failed to compile Windows resources: {}", e); + } } } diff --git a/ostp-tun/Cargo.toml b/ostp-tun/Cargo.toml index edd2ce0..48a64a6 100644 --- a/ostp-tun/Cargo.toml +++ b/ostp-tun/Cargo.toml @@ -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" diff --git a/ostp-tun/src/sys_win_api.rs b/ostp-tun/src/sys_win_api.rs new file mode 100644 index 0000000..b8234ac --- /dev/null +++ b/ostp-tun/src/sys_win_api.rs @@ -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 = 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(()) +} diff --git a/ostp-tun/src/windows.rs b/ostp-tun/src/windows.rs index 70d1eda..9c410bb 100644 --- a/ostp-tun/src/windows.rs +++ b/ostp-tun/src/windows.rs @@ -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 { } } - // 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 { } } }; + 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 { diff --git a/ostp/Cargo.toml b/ostp/Cargo.toml index 320c7c6..f395529 100644 --- a/ostp/Cargo.toml +++ b/ostp/Cargo.toml @@ -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"] } + diff --git a/ostp/build.rs b/ostp/build.rs new file mode 100644 index 0000000..bdc377e --- /dev/null +++ b/ostp/build.rs @@ -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); + } + } +} diff --git a/ostp/src/main.rs b/ostp/src/main.rs index e59e984..5c0f9e3 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -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 = exe.as_os_str().encode_wide().chain(Some(0)).collect(); + let verb_wstr: Vec = OsStr::new("runas").encode_wide().chain(Some(0)).collect(); + + // Reconstruct arguments + let args: Vec = std::env::args().skip(1).collect(); + let params_str = args.iter().map(|s| format!("\"{}\"", s)).collect::>().join(" "); + let params_wstr: Vec = OsStr::new(¶ms_str).encode_wide().chain(Some(0)).collect(); + + let cwd = std::env::current_dir()?; + let cwd_wstr: Vec = 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);