#!/bin/sh
# CVE Watch agent installer (PLAN.md §6.7, AC-1.1).
#
#   curl -fsSL https://cvewatch.sh | sh -s -- --key cw_enr_...
#
# Installs the agent (the .deb on Debian and Ubuntu, otherwise the tarball with the systemd unit),
# creates the unprivileged cvewatch user, enrolls the host, runs a first scan so the host shows
# up at once, and starts the service. Re-running it upgrades the agent and keeps the enrollment.
#
# Every artifact is checked against its SHA-256 from checksums.txt, fetched over a separate
# connection from releases.cvewatch.sh. Downloads come from the same host for now; once the
# repository is public (Phase 3) they move to GitHub releases, so that one compromised host can no
# longer serve a matching pair. Nothing runs until the whole script has downloaded: the body is
# one function called on the last line.
#
# The version is the promoted release from releases.cvewatch.sh/stable/latest.json, except during
# an auto-update canary (rollout below 100%), when new installs keep the previous release.
#
# Environment (all optional): CVEWATCH_ENROLL_KEY instead of --key; CVEWATCH_DOWNLOAD_URL and
# CVEWATCH_RELEASES_URL to use a mirror; CVEWATCH_NO_DOCKER_GROUP=1 to keep the agent out of the
# docker group (it then cannot see containers).

set -eu

DOWNLOAD_URL=${CVEWATCH_DOWNLOAD_URL:-https://releases.cvewatch.sh}
RELEASES_URL=${CVEWATCH_RELEASES_URL:-https://releases.cvewatch.sh}
AGENT_USER=cvewatch
STATE_DIR=/var/lib/cvewatch

usage() {
	cat <<'EOF'
usage: cvewatch.sh [options]

  --key KEY        enrollment key (cw_enr_...); without it the agent is installed but not enrolled
  --server URL     control plane URL (default: the agent's built-in https://api.cvewatch.dev)
  --tags a,b       host tags
  --ephemeral      delete the host after 24h without a heartbeat
  --force          enroll again even if this host is already enrolled
  --version X.Y.Z  install this version (default: the current stable release)
  --from-dir DIR   install from release artifacts in DIR instead of downloading them
  --no-start       install and enroll, but do not start or enable the service
  -h, --help       show this help
EOF
}

say() { printf 'cvewatch: %s\n' "$*" >&2; }
die() {
	say "error: $*"
	exit 1
}

# need_value NAME VALUE exits with a usage error unless the option NAME was given a VALUE. It
# runs in the main shell (not in $(...)), so die really exits.
need_value() {
	[ -n "$2" ] && [ "${2#-}" = "$2" ] || die "$1 needs a value"
}

parse_args() {
	KEY=${CVEWATCH_ENROLL_KEY:-}
	SERVER='' TAGS='' EPHEMERAL='' FORCE='' VERSION='' FROM_DIR='' NO_START=''
	while [ $# -gt 0 ]; do
		case $1 in
		--key | --server | --tags | --version | --from-dir)
			need_value "$1" "${2:-}"
			case $1 in
			--key) KEY=$2 ;;
			--server) SERVER=$2 ;;
			--tags) TAGS=$2 ;;
			--version) VERSION=$2 ;;
			--from-dir) FROM_DIR=$2 ;;
			esac
			shift
			;;
		--key=*) KEY=${1#*=} ;;
		--server=*) SERVER=${1#*=} ;;
		--tags=*) TAGS=${1#*=} ;;
		--version=*) VERSION=${1#*=} ;;
		--from-dir=*) FROM_DIR=${1#*=} ;;
		--ephemeral) EPHEMERAL=1 ;;
		--force) FORCE=1 ;;
		--no-start) NO_START=1 ;;
		-h | --help)
			usage
			exit 0
			;;
		*)
			usage >&2
			die "unknown option: $1"
			;;
		esac
		shift
	done
	# The key is never echoed; the agent checks it fully.
	case $KEY in
	'' | cw_enr_*) ;;
	*) die "--key: not an enrollment key (they start with cw_enr_)" ;;
	esac
	VERSION=${VERSION#v}
}

