Skip to content
170 changes: 94 additions & 76 deletions nmrs/src/core/vpn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,36 @@ use crate::dbus::{NMActiveConnectionProxy, NMProxy};
use crate::util::utils::{extract_connection_state_reason, nm_proxy, settings_proxy};
use crate::util::validation::{validate_connection_name, validate_vpn_credentials};

// Detects the VPN type from a raw NM connection settings map.
// WireGuard: connection.type == "wireguard"
// OpenVPN: connection.type == "vpn" + vpn.service-type == "org.freedesktop.NetworkManager.openvpn"
fn detect_vpn_type(
settings: &HashMap<String, HashMap<String, zvariant::Value<'_>>>,
) -> Option<VpnType> {
let conn = settings.get("connection")?;
let conn_type = match conn.get("type")? {
zvariant::Value::Str(s) => s.as_str(),
_ => return None,
};

match conn_type {
"wireguard" => Some(VpnType::WireGuard),
"vpn" => {
let vpn = settings.get("vpn")?;
let service = match vpn.get("service-type")? {
zvariant::Value::Str(s) => s.as_str(),
_ => return None,
};
if service == "org.freedesktop.NetworkManager.openvpn" {
Some(VpnType::OpenVpn)
} else {
None
}
}
_ => None,
}
}

/// Connects to a WireGuard connection.
///
/// This function checks for an existing saved connection by name.
Expand Down Expand Up @@ -69,7 +99,12 @@ pub(crate) async fn connect_vpn(
autoconnect_retries: None,
};

let settings = build_wireguard_connection(&creds, &opts)?;
let settings = match creds.vpn_type {
VpnType::WireGuard => build_wireguard_connection(&creds, &opts)?,
VpnType::OpenVpn => return Err(ConnectionError::VpnFailed(
"OpenVPN connect not yet implemented".into()
)),
};

let settings_api = settings_proxy(conn).await?;

Expand Down Expand Up @@ -237,16 +272,10 @@ pub(crate) async fn disconnect_vpn(conn: &Connection, name: &str) -> Result<()>
})
.unwrap_or(false);

let is_wireguard = conn_sec
.get("type")
.and_then(|v| match v {
zvariant::Value::Str(s) => Some(s.as_str() == "wireguard"),
_ => None,
})
.unwrap_or(false);
let is_vpn = detect_vpn_type(&settings_map).is_some();

