#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
#
# Install or update scradle from the ShadowCradle CDN.
#
#   curl -fsSL https://cdn.shadowcradle.com/download/linux-scradle/install.sh | sudo bash
#   curl -fsSL .../install.sh | sudo bash -s -- --force        # reinstall same version
#
#   --force         reinstall even when the newest version is already installed
#   --yes           never prompt (upgrades proceed; a same-version reinstall does not)
#   --tag TAG       install a specific release (YYYY.MM.DD[.N]) instead of the newest
#   --type TYPE     deb | rpm | run: skip detection of the package type
#   --dry-run       detect, download and verify; install nothing
#   --self-test     only run the post-install self test
#   --no-self-test  skip the self test after installing
#   --allow-container  install inside a container (the module loads into the HOST kernel)
#   --base-url URL  CDN root (default https://cdn.shadowcradle.com)
#
# Published by scripts/cdn-publish.py as download/linux-scradle/install.sh.
set -euo pipefail

BASE_URL="${SCRADLE_CDN:-https://cdn.shadowcradle.com}"
ROOT_PATH="download/linux-scradle"
MOD="scradle"
PKG="scradle-dkms"

FORCE=0 YES=0 TAG="" TYPE="" DRY=0 ONLY_TEST=0 NO_TEST=0 ALLOW_CONTAINER=0

while [ $# -gt 0 ]; do
    case "$1" in
        --force) FORCE=1 ;;
        --yes|-y) YES=1 ;;
        --tag) TAG="${2:?--tag needs a value}"; shift ;;
        --type) TYPE="${2:?--type needs a value}"; shift ;;
        --dry-run) DRY=1 ;;
        --self-test) ONLY_TEST=1 ;;
        --no-self-test) NO_TEST=1 ;;
        --allow-container) ALLOW_CONTAINER=1 ;;
        --base-url) BASE_URL="${2:?--base-url needs a value}"; shift ;;
        -h|--help) sed -n '3,17p' "$0"; exit 0 ;;
        *) echo "unknown option: $1" >&2; exit 2 ;;
    esac
    shift
done
BASE_URL="${BASE_URL%/}"

