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

@@ -143,6 +143,7 @@ EOF
echo "Applied WireGuard config from ${WG_CONF}"
echo "Applied nftables config from ${NFT_CONF}"
wg show "${IFACE}" latest-handshakes transfer 2>/dev/null || true
}
while true; do

View File

@@ -304,6 +304,13 @@ fn disconnect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), S
tunnel_manager::disconnect(&app, std::path::Path::new(&session.profile_path))
}
#[tauri::command]
fn tunnel_status(app: AppHandle, state: State<'_, AppState>) -> Result<bool, String> {
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
let session = session.as_ref().ok_or_else(|| "No active session is available".to_string())?;
tunnel_manager::is_active(&app, std::path::Path::new(&session.profile_path))
}
fn generate_keypair() -> (String, String) {
let private = StaticSecret::random_from_rng(OsRng);
let public = PublicKey::from(&private);
@@ -431,7 +438,7 @@ pub fn run() {
}
_ => {}
})
.invoke_handler(tauri::generate_handler![load_state, clear_session, enroll_device, sync_profile, connect_tunnel, disconnect_tunnel])
.invoke_handler(tauri::generate_handler![load_state, clear_session, enroll_device, sync_profile, connect_tunnel, disconnect_tunnel, tunnel_status])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -47,6 +47,23 @@ pub fn disconnect(app: &AppHandle, profile_path: &Path) -> Result<(), String> {
Ok(())
}
pub fn is_active(app: &AppHandle, profile_path: &Path) -> Result<bool, String> {
let backend = bundled_backend(app)?;
let output = Command::new(backend)
.arg("status")
.arg("--profile")
.arg(profile_path)
.output()
.map_err(|err| format!("Unable to query embedded tunnel backend: {}", err))?;
if !output.status.success() {
return Err(format_helper_error("status", &output));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.trim().eq_ignore_ascii_case("active"))
}
fn bundled_backend(app: &AppHandle) -> Result<PathBuf, String> {
let resource_dir = app
.path()

View File

@@ -51,9 +51,15 @@ export function App() {
useEffect(() => {
void invoke<EnrollmentState | null>("load_state")
.then((value) => {
.then(async (value) => {
if (value) {
setState(value);
try {
const active = await invoke<boolean>("tunnel_status");
setConnected(active);
} catch {
setConnected(false);
}
}
})
.catch(() => undefined);
@@ -96,8 +102,13 @@ export function App() {
const command = connected ? "disconnect_tunnel" : "connect_tunnel";
try {
await invoke(command);
setConnected((value) => !value);
const active = await invoke<boolean>("tunnel_status");
setConnected(active);
if (!connected && !active) {
setError("Tunnel was installed, but no active interface could be verified yet.");
} else {
setError(null);
}
} catch (err) {
setError(formatInvokeError(err, "Tunnel action failed"));
}

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 = [