#!/bin/sh
# Kelva agent installer, for Linux with systemd, on amd64 and arm64.
#
#   curl -fsSL https://get.kelva.sh | sudo sh -s -- --token kv_enr_...
#
# It installs the agent binary, the kelva system user and the kelva systemd service, then
# enrolls this machine with the token of the install command shown in Kelva. Running it again
# upgrades the agent and keeps the machine's key. --help lists the options, --uninstall
# removes the agent.
#
# What it changes on the system, and nothing else:
#   /usr/local/bin/kelva                 the agent, checked against the published SHA-256
#   /usr/bin/kelva                       a link to it, so that "sudo kelva" works where sudo's
#                                        secure_path leaves out /usr/local/bin (the Red Hat
#                                        family); only when no other file has that name
#   /etc/systemd/system/kelva.service    the service (kelva.service.d/no-read-all.conf too,
#                                        with --no-read-all)
#   /etc/kelva/agent.conf                a commented configuration, only when there is none
#   /var/lib/kelva/                      the machine's key and the agent's state
#   the kelva system user and group
#
# MIT license, like the agent.

set -eu

readonly KELVA_USER=kelva
readonly KELVA_BIN=/usr/local/bin/kelva
readonly KELVA_LINK=/usr/bin/kelva
readonly KELVA_UNIT=/etc/systemd/system/kelva.service
readonly KELVA_DROPIN_DIR=/etc/systemd/system/kelva.service.d
readonly KELVA_NO_READ_ALL=/etc/systemd/system/kelva.service.d/no-read-all.conf
readonly KELVA_CONF_DIR=/etc/kelva
readonly KELVA_CONF=/etc/kelva/agent.conf
readonly KELVA_STATE_DIR=/var/lib/kelva
readonly DEFAULT_DOWNLOAD_BASE=https://get.kelva.sh

# Options.
opt_action=install
opt_purge=0
opt_token=
opt_token_set=0
opt_endpoint=
opt_allow_http=0
opt_download_base=$DEFAULT_DOWNLOAD_BASE
opt_version=
opt_binary=
opt_force_enroll=0
opt_read_all= # yes, no, or empty: keep the current choice

# State.
arch=
tmp_dir=
staged=
echo_off=0
enroll_failed=0
key_was_rejected=0
upload_before=

say() {
	printf '%s\n' "$*"
}

fail() {
	printf 'Error: %s\n' "$*" >&2
	exit 1
}

usage_error() {
	printf 'Error: %s\nRun it with --help for the options.\n' "$*" >&2
	exit 2
}

