#!/bin/sh
# Jourchin agent installer (Phase 8 spec §66, §71).
#
# Install (the Jourchin web app shows this command with a one-time token):
#   curl -fsSL https://install.jourchin.com/agent.sh | sudo JOURCHIN_TOKEN=jat_... sh
# Upgrade in place (keeps the registration):
#   curl -fsSL https://install.jourchin.com/agent.sh | sudo sh
# Remove:
#   curl -fsSL https://install.jourchin.com/agent.sh | sudo sh -s -- --uninstall
#
# Optional environment:
#   JOURCHIN_TOKEN         one-time registration token (required for a new installation)
#   JOURCHIN_VERSION       install this version instead of the latest (e.g. 0.1.0)
#   JOURCHIN_AUTO_UPDATE   0 = don't install signed updates automatically (default 1)
#   JOURCHIN_ENDPOINT      agent gateway (default https://agent.jourchin.com)
#   JOURCHIN_INSTALL_URL   where releases are published (default https://install.jourchin.com)
#   JOURCHIN_CA_FILE       extra CA certificate to trust for a private agent gateway
#
# What it does: checks the platform, downloads the release, verifies its signature (openssl,
# against the key below) and checksum, creates the unprivileged "jourchin-agent" user,
# installs the binary and systemd units, registers, and waits for the first heartbeat. An
# upgrade that doesn't come up healthy is rolled back to the previous binary.
#
# Everything runs inside main(), so a partially downloaded script does nothing.

set -eu
umask 022

INSTALL_URL="${JOURCHIN_INSTALL_URL:-https://install.jourchin.com}"
INSTALL_URL="${INSTALL_URL%/}"
ENDPOINT="${JOURCHIN_ENDPOINT:-}"
CA_FILE="${JOURCHIN_CA_FILE:-}"
AUTO_UPDATE="${JOURCHIN_AUTO_UPDATE:-1}"
BIN=/usr/local/bin/jourchin-agent
STATE_DIR=/var/lib/jourchin-agent
UPDATE_DIR=/var/lib/jourchin-agent-update
AGENT_USER=jourchin-agent
UNIT_DIR=/etc/systemd/system
DEFAULTS=/etc/default/jourchin-agent

# The release signing key (public half). Must equal apps/agent/internal/release/release-key.pem;
# a Go test checks this.
RELEASE_KEY='-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEXnDcpoqlHSKOsjfwWnUO5yD/E/Km
Fkoo8LlFSDAiXRQqx0Ud52jZmfB3BimMsh9BDipRO+o/U8L40xcxiC1SsA==
-----END PUBLIC KEY-----'

say() { printf 'jourchin: %s\n' "$*"; }
warn() { printf 'jourchin: warning: %s\n' "$*" >&2; }
die() {
	printf 'jourchin: error: %s\n' "$*" >&2
	exit 1
}
have() { command -v "$1" >/dev/null 2>&1; }

TMP=""
cleanup() { if [ -n "$TMP" ]; then rm -rf "$TMP"; fi; }

fetch() { # fetch <url> <file>
	if have curl; then
		curl -fsS --retry 3 --max-time 300 -o "$2" "$1"
	else
		wget -q --tries=3 --timeout=300 -O "$2" "$1"
	fi
}

preflight() {
	[ "$(id -u)" = 0 ] || die "run as root (pipe to 'sudo sh')"
	[ "$(uname -s)" = Linux ] || die "the agent runs on Linux only"
	[ -d /run/systemd/system ] || die "systemd is required (this system doesn't run it)"
	for c in systemctl sha256sum openssl useradd runuser install; do
		have "$c" || die "missing required command: $c"
	done
	have curl || have wget || die "curl or wget is required"
	# Signatures are ECDSA P-256 / SHA-256, which every supported OpenSSL can verify.
	openssl version >/dev/null 2>&1 || die "openssl is not working"

	if [ -z "${JOURCHIN_TOKEN:-}" ] && [ ! -f "$STATE_DIR/agent.json" ]; then
		die "JOURCHIN_TOKEN is required for a new installation (copy the command from Jourchin → Servers → Add server)"
	fi
	case "$AUTO_UPDATE" in 0 | 1) ;; *) die "JOURCHIN_AUTO_UPDATE must be 0 or 1" ;; esac
	if [ -n "$CA_FILE" ] && [ ! -r "$CA_FILE" ]; then die "JOURCHIN_CA_FILE $CA_FILE is not readable"; fi
}

