webui serve takes --proof-header NAME --proof-file PATH, only with the --public-host preview flags. Before listen it reads the file once through O_NOFOLLOW and fstat: a regular file owned by the Console uid, mode 0400 or 0600, at least 32 base64url bytes with one optional final LF. Any file problem gives one fixed refusal that names neither the path nor the contents. The header name can't collide with Host, Origin, auth or tester names. On a public Host the guard runs before any auth header is read. It needs exactly one proof header and compares equal-length buffers with timingSafeEqual. Missing, wrong and repeated proofs get the same fixed 401. A proof header on another Host or on the tester listener gets 403. The existing auth, Origin, Fetch Metadata and human write checks are unchanged. webui-service.sh renders both flags and checks the file's metadata and canonical location without opening it, and the send-status regex accepts the proof pair. stack-release.sh only gains a comment: it already carries Console flags through re-pin and rollback, and new tests pin that. This is a partial fix. Same-uid seats can still read the proof file. Activation (middleware, unit flags, restart, smoke) is a separate Mos-checked window and isn't part of this commit. Dewey approved round 2 in #1676 comment 29386, candidate 9300a670. Round 1 changes were in 29374.
436 lines
23 KiB
Bash
Executable File
436 lines
23 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# `scripts/webui-service.sh copy|render|install|uninstall|status`: the
|
|
# systemd user unit that runs the Console, `node packages/webui/src/cli.mjs
|
|
# serve` (lead decision 94), from a read-only copy of one landed commit
|
|
# (row 77, #1571, lead decision 112). The template is
|
|
# packages/webui/systemd/mosaic-webui.service.in; the rendered unit is
|
|
# mosaic-webui.service in the systemd user directory, one instance.
|
|
#
|
|
# copy COMMIT [--copies DIR] [--allow-worktree]
|
|
# write `git archive` of the paths the Console
|
|
# loads at COMMIT into DIR/<full id>/, read-only;
|
|
# DIR defaults to
|
|
# ${XDG_DATA_HOME:-~/.local/share}/mosaic-stack/copies
|
|
# render [--copy ID [--copies DIR]] [FLAGS]
|
|
# print the rendered unit on stdout; without
|
|
# --copy it runs from this checkout
|
|
# install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS]
|
|
# write the unit (write to a temp file, then
|
|
# rename) and run `systemctl --user daemon-reload`;
|
|
# refuses while the unit is running
|
|
# uninstall [--dir DIR] [--no-reload]
|
|
# remove the unit; refuses while it is running
|
|
# status [--dir DIR] unit state, the copy it runs and whether
|
|
# refactor has moved past it, whether it sends,
|
|
# the tester listener, then GET /healthz on its port
|
|
#
|
|
# With --copy the unit's WorkingDirectory and ExecStart point at the copy,
|
|
# and ExecStart passes this checkout as serve's --checkout: the queue read
|
|
# runs the checkout's queue-read.mjs, since the queue store refuses code
|
|
# outside the canonical checkout; everything else comes from the copy.
|
|
#
|
|
# copy and install run only from the canonical checkout, whose root holds a
|
|
# .git directory (a linked worktree has a .git file). --allow-worktree lifts
|
|
# that refusal and exists for the tests only, which run in worktrees.
|
|
#
|
|
# The update step: `copy COMMIT`; Jason or Mos stops the unit; `install
|
|
# --copy ID` with the same flags; Jason or Mos starts it. Rollback is the
|
|
# same with the earlier id. Copies stay until someone removes them. install
|
|
# refuses unless the bus unit runs from the same copy (bus_on_copy below),
|
|
# row 85 supplies the shared copy and bus unit flags; Mos owns switching.
|
|
#
|
|
# FLAGS are serve's: --port N (default 7332; 7330 is the operator's own
|
|
# Console), --tester-port N (1024..65535, neither --port nor the tunnel's
|
|
# 7332; binds the tester on loopback), --business ID, --board URL, and the preview set --public-host
|
|
# NAME --auth-header HEADER --allow-user ID, all three or none, and --send
|
|
# (row 83), taken only with the preview set: the unit's Console then sends
|
|
# human messages to roles and needs the system config at start. systemd
|
|
# expands % and $ and splits on spaces, so each value is held to a plain
|
|
# character set and refused otherwise. --proof-header NAME --proof-file PATH
|
|
# go together under preview; only the path is carried here, never the secret.
|
|
# render checks ownership, mode and location without opening the proof file.
|
|
#
|
|
# There is no start, stop, enable or restart verb. The Console reads the
|
|
# main listener's bus through the human transport; the optional loopback
|
|
# listener binds the tester. Whoever starts it holds the human's
|
|
# capability. Jason starts and restarts it
|
|
# (`systemctl --user enable --now mosaic-webui`), and so may Mos under lead
|
|
# decision 102, which accepts that sends from a Console Mos started enter
|
|
# the trail as Jason (human-cli). Any other agent starting it, even detached
|
|
# through systemd, works around the human proof (lead decisions 62 and 94).
|
|
# install and uninstall refuse while the unit is running, with or without
|
|
# --no-reload: a unit rewritten under a running Console would take the new
|
|
# ExecStart at its next Restart=on-failure, with nobody starting it.
|
|
#
|
|
# Exit codes: 0 ok, 1 operation failed, 4 usage.
|
|
set -euo pipefail
|
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
|
|
UNIT_NAME="mosaic-webui.service"
|
|
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
|
|
FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
|
|
--tester-port: 1024..65535, different from --port and never 7332; loopback tester listener.
|
|
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
|
|
|
|
die() { echo "webui-service: $*" >&2; exit 1; }
|
|
usage() { echo "$USAGE" >&2; exit 4; }
|
|
|
|
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
|
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
|
|
COPY_ID=""
|
|
COPY_FLAGS=0
|
|
ALLOW_WORKTREE=0
|
|
RELOAD=1
|
|
DIR_FLAGS=0
|
|
PORT=7332
|
|
TESTER_PORT=""
|
|
TESTER_SET=0
|
|
BUSINESS=""
|
|
BOARD=""
|
|
PUBLIC_HOST=""
|
|
AUTH_HEADER=""
|
|
ALLOW_USER=""
|
|
SEND=0
|
|
PROOF_HEADER=""
|
|
PROOF_FILE=""
|
|
PROOF_SET=0
|
|
SERVE_SET=0
|
|
|
|
parse_flags() {
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; DIR_FLAGS=1; shift 2 ;;
|
|
--copy) [ $# -ge 2 ] || usage; COPY_ID="$2"; COPY_FLAGS=1; shift 2 ;;
|
|
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; COPY_FLAGS=1; shift 2 ;;
|
|
--allow-worktree) ALLOW_WORKTREE=1; shift ;;
|
|
--no-reload) RELOAD=0; DIR_FLAGS=1; shift ;;
|
|
--port) [ $# -ge 2 ] || usage; PORT="$2"; SERVE_SET=1; shift 2 ;;
|
|
--tester-port) [ $# -ge 2 ] || usage; TESTER_PORT="$2"; TESTER_SET=1; SERVE_SET=1; shift 2 ;;
|
|
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;;
|
|
--board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;;
|
|
--public-host) [ $# -ge 2 ] || usage; PUBLIC_HOST="$2"; SERVE_SET=1; shift 2 ;;
|
|
--auth-header) [ $# -ge 2 ] || usage; AUTH_HEADER="$2"; SERVE_SET=1; shift 2 ;;
|
|
--allow-user) [ $# -ge 2 ] || usage; ALLOW_USER="$2"; SERVE_SET=1; shift 2 ;;
|
|
--proof-header) [ $# -ge 2 ] || usage; PROOF_HEADER="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
|
|
--proof-file) [ $# -ge 2 ] || usage; PROOF_FILE="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
|
|
--send) SEND=1; SERVE_SET=1; shift ;;
|
|
*) usage ;;
|
|
esac
|
|
done
|
|
# Validate here so a bad tester value exits 4 before render's existing
|
|
# operation-failure wrappers, and before install reads or changes units.
|
|
[ "$TESTER_SET" = 0 ] || tester_port_ok
|
|
}
|
|
|
|
tester_port_usage() {
|
|
echo "webui-service: --tester-port must be 1024..65535, different from --port and never 7332: $TESTER_PORT" >&2
|
|
exit 4
|
|
}
|
|
|
|
tester_port_ok() {
|
|
case "$TESTER_PORT" in ''|*[!0-9]*) tester_port_usage ;; esac
|
|
# At most five digits as written, before the zeros go, as serve and stack-release.sh count them.
|
|
[ "${#TESTER_PORT}" -le 5 ] || tester_port_usage
|
|
local tester_port="$TESTER_PORT"
|
|
# Compare decimal values, including values written with leading zeros.
|
|
while [ "${tester_port#0}" != "$tester_port" ]; do tester_port="${tester_port#0}"; done
|
|
[ -n "$tester_port" ] && [ "${#tester_port}" -le 5 ] || tester_port_usage
|
|
[ "$tester_port" -ge 1024 ] && [ "$tester_port" -le 65535 ] || tester_port_usage
|
|
[ "$tester_port" -ne 7332 ] || tester_port_usage
|
|
# Invalid main ports retain their existing operation-failure behavior.
|
|
if [[ "$PORT" =~ ^[0-9]+$ ]] && [ "${#PORT}" -le 5 ]; then
|
|
[ "$tester_port" -ne "$PORT" ] || tester_port_usage
|
|
fi
|
|
}
|
|
|
|
# The serve flags as one ExecStart fragment. Each character set excludes
|
|
# space, %, $, quotes, backslash, & and |, so neither systemd nor the sed
|
|
# below can read a value as anything but itself.
|
|
serve_flags() {
|
|
case "$PORT" in ''|*[!0-9]*) die "port must be 1..65535: $PORT" ;; esac
|
|
[ "${#PORT}" -le 5 ] && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || die "port must be 1..65535: $PORT"
|
|
local flags="--port $PORT"
|
|
if [ -n "$BUSINESS" ]; then
|
|
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"
|
|
flags="$flags --business $BUSINESS"
|
|
fi
|
|
if [ -n "$BOARD" ]; then
|
|
# serve checks the rest (a loopback origin, no path or credentials).
|
|
case "$BOARD" in http://?*) ;; *) die "board must be an http:// URL: $BOARD" ;; esac
|
|
case "$BOARD" in *[!A-Za-z0-9.:/_-]*) die "board contains a character the unit cannot carry: $BOARD" ;; esac
|
|
flags="$flags --board $BOARD"
|
|
fi
|
|
local preview=0
|
|
[ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
|
|
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
|
|
[ -z "$ALLOW_USER" ] || preview=$((preview + 1))
|
|
# serve refuses --send without the preview set; refuse it here first,
|
|
# so no unit is written that would only exit 2 at start.
|
|
[ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user)"
|
|
if [ "$preview" -ne 0 ]; then
|
|
[ "$preview" -eq 3 ] || die "--public-host, --auth-header and --allow-user go together: all three or none"
|
|
case "$PUBLIC_HOST" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac
|
|
case "$PUBLIC_HOST" in *[!A-Za-z0-9.-]*) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac
|
|
case "$AUTH_HEADER" in [A-Za-z0-9]*) ;; *) die "auth header must be a header name: $AUTH_HEADER" ;; esac
|
|
case "$AUTH_HEADER" in *[!A-Za-z0-9-]*) die "auth header must be a header name: $AUTH_HEADER" ;; esac
|
|
case "$ALLOW_USER" in [A-Za-z0-9]*) ;; *) die "allowed user must start with a letter or digit: $ALLOW_USER" ;; esac
|
|
case "$ALLOW_USER" in *[!A-Za-z0-9._@+-]*) die "allowed user contains a character the unit cannot carry: $ALLOW_USER" ;; esac
|
|
# Lead decision 101: the uid header carries the Authentik uid, 64
|
|
# lower-case hex characters, and nothing else is let through on it.
|
|
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 (^[0-9a-f]{64}\$, lead decision 101): $ALLOW_USER"
|
|
fi
|
|
flags="$flags --public-host $PUBLIC_HOST --auth-header $AUTH_HEADER --allow-user $ALLOW_USER"
|
|
fi
|
|
if [ "$PROOF_SET" = 1 ]; then
|
|
[ -n "$PROOF_HEADER" ] && [ -n "$PROOF_FILE" ] && [ "$preview" -eq 3 ] || die "proxy proof needs both flags and preview mode"
|
|
[[ "$PROOF_HEADER" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,63}$ ]] || die "invalid proxy proof header"
|
|
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
|
|
case "$PROOF_FILE" in /*) ;; *) die "proxy proof file must be an absolute path" ;; esac
|
|
case "$PROOF_FILE" in *[!A-Za-z0-9/._+-]*) die "proxy proof path cannot be carried by the unit" ;; esac
|
|
# Metadata only: render never opens the proof, even to validate its contents.
|
|
[ ! -L "$PROOF_FILE" ] && [ -f "$PROOF_FILE" ] || die "invalid proxy proof file metadata"
|
|
local proof_path copies_path proof_mode proof_uid
|
|
proof_path="$(realpath -e -- "$PROOF_FILE")" || die "cannot stat proxy proof file"
|
|
copies_path="$(realpath -m -- "$COPIES")" || die "cannot resolve copies directory"
|
|
case "$proof_path" in "$REPO"|"$REPO"/*|"$copies_path"|"$copies_path"/*) die "proxy proof file must be outside the repository and copies" ;; esac
|
|
proof_mode="$(stat -c '%a' -- "$PROOF_FILE")" || die "cannot stat proxy proof file"
|
|
proof_uid="$(stat -c '%u' -- "$PROOF_FILE")" || die "cannot stat proxy proof file"
|
|
{ [ "$proof_mode" = 600 ] || [ "$proof_mode" = 400 ]; } && [ "$proof_uid" = "$(id -u)" ] || die "invalid proxy proof file metadata"
|
|
flags="$flags --proof-header $PROOF_HEADER --proof-file $PROOF_FILE"
|
|
fi
|
|
[ "$SEND" = 0 ] || flags="$flags --send"
|
|
[ "$TESTER_SET" = 0 ] || flags="$flags --tester-port $TESTER_PORT"
|
|
echo "$flags"
|
|
}
|
|
|
|
# Row 85: stack-copy.sh owns the pushed runtime closure and npm install.
|
|
# This script delegates copy and shares its copies directory.
|
|
|
|
# N2 (row 75 review): copy and install read and point at this checkout, so
|
|
# they run from the canonical one, never a worktree someone may remove.
|
|
canonical() {
|
|
[ -d "$REPO/.git" ] || [ "$ALLOW_WORKTREE" = 1 ] || die "refusing: $REPO is not the canonical checkout (its .git is not a directory); run copy and install from the canonical checkout"
|
|
}
|
|
|
|
# The copies directory goes into the unit as part of the copy's path.
|
|
copies_ok() {
|
|
case "$COPIES" in /*) ;; *) die "copies directory must be an absolute path: $COPIES" ;; esac
|
|
case "$COPIES" in *[!A-Za-z0-9/._+-]*) die "copies directory contains a character the unit cannot carry: $COPIES" ;; esac
|
|
}
|
|
|
|
# The root the unit runs from: the copy named by --copy, else this checkout.
|
|
unit_root() {
|
|
if [ -z "$COPY_ID" ]; then echo "$REPO"; return 0; fi
|
|
case "$COPY_ID" in *[!0-9a-f]*) die "--copy must be a full 40-character commit id: $COPY_ID" ;; esac
|
|
[ "${#COPY_ID}" -eq 40 ] || die "--copy must be a full 40-character commit id: $COPY_ID"
|
|
copies_ok
|
|
[ -f "$COPIES/$COPY_ID/packages/webui/src/cli.mjs" ] || die "no copy $COPY_ID in $COPIES; make it with: scripts/webui-service.sh copy $COPY_ID"
|
|
echo "$COPIES/$COPY_ID"
|
|
}
|
|
|
|
render() {
|
|
[ -f "$TEMPLATE" ] || die "template missing: $TEMPLATE"
|
|
local node
|
|
node="$(command -v node || true)"
|
|
[ -n "$node" ] || die "node not found on PATH"
|
|
# systemd does not look an ExecStart command up in Environment=PATH,
|
|
# so the unit runs node by the path found here, kept as given.
|
|
case "$node" in /*) ;; *) die "node path must be absolute: $node" ;; esac
|
|
case "$node" in *[!A-Za-z0-9/._+-]*) die "node path contains a character the unit cannot carry: $node" ;; esac
|
|
case "$REPO" in *[!A-Za-z0-9/._+-]*) die "repository path contains a character the unit cannot carry: $REPO" ;; esac
|
|
local root
|
|
root="$(unit_root)" || exit 1
|
|
local node_dir path="/usr/local/bin:/usr/bin:/bin"
|
|
node_dir="$(dirname "$node")"
|
|
case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac
|
|
local flags
|
|
flags="$(serve_flags)" || exit 1
|
|
[ -z "$COPY_ID" ] || flags="--checkout $REPO $flags"
|
|
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "$TEMPLATE"
|
|
}
|
|
|
|
# The unit's active state from list-units, printed when it is anything but
|
|
# inactive or failed (active, activating, deactivating, reloading...). A
|
|
# unit systemd has not loaded is not listed and prints nothing. N4: a
|
|
# failed call refuses with one line naming it; the state is unknown, so
|
|
# nothing is written or removed.
|
|
running() {
|
|
local out
|
|
out="$(systemctl --user list-units --no-legend --plain "$UNIT_NAME" 2>/dev/null)" || die "refusing: systemctl --user list-units --no-legend --plain $UNIT_NAME failed, so the unit's state is unknown; nothing was changed"
|
|
printf '%s\n' "$out" | awk -v unit="$UNIT_NAME" '$1 == unit && $3 != "inactive" && $3 != "failed" {print $3}'
|
|
}
|
|
|
|
# The bus host proves a human by checking that the calling process runs its
|
|
# own packages/bus/src/human-cli.mjs (packages/bus/src/human.mjs), and the
|
|
# Console spawns the one beside its own files. So a Console on a copy works
|
|
# only with a bus on that same copy: install refuses unless every bus unit
|
|
# it would call has an ExecStart path inside the copy. With --business the
|
|
# unit is [email protected]; without it, every mosaic-bus@ unit systemd
|
|
# has loaded, and none loaded refuses. Like the state read, a failed call
|
|
# refuses with one line.
|
|
bus_on_copy() {
|
|
local copy="$1" units unit out load paths p working
|
|
if [ -n "$BUSINESS" ]; then
|
|
units="mosaic-bus@$BUSINESS.service"
|
|
else
|
|
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl --user list-units --all --no-legend --plain mosaic-bus@*.service failed, so the bus unit is unknown; nothing was changed"
|
|
units="$(printf '%s\n' "$out" | awk '$1 ~ /^mosaic-bus@.+\.service$/ {print $1}')"
|
|
[ -n "$units" ] || die "refusing: no mosaic-bus@ unit is loaded, and the Console on the copy $copy needs its bus on that copy; name the business with --business ID"
|
|
fi
|
|
for unit in $units; do
|
|
out="$(systemctl --user show "$unit" -p LoadState -p WorkingDirectory -p ExecStart 2>/dev/null)" || die "refusing: systemctl --user show $unit failed, so the bus unit is unknown; nothing was changed"
|
|
load="$(printf '%s\n' "$out" | sed -n 's/^LoadState=//p')"
|
|
[ "$load" = loaded ] || die "refusing: no bus unit $unit is installed (LoadState ${load:-unknown}); the Console on the copy $copy needs its bus on that copy"
|
|
paths="$(printf '%s\n' "$out" | sed -n 's/^ExecStart=//p' | grep -o 'path=[^ ;]*' | sed 's/^path=//' || true)"
|
|
[ -n "$paths" ] || die "refusing: $unit has no ExecStart path, so where its bus runs from is unknown; nothing was changed"
|
|
for p in $paths; do
|
|
case "$p" in "$copy"/*) ;; *) die "refusing: $unit runs from $p, not the copy $copy; the bus accepts only its own human-cli.mjs, so every Console bus call would fail human-required until the bus runs from the same copy" ;; esac
|
|
done
|
|
working="$(printf '%s\n' "$out" | sed -n 's/^WorkingDirectory=//p')"
|
|
[ "$working" = "$copy" ] || die "refusing: $unit WorkingDirectory is ${working:-unknown}, not the copy $copy; nothing was changed"
|
|
done
|
|
}
|
|
|
|
install_unit() {
|
|
parse_flags "$@"
|
|
canonical
|
|
[ -n "$COPY_ID" ] || die "install needs --copy ID, a copy made with: scripts/webui-service.sh copy COMMIT"
|
|
local unit
|
|
unit="$(render)" || exit 1
|
|
bus_on_copy "$COPIES/$COPY_ID"
|
|
# N1: a running unit would take the new ExecStart at its next restart.
|
|
local state
|
|
state="$(running)" || exit 1
|
|
[ -z "$state" ] || die "refusing: $UNIT_NAME is running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui), then install, then start it"
|
|
mkdir -p "$UNIT_DIR"
|
|
local target="$UNIT_DIR/$UNIT_NAME" tmp
|
|
tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")"
|
|
printf '%s\n' "$unit" > "$tmp"
|
|
chmod 0644 "$tmp"
|
|
if [ -f "$target" ] && cmp -s "$tmp" "$target"; then
|
|
rm -f "$tmp"
|
|
echo "unchanged: $target"
|
|
else
|
|
mv -f "$tmp" "$target"
|
|
echo "written: $target"
|
|
fi
|
|
echo "serve: $(sed -n 's/^ExecStart=//p' "$target")"
|
|
if [ "$RELOAD" = 1 ]; then
|
|
systemctl --user daemon-reload || die "daemon-reload failed"
|
|
echo "daemon-reload done"
|
|
fi
|
|
cat <<MSG
|
|
this script starts nothing: the Console holds the human's capability. Jason
|
|
starts and restarts it, or Mos under lead decision 102 (sends then enter the
|
|
trail as Jason); no other agent does:
|
|
systemctl --user enable --now mosaic-webui start now and at login
|
|
systemctl --user status mosaic-webui
|
|
journalctl --user -u mosaic-webui -f the Console's log
|
|
systemctl --user stop mosaic-webui SIGTERM; restartable
|
|
scripts/webui-service.sh status unit state and /healthz
|
|
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
|
|
MSG
|
|
}
|
|
|
|
uninstall_unit() {
|
|
parse_flags "$@"
|
|
[ "$SERVE_SET" = 0 ] && [ "$COPY_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage
|
|
local target="$UNIT_DIR/$UNIT_NAME"
|
|
# N3: checked with --no-reload too, which skips only daemon-reload.
|
|
local state
|
|
state="$(running)" || exit 1
|
|
[ -z "$state" ] || die "refusing: $UNIT_NAME is still running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui)"
|
|
if [ -f "$target" ]; then
|
|
rm -f "$target"
|
|
echo "removed: $target"
|
|
else
|
|
echo "absent: $target"
|
|
fi
|
|
if [ "$RELOAD" = 1 ]; then
|
|
systemctl --user daemon-reload || die "daemon-reload failed"
|
|
echo "daemon-reload done"
|
|
fi
|
|
}
|
|
|
|
status_unit() {
|
|
parse_flags "$@"
|
|
[ "$SERVE_SET" = 0 ] && [ "$RELOAD" = 1 ] && [ "$COPY_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage
|
|
local target="$UNIT_DIR/$UNIT_NAME"
|
|
echo "unit: $UNIT_NAME"
|
|
echo " enabled: $(systemctl --user is-enabled "$UNIT_NAME" 2>&1 || true)"
|
|
echo " active: $(systemctl --user is-active "$UNIT_NAME" 2>&1 || true)"
|
|
systemctl --user show "$UNIT_NAME" -p MainPID -p NRestarts -p ExecMainStartTimestamp -p Result 2>/dev/null | sed 's/^/ /' || true
|
|
if [ ! -f "$target" ]; then
|
|
echo "healthz: not checked; no unit at $target"
|
|
return 0
|
|
fi
|
|
# The copy is the last part of WorkingDirectory; status only reads.
|
|
local wd copy head
|
|
wd="$(sed -n 's/^WorkingDirectory=//p' "$target")"
|
|
copy="${wd##*/}"
|
|
if [[ "$copy" =~ ^[0-9a-f]{40}$ ]]; then
|
|
echo "copy: $copy"
|
|
if head="$(git -C "$REPO" rev-parse --verify --quiet refs/heads/refactor 2>/dev/null)"; then
|
|
if [ "$head" = "$copy" ]; then
|
|
echo "refactor: $head, the copy's commit"
|
|
else
|
|
echo "refactor: $head, another commit; the copy is $copy"
|
|
fi
|
|
else
|
|
echo "refactor: unknown; git rev-parse refs/heads/refactor failed in $REPO"
|
|
fi
|
|
else
|
|
echo "copy: none; the unit runs from $wd"
|
|
fi
|
|
# render puts --send after the allowed user and optional proof pair,
|
|
# before the tester port. The allowed user
|
|
# starts with a letter or digit (N5), so it is never the value --send.
|
|
if grep -Eq '^ExecStart=.* --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)? --send( --tester-port [0-9]+)?$' "$target"; then
|
|
echo "send: on (--send; human messages to roles)"
|
|
else
|
|
echo "send: off (read-only)"
|
|
fi
|
|
local tester_port
|
|
tester_port="$(sed -n 's/^ExecStart=.* --tester-port \([0-9][0-9]*\)$/\1/p' "$target")"
|
|
if [ -n "$tester_port" ]; then
|
|
echo "tester: http://127.0.0.1:$tester_port (binds the tester)"
|
|
else
|
|
echo "tester: off"
|
|
fi
|
|
local port
|
|
port="$(sed -n 's/^ExecStart=.* --port \([0-9][0-9]*\).*$/\1/p' "$target")"
|
|
[ -n "$port" ] || { echo "healthz: not checked; no --port in $target"; return 0; }
|
|
local url="http://127.0.0.1:$port/healthz"
|
|
echo "healthz: $url $(node -e '
|
|
fetch(process.argv[1], { signal: AbortSignal.timeout(5000) })
|
|
.then(async (res) => console.log(`${res.status} ${(await res.text()).trim()}`))
|
|
.catch((err) => console.log(`unreachable (${err.cause?.code ?? err.name})`));
|
|
' "$url")"
|
|
}
|
|
|
|
# Writes the copy into a temp directory beside the target, takes write
|
|
# permission off every file, then renames it into place, so a copy at its
|
|
# final path is always whole and read-only. An existing copy is left alone.
|
|
copy_commit() {
|
|
exec "$REPO/scripts/stack-copy.sh" copy "$@"
|
|
}
|
|
|
|
[ $# -ge 1 ] || usage
|
|
cmd="$1"; shift
|
|
case "$cmd" in
|
|
copy) copy_commit "$@" ;;
|
|
render)
|
|
parse_flags "$@"
|
|
[ "$DIR_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage
|
|
{ [ -n "$COPY_ID" ] || [ "$COPY_FLAGS" = 0 ]; } || usage
|
|
render ;;
|
|
install) install_unit "$@" ;;
|
|
uninstall) uninstall_unit "$@" ;;
|
|
status) status_unit "$@" ;;
|
|
-h|--help) echo "$USAGE" ;;
|
|
*) usage ;;
|
|
esac
|