use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteConfig {
#[serde(default)]
pub full_tunnel: bool,
#[serde(default)]
pub include_routes: Vec<String>,
#[serde(default)]
pub exclude_routes: Vec<String>,
#[serde(default)]
pub dns_servers: Vec<IpAddr>,
#[serde(default)]
pub modify_default_route: bool,
#[serde(default = "default_table_id")]
pub table_id: u32,
#[serde(default)]
pub fwmark: Option<u32>,
}
fn default_table_id() -> u32 {
100
}
impl Default for RouteConfig {
fn default() -> Self {
Self {
full_tunnel: false,
include_routes: Vec::new(),
exclude_routes: Vec::new(),
dns_servers: Vec::new(),
modify_default_route: false,
table_id: default_table_id(),
fwmark: None,
}
}
}
#[derive(Debug, Clone)]
pub struct Route {
pub destination: String,
pub gateway: Option<IpAddr>,
pub interface: String,
pub metric: Option<u32>,
}
impl Route {
pub fn via_interface(destination: &str, interface: &str) -> Self {
Self {
destination: destination.to_string(),
gateway: None,
interface: interface.to_string(),
metric: None,
}
}
pub fn via_gateway(destination: &str, gateway: IpAddr, interface: &str) -> Self {
Self {
destination: destination.to_string(),
gateway: Some(gateway),
interface: interface.to_string(),
metric: None,
}
}
pub fn with_metric(mut self, metric: u32) -> Self {
self.metric = Some(metric);
self
}
}
pub struct RouteManager {
config: RouteConfig,
interface: String,
added_routes: Vec<Route>,
original_default_gw: Option<(IpAddr, String)>,
is_setup: bool,
}
impl RouteManager {
pub fn new(config: RouteConfig, interface: String) -> Self {
Self {
config,
interface,
added_routes: Vec::new(),
original_default_gw: None,
is_setup: false,
}
}
pub fn setup(&mut self) -> Result<()> {
if self.is_setup {
return Ok(());
}
if self.config.full_tunnel || self.config.modify_default_route {
self.original_default_gw = self.get_default_gateway()?;
tracing::info!(
gateway = ?self.original_default_gw,
"Saved original default gateway"
);
}
let exclude_routes = self.config.exclude_routes.clone();
let original_gw = self.original_default_gw.clone();
for network in &exclude_routes {
if let Some((gw, iface)) = &original_gw {
self.add_route_via_gateway(network, *gw, iface)?;
}
}
if self.config.full_tunnel {
self.setup_full_tunnel()?;
} else {
for network in &self.config.include_routes.clone() {
self.add_route(network)?;
}
}
for dns in &self.config.dns_servers.clone() {
let network = format!("{}/32", dns);
self.add_route(&network)?;
}
self.is_setup = true;
Ok(())
}
pub fn teardown(&mut self) -> Result<()> {
if !self.is_setup {
return Ok(());
}
let routes_to_remove: Vec<_> = self.added_routes.drain(..).collect();
for route in routes_to_remove.into_iter().rev() {
if let Err(e) = self.remove_route_impl(&route) {
tracing::warn!(
route = %route.destination,
error = %e,
"Failed to remove route"
);
}
}
if let Some((gw, iface)) = self.original_default_gw.take() {
self.restore_default_gateway(gw, &iface)?;
}
self.is_setup = false;
Ok(())
}
pub fn add_route(&mut self, destination: &str) -> Result<()> {
let route = Route::via_interface(destination, &self.interface);
self.add_route_impl(&route)?;
self.added_routes.push(route);
Ok(())
}
pub fn add_route_via_gateway(&mut self, destination: &str, gateway: IpAddr, interface: &str) -> Result<()> {
let route = Route::via_gateway(destination, gateway, interface);
self.add_route_impl(&route)?;
self.added_routes.push(route);
Ok(())
}
pub fn is_setup(&self) -> bool {
self.is_setup
}
fn setup_full_tunnel(&mut self) -> Result<()> {
#[cfg(target_os = "linux")]
{
self.setup_full_tunnel_linux()
}
#[cfg(target_os = "macos")]
{
self.setup_full_tunnel_macos()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
Err(Error::Config("Full tunnel not supported on this platform".into()))
}
}
#[cfg(target_os = "linux")]
fn setup_full_tunnel_linux(&mut self) -> Result<()> {
let table = self.config.table_id.to_string();
let output = Command::new("ip")
.args(["route", "add", "default", "dev", &self.interface, "table", &table])
.output()
.map_err(|e| Error::Io(e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("File exists") {
return Err(Error::Config(format!("Failed to add default route: {}", stderr)));
}
}
if let Some(mark) = self.config.fwmark {
let output = Command::new("ip")
.args(["rule", "add", "fwmark", &mark.to_string(), "table", &table])
.output()
.map_err(|e| Error::Io(e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("File exists") {
return Err(Error::Config(format!("Failed to add routing rule: {}", stderr)));
}
}
} else {
let output = Command::new("ip")
.args(["rule", "add", "from", "all", "table", &table, "priority", "100"])
.output()
.map_err(|e| Error::Io(e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("File exists") {
return Err(Error::Config(format!("Failed to add routing rule: {}", stderr)));
}
}
}
self.added_routes.push(Route {
destination: "default".to_string(),
gateway: None,
interface: self.interface.clone(),
metric: None,
});
tracing::info!(
interface = %self.interface,
table = table,
"Set up full tunnel routing (Linux)"
);
Ok(())
}
#[cfg(target_os = "macos")]
fn setup_full_tunnel_macos(&mut self) -> Result<()> {
let output = Command::new("route")
.args(["-n", "add", "-net", "0.0.0.0/1", "-interface", &self.interface])
.output()
.map_err(|e| Error::Io(e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("File exists") {
return Err(Error::Config(format!("Failed to add route 0.0.0.0/1: {}", stderr)));
}
}
self.added_routes.push(Route::via_interface("0.0.0.0/1", &self.interface));
let output = Command::new("route")
.args(["-n", "add", "-net", "128.0.0.0/1", "-interface", &self.interface])
.output()
.map_err(|e| Error::Io(e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("File exists") {
return Err(Error::Config(format!("Failed to add route 128.0.0.0/1: {}", stderr)));
}
}
self.added_routes.push(Route::via_interface("128.0.0.0/1", &self.interface));
tracing::info!(
interface = %self.interface,
"Set up full tunnel routing (macOS)"
);
Ok(())
}
fn add_route_impl(&self, route: &Route) -> Result<()> {
#[cfg(target_os = "linux")]
{
self.add_route_linux(route)
}
#[cfg(target_os = "macos")]
{
self.add_route_macos(route)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
Err(Error::Config("Route management not supported".into()))
}
}
#[cfg(target_os = "linux")]
fn add_route_linux(&self, route: &Route) -> Result<()> {
let mut args = vec!["route", "add", &route.destination];
if let Some(gw) = route.gateway {
args.push("via");
args.push(&gw.to_string());
}
args.push("dev");
args.push(&route.interface);
if let Some(metric) = route.metric {
args.push("metric");
args.push(&metric.to_string());
}
let output = Command::new("ip")
.args(&args)
.output()
.map_err(|e| Error::Io(e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("File exists") {
return Err(Error::Config(format!("Failed to add route: {}", stderr)));
}
}
tracing::debug!(destination = %route.destination, interface = %route.interface, "Added route");
Ok(())
}
#[cfg(target_os = "macos")]
fn add_route_macos(&self, route: &Route) -> Result<()> {
let gw_str = route.gateway.map(|gw| gw.to_string());
let mut args = vec!["-n", "add", "-net", &route.destination];
if let Some(ref gw) = gw_str {
args.push("-gateway");
args.push(gw);
} else {
args.push("-interface");
args.push(&route.interface);
}
let output = Command::new("route")
.args(&args)
.output()
.map_err(|e| Error::Io(e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("File exists") {
return Err(Error::Config(format!("Failed to add route: {}", stderr)));
}
}
tracing::debug!(destination = %route.destination, interface = %route.interface, "Added route");
Ok(())
}
fn remove_route_impl(&self, route: &Route) -> Result<()> {
#[cfg(target_os = "linux")]
{
self.remove_route_linux(route)
}
#[cfg(target_os = "macos")]
{
self.remove_route_macos(route)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
Ok(())
}
}
#[cfg(target_os = "linux")]
fn remove_route_linux(&self, route: &Route) -> Result<()> {
let output = Command::new("ip")
.args(["route", "del", &route.destination, "dev", &route.interface])
.output()
.map_err(|e| Error::Io(e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("No such process") {
return Err(Error::Config(format!("Failed to remove route: {}", stderr)));
}
}
tracing::debug!(destination = %route.destination, "Removed route");
Ok(())
}
#[cfg(target_os = "macos")]
fn remove_route_macos(&self, route: &Route) -> Result<()> {
let output = Command::new("route")
.args(["-n", "delete", "-net", &route.destination])
.output()
.map_err(|e| Error::Io(e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("not in table") {
return Err(Error::Config(format!("Failed to remove route: {}", stderr)));
}
}
tracing::debug!(destination = %route.destination, "Removed route");
Ok(())
}
fn get_default_gateway(&self) -> Result<Option<(IpAddr, String)>> {
#[cfg(target_os = "linux")]
{
self.get_default_gateway_linux()
}
#[cfg(target_os = "macos")]
{
self.get_default_gateway_macos()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
Ok(None)
}
}
#[cfg(target_os = "linux")]
fn get_default_gateway_linux(&self) -> Result<Option<(IpAddr, String)>> {
let output = Command::new("ip")
.args(["route", "show", "default"])
.output()
.map_err(|e| Error::Io(e))?;
if !output.status.success() {
return Ok(None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.starts_with("default") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 5 && parts[1] == "via" {
if let Ok(gw) = parts[2].parse::<IpAddr>() {
if parts[3] == "dev" {
return Ok(Some((gw, parts[4].to_string())));
}
}
}
}
}
Ok(None)
}
#[cfg(target_os = "macos")]
fn get_default_gateway_macos(&self) -> Result<Option<(IpAddr, String)>> {
let output = Command::new("route")
.args(["-n", "get", "default"])
.output()
.map_err(|e| Error::Io(e))?;
if !output.status.success() {
return Ok(None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut gateway = None;
let mut interface = None;
for line in stdout.lines() {
let line = line.trim();
if line.starts_with("gateway:") {
gateway = line.split(':').nth(1).and_then(|s| s.trim().parse().ok());
} else if line.starts_with("interface:") {
interface = line.split(':').nth(1).map(|s| s.trim().to_string());
}
}
match (gateway, interface) {
(Some(gw), Some(iface)) => Ok(Some((gw, iface))),
_ => Ok(None),
}
}
fn restore_default_gateway(&self, gateway: IpAddr, interface: &str) -> Result<()> {
#[cfg(target_os = "linux")]
{
let table = self.config.table_id.to_string();
let _ = Command::new("ip")
.args(["rule", "del", "table", &table])
.output();
let _ = Command::new("ip")
.args(["route", "del", "default", "table", &table])
.output();
tracing::info!("Restored default routing (Linux)");
}
#[cfg(target_os = "macos")]
{
tracing::info!("Restored default routing (macOS)");
}
Ok(())
}
}
impl Drop for RouteManager {
fn drop(&mut self) {
if self.is_setup {
if let Err(e) = self.teardown() {
tracing::error!(error = %e, "Failed to teardown routes on drop");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_route_config_default() {
let config = RouteConfig::default();
assert!(!config.full_tunnel);
assert_eq!(config.table_id, 100);
}
#[test]
fn test_route_creation() {
let route = Route::via_interface("10.0.0.0/8", "tun0");
assert_eq!(route.destination, "10.0.0.0/8");
assert_eq!(route.interface, "tun0");
assert!(route.gateway.is_none());
let route = Route::via_gateway(
"192.168.0.0/16",
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
"eth0"
).with_metric(100);
assert!(route.gateway.is_some());
assert_eq!(route.metric, Some(100));
}
}