use ntk_netlink::{
KernelCapabilities, Netlink, NetlinkError, TopologyQuery, UnsupportedKernel, probe_capabilities,
};
pub async fn check<K: Netlink>(kernel: &K) -> Result<KernelCapabilities, PreflightError> {
let capabilities = match probe_capabilities(kernel).await {
Ok(capabilities) => capabilities,
Err(error) => return Err(PreflightError::PermissionDenied(error.to_string())),
};
capabilities.ensure_supported()?;
Ok(capabilities)
}
pub async fn check_nics<K: TopologyQuery>(kernel: &K, nics: &[String]) -> Result<(), MissingNics> {
if nics.is_empty() {
return Ok(());
}
let links = kernel.list_links().await.map_err(MissingNics::Query)?;
let mut available: Vec<String> = links.into_iter().map(|link| link.name).collect();
available.sort();
available.dedup();
let mut missing: Vec<String> = nics
.iter()
.filter(|nic| !available.contains(nic))
.cloned()
.collect();
missing.sort();
missing.dedup();
if missing.is_empty() {
Ok(())
} else {
Err(MissingNics::NotFound { missing, available })
}
}
fn netsukuku_address_space() -> ntk_netlink::Ipv4Net {
ntk_netlink::Ipv4Net::new(std::net::Ipv4Addr::new(10, 0, 0, 0), 8)
.expect("10.0.0.0/8 is a valid network")
}
pub async fn warn_address_space_conflicts<K: ntk_netlink::AddressTable>(kernel: &K) {
let addresses = match kernel.list_addresses(None).await {
Ok(addresses) => addresses,
Err(err) => {
tracing::debug!(%err, "preflight: could not list host addresses to check for 10.0.0.0/8 overlap");
return;
}
};
let space = netsukuku_address_space();
let conflicting: Vec<String> = addresses
.iter()
.filter(|entry| space.contains(entry.network.address()))
.map(|entry| format!("{} (ifindex {})", entry.network, entry.interface_index))
.collect();
if !conflicting.is_empty() {
tracing::warn!(
conflicts = %conflicting.join(", "),
"preflight: this host already uses addresses inside 10.0.0.0/8, the range Netsukuku \
routes in — ntkd will install g-node routes that may collide with them (common \
causes: Docker, WireGuard, Tailscale, corporate VPNs). Starting anyway; verify the \
ranges do not overlap"
);
}
}
#[derive(Debug)]
pub enum MissingNics {
Query(NetlinkError),
NotFound {
missing: Vec<String>,
available: Vec<String>,
},
}
impl std::fmt::Display for MissingNics {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Query(err) => write!(f, "failed to list kernel interfaces: {err}"),
Self::NotFound { missing, available } => {
let (noun, verb) = if missing.len() == 1 {
("interface", "does")
} else {
("interfaces", "do")
};
let missing_list = missing
.iter()
.map(|name| format!("{name:?}"))
.collect::<Vec<_>>()
.join(", ");
write!(
f,
"configured {noun} {missing_list} {verb} not exist; available interfaces: \
{} — fix `nics` in the ntkd config",
available.join(", ")
)
}
}
}
}
impl std::error::Error for MissingNics {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Query(err) => Some(err),
Self::NotFound { .. } => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PreflightError {
Unsupported(UnsupportedKernel),
PermissionDenied(String),
}
impl std::fmt::Display for PreflightError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unsupported(unsupported) => {
write!(f, "{unsupported}; enable {}", Self::remedy(unsupported))
}
Self::PermissionDenied(detail) => write!(
f,
"not permitted to program kernel routing ({detail}); run ntkd as root or grant \
it CAP_NET_ADMIN (and CAP_NET_BIND_SERVICE for privileged ports)"
),
}
}
}
impl std::error::Error for PreflightError {}
impl From<UnsupportedKernel> for PreflightError {
fn from(unsupported: UnsupportedKernel) -> Self {
Self::Unsupported(unsupported)
}
}
impl PreflightError {
fn remedy(unsupported: &UnsupportedKernel) -> String {
let mut remedies = Vec::new();
if unsupported.missing_multiple_routing_tables {
remedies.push(
"CONFIG_IP_MULTIPLE_TABLES in the running kernel (rebuild/reboot into a kernel \
built with it; most distro kernels already enable it)",
);
}
if unsupported.missing_multipath_routes {
remedies.push(
"IP_ROUTE_MULTIPATH support (CONFIG_IP_ROUTE_MULTIPATH at build time; if already \
built in, check `sysctl net.ipv4.fib_multipath_hash_policy` exists)",
);
}
remedies.join(" and ")
}
}
#[cfg(test)]
mod tests {
use super::*;
use ntk_netlink::{AddressTable, FakeNetlink, LinkInfo};
#[tokio::test]
async fn fake_netlink_with_loopback_passes_preflight() {
let kernel = FakeNetlink::with_links(vec![LinkInfo {
index: 1,
name: "lo".into(),
is_up: true,
}]);
check(&kernel)
.await
.expect("FakeNetlink with lo supports both features");
}
#[tokio::test]
async fn fake_netlink_without_loopback_fails_preflight_with_actionable_message() {
let kernel = FakeNetlink::new();
let err = check(&kernel)
.await
.expect_err("no lo means no multipath probe");
assert!(err.to_string().contains("IP_ROUTE_MULTIPATH"));
}
#[tokio::test]
async fn permission_denied_probe_asks_for_cap_net_admin_not_a_kernel_rebuild() {
let kernel = FakeNetlink::with_links(vec![LinkInfo {
index: 1,
name: "lo".into(),
is_up: true,
}]);
kernel.arm_route_failure(
ntk_netlink::Ipv4Net::new(std::net::Ipv4Addr::new(127, 255, 255, 0), 24)
.expect("valid network"),
NetlinkError::PermissionDenied("Operation not permitted".into()),
);
let err = check(&kernel).await.expect_err("probe was refused");
assert!(matches!(err, PreflightError::PermissionDenied(_)));
let message = err.to_string();
assert!(message.contains("CAP_NET_ADMIN"));
assert!(!message.contains("rebuild"));
}
#[test]
fn the_netsukuku_address_space_covers_ten_slash_eight_and_nothing_outside_it() {
let space = netsukuku_address_space();
assert!(space.contains(std::net::Ipv4Addr::new(10, 0, 0, 1)));
assert!(space.contains(std::net::Ipv4Addr::new(10, 255, 255, 254)));
assert!(!space.contains(std::net::Ipv4Addr::new(192, 168, 1, 1)));
assert!(!space.contains(std::net::Ipv4Addr::new(100, 64, 0, 1)));
}
#[tokio::test]
async fn an_address_outside_ten_slash_eight_is_not_reported_as_a_conflict() {
let kernel = FakeNetlink::with_links(vec![LinkInfo {
index: 1,
name: "eth0".into(),
is_up: true,
}]);
kernel
.add_address(
&ntk_netlink::Interface::index(1),
ntk_netlink::Ipv4Net::new(std::net::Ipv4Addr::new(192, 168, 1, 10), 24)
.expect("valid network"),
)
.await
.expect("add a non-conflicting address");
warn_address_space_conflicts(&kernel).await;
}
#[tokio::test]
async fn an_address_inside_ten_slash_eight_still_lets_startup_proceed() {
let kernel = FakeNetlink::with_links(vec![LinkInfo {
index: 1,
name: "docker0".into(),
is_up: true,
}]);
kernel
.add_address(
&ntk_netlink::Interface::index(1),
ntk_netlink::Ipv4Net::new(std::net::Ipv4Addr::new(10, 42, 0, 1), 16)
.expect("valid network"),
)
.await
.expect("add a conflicting address");
warn_address_space_conflicts(&kernel).await;
}
#[tokio::test]
async fn existing_configured_nic_passes_preflight() {
let kernel = FakeNetlink::with_links(vec![LinkInfo {
index: 1,
name: "enp0s31f6".into(),
is_up: true,
}]);
check_nics(&kernel, &["enp0s31f6".to_string()])
.await
.expect("configured nic exists in the link table");
}
#[tokio::test]
async fn missing_configured_nic_names_itself_and_the_available_interfaces() {
let kernel = FakeNetlink::with_links(vec![LinkInfo {
index: 1,
name: "enp0s31f6".into(),
is_up: true,
}]);
let err = check_nics(&kernel, &["eth0".to_string()])
.await
.expect_err("eth0 is not in the link table");
let message = err.to_string();
assert!(message.contains("eth0"));
assert!(message.contains("enp0s31f6"));
}
}