#!/bin/sh
#
# freecore-enroll — point a TrueNAS CORE 13.3 system at the FreeCORE update
# train, or attempt the same transition from a compatible zVault 13.3 system.
#
# After it runs, this machine will accept FreeCORE-signed operating system
# updates. That is the whole point, and it is also why it is worth reading
# before running: whoever controls that signing key controls what this box will
# accept as an operating system update.
#
# The enrollment changes below are all reversible with --revert:
#
#   /usr/local/share/certs/<train>.pem   FreeCORE's train certificate (added).
#   /data/update.conf                    points the updater at FreeCORE (added).
#   /data/update.conf.pre-freecore.train records the prior and enrolled trains.
#   system.update.upd_train               selects the requested discovered train.
#   …/middlewared/plugins/update_/utils.py
#                                        one function patched, see below.
#
# The third one is the awkward but unavoidable part. `can_update()` decides
# whether an update is allowed by splitting both version strings on [-.] and
# comparing token by token. The FIRST pair is the product name — `TrueNAS` vs
# `FreeCORE` — and because 'TrueNAS' > 'FreeCORE' lexicographically it returns
# False and refuses, before it ever reaches 13 vs 15:
#
#   Unable to downgrade from TrueNAS-13.3-U1.2 to FreeCORE-15.0-…
#
# That check runs in the *installed 13.3 middleware*, on this machine — it is
# iXsystems' code, not ours, so there is nothing we can ship that avoids it.
# Both update routes hit it: the manual-file path and the train path both end
# up in update.install_impl().
#
# The patch does NOT disable the check. It normalises the leading product name
# on both sides so the comparison is decided by the version numbers instead of
# the brand. Downgrade protection is preserved: 15.0 -> 13.3 is still refused.
# middlewared is restarted afterwards, because it holds the module in memory.
#
# Nothing iXsystems ships is replaced or removed. iX-CA.pem, Production.pem and
# Nightlies.pem are left exactly as they are — manifest verification looks for a
# per-train certificate first, so FreeCORE's is added under its own name and
# never displaces theirs.
#
# The FreeCORE update CA is downloaded and its fingerprint checked, but it is
# NOT installed. It is used here only to prove that the train certificate is
# genuinely ours before that certificate is trusted.
#
# It changes no data and no pool, and does not install an update — it leaves
# the system able to *see* and accept the FreeCORE train, and stops there.
# It does restart middlewared, so the web interface will blink.

set -eu

# Where the public certs are fetched from. The website is the published home and
# stays the default; --pki-base exists because the certs and the train are served
# from different places during validation, and pinning the fingerprint below is
# what makes pointing this elsewhere safe — a substituted CA fails the check.
PKI_BASE="https://freecore.org/pki"
CA_FINGERPRINT="50:DF:7A:7C:49:44:58:AE:13:57:AE:90:E1:95:D0:01:53:FC:59:30:2E:50:7C:B4:E3:33:A2:FB:F6:E0:AD:08"

# Which train to enrol on. STABLE is the right answer for a normal install and
# stays the default; --train exists because the trains are minted per release
# line and a box may need to sit on a different one.
TRAIN="FreeCORE-15.0-STABLE"

# updates.freecore.org is the canonical published endpoint: it is the active R2
# binding and current product contract. The singular spelling remains a
# noncanonical internal compatibility alias and must not appear here.
UPDATE_BASE="https://updates.freecore.org"

CERT_DIR="/usr/local/share/certs"
IX_ROOT_CA="${CERT_DIR}/iX-CA.pem"   # referenced only by --revert, never written
CONF="/data/update.conf"
SYSTEM_MANIFEST="/data/manifest"
BAK=".pre-freecore"
TRAIN_STATE="${CONF}${BAK}.train"

say()  { printf '%s\n' "$*"; }
die()  { printf 'freecore-enroll: %s\n' "$*" >&2; exit 1; }

pick_python() {
    for _py in /usr/local/bin/python3 python3; do
        command -v "$_py" >/dev/null 2>&1 && { printf '%s\n' "$_py"; return 0; }
    done
    return 1
}
ENROLL_PY=$(pick_python) || ENROLL_PY=""
MIDCLT=$(command -v midclt 2>/dev/null || true)

