reload destination -> target and module fixes

This commit is contained in:
Robin Appelman 2025-10-31 22:40:04 +01:00
commit 5e5ee227fc
10 changed files with 69 additions and 42 deletions

9
README.md Normal file
View file

@ -0,0 +1,9 @@
# netnsd
A declarative manager for Linux network namespaces.
## Features
- Fully declarative configuration
- Hot reloading of configuration
- Port forwarding into the namespace

View file

@ -21,7 +21,8 @@
... ...
}: { }: {
imports = [./nix/module.nix]; imports = [./nix/module.nix];
config = lib.mkIf config.networking.netnsd.enable { config = lib.mkIf (config.networking.netnsd.namespaces != {}) {
nixpkgs.overlays = [ outputs.overlays.default ];
networking.netnsd.package = lib.mkDefault pkgs.netnsd; networking.netnsd.package = lib.mkDefault pkgs.netnsd;
}; };
}; };

View file

@ -9,7 +9,7 @@ with lib; let
hasNamespaces = cfg.namespaces != {}; hasNamespaces = cfg.namespaces != {};
format = pkgs.formats.toml {}; format = pkgs.formats.toml {};
configFile = format.generate "netnsd.toml" { configFile = format.generate "netnsd.toml" {
inherit (cfg) namespaces; namespace = mapAttrsToList (_: value: value) cfg.namespaces;
}; };
in { in {
options.networking.netnsd = { options.networking.netnsd = {
@ -18,6 +18,12 @@ in {
description = "package to use"; description = "package to use";
}; };
logLevel = mkOption {
type = types.str;
default = "info";
description = "Log level";
};
namespaces = mkOption { namespaces = mkOption {
type = types.attrsOf (types.submodule ({name, ...}: { type = types.attrsOf (types.submodule ({name, ...}: {
options = { options = {
@ -31,11 +37,11 @@ in {
options = { options = {
source = mkOption { source = mkOption {
type = types.oneOf [types.port types.str]; type = types.oneOf [types.port types.str];
default = config.destination; default = config.target;
defaultText = "<destination>"; defaultText = "<target>";
description = "source port, address or socket outside the namespace"; description = "source port, address or socket outside the namespace";
}; };
destination = mkOption { target = mkOption {
type = types.oneOf [types.port types.str]; type = types.oneOf [types.port types.str];
description = "target port or address inside the namespace"; description = "target port or address inside the namespace";
}; };
@ -46,6 +52,7 @@ in {
}; };
})); }));
description = "namespaces to setup"; description = "namespaces to setup";
default = {};
}; };
}; };
@ -53,17 +60,20 @@ in {
# symlink instead of passing `configFile` directly to netnsd to allow changing the config without changing the path # symlink instead of passing `configFile` directly to netnsd to allow changing the config without changing the path
environment.etc."netnsd/netnsd.toml".source = configFile; environment.etc."netnsd/netnsd.toml".source = configFile;
systemd.services.netcsctl = { systemd.services.netnsd = {
reloadTriggers = [configFile]; reloadTriggers = [configFile];
wantedBy = ["multi-user.target"]; wantedBy = ["multi-user.target"];
before = ["network.target"];
environment = {
RUST_LOG = cfg.logLevel;
};
serviceConfig = { serviceConfig = {
Restart = "on-failure"; Restart = "on-failure";
Type = "notify-reload"; Type = "notify-reload";
ExecStart = "${getExec cfg.pkg} daemon -c /etc/netnsd/netnsd.toml"; ExecStart = "${getExe cfg.package} daemon -c /etc/netnsd/netnsd.toml";
PrivateTmp = true;
ProtectSystem = "full";
ProtectHome = true;
NoNewPrivileges = true; NoNewPrivileges = true;
}; };
}; };

View file

@ -16,4 +16,6 @@ in
cargoLock = { cargoLock = {
lockFile = ../Cargo.lock; lockFile = ../Cargo.lock;
}; };
meta.mainProgram = "netnsd";
} }

View file

@ -1,8 +1,8 @@
mod destination; mod target;
mod name; mod name;
mod source; mod source;
pub use crate::config::destination::ForwardDestination; pub use crate::config::target::ForwardTarget;
pub use crate::config::name::NamespaceName; pub use crate::config::name::NamespaceName;
pub use crate::config::source::ForwardSource; pub use crate::config::source::ForwardSource;
use serde::Deserialize; use serde::Deserialize;
@ -80,7 +80,7 @@ pub struct NamespaceConfig {
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct ForwardConfig { pub struct ForwardConfig {
pub source: ForwardSource, pub source: ForwardSource,
pub destination: ForwardDestination, pub target: ForwardTarget,
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]

View file

@ -5,31 +5,31 @@ use std::net::{IpAddr, SocketAddr};
use std::str::FromStr; use std::str::FromStr;
#[derive(Debug, PartialEq, Clone, Hash, Eq)] #[derive(Debug, PartialEq, Clone, Hash, Eq)]
pub struct ForwardDestination { pub struct ForwardTarget {
pub addr: SocketAddr, pub addr: SocketAddr,
} }
impl From<ForwardDestination> for SocketAddr { impl From<ForwardTarget> for SocketAddr {
fn from(value: ForwardDestination) -> Self { fn from(value: ForwardTarget) -> Self {
value.addr value.addr
} }
} }
impl Display for ForwardDestination { impl Display for ForwardTarget {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.addr) write!(f, "{}", self.addr)
} }
} }
impl<'de> Deserialize<'de> for ForwardDestination { impl<'de> Deserialize<'de> for ForwardTarget {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where where
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
struct ForwardDestinationVisitor; struct ForwardTargetVisitor;
impl Visitor<'_> for ForwardDestinationVisitor { impl Visitor<'_> for ForwardTargetVisitor {
type Value = ForwardDestination; type Value = ForwardTarget;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter formatter
@ -51,7 +51,7 @@ impl<'de> Deserialize<'de> for ForwardDestination {
E: Error, E: Error,
{ {
let ip = IpAddr::from([127, 0, 0, 1]); let ip = IpAddr::from([127, 0, 0, 1]);
Ok(ForwardDestination { Ok(ForwardTarget {
addr: SocketAddr::from((ip, v)), addr: SocketAddr::from((ip, v)),
}) })
} }
@ -76,11 +76,11 @@ impl<'de> Deserialize<'de> for ForwardDestination {
let addr = v let addr = v
.parse() .parse()
.map_err(|_| E::invalid_value(Unexpected::Str(v), &self))?; .map_err(|_| E::invalid_value(Unexpected::Str(v), &self))?;
Ok(ForwardDestination { addr }) Ok(ForwardTarget { addr })
} }
} }
deserializer.deserialize_any(ForwardDestinationVisitor) deserializer.deserialize_any(ForwardTargetVisitor)
} }
} }
@ -90,14 +90,14 @@ fn test_de() {
let addr_str = "127.0.0.1:80"; let addr_str = "127.0.0.1:80";
let addr = SocketAddr::from_str("127.0.0.1:80").unwrap(); let addr = SocketAddr::from_str("127.0.0.1:80").unwrap();
fn port_addr(port: u16) -> ForwardDestination { fn port_addr(port: u16) -> ForwardTarget {
ForwardDestination { ForwardTarget {
addr: SocketAddr::new(IpAddr::from([127, 0, 0, 1]), port), addr: SocketAddr::new(IpAddr::from([127, 0, 0, 1]), port),
} }
} }
assert_de_tokens(&ForwardDestination { addr }, &[Token::String(addr_str)]); assert_de_tokens(&ForwardTarget { addr }, &[Token::String(addr_str)]);
assert_de_tokens(&ForwardDestination { addr }, &[Token::Str(addr_str)]); assert_de_tokens(&ForwardTarget { addr }, &[Token::Str(addr_str)]);
assert_de_tokens(&port_addr(80), &[Token::Str("80")]); assert_de_tokens(&port_addr(80), &[Token::Str("80")]);
assert_de_tokens(&port_addr(80), &[Token::U8(80)]); assert_de_tokens(&port_addr(80), &[Token::U8(80)]);
@ -107,19 +107,19 @@ fn test_de() {
assert_de_tokens(&port_addr(80), &[Token::I16(80)]); assert_de_tokens(&port_addr(80), &[Token::I16(80)]);
assert_de_tokens(&port_addr(80), &[Token::I64(80)]); assert_de_tokens(&port_addr(80), &[Token::I64(80)]);
assert_de_tokens_error::<ForwardDestination>( assert_de_tokens_error::<ForwardTarget>(
&[Token::I64(-80)], &[Token::I64(-80)],
"invalid value: integer `-80`, expected Either a port as integer, or a string containing a socket address", "invalid value: integer `-80`, expected Either a port as integer, or a string containing a socket address",
); );
assert_de_tokens_error::<ForwardDestination>( assert_de_tokens_error::<ForwardTarget>(
&[Token::U64(12345678)], &[Token::U64(12345678)],
"invalid value: integer `12345678`, expected Either a port as integer, or a string containing a socket address", "invalid value: integer `12345678`, expected Either a port as integer, or a string containing a socket address",
); );
assert_de_tokens_error::<ForwardDestination>( assert_de_tokens_error::<ForwardTarget>(
&[Token::Str("hello world")], &[Token::Str("hello world")],
"invalid value: string \"hello world\", expected Either a port as integer, or a string containing a socket address", "invalid value: string \"hello world\", expected Either a port as integer, or a string containing a socket address",
); );
assert_de_tokens_error::<ForwardDestination>( assert_de_tokens_error::<ForwardTarget>(
&[Token::Str("localhost:80")], &[Token::Str("localhost:80")],
"invalid value: string \"localhost:80\", expected Either a port as integer, or a string containing a socket address", "invalid value: string \"localhost:80\", expected Either a port as integer, or a string containing a socket address",
); );

View file

@ -38,9 +38,12 @@ async fn daemon_async(mut config: Config) -> Result<(), DaemonError> {
let info_signal = signal(SignalKind::user_defined1()).map_err(DaemonError::Signal)?; let info_signal = signal(SignalKind::user_defined1()).map_err(DaemonError::Signal)?;
let info_signal = SignalStream::new(info_signal).map(|_| Event::Info); let info_signal = SignalStream::new(info_signal).map(|_| Event::Info);
let stop_signal = signal(SignalKind::terminate()).map_err(DaemonError::Signal)?;
let stop_signal = SignalStream::new(stop_signal).map(|_| Event::Quit);
let quit_signal = ctrl_c().into_stream().map(|_| Event::Quit); let quit_signal = ctrl_c().into_stream().map(|_| Event::Quit);
let events = (reload_signal, info_signal, quit_signal).merge(); let events = (reload_signal, info_signal, stop_signal, quit_signal).merge();
let mut events = pin!(events); let mut events = pin!(events);
while let Some(event) = events.next().await { while let Some(event) = events.next().await {

View file

@ -11,7 +11,7 @@ use std::fs::{File, create_dir_all, remove_file};
use std::io::{Error as IoError, ErrorKind}; use std::io::{Error as IoError, ErrorKind};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use thiserror::Error; use thiserror::Error;
use tracing::{error, info}; use tracing::{debug, error, info};
pub struct NetNs { pub struct NetNs {
name: NamespaceName, name: NamespaceName,
@ -21,7 +21,6 @@ pub struct NetNs {
impl NetNs { impl NetNs {
/// Create a new named network namespace that will be removed when dropped /// Create a new named network namespace that will be removed when dropped
pub fn new(name: NamespaceName) -> Result<Self, NamespaceError> { pub fn new(name: NamespaceName) -> Result<Self, NamespaceError> {
info!(%name, "creating network namespace");
let parent = Path::new("/var/run/netns"); let parent = Path::new("/var/run/netns");
create_dir_all(parent).map_err(NamespaceError::Parent)?; create_dir_all(parent).map_err(NamespaceError::Parent)?;
let path = parent.join(&name); let path = parent.join(&name);
@ -29,6 +28,7 @@ impl NetNs {
match File::create_new(&path) { match File::create_new(&path) {
Ok(_) => {} Ok(_) => {}
Err(e) if e.kind() == ErrorKind::AlreadyExists => { Err(e) if e.kind() == ErrorKind::AlreadyExists => {
info!(%name, "using existing network namespace");
return Ok(NetNs { return Ok(NetNs {
name: name.clone(), name: name.clone(),
path, path,
@ -36,6 +36,7 @@ impl NetNs {
} }
Err(e) => return Err(NamespaceError::from_create(path.clone(), e)), Err(e) => return Err(NamespaceError::from_create(path.clone(), e)),
} }
info!(%name, "creating network namespace");
let ns = create_network_namespace(move |ns| { let ns = create_network_namespace(move |ns| {
bind_namespace(&ns, &path)?; bind_namespace(&ns, &path)?;
@ -52,6 +53,7 @@ impl NetNs {
} }
fn bind_namespace(namespace: &Path, path: &Path) -> Result<(), NamespaceError> { fn bind_namespace(namespace: &Path, path: &Path) -> Result<(), NamespaceError> {
debug!(namespace = %namespace.display(), path = %path.display(), "mounting namespace");
mount( mount(
Some(namespace.as_os_str()), Some(namespace.as_os_str()),
path.as_os_str(), path.as_os_str(),

View file

@ -1,6 +1,6 @@
mod tcp; mod tcp;
use crate::config::{ForwardConfig, ForwardDestination, ForwardSource, NamespaceName}; use crate::config::{ForwardConfig, ForwardTarget, ForwardSource, NamespaceName};
use crate::daemon::proxy::tcp::Proxy; use crate::daemon::proxy::tcp::Proxy;
use futures::future::AbortHandle; use futures::future::AbortHandle;
use nix::sched::{CloneFlags, setns}; use nix::sched::{CloneFlags, setns};
@ -35,7 +35,7 @@ pub enum ProxyError {
pub struct ActiveProxy { pub struct ActiveProxy {
pub source: ForwardSource, pub source: ForwardSource,
pub destination: ForwardDestination, pub destination: ForwardTarget,
abort: AbortHandle, abort: AbortHandle,
pub stats: ProxyStats, pub stats: ProxyStats,
} }
@ -50,7 +50,7 @@ impl ActiveProxy {
let (abort, abort_reg) = AbortHandle::new_pair(); let (abort, abort_reg) = AbortHandle::new_pair();
let destination = config.destination.clone(); let destination = config.target.clone();
let run_stats = stats.clone(); let run_stats = stats.clone();
let ns_path = PathBuf::from(format!("/var/run/netns/{namespace}")); let ns_path = PathBuf::from(format!("/var/run/netns/{namespace}"));
let ns_handle = File::open(&ns_path).map_err(|error| ProxyError::OpenNamespace { let ns_handle = File::open(&ns_path).map_err(|error| ProxyError::OpenNamespace {
@ -75,7 +75,7 @@ impl ActiveProxy {
Ok(ActiveProxy { Ok(ActiveProxy {
source: config.source.clone(), source: config.source.clone(),
destination: config.destination.clone(), destination: config.target.clone(),
abort, abort,
stats, stats,
}) })
@ -95,7 +95,7 @@ impl Drop for ActiveProxy {
impl PartialEq<ForwardConfig> for ActiveProxy { impl PartialEq<ForwardConfig> for ActiveProxy {
fn eq(&self, other: &ForwardConfig) -> bool { fn eq(&self, other: &ForwardConfig) -> bool {
self.source == other.source && self.destination == other.destination self.source == other.source && self.destination == other.target
} }
} }

View file

@ -1,5 +1,5 @@
/// Loosely based on https://github.com/fooker/netns-proxy/blob/main/src/tcp.rs /// Loosely based on https://github.com/fooker/netns-proxy/blob/main/src/tcp.rs
use crate::config::{ForwardDestination, ForwardSource}; use crate::config::{ForwardTarget, ForwardSource};
use crate::daemon::proxy::{ProxyError, ProxyStats}; use crate::daemon::proxy::{ProxyError, ProxyStats};
use futures::TryStreamExt; use futures::TryStreamExt;
use futures::stream::{AbortRegistration, Abortable}; use futures::stream::{AbortRegistration, Abortable};
@ -61,7 +61,7 @@ impl Proxy {
}) })
} }
pub async fn run(self, target: ForwardDestination, abort: AbortRegistration, stats: ProxyStats) { pub async fn run(self, target: ForwardTarget, abort: AbortRegistration, stats: ProxyStats) {
let proxy_stats = stats.clone(); let proxy_stats = stats.clone();
match self.socket { match self.socket {
ProxyListener::Tcp(socket) => { ProxyListener::Tcp(socket) => {