#!/bin/sh
#
# UptimeCraft host agent installer.
#
#   curl -fsSL https://uptimecraft.com/install.sh | UPTIMECRAFT_ENROLLMENT_TOKEN=uc_enroll_... sh
#
# What this does, so that piping it to a shell is a decision rather than a leap:
#
#   1. Works out your platform and downloads the matching release from
#      get.uptimecraft.com.
#   2. Verifies that the checksum file was signed by UptimeCraft, using a public
#      key carried in this script, and stops if it was not.
#   3. Verifies the download against that checksum, and stops if it does not
#      match.
#
#      Both halves matter. The checksum alone only proves the archive arrived
#      intact; anyone able to replace the archive could replace the checksum file
#      beside it. The signature is what makes the checksum ours, and the private
#      key is not on the machine that serves the download.
#   4. Creates an unprivileged 'uptimecraft' user that owns nothing else.
#   5. Installs the binary, a systemd unit, and your enrollment token in a file
#      only that user can read.
#   6. Starts the service, which enrols itself and begins reporting.
#
# The agent makes outbound HTTPS connections and nothing else. It does not listen
# on a port, and it cannot execute commands -- there is no exec in the binary, so
# that is a property of the code rather than a promise we are making to you.
#
# Everything below runs with `set -e`, so any failure stops the install rather
# than leaving a half-configured service behind.

set -eu

# Where the builds live.
#
# Our own object storage rather than GitHub, for two reasons. The source
# repository is private, so its release assets need a token no customer has --
# the install would simply 404. And serving from the same origin that served this
# script means the TLS chain covers the whole download, so there is one host to
# trust rather than two.
DOWNLOAD_BASE="${UPTIMECRAFT_DOWNLOAD_BASE:-https://get.uptimecraft.com/agent}"
VERSION="${UPTIMECRAFT_VERSION:-latest}"
API="${UPTIMECRAFT_API:-https://api.uptimecraft.com}"
BIN_DIR="${UPTIMECRAFT_BIN_DIR:-/usr/local/bin}"
ETC_DIR="${UPTIMECRAFT_ETC_DIR:-/etc/uptimecraft}"
STATE_DIR="${UPTIMECRAFT_STATE_DIR:-/var/lib/uptimecraft}"
SERVICE_USER="${UPTIMECRAFT_USER:-uptimecraft}"

# ---------------------------------------------------------------------------
# The release signing key.
#
# This is what makes the install trustworthy rather than merely consistent. The
# checksum file proves the archive was not corrupted in transit; this proves the
# checksum file is ours. Without it, anyone who could replace the archive could
# replace SHA256SUMS alongside it and the two would agree with each other all the
# way onto your server.
#
# The trust chain is: HTTPS to uptimecraft.com delivered this script, this script
# carries the public key, the key verifies SHA256SUMS, SHA256SUMS verifies the
# archive. A compromise of the release host alone is not enough, because the
# private key is not there.
#
# There is deliberately no way to override or skip this from the environment. A
# switch that turns verification off is a switch that ends up turned off.
#
# Rotating: add the new public key below alongside the old one and sign releases
# with both for one cycle, then remove the old. Any listed key verifying is
# enough, so an older installer keeps working through the change.
# ---------------------------------------------------------------------------
RELEASE_KEYS=$(cat <<'KEYS'
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqhWSAS3MvNIfOrSMiEUDmlu9qCMZ
5f7xR0FA6CGHromuHClAHf/wPViCbK4YO6ylk1SiLhLSGVeKT5gGVnEOAA==
-----END PUBLIC KEY-----
KEYS
)

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

# ---------------------------------------------------------------------------
# Checks before anything is changed, so a machine that cannot be installed on
# is left exactly as it was.
# ---------------------------------------------------------------------------

[ "$(id -u)" -eq 0 ] || fail "run this as root: the agent needs a system user and a systemd unit"

