Compare commits

...

16 commits

20 changed files with 1035 additions and 548 deletions

554
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,28 +1,29 @@
[package]
name = "netnsd"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
rust-version = "1.88.0"
[dependencies]
tokio = { version = "1.48.0", features = ["macros", "rt", "signal", "net", "io-util"] }
tokio-stream = { version = "0.1.17", features = ["signal", "net"] }
toml = "0.9.8"
tokio = { version = "1.49.0", features = ["macros", "rt", "signal", "net", "io-util"] }
tokio-stream = { version = "0.1.18", features = ["signal", "net"] }
toml = "1.0.1"
serde = { version = "1.0.228", features = ["derive"] }
clap = { version = "4.5.51", features = ["derive"] }
thiserror = "2.0.17"
tracing = "0.1.41"
tracing-subscriber = "0.3.20"
clap = { version = "4.5.58", features = ["derive"] }
thiserror = "2.0.18"
tracing = "0.1.44"
tracing-subscriber = "0.3.22"
main_error = "0.1.2"
nix = { version = "0.30.1", features = ["mount", "sched", "user", "signal"] }
sd-notify = "0.4.5"
nix = { version = "0.31.1", features = ["mount", "sched", "user", "signal"] }
sd-notify = "0.5.0"
futures = "0.3.31"
futures-concurrency = "7.6.3"
neli = "0.7.1"
futures-concurrency = "7.7.1"
neli = "0.7.4"
either = "1.15.0"
uzers = "0.12.1"
sysinfo = "0.37.2"
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"

View file

@ -5,8 +5,11 @@ A declarative manager for Linux network namespaces.
## Features
- Fully declarative configuration
- Standalone binary with no runtime dependencies
- Hot reloading of configuration
- Port forwarding into the namespace
- Port forwarding into or out of the namespace
- Moving network devices to the namespace
- Setting up routing inside the namespace
## Usage
@ -53,6 +56,13 @@ You can specify a different configuration path with the `--config` option.
[[namespace]]
# name of the namespace to create
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]]

View file

@ -2,6 +2,13 @@
[[namespace]]
# name of the namespace to create
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]]