log()  { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m warn:\033[0m %s\n' "$*" >&2; }
die()  { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }

# ---------------------------------------------------------------- self test
# Each check prints PASS/FAIL; any FAIL makes the whole run fail. Nothing here
# modifies the system beyond loading the module if the package left it unloaded.
self_test() {
    local fails=0 t0
    pass() { printf '  \033[1;32mPASS\033[0m %s\n' "$*"; }
    fail() { printf '  \033[1;31mFAIL\033[0m %s\n' "$*"; fails=$((fails + 1)); }

    log "self test"
    t0="$(date +%s)"

    # 1. something installed it
    if command -v dpkg-query >/dev/null && dpkg-query -W -f='${Status}' "$PKG" 2>/dev/null | grep -q 'install ok installed'; then
        pass "package $PKG registered (dpkg)"
    elif command -v rpm >/dev/null && rpm -q "$PKG" >/dev/null 2>&1; then
        pass "package $PKG registered (rpm)"
    elif command -v dkms >/dev/null && dkms status -m "$MOD" 2>/dev/null | grep -q .; then
        pass "module registered with DKMS (installer or manual install)"
    else
        fail "no package or DKMS registration for $MOD"
    fi

    # 2. built for the running kernel
    if command -v dkms >/dev/null; then
        if dkms status -m "$MOD" -k "$(uname -r)" 2>/dev/null | grep -qi 'installed'; then
            pass "DKMS module installed for $(uname -r)"
        else
            fail "DKMS has no installed $MOD for $(uname -r) (kernel headers missing? see: dkms status)"
        fi
    fi

    # 3. module resolvable and loadable
    if modinfo "$MOD" >/dev/null 2>&1; then
        pass "modinfo $MOD: version $(modinfo -F version "$MOD" 2>/dev/null)"
    else
        fail "modinfo cannot find $MOD for $(uname -r)"
    fi
    if ! grep -q "^$MOD " /proc/modules 2>/dev/null; then
        modprobe "$MOD" 2>/dev/null || true
    fi
    if grep -q "^$MOD " /proc/modules 2>/dev/null; then
        pass "module loaded"
    else
        if command -v mokutil >/dev/null && mokutil --sb-state 2>/dev/null | grep -qi enabled; then
            fail "module not loaded; Secure Boot is on, the module needs a MOK enrolled (mokutil --import, then reboot)"
        else
            fail "module not loaded (modprobe $MOD failed; see dmesg)"
        fi
    fi

    # 4. the control surface exists
    [ -e /dev/scradle-ctl ] && pass "/dev/scradle-ctl present" || fail "/dev/scradle-ctl missing"
    [ -r /proc/scradle-info ] && pass "/proc/scradle-info readable" || fail "/proc/scradle-info missing"

    # 5. the CLI runs against the module
    local ctl
    ctl="$(command -v scradlectl || true)"
    [ -n "$ctl" ] || ctl="$(ls /usr/sbin/scradlectl /usr/local/sbin/scradlectl /usr/bin/scradlectl 2>/dev/null | head -1 || true)"
    if [ -z "$ctl" ]; then
        fail "scradlectl not found"
    elif "$ctl" status >/dev/null 2>&1; then
        pass "scradlectl status ($ctl)"
    else
        fail "scradlectl status failed"
    fi

    # 6. no kernel complaint since the run began (a WARNING fails a test run
    #    even when every assertion passes, so it fails here too)
    if command -v dmesg >/dev/null && dmesg 2>/dev/null | tail -n 300 | grep -iE "$MOD" | grep -qE 'BUG:|WARNING:|Oops|Call Trace'; then
        fail "kernel WARNING/BUG mentioning $MOD in dmesg"
    else
        pass "no kernel warnings mentioning $MOD"
    fi

    if [ "$fails" -eq 0 ]; then
        log "self test passed ($(( $(date +%s) - t0 ))s)"
    else
        die "self test: $fails check(s) failed"
    fi
}

if [ "$ONLY_TEST" -eq 1 ]; then
    [ "$(id -u)" -eq 0 ] || die "run as root: curl -fsSL $BASE_URL/$ROOT_PATH/install.sh | sudo bash -s -- --self-test"
    self_test
    exit 0
fi

# ---------------------------------------------------------------- environment

[ "$(id -u)" -eq 0 ] || [ "$DRY" -eq 1 ] || die "run as root: curl -fsSL $BASE_URL/$ROOT_PATH/install.sh | sudo bash"
[ "$(uname -s)" = Linux ] || die "Linux only"

fetch() {  # fetch URL [DEST]  -> stdout when no DEST
    if command -v curl >/dev/null; then
        if [ $# -gt 1 ]; then curl -fsSL --retry 3 -o "$2" "$1"; else curl -fsSL --retry 3 "$1"; fi
    elif command -v wget >/dev/null; then
        if [ $# -gt 1 ]; then wget -q -O "$2" "$1"; else wget -q -O - "$1"; fi
    else
        die "need curl or wget"
    fi
}

in_container() {
    if command -v systemd-detect-virt >/dev/null && systemd-detect-virt -cq 2>/dev/null; then return 0; fi
    [ -f /.dockerenv ] || [ -f /run/.containerenv ] || grep -qaE 'docker|lxc|kubepods' /proc/1/cgroup 2>/dev/null
}
if in_container && [ "$ALLOW_CONTAINER" -eq 0 ] && [ "$DRY" -eq 0 ]; then
    die "inside a container: the module would load into the HOST kernel. Install on the host, or pass --allow-container."
fi

[ -r /etc/os-release ] || die "no /etc/os-release; cannot detect the distribution"
# Read os-release in a subshell: it defines NAME, VERSION and friends, which
# would silently overwrite this script's own variables.
eval "$( . /etc/os-release; printf 'OS_ID=%q OS_LIKE=%q OS_VER=%q' "${ID:-unknown}" "${ID_LIKE:-}" "${VERSION_ID:-}" )"
OS_MAJOR="${OS_VER%%.*}"
ARCH="$(uname -m)"
log "detected: $OS_ID ${OS_VER:-?} ($ARCH), kernel $(uname -r)"

# Candidate (type, os) pairs, most specific first. The self-extracting .run is
# the universal fallback: it needs only a compiler and kernel headers.
CANDS=()
add() { CANDS+=("$1/$2"); }
case " $OS_ID $OS_LIKE " in
    *" ubuntu "*|*" debian "*)
        add deb "$OS_ID-$OS_VER"
        case " $OS_LIKE " in *" ubuntu "*) add deb "ubuntu-$OS_VER" ;; esac
        case " $OS_LIKE " in *" debian "*) add deb "debian-$OS_MAJOR" ;; esac ;;
esac
case " $OS_ID $OS_LIKE " in
    *" rhel "*|*" centos "*|*" fedora "*)
        add rpm "$OS_ID-$OS_MAJOR"
        case " $OS_ID " in *" fedora "*) : ;; *) add rpm "rhel-$OS_MAJOR" ;; esac ;;
esac
case " $OS_ID $OS_LIKE " in
    *" suse "*|*" opensuse "*|*" sles "*) add rpm "opensuse-leap-${OS_MAJOR:-15}" ;;
esac
add run any

