#!/bin/sh
# SC-355 / SC-216 / SC-538: select identity and protect the lifecycle lock.
# shellcheck disable=SC2015 # Every A && B || die chain intentionally fails either check.
set -eu
# Read by updater preflight; this is a fixed capability marker, not an override.
# shellcheck disable=SC2034
SHCP_CONSOLE_IDENTITY_SCHEMA=1
# Read by installer/updater: this wrapper opens the lifecycle lock only as root,
# so they may provision it root:root 0600 (shcp-installer#1033, SC-538).
# shellcheck disable=SC2034
SHCP_CONSOLE_LOCK_SCHEMA=2
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
SHCPD=/usr/sbin/shcpd
SYSTEMCTL=systemctl
RELEASE=/opt/shcp
CONFIG=/etc/shcp/console.env
LOCK=/run/shcp/release-lifecycle.lock
# A command waits behind an install, update or installer converge instead of
# failing. The bound is the longest routine job that holds the lock: systemd
# stops installer converge after 30 minutes (shcp-update-converge.service).
LOCK_WAIT=1800
LOCK_NOTE=60
readonly CPMOVE_SUPERVISOR=/usr/sbin/shcp-cpmove-supervisor

cpmove_error() {
	printf '{"schema_version":1,"ok":false,"mode":"dry-run","executable":false,"plan":null,"errors":[{"code":"PARSER_SANDBOX_REQUIRED","message":"%s"}]}\n' "$1"
	exit 4
}

route_cpmove() {
	# Parse a private copy of argv; the ordinary console path still receives
	# its original arguments. Stop at the command, never scan its arguments.
	while [ "$#" -gt 0 ]; do
		case "$1" in
			--env|-e) [ "$#" -ge 2 ] || return 0; shift 2 ;;
			--env=*|-e?*|--quiet|-q|--verbose|--verbose=*|-v|-vv|-vvv|--ansi|--no-ansi|--no-interaction|-n|--no-debug|--profile) shift ;;
			--) shift; break ;;
			*) break ;;
		esac
	done
	case "${1:-}" in
		import:cpmove|shcp:import:cpmove) ;;
		*) return 0 ;;
	esac
	[ "$(id -u)" = 0 ] || cpmove_error 'The cpmove import must be run as root.'
	# Check the same target that exec will follow, including through symlinks.
	if [ -f "$CPMOVE_SUPERVISOR" ] &&
		[ "$(stat -Lc %u -- "$CPMOVE_SUPERVISOR" 2>/dev/null)" = 0 ]; then
		mode=$(stat -Lc %a -- "$CPMOVE_SUPERVISOR" 2>/dev/null) || mode=022
		if [ "$((0$mode & 022))" -eq 0 ]; then
			exec "$CPMOVE_SUPERVISOR" "$@"
		fi
	fi
	cpmove_error 'The cpmove import supervisor is not installed; update the installer.'
}
# The installer owns the isolated parser and its lifecycle. Hand off before
# reading panel configuration, taking its lock, or dropping to the shcp user.
route_cpmove "$@"

die() { printf 'shcp: %s\n' "$*" >&2; exit 1; }
usage() {
	printf '%s\n' 'Usage: shcp <exact-command-or-alias> [arguments/options]' \
		'       shcp help <exact-command-or-alias> [options]' \
		'       shcp list [options] | --help | --version' \
		'Unprefixed SHCP aliases are accepted. Abbreviations are refused.' \
		'Global options must follow the command (except import:cpmove). No arguments lists SHCP commands.'
}
case "${1:-}" in
	-h|--help) usage; exit 0 ;;
	-V|--version) [ "$#" -eq 1 ] || { usage >&2; exit 2; } ;;
	-*) usage >&2; exit 2 ;;
esac

