Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.

20 changed files with 548 additions and 1035 deletions

562
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,29 +1,28 @@
[package]
name = "netnsd"
version = "0.2.0"
version = "0.1.0"
edition = "2024"
rust-version = "1.88.0"
[dependencies]
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"
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"
serde = { version = "1.0.228", features = ["derive"] }
clap = { version = "4.5.58", features = ["derive"] }
thiserror = "2.0.18"
tracing = "0.1.44"
tracing-subscriber = "0.3.22"
clap = { version = "4.5.51", features = ["derive"] }
thiserror = "2.0.17"
tracing = "0.1.41"
tracing-subscriber = "0.3.20"
main_error = "0.1.2"
nix = { version = "0.31.1", features = ["mount", "sched", "user", "signal"] }
sd-notify = "0.5.0"
nix = { version = "0.30.1", features = ["mount", "sched", "user", "signal"] }
sd-notify = "0.4.5"
futures = "0.3.31"
futures-concurrency = "7.7.1"
neli = "0.7.4"
futures-concurrency = "7.6.3"
neli = "0.7.1"
either = "1.15.0"
uzers = "0.12.2"
sysinfo = "0.38.1"
uzers = "0.12.1"
sysinfo = "0.37.2"
landlock = "0.4.4"
cidr = { version = "0.3.2", features = ["serde"] }
[dev-dependencies]
serde_test = "1.0.177"
serde_test = "1.0.177"

View file