detect_platform() {
	case "$(uname -m)" in
	x86_64 | amd64) ARCH=amd64 ;;
	aarch64 | arm64) ARCH=arm64 ;;
	*) die "unsupported CPU architecture $(uname -m) (supported: x86_64, aarch64)" ;;
	esac

	DISTRO=unknown
	DISTRO_VERSION=""
	if [ -r /etc/os-release ]; then
		# shellcheck disable=SC1091
		DISTRO=$(. /etc/os-release && printf '%s' "${ID:-unknown}")
		# shellcheck disable=SC1091
		DISTRO_VERSION=$(. /etc/os-release && printf '%s' "${VERSION_ID:-}")
	fi
	major=${DISTRO_VERSION%%.*}
	min=""
	case "$DISTRO" in
	ubuntu) min=20 ;;
	debian) min=11 ;;
	rhel | rocky | almalinux | centos | ol) min=8 ;;
	amzn) min=2023 ;;
	fedora) min=0 ;;
	esac
	if [ -n "$min" ] && [ "${major:-0}" -ge "$min" ] 2>/dev/null; then
		say "platform: $DISTRO $DISTRO_VERSION ($ARCH)"
	else
		warn "$DISTRO $DISTRO_VERSION isn't a tested distribution; continuing (the agent needs only Linux and systemd)"
	fi
}

resolve_version() {
	if [ -n "${JOURCHIN_VERSION:-}" ]; then
		VERSION=$JOURCHIN_VERSION
	else
		fetch "$INSTALL_URL/agent/latest" "$TMP/latest" || die "can't reach $INSTALL_URL"
		VERSION=$(tr -d ' \r\n' <"$TMP/latest")
	fi
	printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || die "invalid version '$VERSION'"
}

download_and_verify() {
	ASSET="jourchin-agent-linux-$ARCH"
	base="$INSTALL_URL/agent/$VERSION"
	say "downloading jourchin-agent $VERSION"
	fetch "$base/SHA256SUMS" "$TMP/SHA256SUMS" || die "release $VERSION not found"
	fetch "$base/SHA256SUMS.sig" "$TMP/SHA256SUMS.sig" || die "release $VERSION has no signature"
	fetch "$base/$ASSET" "$TMP/$ASSET" || die "release $VERSION has no build for $ARCH"

	printf '%s\n' "$RELEASE_KEY" >"$TMP/release-key.pem"
	openssl dgst -sha256 -verify "$TMP/release-key.pem" -signature "$TMP/SHA256SUMS.sig" "$TMP/SHA256SUMS" >/dev/null 2>&1 ||
		die "SIGNATURE CHECK FAILED for release $VERSION; not installing"
	awk -v a="$ASSET" '{ n = $2; sub(/^\*/, "", n); if (n == a) print $1 "  " a }' "$TMP/SHA256SUMS" >"$TMP/expected"
	[ -s "$TMP/expected" ] || die "release $VERSION lists no checksum for $ASSET"
	(cd "$TMP" && sha256sum -c expected >/dev/null 2>&1) || die "CHECKSUM MISMATCH for $ASSET; not installing"
	chmod 0755 "$TMP/$ASSET"
	got=$("$TMP/$ASSET" version 2>/dev/null || true)
	[ "$got" = "$VERSION" ] || die "the downloaded binary reports version '$got', expected $VERSION"
	say "signature and checksum verified"
}

nologin_shell() {
	for s in /usr/sbin/nologin /sbin/nologin /bin/false; do
		if [ -x "$s" ]; then
			printf '%s' "$s"
			return
		fi
	done
	printf '/bin/false'
}

