feat: implement wintun dynamic downloading, add missing driver frontend modal, fix background logging and UAC helper issues

This commit is contained in:
ospab 2026-06-09 01:01:36 +03:00
parent 60282d730f
commit 04c31c7f53
26 changed files with 1936 additions and 332 deletions

95
Cargo.lock generated
View File

@ -432,6 +432,15 @@ dependencies = [
"libc",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@ -525,6 +534,15 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
@ -1331,6 +1349,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-traits"
version = "0.2.19"
@ -1396,6 +1420,7 @@ dependencies = [
"libc",
"netstack-smoltcp",
"ostp-core",
"ostp-tun",
"portable-atomic",
"rand 0.8.5",
"serde",
@ -1404,6 +1429,8 @@ dependencies = [
"socket2",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"tun",
"webpki-roots 0.26.11",
"winapi",
@ -1476,6 +1503,18 @@ dependencies = [
"x25519-dalek",
]
[[package]]
name = "ostp-tun"
version = "0.2.86"
dependencies = [
"anyhow",
"libc",
"tokio",
"tracing",
"tun",
"winapi",
]
[[package]]
name = "ostp-tun-helper"
version = "0.2.86"
@ -1557,6 +1596,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@ -2119,6 +2164,12 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]]
name = "syn"
version = "2.0.117"
@ -2199,6 +2250,37 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.3"
@ -2341,6 +2423,19 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"

View File

@ -5,7 +5,7 @@ members = [
"ostp-server",
"ostp-jni", "ostp",
"ostp-tun-helper"
]
, "ostp-tun"]
exclude = ["ostp-gui/src-tauri", "ostp-brain", "ostp-prober"]
resolver = "2"

View File

@ -9,7 +9,10 @@ anyhow.workspace = true
bytes.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
ostp-core = { path = "../ostp-core" }
ostp-tun = { path = "../ostp-tun" }
rand.workspace = true
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@ -153,8 +153,6 @@ impl Bridge {
self.running = false;
self.metrics.connection_state.store(0, Ordering::Relaxed);
proxy_guard = None;
sessions_opt = None;
udp_rx_opt = None;
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "manual stop");
break;

View File

@ -8,3 +8,4 @@ pub mod tunnel;
pub mod runner;
pub mod logging;

View File

@ -0,0 +1,84 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
pub fn setup_panic_hook() {
std::panic::set_hook(Box::new(|info| {
let payload = info.payload();
let msg = if let Some(s) = payload.downcast_ref::<&str>() {
*s
} else if let Some(s) = payload.downcast_ref::<String>() {
s.as_str()
} else {
"Box<dyn Any>"
};
let location = info.location().unwrap_or_else(|| std::panic::Location::caller());
let backtrace = std::backtrace::Backtrace::force_capture();
let crash_msg = format!(
"[{}] PANIC at {}:{}\nMessage: {}\nBacktrace:\n{:?}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
location.file(),
location.line(),
msg,
backtrace
);
eprintln!("{}", crash_msg);
let path = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("ostp-crash.log")))
.unwrap_or_else(|| PathBuf::from("ostp-crash.log"));
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
let _ = file.write_all(crash_msg.as_bytes());
let _ = file.write_all(b"\n===================================================\n");
}
}));
}
pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(level));
let path = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join(format!("{}.log", app_name))))
.unwrap_or_else(|| PathBuf::from(format!("{}.log", app_name)));
if let Ok(file) = OpenOptions::new().create(true).append(true).open(path) {
let (file_writer, guard) = tracing_appender::non_blocking(file);
let fmt_layer = tracing_subscriber::fmt::layer()
.with_target(true)
.with_thread_ids(false)
.with_thread_names(false)
.with_ansi(false)
.with_writer(file_writer);
let stderr_layer = tracing_subscriber::fmt::layer()
.with_target(false)
.with_writer(std::io::stderr);
let _ = tracing_subscriber::registry()
.with(env_filter)
.with(fmt_layer)
.with(stderr_layer)
.try_init();
tracing::info!(
"{} v{} | OS: {} | Arch: {}",
app_name,
version,
std::env::consts::OS,
std::env::consts::ARCH
);
Some(guard)
} else {
None
}
}

View File

