#!/bin/sh
# Nightsquire executor installer (template). Nightsquire serves this file at
# https://get.nightsquire.dev with the placeholders below filled in; secrets are never baked
# in. Usage on an amd64 Linux host with systemd: Debian, Ubuntu (and
# derivatives such as Linux Mint and Pop!_OS), RHEL, Rocky Linux, AlmaLinux,
# Oracle Linux, CentOS Stream or Fedora (the registration token never needs to
# be on a command line):
#
#   curl -fsSL https://get.nightsquire.dev | sudo sh
#       prompts for the token on the terminal (input not echoed)
#
#   curl -fsSL https://get.nightsquire.dev -o install.sh
#   sudo sh install.sh --token-stdin < token.txt      (or: <secret-tool> | sudo sh install.sh --token-stdin)
#       non-interactive; the token is read from stdin
#
#   read -rs REGISTRATION_TOKEN && export REGISTRATION_TOKEN
#   curl -fsSL https://get.nightsquire.dev | sudo -E sh
#       environment; `read -rs` keeps the token out of shell history (sudo -E
#       must be permitted by sudoers). Typing `REGISTRATION_TOKEN=<token> sudo -E sh`
#       works too but leaves the token in shell history.
#
# Avoid `sudo REGISTRATION_TOKEN=<token> sh`: the token then sits in sudo's
# argv (readable by every local user via ps) and in shell history.
#
# Images are public: every pull is anonymous, no registry credential.
#
# Installs Docker Engine if missing, writes /opt/nightsquire-executor/{docker-compose.yml,.env},
# starts the executor, waits for it to register and then removes the
# (single-use) token from .env. Prints nothing secret.
#
# Docker: the executor needs Docker Engine 26 or newer (not Podman). Missing,
# it is installed from download.docker.com (docker-ce, docker-ce-cli,
# containerd.io, docker-buildx-plugin, docker-compose-plugin; apt on
# Debian/Ubuntu, dnf on the RHEL family and Fedora, docker.com's signing key
# checked by fingerprint). Docker that does not qualify is replaced with it:
# Docker Engine older than 26, the distribution's packages (docker.io,
# moby-engine, the RHEL docker package) or Podman's docker command
# (podman-docker). Replacing removes the conflicting packages Docker's install
# docs list for the distribution, keeps /var/lib/docker and restarts Docker.
# While containers that are not Nightsquire's run (anything outside the
# nightsquire-executor compose project and the dev.nightsquire.managed
# containers), it stops instead and names them; rerun with --replace-docker
# (or NIGHTSQUIRE_REPLACE_DOCKER=1) to replace Docker anyway. Docker from the
# snap or a manual install is never removed: older than 26, it stops with a
# message. Other distributions (Amazon Linux, SUSE, Arch, Alpine, ...) stop
# with the list of supported ones, unless Docker Engine 26+ is installed
# already.
#
#   curl -fsSL https://get.nightsquire.dev | sudo sh -s -- --replace-docker
#
# SELinux (RHEL family, Fedora): docker.com's Docker runs containers without
# SELinux labels, so the executor's bind mounts need no :z; Docker with
# selinux-enabled is refused. firewalld, when running, stays in charge: the
# Nightsquire firewall stays off ("conflict" in the dashboard) until it is
# disabled.
#
# The install dir is mounted read-write into the executor (self-update: the
# updater container records the new image as NIGHTSQUIRE_EXECUTOR_IMAGE in .env).
# Rerunning this script resets NIGHTSQUIRE_EXECUTOR_IMAGE to Nightsquire's image;
# Nightsquire moves the executor to the desired version again afterwards.
#
# Host agent (systemd + apt or dnf hosts): installs nftables and
# unattended-upgrades (apt) or dnf-plugins-core for needs-restarting (dnf) if
# missing (nftables.service is never enabled), copies nightsquire-host-agent out of
# the executor image, checks its digest and self-test, and runs it as
# nightsquire-host-agent.service (updates, reboots and the inbound firewall in the
# dashboard's maintenance window). --no-host-agent (or NIGHTSQUIRE_HOST_AGENT=0)
# skips it; --no-firewall (or NIGHTSQUIRE_FIREWALL=off) leaves the host firewall
# off until the owner enables it. A rerun on a registered host (no token
# needed) installs or updates the agent.
#
# Swap: a host without swap gets a swapfile from the host agent when it starts
# (/var/lib/nightsquire-host-agent/swapfile, the size of the host's memory,
# 1 to 4 GiB, only with 5 GiB of disk to spare beside it; in /etc/fstab, with
# vm.swappiness 10). Steps may page out into it when their memory runs out, so
# they slow down instead of being stopped. A host with swap of its own is left
# alone. --no-swap (or NIGHTSQUIRE_SWAP=off) skips it; a rerun keeps the choice.
#
# The firewall of a new install allows SSH only from the address this script
# runs from over SSH (detected also under `curl … | sudo sh`, where sudo drops
# SSH_CONNECTION) and drops ping; the owner can change both in the dashboard.
# Nothing detected (a console, no SSH): SSH stays open to everyone. Override
# with NIGHTSQUIRE_SSH_SOURCE=<address> (or =none). A rerun that detects nothing
# keeps the address of the earlier install.
#
# Docker for agents: every install also sets up sysbox-ce 0.7.1 (the GitHub
# release .deb, sha256 pinned below), so the executor gives each agent step its
# own Docker daemon under the sysbox-runc runtime (never privileged, never the
# host's Docker socket). sysbox publishes packages for Debian and Ubuntu only
# (no RPMs), so it needs Debian 11+ or Ubuntu 22.04+ with systemd, amd64 or
# arm64, Linux >= 5.12 and Docker Engine from docker.com (not the snap). Where
# that is not the case, or the sysbox install fails, the install goes on
# without it: it says why, the executor reports no `docker` capability (its
# page in Nightsquire shows it) and agent steps run without a Docker daemon.
# Docker keeps its bridge address and address pools: their current values are
# written into /etc/docker/daemon.json (a backup is kept), so the sysbox
# package does not re-address Docker's networks and dockerd only reloads its
# configuration (no Docker restart). A running executor is stopped first
# (docker compose stop: running steps finish) and started again afterwards. An
# installed sysbox-ce 0.7.1, or a Docker that already lists the sysbox-runc
# runtime, is left alone. --docker (before 1.0.2: "also install sysbox") is
# accepted and does nothing.
set -eu

IMAGE_FROM_ENV="${NIGHTSQUIRE_EXECUTOR_IMAGE:+1}"
CONTROLLER_URL="${CONTROLLER_URL:-https://connect-ns-stag.nbg-04.bastiaanprummel.nl}"
NIGHTSQUIRE_EXECUTOR_IMAGE="${NIGHTSQUIRE_EXECUTOR_IMAGE:-registry.prummeltech.com/nightsquire/images/executor:55c002cbe96d7b67a4937b498c6ce748ad5e8d8a}"
REGISTRATION_TOKEN="${REGISTRATION_TOKEN:-}"
EXECUTOR_NAME="${EXECUTOR_NAME:-$(hostname 2>/dev/null || uname -n)}"
# Empty: automatic, one concurrent step per 2 CPUs and 4 GiB of memory (the executor sizes it from the host).
EXECUTOR_CAPACITY="${EXECUTOR_CAPACITY:-}"
EXECUTOR_LABELS="${EXECUTOR_LABELS:-}"
INSTALL_DIR="${INSTALL_DIR:-/opt/nightsquire-executor}"
STATE_DIR=/var/lib/nightsquire-executor
NIGHTSQUIRE_HOST_AGENT="${NIGHTSQUIRE_HOST_AGENT:-1}"
NIGHTSQUIRE_FIREWALL="${NIGHTSQUIRE_FIREWALL:-}"
NIGHTSQUIRE_SWAP="${NIGHTSQUIRE_SWAP:-}"
NIGHTSQUIRE_SSH_SOURCE="${NIGHTSQUIRE_SSH_SOURCE:-}"
HOST_AGENT_DIR=/usr/local/lib/nightsquire-host-agent
HOST_AGENT_CONFIG=/etc/nightsquire-host-agent/config.json
HOST_AGENT_UNIT=/etc/systemd/system/nightsquire-host-agent.service
REPLACE_DOCKER="${NIGHTSQUIRE_REPLACE_DOCKER:-0}"
# sysbox-ce release for Docker for agents (https://github.com/nestybox/sysbox/releases).
SYSBOX_VERSION=0.7.1
SYSBOX_SHA256_AMD64=9d6d5484f980d0a17f86c492c1262015c2afb66280bdb97215b79fde6a0261c5
SYSBOX_SHA256_ARM64=04ca894ae0b53f0fa54eaacc173ce40363c9a95ea5450f773716a84ef650a69b
DOCKER_DAEMON_JSON=/etc/docker/daemon.json

compose() { (cd "$INSTALL_DIR" && docker compose "$@"); }
say() { printf '%s\n' "nightsquire-executor: $*"; }
die() { printf '%s\n' "nightsquire-executor: error: $*" >&2; exit 1; }