32
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1763938834,
"narHash": "sha256-j8iB0Yr4zAvQLueCZ5abxfk6fnG/SJ5JnGUziETjwfg=",
"lastModified": 1774313767,
"narHash": "sha256-hy0XTQND6avzGEUFrJtYBBpFa/POiiaGBr2vpU6Y9tY=",
"owner": "ipetkov",
"repo": "crane",
"rev": "d9e753122e51cee64eb8d2dddfe11148f339f5a2",
"rev": "3d9df76e29656c679c744968b17fbaf28f0e923d",
"type": "github"
},
"original": {
@ -22,11 +22,11 @@
]
},
"locked": {
"lastModified": 1764593611,
"narHash": "sha256-6SdexcO69Dlu14YN2xuB1A6JHWSrcqMj7Na9oK7IT2M=",
"lastModified": 1777298791,
"narHash": "sha256-MEQeYwRQcV7RvlKMVrFy07dmoY8t2s/SIK7EpCNLOu8=",
"owner": "nix-community",
"repo": "flakelight",
"rev": "0d63256401341f528dd628f1a8e96d3afecade7a",
"rev": "c4b125c5453559de6a228be70f71abeb017f3265",
"type": "github"
},
"original": {
@ -44,11 +44,11 @@
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1764619631,
"narHash": "sha256-WojMP5S9qLmOLecEQ+7+yc33Ly1ydoRsODNG6hlLqiQ=",
"lastModified": 1776692281,
"narHash": "sha256-DCCSbpTUDqRiHsTS/sIgCTwa1r4gsJlWSFE6difdWxk=",
"ref": "refs/heads/main",
"rev": "0fae557bf52d8493840aca52d433c473ecc305ef",
"revCount": 67,
"rev": "d1d0e0bb5b0acebed06ccced9cc27f82aafab058",
"revCount": 71,
"type": "git",
"url": "https://codeberg.org/icewind/mill-scale"
},
@ -59,11 +59,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1764522689,
"narHash": "sha256-SqUuBFjhl/kpDiVaKLQBoD8TLD+/cTUzzgVFoaHrkqY=",
"lastModified": 1777428379,
"narHash": "sha256-ypxFOeDz+CqADEQNL72haqGjvZQdBR5Vc7pyx2JDttI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8bb5646e0bed5dbd3ab08c7a7cc15b75ab4e1d0f",
"rev": "755f5aa91337890c432639c60b6064bb7fe67769",
"type": "github"
},
"original": {
@ -88,11 +88,11 @@
]
},
"locked": {
"lastModified": 1764557621,
"narHash": "sha256-kX5PoY8hQZ80+amMQgOO9t8Tc1JZ70gYRnzaVD4AA+o=",
"lastModified": 1774535687,
"narHash": "sha256-dpKS/8+uB0EoI4mCrpio+xs8Xxry6ZhLLwV8VIbbfrs=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "93316876c2229460a5d6f5f052766cc4cef538ce",
"rev": "75900435aa883f84b038316864b3f60956681523",
"type": "github"
},
"original": {

View file

@ -45,11 +45,37 @@ in {
type = types.oneOf [types.port types.str];
description = "target port or address inside the namespace";
};
reverse = mkOption {
type = types.bool;
default = false;
description = "forward from inside the namespace to outside instead";
};
};
}));
description = "ports to forward into the namespace";
default = [];
};
devices = mkOption {
type = types.listOf types.str;
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";
@ -61,7 +87,7 @@ 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;
environment.systemPackages = with pkgs; [cfg.package];
environment.systemPackages = [cfg.package];
systemd.services.netnsd = {
reloadTriggers = [configFile];

View file

@ -12,7 +12,7 @@
rustc = rust-bin.stable.latest.minimal;
};
in
rustPlatform.buildRustPackage rec {
rustPlatform.buildRustPackage {
pname = cargoPackage.name;
inherit (cargoPackage) version;

View file

@ -2,13 +2,18 @@ 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;
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;
@ -80,7 +85,12 @@ impl RawConfig {
#[derive(Deserialize, Debug)]
pub struct NamespaceConfig {
pub name: NamespaceName,
#[serde(default)]
pub forward: Vec<ForwardConfig>,
#[serde(default)]
pub devices: Vec<DeviceName>,
#[serde(default, rename = "route")]
pub routes: Vec<RouteConfig>,
}
#[derive(Deserialize, Debug)]
@ -91,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<AnyIpCidr, D::Error> {
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())]

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,10 @@
use crate::config::{Config, ForwardConfig, NamespaceConfig, NamespaceName};
use crate::namespace::{NamespaceError, NetNs};
use crate::config::{
Config, DeviceName, ForwardConfig, NamespaceConfig, NamespaceName, RouteConfig,
};
use crate::link::{LinkError, LinkManager};
use crate::namespace::{
NamespaceEnterError, NamespaceError, NamespaceHandle, NamespaceHandleError, NetNs,
};
use crate::proxy::{ActiveProxy, ProxyError};
use futures::FutureExt;
use futures::StreamExt;
@ -25,7 +30,15 @@ async fn daemon_async(mut config: Config) -> Result<(), DaemonError> {
state.update(&config)?;
// now the namespaces are setup, we can tell systemd to start any service depending on them
notify(false, &[NotifyState::Ready]).map_err(DaemonError::Notify)?;
notify(&[
NotifyState::Ready,
NotifyState::Status(&format!(
"Started with {} namespaces",
state.namespaces.len()
)),
])
.map_err(DaemonError::Notify)?;
info!("ready");
let reload_signal = signal(SignalKind::hangup()).map_err(DaemonError::Signal)?;
let reload_signal = SignalStream::new(reload_signal).map(|_| Event::Reload);
@ -52,7 +65,7 @@ async fn daemon_async(mut config: Config) -> Result<(), DaemonError> {
match NotifyState::monotonic_usec_now() {
Ok(notify_time) => {
notify(false, &[NotifyState::Reloading, notify_time])
notify(&[NotifyState::Reloading, notify_time])
.map_err(DaemonError::Notify)?;
}
Err(error) => {
@ -70,7 +83,15 @@ async fn daemon_async(mut config: Config) -> Result<(), DaemonError> {
}
}
notify(false, &[NotifyState::Ready]).map_err(DaemonError::Notify)?;
notify(&[
NotifyState::Ready,
NotifyState::Status(&format!(
"Reloaded with {} namespaces",
state.namespaces.len()
)),
])
.map_err(DaemonError::Notify)?;
info!("reloaded");
}
Event::Info => {
for namespace in &state.namespaces {
@ -83,7 +104,7 @@ async fn daemon_async(mut config: Config) -> Result<(), DaemonError> {
}
}
let _ = notify(false, &[NotifyState::Stopping]);
let _ = notify(&[NotifyState::Stopping]);
Ok(())
}
@ -101,7 +122,7 @@ struct State {
impl State {
pub fn new() -> Result<Self, DaemonError> {
let namespaces = NetNs::existing()?
let namespaces = NetNs::existing(false)?
.map(ActiveNamespace::new)
.collect::<Result<Vec<_>, _>>()?;
Ok(State { namespaces })
@ -124,6 +145,8 @@ impl State {
for namespace in &mut self.namespaces {
let config = config.get_namespace(namespace.name()).unwrap();
namespace.update_proxies(config)?;
namespace.update_devices(config)?;
namespace.update_links(config)?;
}
Ok(())
@ -139,6 +162,8 @@ impl State {
struct ActiveNamespace {
ns: NetNs,
proxies: Vec<ActiveProxy>,
devices: Vec<DeviceName>,
routes: Vec<RouteConfig>,
}
impl ActiveNamespace {
@ -148,6 +173,8 @@ impl ActiveNamespace {
Ok(ActiveNamespace {
ns,
proxies: Vec::default(),
devices: Vec::default(),
routes: Vec::default(),
})
}
@ -164,10 +191,106 @@ 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(())
}
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)
}
fn has_device(&self, name: &DeviceName) -> bool {
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()
}
@ -183,4 +306,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

@ -2,7 +2,7 @@ use crate::namespace::NetNs;
use main_error::MainResult;
pub fn down() -> MainResult {
for name in NetNs::existing()? {
for name in NetNs::existing(true)? {
let ns = NetNs::new(name)?;
ns.delete()?
}

View file

@ -1,96 +1,265 @@
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::Ifinfomsg;
use neli::rtnl::IfinfomsgBuilder;
use neli::rtnl::{Ifinfomsg, RtattrBuilder, Rtmsg};
use neli::rtnl::{IfinfomsgBuilder, RtmsgBuilder};
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::fmt::{Debug, Display, Formatter};
use std::os::fd::AsRawFd;
use thiserror::Error;
use tracing::{info, instrument};
#[derive(Debug, Error)]
pub enum LinkError {
#[error("failed to communicate with netlink")]
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("Failed to communicate with netlink: {0}")]
Netlink(String),
#[error("Failed to parse netlink response: {0}")]
Parse(String),
#[error("Link not found: {0}")]
NotFound(String),
}
impl<T, P> From<RouterError<T, P>> for LinkError {
fn from(_value: RouterError<T, P>) -> Self {
LinkError::Netlink
impl<T, P> From<RouterError<T, P>> for LinkError
where
T: Debug,
P: Debug,
{
fn from(value: RouterError<T, P>) -> Self {
LinkError::Netlink(value.to_string())
}
}
/// 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 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()
.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(|e| LinkError::Parse(e.to_string()))?;
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(ns_handle)
.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(())
}
pub fn up(&self, link: &Link) -> Result<(), LinkError> {
let up_msg = link.msg_builder().up().build().unwrap();
self.router.send::<_, _, Rtm, Ifinfomsg>(
Rtm::Setlink,
NlmF::ACK,
NlPayload::Payload(up_msg),
)?;
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
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)?;
manager.up(&link)
}
let recv = rtnl.send::<_, _, Rtm, Ifinfomsg>(
Rtm::Getlink,
NlmF::DUMP | NlmF::ACK,
NlPayload::Payload(ifinfomsg),
)?;
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),
)?;
}
/// Move a link into a namespace
pub fn move_link_into<Fd: AsRawFd>(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)
}
/// Move all links from the current namespace (except lo) into a namespace
pub fn move_all_links<Fd: AsRawFd>(namespace: &Fd) -> Result<(), LinkError> {
let manager = LinkManager::new()?;
for link in manager.get_links()?.flatten() {
if link.name != "lo" {
info!(name = &link.name, "moving link");
manager.move_link(&link, namespace)?
}
}
Ok(())
Ok::<_, LinkError>(())
}
fn route_message_for(link: &Link, destination: AnyIpCidr) -> Rtmsg {
let mut info_attrs = RtBuffer::<Rta, Buffer>::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")
}

View file

@ -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");
}

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,8 +1,12 @@
mod handle;
mod raw;
mod sysctl;
use crate::config::NamespaceName;
use crate::link::{link_up_ns, LinkError};
use crate::config::{DeviceName, NamespaceName};
use crate::link::{link_up, move_all_links, move_link_into, LinkError};
pub use crate::namespace::handle::{NamespaceHandle, NamespaceHandleError};
use crate::namespace::raw::{create_network_namespace, NamespaceSetupError};
use crate::namespace::sysctl::{CtlError, NamespaceCtl};
use either::Either;
use nix::errno::Errno;
use nix::mount::{mount, umount2, MntFlags, MsFlags};
@ -12,16 +16,19 @@ 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() -> 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 => {
@ -32,12 +39,14 @@ impl NetNs {
error,
}),
}?;
Ok(Either::Right(dir.flatten().flat_map(|entry| {
NamespaceName::try_from(entry.file_name()).ok()
})))
Ok(Either::Right(
dir.flatten()
.filter(move |entry| include_broken || entry.path().is_symlink())
.flat_map(|entry| NamespaceName::try_from(entry.file_name()).ok()),
))
}
/// Create a new named network namespace that will be removed when dropped
/// Create a new named network namespace
pub fn new(name: NamespaceName) -> Result<Self, NamespaceError> {
let parent = Path::new("/var/run/netns");
let nsd_parent = Path::new("/var/run/netnsd");
@ -47,22 +56,34 @@ impl NetNs {
let path = parent.join(&name);
let nsd_path = nsd_parent.join(&name);
remove_non_mount(&path).map_err(|error| NamespaceError::Delete {
error,
path: nsd_path.clone(),
})?;
match File::create_new(&path) {
Ok(_) => {}
Err(e) if e.kind() == ErrorKind::AlreadyExists => {
info!(%name, "using existing network namespace");
if !nsd_path.is_symlink() {
remove_file_if_exists(&nsd_path).map_err(|error| NamespaceError::Delete {
error,
path: nsd_path.clone(),
})?;
symlink(&path, &nsd_path).map_err(|error| NamespaceError::Symlink {
error,
path: nsd_path.clone(),
})?;
}
let handle = NamespaceHandle::open(&path)?;
return Ok(NetNs {
name,
nsd_path,
path,
handle,
});
}
Err(e) => return Err(NamespaceError::from_create(path.clone(), e)),
@ -77,10 +98,12 @@ impl NetNs {
path: nsd_path.clone(),
})?;
}
let handle = NamespaceHandle::open(&path)?;
Result::<_, NamespaceError>::Ok(NetNs {
name,
path,
nsd_path,
handle,
})
})?;
@ -133,24 +156,46 @@ impl NetNs {
}
fn setup_interfaces(&self) -> Result<(), NamespaceError> {
link_up_ns(&self.path, "lo")?;
let ctl = NamespaceCtl::read()?;
self.handle.run_in(move || {
link_up("lo").map_err(NamespaceError::from)?;
dbg!(ctl).apply()?;
Ok::<_, NamespaceError>(())
})??;
Ok(())
}
pub fn delete(self) -> Result<(), NamespaceError> {
let parent_namespace = NamespaceHandle::parent()?;
self.handle.run_in(|| move_all_links(&parent_namespace))??;
let name = self.path.file_name().unwrap().to_str().unwrap();
info!(name, "deleting network namespace");
umount2(&self.path, MntFlags::MNT_DETACH).map_err(NamespaceError::UnMount)?;
remove_file(&self.path).map_err(|error| NamespaceError::Delete {
match umount2(&self.path, MntFlags::MNT_DETACH) {
Err(Errno::EINVAL) => Ok(()), // not a mountpoint, namespace doesn't exist
rest => rest,
}
.map_err(NamespaceError::UnMount)?;
remove_file_if_exists(&self.path).map_err(|error| NamespaceError::Delete {
error,
path: self.path,
})?;
remove_file(&self.nsd_path).map_err(|error| NamespaceError::Delete {
remove_file_if_exists(&self.nsd_path).map_err(|error| NamespaceError::Delete {
error,
path: self.nsd_path,
})?;
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)]
@ -171,10 +216,16 @@ 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),
#[error("Failed to setup sysctl for namespace: {0:#}")]
Sysctl(#[from] CtlError),
}
impl NamespaceError {
@ -182,3 +233,27 @@ impl NamespaceError {
NamespaceError::Create { path, error }
}
}
#[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) {
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
rest => rest,
}
}
/// `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) {
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) if err.kind() == ErrorKind::ResourceBusy => Ok(()),
rest => rest,
}
}