usage() {
	cat <<'EOF'
Kelva agent installer

Usage:
  curl -fsSL https://get.kelva.sh | sudo sh -s -- --token kv_enr_...
  sudo sh install.sh [options]

Installs or upgrades the Kelva agent and its systemd service, then enrolls this machine.
Running it again upgrades the agent and keeps the machine's key.

Options:
  --token TOKEN        the enrollment token of the install command shown in Kelva
                       (kv_enr_..., valid 15 minutes, single use); "-" reads it from the
                       standard input when the script is run from a file
  --endpoint URL       the ingestion server to enroll with (default https://ingest.kelva.sh)
  --allow-http         allow http:// for the endpoint and the downloads (development only:
                       the key would travel unencrypted)
  --force-enroll       enroll again a machine that already has a key
  --no-read-all        withhold CAP_DAC_READ_SEARCH, which lets the agent read (never write)
                       files only root can read: the SSH, TLS certificate and backup checks
                       then report permission_denied for such files
  --read-all           give that capability back (the default of a first install)
  --version VERSION    install this version instead of the latest
  --download-base URL  where to download the agent from (default https://get.kelva.sh)
  --binary FILE        install this kelva binary instead of downloading one
  --uninstall          stop and remove the agent; keep its key, configuration and user
  --purge              remove everything: the agent, its key, configuration and user
  -h, --help           show this help
EOF
}

# parse_args reads the options, as "--name value" or "--name=value".
parse_args() {
	while [ "$#" -gt 0 ]; do
		arg=$1
		value=
		inline=0
		case $arg in
		--*=*)
			value=${arg#*=}
			arg=${arg%%=*}
			inline=1
			;;
		esac
		case $arg in
		--token | --endpoint | --download-base | --version | --binary)
			if [ "$inline" = 0 ]; then
				[ "$#" -ge 2 ] || usage_error "$arg needs a value."
				value=$2
				shift
			fi
			case $arg in
			--token)
				opt_token=$value
				opt_token_set=1
				;;
			--endpoint) opt_endpoint=$value ;;
			--download-base) opt_download_base=${value%/} ;;
			--version) opt_version=$value ;;
			--binary)
				# Absolute, since the script works from /.
				case $value in
				/* | '') opt_binary=$value ;;
				*) opt_binary=$PWD/$value ;;
				esac
				;;
			esac
			;;
		--allow-http | --force-enroll | --no-read-all | --read-all | --uninstall | --purge | -h | --help)
			[ "$inline" = 0 ] || usage_error "$arg takes no value."
			case $arg in
			--allow-http) opt_allow_http=1 ;;
			--force-enroll) opt_force_enroll=1 ;;
			--no-read-all)
				[ "$opt_read_all" != yes ] || usage_error "--read-all and --no-read-all contradict each other."
				opt_read_all=no
				;;
			--read-all)
				[ "$opt_read_all" != no ] || usage_error "--read-all and --no-read-all contradict each other."
				opt_read_all=yes
				;;
			--uninstall) opt_action=uninstall ;;
			--purge)
				opt_action=uninstall
				opt_purge=1
				;;
			-h | --help)
				usage
				exit 0
				;;
			esac
			;;
		kv_* | *kv_enr_* | *kv_agent_*)
			# Never print what looks like a secret pasted in the wrong place.
			usage_error "unexpected argument (not shown: it looks like a token). Pass the token with --token."
			;;
		*) usage_error "unknown option: $arg" ;;
		esac
		shift
	done
}

# check_url refuses anything but https://, and http:// without --allow-http.
check_url() {
	case $2 in
	https://?*) ;;
	http://?*)
		[ "$opt_allow_http" = 1 ] ||
			usage_error "$1 is an http:// URL: add --allow-http to allow it (development only)."
		;;
	*) usage_error "$1 must be an https:// URL." ;;
	esac
}

check_args() {
	if [ "$opt_action" = uninstall ]; then
		if [ "$opt_token_set" = 1 ] || [ -n "$opt_endpoint$opt_version$opt_binary$opt_read_all" ] ||
			[ "$opt_force_enroll" = 1 ] || [ "$opt_download_base" != "$DEFAULT_DOWNLOAD_BASE" ]; then
			usage_error "--uninstall and --purge take no other option."
		fi
		return 0
	fi
	if [ "$opt_token_set" = 0 ]; then
		[ -z "$opt_endpoint" ] || usage_error "--endpoint is the server to enroll with: pass it with --token."
		[ "$opt_force_enroll" = 0 ] || usage_error "--force-enroll needs --token."
	fi
	[ -z "$opt_endpoint" ] || check_url --endpoint "$opt_endpoint"
	if [ -n "$opt_binary" ]; then
		[ -z "$opt_version" ] || usage_error "--binary and --version contradict each other."
		[ -f "$opt_binary" ] || usage_error "--binary $opt_binary: no such file."
	else
		check_url --download-base "$opt_download_base"
	fi
	if [ -n "$opt_version" ]; then
		check_version "$opt_version"
	fi
}

# check_version accepts what a release name holds: 0.1.0, 0.0.0-dev+3f2a9c1.
check_version() {
	case $1 in
	'' | .* | *..* | *[!A-Za-z0-9.+_-]*) fail "\"$1\" is not a version of the agent." ;;
	esac
}

check_system() {
	[ "$(uname -s)" = Linux ] || fail "The Kelva agent runs on Linux; this is $(uname -s)."
	[ "$(id -u)" = 0 ] || fail "Run it as root: curl -fsSL https://get.kelva.sh | sudo sh -s -- ..."
	[ -d /run/systemd/system ] ||
		fail "This machine does not run systemd, which the agent's service needs."
	command -v systemctl >/dev/null 2>&1 || fail "systemctl is missing."
}

detect_arch() {
	machine=$(uname -m)
	case $machine in
	x86_64 | amd64) arch=amd64 ;;
	aarch64 | arm64) arch=arm64 ;;
	*) fail "The Kelva agent runs on x86_64 (amd64) and aarch64 (arm64); this machine is $machine." ;;
	esac
}

# read_token reads the token from the standard input for --token -, then checks its shape. The
# token is never printed. It reaches "kelva enroll -" on its standard input, through the shell's
# own printf: only the command line of this script may hold it (--token TOKEN), for the few
# seconds of the install, and it is single use.
read_token() {
	[ "$opt_token_set" = 1 ] || return 0
	if [ "$opt_token" = - ]; then
		opt_token=
		if [ -t 0 ]; then
			printf 'Enrollment token: ' >&2
			if stty -echo 2>/dev/null; then
				echo_off=1
			fi
			IFS= read -r opt_token || true
			if [ "$echo_off" = 1 ]; then
				stty echo 2>/dev/null || true
				echo_off=0
			fi
			printf '\n' >&2
		else
			IFS= read -r opt_token || true
		fi
		opt_token=$(printf '%s' "$opt_token" | tr -d ' \t\r')
		[ -n "$opt_token" ] || fail "No token on the standard input. --token - reads it there when the script \
runs from a file (sudo sh install.sh --token -), not when it is piped to sh."
	fi
	case $opt_token in
	kv_enr_?*) ;;
	*) fail "That is not an enrollment token: tokens start with kv_enr_. Copy the install command from Kelva again." ;;
	esac
	case $opt_token in
	*[!A-Za-z0-9_-]*) fail "That is not an enrollment token. Copy the install command from Kelva again." ;;
	esac
	[ "${#opt_token}" -le 255 ] || fail "That is not an enrollment token. Copy the install command from Kelva again."
}

cleanup() {
	if [ "$echo_off" = 1 ]; then
		stty echo 2>/dev/null || true
	fi
	if [ -n "$staged" ]; then
		rm -f "$staged"
	fi
	if [ -n "$tmp_dir" ]; then
		rm -rf "$tmp_dir"
	fi
}

# download fetches a URL into a file, with curl (preferred: it refuses a redirect to http://),
# or wget, which follows no redirect without --allow-http: its --https-only leaves a redirect
# to http:// alone outside recursive mode. A transfer slower than 1 KiB/s for a minute is
# abandoned.
download() {
	if command -v curl >/dev/null 2>&1; then
		proto='=https'
		if [ "$opt_allow_http" = 1 ]; then
			proto='=http,https'
		fi
		curl -fsSL --proto "$proto" --proto-redir "$proto" --tlsv1.2 --retry 3 --connect-timeout 15 \
			--speed-limit 1024 --speed-time 60 -o "$2" "$1" ||
			fail "Could not download $1. Check the network and any proxy (HTTPS_PROXY)."
	elif command -v wget >/dev/null 2>&1; then
		redirects=0
		if [ "$opt_allow_http" = 1 ]; then
			redirects=20
		fi
		wget -q --max-redirect="$redirects" -T 60 -t 3 -O "$2" "$1" ||
			fail "Could not download $1. Check the network and any proxy (HTTPS_PROXY)."
	else
		fail "Install curl or wget, then run this again."
	fi
}

sha256_of() {
	command -v sha256sum >/dev/null 2>&1 ||
		fail "Cannot check the download: install sha256sum (coreutils), then run this again."
	sha256sum "$1" | cut -d ' ' -f 1
}

# fetch_binary downloads the release for this architecture and checks its SHA-256 (or takes
# --binary), then stages it next to its destination: it runs from there (/tmp may be mounted
# noexec) and moves in place atomically.
fetch_binary() {
	tmp_dir=$(mktemp -d)
	if [ -n "$opt_binary" ]; then
		cp "$opt_binary" "$tmp_dir/kelva"
	else
		version=$opt_version
		if [ -z "$version" ]; then
			download "$opt_download_base/agent/latest.txt" "$tmp_dir/latest.txt"
			version=$(head -n 1 "$tmp_dir/latest.txt" | tr -d ' \t\r')
			check_version "$version"
		fi
		name=kelva_linux_$arch
		say "Downloading the Kelva agent $version for linux/$arch."
		download "$opt_download_base/agent/$version/$name" "$tmp_dir/kelva"
		download "$opt_download_base/agent/$version/checksums.txt" "$tmp_dir/checksums.txt"
		expected=$(awk -v name="$name" '$2 == name || $2 == "*" name { print $1; exit }' "$tmp_dir/checksums.txt")
		[ -n "$expected" ] || fail "checksums.txt lists no $name."
		actual=$(sha256_of "$tmp_dir/kelva")
		[ "$actual" = "$expected" ] ||
			fail "The downloaded $name does not match its published SHA-256: run this again."
	fi
	mkdir -p "$(dirname "$KELVA_BIN")"
	staged=$(dirname "$KELVA_BIN")/.kelva.new.$$
	cp "$tmp_dir/kelva" "$staged"
	chown 0:0 "$staged"
	chmod 0755 "$staged"
	if ! new_version=$("$staged" version 2>&1); then
		fail "This kelva binary does not run on this machine (linux/$arch): $new_version"
	fi
}

# our_user tells whether the kelva user is the one this script creates: home /var/lib/kelva
# and the comment "Kelva agent". --purge deletes no other.
our_user() {
	getent passwd "$KELVA_USER" 2>/dev/null |
		awk -F: -v home="$KELVA_STATE_DIR" '$5 == "Kelva agent" && $6 == home { found = 1 } END { exit !found }'
}

ensure_user() {
	if id -u "$KELVA_USER" >/dev/null 2>&1; then
		# The service runs as kelva:kelva and its files are given to that group.
		getent group "$KELVA_USER" >/dev/null 2>&1 ||
			fail "A $KELVA_USER user exists without a $KELVA_USER group: create the group (groupadd $KELVA_USER), or remove that user, then run this again."
		if ! our_user; then
			say "Warning: a $KELVA_USER user already exists, not created by this installer: the agent runs as it."
		fi
		return 0
	fi
	command -v useradd >/dev/null 2>&1 ||
		fail "useradd is missing: install the passwd (Debian, Ubuntu) or shadow-utils (Red Hat) package."
	shell=/bin/false
	for candidate in /usr/sbin/nologin /sbin/nologin /usr/bin/nologin; do
		if [ -x "$candidate" ]; then
			shell=$candidate
			break
		fi
	done
	if getent group "$KELVA_USER" >/dev/null 2>&1; then
		useradd --system --gid "$KELVA_USER" --home-dir "$KELVA_STATE_DIR" --no-create-home \
			--shell "$shell" --comment "Kelva agent" "$KELVA_USER"
	else
		useradd --system --user-group --home-dir "$KELVA_STATE_DIR" --no-create-home \
			--shell "$shell" --comment "Kelva agent" "$KELVA_USER"
	fi
	say "Created the $KELVA_USER system user."
}

# version_of prints the version in a "kelva version" line: "kelva 0.1.0 (commit ...)".
version_of() {
	printf '%s\n' "$1" | awk '{ print $2; exit }'
}

install_files() {
	previous=
	if [ -x "$KELVA_BIN" ]; then
		previous=$("$KELVA_BIN" version 2>/dev/null || true)
	fi
	mv -f "$staged" "$KELVA_BIN"
	staged=
	new=$(version_of "$new_version")
	old=$(version_of "$previous")
	if [ -z "$old" ]; then
		say "Installed the Kelva agent $new."
	elif [ "$old" = "$new" ]; then
		say "Reinstalled the Kelva agent $new."
	else
		say "Replaced the Kelva agent $old with $new."
	fi
	link_bin

	mkdir -p "$KELVA_CONF_DIR"
	chmod 0755 "$KELVA_CONF_DIR"
	if [ ! -e "$KELVA_CONF" ]; then
		write_config >"$KELVA_CONF.new"
		chmod 0644 "$KELVA_CONF.new"
		mv -f "$KELVA_CONF.new" "$KELVA_CONF"
	fi
	# Recursive: if the user was created again with other ids, its files follow (systemd only
	# fixes a state directory whose top it does not own). -h: links are changed, never followed.
	mkdir -p "$KELVA_STATE_DIR"
	chown -hR "$KELVA_USER:$KELVA_USER" "$KELVA_STATE_DIR"
	chmod 0700 "$KELVA_STATE_DIR"

	write_unit >"$KELVA_UNIT.new"
	chmod 0644 "$KELVA_UNIT.new"
	mv -f "$KELVA_UNIT.new" "$KELVA_UNIT"
	case $opt_read_all in
	no)
		mkdir -p "$KELVA_DROPIN_DIR"
		write_no_read_all >"$KELVA_NO_READ_ALL.new"
		chmod 0644 "$KELVA_NO_READ_ALL.new"
		mv -f "$KELVA_NO_READ_ALL.new" "$KELVA_NO_READ_ALL"
		;;
	yes)
		rm -f "$KELVA_NO_READ_ALL"
		rmdir "$KELVA_DROPIN_DIR" 2>/dev/null || true
		;;
	esac
	systemctl daemon-reload
}

# our_link reports whether /usr/bin/kelva is the link this installer makes.
our_link() {
	[ -L "$KELVA_LINK" ] && [ "$(readlink "$KELVA_LINK")" = "$KELVA_BIN" ]
}

# link_bin links /usr/bin/kelva to the agent: sudo runs commands with its secure_path, which
# leaves out /usr/local/bin on the Red Hat family, so "sudo kelva status" would not be found
# there. A file of that name that is not the installer's link is left alone, and a /usr that
# cannot be written (an image-based system) only costs a warning: the agent works without the
# link.
link_bin() {
	our_link && return 0
	if [ -e "$KELVA_LINK" ] || [ -L "$KELVA_LINK" ]; then
		say "Warning: $KELVA_LINK already exists and is not the installer's link, so it is left alone."
		say "Where sudo's secure_path leaves out /usr/local/bin, \"sudo kelva\" runs that file instead of"
		say "the agent: run sudo $KELVA_BIN."
	elif link_error=$(ln -s "$KELVA_BIN" "$KELVA_LINK" 2>&1); then
		say "Linked $KELVA_LINK to $KELVA_BIN, so that sudo finds kelva where its secure_path leaves out"
		say "/usr/local/bin."
	else
		say "Warning: $KELVA_LINK could not be created ($link_error). Where sudo's secure_path leaves"
		say "out /usr/local/bin, run sudo $KELVA_BIN."
	fi
}

# as_kelva runs a command as the kelva user: enrollment talks to the network and needs no
# privilege. Without runuser (util-linux) it runs as root, and kelva enroll gives its files to
# the kelva user.
as_kelva() {
	if command -v runuser >/dev/null 2>&1; then
		runuser -u "$KELVA_USER" -- "$@"
	else
		"$@"
	fi
}

machine_id() {
	sed -n 's/.*"machineId": *"\([^"]*\)".*/\1/p' "$KELVA_STATE_DIR/enrollment.json" 2>/dev/null | head -n 1
}

