#!/usr/bin/env bash # Row 240 (I4, #1746): the Case 1 host's drift stop for bwrap, r6 B2 # (agents/darkwing/work/queue-195/DESIGN.md at b7239bb66, as docs/plans/2026-10-20_installer-rows.md # "I4 hostcheck" ships it). Deviations and decisions: agents/darkwing/work/queue-240/BUILD.md. # # case1-hostcheck.sh --check | --enforce | --upstream # # Installed as /usr/local/lib/mosaic-stack/hostcheck.sh, root:root 0755, with case1-pins.sh beside it, # root:root 0644. Three callers: stack-upgrade.sh A1 runs --check and refuses profile-drift on any # exit but 0; the apt hook /etc/apt/apt.conf.d/80mosaic-hostcheck runs --enforce after every dpkg run; # mosaic-hostcheck.service runs --enforce at boot, before the bus. # # Seven items, one line each, `ok N ...` or `FAIL N ...`: # 1. /etc/apparmor.d/bwrap-userns-restrict has the pinned sha256, and `dpkg -S` finds no owner. # 2. `aa-status --json` lists bwrap and unpriv_bwrap in enforce mode, and no mosaic-bwrap. # 3. `grep -rlw bwrap /etc/apparmor.d` lists that one file and nothing else. # 4. A bwrap child started as mosaic under the units' no-new-privs reads the label # `bwrap//&unpriv_bwrap (enforce)`, exactly. # 5. The statoverride on /usr/bin/bwrap has root mosaic and octal mode 0750 (750 accepted), and stat agrees. # 6. kernel.apparmor_restrict_unprivileged_userns and fs.protected_hardlinks are 1. # 7. net.ipv4.ip_unprivileged_port_start is above the Console's --port in the installed unit, or # above 1023, the highest --port case2-unit.sh takes, while no unit is installed. # A `note` line says whether bubblewrap is the install pin; it is never a failure. # # --check exits 0 when all seven hold and 1 otherwise. --enforce runs the same items and exits 0. On # a failure, or while /etc/mosaic-stack/DRIFT exists, it stops bwrap: `apparmor_parser -R` on the # profile if it is loaded, then root:root 0700 on /usr/bin/bwrap. It appends the failed items to DRIFT # and one line to /var/log/mosaic-stack/hostcheck.log. Only root removes DRIFT, in a reviewed step. # It never writes the statoverride, because dpkg's lock may be held under apt's (r6). # --upstream downloads the apparmor-profiles candidate into a temp dir and compares its # bwrap-userns-restrict to the pin. A difference is printed and exits 0, because it refuses nothing; # it exits 1 when the download or the unpack fails. # # Exit 2 on usage or a refusal. Root takes no --root; without root it runs only with --root. # # Test seam, for packages/cli/tests/case1-hostcheck.test.mjs only: # --root DIR prefixes /etc, /proc/sys, /var/log and /usr/bin/bwrap set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" usage() { echo "usage: case1-hostcheck.sh --check | --enforce | --upstream" >&2; exit 2; } MODE="" ROOT="" while [ $# -gt 0 ]; do case "$1" in --check|--enforce|--upstream) [ -z "$MODE" ] || usage; MODE="${1#--}"; shift ;; --root) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [ -d "$2" ] || usage; ROOT="${2%/}"; shift 2 ;; *) usage ;; esac done [ -n "$MODE" ] || usage if [ "$(id -u)" = 0 ]; then [ -z "$ROOT" ] || { echo "hostcheck: refusing: --root is a test seam, and root takes none" >&2; exit 2; } PATH=/usr/sbin:/usr/bin:/sbin:/bin else [ -n "$ROOT" ] || { echo "hostcheck: refusing: run it as root" >&2; exit 2; } fi PIN_PROFILES_VERSION="" PIN_PROFILE_SHA256="" PIN_BUBBLEWRAP_VERSION="" # shellcheck source=case1-pins.sh [ -f "$HERE/case1-pins.sh" ] && . "$HERE/case1-pins.sh" PROFILE=/etc/apparmor.d/bwrap-userns-restrict BWRAP=/usr/bin/bwrap DRIFT="$ROOT/etc/mosaic-stack/DRIFT" LOGF="$ROOT/var/log/mosaic-stack/hostcheck.log" LABEL='bwrap//&unpriv_bwrap (enforce)' stamp() { date -u +%Y-%m-%dT%H:%M:%SZ; } FAILED=() LOADED=1 pass() { echo "ok $*"; } fail() { echo "FAIL $*"; FAILED+=("$*"); } item1() { local sum owner rc [[ "$PIN_PROFILE_SHA256" =~ ^[0-9a-f]{64}$ ]] || { fail "1 no profile pin in $HERE/case1-pins.sh"; return; } sum="$(sha256sum < "$ROOT$PROFILE" 2>/dev/null | cut -d' ' -f1)" [ "$sum" = "$PIN_PROFILE_SHA256" ] || { fail "1 $PROFILE sha256 is ${sum:-unreadable}, not the pin $PIN_PROFILE_SHA256"; return; } owner="$(dpkg -S "$PROFILE" 2>/dev/null)"; rc=$? case "$rc" in 1) pass "1 $PROFILE matches the pin from apparmor-profiles $PIN_PROFILES_VERSION, and no package owns it" ;; 0) fail "1 $PROFILE is owned by a package: $owner" ;; *) fail "1 dpkg -S $PROFILE exited $rc" ;; esac } item2() { local json list if ! json="$(aa-status --json 2>/dev/null)"; then fail "2 aa-status --json failed"; return; fi # The profiles object holds name-to-mode pairs and nothing nested; one pair per line. list="$(sed -n 's/.*"profiles": *{\([^}]*\)}.*/\1/p' <<< "$json" | tr ',' '\n' | sed 's/^ *//; s/ *$//')" grep -Eq '^"(bwrap|unpriv_bwrap)":' <<< "$list" || LOADED=0 grep -qxE '"bwrap": *"enforce"' <<< "$list" || { fail "2 aa-status lists no bwrap profile in enforce mode"; return; } grep -qxE '"unpriv_bwrap": *"enforce"' <<< "$list" || { fail "2 aa-status lists no unpriv_bwrap profile in enforce mode"; return; } ! grep -qE '^"mosaic-bwrap":' <<< "$list" || { fail "2 aa-status lists a mosaic-bwrap profile"; return; } pass "2 bwrap and unpriv_bwrap are in enforce mode, and no mosaic-bwrap is loaded" } item3() { local found found="$(grep -rlw bwrap "$ROOT/etc/apparmor.d" 2>/dev/null | sort)" [ "$found" = "$ROOT$PROFILE" ] || { fail "3 the files under /etc/apparmor.d that name bwrap are not $PROFILE alone: $(tr '\n' ' ' <<< "$found")"; return; } pass "3 $PROFILE is the only file under /etc/apparmor.d that names bwrap" } item4() { local label label="$(timeout 60 systemd-run --wait --pipe --uid=mosaic --gid=mosaic -p NoNewPrivileges=yes \ -p ProtectSystem=strict -p ProtectHome=yes -p PrivateTmp=yes \ "$BWRAP" --unshare-user --unshare-pid --ro-bind / / cat /proc/self/attr/current 2>/dev/null)" [ "$label" = "$LABEL" ] || { fail "4 a bwrap child under no-new-privs has the label '$label', not '$LABEL'"; return; } pass "4 a bwrap child under no-new-privs has the label '$LABEL'" } item5() { local so st so="$(dpkg-statoverride --list "$BWRAP" 2>/dev/null)" st="$(stat -c '%U %G %a' "$ROOT$BWRAP" 2>/dev/null)" [[ "$so" =~ ^root\ mosaic\ 0?750\ "$BWRAP"$ ]] || { fail "5 the statoverride on $BWRAP reads '$so', not 'root mosaic 0750'"; return; } [ "$st" = "root mosaic 750" ] || { fail "5 $BWRAP is '$st', not 'root mosaic 750'"; return; } pass "5 $BWRAP is root mosaic 0750, by the statoverride and by stat" } item6() { local userns links userns="$(cat "$ROOT/proc/sys/kernel/apparmor_restrict_unprivileged_userns" 2>/dev/null)" links="$(cat "$ROOT/proc/sys/fs/protected_hardlinks" 2>/dev/null)" [ "$userns" = 1 ] && [ "$links" = 1 ] || { fail "6 kernel.apparmor_restrict_unprivileged_userns is '$userns' and fs.protected_hardlinks is '$links', not 1 and 1"; return; } pass "6 kernel.apparmor_restrict_unprivileged_userns and fs.protected_hardlinks are 1" } item7() { local floor unit="$ROOT/etc/systemd/system/mosaic-webui.service" ports port=0 p from floor="$(cat "$ROOT/proc/sys/net/ipv4/ip_unprivileged_port_start" 2>/dev/null)" [[ "$floor" =~ ^[0-9]{1,5}$ ]] || { fail "7 net.ipv4.ip_unprivileged_port_start is '$floor', not a number"; return; } if [ -e "$unit" ]; then ports="$(sed -n 's/^ExecStart=//p' "$unit" 2>/dev/null | grep -oE ' --port [0-9]{1,5}( |$)' | tr -dc '0-9\n')" [ -n "$ports" ] || { fail "7 /etc/systemd/system/mosaic-webui.service has no --port"; return; } for p in $ports; do [ "$((10#$p))" -le "$port" ] || port="$((10#$p))"; done from="the Console's --port $port" else port=1023 from="1023, because no Console unit is installed" fi [ "$((10#$floor))" -gt "$port" ] || { fail "7 net.ipv4.ip_unprivileged_port_start is $floor, not above $from"; return; } pass "7 net.ipv4.ip_unprivileged_port_start is $floor, above $from" } items() { local v item1; item2; item3; item4; item5; item6; item7 v="$(dpkg -s bubblewrap 2>/dev/null | sed -n 's/^Version: //p')" if [ "$v" = "$PIN_BUBBLEWRAP_VERSION" ]; then echo "note bubblewrap $v is the install pin" else echo "note bubblewrap ${v:-unknown} is not the install pin ${PIN_BUBBLEWRAP_VERSION:-unknown}, which is not a drift item" fi } # The stop: unload the profile if it is loaded, then close bwrap to root. Prints the results. stop_bwrap() { local unload=skipped chown_rc=0 chmod_rc=0 if [ "$LOADED" = 1 ]; then if [ -f "$ROOT$PROFILE" ]; then apparmor_parser -R "$ROOT$PROFILE" >/dev/null 2>&1; unload="rc=$?"; else unload=no-file; fi fi chown root:root "$ROOT$BWRAP" || chown_rc=$? chmod 0700 "$ROOT$BWRAP" || chmod_rc=$? echo "unload=$unload chown=$chown_rc chmod=$chmod_rc" } enforce() { local had=0 now result f ids="" [ ! -e "$DRIFT" ] || had=1 items [ ${#FAILED[@]} -gt 0 ] || [ "$had" = 1 ] || { echo "hostcheck: all seven hold"; exit 0; } umask 027 now="$(stamp)" if [ ${#FAILED[@]} -gt 0 ]; then mkdir -p "$(dirname "$DRIFT")" && for f in "${FAILED[@]}"; do echo "$now $f"; done >> "$DRIFT" || echo "hostcheck: cannot write $DRIFT" >&2 fi for f in "${FAILED[@]}"; do ids+="${ids:+,}${f%% *}"; done result="$(stop_bwrap)" mkdir -p "$(dirname "$LOGF")" && echo "$now enforce stop items=${ids:-none} drift-before=$had $result" >> "$LOGF" || echo "hostcheck: cannot write $LOGF" >&2 echo "hostcheck: bwrap stopped ($result); only root removes $DRIFT" exit 0 } upstream() { local tmp deb ver f sum tmp="$(mktemp -d)" || exit 1 trap 'rm -rf "$tmp"' EXIT (cd "$tmp" && apt-get download apparmor-profiles) >/dev/null 2>&1 || { echo "hostcheck: upstream: apt-get download apparmor-profiles failed" >&2; exit 1; } deb=("$tmp"/apparmor-profiles_*.deb) [ ${#deb[@]} = 1 ] && [ -f "${deb[0]}" ] || { echo "hostcheck: upstream: no single apparmor-profiles deb in the download" >&2; exit 1; } ver="$(dpkg-deb -f "${deb[0]}" Version 2>/dev/null)" && dpkg-deb -x "${deb[0]}" "$tmp/x" >/dev/null 2>&1 || { echo "hostcheck: upstream: cannot unpack ${deb[0]##*/}" >&2; exit 1; } f="$tmp/x/usr/share/apparmor/extra-profiles/bwrap-userns-restrict" if [ ! -f "$f" ]; then echo "upstream: DIFFERS: apparmor-profiles $ver has no bwrap-userns-restrict; the pin is $PIN_PROFILE_SHA256 from $PIN_PROFILES_VERSION. Send it to Mos as a new-pin request." exit 0 fi sum="$(sha256sum < "$f" | cut -d' ' -f1)" if [ "$sum" = "$PIN_PROFILE_SHA256" ]; then echo "upstream: apparmor-profiles $ver's bwrap-userns-restrict matches the pin from $PIN_PROFILES_VERSION" else echo "upstream: DIFFERS: apparmor-profiles $ver's bwrap-userns-restrict has sha256 $sum; the pin is $PIN_PROFILE_SHA256 from $PIN_PROFILES_VERSION. Send it to Mos as a new-pin request." diff -u --label "installed $PROFILE" --label "apparmor-profiles $ver" "$ROOT$PROFILE" "$f" || true fi exit 0 } case "$MODE" in check) items; [ ${#FAILED[@]} -eq 0 ] || exit 1; exit 0 ;; enforce) enforce ;; upstream) upstream ;; esac