case "$(uname -s)" in
    Linux) OS=linux ;;
    Darwin) fail "macOS support is partial and has no installer yet; download the darwin build by hand" ;;
    *) fail "$(uname -s) is not supported" ;;
esac

case "$(uname -m)" in
    x86_64|amd64) ARCH=amd64 ;;
    aarch64|arm64) ARCH=arm64 ;;
    *) fail "$(uname -m) is not supported; the agent ships for amd64 and arm64" ;;
esac

command -v curl >/dev/null 2>&1 || fail "curl is required"
command -v systemctl >/dev/null 2>&1 || fail "this installer needs systemd"

# Needed to check the release signature. Slim container images tend not to have
# it, ordinary server installs do. Required rather than optional: an install that
# silently skips verification is the thing this whole section exists to prevent.
if ! command -v openssl >/dev/null 2>&1; then
    fail "openssl is required to verify the release signature.
  Debian/Ubuntu:  apt-get install -y openssl
  RHEL/Fedora:    dnf install -y openssl
  Alpine:         apk add openssl"
fi

# A build of this script that never had a key substituted in cannot verify
# anything, and would otherwise install happily. Refused loudly, because the
# failure it prevents is silent.
case "$RELEASE_KEYS" in
    *REPLACE_WITH_THE_UPTIMECRAFT_RELEASE_PUBLIC_KEY*)
        fail "this installer was built without a release key, so it cannot verify \
what it downloads. Get it from https://uptimecraft.com/install.sh" ;;
esac

# Checked here rather than after downloading: there is no point installing an
# agent that has nothing to enrol with, and finding out at the end is worse.
if [ -z "${UPTIMECRAFT_ENROLLMENT_TOKEN:-}" ]; then
    fail "set UPTIMECRAFT_ENROLLMENT_TOKEN, from Settings > Servers in the dashboard"
fi

# A checksum tool, whichever this distribution ships.
if command -v sha256sum >/dev/null 2>&1; then
    SHA_CMD="sha256sum"
elif command -v shasum >/dev/null 2>&1; then
    SHA_CMD="shasum -a 256"
else
    fail "neither sha256sum nor shasum is available, so the download cannot be verified"
fi

# ---------------------------------------------------------------------------
# Download and verify.
# ---------------------------------------------------------------------------

# Overridable so the installer can be tested against a local copy, which matters
# more than it might seem: an installer nobody has ever run is the one piece of
# this that reaches a customer's machine untested. Not a new weakness -- anything
# able to set these variables is already the thing running the script, and the
# signature check below is what actually decides whether to trust what arrives.
BASE="${UPTIMECRAFT_BASE_URL:-${DOWNLOAD_BASE}/${VERSION}}"

WORK="$(mktemp -d)"
# Cleared on every exit path, including failure: a downloaded binary left in
# /tmp is one somebody might run later without the checksum having passed.
trap 'rm -rf "$WORK"' EXIT INT TERM

ARCHIVE="uptimecraft-agent_${OS}_${ARCH}.tar.gz"
say "Downloading ${ARCHIVE}"
curl -fsSL "${BASE}/${ARCHIVE}" -o "${WORK}/${ARCHIVE}" \
    || fail "could not download ${BASE}/${ARCHIVE}"
curl -fsSL "${BASE}/SHA256SUMS" -o "${WORK}/SHA256SUMS" \
    || fail "could not download the checksum file"
curl -fsSL "${BASE}/SHA256SUMS.sig" -o "${WORK}/SHA256SUMS.sig" \
    || fail "could not download the release signature"

say "Verifying the release signature"
# Split into one file per key so each can be tried in turn, which is what makes
# rotation possible without breaking installers that predate the new key.
#
# Anchored to the line start, so a stray mention of the marker in a comment
# cannot split a key in half and quietly make it unverifiable.
printf '%s\n' "$RELEASE_KEYS" | awk -v dir="$WORK" '
    /^-----BEGIN PUBLIC KEY-----$/ { n++; out = dir "/key." n }
    out { print > out }
    /^-----END PUBLIC KEY-----$/ { out = "" }
