Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,7 @@ path = "src/bin/bunkerbox-vscomm.rs"
[[bin]]
name = "bunkerbox-status"
path = "src/bin/bunkerbox-status.rs"

[[bin]]
name = "bunkerbox-netrelay"
path = "src/bin/bunkerbox-netrelay.rs"
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -43,25 +43,31 @@ ensure-toolchain:

dev: ensure-toolchain
cargo build --bin bunkerbox --bin bunkerbox-image
cargo build --bin bunkerbox-netrelay --target $(VSCOMM_TARGET)
cargo build --bin bunkerbox-vscomm --target $(VSCOMM_TARGET)
cargo build --bin bunkerbox-status --target $(VSCOMM_TARGET)
rm -rf target/dist
mkdir -p target/dist
cp target/debug/bunkerbox target/dist/
cp target/debug/bunkerbox-image target/dist/
cp target/$(VSCOMM_TARGET)/debug/bunkerbox-netrelay target/dist/
cp target/$(VSCOMM_TARGET)/debug/bunkerbox-vscomm target/dist/
cp target/$(VSCOMM_TARGET)/debug/bunkerbox-status target/dist/
cp target/$(VSCOMM_TARGET)/debug/bunkerbox-netrelay target/debug/bunkerbox-netrelay

release: ensure-toolchain
cargo build --bin bunkerbox --bin bunkerbox-image --release
cargo build --bin bunkerbox-netrelay --target $(VSCOMM_TARGET) --release
cargo build --bin bunkerbox-vscomm --target $(VSCOMM_TARGET) --release
cargo build --bin bunkerbox-status --target $(VSCOMM_TARGET) --release
rm -rf target/dist
mkdir -p target/dist
cp target/release/bunkerbox target/dist/
cp target/release/bunkerbox-image target/dist/
cp target/$(VSCOMM_TARGET)/release/bunkerbox-netrelay target/dist/
cp target/$(VSCOMM_TARGET)/release/bunkerbox-vscomm target/dist/
cp target/$(VSCOMM_TARGET)/release/bunkerbox-status target/dist/
cp target/$(VSCOMM_TARGET)/release/bunkerbox-netrelay target/release/bunkerbox-netrelay

check:
cargo fmt --all
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ It is built for the world where developer tools are becoming more capable, more

A tool launched through Bunkerbox sees the project workspace it needs, but not the whole host. Its application state can be persisted between runs without exposing the real user home. Its image is built ahead of time from a reproducible config. Its runtime behavior is described separately, so packaging a tool is a matter of pairing an OCI image with a small runtime config.

When the agent runs a build command like `cargo build`, that command executes on your host — but not freely. Bunkerbox wraps it in a bubblewrap sandbox that strips the environment, blocks the network, and exposes only the tools and directories declared in a sandbox profile. The agent can compile your code. It cannot read your SSH keys, curl a payload, or peek at host processes.
When the agent runs a build command like `cargo build`, that command executes on your host — but not freely. Bunkerbox wraps it in a bubblewrap sandbox that strips the environment, isolates direct networking, and exposes only the tools and directories declared in a sandbox profile. When a network allowlist is configured, HTTP(S) access is available only through the kernel-enforced proxy path; raw sockets and ignored proxy variables cannot bypass it. The agent can compile your code. It cannot read your SSH keys, curl a payload, or peek at host processes.

The result is a workflow where tools still feel like normal commands, but run with a stronger boundary around them.

Expand Down
10 changes: 6 additions & 4 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,12 @@ active, they merge: the union of all binaries and paths is available to the
sandboxed command.

Inside the sandbox, the command sees a scratch `/home`, an empty `/tmp`, its
own `/proc`, no network, and only the binaries and paths you explicitly
allowed. Home-relative cache paths are deliberate writable carryover paths;
profile declarations are trusted host policy, not a complete rogue-process
capability model.
own `/proc`, no direct network access, and only the binaries and paths you
explicitly allowed. When an allowlist is active, mediated HTTP(S) proxying
is available through the kernel-enforced `--unshare-net` namespace; raw
sockets cannot bypass it. Home-relative cache paths are deliberate writable
carryover paths; profile declarations are trusted host policy, not a complete
rogue-process capability model.

