The bwrap engine + OCI engine that lived in internal/convert/runner.go
both leak isolation policy into Go code. Replaced with a single image-
side wrapper that drop-in-shadows pandoc and chromium-browser on PATH.
zddc-server's only contract with the image is now "exec.Command(name,
args) gets you that tool's behavior" — sandboxing, resource caps, and
namespace setup live entirely in shell scripts shipped by the image.
Architecture:
- zddc/runtime/zddc-cgroup-init runs at container start. cgroup v2's
"no internal processes" constraint forbids a cgroup from having both
children and processes; the init script moves PID 1 into a child,
enables +memory +pids in subtree_control, then exec's zddc-server.
Best-effort: degrades cleanly to "no resource caps" if cgroupfs
isn't writable.
- zddc/runtime/zddc-sandbox-exec is the per-call wrapper, symlinked
from /usr/local/bin/{pandoc,chromium-browser}. Creates a transient
cgroup v2 (memory.max + pids.max), then bubblewrap-sandboxes the
real binary at /usr/bin/<name>: --unshare-all, --ro-bind /usr,
--proc /proc, --tmpfs /tmp, --clearenv. Caller's scratch dir comes
in via ZDDC_SCRATCH env and is bind-mounted at the SAME path so
absolute paths round-trip unchanged.
Go simplifications (~250 lines net deletion):
- Runner interface: Run(ctx, binary, stdin, scratchDir, cmd) — no
ToolSpec, no mount list, no engine concept. Single localRunner
implementation; bwrapRunner + containerRunner both deleted.
- health.Probe just looks up pandoc + chromium on PATH; Capabilities
drops engine kinds.
- Convert.go: ToHTML/ToPDF write to a per-call scratch dir under
TMPDIR and pass absolute paths; the wrapper bind-mounts the dir.
No more "/tpl" / "/pdf" mount-point indirection.
- Config drops --convert-pandoc-image, --convert-chromium-image,
--convert-engine, --convert-podman-socket (OCI engine gone) and
--convert-cpus (CPU caps don't apply in the new model — wall-clock
+ memory + pids is the cap set). Defaults raised to match the new
caps the user authorized: mem 512→1024 MiB, pids 100→256,
timeout 30→60 s.
Image:
- zddc/runtime.Containerfile builds the production runtime image
(alpine + bubblewrap + pandoc + chromium + font-noto). Two
COPY statements pull in the wrapper scripts; ln -s symlinks the
shadow names.
- bitnest dev image mirrors this layout under /var/lib/zddc-dev-build/.
Container privilege required:
- Nested bwrap needs the outer container to permit user + mount
namespace creation + MS_SLAVE on root. The default seccomp +
AppArmor profiles block all of these. Quadlet adds:
--cap-add=ALL
--security-opt=seccomp=unconfined
--security-opt=apparmor=unconfined
--security-opt=unmask=ALL
Helm chart sets the equivalent via securityContext (capabilities.
add: SYS_ADMIN, seccompProfile.type: Unconfined, appArmorProfile.
type: Unconfined). Trade-off documented in AGENTS.md: zddc-server
RCE now has near-root power within the container, but the bind-
mount layout still bounds blast radius; bwrap is the real boundary
between zddc-server and untrusted markdown.
Tests: convert_test.go fully rewritten for the new Runner signature.
Drops TestBwrapArgs_* (functionality moved out of Go) and
TestImageTag (no more image refs). All 15 Go test packages green.
Verified live on bitnest: pandoc --version round-trip exits 0
through the wrapper; MD→DOCX produces a valid Word 2007+ file
end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
82 lines
3.8 KiB
Bash
Executable file
82 lines
3.8 KiB
Bash
Executable file
#!/bin/sh
|
|
# zddc-cgroup-init — prepare cgroup v2 hierarchy and exec zddc-server.
|
|
#
|
|
# The per-conversion wrapper (zddc-sandbox-exec) creates a transient
|
|
# child cgroup for each pandoc / chromium invocation, sets memory.max
|
|
# and pids.max on it, and moves the conversion process in. That only
|
|
# works when:
|
|
#
|
|
# (a) the cgroup v2 hierarchy is mounted at /sys/fs/cgroup, AND
|
|
# (b) the controllers we need (memory, pids) are enabled in the
|
|
# parent cgroup's subtree_control file, AND
|
|
# (c) the parent cgroup has NO processes in it (cgroup v2's
|
|
# "no internal processes" constraint: a cgroup can have
|
|
# children OR processes, not both).
|
|
#
|
|
# A bare container with PID 1 in the root cgroup violates (c). This
|
|
# init script does the one-time setup BEFORE exec'ing zddc-server:
|
|
#
|
|
# 1. mkdir /sys/fs/cgroup/zddc/ (a sibling for zddc-server)
|
|
# 2. move every PID out of root into /sys/fs/cgroup/zddc/
|
|
# 3. enable +memory +pids in root's subtree_control (now empty)
|
|
# 4. enable +memory +pids in zddc/'s subtree_control (so its
|
|
# children — the per-conversion cgroups created by the wrapper
|
|
# — can use those controllers)
|
|
# 5. exec zddc-server (which inherits cgroup membership in zddc/)
|
|
#
|
|
# After this, the wrapper script creates /sys/fs/cgroup/conv.<pid>/
|
|
# as a sibling of /sys/fs/cgroup/zddc/, sets limits, and moves the
|
|
# pandoc/chromium process in. Each conversion gets a fresh transient
|
|
# cgroup that vanishes when the process exits.
|
|
#
|
|
# Best-effort: if any step fails (cgroup v1, undelegated subtree,
|
|
# read-only cgroupfs in some other container shape), this script
|
|
# still exec's zddc-server. The convert pipeline degrades to
|
|
# "bwrap sandbox + wall-clock timeout"; an operator notices via
|
|
# the warning log line below.
|
|
|
|
set -eu
|
|
|
|
setup_cgroup_v2() {
|
|
cgroot=/sys/fs/cgroup
|
|
[ -d "$cgroot" ] || return 1
|
|
# Detect cgroup v2 by the presence of cgroup.controllers at root.
|
|
[ -r "$cgroot/cgroup.controllers" ] || return 1
|
|
# Need memory + pids in available controllers.
|
|
if ! grep -qw memory "$cgroot/cgroup.controllers"; then
|
|
echo "zddc-cgroup-init: cgroup.controllers lacks 'memory' — per-conversion memory cap will be unenforced" >&2
|
|
fi
|
|
# Create the leaf where zddc-server itself will live.
|
|
mkdir -p "$cgroot/zddc" || return 1
|
|
# Move every PID currently in the root cgroup into zddc/. The
|
|
# root must be empty before we can enable subtree_control.
|
|
if [ -r "$cgroot/cgroup.procs" ]; then
|
|
while read -r pid; do
|
|
[ -n "$pid" ] || continue
|
|
# Best-effort; processes can exit between read and write.
|
|
printf "%s\n" "$pid" > "$cgroot/zddc/cgroup.procs" 2>/dev/null || true
|
|
done < "$cgroot/cgroup.procs"
|
|
fi
|
|
# Enable controllers at root → makes them usable in immediate
|
|
# children (zddc/ and any sibling per-conversion cgroup).
|
|
printf "+memory +pids" > "$cgroot/cgroup.subtree_control" 2>/dev/null || {
|
|
echo "zddc-cgroup-init: could not enable +memory +pids in $cgroot/cgroup.subtree_control — caps will not apply" >&2
|
|
return 1
|
|
}
|
|
# Enable inside zddc/ too, so any deeper children of zddc-server
|
|
# (which there shouldn't be, but defense in depth) inherit.
|
|
printf "+memory +pids" > "$cgroot/zddc/cgroup.subtree_control" 2>/dev/null || true
|
|
return 0
|
|
}
|
|
|
|
if ! setup_cgroup_v2; then
|
|
echo "zddc-cgroup-init: cgroup v2 setup unavailable — running without per-conversion caps" >&2
|
|
fi
|
|
|
|
# Hand off to zddc-server. The exec'd process lands in
|
|
# /sys/fs/cgroup/zddc/ (we moved ourselves there above). When it
|
|
# spawns the wrapper, the wrapper creates a transient sibling cgroup
|
|
# under /sys/fs/cgroup/, NOT a child of zddc/, so the conversion's
|
|
# cgroup is a peer of zddc-server's — keeping zddc-server's own
|
|
# resource accounting separate from conversion accounting.
|
|
exec "$@"
|