Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
97 changes: 94 additions & 3 deletions src/autostart.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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()))?;
Expand Down Expand Up @@ -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();
Expand All @@ -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()
Expand All @@ -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)
Expand Down
8 changes: 3 additions & 5 deletions src/doctor.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::{
config::{SmartRouteConfig, load_config, validate_config},
platform,
singbox::generate_singbox_config,
};
use anyhow::Result;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
98 changes: 48 additions & 50 deletions src/health.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -266,7 +268,8 @@ fn check_generated_singbox_config(config: &SmartRouteConfig) -> Result<()> {
}

fn pid_file_process_is_running() -> Result<bool> {
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);
};

Expand All @@ -276,56 +279,38 @@ fn pid_file_process_is_running() -> Result<bool> {
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::<u32>()
.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<bool> {
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::<Vec<_>>();

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)
}
Expand All @@ -341,13 +326,29 @@ fn socks_is_listening(config: &SmartRouteConfig) -> Result<bool> {
}

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 {
Expand All @@ -373,7 +374,7 @@ fn curl_head(url: &str, socks: Option<&str>, max_time: u64) -> Result<bool> {
&max_time.to_string(),
"-sS",
"-o",
"/dev/null",
platform::null_device(),
"-w",
"%{http_code}",
])
Expand All @@ -392,10 +393,7 @@ fn curl_head(url: &str, socks: Option<&str>, max_time: u64) -> Result<bool> {
}

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 {
Expand Down Expand Up @@ -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"));
}
Loading
Loading