need_root() {
    [ "$(id -u)" = "0" ] || die "must run as root"
}

# Only the known 13.3 source families. Running this against another release or
# an arbitrary rebrand means the paths above were not the ones checked, so
# refuse rather than guess. zVault is admitted explicitly as an unsupported
# compatibility attempt; the source-anchor checks below still fail closed if
# its installed updater no longer has the expected 13.3 shape.
check_release() {
    [ -f /etc/version ] || die "/etc/version missing — this does not look like a compatible system"
    _v=$(cat /etc/version)
    case "$_v" in
        TrueNAS-13.3|TrueNAS-13.3-*)
            say "system:      $_v"
            ;;
        zVault-13.3|zVault-13.3-*)
            say "system:      $_v"
            say "compatibility: zVault 13.3 enrollment is experimental and unsupported; attempting it anyway"
            ;;
        FreeCORE-*)
            die "already FreeCORE ($_v) — nothing to enroll"
            ;;
        *)
            die "unsupported release: $_v (this script covers TrueNAS 13.3 and experimental zVault 13.3 only)"
            ;;
    esac
}

fetch() {
    # -f so a 404 is an error rather than an HTML page written over a
    # certificate; no -k, ever.
    fetch_url="$1"; fetch_out="$2"
    if command -v curl >/dev/null 2>&1; then
        curl -fsS --proto '=https' --tlsv1.2 -o "$fetch_out" "$fetch_url"
    else
        env fetch -q -o "$fetch_out" "$fetch_url"
    fi
}

fingerprint_of() {
    openssl x509 -in "$1" -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//'
}

# Ask the installed middleware where its own update utils module lives, rather
# than guessing a python version in the path. Falls back to a search.
find_utils_py() {
    for _py in /usr/local/bin/python3 python3; do
        command -v "$_py" >/dev/null 2>&1 || continue
        _p=$("$_py" -c 'import middlewared.plugins.update_.utils as m; print(m.__file__)' 2>/dev/null) || continue
        case "$_p" in *.py) [ -f "$_p" ] && { printf '%s\n' "$_p"; return 0; } ;; esac
    done
    _p=$(find /usr/local/lib -path '*/middlewared/plugins/update_/utils.py' -type f 2>/dev/null | head -1)
    [ -n "$_p" ] && { printf '%s\n' "$_p"; return 0; }
    return 1
}

# freenasOS is what actually applies the update, and it is what names the new
# boot environment. Same approach: ask python where the module lives rather than
# guessing a path, and fall back to the known one.
find_update_py() {
    for _py in /usr/local/bin/python3 python3; do
        command -v "$_py" >/dev/null 2>&1 || continue
        _p=$("$_py" -c 'import freenasOS.Update as m; print(m.__file__)' 2>/dev/null) || continue
        case "$_p" in *.py) [ -f "$_p" ] && { printf '%s\n' "$_p"; return 0; } ;; esac
    done
    [ -f /usr/local/lib/freenasOS/Update.py ] && {
        printf '%s\n' /usr/local/lib/freenasOS/Update.py; return 0; }
    return 1
}

# Drop the compiled copy so the edited source is definitely what gets imported.
# Keyed on the file being patched rather than a fixed name, because this is used
# for both middlewared's utils.py and freenasOS's Update.py.
drop_pyc() {
    _d=$(dirname "$1")
    _b=$(basename "$1" .py)
    rm -f "${_d}/__pycache__/${_b}."*.pyc 2>/dev/null || true
}

restart_middleware() {
    say "restarting middlewared (the web interface will blink)..."
    if ! service middlewared restart >/dev/null 2>&1; then
        say "  could not restart automatically — reboot before updating"
        return 1
    fi

    # service(8) returning only means the new process was started. The API can
    # take a few seconds longer to become usable; selecting the train before
    # READY is the same as not selecting it at all.
    if [ -n "$MIDCLT" ]; then
        _wait_count=0
        while [ "$_wait_count" -lt 30 ]; do
            _system_state=$("$MIDCLT" call system.state 2>/dev/null || true)
            [ "$_system_state" = "READY" ] && return 0
            sleep 2
            _wait_count=$((_wait_count + 1))
        done
        say "  middlewared did not return READY"
        return 1
    fi
}