View file

@ -4,7 +4,6 @@ use nix::sys::signal::Signal;
use nix::sys::wait::{waitpid, WaitStatus};
use std::path::PathBuf;
use thiserror::Error;
use tracing::error;
#[derive(Debug, Error)]
pub enum NamespaceSetupError {

90
src/namespace/sysctl.rs Normal file
View file

@ -0,0 +1,90 @@
use std::fmt::Display;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::io::{Error as IoError, ErrorKind, Read};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use thiserror::Error;
#[derive(Debug)]
pub struct NamespaceCtl {
use_temp_addr: u32,
temp_valid_lft: u32,
temp_preferred_lft: u32,
}
const PATH_TEMP_ADDR: &str = "/proc/sys/net/ipv6/conf/default/use_tempaddr";
const PATH_VALID_LFT: &str = "/proc/sys/net/ipv6/conf/default/temp_valid_lft";
const PATH_PREFERRED_LFT: &str = "/proc/sys/net/ipv6/conf/default/temp_prefered_lft";
impl NamespaceCtl {
pub fn read() -> Result<NamespaceCtl, CtlError> {
Ok(NamespaceCtl {
use_temp_addr: read_sysctl(PATH_TEMP_ADDR)?,
temp_valid_lft: read_sysctl(PATH_VALID_LFT)?,
temp_preferred_lft: read_sysctl(PATH_PREFERRED_LFT)?,
})
}
pub fn apply(&self) -> Result<(), CtlError> {
write_sysctl(PATH_TEMP_ADDR, self.use_temp_addr)?;
write_sysctl(PATH_VALID_LFT, self.temp_valid_lft)?;
write_sysctl(PATH_PREFERRED_LFT, self.temp_preferred_lft)?;
Ok(())
}
}
fn read_sysctl<T: FromStr, P: AsRef<Path>>(path: P) -> Result<T, CtlError>
where
<T as FromStr>::Err: Display,
{
let path = path.as_ref();
let mut buff = [0; 16];
let mut file = File::open(path).map_err(|error| CtlError::Read {
path: path.into(),
error,
})?;
let read = file.read(&mut buff).map_err(|error| CtlError::Read {
path: path.into(),
error,
})?;
let data = &buff[0..read];
let str = str::from_utf8(data)
.map_err(|_| CtlError::Read {
path: path.into(),
error: IoError::new(ErrorKind::InvalidData, "stream did not contain valid UTF-8"),
})?
.trim();
T::from_str(str).map_err(|error| CtlError::Parse {
path: path.into(),
error: error.to_string(),
})
}
fn write_sysctl<T: Display, P: AsRef<Path>>(path: P, value: T) -> Result<(), CtlError> {
let path = path.as_ref();
let mut file = OpenOptions::new()
.create(false)
.truncate(true)
.write(true)
.open(path)
.map_err(|error| CtlError::Write {
path: path.into(),
error,
})?;
writeln!(&mut file, "{value}").map_err(|error| CtlError::Write {
path: path.into(),
error,
})
}
#[derive(Debug, Error)]
pub enum CtlError {
#[error("failed to read sysctl option {}: {error:#}", path.display())]
Read { path: PathBuf, error: IoError },
#[error("failed to read sysctl option {}: {error}", path.display())]
Parse { path: PathBuf, error: String },
#[error("failed to write sysctl option {}: {error:#}", path.display())]
Write { path: PathBuf, error: IoError },
}

View file

@ -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;

View file

@ -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 {

View file

@ -1,9 +1,11 @@
use crate::config::{Config, NamespaceName};
use crate::link::{LinkError, LinkManager};
use crate::namespace::NetNs;
use main_error::MainResult;
use tracing::error;
pub fn up(config: Config) -> MainResult {
let mut namespaces = NetNs::existing()?
let mut namespaces = NetNs::existing(false)?
.map(NetNs::new)
.collect::<Result<Vec<_>, _>>()?;
@ -15,7 +17,21 @@ 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");
}
}
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>(())
})??;
}
}
@ -25,3 +41,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)
}