TOKEN_STDIN=0
for arg in "$@"; do
	case "$arg" in
	--token-stdin) TOKEN_STDIN=1 ;;
	--no-host-agent) NIGHTSQUIRE_HOST_AGENT=0 ;;
	--no-firewall) NIGHTSQUIRE_FIREWALL=off ;;
	--no-swap) NIGHTSQUIRE_SWAP=off ;;
	--docker) say "--docker is no longer needed: every install sets up Docker for agents where the host supports it" ;;
	--replace-docker) REPLACE_DOCKER=1 ;;
	*) die "unknown argument: $arg (supported: --token-stdin, --no-host-agent, --no-firewall, --no-swap, --replace-docker)" ;;
	esac
done
case "$NIGHTSQUIRE_HOST_AGENT" in 0|1) ;; *) die "NIGHTSQUIRE_HOST_AGENT must be 0 or 1" ;; esac
case "$REPLACE_DOCKER" in 0|1) ;; *) die "NIGHTSQUIRE_REPLACE_DOCKER must be 0 or 1 (1: like --replace-docker)" ;; esac
case "$NIGHTSQUIRE_FIREWALL" in ''|on|off) ;; *) die "NIGHTSQUIRE_FIREWALL must be on or off" ;; esac
case "$NIGHTSQUIRE_SWAP" in ''|on|off) ;; *) die "NIGHTSQUIRE_SWAP must be on or off" ;; esac

# read_hidden prompts with $1 on the terminal and reads one line without echo
# into HIDDEN_INPUT.
read_hidden() {
	printf '%s' "$1" >/dev/tty
	stty -echo </dev/tty 2>/dev/null || true
	trap 'stty echo </dev/tty 2>/dev/null' EXIT INT TERM
	HIDDEN_INPUT=""
	IFS= read -r HIDDEN_INPUT </dev/tty || true
	stty echo </dev/tty 2>/dev/null || true
	trap - EXIT INT TERM
	printf '\n' >/dev/tty
}

# read_token reads one line from $1 into REGISTRATION_TOKEN; on the terminal
# it prompts and does not echo the input.
read_token() {
	if [ "$1" = /dev/tty ]; then
		read_hidden 'Registration token (input hidden): '
		REGISTRATION_TOKEN="$HIDDEN_INPUT"
		HIDDEN_INPUT=""
	else
		IFS= read -r REGISTRATION_TOKEN <"$1" || true
	fi
}

[ "$(id -u)" -eq 0 ] || die "run as root (sudo)"
# Nightsquire's executor image is linux/amd64 only. An image given in the environment (a test build for
# another architecture) is checked against this host after the pull instead.
if [ "$IMAGE_FROM_ENV" != 1 ]; then
	case "$(uname -m)" in
	x86_64 | amd64) ;;
	*) die "the Nightsquire executor runs on amd64 (x86_64) hosts only; this host is $(uname -m)" ;;
	esac