# detect_platform sets ARCH (Go arch), FORMAT (deb or tarball) and SYSTEMD (1 if running).
detect_platform() {
	[ "$(uname -s)" = Linux ] || die "the agent runs on Linux only"
	case $(uname -m) in
	x86_64 | amd64) ARCH=amd64 ;;
	aarch64 | arm64) ARCH=arm64 ;;
	*) die "unsupported architecture $(uname -m); the agent is built for amd64 and arm64" ;;
	esac

	FORMAT=tarball
	if command -v dpkg >/dev/null 2>&1; then
		# Parsed, not sourced: os-release is data.
		os_id=$(os_release_field ID)
		os_like=$(os_release_field ID_LIKE)
		case " $os_id $os_like " in
		*" debian "* | *" ubuntu "*) FORMAT=deb ;;
		esac
	fi

	SYSTEMD=''
	[ -d /run/systemd/system ] && SYSTEMD=1
	return 0
}

os_release_field() {
	f=/etc/os-release
	[ -r "$f" ] || f=/usr/lib/os-release
	[ -r "$f" ] || return 0
	sed -n "s/^$1=//p" "$f" | head -n 1 | tr -d '"'"'"
}

# fetch URL FILE downloads over HTTPS only (no redirects to http) with curl or wget.
fetch() {
	case $1 in
	https://*) ;;
	*) die "refusing to download over plain HTTP: $1" ;;
	esac
	if command -v curl >/dev/null 2>&1; then
		curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -o "$2" "$1"
	elif command -v wget >/dev/null 2>&1; then
		wget -q --https-only -O "$2" "$1"
	else
		die "neither curl nor wget is installed"
	fi
}

# resolve_version sets VERSION to the current stable release unless --version was given.
resolve_version() {
	[ -n "$VERSION" ] && return 0
	[ -z "$FROM_DIR" ] || die "--from-dir needs --version"
	fetch "$RELEASES_URL/stable/latest.json" "$TMP/latest.json" || die "could not look up the current release"
	VERSION=$(latest_install_version "$TMP/latest.json")
	[ -n "$VERSION" ] || die "malformed $RELEASES_URL/stable/latest.json"
}

# latest_install_version FILE prints the version a new install gets from latest.json (written by
# install/release/promote.sh): "version" when fully rolled out, else "previous_version" if any.
latest_install_version() {
	json=$(tr -d '\n\r' <"$1")
	version=$(json_string "$json" version)
	previous=$(json_string "$json" previous_version)
	percent=$(printf '%s' "$json" | sed -n 's/.*"rollout_percent"[[:space:]]*:[[:space:]]*\([0-9]\{1,3\}\).*/\1/p')
	if [ -n "$previous" ] && [ "${percent:-100}" -lt 100 ]; then
		printf '%s\n' "$previous"
	else
		printf '%s\n' "$version"
	fi
}

# json_string JSON KEY prints a string field that holds a version, or nothing.
json_string() {
	printf '%s' "$1" | sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"v\{0,1\}\([0-9][0-9A-Za-z.+~-]*\)".*/\1/p'
}

# get_artifact NAME places the artifact and checksums.txt in $TMP, then verifies the checksum.
get_artifact() {
	if [ -n "$FROM_DIR" ]; then
		cp "$FROM_DIR/$1" "$TMP/$1" || die "$1 not found in $FROM_DIR"
		cp "$FROM_DIR/checksums.txt" "$TMP/checksums.txt" || die "checksums.txt not found in $FROM_DIR"
	else
		say "downloading $1"
		fetch "$DOWNLOAD_URL/v$VERSION/$1" "$TMP/$1" || die "could not download $1"
		fetch "$RELEASES_URL/v$VERSION/checksums.txt" "$TMP/checksums.txt" || die "could not download checksums.txt"
	fi
	want=$(awk -v f="$1" '$2 == f || $2 == "*" f { print $1; exit }' "$TMP/checksums.txt")
	[ -n "$want" ] || die "checksums.txt does not list $1"
	got=$(sha256 "$TMP/$1")
	[ "$got" = "$want" ] || die "checksum mismatch for $1 (got $got, want $want); not installing"
}