enrolled_endpoint() {
	sed -n 's/.*"endpoint": *"\([^"]*\)".*/\1/p' "$KELVA_STATE_DIR/enrollment.json" 2>/dev/null | head -n 1
}

is_enrolled() {
	[ -s "$KELVA_STATE_DIR/agent.key" ]
}

# key_rejected tells, from "kelva status --json", whether the server refused the agent key at
# its last attempt: the machine was removed from Kelva or its key revoked. The mark is kept in
# step with the agent by cmd/kelva's TestStatusJSON.
key_rejected() {
	case $("$KELVA_BIN" status --json 2>/dev/null || true) in
	*'"keyRejectedAt": "'*) return 0 ;;
	esac
	return 1
}

enroll() {
	[ "$opt_token_set" = 1 ] || return 0
	if is_enrolled && key_rejected; then
		key_was_rejected=1
	fi
	if [ "$key_was_rejected" = 1 ] && [ "$opt_force_enroll" = 0 ]; then
		say "The server refuses this machine's key (it was removed from Kelva): enrolling it again."
		opt_force_enroll=1
	fi
	if is_enrolled && [ "$opt_force_enroll" = 0 ]; then
		say "This machine is already enrolled (machine $(machine_id)): the token was not used."
		current=$(enrolled_endpoint)
		if [ -n "$opt_endpoint" ] && [ -n "$current" ] && [ "${opt_endpoint%/}" != "$current" ]; then
			say "It stays enrolled with $current, not $opt_endpoint."
		fi
		say "To enroll it again, run the command with --force-enroll."
		return 0
	fi
	set -- enroll -
	if [ -n "$opt_endpoint" ]; then
		set -- "$@" --endpoint "$opt_endpoint"
	fi
	if [ "$opt_allow_http" = 1 ]; then
		set -- "$@" --allow-http
	fi
	if [ "$opt_force_enroll" = 1 ]; then
		set -- "$@" --force
	fi
	if ! printf '%s\n' "$opt_token" | as_kelva "$KELVA_BIN" "$@"; then
		enroll_failed=1
	fi
}