if id_match && is_wireguard {
debug!("Found active WireGuard connection, deactivating: {name}");
if id_match && is_vpn {
debug!("Found active VPN connection, deactivating: {name}");
match nm.deactivate_connection(ac_path.clone()).await {
Ok(_) => info!("Successfully disconnected VPN: {name}"),
Err(e) => warn!("Failed to deactivate connection {}: {}", ac_path, e),
Expand Down Expand Up @@ -368,12 +397,7 @@ pub(crate) async fn list_vpn_connections(conn: &Connection) -> Result<Vec<VpnCon
_ => continue,
};

let conn_type = match conn_sec.get("type") {
Some(zvariant::Value::Str(s)) => s.as_str(),
_ => continue,
};

if conn_type != "wireguard" {
if detect_vpn_type(&settings_map).is_none() {
continue;
}

Expand Down Expand Up @@ -474,14 +498,9 @@ pub(crate) async fn list_vpn_connections(conn: &Connection) -> Result<Vec<VpnCon
_ => continue,
};

let conn_type = match conn_sec.get("type") {
Some(zvariant::Value::Str(s)) => s.as_str(),
_ => continue,
};

if conn_type != "wireguard" {
let Some(vpn_type) = detect_vpn_type(&settings_map) else {
continue;
}
};

let (state, interface) = active_wg_map
.get(&id)
Expand All @@ -490,7 +509,7 @@ pub(crate) async fn list_vpn_connections(conn: &Connection) -> Result<Vec<VpnCon

wg_conns.push(VpnConnection {
name: id,
vpn_type: VpnType::WireGuard,
vpn_type,
interface,
state,
});
Expand All @@ -503,7 +522,6 @@ pub(crate) async fn list_vpn_connections(conn: &Connection) -> Result<Vec<VpnCon
///
/// If currently connected, the connection will be disconnected first before deletion.
pub(crate) async fn forget_vpn(conn: &Connection, name: &str) -> Result<()> {
// Validate connection name
validate_connection_name(name)?;

debug!("Starting forget operation for VPN: {name}");
Expand Down Expand Up @@ -550,36 +568,30 @@ pub(crate) async fn forget_vpn(conn: &Connection, name: &str) -> Result<()> {
};

let body = msg.body();
let settings_map: HashMap<String, HashMap<String, zvariant::Value>> = body.deserialize()?;
let settings_map: HashMap<String, HashMap<String, zvariant::Value>> =
body.deserialize()?;

if let Some(conn_sec) = settings_map.get("connection") {
let id_ok = conn_sec
.get("id")
.and_then(|v| match v {
zvariant::Value::Str(s) => Some(s.as_str() == name),
_ => None,
})
.unwrap_or(false);
let id_ok = settings_map
.get("connection")
.and_then(|c| c.get("id"))
.and_then(|v| match v {
zvariant::Value::Str(s) => Some(s.as_str() == name),
_ => None,
})
.unwrap_or(false);

let type_ok = conn_sec
.get("type")
.and_then(|v| match v {
zvariant::Value::Str(s) => Some(s.as_str() == "wireguard"),
_ => None,
})
.unwrap_or(false);
let is_vpn = detect_vpn_type(&settings_map).is_some();

if id_ok && type_ok {
debug!("Found WireGuard connection, deleting: {name}");
cproxy.call_method("Delete", &()).await.map_err(|e| {
ConnectionError::DbusOperation {
context: format!("failed to delete VPN connection '{}'", name),
source: e,
}
})?;
info!("Successfully deleted VPN connection: {name}");
return Ok(());
}
if id_ok && is_vpn {
debug!("Found VPN connection, deleting: {name}");
cproxy.call_method("Delete", &()).await.map_err(|e| {
ConnectionError::DbusOperation {
context: format!("failed to delete VPN connection '{}'", name),
source: e,
}
})?;
info!("Successfully deleted VPN connection: {name}");
return Ok(());
}
}

Expand Down Expand Up @@ -677,18 +689,14 @@ pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result<VpnCon
_ => continue,
};

let conn_type = match conn_sec.get("type") {
Some(zvariant::Value::Str(s)) => s.as_str(),
_ => continue,
let Some(vpn_type) = detect_vpn_type(&settings_map) else {
continue;
};

if conn_type != "wireguard" || id != name {
if id != name {
continue;
}

// WireGuard type is known by connection.type
let vpn_type = VpnType::WireGuard;

// ActiveConnection state
let state_val: u32 = ac_proxy.get_property("State").await?;
let state = DeviceState::from(state_val);
Expand All @@ -707,25 +715,35 @@ pub(crate) async fn get_vpn_info(conn: &Connection, name: &str) -> Result<VpnCon
None
};

// Best-effort endpoint extraction from wireguard.peers (nmcli-style string)
// This is not guaranteed to exist or be populated.
let gateway = settings_map
.get("wireguard")
.and_then(|wg_sec| wg_sec.get("peers"))
.and_then(|v| match v {
zvariant::Value::Str(s) => Some(s.as_str().to_string()),
_ => None,
})
.and_then(|peers| {
// peers: "pubkey endpoint=host:port allowed-ips=... , pubkey2 ..."
let first = peers.split(',').next()?.trim().to_string();
for tok in first.split_whitespace() {
if let Some(rest) = tok.strip_prefix("endpoint=") {
return Some(rest.to_string());
// Best-effort endpoint extraction from the connection settings.
// WireGuard reads from wireguard.peers (nmcli-style string).
// OpenVPN reads from vpn.data["remote"] (not yet implemented, see FIXME below).
// Neither is guaranteed to be populated.
let gateway = match vpn_type {
VpnType::WireGuard => settings_map
.get("wireguard")
.and_then(|wg_sec| wg_sec.get("peers"))
.and_then(|v| match v {
zvariant::Value::Str(s) => Some(s.as_str().to_string()),
_ => None,
})
.and_then(|peers| {
let first = peers.split(',').next()?.trim().to_string();
for tok in first.split_whitespace() {
if let Some(rest) = tok.strip_prefix("endpoint=") {
return Some(rest.to_string());
}
}
}
None
}),
VpnType::OpenVpn => {
// FIXME : vpn.data is a Dict<String, String> in NM, not a plain string.
// Need to deserialize as HashMap<String, String> and look up "remote".
// Cannot test without a real NM OpenVPN profile.
None
});

}
};

// IPv4 config
let ip4_path: OwnedObjectPath = ac_proxy.get_property("Ip4Config").await?;
Expand Down