add support for moving devices into the namespace

This commit is contained in:
Robin Appelman 2026-02-14 15:59:47 +01:00
commit 3fa69dc434
11 changed files with 411 additions and 88 deletions

View file

@ -2,7 +2,7 @@ mod name;
mod source;
mod target;
pub use crate::config::name::NamespaceName;
pub use crate::config::name::{DeviceName, NamespaceName};
pub use crate::config::source::ForwardSource;
pub use crate::config::target::ForwardTarget;
use serde::Deserialize;
@ -80,7 +80,10 @@ impl RawConfig {
#[derive(Deserialize, Debug)]
pub struct NamespaceConfig {
pub name: NamespaceName,
#[serde(default)]
pub forward: Vec<ForwardConfig>,
#[serde(default)]
pub devices: Vec<DeviceName>,
}
#[derive(Deserialize, Debug)]

View file

@ -7,8 +7,18 @@ use std::str::FromStr;
use thiserror::Error;
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
struct ValidatedName(String);
#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize)]
#[serde(from = "ValidatedName")]
pub struct NamespaceName(String);
impl From<ValidatedName> for NamespaceName {
fn from(value: ValidatedName) -> Self {
NamespaceName(value.0)
}
}
impl TryFrom<OsString> for NamespaceName {
type Error = ();
@ -52,7 +62,60 @@ impl From<NamespaceName> for String {
}
}
impl<'de> Deserialize<'de> for NamespaceName {
#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize)]
#[serde(from = "ValidatedName")]
pub struct DeviceName(String);
impl From<ValidatedName> for DeviceName {
fn from(value: ValidatedName) -> Self {
DeviceName(value.0)
}
}
impl TryFrom<OsString> for DeviceName {
type Error = ();
fn try_from(value: OsString) -> Result<Self, Self::Error> {
let str = value.into_string().map_err(|_| ())?;
if validate_name(&str) {
Ok(DeviceName(str))
} else {
Err(())
}
}
}
impl Display for DeviceName {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl AsRef<str> for DeviceName {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
impl AsRef<Path> for DeviceName {
fn as_ref(&self) -> &Path {
self.0.as_ref()
}
}
impl PartialEq<&str> for DeviceName {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl From<DeviceName> for String {
fn from(value: DeviceName) -> Self {
value.0
}
}
impl<'de> Deserialize<'de> for ValidatedName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
@ -60,7 +123,7 @@ impl<'de> Deserialize<'de> for NamespaceName {
struct NamespaceNameVisitor;
impl Visitor<'_> for NamespaceNameVisitor {
type Value = NamespaceName;
type Value = ValidatedName;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter.write_str("A valid namespace name")
@ -73,7 +136,7 @@ impl<'de> Deserialize<'de> for NamespaceName {
if !validate_name(v) {
return Err(E::invalid_value(Unexpected::Str(v), &self));
}
Ok(NamespaceName(v.into()))
Ok(ValidatedName(v.into()))
}
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
@ -83,7 +146,7 @@ impl<'de> Deserialize<'de> for NamespaceName {
if !validate_name(&v) {
return Err(E::invalid_value(Unexpected::Str(&v), &self));
}
Ok(NamespaceName(v))
Ok(ValidatedName(v))
}
}
@ -92,20 +155,24 @@ impl<'de> Deserialize<'de> for NamespaceName {
}
impl FromStr for NamespaceName {
type Err = InvalidNamespaceNameError;
type Err = InvalidNameError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if !validate_name(s) {
return Err(InvalidNamespaceNameError { name: s.into() });
return Err(InvalidNameError {
name: s.into(),
kind: "namespace",
});
}
Ok(NamespaceName(s.into()))
}
}
#[derive(Debug, Error)]
#[error("invalid name for namespace: '{name}'")]
pub struct InvalidNamespaceNameError {
#[error("invalid name for {kind}: '{name}'")]
pub struct InvalidNameError {
name: String,
kind: &'static str,
}
/// Check if a name follows the portable filename character set

View file

@ -1,5 +1,8 @@
use crate::config::{Config, ForwardConfig, NamespaceConfig, NamespaceName};
use crate::namespace::{NamespaceError, NetNs};
use crate::config::{Config, DeviceName, ForwardConfig, NamespaceConfig, NamespaceName};
use crate::link::{LinkError, LinkManager};
use crate::namespace::{
NamespaceEnterError, NamespaceError, NamespaceHandle, NamespaceHandleError, NetNs,
};
use crate::proxy::{ActiveProxy, ProxyError};
use futures::FutureExt;
use futures::StreamExt;
@ -124,6 +127,7 @@ impl State {
for namespace in &mut self.namespaces {
let config = config.get_namespace(namespace.name()).unwrap();
namespace.update_proxies(config)?;
namespace.update_devices(config)?;
}
Ok(())
@ -139,6 +143,7 @@ impl State {
struct ActiveNamespace {
ns: NetNs,
proxies: Vec<ActiveProxy>,
devices: Vec<DeviceName>,
}
impl ActiveNamespace {
@ -148,6 +153,7 @@ impl ActiveNamespace {
Ok(ActiveNamespace {
ns,
proxies: Vec::default(),
devices: Vec::default(),
})
}
@ -164,10 +170,56 @@ impl ActiveNamespace {
Ok(())
}
pub fn update_devices(&mut self, config: &NamespaceConfig) -> Result<(), DaemonError> {
let parent_namespace = NamespaceHandle::parent()?;
let removed: Vec<_> = self
.devices
.extract_if(.., |existing| {
!config.devices.iter().any(|new| existing == new)
})
.collect();
self.ns.handle().run_in(move || {
let link_manager = LinkManager::new()?;
for link in link_manager.get_links()?.flatten() {
if removed.iter().any(|name| *name == link.name.as_str()) {
info!(namespace = %config.name, link = link.name , "moving link out of namespace");
link_manager.move_link(&link, &parent_namespace)?
}
}
Ok::<_, LinkError>(())
})??;
let mut added = Vec::new();
for new in &config.devices {
if !self.has_device(new) {
added.push(new.clone());
}
}
let link_manager = LinkManager::new()?;
for link in link_manager.get_links()?.flatten() {
if added.iter().any(|name| *name == link.name.as_str()) {
info!(namespace = %config.name, link = link.name , "moving link into namespace");
link_manager.move_link(&link, self.ns.handle())?
}
}
for new in added {
self.devices.push(new);
}
Ok(())
}
fn has_forward(&self, config: &ForwardConfig) -> bool {
self.proxies.iter().any(|existing| existing == config)
}
fn has_device(&self, name: &DeviceName) -> bool {
self.devices.iter().any(|existing| existing == name)
}
pub fn name(&self) -> &NamespaceName {
self.ns.name()
}
@ -183,4 +235,10 @@ pub enum DaemonError {
Signal(IoError),
#[error(transparent)]
Proxy(#[from] ProxyError),
#[error(transparent)]
Handle(#[from] NamespaceHandleError),
#[error(transparent)]
Enter(#[from] NamespaceEnterError),
#[error(transparent)]
Link(#[from] LinkError),
}

View file

@ -1,3 +1,4 @@
use crate::namespace::{NamespaceEnterError, NamespaceHandle};
use neli::consts::nl::NlmF;
use neli::consts::rtnl::Ifla;
use neli::consts::rtnl::RtAddrFamily;
@ -6,16 +7,14 @@ use neli::consts::socket::NlFamily;
use neli::err::RouterError;
use neli::nl::NlPayload;
use neli::router::synchronous::NlRouter;
use neli::rtnl::Ifinfomsg;
use neli::rtnl::IfinfomsgBuilder;
use neli::rtnl::{Ifinfomsg, RtattrBuilder};
use neli::types::{Buffer, RtBuffer};
use neli::utils::Groups;
use nix::errno::Errno;
use nix::sched::{setns, CloneFlags};
use std::fs::File;
use std::io::Error as IoError;
use std::path::{Path, PathBuf};
use std::thread::spawn;
use nix::libc::c_int;
use std::os::fd::AsRawFd;
use thiserror::Error;
use tracing::info;
#[derive(Debug, Error)]
pub enum LinkError {
@ -23,12 +22,10 @@ pub enum LinkError {
Netlink,
#[error("failed to code netlink response")]
Parse,
#[error("unexpected panic in link setup")]
Panic,
#[error("failed to enter namespace in link setup: {0}")]
Namespace(Errno),
#[error("Failed to open namespace file {}: {error:#}", path.display())]
OpenNamespace { path: PathBuf, error: IoError },
#[error("Link not found: {0}")]
NotFound(String),
#[error(transparent)]
Enter(#[from] NamespaceEnterError),
}
impl<T, P> From<RouterError<T, P>> for LinkError {
@ -37,60 +34,133 @@ impl<T, P> From<RouterError<T, P>> for LinkError {
}
}
/// Set a link to UP inside a namespace
pub fn link_up_ns(namespace: impl AsRef<Path>, link_name: &'static str) -> Result<(), LinkError> {
let namespace = namespace.as_ref();
let ns_handle = File::open(namespace).map_err(|error| LinkError::OpenNamespace {
error,
path: namespace.into(),
})?;
pub struct LinkManager {
router: NlRouter,
}
spawn(move || {
setns(ns_handle, CloneFlags::CLONE_NEWNET).map_err(LinkError::Namespace)?;
link_up(link_name)
})
.join()
.map_err(|_| LinkError::Panic)?
pub struct Link {
family: RtAddrFamily,
index: c_int,
pub name: String,
}
impl Link {
fn msg_builder(&self) -> IfinfomsgBuilder {
IfinfomsgBuilder::default()
.ifi_family(self.family)
.ifi_index(self.index)
}
}
impl LinkManager {
pub fn new() -> Result<Self, LinkError> {
let (router, _) = NlRouter::connect(NlFamily::Route, None, Groups::empty())?;
router.enable_ext_ack(true)?;
router.enable_strict_checking(true)?;
Ok(LinkManager { router })
}
pub fn get_link(&self, name: impl AsRef<str>) -> Result<Link, LinkError> {
let name = name.as_ref();
for link in self.get_links()? {
let link = link?;
if link.name == name {
return Ok(link);
}
}
Err(LinkError::NotFound(name.into()))
}
pub fn get_links(&self) -> Result<impl Iterator<Item = Result<Link, LinkError>>, LinkError> {
let ifinfomsg = IfinfomsgBuilder::default()
.ifi_family(RtAddrFamily::Inet)
.build()
.unwrap();
let recv = self.router.send::<_, _, Rtm, Ifinfomsg>(
Rtm::Getlink,
NlmF::DUMP | NlmF::ACK,
NlPayload::Payload(ifinfomsg),
)?;
Ok(recv
.map(|response| {
if let Some(payload) = response?.get_payload() {
let name = payload
.rtattrs()
.get_attr_handle()
.get_attr_payload_as_with_len::<String>(Ifla::Ifname)
.map_err(|_| LinkError::Parse)?;
Ok(Some(Link {
family: *payload.ifi_family(),
index: *payload.ifi_index(),
name,
}))
} else {
Ok(None)
}
})
.filter_map(|item| item.transpose()))
}
/// Move a link to a namespace
pub fn move_link<Fd: AsRawFd>(&self, link: &Link, namespace: Fd) -> Result<(), LinkError> {
let ns_handle = namespace.as_raw_fd();
let mut info_attrs = RtBuffer::<Ifla, Buffer>::new();
info_attrs.push(
RtattrBuilder::default()
.rta_type(Ifla::NetNsFd)
.rta_payload(Buffer::from(ns_handle.to_ne_bytes().as_slice()))
.build()
.expect("invalid rtattr"),
);
let msg = link.msg_builder().rtattrs(info_attrs).build().unwrap();
self.router.send::<_, _, Rtm, Ifinfomsg>(
Rtm::Setlink,
NlmF::ACK,
NlPayload::Payload(msg),
)?;
Ok(())
}
}
/// Set a link to UP
pub fn link_up(link_name: &str) -> Result<(), LinkError> {
// I honestly don't really know how this code works
// It's mostly a copy from one of neli's examples and seems to do what it needs to
let (rtnl, _) = NlRouter::connect(NlFamily::Route, None, Groups::empty())?;
rtnl.enable_ext_ack(true)?;
rtnl.enable_strict_checking(true)?;
let ifinfomsg = IfinfomsgBuilder::default()
.ifi_family(RtAddrFamily::Inet)
.build()
.unwrap();
let manager = LinkManager::new()?;
let link = manager.get_link(link_name)?;
let recv = rtnl.send::<_, _, Rtm, Ifinfomsg>(
Rtm::Getlink,
NlmF::DUMP | NlmF::ACK,
NlPayload::Payload(ifinfomsg),
let up_msg = link.msg_builder().up().build().unwrap();
manager.router.send::<_, _, Rtm, Ifinfomsg>(
Rtm::Setlink,
NlmF::ACK,
NlPayload::Payload(up_msg),
)?;
for response in recv {
if let Some(payload) = response?.get_payload() {
let name = payload
.rtattrs()
.get_attr_handle()
.get_attr_payload_as_with_len::<String>(Ifla::Ifname)
.map_err(|_| LinkError::Parse)?;
if name == link_name {
let up_msg = IfinfomsgBuilder::default()
.ifi_family(RtAddrFamily::Inet)
.ifi_index(*payload.ifi_index())
.up()
.build()
.unwrap();
rtnl.send::<_, _, Rtm, Ifinfomsg>(
Rtm::Setlink,
NlmF::ACK,
NlPayload::Payload(up_msg),
)?;
Ok(())
}
/// Move a link into a namespace
pub fn move_link_into(link_name: &str, namespace: &NamespaceHandle) -> Result<(), LinkError> {
let manager = LinkManager::new()?;
let link = manager.get_link(link_name)?;
info!(name = &link.name, "moving link into namespace");
manager.move_link(&link, namespace)
}
/// Move all links out of a namespace, except for lo
pub fn move_all_links_out(
namespace: &NamespaceHandle,
parent: &NamespaceHandle,
) -> Result<(), LinkError> {
namespace.run_in(|| {
let manager = LinkManager::new()?;
for link in manager.get_links()?.flatten() {
if link.name != "lo" {
info!(name = &link.name, "moving link out of namespace");
manager.move_link(&link, parent)?
}
}
}
Ok::<_, LinkError>(())
})??;
Ok(())
}

76
src/namespace/handle.rs Normal file
View file

@ -0,0 +1,76 @@
use crate::namespace::NamespaceEnterError;
use nix::errno::Errno;
use nix::sched::{setns, CloneFlags};
use std::fs::File;
use std::io::Error as IoError;
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd, RawFd};
use std::path::{Path, PathBuf};
use std::thread::scope;
use thiserror::Error;
pub struct NamespaceHandle {
path: PathBuf,
fd: OwnedFd,
}
impl NamespaceHandle {
/// Open the namespace handle for a path
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, NamespaceHandleError> {
let path = path.as_ref();
let file = File::open(path).map_err(|error| NamespaceHandleError::Open {
error,
path: path.into(),
})?;
Ok(NamespaceHandle {
path: path.into(),
fd: file.into(),
})
}
/// Open the namespace handle for the namespace the current process is in
pub fn parent() -> Result<Self, NamespaceHandleError> {
Self::open("/proc/self/ns/net")
}
pub fn run_in<T: Send, F: FnOnce() -> T + Send>(&self, f: F) -> Result<T, NamespaceEnterError> {
scope(|scope| {
scope
.spawn(|| {
setns(&self.fd, CloneFlags::CLONE_NEWNET)?;
Ok(f())
})
.join()
.expect("namespace thread panicked")
})
.map_err(|error| NamespaceEnterError {
namespace: self.path.clone(),
error,
})
}
}
impl AsFd for NamespaceHandle {
fn as_fd(&self) -> BorrowedFd<'_> {
self.fd.as_fd()
}
}
impl AsRawFd for NamespaceHandle {
fn as_raw_fd(&self) -> RawFd {
self.fd.as_raw_fd()
}
}
impl AsRawFd for &NamespaceHandle {
fn as_raw_fd(&self) -> RawFd {
self.fd.as_raw_fd()
}
}
#[derive(Debug, Error)]
pub enum NamespaceHandleError {
#[error("Failed to open namespace handle {}: {error:#}", path.display())]
Open { path: PathBuf, error: IoError },
#[error("Failed to enter namespace: {0:#}")]
Enter(Errno),
}

View file

@ -1,27 +1,32 @@
mod handle;
mod raw;
use crate::config::NamespaceName;
use crate::link::{LinkError, link_up_ns};
use crate::namespace::raw::{NamespaceSetupError, create_network_namespace};
use crate::config::{DeviceName, NamespaceName};
use crate::link::{link_up, move_all_links_out, move_link_into, LinkError};
pub use crate::namespace::handle::{NamespaceHandle, NamespaceHandleError};
use crate::namespace::raw::{create_network_namespace, NamespaceSetupError};
use either::Either;
use nix::errno::Errno;
use nix::mount::{MntFlags, MsFlags, mount, umount2};
use std::fs::{File, create_dir, read_dir, remove_file};
use nix::mount::{mount, umount2, MntFlags, MsFlags};
use std::fs::{create_dir, read_dir, remove_file, File};
use std::io::{Error as IoError, ErrorKind};
use std::iter::empty;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::{debug, error, info};
use tracing::{debug, info};
pub struct NetNs {
name: NamespaceName,
path: PathBuf,
nsd_path: PathBuf,
handle: NamespaceHandle,
}
impl NetNs {
pub fn existing(include_broken: bool) -> Result<impl Iterator<Item = NamespaceName>, NamespaceError> {
pub fn existing(
include_broken: bool,
) -> Result<impl Iterator<Item = NamespaceName>, NamespaceError> {
let dir = match read_dir("/var/run/netnsd") {
Ok(dir) => Ok(dir),
Err(error) if error.kind() == ErrorKind::NotFound => {
@ -71,10 +76,12 @@ impl NetNs {
})?;
}
let handle = NamespaceHandle::open(&path)?;
return Ok(NetNs {
name,
nsd_path,
path,
handle,
});
}
Err(e) => return Err(NamespaceError::from_create(path.clone(), e)),
@ -89,10 +96,12 @@ impl NetNs {
path: nsd_path.clone(),
})?;
}
let handle = NamespaceHandle::open(&path)?;
Result::<_, NamespaceError>::Ok(NetNs {
name,
path,
nsd_path,
handle,
})
})?;
@ -145,11 +154,15 @@ impl NetNs {
}
fn setup_interfaces(&self) -> Result<(), NamespaceError> {
link_up_ns(&self.path, "lo")?;
self.handle
.run_in(move || link_up("lo").map_err(NamespaceError::from))??;
Ok(())
}
pub fn delete(self) -> Result<(), NamespaceError> {
let parent_namespace = NamespaceHandle::parent()?;
move_all_links_out(self.handle(), &parent_namespace)?;
let name = self.path.file_name().unwrap().to_str().unwrap();
info!(name, "deleting network namespace");
match umount2(&self.path, MntFlags::MNT_DETACH) {
@ -167,6 +180,16 @@ impl NetNs {
})?;
Ok(())
}
/// Move a device into this namespace
pub fn move_device(&self, device: &DeviceName) -> Result<(), LinkError> {
move_link_into(device.as_ref(), self.handle())
}
/// Get the namespace handle
pub fn handle(&self) -> &NamespaceHandle {
&self.handle
}
}
#[derive(Debug, Error)]
@ -187,10 +210,14 @@ pub enum NamespaceError {
Mount(Errno),
#[error("Failed to unmount netns handle: {0:?}")]
UnMount(Errno),
#[error("Failed to setup loopback inside namespace: {0:#}")]
Link(#[from] LinkError),
#[error("Failed to scan {} for namespaces: {error:#}", path.display())]
Scan { path: PathBuf, error: IoError },
#[error(transparent)]
Handle(#[from] NamespaceHandleError),
#[error(transparent)]
Enter(#[from] NamespaceEnterError),
#[error(transparent)]
Link(#[from] LinkError),
}
impl NamespaceError {
@ -199,6 +226,13 @@ impl NamespaceError {
}
}
#[derive(Debug, Error)]
#[error("Error while entering namespace {}: {0:#}", namespace.display())]
pub struct NamespaceEnterError {
namespace: PathBuf,
error: Errno,
}
/// `remove_file`, but ignore "file not found" errors
fn remove_file_if_exists<P: AsRef<Path>>(path: P) -> std::io::Result<()> {
match remove_file(path) {
@ -207,7 +241,6 @@ fn remove_file_if_exists<P: AsRef<Path>>(path: P) -> std::io::Result<()> {
}
}
/// `remove_file`, but ignore errors if the file doesn't exist or is a mount point
fn remove_non_mount<P: AsRef<Path>>(path: P) -> std::io::Result<()> {
match remove_file(path) {
@ -215,4 +248,4 @@ fn remove_non_mount<P: AsRef<Path>>(path: P) -> std::io::Result<()> {
Err(err) if err.kind() == ErrorKind::ResourceBusy => Ok(()),
rest => rest,
}
}
}

View file

@ -1,6 +1,7 @@
use crate::config::{Config, NamespaceName};
use crate::namespace::NetNs;
use main_error::MainResult;
use tracing::error;
pub fn up(config: Config) -> MainResult {
let mut namespaces = NetNs::existing(false)?
@ -15,7 +16,13 @@ pub fn up(config: Config) -> MainResult {
for new in config.namespaces {
if !has_namespace(&namespaces, &new.name) {
namespaces.push(NetNs::new(new.name)?);
namespaces.push(NetNs::new(new.name.clone())?);
}
let namespace = get_namespace(&namespaces, &new.name).expect("namespace is just created");
for device in new.devices {
if let Err(error) = namespace.move_device(&device) {
error!(%error, "failed to move device into namespace");
}
}
}
@ -25,3 +32,7 @@ pub fn up(config: Config) -> MainResult {
fn has_namespace(namespaces: &[NetNs], name: &NamespaceName) -> bool {
namespaces.iter().any(|existing| existing.name() == name)
}
fn get_namespace<'a>(namespaces: &'a [NetNs], name: &NamespaceName) -> Option<&'a NetNs> {
namespaces.iter().find(|existing| existing.name() == name)
}