valid_train_name() {
    case "$1" in
        ""|*/*|*..*|*[!A-Za-z0-9._-]*) return 1 ;;
        *) return 0 ;;
    esac
}

selected_train() {
    [ -n "$MIDCLT" ] && [ -n "$ENROLL_PY" ] || return 1
    _trains_json=$("$MIDCLT" call update.get_trains) || return 1
    printf '%s\n' "$_trains_json" | "$ENROLL_PY" -c '
import json, sys

value = json.load(sys.stdin).get("selected")
if not isinstance(value, str) or not value:
    raise SystemExit(1)
print(value)
'
}

# Read the stored train directly from the local middleware datastore. Unlike
# update.get_trains, this does not fetch the old vendor's trains.txt first.
# That distinction matters when enrolling from an otherwise-compatible fork
# whose update service has disappeared or carries a broken hostname.
configured_train() {
    [ -n "$MIDCLT" ] && [ -n "$ENROLL_PY" ] || return 1
    _update_json=$("$MIDCLT" call datastore.config system.update) || return 1
    printf '%s\n' "$_update_json" | "$ENROLL_PY" -c '
import json, sys

value = json.load(sys.stdin).get("upd_train", "")
if not isinstance(value, str):
    raise SystemExit(1)
print(value)
'
}

# With no explicit stored selection, update.get_trains uses the installed
# manifest's train. Read that manifest locally rather than making the same
# network-dependent call we are deliberately avoiding above.
current_manifest_train() {
    [ -n "$ENROLL_PY" ] && [ -r "$SYSTEM_MANIFEST" ] || return 1
    "$ENROLL_PY" - "$SYSTEM_MANIFEST" <<'PY'
import json, sys

with open(sys.argv[1], "r", encoding="utf-8") as manifest_file:
    manifest = json.load(manifest_file)

if not isinstance(manifest, dict):
    raise SystemExit(1)
value = manifest.get("NewTrainName") or manifest.get("Train")
if not isinstance(value, str) or not value:
    raise SystemExit(1)
print(value)
PY
}

prior_selected_train() {
    _prior=$(configured_train) || return 1
    if [ -n "$_prior" ]; then
        printf '%s\n' "$_prior"
    else
        current_manifest_train
    fi
}

# Restoring the previous update.conf can also restore an unreachable update
# service, so update.set_train cannot safely be used for rollback: it lists the
# remote trains before it writes the local selection. The value here came from
# our root-only state file and has already passed valid_train_name().
restore_configured_train() {
    _wanted_train="$1"
    [ -n "$MIDCLT" ] && [ -n "$ENROLL_PY" ] || return 1
    _update_json=$("$MIDCLT" call datastore.config system.update) || return 1
    _update_id=$(printf '%s\n' "$_update_json" | "$ENROLL_PY" -c '
import json, sys

value = json.load(sys.stdin).get("id")
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
    raise SystemExit(1)
print(value)
') || return 1
    "$MIDCLT" call datastore.update system.update "$_update_id" \
        "{\"upd_train\":\"$_wanted_train\"}" >/dev/null || return 1
    _restored=$(configured_train) || return 1
    [ "$_restored" = "$_wanted_train" ]
}

train_available() {
    [ -n "$MIDCLT" ] && [ -n "$ENROLL_PY" ] || return 1
    _wanted_train="$1"
    _trains_json=$("$MIDCLT" call update.get_trains) || return 1
    printf '%s\n' "$_trains_json" | "$ENROLL_PY" -c '
import json, sys

data = json.load(sys.stdin)
raise SystemExit(0 if sys.argv[1] in data.get("trains", {}) else 1)
' "$_wanted_train"
}

# update.conf changes which trains middleware can discover. Selecting the
# requested one is a separate persistent API operation; merely seeing it in
# update.get_trains is not enough.
select_train() {
    _wanted_train="$1"
    _selected=$(selected_train) || return 1
    if [ "$_selected" != "$_wanted_train" ]; then
        train_available "$_wanted_train" || return 1
        "$MIDCLT" call update.set_train "$_wanted_train" >/dev/null || return 1
    fi
    _selected=$(selected_train) || return 1
    [ "$_selected" = "$_wanted_train" ]
}

state_value() {
    _state_key="$1"
    sed -n "s/^${_state_key}=//p" "$TRAIN_STATE" | sed -n '1p'
}

write_train_state() {
    _prior_train="$1"
    _enrolled_train="$2"
    _state_tmp=$(mktemp "${TRAIN_STATE}.XXXXXX") || return 1
    if ! (
        umask 077
        printf 'prior=%s\nenrolled=%s\n' "$_prior_train" "$_enrolled_train" > "$_state_tmp"
    ); then
        rm -f "$_state_tmp"
        return 1
    fi
    chmod 0600 "$_state_tmp" || { rm -f "$_state_tmp"; return 1; }
    mv "$_state_tmp" "$TRAIN_STATE"
}

patch_can_update() {
    _u="$1"
    # Recognize either compatible marker already in circulation. A box carrying
    # the same fix needs nothing from us; patching on top would stack two shims
    # and leave --revert restoring an already-modified backup.
    if grep -qE 'FreeCORE enrollment shim|LAB PATCH' "$_u" 2>/dev/null; then
        say "already patched  $_u"
        return 0
    fi
    [ -f "${_u}${BAK}" ] || cp -p "$_u" "${_u}${BAK}"

    "$ENROLL_PY" - "$_u" <<'PY'
import re, sys

path = sys.argv[1]
src  = open(path).read()

anchor = "def can_update(old_version, new_version):"
if anchor not in src:
    sys.exit("can_update() not found — refusing to patch %s" % path)

shim = anchor + '''
    # --- FreeCORE enrollment shim -------------------------------------------
    # Upstream compares the leading product-name token as a plain string, so
    # 'TrueNAS' > 'FreeCORE' refuses the update before the version numbers are
    # ever reached. Drop that token from both sides -- and only when the two
    # products actually differ -- so the version numbers decide.
    #
    # This does not weaken the check, it tightens it. The same string compare
    # runs the other way too, and 'FreeCORE' < 'TrueNAS', so UNPATCHED the stock
    # check returns True for FreeCORE-15.0 -> TrueNAS-13.3: it will happily call
    # a downgrade to 13.3 an upgrade. With the token dropped that comparison
    # becomes 15 > 13 and is correctly refused.
    #
    # Deliberately product-agnostic. A hardcoded product-name list would leave
    # unknown names to the stock string comparison and could call a downgrade
    # an upgrade. Keying on "the leading token is non-numeric and the two
    # differ" has no list to fall off.
    #
    # SEP splits on '-' and '.' alike, so rejoining the tail with '-' is
    # lossless here: the caller re-splits on the same separators.
    _o = SEP.split(old_version)
    _n = SEP.split(new_version)
    if (_o and _n and _o[0] != _n[0]
            and not _o[0].isdigit() and not _n[0].isdigit()):
        old_version = '-'.join(_o[1:])
        new_version = '-'.join(_n[1:])
    # --- end FreeCORE enrollment shim ---------------------------------------
'''

open(path, "w").write(src.replace(anchor, shim, 1))
print("patched      %s" % path)
PY
}

# The new boot environment's name is decided here, on this box, by iX's shipped
# freenasOS -- so like can_update() it cannot be fixed by anything we ship in
# the image, only by patching it before the upgrade runs.
patch_boot_env_name() {
    _u="$1"
    if grep -q 'FreeCORE enrollment shim' "$_u" 2>/dev/null; then
        say "already patched  $_u"
        return 0
    fi
    [ -f "${_u}${BAK}" ] || cp -p "$_u" "${_u}${BAK}"

    "$ENROLL_PY" - "$_u" <<'PY'
import sys

path = sys.argv[1]
src  = open(path).read()

# Matched as exact text, not by line number, and the script refuses rather than
# guesses if it is absent -- a mis-patch here breaks the apply, not just a gate.
anchor = '''    if new_manifest.Version().startswith(Avatar() + "-"):
        new_boot_name = new_manifest.Version()[len(Avatar() + "-"):]
    else:
        new_boot_name = "%s-%s" % (Avatar(), new_manifest.Version()[len(Avatar() + "-"):])'''

if src.count(anchor) != 1:
    sys.exit("boot-environment naming block not found (or not unique) -- refusing to patch %s" % path)

fixed = '''    # --- FreeCORE enrollment shim -------------------------------------------
    # The else branch below used to slice len(Avatar() + "-") characters off a
    # version string that had just FAILED the startswith() test -- i.e. it cut
    # off a prefix it had no reason to believe was there. That only goes
    # unnoticed while the product name never changes across an update.
    #
    # Upgrading TrueNAS-13.3 -> FreeCORE-15.0 it produced, verbatim:
    #     TrueNAS--15.0-MASTER-202608102214
    # wrong brand, and a doubled dash because len("TrueNAS-") happens to equal
    # len("FreeCORE"), so the slice ate exactly the product name and left its
    # separator behind.
    #
    # The convention this code already follows is "version without the product
    # prefix" -- the 13.3 box's own BE is named 13.3-U1.2 -- so strip whatever
    # product prefix the new version actually carries.
    _fc_ver = new_manifest.Version()
    _fc_pfx = Avatar() + "-"
    if _fc_ver.startswith(_fc_pfx):
        new_boot_name = _fc_ver[len(_fc_pfx):]
    elif "-" in _fc_ver:
        new_boot_name = _fc_ver.split("-", 1)[1]
    else:
        new_boot_name = _fc_ver
    # --- end FreeCORE enrollment shim ---------------------------------------'''

open(path, "w").write(src.replace(anchor, fixed, 1))
print("patched      %s" % path)
PY
}

revert() {
    _revert_reason="${1:-}"
    need_root
    _did=0
    _restart=0
    _prior_train=""
    _enrolled_train="$TRAIN"
    if [ -f "$TRAIN_STATE" ]; then
        _prior_train=$(state_value prior)
        _enrolled_train=$(state_value enrolled)
        valid_train_name "$_prior_train" || die "invalid prior train in $TRAIN_STATE"
        valid_train_name "$_enrolled_train" || die "invalid enrolled train in $TRAIN_STATE"
    fi
    _enrolled_cert="${CERT_DIR}/${_enrolled_train}.pem"

    # Restore a compatible saved iX-CA.pem if one is present so --revert remains
    # complete for every supported prior enrolment state.
    if [ -f "${IX_ROOT_CA}${BAK}" ]; then
        mv "${IX_ROOT_CA}${BAK}" "$IX_ROOT_CA"
        say "restored     $IX_ROOT_CA"
        _did=1
    fi
    if [ -f "${CONF}${BAK}" ]; then
        mv "${CONF}${BAK}" "$CONF"
        say "restored     $CONF"
        _did=1
    elif [ -f "$CONF" ]; then
        # There was no update.conf before enrolment; this one is ours.
        rm -f "$CONF"
        say "removed      $CONF"
        _did=1
    fi
    if [ -f "$_enrolled_cert" ]; then
        rm -f "$_enrolled_cert"
        say "removed      $_enrolled_cert"
        _did=1
    fi
    _u=$(find_utils_py) || _u=""
    if [ -n "$_u" ] && [ -f "${_u}${BAK}" ]; then
        mv "${_u}${BAK}" "$_u"
        drop_pyc "$_u"
        say "restored     $_u"
        _did=1
        _restart=1
    fi
    _up=$(find_update_py) || _up=""
    if [ -n "$_up" ] && [ -f "${_up}${BAK}" ]; then
        mv "${_up}${BAK}" "$_up"
        drop_pyc "$_up"
        say "restored     $_up"
        _did=1
        _restart=1
    fi
    if [ -n "$_prior_train" ]; then
        if ! restore_configured_train "$_prior_train"; then
            die "files were restored, but prior train $_prior_train could not be restored; state retained at $TRAIN_STATE"
        fi
        say "restored     update train $_prior_train"
        _did=1
    fi

    if [ "$_restart" = "1" ] && ! restart_middleware; then
        die "files and prior train were restored, but middlewared did not become ready; state retained at $TRAIN_STATE"
    fi

    if [ -n "$_prior_train" ]; then
        _restored_train=$(configured_train) ||
            die "files were restored, but prior train could not be verified; state retained at $TRAIN_STATE"
        [ "$_restored_train" = "$_prior_train" ] ||
            die "files were restored, but prior train $_prior_train was not retained; state retained at $TRAIN_STATE"
        rm -f "$TRAIN_STATE"
    fi

    [ "$_did" = "1" ] || die "nothing to revert"
    say ""
    say "Reverted. Enrollment files, patches, and prior train are restored."
    if [ -n "$_revert_reason" ]; then
        die "$_revert_reason; all enrollment changes were reverted"
    fi
    exit 0
}

assume_yes=0
do_revert=0
while [ $# -gt 0 ]; do
    case "$1" in
        --revert)  do_revert=1; shift ;;
        --yes|-y)  assume_yes=1; shift ;;
        --train)   [ $# -ge 2 ] || die "--train needs a value"; TRAIN="$2"; shift 2 ;;
        --train=*) TRAIN="${1#--train=}"; shift ;;
        --pki-base)   [ $# -ge 2 ] || die "--pki-base needs a value"; PKI_BASE="$2"; shift 2 ;;
        --pki-base=*) PKI_BASE="${1#--pki-base=}"; shift ;;
        --help|-h)
            say "usage: freecore-enroll.sh [--train NAME] [--yes] [--revert]"
            say ""
            say "  --train NAME  train to enrol on (default: ${TRAIN})"
            say "  --pki-base U  where to fetch the certs (default: ${PKI_BASE})"
            say "  --yes, -y     do not prompt for confirmation"
            say "  --revert      undo a previous run"
            exit 0 ;;
        *) die "unknown argument: $1" ;;
    esac
done

# TRAIN becomes both a URL component and a filesystem path, so refuse anything
# that is not a plain name.
valid_train_name "$TRAIN" || die "invalid train name: $TRAIN"

# The CA is fingerprint-pinned below, so a substituted one is caught -- but there
# is no reason to accept a plaintext source for it.
case "$PKI_BASE" in
    https://*) PKI_BASE="${PKI_BASE%/}" ;;
    *) die "--pki-base must be an https:// URL: $PKI_BASE" ;;
esac

# Derived from TRAIN, so they have to be computed after argument parsing.
CA_URL="${PKI_BASE}/freecore-update-ca.pem"
TRAIN_CERT_URL="${PKI_BASE}/${TRAIN}.pem"
TRAIN_CERT="${CERT_DIR}/${TRAIN}.pem"
UPDATE_URL="${UPDATE_BASE}/FreeCORE"

[ "$do_revert" = "1" ] && revert ""

need_root
check_release
[ -n "$ENROLL_PY" ] || die "python3 not found — cannot patch the middleware check"
[ -n "$MIDCLT" ] || die "midclt not found — cannot select or verify the update train"

if [ -f "$TRAIN_STATE" ]; then
    prior_train=$(state_value prior)
    enrolled_train=$(state_value enrolled)
    valid_train_name "$prior_train" || die "invalid prior train in $TRAIN_STATE"
    valid_train_name "$enrolled_train" || die "invalid enrolled train in $TRAIN_STATE"
    [ "$enrolled_train" = "$TRAIN" ] ||
        die "already enrolled on $enrolled_train — run --revert before selecting $TRAIN"
else
    prior_train=$(prior_selected_train) || die "could not read the currently selected update train"
    valid_train_name "$prior_train" || die "middleware returned an invalid selected train: $prior_train"
    [ ! -e "$TRAIN_CERT" ] ||
        die "$TRAIN_CERT already exists without enrollment state — refusing to overwrite it"
fi

say "train:       $TRAIN"
say "prior train: $prior_train"
say "update url:  $UPDATE_URL"
say ""

tmp=$(mktemp -d) || die "cannot create a temporary directory"
trap 'rm -rf "$tmp"' EXIT INT TERM

say "fetching the FreeCORE update CA..."
fetch "$CA_URL" "$tmp/ca.pem" || die "could not fetch $CA_URL"

got=$(fingerprint_of "$tmp/ca.pem")
[ -n "$got" ] || die "downloaded file is not a certificate"

say ""
say "  subject      $(openssl x509 -in "$tmp/ca.pem" -noout -subject | sed 's/^subject= *//')"
say "  sha256       $got"
say ""

# The fingerprint check is the point of the whole script. TLS says the file
# came from freecore.org; this says it is the CA we mean. Verify it against a
# source that is not this script before answering yes.
if [ "$got" != "$CA_FINGERPRINT" ]; then
    die "fingerprint mismatch — expected $CA_FINGERPRINT. Refusing."
fi
say "fingerprint matches the value published with this script."

if [ "$assume_yes" != "1" ]; then
    say ""
    say "This makes this system accept FreeCORE-signed operating system updates."
    say ""
    say "  - adds FreeCORE's train certificate (nothing iX ships is removed)"
    say "  - points /data/update.conf at FreeCORE and selects $TRAIN"
    say "  - patches can_update() in the installed middleware so the product"
    say "    rename is not read as a downgrade, then restarts middlewared"
    say ""
    say "All enrollment changes are undone by --revert."
    printf 'Continue? [y/N] '
    read -r reply
    case "$reply" in y|Y|yes|YES) ;; *) die "aborted" ;; esac