sha256() {
	if command -v sha256sum >/dev/null 2>&1; then
		sha256sum "$1" | cut -d ' ' -f 1
	elif command -v shasum >/dev/null 2>&1; then
		shasum -a 256 "$1" | cut -d ' ' -f 1
	else
		openssl dgst -sha256 -r "$1" | cut -d ' ' -f 1
	fi
}

install_deb() {
	pkg=cvewatch_${VERSION}_$ARCH.deb
	get_artifact "$pkg"
	DEBIAN_FRONTEND=noninteractive dpkg -i "$TMP/$pkg" >&2
	BIN=/usr/bin/cvewatch
}

install_tarball() {
	tarball=cvewatch_${VERSION}_linux_$ARCH.tar.gz
	get_artifact "$tarball"
	mkdir "$TMP/x"
	tar -xzf "$TMP/$tarball" -C "$TMP/x"
	BIN=/usr/local/bin/cvewatch
	install -m 0755 "$TMP/x/cvewatch" "$BIN"

	create_user
	install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" "$STATE_DIR"
	join_docker_group
	if [ -n "$SYSTEMD" ]; then
		sed "s|^ExecStart=/usr/bin/cvewatch|ExecStart=$BIN|" "$TMP/x/systemd/cvewatch.service" >/etc/systemd/system/cvewatch.service
		chmod 0644 /etc/systemd/system/cvewatch.service
		systemctl daemon-reload
	fi
}

create_user() {
	getent passwd "$AGENT_USER" >/dev/null 2>&1 && return 0
	nologin=/usr/sbin/nologin
	[ -x "$nologin" ] || nologin=/sbin/nologin
	if command -v useradd >/dev/null 2>&1; then
		useradd --system --user-group --home-dir "$STATE_DIR" --no-create-home \
			--shell "$nologin" --comment "CVE Watch agent" "$AGENT_USER"
	elif command -v adduser >/dev/null 2>&1; then # BusyBox (Alpine)
		addgroup -S "$AGENT_USER"
		adduser -S -D -H -h "$STATE_DIR" -s "$nologin" -G "$AGENT_USER" -g "CVE Watch agent" "$AGENT_USER"
	else
		die "cannot create the $AGENT_USER user: neither useradd nor adduser is installed"
	fi
}

# join_docker_group gives the agent the container runtime socket. The deb's postinst does the same.
join_docker_group() {
	[ -z "${CVEWATCH_NO_DOCKER_GROUP:-}" ] || return 0
	getent group docker >/dev/null 2>&1 || return 0
	id -nG "$AGENT_USER" | tr ' ' '\n' | grep -qx docker && return 0
	if command -v usermod >/dev/null 2>&1; then
		usermod -aG docker "$AGENT_USER"
	else
		addgroup "$AGENT_USER" docker
	fi
	say "added user $AGENT_USER to group docker to read container inventory (root-equivalent; set CVEWATCH_NO_DOCKER_GROUP=1 to skip)"
}