# json_field NAME JSON prints a top-level string of "kelva status --json".
json_field() {
	printf '%s\n' "$2" | sed -n "s/^ *\"$1\": *\"\([^\"]*\)\".*/\1/p" | head -n 1
}

start_service() {
	systemctl enable --quiet kelva.service
	# The last upload before the restart: the new process has sent once it changed.
	upload_before=$(json_field lastUploadAt "$("$KELVA_BIN" status --json 2>/dev/null || true)")
	if ! systemctl restart kelva.service; then
		systemctl status --no-pager kelva.service >&2 || true
		fail "The kelva service did not start: see above, and journalctl -u kelva."
	fi
}

# uploaded_since_start tells, from "kelva status --json", whether the process just started has
# uploaded: the state carries the last upload of the previous process over a restart. Times
# are RFC 3339 in UTC at second precision, so they compare as strings, and the previous
# process's last upload can fall in the new one's first second: the time must also differ from
# the one read before the restart.
uploaded_since_start() {
	started=$(json_field startedAt "$1")
	uploaded=$(json_field lastUploadAt "$1")
	[ -n "$started" ] && [ -n "$uploaded" ] && [ "$uploaded" != "$upload_before" ] &&
		LC_ALL=C expr "$uploaded" \>= "$started" >/dev/null
}

