use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context as _, Result};
use super::isolate::{Session, relay_to_proxy, system_bwrap};
use super::{Confined, Rules};
use crate::context::path_string;
use crate::sandbox::Report;
const UNSHARE: [&str; 3] = ["--unshare-user", "--unshare-net", "--die-with-parent"];
const ROOT: [&str; 3] = ["--dev-bind", "/", "/"];
const IGNORE_INTERRUPT: &str = "trap '' INT; exec \"$0\" \"$@\"";
const TCP_PORT_ALLOWANCE: &str = "landlock.tcp-port";
#[derive(Debug, PartialEq, Eq)]
pub enum Network {
Namespace(PathBuf),
PortOnly(String),
}
pub fn network() -> Network {
decide(system_bwrap(), refused)
}
fn decide(bwrap: Option<PathBuf>, refused: impl FnOnce(&Path) -> Option<String>) -> Network {
let Some(bwrap) = bwrap else {
return Network::PortOnly(
"bubblewrap is not installed (`bwrap` in /usr/bin, /bin or /usr/local/bin)".to_owned(),
);
};
match refused(&bwrap) {
None => Network::Namespace(bwrap),
Some(why) => Network::PortOnly(format!(
"bubblewrap cannot create a network namespace here ({why}; unprivileged user \
namespaces must be allowed, and on Ubuntu 24.04 AppArmor restricts them: \
kernel.apparmor_restrict_unprivileged_userns)"
)),
}
}
fn refused(bwrap: &Path) -> Option<String> {
let out = Command::new(bwrap)
.args(UNSHARE)
.args(ROOT)
.arg("--")
.arg(bwrap)
.arg("--version")
.output();
match out {
Ok(out) if out.status.success() => None,
Ok(out) => Some(String::from_utf8_lossy(&out.stderr).trim().to_owned()),
Err(error) => Some(format!("starting {}: {error}", bwrap.display())),
}
}
pub fn port_only_notice(port: u16, why: &str) -> String {
format!(
"moat run: the network is restricted by port only: {why}, so a command in the sandbox \
can reach any host on port {port} and go around the proxy's checks and audit. With \
bubblewrap (`apt install bubblewrap`) `openmoat run` gives the agent a network of its own \
whose only exit is the proxy; `openmoat run --isolate` needs it too and also hides the \
rest of the machine"
)
}
pub fn doctor_warning() -> Option<String> {
match network() {
Network::Namespace(_) => None,
Network::PortOnly(why) => Some(format!(
"openmoat run network restricted by port only: {why}; commands could reach any \
host on the proxy's port. Install bubblewrap for a network namespace (also needed \
by `openmoat run --isolate`)"
)),
}
}
pub fn namespaced(
bwrap: &Path,
rules: &Rules,
mut report: Report,
program: &Path,
cwd: &str,
port: u16,
) -> Result<Confined> {
report.allowances.retain(|a| a.rule != TCP_PORT_ALLOWANCE);
let session = Session::create(&[])?;
relay_to_proxy(&session.socket, port)?;
let moat = std::env::current_exe()
.and_then(std::fs::canonicalize)
.context("locating the moat binary")?;
let mut command = Command::new("/bin/sh");
command.args(arguments(
bwrap,
&moat,
&session.socket,
port,
&serde_json::to_string(rules)?,
cwd,
));
command.arg(program);
Ok(Confined {
command,
report,
restrict: None,
_session: Some(session),
warning: None,
})
}
fn arguments(
bwrap: &Path,
moat: &Path,
socket: &Path,
port: u16,
rules: &str,
cwd: &str,
) -> Vec<String> {
let mut args = vec![
"-c".to_owned(),
IGNORE_INTERRUPT.to_owned(),
path_string(bwrap),
];
args.extend(UNSHARE.map(str::to_owned));
args.extend(ROOT.map(str::to_owned));
args.extend(["--chdir", cwd, "--"].map(str::to_owned));
args.extend([
path_string(moat),
"isolated".to_owned(),
"--socket".to_owned(),
]);
args.push(path_string(socket));
args.extend(["--port".to_owned(), port.to_string(), "--rules".to_owned()]);
args.extend([rules.to_owned(), "--".to_owned()]);
args
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_working_bubblewrap_gives_a_namespace() {
let bwrap = PathBuf::from("/usr/bin/bwrap");
let chosen = decide(Some(bwrap.clone()), |_| None);
assert_eq!(chosen, Network::Namespace(bwrap));
}
#[test]
fn without_bubblewrap_the_network_is_port_only_and_says_why() {
let Network::PortOnly(why) = decide(None, |_| unreachable!("nothing to probe")) else {
panic!("no bubblewrap, no namespace");
};
assert!(why.contains("bubblewrap is not installed"), "{why}");
let notice = port_only_notice(41_000, &why);
for expected in [
"restricted by port only",
"any host on port 41000",
"apt install bubblewrap",
"openmoat run --isolate",
] {
assert!(notice.contains(expected), "{expected}: {notice}");
}
}
#[test]
fn a_bubblewrap_that_cannot_make_namespaces_is_port_only_with_its_reason() {
let bwrap = PathBuf::from("/usr/bin/bwrap");
let chosen = decide(Some(bwrap), |_| {
Some("setting up uid map: Permission denied".to_owned())
});
let Network::PortOnly(why) = chosen else {
panic!("a refused namespace is not used");
};
assert!(why.contains("uid map: Permission denied"), "{why}");
assert!(
why.contains("apparmor_restrict_unprivileged_userns"),
"{why}"
);
}
#[test]
fn bubblewrap_gets_a_network_namespace_over_the_whole_root() {
let args = arguments(
Path::new("/usr/bin/bwrap"),
Path::new("/opt/moat"),
Path::new("/tmp/moat-run-1/proxy.sock"),
41_000,
"{}",
"/work/project",
);
let expected = [
"-c",
IGNORE_INTERRUPT,
"/usr/bin/bwrap",
"--unshare-user",
"--unshare-net",
"--die-with-parent",
"--dev-bind",
"/",
"/",
"--chdir",
"/work/project",
"--",
"/opt/moat",
"isolated",
"--socket",
"/tmp/moat-run-1/proxy.sock",
"--port",
"41000",
"--rules",
"{}",
"--",
];
assert_eq!(args, expected);
assert!(
!args
.iter()
.any(|a| a == "--unshare-all" || a == "--unshare-pid")
);
}
#[test]
fn the_shell_execs_bubblewrap_with_sigint_ignored() {
let out = Command::new("/bin/sh")
.args(["-c", IGNORE_INTERRUPT, "/bin/sh", "-c"])
.arg("grep '^SigIgn' /proc/self/status; echo \"$0|$1\"")
.args(["first arg", "second"])
.output()
.expect("sh runs");
let shown = String::from_utf8_lossy(&out.stdout);
assert!(shown.contains("first arg|second"), "{shown}");
let ignored = shown
.lines()
.find_map(|l| l.strip_prefix("SigIgn:"))
.and_then(|mask| u64::from_str_radix(mask.trim(), 16).ok())
.expect("the mask");
assert_ne!(ignored & (1 << (libc::SIGINT - 1)), 0, "{shown}");
}
}