Darkwing'sef8b4439e(darkwing/row245), Duckworth round 1 APPROVE (rev 2849, comment 30043), manifest 69bc05f4f706, 5 paths. Slot run sage-1961 (darkwing-0107): singles 23/0, mutants 16/16 killed, N1 and N3 among them. DESIGN.md:336-338 stays unedited, superseded by gosalyn-0087. Sage gate on856c1826bplus these 5 paths (12:24-12:35Z): case1-accept.test.mjs alone 23/0, cli 681/0, every scripts/test-*.sh 429/0.
466 lines
24 KiB
Bash
Executable File
466 lines
24 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Row 195 (#1693): the Case 1 acceptance on 10.1.1.53, design r7 section 5
|
|
# (agents/darkwing/work/queue-195/DESIGN.md). Deviations and decisions: agents/darkwing/work/queue-195/BUILD.md.
|
|
#
|
|
# case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N
|
|
#
|
|
# Root runs it on the Case 1 host after the install window, from a root-owned checkout. It reads the
|
|
# user, home, copy, node, claude and both ports from the args file, which must pass stack-upgrade.sh's
|
|
# rules and be a factory file for --business. Every command that runs the stack's code runs as
|
|
# `sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from /. Root itself reads ss,
|
|
# /proc, the account database and file metadata, and runs these: systemctl show and is-enabled (F1,
|
|
# P11), curl on the Console's loopback healthz (F2), sudo -l (P1, P3), sudo -u jarvis bwrap --version
|
|
# and hostcheck.sh --check (P9), and node to parse managed-settings.json (F9). F9 runs node only when
|
|
# --node is the pinned path and it and every directory above it are root's with no group or other
|
|
# write. The script changes nothing on the host but the message F3 sends and its reply, and it never
|
|
# opens claude-session.env.
|
|
#
|
|
# One line per check, `<id> <path> <mark> <evidence>`, then `count <mark> N` for each mark and a
|
|
# result line. F checks are `pass` or `FAIL`. P checks are `closed`, `accepted <ruling>` or
|
|
# `unnamed`, which means no Q182, Q183 or Mos ruling names the path; each unnamed line goes to Mos.
|
|
#
|
|
# Exit 0 when every F check passes, 1 when one fails, 2 on usage or a refusal (a bad args file, or
|
|
# --root misused). A P check never changes the exit.
|
|
#
|
|
# Test seam, for packages/cli/tests/case1-accept.test.mjs only. Root takes none:
|
|
# --root DIR prefixes the args file, /etc, /proc, the stack's home, the host check and node, and
|
|
# reads the account database from DIR/etc/passwd and DIR/etc/group instead of getent
|
|
set -uo pipefail
|
|
|
|
usage() { echo "usage: case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N" >&2; exit 2; }
|
|
refuse() { echo "case1-accept: refusing: $*" >&2; exit 2; }
|
|
BUSINESS="" ARGS_FILE="" CLAUDE_PIN="" NODE_PIN="" ROOT=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--business) [ $# -ge 2 ] && [[ "$2" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage; BUSINESS="$2"; shift 2 ;;
|
|
--args) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] || usage; ARGS_FILE="$2"; shift 2 ;;
|
|
--claude-pin) [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; CLAUDE_PIN="$2"; shift 2 ;;
|
|
--node-pin) [ $# -ge 2 ] && [[ "$2" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; NODE_PIN="$2"; shift 2 ;;
|
|
--root) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [ -d "$2" ] || usage; ROOT="${2%/}"; shift 2 ;;
|
|
*) usage ;;
|
|
esac
|
|
done
|
|
[ -n "$BUSINESS" ] && [ -n "$ARGS_FILE" ] && [ -n "$CLAUDE_PIN" ] && [ -n "$NODE_PIN" ] || usage
|
|
if [ "$(id -u)" = 0 ]; then
|
|
[ -z "$ROOT" ] || refuse "--root is a test seam, and root takes none"
|
|
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
|
else
|
|
[ -n "$ROOT" ] || refuse "run it as root"
|
|
fi
|
|
cd / || refuse "cannot cd to /"
|
|
|
|
# --- The args file, by stack-upgrade.sh's read_args rules, plus the serve ports ---
|
|
|
|
f="$ROOT$ARGS_FILE"
|
|
[ -f "$f" ] && [ ! -L "$f" ] || refuse "no regular file $ARGS_FILE"
|
|
st="$(stat -c '%u %a' -- "$f")" || refuse "cannot stat $ARGS_FILE"
|
|
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) ||
|
|
refuse "$ARGS_FILE must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })"
|
|
mapfile -t ARGS < "$f" || refuse "cannot read $ARGS_FILE"
|
|
S_USER="" S_HOME="" A_BUSINESS="" NODE="" COPY_ID="" CLAUDE="" PROFILE="" PORT="" TESTER_PORT=""
|
|
copies=0 past=""
|
|
for ((i = 0; i < ${#ARGS[@]}; i++)); do
|
|
a="${ARGS[i]}" v="${ARGS[i + 1]:-}"
|
|
if [ -n "$past" ]; then
|
|
case "$a" in --port) PORT="$v"; i=$((i + 1)) ;; --tester-port) TESTER_PORT="$v"; i=$((i + 1)) ;; esac
|
|
continue
|
|
fi
|
|
case "$a" in
|
|
--) past=1 ;;
|
|
--pm) ;;
|
|
--user) S_USER="$v"; i=$((i + 1)) ;;
|
|
--home) S_HOME="$v"; i=$((i + 1)) ;;
|
|
--business) A_BUSINESS="$v"; i=$((i + 1)) ;;
|
|
--node) NODE="$v"; i=$((i + 1)) ;;
|
|
--claude) CLAUDE="$v"; i=$((i + 1)) ;;
|
|
--profile) PROFILE="$v"; i=$((i + 1)) ;;
|
|
--copy) COPY_ID="$v"; copies=$((copies + 1)); i=$((i + 1)) ;;
|
|
*) refuse "$ARGS_FILE has $a before --, which case2-unit.sh does not take" ;;
|
|
esac
|
|
done
|
|
[ "$copies" = 1 ] || refuse "$ARGS_FILE needs exactly one --copy before --, not $copies"
|
|
[[ "$S_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] && [[ "$S_HOME" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$A_BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] &&
|
|
[[ "$NODE" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] && [[ "$CLAUDE" =~ ^/[A-Za-z0-9/._+@-]+$ ]] ||
|
|
refuse "$ARGS_FILE lacks a plain --user, --home, --business, --node, --claude or --copy"
|
|
[ "$PROFILE" = factory ] || refuse "$ARGS_FILE has --profile '${PROFILE}', not factory"
|
|
[ "$A_BUSINESS" = "$BUSINESS" ] || refuse "$ARGS_FILE is for business $A_BUSINESS, not $BUSINESS"
|
|
[[ "$PORT" =~ ^[1-9][0-9]{0,4}$ ]] && [[ "$TESTER_PORT" =~ ^[1-9][0-9]{0,4}$ ]] ||
|
|
refuse "$ARGS_FILE lacks a numeric --port or --tester-port after --"
|
|
|
|
COPY="$S_HOME/.local/share/mosaic-stack/copies/$COPY_ID"
|
|
DATA_ROOT="$S_HOME/.mosaic-dev"
|
|
CRED="$S_HOME/.config/mosaic-dev/secrets/claude-session.env"
|
|
BUSINESS_FILE="$S_HOME/.config/mosaic-dev/businesses/$BUSINESS.json"
|
|
BUS_UNIT="mosaic-bus@$BUSINESS.service" CON_UNIT=mosaic-webui.service
|
|
HOSTCHECK=/usr/local/lib/mosaic-stack/hostcheck.sh
|
|
SETTINGS=/etc/claude-code/managed-settings.json
|
|
LABEL_OUTER='bwrap (enforce)' LABEL_CHILD='bwrap//&unpriv_bwrap (enforce)'
|
|
AS_USER=(sudo -u "$S_USER" -H env "PATH=$(dirname "$NODE"):/usr/local/bin:/usr/bin:/bin")
|
|
WORK="$(mktemp -d)" || refuse "cannot make a temp dir"
|
|
trap 'rm -rf -- "$WORK"' EXIT
|
|
|
|
# --- Output ---
|
|
|
|
MARKS=(pass FAIL closed "accepted Q182+Q183" "accepted Q183" "accepted mos-1096" "accepted mos-1101" unnamed)
|
|
declare -A COUNT=()
|
|
F_FAILED=0
|
|
# Text the stack's user wrote, made safe to print: printable ASCII, one line, at most 200 bytes.
|
|
clean() { tr -c '[:print:]' ' ' <<< "$1" | cut -c1-200 | sed 's/ *$//'; }
|
|
line() { local id="$1" path="$2" mark="$3"; shift 3; echo "$id $path $mark $*"; COUNT[$mark]=$(( ${COUNT[$mark]:-0} + 1 )); }
|
|
fpass() { line "$1" "$2" pass "${@:3}"; }
|
|
ffail() { line "$1" "$2" FAIL "${@:3}"; F_FAILED=1; }
|
|
|
|
# --- The account database: getent on the host, the seam's files under --root ---
|
|
|
|
db() {
|
|
if [ -n "$ROOT" ]; then
|
|
if [ $# -gt 1 ]; then awk -F: -v k="$2" '$1 == k' "$ROOT/etc/$1"; else cat "$ROOT/etc/$1"; fi
|
|
else
|
|
getent "$@"
|
|
fi
|
|
}
|
|
uid_of() { db passwd "$1" | cut -d: -f3; }
|
|
gid_of() { db passwd "$1" | cut -d: -f4; }
|
|
# The group ids USER belongs to, as `id -G USER` prints them: the primary gid, then each group
|
|
# whose member list names USER.
|
|
groups_of() {
|
|
local g; g="$(gid_of "$1")"
|
|
[ -n "$g" ] || return 0
|
|
{ echo "$g"; db group | awk -F: -v u="$1" '{ n = split($4, m, ","); for (i = 1; i <= n; i++) if (m[i] == u) print $3 }'; } | awk '!seen[$0]++'
|
|
}
|
|
names_of_groups() { local g; for g in "$@"; do db group | awk -F: -v g="$g" '$3 == g { print $1 }'; done; }
|
|
MOSAIC_UID="$(uid_of "$S_USER")"
|
|
|
|
# --- systemd, /proc and listeners ---
|
|
|
|
# KEY=VALUE lines for UNIT's properties.
|
|
unit_show() { local u="$1"; shift; systemctl show "$u" "${@/#/-p}" 2>/dev/null; }
|
|
prop() { sed -n "s/^$2=//p" <<< "$1" | tail -n 1; }
|
|
# The real, effective, saved and filesystem uids of PID, space-separated.
|
|
proc_uids() { awk '$1 == "Uid:" { print $2, $3, $4, $5 }' "$ROOT/proc/$1/status" 2>/dev/null; }
|
|
proc_label() { local l; l="$(cat "$ROOT/proc/$1/attr/current" 2>/dev/null)" || return 1; printf '%s' "${l%$'\n'}"; }
|
|
# One `ADDRESS PIDS` line per TCP listener on port $1, PIDS comma-separated.
|
|
listeners() {
|
|
ss -Hltnp 2>/dev/null | awk -v p="$1" '{
|
|
n = split($4, a, ":"); if (a[n] != p) next
|
|
addr = substr($4, 1, length($4) - length(p) - 1); pids = ""; s = $0
|
|
while (match(s, /pid=[0-9]+/)) { pids = pids (pids == "" ? "" : ",") substr(s, RSTART + 4, RLENGTH - 4); s = substr(s, RSTART + RLENGTH) }
|
|
print addr, pids
|
|
}'
|
|
}
|
|
# 0 when port $1 has a listener and every one is on 127.0.0.1, held by pid $2 alone.
|
|
loopback_by() {
|
|
local addr pids p any=0
|
|
[ -n "$2" ] && [ "$2" != 0 ] || return 1
|
|
while read -r addr pids; do
|
|
[ -n "$addr" ] || continue
|
|
any=1
|
|
[ "$addr" = 127.0.0.1 ] || return 1
|
|
for p in ${pids//,/ }; do [ "$p" = "$2" ] || return 1; done
|
|
[ -n "$pids" ] || return 1
|
|
done <<< "$(listeners "$1")"
|
|
[ "$any" = 1 ]
|
|
}
|
|
listener_text() { local t; t="$(listeners "$1" | tr '\n' ';')"; echo "${t:-none}"; }
|
|
|
|
BUS_SHOW="$(unit_show "$BUS_UNIT" ActiveState User MainPID)"
|
|
CON_SHOW="$(unit_show "$CON_UNIT" ActiveState User MainPID)"
|
|
CON_PID="$(prop "$CON_SHOW" MainPID)"
|
|
|
|
# --- F checks ---
|
|
|
|
check_f1() {
|
|
local u show state user pid uids why=""
|
|
for u in "$BUS_UNIT" "$CON_UNIT"; do
|
|
if [ "$u" = "$BUS_UNIT" ]; then show="$BUS_SHOW"; else show="$CON_SHOW"; fi
|
|
state="$(prop "$show" ActiveState)" user="$(prop "$show" User)" pid="$(prop "$show" MainPID)"
|
|
uids="$(proc_uids "${pid:-0}")"
|
|
[ "$state" = active ] || { why+="$u is ${state:-unknown}; "; continue; }
|
|
[ "$user" = "$S_USER" ] || { why+="$u has User=${user:-unset}; "; continue; }
|
|
[ -n "$MOSAIC_UID" ] && [ "$uids" = "$MOSAIC_UID $MOSAIC_UID $MOSAIC_UID $MOSAIC_UID" ] ||
|
|
why+="$u MainPID ${pid:-none} has uids '${uids:-unreadable}', not $S_USER's ${MOSAIC_UID:-unknown}; "
|
|
done
|
|
if [ -z "$why" ]; then fpass F1 units-user "$BUS_UNIT and $CON_UNIT are active as $S_USER (uid $MOSAIC_UID)"
|
|
else ffail F1 units-user "${why%; }"; fi
|
|
}
|
|
|
|
check_f2() {
|
|
local rc=0
|
|
curl -fsS --max-time 10 -o /dev/null "http://127.0.0.1:$PORT/healthz" 2>/dev/null || rc=$?
|
|
if [ "$rc" != 0 ]; then ffail F2 console-loopback "curl http://127.0.0.1:$PORT/healthz exited $rc"
|
|
elif ! loopback_by "$PORT" "$CON_PID"; then ffail F2 console-loopback "port $PORT listeners are '$(listener_text "$PORT")', not 127.0.0.1 by the Console's MainPID ${CON_PID:-none}"
|
|
else fpass F2 console-loopback "healthz answers, and $PORT is bound to 127.0.0.1 only, by MainPID $CON_PID"; fi
|
|
}
|
|
|
|
# F5's scan: an outer /usr/bin/bwrap owned by the stack's user with a claude descendant. Sets F5_OK
|
|
# when one pair has both labels exactly, F5_WHY to the last mismatch otherwise.
|
|
F5_OK="" F5_WHY="no /usr/bin/bwrap process of $S_USER with a claude descendant was seen while the reply was pending"
|
|
scan_f5() {
|
|
local pid ppid uids kids q c argv0 comm lo lc
|
|
local -A parent=() children=() bw=()
|
|
# One read of every PPid line and one of every exe link, so a scan stays short on a busy host.
|
|
while read -r pid ppid; do
|
|
parent[$pid]="$ppid" children[$ppid]+=" $pid"
|
|
done < <(grep -H '^PPid:' "$ROOT"/proc/[0-9]*/status 2>/dev/null | sed -n 's#^.*/proc/\([0-9]*\)/status:PPid:[[:space:]]*\([0-9]*\)$#\1 \2#p')
|
|
while read -r pid; do
|
|
[[ "$pid" =~ ^[0-9]+$ ]] && [ -n "${parent[$pid]:-}" ] && bw[$pid]=1
|
|
done < <(find "$ROOT/proc" -mindepth 2 -maxdepth 2 -name exe -lname /usr/bin/bwrap -printf '%h\n' 2>/dev/null | sed 's#.*/##')
|
|
for pid in "${!bw[@]}"; do
|
|
[ -z "${bw[${parent[$pid]}]:-}" ] || continue
|
|
uids="$(proc_uids "$pid")"
|
|
[ -n "$MOSAIC_UID" ] && [ "${uids%% *}" = "$MOSAIC_UID" ] || continue
|
|
# Every descendant, breadth first.
|
|
kids=() q=("$pid")
|
|
while [ ${#q[@]} -gt 0 ]; do
|
|
for c in ${children[${q[0]}]:-}; do kids+=("$c") q+=("$c"); done
|
|
q=("${q[@]:1}")
|
|
done
|
|
for c in "${kids[@]}"; do
|
|
argv0="$(tr '\0' '\n' < "$ROOT/proc/$c/cmdline" 2>/dev/null | sed -n 1p)"
|
|
comm="$(cat "$ROOT/proc/$c/comm" 2>/dev/null)"
|
|
[[ "${argv0##*/}" == claude* ]] || [[ "$comm" == claude* ]] || continue
|
|
lo="$(proc_label "$pid")" lc="$(proc_label "$c")"
|
|
if [ "$lo" = "$LABEL_OUTER" ] && [ "$lc" = "$LABEL_CHILD" ]; then
|
|
F5_OK="bwrap $pid reads '$lo' and its claude descendant $c reads '$lc'"; return 0
|
|
fi
|
|
F5_WHY="bwrap $pid reads '$lo' and its claude descendant $c reads '$lc', not '$LABEL_OUTER' and '$LABEL_CHILD'"
|
|
done
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# F3 and F5 together: the message goes out in the background and the scan runs until it is answered.
|
|
check_f3_f5() {
|
|
local marker text rc=0 out err n tpid
|
|
marker="case1-accept-$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
|
|
text="$marker: this is the Case 1 acceptance; reply with one short line."
|
|
timeout 330 "${AS_USER[@]}" "$COPY/scripts/mosaic" talk chief "$text" --business "$BUSINESS" --tester --wait 300 > "$WORK/talk.out" 2> "$WORK/talk.err" &
|
|
tpid=$!
|
|
while :; do
|
|
scan_f5 && break
|
|
kill -0 "$tpid" 2>/dev/null || break
|
|
sleep 0.5
|
|
done
|
|
wait "$tpid" || rc=$?
|
|
# Both the sent line and the reply are on stdout; an error is on stderr.
|
|
out="$(cat "$WORK/talk.out")" err="$(cat "$WORK/talk.err")"
|
|
n="$(grep -c . <<< "$out")"
|
|
if [ "$rc" = 0 ] && grep -q "^sent [^ ]* to chief" <<< "$out" && [ "$n" -ge 2 ]; then
|
|
fpass F3 tester-reply "chief replied to $marker"
|
|
else
|
|
ffail F3 tester-reply "talk exited $rc with $n lines on stdout; last: $(clean "$(tail -n 1 <<< "${err:-$out}")")"
|
|
fi
|
|
if [ -n "$F5_OK" ]; then F5_LINE=(fpass "$F5_OK"); else F5_LINE=(ffail "$F5_WHY"); fi
|
|
}
|
|
|
|
check_f4() {
|
|
local rc=0 out
|
|
out="$("${AS_USER[@]}" "$COPY/scripts/case2-precheck.sh" --business "$BUSINESS" --profile factory 2>&1)" || rc=$?
|
|
if [ "$rc" = 0 ]; then fpass F4 precheck "case2-precheck.sh --profile factory exits 0"
|
|
else ffail F4 precheck "case2-precheck.sh --profile factory exited $rc; last: $(clean "$(tail -n 1 <<< "$out")")"; fi
|
|
}
|
|
|
|
check_f6() {
|
|
local want="/opt/mosaic-stack/claude-$CLAUDE_PIN/" v
|
|
[[ "$CLAUDE" == "$want"* ]] || { ffail F6 claude-pin "--claude $CLAUDE is not under $want"; return; }
|
|
[[ "$CLAUDE/" != */../* ]] || { ffail F6 claude-pin "--claude $CLAUDE has a .. segment"; return; }
|
|
v="$("${AS_USER[@]}" "$CLAUDE" --version 2>/dev/null | sed -n 1p)"
|
|
if [ "$v" = "$CLAUDE_PIN (Claude Code)" ]; then fpass F6 claude-pin "$CLAUDE prints '$v'"
|
|
else ffail F6 claude-pin "$CLAUDE --version prints '$(clean "$v")', not '$CLAUDE_PIN (Claude Code)'"; fi
|
|
}
|
|
|
|
# The PM check confirm runs (stack-release.sh pm_external): exactly one live launches list entry for
|
|
# launch.by, for this business. The stack's user runs the whole check and prints one result line.
|
|
F7_JS='
|
|
const {readFileSync}=require("node:fs");const {spawnSync}=require("node:child_process");
|
|
const [file,cli,b]=process.argv.slice(1);
|
|
let by;
|
|
try{by=JSON.parse(readFileSync(file,"utf8")).launch?.by}catch(e){console.log(`fail cannot read ${file}: ${e.code??"not JSON"}`);process.exit(0)}
|
|
if(typeof by!=="string"||!by){console.log("fail the business file has no launch.by");process.exit(0)}
|
|
const r=spawnSync(cli,["launches","list","--json"],{encoding:"utf8"});
|
|
if(r.status!==0){console.log(`fail launches list exited ${r.status}`);process.exit(0)}
|
|
let l;try{l=JSON.parse(r.stdout)}catch{console.log("fail launches list printed no JSON");process.exit(0)}
|
|
const live=Array.isArray(l)?l.filter((x)=>x&&x.instance===by&&x.live===true):[];
|
|
console.log(live.length!==1?`fail ${live.length} live launches of ${by}`:live[0].business!==b?`fail the live launch of ${by} is for ${live[0].business}`:`ok ${by} run ${live[0].run}`);'
|
|
check_f7() {
|
|
local out
|
|
out="$("${AS_USER[@]}" "$NODE" -e "$F7_JS" -- "$BUSINESS_FILE" "$COPY/scripts/mosaic" "$BUSINESS" 2>/dev/null | sed -n 1p)"
|
|
if [[ "$out" =~ ^ok\ [a-z0-9][a-z0-9-]*\ run\ [A-Za-z0-9._-]+$ ]]; then fpass F7 pm-live "one live launch: ${out#ok }"
|
|
else ffail F7 pm-live "$(clean "${out#fail }")"; fi
|
|
}
|
|
|
|
check_f8() {
|
|
local s
|
|
s="$(stat -c '%U %a %F' -- "$ROOT$CRED" 2>/dev/null)" || s="missing"
|
|
if [ ! -L "$ROOT$CRED" ] && [ "$s" = "$S_USER 600 regular file" ]; then fpass F8 credential-file "$CRED is '$s'"
|
|
else ffail F8 credential-file "$CRED is '$s', not '$S_USER 600 regular file'"; fi
|
|
}
|
|
|
|
# Empty when $1 and every directory above it, up to /, are the running user's (root's on the host) with
|
|
# no group or other write: case2-unit.sh's root_only rule for --node, checked again before root runs it.
|
|
# Otherwise the first path that breaks the rule. A symlink breaks it, because its mode is 777.
|
|
not_root_only() {
|
|
local p="$1" st
|
|
while :; do
|
|
st="$(stat -c '%u %a' -- "$ROOT$p" 2>/dev/null)" || { echo "$p (cannot stat)"; return; }
|
|
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || { echo "$p (uid ${st% *}, mode ${st#* })"; return; }
|
|
[ "$p" = / ] && return
|
|
p="$(dirname -- "$p")"
|
|
done
|
|
}
|
|
|
|
check_f9() {
|
|
local s v bad
|
|
s="$(stat -c '%U:%G %a %F' -- "$ROOT$SETTINGS" 2>/dev/null)" || s="missing"
|
|
[ ! -L "$ROOT$SETTINGS" ] && [ "$s" = "root:root 644 regular file" ] || { ffail F9 autoupdater-off "$SETTINGS is '$s', not 'root:root 644 regular file'"; return; }
|
|
# Root runs node here, so it runs only the pinned path, and only when nothing but root can replace it.
|
|
[ "$NODE" = "/opt/mosaic-stack/node-$NODE_PIN/bin/node" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: it is not the --node-pin path"; return; }
|
|
bad="$(not_root_only "$NODE")"
|
|
[ -z "$bad" ] || { ffail F9 autoupdater-off "root won't run --node $NODE: $bad is not root's alone"; return; }
|
|
v="$("$ROOT$NODE" -e 'const s=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(JSON.stringify(s?.env?.DISABLE_AUTOUPDATER??null))' -- "$ROOT$SETTINGS" 2>/dev/null)" || v="unreadable"
|
|
if [ "$v" = '"1"' ]; then fpass F9 autoupdater-off "$SETTINGS is root:root 0644 with env.DISABLE_AUTOUPDATER \"1\""
|
|
else ffail F9 autoupdater-off "$SETTINGS env.DISABLE_AUTOUPDATER is $v, not \"1\""; fi
|
|
}
|
|
|
|
check_f10() {
|
|
local want="/opt/mosaic-stack/node-$NODE_PIN/bin/node" v
|
|
[ "$NODE" = "$want" ] || { ffail F10 node-pin "--node $NODE is not $want"; return; }
|
|
v="$("${AS_USER[@]}" "$NODE" --version 2>/dev/null | sed -n 1p)"
|
|
if [ "$v" = "$NODE_PIN" ]; then fpass F10 node-pin "$NODE prints $v"
|
|
else ffail F10 node-pin "$NODE --version prints '$(clean "$v")', not $NODE_PIN"; fi
|
|
}
|
|
|
|
check_f11() {
|
|
local d="$ROOT$DATA_ROOT/discord" found
|
|
if [ -L "$d" ]; then ffail F11 no-discord "$DATA_ROOT/discord is a symlink"; return; fi
|
|
[ -d "$d" ] || { fpass F11 no-discord "$DATA_ROOT/discord is absent"; return; }
|
|
found="$(find "$d" -maxdepth 1 -name '*.json' -printf '%f ' 2>/dev/null)"
|
|
if [ -z "$found" ]; then fpass F11 no-discord "$DATA_ROOT/discord holds no binding"
|
|
else ffail F11 no-discord "$DATA_ROOT/discord holds bindings: ${found% }"; fi
|
|
}
|
|
|
|
# --- P checks ---
|
|
|
|
check_p1() {
|
|
local out
|
|
out="$(sudo -l -U "$S_USER" 2>&1)"
|
|
if grep -q "is not allowed to run sudo" <<< "$out"; then line P1 "$S_USER-sudo" closed "sudo -l: $(clean "$(sed -n 1p <<< "$out")")"
|
|
else line P1 "$S_USER-sudo" unnamed "sudo -l: $(clean "$(tr '\n' ' ' <<< "$out")")"; fi
|
|
}
|
|
|
|
check_p2() {
|
|
local gs keys="$ROOT$S_HOME/.ssh/authorized_keys" kstate n
|
|
gs="$(groups_of "$S_USER" | tr '\n' ' ')"; gs="${gs% }"
|
|
n="$(wc -w <<< "$gs")"
|
|
if [ ! -e "$keys" ] && [ ! -L "$keys" ]; then kstate=absent
|
|
elif [ -f "$keys" ] && [ ! -L "$keys" ] && [ ! -s "$keys" ]; then kstate=empty
|
|
else kstate=present; fi
|
|
if [ "$n" = 1 ] && [ "$kstate" != present ]; then line P2 "$S_USER-groups-keys" closed "id -G is '$gs'; authorized_keys is $kstate"
|
|
else line P2 "$S_USER-groups-keys" unnamed "id -G is '$gs'; authorized_keys is $kstate"; fi
|
|
}
|
|
|
|
check_p3() {
|
|
local n
|
|
n="$(sudo -l -U localadmin 2>&1 | grep -c NOPASSWD)"
|
|
line P3 localadmin-sudo "accepted Q183" "sudo -l -U localadmin lists $n NOPASSWD lines"
|
|
}
|
|
|
|
check_p4() { line P4 pve-root-pbs "accepted Q182+Q183" "root on pve over VM 1008, and PBS backups that hold $S_HOME with the credential; this script can't see pve"; }
|
|
|
|
check_p5() {
|
|
local gs reach
|
|
if [ -z "$(uid_of jarvis)" ]; then line P5 jarvis-reach closed "no jarvis account"; return; fi
|
|
gs="$(names_of_groups $(groups_of jarvis) | tr '\n' ' ')"
|
|
reach="$(tr ' ' '\n' <<< "$gs" | grep -xE 'sudo|docker|lxd' | tr '\n' ' ')"
|
|
if [ -z "$reach" ]; then line P5 jarvis-reach closed "jarvis is in none of sudo, docker and lxd (groups: ${gs% })"
|
|
else line P5 jarvis-reach unnamed "jarvis is in ${reach% }"; fi
|
|
}
|
|
|
|
check_p6() {
|
|
local g m members bad=0
|
|
for g in sudo docker lxd; do
|
|
members="$(db group "$g" | cut -d: -f4)"
|
|
for m in ${members//,/ }; do
|
|
[ "$g" = sudo ] && [ "$m" = localadmin ] && continue
|
|
line P6 "group-$g" unnamed "$m is a member of $g"; bad=1
|
|
done
|
|
done
|
|
[ "$bad" = 1 ] || line P6 group-members closed "sudo holds only localadmin, and docker and lxd have no members"
|
|
}
|
|
|
|
check_p7() {
|
|
local found
|
|
found="$(grep -rlw -- "$S_USER" "$ROOT/etc/sudoers.d" 2>/dev/null | sed "s#^$ROOT##" | tr '\n' ' ')"
|
|
if [ -z "$found" ]; then line P7 sudoers-d closed "no file under /etc/sudoers.d names $S_USER"
|
|
else line P7 sudoers-d unnamed "files naming $S_USER: ${found% }"; fi
|
|
}
|
|
|
|
check_p8() {
|
|
local other
|
|
other="$(listeners 7331)"
|
|
if loopback_by "$PORT" "$CON_PID" && loopback_by "$TESTER_PORT" "$CON_PID" && [ -z "$other" ]; then
|
|
line P8 lan-listeners closed "$PORT and $TESTER_PORT are on 127.0.0.1 only, by MainPID $CON_PID; nothing on 7331"
|
|
else
|
|
line P8 lan-listeners unnamed "$PORT: $(listener_text "$PORT"); $TESTER_PORT: $(listener_text "$TESTER_PORT"); 7331: $(listener_text 7331); Console MainPID ${CON_PID:-none}"
|
|
fi
|
|
}
|
|
|
|
check_p9() {
|
|
local out n missing=() rc=0 err mg others
|
|
out="$("$ROOT$HOSTCHECK" --check 2>/dev/null)"
|
|
for n in 1 2 3 4 5 6; do grep -q "^ok $n " <<< "$out" || missing+=("hostcheck item $n has no ok line")
|
|
done
|
|
err="$(sudo -u jarvis /usr/bin/bwrap --version 2>&1 >/dev/null)" || rc=$?
|
|
[ "$rc" != 0 ] && grep -q "Permission denied" <<< "$err" || missing+=("sudo -u jarvis /usr/bin/bwrap --version exited $rc without Permission denied")
|
|
mg="$(gid_of "$S_USER")"
|
|
[ -n "$mg" ] && [ -z "$(db group "$S_USER" | cut -d: -f4)" ] || missing+=("group $S_USER lists members, or $S_USER has no group")
|
|
others="$(db passwd | awk -F: -v g="$mg" -v u="$S_USER" '$4 == g && $1 != u { print $1 }' | tr '\n' ' ')"
|
|
[ -z "$others" ] || missing+=("other accounts have $S_USER's gid $mg: ${others% }")
|
|
if [ ${#missing[@]} = 0 ]; then
|
|
line P9 bwrap-userns "accepted mos-1096" "hostcheck items 1-6 ok, jarvis is denied bwrap, and group $S_USER has no other member"
|
|
else
|
|
for n in "${missing[@]}"; do line P9 bwrap-userns unnamed "$n"; done
|
|
fi
|
|
}
|
|
|
|
check_p10() {
|
|
local floor
|
|
floor="$(cat "$ROOT/proc/sys/net/ipv4/ip_unprivileged_port_start" 2>/dev/null)"
|
|
if [[ "$floor" =~ ^[0-9]+$ ]] && [ "$floor" -gt "$PORT" ] && loopback_by "$TESTER_PORT" "$CON_PID"; then
|
|
line P10 tester-port-squat "accepted mos-1101" "the port floor $floor is above $PORT, and MainPID $CON_PID holds $TESTER_PORT"
|
|
else
|
|
line P10 tester-port-squat unnamed "the port floor is '${floor:-unreadable}' against $PORT; $TESTER_PORT: $(listener_text "$TESTER_PORT"); Console MainPID ${CON_PID:-none}"
|
|
fi
|
|
}
|
|
|
|
check_p11() {
|
|
local en before
|
|
en="$(systemctl is-enabled mosaic-hostcheck.service 2>/dev/null)"
|
|
before="$(prop "$(unit_show mosaic-hostcheck.service Before)" Before)"
|
|
if [ "$en" = enabled ] && grep -qxF -- "$BUS_UNIT" <<< "${before// /$'\n'}"; then
|
|
line P11 hostcheck-before-bus closed "mosaic-hostcheck.service is enabled, and its Before= names $BUS_UNIT"
|
|
else
|
|
line P11 hostcheck-before-bus unnamed "mosaic-hostcheck.service is '${en:-unknown}', Before='$before'; it needs enabled and $BUS_UNIT"
|
|
fi
|
|
}
|
|
|
|
check_f1
|
|
check_f2
|
|
F5_LINE=()
|
|
check_f3_f5
|
|
check_f4
|
|
"${F5_LINE[0]}" F5 bwrap-label "${F5_LINE[@]:1}"
|
|
check_f6
|
|
check_f7
|
|
check_f8
|
|
check_f9
|
|
check_f10
|
|
check_f11
|
|
check_p1; check_p2; check_p3; check_p4; check_p5; check_p6
|
|
check_p7; check_p8; check_p9; check_p10; check_p11
|
|
|
|
for m in "${MARKS[@]}"; do echo "count $m ${COUNT[$m]:-0}"; done
|
|
if [ "$F_FAILED" = 0 ]; then echo "case1-accept: every F check passes; send the whole output to Duckworth, and each unnamed line to Mos"; exit 0; fi
|
|
echo "case1-accept: an F check failed, so the row fails"
|
|
exit 1
|