# as_agent runs the agent binary as the cvewatch user, with that user's groups, keeping the
# environment (the enrollment key travels in it, not on the command line). Minimal images may
# lack every tool for that (fedora's container image has no runuser, setpriv or su); then the
# command runs as root and the state directory is handed back to the agent user afterwards. The
# agent writes its files with mode 0600, which chown keeps.
as_agent() {
	if command -v runuser >/dev/null 2>&1; then
		runuser -u "$AGENT_USER" -- "$BIN" "$@"
	elif command -v su >/dev/null 2>&1; then
		# "--" stops su (BusyBox's too) from parsing the agent's flags as its own.
		# shellcheck disable=SC2016 # expanded by the inner shell, which gets $BIN as $0
		su -s /bin/sh -c 'exec "$0" "$@"' -- "$AGENT_USER" "$BIN" "$@"
	elif setpriv --help 2>&1 | grep -q -- --reuid; then # util-linux's, not BusyBox's
		setpriv --reuid="$AGENT_USER" --regid="$AGENT_USER" --init-groups -- "$BIN" "$@"
	else
		rc=0
		"$BIN" "$@" || rc=$?
		chown -R "$AGENT_USER:$AGENT_USER" "$STATE_DIR"
		return "$rc"
	fi
}

# is_enrolled reports whether the agent has an enrollment; it dies when the agent cannot run as
# its user at all, rather than guessing.
is_enrolled() {
	out=$(as_agent status 2>&1) && return 0
	case $out in
	"not enrolled"*) return 1 ;;
	*"host "*) return 0 ;; # enrolled, but unhealthy (failed scan, rejected credential)
	*) die "cannot run the agent as user $AGENT_USER: $out" ;;
	esac
}

enroll_and_scan() {
	if is_enrolled; then
		enrolled=1
	else
		enrolled=''
	fi
	if [ -z "$KEY" ]; then
		[ -n "$enrolled" ] || say "installed but not enrolled; enroll with: sudo -u $AGENT_USER $BIN enroll --key cw_enr_..."
		return 0
	fi
	if [ -n "$enrolled" ] && [ -z "$FORCE" ]; then
		say "already enrolled; keeping the enrollment (use --force to enroll again with the new key)"
		return 0
	fi

	set -- enroll
	[ -z "$SERVER" ] || set -- "$@" --server "$SERVER"
	[ -z "$TAGS" ] || set -- "$@" --tags "$TAGS"
	[ -z "$EPHEMERAL" ] || set -- "$@" --ephemeral
	[ -z "$enrolled" ] || set -- "$@" --force
	# Exported explicitly: an assignment prefixed to a function call is not reliably exported
	# (POSIX leaves it unspecified; dash does not).
	CVEWATCH_ENROLL_KEY=$KEY
	export CVEWATCH_ENROLL_KEY
	as_agent "$@" >&2 || die "enrollment failed"
	unset KEY CVEWATCH_ENROLL_KEY

	# The first scan now, so the host appears in the dashboard within a minute; the service then
	# schedules the next one from it.
	as_agent scan >&2 || say "warning: the first scan failed; the service will retry"
}

start_service() {
	if [ -n "$NO_START" ]; then
		say "not starting the service (--no-start)"
	elif [ -n "$SYSTEMD" ]; then
		systemctl enable cvewatch.service >/dev/null 2>&1
		systemctl restart cvewatch.service
		say "service cvewatch started; check it with: $BIN status"
	else
		say "systemd is not running here; start \`$BIN run\` as user $AGENT_USER under your service manager"
	fi
}

main() {
	parse_args "$@"
	[ "$(id -u)" -eq 0 ] || die "run as root (for example: curl -fsSL https://cvewatch.sh | sudo sh -s -- --key ...)"
	detect_platform

	umask 077
	TMP=$(mktemp -d)
	trap 'rm -rf "$TMP"' EXIT
	trap 'exit 130' INT TERM

	resolve_version
	say "installing cvewatch $VERSION ($FORMAT, linux/$ARCH)"
	case $FORMAT in
	deb) install_deb ;;
	tarball) install_tarball ;;
	esac
	"$BIN" version >/dev/null || die "the installed agent does not run"
	enroll_and_scan
	start_service
}

# CVEWATCH_INSTALL_LIB=1 loads the functions without installing (install/release/test.sh).
[ -n "${CVEWATCH_INSTALL_LIB:-}" ] || main "$@"