fi
case "$CONTROLLER_URL" in __*__|"") die "CONTROLLER_URL is not set" ;; esac
case "$NIGHTSQUIRE_EXECUTOR_IMAGE" in __*__|"") die "NIGHTSQUIRE_EXECUTOR_IMAGE is not set" ;; esac
case "$EXECUTOR_NAME" in *[!A-Za-z0-9._-]*) die "EXECUTOR_NAME may only contain letters, digits, '.', '_' and '-'" ;; esac
case "$EXECUTOR_CAPACITY" in *[!0-9]*) die "EXECUTOR_CAPACITY must be a number (or empty for automatic)" ;; esac
case "$INSTALL_DIR" in /*) ;; *) die "INSTALL_DIR must be an absolute path" ;; esac
case "$INSTALL_DIR" in *[!A-Za-z0-9._/-]*|*//*|*/|*/./*|*/../*) die "INSTALL_DIR may only contain letters, digits, '.', '_', '-' and single '/'" ;; esac

# --- Docker Engine -------------------------------------------------------------

# The packages of Docker Engine from docker.com, and the fingerprints of the
# keys that sign them (https://docs.docker.com/engine/install/).
DOCKER_PACKAGES="docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin"
DOCKER_KEY_DEB=9DC858229FC7DD38854AE2D88D81803C0EBFCD88
DOCKER_KEY_RPM=060A61C51B558A7F742B77AAC52FEB6B621E9F35
SUPPORTED_OS="Debian, Ubuntu (and derivatives such as Linux Mint and Pop!_OS), RHEL, Rocky Linux, AlmaLinux, Oracle Linux, CentOS Stream and Fedora"

# detect_os reads /etc/os-release into OS_NAME, OS_PKG (apt|dnf), DOCKER_DISTRO
# (docker.com's repository: debian, ubuntu, rhel, centos, fedora) and
# DOCKER_SUITE (the apt codename or the dnf release). On a distribution
# docker.com has no Docker Engine for it returns 1 with OS_UNSUPPORTED set.
detect_os() {
	OS_NAME=unknown OS_PKG="" DOCKER_DISTRO="" DOCKER_SUITE="" OS_UNSUPPORTED=""
	if [ ! -r /etc/os-release ]; then
		OS_UNSUPPORTED="cannot read /etc/os-release"
		return 1
	fi
	# shellcheck source=/dev/null
	osr="$(. /etc/os-release && printf '%s|%s|%s|%s|%s|%s|%s' "${ID:-}" "${ID_LIKE:-}" "${VERSION_ID:-}" \
		"${VERSION_CODENAME:-}" "${UBUNTU_CODENAME:-}" "${DEBIAN_CODENAME:-}" "${PRETTY_NAME:-}")"
	IFS='|' read -r os_id os_like os_ver os_code os_ubuntu os_debian os_pretty <<EOF
$osr
EOF
	OS_NAME="${os_pretty:-${os_id:-unknown}}"
	os_major="${os_ver%%.*}"
	case "$os_major" in '' | *[!0-9]*) os_major=0 ;; esac
	case "$os_id" in
	ubuntu) OS_PKG=apt DOCKER_DISTRO=ubuntu DOCKER_SUITE="${os_ubuntu:-$os_code}" ;;
	debian) OS_PKG=apt DOCKER_DISTRO=debian DOCKER_SUITE="$os_code" ;;
	rhel | rocky | almalinux | ol)
		# Binary compatible with RHEL: docker.com's RHEL repository.
		OS_PKG=dnf DOCKER_DISTRO=rhel DOCKER_SUITE="$os_major"
		[ "$os_major" -ge 8 ] || OS_UNSUPPORTED="$OS_NAME is too old (8 or newer is needed)"
		;;
	centos)
		OS_PKG=dnf DOCKER_DISTRO=centos DOCKER_SUITE="$os_major"
		[ "$os_major" -ge 9 ] || OS_UNSUPPORTED="$OS_NAME is end of life (CentOS Stream 9 or newer is needed)"
		;;
	fedora) OS_PKG=dnf DOCKER_DISTRO=fedora DOCKER_SUITE="$os_ver" ;;
	amzn) OS_UNSUPPORTED="Amazon Linux: docker.com publishes no Docker Engine for it and its own docker package is older than Docker Engine 26" ;;
	sles | sled | opensuse*) OS_UNSUPPORTED="SUSE: docker.com publishes no x86_64 Docker Engine for it" ;;
	*)
		# Derivatives: the release they are built on.
		case " $os_like " in
		*" ubuntu "*) OS_PKG=apt DOCKER_DISTRO=ubuntu DOCKER_SUITE="$os_ubuntu" ;;
		*" debian "*) OS_PKG=apt DOCKER_DISTRO=debian DOCKER_SUITE="${os_debian:-$os_code}" ;;
		*) OS_UNSUPPORTED="$OS_NAME is not supported" ;;
		esac
		;;
	esac
	if [ -z "$OS_UNSUPPORTED" ] && [ -z "$DOCKER_SUITE" ]; then
		OS_UNSUPPORTED="$OS_NAME: cannot tell which $DOCKER_DISTRO release it is (/etc/os-release)"
	fi
	[ -z "$OS_UNSUPPORTED" ]
}

# pkg_installed <package>: installed according to dpkg or rpm.
pkg_installed() {
	if command -v dpkg-query >/dev/null 2>&1; then
		dpkg-query -W -f='${db:Status-Abbrev}' "$1" 2>/dev/null | grep -q '^ii' && return 0
	fi
	if command -v rpm >/dev/null 2>&1; then
		rpm -q "$1" >/dev/null 2>&1 && return 0
	fi
	return 1
}

# pkg_version <package>: its installed version without epoch (empty: none).
pkg_version() {
	v=""
	if command -v dpkg-query >/dev/null 2>&1 && pkg_installed "$1"; then
		v="$(dpkg-query -W -f='${Version}' "$1" 2>/dev/null)"
	elif command -v rpm >/dev/null 2>&1 && rpm -q "$1" >/dev/null 2>&1; then
		v="$(rpm -q --qf '%{VERSION}\n' "$1" 2>/dev/null | head -n 1)"
	fi
	printf '%s\n' "${v#*:}"
}

# major <version>: its first number (0 when there is none).
major() {
	m="${1%%.*}"
	case "$m" in '' | *[!0-9]*) m=0 ;; esac
	printf '%s\n' "$m"
}

# docker_conflicts prints the installed packages Docker's install docs say to
# remove before installing Docker Engine (per family; podman-docker, Podman's
# docker command, and Fedora's moby-engine everywhere they exist).
docker_conflicts() {
	case "$OS_PKG:$DOCKER_DISTRO" in
	apt:*) list="docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc" ;;
	dnf:rhel) list="docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-engine podman-docker podman runc" ;;
	dnf:centos) list="docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-engine podman-docker" ;;
	dnf:fedora) list="docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-selinux docker-engine-selinux docker-engine moby-engine docker-cli docker-buildx docker-compose podman-docker" ;;
	*) list="" ;;
	esac
	found=""
	for p in $list; do
		pkg_installed "$p" && found="${found:+$found }$p"
	done
	printf '%s\n' "$found"
}

# docker_engine prints the running engine as "<version> <components…>"
# (empty when the daemon does not answer).
docker_engine() {
	docker version --format '{{.Server.Version}} {{range .Server.Components}}{{.Name}} {{end}}' 2>/dev/null || true
}

# docker_assess decides what to do about Docker: DOCKER_ACTION is none,
# install (no Docker yet), compose (docker-ce without its compose plugin) or
# replace (an unsuitable Docker, described in DOCKER_FOUND). Docker that this
# script cannot replace (a snap, a manual install) stops it with a message.
docker_assess() {
	DOCKER_ACTION=none DOCKER_FOUND="" DOCKER_REMOVE=""
	if detect_os; then
		DOCKER_REMOVE="$(docker_conflicts)"
	fi
	ce="$(pkg_version docker-ce)"
	# Docker the distribution packaged, or Podman's docker command.
	distro_docker=""
	for p in $DOCKER_REMOVE; do
		case "$p" in docker | docker-engine | docker.io | moby-engine | podman-docker) distro_docker="${distro_docker:+$distro_docker }$p" ;; esac
	done
	if [ -n "$distro_docker" ]; then
		DOCKER_ACTION=replace DOCKER_FOUND="the distribution's or Podman's Docker ($distro_docker)"
		return 0
	fi
	if ! command -v docker >/dev/null 2>&1; then
		DOCKER_ACTION=install
		return 0
	fi
	if [ -n "$ce" ] && [ -d /run/systemd/system ] && ! docker info >/dev/null 2>&1; then
		systemctl enable --now docker >/dev/null 2>&1 || true
	fi
	engine="$(docker_engine)"
	case "$engine" in
	*[Pp]odman*) die "docker on this host is Podman ($(command -v docker)), not from a package this script can replace: remove it and rerun, or install Docker Engine 26 or newer from docker.com" ;;
	esac
	ver="${engine%% *}"
	[ -n "$ver" ] || ver="$ce"
	if [ -n "$ver" ] && [ "$(major "$ver")" -lt 26 ]; then
		if [ -n "$ce" ]; then
			# An upgrade: nothing else needs to go (Podman, say, stays).
			DOCKER_ACTION=replace DOCKER_FOUND="Docker Engine $ver (docker-ce, older than 26)" DOCKER_REMOVE=""
			return 0
		fi
		case "$(command -v docker)" in /snap/*)
			die "Docker Engine $ver from the snap is older than 26: remove it (snap remove docker) and rerun; this script then installs Docker Engine from docker.com" ;;
		esac
		die "Docker Engine $ver ($(command -v docker)) is older than 26 and not from a package this script can replace: remove it and rerun"
	fi
	if [ -n "$ce" ] && ! docker compose version >/dev/null 2>&1; then
		DOCKER_ACTION=compose DOCKER_REMOVE=""
	fi
	return 0
}

# foreign_containers prints the running containers that are not
# Nightsquire's (the executor's compose project nightsquire-executor, or
# labelled dev.nightsquire.managed=true: steps, gateway, updater), one name
# per line, from Docker and Podman.
foreign_containers() {
	if command -v docker >/dev/null 2>&1; then
		case "$(docker_engine)" in
		'' | *[Pp]odman*) ;;
		*) docker ps --format '{{.Names}}|{{.Label "com.docker.compose.project"}}|{{.Label "dev.nightsquire.managed"}}' 2>/dev/null ||
			true ;;
		esac
	fi
	if command -v podman >/dev/null 2>&1; then
		podman ps --format '{{.Names}}|{{index .Labels "com.docker.compose.project"}}|{{index .Labels "dev.nightsquire.managed"}}' 2>/dev/null ||
			podman ps --format '{{.Names}}||' 2>/dev/null || true
	fi | awk -F'|' '$1 != "" && $2 != "nightsquire-executor" && $3 != "true" { print $1 }' | sort -u
}

# verify_key <file> <fingerprint>: the OpenPGP key in file has that primary
# fingerprint (1: it has not; 2: no gpg to check with).
verify_key() {
	command -v gpg >/dev/null 2>&1 || return 2
	gh="$(mktemp -d)"
	got="$(GNUPGHOME="$gh" gpg --batch --with-colons --show-keys "$1" 2>/dev/null |
		awk -F: '$1 == "pub" { p = 1; next } p && $1 == "fpr" { print $10; exit }')"
	rm -rf "$gh"
	[ "$got" = "$2" ]
}

# fetch_docker_key <url> <file> <fingerprint> downloads docker.com's signing
# key and checks its fingerprint.
fetch_docker_key() {
	curl -fsSL --retry 3 "$1" -o "$2" || die "downloading $1 failed"
	rc=0
	verify_key "$2" "$3" || rc=$?
	case "$rc" in
	0) say "docker.com signing key $3 verified" ;;
	2) say "gpg not found: the key's fingerprint is not checked (the package manager still verifies every package against it)" ;;
	*) rm -f "$2"; die "$1 is not docker.com's signing key (want fingerprint $3)" ;;
	esac
}

# docker_repo_apt adds docker.com's apt repository (unless a sources file
# already has it).
docker_repo_apt() {
	export DEBIAN_FRONTEND=noninteractive
	apt-get update -q
	apt-get install -y -q ca-certificates curl
	url="https://download.docker.com/linux/$DOCKER_DISTRO"
	curl -fsSIL --retry 3 "$url/dists/$DOCKER_SUITE/Release" >/dev/null 2>&1 ||
		die "docker.com publishes no Docker Engine for $OS_NAME ($url/dists/$DOCKER_SUITE), or it cannot be reached; supported: $SUPPORTED_OS"
	other="$(grep -rls 'download\.docker\.com/linux' /etc/apt/sources.list /etc/apt/sources.list.d 2>/dev/null |
		grep -vx /etc/apt/sources.list.d/docker.list | head -n 1 || true)"
	if [ -n "$other" ]; then
		say "using the docker.com repository already in $other"
	else
		install -m 0755 -d /etc/apt/keyrings
		fetch_docker_key "$url/gpg" /etc/apt/keyrings/docker.asc.tmp "$DOCKER_KEY_DEB"
		chmod a+r /etc/apt/keyrings/docker.asc.tmp
		mv -f /etc/apt/keyrings/docker.asc.tmp /etc/apt/keyrings/docker.asc
		printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] %s %s stable\n' \
			"$(dpkg --print-architecture)" "$url" "$DOCKER_SUITE" >/etc/apt/sources.list.d/docker.list
	fi
	apt-get update -q
}

# docker_repo_dnf adds docker.com's dnf repository (unless a repo file
# already has it): the key imported after its fingerprint is checked, the
# release written into the repo file (a releasever pinned to a minor
# release, e.g. 9.4, has no docker.com directory).
docker_repo_dnf() {
	url="https://download.docker.com/linux/$DOCKER_DISTRO"
	command -v curl >/dev/null 2>&1 || dnf -y -q install curl
	curl -fsSIL --retry 3 "$url/$DOCKER_SUITE/x86_64/stable/repodata/repomd.xml" >/dev/null 2>&1 ||
		die "docker.com publishes no Docker Engine for $OS_NAME ($url/$DOCKER_SUITE), or it cannot be reached; supported: $SUPPORTED_OS"
	other="$(grep -ls '^\[docker-ce-stable\]' /etc/yum.repos.d/*.repo 2>/dev/null | grep -vx /etc/yum.repos.d/docker-ce.repo | head -n 1 || true)"
	if [ -n "$other" ]; then
		say "using the docker.com repository already in $other"
		return 0
	fi
	command -v gpg >/dev/null 2>&1 || dnf -y -q install gnupg2 || true
	key="$(mktemp)"
	fetch_docker_key "$url/gpg" "$key" "$DOCKER_KEY_RPM"
	rpm --import "$key" || { rm -f "$key"; die "rpm --import of docker.com's key failed"; }
	rm -f "$key"
	{
		printf '%s\n' "# Docker Engine from docker.com: Docker's docker-ce.repo with release $DOCKER_SUITE (written by the Nightsquire installer)"
		printf '%s\n' "[docker-ce-stable]"
		printf '%s\n' "name=Docker CE Stable - \$basearch"
		printf '%s\n' "baseurl=$url/$DOCKER_SUITE/\$basearch/stable"
		printf '%s\n' "enabled=1" "gpgcheck=1" "gpgkey=$url/gpg"
	} >/etc/yum.repos.d/docker-ce.repo.tmp
	chmod 0644 /etc/yum.repos.d/docker-ce.repo.tmp
	mv -f /etc/yum.repos.d/docker-ce.repo.tmp /etc/yum.repos.d/docker-ce.repo
}

# install_docker installs, completes or replaces Docker Engine from
# docker.com (DOCKER_ACTION) and starts it. Replacing removes the packages
# in DOCKER_REMOVE (never /var/lib/docker: images, containers and volumes
# stay) and restarts Docker; it needs --replace-docker while containers that
# are not Nightsquire's run.
install_docker() {
	detect_os || die "cannot install Docker Engine on this host: $OS_UNSUPPORTED. Supported: $SUPPORTED_OS (amd64, systemd)"
	if [ "${NIGHTSQUIRE_INSTALL_TEST:-}" != docker-packages ]; then
		[ -d /run/systemd/system ] || die "installing Docker Engine needs systemd as the init system; supported: $SUPPORTED_OS"
	fi
	pkgs="$DOCKER_PACKAGES"
	[ "$DOCKER_ACTION" = compose ] && pkgs=docker-compose-plugin
	if [ -n "$DOCKER_REMOVE" ] || [ "$DOCKER_ACTION" = replace ]; then
		running="$(foreign_containers)"
		if [ -n "$running" ] && [ "$REPLACE_DOCKER" != 1 ]; then
			n="$(printf '%s\n' "$running" | wc -l | tr -d ' ')"
			names="$(printf '%s\n' "$running" | head -n 10 | tr '\n' ' ')"
			say "this host runs ${DOCKER_FOUND:-packages that conflict with Docker Engine ($DOCKER_REMOVE)}"
			say "the executor needs Docker Engine 26 or newer from docker.com; installing it removes: ${DOCKER_REMOVE:-nothing} and restarts Docker"
			say "$n running container(s) are not Nightsquire's and stop: $names"
			say "containers with a restart policy start again under the new Docker; Podman containers do not move to Docker; /var/lib/docker is kept"
			die "rerun with --replace-docker to replace Docker anyway: curl -fsSL $CONTROLLER_URL/install.sh | sudo sh -s -- --replace-docker"
		fi
		if [ -n "$running" ]; then
			say "--replace-docker: stopping $(printf '%s\n' "$running" | wc -l | tr -d ' ') running container(s) that are not Nightsquire's"
		fi
	fi
	case "$DOCKER_ACTION" in
	replace) say "replacing ${DOCKER_FOUND} with Docker Engine from download.docker.com ($OS_NAME)" ;;
	compose) say "installing the docker compose plugin from download.docker.com ($OS_NAME)" ;;
	*) say "installing Docker Engine from download.docker.com ($OS_NAME)" ;;
	esac
	case "$OS_PKG" in
	apt)
		docker_repo_apt
		if [ -n "$DOCKER_REMOVE" ]; then
			say "removing $DOCKER_REMOVE (apt-get remove: configuration and /var/lib/docker are kept)"
			# shellcheck disable=SC2086 # word splitting of the package list is intended
			apt-get remove -y -q $DOCKER_REMOVE
		fi
		# shellcheck disable=SC2086
		apt-get install -y -q $pkgs
		;;
	dnf)
		docker_repo_dnf
		if [ -n "$DOCKER_REMOVE" ]; then
			say "removing $DOCKER_REMOVE (dnf remove: /var/lib/docker is kept)"
			# Only these packages: no autoremove of the dependencies they pulled in
			# (container-selinux, selinux-policy-targeted: protected on Fedora, and
			# docker-ce needs container-selinux again). The option exists in dnf4 and dnf5.
			# shellcheck disable=SC2086
			dnf -y --setopt=clean_requirements_on_remove=False remove $DOCKER_REMOVE
		fi
		# --allowerasing: whatever else still conflicts (e.g. Fedora's runc) gives way.
		# shellcheck disable=SC2086
		dnf -y install --allowerasing $pkgs
		;;
	esac
	if [ "$(major "$(pkg_version docker-ce)")" -lt 26 ]; then
		say "upgrading docker-ce $(pkg_version docker-ce)"
		# shellcheck disable=SC2086
		case "$OS_PKG" in
		apt) apt-get install -y -q --only-upgrade $DOCKER_PACKAGES ;;
		dnf) dnf -y upgrade $DOCKER_PACKAGES ;;
		esac
	fi
	# Test hook (CI, packages only): nothing is started.
	[ "${NIGHTSQUIRE_INSTALL_TEST:-}" != docker-packages ] || return 0
	systemctl daemon-reload
	systemctl enable docker.service >/dev/null 2>&1 || true
	# A replaced or upgraded daemon may still be the old one: restart unless it is new enough.
	running_ver="$(docker_engine)"
	running_ver="${running_ver%% *}"
	if [ -z "$running_ver" ] || [ "$(major "$running_ver")" -lt 26 ]; then
		systemctl restart docker.service || die "starting Docker failed (journalctl -u docker)"
	fi
	i=0
	until docker info >/dev/null 2>&1; do
		i=$((i + 1))
		[ "$i" -lt 30 ] || die "Docker did not start (journalctl -u docker)"
		sleep 1
	done
	say "Docker Engine $(docker version --format '{{.Server.Version}}' 2>/dev/null) from docker.com is running"
}

# --- Docker for agents: sysbox -------------------------------------------------
# Never fails the install: each check that does not pass sets SYSBOX_SKIP (the
# reason) and returns 1, and the executor then runs without Docker for agents.
SYSBOX_SKIP=""

# sysbox_skip records why sysbox is not set up (the first reason wins).
sysbox_skip() {
	[ -n "$SYSBOX_SKIP" ] || SYSBOX_SKIP="$*"
}

# sysbox_preflight checks what sysbox needs of the host itself (before
# anything is installed or changed).
sysbox_preflight() {
	[ -r /etc/os-release ] || { sysbox_skip "cannot read /etc/os-release"; return 1; }
	# shellcheck source=/dev/null
	os_id="$(. /etc/os-release && printf '%s' "${ID:-}")"
	# shellcheck source=/dev/null
	os_ver="$(. /etc/os-release && printf '%s' "${VERSION_ID:-}")"
	os_major="${os_ver%%.*}"
	case "$os_major" in '' | *[!0-9]*) os_major=0 ;; esac
	case "$os_id" in
	debian) [ "$os_major" -ge 11 ] || { sysbox_skip "sysbox needs Debian 11 or newer (found Debian ${os_ver:-without a version})"; return 1; } ;;
	ubuntu) [ "$os_major" -ge 22 ] || { sysbox_skip "sysbox needs Ubuntu 22.04 or newer (found Ubuntu ${os_ver:-unknown})"; return 1; } ;;
	*) sysbox_skip "sysbox publishes packages for Debian 11+ and Ubuntu 22.04+ only (found ${os_id:-unknown})"; return 1 ;;
	esac
	[ -d /run/systemd/system ] || { sysbox_skip "sysbox needs systemd (it runs as systemd services)"; return 1; }
	SYSBOX_ARCH="$(dpkg --print-architecture 2>/dev/null || true)"
	case "$SYSBOX_ARCH" in
	amd64) SYSBOX_SHA256="$SYSBOX_SHA256_AMD64" ;;
	arm64) SYSBOX_SHA256="$SYSBOX_SHA256_ARM64" ;;
	*) sysbox_skip "sysbox supports amd64 and arm64 only (found ${SYSBOX_ARCH:-unknown})"; return 1 ;;
	esac
	kernel="$(uname -r)"
	kmajor="${kernel%%.*}"
	kminor="${kernel#*.}"
	kminor="${kminor%%[!0-9]*}"
	case "$kmajor$kminor" in '' | *[!0-9]*) sysbox_skip "cannot read the kernel version ($kernel)"; return 1 ;; esac
	if [ "$kmajor" -lt 5 ] || { [ "$kmajor" -eq 5 ] && [ "$kminor" -lt 12 ]; }; then
		sysbox_skip "sysbox needs Linux 5.12 or newer (idmapped mounts); this host runs $kernel"
		return 1
	fi
	cgroup="$(stat -fc %T /sys/fs/cgroup 2>/dev/null || true)"
	case "$cgroup" in
	cgroup2fs | tmpfs) ;; # cgroup v2, or v1/hybrid
	*) sysbox_skip "unsupported cgroup setup (/sys/fs/cgroup is ${cgroup:-missing}); sysbox needs cgroup v2 or v1"; return 1 ;;
	esac
	return 0
}

# sysbox_engine_check: Docker Engine from docker.com, not the snap, and a
# daemon.json sysbox's installer can work with.
sysbox_engine_check() {
	case "$(command -v docker)" in /snap/*) sysbox_skip "sysbox does not work with Docker from the snap; install Docker Engine from docker.com"; return 1 ;; esac
	if command -v snap >/dev/null 2>&1 && snap list docker >/dev/null 2>&1; then
		sysbox_skip "sysbox does not work with Docker from the snap (snap list docker); install Docker Engine from docker.com"
		return 1
	fi
	dpkg-query -W -f='${db:Status-Abbrev}' docker-ce 2>/dev/null | grep -q '^ii' || {
		sysbox_skip "sysbox needs Docker Engine from docker.com (package docker-ce); this host's Docker comes from elsewhere"
		return 1
	}
	if [ -s "$DOCKER_DAEMON_JSON" ] && command -v jq >/dev/null 2>&1; then
		jq -e 'type == "object"' "$DOCKER_DAEMON_JSON" >/dev/null 2>&1 || { sysbox_skip "$DOCKER_DAEMON_JSON is not a JSON object; fix it and rerun"; return 1; }
		if jq -e '(.bridge // "") != ""' "$DOCKER_DAEMON_JSON" >/dev/null 2>&1; then
			sysbox_skip "$DOCKER_DAEMON_JSON sets \"bridge\"; install sysbox manually (it needs \"bip\" and \"default-address-pools\")"
			return 1
		fi
	fi
	return 0
}

# sysbox_installed prints the installed sysbox-ce version (empty: none).
sysbox_installed() {
	dpkg-query -W -f='${db:Status-Abbrev} ${Version}\n' sysbox-ce 2>/dev/null | awk '$1 == "ii" { print $2 }'
}

# sysbox_docker_network_config writes Docker's current bridge address and
# address pools into daemon.json when it has none. sysbox's package installer
# otherwise sets its own (172.20.0.1/16, 172.25.0.0/16) and restarts Docker,
# and refuses to install at all while any container exists. Equal values need
# no restart.
sysbox_docker_network_config() {
	cur='{}'
	if [ -s "$DOCKER_DAEMON_JSON" ]; then
		jq -e 'type == "object"' "$DOCKER_DAEMON_JSON" >/dev/null 2>&1 || { sysbox_fail "$DOCKER_DAEMON_JSON is not a JSON object"; return 1; }
		cur="$(cat "$DOCKER_DAEMON_JSON")"
	fi
	need_bip=0
	need_pools=0
	printf '%s' "$cur" | jq -e '(.bip // "") != ""' >/dev/null || need_bip=1
	printf '%s' "$cur" | jq -e '(."default-address-pools" // []) | length > 0' >/dev/null || need_pools=1
	[ "$need_bip" = 1 ] || [ "$need_pools" = 1 ] || return 0
	bip=""
	if [ "$need_bip" = 1 ]; then
		# The default bridge's IPv4 gateway and prefix, e.g. 172.17.0.1/16.
		bip="$(docker network inspect bridge --format '{{range .IPAM.Config}}{{.Gateway}} {{.Subnet}}{{"\n"}}{{end}}' 2>/dev/null |
			awk '$1 ~ /^[0-9]+[.][0-9]+[.][0-9]+[.][0-9]+$/ && $2 ~ /^[0-9.]+[/][0-9]+$/ { split($2, s, "/"); print $1 "/" s[2]; exit }')"
		[ -n "$bip" ] || { sysbox_fail "cannot read the address of Docker's default bridge (docker network inspect bridge)"; return 1; }
	fi
	# Docker's built-in local pools (moby ipamutils), unchanged.
	pools='[{"base":"172.17.0.0/16","size":16},{"base":"172.18.0.0/16","size":16},{"base":"172.19.0.0/16","size":16},{"base":"172.20.0.0/14","size":16},{"base":"172.24.0.0/14","size":16},{"base":"172.28.0.0/14","size":16},{"base":"192.168.0.0/16","size":20}]'
	tmp="$DOCKER_DAEMON_JSON.nightsquire.tmp"
	mkdir -p "$(dirname "$DOCKER_DAEMON_JSON")"
	printf '%s' "$cur" | jq --arg bip "$bip" --argjson pools "$pools" --argjson nb "$need_bip" --argjson np "$need_pools" \
		'(if $nb == 1 then .bip = $bip else . end) | (if $np == 1 then ."default-address-pools" = $pools else . end)' >"$tmp" ||
		{ rm -f "$tmp"; sysbox_fail "writing $DOCKER_DAEMON_JSON failed"; return 1; }
	if [ -f "$DOCKER_DAEMON_JSON" ]; then
		backup="$DOCKER_DAEMON_JSON.nightsquire-$(date +%Y%m%d%H%M%S).bak"
		cp -p "$DOCKER_DAEMON_JSON" "$backup"
		cat "$tmp" >"$DOCKER_DAEMON_JSON"
		rm -f "$tmp"
		say "kept Docker's network settings in $DOCKER_DAEMON_JSON (backup: $backup)"
	else
		chmod 0644 "$tmp"
		mv "$tmp" "$DOCKER_DAEMON_JSON"
		say "kept Docker's network settings in $DOCKER_DAEMON_JSON (new file)"
	fi
	[ "$need_bip" = 1 ] && say "  bip: $bip (the current docker0 address)"
	[ "$need_pools" = 1 ] && say "  default-address-pools: Docker's built-in defaults"
	return 0
}

# sysbox_fail restarts a stopped executor and records why sysbox is not set
# up; the caller returns 1.
sysbox_fail() {
	if [ "${EXECUTOR_STOPPED:-0}" = 1 ]; then
		compose start >/dev/null 2>&1 || true
		EXECUTOR_STOPPED=0
	fi
	sysbox_skip "$*"
}

# sysbox_runtime: Docker lists the sysbox-runc runtime.
sysbox_runtime() {
	docker info --format '{{json .Runtimes}}' 2>/dev/null | grep -q '"sysbox-runc"'
}

# install_sysbox installs sysbox-ce $SYSBOX_VERSION (verified .deb) unless it
# is installed, and checks that Docker lists the sysbox-runc runtime. Returns 1
# (SYSBOX_SKIP says why) instead of failing the install; a package that did not
# install is removed again, so it never blocks later apt runs.
install_sysbox() {
	EXECUTOR_STOPPED=0
	have="$(sysbox_installed)"
	if [ "$have" = "$SYSBOX_VERSION.linux" ] || [ "$have" = "$SYSBOX_VERSION" ]; then
		say "sysbox-ce $SYSBOX_VERSION is already installed; left alone"
	else
		export DEBIAN_FRONTEND=noninteractive
		# Fresh package lists: the .deb's dependencies (jq, fuse, rsync, …) come from the OS mirrors.
		apt-get update -q >/dev/null || { sysbox_fail "apt-get update failed"; return 1; }
		missing=""
		command -v curl >/dev/null 2>&1 || missing="curl"
		command -v jq >/dev/null 2>&1 || missing="${missing:+$missing }jq"
		if [ -n "$missing" ]; then
			say "installing $missing"
			# shellcheck disable=SC2086 # word splitting of the package list is intended
			apt-get install -y -q $missing >/dev/null || { sysbox_fail "installing $missing failed"; return 1; }
		fi
		dir="$(mktemp -d)" || { sysbox_fail "mktemp failed"; return 1; }
		chmod 0755 "$dir"
		deb="$dir/sysbox-ce_${SYSBOX_VERSION}.linux_${SYSBOX_ARCH}.deb"
		url="https://github.com/nestybox/sysbox/releases/download/v${SYSBOX_VERSION}/sysbox-ce_${SYSBOX_VERSION}.linux_${SYSBOX_ARCH}.deb"
		say "downloading $url"
		curl -fsSL --retry 3 -o "$deb" "$url" || { rm -rf "$dir"; sysbox_fail "downloading $url failed"; return 1; }
		got="$(sha256sum "$deb" | cut -d' ' -f1)"
		if [ "$got" != "$SYSBOX_SHA256" ]; then
			rm -rf "$dir"
			sysbox_fail "sha256 mismatch for $url: got $got, want $SYSBOX_SHA256"
			return 1
		fi
		chmod 0644 "$deb"
		say "sysbox-ce $SYSBOX_VERSION ($SYSBOX_ARCH) sha256 verified"
		if [ -f "$INSTALL_DIR/docker-compose.yml" ] && [ -n "$(compose ps -q executor 2>/dev/null)" ]; then
			say "stopping the executor (running steps finish first; up to 11 min)"
			compose stop >/dev/null || { rm -rf "$dir"; sysbox_fail "stopping the executor failed"; return 1; }
			EXECUTOR_STOPPED=1
		fi
		sysbox_docker_network_config || { rm -rf "$dir"; return 1; }
		[ -n "$have" ] && say "replacing sysbox-ce $have"
		say "installing sysbox-ce $SYSBOX_VERSION (apt-get install; it registers sysbox-runc in $DOCKER_DAEMON_JSON and reloads dockerd)"
		if ! apt-get install -y -q "$deb"; then
			rm -rf "$dir"
			if [ -z "$(sysbox_installed)" ] && dpkg-query -W sysbox-ce >/dev/null 2>&1; then
				say "removing the half-installed sysbox-ce package"
				apt-get remove -y -q sysbox-ce >/dev/null 2>&1 || dpkg --remove --force-remove-reinstreq sysbox-ce >/dev/null 2>&1 || true
			fi
			sysbox_fail "apt-get install $deb failed (see the output above)"
			return 1
		fi
		rm -rf "$dir"
	fi
	i=0
	until sysbox_runtime; do
		i=$((i + 1))
		if [ "$i" = 10 ]; then
			systemctl reload docker >/dev/null 2>&1 || true
		fi
		if [ "$i" -ge 30 ]; then
			sysbox_fail "Docker does not list the sysbox-runc runtime (docker info; $DOCKER_DAEMON_JSON)"
			return 1
		fi
		sleep 1
	done
	systemctl is-active --quiet sysbox.service || { sysbox_fail "sysbox.service is not running (journalctl -u sysbox-mgr -u sysbox-fs)"; return 1; }
	say "sysbox-ce $(sysbox_installed): Docker runtimes: $(docker info --format '{{range $name, $_ := .Runtimes}}{{$name}} {{end}}' 2>/dev/null)"
	if [ "$EXECUTOR_STOPPED" = 1 ]; then
		say "the executor starts again below"
	fi
	return 0
}

sysbox_preflight || say "Docker for agents: not available on this host: $SYSBOX_SKIP"
docker_assess
if [ "$DOCKER_ACTION" != none ]; then
	install_docker
fi
if [ "${NIGHTSQUIRE_INSTALL_TEST:-}" = docker-packages ]; then
	say "test: Docker packages done (docker-ce $(pkg_version docker-ce), action $DOCKER_ACTION); nothing started"
	exit 0
fi
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is required"
docker info >/dev/null 2>&1 || die "the Docker daemon is not running (systemctl start docker)"
# The executor refuses Docker Engine before 26.0 and Podman (apps/executor/internal/engine/version.go): after
# the install or replacement above, check the running engine now instead of after a registration that never
# comes up.
engine="$(docker_engine)"
case "$engine" in *[Pp]odman*) die "Podman is not supported: Docker Engine 26 or newer from docker.com is required (rerun this script with --replace-docker to replace it)" ;; esac
engine="${engine%% *}"
case "$engine" in
[0-9]*.[0-9]*) [ "$(major "$engine")" -ge 26 ] ||
	die "Docker Engine 26 or newer is required (found $engine): rerun this script with --replace-docker to install Docker Engine from docker.com, or upgrade it" ;;
*) die "could not read the Docker Engine version (docker version); Docker Engine 26 or newer is required" ;;
esac
# SELinux: docker.com's Docker runs containers without SELinux labels (dockerd's selinux-enabled is off by
# default), so the executor's bind mounts (the Docker socket, /var/lib/nightsquire-executor, this directory)
# need no :z relabelling. With selinux-enabled on, the executor could neither use the socket nor share step
# directories with the containers it starts.
case "$(docker info --format '{{range .SecurityOptions}}{{.}} {{end}}' 2>/dev/null)" in
*name=selinux*) die "Docker runs with SELinux labelling (\"selinux-enabled\": true in $DOCKER_DAEMON_JSON or --selinux-enabled): the executor shares host directories with its step containers and needs it off (docker.com's default); remove it and restart Docker" ;;
esac
if [ -z "$SYSBOX_SKIP" ]; then
	sysbox_engine_check || say "Docker for agents: not available on this host: $SYSBOX_SKIP"
fi

# The token first: a run that cannot register changes nothing.
if [ -f "$STATE_DIR/state.json" ]; then
	say "already registered ($STATE_DIR/state.json exists); updating and restarting"
	REGISTRATION_TOKEN=""
elif [ -z "$REGISTRATION_TOKEN" ]; then
	if [ "$TOKEN_STDIN" = 1 ]; then
		read_token /dev/stdin
	elif (: </dev/tty) 2>/dev/null; then
		read_token /dev/tty
	fi
	[ -n "$REGISTRATION_TOKEN" ] || die "a registration token is required for the first install: run interactively, pass --token-stdin, or set REGISTRATION_TOKEN (see the top of this script)"
fi
case "$REGISTRATION_TOKEN" in *[!A-Za-z0-9._~+/=:-]*) die "the registration token contains unexpected characters" ;; esac

umask 077
mkdir -p "$INSTALL_DIR" "$STATE_DIR"
chmod 0700 "$STATE_DIR"

write_env() {
	# $1: token to include (may be empty)
	tmp="$INSTALL_DIR/.env.tmp"
	{
		printf 'NIGHTSQUIRE_EXECUTOR_IMAGE=%s\n' "$NIGHTSQUIRE_EXECUTOR_IMAGE"
		printf 'CONTROLLER_URL=%s\n' "$CONTROLLER_URL"
		printf 'EXECUTOR_NAME=%s\n' "$EXECUTOR_NAME"
		printf 'EXECUTOR_CAPACITY=%s\n' "$EXECUTOR_CAPACITY"
		printf 'EXECUTOR_LABELS=%s\n' "$EXECUTOR_LABELS"
		[ -n "${NIGHTSQUIRE_GATEWAY_ALLOW_CIDRS:-}" ] && printf 'NIGHTSQUIRE_GATEWAY_ALLOW_CIDRS=%s\n' "$NIGHTSQUIRE_GATEWAY_ALLOW_CIDRS"
		[ -n "${NIGHTSQUIRE_GITLAB_CA_FILE:-}" ] && printf 'NIGHTSQUIRE_GITLAB_CA_FILE=%s\n' "$NIGHTSQUIRE_GITLAB_CA_FILE"
		[ -n "$1" ] && printf 'REGISTRATION_TOKEN=%s\n' "$1"
		true
	} >"$tmp"
	chmod 0600 "$tmp"
	mv "$tmp" "$INSTALL_DIR/.env"
}

# host_agent_supported: systemd as init and apt (Debian/Ubuntu) or dnf (the
# RHEL family, Fedora); else HOST_AGENT_SKIP says why. HOST_AGENT_PKG is apt
# or dnf (the agent decides the same way: apt-get first).
host_agent_supported() {
	if [ ! -d /run/systemd/system ]; then
		HOST_AGENT_SKIP="systemd is not running"
		return 1
	fi
	if command -v apt-get >/dev/null 2>&1 && command -v dpkg-query >/dev/null 2>&1; then
		HOST_AGENT_PKG=apt
	elif command -v dnf >/dev/null 2>&1 && command -v rpm >/dev/null 2>&1; then
		HOST_AGENT_PKG=dnf
	else
		HOST_AGENT_SKIP="neither apt nor dnf found"
		return 1
	fi
	return 0
}

# host_agent_packages installs what the agent uses when missing: nftables,
# and unattended-upgrades (apt) or needs-restarting (dnf: dnf-plugins-core,
# dnf5-plugins on dnf5; the agent writes its own security-update timer, see
# apps/executor/internal/hostagent/dnf.go). nftables.service stays disabled:
# its config flushes the whole ruleset, which would drop Docker's rules.
host_agent_packages() {
	missing=""
	command -v nft >/dev/null 2>&1 || missing="nftables"
	if [ "$HOST_AGENT_PKG" = apt ]; then
		pkg_installed unattended-upgrades || missing="${missing:+$missing }unattended-upgrades"
	elif [ -x /usr/bin/dnf5 ]; then
		pkg_installed dnf5-plugins || missing="${missing:+$missing }dnf5-plugins"
	else
		pkg_installed dnf-plugins-core || missing="${missing:+$missing }dnf-plugins-core"
	fi
	if [ -n "$missing" ]; then
		say "installing $missing"
		ok=1
		if [ "$HOST_AGENT_PKG" = apt ]; then
			# shellcheck disable=SC2086 # word splitting of the package list is intended
			{ DEBIAN_FRONTEND=noninteractive apt-get update -q >/dev/null &&
				DEBIAN_FRONTEND=noninteractive apt-get install -y -q $missing >/dev/null; } || ok=0
		else
			# shellcheck disable=SC2086
			dnf -y -q install $missing >/dev/null || ok=0
		fi
		[ "$ok" = 1 ] || say "warning: installing $missing failed; the agent reports what is missing"
	fi
	if systemctl is-enabled nftables.service >/dev/null 2>&1; then
		say "warning: nftables.service is enabled; restarting it flushes Docker's rules (systemctl disable nftables)"
	fi
}

# firewalld: left in charge. The agent does not put its firewall next to it
# (two inbound policies, each able to drop what the other allows, and a
# rollback that only covers ours) but reports the firewall as "conflict"
# until the owner turns firewalld off; Docker integrates with firewalld
# itself. FIREWALLD_ACTIVE is 1 while it runs.
firewalld_check() {
	FIREWALLD_ACTIVE=0
	systemctl is-active --quiet firewalld.service 2>/dev/null || return 0
	FIREWALLD_ACTIVE=1
	say "firewalld is running: it stays in charge of this host's firewall, and the Nightsquire firewall (and outbound lockdown) stays off while it runs (the dashboard shows the firewall as 'conflict')"
	say "  to let Nightsquire manage the firewall instead: systemctl disable --now firewalld, then turn it on in the dashboard (Executors → this executor → Firewall)"
}

# clean_address prints $1 when it is a usable IPv4/IPv6 source address
# (IPv4-mapped IPv6 unmapped; loopback, zones and anything else dropped: a
# loopback source would allow SSH from nowhere outside).
clean_address() {
	a="$1"
	case "$a" in ::ffff:*.*) a="${a#::ffff:}" ;; esac
	case "$a" in
	'' | *[!0-9A-Fa-f:.]* | 127.* | ::1 | 0.0.0.0 | ::) return 0 ;;
	*.* | *:*) printf '%s\n' "$a" ;;
	esac
}

# detect_ssh_source prints the address this script's SSH session comes from,
# or nothing. sudo resets the environment (`curl … | sudo sh`) and with
# use_pty runs us on a new pty, so after our own environment it walks up the
# process tree: the login shell that ran sudo still has SSH_CONNECTION in
# /proc/<pid>/environ; failing that, the terminal of an ancestor is looked up
# in utmp (who).
detect_ssh_source() {
	for v in "${SSH_CONNECTION:-}" "${SSH_CLIENT:-}"; do
		a="$(clean_address "${v%% *}")"
		[ -n "$a" ] && { printf '%s\n' "$a"; return 0; }
	done
	pid=$$
	tty=""
	i=0
	while [ -n "$pid" ] && [ "$pid" -gt 1 ] && [ "$i" -lt 40 ]; do
		if [ -r "/proc/$pid/environ" ]; then
			v="$(tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n 1)"
			a="$(clean_address "${v%% *}")"
			[ -n "$a" ] && { printf '%s\n' "$a"; return 0; }
		fi
		if [ -z "$tty" ]; then
			t="$(readlink "/proc/$pid/fd/0" 2>/dev/null || true)"
			case "$t" in /dev/pts/* | /dev/tty[0-9]*) tty="${t#/dev/}" ;; esac
		fi
		pid="$(sed -n 's/^PPid:[[:space:]]*//p' "/proc/$pid/status" 2>/dev/null)"
		i=$((i + 1))
	done
	if [ -n "$tty" ] && command -v who >/dev/null 2>&1; then
		v="$(who 2>/dev/null | awk -v t="$tty" '$2 == t { print $NF; exit }' | tr -d '()')"
		clean_address "$v"
	fi
	return 0
}

