use anyhow::{Context, Result, bail};
use ipnet::{IpNet, Ipv4Net, Ipv6Net};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
error::Error as StdError,
fmt, fs,
io::ErrorKind,
net::{IpAddr, Ipv4Addr, Ipv6Addr},
path::{Path, PathBuf},
process::Command,
sync::Mutex,
};
use tracing::{debug, info, warn};
use super::WgRuntimeConfig;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct RouteInfo {
pub interface: Option<String>,
pub gateway: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct HookPlan {
pub up: Vec<String>,
pub down: Vec<String>,
}
pub(crate) struct HookGuard {
label: &'static str,
hooks: Vec<String>,
}
pub(crate) struct SwitchableHookGuard {
label: &'static str,
plan: HookPlan,
active: bool,
}
pub(crate) struct DynamicRouteManager {
endpoint_ip: IpAddr,
route: Mutex<RouteInfo>,
tunnel: TunnelPair,
direct_hosts: Mutex<BTreeMap<IpAddr, String>>,
ledger: DynamicRouteLedger,
}
#[derive(Clone, Debug)]
struct DynamicRouteLedger {
path: PathBuf,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
struct DynamicRouteLedgerFile {
routes: Vec<DynamicRouteLedgerEntry>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
struct DynamicRouteLedgerEntry {
ip: IpAddr,
domain: String,
}
#[derive(Debug)]
pub(crate) struct EgressRouteNotReady {
target: String,
detail: String,
route_output: Option<String>,
}
impl EgressRouteNotReady {
fn new(target: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
target: target.into(),
detail: detail.into(),
route_output: None,
}
}
fn with_route_output(mut self, output: impl Into<String>) -> Self {
self.route_output = Some(output.into());
self
}
pub(crate) fn target(&self) -> &str {
&self.target
}
pub(crate) fn detail(&self) -> &str {
&self.detail
}
pub(crate) fn route_output_for_log(&self) -> Option<String> {
self.route_output
.as_deref()
.map(|output| output.lines().map(str::trim).collect::<Vec<_>>().join("; "))
}
}
impl fmt::Display for EgressRouteNotReady {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"egress route to {} is not ready: {}",
self.target, self.detail
)
}
}
impl StdError for EgressRouteNotReady {}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TunnelPair {
V4 { local: Ipv4Addr, peer: Ipv4Addr },
V6 { local: Ipv6Addr, peer: Ipv6Addr },
}
impl TunnelPair {
#[cfg(target_os = "macos")]
fn peer_ip(self) -> IpAddr {
match self {
Self::V4 { peer, .. } => IpAddr::V4(peer),
Self::V6 { peer, .. } => IpAddr::V6(peer),
}
}
fn peer_host_route(self) -> IpNet {
match self {
Self::V4 { peer, .. } => IpNet::V4(Ipv4Net::new(peer, 32).expect("valid peer route")),
Self::V6 { peer, .. } => IpNet::V6(Ipv6Net::new(peer, 128).expect("valid peer route")),
}
}
fn family_label(self) -> &'static str {
match self {
Self::V4 { .. } => "IPv4",
Self::V6 { .. } => "IPv6",
}
}
fn supports_ip(self, ip: IpAddr) -> bool {
matches!(
(self, ip),
(Self::V4 { .. }, IpAddr::V4(_)) | (Self::V6 { .. }, IpAddr::V6(_))
)
}
}
impl HookGuard {
pub(crate) fn new(label: &'static str, hooks: Vec<String>) -> Self {
Self { label, hooks }
}
}
impl DynamicRouteManager {
pub(crate) fn for_client(runtime: &WgRuntimeConfig) -> Result<Self> {
let endpoint_ip = runtime
.endpoint_ip()
.context("wg client endpoint is required to build dynamic domain routes")?;
let ledger = DynamicRouteLedger::for_client();
ledger.cleanup_previous()?;
let route = detect_egress_route(endpoint_ip)?;
let tunnel = ensure_tunnel_pair(
runtime.tunnel_ip,
runtime.peer_tunnel_ip,
"wg client tunnel_ip",
"wg client peer_tunnel_ip",
)?;
Ok(Self {
endpoint_ip,
route: Mutex::new(route),
tunnel,
direct_hosts: Mutex::new(BTreeMap::new()),
ledger,
})
}
pub(crate) fn add_direct_host(&self, domain: &str, ip: IpAddr) -> Result<bool> {
if !self.tunnel.supports_ip(ip) {
return Ok(false);
}
if !is_dynamic_direct_route_ip(ip) {
debug!(
domain = %domain,
ip = %ip,
"skipped wg dynamic direct route for special-purpose IP"
);
return Ok(false);
}
let current_route = detect_egress_route(self.endpoint_ip)?;
let mut route = self.route.lock().expect("dynamic route mutex");
let mut direct_hosts = self.direct_hosts.lock().expect("dynamic route mutex");
if *route != current_route {
refresh_direct_host_routes(&direct_hosts, ¤t_route)?;
*route = current_route;
}
if direct_hosts.contains_key(&ip) {
return Ok(false);
}
direct_hosts.insert(ip, domain.to_owned());
if let Err(err) = self.ledger.save_hosts(&direct_hosts) {
direct_hosts.remove(&ip);
return Err(err);
}
if let Err(err) = run_domain_route_hook(domain, ip, &direct_host_add_command(ip, &route)) {
direct_hosts.remove(&ip);
if let Err(ledger_err) = self.ledger.save_hosts(&direct_hosts) {
warn!(
domain = %domain,
ip = %ip,
error = %ledger_err,
"wg dynamic direct route ledger rollback failed"
);
}
return Err(err);
}
Ok(true)
}
}
fn refresh_direct_host_routes(hosts: &BTreeMap<IpAddr, String>, route: &RouteInfo) -> Result<()> {
for (ip, domain) in hosts {
run_domain_route_hook(domain, *ip, &direct_host_delete_command(*ip))?;
run_domain_route_hook(domain, *ip, &direct_host_add_command(*ip, route))?;
}
Ok(())
}
fn is_dynamic_direct_route_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => {
!ip.is_loopback()
&& !ip.is_unspecified()
&& !ip.is_link_local()
&& !ip.is_multicast()
&& !ip.is_broadcast()
}
IpAddr::V6(ip) => {
!ip.is_loopback()
&& !ip.is_unspecified()
&& !ip.is_unicast_link_local()
&& !ip.is_multicast()
}
}
}
impl DynamicRouteLedger {
fn for_client() -> Self {
Self {
path: dynamic_route_ledger_path(),
}
}
#[cfg(test)]
fn new(path: PathBuf) -> Self {
Self { path }
}
fn cleanup_previous(&self) -> Result<()> {
let entries = read_dynamic_route_ledger_entries(&self.path)?;
let mut cleanup_ok = true;
for entry in entries.iter().rev() {
if let Err(err) = run_domain_route_hook(
&entry.domain,
entry.ip,
&direct_host_delete_command(entry.ip),
) {
cleanup_ok = false;
warn!(
domain = %entry.domain,
ip = %entry.ip,
error = %err,
"wg stale dynamic direct route cleanup failed"
);
}
}
if cleanup_ok { self.remove() } else { Ok(()) }
}
fn save_hosts(&self, hosts: &BTreeMap<IpAddr, String>) -> Result<()> {
let entries = hosts
.iter()
.map(|(ip, domain)| DynamicRouteLedgerEntry {
ip: *ip,
domain: domain.clone(),
})
.collect::<Vec<_>>();
write_dynamic_route_ledger_entries(&self.path, entries)
}
fn remove(&self) -> Result<()> {
match fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) => {
Err(err).with_context(|| format!("failed to remove {}", self.path.display()))
}
}
}
}
impl Drop for DynamicRouteManager {
fn drop(&mut self) {
let hosts = self
.direct_hosts
.get_mut()
.expect("dynamic route mutex")
.iter()
.map(|(ip, domain)| (*ip, domain.clone()))
.collect::<Vec<_>>();
let mut cleanup_ok = true;
for (ip, domain) in hosts.into_iter().rev() {
if let Err(err) = run_domain_route_hook(&domain, ip, &direct_host_delete_command(ip)) {
cleanup_ok = false;
warn!(host = %ip, error = %err, "wg dynamic direct route cleanup failed");
}
}
if cleanup_ok && let Err(err) = self.ledger.remove() {
warn!(error = %err, "wg dynamic direct route ledger cleanup failed");
}
}
}
impl Drop for HookGuard {
fn drop(&mut self) {
if self.hooks.is_empty() {
return;
}
if let Err(err) = run_hooks(&self.hooks) {
warn!(label = self.label, error = %err, "wg cleanup hooks failed");
}
}
}
impl SwitchableHookGuard {
pub(crate) fn new(label: &'static str, plan: HookPlan) -> Self {
Self {
label,
plan,
active: true,
}
}
pub(crate) fn bypass(&mut self) -> Result<bool> {
if !self.active {
return Ok(false);
}
run_hooks(&self.plan.down)?;
self.active = false;
Ok(true)
}
pub(crate) fn proxying(&mut self) -> Result<bool> {
if self.active {
return Ok(false);
}
run_hooks(&self.plan.up)?;
self.active = true;
Ok(true)
}
}
impl Drop for SwitchableHookGuard {
fn drop(&mut self) {
if !self.active || self.plan.down.is_empty() {
return;
}
if let Err(err) = run_hooks(&self.plan.down) {
warn!(label = self.label, error = %err, "wg cleanup hooks failed");
}
}
}
pub(crate) fn run_hooks(hooks: &[String]) -> Result<()> {
for hook in hooks {
run_hook(hook, None, None)?;
}
Ok(())
}
fn run_domain_route_hook(domain: &str, ip: IpAddr, hook: &str) -> Result<()> {
run_hook(hook, Some(domain), Some(ip))
}
fn dynamic_route_ledger_path() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join(".runnel")
.join("current")
.join("wg-client-dynamic-routes.json")
}
fn read_dynamic_route_ledger_entries(path: &Path) -> Result<Vec<DynamicRouteLedgerEntry>> {
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err).with_context(|| format!("failed to read {}", path.display())),
};
let file = serde_json::from_slice::<DynamicRouteLedgerFile>(&bytes)
.with_context(|| format!("failed to parse {}", path.display()))?;
Ok(file.routes)
}
fn write_dynamic_route_ledger_entries(
path: &Path,
routes: Vec<DynamicRouteLedgerEntry>,
) -> Result<()> {
if routes.is_empty() {
return remove_file_if_exists(path);
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let data = serde_json::to_vec_pretty(&DynamicRouteLedgerFile { routes })
.context("failed to encode dynamic route ledger")?;
let tmp = path.with_extension(format!("json.tmp.{}", std::process::id()));
fs::write(&tmp, data).with_context(|| format!("failed to write {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| format!("failed to replace {}", path.display()))?;
Ok(())
}
fn remove_file_if_exists(path: &Path) -> Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) => Err(err).with_context(|| format!("failed to remove {}", path.display())),
}
}
fn run_hook(hook: &str, domain: Option<&str>, ip: Option<IpAddr>) -> Result<()> {
match (domain, ip) {
(Some(domain), Some(ip)) => {
info!(hook = %hook, domain = %domain, ip = %ip, "running wg hook")
}
(Some(domain), None) => info!(hook = %hook, domain = %domain, "running wg hook"),
(None, Some(ip)) => info!(hook = %hook, ip = %ip, "running wg hook"),
(None, None) => info!(hook = %hook, "running wg hook"),
}
let status = Command::new("/bin/sh")
.arg("-lc")
.arg(hook)
.status()
.with_context(|| format!("failed to spawn wg hook: {hook}"))?;
if !status.success() {
bail!("wg hook failed with status {status}: {hook}");
}
Ok(())
}
pub(crate) fn effective_hook_plan(
default: HookPlan,
up_override: &[String],
down_override: &[String],
) -> HookPlan {
HookPlan {
up: if up_override.is_empty() {
default.up
} else {
up_override.to_vec()
},
down: if down_override.is_empty() {
default.down
} else {
down_override.to_vec()
},
}
}
pub(crate) fn print_plan(lines: &[String]) {
for line in lines {
println!("{line}");
}
}
pub(crate) fn log_plan_lines(lines: &[String]) {
for line in lines {
info!("{line}");
}
}
pub(crate) fn plan_client_hooks(device: &str, runtime: &WgRuntimeConfig) -> Result<HookPlan> {
let endpoint_ip = runtime
.endpoint_ip()
.context("wg client endpoint is required to build default hooks")?;
let route = detect_egress_route(endpoint_ip)?;
build_client_hook_plan(device, endpoint_ip, runtime, &route)
}
pub(crate) fn plan_server_hooks(
device: &str,
runtime: &WgRuntimeConfig,
nat_out_interface: Option<&str>,
) -> Result<HookPlan> {
build_server_hook_plan(device, runtime, nat_out_interface)
}
pub(crate) fn build_client_hook_plan(
device: &str,
endpoint_ip: IpAddr,
runtime: &WgRuntimeConfig,
route: &RouteInfo,
) -> Result<HookPlan> {
let tunnel = ensure_tunnel_pair(
runtime.tunnel_ip,
runtime.peer_tunnel_ip,
"wg client tunnel_ip",
"wg client peer_tunnel_ip",
)?;
let routes = allowed_routes("wg client", &runtime.peer_allowed_ips, tunnel)?;
let excluded_routes = excluded_routes("wg client exclude", &runtime.excluded_ips, tunnel)?;
let routes = exclude_routes(routes, &excluded_routes);
#[cfg(target_os = "macos")]
{
let interface = route.interface.as_deref();
if interface.is_none() {
bail!(
"failed to determine macOS outbound interface for {}; explicit hooks are required",
endpoint_ip
);
}
if interface.is_some_and(is_macos_tunnel_interface) {
bail!(
"route to WG endpoint {endpoint_ip} currently resolves to tunnel interface {}; clean stale tunnel routes before starting the client",
interface.unwrap()
);
}
let mut up = vec![macos_ifconfig_command(device, tunnel, runtime.mtu)];
up.push(macos_bypass_route(endpoint_ip, route));
up.extend(
routes
.iter()
.map(|route| macos_add_route_command(*route, tunnel.peer_ip())),
);
let mut down = routes
.iter()
.rev()
.map(|route| macos_delete_route_command(*route))
.collect::<Vec<_>>();
down.push(macos_delete_host_route_command(endpoint_ip));
down.push(format!("ifconfig {device} down >/dev/null 2>&1 || true"));
return Ok(HookPlan { up, down });
}
#[cfg(target_os = "linux")]
{
if route.interface.is_none() {
bail!(
"failed to determine linux outbound interface for {}; explicit hooks are required",
endpoint_ip
);
}
let mut up = vec![
linux_address_add_command(device, tunnel),
format!("ip link set mtu {} up dev {device}", runtime.mtu),
linux_bypass_route(endpoint_ip, route),
];
up.extend(
routes
.iter()
.map(|route| linux_add_route_command(*route, device)),
);
let mut down = routes
.iter()
.rev()
.map(|route| linux_delete_route_command(*route, device))
.collect::<Vec<_>>();
down.push(linux_delete_host_route_command(endpoint_ip));
down.push(linux_address_del_command(device, tunnel));
down.push(format!(
"ip link set dev {device} down >/dev/null 2>&1 || true"
));
return Ok(HookPlan { up, down });
}
#[allow(unreachable_code)]
Ok(HookPlan::default())
}
pub(crate) fn build_server_hook_plan(
device: &str,
runtime: &WgRuntimeConfig,
nat_out_interface: Option<&str>,
) -> Result<HookPlan> {
let tunnel = ensure_tunnel_pair(
runtime.tunnel_ip,
runtime.peer_tunnel_ip,
"wg server tunnel_ip",
"wg server peer_tunnel_ip",
)?;
let peer_host_route = tunnel.peer_host_route();
let routes = allowed_routes("wg server", &runtime.peer_allowed_ips, tunnel)?
.into_iter()
.filter(|route| route != &peer_host_route)
.collect::<Vec<_>>();
#[cfg(target_os = "macos")]
{
let mut up = vec![macos_ifconfig_command(device, tunnel, runtime.mtu)];
up.extend(
routes
.iter()
.map(|route| macos_add_route_command(*route, tunnel.peer_ip())),
);
let mut down = routes
.iter()
.rev()
.map(|route| macos_delete_route_command(*route))
.collect::<Vec<_>>();
down.push(format!("ifconfig {device} down >/dev/null 2>&1 || true"));
if nat_out_interface.is_some() {
warn!("wg server nat_out_interface is currently ignored on macOS");
}
return Ok(HookPlan { up, down });
}
#[cfg(target_os = "linux")]
{
let mut up = vec![
linux_address_add_command(device, tunnel),
format!("ip link set mtu {} up dev {device}", runtime.mtu),
];
up.extend(
routes
.iter()
.map(|route| linux_add_route_command(*route, device)),
);
let mut down = routes
.iter()
.rev()
.map(|route| linux_delete_route_command(*route, device))
.collect::<Vec<_>>();
down.push(linux_address_del_command(device, tunnel));
down.push(format!(
"ip link set dev {device} down >/dev/null 2>&1 || true"
));
if let Some(nat_if) = nat_out_interface {
if matches!(tunnel, TunnelPair::V6 { .. }) {
bail!(
"wg server nat_out_interface automatic hooks currently only support IPv4 tunnels; use explicit --up/--down hooks for IPv6 routing/NAT"
);
}
up.push("sysctl -w net.ipv4.ip_forward=1 >/dev/null".to_owned());
up.push(format!("iptables -A FORWARD -i {device} -j ACCEPT"));
up.push(format!(
"iptables -A FORWARD -o {device} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT"
));
up.push(format!(
"iptables -t nat -A POSTROUTING -o {nat_if} -j MASQUERADE"
));
down.insert(
0,
format!(
"iptables -t nat -D POSTROUTING -o {nat_if} -j MASQUERADE >/dev/null 2>&1 || true"
),
);
down.insert(
0,
format!(
"iptables -D FORWARD -o {device} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT >/dev/null 2>&1 || true"
),
);
down.insert(
0,
format!("iptables -D FORWARD -i {device} -j ACCEPT >/dev/null 2>&1 || true"),
);
}
return Ok(HookPlan { up, down });
}
#[allow(unreachable_code)]
Ok(HookPlan::default())
}
fn allowed_routes(role: &str, allowed_ips: &[String], tunnel: TunnelPair) -> Result<Vec<IpNet>> {
let mut result = BTreeSet::new();
for allowed_ip in allowed_ips {
let net = allowed_ip
.parse::<IpNet>()
.with_context(|| format!("{role} allowed_ip must be CIDR, got {allowed_ip}"))?;
match (net, tunnel) {
(IpNet::V4(net), TunnelPair::V4 { .. }) if net.prefix_len() == 0 => {
result.extend(split_default_ipv4_routes().map(IpNet::V4));
}
(IpNet::V6(net), TunnelPair::V6 { .. }) if net.prefix_len() == 0 => {
result.extend(split_default_ipv6_routes().map(IpNet::V6));
}
(IpNet::V4(net), TunnelPair::V4 { .. }) => {
result.insert(IpNet::V4(net.trunc()));
}
(IpNet::V6(net), TunnelPair::V6 { .. }) => {
result.insert(IpNet::V6(net.trunc()));
}
(net, tunnel) => {
bail!(
"{role} automatic hooks use {} tunnel addresses but got mismatched allowed_ip {net}; use matching tunnel addresses or explicit --up/--down hooks",
tunnel.family_label()
);
}
}
}
Ok(result.into_iter().collect())
}
fn excluded_routes(role: &str, excluded_ips: &[String], tunnel: TunnelPair) -> Result<Vec<IpNet>> {
let mut result = BTreeSet::new();
for excluded_ip in excluded_ips {
let net = excluded_ip
.parse::<IpNet>()
.with_context(|| format!("{role} exclude_ip must be CIDR, got {excluded_ip}"))?;
match (net, tunnel) {
(IpNet::V4(net), TunnelPair::V4 { .. }) => {
result.insert(IpNet::V4(net.trunc()));
}
(IpNet::V6(net), TunnelPair::V6 { .. }) => {
result.insert(IpNet::V6(net.trunc()));
}
_ => {}
}
}
Ok(result.into_iter().collect())
}
fn exclude_routes(routes: Vec<IpNet>, excluded_routes: &[IpNet]) -> Vec<IpNet> {
let mut result = BTreeSet::new();
for route in routes {
let mut remaining = vec![route];
for excluded in excluded_routes {
remaining = remaining
.into_iter()
.flat_map(|candidate| subtract_route(candidate, *excluded))
.collect();
}
result.extend(remaining);
}
result.into_iter().collect()
}
fn subtract_route(route: IpNet, excluded: IpNet) -> Vec<IpNet> {
match (route, excluded) {
(IpNet::V4(route), IpNet::V4(excluded)) => subtract_ipv4_route(route, excluded)
.into_iter()
.map(IpNet::V4)
.collect(),
(IpNet::V6(route), IpNet::V6(excluded)) => subtract_ipv6_route(route, excluded)
.into_iter()
.map(IpNet::V6)
.collect(),
_ => vec![route],
}
}
fn subtract_ipv4_route(route: Ipv4Net, excluded: Ipv4Net) -> Vec<Ipv4Net> {
if !ipv4_routes_overlap(route, excluded) {
return vec![route];
}
if excluded.contains(&route) {
return Vec::new();
}
if route.prefix_len() == 32 {
return Vec::new();
}
route
.subnets(route.prefix_len() + 1)
.expect("splitting a non-/32 IPv4 route is valid")
.flat_map(|subnet| subtract_ipv4_route(subnet, excluded))
.collect()
}
fn ipv4_routes_overlap(left: Ipv4Net, right: Ipv4Net) -> bool {
left.contains(&right.network()) || right.contains(&left.network())
}
fn subtract_ipv6_route(route: Ipv6Net, excluded: Ipv6Net) -> Vec<Ipv6Net> {
if !ipv6_routes_overlap(route, excluded) {
return vec![route];
}
if excluded.contains(&route) {
return Vec::new();
}
if route.prefix_len() == 128 {
return Vec::new();
}
route
.subnets(route.prefix_len() + 1)
.expect("splitting a non-/128 IPv6 route is valid")
.flat_map(|subnet| subtract_ipv6_route(subnet, excluded))
.collect()
}
fn ipv6_routes_overlap(left: Ipv6Net, right: Ipv6Net) -> bool {
left.contains(&right.network()) || right.contains(&left.network())
}
fn split_default_ipv4_routes() -> [Ipv4Net; 2] {
[
Ipv4Net::new(Ipv4Addr::UNSPECIFIED, 1).expect("valid split default route"),
Ipv4Net::new(Ipv4Addr::new(128, 0, 0, 0), 1).expect("valid split default route"),
]
}
fn split_default_ipv6_routes() -> [Ipv6Net; 2] {
[
Ipv6Net::new(Ipv6Addr::UNSPECIFIED, 1).expect("valid split default route"),
Ipv6Net::new(
Ipv6Addr::from(0x8000_0000_0000_0000_0000_0000_0000_0000u128),
1,
)
.expect("valid split default route"),
]
}
#[cfg(target_os = "macos")]
fn macos_ifconfig_command(device: &str, tunnel: TunnelPair, mtu: u16) -> String {
match tunnel {
TunnelPair::V4 { local, peer } => {
format!("ifconfig {device} inet {local} {peer} mtu {mtu} up")
}
TunnelPair::V6 { local, peer } => {
format!("ifconfig {device} inet6 {local} {peer} prefixlen 128 mtu {mtu} up")
}
}
}
#[cfg(target_os = "macos")]
fn macos_add_route_command(route: IpNet, gateway: IpAddr) -> String {
let rendered = route.to_string();
match route {
IpNet::V4(route) if route.prefix_len() == 32 => {
let host = rendered.trim_end_matches("/32");
format!(
"route -q -n add -host {host} {gateway} >/dev/null 2>&1 || route -q -n change -host {host} {gateway}"
)
}
IpNet::V4(_) => {
format!(
"route -q -n add -net {rendered} {gateway} >/dev/null 2>&1 || route -q -n change -net {rendered} {gateway}"
)
}
IpNet::V6(route) if route.prefix_len() == 128 => {
let host = rendered.trim_end_matches("/128");
format!(
"route -q -n add -inet6 -host {host} {gateway} >/dev/null 2>&1 || route -q -n change -inet6 -host {host} {gateway}"
)
}
IpNet::V6(_) => {
format!(
"route -q -n add -inet6 -net {rendered} {gateway} >/dev/null 2>&1 || route -q -n change -inet6 -net {rendered} {gateway}"
)
}
}
}
#[cfg(target_os = "macos")]
fn macos_delete_route_command(route: IpNet) -> String {
let rendered = route.to_string();
match route {
IpNet::V4(route) if route.prefix_len() == 32 => {
let host = rendered.trim_end_matches("/32");
format!("route -q -n delete -host {host} >/dev/null 2>&1 || true")
}
IpNet::V4(_) => {
format!("route -q -n delete -net {rendered} >/dev/null 2>&1 || true")
}
IpNet::V6(route) if route.prefix_len() == 128 => {
let host = rendered.trim_end_matches("/128");
format!("route -q -n delete -inet6 -host {host} >/dev/null 2>&1 || true")
}
IpNet::V6(_) => {
format!("route -q -n delete -inet6 -net {rendered} >/dev/null 2>&1 || true")
}
}
}
fn ensure_tunnel_pair(
local: IpAddr,
peer: IpAddr,
local_label: &str,
peer_label: &str,
) -> Result<TunnelPair> {
match (local, peer) {
(IpAddr::V4(local), IpAddr::V4(peer)) => Ok(TunnelPair::V4 { local, peer }),
(IpAddr::V6(local), IpAddr::V6(peer)) => Ok(TunnelPair::V6 { local, peer }),
_ => bail!("{local_label} and {peer_label} must use the same IP version"),
}
}
#[cfg(target_os = "macos")]
fn macos_bypass_route(endpoint: IpAddr, route: &RouteInfo) -> String {
match route.gateway.as_deref() {
Some(gateway) if !gateway.is_empty() && endpoint.is_ipv6() => {
format!(
"route -q -n add -inet6 -host {endpoint} {gateway} >/dev/null 2>&1 || route -q -n change -inet6 -host {endpoint} {gateway}"
)
}
Some(gateway) if !gateway.is_empty() => {
format!(
"route -q -n add -host {endpoint} {gateway} >/dev/null 2>&1 || route -q -n change -host {endpoint} {gateway}"
)
}
_ if endpoint.is_ipv6() => format!(
"route -q -n add -inet6 -host {endpoint} -interface {} >/dev/null 2>&1 || route -q -n change -inet6 -host {endpoint} -interface {}",
route.interface.as_deref().unwrap_or(""),
route.interface.as_deref().unwrap_or("")
),
_ => format!(
"route -q -n add -host {endpoint} -interface {} >/dev/null 2>&1 || route -q -n change -host {endpoint} -interface {}",
route.interface.as_deref().unwrap_or(""),
route.interface.as_deref().unwrap_or("")
),
}
}
#[cfg(target_os = "macos")]
fn macos_delete_host_route_command(endpoint: IpAddr) -> String {
if endpoint.is_ipv6() {
format!("route -q -n delete -inet6 -host {endpoint} >/dev/null 2>&1 || true")
} else {
format!("route -q -n delete -host {endpoint} >/dev/null 2>&1 || true")
}
}
#[cfg(target_os = "macos")]
fn direct_host_add_command(ip: IpAddr, route: &RouteInfo) -> String {
macos_bypass_route(ip, route)
}
#[cfg(target_os = "macos")]
fn direct_host_delete_command(ip: IpAddr) -> String {
macos_delete_host_route_command(ip)
}
#[cfg(target_os = "linux")]
fn linux_address_add_command(device: &str, tunnel: TunnelPair) -> String {
match tunnel {
TunnelPair::V4 { local, peer } => {
format!("ip address add {local} peer {peer} dev {device}")
}
TunnelPair::V6 { local, .. } => format!("ip -6 address add {local}/128 dev {device}"),
}
}
#[cfg(target_os = "linux")]
fn linux_address_del_command(device: &str, tunnel: TunnelPair) -> String {
match tunnel {
TunnelPair::V4 { local, peer } => {
format!("ip address del {local} peer {peer} dev {device} >/dev/null 2>&1 || true")
}
TunnelPair::V6 { local, .. } => {
format!("ip -6 address del {local}/128 dev {device} >/dev/null 2>&1 || true")
}
}
}
#[cfg(target_os = "linux")]
fn linux_add_route_command(route: IpNet, device: &str) -> String {
match route {
IpNet::V4(_) => format!("ip route replace {route} dev {device}"),
IpNet::V6(_) => format!("ip -6 route replace {route} dev {device}"),
}
}
#[cfg(target_os = "linux")]
fn linux_delete_route_command(route: IpNet, device: &str) -> String {
match route {
IpNet::V4(_) => format!("ip route del {route} dev {device} >/dev/null 2>&1 || true"),
IpNet::V6(_) => format!("ip -6 route del {route} dev {device} >/dev/null 2>&1 || true"),
}
}
#[cfg(target_os = "linux")]
fn linux_bypass_route(endpoint: IpAddr, route: &RouteInfo) -> String {
let command = if endpoint.is_ipv6() {
"ip -6 route replace"
} else {
"ip route replace"
};
let prefix = if endpoint.is_ipv6() { 128 } else { 32 };
match route.gateway.as_deref() {
Some(gateway) if !gateway.is_empty() => {
format!(
"{command} {endpoint}/{prefix} via {gateway} dev {}",
route.interface.as_deref().unwrap()
)
}
_ => format!(
"{command} {endpoint}/{prefix} dev {}",
route.interface.as_deref().unwrap()
),
}
}
#[cfg(target_os = "linux")]
fn linux_delete_host_route_command(endpoint: IpAddr) -> String {
if endpoint.is_ipv6() {
format!("ip -6 route del {endpoint}/128 >/dev/null 2>&1 || true")
} else {
format!("ip route del {endpoint}/32 >/dev/null 2>&1 || true")
}
}
#[cfg(target_os = "linux")]
fn direct_host_add_command(ip: IpAddr, route: &RouteInfo) -> String {
linux_bypass_route(ip, route)
}
#[cfg(target_os = "linux")]
fn direct_host_delete_command(ip: IpAddr) -> String {
linux_delete_host_route_command(ip)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn direct_host_add_command(ip: IpAddr, _route: &RouteInfo) -> String {
format!("true # dynamic direct route for {ip} is unsupported on this OS")
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn direct_host_delete_command(ip: IpAddr) -> String {
format!("true # dynamic direct route cleanup for {ip} is unsupported on this OS")
}
fn detect_egress_route(target: IpAddr) -> Result<RouteInfo> {
#[cfg(target_os = "macos")]
{
let target = target.to_string();
let args = if target.contains(':') {
vec!["-n", "get", "-inet6", target.as_str()]
} else {
vec!["-n", "get", target.as_str()]
};
let output = Command::new("route")
.args(args)
.output()
.with_context(|| format!("failed to inspect route to {target}"))?;
if !output.status.success() {
return Err(EgressRouteNotReady::new(
target,
format!(
"route command failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
)
.into());
}
return parse_macos_route_get(&target, &String::from_utf8_lossy(&output.stdout));
}
#[cfg(target_os = "linux")]
{
let target = target.to_string();
let args = if target.contains(':') {
vec!["-6", "route", "get", target.as_str()]
} else {
vec!["route", "get", target.as_str()]
};
let output = Command::new("ip")
.args(args)
.output()
.with_context(|| format!("failed to inspect route to {target}"))?;
if !output.status.success() {
return Err(EgressRouteNotReady::new(
target,
format!(
"ip route command failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
)
.into());
}
return parse_linux_route_get(&target, &String::from_utf8_lossy(&output.stdout));
}
#[allow(unreachable_code)]
Ok(RouteInfo {
interface: None,
gateway: None,
})
}
#[cfg(target_os = "macos")]
fn parse_macos_route_get(target: &str, output: &str) -> Result<RouteInfo> {
let mut interface = None;
let mut gateway = None;
for line in output.lines() {
let trimmed = line.trim();
if let Some(value) = trimmed.strip_prefix("interface:") {
interface = Some(value.trim().to_owned());
} else if let Some(value) = trimmed.strip_prefix("gateway:") {
let value = value.trim();
if value.parse::<IpAddr>().is_ok() {
gateway = Some(value.to_owned());
}
}
}
if interface.is_none() {
return Err(
EgressRouteNotReady::new(target, "route output did not include an interface")
.with_route_output(output)
.into(),
);
}
Ok(RouteInfo { interface, gateway })
}
#[cfg(target_os = "macos")]
fn is_macos_tunnel_interface(interface: &str) -> bool {
interface.starts_with("utun") || interface.starts_with("tun")
}
#[cfg(target_os = "linux")]
fn parse_linux_route_get(target: &str, output: &str) -> Result<RouteInfo> {
let tokens = output.split_whitespace().collect::<Vec<_>>();
let mut interface = None;
let mut gateway = None;
for window in tokens.windows(2) {
match window {
["dev", value] => interface = Some((*value).to_owned()),
["via", value] => gateway = Some((*value).to_owned()),
_ => {}
}
}
if interface.is_none() {
return Err(EgressRouteNotReady::new(
target,
"linux route output did not include an interface",
)
.with_route_output(output)
.into());
}
Ok(RouteInfo { interface, gateway })
}
#[cfg(test)]
mod tests {
use super::{EgressRouteNotReady, RouteInfo, build_client_hook_plan, exclude_routes};
use crate::wg::{WgRuntimeConfig, default_client_allowed_ips};
use ipnet::IpNet;
use std::{
collections::BTreeMap,
fs,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
fn client_runtime() -> WgRuntimeConfig {
WgRuntimeConfig {
bind: SocketAddr::from(([0, 0, 0, 0], 0)),
endpoint: Some(SocketAddr::from(([198, 51, 100, 10], 51820))),
tunnel_ip: IpAddr::V4(Ipv4Addr::new(10, 8, 0, 2)),
peer_tunnel_ip: IpAddr::V4(Ipv4Addr::new(10, 8, 0, 1)),
mtu: 1420,
persistent_keepalive_secs: Some(25),
private_key: [1u8; 32],
peer_public_key: [2u8; 32],
peer_allowed_ips: default_client_allowed_ips(),
excluded_ips: Vec::new(),
}
}
fn unique_test_path(name: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("runnel-{name}-{}-{nonce}.json", std::process::id()))
}
#[test]
fn dynamic_direct_route_rejects_special_purpose_ips() {
for ip in [
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1)),
IpAddr::V4(Ipv4Addr::new(224, 0, 0, 1)),
IpAddr::V4(Ipv4Addr::new(255, 255, 255, 255)),
IpAddr::V6(Ipv6Addr::LOCALHOST),
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
IpAddr::V6("fe80::1".parse().unwrap()),
IpAddr::V6("ff02::1".parse().unwrap()),
] {
assert!(
!super::is_dynamic_direct_route_ip(ip),
"{ip} should not become a dynamic direct route"
);
}
}
#[test]
fn dynamic_direct_route_accepts_unicast_ips() {
assert!(super::is_dynamic_direct_route_ip(IpAddr::V4(
Ipv4Addr::new(8, 8, 8, 8)
)));
assert!(super::is_dynamic_direct_route_ip(IpAddr::V6(
"2606:4700:4700::1111".parse().unwrap()
)));
}
#[test]
fn dynamic_route_ledger_round_trips_hosts() {
let path = unique_test_path("dynamic-routes");
let ledger = super::DynamicRouteLedger::new(path.clone());
let mut hosts = BTreeMap::new();
hosts.insert(
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 108)),
"example.com".to_owned(),
);
hosts.insert(
IpAddr::V4(Ipv4Addr::new(203, 0, 113, 109)),
"example.com".to_owned(),
);
ledger.save_hosts(&hosts).expect("ledger should be saved");
let entries =
super::read_dynamic_route_ledger_entries(&path).expect("ledger should be readable");
assert_eq!(
entries,
vec![
super::DynamicRouteLedgerEntry {
ip: IpAddr::V4(Ipv4Addr::new(203, 0, 113, 108)),
domain: "example.com".to_owned(),
},
super::DynamicRouteLedgerEntry {
ip: IpAddr::V4(Ipv4Addr::new(203, 0, 113, 109)),
domain: "example.com".to_owned(),
},
]
);
ledger.remove().expect("ledger should be removed");
assert!(!path.exists());
}
#[test]
fn dynamic_route_ledger_removes_empty_host_set() {
let path = unique_test_path("dynamic-routes-empty");
fs::write(&path, b"{\"routes\":[]}").expect("seed ledger should be writable");
let ledger = super::DynamicRouteLedger::new(path.clone());
ledger
.save_hosts(&BTreeMap::new())
.expect("empty host set should remove ledger");
assert!(!path.exists());
}
#[cfg(target_os = "macos")]
fn client_runtime_v6() -> WgRuntimeConfig {
WgRuntimeConfig {
bind: SocketAddr::from(([0, 0, 0, 0], 0)),
endpoint: Some("[2001:db8::10]:51820".parse().unwrap()),
tunnel_ip: IpAddr::V6("fd00:8::2".parse().unwrap()),
peer_tunnel_ip: IpAddr::V6("fd00:8::1".parse().unwrap()),
mtu: 1420,
persistent_keepalive_secs: Some(25),
private_key: [1u8; 32],
peer_public_key: [2u8; 32],
peer_allowed_ips: vec!["::/0".to_owned()],
excluded_ips: vec!["fc00::/7".to_owned(), "10.0.0.0/8".to_owned()],
}
}
#[test]
fn excluded_ipv4_route_removes_subnet_from_default_halves() {
let routes = vec![
"0.0.0.0/1".parse::<IpNet>().unwrap(),
"128.0.0.0/1".parse::<IpNet>().unwrap(),
];
let excluded = vec!["192.168.0.0/16".parse::<IpNet>().unwrap()];
let result = exclude_routes(routes, &excluded);
assert!(!result.contains(&"192.168.0.0/16".parse::<IpNet>().unwrap()));
assert!(
result
.iter()
.any(|route| route.contains(&IpAddr::V4(Ipv4Addr::new(192, 167, 255, 255))))
);
assert!(
result
.iter()
.any(|route| route.contains(&IpAddr::V4(Ipv4Addr::new(192, 169, 0, 1))))
);
assert!(
!result
.iter()
.any(|route| route.contains(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))))
);
}
#[test]
fn excluded_ipv6_route_removes_subnet_from_default_halves() {
let routes = vec![
"::/1".parse::<IpNet>().unwrap(),
"8000::/1".parse().unwrap(),
];
let excluded = vec!["fc00::/7".parse::<IpNet>().unwrap()];
let result = exclude_routes(routes, &excluded);
assert!(!result.contains(&"fc00::/7".parse::<IpNet>().unwrap()));
assert!(
result
.iter()
.any(|route| route.contains(&IpAddr::V6("fbff::1".parse::<Ipv6Addr>().unwrap())))
);
assert!(
result
.iter()
.any(|route| route.contains(&IpAddr::V6("fe00::1".parse::<Ipv6Addr>().unwrap())))
);
assert!(
!result
.iter()
.any(|route| route.contains(&IpAddr::V6("fc00::1".parse::<Ipv6Addr>().unwrap())))
);
}
#[cfg(target_os = "macos")]
#[test]
fn macos_route_get_ignores_non_ip_gateway_values() {
let route = super::parse_macos_route_get(
"172.235.244.118",
r#"
route to: 172.235.244.118
destination: 172.235.244.118
gateway: index: 27 utun5
interface: utun5
"#,
)
.unwrap();
assert_eq!(route.interface.as_deref(), Some("utun5"));
assert_eq!(route.gateway, None);
}
#[cfg(target_os = "macos")]
#[test]
fn macos_route_get_missing_interface_is_recoverable() {
let err = super::parse_macos_route_get(
"172.235.244.118",
r#"
route to: 172.235.244.118
destination: 172.235.244.118
mask: 255.255.255.255
"#,
)
.expect_err("missing route interface should be treated as route-not-ready");
let route_not_ready = err
.downcast_ref::<EgressRouteNotReady>()
.expect("missing interface should produce EgressRouteNotReady");
assert_eq!(route_not_ready.target(), "172.235.244.118");
assert_eq!(
route_not_ready.detail(),
"route output did not include an interface"
);
assert!(
route_not_ready
.route_output_for_log()
.expect("route output should be preserved")
.contains("destination: 172.235.244.118")
);
}
#[cfg(target_os = "macos")]
#[test]
fn macos_client_hook_plan_rejects_endpoint_route_through_tunnel_interface() {
for gateway in [None, Some("198.18.0.1".to_owned())] {
let err = build_client_hook_plan(
"utun123",
IpAddr::V4(Ipv4Addr::new(198, 51, 100, 10)),
&client_runtime(),
&RouteInfo {
interface: Some("utun5".to_owned()),
gateway,
},
)
.expect_err("endpoint route through tunnel should be rejected");
assert!(format!("{err:#}").contains("stale tunnel routes"));
}
}
#[cfg(target_os = "macos")]
#[test]
fn macos_client_hook_plan_installs_split_default_routes() {
let plan = build_client_hook_plan(
"utun123",
IpAddr::V4(Ipv4Addr::new(198, 51, 100, 10)),
&client_runtime(),
&RouteInfo {
interface: Some("en0".to_owned()),
gateway: Some("192.168.3.1".to_owned()),
},
)
.unwrap();
assert_eq!(
plan.up.first().map(String::as_str),
Some("ifconfig utun123 inet 10.8.0.2 10.8.0.1 mtu 1420 up")
);
assert!(
plan.up
.iter()
.any(|hook| hook.contains("route -q -n add -host 198.51.100.10 192.168.3.1"))
);
assert!(
plan.up
.iter()
.any(|hook| hook.contains("route -q -n add -net 0.0.0.0/1 10.8.0.1"))
);
assert!(
plan.up
.iter()
.any(|hook| hook.contains("route -q -n add -net 128.0.0.0/1 10.8.0.1"))
);
assert!(
plan.down
.iter()
.any(|hook| hook.contains("ifconfig utun123 down"))
);
}
#[cfg(target_os = "macos")]
#[test]
fn macos_client_hook_plan_uses_custom_allowed_routes() {
let mut runtime = client_runtime();
runtime.peer_allowed_ips = vec!["203.0.113.0/24".to_owned(), "198.18.0.2/32".to_owned()];
let plan = build_client_hook_plan(
"utun123",
IpAddr::V4(Ipv4Addr::new(198, 51, 100, 10)),
&runtime,
&RouteInfo {
interface: Some("en0".to_owned()),
gateway: Some("192.168.3.1".to_owned()),
},
)
.unwrap();
assert!(
plan.up
.iter()
.any(|hook| hook.contains("add -net 203.0.113.0/24 10.8.0.1"))
);
assert!(
plan.up
.iter()
.any(|hook| hook.contains("add -host 198.18.0.2 10.8.0.1"))
);
assert!(!plan.up.iter().any(|hook| hook.contains("0.0.0.0/1")));
}
#[cfg(target_os = "macos")]
#[test]
fn macos_client_hook_plan_supports_ipv6_tunnel_and_endpoint() {
let plan = build_client_hook_plan(
"utun123",
IpAddr::V6("2001:db8::10".parse().unwrap()),
&client_runtime_v6(),
&RouteInfo {
interface: Some("en0".to_owned()),
gateway: Some("fe80::1".to_owned()),
},
)
.unwrap();
assert_eq!(
plan.up.first().map(String::as_str),
Some("ifconfig utun123 inet6 fd00:8::2 fd00:8::1 prefixlen 128 mtu 1420 up")
);
assert!(
plan.up
.iter()
.any(|hook| hook.contains("route -q -n add -inet6 -host 2001:db8::10 fe80::1"))
);
assert!(
plan.up
.iter()
.any(|hook| hook.contains("route -q -n add -inet6 -net ::/1 fd00:8::1"))
);
assert!(
plan.up
.iter()
.any(|hook| hook.contains("route -q -n add -inet6 -net fe00::/7 fd00:8::1"))
);
assert!(
!plan
.up
.iter()
.any(|hook| hook.contains("route -q -n add -net 10.0.0.0/8"))
);
}
#[cfg(target_os = "linux")]
#[test]
fn linux_client_hook_plan_installs_split_default_routes() {
let plan = build_client_hook_plan(
"runnelwg0",
IpAddr::V4(Ipv4Addr::new(198, 51, 100, 10)),
&client_runtime(),
&RouteInfo {
interface: Some("eth0".to_owned()),
gateway: Some("192.168.1.1".to_owned()),
},
)
.unwrap();
assert_eq!(
plan.up.first().map(String::as_str),
Some("ip address add 10.8.0.2 peer 10.8.0.1 dev runnelwg0")
);
assert!(
plan.up
.iter()
.any(|hook| hook == "ip route replace 0.0.0.0/1 dev runnelwg0")
);
assert!(
plan.up
.iter()
.any(|hook| hook == "ip route replace 128.0.0.0/1 dev runnelwg0")
);
assert!(
plan.down
.iter()
.any(|hook| hook.contains("ip link set dev runnelwg0 down"))
);
}
#[cfg(target_os = "linux")]
#[test]
fn linux_client_hook_plan_uses_custom_allowed_routes() {
let mut runtime = client_runtime();
runtime.peer_allowed_ips = vec!["203.0.113.0/24".to_owned(), "198.18.0.2/32".to_owned()];
let plan = build_client_hook_plan(
"runnelwg0",
IpAddr::V4(Ipv4Addr::new(198, 51, 100, 10)),
&runtime,
&RouteInfo {
interface: Some("eth0".to_owned()),
gateway: Some("192.168.1.1".to_owned()),
},
)
.unwrap();
assert!(
plan.up
.iter()
.any(|hook| hook == "ip route replace 203.0.113.0/24 dev runnelwg0")
);
assert!(
plan.up
.iter()
.any(|hook| hook == "ip route replace 198.18.0.2/32 dev runnelwg0")
);
assert!(
!plan
.up
.iter()
.any(|hook| hook == "ip route replace 0.0.0.0/1 dev runnelwg0")
);
}
#[cfg(target_os = "linux")]
#[test]
fn linux_server_hook_plan_adds_nat_rules_and_peer_routes_when_requested() {
use super::{HookPlan, build_server_hook_plan};
let runtime = WgRuntimeConfig {
bind: SocketAddr::from(([0, 0, 0, 0], 51820)),
endpoint: None,
tunnel_ip: IpAddr::V4(Ipv4Addr::new(10, 8, 0, 1)),
peer_tunnel_ip: IpAddr::V4(Ipv4Addr::new(10, 8, 0, 2)),
mtu: 1420,
persistent_keepalive_secs: None,
private_key: [3u8; 32],
peer_public_key: [4u8; 32],
peer_allowed_ips: vec!["10.9.0.0/24".to_owned(), "10.8.0.2/32".to_owned()],
excluded_ips: Vec::new(),
};
let HookPlan { up, down } =
build_server_hook_plan("runnelwg0", &runtime, Some("eth0")).unwrap();
assert!(up.iter().any(|hook| hook.contains("net.ipv4.ip_forward=1")));
assert!(
up.iter()
.any(|hook| hook == "ip route replace 10.9.0.0/24 dev runnelwg0")
);
assert!(
!up.iter()
.any(|hook| hook == "ip route replace 10.8.0.2/32 dev runnelwg0")
);
assert!(
up.iter()
.any(|hook| hook.contains("POSTROUTING -o eth0 -j MASQUERADE"))
);
assert!(
down.iter()
.any(|hook| hook.contains("-D POSTROUTING -o eth0 -j MASQUERADE"))
);
}
}