Lands Darkwing's round 1 candidate, manifest 573ece95a4a9 (ad1b23ab2on darkwing/row240-r3, basec771a36f4), approved by Filbert (29945, rev 2696). Nine paths: scripts/case1-hostcheck.sh (new), scripts/case1-pins.sh (new), packages/cli/systemd/mosaic-hostcheck.service (new), packages/cli/tests/case1-hostcheck.test.mjs (new), scripts/stack-upgrade.sh, packages/cli/tests/stack-upgrade.test.mjs, docs/guides/host-run-sheet.md, docs/TOOLS.md and agents/darkwing/work/queue-240/BUILD.md. File modes match the candidate. case1-hostcheck.sh and stack-upgrade.sh are 100755. case1-pins.sh is 100644, because the host check sources it. Evidence: Darkwing's first slot run failed one test, the N1 test's after hook (ENOENT once the fixture had removed its root). The fixb45852daais test-only. The second run onb45852daawas green: singles 37/0, mutants M1-M8 8 of 8 killed, cli 639/0, scripts 585/0. Sincec771a36f4no commit touches the nine paths. Sage's gate on060a17b54plus the nine paths (06:45:05Z to 06:56:21Z): packages/cli 639 pass, 0 fail, and all ten scripts/test-*.sh 585 pass, 0 fail (out/, run.log). `queue review verify-commit 240` matches the manifest on all 9 paths. Filbert's notes: N1 (the boot unit orders only mosaic-bus@mosaic-stack) and N2 (the run sheet writes the apt hook before the first --check) are in docs/plans/DEFERRED.md. N3, repeated DRIFT lines after bwrap is closed, is by design and is noted with N2.
217 lines
11 KiB
Bash
Executable File
217 lines
11 KiB
Bash
Executable File
#!/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 reads `root mosaic 0750`, 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 0750 $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
|