From 7588b5db00f0f71a450f07acd6786c75bbac332c Mon Sep 17 00:00:00 2001 From: Robin Appelman Date: Mon, 23 Feb 2026 22:59:31 +0100 Subject: [PATCH] add support for setting up routing inside the netns --- Cargo.lock | 10 ++++ Cargo.toml | 1 + README.md | 6 ++ config.sample.toml | 5 ++ nix/module.nix | 16 ++++++ src/config/mod.rs | 30 +++++++++- src/config/name.rs | 2 +- src/config/source.rs | 2 +- src/config/target.rs | 2 +- src/daemon.rs | 57 ++++++++++++++++++- src/link.rs | 118 +++++++++++++++++++++++++++++++++++++--- src/main.rs | 12 ++-- src/namespace/handle.rs | 2 +- src/namespace/mod.rs | 12 ++-- src/namespace/raw.rs | 4 +- src/proxy/mod.rs | 9 ++- src/proxy/tcp.rs | 28 +++------- src/up.rs | 9 +++ 18 files changed, 272 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26795f7..27f6640 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cidr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579504560394e388085d0c080ea587dfa5c15f7e251b4d5247d1e1a61d1d6928" +dependencies = [ + "serde", +] + [[package]] name = "clap" version = "4.5.58" @@ -509,6 +518,7 @@ dependencies = [ name = "netnsd" version = "0.2.0" dependencies = [ + "cidr", "clap", "either", "futures", diff --git a/Cargo.toml b/Cargo.toml index d6b4a06..72a3f9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ either = "1.15.0" uzers = "0.12.2" sysinfo = "0.38.1" landlock = "0.4.4" +cidr = { version = "0.3.2", features = ["serde"] } [dev-dependencies] serde_test = "1.0.177" diff --git a/README.md b/README.md index f127efb..87d4ddb 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A declarative manager for Linux network namespaces. - Hot reloading of configuration - Port forwarding into or out of the namespace - Moving network devices to the namespace +- Setting up routing inside the namespace ## Usage @@ -57,6 +58,11 @@ name = "test" # move existing devices into the namespace devices = ["somelink"] +# create a route inside the namespace +[[namespace.route]] +destination = "default" # either "default" or an ip range in CIDR notation +device = "somelink" + # You can define any number of port forwards to setup into the namespace [[namespace.forward]] # port, address or socket outside the namespace to listen on diff --git a/config.sample.toml b/config.sample.toml index e128af0..7cfdef8 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -5,6 +5,11 @@ name = "test" # move existing devices into the namespace devices = ["somelink"] +# create a route inside the namespace +[[namespace.route]] +destination = "default" # either "default" or an ip range in CIDR notation +device = "somelink" + # You can define any number of port forwards to setup into the namespace [[namespace.forward]] # port, address or socket outside the namespace to listen on diff --git a/nix/module.nix b/nix/module.nix index dd28b57..42eb546 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -60,6 +60,22 @@ in { default = []; description = "devices to move into the namespace"; }; + route = mkOption { + type = types.listOf (types.submodule ({config, ...}: { + options = { + device = mkOption { + type = types.str; + description = "device to route the traffic trough"; + }; + destination = mkOption { + type = types.str; + description = "What traffic to route. Either \"default\" or an ip range in CIDR notation"; + }; + }; + })); + description = "routes to setup inside the namespace"; + default = []; + }; }; })); description = "namespaces to setup"; diff --git a/src/config/mod.rs b/src/config/mod.rs index 2395ae3..0a31dd0 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -5,10 +5,15 @@ mod target; pub use crate::config::name::{DeviceName, NamespaceName}; pub use crate::config::source::ForwardSource; pub use crate::config::target::ForwardTarget; -use serde::Deserialize; +use cidr::AnyIpCidr; +use serde::de::Error; +use serde::{Deserialize, Deserializer}; +use std::borrow::Cow; use std::collections::HashSet; +use std::fmt::{Display, Formatter}; use std::fs::read_to_string; use std::path::{Path, PathBuf}; +use std::str::FromStr; use thiserror::Error; use toml::from_str; @@ -84,6 +89,8 @@ pub struct NamespaceConfig { pub forward: Vec, #[serde(default)] pub devices: Vec, + #[serde(default, rename = "route")] + pub routes: Vec, } #[derive(Deserialize, Debug)] @@ -94,6 +101,27 @@ pub struct ForwardConfig { pub reverse: bool, } +#[derive(Deserialize, Debug, PartialEq, Clone)] +pub struct RouteConfig { + #[serde(deserialize_with = "parse_cidr")] + pub destination: AnyIpCidr, + pub device: DeviceName, +} + +impl Display for RouteConfig { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{} dev {}", self.destination, self.device) + } +} + +fn parse_cidr<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let str = Cow::<'de, str>::deserialize(deserializer)?; + match str.as_ref() { + "default" => Ok(AnyIpCidr::Any), + str => AnyIpCidr::from_str(str).map_err(D::Error::custom), + } +} + #[derive(Debug, Error)] pub enum ConfigError { #[error("Error while reading config from {}: {error:#}", path.display())] diff --git a/src/config/name.rs b/src/config/name.rs index 9490fbe..c54ac6f 100644 --- a/src/config/name.rs +++ b/src/config/name.rs @@ -186,7 +186,7 @@ fn validate_name(name: &str) -> bool { #[test] fn test_de() { - use serde_test::{assert_de_tokens, assert_de_tokens_error, Token}; + use serde_test::{Token, assert_de_tokens, assert_de_tokens_error}; assert_de_tokens(&NamespaceName("foo".into()), &[Token::String("foo")]); diff --git a/src/config/source.rs b/src/config/source.rs index 0f185ac..1b292fd 100644 --- a/src/config/source.rs +++ b/src/config/source.rs @@ -114,7 +114,7 @@ pub struct InvalidForwardSource { #[test] fn test_de() { - use serde_test::{assert_de_tokens, assert_de_tokens_error, Token}; + use serde_test::{Token, assert_de_tokens, assert_de_tokens_error}; let addr_str = "127.0.0.1:80"; let addr = SocketAddr::from_str("127.0.0.1:80").unwrap(); diff --git a/src/config/target.rs b/src/config/target.rs index 68206c5..249b2be 100644 --- a/src/config/target.rs +++ b/src/config/target.rs @@ -111,7 +111,7 @@ pub struct InvalidForwardTarget { #[test] fn test_de() { - use serde_test::{assert_de_tokens, assert_de_tokens_error, Token}; + use serde_test::{Token, assert_de_tokens, assert_de_tokens_error}; let addr_str = "127.0.0.1:80"; let addr = SocketAddr::from_str("127.0.0.1:80").unwrap(); diff --git a/src/daemon.rs b/src/daemon.rs index 27d3bbb..a797265 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,4 +1,6 @@ -use crate::config::{Config, DeviceName, ForwardConfig, NamespaceConfig, NamespaceName}; +use crate::config::{ + Config, DeviceName, ForwardConfig, NamespaceConfig, NamespaceName, RouteConfig, +}; use crate::link::{LinkError, LinkManager}; use crate::namespace::{ NamespaceEnterError, NamespaceError, NamespaceHandle, NamespaceHandleError, NetNs, @@ -128,6 +130,7 @@ impl State { let config = config.get_namespace(namespace.name()).unwrap(); namespace.update_proxies(config)?; namespace.update_devices(config)?; + namespace.update_links(config)?; } Ok(()) @@ -144,6 +147,7 @@ struct ActiveNamespace { ns: NetNs, proxies: Vec, devices: Vec, + routes: Vec, } impl ActiveNamespace { @@ -154,6 +158,7 @@ impl ActiveNamespace { ns, proxies: Vec::default(), devices: Vec::default(), + routes: Vec::default(), }) } @@ -212,6 +217,52 @@ impl ActiveNamespace { Ok(()) } + pub fn update_links(&mut self, config: &NamespaceConfig) -> Result<(), DaemonError> { + let removed: Vec<_> = self + .routes + .extract_if(.., |existing| { + !config.routes.iter().any(|new| existing == new) + }) + .collect(); + + let mut added = Vec::new(); + for new in &config.routes { + if !self.has_route(new) { + added.push(new.clone()); + } + } + + self.ns.handle().run_in(|| { + let link_manager = LinkManager::new()?; + for link in link_manager.get_links()?.flatten() { + if let Some(route) = removed + .iter() + .find(|route| route.device == link.name.as_str()) + { + info!(namespace = %config.name, %route, "deleting route"); + link_manager.delete_route(&link, route.destination)?; + } + } + + for link in link_manager.get_links()?.flatten() { + if let Some(route) = added + .iter() + .find(|route| route.device == link.name.as_str()) + { + info!(namespace = %config.name, %route, "adding route"); + link_manager.add_route(&link, route.destination)?; + } + } + Ok::<_, DaemonError>(()) + })??; + + for new in added { + self.routes.push(new); + } + + Ok(()) + } + fn has_forward(&self, config: &ForwardConfig) -> bool { self.proxies.iter().any(|existing| existing == config) } @@ -220,6 +271,10 @@ impl ActiveNamespace { self.devices.iter().any(|existing| existing == name) } + fn has_route(&self, route: &RouteConfig) -> bool { + self.routes.iter().any(|existing| existing == route) + } + pub fn name(&self) -> &NamespaceName { self.ns.name() } diff --git a/src/link.rs b/src/link.rs index 865d86c..98bf0cc 100644 --- a/src/link.rs +++ b/src/link.rs @@ -1,26 +1,27 @@ +use cidr::{AnyIpCidr, Family}; use neli::consts::nl::NlmF; -use neli::consts::rtnl::Ifla; -use neli::consts::rtnl::RtAddrFamily; use neli::consts::rtnl::Rtm; +use neli::consts::rtnl::{Ifla, RtScope, RtTable, Rta}; +use neli::consts::rtnl::{RtAddrFamily, Rtn, Rtprot}; use neli::consts::socket::NlFamily; use neli::err::RouterError; use neli::nl::NlPayload; use neli::router::synchronous::NlRouter; -use neli::rtnl::IfinfomsgBuilder; -use neli::rtnl::{Ifinfomsg, RtattrBuilder}; +use neli::rtnl::{Ifinfomsg, RtattrBuilder, Rtmsg}; +use neli::rtnl::{IfinfomsgBuilder, RtmsgBuilder}; use neli::types::{Buffer, RtBuffer}; use neli::utils::Groups; use nix::libc::c_int; -use std::fmt::Debug; +use std::fmt::{Debug, Display, Formatter}; use std::os::fd::AsRawFd; use thiserror::Error; -use tracing::info; +use tracing::{info, instrument}; #[derive(Debug, Error)] pub enum LinkError { - #[error("Failed to communicate with netlink:")] + #[error("Failed to communicate with netlink: {0}")] Netlink(String), - #[error("Failed to parse netlink response")] + #[error("Failed to parse netlink response: {0}")] Parse(String), #[error("Link not found: {0}")] NotFound(String), @@ -46,6 +47,12 @@ pub struct Link { pub name: String, } +impl Display for Link { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.name) + } +} + impl Link { fn msg_builder(&self) -> IfinfomsgBuilder { IfinfomsgBuilder::default() @@ -112,7 +119,7 @@ impl LinkManager { info_attrs.push( RtattrBuilder::default() .rta_type(Ifla::NetNsFd) - .rta_payload(Buffer::from(ns_handle.to_ne_bytes().as_slice())) + .rta_payload(ns_handle) .build() .expect("invalid rtattr"), ); @@ -135,6 +142,47 @@ impl LinkManager { )?; Ok(()) } + + #[instrument(skip_all, fields(link = %link, destination = %destination))] + pub fn add_route(&self, link: &Link, destination: AnyIpCidr) -> Result<(), LinkError> { + let rt_msg = route_message_for(link, destination); + + let res = self.router.send::<_, _, Rtm, Rtmsg>( + Rtm::Newroute, + NlmF::CREATE | NlmF::EXCL | NlmF::REQUEST | NlmF::ACK, + NlPayload::Payload(rt_msg), + )?; + + for msg in res { + match msg { + Err(RouterError::Nlmsgerr(err)) if *err.error() == -17 => { + info!("route already exists"); + // already exists + } + Err(err) => { + return Err(err.into()); + } + _ => {} + } + } + Ok(()) + } + + #[instrument(skip_all, fields(link = %link, destination = %destination))] + pub fn delete_route(&self, link: &Link, destination: AnyIpCidr) -> Result<(), LinkError> { + let rt_msg = route_message_for(link, destination); + + let res = self.router.send::<_, _, Rtm, Rtmsg>( + Rtm::Delroute, + NlmF::REQUEST | NlmF::ACK, + NlPayload::Payload(rt_msg), + )?; + + for msg in res { + msg?; + } + Ok(()) + } } /// Set a link to UP @@ -147,6 +195,7 @@ pub fn link_up(link_name: &str) -> Result<(), LinkError> { /// Move a link into a namespace pub fn move_link_into(link_name: &str, namespace: &Fd) -> Result<(), LinkError> { let manager = LinkManager::new()?; + // todo, might already be in target ns let link = manager.get_link(link_name)?; info!(name = &link.name, "moving link into namespace"); manager.move_link(&link, namespace) @@ -163,3 +212,54 @@ pub fn move_all_links(namespace: &Fd) -> Result<(), LinkError> { } Ok::<_, LinkError>(()) } + +fn route_message_for(link: &Link, destination: AnyIpCidr) -> Rtmsg { + let mut info_attrs = RtBuffer::::new(); + match &destination { + AnyIpCidr::V4(addr) => { + info_attrs.push( + RtattrBuilder::default() + .rta_type(Rta::Dst) + .rta_payload(addr.first_address().octets()) + .build() + .expect("invalid rtattr"), + ); + } + AnyIpCidr::V6(addr) => { + info_attrs.push( + RtattrBuilder::default() + .rta_type(Rta::Dst) + .rta_payload(addr.first_address().octets()) + .build() + .expect("invalid rtattr"), + ); + } + _ => {} + } + info_attrs.push( + RtattrBuilder::default() + .rta_type(Rta::Oif) + .rta_payload(link.index) + .build() + .expect("invalid rtattr"), + ); + + let family = match &destination.family() { + None => RtAddrFamily::Inet, + Some(Family::Ipv4) => RtAddrFamily::Inet, + Some(Family::Ipv6) => RtAddrFamily::Inet6, + }; + + RtmsgBuilder::default() + .rtm_table(RtTable::Main) + .rtm_scope(RtScope::Universe) + .rtm_family(family) + .rtattrs(info_attrs) + .rtm_src_len(0) + .rtm_tos(0) + .rtm_protocol(Rtprot::Boot) + .rtm_type(Rtn::Unicast) + .rtm_dst_len(destination.network_length().unwrap_or_default()) + .build() + .expect("rt msg") +} diff --git a/src/main.rs b/src/main.rs index e5e52a7..c906f49 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,10 +5,10 @@ use crate::proxy::proxy; use crate::up::up; use clap::{Parser, Subcommand}; use main_error::MainResult; -use std::path::PathBuf; use nix::errno::Errno; -use nix::sys::signal::{kill, Signal}; +use nix::sys::signal::{Signal, kill}; use nix::unistd::Pid; +use std::path::PathBuf; use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind}; use tracing::{error, info, warn}; @@ -94,10 +94,12 @@ fn reload() -> MainResult { match kill(Pid::from_raw(proc.pid().as_u32() as i32), Signal::SIGHUP) { Ok(_) => { info!("Sent reload command to daemon") - }, + } Err(Errno::EPERM) => { - error!("Sending signal not permitted, try are you running the command as root?"); - }, + error!( + "Sending signal not permitted, try are you running the command as root?" + ); + } Err(error) => { error!(%error, "Unexpected error"); } diff --git a/src/namespace/handle.rs b/src/namespace/handle.rs index 3e73187..5a24623 100644 --- a/src/namespace/handle.rs +++ b/src/namespace/handle.rs @@ -1,6 +1,6 @@ use crate::namespace::NamespaceEnterError; use nix::errno::Errno; -use nix::sched::{setns, CloneFlags}; +use nix::sched::{CloneFlags, setns}; use std::fs::File; use std::io::Error as IoError; use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd, RawFd}; diff --git a/src/namespace/mod.rs b/src/namespace/mod.rs index 2b9295f..7b34443 100644 --- a/src/namespace/mod.rs +++ b/src/namespace/mod.rs @@ -2,13 +2,13 @@ mod handle; mod raw; use crate::config::{DeviceName, NamespaceName}; -use crate::link::{link_up, move_all_links, move_link_into, LinkError}; +use crate::link::{LinkError, link_up, move_all_links, move_link_into}; pub use crate::namespace::handle::{NamespaceHandle, NamespaceHandleError}; -use crate::namespace::raw::{create_network_namespace, NamespaceSetupError}; +use crate::namespace::raw::{NamespaceSetupError, create_network_namespace}; use either::Either; use nix::errno::Errno; -use nix::mount::{mount, umount2, MntFlags, MsFlags}; -use std::fs::{create_dir, read_dir, remove_file, File}; +use nix::mount::{MntFlags, MsFlags, mount, umount2}; +use std::fs::{File, create_dir, read_dir, remove_file}; use std::io::{Error as IoError, ErrorKind}; use std::iter::empty; use std::os::unix::fs::symlink; @@ -162,9 +162,7 @@ impl NetNs { pub fn delete(self) -> Result<(), NamespaceError> { let parent_namespace = NamespaceHandle::parent()?; - self.handle.run_in(|| { - move_all_links(&parent_namespace) - })??; + self.handle.run_in(|| move_all_links(&parent_namespace))??; let name = self.path.file_name().unwrap().to_str().unwrap(); info!(name, "deleting network namespace"); match umount2(&self.path, MntFlags::MNT_DETACH) { diff --git a/src/namespace/raw.rs b/src/namespace/raw.rs index 5cffe5d..db1c4fb 100644 --- a/src/namespace/raw.rs +++ b/src/namespace/raw.rs @@ -1,7 +1,7 @@ use nix::errno::Errno; -use nix::sched::{clone, CloneFlags}; +use nix::sched::{CloneFlags, clone}; use nix::sys::signal::Signal; -use nix::sys::wait::{waitpid, WaitStatus}; +use nix::sys::wait::{WaitStatus, waitpid}; use std::path::PathBuf; use thiserror::Error; diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs index b7de7ba..ef353fa 100644 --- a/src/proxy/mod.rs +++ b/src/proxy/mod.rs @@ -3,7 +3,12 @@ mod tcp; use crate::config::{ForwardConfig, ForwardSource, ForwardTarget, NamespaceName}; use crate::proxy::tcp::Proxy; use futures::future::AbortHandle; +use landlock::{ + ABI, Access, AccessFs, AccessNet, NetPort, Ruleset, RulesetAttr, RulesetCreatedAttr, + RulesetError, RulesetStatus, +}; use main_error::MainResult; +use nix::errno::Errno; use nix::sched::{CloneFlags, setns}; use nix::sys::signal::{SIGINT, kill}; use nix::unistd::{Gid, Pid, Uid, setgid, setuid}; @@ -13,8 +18,6 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::{Child, Command}; use std::thread::spawn; -use landlock::{Access, AccessFs, AccessNet, NetPort, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetError, RulesetStatus, ABI}; -use nix::errno::Errno; use thiserror::Error; use tokio::runtime::Builder; use tokio::signal::ctrl_c; @@ -215,4 +218,4 @@ fn landlock(port: u16) -> Result<(), RulesetError> { RulesetStatus::NotEnforced => error!("Not sandboxed! Please update your kernel."), } Ok(()) -} \ No newline at end of file +} diff --git a/src/proxy/tcp.rs b/src/proxy/tcp.rs index 0f163c1..159be8e 100644 --- a/src/proxy/tcp.rs +++ b/src/proxy/tcp.rs @@ -1,6 +1,6 @@ /// Loosely based on https://github.com/fooker/netns-proxy/blob/main/src/tcp.rs -use crate::config::{ForwardTarget, ForwardSource}; -use crate::proxy::{ProxyError}; +use crate::config::{ForwardSource, ForwardTarget}; +use crate::proxy::ProxyError; use futures::TryStreamExt; use futures::stream::{AbortRegistration, Abortable}; use std::fs::{remove_file, set_permissions}; @@ -56,28 +56,18 @@ impl Proxy { })?; debug!("Created TCP socket"); - Ok(Self { - socket, - }) + Ok(Self { socket }) } pub async fn run(self, target: ForwardTarget, abort: AbortRegistration) { match self.socket { - ProxyListener::Tcp(socket) => { - run_tcp(socket, target.addr, abort).await - } - ProxyListener::Unix(socket) => { - run_unix(socket, target.addr, abort).await - } + ProxyListener::Tcp(socket) => run_tcp(socket, target.addr, abort).await, + ProxyListener::Unix(socket) => run_unix(socket, target.addr, abort).await, } } } -async fn run_tcp( - socket: TcpListener, - target: SocketAddr, - abort: AbortRegistration, -) { +async fn run_tcp(socket: TcpListener, target: SocketAddr, abort: AbortRegistration) { let accepts = TcpListenerStream::new(socket).map_err(|error| ProxyError::Accept { error }); let mut accepts = pin!(Abortable::new(accepts, abort)); while let Some(client) = accepts.next().await { @@ -94,11 +84,7 @@ async fn run_tcp( } } -async fn run_unix( - socket: UnixListener, - target: SocketAddr, - abort: AbortRegistration, -) { +async fn run_unix(socket: UnixListener, target: SocketAddr, abort: AbortRegistration) { let accepts = UnixListenerStream::new(socket).map_err(|error| ProxyError::Accept { error }); let mut accepts = pin!(Abortable::new(accepts, abort)); while let Some(client) = accepts.next().await { diff --git a/src/up.rs b/src/up.rs index 3bc8a5f..4e33d78 100644 --- a/src/up.rs +++ b/src/up.rs @@ -1,4 +1,5 @@ use crate::config::{Config, NamespaceName}; +use crate::link::{LinkError, LinkManager}; use crate::namespace::NetNs; use main_error::MainResult; use tracing::error; @@ -24,6 +25,14 @@ pub fn up(config: Config) -> MainResult { error!(%error, "failed to move device into namespace"); } } + for route in new.routes { + namespace.handle().run_in(|| { + let manager = LinkManager::new()?; + let link = manager.get_link(&route.device)?; + manager.add_route(&link, route.destination)?; + Ok::<_, LinkError>(()) + })??; + } } Ok(())