@ -5,11 +5,8 @@ A declarative manager for Linux network namespaces.
## Features
- Fully declarative configuration
- Standalone binary with no runtime dependencies
- Hot reloading of configuration
- Port forwarding into or out of the namespace
- Moving network devices to the namespace
- Setting up routing inside the namespace
- Port forwarding into the namespace
## Usage
@ -56,13 +53,6 @@ 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,13 +2,6 @@
[[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": 1774313767,
"narHash": "sha256-hy0XTQND6avzGEUFrJtYBBpFa/POiiaGBr2vpU6Y9tY=",
"lastModified": 1763938834,
"narHash": "sha256-j8iB0Yr4zAvQLueCZ5abxfk6fnG/SJ5JnGUziETjwfg=",
"owner": "ipetkov",
"repo": "crane",
"rev": "3d9df76e29656c679c744968b17fbaf28f0e923d",
"rev": "d9e753122e51cee64eb8d2dddfe11148f339f5a2",
"type": "github"
},
"original": {
@ -22,11 +22,11 @@
]
},
"locked": {
"lastModified": 1777298791,
"narHash": "sha256-MEQeYwRQcV7RvlKMVrFy07dmoY8t2s/SIK7EpCNLOu8=",
"lastModified": 1764593611,
"narHash": "sha256-6SdexcO69Dlu14YN2xuB1A6JHWSrcqMj7Na9oK7IT2M=",
"owner": "nix-community",
"repo": "flakelight",
"rev": "c4b125c5453559de6a228be70f71abeb017f3265",
"rev": "0d63256401341f528dd628f1a8e96d3afecade7a",
"type": "github"
},
"original": {
@ -44,11 +44,11 @@
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1776692281,
"narHash": "sha256-DCCSbpTUDqRiHsTS/sIgCTwa1r4gsJlWSFE6difdWxk=",
"lastModified": 1764619631,
"narHash": "sha256-WojMP5S9qLmOLecEQ+7+yc33Ly1ydoRsODNG6hlLqiQ=",
"ref": "refs/heads/main",
"rev": "d1d0e0bb5b0acebed06ccced9cc27f82aafab058",
"revCount": 71,
"rev": "0fae557bf52d8493840aca52d433c473ecc305ef",
"revCount": 67,
"type": "git",
"url": "https://codeberg.org/icewind/mill-scale"
},
@ -59,11 +59,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1777428379,
"narHash": "sha256-ypxFOeDz+CqADEQNL72haqGjvZQdBR5Vc7pyx2JDttI=",
"lastModified": 1764522689,
"narHash": "sha256-SqUuBFjhl/kpDiVaKLQBoD8TLD+/cTUzzgVFoaHrkqY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "755f5aa91337890c432639c60b6064bb7fe67769",
"rev": "8bb5646e0bed5dbd3ab08c7a7cc15b75ab4e1d0f",
"type": "github"
},
"original": {
@ -88,11 +88,11 @@
]
},
"locked": {
"lastModified": 1774535687,
"narHash": "sha256-dpKS/8+uB0EoI4mCrpio+xs8Xxry6ZhLLwV8VIbbfrs=",
"lastModified": 1764557621,
"narHash": "sha256-kX5PoY8hQZ80+amMQgOO9t8Tc1JZ70gYRnzaVD4AA+o=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "75900435aa883f84b038316864b3f60956681523",
"rev": "93316876c2229460a5d6f5f052766cc4cef538ce",
"type": "github"
},
"original": {

View file

@ -45,37 +45,11 @@ 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";
@ -87,7 +61,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 = [cfg.package];
environment.systemPackages = with pkgs; [cfg.package];
systemd.services.netnsd = {
reloadTriggers = [configFile];

View file

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

View file

@ -2,18 +2,13 @@ mod name;
mod source;
mod target;
pub use crate::config::name::{DeviceName, NamespaceName};
pub use crate::config::name::NamespaceName;
pub use crate::config::source::ForwardSource;
pub use crate::config::target::ForwardTarget;
use cidr::AnyIpCidr;
use serde::de::Error;
use serde::{Deserialize, Deserializer};
use std::borrow::Cow;
use serde::Deserialize;
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;
@ -85,12 +80,7 @@ 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)]
@ -101,27 +91,6 @@ 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,18 +7,8 @@ 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 = ();
@ -62,60 +52,7 @@ impl From<NamespaceName> for String {
}
}
#[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 {
impl<'de> Deserialize<'de> for NamespaceName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
@ -123,7 +60,7 @@ impl<'de> Deserialize<'de> for ValidatedName {
struct NamespaceNameVisitor;
impl Visitor<'_> for NamespaceNameVisitor {
type Value = ValidatedName;
type Value = NamespaceName;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
formatter.write_str("A valid namespace name")
@ -136,7 +73,7 @@ impl<'de> Deserialize<'de> for ValidatedName {
if !validate_name(v) {
return Err(E::invalid_value(Unexpected::Str(v), &self));
}
Ok(ValidatedName(v.into()))
Ok(NamespaceName(v.into()))
}
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
@ -146,7 +83,7 @@ impl<'de> Deserialize<'de> for ValidatedName {
if !validate_name(&v) {
return Err(E::invalid_value(Unexpected::Str(&v), &self));
}
Ok(ValidatedName(v))
Ok(NamespaceName(v))
}
}
@ -155,24 +92,20 @@ impl<'de> Deserialize<'de> for ValidatedName {
}
impl FromStr for NamespaceName {
type Err = InvalidNameError;
type Err = InvalidNamespaceNameError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if !validate_name(s) {
return Err(InvalidNameError {
name: s.into(),
kind: "namespace",
});
return Err(InvalidNamespaceNameError { name: s.into() });
}
Ok(NamespaceName(s.into()))
}
}
#[derive(Debug, Error)]
#[error("invalid name for {kind}: '{name}'")]
pub struct InvalidNameError {
#[error("invalid name for namespace: '{name}'")]
pub struct InvalidNamespaceNameError {
name: String,
kind: &'static str,
}
/// Check if a name follows the portable filename character set

View file

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

View file

@ -1,265 +1,96 @@
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, RtattrBuilder, Rtmsg};
use neli::rtnl::{IfinfomsgBuilder, RtmsgBuilder};
use neli::types::{Buffer, RtBuffer};
use neli::rtnl::Ifinfomsg;
use neli::rtnl::IfinfomsgBuilder;
use neli::utils::Groups;
use nix::libc::c_int;
use std::fmt::{Debug, Display, Formatter};
use std::os::fd::AsRawFd;
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 thiserror::Error;
use tracing::{info, instrument};
#[derive(Debug, Error)]
pub enum LinkError {
#[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),
#[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 },
}
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())
impl<T, P> From<RouterError<T, P>> for LinkError {
fn from(_value: RouterError<T, P>) -> Self {
LinkError::Netlink
}
}
pub struct LinkManager {
router: NlRouter,
}
/// 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 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(())
}
spawn(move || {
setns(ns_handle, CloneFlags::CLONE_NEWNET).map_err(LinkError::Namespace)?;
link_up(link_name)
})
.join()
.map_err(|_| LinkError::Panic)?
}
/// Set a link to UP
pub fn link_up(link_name: &str) -> Result<(), LinkError> {
let manager = LinkManager::new()?;
let link = manager.get_link(link_name)?;
manager.up(&link)
}
/// 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::<_, 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())
// 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()
.expect("rt msg")
.unwrap();
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),
)?;
}
}
}
Ok(())
}

