#!/usr/bin/env bash
# nexpanel-updater: installs a signed NexPanel controller release (root).
#
# Started by nexpanel-updater.path when the panel drops request.json into
# /var/lib/nexpanel-updater. It trusts nothing in the request except the version
# number: the manifest and the package are downloaded again here and verified
# against the NexPanel release key below before anything is installed.
#
# Manual use:   nexpanel-updater --check            show the offered release
#               nexpanel-updater --version 1.1.0     update now (same checks)
set -euo pipefail

DIR=/var/lib/nexpanel-updater
CONF=/etc/nexpanel/updater.conf
APP_DIR=/opt/nexpanel-controller
BASE=https://downloads.nexpanel.app
CHANNEL=stable
# shellcheck disable=SC1090
[[ -f $CONF ]] && . "$CONF"

PUBKEY='-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAdtU4J99jJQD7H2Urdgt2g1cMhXgyqdPBZmkkgseBvcg=
-----END PUBLIC KEY-----'

WANT=""; CHECK=0
while [[ $# -gt 0 ]]; do
  case "$1" in
    --version) WANT="$2"; shift 2 ;;
    --check) CHECK=1; shift ;;
    --from-request) shift ;;
    *) echo "unknown option $1" >&2; exit 2 ;;
  esac
done

[[ $EUID -eq 0 ]] || { echo "run as root" >&2; exit 1; }
mkdir -p "$DIR/work" "$DIR/backups"
LOG="$DIR/update.log"

status() { # state step message
  python3 - "$DIR/status.json" "$1" "$2" "$3" "${WANT:-}" "$LOG" <<'PY'
import json, sys, datetime, os
path, state, step, msg, ver, log = sys.argv[1:]
try:
    tail = open(log, errors="replace").read()[-6000:]
except OSError:
    tail = ""
tmp = path + ".tmp"
json.dump({"state": state, "step": step, "message": msg, "version": ver, "log": tail,
           "at": datetime.datetime.now(datetime.timezone.utc).isoformat()}, open(tmp, "w"))
os.chmod(tmp, 0o640)
os.replace(tmp, path)
PY
  chgrp nexpanel "$DIR/status.json" 2>/dev/null || true
}
fail() { echo "ERROR: $*" >>"$LOG"; status failed "${STEP:-}" "$*"; exit 1; }

verify() { # file sigfile
  openssl pkeyutl -verify -pubin -inkey <(printf '%s\n' "$PUBKEY") -rawin -in "$1" -sigfile "$2" >/dev/null 2>&1
}
fetch() { curl -fsSL --proto '=https' --tlsv1.2 --max-time 600 -o "$2" "$1"; }

# A request from the panel: take the version, delete the request.
if [[ -z "$WANT" && $CHECK -eq 0 ]]; then
  [[ -f $DIR/request.json ]] || exit 0
  WANT="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("version",""))' "$DIR/request.json" 2>/dev/null || true)"
  rm -f "$DIR/request.json"
fi
[[ $CHECK -eq 1 || "$WANT" =~ ^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,6}$ ]] || { WANT=""; fail "invalid version in the request"; }

: >"$LOG"
STEP=manifest
[[ $CHECK -eq 1 ]] || status running manifest "Checking the release manifest"
T="$(mktemp -d "$DIR/work/run.XXXXXX")"
trap 'rm -rf "$T"' EXIT
fetch "$BASE/manifest/$CHANNEL.json" "$T/m.json" || fail "could not download the manifest"
fetch "$BASE/manifest/$CHANNEL.json.sig" "$T/m.sig" || fail "could not download the manifest signature"
verify "$T/m.json" "$T/m.sig" || fail "the manifest signature does not match the NexPanel release key"
read -r M_VER M_URL M_SHA M_FROM < <(python3 - "$T/m.json" <<'PY'
import json, sys
c = json.load(open(sys.argv[1]))["controller"]
print(c["version"], c["url"], c["sha256"], c.get("upgrade_from", "") or "-")
PY
) || fail "the manifest has an unknown format"
CUR="$(cat "$APP_DIR/VERSION" 2>/dev/null || echo 0.0.0)"
if [[ $CHECK -eq 1 ]]; then
  echo "installed: $CUR"; echo "offered:   $M_VER ($M_URL)"; exit 0
fi
[[ "$M_VER" == "$WANT" ]] || fail "the manifest offers $M_VER, not $WANT"
newer() { [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" == "$1" && "$1" != "$2" ]]; }
newer "$WANT" "$CUR" || fail "$WANT is not newer than the installed $CUR"
if [[ "$M_FROM" != "-" ]]; then
  MIN="${M_FROM#>=}"
  newer "$MIN" "$CUR" && fail "$WANT can only be installed over $MIN or newer (installed: $CUR)"
fi
[[ "$M_URL" == "$BASE/"* ]] || fail "the package URL is not on $BASE"

STEP=download
status running download "Downloading NexPanel controller $WANT"
PKG="$T/pkg.tar.gz"
fetch "$M_URL" "$PKG" || fail "download failed: $M_URL"
fetch "$M_URL.sig" "$PKG.sig" || fail "signature download failed"
echo "$M_SHA  $PKG" | sha256sum -c --quiet - >>"$LOG" 2>&1 || fail "SHA-256 does not match the manifest"
verify "$PKG" "$PKG.sig" || fail "the package signature does not match the NexPanel release key"
echo "verified $M_URL (sha256 $M_SHA)" >>"$LOG"

STEP=install
status running install "Installing $WANT"
tar -xzf "$PKG" -C "$T" --no-same-owner
SRC="$(find "$T" -mindepth 1 -maxdepth 1 -type d -name "nexpanel-controller-*" | head -1)"
[[ -n "$SRC" && -f "$SRC/packaging/update-controller.sh" ]] || fail "the package has no update-controller.sh"
[[ "$(cat "$SRC/VERSION")" == "$WANT" ]] || fail "the package is not version $WANT"
if bash "$SRC/packaging/update-controller.sh" >>"$LOG" 2>&1; then
  # the new release may ship a newer updater: install it for next time
  install -m 0755 "$SRC/packaging/nexpanel-updater" /usr/local/sbin/nexpanel-updater 2>/dev/null || true
  status succeeded done "NexPanel controller updated from $CUR to $WANT"
else
  if grep -q "ROLLED BACK" "$LOG"; then
    status rolled_back install "The update failed and $CUR was restored. See the log."
  else
    status failed install "The update failed. See the log."
  fi
  exit 1
fi
