#!/usr/bin/env bash # `scripts/webui-service.sh render|install|uninstall|status`: the systemd # user unit that runs the Console, `node packages/webui/src/cli.mjs serve` # (lead decision 94). The template is # packages/webui/systemd/mosaic-webui.service.in; the rendered unit is # mosaic-webui.service in the systemd user directory, one instance. # # render [FLAGS] print the rendered unit on stdout # install [--dir DIR] [--no-reload] [FLAGS] # write the unit (write to a temp file, then # rename) and run `systemctl --user daemon-reload` # uninstall [--dir DIR] [--no-reload] # remove the unit; refuses while it is active # status [--dir DIR] unit state, then GET /healthz on its port # # FLAGS are serve's: --port N (default 7332; 7330 is the operator's own # Console), --business ID, --board URL, and the preview set --public-host # NAME --auth-header HEADER --allow-user ID, all three or none. systemd # expands % and $ and splits on spaces, so each value is held to a plain # character set and refused otherwise. None is a secret. # # There is no start, stop, enable or restart verb. The Console reads the # bus through the human transport, so whoever starts it holds the human's # capability. An agent starting it, even detached through systemd, works # around the human proof (lead decisions 62 and 94). Jason starts it from # his own shell: `systemctl --user enable --now mosaic-webui`. # # 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 render [FLAGS] | install [--dir DIR] [--no-reload] [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]" die() { echo "webui-service: $*" >&2; exit 1; } usage() { echo "$USAGE" >&2; exit 4; } UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" RELOAD=1 DIR_FLAGS=0 PORT=7332 BUSINESS="" BOARD="" PUBLIC_HOST="" AUTH_HEADER="" ALLOW_USER="" SERVE_SET=0 parse_flags() { while [ $# -gt 0 ]; do case "$1" in --dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; DIR_FLAGS=1; shift 2 ;; --no-reload) RELOAD=0; DIR_FLAGS=1; shift ;; --port) [ $# -ge 2 ] || usage; PORT="$2"; 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 ;; *) usage ;; esac done } # 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)) 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 contains a character the unit cannot carry: $ALLOW_USER" ;; esac flags="$flags --public-host $PUBLIC_HOST --auth-header $AUTH_HEADER --allow-user $ALLOW_USER" fi echo "$flags" } 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 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 sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "$TEMPLATE" } install_unit() { parse_flags "$@" local unit unit="$(render)" || exit 1 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 </dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')" [ -z "$active" ] || die "refusing: $UNIT_NAME is still running; the human stops it first (systemctl --user stop mosaic-webui)" fi 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 ] || 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 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")" } [ $# -ge 1 ] || usage cmd="$1"; shift case "$cmd" in render) parse_flags "$@" [ "$DIR_FLAGS" = 0 ] || usage render ;; install) install_unit "$@" ;; uninstall) uninstall_unit "$@" ;; status) status_unit "$@" ;; -h|--help) echo "$USAGE" ;; *) usage ;; esac