fi

say ""
fetch "$TRAIN_CERT_URL" "$tmp/train.pem" || die "could not fetch $TRAIN_CERT_URL"
fingerprint_of "$tmp/train.pem" >/dev/null || die "train certificate is not a certificate"

# The train certificate is the thing that actually verifies manifests, so prove
# it was issued by the CA whose fingerprint was just checked. Without this the
# pinned fingerprint would be guarding a file that is then never used.
if ! openssl verify -CAfile "$tmp/ca.pem" "$tmp/train.pem" >/dev/null 2>&1; then
    die "$TRAIN certificate does not chain to the FreeCORE update CA. Refusing."
fi
say "train certificate is issued by that CA."

# Back up before writing, and never clobber an existing backup — a second run
# would otherwise overwrite the original with our own file and strand --revert.
# Written as an if rather than an && chain: under `set -e` a false test at the
# head of an AND-OR list is an easy way to exit the script by accident.
if [ ! -f "$TRAIN_STATE" ]; then
    write_train_state "$prior_train" "$TRAIN" || die "could not record prior update train"
fi
if [ -f "$CONF" ] && [ ! -f "${CONF}${BAK}" ]; then
    cp -p "$CONF" "${CONF}${BAK}"
fi

install -m 0644 "$tmp/train.pem" "$TRAIN_CERT"

