From 559ed8fe8231013c28fd57182fa2632ab12e7c9d Mon Sep 17 00:00:00 2001 From: Jyste Date: Sun, 10 May 2026 19:41:55 +0300 Subject: [PATCH 1/9] Add cross-platform runtime helpers --- src/platform.rs | 230 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 src/platform.rs diff --git a/src/platform.rs b/src/platform.rs new file mode 100644 index 0000000..864e354 --- /dev/null +++ b/src/platform.rs @@ -0,0 +1,230 @@ +use anyhow::{Context, Result}; +use std::{ + env, fs, + path::PathBuf, + process::{Command, Stdio}, +}; + +pub fn runtime_dir() -> PathBuf { + if let Some(path) = env::var_os("SMARTROUTE_RUNTIME_DIR") { + return PathBuf::from(path); + } + + #[cfg(windows)] + { + if let Some(path) = env::var_os("PROGRAMDATA") { + return PathBuf::from(path).join("SmartRoute").join("run"); + } + + if let Some(path) = env::var_os("LOCALAPPDATA") { + return PathBuf::from(path).join("SmartRoute").join("run"); + } + + env::temp_dir().join("SmartRoute").join("run") + } + + #[cfg(not(windows))] + { + PathBuf::from("/run/smartroute") + } +} + +pub fn pid_file() -> PathBuf { + runtime_dir().join("smartroute.pid") +} + +pub fn log_file() -> PathBuf { + runtime_dir().join("sing-box.log") +} + +pub fn singbox_config_file() -> PathBuf { + runtime_dir().join("sing-box.json") +} + +pub fn ensure_runtime_dir() -> Result { + let dir = runtime_dir(); + fs::create_dir_all(&dir).with_context(|| format!("Failed to create {}", dir.display()))?; + Ok(dir) +} + +pub fn singbox_bin() -> String { + env::var("SMARTROUTE_SINGBOX").unwrap_or_else(|_| { + if cfg!(windows) { + "sing-box.exe".to_string() + } else { + "sing-box".to_string() + } + }) +} + +pub fn null_device() -> &'static str { + if cfg!(windows) { "NUL" } else { "/dev/null" } +} + +pub fn temp_file(stem: &str, extension: &str) -> PathBuf { + let pid = std::process::id(); + + let file_name = if extension.trim().is_empty() { + format!("smartroute-{}-{}", stem, pid) + } else { + format!("smartroute-{}-{}.{}", stem, pid, extension.trim_start_matches('.')) + }; + + env::temp_dir().join(file_name) +} + +pub fn process_exists(pid: u32) -> Result { + process_exists_impl(pid) +} + +pub fn stop_process(pid: u32) -> Result { + stop_process_impl(pid) +} + +pub fn process_name_exists(names: &[&str]) -> bool { + names.iter().any(|name| process_name_exists_impl(name)) +} + +pub fn find_pids_by_name(name: &str) -> Result> { + find_pids_by_name_impl(name) +} + +#[cfg(not(windows))] +fn process_exists_impl(pid: u32) -> Result { + let status = Command::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .context("Failed to check process")?; + + Ok(status.success()) +} + +#[cfg(windows)] +fn process_exists_impl(pid: u32) -> Result { + let filter = format!("PID eq {}", pid); + let output = Command::new("tasklist") + .args(["/FI", &filter, "/FO", "CSV", "/NH"]) + .output() + .context("Failed to run tasklist")?; + + if !output.status.success() { + return Ok(false); + } + + let text = String::from_utf8_lossy(&output.stdout); + Ok(text.lines().any(|line| line.split(',').any(|field| field.trim_matches('"') == pid.to_string()))) +} + +#[cfg(not(windows))] +fn stop_process_impl(pid: u32) -> Result { + let status = Command::new("kill") + .arg(pid.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .context("Failed to stop process")?; + + Ok(status.success()) +} + +#[cfg(windows)] +fn stop_process_impl(pid: u32) -> Result { + let status = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .context("Failed to run taskkill")?; + + Ok(status.success()) +} + +#[cfg(not(windows))] +fn process_name_exists_impl(name: &str) -> bool { + Command::new("pgrep") + .arg("-x") + .arg(name) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +#[cfg(windows)] +fn process_name_exists_impl(name: &str) -> bool { + let filter = format!("IMAGENAME eq {}", normalize_windows_process_name(name)); + + Command::new("tasklist") + .args(["/FI", &filter, "/FO", "CSV", "/NH"]) + .output() + .map(|output| { + if !output.status.success() { + return false; + } + + let text = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase(); + text.contains(&normalize_windows_process_name(name).to_ascii_lowercase()) + }) + .unwrap_or(false) +} + +#[cfg(not(windows))] +fn find_pids_by_name_impl(name: &str) -> Result> { + let output = Command::new("pgrep") + .arg("-x") + .arg(name) + .output() + .context("Failed to run pgrep")?; + + if !output.status.success() { + return Ok(Vec::new()); + } + + parse_pid_lines(&String::from_utf8_lossy(&output.stdout)) +} + +#[cfg(windows)] +fn find_pids_by_name_impl(name: &str) -> Result> { + let process_name = normalize_windows_process_name(name).trim_end_matches(".exe").to_string(); + let script = format!( + "Get-Process -Name '{}' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id", + process_name.replace(''', "''") + ); + + let output = Command::new("powershell") + .args(["-NoProfile", "-Command", &script]) + .output() + .context("Failed to run powershell Get-Process")?; + + if !output.status.success() { + return Ok(Vec::new()); + } + + parse_pid_lines(&String::from_utf8_lossy(&output.stdout)) +} + +fn parse_pid_lines(text: &str) -> Result> { + let mut pids = Vec::new(); + + for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) { + let pid = line + .parse::() + .with_context(|| format!("Failed to parse process id: {}", line))?; + pids.push(pid); + } + + Ok(pids) +} + +#[cfg(windows)] +fn normalize_windows_process_name(name: &str) -> String { + if name.to_ascii_lowercase().ends_with(".exe") { + name.to_string() + } else { + format!("{}.exe", name) + } +} From f73474c52ed0a6dffa353cb2cbe825ac757f8413 Mon Sep 17 00:00:00 2001 From: Jyste Date: Sun, 10 May 2026 19:42:15 +0300 Subject: [PATCH 2/9] Expose platform helpers --- src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib.rs b/src/lib.rs index 13faa57..649a110 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod leaktest; pub mod mask; pub mod merge; pub mod picker; +pub mod platform; pub mod resolve; pub mod runtime; pub mod singbox; From a16145ab6b0ec0434566799af1607edbb3ad5d1b Mon Sep 17 00:00:00 2001 From: Jyste Date: Sun, 10 May 2026 19:42:56 +0300 Subject: [PATCH 3/9] Fix Windows process helper escaping --- src/platform.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/platform.rs b/src/platform.rs index 864e354..da2237f 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -115,7 +115,10 @@ fn process_exists_impl(pid: u32) -> Result { } let text = String::from_utf8_lossy(&output.stdout); - Ok(text.lines().any(|line| line.split(',').any(|field| field.trim_matches('"') == pid.to_string()))) + Ok(text.lines().any(|line| { + line.split(',') + .any(|field| field.trim_matches('"') == pid.to_string()) + })) } #[cfg(not(windows))] @@ -189,10 +192,13 @@ fn find_pids_by_name_impl(name: &str) -> Result> { #[cfg(windows)] fn find_pids_by_name_impl(name: &str) -> Result> { - let process_name = normalize_windows_process_name(name).trim_end_matches(".exe").to_string(); + let process_name = normalize_windows_process_name(name) + .trim_end_matches(".exe") + .to_string(); + let escaped = process_name.replace('\'', "''"); let script = format!( "Get-Process -Name '{}' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id", - process_name.replace(''', "''") + escaped ); let output = Command::new("powershell") From a0e84f367d001bb81cc24c7476726e27139f5446 Mon Sep 17 00:00:00 2001 From: Jyste Date: Sun, 10 May 2026 19:45:22 +0300 Subject: [PATCH 4/9] Make runtime cross-platform --- src/runtime.rs | 151 ++++++++++++++++++++++++++----------------------- 1 file changed, 81 insertions(+), 70 deletions(-) diff --git a/src/runtime.rs b/src/runtime.rs index 932f2b7..11f499a 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1,5 +1,6 @@ use crate::{ config::{load_config, validate_config}, + platform, resolve::resolve_domains_to_ip, singbox::generate_singbox_config, }; @@ -7,16 +8,32 @@ use anyhow::{Context, Result}; use std::{ fs, net::{TcpStream, ToSocketAddrs}, - path::Path, + path::{Path, PathBuf}, process::{Command, Stdio}, thread, time::Duration, }; -pub const RUNTIME_DIR: &str = "/run/smartroute"; -pub const PID_FILE: &str = "/run/smartroute/smartroute.pid"; -pub const LOG_FILE: &str = "/run/smartroute/sing-box.log"; -pub const SINGBOX_CONFIG_FILE: &str = "/run/smartroute/sing-box.json"; +pub const RUNTIME_DIR: &str = if cfg!(windows) { + "%PROGRAMDATA%\\SmartRoute\\run" +} else { + "/run/smartroute" +}; +pub const PID_FILE: &str = if cfg!(windows) { + "%PROGRAMDATA%\\SmartRoute\\run\\smartroute.pid" +} else { + "/run/smartroute/smartroute.pid" +}; +pub const LOG_FILE: &str = if cfg!(windows) { + "%PROGRAMDATA%\\SmartRoute\\run\\sing-box.log" +} else { + "/run/smartroute/sing-box.log" +}; +pub const SINGBOX_CONFIG_FILE: &str = if cfg!(windows) { + "%PROGRAMDATA%\\SmartRoute\\run\\sing-box.json" +} else { + "/run/smartroute/sing-box.json" +}; const STARTUP_TIMEOUT: Duration = Duration::from_secs(10); const POLL_INTERVAL: Duration = Duration::from_millis(100); @@ -55,13 +72,16 @@ pub fn start_smartroute(input: &Path) -> Result<()> { let config = load_config(input)?; validate_config(&config)?; - fs::create_dir_all(RUNTIME_DIR).context("Failed to create /run/smartroute")?; + let runtime_dir = platform::ensure_runtime_dir()?; + let pid_file = platform::pid_file(); + let log_file_path = platform::log_file(); + let singbox_config_file = platform::singbox_config_file(); if is_running()? { anyhow::bail!("SmartRoute is already running"); } - let _ = fs::remove_file(PID_FILE); + let _ = fs::remove_file(&pid_file); tracing::info!("Generating sing-box configuration"); let singbox_config = generate_singbox_config(&config)?; @@ -69,23 +89,32 @@ pub fn start_smartroute(input: &Path) -> Result<()> { let pretty = serde_json::to_string_pretty(&singbox_config) .context("Failed to serialize sing-box config")?; - crate::util::atomic_write(Path::new(SINGBOX_CONFIG_FILE), &pretty) - .with_context(|| format!("Failed to write {}", SINGBOX_CONFIG_FILE))?; + crate::util::atomic_write(&singbox_config_file, &pretty) + .with_context(|| format!("Failed to write {}", singbox_config_file.display()))?; + + let log_file = fs::File::create(&log_file_path) + .with_context(|| format!("Failed to create sing-box log file: {}", log_file_path.display()))?; - let log_file = fs::File::create(LOG_FILE).context("Failed to create sing-box log file")?; + let singbox_bin = platform::singbox_bin(); - tracing::info!("Starting sing-box process"); - let mut child = Command::new("sing-box") + tracing::info!( + binary = %singbox_bin, + runtime_dir = %runtime_dir.display(), + "Starting sing-box process" + ); + + let mut child = Command::new(&singbox_bin) .arg("run") .arg("-c") - .arg(SINGBOX_CONFIG_FILE) + .arg(&singbox_config_file) .stdout(Stdio::from(log_file.try_clone()?)) .stderr(Stdio::from(log_file)) .spawn() - .context("Failed to start sing-box. Is sing-box installed?")?; + .with_context(|| format!("Failed to start {}. Is sing-box installed and in PATH?", singbox_bin))?; let pid = child.id(); - fs::write(PID_FILE, pid.to_string()).context("Failed to write PID file")?; + fs::write(&pid_file, pid.to_string()) + .with_context(|| format!("Failed to write PID file: {}", pid_file.display()))?; tracing::debug!( pid = %pid, @@ -95,15 +124,13 @@ pub fn start_smartroute(input: &Path) -> Result<()> { "Waiting for sing-box to become ready" ); - // Wait for sing-box to become ready by checking if the port is available let wait_result = wait_for_port(&config.general.listen, config.general.listen_port, STARTUP_TIMEOUT); - // Check if process is still running if let Some(status) = child .try_wait() .context("Failed to check sing-box process status")? { - let log = fs::read_to_string(LOG_FILE).unwrap_or_default(); + let log = fs::read_to_string(&log_file_path).unwrap_or_default(); let tail = last_lines(&log, 60); tracing::error!( @@ -112,6 +139,8 @@ pub fn start_smartroute(input: &Path) -> Result<()> { "sing-box exited immediately" ); + let _ = fs::remove_file(&pid_file); + anyhow::bail!( "sing-box exited immediately with status: {}\nLast log lines:\n{}", status, @@ -119,9 +148,8 @@ pub fn start_smartroute(input: &Path) -> Result<()> { ); } - // If port check failed but process is still running, report the error if let Err(e) = wait_result { - let log = fs::read_to_string(LOG_FILE).unwrap_or_default(); + let log = fs::read_to_string(&log_file_path).unwrap_or_default(); let tail = last_lines(&log, 60); tracing::error!( @@ -145,8 +173,8 @@ pub fn start_smartroute(input: &Path) -> Result<()> { pid = %pid, mode = %config.general.mode, listen = %format!("{}:{}", config.general.listen, config.general.listen_port), - config_file = %SINGBOX_CONFIG_FILE, - log_file = %LOG_FILE, + config_file = %singbox_config_file.display(), + log_file = %log_file_path.display(), "SmartRoute started successfully" ); @@ -154,33 +182,20 @@ pub fn start_smartroute(input: &Path) -> Result<()> { } pub fn stop_smartroute() -> Result<()> { - let pid = match fs::read_to_string(PID_FILE) { - Ok(pid) => pid.trim().to_string(), + let pid_file = platform::pid_file(); + + let pid = match read_pid(&pid_file) { + Ok(pid) => pid, Err(_) => { tracing::info!("SmartRoute is not running (no PID file)"); return Ok(()); } }; - let running = Command::new("kill") - .arg("-0") - .arg(&pid) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .context("Failed to check SmartRoute process")? - .success(); - - if running { + if platform::process_exists(pid)? { tracing::info!(pid = %pid, "Stopping SmartRoute"); - let status = Command::new("kill") - .arg(&pid) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .context("Failed to stop SmartRoute")?; - - if status.success() { + + if platform::stop_process(pid)? { tracing::info!(pid = %pid, "SmartRoute stopped successfully"); } else { tracing::error!(pid = %pid, "Failed to stop SmartRoute process"); @@ -189,34 +204,27 @@ pub fn stop_smartroute() -> Result<()> { tracing::warn!(pid = %pid, "SmartRoute process was not running, removing stale PID file"); } - let _ = fs::remove_file(PID_FILE); + let _ = fs::remove_file(&pid_file); Ok(()) } pub fn status_smartroute() -> Result<()> { - match fs::read_to_string(PID_FILE) { - Ok(pid) => { - let pid = pid.trim(); + let pid_file = platform::pid_file(); + let log_file = platform::log_file(); - let status = Command::new("kill") - .arg("-0") - .arg(pid) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .context("Failed to check SmartRoute status")?; - - if status.success() { + match read_pid(&pid_file) { + Ok(pid) => { + if platform::process_exists(pid)? { tracing::info!( pid = %pid, - log_file = %LOG_FILE, + log_file = %log_file.display(), "SmartRoute is running" ); } else { tracing::warn!( pid = %pid, - pid_file = %PID_FILE, + pid_file = %pid_file.display(), "SmartRoute PID file exists, but process is not running" ); } @@ -230,25 +238,28 @@ pub fn status_smartroute() -> Result<()> { } fn is_running() -> Result { - let Ok(pid) = fs::read_to_string(PID_FILE) else { + let pid_file = platform::pid_file(); + + let Ok(pid) = read_pid(&pid_file) else { return Ok(false); }; - let pid = pid.trim(); - - let status = Command::new("kill") - .arg("-0") - .arg(pid) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .context("Failed to check old SmartRoute process")?; + let running = platform::process_exists(pid)?; - if !status.success() { - let _ = fs::remove_file(PID_FILE); + if !running { + let _ = fs::remove_file(&pid_file); } - Ok(status.success()) + Ok(running) +} + +fn read_pid(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .with_context(|| format!("Failed to read PID file: {}", path.display()))?; + + raw.trim() + .parse::() + .with_context(|| format!("Invalid PID in {}", path.display())) } fn last_lines(text: &str, max_lines: usize) -> String { From b0176c1c19e666908ee29bb0f2a4453ffb88193b Mon Sep 17 00:00:00 2001 From: Jyste Date: Sun, 10 May 2026 19:47:58 +0300 Subject: [PATCH 5/9] Add Windows autostart support --- src/autostart.rs | 97 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 3 deletions(-) diff --git a/src/autostart.rs b/src/autostart.rs index 0bf7479..fe65cc1 100644 --- a/src/autostart.rs +++ b/src/autostart.rs @@ -1,18 +1,36 @@ use anyhow::{Context, Result}; use std::{ env, fs, - os::unix::fs::PermissionsExt, path::{Path, PathBuf}, process::Command, }; +#[cfg(not(windows))] const SERVICE_PATH: &str = "/etc/systemd/system/smartroute.service"; +#[cfg(windows)] +const TASK_NAME: &str = "SmartRoute"; + fn current_exe_path() -> Result { env::current_exe().context("Failed to detect current executable path") } pub fn enable_autostart(config_path: &Path) -> Result<()> { + enable_autostart_impl(config_path) +} + +pub fn disable_autostart() -> Result<()> { + disable_autostart_impl() +} + +pub fn status_autostart() -> Result<()> { + status_autostart_impl() +} + +#[cfg(not(windows))] +fn enable_autostart_impl(config_path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let exe = current_exe_path()?; let config = fs::canonicalize(config_path) .with_context(|| format!("Config not found: {}", config_path.display()))?; @@ -48,7 +66,45 @@ WantedBy=multi-user.target Ok(()) } -pub fn disable_autostart() -> Result<()> { +#[cfg(windows)] +fn enable_autostart_impl(config_path: &Path) -> Result<()> { + let exe = current_exe_path()?; + let config = fs::canonicalize(config_path) + .with_context(|| format!("Config not found: {}", config_path.display()))?; + + let task_run = format!( + "\"{}\" daemon \"{}\" --diagnose-interval 300 --timeout 8 --jobs 12 --samples 3 --hysteresis 25", + exe.display(), + config.display() + ); + + let status = Command::new("schtasks") + .args([ + "/Create", + "/TN", + TASK_NAME, + "/TR", + &task_run, + "/SC", + "ONLOGON", + "/RL", + "HIGHEST", + "/F", + ]) + .status() + .context("Failed to create Windows Scheduled Task with schtasks")?; + + if !status.success() { + anyhow::bail!("schtasks failed to create autostart task"); + } + + println!("Autostart enabled: Windows Scheduled Task '{}'", TASK_NAME); + println!("Task command: {}", task_run); + Ok(()) +} + +#[cfg(not(windows))] +fn disable_autostart_impl() -> Result<()> { let _ = Command::new("systemctl") .args(["disable", "--now", "smartroute.service"]) .status(); @@ -61,7 +117,24 @@ pub fn disable_autostart() -> Result<()> { Ok(()) } -pub fn status_autostart() -> Result<()> { +#[cfg(windows)] +fn disable_autostart_impl() -> Result<()> { + let status = Command::new("schtasks") + .args(["/Delete", "/TN", TASK_NAME, "/F"]) + .status() + .context("Failed to delete Windows Scheduled Task with schtasks")?; + + if status.success() { + println!("Autostart disabled"); + } else { + println!("Autostart was probably already disabled"); + } + + Ok(()) +} + +#[cfg(not(windows))] +fn status_autostart_impl() -> Result<()> { let enabled = Command::new("systemctl") .args(["is-enabled", "smartroute.service"]) .output() @@ -84,6 +157,24 @@ pub fn status_autostart() -> Result<()> { Ok(()) } +#[cfg(windows)] +fn status_autostart_impl() -> Result<()> { + let output = Command::new("schtasks") + .args(["/Query", "/TN", TASK_NAME]) + .output() + .context("Failed to query Windows Scheduled Task with schtasks")?; + + if output.status.success() { + println!("Autostart: enabled"); + println!("{}", String::from_utf8_lossy(&output.stdout).trim()); + } else { + println!("Autostart: disabled"); + } + + Ok(()) +} + +#[cfg(not(windows))] fn run(cmd: &str, args: &[&str]) -> Result<()> { let status = Command::new(cmd) .args(args) From d85e435f06904f5ab62c7b71fe20adc68f71f13d Mon Sep 17 00:00:00 2001 From: Jyste Date: Sun, 10 May 2026 19:48:27 +0300 Subject: [PATCH 6/9] Add Windows firewall kill-switch --- src/killswitch.rs | 189 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 181 insertions(+), 8 deletions(-) diff --git a/src/killswitch.rs b/src/killswitch.rs index 48345c8..9e7778a 100644 --- a/src/killswitch.rs +++ b/src/killswitch.rs @@ -2,7 +2,22 @@ use crate::{config::load_config, resolve::resolve_domains_to_ip}; use anyhow::{Context, Result}; use std::{net::IpAddr, path::Path, process::Command}; +const FIREWALL_GROUP: &str = "SmartRoute KillSwitch"; + pub fn enable_killswitch(config_path: &Path, smart_mode: bool) -> Result<()> { + enable_killswitch_impl(config_path, smart_mode) +} + +pub fn disable_killswitch() -> Result<()> { + disable_killswitch_impl() +} + +pub fn status_killswitch() -> Result<()> { + status_killswitch_impl() +} + +#[cfg(not(windows))] +fn enable_killswitch_impl(config_path: &Path, smart_mode: bool) -> Result<()> { let resolved = resolve_domains_to_ip(config_path)?; if resolved > 0 { println!("Resolved {} proxy domain(s) before kill-switch", resolved); @@ -91,18 +106,106 @@ pub fn enable_killswitch(config_path: &Path, smart_mode: bool) -> Result<()> { apply_nft(&rules)?; - println!("Kill-switch enabled"); - if smart_mode { - println!("Mode: proxy-only"); - println!("Direct outbound is blocked. All traffic should go through proxy/chain."); - } else { - println!("Mode: strict"); + print_enabled_message(smart_mode); + Ok(()) +} + +#[cfg(windows)] +fn enable_killswitch_impl(config_path: &Path, smart_mode: bool) -> Result<()> { + let resolved = resolve_domains_to_ip(config_path)?; + if resolved > 0 { + println!("Resolved {} proxy domain(s) before kill-switch", resolved); + } + + let config = load_config(config_path)?; + let mut proxy_ips = Vec::new(); + + for node in &config.nodes { + let server = node.server.trim(); + let ip = server + .parse::() + .with_context(|| format!("Proxy node is still not IP after resolve: {}", server))?; + proxy_ips.push(ip.to_string()); } + proxy_ips.sort(); + proxy_ips.dedup(); + + if proxy_ips.is_empty() { + anyhow::bail!("No proxy nodes found in config. Kill-switch cannot be enabled."); + } + + let _ = disable_killswitch_impl(); + + for ip in proxy_ips { + run_netsh(&[ + "advfirewall", + "firewall", + "add", + "rule", + &format!("name=SmartRoute Allow Proxy {}", ip), + &format!("group={}", FIREWALL_GROUP), + "dir=out", + "action=allow", + "enable=yes", + "profile=any", + "protocol=any", + &format!("remoteip={}", ip), + ])?; + } + + run_netsh(&[ + "advfirewall", + "firewall", + "add", + "rule", + "name=SmartRoute Allow Loopback", + &format!("group={}", FIREWALL_GROUP), + "dir=out", + "action=allow", + "enable=yes", + "profile=any", + "protocol=any", + "remoteip=127.0.0.1,::1", + ])?; + + run_netsh(&[ + "advfirewall", + "firewall", + "add", + "rule", + "name=SmartRoute Allow LAN", + &format!("group={}", FIREWALL_GROUP), + "dir=out", + "action=allow", + "enable=yes", + "profile=any", + "protocol=any", + "remoteip=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,fe80::/10", + ])?; + + run_netsh(&[ + "advfirewall", + "firewall", + "add", + "rule", + "name=SmartRoute Block Outbound", + &format!("group={}", FIREWALL_GROUP), + "dir=out", + "action=block", + "enable=yes", + "profile=any", + "protocol=any", + ])?; + + print_enabled_message(smart_mode); + println!("Windows Firewall group: {}", FIREWALL_GROUP); + println!("Note: run this command from an elevated Administrator terminal."); Ok(()) } -pub fn disable_killswitch() -> Result<()> { +#[cfg(not(windows))] +fn disable_killswitch_impl() -> Result<()> { let status = Command::new("nft") .args(["delete", "table", "inet", "smartroute"]) .status() @@ -117,7 +220,30 @@ pub fn disable_killswitch() -> Result<()> { Ok(()) } -pub fn status_killswitch() -> Result<()> { +#[cfg(windows)] +fn disable_killswitch_impl() -> Result<()> { + let status = Command::new("netsh") + .args([ + "advfirewall", + "firewall", + "delete", + "rule", + &format!("group={}", FIREWALL_GROUP), + ]) + .status() + .context("Failed to run netsh")?; + + if status.success() { + println!("Kill-switch disabled"); + } else { + println!("Kill-switch was probably already disabled"); + } + + Ok(()) +} + +#[cfg(not(windows))] +fn status_killswitch_impl() -> Result<()> { let status = Command::new("nft") .args(["list", "table", "inet", "smartroute"]) .status() @@ -132,6 +258,29 @@ pub fn status_killswitch() -> Result<()> { Ok(()) } +#[cfg(windows)] +fn status_killswitch_impl() -> Result<()> { + let status = Command::new("netsh") + .args([ + "advfirewall", + "firewall", + "show", + "rule", + &format!("group={}", FIREWALL_GROUP), + ]) + .status() + .context("Failed to run netsh")?; + + if status.success() { + println!("Kill-switch: enabled"); + } else { + println!("Kill-switch: disabled"); + } + + Ok(()) +} + +#[cfg(not(windows))] fn apply_nft(rules: &str) -> Result<()> { let mut child = Command::new("nft") .arg("-f") @@ -154,3 +303,27 @@ fn apply_nft(rules: &str) -> Result<()> { Ok(()) } + +#[cfg(windows)] +fn run_netsh(args: &[&str]) -> Result<()> { + let status = Command::new("netsh") + .args(args) + .status() + .context("Failed to run netsh")?; + + if !status.success() { + anyhow::bail!("netsh command failed: netsh {}", args.join(" ")); + } + + Ok(()) +} + +fn print_enabled_message(smart_mode: bool) { + println!("Kill-switch enabled"); + if smart_mode { + println!("Mode: proxy-only"); + println!("Direct outbound is blocked. All traffic should go through proxy/chain."); + } else { + println!("Mode: strict"); + } +} From 6db9c6a3a6ec9ce8512f0c826e21b204de89bb5f Mon Sep 17 00:00:00 2001 From: Jyste Date: Sun, 10 May 2026 19:49:48 +0300 Subject: [PATCH 7/9] Make health checks cross-platform --- src/health.rs | 98 +++++++++++++++++++++++++-------------------------- 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/src/health.rs b/src/health.rs index 3bfab32..4638443 100644 --- a/src/health.rs +++ b/src/health.rs @@ -1,8 +1,9 @@ use crate::{ config::{SmartRouteConfig, load_config, validate_config}, killswitch::enable_killswitch, + platform, resolve::resolve_domains_to_ip, - runtime::{LOG_FILE, PID_FILE, start_smartroute, stop_smartroute}, + runtime::{start_smartroute, stop_smartroute}, singbox::generate_singbox_config, }; use anyhow::{Context, Result}; @@ -21,6 +22,7 @@ pub fn health_check(input: &Path, domain: &str, full: bool) -> Result<()> { println!("SmartRoute health check"); println!("────────────────────────────────────────────────────────"); println!("Config: {}", input.display()); + println!("Runtime dir: {}", platform::runtime_dir().display()); println!( "SOCKS5: {}:{}", config.general.listen, config.general.listen_port @@ -248,7 +250,7 @@ fn check_generated_singbox_config(config: &SmartRouteConfig) -> Result<()> { let path = temp_singbox_config_path(); fs::write(&path, raw).with_context(|| format!("Failed to write {}", path.display()))?; - let output = Command::new("sing-box") + let output = Command::new(platform::singbox_bin()) .arg("check") .arg("-c") .arg(&path) @@ -266,7 +268,8 @@ fn check_generated_singbox_config(config: &SmartRouteConfig) -> Result<()> { } fn pid_file_process_is_running() -> Result { - let Ok(pid) = fs::read_to_string(PID_FILE) else { + let pid_file = platform::pid_file(); + let Ok(pid) = fs::read_to_string(&pid_file) else { return Ok(false); }; @@ -276,56 +279,38 @@ fn pid_file_process_is_running() -> Result { return Ok(false); } - let status = Command::new("kill") - .arg("-0") - .arg(pid) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .context("Failed to check PID")?; + let pid = pid + .parse::() + .with_context(|| format!("Invalid PID in {}", pid_file.display()))?; - if !status.success() { - let _ = fs::remove_file(PID_FILE); + let running = platform::process_exists(pid)?; + + if !running { + let _ = fs::remove_file(&pid_file); } - Ok(status.success()) + Ok(running) } fn singbox_process_exists() -> bool { - Command::new("pgrep") - .arg("-x") - .arg("sing-box") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|status| status.success()) - .unwrap_or(false) + platform::process_name_exists(&["sing-box", "sing-box.exe"]) } fn adopt_existing_singbox_if_possible() -> Result { - let output = Command::new("pgrep") - .arg("-x") - .arg("sing-box") - .output() - .context("Failed to run pgrep")?; + let mut pids = platform::find_pids_by_name("sing-box")?; - if !output.status.success() { - return Ok(false); + if pids.is_empty() && cfg!(windows) { + pids = platform::find_pids_by_name("sing-box.exe")?; } - let output_text = String::from_utf8_lossy(&output.stdout); - - let pids = output_text - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>(); - if pids.len() != 1 { return Ok(false); } - fs::write(PID_FILE, pids[0]).context("Failed to write SmartRoute PID file")?; + let pid_file = platform::pid_file(); + platform::ensure_runtime_dir()?; + fs::write(&pid_file, pids[0].to_string()) + .with_context(|| format!("Failed to write SmartRoute PID file: {}", pid_file.display()))?; Ok(true) } @@ -341,13 +326,29 @@ fn socks_is_listening(config: &SmartRouteConfig) -> Result { } fn killswitch_is_enabled() -> bool { - Command::new("nft") - .args(["list", "table", "inet", "smartroute"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|status| status.success()) - .unwrap_or(false) + if cfg!(windows) { + Command::new("netsh") + .args([ + "advfirewall", + "firewall", + "show", + "rule", + "group=SmartRoute KillSwitch", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) + } else { + Command::new("nft") + .args(["list", "table", "inet", "smartroute"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) + } } fn count_direct_rules(config: &SmartRouteConfig) -> usize { @@ -373,7 +374,7 @@ fn curl_head(url: &str, socks: Option<&str>, max_time: u64) -> Result { &max_time.to_string(), "-sS", "-o", - "/dev/null", + platform::null_device(), "-w", "%{http_code}", ]) @@ -392,10 +393,7 @@ fn curl_head(url: &str, socks: Option<&str>, max_time: u64) -> Result { } fn temp_singbox_config_path() -> PathBuf { - PathBuf::from(format!( - "/tmp/smartroute-health-singbox-{}.json", - std::process::id() - )) + platform::temp_file("health-singbox", "json") } fn normalize_host(value: &str) -> String { @@ -429,6 +427,6 @@ fn tail_log_hint() { let _ = fs::File::options() .create(true) .append(true) - .open(LOG_FILE) + .open(platform::log_file()) .and_then(|mut file| writeln!(file, "SmartRoute health check touched log")); } From f1d0bdf953d7481022e847a9f86abf6dc33534cf Mon Sep 17 00:00:00 2001 From: Jyste Date: Sun, 10 May 2026 20:12:29 +0300 Subject: [PATCH 8/9] Use platform paths in doctor --- src/doctor.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/doctor.rs b/src/doctor.rs index bc96798..c459f5c 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -1,5 +1,6 @@ use crate::{ config::{SmartRouteConfig, load_config, validate_config}, + platform, singbox::generate_singbox_config, }; use anyhow::Result; @@ -554,7 +555,7 @@ fn check_generated_singbox(config: &SmartRouteConfig, report: &mut DoctorReport) return; } - match Command::new("sing-box") + match Command::new(platform::singbox_bin()) .arg("check") .arg("-c") .arg(&path) @@ -630,10 +631,7 @@ fn is_same_or_subdomain(name: &str, base: &str) -> bool { } fn temp_singbox_config_path() -> PathBuf { - PathBuf::from(format!( - "/tmp/smartroute-doctor-singbox-{}.json", - std::process::id() - )) + platform::temp_file("doctor-singbox", "json") } fn redact_url(url: &str) -> String { From fb98966560fc1193d5fccae0d3d7125bbe02502d Mon Sep 17 00:00:00 2001 From: Jyste Date: Sun, 10 May 2026 20:14:42 +0300 Subject: [PATCH 9/9] Add cross-platform CI --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0773fbe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [ master, windows-support ] + pull_request: + branches: [ master ] + +jobs: + test: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Format check + run: cargo fmt --all -- --check + + - name: Cargo check + run: cargo check --all-targets + + - name: Tests + run: cargo test --all