fix(deploy): script-sandbox helper — UID drop via systemd-run, --unshare-user-try, /etc/alternatives

Smoke testing on the test host revealed three issues with the helper as
shipped:

1. bwrap 0.11+ rejects --uid without --unshare-user. Switching the UID
   drop from inside bwrap to systemd-run (--uid=l4d2-sandbox
   --gid=l4d2-sandbox) sidesteps the userns UID-mapping headaches and
   keeps file ownership on the bind-mounted /overlay matching
   l4d2-sandbox on the host (which the wipe path relies on).

2. bwrap running as an unprivileged uid still needs a user namespace to
   set up its mount-namespace bind-mounts. Adding --unshare-user-try
   gives it the userns context when needed and is a no-op otherwise.

3. /etc/alternatives wasn't bind-mounted, so symlinked tools like
   /usr/bin/awk -> /etc/alternatives/awk fell over inside the sandbox.
   Adds the ro-bind.

Also: the helper now chowns the overlay dir to l4d2-sandbox before bwrap
(idempotent — needed because the web app creates the dir as left4me),
and the deploy script chmods /var/lib/left4me to 0711 so l4d2-sandbox
can traverse to the bind-mount source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-08 16:12:46 +02:00
parent 1e62a44c16
commit 06ae84fbe4
No known key found for this signature in database
3 changed files with 26 additions and 7 deletions

View file

@ -113,6 +113,12 @@ $sudo_cmd chown left4me:left4me \
/var/lib/left4me/runtime \ /var/lib/left4me/runtime \
/var/lib/left4me/workshop_cache \ /var/lib/left4me/workshop_cache \
/var/lib/left4me/tmp /var/lib/left4me/tmp
# /var/lib/left4me is left4me's home dir (mode 0700 from useradd --create-home).
# Allow other uids (notably l4d2-sandbox, used by script overlay builds) to
# traverse — but not list — so the bwrap bind-mount can resolve the overlay
# path under the dropped privilege.
$sudo_cmd chmod 0711 /var/lib/left4me
$sudo_cmd chown -R left4me:left4me /opt/left4me $sudo_cmd chown -R left4me:left4me /opt/left4me
mkdir -p "$repo_tmp" mkdir -p "$repo_tmp"

View file

@ -25,21 +25,30 @@ OVERLAY_DIR=/var/lib/left4me/overlays/$OVERLAY_ID
[[ -d $OVERLAY_DIR ]] || { echo "no overlay dir at $OVERLAY_DIR" >&2; exit 65; } [[ -d $OVERLAY_DIR ]] || { echo "no overlay dir at $OVERLAY_DIR" >&2; exit 65; }
[[ -f $SCRIPT ]] || { echo "no script at $SCRIPT" >&2; exit 65; } [[ -f $SCRIPT ]] || { echo "no script at $SCRIPT" >&2; exit 65; }
SBX_UID=$(id -u l4d2-sandbox)
SBX_GID=$(id -g l4d2-sandbox)
if [[ "${LEFT4ME_SCRIPT_SANDBOX_DRY_RUN:-}" == "1" ]]; then if [[ "${LEFT4ME_SCRIPT_SANDBOX_DRY_RUN:-}" == "1" ]]; then
echo "DRY RUN: overlay_id=$OVERLAY_ID script=$SCRIPT uid=$SBX_UID gid=$SBX_GID overlay_dir=$OVERLAY_DIR" echo "DRY RUN: overlay_id=$OVERLAY_ID script=$SCRIPT overlay_dir=$OVERLAY_DIR"
exit 0 exit 0
fi fi
# Make sure the sandbox UID owns the overlay dir so the script can write there.
# Idempotent: a no-op when the dir is already l4d2-sandbox-owned (re-run case),
# and corrects the ownership the first time the dir was created by the web app
# under the left4me UID. Group-readable so the gameserver process (left4me)
# can read the overlay contents via the kernel-overlayfs lowerdir at runtime.
chown -R l4d2-sandbox:l4d2-sandbox "$OVERLAY_DIR"
chmod 0755 "$OVERLAY_DIR"
# UID/GID drop happens via systemd-run --uid/--gid before bwrap is invoked.
# bwrap then runs unprivileged as l4d2-sandbox; --unshare-user-try gives it
# the user-namespace context it needs for bind-mounts as a regular user.
exec systemd-run --quiet --scope --collect \ exec systemd-run --quiet --scope --collect \
--uid=l4d2-sandbox --gid=l4d2-sandbox \
-p MemoryMax=4G -p MemorySwapMax=0 -p TasksMax=512 \ -p MemoryMax=4G -p MemorySwapMax=0 -p TasksMax=512 \
-p CPUQuota=200% -p RuntimeMaxSec=3600 \ -p CPUQuota=200% -p RuntimeMaxSec=3600 \
-- bwrap \ -- bwrap \
--die-with-parent --new-session \ --die-with-parent --new-session \
--unshare-user-try \
--unshare-pid --unshare-ipc --unshare-uts --unshare-cgroup \ --unshare-pid --unshare-ipc --unshare-uts --unshare-cgroup \
--uid "$SBX_UID" --gid "$SBX_GID" \
--proc /proc --dev /dev --tmpfs /tmp --tmpfs /run \ --proc /proc --dev /dev --tmpfs /tmp --tmpfs /run \
--ro-bind /usr /usr --ro-bind /lib /lib --ro-bind /lib64 /lib64 \ --ro-bind /usr /usr --ro-bind /lib /lib --ro-bind /lib64 /lib64 \
--symlink usr/bin /bin --symlink usr/sbin /sbin \ --symlink usr/bin /bin --symlink usr/sbin /sbin \
@ -47,6 +56,7 @@ exec systemd-run --quiet --scope --collect \
--ro-bind /etc/ssl /etc/ssl \ --ro-bind /etc/ssl /etc/ssl \
--ro-bind /etc/ca-certificates /etc/ca-certificates \ --ro-bind /etc/ca-certificates /etc/ca-certificates \
--ro-bind /etc/nsswitch.conf /etc/nsswitch.conf \ --ro-bind /etc/nsswitch.conf /etc/nsswitch.conf \
--ro-bind /etc/alternatives /etc/alternatives \
--bind "$OVERLAY_DIR" /overlay \ --bind "$OVERLAY_DIR" /overlay \
--chdir /overlay \ --chdir /overlay \
--setenv HOME /tmp --setenv PATH /usr/bin:/usr/sbin \ --setenv HOME /tmp --setenv PATH /usr/bin:/usr/sbin \

View file

@ -327,8 +327,11 @@ def test_script_sandbox_helper_invokes_systemd_run_and_bwrap():
assert "bwrap" in text assert "bwrap" in text
assert "--unshare-pid" in text assert "--unshare-pid" in text
assert "--unshare-net" not in text, "scripts must keep host network access" assert "--unshare-net" not in text, "scripts must keep host network access"
assert 'id -u l4d2-sandbox' in text # UID drop happens at systemd-run, not inside bwrap (modern bwrap requires
assert 'id -g l4d2-sandbox' in text # --unshare-user for --uid; doing the drop earlier keeps file ownership
# straight on the host bind-mount).
assert "--uid=l4d2-sandbox" in text
assert "--gid=l4d2-sandbox" in text
def test_script_sandbox_helper_validates_overlay_id(): def test_script_sandbox_helper_validates_overlay_id():