cat > "$CONF" <<EOF
[Defaults]
update_server = freecore

[freecore]
name = FreeCORE
url = $UPDATE_URL
master = $UPDATE_URL
signing = true
EOF
chmod 0644 "$CONF"

say "installed    $TRAIN_CERT"
say "wrote        $CONF"

# The product-name check in the installed 13.3 middleware. Without this the
# certificate and update.conf above are useless: the box would see the train,
# download from it, and then refuse to install with "Unable to downgrade".
utils_py=$(find_utils_py) || die "could not locate middlewared's update_/utils.py"
patch_can_update "$utils_py"
drop_pyc "$utils_py"

# Cosmetic rather than blocking: without it the upgrade still works, it just
# leaves a boot environment called TrueNAS--15.0-… on a FreeCORE box. Not fatal,
# so a box whose freenasOS does not match the expected text is warned about and
# carries on rather than having its enrolment aborted.
if update_py=$(find_update_py); then
    patch_boot_env_name "$update_py" || say "  boot-environment name left as-is (see above)"
    drop_pyc "$update_py"
else
    say "note: freenasOS/Update.py not found; boot-environment name left as-is"
fi

if ! restart_middleware; then
    say "middlewared did not become ready after enrollment; reverting..."
    revert "middlewared did not become ready after enrollment"
fi

if ! select_train "$TRAIN"; then
    say "requested train $TRAIN was not available or could not be selected; reverting..."
    revert "could not select requested train $TRAIN"
fi
say "selected     update train $TRAIN"

say ""
say "checking for an update on $TRAIN ..."
say ""

"$MIDCLT" call update.check_available || true

say ""
say "Done. Nothing has been installed; this only pointed the updater."
say "Apply an update from System > Update, or re-run with --revert to undo."
