#!/bin/sh # zddc-sandbox-exec — drop-in wrapper for pandoc and chromium-browser. # # Invoked via symlinks at /usr/local/bin/pandoc and # /usr/local/bin/chromium-browser. zddc-server (and any other caller # that uses the default PATH) exec's by short name, hits this script # first, and we transparently run the real binary at /usr/bin/ # inside: # # 1. a transient cgroup v2 (memory + pids cap, kernel-enforced) # 2. a bubblewrap sandbox (private namespaces, ro-bind /usr, fresh # tmpfs at /tmp, no network) # # zddc-server's Go code does not know about either layer — its only # contract with the image is "if I exec pandoc with these args, I # get pandoc behavior back." Swap the wrapper for a different # isolation strategy (firejail, nspawn, podman-run, raw exec) and # nothing changes in Go. # # Caller-tunable env (with defaults): # # ZDDC_SCRATCH host directory to bind-mount read-write # inside the sandbox at the SAME path. Set by # zddc-server per-conversion; the markdown # template, intermediate HTML, and chromium # output PDF all live there. Absent = no extra # bind mount; /tmp is a fresh tmpfs only. # ZDDC_CONV_MEM_MAX cgroup memory.max value (default "1G"). # cgroup v2 syntax — bytes, "1G", or "max". # ZDDC_CONV_PIDS_MAX cgroup pids.max value (default "256"). # ZDDC_CONV_TMPFS_SIZE bwrap tmpfs /tmp byte size (default 256 MiB). set -eu NAME=$(basename "$0") REAL="/usr/bin/$NAME" if [ ! -x "$REAL" ]; then echo "zddc-sandbox-exec: $NAME — real binary not found at $REAL" >&2 exit 127 fi # ── 1. cgroup v2 (best-effort) ────────────────────────────────────────── # # zddc-cgroup-init enables +memory +pids in /sys/fs/cgroup/cgroup. # subtree_control at container start (see that script for the cgroup # v2 "no internal processes" wrinkle that requires the indirection). # Here we just need to mkdir a transient child, set caps, move # ourselves in. The real binary inherits cgroup membership at exec. CG_ROOT="/sys/fs/cgroup" CG_CONTROL="$CG_ROOT/cgroup.subtree_control" if [ -w "$CG_CONTROL" ] && grep -qw memory "$CG_CONTROL" 2>/dev/null; then CG="$CG_ROOT/conv.$$" if mkdir "$CG" 2>/dev/null; then # rmdir on exit so the cgroupfs doesn't leak. Best-effort: # the kernel reaps empty cgroups when the last PID leaves # anyway, but we tidy up for the case where the wrapper # itself exits before exec'ing the real binary. trap 'rmdir "$CG" 2>/dev/null || true' EXIT INT TERM printf "%s\n" "${ZDDC_CONV_MEM_MAX:-1G}" > "$CG/memory.max" 2>/dev/null || true printf "%s\n" "${ZDDC_CONV_PIDS_MAX:-256}" > "$CG/pids.max" 2>/dev/null || true printf "%s\n" "$$" > "$CG/cgroup.procs" 2>/dev/null || true fi fi # ── 2. bwrap sandbox ──────────────────────────────────────────────────── # # Mirror the hardening that internal/convert previously assembled in # Go: unshare every namespace (--unshare-all also covers network), # bind /usr read-only so the binary + its libs are visible, drop a # fresh tmpfs at /tmp, clear the environment to a minimal floor. # # Building the bwrap argv preserves "$@" (the original pandoc / # chromium args) by PREPENDING bwrap flags onto the existing # positional parameters. Each `set -- new-flag "$@"` puts one flag # at the front; reads back-to-front the final argv is: # # bwrap --unshare-all --unshare-user-try ... -- REAL_BINARY ORIG_ARGS # # This is the standard POSIX-sh idiom for "build a command line # without an array type." set -- "$REAL" "$@" # REAL ORIG set -- -- "$@" # -- REAL ORIG # Optional scratch dir, prepended just before "-- REAL ORIG" so it # lands inside the bwrap flag list: if [ -n "${ZDDC_SCRATCH:-}" ] && [ -d "$ZDDC_SCRATCH" ]; then set -- --bind "$ZDDC_SCRATCH" "$ZDDC_SCRATCH" "$@" fi # Common bwrap flags (each one prepended; final order is bottom-up). set -- --setenv LANG C.UTF-8 "$@" set -- --setenv PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin "$@" set -- --setenv HOME /tmp "$@" set -- --clearenv "$@" set -- --chdir /tmp "$@" # bwrap's --size sets the size of the NEXT --tmpfs, so in argv order # --size must come before --tmpfs. Building bottom-up via prepend means # the LATER statement here lands earlier in argv: write --tmpfs first # then --size, so the final $@ starts with "... --size N --tmpfs /tmp". set -- --tmpfs /tmp "$@" set -- --size "${ZDDC_CONV_TMPFS_SIZE:-268435456}" "$@" set -- --dev /dev "$@" set -- --proc /proc "$@" set -- --ro-bind-try /etc /etc "$@" set -- --ro-bind-try /sbin /sbin "$@" set -- --ro-bind-try /bin /bin "$@" set -- --ro-bind-try /lib64 /lib64 "$@" set -- --ro-bind-try /lib /lib "$@" set -- --ro-bind /usr /usr "$@" set -- --die-with-parent "$@" set -- --unshare-user-try "$@" set -- --unshare-all "$@" exec bwrap "$@"