# wait_for_agent polls "kelva status" until the new process has uploaded (the first sample is
# sent at once on a new install, within an interval after a restart): 45 seconds while all
# goes well, 15 while it reports a failure (the network may still be coming up). Not after a
# failed enrollment, which fails the install anyway.
wait_for_agent() {
	elapsed=0
	sending=0
	while [ "$enroll_failed" = 0 ]; do
		code=0
		json=$("$KELVA_BIN" status --json 2>/dev/null) || code=$?
		case $code in
		0)
			if uploaded_since_start "$json"; then
				sending=1
				break
			fi
			limit=45
			;;
		1 | 3) break ;;
		*)
			case $json in
			*'"status": "starting"'* | *'"status": "not_started"'* | *'"status": "stopped"'*) limit=45 ;;
			*) limit=15 ;;
			esac
			;;
		esac
		[ "$elapsed" -lt "$limit" ] || break
		sleep 1
		elapsed=$((elapsed + 1))
	done
	say ""
	"$KELVA_BIN" status || true
	say ""
	if [ -e "$KELVA_NO_READ_ALL" ]; then
		say "The agent reads only the files its user may read (--no-read-all)."
	fi
	if [ "$enroll_failed" = 1 ]; then
		if is_enrolled && [ "$key_was_rejected" = 0 ]; then
			fail "The enrollment failed (see the reason above): the machine keeps its previous key."
		fi
		fail "The agent is installed, but this machine is not enrolled: see the reason above."
	fi
	# Paused by Kelva (the plan watches fewer machines): the mark is kept in step with the agent
	# by cmd/kelva's TestStatusJSON.
	paused=0
	case $json in
	*'"status": "paused"'*) paused=1 ;;
	esac
	if [ "$sending" = 1 ]; then
		say "The Kelva agent is installed and sending. Logs: journalctl -u kelva"
	elif [ "$paused" = 1 ]; then
		say "The Kelva agent is installed, but Kelva paused this machine: the organization's plan watches"
		say "fewer machines. It checks back every 15 minutes and resumes by itself once the machine is active again."
	elif [ "$code" = 1 ]; then
		say "The agent is installed and waits for its enrollment: run the install command shown in Kelva,"
		say "which holds a token, or: sudo kelva enroll -  (then paste the token)."
	else
		say "The agent is installed but has not sent anything yet: see above, and journalctl -u kelva."
	fi
}