# An immutable file in a writable directory is replaceable. Check resolved
# ancestors too; the release symlink itself lives under the trusted /opt parent.
trusted() (
	path=$(readlink -f -- "$1") || exit 1
	[ -n "$path" ] || exit 1
	while :; do
		[ "$(stat -c %u -- "$path")" = 0 ] || exit 1
		mode=$(stat -c %a -- "$path") || exit 1
		[ "$((0$mode & 022))" -eq 0 ] || exit 1
		[ "$path" != / ] || break
		path=${path%/*}
		[ -n "$path" ] || path=/
	done
)
# root:root 0600 is the target. root:shcp 0640 is what an installer or updater
# that predates SHCP_CONSOLE_LOCK_SCHEMA left behind; only root opens it here
# either way, and their next run tightens it in place.
trusted_lock() {
	[ ! -L "$LOCK" ] && [ -f "$LOCK" ] && trusted "$LOCK" || return 1
	case "$(stat -c '%u:%g:%a:%h' -- "$LOCK")" in
		0:0:600:1|"0:$panel_gid:640:1") return 0 ;;
	esac
	return 1
}
[ -f "$CONFIG" ] && trusted "$CONFIG" || die "missing or unsafe $CONFIG; reinstall the current installer console configuration"
OS_SETPRIV='' OS_FLOCK=''
# Two-key data, never shell code. Reject duplicates and extras.
while IFS= read -r line || [ -n "$line" ]; do
	case "$line" in
		OS_SETPRIV=/*) [ -z "$OS_SETPRIV" ] || die 'duplicate OS_SETPRIV'; OS_SETPRIV=${line#*=} ;;
		OS_FLOCK=/*) [ -z "$OS_FLOCK" ] || die 'duplicate OS_FLOCK'; OS_FLOCK=${line#*=} ;;
		*) die 'invalid console configuration' ;;
	esac
done < "$CONFIG"
for executable in "$OS_SETPRIV" "$OS_FLOCK" "$SHCPD"; do
	case "$executable" in *[!a-zA-Z0-9_./-]*|'') die 'invalid console executable path' ;; esac
	[ -f "$executable" ] && [ -x "$executable" ] && trusted "$executable" || die "missing or unsafe executable: $executable"
done
# SC-538: read access is sufficient to take an exclusive or shared flock, and a
# held shared lock stalls every release writer. Only root may open this inode:
# a panel-account compromise must not be able to pin the release (installer#1033).
panel_uid=$(id -u shcp) && panel_gid=$(id -g shcp) || die 'shcp account is unavailable'
case "$panel_uid:$panel_gid" in *[!0-9:]*|0:*|*:0|:*|*:) die 'invalid shcp account identity' ;; esac
caller=$(id -u) || die 'cannot determine caller identity'
[ "$caller" = 0 ] || die 'console must run as root; panel commands drop to shcp themselves'
trusted_lock || die "missing or unsafe lifecycle lock: $LOCK; require root:root 0600 with one link; rerun installer configuration"
# The installer creates this inode once; never truncate, unlink or recreate it.
# fd 9 survives both execs, retaining the lock until the command exits.
exec 9< "$LOCK"
# 75 is flock's answer for a lock someone else holds; any other failure is an
# error to report, never a reason to keep waiting.
lock_shared() {
	rc=0
	"$OS_FLOCK" --shared --conflict-exit-code 75 "$@" 9 || rc=$?
	[ "$rc" = 0 ] || [ "$rc" = 75 ] || die "cannot lock $LOCK (flock exit $rc)"
	return "$rc"
}
if ! lock_shared --nonblock; then
	printf 'shcp: an install, update or installer converge is running; waiting up to %s minutes for it to finish\n' "$((LOCK_WAIT / 60))" >&2
	waited=0
	until lock_shared --timeout "$LOCK_NOTE"; do
		waited=$((waited + LOCK_NOTE))
		[ "$waited" -lt "$LOCK_WAIT" ] || die "release lifecycle busy for ${waited}s; retry after installation/update completes"
		printf 'shcp: still waiting (%s seconds so far)\n' "$waited" >&2
	done
	printf 'shcp: it finished; running the command\n' >&2
fi
trusted "$(dirname -- "$RELEASE")" && trusted "$RELEASE" || die 'unsafe active release path'
cd -P -- "$RELEASE" || die 'active release is unavailable'
for artifact in bin/command-identity config/console-identities.json bin/console; do
	[ -f "$artifact" ] && trusted "$artifact" || die 'release lacks trusted command identity metadata; upgrade shcp-base first. Recovery: /usr/sbin/shcpd php-cli /opt/shcp/bin/console <exact-command> (root-cache semantics)'
done

resolve() {
	resolved=$("$SHCPD" php-cli bin/command-identity --resolve "$1") || die "unknown or unclassified command: $1 (use an exact name)"
	case "$resolved" in ''|*[!a-zA-Z0-9_:.-]*|-*) die 'invalid command resolver output' ;; esac
	printf '%s\n' "$resolved"
}
if [ "$#" -eq 0 ]; then
	command=list
	set -- list shcp
else
	case "$1" in
		-V|--version) command=list; set -- --version ;;
		list) shift; command=list; set -- list shcp "$@" ;;
		help)
			shift
			if [ "$#" -gt 0 ]; then
				command=$(resolve "$1") || exit 1
				shift
				set -- help "$command" "$@"
			else
				command=help; set -- help
			fi ;;
		*) command=$(resolve "$1") || exit 1; shift; set -- "$command" "$@" ;;
	esac
fi
identity=$("$SHCPD" php-cli bin/command-identity "$command") || die "missing identity for $command"
case "$identity" in
	manual) die "$command requires explicit worker identity; use the configured worker service or /usr/sbin/shcpd php-cli /opt/shcp/bin/console as the required user" ;;
	root) set -- "$SHCPD" php-cli bin/console "$@" ;;
	panel) set -- "$OS_SETPRIV" --reuid="$panel_uid" --regid="$panel_gid" --init-groups -- "$SHCPD" php-cli bin/console "$@" ;;
	*) die 'invalid command identity output' ;;
esac

# shcp-base#2736: cache:clear swaps the compiled container out from under every
# running messenger worker, which then dies on its next lazily loaded service.
# So the workers are stopped first, none mid-message when the files go, and
# started again with the panel restarted once the clear has ended, whatever its
# exit status. They are found from systemd (any active shcp-* unit whose
# ExecStart is messenger:consume), not from a list kept here.
clears_container() {
	case "$command" in cache:clear|shcp:clearcache) ;; *) return 1 ;; esac
	[ "$1" = "$command" ] || return 1
	for argument; do
		case "$argument" in -h|--help) return 1 ;; esac
	done
}
console_args() {
	while [ "$1" != bin/console ]; do shift; done
	shift
	clears_container "$@"
}
console_args "$@" || exec "$@"

units=$("$SYSTEMCTL" list-units --type=service --state=active,activating --plain --no-legend --no-pager 'shcp-*.service') ||
	die 'cannot list the running messenger workers; nothing was cleared'
workers=''
while read -r unit _; do
	case "$unit" in ''|*[!a-zA-Z0-9@._:-]*) continue ;; esac
	case " $("$SYSTEMCTL" show --property=ExecStart --value -- "$unit") " in
		*' messenger:consume '*) workers="$workers $unit" ;;
	esac
done <<EOF_UNITS
$units
EOF_UNITS

# The traps hold from before the first worker stops until the last one is
# started again: a signal that killed this shell anywhere in between would
# leave every worker down. A signal before the clear starts clears nothing;
# one during it goes on to the child, as TERM for an INT, because a
# background child of a non-interactive shell ignores SIGINT. Either way the
# workers come back and this shell then dies of the same signal.
signal=''
child=''
for s in TERM INT HUP; do
	forward=$s
	[ "$s" != INT ] || forward=TERM
	# shellcheck disable=SC2064 # bind the signal names now
	trap "signal=$s; [ -z \"\$child\" ] || kill -$forward \$child 2>/dev/null || :" "$s"
done
if [ -n "$workers" ]; then
	printf 'shcp: stopping messenger workers until the cache is rebuilt:%s\n' "$workers" >&2
	# shellcheck disable=SC2086 # unit names, checked above
	"$SYSTEMCTL" stop -- $workers || {
		"$SYSTEMCTL" start -- $workers || :
		[ -z "$signal" ] || { trap - "$signal"; kill -"$signal" $$; }
		die 'cannot stop the messenger workers; nothing was cleared'
	}
fi

# Run the clear as a child so the workers come back whatever ends it.
rc=0
if [ -z "$signal" ]; then
	"$@" &
	child=$!
	if [ -n "$signal" ]; then
		forward=$signal
		[ "$forward" != INT ] || forward=TERM
		kill -"$forward" "$child" 2>/dev/null || :
	fi
	wait "$child" || rc=$?
	while [ -n "$signal" ] && kill -0 "$child" 2>/dev/null; do
		rc=0
		wait "$child" || rc=$?
	done
fi
child=''

restarted=1
"$SYSTEMCTL" try-restart shcpd.service || restarted=0
if [ -n "$workers" ]; then
	# shellcheck disable=SC2086 # unit names, checked above
	if "$SYSTEMCTL" start -- $workers; then
		printf 'shcp: started the messenger workers again\n' >&2
	else
		restarted=0
	fi
fi
trap - TERM INT HUP
[ -z "$signal" ] || kill -"$signal" $$
[ "$restarted" = 1 ] || die "the cache command exited $rc, but the panel or its workers did not start again; run: systemctl start shcpd.service$workers"
exit "$rc"
