use crate::app::{Protocol, VpnProfile};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::SystemTime;
fn cmd_output(cmd: &mut Command) -> Option<std::process::Output> {
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.ok()
}
#[derive(Clone, Default, Debug)]
pub struct ActiveSession {
pub name: String,
pub pid: Option<u32>,
pub started_at: Option<SystemTime>,
pub interface: String,
pub internal_ip: String,
pub endpoint: String,
pub mtu: String,
pub public_key: String,
pub listen_port: String,
pub transfer_rx: String,
pub transfer_tx: String,
pub latest_handshake: String,
}
#[must_use]
pub fn get_active_profiles(profiles: &[VpnProfile]) -> Vec<ActiveSession> {
let mut active = Vec::new();
let openvpn_pids = get_all_openvpn_pids();
for profile in profiles {
let session_info = match profile.protocol {
Protocol::WireGuard => check_wireguard_by_name(&profile.name),
Protocol::OpenVPN => {
let path_str = profile.config_path.to_str().unwrap_or("");
openvpn_pids
.iter()
.find(|(path, _)| path.contains(path_str) || path_str.contains(*path))
.and_then(|(_, &pid)| check_openvpn_by_pid(pid, &profile.config_path))
}
};
if let Some(mut session) = session_info {
session.name.clone_from(&profile.name);
active.push(session);
}
}
active
}
fn get_all_openvpn_pids() -> std::collections::HashMap<String, u32> {
let mut pids = std::collections::HashMap::new();
if let Some(output) = cmd_output(Command::new("ps").args(["-ax", "-o", "pid,command"])) {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines().skip(1) {
let line = line.trim();
if line.contains("openvpn") && line.contains("--config") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
if let Ok(pid) = parts[0].parse::<u32>() {
let cmd = parts[1..].join(" ");
pids.insert(cmd, pid);
}
}
}
}
}
pids
}
fn check_wireguard_by_name(name: &str) -> Option<ActiveSession> {
use crate::platform::InterfaceDetector;
#[cfg(target_os = "macos")]
type PlatformInterface = crate::platform::macos::interface::MacInterface;
#[cfg(target_os = "linux")]
type PlatformInterface = crate::platform::linux::interface::LinuxInterface;
if !PlatformInterface::check_wireguard_interface(name) {
return None;
}
let interface_name =
PlatformInterface::resolve_wireguard_interface(name).unwrap_or_else(|| name.to_string());
let mut session = ActiveSession {
interface: interface_name.clone(),
..Default::default()
};
if let Some(pid) = PlatformInterface::get_wireguard_pid(&interface_name) {
session.pid = Some(pid);
if let Some(output) =
cmd_output(Command::new("ps").args(["-p", &pid.to_string(), "-o", "etime="]))
{
let etime = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !etime.is_empty() {
if let Some(duration) = parse_ps_etime(&etime) {
session.started_at = SystemTime::now().checked_sub(duration);
}
}
}
}
#[cfg(target_os = "macos")]
if session.started_at.is_none() {
let pid_file =
PathBuf::from(crate::constants::WIREGUARD_RUN_DIR).join(format!("{name}.name"));
if pid_file.exists() {
session.started_at = std::fs::metadata(&pid_file).and_then(|m| m.created()).ok();
}
}
if session.started_at.is_none() {
crate::logger::log(
crate::logger::LogLevel::Debug,
"SCANNER",
format!(
"Could not determine start time for WireGuard interface '{interface_name}' (ps/metadata fallbacks failed)"
),
);
}
if let Some(output) = cmd_output(Command::new("wg").args(["show", &interface_name])) {
let out = String::from_utf8_lossy(&output.stdout);
for line in out.lines() {
let line = line.trim();
if let Some(v) = line.strip_prefix("public key: ") {
session.public_key = v.to_string();
}
if let Some(v) = line.strip_prefix("listening port: ") {
session.listen_port = v.to_string();
}
if let Some(v) = line.strip_prefix("endpoint: ") {
session.endpoint = v.to_string();
}
if let Some(v) = line.strip_prefix("latest handshake: ") {
session.latest_handshake = v.to_string();
}
if let Some(v) = line.strip_prefix("transfer: ") {
let parts: Vec<&str> = v.split_terminator(',').collect();
if parts.len() >= 2 {
session.transfer_rx = parts[0].trim().replace(" received", "");
session.transfer_tx = parts[1].trim().replace(" sent", "");
}
}
}
}
let (ip, mtu) = PlatformInterface::get_interface_info(&interface_name);
if !ip.is_empty() {
session.internal_ip = ip;
}
if !mtu.is_empty() {
session.mtu = mtu;
}
Some(session)
}
#[allow(clippy::too_many_lines)]
fn check_openvpn_by_pid(pid: u32, config_path: &Path) -> Option<ActiveSession> {
let mut session = ActiveSession {
pid: Some(pid),
..Default::default()
};
if let Some(output) =
cmd_output(Command::new("ps").args(["-p", &pid.to_string(), "-o", "etime="]))
{
let etime = String::from_utf8_lossy(&output.stdout);
let etime = etime.trim();
if !etime.is_empty() {
if let Some(duration) = parse_ps_etime(etime) {
session.started_at = SystemTime::now().checked_sub(duration);
}
}
}
let mut detected_iface = String::new();
#[cfg(target_os = "macos")]
{
if let Some(output) =
cmd_output(Command::new("lsof").args(["-n", "-P", "-p", &pid.to_string()]))
{
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if let Some(idx) = line.find("/dev/") {
let dev_path = line[idx..].split_whitespace().next().unwrap_or("");
if dev_path.contains("utun")
|| dev_path.contains("tun")
|| dev_path.contains("tap")
{
detected_iface = dev_path.trim_start_matches("/dev/").to_string();
break;
}
}
}
}
}
#[cfg(target_os = "macos")]
{
if let Some(output) = cmd_output(&mut Command::new("ifconfig")) {
let stdout = String::from_utf8_lossy(&output.stdout);
let mut current_iface = String::new();
let mut found_openvpn_iface = false;
let mut iface_mtu = String::new();
for line in stdout.lines() {
if !line.starts_with(' ') && !line.starts_with('\t') {
if let Some(iface_name) = line.split(':').next() {
current_iface = iface_name.to_string();
if detected_iface.is_empty() {
found_openvpn_iface = current_iface.starts_with("utun")
|| current_iface.starts_with("tun")
|| current_iface.starts_with("tap");
} else {
found_openvpn_iface = current_iface == detected_iface;
}
if found_openvpn_iface {
if let Some(mtu_idx) = line.find("mtu ") {
iface_mtu = line[mtu_idx + 4..]
.split_whitespace()
.next()
.unwrap_or("")
.to_string();
if !detected_iface.is_empty() {
session.interface.clone_from(&detected_iface);
session.mtu.clone_from(&iface_mtu);
}
}
}
}
} else if found_openvpn_iface {
let line = line.trim();
if line.starts_with("inet ") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let wg_check =
cmd_output(Command::new("wg").args(["show", ¤t_iface]));
if !matches!(wg_check, Some(o) if o.status.success()) {
session.internal_ip = parts[1].to_string();
session.mtu.clone_from(&iface_mtu);
session.interface.clone_from(¤t_iface);
break;
}
}
}
}
}
}
}
#[cfg(target_os = "linux")]
{
if let Some(output) = cmd_output(Command::new("ip").args(["addr"])) {
let stdout = String::from_utf8_lossy(&output.stdout);
let mut current_iface = String::new();
let mut found_tun = false;
for line in stdout.lines() {
if !line.starts_with(' ') {
if let Some(name_part) = line.split(':').nth(1) {
current_iface = name_part.trim().to_string();
found_tun =
current_iface.starts_with("tun") || current_iface.starts_with("tap");
if found_tun {
let wg_check =
cmd_output(Command::new("wg").args(["show", ¤t_iface]));
if matches!(wg_check, Some(o) if o.status.success()) {
found_tun = false;
continue;
}
if let Some(mtu_idx) = line.find("mtu ") {
session.mtu = line[mtu_idx + 4..]
.split_whitespace()
.next()
.unwrap_or("")
.to_string();
}
detected_iface.clone_from(¤t_iface);
}
}
} else if found_tun {
let trimmed = line.trim();
if trimmed.starts_with("inet ") {
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 2 {
session.internal_ip =
parts[1].split('/').next().unwrap_or("").to_string();
session.interface.clone_from(¤t_iface);
break;
}
}
}
}
}
}
if session.interface.is_empty() && !detected_iface.is_empty() {
session.interface = detected_iface;
}
if session.interface.is_empty() {
crate::logger::log(
crate::logger::LogLevel::Debug,
"SCANNER",
format!("OpenVPN pid {pid} running but no tunnel interface detected yet"),
);
return None;
}
if let Some(output) =
cmd_output(Command::new("ps").args(["-p", &pid.to_string(), "-o", "args="]))
{
let args = String::from_utf8_lossy(&output.stdout);
if let Some(remote_idx) = args.find("--remote") {
let rest = args.get(remote_idx + "--remote ".len()..).unwrap_or("");
let parts: Vec<&str> = rest.split_whitespace().collect();
if !parts.is_empty() {
let host = parts[0];
let port = parts.get(1).unwrap_or(&"1194");
session.endpoint = format!("{host}:{port}");
}
}
}
session.public_key = "OpenVPN".to_string();
if let Ok(config_content) = std::fs::read_to_string(config_path) {
if session.endpoint.is_empty() {
for line in config_content.lines() {
let line = line.trim();
if line.to_lowercase().starts_with("remote ") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let host = parts[1];
let port = parts.get(2).unwrap_or(&"1194");
session.endpoint = format!("{host}:{port}");
break;
}
}
}
}
for line in config_content.lines() {
let line = line.trim();
if line.to_lowercase().starts_with("cipher ") {
if let Some(cipher) = line.split_whitespace().nth(1) {
session.latest_handshake = format!("Cipher: {cipher}");
break;
}
}
}
}
Some(session)
}
fn parse_ps_etime(etime: &str) -> Option<std::time::Duration> {
use std::time::Duration;
let etime = etime.trim();
if etime.is_empty() || etime == "-" {
return None;
}
if !etime.contains(':') {
return etime.parse::<u64>().ok().map(Duration::from_secs);
}
let parts: Vec<&str> = etime.split(':').collect();
if parts.len() < 2 {
return None;
}
let mut seconds = 0u64;
let secs: u64 = parts.last()?.parse().ok()?;
let mins: u64 = parts[parts.len() - 2].parse().ok()?;
seconds += secs + (mins * 60);
if parts.len() >= 3 {
let hour_part = parts[parts.len() - 3];
if let Some(dash_idx) = hour_part.find('-') {
let days: u64 = hour_part[..dash_idx].parse().ok()?;
let hours: u64 = hour_part[dash_idx + 1..].parse().ok()?;
seconds += (days * 86400) + (hours * 3600);
} else {
let hours: u64 = hour_part.parse().ok()?;
seconds += hours * 3600;
}
}
if parts.len() == 4 && !parts[0].contains('-') {
let days: u64 = parts[0].parse().ok()?;
seconds += days * 86400;
}
Some(Duration::from_secs(seconds))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_parse_ps_etime_minutes_seconds() {
assert_eq!(parse_ps_etime("01:23"), Some(Duration::from_secs(83)));
assert_eq!(parse_ps_etime("00:05"), Some(Duration::from_secs(5)));
assert_eq!(parse_ps_etime("59:59"), Some(Duration::from_secs(3599)));
}
#[test]
fn test_parse_ps_etime_hours_minutes_seconds() {
assert_eq!(parse_ps_etime("1:02:03"), Some(Duration::from_secs(3723)));
assert_eq!(parse_ps_etime("12:34:56"), Some(Duration::from_secs(45296)));
}
#[test]
fn test_parse_ps_etime_days_hours_minutes_seconds() {
assert_eq!(
parse_ps_etime("2-03:04:05"),
Some(Duration::from_secs(2 * 86400 + 3 * 3600 + 4 * 60 + 5))
);
assert_eq!(
parse_ps_etime("1-00:00:00"),
Some(Duration::from_secs(86400))
);
}
#[test]
fn test_parse_ps_etime_just_seconds() {
assert_eq!(parse_ps_etime("5"), Some(Duration::from_secs(5)));
assert_eq!(parse_ps_etime("0"), Some(Duration::from_secs(0)));
}
#[test]
fn test_parse_ps_etime_empty_and_invalid() {
assert_eq!(parse_ps_etime(""), None);
assert_eq!(parse_ps_etime("-"), None);
assert_eq!(parse_ps_etime("abc"), None);
}
#[test]
fn test_parse_ps_etime_whitespace() {
assert_eq!(parse_ps_etime(" 01:23 "), Some(Duration::from_secs(83)));
assert_eq!(parse_ps_etime(" 5 "), Some(Duration::from_secs(5)));
}
}