# notify_uninstall tells Kelva, before anything is removed, that the agent is going: Kelva marks
# the machine uninstalled, keeps its history and, with --purge, revokes its key. It runs as the
# kelva user when there is one (it only reads the key and talks to the network). Kelva not
# being told never stops the uninstall: it only says so.
notify_uninstall() {
	notified=0
	is_enrolled || return 0
	if [ ! -x "$KELVA_BIN" ]; then
		reason="the agent is not installed"
	else
		purge_flag=
		if [ "$opt_purge" = 1 ]; then
			purge_flag=--purge
		fi
		if id -u "$KELVA_USER" >/dev/null 2>&1; then
			runner=as_kelva
		else
			runner=
		fi
		if output=$($runner "$KELVA_BIN" notify-uninstall ${purge_flag:+"$purge_flag"} 2>&1); then
			notified=1
			say "$output"
			return 0
		fi
		case $output in
		*'unknown command'*) reason="this version of the agent cannot tell it" ;;
		*) reason=$(printf '%s\n' "$output" | tail -n 1) ;; # the agent's verdict, after any log line
		esac
	fi
	say "Warning: Kelva was not told of the uninstall ($reason)."
	case $reason in
	*'belongs to another machine'*) ;; # a clone: the machine in Kelva is the original, still running
	*) say "Remove the machine in Kelva, or it will show as offline there." ;;
	esac
}

