feat: add tunnel status checking with active interface verification

Add tunnel_status command to desktop client for querying active tunnel state. Add is_active method to tunnel_manager that calls status command on bundled backend. Add status command to tunnel-helper that checks WireGuard service state on Windows via sc query and interface state on macOS via wg show. Add windows_client_status function for IPC-based status queries with active field in TunnelResponse. Update App.tsx to query tunnel status on
This commit is contained in:
2026-03-18 07:02:39 +01:00
parent 0b29331f26
commit 31369a7743
5 changed files with 145 additions and 6 deletions

View File

@@ -54,6 +54,7 @@ struct TunnelRequest {
struct TunnelResponse {
ok: bool,
error: Option<String>,
active: Option<bool>,
}
fn main() -> ExitCode {
@@ -76,6 +77,12 @@ fn run() -> Result<(), String> {
"service" => run_windows_service_dispatcher(),
"install-service" => install_windows_service(),
"uninstall-service" => uninstall_windows_service(),
"status" => {
let profile = parse_profile_arg(args)?;
let active = windows_client_status(&profile)?;
println!("{}", if active { "active" } else { "inactive" });
Ok(())
}
"connect" | "disconnect" => {
let profile = parse_profile_arg(args)?;
windows_client_request(command.as_str(), &profile)
@@ -88,6 +95,11 @@ fn run() -> Result<(), String> {
{
let profile = parse_profile_arg(args)?;
match command.as_str() {
"status" => {
let active = tunnel_is_active(&profile)?;
println!("{}", if active { "active" } else { "inactive" });
Ok(())
}
"connect" => connect_direct(&profile),
"disconnect" => disconnect_direct(&profile),
_ => Err("unsupported command".into()),
@@ -201,22 +213,37 @@ fn handle_service_client(mut stream: TcpStream) -> Result<(), String> {
.map_err(|err| format!("Unable to decode IPC request: {err}"))?;
let response = match request.action.as_str() {
"connect" => match connect_direct(Path::new(&request.profile)) {
Ok(()) => TunnelResponse { ok: true, error: None },
Ok(()) => TunnelResponse { ok: true, error: None, active: None },
Err(err) => TunnelResponse {
ok: false,
error: Some(err),
active: None,
},
},
"disconnect" => match disconnect_direct(Path::new(&request.profile)) {
Ok(()) => TunnelResponse { ok: true, error: None },
Ok(()) => TunnelResponse { ok: true, error: None, active: None },
Err(err) => TunnelResponse {
ok: false,
error: Some(err),
active: None,
},
},
"status" => match tunnel_is_active(Path::new(&request.profile)) {
Ok(active) => TunnelResponse {
ok: true,
error: None,
active: Some(active),
},
Err(err) => TunnelResponse {
ok: false,
error: Some(err),
active: None,
},
},
_ => TunnelResponse {
ok: false,
error: Some("unsupported tunnel action".into()),
active: None,
},
};
@@ -314,6 +341,41 @@ fn windows_client_request(action: &str, profile: &Path) -> Result<(), String> {
.unwrap_or_else(|| "NexaVPN tunnel service reported an unknown error".into()))
}
#[cfg(target_os = "windows")]
fn windows_client_status(profile: &Path) -> Result<bool, String> {
let mut stream = TcpStream::connect(IPC_BIND_ADDR).map_err(|_| {
"NexaVPN background service is not available. Reinstall NexaVPN or install the tunnel service once as administrator."
.to_string()
})?;
let payload = serde_json::to_string(&TunnelRequest {
action: "status".to_string(),
profile: profile.display().to_string(),
})
.map_err(|err| format!("Unable to encode tunnel status request: {err}"))?;
stream
.write_all(payload.as_bytes())
.and_then(|_| stream.write_all(b"\n"))
.map_err(|err| format!("Unable to send tunnel status request: {err}"))?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader
.read_line(&mut line)
.map_err(|err| format!("Unable to read tunnel status response: {err}"))?;
let response = serde_json::from_str::<TunnelResponse>(&line)
.map_err(|err| format!("Unable to decode tunnel status response: {err}"))?;
if !response.ok {
return Err(response
.error
.unwrap_or_else(|| "NexaVPN tunnel service status check failed".into()));
}
Ok(response.active.unwrap_or(false))
}
fn connect_direct(profile: &Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
@@ -386,6 +448,47 @@ fn disconnect_direct(profile: &Path) -> Result<(), String> {
Err("unsupported platform".into())
}
fn tunnel_is_active(profile: &Path) -> Result<bool, String> {
#[cfg(target_os = "windows")]
{
let tunnel_name = profile
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| "invalid profile filename".to_string())?;
let service_name = format!("WireGuardTunnel${}", tunnel_name);
let output = Command::new("sc")
.arg("query")
.arg(&service_name)
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|err| format!("Unable to query tunnel status: {err}"))?;
if !output.status.success() {
return Ok(false);
}
let stdout = String::from_utf8_lossy(&output.stdout);
return Ok(stdout.contains("RUNNING"));
}
#[cfg(target_os = "macos")]
{
let tunnel_name = profile
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| "invalid profile filename".to_string())?;
let status = Command::new("wg")
.arg("show")
.arg(tunnel_name)
.status()
.map_err(|err| format!("Unable to query tunnel status: {err}"))?;
return Ok(status.success());
}
#[allow(unreachable_code)]
Err("unsupported platform".into())
}
#[cfg(target_os = "windows")]
fn find_windows_wireguard() -> Result<PathBuf, String> {
let candidates = [