pick=""
for c in "${CANDS[@]}"; do
    t="${c%%/*}"
    [ -z "$TYPE" ] || [ "$TYPE" = "$t" ] || continue
    if [ -n "$TAG" ]; then probe="$c/$TAG/SHA256SUMS"; else probe="$c/LATEST"; fi
    if fetch "$BASE_URL/$ROOT_PATH/$probe" >/dev/null 2>&1; then pick="$c"; break; fi
done
[ -n "$pick" ] || die "no published package for $OS_ID $OS_VER${TYPE:+ (type $TYPE)}${TAG:+ (tag $TAG)}"
PTYPE="${pick%%/*}"

if [ -z "$TAG" ]; then TAG="$(fetch "$BASE_URL/$ROOT_PATH/$pick/LATEST" | tr -d '[:space:]')"; fi
DIR_URL="$BASE_URL/$ROOT_PATH/$pick/$TAG"
log "release $TAG, package type $PTYPE ($pick)"

# ---------------------------------------------------------------- download + verify

WORK="$(mktemp -d "${TMPDIR:-/var/tmp}/scradle-install.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
chmod 755 "$WORK"   # apt downloads as _apt and warns when it cannot read the file
fetch "$DIR_URL/SHA256SUMS" "$WORK/SHA256SUMS"

case "$PTYPE" in
    deb) FILE="$(awk '$2 ~ /^scradle-dkms_.*_all\.deb$/ {print $2; exit}' "$WORK/SHA256SUMS")" ;;
    rpm) FILE="$(awk -v a="$ARCH" '$2 ~ /^scradle-dkms-.*\.rpm$/ && $2 !~ /\.src\.rpm$/ && ($2 ~ ("\\." a "\\.rpm$") || $2 ~ /\.noarch\.rpm$/) {print $2; exit}' "$WORK/SHA256SUMS")" ;;
    run) FILE="$(awk '$2 ~ /^scradle-installer-.*\.run$/ {print $2; exit}' "$WORK/SHA256SUMS")" ;;
    *) die "unsupported package type $PTYPE" ;;
esac
[ -n "$FILE" ] || die "no $PTYPE package listed in $DIR_URL/SHA256SUMS"

log "downloading $FILE"
fetch "$DIR_URL/$FILE" "$WORK/$FILE"
( cd "$WORK" && grep -F "  $FILE" SHA256SUMS | sha256sum -c - >/dev/null ) \
    || die "SHA256 mismatch for $FILE; refusing to install"
log "sha256 verified"
chmod 644 "$WORK/$FILE"

# Signature is checked when the release has one and gpg is here; the key must
# already be in the keyring, so this never trusts a key fetched from the same CDN.
if fetch "$DIR_URL/SHA256SUMS.asc" "$WORK/SHA256SUMS.asc" 2>/dev/null && command -v gpg >/dev/null; then
    if gpg --verify "$WORK/SHA256SUMS.asc" "$WORK/SHA256SUMS" >/dev/null 2>&1; then
        log "gpg signature verified"
    else
        warn "SHA256SUMS.asc present but not verifiable with the local keyring; continuing on sha256 only"
    fi
fi

# ---------------------------------------------------------------- installed?

# The package version is the file name's, minus any -release suffix.
NEW_VER="$(printf '%s' "$FILE" | sed -E 's/^scradle-(dkms[_-]|installer-)//; s/(_all\.deb|\.run|\.(noarch|[a-z0-9_]+)\.rpm)$//; s/-[0-9]+(\.[A-Za-z0-9_]+)?$//')"
cur=""
case "$PTYPE" in
    deb) cur="$(dpkg-query -W -f='${db:Status-Abbrev}${Version}' "$PKG" 2>/dev/null | sed -n 's/^ii *//p' || true)" ;;
    rpm) cur="$(rpm -q --qf '%{VERSION}' "$PKG" 2>/dev/null | grep -v 'not installed' || true)" ;;
esac
if [ -z "$cur" ] && command -v modinfo >/dev/null; then cur="$(modinfo -F version "$MOD" 2>/dev/null || true)"; fi
cur="${cur%%-*}"

# The release tag is what identifies a release: two tags can carry the same
# package version, so comparing versions alone would skip a newer release. The
# tag is recorded here after every successful install. An installation with no
# record (an older installer, or a package installed by hand) has an unknown
# release, which is treated as older than any tag.
RECORD=/var/lib/scradle/release
rec_tag=""
[ -r "$RECORD" ] && rec_tag="$(sed -n 's/^tag=//p' "$RECORD" | head -1)"