See the [Profiles guide](guides/profiles.md) for the full reference.

Expand Down
41 changes: 37 additions & 4 deletions docs/guides/passthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,43 @@ spawning it.
Bubblewrap uses Linux user namespaces to build a thin, unprivileged container
around the command. The daemon reads the profile and translates it into
filesystem and network boundaries: only the binaries you allowed are visible,
only the directories you declared are accessible, and the network is blocked
unless you opened it. The command gets a clean environment and a scratch home
directory — it cannot read your SSH keys, your AWS tokens, or anything else on
your host.
only the directories you declared are accessible. The command gets a clean
environment, a scratch home directory, and its own `/proc` and `/dev`. It
cannot read your SSH keys, your AWS tokens, or anything else on your
host.

**Network isolation.** Every profiled passthrough command runs with
`--unshare-net`. The sandbox has no direct host or Internet networking.
Raw `socket()` / `connect()` calls cannot reach any IP destination.

When a runtime `allow` list is configured, a mediated HTTP proxy path is
available:

```text
target inside bwrap
→ TCP 127.0.0.1:20000
→ bunkerbox-netrelay
→ mounted AF_UNIX socket
→ host FilterProxy
→ resolved and validated destination
```

`bunkerbox-netrelay` is a static helper that listens on the isolated bwrap
loopback and forwards every connection to the host FilterProxy through a
mounted pathname Unix socket. FilterProxy enforces the hostname allowlist,
resolves DNS once, validates every concrete destination address against the
address policy, and connects only to allowed public destinations.

`HTTP_PROXY` and `HTTPS_PROXY` environment variables are compatibility
hints for well-behaved tools. They are **not** the security boundary.
Ignoring proxy environment variables does not restore direct network access.
The kernel network-namespace isolation (`--unshare-net`) is the boundary.

Without an allowlist, no proxy or relay infrastructure is created and the
sandboxed command has no network access at all.

No iptables, veth pairs, or root networking machinery is required for
this path.

When profiles are empty (the default), passthrough commands run directly on
the host with no sandbox wrapping.
Expand Down
6 changes: 5 additions & 1 deletion docs/guides/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,11 @@ etc.). `${HOME}` expands to `/home`; `${USER}` and `${TERM}` use the host
runtime values when present.

**`network`** — currently only `none` is supported. The sandboxed command has
no network access.
no direct network access. When a runtime `allow` list is configured, mediated
HTTP access is available through `bunkerbox-netrelay` and the host FilterProxy
via a mounted AF_UNIX socket. This path is kernel-enforced through the
`--unshare-net` namespace; ignoring `HTTP_PROXY` does not restore direct
network access.

**`shell`** — the absolute path to the shell used when the command specifies
`/bin/sh` as its interpreter. Defaults to `/bin/sh`.
Expand Down
63 changes: 63 additions & 0 deletions src/bin/bunkerbox-netrelay.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
use bunkerbox::netrelay::{bind_relay_listener, relay};
use std::env;
use std::os::unix::process::ExitStatusExt;
use std::path::PathBuf;

fn main() {
let result = run();
if let Err(err) = result {
eprintln!("bunkerbox-netrelay: {err}");
std::process::exit(1);
}
}