View file

@ -5,10 +5,10 @@ use crate::proxy::proxy;
use crate::up::up;
use clap::{Parser, Subcommand};
use main_error::MainResult;
use nix::errno::Errno;
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use std::path::PathBuf;
use nix::errno::Errno;
use nix::sys::signal::{kill, Signal};
use nix::unistd::Pid;
use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind};
use tracing::{error, info, warn};
@ -94,12 +94,10 @@ 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");
}

View file

@ -1,76 +0,0 @@
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,12 +1,8 @@
mod handle;
mod raw;
mod sysctl;
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::config::NamespaceName;
use crate::link::{link_up_ns, LinkError};
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};
@ -16,19 +12,16 @@ use std::iter::empty;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::{debug, info};
use tracing::{debug, error, 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() -> 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 => {
@ -39,14 +32,12 @@ impl NetNs {
error,
}),
}?;
Ok(Either::Right(
dir.flatten()
.filter(move |entry| include_broken || entry.path().is_symlink())
.flat_map(|entry| NamespaceName::try_from(entry.file_name()).ok()),
))
Ok(Either::Right(dir.flatten().flat_map(|entry| {
NamespaceName::try_from(entry.file_name()).ok()
})))
}
/// Create a new named network namespace
/// Create a new named network namespace that will be removed when dropped
pub fn new(name: NamespaceName) -> Result<Self, NamespaceError> {
let parent = Path::new("/var/run/netns");
let nsd_parent = Path::new("/var/run/netnsd");
@ -56,34 +47,22 @@ 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)),
@ -98,12 +77,10 @@ impl NetNs {
path: nsd_path.clone(),
})?;
}
let handle = NamespaceHandle::open(&path)?;
Result::<_, NamespaceError>::Ok(NetNs {
name,
path,
nsd_path,
handle,
})
})?;
@ -156,46 +133,24 @@ impl NetNs {
}
fn setup_interfaces(&self) -> Result<(), NamespaceError> {
let ctl = NamespaceCtl::read()?;
self.handle.run_in(move || {
link_up("lo").map_err(NamespaceError::from)?;
dbg!(ctl).apply()?;
Ok::<_, NamespaceError>(())
})??;
link_up_ns(&self.path, "lo")?;
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");
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 {
umount2(&self.path, MntFlags::MNT_DETACH).map_err(NamespaceError::UnMount)?;
remove_file(&self.path).map_err(|error| NamespaceError::Delete {
error,
path: self.path,
})?;
remove_file_if_exists(&self.nsd_path).map_err(|error| NamespaceError::Delete {
remove_file(&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)]
@ -216,16 +171,10 @@ 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 {
@ -233,27 +182,3 @@ 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,6 +4,7 @@ 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 {

View file

@ -1,90 +0,0 @@
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,12 +3,7 @@ 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};
@ -18,6 +13,8 @@ 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;
@ -218,4 +215,4 @@ fn landlock(port: u16) -> Result<(), RulesetError> {
RulesetStatus::NotEnforced => error!("Not sandboxed! Please update your kernel."),
}
Ok(())
}
}

View file

@ -1,6 +1,6 @@
/// Loosely based on https://github.com/fooker/netns-proxy/blob/main/src/tcp.rs
use crate::config::{ForwardSource, ForwardTarget};
use crate::proxy::ProxyError;
use crate::config::{ForwardTarget, ForwardSource};
use crate::proxy::{ProxyError};
use futures::TryStreamExt;
use futures::stream::{AbortRegistration, Abortable};
use std::fs::{remove_file, set_permissions};
@ -56,18 +56,28 @@ 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 {
@ -84,7 +94,11 @@ async fn run_tcp(socket: TcpListener, target: SocketAddr, abort: AbortRegistrati
}
}
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,11 +1,9 @@
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(false)?
let mut namespaces = NetNs::existing()?
.map(NetNs::new)
.collect::<Result<Vec<_>, _>>()?;
@ -17,21 +15,7 @@ pub fn up(config: Config) -> MainResult {
for new in config.namespaces {
if !has_namespace(&namespaces, &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>(())
})??;
namespaces.push(NetNs::new(new.name)?);
}
}
@ -41,7 +25,3 @@ 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)
}