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];
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;
};
};

View file

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

View file

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

View file

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

View file

@ -5,31 +5,31 @@ use std::net::{IpAddr, SocketAddr};
use std::str::FromStr;
#[derive(Debug, PartialEq, Clone, Hash, Eq)]
pub struct ForwardDestination {
pub struct ForwardTarget {
pub addr: SocketAddr,
}
impl From<ForwardDestination> for SocketAddr {
fn from(value: ForwardDestination) -> Self {
impl From<ForwardTarget> for SocketAddr {
fn from(value: ForwardTarget) -> Self {
value.addr
}
}
impl Display for ForwardDestination {
impl Display for ForwardTarget {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
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>
where
D: Deserializer<'de>,
{
struct ForwardDestinationVisitor;
struct ForwardTargetVisitor;
impl Visitor<'_> for ForwardDestinationVisitor {
type Value = ForwardDestination;
impl Visitor<'_> for ForwardTargetVisitor {
type Value = ForwardTarget;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter
@ -51,7 +51,7 @@ impl<'de> Deserialize<'de> for ForwardDestination {
E: Error,
{
let ip = IpAddr::from([127, 0, 0, 1]);
Ok(ForwardDestination {
Ok(ForwardTarget {
addr: SocketAddr::from((ip, v)),
})
}
@ -76,11 +76,11 @@ impl<'de> Deserialize<'de> for ForwardDestination {
let addr = v
.parse()
.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 = SocketAddr::from_str("127.0.0.1:80").unwrap();
fn port_addr(port: u16) -> ForwardDestination {
ForwardDestination {
fn port_addr(port: u16) -> ForwardTarget {
ForwardTarget {
addr: SocketAddr::new(IpAddr::from([127, 0, 0, 1]), port),
}
}
assert_de_tokens(&ForwardDestination { addr }, &[Token::String(addr_str)]);
assert_de_tokens(&ForwardDestination { addr }, &[Token::Str(addr_str)]);
assert_de_tokens(&ForwardTarget { addr }, &[Token::String(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::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::I64(80)]);
assert_de_tokens_error::<ForwardDestination>(
assert_de_tokens_error::<ForwardTarget>(
&[Token::I64(-80)],
"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)],
"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")],
"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")],
"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 = 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 events = (reload_signal, info_signal, quit_signal).merge();
let events = (reload_signal, info_signal, stop_signal, quit_signal).merge();
let mut events = pin!(events);
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::path::{Path, PathBuf};
use thiserror::Error;
use tracing::{error, info};
use tracing::{debug, error, info};
pub struct NetNs {
name: NamespaceName,
@ -21,7 +21,6 @@ pub struct NetNs {
impl NetNs {
/// Create a new named network namespace that will be removed when dropped
pub fn new(name: NamespaceName) -> Result<Self, NamespaceError> {
info!(%name, "creating network namespace");
let parent = Path::new("/var/run/netns");
create_dir_all(parent).map_err(NamespaceError::Parent)?;
let path = parent.join(&name);
@ -29,6 +28,7 @@ impl NetNs {
match File::create_new(&path) {
Ok(_) => {}
Err(e) if e.kind() == ErrorKind::AlreadyExists => {
info!(%name, "using existing network namespace");
return Ok(NetNs {
name: name.clone(),
path,
@ -36,6 +36,7 @@ impl NetNs {
}
Err(e) => return Err(NamespaceError::from_create(path.clone(), e)),
}
info!(%name, "creating network namespace");
let ns = create_network_namespace(move |ns| {
bind_namespace(&ns, &path)?;
@ -52,6 +53,7 @@ impl NetNs {
}
fn bind_namespace(namespace: &Path, path: &Path) -> Result<(), NamespaceError> {
debug!(namespace = %namespace.display(), path = %path.display(), "mounting namespace");
mount(
Some(namespace.as_os_str()),
path.as_os_str(),

View file

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