uninstall() {
	# Stopped before telling Kelva: a batch accepted after the notice would mark the machine
	# installed again.
	systemctl stop kelva.service >/dev/null 2>&1 || true
	notify_uninstall
	# Even without the unit file, so that no enablement link is left behind.
	systemctl disable --now --quiet kelva.service >/dev/null 2>&1 || true
	# The leftovers of an interrupted run too. The drop-ins, --no-read-all's included, stay
	# until --purge: installing again keeps that choice and "systemctl edit" overrides.
	rm -f "$KELVA_UNIT" "$KELVA_UNIT.new"
	if [ "$opt_purge" = 1 ]; then
		rm -rf "$KELVA_DROPIN_DIR"
	fi
	systemctl daemon-reload
	systemctl reset-failed kelva.service >/dev/null 2>&1 || true
	rm -f "$KELVA_BIN" "$(dirname "$KELVA_BIN")"/.kelva.new.*
	# Only the installer's own link, never another file of that name; a /usr that cannot be
	# written does not stop the uninstall.
	if our_link && ! rm -f "$KELVA_LINK" 2>/dev/null; then
		say "Warning: $KELVA_LINK could not be removed."
	fi
	if [ "$opt_purge" = 1 ]; then
		rm -rf "$KELVA_CONF_DIR" "$KELVA_STATE_DIR"
		if ! id -u "$KELVA_USER" >/dev/null 2>&1; then
			say "Removed the Kelva agent, its key and its configuration."
		elif our_user; then
			userdel "$KELVA_USER"
			# A group other users belong to (it was there before the installer) stays; groupdel
			# itself refuses another user's primary group.
			if getent group "$KELVA_USER" >/dev/null 2>&1; then
				if [ -n "$(getent group "$KELVA_USER" | cut -d : -f 4)" ] || ! groupdel "$KELVA_USER" 2>/dev/null; then
					say "Kept the $KELVA_USER group: other users belong to it."
				fi
			fi
			say "Removed the Kelva agent, its key, its configuration and the $KELVA_USER user."
		else
			say "Removed the Kelva agent, its key and its configuration. The $KELVA_USER user was not"
			say "created by this installer: it is left in place."
		fi
	else
		say "Removed the Kelva agent. Kept its key ($KELVA_STATE_DIR), its configuration ($KELVA_CONF_DIR)"
		say "and the $KELVA_USER user: installing again resumes with the same key. --purge removes them."
	fi
	if [ "$notified" = 1 ]; then
		say "Kelva keeps the machine's history: remove the machine there to erase it."
	fi
}

write_config() {
	cat <<'EOF'
# Kelva agent configuration: "key = value" lines; # starts a comment.
# Apply a change with: systemctl restart kelva
#
# The server is the one this machine enrolled with (/var/lib/kelva/enrollment.json): set
# endpoint only to override it.
#endpoint = https://ingest.kelva.sh
#
# recent_backup: the backup file or directory to watch, and the age past which it is late.
# The service cannot see /tmp or /var/tmp (it has its own).
#backup_path = /var/backups/nightly
#backup_max_age = 26h
#
# tls_certificates: certificates beyond Let's Encrypt, nginx and Apache (files or globs,
# comma separated).
#tls_paths = /etc/ssl/certs/site.pem, /opt/app/tls/*.crt
#
# log_level = info
EOF
}

write_no_read_all() {
	cat <<'EOF'
# Written by the Kelva installer (--no-read-all): the agent reads only the files the kelva
# user may read. Delete this file, or run the installer with --read-all, to give the
# capability back.
[Service]
AmbientCapabilities=
CapabilityBoundingSet=
EOF
}

