Skip to content

Commit b77b60b

Browse files
authored
Two podman driver fixes (#1077)
* fix(driver-podman): use explicit cap_drop instead of cap_drop:ALL cap_drop:ALL removes every capability in Podman's default set — including CAP_DAC_OVERRIDE, CAP_CHOWN, CAP_FOWNER, CAP_SETUID, CAP_SETGID — and the original cap_add list failed to re-add them which we need. We also don't need to add CAP_SETUID and CAP_SETGID - those are defaults. Signed-off-by: Colin Walters <walters@verbum.org> * fix(e2e): fix gateway health check and SSH port in e2e-podman.sh Two bugs in the Podman e2e gateway startup: 1. Health check: curl probed /healthz on the main gRPC port, which always returns 404 because the health router is only served on --health-port (a separate listener, disabled by default). Allocate a second free port and pass --health-port to the gateway; health check now probes that port. 2. SSH port: --ssh-gateway-port was not set, so the gateway always told clients to connect SSH to port 8080 regardless of the actual port. Explicitly pass --ssh-gateway-port ${PORT} to fix sandbox exec/SSH. Signed-off-by: Colin Walters <walters@verbum.org> --------- Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 8bfd3e1 commit b77b60b

2 files changed

Lines changed: 95 additions & 37 deletions

File tree

‎crates/openshell-driver-podman/src/container.rs‎

Lines changed: 87 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -377,36 +377,61 @@ pub fn build_container_spec(sandbox: &DriverSandbox, config: &PodmanComputeConfi
377377
// proxy, and configure Landlock/seccomp. This matches the K8s
378378
// driver's runAsUser: 0.
379379
user: "0:0".into(),
380-
// The sandbox supervisor needs these capabilities during startup:
381-
// SYS_ADMIN – seccomp filter installation, namespace creation, Landlock
382-
// NET_ADMIN – network namespace veth setup, IP/route configuration
383-
// SYS_PTRACE – reading /proc/<pid>/exe and ancestor walk for policy
384-
// SYSLOG – reading /dev/kmsg for bypass-detection diagnostics
385-
// SETUID – drop_privileges(): setuid() to the sandbox user
386-
// SETGID – drop_privileges(): setgid() + initgroups() to the sandbox group
387-
// DAC_READ_SEARCH – reading /proc/<pid>/fd/ across UIDs for process
388-
// identity resolution. In rootless Podman the supervisor
389-
// runs as UID 0 inside a user namespace while sandbox
390-
// processes run as the sandbox user. The kernel's
391-
// proc_fd_permission() calls generic_permission() which
392-
// denies cross-UID access to the dr-x------ fd directory
393-
// unless CAP_DAC_READ_SEARCH is present. Without it the
394-
// proxy cannot determine which binary made each outbound
395-
// connection and all traffic is denied.
396-
// SETUID/SETGID are needed in rootless Podman: cap_drop:ALL removes them from
397-
// the bounding set even though uid=0 owns the user namespace. Without them,
398-
// setuid/setgid fail EPERM and the supervisor cannot drop to the sandbox user.
399-
cap_drop: vec!["ALL".into()],
380+
// Podman's default container capability set is already restricted:
381+
// CHOWN DAC_OVERRIDE FOWNER FSETID KILL SETGID SETUID SETPCAP
382+
// NET_BIND_SERVICE SYS_CHROOT SETFCAP
383+
// We add what the supervisor needs and drop what it doesn't.
384+
cap_drop: vec![
385+
// Not needed: standard file permission bits are sufficient; dropping
386+
// prevents the supervisor from bypassing DAC checks it shouldn't need.
387+
"DAC_OVERRIDE".into(),
388+
// Not needed: the supervisor does not create setuid/setgid executables.
389+
"FSETID".into(),
390+
// Not needed: the supervisor does not send signals to arbitrary processes.
391+
"KILL".into(),
392+
// Not needed: the supervisor does not bind privileged ports (<1024).
393+
"NET_BIND_SERVICE".into(),
394+
// Not in Podman's default set but explicitly denied in case the image
395+
// or runtime adds it; raw sockets are not required.
396+
"NET_RAW".into(),
397+
// Not needed: the supervisor does not manipulate file capabilities.
398+
"SETFCAP".into(),
399+
// Not needed: the supervisor does not manage its own capability bounding set.
400+
"SETPCAP".into(),
401+
// Not needed: the supervisor does not call chroot().
402+
"SYS_CHROOT".into(),
403+
],
400404
cap_add: vec![
405+
// seccomp filter installation, namespace creation, Landlock setup.
401406
"SYS_ADMIN".into(),
407+
// Network namespace veth setup, IP/route configuration.
402408
"NET_ADMIN".into(),
409+
// Reading /proc/<pid>/exe and ancestor walk for process identity in policy.
403410
"SYS_PTRACE".into(),
411+
// Reading /dev/kmsg for bypass-detection diagnostics.
404412
"SYSLOG".into(),
405-
"SETUID".into(),
406-
"SETGID".into(),
413+
// Reading /proc/<pid>/fd/ across UIDs for process identity resolution.
414+
// In rootless Podman the supervisor runs as UID 0 inside a user namespace
415+
// while sandbox processes run as the sandbox user. The kernel's
416+
// proc_fd_permission() calls generic_permission() which denies cross-UID
417+
// access to the dr-x------ fd directory unless this cap is present.
418+
// Without it the proxy cannot determine which binary made each outbound
419+
// connection and all traffic is denied.
407420
"DAC_READ_SEARCH".into(),
408421
],
409-
// Disable the container-level seccomp profile. The sandbox supervisor
422+
// SETUID, SETGID, CHOWN, and FOWNER are intentionally kept from Podman's
423+
// default set and not dropped:
424+
// SETUID/SETGID – drop_privileges(): setuid()/setgid()/initgroups() to the
425+
// sandbox user. In rootless Podman cap_drop:ALL removes them
426+
// from the bounding set even though uid=0 owns the user
427+
// namespace — so we keep them by not dropping them explicitly.
428+
// CHOWN – prepare_filesystem(): chown(path, uid, gid) on newly
429+
// created read_write directories so the sandbox user can
430+
// write to them.
431+
// FOWNER – chown on files where the supervisor is not the owner
432+
// (e.g. pre-existing directories owned by another user).
433+
//
434+
// Disable the container-level seccomp profile. The sandbox supervisor The sandbox supervisor
410435
// installs its own policy-aware BPF seccomp filter at runtime via
411436
// seccompiler (two-phase: clone3 blocker + main filter). The runtime
412437
// filter is more restrictive than Podman's default — it blocks 20+
@@ -596,17 +621,46 @@ mod tests {
596621
let config = test_config();
597622
let spec = build_container_spec(&sandbox, &config);
598623

599-
let cap_add = spec["cap_add"]
624+
let added: Vec<&str> = spec["cap_add"]
625+
.as_array()
626+
.expect("cap_add should be an array")
627+
.iter()
628+
.filter_map(|v| v.as_str())
629+
.collect();
630+
assert!(added.contains(&"SYS_ADMIN"), "missing SYS_ADMIN");
631+
assert!(added.contains(&"NET_ADMIN"), "missing NET_ADMIN");
632+
assert!(added.contains(&"SYS_PTRACE"), "missing SYS_PTRACE");
633+
assert!(added.contains(&"SYSLOG"), "missing SYSLOG");
634+
assert!(
635+
added.contains(&"DAC_READ_SEARCH"),
636+
"missing DAC_READ_SEARCH"
637+
);
638+
639+
// SETUID and SETGID are NOT in cap_add — they remain available from the
640+
// default bounding set because we no longer use cap_drop:ALL. Verify they
641+
// are also not explicitly dropped. Similarly CHOWN and FOWNER must not be
642+
// dropped because prepare_filesystem() calls chown() on newly created
643+
// read_write directories before the supervisor drops privileges.
644+
let dropped: Vec<&str> = spec["cap_drop"]
600645
.as_array()
601-
.expect("cap_add should be an array");
602-
let caps: Vec<&str> = cap_add.iter().filter_map(|v| v.as_str()).collect();
603-
assert!(caps.contains(&"SYS_ADMIN"), "missing SYS_ADMIN");
604-
assert!(caps.contains(&"NET_ADMIN"), "missing NET_ADMIN");
605-
assert!(caps.contains(&"SYS_PTRACE"), "missing SYS_PTRACE");
606-
assert!(caps.contains(&"SYSLOG"), "missing SYSLOG");
607-
assert!(caps.contains(&"SETUID"), "missing SETUID");
608-
assert!(caps.contains(&"SETGID"), "missing SETGID");
609-
assert!(caps.contains(&"DAC_READ_SEARCH"), "missing DAC_READ_SEARCH");
646+
.expect("cap_drop should be an array")
647+
.iter()
648+
.filter_map(|v| v.as_str())
649+
.collect();
650+
assert!(!dropped.contains(&"SETUID"), "SETUID must not be dropped");
651+
assert!(!dropped.contains(&"SETGID"), "SETGID must not be dropped");
652+
assert!(
653+
!dropped.contains(&"CHOWN"),
654+
"CHOWN must not be dropped (needed for prepare_filesystem chown)"
655+
);
656+
assert!(
657+
!dropped.contains(&"FOWNER"),
658+
"FOWNER must not be dropped (needed for chown on non-owned files)"
659+
);
660+
assert!(
661+
!dropped.contains(&"ALL"),
662+
"must not use cap_drop:ALL in rootless Podman"
663+
);
610664
}
611665

612666
#[test]

‎e2e/rust/e2e-podman.sh‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ if [ -z "${PORT}" ]; then
4242
PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()')
4343
fi
4444

45+
# Allocate a separate port for the unauthenticated health endpoint.
46+
HEALTH_PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()')
47+
4548
# ── Pre-flight checks ───────────────────────────────────────────────
4649

4750
if ! command -v podman &>/dev/null; then
@@ -73,7 +76,7 @@ if ! podman image exists "${SUPERVISOR_IMAGE}" 2>/dev/null; then
7376
fi
7477

7578
# ── Generate a unique handshake secret ───────────────────────────────
76-
HANDSHAKE_SECRET="e2e-podman-$(head -c 16 /dev/urandom | xxd -p)"
79+
HANDSHAKE_SECRET="e2e-podman-$(python3 -c 'import secrets; print(secrets.token_hex(16))')"
7780

7881
# ── Start the gateway ────────────────────────────────────────────────
7982
GW_LOG=$(mktemp /tmp/openshell-gw-podman-e2e.XXXXXX)
@@ -118,6 +121,8 @@ OPENSHELL_SSH_HANDSHAKE_SECRET="${HANDSHAKE_SECRET}" \
118121
OPENSHELL_SUPERVISOR_IMAGE="${SUPERVISOR_IMAGE}" \
119122
"${GATEWAY_BIN}" \
120123
--port "${PORT}" \
124+
--health-port "${HEALTH_PORT}" \
125+
--ssh-gateway-port "${PORT}" \
121126
--drivers podman \
122127
--disable-tls \
123128
--db-url "sqlite::memory:" \
@@ -137,9 +142,8 @@ while [ "${elapsed}" -lt "${TIMEOUT}" ]; do
137142
exit 1
138143
fi
139144

140-
# Use curl to check the gateway's gRPC health endpoint.
141-
# The gateway serves both gRPC and HTTP on the same port.
142-
if curl -sf "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1; then
145+
# Poll the dedicated health port (--health-port).
146+
if curl -sf "http://127.0.0.1:${HEALTH_PORT}/healthz" >/dev/null 2>&1; then
143147
healthy=true
144148
break
145149
fi

0 commit comments

Comments
 (0)