use std::net::{IpAddr, SocketAddr};
use std::process::Command as ProcessCommand;
use std::sync::Arc;
#[cfg(any(target_os = "macos", target_os = "windows"))]
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
#[cfg(any(target_os = "linux", target_os = "macos"))]
use boringtun::device::tun::TunSocket;
#[cfg(target_os = "windows")]
use wintun::Session as WintunSession;
use nostr_vpn_core::config::WireGuardExitConfig;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use nostr_vpn_core::wg_upstream::MAX_WG_PACKET;
pub use nostr_vpn_core::wg_upstream::WgUpstreamRuntime;
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub use nostr_vpn_core::wg_upstream::{
DAEMON_WG_UPSTREAM_HANDSHAKE_TIMEOUT, WireGuardExitFingerprint,
};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub async fn start_wg_runtime_with_posix_tun(
config: &WireGuardExitConfig,
tun: Arc<TunSocket>,
) -> Result<WgUpstreamRuntime> {
let (in_tx, in_rx) = mpsc::channel::<Vec<u8>>(256);
let (out_tx, out_rx) = mpsc::channel::<Vec<u8>>(256);
let reader = spawn_posix_tun_reader(tun.clone(), in_tx);
let writer = spawn_posix_tun_writer(tun, out_rx);
WgUpstreamRuntime::start_with_io(config, Some((in_rx, out_tx)), Some((reader, writer))).await
}
#[cfg(target_os = "windows")]
pub async fn start_wg_runtime_with_wintun(
config: &WireGuardExitConfig,
session: Arc<WintunSession>,
) -> Result<WgUpstreamRuntime> {
let (in_tx, in_rx) = mpsc::channel::<Vec<u8>>(256);
let (out_tx, out_rx) = mpsc::channel::<Vec<u8>>(256);
let reader = spawn_wintun_reader(session.clone(), in_tx);
let writer = spawn_wintun_writer(session, out_rx);
WgUpstreamRuntime::start_with_io(config, Some((in_rx, out_tx)), Some((reader, writer))).await
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn spawn_posix_tun_reader(tun: Arc<TunSocket>, tun_tx: mpsc::Sender<Vec<u8>>) -> JoinHandle<()> {
use std::os::unix::io::{AsRawFd, RawFd};
use tokio::io::Interest;
use tokio::io::unix::AsyncFd;
struct BorrowedFd(RawFd);
impl AsRawFd for BorrowedFd {
fn as_raw_fd(&self) -> RawFd {
self.0
}
}
tokio::spawn(async move {
let async_fd = match AsyncFd::with_interest(BorrowedFd(tun.as_raw_fd()), Interest::READABLE)
{
Ok(fd) => fd,
Err(error) => {
tracing::warn!(?error, "wg-upstream: failed to register tun fd");
return;
}
};
let mut buf = vec![0u8; MAX_WG_PACKET];
loop {
let mut guard = match async_fd.readable().await {
Ok(g) => g,
Err(error) => {
tracing::warn!(?error, "wg-upstream: tun reactor error");
return;
}
};
match tun.read(&mut buf) {
Ok([]) => guard.clear_ready(),
Ok(packet) => {
let bytes = packet.to_vec();
if tun_tx.send(bytes).await.is_err() {
return;
}
}
Err(_) => guard.clear_ready(),
}
}
})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn spawn_posix_tun_writer(tun: Arc<TunSocket>, mut rx: mpsc::Receiver<Vec<u8>>) -> JoinHandle<()> {
tokio::spawn(async move {
while let Some(packet) = rx.recv().await {
match packet.first().map(|byte| byte >> 4) {
Some(4) => {
let _ = tun.write4(&packet);
}
Some(6) => {
let _ = tun.write6(&packet);
}
_ => {}
}
}
})
}
#[cfg(target_os = "windows")]
fn spawn_wintun_reader(
session: Arc<WintunSession>,
tun_tx: mpsc::Sender<Vec<u8>>,
) -> JoinHandle<()> {
tokio::spawn(async move {
loop {
match session.try_receive() {
Ok(Some(packet)) => {
let bytes = packet.bytes().to_vec();
drop(packet);
if tun_tx.send(bytes).await.is_err() {
return;
}
}
Ok(None) => {
tokio::time::sleep(Duration::from_millis(10)).await;
}
Err(error) => {
tracing::warn!(?error, "wg-upstream: wintun receive failed");
return;
}
}
}
})
}
#[cfg(target_os = "windows")]
fn spawn_wintun_writer(
session: Arc<WintunSession>,
mut rx: mpsc::Receiver<Vec<u8>>,
) -> JoinHandle<()> {
tokio::spawn(async move {
while let Some(packet) = rx.recv().await {
let Ok(size) = u16::try_from(packet.len()) else {
tracing::warn!(
"wg-upstream: wintun packet too large to send ({} bytes)",
packet.len()
);
continue;
};
match session.allocate_send_packet(size) {
Ok(mut outbound) => {
outbound.bytes_mut().copy_from_slice(&packet);
session.send_packet(outbound);
}
Err(error) => {
tracing::warn!(?error, "wg-upstream: wintun allocate_send_packet failed");
}
}
}
})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn apply_scoped_host_route(
iface: &str,
address: &str,
target: IpAddr,
mtu: u16,
) -> Result<ScopedHostRoute> {
let target_str = target.to_string();
let address_ip = address
.split('/')
.next()
.ok_or_else(|| anyhow!("empty WG tunnel address"))?
.to_string();
let mtu_str = mtu.to_string();
#[cfg(target_os = "linux")]
{
run_checked(
ProcessCommand::new("ip")
.arg("address")
.arg("replace")
.arg(format!("{address_ip}/32"))
.arg("dev")
.arg(iface),
)?;
run_checked(
ProcessCommand::new("ip")
.arg("link")
.arg("set")
.arg("mtu")
.arg(&mtu_str)
.arg("up")
.arg("dev")
.arg(iface),
)?;
run_checked(
ProcessCommand::new("ip")
.arg("route")
.arg("replace")
.arg(format!("{target_str}/32"))
.arg("dev")
.arg(iface),
)?;
return Ok(ScopedHostRoute {
iface: iface.to_string(),
target,
});
}
#[cfg(target_os = "macos")]
{
run_checked(
ProcessCommand::new("ifconfig")
.arg(iface)
.arg("inet")
.arg(&address_ip)
.arg(&address_ip)
.arg("netmask")
.arg("255.255.255.255")
.arg("mtu")
.arg(&mtu_str)
.arg("up"),
)?;
run_checked(
ProcessCommand::new("route")
.arg("-n")
.arg("add")
.arg("-host")
.arg(&target_str)
.arg("-interface")
.arg(iface),
)?;
return Ok(ScopedHostRoute {
iface: iface.to_string(),
target,
});
}
#[allow(unreachable_code)]
Err(anyhow!(
"scoped host route is only implemented on Linux and macOS"
))
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub struct ScopedHostRoute {
iface: String,
target: IpAddr,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn apply_full_default_route(
iface: &str,
address: &str,
upstream_endpoint: SocketAddr,
mtu: u16,
) -> Result<FullDefaultRoute> {
let upstream_ip = upstream_endpoint.ip();
let address_ip = address
.split('/')
.next()
.ok_or_else(|| anyhow!("empty WG tunnel address"))?
.to_string();
let mtu_str = mtu.to_string();
#[cfg(target_os = "linux")]
{
run_checked(
ProcessCommand::new("ip")
.arg("address")
.arg("replace")
.arg(format!("{address_ip}/32"))
.arg("dev")
.arg(iface),
)?;
run_checked(
ProcessCommand::new("ip")
.arg("link")
.arg("set")
.arg("mtu")
.arg(&mtu_str)
.arg("up")
.arg("dev")
.arg(iface),
)?;
}
#[cfg(target_os = "macos")]
{
run_checked(
ProcessCommand::new("ifconfig")
.arg(iface)
.arg("inet")
.arg(&address_ip)
.arg(&address_ip)
.arg("netmask")
.arg("255.255.255.255")
.arg("mtu")
.arg(&mtu_str)
.arg("up"),
)?;
}
let original_default = capture_default_route()?;
install_endpoint_bypass(&upstream_ip, &original_default)?;
install_default_via_iface(iface, &address_ip)?;
Ok(FullDefaultRoute {
iface: iface.to_string(),
bypass_target: upstream_ip,
original_default,
})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub struct FullDefaultRoute {
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
iface: String,
bypass_target: IpAddr,
original_default: CapturedDefaultRoute,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[derive(Debug, Clone)]
struct CapturedDefaultRoute {
#[cfg(target_os = "linux")]
line: String,
#[cfg(target_os = "macos")]
gateway: String,
#[cfg(target_os = "macos")]
interface: String,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn capture_default_route() -> Result<CapturedDefaultRoute> {
#[cfg(target_os = "linux")]
{
let output = ProcessCommand::new("ip")
.arg("-4")
.arg("route")
.arg("show")
.arg("default")
.output()
.context("ip route show default")?;
if !output.status.success() {
return Err(anyhow!("ip route show default exited {}", output.status));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout
.lines()
.find(|line| {
let line = line.trim();
!line.is_empty()
&& !line.contains(" dev utun")
&& !line.contains(" dev wg-")
&& !line.contains(" dev nvpn-wg")
})
.or_else(|| stdout.lines().find(|line| !line.trim().is_empty()))
.ok_or_else(|| anyhow!("no IPv4 default route found"))?
.trim()
.to_string();
Ok(CapturedDefaultRoute { line })
}
#[cfg(target_os = "macos")]
{
let output = ProcessCommand::new("netstat")
.arg("-rn")
.arg("-f")
.arg("inet")
.output()
.context("netstat -rn -f inet")?;
if !output.status.success() {
return Err(anyhow!("netstat exited {}", output.status));
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() < 4 || tokens[0] != "default" {
continue;
}
let gateway = tokens[1];
let interface = tokens.last().copied().unwrap_or("");
if interface.starts_with("utun")
|| interface.starts_with("bridge")
|| interface == "lo0"
{
continue;
}
if gateway.starts_with("link#") {
continue;
}
return Ok(CapturedDefaultRoute {
gateway: gateway.to_string(),
interface: interface.to_string(),
});
}
Err(anyhow!(
"no underlay IPv4 default route found in netstat output"
))
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn install_endpoint_bypass(target: &IpAddr, original: &CapturedDefaultRoute) -> Result<()> {
let target_str = target.to_string();
#[cfg(target_os = "linux")]
{
let after_default = original
.line
.strip_prefix("default ")
.unwrap_or(&original.line)
.trim();
let mut command = ProcessCommand::new("ip");
command
.arg("route")
.arg("replace")
.arg(format!("{target_str}/32"));
for arg in after_default.split_whitespace() {
command.arg(arg);
}
run_checked(&mut command)?;
}
#[cfg(target_os = "macos")]
{
run_checked(
ProcessCommand::new("route")
.arg("-n")
.arg("add")
.arg("-host")
.arg(&target_str)
.arg(&original.gateway)
.arg("-ifscope")
.arg(&original.interface),
)?;
}
Ok(())
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn install_default_via_iface(iface: &str, _src: &str) -> Result<()> {
#[cfg(target_os = "linux")]
{
run_checked(
ProcessCommand::new("ip")
.arg("-4")
.arg("route")
.arg("replace")
.arg("default")
.arg("dev")
.arg(iface)
.arg("src")
.arg(_src),
)?;
}
#[cfg(target_os = "macos")]
{
let _ = ProcessCommand::new("route")
.arg("-n")
.arg("delete")
.arg("default")
.status();
run_checked(
ProcessCommand::new("route")
.arg("-n")
.arg("add")
.arg("default")
.arg("-interface")
.arg(iface),
)?;
}
Ok(())
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
impl FullDefaultRoute {
pub fn revert(&mut self) -> Result<()> {
let target_str = self.bypass_target.to_string();
#[cfg(target_os = "linux")]
{
let mut command = ProcessCommand::new("ip");
command.arg("route").arg("replace");
for arg in self.original_default.line.split_whitespace() {
command.arg(arg);
}
run_checked(&mut command)?;
let _ = ProcessCommand::new("ip")
.arg("route")
.arg("del")
.arg(format!("{target_str}/32"))
.status();
}
#[cfg(target_os = "macos")]
{
let _ = ProcessCommand::new("route")
.arg("-n")
.arg("delete")
.arg("default")
.arg("-interface")
.arg(&self.iface)
.status();
run_checked(
ProcessCommand::new("route")
.arg("-n")
.arg("add")
.arg("default")
.arg(&self.original_default.gateway)
.arg("-ifscope")
.arg(&self.original_default.interface),
)?;
let _ = ProcessCommand::new("route")
.arg("-n")
.arg("delete")
.arg("-host")
.arg(&target_str)
.status();
}
Ok(())
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
impl Drop for FullDefaultRoute {
fn drop(&mut self) {
if let Err(error) = self.revert() {
eprintln!(
"wg-upstream: WARNING — failed to restore default route on cleanup: {error}. \
You may need to run `route delete default && route add default <gateway>` \
(macOS) or `ip route replace {}` (Linux) manually.",
self.original_default_repr()
);
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
impl FullDefaultRoute {
fn original_default_repr(&self) -> String {
#[cfg(target_os = "linux")]
{
self.original_default.line.clone()
}
#[cfg(target_os = "macos")]
{
format!(
"default via {} -ifscope {}",
self.original_default.gateway, self.original_default.interface
)
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
impl Drop for ScopedHostRoute {
fn drop(&mut self) {
let target = self.target.to_string();
#[cfg(target_os = "linux")]
{
let _ = ProcessCommand::new("ip")
.arg("route")
.arg("del")
.arg(format!("{target}/32"))
.arg("dev")
.arg(&self.iface)
.status();
}
#[cfg(target_os = "macos")]
{
let _ = ProcessCommand::new("route")
.arg("-n")
.arg("delete")
.arg("-host")
.arg(&target)
.arg("-interface")
.arg(&self.iface)
.status();
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn run_checked(command: &mut ProcessCommand) -> Result<()> {
let status = command
.status()
.with_context(|| format!("spawn {:?}", command.get_program()))?;
if !status.success() {
return Err(anyhow!(
"{:?} {:?} failed: {status}",
command.get_program(),
command
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect::<Vec<_>>()
));
}
Ok(())
}
#[cfg(target_os = "macos")]
pub struct DaemonWgUpstream {
pub iface: String,
pub upstream: SocketAddr,
runtime: Option<WgUpstreamRuntime>,
full_route: Option<FullDefaultRoute>,
_tun: Arc<TunSocket>,
config_fingerprint: WireGuardExitFingerprint,
}
#[cfg(target_os = "windows")]
pub struct DaemonWgUpstream {
pub iface: String,
pub upstream: SocketAddr,
runtime: Option<WgUpstreamRuntime>,
full_route: Option<WindowsFullDefaultRoute>,
_session: Arc<WintunSession>,
_adapter: Arc<wintun::Adapter>,
config_fingerprint: WireGuardExitFingerprint,
}
#[cfg(target_os = "macos")]
pub async fn apply_daemon_wg_upstream(
config: &WireGuardExitConfig,
handshake_timeout: Duration,
) -> Result<DaemonWgUpstream> {
let fingerprint = WireGuardExitFingerprint::from_config(config);
let interface_hint =
if config.interface.trim().is_empty() || !config.interface.starts_with("utun") {
"utun".to_string()
} else {
config.interface.clone()
};
let tun = TunSocket::new(&interface_hint)
.with_context(|| format!("create utun for WG upstream (hint='{interface_hint}')"))?
.set_non_blocking()
.context("set utun non-blocking")?;
let actual_iface = tun.name().context("read utun name (probably needs root)")?;
let tun = Arc::new(tun);
let runtime = start_wg_runtime_with_posix_tun(config, tun.clone())
.await
.context("start userspace WG runtime")?;
let upstream = runtime.upstream();
if !runtime.wait_for_handshake(handshake_timeout).await {
runtime.shutdown().await;
return Err(anyhow!(
"WG upstream handshake to {upstream} did not complete within {}s; \
routing table NOT modified",
handshake_timeout.as_secs()
));
}
let mtu = if config.mtu > 0 { config.mtu } else { 1420 };
let full_route = match apply_full_default_route(&actual_iface, &config.address, upstream, mtu) {
Ok(route) => route,
Err(error) => {
runtime.shutdown().await;
return Err(error.context("swap default route via WG upstream"));
}
};
Ok(DaemonWgUpstream {
iface: actual_iface,
upstream,
runtime: Some(runtime),
full_route: Some(full_route),
_tun: tun,
config_fingerprint: fingerprint,
})
}
#[cfg(target_os = "macos")]
impl DaemonWgUpstream {
pub fn matches(&self, new_config: &WireGuardExitConfig) -> bool {
self.config_fingerprint == WireGuardExitFingerprint::from_config(new_config)
}
pub async fn cleanup(mut self) {
if let Some(mut full_route) = self.full_route.take() {
if let Err(error) = full_route.revert() {
eprintln!(
"fips: WG upstream route revert failed: {error}. \
Routing table may need manual cleanup."
);
}
drop(full_route);
}
if let Some(runtime) = self.runtime.take() {
runtime.shutdown().await;
}
}
}
#[cfg(target_os = "windows")]
impl DaemonWgUpstream {
pub fn matches(&self, new_config: &WireGuardExitConfig) -> bool {
self.config_fingerprint == WireGuardExitFingerprint::from_config(new_config)
}
pub async fn cleanup(mut self) {
if let Some(mut full_route) = self.full_route.take() {
if let Err(error) = full_route.revert() {
eprintln!(
"fips: WG upstream route revert failed: {error}. \
Routing table may need manual cleanup."
);
}
drop(full_route);
}
if let Some(runtime) = self.runtime.take() {
runtime.shutdown().await;
}
}
}
#[cfg(target_os = "windows")]
pub fn apply_windows_full_default_route(
wg_iface_index: u32,
upstream: SocketAddr,
) -> Result<WindowsFullDefaultRoute> {
let upstream_ip = match upstream.ip() {
IpAddr::V4(ip) => ip,
IpAddr::V6(_) => {
return Err(anyhow!(
"WG upstream IPv6 endpoint not yet supported on Windows"
));
}
};
let original = capture_windows_default_route()?;
if original.interface_index == wg_iface_index {
return Err(anyhow!(
"captured default route already points at the WG WinTun adapter (interface={}); \
refusing to swap to avoid creating a routing loop",
wg_iface_index
));
}
run_windows_netsh(&[
"interface".to_string(),
"ipv4".to_string(),
"add".to_string(),
"route".to_string(),
format!("{upstream_ip}/32"),
format!("interface={}", original.interface_index),
format!("nexthop={}", original.gateway),
"metric=1".to_string(),
"store=active".to_string(),
])?;
if let Err(error) = run_windows_netsh(&[
"interface".to_string(),
"ipv4".to_string(),
"add".to_string(),
"route".to_string(),
"0.0.0.0/0".to_string(),
format!("interface={}", wg_iface_index),
"metric=1".to_string(),
"store=active".to_string(),
]) {
let _ = run_windows_netsh(&[
"interface".to_string(),
"ipv4".to_string(),
"delete".to_string(),
"route".to_string(),
format!("{upstream_ip}/32"),
format!("interface={}", original.interface_index),
"store=active".to_string(),
]);
return Err(error);
}
Ok(WindowsFullDefaultRoute {
wg_iface_index,
bypass_target: upstream_ip,
original,
reverted: false,
})
}
#[cfg(target_os = "windows")]
pub struct WindowsFullDefaultRoute {
wg_iface_index: u32,
bypass_target: std::net::Ipv4Addr,
original: WindowsDefaultRoute,
reverted: bool,
}
#[cfg(target_os = "windows")]
#[derive(Debug, Clone)]
struct WindowsDefaultRoute {
gateway: String,
interface_index: u32,
}
#[cfg(target_os = "windows")]
impl WindowsFullDefaultRoute {
pub fn revert(&mut self) -> Result<()> {
if self.reverted {
return Ok(());
}
let _ = run_windows_netsh(&[
"interface".to_string(),
"ipv4".to_string(),
"delete".to_string(),
"route".to_string(),
"0.0.0.0/0".to_string(),
format!("interface={}", self.wg_iface_index),
"store=active".to_string(),
]);
let _ = run_windows_netsh(&[
"interface".to_string(),
"ipv4".to_string(),
"delete".to_string(),
"route".to_string(),
format!("{}/32", self.bypass_target),
format!("interface={}", self.original.interface_index),
"store=active".to_string(),
]);
self.reverted = true;
Ok(())
}
}
#[cfg(target_os = "windows")]
impl Drop for WindowsFullDefaultRoute {
fn drop(&mut self) {
if let Err(error) = self.revert() {
eprintln!(
"wg-upstream: WARNING — Windows route revert failed: {error}. \
You may need to run `netsh interface ipv4 delete route 0.0.0.0/0 \
interface={}` manually.",
self.wg_iface_index
);
}
}
}
#[cfg(target_os = "windows")]
fn capture_windows_default_route() -> Result<WindowsDefaultRoute> {
let output = ProcessCommand::new("route")
.arg("print")
.arg("-4")
.arg("0.0.0.0")
.output()
.context("spawn `route print -4 0.0.0.0`")?;
if !output.status.success() {
return Err(anyhow!("route print failed: {}", output.status));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let interface_ip = parse_windows_default_route_columns(&stdout)
.ok_or_else(|| anyhow!("no IPv4 default route found in `route print` output"))?;
let interface_index = resolve_windows_interface_index_for_address(&interface_ip.interface_ip)?;
Ok(WindowsDefaultRoute {
gateway: interface_ip.gateway,
interface_index,
})
}
#[cfg(any(test, target_os = "windows"))]
struct ParsedWindowsDefaultRoute {
gateway: String,
interface_ip: String,
metric: u32,
}
#[cfg(any(test, target_os = "windows"))]
fn parse_windows_default_route_columns(output: &str) -> Option<ParsedWindowsDefaultRoute> {
let mut best: Option<ParsedWindowsDefaultRoute> = None;
for line in output.lines() {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() < 5 {
continue;
}
if tokens[0] == "0.0.0.0" && tokens[1] == "0.0.0.0" {
if tokens[2].eq_ignore_ascii_case("on-link") {
continue;
}
let metric = tokens[4].parse::<u32>().unwrap_or(u32::MAX);
let candidate = ParsedWindowsDefaultRoute {
gateway: tokens[2].to_string(),
interface_ip: tokens[3].to_string(),
metric,
};
if best
.as_ref()
.is_none_or(|current| candidate.metric < current.metric)
{
best = Some(candidate);
}
}
}
best
}
#[cfg(target_os = "windows")]
fn resolve_windows_interface_index_for_address(interface_ip: &str) -> Result<u32> {
use std::net::Ipv4Addr;
let target: Ipv4Addr = interface_ip
.parse()
.with_context(|| format!("invalid IPv4 interface address {interface_ip}"))?;
let output = ProcessCommand::new("netsh")
.args(["interface", "ipv4", "show", "ipaddresses", "level=verbose"])
.output()
.context("spawn `netsh interface ipv4 show ipaddresses`")?;
if !output.status.success() {
return Err(anyhow!("netsh show ipaddresses failed: {}", output.status));
}
let stdout = String::from_utf8_lossy(&output.stdout);
match parse_windows_ipaddresses_interface(&stdout, target) {
Some(WindowsAddressInterface::Index(idx)) => return Ok(idx),
Some(WindowsAddressInterface::Alias(alias)) => {
let output = ProcessCommand::new("netsh")
.args(["interface", "ipv4", "show", "interfaces"])
.output()
.context("spawn `netsh interface ipv4 show interfaces`")?;
if !output.status.success() {
return Err(anyhow!("netsh show interfaces failed: {}", output.status));
}
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(idx) = parse_windows_interface_index_for_alias(&stdout, &alias) {
return Ok(idx);
}
return Err(anyhow!(
"no Windows interface index found for alias {alias:?} with IPv4 address {target}"
));
}
None => {}
}
Err(anyhow!(
"no Windows interface found with IPv4 address {target}"
))
}
#[cfg(any(test, target_os = "windows"))]
#[derive(Debug, Clone, PartialEq, Eq)]
enum WindowsAddressInterface {
Index(u32),
Alias(String),
}
#[cfg(any(test, target_os = "windows"))]
fn parse_windows_ipaddresses_interface(
output: &str,
target: std::net::Ipv4Addr,
) -> Option<WindowsAddressInterface> {
let mut current_index: Option<u32> = None;
let mut current_address_matches = false;
for line in output.lines() {
let trimmed = line.trim();
if current_address_matches
&& let Some((_, alias)) = trimmed.split_once(':')
&& trimmed.starts_with("Interface Luid")
{
let alias = alias.trim();
if !alias.is_empty() {
return Some(WindowsAddressInterface::Alias(alias.to_string()));
}
} else if let Some(rest) = trimmed.strip_prefix("Interface ") {
if let Some((idx_str, _)) = rest.split_once(':')
&& let Ok(idx) = idx_str.trim().parse::<u32>()
{
current_index = Some(idx);
}
} else if let Some(rest) = trimmed.strip_prefix("Address ") {
current_address_matches = false;
let Some(addr_str) = rest.split_whitespace().next() else {
continue;
};
if let Ok(addr) = addr_str.parse::<std::net::Ipv4Addr>()
&& addr == target
{
if let Some(idx) = current_index {
return Some(WindowsAddressInterface::Index(idx));
}
current_address_matches = true;
}
}
}
None
}
#[cfg(any(test, target_os = "windows"))]
fn parse_windows_interface_index_for_alias(output: &str, alias: &str) -> Option<u32> {
for line in output.lines() {
let trimmed = line.trim();
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
if tokens.len() < 5 {
continue;
}
let Ok(idx) = tokens[0].parse::<u32>() else {
continue;
};
let name = tokens[4..].join(" ");
if name.eq_ignore_ascii_case(alias.trim()) {
return Some(idx);
}
}
None
}
#[cfg(target_os = "windows")]
fn run_windows_netsh(args: &[String]) -> Result<()> {
let output = ProcessCommand::new("netsh")
.args(args)
.output()
.with_context(|| format!("spawn `netsh {}`", args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(anyhow!(
"netsh {} failed:\n stdout: {}\n stderr: {}",
args.join(" "),
stdout.trim(),
stderr.trim()
));
}
Ok(())
}
#[cfg(target_os = "windows")]
pub async fn apply_daemon_wg_upstream(
config: &WireGuardExitConfig,
handshake_timeout: Duration,
) -> Result<DaemonWgUpstream> {
let fingerprint = WireGuardExitFingerprint::from_config(config);
let adapter_name = if config.interface.trim().is_empty() {
"nvpn-wg-upstream".to_string()
} else {
config.interface.clone()
};
let wintun = nostr_vpn_wintun::load_wintun().context("load wintun.dll for WG upstream")?;
let adapter = wintun::Adapter::open(&wintun, &adapter_name)
.or_else(|_| wintun::Adapter::create(&wintun, &adapter_name, "NostrVPN", None))
.with_context(|| format!("open or create wintun adapter {adapter_name}"))?;
let mtu = if config.mtu > 0 { config.mtu } else { 1420 };
adapter
.set_mtu(mtu as usize)
.with_context(|| format!("set MTU on wintun adapter {adapter_name}"))?;
let parsed_address = crate::windows_tunnel::windows_interface_address(&config.address)?;
adapter
.set_network_addresses_tuple(
parsed_address.address.into(),
parsed_address.mask.into(),
None,
)
.with_context(|| format!("set address on wintun adapter {adapter_name}"))?;
let interface_index = adapter
.get_adapter_index()
.with_context(|| format!("read interface index for {adapter_name}"))?;
let session = Arc::new(
adapter
.start_session(wintun::MAX_RING_CAPACITY)
.with_context(|| format!("start wintun session for {adapter_name}"))?,
);
let runtime = start_wg_runtime_with_wintun(config, session.clone())
.await
.context("start userspace WG runtime on wintun")?;
let upstream = runtime.upstream();
if !runtime.wait_for_handshake(handshake_timeout).await {
runtime.shutdown().await;
return Err(anyhow!(
"WG upstream handshake to {upstream} did not complete within {}s; \
routing table NOT modified",
handshake_timeout.as_secs()
));
}
let full_route = match apply_windows_full_default_route(interface_index, upstream) {
Ok(route) => route,
Err(error) => {
runtime.shutdown().await;
return Err(error.context("swap Windows default route via WG upstream"));
}
};
Ok(DaemonWgUpstream {
iface: adapter_name,
upstream,
runtime: Some(runtime),
full_route: Some(full_route),
_session: session,
_adapter: adapter,
config_fingerprint: fingerprint,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_windows_default_route_from_route_print() {
let sample = "\
===========================================================================
Interface List
23...00 ff a1 b2 c3 d4 ......WireGuard Tunnel
12...c0 d4 fe ff aa bb ......Realtek PCIe GbE
===========================================================================
IPv4 Route Table
===========================================================================
Active Routes:
Network Destination Netmask Gateway Interface Metric
0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.42 25
127.0.0.0 255.0.0.0 On-link 127.0.0.1 331
===========================================================================
";
let parsed = parse_windows_default_route_columns(sample).expect("default route parsed");
assert_eq!(parsed.gateway, "192.168.1.1");
assert_eq!(parsed.interface_ip, "192.168.1.42");
assert_eq!(parsed.metric, 25);
}
#[test]
fn skips_on_link_default_routes() {
let sample = "\
Active Routes:
Network Destination Netmask Gateway Interface Metric
0.0.0.0 0.0.0.0 On-link 10.0.0.1 50
0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.42 25
";
let parsed =
parse_windows_default_route_columns(sample).expect("non-On-link default parsed");
assert_eq!(parsed.gateway, "192.168.1.1");
assert_eq!(parsed.interface_ip, "192.168.1.42");
assert_eq!(parsed.metric, 25);
}
#[test]
fn chooses_lowest_metric_windows_default_route() {
let sample = "\
Active Routes:
Network Destination Netmask Gateway Interface Metric
0.0.0.0 0.0.0.0 172.20.0.1 172.20.0.22 75
0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.42 25
";
let parsed = parse_windows_default_route_columns(sample).expect("default route parsed");
assert_eq!(parsed.gateway, "192.168.1.1");
assert_eq!(parsed.interface_ip, "192.168.1.42");
assert_eq!(parsed.metric, 25);
}
#[test]
fn returns_none_when_no_default_route_present() {
let sample = "Active Routes:\n 127.0.0.0 255.0.0.0 On-link 127.0.0.1 331\n";
assert!(parse_windows_default_route_columns(sample).is_none());
}
#[test]
fn parses_windows_ipaddress_alias_from_verbose_netsh() {
let sample = "\
Address 127.0.0.1 Parameters
---------------------------------------------------------
Interface Luid : Loopback Pseudo-Interface 1
Address 192.168.122.147 Parameters
---------------------------------------------------------
Interface Luid : Ethernet
Scope Id : 0.0
";
assert_eq!(
parse_windows_ipaddresses_interface(sample, "192.168.122.147".parse().expect("ip")),
Some(WindowsAddressInterface::Alias("Ethernet".to_string()))
);
}
#[test]
fn parses_windows_interface_index_for_alias() {
let sample = "\
Idx Met MTU State Name
--- ---------- ---------- ------------ ---------------------------
1 75 4294967295 connected Loopback Pseudo-Interface 1
3 25 1500 connected Ethernet
11 5 1150 connected nvpn
";
assert_eq!(
parse_windows_interface_index_for_alias(sample, "Ethernet"),
Some(3)
);
assert_eq!(
parse_windows_interface_index_for_alias(sample, "Loopback Pseudo-Interface 1"),
Some(1)
);
}
}