# write_agent_config <file> <firewallDefault> <installSshSource or ""> [swap: on|off]
write_agent_config() {
	extra=""
	[ -z "$3" ] || extra="$extra, \"installSshSource\": \"$3\""
	[ "${4:-on}" != off ] || extra="$extra, \"swap\": \"off\""
	printf '{"installDir": "%s", "firewallDefault": "%s"%s}\n' "$INSTALL_DIR" "$2" "$extra" >"$1"
	chmod 0644 "$1"
}

# install_host_agent copies the agent out of the executor image, verifies and
# installs it (keeping .prev), writes its config and unit and (re)starts it.
# On failure HOST_AGENT_ERR says why and nothing is swapped in.
install_host_agent() {
	host_agent_packages
	mkdir -p "$HOST_AGENT_DIR" /etc/nightsquire-host-agent
	chmod 0755 "$HOST_AGENT_DIR" /etc/nightsquire-host-agent
	new="$HOST_AGENT_DIR/nightsquire-host-agent.install"
	sum="$HOST_AGENT_DIR/nightsquire-host-agent.install.sha256"
	rm -f "$new" "$sum"
	if ! cid="$(docker create "$NIGHTSQUIRE_EXECUTOR_IMAGE")"; then
		HOST_AGENT_ERR="docker create $NIGHTSQUIRE_EXECUTOR_IMAGE failed"
		return 1
	fi
	ok=1
	docker cp "$cid:/nightsquire-host-agent" "$new" >/dev/null 2>&1 || ok=0
	docker cp "$cid:/nightsquire-host-agent.sha256" "$sum" >/dev/null 2>&1 || ok=0
	docker rm "$cid" >/dev/null 2>&1 || true
	if [ "$ok" = 0 ]; then
		rm -f "$new" "$sum"
		HOST_AGENT_ERR="the executor image $NIGHTSQUIRE_EXECUTOR_IMAGE has no /nightsquire-host-agent"
		return 1
	fi
	want="$(tr -d ' \r\n' <"$sum")"
	rm -f "$sum"
	got="$(sha256sum "$new" | cut -d' ' -f1)"
	case "$want" in
	????????????????????????????????????????????????????????????????) ;;
	*) want="" ;;
	esac
	case "$want" in *[!0-9a-f]*) want="" ;; esac
	if [ -z "$want" ] || [ "$got" != "$want" ]; then
		rm -f "$new"
		HOST_AGENT_ERR="digest mismatch: /nightsquire-host-agent is $got, the image says ${want:-nothing valid}"
		return 1
	fi
	chown 0:0 "$new"
	chmod 0755 "$new"

	firewall="$NIGHTSQUIRE_FIREWALL"
	if [ -z "$firewall" ] && [ -f "$HOST_AGENT_CONFIG" ]; then
		# Keep the choice of an earlier install.
		case "$(cat "$HOST_AGENT_CONFIG")" in *'"firewallDefault": "off"'* | *'"firewallDefault":"off"'*) firewall=off ;; esac
	fi
	[ -n "$firewall" ] || firewall=on
	swap="$NIGHTSQUIRE_SWAP"
	if [ -z "$swap" ] && [ -f "$HOST_AGENT_CONFIG" ]; then
		case "$(cat "$HOST_AGENT_CONFIG")" in *'"swap": "off"'* | *'"swap":"off"'*) swap=off ;; esac
	fi
	[ -n "$swap" ] || swap=on

	# The firewall's default SSH source (see the header).
	case "$NIGHTSQUIRE_SSH_SOURCE" in
	none) ssh_source="" ;;
	'') ssh_source="$(detect_ssh_source)" ;;
	*) ssh_source="$(clean_address "$NIGHTSQUIRE_SSH_SOURCE")" ;;
	esac
	if [ -z "$ssh_source" ] && [ -z "$NIGHTSQUIRE_SSH_SOURCE" ] && [ -f "$HOST_AGENT_CONFIG" ]; then
		ssh_source="$(sed -n 's/.*"installSshSource": *"\([0-9A-Fa-f:.]*\)".*/\1/p' "$HOST_AGENT_CONFIG" | head -n 1)"
	fi
	tmp="$HOST_AGENT_CONFIG.tmp"
	write_agent_config "$tmp" "$firewall" "$ssh_source" "$swap"
	ok=1
	out="$("$new" --self-test --config "$tmp" 2>&1)" || ok=0
	if [ "$ok" = 0 ] && [ -n "$ssh_source" ]; then
		# An address the agent does not take must not cost the agent.
		say "warning: SSH source $ssh_source not usable; continuing without it"
		ssh_source=""
		write_agent_config "$tmp" "$firewall" "" "$swap"
		ok=1
		out="$("$new" --self-test --config "$tmp" 2>&1)" || ok=0
	fi
	if [ "$ok" = 0 ]; then
		rm -f "$new" "$tmp"
		HOST_AGENT_ERR="self-test failed: $out"
		return 1
	fi
	mv -f "$tmp" "$HOST_AGENT_CONFIG"

	bin="$HOST_AGENT_DIR/nightsquire-host-agent"
	if [ -f "$bin" ] && [ "$(sha256sum "$bin" | cut -d' ' -f1)" = "$got" ]; then
		rm -f "$new"
		changed=0
	else
		[ -f "$bin" ] && cp -p "$bin" "$bin.prev"
		mv -f "$new" "$bin"
		changed=1
	fi
	# SELinux: docker cp's file may carry another label than its directory's
	# default; systemd runs the agent unconfined either way once it is restored.
	if command -v restorecon >/dev/null 2>&1; then
		restorecon -RF "$HOST_AGENT_DIR" /etc/nightsquire-host-agent >/dev/null 2>&1 || true
	fi

	mkdir -p "$INSTALL_DIR/host-agent"
	chown 0:0 "$INSTALL_DIR/host-agent"
	chmod 0700 "$INSTALL_DIR/host-agent"

	unit_tmp="$HOST_AGENT_UNIT.tmp"
	cat >"$unit_tmp" <<'UNIT'
