diff --git a/docs/TESTS.md b/docs/TESTS.md index 2b31958..1e9a6c3 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -2,7 +2,10 @@ ## Integration tests -Run with `cargo test -p vuinputd-tests --features "requires-root requires-uinput"`. +Install bubblewrap: +`apt-get install bubblewrap`. + +Run with `cargo test -p vuinputd-tests --features "requires-root requires-uinput requires-bwrap"`. ## Manual end-to-end tests diff --git a/vuinputd-tests/Cargo.toml b/vuinputd-tests/Cargo.toml index a325378..70ae4bc 100644 --- a/vuinputd-tests/Cargo.toml +++ b/vuinputd-tests/Cargo.toml @@ -6,12 +6,16 @@ edition = "2021" [[bin]] name = "keyboard-in-container" +[[bin]] +name = "bwrap-ipc" + [dependencies] uinput-ioctls = { path = "../uinput-ioctls" } -nix = { version = "0.30", features = ["ioctl"] } # ioctl & libc bindings +nix = { version = "0.30", features = ["ioctl","socket"] } # ioctl & libc bindings libc = "0.2" # raw system calls libudev = "0.3" # enumerate-udev [features] requires-root = [] -requires-uinput = [] \ No newline at end of file +requires-uinput = [] +requires-bwrap = [] \ No newline at end of file diff --git a/vuinputd-tests/src/bin/bwrap-ipc.rs b/vuinputd-tests/src/bin/bwrap-ipc.rs new file mode 100644 index 0000000..f1f2389 --- /dev/null +++ b/vuinputd-tests/src/bin/bwrap-ipc.rs @@ -0,0 +1,21 @@ +use core::panic; +use std::{str::from_utf8_unchecked, time::Duration}; + +use vuinputd_tests::bwrap::SandboxChildIpc; + +fn main() { + println!("starting bwrap-ipc"); + let ipc = unsafe { SandboxChildIpc::from_fd() }; + + let incoming = ipc + .recv(Some(Duration::from_secs(5))) + .expect("error receiving input from ipc as child within 5 seconds"); + let incoming_str = + str::from_utf8(&incoming).expect("message received from ipc is not encoded as utf8"); + if incoming_str == "continue" { + ipc.send(b"ok").unwrap(); + } else { + ipc.send(b"nok").unwrap(); + panic!("expected ipc message to be 'continue'"); + } +} diff --git a/vuinputd-tests/src/bwrap.rs b/vuinputd-tests/src/bwrap.rs new file mode 100644 index 0000000..f6feb83 --- /dev/null +++ b/vuinputd-tests/src/bwrap.rs @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::io; +use std::os::fd::{FromRawFd, IntoRawFd, RawFd}; +use std::os::unix::net::UnixDatagram; +use std::os::unix::process::CommandExt; +use std::process::{Command, Output}; +use std::time::Duration; + +use nix::errno::Errno; +use nix::sys::socket::{socketpair, AddressFamily, SockFlag, SockType}; +use nix::unistd::close; + +/// Check if bubblewrap is available. +pub fn bwrap_available() -> bool { + Command::new("bwrap") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// IPC handle kept by the parent. +pub struct SandboxIpc { + sock: UnixDatagram, +} + +impl SandboxIpc { + pub fn recv(&self, read_timeout: Option) -> io::Result> { + let mut buf = vec![0u8; 4096]; + self.sock.set_read_timeout(read_timeout)?; + let n = self.sock.recv(&mut buf)?; + buf.truncate(n); + Ok(buf) + } + + pub fn send(&self, data: &[u8]) -> io::Result<()> { + self.sock.send(data)?; + Ok(()) + } +} + +/// IPC handle inside the container. +pub struct SandboxChildIpc { + sock: UnixDatagram, +} + +impl SandboxChildIpc { + /// FD number is fixed and known. + pub const FD: RawFd = 3; + + /// # Safety + /// Must only be called once in the child. + pub unsafe fn from_fd() -> Self { + let sock = UnixDatagram::from_raw_fd(Self::FD); + Self { sock } + } + + pub fn send(&self, data: &[u8]) -> io::Result<()> { + self.sock.send(data)?; + Ok(()) + } + + pub fn recv(&self, read_timeout: Option) -> io::Result> { + let mut buf = vec![0u8; 4096]; + self.sock.set_read_timeout(read_timeout)?; + let n = self.sock.recv(&mut buf)?; + buf.truncate(n); + Ok(buf) + } +} + +/// Builder for bubblewrap invocations. +#[derive(Default)] +pub struct BwrapBuilder { + args: Vec, + ipc_child_fd: Option, +} + +impl BwrapBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn unshare_all(mut self) -> Self { + self.args.push("--unshare-all".into()); + self + } + + pub fn unshare_net(mut self) -> Self { + self.args.push("--unshare-net".into()); + self + } + + pub fn proc(mut self) -> Self { + self.args.push("--proc".into()); + self.args.push("/proc".into()); + self + } + + pub fn tmpfs(mut self, path: &str) -> Self { + self.args.push("--tmpfs".into()); + self.args.push(path.into()); + self + } + + // https://superuser.com/questions/1577262/bwrap-execvp-no-such-file-or-directory-when-ro-binding-non-root-path + pub fn ro_bind(mut self, src: &str, dst: &str) -> Self { + self.args + .extend(["--ro-bind".into(), src.into(), dst.into()]); + self + } + + pub fn bind(mut self, src: &str, dst: &str) -> Self { + self.args.extend(["--bind".into(), src.into(), dst.into()]); + self + } + + /// Ensure the container dies if the parent dies. + /// + /// This uses bwrap's `--die-with-parent` flag, which internally + /// uses a parent-death signal (PR_SET_PDEATHSIG). + pub fn die_with_parent(mut self) -> Self { + self.args.push("--die-with-parent".into()); + self + } + + /// Enable bidirectional IPC using a Unix seqpacket socketpair. + pub fn with_ipc(mut self) -> io::Result<(Self, SandboxIpc)> { + let (parent, child) = socketpair( + AddressFamily::Unix, + SockType::SeqPacket, + None, + SockFlag::empty(), + ) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + + // Parent side + let parent_sock = unsafe { UnixDatagram::from_raw_fd(parent.into_raw_fd()) }; + + // Child side must become FD 3 inside container + self.ipc_child_fd = Some(child.into_raw_fd()); + + Ok((self, SandboxIpc { sock: parent_sock })) + } + + /// Final command executed inside the container. + pub fn command(mut self, cmd: &str) -> Self { + //self.args.push("--".into()); + self.args.push(cmd.into()); + self + } + + pub fn run(mut self) -> io::Result { + println!("Arguments for bwrap: {:?}", &self.args); + + let mut cmd = Command::new("bwrap"); + + if let Some(fd) = self.ipc_child_fd.take() { + // Move child FD to 3. Note that the FD 3 needs to be linked at the + // beginning of the child program. + unsafe { + cmd.pre_exec(move || { + let res = libc::dup2(fd, SandboxChildIpc::FD); + Errno::result(res) + .map(drop) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + close(fd).ok(); + Ok(()) + }) + }; + } + + cmd.args(&self.args).output() + } +} + +#[cfg(feature = "requires-bwrap")] +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bwrap_works() { + if !bwrap_available() { + panic!("bwrap not available"); + } + + let out = BwrapBuilder::new() + .unshare_net() + //.proc() + .ro_bind("/", "/") + .tmpfs("/tmp") + .die_with_parent() + .command("/usr/bin/sh") + .run() + .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); + + assert!(out.status.success()); + } +} diff --git a/vuinputd-tests/src/lib.rs b/vuinputd-tests/src/lib.rs new file mode 100644 index 0000000..238911a --- /dev/null +++ b/vuinputd-tests/src/lib.rs @@ -0,0 +1 @@ +pub mod bwrap; diff --git a/vuinputd-tests/tests/integration_tests.rs b/vuinputd-tests/tests/integration_tests.rs index f547794..06de7be 100644 --- a/vuinputd-tests/tests/integration_tests.rs +++ b/vuinputd-tests/tests/integration_tests.rs @@ -1,4 +1,55 @@ -use std::process::Command; +// SPDX-License-Identifier: MIT +// +// Author: Johannes Leupolz + +use std::{process::Command, time::Duration}; +use vuinputd_tests::bwrap; + +#[cfg(all(feature = "requires-root", feature = "requires-bwrap"))] +#[test] +fn test_bwrap_simple() { + let out = bwrap::BwrapBuilder::new() + .unshare_all() + .ro_bind("/", "/") + .tmpfs("/tmp") + .die_with_parent() + .command("ls /") + .run() + .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); +} + +#[cfg(all(feature = "requires-root", feature = "requires-bwrap"))] +#[ignore] +#[test] +fn test_bwrap_ipc() { + let bwrap_ipc = env!("CARGO_BIN_EXE_bwrap-ipc"); + + let (builder, ipc) = bwrap::BwrapBuilder::new() + .unshare_all() + .ro_bind("/", "/") + .tmpfs("/tmp") + .die_with_parent() + .with_ipc() + .expect("failed to create IPC"); + + let out = builder + .command(bwrap_ipc) + .run() + .unwrap_or_else(|e| panic!("failed to run bwrap!: {e}")); + ipc.send("continue".as_bytes()) + .unwrap_or_else(|e| panic!("failed to send data via ipc: {e}")); + + ipc.recv(Some(Duration::from_secs(5))) + .expect("error receiving input from ipc as host within 5 seconds"); + + println!("Output"); + println!("stdout: {}", str::from_utf8(&out.stdout).unwrap()); + println!("stderr: {}", str::from_utf8(&out.stderr).unwrap()); +} #[cfg(all(feature = "requires-root", feature = "requires-uinput"))] #[test]