# newer A B: A sorts after B (YYYY.MM.DD and YYYY.MM.DD.N sort correctly with -V)
newer() { [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" = "$1" ]; }

MODE=install
if [ -n "$cur" ]; then
    if [ "$rec_tag" = "$TAG" ] && [ "$cur" = "$NEW_VER" ]; then
        log "already installed: $cur (release $TAG)"
        if [ "$FORCE" -eq 1 ]; then
            MODE=reinstall
        elif [ "$YES" -eq 0 ] && { : </dev/tty; } 2>/dev/null; then
            printf 'Force reinstall %s? [y/N] ' "$NEW_VER" >/dev/tty
            read -r ans </dev/tty || ans=""
            case "$ans" in y|Y|yes|YES) MODE=reinstall ;; *) MODE=skip ;; esac
        else
            MODE=skip
        fi
    else
        if [ -z "$rec_tag" ]; then
            log "installed $cur (release unknown) -> release $TAG ($NEW_VER)"
            MODE=upgrade
        elif newer "$TAG" "$rec_tag"; then
            log "installed $cur (release $rec_tag) -> updating to release $TAG ($NEW_VER)"
            MODE=upgrade
        else
            warn "installed release $rec_tag is newer than $TAG: downgrading"
            MODE=downgrade
        fi
        # Same package version in a different release: the package manager
        # would call that a no-op, so it has to be a reinstall.
        [ "$cur" = "$NEW_VER" ] && MODE=reinstall
    fi
fi

# ---------------------------------------------------------------- install

if [ "$DRY" -eq 1 ]; then
    log "dry run: would $MODE $FILE (installed: ${cur:-none}); nothing changed"
    exit 0
fi

if [ "$MODE" = skip ]; then
    log "up to date; not reinstalling (pass --force to reinstall)"
else
    log "$MODE $FILE"
    case "$PTYPE" in
        deb)
            # A fresh machine often has empty package lists, and the package
            # depends on dkms/gcc/make: refresh them once and retry.
            # No Recommends: dkms recommends linux-headers-generic, which pulls
            # the distro's newest kernel's headers (and rebuilds every other DKMS
            # module for it) when the machine runs a different kernel.
            opt="-o APT::Install-Recommends=false"
            [ "$MODE" = reinstall ] && opt="$opt --reinstall"
            [ "$MODE" = downgrade ] && opt="$opt --allow-downgrades"
            # The module is built for the RUNNING kernel, so that kernel's headers
            # are what is needed. Best effort: a custom kernel has no such package.
            if [ ! -d "/lib/modules/$(uname -r)/build" ]; then
                log "installing kernel headers for $(uname -r)"
                { apt-get install -y $opt "linux-headers-$(uname -r)" \
                    || { apt-get update -qq && apt-get install -y $opt "linux-headers-$(uname -r)"; }; } \
                    || warn "no linux-headers-$(uname -r) package; the DKMS build needs them"
            fi
            # shellcheck disable=SC2086
            apt-get install -y $opt "$WORK/$FILE" || { apt-get update -qq && apt-get install -y $opt "$WORK/$FILE"; } ;;
        rpm)
            if [ ! -d "/lib/modules/$(uname -r)/build" ]; then
                log "installing kernel headers for $(uname -r)"
                if command -v dnf >/dev/null; then dnf install -y "kernel-devel-$(uname -r)" || warn "no kernel-devel-$(uname -r); the DKMS build needs it"
                elif command -v zypper >/dev/null; then zypper --non-interactive install kernel-default-devel || warn "kernel headers not installed"
                fi
            fi
            if command -v dnf >/dev/null; then
                case "$MODE" in
                    reinstall) dnf reinstall -y "$WORK/$FILE" || dnf install -y "$WORK/$FILE" ;;
                    downgrade) dnf downgrade -y "$WORK/$FILE" ;;
                    *) dnf install -y "$WORK/$FILE" ;;
                esac
            elif command -v zypper >/dev/null; then
                zypper --non-interactive --no-gpg-checks install --force "$WORK/$FILE"
            else
                rpm -U --replacepkgs --oldpackage "$WORK/$FILE"
            fi ;;
        run)
            chmod +x "$WORK/$FILE"
            if [ "$MODE" = install ]; then "$WORK/$FILE"; else "$WORK/$FILE" --force; fi ;;
    esac
fi

# ---------------------------------------------------------------- self test

# Record which release this is (the package version alone cannot tell two
# releases apart). Written before the self test: the package is installed
# whether or not the test passes.
if [ "$MODE" != skip ]; then
    mkdir -p "$(dirname "$RECORD")"
    printf 'tag=%s\nversion=%s\ninstalled=%s\n' "$TAG" "$NEW_VER" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$RECORD"
fi

if [ "$NO_TEST" -eq 0 ]; then self_test; fi
log "done: scradle $NEW_VER (release $TAG)"