install_files() {
	if ! id -u "$AGENT_USER" >/dev/null 2>&1; then
		useradd --system --user-group --no-create-home --home-dir "$STATE_DIR" \
			--shell "$(nologin_shell)" --comment "Jourchin agent" "$AGENT_USER"
		say "created system user $AGENT_USER"
	fi
	install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" "$STATE_DIR"

	UPGRADE=0
	if [ -x "$BIN" ]; then
		UPGRADE=1
		cp -p "$BIN" "$BIN.previous"
	fi
	install -m 0755 -o root -g root "$TMP/$ASSET" "$BIN.new"
	mv -f "$BIN.new" "$BIN"

	if [ -n "$CA_FILE" ]; then
		install -m 0644 -o root -g root "$CA_FILE" /etc/jourchin-agent-ca.pem
	fi
	{
		echo "# Written by the Jourchin installer."
		echo "JOURCHIN_INSTALL_URL=$INSTALL_URL"
		if [ -f /etc/jourchin-agent-ca.pem ]; then echo "JOURCHIN_CA_FILE=/etc/jourchin-agent-ca.pem"; fi
	} >"$DEFAULTS"
	chmod 0644 "$DEFAULTS"
	write_units
	systemctl daemon-reload
}

write_units() {
	cat >"$UNIT_DIR/jourchin-agent.service" <<EOF
# Installed by the Jourchin installer; reinstalling overwrites it.
[Unit]
Description=Jourchin agent (server health reporting)
Documentation=https://jourchin.com
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=$AGENT_USER
Group=$AGENT_USER
EnvironmentFile=-$DEFAULTS
ExecStart=$BIN run --state-dir $STATE_DIR
# Exit status 0 means the server was removed in Jourchin: stay stopped.
Restart=on-failure
RestartSec=10s
UMask=0077
Nice=10
MemoryMax=256M
CPUQuota=20%
TasksMax=64

# Hardening. Deliberately no ProtectSystem/ProtectHome/PrivateTmp/ReadOnlyPaths/PrivateDevices:
# they give the agent a private mount namespace in which filesystems look read-only, so it would
# report wrong mount states. Isolation comes from the unprivileged user plus the options below.
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

[Install]
WantedBy=multi-user.target
EOF

	cat >"$UNIT_DIR/jourchin-agent-update.service" <<EOF
# Installed by the Jourchin installer. Installs newer *signed* releases and rolls back if the new
# version doesn't report healthy. Runs as root because it replaces $BIN.
[Unit]
Description=Jourchin agent updater
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=-$DEFAULTS
ExecStart=$BIN update --state-dir $STATE_DIR
StateDirectory=jourchin-agent-update
StateDirectoryMode=0700
TimeoutStartSec=15min
NoNewPrivileges=yes
EOF

	cat >"$UNIT_DIR/jourchin-agent-update.timer" <<EOF
# Installed by the Jourchin installer: a daily check, in case a notification was missed.
[Unit]
Description=Daily Jourchin agent update check

[Timer]
OnCalendar=daily
RandomizedDelaySec=6h
Persistent=true

[Install]
WantedBy=timers.target
EOF

	cat >"$UNIT_DIR/jourchin-agent-update.path" <<EOF
# Installed by the Jourchin installer. The agent touches this file when the platform announces
# a newer version; the updater then checks the signed release itself.
[Unit]
Description=Start the Jourchin agent updater when a new version is announced

[Path]
PathChanged=$STATE_DIR/update-available
Unit=jourchin-agent-update.service

[Install]
WantedBy=paths.target
EOF
}

registration_failed() {
	# Nothing changed but the binary: put the previous one back too.
	if [ "$UPGRADE" = 1 ] && [ -x "$BIN.previous" ]; then
		mv -f "$BIN.previous" "$BIN"
	fi
	if [ "$1" = 1 ]; then
		systemctl start jourchin-agent.service || true
		warn "kept the existing registration and version; the agent is running again"
	fi
	die "registration failed (the token may be used, expired or revoked: create a new one in Jourchin)"
}