[Unit]
Description=Nightsquire host agent
After=network-online.target docker.service
Wants=network-online.target
StartLimitIntervalSec=0

[Service]
Type=simple
ExecStart=/usr/local/lib/nightsquire-host-agent/nightsquire-host-agent run --config /etc/nightsquire-host-agent/config.json
Restart=always
RestartSec=5
UMask=0077

[Install]
WantedBy=multi-user.target
UNIT
	chmod 0644 "$unit_tmp"
	mv -f "$unit_tmp" "$HOST_AGENT_UNIT"
	systemctl daemon-reload
	if ! systemctl enable nightsquire-host-agent.service >/dev/null 2>&1; then
		HOST_AGENT_ERR="systemctl enable nightsquire-host-agent.service failed"
		return 1
	fi
	if systemctl is-active --quiet nightsquire-host-agent.service; then
		systemctl restart nightsquire-host-agent.service || { HOST_AGENT_ERR="restarting nightsquire-host-agent.service failed"; return 1; }
		verb=restarted
	else
		systemctl start nightsquire-host-agent.service || { HOST_AGENT_ERR="starting nightsquire-host-agent.service failed (journalctl -u nightsquire-host-agent)"; return 1; }
		verb=started
	fi
	version="$("$bin" version 2>/dev/null || echo unknown)"
	if [ "$changed" = 1 ]; then
		say "host agent $version installed in $HOST_AGENT_DIR and $verb (firewall default: $firewall)"
	else
		say "host agent $version unchanged, $verb (firewall default: $firewall)"
	fi
	firewalld_check
	if [ "$firewall" = off ] || [ "$FIREWALLD_ACTIVE" = 1 ]; then
		:
	elif [ -n "$ssh_source" ]; then
		say "firewall default: SSH only from $ssh_source (the address of this SSH session), no ping; change it in the dashboard (Executors → this executor → Firewall)"
	else
		say "firewall default: SSH from everywhere (no SSH session address detected), no ping; restrict SSH in the dashboard (Executors → this executor → Firewall)"
	fi
	say "host agent logs: journalctl -u nightsquire-host-agent"
	return 0
}

