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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
setError(null);
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user