register_agent() {
	[ -n "${JOURCHIN_TOKEN:-}" ] || return 0
	force=""
	if [ -f "$STATE_DIR/agent.json" ]; then
		force="--force"
		say "replacing this server's existing registration"
	fi
	# Registration only writes the new identity once it succeeds, so on failure the existing one
	# is intact: bring its agent back before giving up.
	was_active=0
	if systemctl is-active --quiet jourchin-agent.service; then was_active=1; fi
	systemctl stop jourchin-agent.service 2>/dev/null || true
	# The token travels in the environment, not argv (argv shows up in ps).
	export JOURCHIN_TOKEN
	if [ -f /etc/jourchin-agent-ca.pem ]; then export JOURCHIN_CA_FILE=/etc/jourchin-agent-ca.pem; fi
	if [ -n "$ENDPOINT" ]; then
		# shellcheck disable=SC2086
		runuser -u "$AGENT_USER" -- "$BIN" register --state-dir "$STATE_DIR" --endpoint "$ENDPOINT" $force ||
			registration_failed "$was_active"
	else
		# shellcheck disable=SC2086
		runuser -u "$AGENT_USER" -- "$BIN" register --state-dir "$STATE_DIR" $force ||
			registration_failed "$was_active"
	fi
	unset JOURCHIN_TOKEN
}

# Restart the agent and wait for a heartbeat the platform accepted (status.json is rewritten
# after each one).
start_and_wait() {
	: >"$TMP/started"
	sleep 1 # status.json must be strictly newer than the marker
	systemctl enable jourchin-agent.service >/dev/null 2>&1
	systemctl restart jourchin-agent.service
	i=0
	while [ $i -lt 45 ]; do
		if [ -n "$(find "$STATE_DIR/status.json" -newer "$TMP/started" 2>/dev/null)" ]; then
			return 0
		fi
		sleep 2
		i=$((i + 1))
	done
	return 1
}

start_agent() {
	if start_and_wait; then
		say "the agent is running and reporting to Jourchin"
	else
		warn "the agent hasn't reported within 90 seconds; recent log:"
		journalctl -u jourchin-agent.service -n 20 --no-pager >&2 2>/dev/null || true
		if [ "$UPGRADE" = 1 ] && [ -x "$BIN.previous" ]; then
			mv -f "$BIN.previous" "$BIN"
			systemctl restart jourchin-agent.service || true
			die "version $VERSION didn't report healthy; rolled back to $("$BIN" version 2>/dev/null || echo 'the previous version')"
		fi
		die "installation finished but the agent isn't healthy (check outbound HTTPS to the agent gateway)"
	fi

	if [ "$AUTO_UPDATE" = 1 ]; then
		systemctl enable --now jourchin-agent-update.path jourchin-agent-update.timer >/dev/null 2>&1
		say "automatic signed updates: on (JOURCHIN_AUTO_UPDATE=0 to turn off)"
	else
		systemctl disable --now jourchin-agent-update.path jourchin-agent-update.timer >/dev/null 2>&1 || true
		say "automatic updates: off"
	fi
}

uninstall() {
	[ "$(id -u)" = 0 ] || die "run as root (pipe to 'sudo sh -s -- --uninstall')"
	systemctl disable --now jourchin-agent-update.path jourchin-agent-update.timer \
		jourchin-agent.service >/dev/null 2>&1 || true
	rm -f "$UNIT_DIR/jourchin-agent.service" "$UNIT_DIR/jourchin-agent-update.service" \
		"$UNIT_DIR/jourchin-agent-update.timer" "$UNIT_DIR/jourchin-agent-update.path"
	systemctl daemon-reload || true
	rm -f "$BIN" "$BIN.previous" "$BIN.new" "$DEFAULTS" /etc/jourchin-agent-ca.pem
	rm -rf "$STATE_DIR" "$UPDATE_DIR"
	if id -u "$AGENT_USER" >/dev/null 2>&1; then
		userdel "$AGENT_USER" 2>/dev/null || warn "couldn't remove user $AGENT_USER"
	fi
	say "removed. Also remove (revoke) the server in Jourchin so it stops expecting heartbeats."
}

main() {
	case "${1:-}" in
	--uninstall)
		uninstall
		return
		;;
	"") ;;
	*) die "unknown option '$1' (the only option is --uninstall)" ;;
	esac
	preflight
	TMP=$(mktemp -d)
	trap cleanup EXIT
	trap 'exit 1' INT TERM
	detect_platform
	resolve_version
	download_and_verify
	install_files
	register_agent
	start_agent
}

main "$@"
