#!/bin/sh
# logpulse-agent-upgrade — the privileged half of the agent self-upgrade.
#
# Started as root by logpulse-agent-upgrade.service when the unprivileged agent
# drops /var/lib/logpulse-agent/upgrade.env. That file is written by the
# `logpulse` user, so nothing in it is trusted (ADR-0167, issue #3112):
#
#   - it is never sourced or used as a systemd EnvironmentFile; this script
#     moves it into a root-only directory and reads at most one short line;
#   - the only thing taken from it is a version string, matched against a
#     strict pattern and refused when older than MIN_VERSION, so the agent
#     cannot ask for a release that still ships the vulnerable root unit;
#   - the installer runs with an empty environment plus a fixed PATH, the
#     validated version and LOGPULSE_UPGRADE=1, which pins the download host.
#
# `logpulse-agent-upgrade --parse FILE` only validates FILE and prints the
# version. The tests use it; it changes nothing on the host.
set -eu
umask 077
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH

TRIGGER=/var/lib/logpulse-agent/upgrade.env
# Root-only (StateDirectory= of the unit, mode 0700). The trigger is renamed in
# here before it is read, so the agent can no longer swap it for a symlink or
# FIFO between the checks and the read.
WORK_DIR=/var/lib/logpulse-agent-upgrade
INSTALL_URL=https://get.logpulse.io
# Oldest release this helper installs. Every release before it ships the root
# unit that loaded upgrade.env as an EnvironmentFile; installing one would
# reopen #3112. Raise this whenever a release fixes a hole in this path.
MIN_VERSION=v0.8.1
MAX_TRIGGER_BYTES=64

err() { printf 'logpulse-agent-upgrade: %s\n' "$1" >&2; exit 1; }

# The agent's own versions are vMAJOR.MINOR.PATCH[-pre] (the API checks the
# same shape). No leading zeros and at most nine digits, so the numbers are
# safe in $(( )) — a leading zero would be read as octal.
VERSION_RE='v(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})(-[A-Za-z0-9_.]+)?'

# version_core vX.Y.Z[-pre] → "X Y Z"
version_core() {
  core=${1#v}
  core=${core%%-*}
  printf '%s\n' "$core" | tr '.' ' '
}

# version_at_least A B: true when the release core of A is >= that of B.
version_at_least() {
  # shellcheck disable=SC2046 # word splitting into the three parts is the point
  set -- $(version_core "$1") $(version_core "$2")
  [ "$1" -ne "$4" ] && { [ "$1" -gt "$4" ]; return; }
  [ "$2" -ne "$5" ] && { [ "$2" -gt "$5" ]; return; }
  [ "$3" -ge "$6" ]
}

# parse_trigger FILE: print the requested version, or fail. The file content is
# never echoed, not even in an error: it is attacker-controlled.
parse_trigger() {
  f=$1
  if [ -L "$f" ] || [ ! -f "$f" ]; then
    err "trigger is not a regular file"
  fi
  # One bounded read; everything below looks only at these bytes, so growing
  # the file afterwards (e.g. through a hard link) changes nothing.
  content=$(dd if="$f" bs=$((MAX_TRIGGER_BYTES + 1)) count=1 2>/dev/null) \
    || err "could not read trigger"
  [ "${#content}" -le "$MAX_TRIGGER_BYTES" ] || err "trigger is too long"
  nl='
'
  cr=$(printf '\r')
  case $content in
    *"$nl"* | *"$cr"*) err "trigger must be a single line" ;;
    LOGPULSE_VERSION=*) version=${content#LOGPULSE_VERSION=} ;;
    *) err "trigger does not start with LOGPULSE_VERSION=" ;;
  esac
  printf '%s\n' "$version" | grep -Eqx "$VERSION_RE" \
    || err "trigger holds no valid version"
  version_at_least "$version" "$MIN_VERSION" \
    || err "refusing a version older than $MIN_VERSION"
  printf '%s\n' "$version"
}

if [ "${1:-}" = "--parse" ]; then
  [ $# -eq 2 ] || err "usage: logpulse-agent-upgrade --parse FILE"
  parse_trigger "$2"
  exit 0
fi
[ $# -eq 0 ] || err "unexpected arguments"
[ "$(id -u)" -eq 0 ] || err "must run as root"

[ -d "$WORK_DIR" ] && [ ! -L "$WORK_DIR" ] || err "$WORK_DIR is missing"
[ "$(stat -c '%u %a' "$WORK_DIR")" = "0 700" ] \
  || err "$WORK_DIR must be owned by root with mode 0700"

request="$WORK_DIR/upgrade.request"
installer="$WORK_DIR/install.sh"
# -r: the agent could leave a directory behind as its "trigger".
trap 'rm -rf "$request" "$installer"' EXIT
rm -rf "$request" "$installer"
# rename(2) moves the directory entry itself: a symlink stays a symlink and is
# refused by parse_trigger, its target is never opened.
mv -f "$TRIGGER" "$request" 2>/dev/null || err "no upgrade trigger at $TRIGGER"

version=$(parse_trigger "$request")
printf 'logpulse-agent-upgrade: installing %s\n' "$version"

curl -q -fsSL --proto '=https' --tlsv1.2 -o "$installer" "$INSTALL_URL" \
  || err "could not download the installer from $INSTALL_URL"
env -i PATH="$PATH" LOGPULSE_VERSION="$version" LOGPULSE_UPGRADE=1 \
  /bin/sh "$installer"