cat >"$INSTALL_DIR/docker-compose.yml" <<'COMPOSE'
# Nightsquire executor: one per host, written by install.sh (rerunning the install
# command rewrites this file). The daemon talks to the local Docker socket, runs
# every step in a throwaway container and starts the egress gateway as a sibling
# container from the same image.
#
# Required (.env next to this file, mode 0600):
#   NIGHTSQUIRE_EXECUTOR_IMAGE  the executor image in Nightsquire's image registry (…/executor:<version>)
#   CONTROLLER_URL              Nightsquire's executor endpoint, e.g. https://connect.nightsquire.dev
#   REGISTRATION_TOKEN          only for the first start; install.sh removes it once registered
# Optional: EXECUTOR_NAME, EXECUTOR_CAPACITY (empty: automatic from the host's CPUs and memory),
#   EXECUTOR_LABELS (k=v,k=v), LOG_LEVEL,
#   NIGHTSQUIRE_SHUTDOWN (finish|stop), NIGHTSQUIRE_GATEWAY_CONTROLLER_URL,
#   NIGHTSQUIRE_GATEWAY_ALLOW_CIDRS (private ranges steps may reach through the gateway, e.g.
#   an internal registry; refused by default), NIGHTSQUIRE_ALLOW_HOST_REACHABLE_STEPS (true runs
#   gateway steps on engines that cannot isolate step networks from this host; avoid),
#   NIGHTSQUIRE_TRANSPARENT_PROXY (default true: steps reach 443/80 through the gateway also
#   without HTTPS_PROXY; false keeps only the explicit proxy).
# Docker for agents: agent steps get their own Docker daemon while Docker has the sysbox-runc
#   runtime (install.sh sets up sysbox where the host supports it).
# GitLab route (publish-class steps reach one GitLab project through the gateway on :8081):
#   a GitLab on a private address needs its range in NIGHTSQUIRE_GATEWAY_ALLOW_CIDRS; a GitLab
#   with a private CA needs NIGHTSQUIRE_GITLAB_CA_FILE, a PEM file readable inside the container
#   (e.g. /var/lib/nightsquire-executor/gitlab-ca.pem, which is mounted below).
# Images are public: every pull is anonymous.
# Needs Docker Engine >= 26.0 (>= 28.0 recommended: step networks use gateway_mode_ipv4=isolated).
# Self-update: Nightsquire can move the executor to another image of its own repository
#   (pinned by digest). The daemon starts a one-shot updater container from the new image, which
#   recreates this container with the same settings and the new image and rewrites
#   NIGHTSQUIRE_EXECUTOR_IMAGE in this directory's .env (mounted read-write below), so `docker compose up`
#   keeps the new version.
#
# /var/lib/nightsquire-executor is bind-mounted at the SAME path: the daemon
# creates per-step input dirs and the gateway control socket there and asks
# the engine to bind-mount them into sibling containers by host path.
# Outputs never touch it: they live on size-limited tmpfs volumes.
name: nightsquire-executor