fn run() -> Result<(), String> {
let args: Vec<String> = env::args().collect();

let mut socket_path: Option<PathBuf> = None;
let mut target_start = None;

let mut i = 1;
while i < args.len() {
if args[i] == "--socket" && i + 1 < args.len() {
socket_path = Some(PathBuf::from(args[i + 1].clone()));
i += 2;
} else if args[i] == "--" {
target_start = Some(i + 1);
break;
} else {
i += 1;
}
}

let socket_path = socket_path.ok_or_else(|| {
eprintln!("usage: bunkerbox-netrelay --socket <PATH> -- <COMMAND> [ARGS...]");
"missing --socket".to_string()
})?;

let start_idx = target_start.ok_or_else(|| {
eprintln!("usage: bunkerbox-netrelay --socket <PATH> -- <COMMAND> [ARGS...]");
"missing -- separator".to_string()
})?;

let target_args: Vec<String> = args[start_idx..].to_vec();
if target_args.is_empty() {
eprintln!("usage: bunkerbox-netrelay --socket <PATH> -- <COMMAND> [ARGS...]");
return Err("no target command".to_string());
}

let rt = tokio::runtime::Runtime::new().map_err(|e| format!("tokio: {e}"))?;
let _guard = rt.enter();

let listener = rt.block_on(bind_relay_listener())?;

let status = rt.block_on(relay(listener, socket_path, &target_args))?;

match status.code() {
Some(code) => std::process::exit(code),
None => {
let sig = status.signal().unwrap_or(1);
std::process::exit(128i32.wrapping_add(sig));
}
}
}
113 changes: 93 additions & 20 deletions src/daemon.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
use crate::cfg::EnvMode;
use crate::logging;
use crate::proxy::FilterProxy;
use crate::proxy::{FilterProxy, UnixProxyHandle};
use crate::sandbox::{resolve_profile, MergedProfile, NetworkMode};
use crate::vscomm::{validate_exec_request, validate_process_path, validate_process_string, ExecRequest, Frame, FrameType, TOOLCHAIN_PORT};
use rand::Rng;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::os::fd::{AsRawFd, FromRawFd, RawFd};
use std::os::unix::fs::DirBuilderExt;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
Expand All @@ -21,18 +23,25 @@ enum ChildEvent {
LauncherFailed(String),
}

#[derive(Debug)]
struct SandboxProxyConfig {
socket_path: PathBuf,
netrelay_path: PathBuf,
}

struct VsockSession {
passthrough: Arc<Vec<String>>,
env_mode: EnvMode,
workspace: PathBuf,
merged_profile: Option<Arc<MergedProfile>>,
has_proxy: bool,
proxy_config: Option<Arc<SandboxProxyConfig>>,
}

pub struct VsockDaemon {
join_handle: tokio::task::JoinHandle<()>,
shutdown: tokio::sync::oneshot::Sender<()>,
proxy_handle: Option<tokio::task::JoinHandle<()>>,
sandbox_proxy: Option<UnixProxyHandle>,
sandbox_proxy_dir: Option<PathBuf>,
}

impl VsockDaemon {
Expand All @@ -56,21 +65,43 @@ impl VsockDaemon {
Some(Arc::new(merged))
};

let has_proxy = !allow.is_empty();
let proxy_handle = if has_proxy {
let mut sandbox_proxy: Option<UnixProxyHandle> = None;
let mut sandbox_proxy_dir: Option<PathBuf> = None;
let mut proxy_config: Option<SandboxProxyConfig> = None;

if merged_profile.is_some() && !allow.is_empty() {
let rt = tokio::runtime::Handle::current();
Some(rt.block_on(async {
let proxy = FilterProxy::new(allow);
proxy.bind().await
})?)
} else {
None
};

let session = Arc::new(VsockSession { passthrough: Arc::new(passthrough), env_mode, workspace, merged_profile, has_proxy });
let netrelay_path = find_netrelay_binary()?;

let dir = make_proxy_runtime_dir()?;
sandbox_proxy_dir = Some(dir.clone());

let listener = tokio_vsock::VsockListener::bind(tokio_vsock::VsockAddr::new(libc::VMADDR_CID_ANY, TOOLCHAIN_PORT))
.map_err(|e| format!("failed to bind toolchain vsock port {TOOLCHAIN_PORT}: {e}"))?;
let socket_path = dir.join("proxy.sock");
sandbox_proxy = Some(rt.block_on(FilterProxy::new(allow).bind_unix(&socket_path)).inspect_err(|_| {
let _ = std::fs::remove_dir(&dir);
})?);

proxy_config = Some(SandboxProxyConfig { socket_path, netrelay_path });
}

let session = Arc::new(VsockSession {
passthrough: Arc::new(passthrough),
env_mode,
workspace,
merged_profile,
proxy_config: proxy_config.map(Arc::new),
});

let listener = tokio_vsock::VsockListener::bind(tokio_vsock::VsockAddr::new(libc::VMADDR_CID_ANY, TOOLCHAIN_PORT)).map_err(|e| {
if let Some(h) = sandbox_proxy.take() {
h.stop();
}
if let Some(d) = sandbox_proxy_dir.take() {
let _ = std::fs::remove_dir_all(&d);
}
format!("failed to bind toolchain vsock port {TOOLCHAIN_PORT}: {e}")
})?;

let join_handle = tokio::spawn(async move {
let result = daemon_loop(session, listener, shutdown_rx).await;
Expand All @@ -79,14 +110,17 @@ impl VsockDaemon {
}
});

Ok(Self { join_handle, shutdown: shutdown_tx, proxy_handle })
Ok(Self { join_handle, shutdown: shutdown_tx, sandbox_proxy, sandbox_proxy_dir })
}