'
SIGNATURE_OK=""
for key in "$WORK"/key.*; do
    [ -f "$key" ] || continue
    if openssl dgst -sha256 -verify "$key" \
            -signature "${WORK}/SHA256SUMS.sig" "${WORK}/SHA256SUMS" >/dev/null 2>&1; then
        SIGNATURE_OK="yes"
        break
    fi
done
if [ -z "$SIGNATURE_OK" ]; then
    # Nothing has been written outside the temporary directory at this point, and
    # the trap removes that. Either the release is not ours or it was tampered
    # with, and neither is worth guessing about.
    fail "the release signature does not verify against any known UptimeCraft key.
  This is not a corrupted download -- it means SHA256SUMS was not signed by us.
  Do not install it. Please report this."
fi

say "Verifying the download"
EXPECTED="$(grep " ${ARCHIVE}\$" "${WORK}/SHA256SUMS" | awk '{print $1}')"
[ -n "$EXPECTED" ] || fail "${ARCHIVE} is not listed in SHA256SUMS"
ACTUAL="$(cd "$WORK" && $SHA_CMD "$ARCHIVE" | awk '{print $1}')"
if [ "$EXPECTED" != "$ACTUAL" ]; then
    # Nothing is installed and the temporary directory is removed. A mismatch is
    # either a corrupted transfer or something worse, and neither is worth
    # guessing about.
    fail "checksum mismatch: expected $EXPECTED but got $ACTUAL"
fi

tar -xzf "${WORK}/${ARCHIVE}" -C "$WORK" || fail "could not unpack ${ARCHIVE}"
[ -f "${WORK}/uptimecraft-agent" ] || fail "the archive did not contain the agent binary"

# ---------------------------------------------------------------------------
# Install.
# ---------------------------------------------------------------------------

if ! id "$SERVICE_USER" >/dev/null 2>&1; then
    say "Creating the ${SERVICE_USER} user"
    # No home, no shell, no group memberships. It reads /proc and talks outward;
    # anything more would be a capability nobody asked for.
    useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER" \
        || fail "could not create the ${SERVICE_USER} user"
fi

say "Installing to ${BIN_DIR}/uptimecraft-agent"
install -m 0755 "${WORK}/uptimecraft-agent" "${BIN_DIR}/uptimecraft-agent"

install -d -m 0750 -o root -g "$SERVICE_USER" "$ETC_DIR"
install -d -m 0700 -o "$SERVICE_USER" -g "$SERVICE_USER" "$STATE_DIR"

# The token goes in a file the service user can read and nobody else can. Not in
# the unit file, which is world-readable, and not on the command line, which is
# visible in the process list to every user on the machine.
umask 077
cat > "${ETC_DIR}/agent.env" <<ENV
UPTIMECRAFT_ENROLLMENT_TOKEN=${UPTIMECRAFT_ENROLLMENT_TOKEN}
UPTIMECRAFT_API=${API}
ENV
chown "root:${SERVICE_USER}" "${ETC_DIR}/agent.env"
chmod 0640 "${ETC_DIR}/agent.env"

if [ -f "${WORK}/uptimecraft-agent.service" ]; then
    install -m 0644 "${WORK}/uptimecraft-agent.service" \
        /etc/systemd/system/uptimecraft-agent.service
else
    fail "the archive did not contain a systemd unit"
fi

say "Starting the service"
systemctl daemon-reload
systemctl enable --now uptimecraft-agent

say ""
say "Installed, with the release signature and checksum both verified."
say "The agent enrols itself and begins reporting within a minute."
say ""
say "  systemctl status uptimecraft-agent     is it running"
say "  journalctl -u uptimecraft-agent -f     what it is doing"
say ""
say "If this server appears as 'pending approval' in the dashboard, another"
say "machine is already reporting with the same identity -- approve it there."
