#!/usr/bin/env bash # Case 2 row C2-13a (design 9.3, 10 step 2c): render and install the system # units a hub runs as: mosaic-bus@.service (enabled as # mosaic-bus@.service) and mosaic-webui.service, plus P1's # mosaic-proxy.service under --proxy, from the existing templates, which it # doesn't change. # # case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH # [--profile factory|library] [--proxy] [--pm] [--claude PATH] # -- SERVE FLAGS # # --org ID is the same option as --business ID (rename design r2), in each of # two scopes: this script's own flags before --, and the serve flags after it. # Each scope takes each spelling once, and a second spelling the same value; # nothing is counted across --. The serve flags must name the selector, it must # be the unit's business, and the unit renders it as --business ID. # # Root runs it from a root-owned checkout of the reviewed commit outside # , never from the release, because can write the release. It # writes the bus and Console units (and the proxy unit under --proxy) under # /etc/systemd/system as root:root 0644, runs `systemctl daemon-reload`, # enables them, then starts the proxy (under --proxy), the bus and the # Console (Q189). They come back after a boot on their own. It never links # a unit file into place. Root only stats files under and reads bounded # stdout from a config/target query run as the stack user through sudo. Root # never imports copy code. This adds sudo and GNU timeout to the installer # dependencies. The inner query supervisor runs after the uid drop: 5 s inside, # 7 s on root's wait. Fixed system Bash, setsid, sleep, timeout and head are # resolved and root-validated. Only the config queries use this supervisor. # It renders the default instance only. # # The library profile takes exactly these serve flags, each once: # --port N (1..1023) --business ID (the unit's) --public-host NAME # A member that changes uid and cannot be killed refuses inner-cleanup, never success. # --auth-header HEADER --allow-user ID # --proof-header NAME --proof-file PATH # It is proxy-preview read-only until Case 2 supports --sso (#412); --send # and --sso are explicitly refused. The proof still protects proxy preview reads. # The factory profile takes --port N (1..1023) and --business ID, and an # optional bare --tester-socket marker. It also takes the paired bare # --sso --send (#415): the Console signs its owner in from console.sso, which # the stack user's config must have; either one alone refuses, and the # preview and proof flags still refuse. Retired tester TCP refuses. Only # factory takes --pm and --claude. # # On a re-install the installed units must equal the render in everything # but the copy id, and all selected units must name one copy, so a re-pin or rollback # re-renders them together. A unit that was already running stays on its # old copy, since `systemctl start` leaves it alone; the script says so and # that the bus and Console must restart together, and restarts nothing. # # Test seams, for packages/cli/tests/case2-unit.test.mjs only: # CASE2_UNIT_DIR the unit directory, default /etc/systemd/system; # the root and root-owned checks apply only there # CASE2_SYSTEMCTL the systemctl command, default systemctl # CASE2_GETENT the getent command, default getent # CASE2_PROXY_TEMPLATE default packages/cli/systemd/mosaic-proxy.service.in # CASE2_BUS_TEMPLATE default packages/cli/systemd/mosaic-bus.service.in # CASE2_WEBUI_TEMPLATE default packages/webui/systemd/mosaic-webui.service.in # # Exit 0 done, 1 refused, 4 usage. set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DEFAULT_UNIT_DIR=/etc/systemd/system UNIT_DIR="${CASE2_UNIT_DIR:-$DEFAULT_UNIT_DIR}" SYSTEMCTL="${CASE2_SYSTEMCTL:-systemctl}" GETENT="${CASE2_GETENT:-getent}" BUS_TEMPLATE="${CASE2_BUS_TEMPLATE:-$REPO/packages/cli/systemd/mosaic-bus.service.in}" WEBUI_TEMPLATE="${CASE2_WEBUI_TEMPLATE:-$REPO/packages/webui/systemd/mosaic-webui.service.in}" PROXY_TEMPLATE="${CASE2_PROXY_TEMPLATE:-$REPO/packages/cli/systemd/mosaic-proxy.service.in}" PROXY_UNIT=mosaic-proxy.service BUS_UNIT=mosaic-bus@.service WEBUI_UNIT=mosaic-webui.service usage() { echo 'usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS' >&2; exit 4; } die() { echo "case2-unit: $*" >&2; exit 1; } # A value the unit carries as itself: no space, %, $, quote, backslash, & or |. plain() { case "$1" in *[!A-Za-z0-9/._+@-]*) return 1 ;; esac; } abs_plain() { case "$1" in /*) ;; *) die "$2 must be an absolute path: $1" ;; esac plain "$1" || die "$2 contains a character the unit cannot carry: $1" case "$1/" in */./*|*/../*|?*//*) die "$2 must be a normalized path: $1" ;; esac [ "$1" = / ] || [ "${1%/}" = "$1" ] || die "$2 must not end in /: $1" } # shellcheck source=scripts/selector.sh . "$REPO/scripts/selector.sh" business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; } # The outer selector: a missing value is a usage error, a flag-shaped or invalid one the # business-id refusal, and a repeated or conflicting one a usage error that says why. outer_refused() { case "$1" in missing) usage ;; flag|invalid) die "--business must be a business id: $BUSINESS" ;; *) echo "case2-unit: $2" >&2; exit 4 ;; esac } # The serve selector: every refusal names the serve flag, as the other serve flags do. serve_refused() { case "$1" in missing) die "serve flag $SERVE_NAME needs a value" ;; flag|invalid) die "serve $SERVE_NAME $SERVE_VALUE is not the unit's business $BUSINESS" ;; *) die "serve $2" ;; esac } PROFILE=library USER_NAME='' HOME_DIR='' BUSINESS='' COPY_ID='' NODE='' PROXY=0 PM=0 CLAUDE='' CLAUDE_SET=0 while [ $# -gt 0 ]; do case "$1" in --business|--org) BUSINESS="${2-}"; selector_take outer id outer_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;; --profile|--user|--home|--copy|--node|--claude) [ $# -ge 2 ] || usage case "$1" in --profile) PROFILE="$2" ;; --user) USER_NAME="$2" ;; --home) HOME_DIR="$2" ;; --copy) COPY_ID="$2" ;; --node) NODE="$2" ;; --claude) CLAUDE="$2"; CLAUDE_SET=1 ;; esac shift 2 ;; --proxy) PROXY=1; shift ;; --pm) PM=1; shift ;; --) shift; break ;; *) usage ;; esac done SERVE=("$@") [ -n "$USER_NAME" ] && [ -n "$HOME_DIR" ] && [ -n "$BUSINESS" ] && [ -n "$COPY_ID" ] && [ -n "$NODE" ] || usage case "$PROFILE" in factory|library) ;; *) die "--profile must be factory or library: $PROFILE" ;; esac if [ "$PROFILE" = library ]; then [ "$PM" = 0 ] || die "the library profile refuses --pm" [ "$CLAUDE_SET" = 0 ] || die "the library profile refuses --claude" fi if [ "$UNIT_DIR" = "$DEFAULT_UNIT_DIR" ]; then [ "$(id -u)" = 0 ] || die "writing $DEFAULT_UNIT_DIR needs root" fi # The user, its uid, primary group and home, from the passwd database. [[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--user must be a user name: $USER_NAME" PW="$("$GETENT" passwd "$USER_NAME")" || die "no user $USER_NAME in the passwd database" IFS=: read -r _ _ USER_UID USER_GID _ PW_HOME _ <<<"$PW" [[ "$USER_UID" =~ ^[0-9]+$ ]] && [ "$USER_UID" -ne 0 ] || die "--user must not be root: $USER_NAME" abs_plain "$HOME_DIR" '--home' [ "$HOME_DIR" = "$PW_HOME" ] || die "--home $HOME_DIR is not $USER_NAME's home in the passwd database ($PW_HOME)" GR="$("$GETENT" group "$USER_GID")" || die "no group $USER_GID in the group database" GROUP="${GR%%:*}" [[ "$GROUP" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "the primary group of $USER_NAME is not a group name: $GROUP" case "$BUSINESS" in [a-z0-9]*) ;; *) die "--business must be a business id: $BUSINESS" ;; esac case "$BUSINESS" in *[!a-z0-9._-]*) die "--business must be a business id: $BUSINESS" ;; esac [ "${#BUSINESS}" -le 64 ] || die "--business must be a business id: $BUSINESS" # The copy: copy_root's rule (scripts/stack-copy.sh:8, :22), under . COPIES="$HOME_DIR/.local/share/mosaic-stack/copies" [[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] || die "--copy must be a full 40-character lower-case commit id: $COPY_ID" COPY="$COPIES/$COPY_ID" [ -d "$COPY" ] && [ ! -L "$COPY" ] || die "no copy directory $COPY" [ -f "$COPY/scripts/mosaic" ] || die "no copy $COPY_ID in $COPIES: $COPY/scripts/mosaic is missing" # Owned by root and writable by no one else, the file and every directory above it. root_only() { local p="$1" what="$2" owner mode while :; do owner="$(stat -c '%u' -- "$p")" || die "cannot stat $p" mode="$(stat -c '%a' -- "$p")" || die "cannot stat $p" [ "$owner" = 0 ] || die "$what: $p is not owned by root" (( (8#$mode & 8#022) == 0 )) || die "$what: $p is writable by its group or others" [ "$p" = / ] && break p="$(dirname -- "$p")" done } # --node: an absolute path to a root-owned regular file outside . abs_plain "$NODE" '--node' case "$NODE/" in "$HOME_DIR"/*) die "--node must be outside $HOME_DIR: $NODE" ;; esac [ ! -L "$NODE" ] || die "--node must not be a symlink; pass its target: $NODE" [ -f "$NODE" ] && [ -x "$NODE" ] || die "--node must be an executable regular file: $NODE" root_only "$NODE" '--node' # --claude follows the --node rule, so nothing running as can replace # the binary the bus runs. if [ "$CLAUDE_SET" = 1 ]; then abs_plain "$CLAUDE" '--claude' case "$CLAUDE/" in "$HOME_DIR"/*) die "--claude must be outside $HOME_DIR: $CLAUDE" ;; esac [ ! -L "$CLAUDE" ] || die "--claude must not be a symlink; pass its target: $CLAUDE" [ -f "$CLAUDE" ] && [ -x "$CLAUDE" ] || die "--claude must be an executable regular file: $CLAUDE" root_only "$CLAUDE" '--claude' fi # The checkout the templates come from, when installing for real. if [ "$UNIT_DIR" = "$DEFAULT_UNIT_DIR" ]; then case "$REPO/" in "$HOME_DIR"/*) die "run this from a root-owned checkout outside $HOME_DIR, not $REPO" ;; esac [ "$PROXY" = 0 ] || root_only "$PROXY_TEMPLATE" 'proxy template' root_only "$BUS_TEMPLATE" 'bus template' root_only "$WEBUI_TEMPLATE" 'Console template' fi # 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 2>/dev/null < "/proc/$pid/stat" || : [ -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" ) SSO_QUERY='import {pathToFileURL} from "node:url";const m=await import(pathToFileURL(process.argv[1]).href);const s=m.loadSystem();console.log(JSON.stringify({sso:Boolean(s.console&&s.console.sso)}));' 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 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 # The serve flags, checked as scripts/webui-service.sh serve_flags checks # them (:201-264), and kept in the order given. That script compares the # proof file's owner to `id -u`, which is root here, so this copy has its own. serve_flags() { local -A seen=() local flags='' name value local port='' public_host='' auth_header='' allow_user='' proof_header='' proof_file='' tester_socket=0 sso=0 selected=0 local i=0 n=${#SERVE[@]} while [ "$i" -lt "$n" ]; do name="${SERVE[$i]}" case "$name" in --tester-port) die "case2-unit.sh retires tester TCP; use --tester-socket" ;; --tester-socket) [ "$PROFILE" = factory ] || die "the library profile refuses --tester-socket" ;; --checkout) die "refusing --checkout: Case 2 has no queue panel" ;; --board) die "refusing --board: Case 2 runs no control board" ;; --sso) [ "$PROFILE" = factory ] || die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;; --send) [ "$PROFILE" = factory ] || die "the library profile is read-only; refusing --send until Case 2 supports --sso (#412)" ;; --port|--business|--org) ;; --public-host|--auth-header|--allow-user|--proof-header|--proof-file) [ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy" ;; *) die "unknown serve flag: $name" ;; esac [ -z "${seen[$name]:-}" ] || die "serve flag $name given twice" seen[$name]=1 if [ "$name" = --tester-socket ]; then tester_socket=1; flags="$flags --tester-socket"; i=$((i + 1)); continue; fi if [ "$name" = --sso ]; then sso=1; flags="$flags --sso"; i=$((i + 1)); continue; fi if [ "$name" = --send ]; then flags="$flags --send"; i=$((i + 1)); continue; fi [ $((i + 1)) -lt "$n" ] || die "serve flag $name needs a value" value="${SERVE[$((i + 1))]}" if [ "$name" = --business ] || [ "$name" = --org ]; then # One selector in the serve scope, bound to the unit's business, rendered once # as --business where it first appears. SERVE_NAME="$name" SERVE_VALUE="$value" selector_take serve id serve_refused business_id_ok "$name" "$value" [ "$value" = "$BUSINESS" ] || die "serve $name $value is not the unit's business $BUSINESS" [ "$selected" = 1 ] || flags="$flags --business $value" selected=1 i=$((i + 2)) continue fi case "$name" in --port) port="$value" [[ "$port" =~ ^[1-9][0-9]{0,3}$ ]] && [ "$port" -le 1023 ] || die "--port must be 1..1023, so only the unit's capability can bind it: $port" ;; --public-host) public_host="$value" case "$value" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $value" ;; esac case "$value" in *[!A-Za-z0-9.-]*) die "public host must be a hostname, no port or scheme: $value" ;; esac ;; --auth-header) auth_header="$value" case "$value" in [A-Za-z0-9]*) ;; *) die "auth header must be a header name: $value" ;; esac case "$value" in *[!A-Za-z0-9-]*) die "auth header must be a header name: $value" ;; esac ;; --allow-user) allow_user="$value" case "$value" in [A-Za-z0-9]*) ;; *) die "allowed user must start with a letter or digit: $value" ;; esac case "$value" in *[!A-Za-z0-9._@+-]*) die "allowed user contains a character the unit cannot carry: $value" ;; esac ;; --proof-header) proof_header="$value" [[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,63}$ ]] || die "invalid proxy proof header" ;; --proof-file) proof_file="$value" ;; esac flags="$flags $name $value" i=$((i + 2)) done local required=(--port --business) [ "$PROFILE" = factory ] || required+=(--public-host --auth-header --allow-user --proof-header --proof-file) for name in "${required[@]}"; do [ -n "${seen[$name]:-}" ] || { [ "$name" = --business ] && [ -n "${seen[--org]:-}" ]; } || die "serve flag $name is required" done if [ "$PROFILE" = factory ]; then # #415: only the paired owner sign-in and writes. No preview header or # proof file is inferred. The Console refuses --sso without console.sso # at start; this asks the stack user's config first, before any write. [ "$sso" = 0 ] || [ -n "${seen[--send]:-}" ] || die "refusing --sso without --send in the factory profile" [ -z "${seen[--send]:-}" ] || [ "$sso" = 1 ] || die "the factory profile refuses --send without --sso" if [ "$sso" = 1 ]; then local answer answer="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$SSO_QUERY" "$COPY/packages/cli/src/config.mjs")" || die "sso config query $(tester_capture_reason "$?")" [ "$answer" != '{"sso":false}' ] || die "--sso needs console.sso in $USER_NAME's system config" [ "$answer" = '{"sso":true}' ] || die "sso config query has invalid facts" fi if [ "$tester_socket" = 1 ]; then local query query="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$TESTER_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/webui/src/cli.mjs")" || die "tester config query $(tester_capture_reason "$?")" tester_facts "$query" || die "tester config query has invalid facts" [ "$TS_MARKED" = true ] || die "the target does not support --tester-socket" tester_location "$TS_DATA" "$HOME_DIR" "$USER_UID" "" || die "tester path refuses $TS_REASON" fi echo "${flags# }"; return 0 fi # Lead decision 101: the uid header carries the Authentik uid. if [ "${auth_header,,}" = x-authentik-uid ] && ! [[ "$allow_user" =~ ^[0-9a-f]{64}$ ]]; then die "with --auth-header $auth_header the allowed user must be an Authentik uid, 64 lower-case hex characters (lead decision 101): $allow_user" fi case "${proof_header,,}" in host|origin|x-mosaic-tester|"${auth_header,,}") die "proxy proof header must differ from Host, Origin, tester and auth headers" ;; esac # The proof file: metadata only, never opened (design 9.3). local secrets="$HOME_DIR/.config/mosaic-dev/secrets" real owner mode abs_plain "$proof_file" 'proxy proof file' [ ! -L "$proof_file" ] || die "proxy proof file must not be a symlink: $proof_file" [ -f "$proof_file" ] || die "proxy proof file must be a regular file: $proof_file" real="$(realpath -e -- "$proof_file")" || die "cannot resolve proxy proof file: $proof_file" [ "$real" = "$proof_file" ] || die "proxy proof file path must have no symlink in it: $proof_file" case "$proof_file" in "$secrets"/*) ;; *) die "proxy proof file must be under $secrets, outside the data root: $proof_file" ;; esac owner="$(stat -c '%u' -- "$proof_file")" || die "cannot stat proxy proof file" mode="$(stat -c '%a' -- "$proof_file")" || die "cannot stat proxy proof file" [ "$owner" = "$USER_UID" ] || die "proxy proof file must be owned by $USER_NAME (uid $USER_UID): $proof_file" [ "$mode" = 600 ] || [ "$mode" = 400 ] || die "proxy proof file must be mode 0600 or 0400, not $mode: $proof_file" echo "${flags# }" } # PATH: the node's directory first, then the default list without it. Unlike # scripts/webui-service.sh:303-305 this always puts node's directory first, # so the bus's `exec node` (scripts/mosaic) finds the same node. NODE_DIR="$(dirname -- "$NODE")" UNIT_PATH="$NODE_DIR" for d in /usr/local/bin /usr/bin /bin; do [ "$d" = "$NODE_DIR" ] || UNIT_PATH="$UNIT_PATH:$d"; done FLAGS="$(serve_flags)" || exit 1 HARDENING="ProtectSystem=strict ReadWritePaths=$HOME_DIR ProtectHome=yes PrivateTmp=yes" CAPS="AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE" # The default instance, as scripts/instance.sh:85-107 renders it: no # MOSAIC_CONFIG line, and the session env under %h, which becomes . SESSION_ENV='%h/.config/mosaic-dev/secrets/claude-session.env' ENV_FILE="-$HOME_DIR/.config/mosaic-dev/secrets/claude-session.env" PM_FLAGS='' [ "$PM" = 0 ] || PM_FLAGS=' --pm' CLAUDE_ENV='' [ "$CLAUDE_SET" = 0 ] || CLAUDE_ENV="Environment=MOSAIC_CLAUDE_BIN=$CLAUDE" # One unit from its template. kind is bus or webui. render() { local kind="$1" template="$2" text tokens [ -f "$template" ] && [ ! -L "$template" ] || die "template missing or not a regular file: $template" text="$(sed -e "s|@REPO@|$COPY|g" -e "s|@PATH@|$UNIT_PATH|g" -e "s|@NODE@|$NODE|g" \ -e "s|@PM_FLAGS@|$PM_FLAGS|g" -e "s|^@CLAUDE_ENV@\$|$CLAUDE_ENV|" \ -e '/^@CONFIG_ENV@$/d' -e "s|@SESSION_ENV@|$SESSION_ENV|g" \ -e "s|%h|$HOME_DIR|g" -- "$template")" # Checked before @FLAGS@ goes in, since an allowed user may hold @. tokens="$(printf '%s\n' "$text" | grep -oE '@[A-Z_]+@' || true)" if [ "$kind" = webui ]; then [ "$tokens" = @FLAGS@ ] || die "$template: a token other than one @FLAGS@ is left after the render: $(echo $tokens)" text="${text//@FLAGS@/$FLAGS}" else [ -z "$tokens" ] || die "$template: a token is left after the render: $(echo $tokens)" fi if printf '%s\n' "$text" | grep -q '%h'; then die "$template: %h is left after the render"; fi local add="User=$USER_NAME\nGroup=$GROUP\n${HARDENING//$'\n'/\\n}\n" [ "$kind" != webui ] || add="$add${CAPS//$'\n'/\\n}\n" if [ "$kind" = bus ] && [ "$PROXY" = 1 ]; then text="$(printf '%s\n' "$text" | sed "/^Description=/a Wants=$PROXY_UNIT\\nAfter=$PROXY_UNIT")" fi # The added lines go at the end of [Service], before its trailing blank # lines. In [Install], WantedBy=multi-user.target replaces # WantedBy=default.target in each unit, so each starts at boot (Q189). printf '%s\n' "$text" | awk -v add="$add" ' function blanks_out() { for (; blanks > 0; blanks--) print "" } /^\[.*\]$/ { if (sec == "[Service]") printf "%s", add sec = $0 blanks_out(); print; next } $0 == "" { blanks++; next } { blanks_out() } sec == "[Install]" && $0 == "WantedBy=default.target" { $0 = "WantedBy=multi-user.target" } { print } END { if (sec == "[Service]") printf "%s", add; blanks_out() }' } # The unit's settings as sectionkeyvalue, one per line, keys # trimmed as systemd trims them. A continued line refuses, so no setting # hides in one. settings() { awk ' { sub(/^[ \t]+/, ""); sub(/[ \t]+$/, "") } $0 == "" || /^[#;]/ { next } /\\$/ { print "CONTINUED"; exit } /^\[.*\]$/ { sec = $0; next } { i = index($0, "="); if (i == 0) { print "NOKEY\t" $0; next } k = substr($0, 1, i - 1); v = substr($0, i + 1) sub(/[ \t]+$/, "", k); sub(/^[ \t]+/, "", v) print sec "\t" k "\t" v }' } # Refuses unless one unit's fixed lines hold. what names the unit in messages, # copy is the copy directory it should run from. check_unit() { local kind="$1" text="$2" what="$3" copy="$4" s s="$(printf '%s\n' "$text" | settings)" ! printf '%s\n' "$s" | grep -qx CONTINUED || die "$what: a line continues onto the next" ! printf '%s\n' "$s" | grep -q '^NOKEY' || die "$what: a line is neither a section, a comment nor a setting" # %h in a system unit is root's home, not 's. ! printf '%s\n' "$text" | grep -q '%h' || die "$what: %h is left; a system unit reads it as root's home" local key for key in RestrictNamespaces PrivateUsers SystemCallFilter PermissionsStartOnly; do ! printf '%s\n' "$s" | awk -F'\t' -v k="$key" '$2 == k { found = 1 } END { exit !found }' \ || die "$what: refusing $key=, which the hub's units must not hold" done # An Exec*= value whose leading prefix characters hold + or ! runs with # full privileges, whatever User= says. local bad bad="$(printf '%s\n' "$s" | awk -F'\t' '$2 ~ /^Exec/ { p = $3; sub(/[^-@:+!].*$/, "", p); if (p ~ /[+!]/) print $2 "=" $3 }')" [ -z "$bad" ] || die "$what: refusing an Exec line that runs with full privileges: $bad" # The fixed lines, each exactly once, in [Service]. one() { local k="$1" v="$2" got got="$(printf '%s\n' "$s" | awk -F'\t' -v k="$k" '$2 == k { print $1 "\t" $3 }')" [ "$got" = "[Service] $v" ] || die "$what: $k= must be exactly once in [Service], as $k=$v" } none() { ! printf '%s\n' "$s" | awk -F'\t' -v k="$1" '$2 == k { found = 1 } END { exit !found }' || die "$what: refusing $1=, which this unit must not hold" } one User "$USER_NAME" one Group "$GROUP" one WorkingDirectory "$copy" # Environment= lines: PATH, and MOSAIC_CLAUDE_BIN only with --claude. local envs want_envs="[Service] PATH=$UNIT_PATH" [ "$kind" != bus ] || [ -z "$CLAUDE_ENV" ] || want_envs="$want_envs [Service] MOSAIC_CLAUDE_BIN=$CLAUDE" envs="$(printf '%s\n' "$s" | awk -F'\t' '$2 == "Environment" { print $1 "\t" $3 }')" [ "$envs" = "$want_envs" ] || die "$what: the Environment= lines must be exactly: $(printf '%s' "$want_envs" | cut -f2 | tr '\n' ' ')" one NoNewPrivileges yes one ProtectSystem strict one ReadWritePaths "$HOME_DIR" one ProtectHome yes one PrivateTmp yes local order order="$(printf '%s\n' "$s" | awk -F'\t' '$2 ~ /^(ProtectSystem|ReadWritePaths|ProtectHome|PrivateTmp)$/ { printf "%s ", $2 }')" [ "$order" = "ProtectSystem ReadWritePaths ProtectHome PrivateTmp " ] || die "$what: the hardening lines must be in the order ProtectSystem, ReadWritePaths, ProtectHome, PrivateTmp" local wanted wanted="$(printf '%s\n' "$s" | awk -F'\t' '$2 == "WantedBy" { print $1 "\t" $3 }')" [ "$wanted" = "[Install] multi-user.target" ] || die "$what: WantedBy= must be exactly once, as WantedBy=multi-user.target" if [ "$kind" = bus ]; then one ExecStart "$copy/scripts/mosaic bus start %i$PM_FLAGS" one EnvironmentFile "$ENV_FILE" none AmbientCapabilities none CapabilityBoundingSet elif [ "$kind" = proxy ]; then one ExecStart "$NODE $copy/packages/model-proxy/src/run.mjs" none EnvironmentFile none AmbientCapabilities none CapabilityBoundingSet else one ExecStart "$NODE $copy/packages/webui/src/cli.mjs serve $FLAGS" none EnvironmentFile one AmbientCapabilities CAP_NET_BIND_SERVICE one CapabilityBoundingSet CAP_NET_BIND_SERVICE fi } BUS_TEXT="$(render bus "$BUS_TEMPLATE")" || exit 1 WEBUI_TEXT="$(render webui "$WEBUI_TEMPLATE")" || exit 1 check_unit bus "$BUS_TEXT" "rendered $BUS_UNIT" "$COPY" check_unit webui "$WEBUI_TEXT" "rendered $WEBUI_UNIT" "$COPY" # A re-install: each installed unit passes the same checks, the bus and # Console units (and the proxy unit under --proxy) name one copy, and each # equals the render once its copy id is the new one. [ -d "$UNIT_DIR" ] && [ ! -L "$UNIT_DIR" ] || die "no unit directory $UNIT_DIR" installed_ids='' PAIRS=("bus:$BUS_UNIT:BUS_TEXT" "webui:$WEBUI_UNIT:WEBUI_TEXT") if [ "$PROXY" = 1 ]; then PROXY_TEXT="$(render proxy "$PROXY_TEMPLATE")" || exit 1 check_unit proxy "$PROXY_TEXT" "rendered $PROXY_UNIT" "$COPY" PAIRS=("proxy:$PROXY_UNIT:PROXY_TEXT" "${PAIRS[@]}") else [ ! -e "$UNIT_DIR/$PROXY_UNIT" ] && [ ! -L "$UNIT_DIR/$PROXY_UNIT" ] \ || die "installed $PROXY_UNIT requires --proxy; nothing was changed" fi for pair in "${PAIRS[@]}"; do IFS=: read -r kind name var <<<"$pair" path="$UNIT_DIR/$name" [ ! -L "$path" ] || die "refusing: $path is a symlink" [ -e "$path" ] || continue [ -f "$path" ] || die "refusing: $path is not a regular file" text="$(cat -- "$path")" old="$(printf '%s\n' "$text" | settings | awk -F'\t' -v c="$COPIES/" '$2 == "WorkingDirectory" && index($3, c) == 1 { print substr($3, length(c) + 1) }')" [[ "$old" =~ ^[0-9a-f]{40}$ ]] || die "installed $path names no copy under $COPIES" check_unit "$kind" "$text" "installed $path" "$COPIES/$old" [ "$text" = "$(printf '%s\n' "${!var}" | sed "s|$COPY|$COPIES/$old|g")" ] \ || die "installed $path differs from the render in more than the copy id; the node path, serve flags, --pm, --claude and --proxy must equal the installed ones" installed_ids="$installed_ids $old" done read -r -a ids <<<"$installed_ids" for old in "${ids[@]}"; do [ "$old" = "${ids[0]}" ] || die "the installed units name different copies, ${ids[0]} and $old" done # `systemctl start` leaves a running unit on the copy it started from, so # note which units run before the write (row 195 owns the restart). BUS_NAME="mosaic-bus@$BUSINESS.service" NAMES=("$BUS_NAME" "$WEBUI_UNIT") [ "$PROXY" = 0 ] || NAMES=("$PROXY_UNIT" "${NAMES[@]}") running='' for name in "${NAMES[@]}"; do if "$SYSTEMCTL" is-active --quiet "$name"; then running="$running $name"; fi done # Write each unit to a temp file in the unit directory, then rename. write_unit() { local name="$1" text="$2" tmp tmp="$(mktemp "$UNIT_DIR/.case2-unit.XXXXXX")" printf '%s\n' "$text" >"$tmp" chmod 0644 "$tmp" [ "$(id -u)" != 0 ] || chown root:root "$tmp" mv -fT -- "$tmp" "$UNIT_DIR/$name" } for pair in "${PAIRS[@]}"; do IFS=: read -r kind name var <<<"$pair" write_unit "$name" "${!var}" done # Enable all selected units before starting proxy (if selected), bus, Console. # Only the bus depends on the proxy; the Console calls the bus per request. "$SYSTEMCTL" daemon-reload || die "systemctl daemon-reload failed; the units are written, nothing is enabled or started" "$SYSTEMCTL" enable "${NAMES[@]}" || die "systemctl enable ${NAMES[*]} failed; nothing is started" if [ "$PROXY" = 1 ]; then "$SYSTEMCTL" start "$PROXY_UNIT" || die "systemctl start $PROXY_UNIT failed; the bus and Console are not started" fi "$SYSTEMCTL" start "$BUS_NAME" || die "systemctl start $BUS_NAME failed; the Console is not started" "$SYSTEMCTL" start "$WEBUI_UNIT" || die "systemctl start $WEBUI_UNIT failed; the bus is running" if [ "$PROXY" = 1 ]; then echo "case2-unit: wrote $UNIT_DIR/$PROXY_UNIT, $UNIT_DIR/$BUS_UNIT and $UNIT_DIR/$WEBUI_UNIT from copy $COPY_ID and enabled all three" if [ -z "$running" ]; then echo "case2-unit: enabled and started $PROXY_UNIT, then $BUS_NAME, then $WEBUI_UNIT" exit 0 fi else if [ -z "$running" ]; then echo "case2-unit: wrote $UNIT_DIR/$BUS_UNIT and $UNIT_DIR/$WEBUI_UNIT from copy $COPY_ID; enabled and started $BUS_NAME, then $WEBUI_UNIT" exit 0 fi echo "case2-unit: wrote $UNIT_DIR/$BUS_UNIT and $UNIT_DIR/$WEBUI_UNIT from copy $COPY_ID and enabled both" fi for name in "${NAMES[@]}"; do case " $running " in *" $name "*) echo "case2-unit: $name was already running, so it is still on ${ids[0]:+copy }${ids[0]:-the copy it started from}, not copy $COPY_ID" ;; *) echo "case2-unit: started $name on copy $COPY_ID" ;; esac done if [ "$PROXY" = 1 ]; then echo "case2-unit: restart $PROXY_UNIT, $BUS_NAME and $WEBUI_UNIT together to put all three on copy $COPY_ID; until then they may run different copies" else echo "case2-unit: restart $BUS_NAME and $WEBUI_UNIT together to put both on copy $COPY_ID; until then they may run different copies" fi