pub async fn shutdown(self) {
let _ = self.shutdown.send(());
let _ = self.join_handle.await;
if let Some(handle) = self.proxy_handle {
handle.abort();
if let Some(h) = self.sandbox_proxy {
h.stop();
}
if let Some(d) = self.sandbox_proxy_dir {
let _ = std::fs::remove_dir_all(&d);
}
}
}
Expand Down Expand Up @@ -274,7 +308,7 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san
cmd.arg("--ro-bind").arg(&resolved).arg("/bin/sh");
}

if !session.has_proxy && matches!(merged.network, NetworkMode::None) {
if matches!(merged.network, NetworkMode::None) {
cmd.arg("--unshare-net");
}

Expand All @@ -296,7 +330,13 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san
cmd.arg("--setenv").arg("PATH").arg("/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin");
cmd.arg("--setenv").arg("HOME").arg("/home");

if session.has_proxy {
if let Some(ref cfg) = session.proxy_config {
cmd.arg("--dir").arg("/run/bunkerbox");
cmd.arg("--ro-bind").arg(&cfg.netrelay_path).arg("/run/bunkerbox/netrelay");
cmd.arg("--ro-bind").arg(&cfg.socket_path).arg("/run/bunkerbox/proxy.sock");
}

if session.proxy_config.is_some() {
let proxy_url = "http://127.0.0.1:20000";
cmd.arg("--setenv").arg("HTTP_PROXY").arg(proxy_url);
cmd.arg("--setenv").arg("HTTPS_PROXY").arg(proxy_url);
Expand Down Expand Up @@ -328,6 +368,12 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san

cmd.arg("--json-status-fd").arg(BWRAP_STATUS_FD.to_string());
cmd.arg("--");
if session.proxy_config.is_some() {
cmd.arg("/run/bunkerbox/netrelay");
cmd.arg("--socket");
cmd.arg("/run/bunkerbox/proxy.sock");
cmd.arg("--");
}
cmd.arg(&req.command);
for arg in &req.args {
cmd.arg(arg);
Expand Down Expand Up @@ -516,6 +562,33 @@ async fn write_frame<W: AsyncWriteExt + Unpin>(writer: &mut W, frame: &Frame) ->
Ok(())
}

fn find_netrelay_binary() -> Result<PathBuf, String> {
let exe = std::env::current_exe().map_err(|e| format!("locate self: {e}"))?;
let dir = exe.parent().ok_or("no binary directory")?;
let sibling = dir.join("bunkerbox-netrelay");
if sibling.is_file() {
return Ok(sibling);
}
Err("bunkerbox-netrelay not found. Run: make dev".into())
}

fn make_proxy_runtime_dir() -> Result<PathBuf, String> {
let mut rng = rand::thread_rng();
let base = std::env::temp_dir();
for _ in 0..10 {
let random: u32 = rng.gen();
let path = base.join(format!("bunkerbox-daemon-{}-{:08x}", std::process::id(), random));
let mut builder = std::fs::DirBuilder::new();
builder.mode(0o700);
match builder.create(&path) {
Ok(()) => return Ok(path),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(format!("mkdir {}: {e}", path.display())),
}
}
Err("failed to create exclusive proxy runtime directory after 10 attempts".to_string())
}

#[cfg(test)]
#[path = "daemon_ut.rs"]
mod daemon_tests;
Loading
Loading