use std::env;
use std::ffi::OsStr;
use std::fs::OpenOptions;
use std::path::Path;
use anyhow::{bail, Result};
use crate::cli::Cli;
use crate::network::NetworkBackend;
const ROOTFUL_REQUIRED_COMMANDS: &[&str] = &["ip", "iptables", "ip6tables"];
const ROOTLESS_INTERNAL_REQUIRED_COMMANDS: &[&str] = &["ip"];
const ROOTFUL_REQUIRED_SYSCTLS: &[&str] = &[
"/proc/sys/net/ipv4/ip_forward",
"/proc/sys/net/ipv6/conf/all/forwarding",
];
const ROOTLESS_INTERNAL_NAMESPACE_PATHS: &[&str] = &[
"/proc/self/ns/user",
"/proc/self/ns/net",
"/proc/self/ns/mnt",
];
#[derive(Default)]
struct PreflightReport {
fatal: Vec<String>,
warnings: Vec<String>,
}
impl PreflightReport {
fn push_fatal(&mut self, message: impl Into<String>) {
self.fatal.push(message.into());
}
fn push_warning(&mut self, message: impl Into<String>) {
self.warnings.push(message.into());
}
fn emit_warnings(&self) {
for warning in &self.warnings {
crate::util::warn(format!("preflight: {warning}"));
}
}
fn finish(self, backend_name: &str) -> Result<()> {
self.emit_warnings();
if self.fatal.is_empty() {
return Ok(());
}
bail!(
"preflight checks failed for the `{backend_name}` backend:\n{}",
render_issue_list(&self.fatal)
);
}
}
pub fn run(cli: &Cli) -> Result<()> {
match cli.selected_backend() {
NetworkBackend::Rootful => run_rootful_preflight(cli),
NetworkBackend::RootlessInternal => run_rootless_internal_preflight(),
}
}
fn run_rootful_preflight(cli: &Cli) -> Result<()> {
crate::util::ensure_root()?;
let path_env = env::var_os("PATH").unwrap_or_default();
let missing_commands = find_missing_commands(ROOTFUL_REQUIRED_COMMANDS, &path_env);
let unwritable_paths = find_unwritable_paths(ROOTFUL_REQUIRED_SYSCTLS);
let mut report = build_rootful_report(&missing_commands, &unwritable_paths);
if cli.proxy.is_some() {
report.push_warning(
"transparent proxy mode still depends on Linux TPROXY support (`xt_TPROXY`, `xt_socket`, policy routing, and `IP_TRANSPARENT`) during setup",
);
}
report.finish("rootful")
}
fn run_rootless_internal_preflight() -> Result<()> {
let path_env = env::var_os("PATH").unwrap_or_default();
collect_rootless_internal_report(&path_env).finish("rootless-internal")
}
fn collect_rootless_internal_report(path_env: &OsStr) -> PreflightReport {
let mut report = PreflightReport::default();
let missing_commands = find_missing_commands(ROOTLESS_INTERNAL_REQUIRED_COMMANDS, path_env);
if !missing_commands.is_empty() {
report.push_fatal(format!(
"missing required external commands for the `rootless-internal` backend: {}. Install `iproute2` so childflow can configure `tap0`, loopback, and default routes inside the child namespace.",
missing_commands.join(", ")
));
}
for issue in find_missing_paths(ROOTLESS_INTERNAL_NAMESPACE_PATHS) {
report.push_fatal(issue);
}
match parse_proc_u64("/proc/sys/user/max_user_namespaces") {
Ok(Some(0)) => report.push_fatal(
"`/proc/sys/user/max_user_namespaces` is `0`; enable user namespaces before using the `rootless-internal` backend",
),
Ok(Some(_)) => {}
Ok(None) => report.push_warning(
"`/proc/sys/user/max_user_namespaces` is unavailable in this environment; namespace availability will be determined during setup",
),
Err(err) => report.push_warning(err.to_string()),
}
if unsafe { nix::libc::geteuid() } != 0 {
match parse_proc_u64("/proc/sys/kernel/unprivileged_userns_clone") {
Ok(Some(0)) => report.push_fatal(
"`/proc/sys/kernel/unprivileged_userns_clone` is disabled; enable unprivileged user namespaces or run with sufficient privileges",
),
Ok(Some(_)) => {}
Ok(None) => report.push_warning(
"`/proc/sys/kernel/unprivileged_userns_clone` is unavailable; non-root user-namespace setup may still fail later on this host",
),
Err(err) => report.push_warning(err.to_string()),
}
}
match check_tun_device("/dev/net/tun") {
Ok(()) => {}
Err(err) => report.push_fatal(err.to_string()),
}
report
}
fn check_tun_device(path: &str) -> Result<()> {
let device = Path::new(path);
if !device.exists() {
bail!(
"`{path}` is missing; load the `tun` kernel module and ensure the device node is available for the `rootless-internal` backend"
);
}
OpenOptions::new()
.read(true)
.write(true)
.open(device)
.map(|_| ())
.map_err(|err| {
anyhow::anyhow!(
"failed to open `{path}` ({err}). Check TUN/TAP permissions, container device passthrough, and LSM policy before using the `rootless-internal` backend"
)
})
}
fn find_unwritable_paths(paths: &[&str]) -> Vec<String> {
paths
.iter()
.filter_map(|path| {
OpenOptions::new()
.write(true)
.open(path)
.err()
.map(|err| format!("{path} ({err})"))
})
.collect()
}
fn find_missing_paths(paths: &[&str]) -> Vec<String> {
paths
.iter()
.filter(|path| !Path::new(path).exists())
.map(|path| {
format!(
"`{path}` is missing; verify that this Linux environment exposes the required namespace handles for `rootless-internal`"
)
})
.collect()
}
fn build_rootful_report(
missing_commands: &[String],
unwritable_paths: &[String],
) -> PreflightReport {
let mut report = PreflightReport::default();
if !missing_commands.is_empty() {
report.push_fatal(format!(
"missing required external commands: {}. Install `iproute2` for `ip`, and install an `iptables` / `ip6tables` userspace compatible with your kernel firewall backend.",
missing_commands.join(", ")
));
}
if !unwritable_paths.is_empty() {
report.push_fatal(format!(
"required sysctl files are not writable: {}. Check root privileges, container restrictions, and whether `/proc/sys` is mounted read-write.",
unwritable_paths.join(", ")
));
}
report
}
fn render_issue_list(issues: &[String]) -> String {
issues
.iter()
.map(|issue| format!("- {issue}"))
.collect::<Vec<_>>()
.join("\n")
}
pub fn find_missing_commands(commands: &[&str], path_env: &OsStr) -> Vec<String> {
let path_entries = env::split_paths(path_env).collect::<Vec<_>>();
commands
.iter()
.filter(|command| {
!path_entries
.iter()
.any(|dir| command_exists_in_dir(dir, command))
})
.map(|command| (*command).to_string())
.collect()
}
fn parse_proc_u64(path: &str) -> Result<Option<u64>> {
if !Path::new(path).exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(path)
.map_err(|err| anyhow::anyhow!("failed to read `{path}` during preflight: {err}"))?;
let value = raw.trim().parse::<u64>().map_err(|err| {
anyhow::anyhow!("failed to parse `{path}` as an integer during preflight: {err}")
})?;
Ok(Some(value))
}
fn command_exists_in_dir(dir: &Path, command: &str) -> bool {
dir.join(command).is_file()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn find_missing_commands_reports_only_missing_entries() {
let base = PathBuf::from("/tmp/childflow-preflight-tests");
let path_env = env::join_paths([base.join("bin-a"), base.join("bin-b")]).unwrap();
assert_eq!(
find_missing_commands(&["ip", "iptables"], &path_env),
vec!["ip".to_string(), "iptables".to_string()]
);
}
#[test]
fn build_rootful_issue_messages_reports_only_real_failures() {
let report = build_rootful_report(&["ip".into()], &[]);
assert_eq!(report.fatal.len(), 1);
assert!(report.warnings.is_empty());
}
#[test]
fn render_issue_list_formats_each_entry() {
let rendered = render_issue_list(&["first".into(), "second".into()]);
assert_eq!(rendered, "- first\n- second");
}
#[test]
fn parse_proc_u64_returns_none_when_file_is_missing() {
assert_eq!(
parse_proc_u64("/tmp/childflow-preflight/definitely-missing").unwrap(),
None
);
}
#[test]
fn preflight_report_finish_succeeds_with_only_warnings() {
let mut report = PreflightReport::default();
report.push_warning("heads up");
report.finish("rootless-internal").unwrap();
}
}