use crate::Result;
use crate::error::RouteError;
use crate::network::route::{InstalledExclusionRoute, NextHop};
use crate::utils::command::run_command;
use ipnet::{IpNet, Ipv4Net, Ipv6Net};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::process::Output;
use std::str::FromStr;
use tracing::warn;
#[cfg(target_os = "linux")]
const IP_COMMAND: &str = "ip";
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
const ROUTE_COMMAND: &str = "route";
pub fn add_routes(
networks: &[IpNet],
gateway: &IpAddr,
tunnel_interface: &str,
remote_address: Option<IpAddr>,
) -> Result<Option<InstalledExclusionRoute>> {
let split_networks;
let effective_networks = if cfg!(any(target_os = "macos", target_os = "freebsd")) {
split_networks = bsd_split_default_routes(networks);
&split_networks
} else {
networks
};
let exclusion = match remote_address {
Some(server) => match install_exclusion_for_server(&server, tunnel_interface) {
Ok(token) => token,
Err(err) => {
if any_route_covers_address(effective_networks, &server) {
return Err(RouteError::ExclusionRequired { server }.into());
}
warn!(
%server,
"exclusion route could not be installed but routes do not \
cover the server; continuing: {err}"
);
None
}
},
None => None,
};
for network in effective_networks {
if let Err(add_err) = add_route(network, gateway) {
if let Some(ref token) = exclusion {
if let Err(rm_err) = remove_exclusion_route(token) {
warn!(
"failed to roll back exclusion route for {}: {rm_err}",
token.destination
);
}
}
return Err(add_err);
}
}
Ok(exclusion)
}
fn install_exclusion_for_server(
server: &IpAddr,
tunnel_interface: &str,
) -> Result<Option<InstalledExclusionRoute>> {
let next_hop = get_route_to(server)?;
if next_hop_uses_interface(&next_hop, tunnel_interface) {
return Err(RouteError::PlatformError {
message: format!(
"refusing to install exclusion route for {server}: resolved next-hop \
egress interface '{tunnel_interface}' is the tunnel itself"
),
}
.into());
}
add_exclusion_route(server, &next_hop)
}
fn next_hop_uses_interface(next_hop: &NextHop, interface: &str) -> bool {
if interface.is_empty() {
return false;
}
let hop_iface = match next_hop {
NextHop::Gateway { interface, .. } | NextHop::OnLink { interface } => interface.as_str(),
};
hop_iface == interface
}
pub(crate) fn bsd_split_default_routes(networks: &[IpNet]) -> Vec<IpNet> {
let mut out = Vec::with_capacity(networks.len());
for net in networks {
match net {
IpNet::V4(v4) if v4.prefix_len() == 0 => {
out.push(IpNet::V4(
Ipv4Net::new(Ipv4Addr::new(0, 0, 0, 0), 1).unwrap(),
));
out.push(IpNet::V4(
Ipv4Net::new(Ipv4Addr::new(128, 0, 0, 0), 1).unwrap(),
));
}
IpNet::V6(v6) if v6.prefix_len() == 0 => {
out.push(IpNet::V6(Ipv6Net::new(Ipv6Addr::UNSPECIFIED, 1).unwrap()));
out.push(IpNet::V6(
Ipv6Net::new(Ipv6Addr::new(0x8000, 0, 0, 0, 0, 0, 0, 0), 1).unwrap(),
));
}
other => out.push(*other),
}
}
out
}
pub(crate) fn any_route_covers_address(routes: &[IpNet], address: &IpAddr) -> bool {
routes.iter().any(|net| net.contains(address))
}
fn add_route(network: &IpNet, gateway: &IpAddr) -> Result<()> {
let args = user_route_add_args(network, gateway);
let program = &args[0];
let cmd_args = &args[1..];
let output = run_command(program, cmd_args)
.map_err(|e| RouteError::PlatformError {
message: format!("failed to execute command: {e}"),
})?
.wait_with_output()
.map_err(|e| RouteError::PlatformError {
message: format!("failed to create child process: {e}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RouteError::AddFailed {
destination: network.to_string(),
message: stderr.trim().to_string(),
}
.into());
}
Ok(())
}
#[cfg(target_os = "linux")]
fn user_route_add_args(network: &IpNet, gateway: &IpAddr) -> Vec<String> {
let prefix = format!("{}/{}", network.addr(), network.prefix_len());
let mut args = vec![IP_COMMAND.to_string()];
if matches!(network, IpNet::V6(_)) {
args.push("-6".to_string());
}
args.extend([
"route".to_string(),
"add".to_string(),
prefix,
"via".to_string(),
gateway.to_string(),
]);
args
}
#[cfg(target_os = "macos")]
fn user_route_add_args(network: &IpNet, gateway: &IpAddr) -> Vec<String> {
match network {
IpNet::V4(_) => vec![
ROUTE_COMMAND.to_string(),
"-n".to_string(),
"add".to_string(),
"-net".to_string(),
network.addr().to_string(),
"-netmask".to_string(),
network.netmask().to_string(),
gateway.to_string(),
],
IpNet::V6(_) => vec![
ROUTE_COMMAND.to_string(),
"-n".to_string(),
"add".to_string(),
"-inet6".to_string(),
format!("{}/{}", network.addr(), network.prefix_len()),
gateway.to_string(),
],
}
}
#[cfg(target_os = "freebsd")]
fn user_route_add_args(network: &IpNet, gateway: &IpAddr) -> Vec<String> {
match network {
IpNet::V4(_) => vec![
ROUTE_COMMAND.to_string(),
"add".to_string(),
"-net".to_string(),
network.addr().to_string(),
"-netmask".to_string(),
network.netmask().to_string(),
gateway.to_string(),
],
IpNet::V6(_) => vec![
ROUTE_COMMAND.to_string(),
"add".to_string(),
"-inet6".to_string(),
format!("{}/{}", network.addr(), network.prefix_len()),
gateway.to_string(),
],
}
}
pub fn get_route_to(address: &IpAddr) -> Result<NextHop> {
let output = run_route_get_command(address)?;
let stdout = String::from_utf8_lossy(&output.stdout);
#[cfg(target_os = "linux")]
{
parse_linux_route_get(&stdout, address)
}
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
{
parse_bsd_route_get(&stdout, address)
}
}
pub fn add_exclusion_route(
server: &IpAddr,
next_hop: &NextHop,
) -> Result<Option<InstalledExclusionRoute>> {
let args = exclusion_route_cmd_args(server, next_hop, ExclusionAction::Add);
let program = &args[0];
let cmd_args = &args[1..];
let output = run_command(program, cmd_args)
.map_err(|e| RouteError::PlatformError {
message: format!("failed to execute exclusion route add command: {e}"),
})?
.wait_with_output()
.map_err(|e| RouteError::PlatformError {
message: format!("failed to wait for exclusion route add command: {e}"),
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let already_exists =
output_indicates_already_exists(&stdout) || output_indicates_already_exists(&stderr);
if !output.status.success() && !already_exists {
return Err(RouteError::AddFailed {
destination: server.to_string(),
message: stderr.trim().to_string(),
}
.into());
}
if already_exists {
let existing = get_route_to(server).map_err(|e| RouteError::AddFailed {
destination: server.to_string(),
message: format!(
"exclusion route already exists but re-query failed, \
refusing to adopt foreign route: {e}"
),
})?;
if existing != *next_hop {
return Err(RouteError::AddFailed {
destination: server.to_string(),
message: format!(
"exclusion route already exists with a different next-hop \
({existing:?}) than the one we tried to install ({next_hop:?}); \
refusing to adopt foreign route"
),
}
.into());
}
return Ok(None);
}
Ok(Some(InstalledExclusionRoute {
destination: *server,
next_hop: next_hop.clone(),
}))
}
pub fn remove_exclusion_route(exclusion: &InstalledExclusionRoute) -> Result<()> {
let args = exclusion_route_cmd_args(
&exclusion.destination,
&exclusion.next_hop,
ExclusionAction::Remove,
);
let output = run_exclusion_remove(&args)?;
if output.status.success() {
return Ok(());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if output_indicates_not_found(&stdout) || output_indicates_not_found(&stderr) {
return Ok(());
}
let destination = exclusion.destination;
warn!(
%destination,
"exclusion route removal with stored next-hop failed ({}); retrying \
with destination+interface command",
stderr.trim()
);
let fallback_args =
exclusion_route_remove_fallback_args(&exclusion.next_hop, &exclusion.destination);
let fallback_output = run_exclusion_remove(&fallback_args)?;
if fallback_output.status.success() {
return Ok(());
}
let fb_stdout = String::from_utf8_lossy(&fallback_output.stdout);
let fb_stderr = String::from_utf8_lossy(&fallback_output.stderr);
if output_indicates_not_found(&fb_stdout) || output_indicates_not_found(&fb_stderr) {
return Ok(());
}
Err(RouteError::RemoveFailed {
destination: exclusion.destination.to_string(),
}
.into())
}
fn run_exclusion_remove(args: &[String]) -> Result<Output> {
let program = &args[0];
let cmd_args = &args[1..];
let output = run_command(program, cmd_args)
.map_err(|e| RouteError::PlatformError {
message: format!("failed to execute exclusion route remove command: {e}"),
})?
.wait_with_output()
.map_err(|e| RouteError::PlatformError {
message: format!("failed to wait for exclusion route remove command: {e}"),
})?;
Ok(output)
}
fn next_hop_interface(next_hop: &NextHop) -> &str {
match next_hop {
NextHop::Gateway { interface, .. } | NextHop::OnLink { interface } => interface,
}
}
#[cfg(target_os = "linux")]
fn exclusion_route_remove_fallback_args(next_hop: &NextHop, server: &IpAddr) -> Vec<String> {
let host_cidr = match server {
IpAddr::V4(v4) => format!("{v4}/32"),
IpAddr::V6(v6) => format!("{v6}/128"),
};
let mut args = vec![IP_COMMAND.to_string()];
if server.is_ipv6() {
args.push("-6".to_string());
}
args.extend(["route".to_string(), "delete".to_string(), host_cidr]);
args.extend(["dev".to_string(), next_hop_interface(next_hop).to_string()]);
args
}
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
fn exclusion_route_remove_fallback_args(next_hop: &NextHop, server: &IpAddr) -> Vec<String> {
let mut args = vec![
ROUTE_COMMAND.to_string(),
"-n".to_string(),
"delete".to_string(),
];
if server.is_ipv6() {
args.push("-inet6".to_string());
}
args.extend(["-host".to_string(), server.to_string()]);
args.extend(["-ifp".to_string(), next_hop_interface(next_hop).to_string()]);
args
}
#[derive(Clone, Copy)]
enum ExclusionAction {
Add,
Remove,
}
#[cfg(target_os = "linux")]
fn exclusion_route_cmd_args(
server: &IpAddr,
next_hop: &NextHop,
action: ExclusionAction,
) -> Vec<String> {
let action_str = match action {
ExclusionAction::Add => "add",
ExclusionAction::Remove => "delete",
};
let host_cidr = match server {
IpAddr::V4(v4) => format!("{v4}/32"),
IpAddr::V6(v6) => format!("{v6}/128"),
};
let mut args = vec![IP_COMMAND.to_string()];
if server.is_ipv6() {
args.push("-6".to_string());
}
args.extend(["route".to_string(), action_str.to_string(), host_cidr]);
match next_hop {
NextHop::Gateway { address, interface } => {
args.extend(["via".to_string(), address.to_string()]);
if is_link_local(address) {
args.extend(["dev".to_string(), interface.clone()]);
}
}
NextHop::OnLink { interface } => {
args.extend(["dev".to_string(), interface.clone()]);
}
}
args
}
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
fn exclusion_route_cmd_args(
server: &IpAddr,
next_hop: &NextHop,
action: ExclusionAction,
) -> Vec<String> {
let action_str = match action {
ExclusionAction::Add => "add",
ExclusionAction::Remove => "delete",
};
let mut args = vec![
ROUTE_COMMAND.to_string(),
"-n".to_string(),
action_str.to_string(),
];
if server.is_ipv6() {
args.push("-inet6".to_string());
}
args.extend(["-host".to_string(), server.to_string()]);
match next_hop {
NextHop::Gateway { address, interface } => {
if is_ipv6_link_local(address) {
args.push(format!("{address}%{interface}"));
} else {
args.push(address.to_string());
}
}
NextHop::OnLink { interface } => {
args.extend(["-interface".to_string(), interface.clone()]);
}
}
args
}
fn is_ipv6_link_local(addr: &IpAddr) -> bool {
match addr {
IpAddr::V6(v6) => v6.segments()[0] & 0xffc0 == 0xfe80,
IpAddr::V4(_) => false,
}
}
#[cfg(target_os = "linux")]
fn is_link_local(addr: &IpAddr) -> bool {
match addr {
IpAddr::V4(v4) => v4.is_link_local(),
IpAddr::V6(_) => is_ipv6_link_local(addr),
}
}
fn output_indicates_already_exists(output: &str) -> bool {
let lower = output.to_lowercase();
lower.contains("file exists") || lower.contains("route already in table")
}
fn output_indicates_not_found(output: &str) -> bool {
let lower = output.to_lowercase();
lower.contains("no such process")
|| lower.contains("not in table")
|| lower.contains("cannot find")
|| lower.contains("no such file or directory")
}
fn run_route_get_command(address: &IpAddr) -> Result<Output> {
let addr_str = address.to_string();
#[cfg(target_os = "linux")]
let (program, args): (&str, Vec<&str>) = match address {
IpAddr::V4(_) => (IP_COMMAND, vec!["route", "get", &addr_str]),
IpAddr::V6(_) => (IP_COMMAND, vec!["-6", "route", "get", &addr_str]),
};
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
let (program, args): (&str, Vec<&str>) = match address {
IpAddr::V4(_) => (ROUTE_COMMAND, vec!["-n", "get", &addr_str]),
IpAddr::V6(_) => (ROUTE_COMMAND, vec!["-n", "get", "-inet6", &addr_str]),
};
let output = run_command(program, &args)
.map_err(|e| RouteError::PlatformError {
message: format!("failed to execute route-get command: {e}"),
})?
.wait_with_output()
.map_err(|e| RouteError::PlatformError {
message: format!("failed to wait for route-get command: {e}"),
})?;
if !output.status.success() {
return Err(RouteError::NotFound {
destination: address.to_string(),
}
.into());
}
Ok(output)
}
#[cfg(target_os = "linux")]
fn parse_linux_route_get(output: &str, address: &IpAddr) -> Result<NextHop> {
let joined = output
.lines()
.filter(|l| !l.is_empty())
.fold((String::new(), false), |(mut acc, done), line| {
if done {
return (acc, true);
}
if line.starts_with(' ') || line.starts_with('\t') {
acc.push(' ');
acc.push_str(line.trim());
} else {
if !acc.is_empty() {
return (acc, true);
}
acc.push_str(line.trim());
}
(acc, false)
})
.0;
let tokens: Vec<&str> = joined.split_whitespace().collect();
if let Some(first) = tokens.first() {
if matches!(*first, "unreachable" | "blackhole" | "prohibit" | "throw") {
return Err(RouteError::NotFound {
destination: address.to_string(),
}
.into());
}
}
let interface = find_token_value(&tokens, "dev")
.map(String::from)
.ok_or_else(|| RouteError::PlatformError {
message: format!(
"could not determine interface from route output for {address}: {output}"
),
})?;
let gateway = match find_token_value(&tokens, "via") {
Some(raw) => {
let without_scope = raw.split('%').next().unwrap_or(raw);
let parsed =
IpAddr::from_str(without_scope).map_err(|_| RouteError::PlatformError {
message: format!(
"could not parse gateway '{raw}' from route output for {address}: {output}"
),
})?;
Some(parsed)
}
None => None,
};
match gateway {
Some(addr) => Ok(NextHop::Gateway {
address: addr,
interface,
}),
None => Ok(NextHop::OnLink { interface }),
}
}
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
fn parse_bsd_route_get(output: &str, address: &IpAddr) -> Result<NextHop> {
let mut gateway: Option<IpAddr> = None;
let mut interface: Option<String> = None;
for line in output.lines() {
let line = line.trim();
if let Some(value) = line.strip_prefix("gateway:") {
let raw = value.trim();
let without_scope = raw.split('%').next().unwrap_or(raw);
let parsed = IpAddr::from_str(without_scope)
.ok()
.filter(|addr| !addr.is_unspecified());
if parsed.is_some() {
gateway = parsed;
}
} else if let Some(value) = line.strip_prefix("interface:") {
interface = Some(value.trim().to_string());
}
}
let interface = interface.ok_or_else(|| RouteError::PlatformError {
message: format!("could not determine interface from route output for {address}: {output}"),
})?;
match gateway {
Some(addr) => Ok(NextHop::Gateway {
address: addr,
interface,
}),
None => Ok(NextHop::OnLink { interface }),
}
}
#[cfg(target_os = "linux")]
fn find_token_value<'a>(tokens: &[&'a str], key: &str) -> Option<&'a str> {
tokens
.windows(2)
.find(|pair| pair[0] == key)
.map(|pair| pair[1])
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
#[cfg(target_os = "linux")]
mod linux_parser {
use super::*;
#[test]
fn ipv4_gateway() {
let output = "8.8.8.8 via 192.168.1.1 dev eth0 src 192.168.1.100 uid 1000\n cache\n";
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
let hop = parse_linux_route_get(output, &addr).unwrap();
match hop {
NextHop::Gateway { address, interface } => {
assert_eq!(address, IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)));
assert_eq!(interface, "eth0");
}
_ => panic!("expected Gateway, got {hop:?}"),
}
}
#[test]
fn ipv4_onlink() {
let output = "192.168.1.5 dev eth0 src 192.168.1.100 uid 1000\n cache\n";
let addr = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5));
let hop = parse_linux_route_get(output, &addr).unwrap();
match hop {
NextHop::OnLink { interface } => {
assert_eq!(interface, "eth0");
}
_ => panic!("expected OnLink, got {hop:?}"),
}
}
#[test]
fn ipv4_multiline() {
let output = "\
8.8.8.8 via 10.0.0.1 dev wlan0
src 10.0.0.42 uid 1000
cache
";
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
let hop = parse_linux_route_get(output, &addr).unwrap();
match hop {
NextHop::Gateway { address, interface } => {
assert_eq!(address, IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)));
assert_eq!(interface, "wlan0");
}
_ => panic!("expected Gateway, got {hop:?}"),
}
}
#[test]
fn ipv6_gateway() {
let output = "2001:4860:4860::8888 from :: via fe80::1 dev eth0 proto ra src 2001:db8::1 metric 100 pref medium\n";
let addr = IpAddr::V6(Ipv6Addr::new(0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888));
let hop = parse_linux_route_get(output, &addr).unwrap();
match hop {
NextHop::Gateway { address, interface } => {
assert_eq!(
address,
IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1))
);
assert_eq!(interface, "eth0");
}
_ => panic!("expected Gateway, got {hop:?}"),
}
}
#[test]
fn ipv6_onlink() {
let output = "fe80::5 dev eth0 src fe80::1 metric 0 pref medium\n";
let addr = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 5));
let hop = parse_linux_route_get(output, &addr).unwrap();
match hop {
NextHop::OnLink { interface } => {
assert_eq!(interface, "eth0");
}
_ => panic!("expected OnLink, got {hop:?}"),
}
}
#[test]
fn ipv6_scoped_gateway_stripped() {
let output = "2001:db8::1 from :: via fe80::1%eth0 dev eth0 proto ra src 2001:db8::2 metric 100 pref medium\n";
let addr: IpAddr = "2001:db8::1".parse().unwrap();
let hop = parse_linux_route_get(output, &addr).unwrap();
match hop {
NextHop::Gateway { address, interface } => {
assert_eq!(
address,
IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1))
);
assert_eq!(interface, "eth0");
}
_ => panic!("expected Gateway, got {hop:?}"),
}
}
#[test]
fn unparsable_gateway_returns_error() {
let output = "10.0.0.1 via link#5 dev eth0 src 10.0.0.2\n";
let addr: IpAddr = "10.0.0.1".parse().unwrap();
let err = parse_linux_route_get(output, &addr).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("could not parse gateway"),
"expected parse error, got: {msg}"
);
}
#[test]
fn special_route_types_are_rejected() {
let addr: IpAddr = "10.0.0.1".parse().unwrap();
for route_type in ["unreachable", "blackhole", "prohibit", "throw"] {
let output = format!("{route_type} 10.0.0.1 dev lo\n cache\n");
let err = parse_linux_route_get(&output, &addr).unwrap_err();
assert!(
matches!(
err,
crate::error::QuincyError::Route(RouteError::NotFound { .. })
),
"{route_type}: expected RouteError::NotFound, got: {err}"
);
}
}
#[test]
fn multiblock_uses_only_first_block() {
let output = "\
8.8.8.8 via 10.0.0.1 dev wlan0
src 10.0.0.42 uid 1000
cache
8.8.8.8 via 172.16.0.1 dev eth1
src 172.16.0.42
";
let addr: IpAddr = "8.8.8.8".parse().unwrap();
let hop = parse_linux_route_get(output, &addr).unwrap();
match hop {
NextHop::Gateway { address, interface } => {
assert_eq!(address, IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)));
assert_eq!(interface, "wlan0");
}
_ => panic!("expected Gateway from first block, got {hop:?}"),
}
}
}
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
mod bsd_parser {
use super::*;
#[test]
fn ipv4_gateway() {
let output = "\
route to: 8.8.8.8
destination: default
mask: default
gateway: 192.168.1.1
interface: en0
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING>
recvpipe sendpipe ssthresh rtt,msec mtu weight expire
0 0 0 0 1500 1 0
";
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
let hop = parse_bsd_route_get(output, &addr).unwrap();
match hop {
NextHop::Gateway { address, interface } => {
assert_eq!(address, IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)));
assert_eq!(interface, "en0");
}
_ => panic!("expected Gateway, got {hop:?}"),
}
}
#[test]
fn ipv4_onlink() {
let output = "\
route to: 192.168.1.5
destination: 192.168.1.0
mask: 255.255.255.0
interface: en0
flags: <UP,DONE,CLONING>
recvpipe sendpipe ssthresh rtt,msec mtu weight expire
0 0 0 0 1500 1 0
";
let addr = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5));
let hop = parse_bsd_route_get(output, &addr).unwrap();
match hop {
NextHop::OnLink { interface } => {
assert_eq!(interface, "en0");
}
_ => panic!("expected OnLink, got {hop:?}"),
}
}
#[test]
fn ipv6_gateway() {
let output = "\
route to: 2001:4860:4860::8888
destination: default
gateway: fe80::1%en0
interface: en0
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING>
";
let addr = IpAddr::V6(Ipv6Addr::new(0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888));
let hop = parse_bsd_route_get(output, &addr).unwrap();
match hop {
NextHop::Gateway { address, interface } => {
assert_eq!(
address,
IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1))
);
assert_eq!(interface, "en0");
}
_ => panic!("expected Gateway, got {hop:?}"),
}
}
#[test]
fn ipv6_onlink() {
let output = "\
route to: fe80::5
destination: fe80::
mask: ffff:ffff:ffff:ffff::
interface: en0
flags: <UP,DONE,CLONING>
";
let addr = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 5));
let hop = parse_bsd_route_get(output, &addr).unwrap();
match hop {
NextHop::OnLink { interface } => {
assert_eq!(interface, "en0");
}
_ => panic!("expected OnLink, got {hop:?}"),
}
}
#[test]
fn ipv4_unspecified_gateway_is_onlink() {
let output = "\
route to: 10.0.0.5
destination: 10.0.0.0
mask: 255.255.255.0
gateway: 0.0.0.0
interface: em0
flags: <UP,DONE,CLONING>
";
let addr = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5));
let hop = parse_bsd_route_get(output, &addr).unwrap();
match hop {
NextHop::OnLink { interface } => {
assert_eq!(interface, "em0");
}
_ => panic!("expected OnLink for unspecified gateway, got {hop:?}"),
}
}
#[test]
fn ipv6_unspecified_gateway_is_onlink() {
let output = "\
route to: 2001:db8::5
destination: 2001:db8::
gateway: ::
interface: em0
flags: <UP,DONE,CLONING>
";
let addr: IpAddr = "2001:db8::5".parse().unwrap();
let hop = parse_bsd_route_get(output, &addr).unwrap();
match hop {
NextHop::OnLink { interface } => {
assert_eq!(interface, "em0");
}
_ => panic!("expected OnLink for unspecified IPv6 gateway, got {hop:?}"),
}
}
#[test]
fn valid_gateway_not_overwritten_by_later_invalid() {
let output = "\
route to: 8.8.8.8
destination: default
mask: default
gateway: 192.168.1.1
gateway: link#5
interface: en0
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING>
";
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
let hop = parse_bsd_route_get(output, &addr).unwrap();
match hop {
NextHop::Gateway { address, interface } => {
assert_eq!(address, IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)));
assert_eq!(interface, "en0");
}
_ => panic!("expected Gateway preserved from first line, got {hop:?}"),
}
}
}
#[test]
fn next_hop_uses_interface_matches_gateway() {
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
interface: "tun0".to_string(),
};
assert!(next_hop_uses_interface(&hop, "tun0"));
assert!(!next_hop_uses_interface(&hop, "eth0"));
}
#[test]
fn next_hop_uses_interface_matches_onlink() {
let hop = NextHop::OnLink {
interface: "tun0".to_string(),
};
assert!(next_hop_uses_interface(&hop, "tun0"));
assert!(!next_hop_uses_interface(&hop, "eth0"));
}
#[test]
fn next_hop_uses_interface_is_case_sensitive() {
let hop = NextHop::OnLink {
interface: "tun0".to_string(),
};
assert!(!next_hop_uses_interface(&hop, "TUN0"));
}
#[test]
fn next_hop_uses_interface_empty_disables_check() {
let hop = NextHop::OnLink {
interface: "".to_string(),
};
assert!(!next_hop_uses_interface(&hop, ""));
}
#[cfg(target_os = "linux")]
mod exclusion_linux {
use super::*;
#[test]
fn add_gateway_ipv4() {
let server = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
interface: "eth0".to_string(),
};
let args = exclusion_route_cmd_args(&server, &hop, ExclusionAction::Add);
assert_eq!(
args,
[
IP_COMMAND,
"route",
"add",
"203.0.113.1/32",
"via",
"192.168.1.1"
]
);
}
#[test]
fn add_gateway_ipv6_link_local() {
let server = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
let hop = NextHop::Gateway {
address: IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)),
interface: "eth0".to_string(),
};
let args = exclusion_route_cmd_args(&server, &hop, ExclusionAction::Add);
assert_eq!(
args,
[
IP_COMMAND,
"-6",
"route",
"add",
"2001:db8::1/128",
"via",
"fe80::1",
"dev",
"eth0"
]
);
}
#[test]
fn add_gateway_ipv4_link_local() {
let server = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1)),
interface: "eth0".to_string(),
};
let args = exclusion_route_cmd_args(&server, &hop, ExclusionAction::Add);
assert_eq!(
args,
[
IP_COMMAND,
"route",
"add",
"203.0.113.1/32",
"via",
"169.254.1.1",
"dev",
"eth0"
]
);
}
#[test]
fn add_gateway_ipv6_global() {
let server = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
let hop = NextHop::Gateway {
address: IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0xfe)),
interface: "eth0".to_string(),
};
let args = exclusion_route_cmd_args(&server, &hop, ExclusionAction::Add);
assert_eq!(
args,
[
IP_COMMAND,
"-6",
"route",
"add",
"2001:db8::1/128",
"via",
"2001:db8::fe"
]
);
}
#[test]
fn add_onlink_ipv4() {
let server = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5));
let hop = NextHop::OnLink {
interface: "wlan0".to_string(),
};
let args = exclusion_route_cmd_args(&server, &hop, ExclusionAction::Add);
assert_eq!(
args,
[IP_COMMAND, "route", "add", "10.0.0.5/32", "dev", "wlan0"]
);
}
#[test]
fn add_onlink_ipv6() {
let server = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 5));
let hop = NextHop::OnLink {
interface: "eth0".to_string(),
};
let args = exclusion_route_cmd_args(&server, &hop, ExclusionAction::Add);
assert_eq!(
args,
[
IP_COMMAND,
"-6",
"route",
"add",
"fe80::5/128",
"dev",
"eth0"
]
);
}
#[test]
fn remove_gateway_ipv4() {
let server = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
interface: "eth0".to_string(),
};
let args = exclusion_route_cmd_args(&server, &hop, ExclusionAction::Remove);
assert_eq!(
args,
[
IP_COMMAND,
"route",
"delete",
"203.0.113.1/32",
"via",
"192.168.1.1"
]
);
}
#[test]
fn remove_onlink_ipv6() {
let server = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 5));
let hop = NextHop::OnLink {
interface: "eth0".to_string(),
};
let args = exclusion_route_cmd_args(&server, &hop, ExclusionAction::Remove);
assert_eq!(
args,
[
IP_COMMAND,
"-6",
"route",
"delete",
"fe80::5/128",
"dev",
"eth0"
]
);
}
#[test]
fn remove_fallback_ipv4_scopes_to_interface() {
let server = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
interface: "eth0".to_string(),
};
let args = exclusion_route_remove_fallback_args(&hop, &server);
assert_eq!(
args,
[
IP_COMMAND,
"route",
"delete",
"203.0.113.1/32",
"dev",
"eth0"
]
);
}
#[test]
fn remove_fallback_ipv6_scopes_to_interface() {
let server = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
let hop = NextHop::OnLink {
interface: "eth0".to_string(),
};
let args = exclusion_route_remove_fallback_args(&hop, &server);
assert_eq!(
args,
[
IP_COMMAND,
"-6",
"route",
"delete",
"2001:db8::1/128",
"dev",
"eth0"
]
);
}
}
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
mod exclusion_bsd {
use super::*;
#[test]
fn add_gateway_ipv4() {
let server = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
interface: "en0".to_string(),
};
let args = exclusion_route_cmd_args(&server, &hop, ExclusionAction::Add);
assert_eq!(
args,
[
ROUTE_COMMAND,
"-n",
"add",
"-host",
"203.0.113.1",
"192.168.1.1"
]
);
}
#[test]
fn add_gateway_ipv6_link_local() {
let server = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
let hop = NextHop::Gateway {
address: IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)),
interface: "en0".to_string(),
};
let args = exclusion_route_cmd_args(&server, &hop, ExclusionAction::Add);
assert_eq!(
args,
[
ROUTE_COMMAND,
"-n",
"add",
"-inet6",
"-host",
"2001:db8::1",
"fe80::1%en0"
]
);
}
#[test]
fn add_gateway_ipv6_global() {
let server = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
let hop = NextHop::Gateway {
address: IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0xfe)),
interface: "en0".to_string(),
};
let args = exclusion_route_cmd_args(&server, &hop, ExclusionAction::Add);
assert_eq!(
args,
[
ROUTE_COMMAND,
"-n",
"add",
"-inet6",
"-host",
"2001:db8::1",
"2001:db8::fe"
]
);
}
#[test]
fn add_onlink_ipv4() {
let server = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5));
let hop = NextHop::OnLink {
interface: "en0".to_string(),
};
let args = exclusion_route_cmd_args(&server, &hop, ExclusionAction::Add);
assert_eq!(
args,
[
ROUTE_COMMAND,
"-n",
"add",
"-host",
"10.0.0.5",
"-interface",
"en0"
]
);
}
#[test]
fn add_onlink_ipv6() {
let server = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 5));
let hop = NextHop::OnLink {
interface: "en0".to_string(),
};
let args = exclusion_route_cmd_args(&server, &hop, ExclusionAction::Add);
assert_eq!(
args,
[
ROUTE_COMMAND,
"-n",
"add",
"-inet6",
"-host",
"fe80::5",
"-interface",
"en0"
]
);
}
#[test]
fn remove_gateway_ipv4() {
let server = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
interface: "en0".to_string(),
};
let args = exclusion_route_cmd_args(&server, &hop, ExclusionAction::Remove);
assert_eq!(
args,
[
ROUTE_COMMAND,
"-n",
"delete",
"-host",
"203.0.113.1",
"192.168.1.1"
]
);
}
#[test]
fn remove_onlink_ipv6() {
let server = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 5));
let hop = NextHop::OnLink {
interface: "en0".to_string(),
};
let args = exclusion_route_cmd_args(&server, &hop, ExclusionAction::Remove);
assert_eq!(
args,
[
ROUTE_COMMAND,
"-n",
"delete",
"-inet6",
"-host",
"fe80::5",
"-interface",
"en0"
]
);
}
#[test]
fn remove_fallback_ipv4_scopes_to_interface() {
let server = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
interface: "en0".to_string(),
};
let args = exclusion_route_remove_fallback_args(&hop, &server);
assert_eq!(
args,
[
ROUTE_COMMAND,
"-n",
"delete",
"-host",
"203.0.113.1",
"-ifp",
"en0"
]
);
}
#[test]
fn remove_fallback_ipv6_scopes_to_interface() {
let server = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
let hop = NextHop::OnLink {
interface: "en0".to_string(),
};
let args = exclusion_route_remove_fallback_args(&hop, &server);
assert_eq!(
args,
[
ROUTE_COMMAND,
"-n",
"delete",
"-inet6",
"-host",
"2001:db8::1",
"-ifp",
"en0"
]
);
}
}
#[test]
fn bsd_split_replaces_ipv4_default() {
let nets = vec!["0.0.0.0/0".parse::<IpNet>().unwrap()];
let split = bsd_split_default_routes(&nets);
assert_eq!(split.len(), 2);
assert_eq!(split[0], "0.0.0.0/1".parse::<IpNet>().unwrap());
assert_eq!(split[1], "128.0.0.0/1".parse::<IpNet>().unwrap());
}
#[test]
fn bsd_split_replaces_ipv6_default() {
let nets = vec!["::/0".parse::<IpNet>().unwrap()];
let split = bsd_split_default_routes(&nets);
assert_eq!(split.len(), 2);
assert_eq!(split[0], "::/1".parse::<IpNet>().unwrap());
assert_eq!(split[1], "8000::/1".parse::<IpNet>().unwrap());
}
#[test]
fn bsd_split_preserves_non_default() {
let nets = vec![
"10.0.0.0/8".parse::<IpNet>().unwrap(),
"192.168.1.0/24".parse::<IpNet>().unwrap(),
];
let split = bsd_split_default_routes(&nets);
assert_eq!(split, nets);
}
#[test]
fn bsd_split_mixed() {
let nets = vec![
"0.0.0.0/0".parse::<IpNet>().unwrap(),
"10.0.0.0/8".parse::<IpNet>().unwrap(),
"::/0".parse::<IpNet>().unwrap(),
"fd00::/8".parse::<IpNet>().unwrap(),
];
let split = bsd_split_default_routes(&nets);
assert_eq!(split.len(), 6);
assert_eq!(split[0], "0.0.0.0/1".parse::<IpNet>().unwrap());
assert_eq!(split[1], "128.0.0.0/1".parse::<IpNet>().unwrap());
assert_eq!(split[2], "10.0.0.0/8".parse::<IpNet>().unwrap());
assert_eq!(split[3], "::/1".parse::<IpNet>().unwrap());
assert_eq!(split[4], "8000::/1".parse::<IpNet>().unwrap());
assert_eq!(split[5], "fd00::/8".parse::<IpNet>().unwrap());
}
#[test]
fn bsd_split_empty() {
let split = bsd_split_default_routes(&[]);
assert!(split.is_empty());
}
#[test]
fn covers_address_in_default_route() {
let nets = vec!["0.0.0.0/0".parse::<IpNet>().unwrap()];
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
assert!(any_route_covers_address(&nets, &addr));
}
#[test]
fn covers_address_in_subnet() {
let nets = vec!["10.0.0.0/8".parse::<IpNet>().unwrap()];
let addr = IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3));
assert!(any_route_covers_address(&nets, &addr));
}
#[test]
fn does_not_cover_address_outside_subnet() {
let nets = vec!["10.0.0.0/8".parse::<IpNet>().unwrap()];
let addr = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
assert!(!any_route_covers_address(&nets, &addr));
}
#[test]
fn covers_ipv6_in_default() {
let nets = vec!["::/0".parse::<IpNet>().unwrap()];
let addr: IpAddr = "2001:db8::1".parse().unwrap();
assert!(any_route_covers_address(&nets, &addr));
}
#[test]
fn does_not_cover_ipv4_in_ipv6_route() {
let nets = vec!["::/0".parse::<IpNet>().unwrap()];
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
assert!(!any_route_covers_address(&nets, &addr));
}
#[test]
fn covers_empty_routes() {
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
assert!(!any_route_covers_address(&[], &addr));
}
#[test]
fn covers_split_halves_cover_all_ipv4() {
let nets = vec![
"0.0.0.0/1".parse::<IpNet>().unwrap(),
"128.0.0.0/1".parse::<IpNet>().unwrap(),
];
assert!(any_route_covers_address(
&nets,
&IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))
));
assert!(any_route_covers_address(
&nets,
&IpAddr::V4(Ipv4Addr::new(200, 0, 0, 1))
));
}
#[test]
fn link_local_ipv6_detected() {
let addr: IpAddr = "fe80::1".parse().unwrap();
assert!(is_ipv6_link_local(&addr));
}
#[test]
fn global_ipv6_not_link_local() {
let addr: IpAddr = "2001:db8::1".parse().unwrap();
assert!(!is_ipv6_link_local(&addr));
}
#[test]
fn ipv4_not_ipv6_link_local() {
let addr: IpAddr = "192.168.1.1".parse().unwrap();
assert!(!is_ipv6_link_local(&addr));
}
#[cfg(target_os = "linux")]
#[test]
fn link_local_helper_detects_both_families() {
let v4_ll: IpAddr = "169.254.1.1".parse().unwrap();
let v6_ll: IpAddr = "fe80::1".parse().unwrap();
let v4_global: IpAddr = "8.8.8.8".parse().unwrap();
let v6_global: IpAddr = "2001:db8::1".parse().unwrap();
assert!(is_link_local(&v4_ll));
assert!(is_link_local(&v6_ll));
assert!(!is_link_local(&v4_global));
assert!(!is_link_local(&v6_global));
}
#[test]
fn already_exists_linux_rtnetlink() {
assert!(output_indicates_already_exists(
"RTNETLINK answers: File exists"
));
}
#[test]
fn already_exists_bsd_file_exists() {
assert!(output_indicates_already_exists(
"route: writing to routing socket: File exists"
));
}
#[test]
fn already_exists_bsd_route_already_in_table() {
assert!(output_indicates_already_exists(
"route: route already in table"
));
}
#[test]
fn already_exists_negative() {
assert!(!output_indicates_already_exists("Network is unreachable"));
assert!(!output_indicates_already_exists("Permission denied"));
assert!(!output_indicates_already_exists(""));
}
#[test]
fn not_found_linux_rtnetlink() {
assert!(output_indicates_not_found(
"RTNETLINK answers: No such process"
));
}
#[test]
fn not_found_bsd_not_in_table() {
assert!(output_indicates_not_found(
"route: writing to routing socket: not in table"
));
}
#[test]
fn not_found_linux_cannot_find() {
assert!(output_indicates_not_found(
"RTNETLINK answers: Cannot find device"
));
}
#[test]
fn not_found_negative() {
assert!(!output_indicates_not_found("Permission denied"));
assert!(!output_indicates_not_found("File exists"));
assert!(!output_indicates_not_found(""));
}
#[cfg(target_os = "linux")]
mod user_route_args_linux {
use super::*;
#[test]
fn ipv4_add_argv() {
let net: IpNet = "10.0.0.0/24".parse().unwrap();
let gw: IpAddr = "192.168.1.1".parse().unwrap();
let args = user_route_add_args(&net, &gw);
assert_eq!(
args,
[
IP_COMMAND,
"route",
"add",
"10.0.0.0/24",
"via",
"192.168.1.1"
]
);
}
#[test]
fn ipv6_add_argv() {
let net: IpNet = "2001:db8::/32".parse().unwrap();
let gw: IpAddr = "2001:db8::1".parse().unwrap();
let args = user_route_add_args(&net, &gw);
assert_eq!(
args,
[
IP_COMMAND,
"-6",
"route",
"add",
"2001:db8::/32",
"via",
"2001:db8::1"
]
);
}
}
#[cfg(target_os = "macos")]
mod user_route_args_macos {
use super::*;
#[test]
fn ipv4_add_argv() {
let net: IpNet = "10.0.0.0/24".parse().unwrap();
let gw: IpAddr = "192.168.1.1".parse().unwrap();
let args = user_route_add_args(&net, &gw);
assert_eq!(
args,
[
ROUTE_COMMAND,
"-n",
"add",
"-net",
"10.0.0.0",
"-netmask",
"255.255.255.0",
"192.168.1.1"
]
);
}
#[test]
fn ipv6_add_argv() {
let net: IpNet = "2001:db8::/32".parse().unwrap();
let gw: IpAddr = "2001:db8::1".parse().unwrap();
let args = user_route_add_args(&net, &gw);
assert_eq!(
args,
[
ROUTE_COMMAND,
"-n",
"add",
"-inet6",
"2001:db8::/32",
"2001:db8::1"
]
);
}
}
#[cfg(target_os = "freebsd")]
mod user_route_args_freebsd {
use super::*;
#[test]
fn ipv4_add_argv() {
let net: IpNet = "10.0.0.0/24".parse().unwrap();
let gw: IpAddr = "192.168.1.1".parse().unwrap();
let args = user_route_add_args(&net, &gw);
assert_eq!(
args,
[
ROUTE_COMMAND,
"add",
"-net",
"10.0.0.0",
"-netmask",
"255.255.255.0",
"192.168.1.1"
]
);
}
#[test]
fn ipv6_add_argv() {
let net: IpNet = "2001:db8::/32".parse().unwrap();
let gw: IpAddr = "2001:db8::1".parse().unwrap();
let args = user_route_add_args(&net, &gw);
assert_eq!(
args,
[
ROUTE_COMMAND,
"add",
"-inet6",
"2001:db8::/32",
"2001:db8::1"
]
);
}
}
}