@ -1,6 +1,6 @@
mod proxy;
pub mod native_handler;
pub mod windows_route;
mod udp_nat;
pub async fn run_tun_tunnel(

View File

@ -12,14 +12,10 @@ pub async fn run_native_tunnel(
mut exclusions_rx: watch::Receiver<crate::config::ExclusionConfig>,
) -> Result<()> {
use std::net::ToSocketAddrs;
use std::process::Command;
use netstack_smoltcp::StackBuilder;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use futures::{StreamExt, SinkExt};
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "linux")]
{
use std::io::{self, IsTerminal, Write};
@ -56,50 +52,24 @@ pub async fn run_native_tunnel(
#[allow(unused_variables)]
let server_ip_str = server_ip.to_string();
// ── 2. Windows: grab physical gateway BEFORE we touch any routes ──────────
#[cfg(target_os = "windows")]
let (phys_gw, phys_if) = super::windows_route::sys::get_default_ipv4_route()
.ok_or_else(|| anyhow!("Cannot find physical default IPv4 route"))?;
// ── 3. Resolve excluded domains → IPv4 addresses for bypass routing ───────
//
// Strategy identical to sing-box / v2rayN:
// • IP exclusions → add /32 host routes via physical gateway right now
// • Domain exclusions → resolve them NOW, add /32 routes for the IPs
// • Process exclusions → NOT possible via pure routing on Windows without
// WFP; we log a warning and skip them at the routing level
#[cfg(target_os = "windows")]
// Will be populated after TUN is up; tracks /32 routes added for cleanup.
let bypass_routes: Vec<(std::net::Ipv4Addr, std::net::Ipv4Addr, u32)>;
#[cfg(target_os = "windows")]
{
// Collect all IPs to bypass: server IP + configured IPs + resolved domains
let mut bypass_v4: Vec<std::net::Ipv4Addr> = Vec::new();
// ── 2. Resolve excluded domains → IP addresses for bypass routing ─────────
let mut bypass_ips: Vec<std::net::IpAddr> = Vec::new();
// Server IP always bypasses TUN
if let std::net::IpAddr::V4(v4) = server_ip {
bypass_v4.push(v4);
}
bypass_ips.push(server_ip);
// Explicitly configured IPs / CIDRs
for ip_str in &config.exclusions.ips {
// Accept single IPs ("1.2.3.4") or CIDR ("1.2.3.0/24")
let host = ip_str.split('/').next().unwrap_or(ip_str);
if let Ok(std::net::IpAddr::V4(v4)) = host.parse() {
bypass_v4.push(v4);
if let Ok(ip) = host.parse() {
bypass_ips.push(ip);
}
}
// Resolve configured excluded domains (best-effort, DNS at startup).
// Use (host, port) tuple so lookup_host does NOT borrow a temporary string.
for domain in &config.exclusions.domains {
match tokio::net::lookup_host((domain.as_str(), 443u16)).await {
Ok(addrs) => {
for addr in addrs {
if let std::net::IpAddr::V4(v4) = addr.ip() {
bypass_v4.push(v4);
}
bypass_ips.push(addr.ip());
}
}
Err(e) => {
@ -110,163 +80,28 @@ pub async fn run_native_tunnel(
if !config.exclusions.processes.is_empty() {
tracing::warn!(
"Process-based split tunneling is not supported in TUN mode on Windows \
without WFP. Processes in the exclusion list will still be tunneled. \
"Process-based split tunneling is not fully supported in TUN mode on all platforms \
without WFP/eBPF. Processes in the exclusion list will still be tunneled. \
Use IP or domain exclusions instead."
);
}
// Add /32 bypass routes via physical gateway BEFORE setting up TUN default route
bypass_routes = super::windows_route::sys::add_bypass_routes(&bypass_v4, phys_gw, phys_if, 1);
tracing::info!(
"Added {} bypass routes via {} (if_index={})",
bypass_routes.len(),
phys_gw,
phys_if
);
}
// ── 3. Create TUN device via ostp-tun crate ───────────────────────────────
let opts = ostp_tun::OstpTunOptions {
server_ip,
bypass_ips,
dns_server: config.dns_server.clone(),
kill_switch: config.kill_switch,
mtu: config.ostp.mtu as u16,
wintun_path: None,
};
// ── 4. Create TUN device ──────────────────────────────────────────────────
let mut tun_cfg = tun::Configuration::default();
tun_cfg
.tun_name("ostp_tun")
.address((10, 1, 0, 2))
.netmask((255, 255, 255, 0))
.destination((10, 1, 0, 1))
.mtu(config.ostp.mtu as u16)
.up();
let tun_interface = ostp_tun::OstpTunInterface::create(opts)
.await
.map_err(|e| anyhow!("Failed to create OstpTunInterface: {}", e))?;
#[cfg(target_os = "linux")]
tun_cfg.platform_config(|cfg| {
cfg.packet_information(false);
});
let dev = tun::create(&tun_cfg).map_err(|e| anyhow!("Failed to create TUN device: {}", e))?;
let dev = tun::AsyncDevice::new(dev).map_err(|e| anyhow!("TUN device async failed: {}", e))?;
tracing::info!("TUN device 'ostp_tun' created.");
// ── 5. Windows: set default route through TUN + miscellaneous setup ───────
#[cfg(target_os = "windows")]
{
const CREATE_NO_WINDOW: u32 = 0x08000000;
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
// Wait for ostp_tun to be visible in the routing table
let mut tun_index = None;
for _ in 0..20 {
if let Some(idx) = super::windows_route::sys::get_interface_index("ostp_tun") {
tun_index = Some(idx);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
if let Some(idx) = tun_index {
// Default route through TUN with metric=5 — higher than bypass routes (metric=1)
// so that non-excluded traffic is captured but excluded IPs go via real NIC.
let _ = super::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,
);
tracing::info!("Default route via TUN (if_index={idx}, metric=5) added.");
} else {
tracing::warn!("Could not find ostp_tun index in routing table — traffic may not be captured.");
}
let exe1 = current_exe.clone();
let exe2 = current_exe.clone();
let _ = tokio::task::spawn_blocking(move || {
// Firewall allow-rules for OSTP binary
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();
// Disable DAD / Router Discovery to avoid 15s delay
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 Some(ref dns) = config.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 config.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();
});
}
}
// ── 6. Linux: exclusion routes via real gateway ───────────────────────────
#[cfg(target_os = "linux")]
{
let gw_out = Command::new("ip")
.args(["route", "show", "default"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok());
let real_gw = gw_out.as_deref().and_then(|s| {
s.split_whitespace()
.skip_while(|w| *w != "via")
.nth(1)
.map(|s| s.to_string())
});
let real_dev = gw_out.as_deref().and_then(|s| {
s.split_whitespace()
.skip_while(|w| *w != "dev")
.nth(1)
.map(|s| s.to_string())
});
if let (Some(ref gw), Some(ref dev)) = (&real_gw, &real_dev) {
// Server IP bypass
let _ = Command::new("ip")
.args(["route", "add", &format!("{}/32", server_ip_str), "via", gw, "dev", dev])
.output();
// Configured IP exclusions
for ip_str in &config.exclusions.ips {
let host = ip_str.split('/').next().unwrap_or(ip_str);
let route = if ip_str.contains('/') { ip_str.as_str() } else { &format!("{}/32", host) };
let _ = Command::new("ip")
.args(["route", "add", route, "via", gw, "dev", dev])
.output();
}
}
// Default route through TUN
let _ = Command::new("ip")
.args(["route", "add", "default", "via", "10.1.0.1", "dev", "ostp_tun", "metric", "10"])
.output();
}
let dev = tun_interface.device;
let _route_guard = tun_interface.guard;
// ── 7. Build smoltcp network stack ────────────────────────────────────────
let (stack, tcp_runner, udp_socket, tcp_listener) = StackBuilder::default()
@ -371,7 +206,7 @@ pub async fn run_native_tunnel(
// Physical interface index — Some on Windows, None everywhere else
#[cfg(target_os = "windows")]
let phys_if_for_bypass: Option<u32> = Some(phys_if);
let phys_if_for_bypass: Option<u32> = ostp_tun::windows::windows_route::sys::get_default_ipv4_route().map(|(_, idx)| idx);
#[cfg(not(target_os = "windows"))]
let phys_if_for_bypass: Option<u32> = None;
@ -447,6 +282,7 @@ pub async fn run_native_tunnel(
let Ok(socket) = socket else { return; };
// Bind to physical interface so packets don't loop back into TUN
#[cfg(target_os = "windows")]
if let Some(idx) = phys_if_for_bypass {
if let Err(e) = crate::tunnel::proxy::bind_socket_to_interface(
@ -537,54 +373,7 @@ pub async fn run_native_tunnel(
tracing::info!("Deactivating NATIVE TUN tunnel...");
// ── Cleanup ───────────────────────────────────────────────────────────────
#[cfg(target_os = "windows")]
{
const CREATE_NO_WINDOW: u32 = 0x08000000;
// Remove all bypass /32 host routes we added
super::windows_route::sys::remove_bypass_routes(&bypass_routes);
tracing::info!("Removed {} bypass routes.", bypass_routes.len());
let is_kill_switch = config.kill_switch;
let _ = tokio::task::spawn_blocking(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();
}
});
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("ip").args(["route", "del", "default", "dev", "ostp_tun"]).output();
let _ = Command::new("ip")
.args(["route", "del", &format!("{}/32", server_ip_str)])
.output();
for ip_str in &config.exclusions.ips {
let host = ip_str.split('/').next().unwrap_or(ip_str);
let route = if ip_str.contains('/') {
ip_str.as_str().to_string()
} else {
format!("{}/32", host)
};
let _ = Command::new("ip").args(["route", "del", &route]).output();
}
}
// Cleanup is handled automatically by the _route_guard Drop trait in ostp-tun
Ok(())
}
@ -597,6 +386,7 @@ pub async fn run_native_tunnel(
pub async fn run_native_tunnel(
_config: crate::config::ClientConfig,
_shutdown: watch::Receiver<bool>,
_exclusions_rx: watch::Receiver<crate::config::ExclusionConfig>,
) -> Result<()> {
Err(anyhow!("Native TUN tunnel is only supported on Windows/Linux"))
}

View File

@ -86,7 +86,7 @@ pub fn bind_socket_to_interface(socket: &impl AsRawFd, if_name: &str) -> std::io
pub fn get_windows_physical_if_index() -> Option<u32> {
#[cfg(target_os = "windows")]
{
return super::windows_route::sys::get_default_ipv4_route().map(|(_, idx)| idx);
return ostp_tun::windows::windows_route::sys::get_default_ipv4_route().map(|(_, idx)| idx);
}
#[cfg(not(target_os = "windows"))]
{

View File

@ -52,7 +52,7 @@ pub fn extract_sni(data: &[u8]) -> Option<String> {
if ext_type == 0x0000 { // Server Name Indication (SNI)
if pos + 5 <= extensions_end {
let list_len = ((data[pos] as usize) << 8) | (data[pos + 1] as usize);
let _list_len = ((data[pos] as usize) << 8) | (data[pos + 1] as usize);
let name_type = data[pos + 2];
if name_type == 0 { // Hostname
let name_len = ((data[pos + 3] as usize) << 8) | (data[pos + 4] as usize);

File diff suppressed because it is too large Load Diff

View File

@ -28,4 +28,6 @@ ostp-client = { path = "../../ostp-client" }
portable-atomic = "1"
json_comments = "0.2"
rand = "0.8"
reqwest = { version = "0.13.4", features = ["blocking"] }
zip = "8.6.0"

View File

@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
use anyhow::Result;
use ostp_client::bridge::BridgeMetrics;
use portable_atomic::Ordering;
use tauri::Emitter;
// ── Config types ─────────────────────────────────────────────────────────────
@ -193,6 +194,50 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi
// ── Tauri commands ────────────────────────────────────────────────────────────
#[tauri::command]
async fn download_wintun() -> Result<bool, String> {
tokio::task::spawn_blocking(move || {
let response = reqwest::blocking::get("https://www.wintun.net/builds/wintun-0.14.1.zip")
.map_err(|e| format!("Failed to download wintun.zip: {}", e))?;
let bytes = response.bytes().map_err(|e| format!("Failed to read bytes: {}", e))?;
let cursor = std::io::Cursor::new(bytes);
let mut zip = zip::ZipArchive::new(cursor).map_err(|e| format!("Invalid zip archive: {}", e))?;
let arch = if cfg!(target_arch = "x86") {
"x86"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"amd64"
};
let arch_path = format!("wintun/bin/{}/wintun.dll", arch);
let mut file = zip.by_name(&arch_path).map_err(|e| format!("wintun.dll not found in zip: {}", e))?;
let mut paths_to_write = vec![];
if let Ok(cwd) = std::env::current_dir() {
paths_to_write.push(cwd.join("wintun.dll"));
}
if let Some(helper) = find_helper_exe() {
if let Some(dir) = helper.parent() {
paths_to_write.push(dir.join("wintun.dll"));
}
}
if paths_to_write.is_empty() {
return Err("Could not determine where to place wintun.dll".to_string());
}
let mut buf = Vec::new();
std::io::copy(&mut file, &mut buf).map_err(|e| format!("Failed to read from zip: {}", e))?;
for p in paths_to_write {
let _ = std::fs::write(&p, &buf);
}
Ok(true)
}).await.map_err(|e| e.to_string())?
}
#[tauri::command]
async fn get_config() -> Result<String, String> {
let path = get_config_path();
@ -288,7 +333,7 @@ async fn get_metrics(state: tauri::State<'_, AppState>) -> Result<Option<UIMetri
#[tauri::command]
async fn reload_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String> {
let mut guard = state.0.lock().await;
let guard = state.0.lock().await;
if guard.tunnel.is_none() {
return Ok(false);
}
@ -316,7 +361,7 @@ async fn reload_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String
);
let _ = h.cmd_tx.send(cmd).await;
}
Some(TunnelHandle::InProcess(s)) => {
Some(TunnelHandle::InProcess(_s)) => {
// Restarting in-process tunnel is not supported without re-calling start_tunnel,
// but we can just abort and we should really call start_tunnel again.
// For now, return false.
@ -353,7 +398,7 @@ async fn stop_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String>
}
#[tauri::command]
async fn start_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String> {
async fn start_tunnel(state: tauri::State<'_, AppState>, app: tauri::AppHandle) -> Result<bool, String> {
let mut guard = state.0.lock().await;
if let Some(ref t) = guard.tunnel {
@ -378,16 +423,35 @@ async fn start_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String>
let is_tun_enabled = client_cfg.tun.as_ref().map(|t| t.enable).unwrap_or(false);
#[cfg(target_os = "windows")]
if is_tun_enabled {
start_tun_via_helper(&mut guard, &client_cfg).await
let mut found = false;
if let Ok(cwd) = std::env::current_dir() {
if cwd.join("wintun.dll").exists() { found = true; }
}
if !found {
if let Some(helper) = find_helper_exe() {
if let Some(dir) = helper.parent() {
if dir.join("wintun.dll").exists() { found = true; }
}
}
}
if !found {
return Err("WINTUN_MISSING".to_string());
}
}
if is_tun_enabled {
start_tun_via_helper(&mut guard, &client_cfg, app).await
} else {
start_proxy_in_process(&mut guard, &client_cfg).await
start_proxy_in_process(&mut guard, &client_cfg, app).await
}
}
async fn start_proxy_in_process(
guard: &mut AppStateInner,
raw: &ClientConfigRaw,
app: tauri::AppHandle,
) -> Result<bool, String> {
let mapped = map_to_client_config(raw, "proxy");
let metrics = Arc::new(BridgeMetrics {
@ -404,7 +468,10 @@ async fn start_proxy_in_process(
let handle = tokio::spawn(async move {
match ostp_client::runner::run_client_core(mapped, metrics_clone, shutdown_rx, None).await {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
Err(e) => {
let _ = app.emit("tunnel-error", e.to_string());
Err(e.to_string())
}
}
});
@ -419,6 +486,7 @@ async fn start_proxy_in_process(
async fn start_tun_via_helper(
guard: &mut AppStateInner,
raw: &ClientConfigRaw,
app: tauri::AppHandle,
) -> Result<bool, String> {
let port = {
let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| format!("Bind error: {}", e))?;
@ -470,7 +538,11 @@ async fn start_tun_via_helper(
match msg {
HelperMsg::Status { value } => s.connection_state = value,
HelperMsg::Metrics { bytes_sent, bytes_recv, rtt_ms } => { s.bytes_sent = bytes_sent; s.bytes_recv = bytes_recv; s.rtt_ms = rtt_ms; }
HelperMsg::Error { message } => { s.connection_state = 0; eprintln!("Helper error: {}", message); }
HelperMsg::Error { message } => {
s.connection_state = 0;
eprintln!("Helper error: {}", message);
let _ = app.emit("tunnel-error", message);
}
_ => {}
}
}
@ -617,11 +689,13 @@ pub fn run() {
let connect_i = MenuItem::with_id(app, "connect", "Подключиться", true, None::<&str>)?;
let disconnect_i = MenuItem::with_id(app, "disconnect", "Отключиться", true, None::<&str>)?;
let server_i = MenuItem::with_id(app, "server", format!("Сервер: {}", masked_ip), false, None::<&str>)?;
let version_i = MenuItem::with_id(app, "version", format!("OSTP v{}", env!("CARGO_PKG_VERSION")), false, None::<&str>)?;
let show_i = MenuItem::with_id(app, "show", "Показать окно", true, None::<&str>)?;
let exit_i = MenuItem::with_id(app, "exit", "Выход", true, None::<&str>)?;
let menu = Menu::with_items(app, &[
&server_i,
&version_i,
&connect_i,
&disconnect_i,
&show_i,
@ -671,7 +745,7 @@ pub fn run() {
}
_ => {}
})
.invoke_handler(tauri::generate_handler![start_tunnel, stop_tunnel, reload_tunnel, get_tunnel_status, get_metrics, get_config, save_config])
.invoke_handler(tauri::generate_handler![start_tunnel, stop_tunnel, reload_tunnel, get_tunnel_status, get_metrics, get_config, save_config, download_wintun])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -2,5 +2,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
ostp_client::logging::setup_panic_hook();
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-gui", env!("CARGO_PKG_VERSION"));
ostp_gui_lib::run()
}

View File

@ -33,6 +33,17 @@
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
</svg>
</button>
<button id="btn-theme-toggle" class="theme-toggle-btn" aria-label="Toggle theme">
<!-- Sun icon (shown in dark mode) -->
<svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<!-- Moon icon (shown in light mode) -->
<svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
<button id="btn-go-settings" class="icon-btn" aria-label="Settings">
<!-- Gear icon -->
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@ -327,6 +338,18 @@
<!-- Toast -->
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<!-- Wintun Modal -->
<div id="wintun-modal" class="modal-overlay hidden">
<div class="modal-content">
<h3 class="modal-title" data-i18n="wintun_missing_title">Wintun Driver Missing</h3>
<p class="modal-text" data-i18n="wintun_missing_desc">To use TUN mode, the Wintun driver (wintun.dll) is required. Would you like to download it now?</p>
<div class="modal-actions">
<button id="btn-wintun-cancel" class="btn secondary">Cancel</button>
<button id="btn-wintun-download" class="btn primary">Download</button>
</div>
</div>
</div>
</div>
<script type="module" src="main.js"></script>
</body>

View File

@ -59,6 +59,10 @@ const inDomains = $('in-ex-domains');
const inIps = $('in-ex-ips');
const inProcesses = $('in-ex-processes');
const wintunModal = $('wintun-modal');
const btnWintunCancel = $('btn-wintun-cancel');
const btnWintunDownload = $('btn-wintun-download');
// ── Utilities ────────────────────────────────────────────────────────────────
function fmtBytes(b) {
if (!b || b === 0) return '0 B';
@ -81,6 +85,22 @@ function splitLines(val) {
return val.split('\n').map(l => l.trim()).filter(Boolean);
}
// ── Theme ────────────────────────────────────────────────────────────────────
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('ostp-theme', theme);
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
applyTheme(current === 'light' ? 'dark' : 'light');
}
// Apply saved theme immediately (before any paint)
(function() {
const saved = localStorage.getItem('ostp-theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
})();
// ── Toast ────────────────────────────────────────────────────────────────────
let toastTimer = null;
function showToast(msg, variant = '') {
@ -216,9 +236,13 @@ async function handleToggle() {
}
} catch (err) {
setState('disconnected');
if (err === "WINTUN_MISSING") {
wintunModal.classList.remove('hidden');
} else {
showToast(String(err), 'error');
alert(String(err));
}
}
} else {
try { await invoke('stop_tunnel'); } catch { /* ignore */ }
setState('disconnected');
@ -487,6 +511,12 @@ window.addEventListener('DOMContentLoaded', async () => {
btnBack.addEventListener('click', () => showScreen('home'));
btnImport.addEventListener('click', handleImport);
btnPeekKey.addEventListener('click', togglePeek);
// Theme toggle
const btnThemeToggle = $('btn-theme-toggle');
if (btnThemeToggle) {
btnThemeToggle.addEventListener('click', toggleTheme);
}
inOwndns.addEventListener('change', () => {
updateDnsVisibility();
scheduleAutoSave();
@ -511,7 +541,30 @@ window.addEventListener('DOMContentLoaded', async () => {
el.addEventListener('change', scheduleAutoSave);
});
btnTestPing.addEventListener('click', async () => {
btnTestPing.addEventListener('click', runPingTest);
btnWintunCancel.addEventListener('click', () => {
wintunModal.classList.add('hidden');
});
btnWintunDownload.addEventListener('click', async () => {
try {
btnWintunDownload.disabled = true;
btnWintunDownload.textContent = "Downloading...";
await invoke('download_wintun');
wintunModal.classList.add('hidden');
showToast("Wintun driver downloaded successfully!", "ok");
handleToggle();
} catch (err) {
showToast("Failed to download: " + err, "error");
alert("Download failed: " + err);
} finally {
btnWintunDownload.disabled = false;
btnWintunDownload.textContent = "Download";
}
});
async function runPingTest() {
pingValueTxt.textContent = 'Testing...';
pingValueTxt.className = 'ping-test-value';
try {
@ -528,7 +581,7 @@ window.addEventListener('DOMContentLoaded', async () => {
} catch {
pingValueTxt.textContent = 'Target Ping: Error';
}
});
}
// Restore status on app open
try {

View File

@ -3,7 +3,7 @@
Minimal dark with vivid accents. Glassmorphism. No frameworks.
*/
/* ── Tokens ──────────────────────────────────────────────────────────────── */
/* ── Tokens — Dark (default) ─────────────────────────────────────────────── */
:root {
/* Colors */
--c-bg: #08080f;
@ -44,6 +44,132 @@
color: var(--c-txt-1);
}
/* ── Tokens — Light theme override ────────────────────────────────────────── */
[data-theme="light"] {
--c-bg: #f0f2fa;
--c-surface: #ffffff;
--c-card: rgba(255,255,255,0.75);
--c-card-border: rgba(0,0,0,0.08);
--c-accent: #5b61f0;
--c-accent-2: #8b5cf6;
--c-accent-glow: rgba(91,97,240,0.25);
--c-accent-dim: rgba(91,97,240,0.10);
--c-green: #10b981;
--c-green-glow: rgba(16,185,129,0.25);
--c-green-dim: rgba(16,185,129,0.10);
--c-amber: #d97706;
--c-red: #ef4444;
--c-txt-1: #0f1020;
--c-txt-2: #5a5f7a;
--c-txt-3: #b0b4cc;
}
/* ── Light theme element overrides ───────────────────────────────────────── */
html[data-theme="light"],
html[data-theme="light"] body {
background: var(--c-bg);
}
html[data-theme="light"] .blob-1 { opacity: 0.08; }
html[data-theme="light"] .blob-2 { opacity: 0.06; }
html[data-theme="light"] .power-btn {
background: var(--c-surface);
box-shadow:
0 0 0 8px rgba(0,0,0,0.04),
0 8px 32px rgba(0,0,0,0.12);
}
html[data-theme="light"] .server-badge {
background: rgba(0,0,0,0.05);
border-color: rgba(0,0,0,0.10);
color: rgba(0,0,0,0.65);
}
html[data-theme="light"] .ping-test-box {
background: rgba(0,0,0,0.04);
border-color: rgba(0,0,0,0.07);
}
html[data-theme="light"] .ping-test-title { color: rgba(0,0,0,0.35); }
html[data-theme="light"] .ping-test-value { color: rgba(0,0,0,0.65); }
html[data-theme="light"] .metrics-bar {
background: rgba(0,0,0,0.04);
border-top-color: rgba(0,0,0,0.08);
}
html[data-theme="light"] .metric-sep { background: rgba(0,0,0,0.12); }
html[data-theme="light"] .metric-label { color: rgba(0,0,0,0.45); }
html[data-theme="light"] .field-input {
background: rgba(0,0,0,0.04);
border-color: rgba(0,0,0,0.10);
}
html[data-theme="light"] .field-input::placeholder { color: var(--c-txt-3); }
html[data-theme="light"] .toggle-track {
background: rgba(0,0,0,0.08);
border-color: rgba(0,0,0,0.12);
}
html[data-theme="light"] .toggle-row {
border-top-color: rgba(0,0,0,0.06);
}
html[data-theme="light"] .section-head {
border-top-color: rgba(0,0,0,0.06);
}
html[data-theme="light"] .card.scrollable {
scrollbar-color: rgba(0,0,0,0.12) transparent;
}
html[data-theme="light"] .card.scrollable::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.12);
}
html[data-theme="light"] .toast {
background: rgba(255,255,255,0.95);
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
}
html[data-theme="light"] .icon-btn:hover {
border-color: rgba(0,0,0,0.14);
color: var(--c-txt-1);
background: rgba(0,0,0,0.06);
}
/* ── Theme toggle button ─────────────────────────────────────────────────── */
.theme-toggle-btn {
width: 34px; height: 34px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--r-sm);
border: 1px solid var(--c-card-border);
background: var(--c-card);
color: var(--c-txt-2);
transition: all var(--t-fast);
backdrop-filter: blur(8px);
cursor: pointer;
}
.theme-toggle-btn:hover {
border-color: var(--c-accent);
color: var(--c-accent);
}
.theme-toggle-btn:active { transform: scale(0.93); }
/* sun icon — shown in dark mode */
.theme-toggle-btn .icon-sun { display: block; }
.theme-toggle-btn .icon-moon { display: none; }
html[data-theme="light"] .theme-toggle-btn .icon-sun { display: none; }
html[data-theme="light"] .theme-toggle-btn .icon-moon { display: block; }
/* ── Reset ────────────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
@ -778,3 +904,80 @@ input, textarea { font-family: inherit; }
.toast.is-error { border-color: var(--c-red); color: var(--c-red); }
.toast.is-ok { border-color: var(--c-green); color: var(--c-green); }
/* ── Modal ────────────────────────────────────────────────────────────────── */
.modal-overlay {
position: absolute;
inset: 0;
z-index: 50;
background: rgba(0,0,0,0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
transition: opacity var(--t-fast);
}
.modal-overlay.hidden {
opacity: 0;
pointer-events: none;
}
.modal-content {
background: var(--c-surface);
border: 1px solid var(--c-card-border);
padding: 20px;
border-radius: var(--r-md);
width: 280px;
box-shadow: 0 10px 40px rgba(0,0,0,0.6);
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--c-txt-1);
}
.modal-text {
font-size: 0.78rem;
color: var(--c-txt-2);
line-height: 1.4;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 8px;
}
.modal-actions .btn {
padding: 6px 14px;
border-radius: var(--r-sm);
font-size: 0.75rem;
font-weight: 600;
border: none;
transition: all var(--t-fast);
}
.modal-actions .btn.secondary {
background: rgba(255,255,255,0.08);
color: var(--c-txt-1);
}
html[data-theme="light"] .modal-actions .btn.secondary {
background: rgba(0,0,0,0.08);
color: var(--c-txt-1);
}
.modal-actions .btn.secondary:hover { background: rgba(255,255,255,0.12); }
html[data-theme="light"] .modal-actions .btn.secondary:hover { background: rgba(0,0,0,0.12); }
.modal-actions .btn.primary {
background: var(--c-accent);
color: #fff;
}
.modal-actions .btn.primary:hover { background: var(--c-accent-2); }

View File

@ -52,6 +52,9 @@ struct TunnelState {
#[tokio::main]
async fn main() -> Result<()> {
ostp_client::logging::setup_panic_hook();
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-helper", env!("CARGO_PKG_VERSION"));
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let _ = std::env::set_current_dir(dir);

17
ostp-tun/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "ostp-tun"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
tokio.workspace = true
tracing.workspace = true
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"] }
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"

40
ostp-tun/src/lib.rs Normal file
View File

@ -0,0 +1,40 @@
use anyhow::Result;
pub struct OstpTunOptions {
pub server_ip: std::net::IpAddr,
pub bypass_ips: Vec<std::net::IpAddr>,
pub dns_server: Option<String>,
pub kill_switch: bool,
pub mtu: u16,
pub wintun_path: Option<String>,
}
pub struct OstpTunInterface {
pub device: tun::AsyncDevice,
pub guard: Box<dyn std::any::Any + Send + Sync>,
}
#[cfg(target_os = "windows")]
pub mod windows;
#[cfg(target_os = "linux")]
pub mod linux;
#[cfg(target_os = "macos")]
pub mod macos;
impl OstpTunInterface {
pub async fn create(opts: OstpTunOptions) -> Result<Self> {
#[cfg(target_os = "windows")]
return windows::create(opts).await;
#[cfg(target_os = "linux")]
return linux::create(opts).await;
#[cfg(target_os = "macos")]
return macos::create(opts).await;
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
anyhow::bail!("Unsupported OS for ostp-tun");
}
}

106
ostp-tun/src/linux.rs Normal file
View File

@ -0,0 +1,106 @@
use crate::{OstpTunInterface, OstpTunOptions};
use anyhow::{anyhow, Result};
use std::process::Command;
struct LinuxRouteGuard {
server_ip: String,
bypass_routes: Vec<String>,
real_gw: Option<String>,
real_dev: Option<String>,
kill_switch: bool,
}
impl Drop for LinuxRouteGuard {
fn drop(&mut self) {
let _ = Command::new("ip").args(["route", "del", "default", "dev", "ostp_tun"]).output();
let _ = Command::new("ip").args(["route", "del", &format!("{}/32", self.server_ip)]).output();
for route in &self.bypass_routes {
let _ = Command::new("ip").args(["route", "del", route]).output();
}
tracing::info!("Removed Linux bypass routes.");
if self.kill_switch {
if let (Some(ref gw), Some(ref dev)) = (&self.real_gw, &self.real_dev) {
let _ = Command::new("ip").args(["route", "add", "default", "via", gw, "dev", dev]).output();
tracing::info!("Restored original default route via {} dev {}", gw, dev);
}
}
}
}
pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
let mut tun_cfg = tun::Configuration::default();
tun_cfg
.tun_name("ostp_tun")
.address((10, 1, 0, 2))
.netmask((255, 255, 255, 0))
.destination((10, 1, 0, 1))
.mtu(opts.mtu)
.up();
tun_cfg.platform_config(|cfg| {
cfg.packet_information(false);
});
let dev = tun::create(&tun_cfg).map_err(|e| anyhow!("Failed to create TUN device: {}", e))?;
let dev = tun::AsyncDevice::new(dev).map_err(|e| anyhow!("TUN device async failed: {}", e))?;
tracing::info!("TUN device 'ostp_tun' created.");
let gw_out = Command::new("ip")
.args(["route", "show", "default"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok());
let real_gw = gw_out.as_deref().and_then(|s| {
s.split_whitespace().skip_while(|w| *w != "via").nth(1).map(|s| s.to_string())
});
let real_dev = gw_out.as_deref().and_then(|s| {
s.split_whitespace().skip_while(|w| *w != "dev").nth(1).map(|s| s.to_string())
});
let mut bypass_routes = Vec::new();
if let (Some(ref gw), Some(ref dev_name)) = (&real_gw, &real_dev) {
let server_ip_str = opts.server_ip.to_string();
let _ = Command::new("ip")
.args(["route", "add", &format!("{}/32", server_ip_str), "via", gw, "dev", dev_name])
.output();
tracing::info!("Added bypass route for server {} via {}", server_ip_str, gw);
for ip in &opts.bypass_ips {
let route = format!("{}/32", ip);
let _ = Command::new("ip").args(["route", "add", &route, "via", gw, "dev", dev_name]).output();
bypass_routes.push(route);
}
let _ = Command::new("ip").args(["route", "add", "default", "dev", "ostp_tun"]).output();
if opts.kill_switch {
tracing::info!("Kill Switch: deleting original default route to prevent leakage.");
let _ = Command::new("ip").args(["route", "del", "default", "via", gw, "dev", dev_name]).output();
}
} else {
tracing::warn!("Could not detect physical default gateway. Tunnel routing might not work correctly.");
}
if let Some(ref dns) = opts.dns_server {
if !dns.is_empty() {
let _ = Command::new("resolvectl").args(["dns", "ostp_tun", dns]).output();
let _ = Command::new("resolvectl").args(["domain", "ostp_tun", "~."]).output();
let _ = Command::new("resolvectl").args(["default-route", "ostp_tun", "true"]).output();
tracing::info!("Configured DNS via resolvectl for ostp_tun: {}", dns);
}
}
Ok(OstpTunInterface {
device: dev,
guard: Box::new(LinuxRouteGuard {
server_ip: opts.server_ip.to_string(),
bypass_routes,
real_gw,
real_dev,
kill_switch: opts.kill_switch,
}),
})
}

89
ostp-tun/src/macos.rs Normal file
View File

@ -0,0 +1,89 @@
use crate::{OstpTunInterface, OstpTunOptions};
use anyhow::{anyhow, Result};
use std::process::Command;
struct MacosRouteGuard {
server_ip: String,
bypass_routes: Vec<String>,
real_gw: Option<String>,
kill_switch: bool,
}
impl Drop for MacosRouteGuard {
fn drop(&mut self) {
let _ = Command::new("route").args(["delete", "-net", "default", "-interface", "utun5"]).output();
let _ = Command::new("route").args(["delete", "-host", &self.server_ip]).output();
for route in &self.bypass_routes {
let _ = Command::new("route").args(["delete", "-host", route]).output();
}
tracing::info!("Removed macOS bypass routes.");
if self.kill_switch {
if let Some(ref gw) = self.real_gw {
let _ = Command::new("route").args(["add", "default", gw]).output();
tracing::info!("Restored original default route via {}", gw);
}
}
}
}
pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
let mut tun_cfg = tun::Configuration::default();
tun_cfg
.tun_name("utun5")
.address((10, 1, 0, 2))
.netmask((255, 255, 255, 0))
.destination((10, 1, 0, 1))
.mtu(opts.mtu)
.up();
let dev = tun::create(&tun_cfg).map_err(|e| anyhow!("Failed to create TUN device: {}", e))?;
let dev = tun::AsyncDevice::new(dev).map_err(|e| anyhow!("TUN device async failed: {}", e))?;
tracing::info!("TUN device 'utun5' created.");
let gw_out = Command::new("route")
.args(["-n", "get", "default"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok());
let real_gw = gw_out.as_deref().and_then(|s| {
s.lines()
.find(|l| l.contains("gateway:"))
.and_then(|l| l.split_whitespace().nth(1))
.map(|s| s.to_string())
});
let mut bypass_routes = Vec::new();
if let Some(ref gw) = real_gw {
let server_ip_str = opts.server_ip.to_string();
let _ = Command::new("route").args(["add", "-host", &server_ip_str, gw]).output();
tracing::info!("Added bypass route for server {} via {}", server_ip_str, gw);
for ip in &opts.bypass_ips {
let route = format!("{}", ip);
let _ = Command::new("route").args(["add", "-host", &route, gw]).output();
bypass_routes.push(route);
}
let _ = Command::new("route").args(["add", "-net", "default", "-interface", "utun5"]).output();
if opts.kill_switch {
tracing::info!("Kill Switch: deleting original default route to prevent leakage.");
let _ = Command::new("route").args(["delete", "default", gw]).output();
}
} else {
tracing::warn!("Could not detect physical default gateway on macOS.");
}
Ok(OstpTunInterface {
device: dev,
guard: Box::new(MacosRouteGuard {
server_ip: opts.server_ip.to_string(),
bypass_routes,
real_gw,
kill_switch: opts.kill_switch,
}),
})
}

156
ostp-tun/src/windows.rs Normal file
View File

@ -0,0 +1,156 @@
use crate::{OstpTunInterface, OstpTunOptions};
use anyhow::{anyhow, Result};
use std::process::Command;
use std::os::windows::process::CommandExt;
pub mod windows_route {
include!("windows_route.rs");
}
struct WindowsRouteGuard {
bypass_routes: Vec<(std::net::Ipv4Addr, std::net::Ipv4Addr, u32)>,
kill_switch: bool,
}
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();
}
});
}
}
pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
const CREATE_NO_WINDOW: u32 = 0x08000000;
let (phys_gw, phys_if) = windows_route::sys::get_default_ipv4_route()
.ok_or_else(|| anyhow!("Cannot find physical default IPv4 route"))?;
let mut bypass_v4: Vec<std::net::Ipv4Addr> = Vec::new();
if let std::net::IpAddr::V4(v4) = opts.server_ip {
bypass_v4.push(v4);
}
for ip in opts.bypass_ips {
if let std::net::IpAddr::V4(v4) = ip {
bypass_v4.push(v4);
}
}
let bypass_routes = windows_route::sys::add_bypass_routes(&bypass_v4, phys_gw, phys_if, 1);
tracing::info!("Added {} bypass routes via {} (if_index={})", bypass_routes.len(), phys_gw, phys_if);
let mut tun_cfg = tun::Configuration::default();
tun_cfg
.tun_name("ostp_tun")
.address((10, 1, 0, 2))
.netmask((255, 255, 255, 0))
.destination((10, 1, 0, 1))
.mtu(opts.mtu)
.up();
let dev = tun::create(&tun_cfg).map_err(|e| anyhow!("Failed to create TUN device: {}", e))?;
let dev = tun::AsyncDevice::new(dev).map_err(|e| anyhow!("TUN device async failed: {}", e))?;
tracing::info!("TUN device 'ostp_tun' created.");
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
let mut tun_index = None;
for _ in 0..20 {
if let Some(idx) = windows_route::sys::get_interface_index("ostp_tun") {
tun_index = Some(idx);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
if let Some(idx) = tun_index {
let _ = 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,
);
tracing::info!("Default route via TUN (if_index={idx}, metric=5) added.");
} else {
tracing::warn!("Could not find ostp_tun index in routing table — traffic may not be captured.");
}
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 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 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();
});
}
Ok(OstpTunInterface {
device: dev,
guard: Box::new(WindowsRouteGuard {
bypass_routes,
kill_switch: opts.kill_switch,
}),
})
}

View File

@ -415,16 +415,8 @@ struct MuxConfig {
#[tokio::main]
async fn main() -> Result<()> {
// Initialize structured logging via tracing
// Default: info level; override with RUST_LOG env var (e.g. RUST_LOG=ostp_server=debug)
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))
)
.with_target(false)
.compact()
.init();
ostp_client::logging::setup_panic_hook();
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-cli", env!("CARGO_PKG_VERSION"));
let res = run_app().await;
if let Err(e) = res {

View File

@ -39,6 +39,39 @@ if (Test-Path $CargoToml) {
[System.IO.File]::WriteAllText($CargoToml, $NewContent)
}
Write-Output "[ok] Version: v$Version"
# Bump Tauri GUI
$TauriConf = Join-Path $ProjectRoot "ostp-gui\src-tauri\tauri.conf.json"
if (Test-Path $TauriConf) {
$TauriContent = [System.IO.File]::ReadAllText($TauriConf)
$TauriRegex = [regex] '"version":\s*"[^"]+"'
$TauriContent = $TauriRegex.Replace($TauriContent, ('"version": "' + $Version + '"'), 1)
[System.IO.File]::WriteAllText($TauriConf, $TauriContent)
Write-Output " [ok] Updated tauri.conf.json"
}
# Bump React Control Panel
$PackageJson = Join-Path $ProjectRoot "ostp-control\package.json"
if (Test-Path $PackageJson) {
$PkgContent = [System.IO.File]::ReadAllText($PackageJson)
$PkgRegex = [regex] '"version":\s*"[^"]+"'
$PkgContent = $PkgRegex.Replace($PkgContent, ('"version": "' + $Version + '"'), 1)
[System.IO.File]::WriteAllText($PackageJson, $PkgContent)
Write-Output " [ok] Updated package.json"
}
# Bump Flutter App
$Pubspec = Join-Path $ProjectRoot "ostp-flutter\pubspec.yaml"
if (Test-Path $Pubspec) {
$PubContent = [System.IO.File]::ReadAllText($Pubspec)
if ($PubContent -match 'version:\s*(\d+\.\d+\.\d+)\+(\d+)') {
$BuildNumber = [int]$Matches[2] + 1
$PubRegex = [regex] 'version:\s*\d+\.\d+\.\d+\+\d+'
$PubContent = $PubRegex.Replace($PubContent, ("version: $Version+$BuildNumber"), 1)
[System.IO.File]::WriteAllText($Pubspec, $PubContent)
Write-Output " [ok] Updated pubspec.yaml"
}
}
}
}