# write_unit prints the systemd unit: agent/packaging/systemd/kelva.service, kept identical
# by a test.
write_unit() {
	cat <<'KELVA_UNIT'
# The Kelva agent: sends this machine's metrics and audit checks to Kelva. It runs as the
# kelva system user, reads without writing (except its own state directory), listens on no
# port and only opens outbound connections.
#
# Installed as /etc/systemd/system/kelva.service by the install script, which keeps a copy
# of this file: change both. Local changes belong in a drop-in (systemctl edit kelva), which
# the installer leaves alone, for example a proxy:
#
#   [Service]
#   Environment=HTTPS_PROXY=http://proxy.example.com:3128
#
# Directives an older systemd does not know are skipped with a warning in the journal.

[Unit]
Description=Kelva agent
Documentation=https://github.com/tessfieldhq/kelva-agent
Wants=network-online.target
After=network-online.target

[Service]
Type=exec
ExecStart=/usr/local/bin/kelva run
Restart=always
RestartSec=5
User=kelva
Group=kelva

# /var/lib/kelva holds the key, the state and the spool: created and given to kelva by
# systemd, readable by kelva only, and the only place the agent writes.
StateDirectory=kelva
StateDirectoryMode=0700
UMask=0077
# A guest on the server: under contention, the server's own work comes first.
CPUWeight=20
IOWeight=20

# Read any file, write none. The SSH checks read /etc/ssh/sshd_config.d (0600 on cloud
# images), tls_certificates /etc/letsencrypt/live (0700), recent_backup wherever backups are,
# failed_logins the system journal (root's and the systemd-journal group's): without this
# capability they report permission_denied on most servers. It grants no write access and
# nothing else. The installer's --no-read-all flag removes it with a drop-in
# (kelva.service.d/no-read-all.conf). No CAP_NET_ADMIN: the firewall check reads service
# state and configuration, never the ruleset, and exposed_services the kernel's socket
# tables, which any user may read.
AmbientCapabilities=CAP_DAC_READ_SEARCH
CapabilityBoundingSet=CAP_DAC_READ_SEARCH
NoNewPrivileges=yes

# Sandbox. The commands the checks run (apt-get -s, apt-config dump, systemctl, timedatectl,
# chronyc, dnf, journalctl, systemd-detect-virt, lsb_release) inherit it and only read.
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
RemoveIPC=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# AF_UNIX for D-Bus (systemctl, timedatectl) and the journal, AF_NETLINK for the list of
# network interfaces and addresses, AF_INET and AF_INET6 for the server.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
# Relaxed or not set, and why:
# - ProtectHome=read-only rather than yes: backup_path and tls_paths may point into /home or
#   /root.
# - ProtectKernelModules=yes: loading a module is already impossible (no CAP_SYS_MODULE in
#   the bounding set, and @system-service has no module system call); its remaining effect
#   would hide /usr/lib/modules, where reboot_required dates a pending kernel update.
# - SystemCallFilter=~@privileged @resources: the commands the checks run are the
#   distribution's (apt, dnf, systemctl), and some set resource limits or switch users at
#   start; refusing those calls could break a check on a system the tests do not cover.
# - ProtectProc=invisible and ProcSubset=pid: the agent counts processes and reads
#   /proc/meminfo, /proc/stat and the other system files of /proc.
# - PrivateUsers=yes: the capability above would no longer apply to the host's files.
# - PrivateNetwork=yes and IPAddressDeny=: the agent must reach its server, wherever it is.
# - MemoryMax=: dnf's metadata can take hundreds of MiB on a large system; a limit would
#   kill the check, not protect the server.

[Install]
WantedBy=multi-user.target
KELVA_UNIT
}

main() {
	parse_args "$@"
	cd /
	check_args
	check_system
	if [ "$opt_action" = uninstall ]; then
		uninstall
		return 0
	fi
	# A signal exits through the EXIT trap, which cleans up (dash runs it on exit only).
	trap cleanup EXIT
	trap 'exit 129' HUP
	trap 'exit 130' INT
	trap 'exit 143' TERM
	read_token
	detect_arch
	fetch_binary
	ensure_user
	install_files
	enroll
	start_service
	wait_for_agent
}

main "$@"
