#!/usr/bin/env bash # Stage 2 of the dev sandbox: build the mounts and run the payload. # # Not called directly. scripts/dev-sandbox.sh (stage 1) creates the user and # network namespaces with `unshare` and re-execs into this script inside them, # so by the time this runs we are already at the target uid with a private # netns. bwrap therefore does NOT create a userns here -- it only adds the # mount and pid namespaces. (`unshare --user` grants its creator full # capabilities in the new userns regardless of which uid it maps, which is what # lets bwrap mount as a non-root uid.) # # The whole interface with stage 1 is the DEV_SANDBOX_* environment, asserted # below: there are no shared functions or variables between the two stages. # Stage 1 locates this script alongside the other sandbox assets (see # DEV_SANDBOX_ASSETS in dev-sandbox.sh), so the Nix wrapper's store copy and a # plain repo checkout both work. set -euo pipefail : "${DEV_SANDBOX_ROOT:?missing DEV_SANDBOX_ROOT}" : "${DEV_SANDBOX_BASH:?missing DEV_SANDBOX_BASH}" : "${DEV_SANDBOX_INTERACTIVE:?missing DEV_SANDBOX_INTERACTIVE}" : "${DEV_SANDBOX_USER:?missing DEV_SANDBOX_USER}" : "${DEV_SANDBOX_HOME:?missing DEV_SANDBOX_HOME}" # Announce our pid so stage 1 can point slirp4netns at these namespaces, # then hold until it reports the network is up. slirp_ready="$DEV_SANDBOX_ROOT/root/logs/slirp.ready" printf '%s\n' "$$" > "$DEV_SANDBOX_ROOT/root/logs/sandbox.pid" for _ in $(seq 1 200); do [ -s "$slirp_ready" ] && break sleep 0.05 done if [ ! -s "$slirp_ready" ]; then echo 'error: timed out waiting for sandbox network setup' >&2 cat "$DEV_SANDBOX_ROOT/root/logs/slirp.log" >&2 || true exit 1 fi # The sandbox HOME is /root for a root install and /home/ for a # user-level one. Only the latter needs its parent created first; --dir / # is not a thing bwrap accepts. home_mounts=() home_parent="$(dirname "$DEV_SANDBOX_HOME")" if [ "$home_parent" != / ]; then home_mounts+=(--dir "$home_parent") fi home_mounts+=(--bind "$DEV_SANDBOX_ROOT/home" "$DEV_SANDBOX_HOME") node_env=() if [ -n "${DEV_SANDBOX_NODE_DIR:-}" ]; then node_env+=(--setenv npm_config_nodedir "$DEV_SANDBOX_NODE_DIR") fi electron_env=() if [ -n "${DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH:-}" ]; then electron_env+=( --setenv LD_LIBRARY_PATH "$DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH" --setenv HERMES_DESKTOP_DISABLE_GPU 1 ) fi gui_mounts=() if [ -n "${DEV_SANDBOX_WAYLAND_SOCKET:-}" ]; then runtime_dir="${DEV_SANDBOX_XDG_RUNTIME_DIR:?missing DEV_SANDBOX_XDG_RUNTIME_DIR}" runtime_parent="$(dirname "$runtime_dir")" runtime_grandparent="$(dirname "$runtime_parent")" gui_mounts+=( --dir "$runtime_grandparent" --dir "$runtime_parent" --dir "$runtime_dir" --bind "$DEV_SANDBOX_WAYLAND_SOCKET" "$DEV_SANDBOX_WAYLAND_SOCKET" --setenv XDG_RUNTIME_DIR "$runtime_dir" --setenv WAYLAND_DISPLAY "${DEV_SANDBOX_WAYLAND_DISPLAY:?missing DEV_SANDBOX_WAYLAND_DISPLAY}" ) fi # How the sandbox gets a usable runtime, and where its own shims go. # # On Nix, every binary lives under /nix/store, so the sandbox can own /bin, # /lib64 and /usr/bin outright and fill them with symlinks into the store. # # Elsewhere the runtime IS /usr, /bin, /lib, /lib64 -- so binding the # sandbox's near-empty versions over them hides the real thing, and bwrap # dies with `execvp /usr/bin/bash: No such file or directory`. Keep the host # directories read-only and override only the individual files we shim. # # The same answer decides how /etc is handled further down. if [ -d /nix ] && [[ "$(readlink -f "$DEV_SANDBOX_BASH")" == /nix/* ]]; then USE_HOST_RUNTIME=false else USE_HOST_RUNTIME=true fi runtime_mounts=() shim_mounts=() if [ "$USE_HOST_RUNTIME" = false ]; then runtime_mounts+=(--ro-bind /nix /nix) shim_mounts+=( --dir /usr --dir /bin --dir /lib64 --bind "$DEV_SANDBOX_ROOT/root/bin" /bin --bind "$DEV_SANDBOX_ROOT/root/lib64" /lib64 --bind "$DEV_SANDBOX_ROOT/root/usr/bin" /usr/bin ) else for path in /usr /bin /sbin /lib /lib64; do [ -e "$path" ] && runtime_mounts+=(--ro-bind "$path" "$path") done # The git-upload-pack shim standing in for github.com is the only file that # must beat the host's copy; sh/ls/env are already there for real. shim_mounts+=(--bind "$DEV_SANDBOX_ROOT/root/usr/bin/ssh" /usr/bin/ssh) fi # /etc: start from a copy of the host's and overwrite only the files we fake. # # Replacing the whole directory with a five-file one is the tempting shortcut # and it is wrong: a distro puts things under /etc that binaries outside /etc # depend on, so hiding all of it breaks tools that look fine on PATH. Two real # examples, both Debian/Ubuntu: openssl's compiled-in openssl.cnf is a symlink # into /etc/ssl, and /usr/bin/awk is a symlink to /etc/alternatives/awk -- with # /etc replaced, openssl cannot mint a certificate and awk reports "not found". # Those are two symptoms of one cause, and nothing says there are only two. # # Copying rather than mount-overlaying the individual files, because several of # these are symlinks in the wild (resolv.conf -> ../run/systemd/... on Ubuntu, # hosts and nsswitch.conf -> /etc/static/... on NixOS) and bwrap cannot bind a # file onto a symlink whose target does not exist inside the sandbox. # # Symlinks are copied as symlinks, never dereferenced: on NixOS /etc/static # points into the store and following it would copy gigabytes per sandbox. The # store is already mounted at /nix on that path, and the host runtime dirs are # mounted at their own paths, so absolute symlinks still resolve. # # The five we override, and why each must differ from the host's: # passwd, group the sandbox identity, which does not exist on the host # resolv.conf slirp4netns's DNS, not the host resolver # nsswitch.conf files+dns only, so nothing consults host NSS modules # hosts minimal, so no host entry leaks in # # os-release is removed rather than replaced. Installers branch on it to reach # for a package manager -- `install.sh` reads ID from it and, on debian/ubuntu, # offers to apt-get build tools, prompting on /dev/tty when sudo exists but is # not passwordless. That prompt cannot be satisfied here (no terminal) and it is # fatal under `set -e`. Inheriting the host's file would make the sandbox claim # to be a distro whose package manager it cannot actually use; absent means # DISTRO="unknown" and the apt path is skipped, which is the truth. etc_mounts=() if [ "$USE_HOST_RUNTIME" = true ] && [ -d /etc ]; then sandbox_etc="$DEV_SANDBOX_ROOT/etc-merged" rm -rf -- "$sandbox_etc" mkdir -p "$sandbox_etc" # -a keeps symlinks as symlinks; unreadable entries (shadow, sudoers) are # skipped rather than failing the run. cp -a /etc/. "$sandbox_etc/" 2>/dev/null || true for etc_file in passwd group resolv.conf nsswitch.conf hosts; do [ -f "$DEV_SANDBOX_ROOT/etc/$etc_file" ] || continue rm -f "$sandbox_etc/$etc_file" cp "$DEV_SANDBOX_ROOT/etc/$etc_file" "$sandbox_etc/$etc_file" done rm -f "$sandbox_etc/os-release" "$sandbox_etc/lsb-release" etc_mounts+=(--ro-bind "$sandbox_etc" /etc) else etc_mounts+=(--bind "$DEV_SANDBOX_ROOT/etc" /etc) fi # /dev without a tty, so a script guarding on `[ -e /dev/tty ]` takes its # no-terminal path. # # bwrap's --dev creates a /dev/tty NODE, but nothing in here has a controlling # terminal, so opening it fails with "No such device or address". That is the # worst of both: the guard passes and the read then fails. Under `set -e` -- # which install.sh uses -- a failed read inside a function aborts the whole # installer, which is exactly how older releases died here while prompting for # sudo to install ripgrep/ffmpeg. # # Making the tty real is not the fix: with an openable terminal that prompt # blocks forever waiting for input nobody will type. Absent is what a headless # machine looks like, and what every prompt in here should assume. # # --dev cannot be used with the node removed afterwards (bwrap refuses to mount # a directory over a device node), so /dev is assembled explicitly. dev_mounts=( --tmpfs /dev --dev-bind /dev/null /dev/null --dev-bind /dev/zero /dev/zero --dev-bind /dev/full /dev/full --dev-bind /dev/random /dev/random --dev-bind /dev/urandom /dev/urandom --symlink /proc/self/fd /dev/fd --symlink /proc/self/fd/0 /dev/stdin --symlink /proc/self/fd/1 /dev/stdout --symlink /proc/self/fd/2 /dev/stderr ) if [ "$DEV_SANDBOX_INTERACTIVE" = true ]; then # An interactive shell is deliberately given a terminal; keep bwrap's /dev. dev_mounts=(--dev /dev) fi exec bwrap \ --unshare-pid \ --die-with-parent --proc /proc --tmpfs /tmp \ "${dev_mounts[@]}" \ "${gui_mounts[@]}" \ "${runtime_mounts[@]}" \ --bind "$DEV_SANDBOX_ROOT/root" /work \ "${shim_mounts[@]}" \ --bind "$DEV_SANDBOX_ROOT/root/usr/local" /usr/local \ "${home_mounts[@]}" \ "${etc_mounts[@]}" \ --chdir /work/repo \ --clearenv \ --setenv PATH "$DEV_SANDBOX_HOME/.local/bin:/usr/local/bin:/usr/bin:$PATH" \ --setenv HOME "$DEV_SANDBOX_HOME" \ --setenv USER "$DEV_SANDBOX_USER" \ --setenv LOGNAME "$DEV_SANDBOX_USER" \ --setenv CURL_CA_BUNDLE /work/certs/ca.pem \ --setenv SSL_CERT_FILE /work/certs/ca.pem \ --setenv GIT_SSL_CAINFO /work/certs/ca.pem \ --setenv NODE_EXTRA_CA_CERTS /work/certs/real-ca.pem \ --setenv OPENSSL_CONF /work/certs/openssl.cnf \ --setenv HTTP_PROXY http://127.0.0.1:8080 \ --setenv HTTPS_PROXY http://127.0.0.1:8080 \ --setenv ALL_PROXY http://127.0.0.1:8080 \ --setenv NO_PROXY '' \ --setenv DEV_SANDBOX_INTERACTIVE "$DEV_SANDBOX_INTERACTIVE" \ --setenv ELECTRON_DISABLE_SANDBOX 1 \ "${node_env[@]}" \ "${electron_env[@]}" \ -- "$DEV_SANDBOX_BASH" -ceu ' python3 /work/proxy.py /work/http /work/certs /work/certs/real-ca.pem >/work/logs/proxy.log 2>&1 & proxy_pid=$! cleanup() { kill "$proxy_pid" 2>/dev/null || true wait "$proxy_pid" 2>/dev/null || true } trap cleanup EXIT INT TERM # Bash opens /dev/tcp itself, so the readiness probe needs no netcat -- # one less binary the sandbox has to find on the host (GitHub runners # ship no `nc`). proxy_up() { (exec 3<>/dev/tcp/127.0.0.1/8080) 2>/dev/null; } for _ in $(seq 1 100); do proxy_up && break sleep 0.05 done if ! proxy_up; then echo "error: the sandbox fake-internet proxy never came up" >&2 cat /work/logs/proxy.log >&2 || true exit 1 fi "$@" ' sandbox-command "$@"