services:
  executor:
    image: ${NIGHTSQUIRE_EXECUTOR_IMAGE:?set NIGHTSQUIRE_EXECUTOR_IMAGE}
    command: ["run"]
    restart: unless-stopped
    # Root inside the container only for the Docker socket and step-dir
    # cleanup; access to the socket is root-equivalent on the host anyway.
    user: "0:0"
    read_only: true
    cap_drop: [ALL]
    cap_add: [CHOWN, DAC_OVERRIDE, FOWNER]
    security_opt: ["no-new-privileges:true"]
    environment:
      CONTROLLER_URL: ${CONTROLLER_URL:?set CONTROLLER_URL}
      REGISTRATION_TOKEN: ${REGISTRATION_TOKEN:-}
      EXECUTOR_NAME: ${EXECUTOR_NAME:-}
      EXECUTOR_CAPACITY: ${EXECUTOR_CAPACITY:-}
      EXECUTOR_LABELS: ${EXECUTOR_LABELS:-}
      LOG_LEVEL: ${LOG_LEVEL:-info}
      NIGHTSQUIRE_SHUTDOWN: ${NIGHTSQUIRE_SHUTDOWN:-finish}
      NIGHTSQUIRE_STATE_PATH: /var/lib/nightsquire-executor/state.json
      NIGHTSQUIRE_WORK_ROOT: /var/lib/nightsquire-executor
      NIGHTSQUIRE_GATEWAY_IMAGE: ${NIGHTSQUIRE_EXECUTOR_IMAGE}
      NIGHTSQUIRE_GATEWAY_CONTROL: unix
      NIGHTSQUIRE_GATEWAY_CONTROLLER_URL: ${NIGHTSQUIRE_GATEWAY_CONTROLLER_URL:-}
      NIGHTSQUIRE_GATEWAY_ALLOW_CIDRS: ${NIGHTSQUIRE_GATEWAY_ALLOW_CIDRS:-}
      NIGHTSQUIRE_ALLOW_HOST_REACHABLE_STEPS: ${NIGHTSQUIRE_ALLOW_HOST_REACHABLE_STEPS:-false}
      NIGHTSQUIRE_TRANSPARENT_PROXY: ${NIGHTSQUIRE_TRANSPARENT_PROXY:-true}
      NIGHTSQUIRE_GITLAB_CA_FILE: ${NIGHTSQUIRE_GITLAB_CA_FILE:-}
      NIGHTSQUIRE_INSTALL_DIR: /etc/nightsquire-executor/install
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /var/lib/nightsquire-executor:/var/lib/nightsquire-executor
      # This directory (docker-compose.yml, .env), read-write for self-update: the updater
      # records the new image as NIGHTSQUIRE_EXECUTOR_IMAGE in .env.
      - .:/etc/nightsquire-executor/install
    # finish mode waits up to 10 min for running steps (see --shutdown-timeout).
    stop_grace_period: 11m
    logging:
      driver: json-file
      options:
        max-size: 10m
        max-file: "3"
