707 lines
36 KiB
Bash
Executable File
707 lines
36 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, main port and tester socket marker 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. A bounded config/target query imports the copy only as the stack
|
|
# user, with sudo and an empty environment. Root parses its two facts in bash. P10 uses the
|
|
# fixed system Python, also as the stack user, for a read-only SO_PEERCRED observation.
|
|
# The config/probe supervisor is fixed root-validated system Bash plus setsid
|
|
# and sleep, after the uid drop: query inner/outer 5/7 s, probe 2/4 s. Root
|
|
# independently bounds its sudo wait. Legacy AS_USER talk and diagnostics below
|
|
# retain their old behavior and are NOT covered by this supervisor (F5 follow-up).
|
|
#
|
|
# 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_SOCKET=0
|
|
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)) ;;
|
|
--business) [ "$v" = "$BUSINESS" ] || refuse "serve business differs"; i=$((i + 1)) ;;
|
|
--tester-socket) TESTER_SOCKET=$((TESTER_SOCKET + 1)) ;;
|
|
--tester-port) refuse "case1-accept.sh retires tester TCP; use --tester-socket" ;;
|
|
*) refuse "unknown serve flag $a" ;;
|
|
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,3}$ ]] && [ "$PORT" -le 1023 ] || refuse "$ARGS_FILE lacks a privileged numeric --port after --"
|
|
[ "$TESTER_SOCKET" = 1 ] || refuse "$ARGS_FILE needs one bare --tester-socket 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
|
|
|
|
# Row253: bounded unprivileged metadata, parsed by root without running copy code.
|
|
# row253-supervisor-begin
|
|
IFS= read -r -d '' TESTER_SUPERVISOR <<'MOSAIC_TESTER_SUPERVISOR' || :
|
|
set +m
|
|
seconds="$1" setsid_bin="$2" sleep_bin="$3"; shift 3
|
|
child="" timer="" verified=0
|
|
stat_fields() {
|
|
local pid="$1" raw="" rest
|
|
# EOF is expected: stat has no NUL. Decide on content, not read's status.
|
|
IFS= read -r -d '' raw < "/proc/$pid/stat" 2>/dev/null || :
|
|
[ -n "$raw" ] || { [ -e "/proc/$pid/stat" ] && return 1; return 3; }
|
|
rest="${raw##*) }"
|
|
[ "$rest" != "$raw" ] || return 1
|
|
read -r ST_STATE ST_PARENT ST_GROUP ST_SESSION _ <<< "$rest"
|
|
[[ "$ST_STATE" =~ ^[A-Za-z]$ && "$ST_SESSION" =~ ^[0-9]+$ && "$ST_GROUP" =~ ^[0-9]+$ ]]
|
|
}
|
|
clock_cs() {
|
|
local boot spare
|
|
read -r boot spare < /proc/uptime || return 1
|
|
[[ "$boot" =~ ^([0-9]+)\.([0-9]{2})$ ]] || return 1
|
|
CLOCK_CS=$((10#${BASH_REMATCH[1]} * 100 + 10#${BASH_REMATCH[2]}))
|
|
}
|
|
clean_session() {
|
|
local entry pid rc live until=""
|
|
while :; do
|
|
live=0
|
|
for entry in /proc/[0-9]*/stat; do
|
|
pid="${entry%/stat}"; pid="${pid##*/}"
|
|
rc=0; stat_fields "$pid" || rc=$?
|
|
[ "$rc" != 3 ] || continue
|
|
[ "$rc" = 0 ] || return 1
|
|
[ "$ST_SESSION" = "$child" ] || continue
|
|
case "$ST_STATE" in Z|X) continue ;; esac
|
|
live=1
|
|
if ! kill -KILL "$pid" 2>/dev/null; then
|
|
rc=0; stat_fields "$pid" || rc=$?
|
|
[ "$rc" = 3 ] || { [ "$rc" = 0 ] && { [ "$ST_STATE" = Z ] || [ "$ST_STATE" = X ]; }; } || return 1
|
|
fi
|
|
done
|
|
[ "$live" != 0 ] || return 0
|
|
clock_cs || return 1
|
|
# Inventory pass 1 is outside the bounded confirmation/retry budget.
|
|
[ -n "$until" ] || until=$((CLOCK_CS + 25))
|
|
[ "$CLOCK_CS" -lt "$until" ] || return 1
|
|
"$sleep_bin" 0.01
|
|
done
|
|
}
|
|
finish() {
|
|
local rc="$?"
|
|
trap - EXIT HUP INT TERM
|
|
if [ "$verified" = 1 ]; then
|
|
clean_session || rc=86
|
|
elif [ -n "$child" ]; then
|
|
kill -KILL "$child" 2>/dev/null || :
|
|
fi
|
|
[ -z "$child" ] || wait "$child" 2>/dev/null || :
|
|
if [ -n "$timer" ]; then kill -KILL "$timer" 2>/dev/null || :; wait "$timer" 2>/dev/null || :; fi
|
|
exit "$rc"
|
|
}
|
|
trap finish EXIT
|
|
trap 'exit 88' HUP INT TERM
|
|
"$sleep_bin" "$seconds" & timer=$!
|
|
"$setsid_bin" "$@" & child=$!
|
|
# Allow session creation the whole inner budget, rather than a 200 ms margin.
|
|
while :; do
|
|
rc=0; stat_fields "$child" || rc=$?
|
|
[ "$rc" = 0 ] || exit 85
|
|
if [ "$ST_SESSION" = "$child" ]; then verified=1; break; fi
|
|
kill -0 "$timer" 2>/dev/null || exit 81
|
|
"$sleep_bin" 0.002
|
|
done
|
|
[ "$verified" = 1 ] || exit 85
|
|
while kill -0 "$child" 2>/dev/null; do
|
|
kill -0 "$timer" 2>/dev/null || exit 81
|
|
"$sleep_bin" 0.01
|
|
done
|
|
kill -0 "$timer" 2>/dev/null || exit 81
|
|
rc=0; wait "$child" || rc=$?
|
|
[ "$rc" = 0 ] || exit 87
|
|
exit 0
|
|
MOSAIC_TESTER_SUPERVISOR
|
|
# row253-supervisor-end
|
|
|
|
tester_tools() {
|
|
local input target p st magic
|
|
TS_BASH="" TS_SETSID="" TS_SLEEP="" TS_TIMEOUT="" TS_HEAD=""
|
|
for input in /bin/bash /usr/bin/setsid /usr/bin/sleep /usr/bin/timeout /usr/bin/head; do
|
|
target="$(realpath -e -- "$input" 2>/dev/null)" || return 1
|
|
case "$target" in /home/*|/root/*|/run/user/*) return 1 ;; esac
|
|
[ -f "$target" ] && [ -x "$target" ] && [ ! -L "$target" ] || return 1
|
|
p="$target"
|
|
while :; do
|
|
st="$(stat -c '%u %a' -- "$p" 2>/dev/null)" || return 1
|
|
[ "${st% *}" = 0 ] && [[ "${st#* }" =~ ^[0-7]{3,4}$ ]] && (( (8#${st#* } & 8#022) == 0 )) || return 1
|
|
[ "$p" != / ] || break
|
|
p="${p%/*}"; [ -n "$p" ] || p=/
|
|
done
|
|
magic="$(/usr/bin/od -An -tx1 -N4 -- "$target" 2>/dev/null | tr -d ' \n')"
|
|
[ "$magic" = 7f454c46 ] || return 1
|
|
case "$input" in
|
|
/bin/bash) TS_BASH="$target" ;; /usr/bin/setsid) TS_SETSID="$target" ;;
|
|
/usr/bin/sleep) TS_SLEEP="$target" ;; /usr/bin/timeout) TS_TIMEOUT="$target" ;;
|
|
/usr/bin/head) TS_HEAD="$target" ;;
|
|
esac
|
|
done
|
|
}
|
|
tester_capture_reason() {
|
|
case "$1" in
|
|
81) echo inner-timeout ;; 82) echo outer-timeout ;; 83) echo output-limit ;;
|
|
84) echo supervisor-tools ;; 85) echo inner-session ;; 86) echo inner-cleanup ;;
|
|
*) echo inner-command ;;
|
|
esac
|
|
}
|
|
tester_capture() (
|
|
local inner="$1" outer="$2" maximum="$3" user="$4" home="$5" path="$6" file notice child="" rc=0; shift 6
|
|
tester_tools || exit 84
|
|
file="$(mktemp)" || exit 88
|
|
notice="$(mktemp)" || { rm -f -- "$file"; exit 88; }
|
|
trap 'if [ -n "$child" ]; then kill -KILL -- "-$child" 2>/dev/null || :; wait "$child" 2>/dev/null || :; fi; rm -f -- "$file" "$notice"' EXIT
|
|
trap 'exit 88' HUP INT TERM
|
|
LC_ALL=C "$TS_TIMEOUT" --verbose --signal=KILL "${outer}s" "$TS_BASH" -c '
|
|
set -o pipefail
|
|
maximum="$1"; head_bin="$2"; shift 2
|
|
"$@" 2>/dev/null | "$head_bin" -c "$((maximum + 1))"
|
|
' -- "$maximum" "$TS_HEAD" sudo -u "$user" -H env -i "HOME=$home" "PATH=$path" "$TS_BASH" -c "$TESTER_SUPERVISOR" -- "$inner" "$TS_SETSID" "$TS_SLEEP" "$@" > "$file" 2> "$notice" &
|
|
child=$!
|
|
wait "$child" 2>/dev/null || rc=$?
|
|
kill -KILL -- "-$child" 2>/dev/null || :
|
|
# Only GNU timeout and the trusted head can write this root-side channel.
|
|
if [ "$rc" = 137 ] && [[ "$(<"$notice")" == *': sending signal KILL to command '* ]]; then exit 82; fi
|
|
[ "$(wc -c < "$file")" -le "$maximum" ] || exit 83
|
|
[ "$rc" = 0 ] || { case "$rc" in 81|85|86|87|88) exit "$rc" ;; *) exit 87 ;; esac; }
|
|
cat -- "$file"
|
|
)
|
|
|
|
TESTER_QUERY='import {readFileSync} from "node:fs";import {pathToFileURL} from "node:url";const m=await import(pathToFileURL(process.argv[1]).href);const s=m.loadSystem();console.log(JSON.stringify({dataRoot:s.dataRoot,socket:readFileSync(process.argv[2],"utf8").includes("MOSAIC_TESTER_SOCKET_V1")}));'
|
|
tester_facts() {
|
|
local LC_ALL=C pattern='^\{"dataRoot":"([A-Za-z0-9/._@+-]+)","socket":(true|false)\}$'
|
|
[[ "$1" =~ $pattern ]] || return 1
|
|
TS_DATA="${BASH_REMATCH[1]}" TS_MARKED="${BASH_REMATCH[2]}"
|
|
}
|
|
tester_location() {
|
|
local LC_ALL=C data="$1" home="$2" uid="$3" prefix="$4" part at="" st
|
|
TS_REASON="" TS_SOCKET=""
|
|
case "$data" in /*) ;; *) TS_REASON=not-absolute; return 1 ;; esac
|
|
case "$data" in *[!A-Za-z0-9/._@+-]*) TS_REASON=not-ascii; return 1 ;; esac
|
|
case "$data/" in */./*|*/../*|*//* ) TS_REASON=not-normalized; return 1 ;; esac
|
|
[ "${data%/}" = "$data" ] || { TS_REASON=not-normalized; return 1; }
|
|
case "$data" in "$home"/*) ;; *) TS_REASON=outside-home; return 1 ;; esac
|
|
case "$home/" in /home/*|/root/*|/run/user/*|/tmp/*|/var/tmp/*) TS_REASON=protected-private-home; return 1 ;; esac
|
|
TS_SOCKET="$data/console/tester.sock"
|
|
[ "${#TS_SOCKET}" -le 107 ] || { TS_REASON=too-long; return 1; }
|
|
local -a parts
|
|
IFS=/ read -r -a parts <<< "$data"
|
|
for part in "${parts[@]}"; do
|
|
[ -n "$part" ] || continue
|
|
at="$at/$part"
|
|
[ ! -L "$prefix$at" ] || { TS_REASON=symlink-component; return 1; }
|
|
if [ -e "$prefix$at" ]; then
|
|
[ -d "$prefix$at" ] || { TS_REASON=component-type; return 1; }
|
|
fi
|
|
done
|
|
[ "${5:-}" != observe ] || return 0
|
|
local parent="$prefix$data/console"
|
|
[ ! -L "$parent" ] || { TS_REASON=symlink-parent; return 1; }
|
|
if [ -e "$parent" ]; then
|
|
[ -d "$parent" ] || { TS_REASON=parent-type; return 1; }
|
|
st="$(stat -c '%u %a' -- "$parent")" || { TS_REASON=parent-stat; return 1; }
|
|
[ "${st% *}" = "$uid" ] || { TS_REASON=parent-owner; return 1; }
|
|
[[ "${st#* }" =~ ^[0-7]{3,4}$ ]] && (( (8#${st#* } & 8#077) == 0 )) || { TS_REASON=parent-mode; return 1; }
|
|
fi
|
|
}
|
|
# tester-location-end
|
|
|
|
|
|
# --- Output ---
|
|
|
|
MARKS=(pass FAIL closed "accepted Q182+Q183" "accepted Q183" "accepted mos-1096" 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")"
|
|
[[ "$MOSAIC_UID" =~ ^[1-9][0-9]*$ ]] || refuse "stack account has no non-root uid"
|
|
[ "$(db passwd "$S_USER" | cut -d: -f6)" = "$S_HOME" ] || refuse "saved home differs from account home"
|
|
QUERY="$(tester_capture 5 7 4096 "$S_USER" "$S_HOME" "$(dirname "$NODE"):/usr/local/bin:/usr/bin:/bin" "$NODE" --input-type=module -e "$TESTER_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/webui/src/cli.mjs")" || refuse "tester config query $(tester_capture_reason "$?")"
|
|
tester_facts "$QUERY" || refuse "tester config query has invalid facts"
|
|
[ "$TS_MARKED" = true ] || refuse "target does not support --tester-socket"
|
|
tester_location "$TS_DATA" "$S_HOME" "$MOSAIC_UID" "$ROOT" observe || refuse "tester path refuses $TS_REASON"
|
|
TESTER_PATH="$TS_SOCKET"
|
|
|
|
# --- 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)$(listeners 7333)"
|
|
if loopback_by "$PORT" "$CON_PID" && [ -z "$other" ]; then
|
|
line P8 lan-listeners closed "$PORT is on 127.0.0.1 only by MainPID $CON_PID; no TCP listener on 7331 or 7333; tester uses $TESTER_PATH"
|
|
else
|
|
line P8 lan-listeners unnamed "$PORT: $(listener_text "$PORT"); 7333: $(listener_text 7333); 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
|
|
}
|
|
|
|
# P10 is an observation of mutable same-uid state, never a closure ruling.
|
|
TESTER_PEER='import socket,struct,sys
|
|
s=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
|
|
try:
|
|
s.settimeout(1)
|
|
s.connect(sys.argv[1])
|
|
p=struct.unpack("3i",s.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
|
|
print(*p)
|
|
finally:
|
|
s.close()'
|
|
tester_snapshot() {
|
|
local parent="${TESTER_PATH%/*}" p s pid
|
|
[ ! -L "$ROOT$parent" ] && [ ! -L "$ROOT$TESTER_PATH" ] || return 1
|
|
p="$(stat -c '%u %a %F %d %i' -- "$ROOT$parent" 2>/dev/null)" || return 1
|
|
s="$(stat -c '%u %a %F %d %i' -- "$ROOT$TESTER_PATH" 2>/dev/null)" || return 1
|
|
[[ "$p" =~ ^$MOSAIC_UID\ 700\ directory\ [0-9]+\ [0-9]+$ ]] || return 1
|
|
[[ "$s" =~ ^$MOSAIC_UID\ 600\ socket\ [0-9]+\ [0-9]+$ ]] || return 1
|
|
pid="$(prop "$(unit_show "$CON_UNIT" MainPID)" MainPID)"
|
|
[[ "$pid" =~ ^[1-9][0-9]*$ ]] && [ "$pid" = "$CON_PID" ] || return 1
|
|
[ "$(proc_uids "$pid")" = "$MOSAIC_UID $MOSAIC_UID $MOSAIC_UID $MOSAIC_UID" ] || return 1
|
|
printf '%s|%s|%s' "$pid" "$p" "$s"
|
|
}
|
|
tester_python() {
|
|
local real bad magic
|
|
# Resolve the system entry, then validate the target and every ancestor.
|
|
[ -f "$ROOT/usr/bin/python3" ] && [ -x "$ROOT/usr/bin/python3" ] || return 1
|
|
real="$(realpath -e -- "$ROOT/usr/bin/python3" 2>/dev/null)" || return 1
|
|
[ -z "$ROOT" ] || real="${real#"$ROOT"}"
|
|
case "$real" in /home/*|/root/*|/run/user/*|"$S_HOME"/*) return 1 ;; esac
|
|
bad="$(not_root_only "$real")"; [ -z "$bad" ] || return 1
|
|
bad="$(not_root_only /usr/bin)"; [ -z "$bad" ] || return 1
|
|
magic="$(od -An -tx1 -N4 -- "$ROOT$real" 2>/dev/null | tr -d ' \n')"
|
|
[ "$magic" = 7f454c46 ] || return 1
|
|
printf '%s' "$real"
|
|
}
|
|
check_p10() {
|
|
local floor before after python peer pid uid gid why="" pattern='^([1-9][0-9]{0,9}) ([0-9]{1,10}) ([0-9]{1,10})$'
|
|
floor="$(cat "$ROOT/proc/sys/net/ipv4/ip_unprivileged_port_start" 2>/dev/null)"
|
|
[[ "$floor" =~ ^[0-9]{1,5}$ ]] && [ "$floor" -gt "$PORT" ] || why=floor
|
|
before="$(tester_snapshot)" || why=metadata
|
|
python="$(tester_python)" || why=interpreter
|
|
if [ -z "$why" ]; then
|
|
peer="$(tester_capture 2 4 128 "$S_USER" "$S_HOME" "$(dirname "$NODE"):/usr/local/bin:/usr/bin:/bin" "$python" -I -S -c "$TESTER_PEER" "$TESTER_PATH")" || why="probe-$(tester_capture_reason "$?")"
|
|
if [[ "$peer" =~ $pattern ]]; then
|
|
pid="${BASH_REMATCH[1]}" uid="${BASH_REMATCH[2]}" gid="${BASH_REMATCH[3]}"
|
|
[ "$pid" = "$CON_PID" ] && [ "$uid" = "$MOSAIC_UID" ] && [ "$gid" -le 2147483647 ] || why=peer
|
|
else [ -n "$why" ] || why=probe; fi
|
|
after="$(tester_snapshot)" || why=metadata
|
|
[ "$before" = "$after" ] || why=changed
|
|
fi
|
|
if [ -z "$why" ]; then
|
|
line P10 tester-socket-squat unnamed "boundary=observed path=$TESTER_PATH parent=0700 owner=$MOSAIC_UID socket=0600 peer_pid=$pid peer_uid=$uid floor=$floor port=$PORT; hostile same-uid substitution remains"
|
|
else
|
|
line P10 tester-socket-squat unnamed "boundary=failed reason=$why path=$TESTER_PATH MainPID=${CON_PID:-none}; hostile same-uid substitution remains"
|
|
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
|