COMPOSE
chmod 0644 "$INSTALL_DIR/docker-compose.yml"

write_env "$REGISTRATION_TOKEN"
say "pulling $NIGHTSQUIRE_EXECUTOR_IMAGE"
compose pull -q || die "pulling $NIGHTSQUIRE_EXECUTOR_IMAGE failed"
host_arch="$(uname -m)"
case "$host_arch" in x86_64) host_arch=amd64 ;; aarch64) host_arch=arm64 ;; esac
image_arch="$(docker image inspect --format '{{.Architecture}}' "$NIGHTSQUIRE_EXECUTOR_IMAGE" 2>/dev/null || true)"
if [ -n "$image_arch" ] && [ "$image_arch" != "$host_arch" ]; then
	write_env ""
	die "$NIGHTSQUIRE_EXECUTOR_IMAGE is built for $image_arch; this host is $host_arch"
fi
if [ "$NIGHTSQUIRE_HOST_AGENT" = 0 ]; then
	say "host agent: skipped (--no-host-agent)"
elif ! host_agent_supported; then
	say "host agent: skipped ($HOST_AGENT_SKIP); updates, reboots and the firewall are not managed on this host"
elif ! install_host_agent; then
	say "warning: host agent not installed: $HOST_AGENT_ERR"
	say "the executor runs without it; rerun this script to retry"
fi
# Docker for agents (see the top of this script): never fails the install.
if sysbox_runtime && [ -z "$(sysbox_installed)" ]; then
	say "Docker for agents: Docker already lists the sysbox-runc runtime; left alone"
elif [ -z "$SYSBOX_SKIP" ] && install_sysbox; then
	say "Docker for agents: on (agent steps get their own Docker daemon)"
else
	say "warning: Docker for agents is not set up: $SYSBOX_SKIP"
	say "the executor runs agent steps without a Docker daemon; its page in Nightsquire shows this"
fi
compose up -d

if [ -n "$REGISTRATION_TOKEN" ]; then
	say "waiting for registration with $CONTROLLER_URL"
	i=0
	while [ ! -f "$STATE_DIR/state.json" ]; do
		i=$((i + 1))
		if [ "$i" -gt 60 ]; then
			write_env ""
			die "not registered after 60 s; see: cd $INSTALL_DIR && docker compose logs"
		fi
		sleep 1
	done
	# The token is single-use; drop it from .env and the container environment.
	write_env ""
	compose up -d
fi

say "executor '$EXECUTOR_NAME' is running (state: $STATE_DIR, compose: $INSTALL_DIR)"
say "logs: cd $INSTALL_DIR && docker compose logs -f"
