43 views +0 -0

Update OPNsense behind proxy

Update-Opnsense.ps1:

[CmdletBinding()]
param(
    [string]$OpnsenseHost  = "",
    [string]$SshUser       = "root",
    [string]$ProxyUrl      = "http://10.254.253.181:8080",

    [ValidateSet("", "status", "update", "upgrade", "log", "diag",
                 "configure", "series", "pkgs", "pkgboot", "bootstrap",
                 "reboot", "clean")]
    [string]$Action        = "",

    [string]$TargetVersion = "",

    # Reboot handling. Interactive runs ask on the firewall itself, in the same
    # SSH session, so there is no second password prompt. Unattended runs never
    # reboot unless -Reboot is given.
    [switch]$Reboot,
    [switch]$NoReboot,

    # Version-dependent opnsense-update syntax. Verify against the option
    # parsing that -Action diag prints, and adjust here if it ever changes.
    [string]$UpdateCmd     = "opnsense-update -bkp",
    [string]$UpgradeCmd    = "opnsense-update -bkpr %VERSION%",
    [string]$BootstrapCmd  = "opnsense-bootstrap -f -r %VERSION%"
)

$ErrorActionPreference = "Stop"

function Fail([string]$Message) {
    Write-Host ""
    Write-Host "ERROR: $Message" -ForegroundColor Red
    exit 1
}

# ======================================================== embedded shell ====
# Single-quoted here-string: nothing is interpolated by PowerShell.
# __PROXY__, __UPDATE_CMD__, __UPGRADE_CMD__ and __BOOTSTRAP_CMD__ are
# substituted below.
$Shell = @'
#!/bin/sh
PROXY="__PROXY__"
NOPROXY="localhost,127.0.0.1"
UPDATE_CMD="__UPDATE_CMD__"
UPGRADE_CMD="__UPGRADE_CMD__"
BOOTSTRAP_CMD="__BOOTSTRAP_CMD__"

PKG_CONF="/usr/local/etc/pkg.conf"
CONFIGD_DIR="/usr/local/opnsense/service/conf/configd.conf.d"
CONFIGD_FILE="$CONFIGD_DIR/zz-update-proxy.conf"
KMOD_OFF="/usr/local/etc/pkg/repos/zz-disable-kmods.conf"
OPN_REPO="/usr/local/etc/pkg/repos/OPNsense.conf"
VERDIR="/usr/local/opnsense/version"
MARK="# managed by Update-Opnsense.ps1"

ACTION="${1:-configure}"
ARGVERSION="$2"
REBOOT_MODE="${3:-no}"   # ask | yes | no

die() { echo ""; echo "ERROR: $*" >&2; exit 1; }
say() { echo "==> $*"; }
rule() { echo "----- $* -----"; }

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

HTTP_PROXY="$PROXY";  HTTPS_PROXY="$PROXY"
http_proxy="$PROXY";  https_proxy="$PROXY"
NO_PROXY="$NOPROXY";  no_proxy="$NOPROXY"
export HTTP_PROXY HTTPS_PROXY http_proxy https_proxy NO_PROXY no_proxy
ABI=$(pkg config abi 2>/dev/null)

read_state() {
    S_BASE=$(cat "$VERDIR/base" 2>/dev/null)
    S_KERNEL=$(cat "$VERDIR/kernel" 2>/dev/null)
    S_CORE=$(opnsense-version -v 2>/dev/null)
    S_ABI=$(sed -n 's/.*"product_abi"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$VERDIR/core" 2>/dev/null)
    S_SETS=$(echo "$S_BASE" | cut -d. -f1,2)
    S_OS=$(freebsd-version -u 2>/dev/null)
    S_RUN=$(uname -r)
}

print_state() {
    read_state
    echo "    core version : $S_CORE   (product_abi: $S_ABI)"
    echo "    base set     : $S_BASE"
    echo "    kernel set   : $S_KERNEL"
    echo "    userland/os  : $S_OS   running kernel: $S_RUN"
    if [ "$S_SETS" != "$S_ABI" ]; then
        echo "    STATE        : MISMATCH. Sets on $S_SETS, core on $S_ABI."
        echo "                   A major upgrade was started and never finished."
    else
        echo "    STATE        : consistent on $S_ABI"
    fi
}

# OPNsense ships in January and July, so the series after 25.7 is 26.1.
next_series() {
    _y=$(echo "$1" | cut -d. -f1)
    _m=$(echo "$1" | cut -d. -f2)
    case "$_m" in
        1) echo "$_y.7" ;;
        *) echo "$((_y + 1)).1" ;;
    esac
}

# Returns 0 = the release exists, 1 = it does not, 2 = could not tell.
# Never use -q here: it hides the difference between a 404 (the release really
# is not published) and a timeout or proxy failure (we simply could not look).
# Reporting the second as the first is how "26.7 does not exist yet" got said
# about a release that had been out for a month.
SERIES_ERR=""
series_exists() {
    _url="https://pkg.opnsense.org/$ABI/$1/latest/meta.conf"
    SERIES_ERR=$(fetch -T 20 -o /dev/null "$_url" 2>&1)
    _rc=$?
    [ "$_rc" -eq 0 ] && return 0
    case "$SERIES_ERR" in
        *"Not Found"*|*"404"*) return 1 ;;
        *)                     return 2 ;;
    esac
}

# CORE_NEXT is only the NAME of the coming release, never a statement that it
# is available, so it is used as a hint and always verified against the mirror.
core_next() {
    sed -n 's/.*"CORE_NEXT"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
        /usr/local/opnsense/version/core 2>/dev/null | head -1
}

repo_series() {
    sed -n 's|.*/\([0-9][0-9.]*\)/latest.*|\1|p' "$OPN_REPO" 2>/dev/null | head -1
}

# Two hard signals that a reboot is pending, plus the exit code the wrapper
# watches for. Exit 64 means "reboot started", so the wrapper knows to wait for
# the box instead of treating the dropped session as a failure.
reboot_reason() {
    _k=$(freebsd-version -k 2>/dev/null)
    _r=$(uname -r)
    if [ -n "$_k" ] && [ "$_k" != "$_r" ]; then
        echo "installed kernel $_k differs from the running kernel $_r"
        return 0
    fi
    if [ -d /var/cache/opnsense-update/.sets.pending ] && \
       [ -n "$(ls -A /var/cache/opnsense-update/.sets.pending 2>/dev/null)" ]; then
        echo "offline sets are staged and install on the next boot"
        return 0
    fi
    return 1
}

do_reboot() {
    say "Rebooting now. The session will drop."
    # The reboot must outlive this SSH session. sshd tears down the whole
    # process group of a pty session, and nohup only blocks SIGHUP, so a
    # backgrounded sleep gets killed before it fires. daemon(8) calls setsid(),
    # putting the process in its own session where the teardown cannot reach it.
    if command -v daemon >/dev/null 2>&1; then
        echo "    issuing: daemon -f /bin/sh -c 'sleep 5; /sbin/shutdown -r now'"
        daemon -f /bin/sh -c 'sleep 5; /sbin/shutdown -r now'
        _drc=$?
    else
        echo "    daemon(8) not found, falling back to nohup"
        nohup /bin/sh -c 'sleep 5; /sbin/shutdown -r now' </dev/null >/dev/null 2>&1 &
        _drc=$?
    fi

    if [ "$_drc" -ne 0 ]; then
        say "Could not detach the reboot (exit $_drc). Rebooting in the foreground."
        /sbin/shutdown -r now
        exit 64
    fi

    # Confirm something is actually queued before claiming success.
    sleep 1
    if pgrep -f 'sleep 5' >/dev/null 2>&1 || pgrep shutdown >/dev/null 2>&1; then
        echo "    reboot is queued, going down in about 5 seconds"
    else
        say "Nothing queued. Rebooting in the foreground instead."
        /sbin/shutdown -r now
    fi
    exit 64
}

maybe_reboot() {
    _level="$1"
    _why="$2"
    echo ""
    if [ "$_level" = "required" ]; then
        say "REBOOT REQUIRED: $_why"
    else
        say "Reboot recommended: $_why"
    fi
    case "$REBOOT_MODE" in
        yes) do_reboot ;;
        no)  echo "    Not rebooting. Run later: /sbin/shutdown -r now"; return 0 ;;
    esac
    printf "    Reboot now? [y/N] "
    read _ans
    case "$_ans" in
        y|Y|yes|YES|j|J|ja) do_reboot ;;
        *) echo "    Skipped. Run later: /sbin/shutdown -r now" ;;
    esac
}

# Drop any pkg_env block plus our marker line, print the rest.
strip_pkg_env() {
    awk '
        $0 ~ /managed by Update-Opnsense\.ps1/ { next }
        skip == 1 {
            o = gsub(/\{/, "{"); c = gsub(/\}/, "}")
            depth += o - c
            if (depth <= 0) skip = 0
            next
        }
        $0 ~ /^[[:space:]]*pkg_env[[:space:]]*:/ {
            skip = 1; depth = 0
            o = gsub(/\{/, "{"); c = gsub(/\}/, "}")
            depth += o - c
            if (depth <= 0) skip = 0
            next
        }
        { print }
    ' "$1"
}

disable_kmods() {
    cat > "$KMOD_OFF" <<'KEOF'
FreeBSD-kmods: {
  enabled: no
}
KEOF
    say "Disabled the FreeBSD-kmods repository via $KMOD_OFF"
}

# Run a firmware command with live output, capture its real exit code, and
# retry once without the FreeBSD-kmods repo if that is what broke it. That
# repo's URL is built from the running FreeBSD minor version and 404s on the
# mirror for some versions; it is not needed for an OPNsense upgrade.
run_firmware() {
    _cmd="$1"
    _out="/tmp/opn-fw.out"
    { eval "$_cmd"; echo "__RC=$?" > /tmp/opn-fw.rc; } 2>&1 | tee "$_out"
    _rc=$(sed -n 's/^__RC=//p' /tmp/opn-fw.rc 2>/dev/null)
    [ -n "$_rc" ] || _rc=1

    if [ "$_rc" -ne 0 ] && grep -q 'repository FreeBSD-kmods' "$_out"; then
        echo ""
        say "Failed on the FreeBSD-kmods repository. Disabling it and retrying once."
        if [ -f "$KMOD_OFF" ]; then
            die "kmods is already disabled and it still failed. Read the output above."
        fi
        disable_kmods
        pkg update -f >/dev/null 2>&1
        echo ""
        say "Retrying: $_cmd"
        { eval "$_cmd"; echo "__RC=$?" > /tmp/opn-fw.rc; } 2>&1 | tee "$_out"
        _rc=$(sed -n 's/^__RC=//p' /tmp/opn-fw.rc 2>/dev/null)
        [ -n "$_rc" ] || _rc=1
    fi
    return "$_rc"
}

# ------------------------------------------------------------- clean -------
if [ "$ACTION" = "clean" ]; then
    [ -f "$CONFIGD_FILE" ] && rm -f "$CONFIGD_FILE" && say "removed $CONFIGD_FILE"
    [ -f "$KMOD_OFF" ] && rm -f "$KMOD_OFF" && say "removed $KMOD_OFF (kmods repo re-enabled)"
    if [ -f "$PKG_CONF" ] && grep -q 'pkg_env' "$PKG_CONF"; then
        strip_pkg_env "$PKG_CONF" > "$PKG_CONF.new" && mv "$PKG_CONF.new" "$PKG_CONF"
        say "removed pkg_env from $PKG_CONF"
    fi
    service configd restart >/dev/null 2>&1
    say "proxy configuration removed"
    exit 0
fi

# ------------------------------------------------------------ reboot -------
if [ "$ACTION" = "reboot" ]; then
    if _why=$(reboot_reason); then
        say "A reboot is pending: $_why"
    else
        say "No pending reboot was detected, but you asked for one."
    fi
    if [ "$REBOOT_MODE" = "no" ]; then
        REBOOT_MODE="ask"
    fi
    maybe_reboot required "requested"
    exit 0
fi

# --------------------------------------------------------------- log -------
if [ "$ACTION" = "log" ]; then
    rule "state"
    print_state
    rule "/var/cache/opnsense-update/.upgrade.log"
    cat /var/cache/opnsense-update/.upgrade.log 2>/dev/null || echo "(not present)"
    rule "everything in /usr/local/opnsense/version/"
    ls -la /usr/local/opnsense/version/ 2>/dev/null
    for f in /usr/local/opnsense/version/*; do
        case "$f" in *.mtree) continue ;; esac
        [ -f "$f" ] || continue
        echo "--- $f"
        head -c 2000 "$f"
        echo
    done
    rule "installed opnsense packages"
    pkg info -x '^opnsense|^os-' 2>&1
    rule "does the next series exist on the mirror?"
    for _s in $(next_series "$(repo_series)") ; do
        for _p in "latest" "aux" ""; do
            _u="https://pkg.opnsense.org/$ABI/$_s${_p:+/$_p}/meta.conf"
            printf '%s -> ' "$_u"
            if fetch -q -T 15 -o /dev/null "$_u" 2>/dev/null; then echo "OK"; else echo "not found"; fi
        done
        printf 'sets: https://pkg.opnsense.org/%s/%s/sets/ -> ' "$ABI" "$_s"
        if fetch -q -T 15 -o /dev/null "https://pkg.opnsense.org/$ABI/$_s/sets/" 2>/dev/null; then echo "OK"; else echo "not found"; fi
    done
    rule "what the GUI reads: cached firmware status"
    ls -la /tmp/pkg_upgrade.progress /var/cache/opnsense-update/.upgrade.log 2>/dev/null
    configctl firmware product 2>&1 | head -30
    rule "pkg repo config files"
    for f in /usr/local/etc/pkg/repos/*.conf; do
        echo "--- $f"
        cat "$f"
    done
    rule "all configured repos"
    pkg -vv 2>/dev/null | sed -n '/^Repositories:/,$p'
    rule "opnsense-update generated repo configs"
    for d in /var/cache/opnsense-update/*/; do
        [ -f "$d/OPNsense.conf" ] || continue
        echo "--- $d"
        cat "$d/OPNsense.conf"
    done
    rule "packages from FreeBSD-kmods"
    pkg query -e '%R == "FreeBSD-kmods"' '%n-%v' 2>/dev/null || echo "(none)"
    rule "end"
    exit 0
fi

# -------------------------------------------------------------- diag -------
if [ "$ACTION" = "diag" ]; then
    rule "state"
    print_state
    rule "pkg binaries and versions"
    echo "pkg        : $(command -v pkg)  -> $(pkg -v 2>&1)"
    echo "pkg-static : $(command -v pkg-static)  -> $(pkg-static -v 2>&1)"
    pkg info -x '^pkg' 2>&1
    rule "kernel messages about signal 11 / core dumps"
    dmesg 2>/dev/null | grep -i -E 'signal 11|core dumped' | tail -20
    echo "(if empty, nothing was logged this boot)"
    rule "core files"
    sysctl kern.corefile 2>/dev/null
    ls -la /*.core /var/crash/*.core 2>/dev/null | head -10
    rule "opnsense tools present"
    ls -la /usr/local/sbin/opnsense-* 2>/dev/null
    rule "opnsense-update option parsing (from the script itself)"
    grep -n 'while getopts' -A 60 /usr/local/sbin/opnsense-update 2>/dev/null | head -80
    rule "how opnsense-update builds the repo config"
    grep -n 'OPNsense.conf\|product_abi\|repos' /usr/local/sbin/opnsense-update 2>/dev/null | head -30
    rule "update cache"
    ls -laR /var/cache/opnsense-update/ 2>/dev/null | head -40
    rule "end"
    exit 0
fi

# ------------------------------------------------------------ status -------
if [ "$ACTION" = "status" ]; then
    say "Current state"
    print_state
    echo ""
    say "What is available"
    CURSER=$(repo_series)
    if pkg update -q >/dev/null 2>&1; then
        UPOUT=$(pkg upgrade -n 2>&1)
        if echo "$UPOUT" | grep -qi 'your packages are up to date'; then
            echo "    updates within $CURSER : none"
        else
            NUP=$(echo "$UPOUT" | sed -n 's/^Number of packages to be upgraded: *\([0-9]*\).*/\1/p')
            NIN=$(echo "$UPOUT" | sed -n 's/^Number of packages to be installed: *\([0-9]*\).*/\1/p')
            echo "    updates within $CURSER : ${NUP:-0} to upgrade, ${NIN:-0} new  -> use the update action"
        fi
    else
        echo "    updates within $CURSER : could not check (no repository access)"
    fi
    if _why=$(reboot_reason); then
        echo "    pending reboot       : YES, $_why"
    else
        echo "    pending reboot       : none detected"
    fi
    NEXT=$(core_next)
    [ -n "$NEXT" ] || NEXT=$(next_series "$CURSER")
    series_exists "$NEXT"
    case $? in
        0) echo "    next major release   : $NEXT is available  -> use the upgrade action" ;;
        1) echo "    next major release   : $NEXT is not published yet" ;;
        *) echo "    next major release   : $NEXT, could not check the mirror"
           echo "                           $SERIES_ERR" ;;
    esac
    echo ""
    rule "pkg repositories"
    pkg -vv 2>/dev/null | grep -A6 '^  OPNsense'
    rule "pkg config pkg_env"
    pkg config pkg_env 2>&1
    rule "configd proxy drop-in"
    cat "$CONFIGD_FILE" 2>/dev/null || echo "(not present)"
    rule "kmods repo override"
    cat "$KMOD_OFF" 2>/dev/null || echo "(not present, repo enabled)"
    rule "end"
    exit 0
fi

# --------------------------------------------------------- pre-flight ------
say "Pre-flight checks"

netstat -rn -f inet | grep -q '^default' || \
    die "no IPv4 default route. A proxy cannot fix a missing gateway."

_hp="${PROXY#http://}"; _hp="${_hp%%/*}"
PROXY_HOST="${_hp%%:*}"
PROXY_PORT="${_hp##*:}"
[ "$PROXY_PORT" = "$PROXY_HOST" ] && PROXY_PORT=80

case "$PROXY_HOST" in
    *[!0-9.]*)
        getent hosts "$PROXY_HOST" >/dev/null 2>&1 || \
            die "cannot resolve $PROXY_HOST. Use the proxy IP instead: with an
     HTTP proxy the firewall needs no working DNS of its own."
        echo "    DNS ok: $PROXY_HOST" ;;
    *)
        echo "    DNS skipped: $PROXY_HOST is a literal IP" ;;
esac

nc -z -w 5 "$PROXY_HOST" "$PROXY_PORT" >/dev/null 2>&1 || \
    die "cannot open TCP $PROXY_HOST:$PROXY_PORT"
echo "    TCP ok: $PROXY_HOST:$PROXY_PORT"

read_state
[ -n "$ABI" ] && [ -n "$S_ABI" ] || die "could not determine ABI or version"

say "Testing an HTTPS fetch through the proxy"
TEST_URL="https://pkg.opnsense.org/$ABI/$(repo_series)/latest/meta.conf"
fetch -q -T 25 -o /dev/null "$TEST_URL" || \
    die "proxy answers on TCP but the HTTPS fetch failed.
     Likely TLS interception, or pkg.opnsense.org not allowed.
     Test by hand: fetch -v -o - $TEST_URL"
echo "    fetch ok: $TEST_URL"

# ----------------------------------------------------------- pkg.conf ------
# Fully managed: an existing pkg_env block is replaced, never appended to.
# Two blocks break UCL parsing, and a partial block (http_proxy only) fails
# silently on the https repository URL.
say "Configuring $PKG_CONF"
[ -f "$PKG_CONF" ] || : > "$PKG_CONF"
[ -f "$PKG_CONF.orig" ] || cp "$PKG_CONF" "$PKG_CONF.orig"

strip_pkg_env "$PKG_CONF" > "$PKG_CONF.new"
cat >> "$PKG_CONF.new" <<EOF

$MARK
pkg_env: {
  http_proxy: "$PROXY",
  https_proxy: "$PROXY",
  no_proxy: "$NOPROXY"
}
EOF
mv "$PKG_CONF.new" "$PKG_CONF"
echo "    pkg_env written:"
pkg config pkg_env 2>&1 | sed 's/^/      /'

# ------------------------------------------------------------ configd ------
# Drop-in, not configd.conf itself: the base file is vendor shipped and is
# overwritten by every core update, which silently drops the proxy again.
say "Configuring $CONFIGD_FILE"
mkdir -p "$CONFIGD_DIR"
cat > "$CONFIGD_FILE" <<EOF
[environment]
HTTP_PROXY=$PROXY
HTTPS_PROXY=$PROXY
http_proxy=$PROXY
https_proxy=$PROXY
NO_PROXY=$NOPROXY
EOF
echo "    written"

say "Restarting configd"
service configd restart || die "configd failed to restart"

say "Refreshing the package catalogue"
pkg update -f || die "pkg update failed even with the proxy set"

echo ""
say "Proxy is working. State:"
print_state

# ------------------------------------------------------------- actions -----
if [ "$ACTION" = "configure" ]; then
    echo ""
    say "Firmware untouched. Next: status, update or upgrade."
    exit 0
fi

if [ "$ACTION" = "update" ]; then
    if [ "$S_SETS" != "$S_ABI" ]; then
        die "refusing to run an in-series update.
     Base/kernel are on $S_SETS while core is on $S_ABI. An in-series update
     would DOWNGRADE base and kernel back to $S_ABI. Use the upgrade action
     to finish the stalled move to $S_SETS instead."
    fi
    echo ""
    say "Running: $UPDATE_CMD"
    run_firmware "$UPDATE_CMD"
    RC=$?
    echo ""
    if [ "$RC" -eq 0 ] && grep -qi 'nothing to do' /tmp/opn-fw.out && \
       ! grep -qiE '^\[[0-9]+/[0-9]+\]' /tmp/opn-fw.out; then
        say "Already up to date on $S_ABI. Nothing was changed."
        exit 0
    fi
    say "Done."
    if [ "$RC" -eq 0 ]; then
        if _why=$(reboot_reason); then
            maybe_reboot required "$_why"
        elif grep -qiE '^\[[0-9]+/[0-9]+\] (Upgrading|Installing|Reinstalling)' /tmp/opn-fw.out; then
            maybe_reboot recommended "packages were replaced under running services"
        fi
    fi
    exit "$RC"
fi

if [ "$ACTION" = "series" ]; then
    TARGET="$ARGVERSION"
    [ -n "$TARGET" ] || TARGET="$S_SETS"
    [ -n "$TARGET" ] || die "no target series given, e.g. 26.1"
    CUR=$(repo_series)
    [ -n "$CUR" ] || die "could not read the current series from $OPN_REPO"

    if [ "$CUR" = "$TARGET" ]; then
        say "Repository is already on $TARGET, nothing to change."
    else
        say "Repointing the package repository from $CUR to $TARGET"
        [ -f "$OPN_REPO.orig" ] || cp "$OPN_REPO" "$OPN_REPO.orig"
        sed -i '' "s|/$CUR/latest|/$TARGET/latest|g" "$OPN_REPO"
        echo "    now: $(repo_series)   (backup at $OPN_REPO.orig)"
    fi

    say "Refreshing the catalogue against $TARGET"
    pkg update -f || die "pkg update failed against the $TARGET repository"
    echo ""
    say "Upgrade candidates:"
    pkg upgrade -n 2>&1 | tail -20
    echo ""
    say "If that list looks right, run the upgrade action next."
    exit 0
fi

if [ "$ACTION" = "pkgs" ]; then
    # opnsense-update reads VERSIONDIR/pkgs as INSTALLED_PKGS and only rewrites
    # it after its own package phase completes. Repointing the repository by
    # hand never reaches that line, so the file can be left claiming an old
    # series while the packages themselves have long since moved on. The GUI
    # believes the file, which is where phantom "packages 25.1 -> next" rows
    # come from.
    PKGSFILE="/usr/local/opnsense/version/pkgs"
    RECORDED=$(cat "$PKGSFILE" 2>/dev/null)
    REAL=$(repo_series)

    say "Package bookkeeping"
    echo "    $PKGSFILE says   : ${RECORDED:-(missing)}"
    echo "    repository is on : $REAL"
    echo "    core is on       : $S_ABI"

    if [ -f "$PKGSFILE.lock" ]; then
        say "A stale $PKGSFILE.lock is present."
        ls -la "$PKGSFILE.lock"
        echo "    opnsense-update takes that to mean a package phase is running."
        rm -f "$PKGSFILE.lock" && echo "    removed."
    fi

    # Only claim a series the installed packages actually agree with.
    if [ "$REAL" != "$S_ABI" ]; then
        die "repository ($REAL) and core ($S_ABI) disagree.
     Not writing anything. Sort the repository series out first."
    fi
    if ! pkg upgrade -n 2>&1 | grep -qi 'your packages are up to date'; then
        die "there are pending package upgrades against $REAL.
     Not writing anything: run the update action first, so the recorded
     series matches what is really installed."
    fi

    if [ "$RECORDED" = "$REAL" ]; then
        say "Already correct, nothing to do."
        exit 0
    fi

    say "Recording $REAL (packages verified up to date against that repository)"
    [ -f "$PKGSFILE" ] && cp "$PKGSFILE" "$PKGSFILE.before"
    echo "$REAL" > "$PKGSFILE"
    echo "    $PKGSFILE now says: $(cat "$PKGSFILE")"

    say "Refreshing the firmware status the GUI reads"
    configctl firmware check >/dev/null 2>&1 && echo "    done"
    echo ""
    say "Reload System > Firmware in the GUI. The Updates tab should be empty."
    exit 0
fi

if [ "$ACTION" = "pkgboot" ]; then
    TARGET="$ARGVERSION"
    [ -n "$TARGET" ] || TARGET="$S_SETS"
    [ -n "$TARGET" ] || die "no target version given, e.g. 25.7"
    REPO="https://pkg.opnsense.org/$ABI/$TARGET/latest"
    WORK="/tmp/pkgboot.$$"

    say "Bootstrapping pkg from $REPO"
    echo "    Current: $(pkg-static -v 2>/dev/null)"
    mkdir -p "$WORK" || die "cannot create $WORK"
    cd "$WORK" || die "cannot enter $WORK"

    fetch -T 60 -o packagesite.pkg "$REPO/packagesite.pkg" || die "cannot fetch the package index"
    tar -xf packagesite.pkg || die "cannot extract the package index"
    [ -f packagesite.yaml ] || die "packagesite.yaml not found in the index"

    LINE=$(grep '"name":"pkg",' packagesite.yaml | head -1)
    [ -n "$LINE" ] || die "no pkg package found in the $TARGET index"
    REPOPATH=$(echo "$LINE" | sed -n 's/.*"repopath":"\([^"]*\)".*/\1/p')
    NEWVER=$(echo "$LINE" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
    [ -n "$REPOPATH" ] || die "could not read repopath for pkg"
    say "Target pkg version: $NEWVER  ($REPOPATH)"

    fetch -T 120 -o newpkg.pkg "$REPO/$REPOPATH" || die "cannot fetch $REPOPATH"

    # A .pkg is a tarball with the metadata members prefixed by + or . and the
    # payload under usr/local. Extracting it by hand is the standard way to
    # replace a pkg that is too broken to upgrade itself.
    say "Installing the new pkg binaries"
    cp -p /usr/local/sbin/pkg-static "/usr/local/sbin/pkg-static.before-$TARGET" 2>/dev/null
    tar -xf newpkg.pkg -C / --exclude '+*' --exclude '.pkg*' || die "extraction failed"

    say "Verifying"
    NOWVER=$(/usr/local/sbin/pkg-static -v 2>&1)
    echo "    pkg-static now reports: $NOWVER"

    say "Registering it in the package database"
    /usr/local/sbin/pkg-static add -f newpkg.pkg 2>&1 | sed 's/^/      /'

    say "Smoke test: pkg-static update"
    /usr/local/sbin/pkg-static update -f 2>&1 | tail -10
    RC=$?
    cd / && rm -rf "$WORK"
    echo ""
    if [ "$RC" -eq 0 ]; then
        say "pkg looks healthy. Now run the upgrade action."
    else
        say "pkg still returns $RC. Check the output above before going further."
    fi
    exit "$RC"
fi

if [ "$ACTION" = "upgrade" ]; then
    TARGET="$ARGVERSION"
    CURSER=$(repo_series)

    if [ -z "$TARGET" ]; then
        if [ "$S_SETS" != "$S_ABI" ]; then
            TARGET="$S_SETS"
            say "Finishing the stalled upgrade to $TARGET."
        else
            TARGET=$(core_next)
            [ -n "$TARGET" ] || TARGET=$(next_series "$CURSER")
            say "Currently on $CURSER. The next release would be $TARGET."
            series_exists "$TARGET"
            case $? in
                0) echo "    $TARGET is available." ;;
                1) say "$TARGET is not published yet, so there is nothing to upgrade to."
                   echo "    Use the update action for updates within $CURSER."
                   exit 0 ;;
                *) say "Could not reach the mirror to confirm $TARGET:"
                   echo "    $SERIES_ERR"
                   echo "    Continuing anyway. opnsense-update will fail cleanly if it"
                   echo "    really is not there, and a failed check is not proof of absence." ;;
            esac
        fi
    fi
    [ -n "$TARGET" ] || die "no target version given, e.g. 26.1"

    # opnsense-update does not repoint the repository: the series is hardcoded
    # in OPNsense.conf and copied verbatim into its temporary config. Without
    # this it would fetch nothing and report "Nothing to do".
    if [ -n "$CURSER" ] && [ "$CURSER" != "$TARGET" ]; then
        say "Repointing the repository from $CURSER to $TARGET"
        [ -f "$OPN_REPO.orig" ] || cp "$OPN_REPO" "$OPN_REPO.orig"
        sed -i '' "s|/$CURSER/latest|/$TARGET/latest|g" "$OPN_REPO"
        pkg update -f >/dev/null 2>&1 || die "no repository access for $TARGET"
        echo "    repository now on $(repo_series)"
    fi

    echo ""
    say "What this will change:"
    pkg upgrade -n 2>&1 | tail -8 | sed 's/^/    /'

    CMD=$(echo "$UPGRADE_CMD" | sed "s/%VERSION%/$TARGET/")
    echo ""
    say "Running: $CMD"
    echo ""
    run_firmware "$CMD"
    RC=$?
    echo ""
    if [ "$RC" -eq 0 ]; then
        if grep -qi 'nothing to do' /tmp/opn-fw.out && \
           ! grep -qiE '^\[[0-9]+/[0-9]+\] (Upgrading|Installing|Extracting)' /tmp/opn-fw.out; then
            say "WARNING: the command succeeded but changed nothing."
            echo "    Do not reboot expecting a difference. Check the repository series."
        else
            say "New state:"
            print_state
            if _why=$(reboot_reason); then
                maybe_reboot required "$_why"
            else
                maybe_reboot recommended "core packages were replaced under running services"
            fi
        fi
    fi
    exit "$RC"
fi

if [ "$ACTION" = "bootstrap" ]; then
    TARGET="$ARGVERSION"
    [ -n "$TARGET" ] || TARGET="$S_SETS"
    [ -n "$TARGET" ] || die "no target version given, e.g. 25.7"
    command -v opnsense-bootstrap >/dev/null 2>&1 || \
        die "opnsense-bootstrap is not present on this system."
    CMD=$(echo "$BOOTSTRAP_CMD" | sed "s/%VERSION%/$TARGET/")
    echo ""
    say "Running: $CMD"
    echo "    This reinstalls every OPNsense package from the $TARGET repository."
    echo "    config.xml is preserved. Everything else is replaced."
    echo ""
    run_firmware "$CMD"
    exit $?
fi

die "unknown action: $ACTION"
'@
# ==================================================== end embedded shell ====

# --- host discovery --------------------------------------------------------
# In this deployment pattern OPNsense is always the DHCP server, so the DHCP
# server address of the active adapter IS the firewall. One candidate means no
# question and no probing. The default gateway is only consulted as a fallback
# for a machine with a static address, and is labelled as such.
function Get-AdapterAddresses {
    param([ValidateSet("dhcp", "gateway")][string]$Kind)
    $map = @{}
    $order = @()
    try {
        $cfgs = Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration `
                    -Filter "IPEnabled=TRUE" -ErrorAction Stop
    } catch { return @() }

    foreach ($cfg in $cfgs) {
        $addrs = if ($Kind -eq "dhcp") { @($cfg.DHCPServer) } else { @($cfg.DefaultIPGateway) }
        foreach ($addr in $addrs) {
            if (-not $addr) { continue }
            if ($addr.Contains(":")) { continue }
            if ($addr -eq "0.0.0.0" -or $addr -eq "255.255.255.255") { continue }
            if ($addr.StartsWith("169.254.")) { continue }
            if ($map.ContainsKey($addr)) { continue }
            $map[$addr] = [pscustomobject]@{
                Address = $addr
                Adapter = $cfg.Description
                Ssh     = $false
            }
            $order += $addr
        }
    }
    return @($order | ForEach-Object { $map[$_] })
}

function Test-SshPort {
    param([string]$Address, [int]$TimeoutMs = 1200)
    try {
        $client = New-Object System.Net.Sockets.TcpClient
        $async  = $client.BeginConnect($Address, 22, $null, $null)
        $opened = $async.AsyncWaitHandle.WaitOne($TimeoutMs, $false)
        $result = $opened -and $client.Connected
        $client.Close()
        return $result
    } catch { return $false }
}

function Select-FromCandidates {
    param([array]$List, [string]$Label)
    foreach ($c in $List) { $c.Ssh = Test-SshPort $c.Address }
    $live = @($List | Where-Object { $_.Ssh })
    if ($live.Count -eq 1) {
        Write-Host ("  Found {0} ({1}, SSH open)" -f $live[0].Address, $Label) -ForegroundColor Green
        return $live[0].Address
    }

    $show = if ($live.Count -gt 1) { $live } else { $List }
    Write-Host ""
    for ($i = 0; $i -lt $show.Count; $i++) {
        $c = $show[$i]
        $ssh = if ($c.Ssh) { "SSH open" } else { "no SSH" }
        Write-Host ("   {0}) {1,-15} {2,-10} {3}" -f ($i + 1), $c.Address, $ssh, $c.Adapter)
    }
    Write-Host "   m) enter an address manually"
    Write-Host ""
    $pick = (Read-Host "  Which host? [1]").Trim()
    if (-not $pick) { $pick = "1" }
    if ($pick -eq "m") {
        $manual = (Read-Host "  OPNsense IP or hostname").Trim()
        if (-not $manual) { Fail "no host given." }
        return $manual
    }
    $n = 0
    if ([int]::TryParse($pick, [ref]$n) -and $n -ge 1 -and $n -le $show.Count) {
        return $show[$n - 1].Address
    }
    Fail "invalid choice: $pick"
}

function Resolve-OpnsenseHost {
    $dhcp = Get-AdapterAddresses -Kind dhcp

    if ($dhcp.Count -eq 1) {
        Write-Host ("  Found {0} (DHCP server)" -f $dhcp[0].Address) -ForegroundColor Green
        return $dhcp[0].Address
    }
    if ($dhcp.Count -gt 1) {
        Write-Host "  Several adapters have a DHCP server." -ForegroundColor DarkGray
        return (Select-FromCandidates -List $dhcp -Label "DHCP server")
    }

    Write-Host "  No DHCP server on any adapter (static address?)." -ForegroundColor Yellow
    $gw = Get-AdapterAddresses -Kind gateway
    if ($gw.Count -eq 1) {
        Write-Host ("  Falling back to the default gateway: {0}" -f $gw[0].Address) -ForegroundColor Yellow
        Write-Host "  Verify this really is the firewall before running anything." -ForegroundColor Yellow
        return $gw[0].Address
    }
    if ($gw.Count -gt 1) {
        Write-Host "  Falling back to the default gateways." -ForegroundColor Yellow
        return (Select-FromCandidates -List $gw -Label "gateway")
    }

    $manual = (Read-Host "  Nothing detected. OPNsense IP or hostname").Trim()
    if (-not $manual) { Fail "no host given." }
    return $manual
}

# --- prerequisites ---------------------------------------------------------
if (-not (Get-Command ssh -ErrorAction SilentlyContinue)) {
    Fail "ssh not found. Install the OpenSSH Client via Settings > Apps > Optional Features."
}

Write-Host ""
Write-Host "  OPNsense update via proxy" -ForegroundColor Cyan
Write-Host "  -------------------------" -ForegroundColor Cyan

if (-not $OpnsenseHost) { $OpnsenseHost = Resolve-OpnsenseHost }

$Interactive = -not $Action

if (-not $Action) {
    $retry = $true
    while ($retry) {
        $retry = $false
        Write-Host ""
        Write-Host ("  Host: {0}    Proxy: {1}" -f $OpnsenseHost, $ProxyUrl) -ForegroundColor DarkGray
        Write-Host ""
        Write-Host "  Normal use" -ForegroundColor Cyan
        Write-Host "   1) status     which version it runs and what is available"
        Write-Host "   2) update     install updates within the current release"
        Write-Host "   3) upgrade    move up to the next major release"
        Write-Host "   r) reboot     reboot the firewall"
        Write-Host ""
        Write-Host "  Diagnostics (read-only)" -ForegroundColor DarkGray
        Write-Host "   4) log        upgrade log and repository definitions"
        Write-Host "   5) diag       pkg versions, crash messages, available tools"
        Write-Host ""
        Write-Host "  Repair and maintenance" -ForegroundColor DarkGray
        Write-Host "   6) configure  (re)apply the proxy settings only"
        Write-Host "   7) series     repoint the repository at a specific release"
        Write-Host "   8) pkgs       fix the recorded package series the GUI reads"
        Write-Host "   9) pkgboot    replace a broken pkg with the target release build"
        Write-Host "   0) clean      remove the proxy configuration again"
        Write-Host "   B) bootstrap  reinstall every package from a release"
        Write-Host ""
        Write-Host "   h) host       target a different firewall"
        Write-Host "   x) proxy      use a different proxy URL"
        Write-Host ""
        $choice = (Read-Host "  Choice [1]").Trim()
        if (-not $choice) { $choice = "1" }
        switch ($choice) {
            "1" { $Action = "status" }
            "2" { $Action = "update" }
            "3" { $Action = "upgrade" }
            "4" { $Action = "log" }
            "5" { $Action = "diag" }
            "6" { $Action = "configure" }
            "7" { $Action = "series" }
            "8" { $Action = "pkgs" }
            "9" { $Action = "pkgboot" }
            "B" { $Action = "bootstrap" }
            "b" { $Action = "bootstrap" }
            "0" { $Action = "clean" }
            "r" { $Action = "reboot" }
            "h" { $OpnsenseHost = Resolve-OpnsenseHost; $retry = $true }
            "x" {
                $np = (Read-Host "  Proxy URL [$ProxyUrl]").Trim()
                if ($np) { $ProxyUrl = $np }
                $retry = $true
            }
            default { Fail "invalid choice: $choice" }
        }
    }
}

if ($Action -eq "series" -and -not $TargetVersion) {
    $TargetVersion = (Read-Host "  Target series (blank = use the installed sets version)").Trim()
}

if ($Action -eq "pkgboot" -and -not $TargetVersion) {
    $TargetVersion = (Read-Host "  Target version (blank = use the installed sets version)").Trim()
}

if ($Action -eq "bootstrap") {
    if (-not $TargetVersion) {
        $TargetVersion = (Read-Host "  Target version (blank = use the installed sets version)").Trim()
    }
    Write-Host ""
    Write-Host "  BOOTSTRAP on $OpnsenseHost reinstalls every package. config.xml survives." -ForegroundColor Yellow
    Write-Host "  Do not run this without a snapshot and console access." -ForegroundColor Yellow
    if ((Read-Host "  Type BOOTSTRAP to continue") -cne "BOOTSTRAP") { Fail "aborted." }
}

if ($Action -eq "upgrade") {
    Write-Host ""
    Write-Host "  MAJOR UPGRADE on $OpnsenseHost." -ForegroundColor Yellow
    Write-Host "  The next release is selected automatically and the package list is" -ForegroundColor Yellow
    Write-Host "  shown before anything is installed." -ForegroundColor Yellow
    Write-Host "  Take a config backup and a VM snapshot, and have console access." -ForegroundColor Yellow
    if ((Read-Host "  Type UPGRADE to continue") -cne "UPGRADE") { Fail "aborted." }
}

# --- reboot mode -----------------------------------------------------------
# ask  : the shell prompts on the firewall, inside the same session
# yes  : reboot without asking
# no   : never reboot, just report
if     ($NoReboot)   { $RebootMode = "no" }
elseif ($Reboot)     { $RebootMode = "yes" }
elseif ($Interactive) { $RebootMode = "ask" }
else                 { $RebootMode = "no" }

# --- build the payload -----------------------------------------------------
$shell = $Shell -replace "`r`n", "`n"
$shell = $shell.Replace("__PROXY__", $ProxyUrl).
                Replace("__UPDATE_CMD__", $UpdateCmd).
                Replace("__UPGRADE_CMD__", $UpgradeCmd).
                Replace("__BOOTSTRAP_CMD__", $BootstrapCmd)

$bytes = [System.Text.Encoding]::UTF8.GetBytes($shell)
$ms    = New-Object System.IO.MemoryStream
$gz    = New-Object System.IO.Compression.GZipStream($ms, [System.IO.Compression.CompressionMode]::Compress)
$gz.Write($bytes, 0, $bytes.Length)
$gz.Dispose()
$b64 = [Convert]::ToBase64String($ms.ToArray())

# Base64 contains only A-Za-z0-9+/= so it needs no shell quoting.
# The arguments are quoted so an empty TargetVersion does not shift $3.
$remote = "printf %s $b64 | openssl base64 -d -A | gzip -d > /tmp/opn-update.sh && " +
          "sh /tmp/opn-update.sh '$Action' '$TargetVersion' '$RebootMode'"

Write-Host ""
Write-Host ("  Action: {0}   Host: {1}   Proxy: {2}" -f $Action, $OpnsenseHost, $ProxyUrl) -ForegroundColor Cyan
Write-Host ("  " + ("-" * 68))

& ssh -t "$SshUser@$OpnsenseHost" $remote
$rc = $LASTEXITCODE

Write-Host ("  " + ("-" * 68))

# --- result ----------------------------------------------------------------
if ($rc -eq 64) {
    Write-Host "  Reboot started. Waiting for the firewall to come back..." -ForegroundColor Yellow
    Start-Sleep -Seconds 20
    $deadline = (Get-Date).AddMinutes(6)
    $back = $false
    while ((Get-Date) -lt $deadline) {
        if (Test-SshPort -Address $OpnsenseHost -TimeoutMs 2000) { $back = $true; break }
        Write-Host "." -NoNewline
        Start-Sleep -Seconds 5
    }
    Write-Host ""
    if ($back) {
        Write-Host "  Back up and answering on SSH. Run the status action to verify." -ForegroundColor Green
        exit 0
    }
    Write-Host "  No SSH after six minutes. Check the console." -ForegroundColor Red
    exit 1
}

if ($rc -eq 0) {
    Write-Host "  Completed successfully." -ForegroundColor Green
    switch ($Action) {
        "status"    { Write-Host "  Nothing was changed." -ForegroundColor Green }
        "log"       { Write-Host "  Nothing was changed." -ForegroundColor Green }
        "diag"      { Write-Host "  Nothing was changed." -ForegroundColor Green }
        "configure" { Write-Host "  Proxy is set. Re-run for status, update or upgrade." -ForegroundColor Green }
        "update"    { }
        "upgrade"   { }
        "reboot"    { Write-Host "  No reboot was performed." -ForegroundColor Green }
        "series"    { Write-Host "  Now run the upgrade action." -ForegroundColor Green }
        "pkgboot"   { Write-Host "  Now run the upgrade action." -ForegroundColor Green }
        "pkgs"      { Write-Host "  Reload System > Firmware in the GUI." -ForegroundColor Green }
        "bootstrap" { Write-Host "  Reboot and check with the status action." -ForegroundColor Green }
        "clean"     { Write-Host "  Proxy configuration removed." -ForegroundColor Green }
    }
    exit 0
}

if ($rc -eq 255) {
    Write-Host "  SSH connection dropped (exit 255)." -ForegroundColor Yellow
    Write-Host "  Expected if the firewall rebooted. Reconnect and run the status action." -ForegroundColor Yellow
    exit 255
}

Write-Host "  Remote script exited with code $rc. The reason is in the output above." -ForegroundColor Red
exit $rc

Usage:
<# .SYNOPSIS Configures an outbound HTTP proxy on an OPNsense firewall and drives its updates and major upgrades, entirely over one SSH session. .DESCRIPTION Self-contained: the shell script is embedded below and shipped to the firewall gzip+base64 encoded as part of the SSH command, so there is no second file, no scp, and only one password prompt. Run it with no parameters. It finds the firewall via the DHCP server address of the active adapter and goes straight to the menu. Requires the Windows OpenSSH Client. For root login, OPNsense needs System > Settings > Administration > "Permit root user login" enabled. .PARAMETER Action status : which version it runs and what is available update : install updates within the current release upgrade : move to the next major release; picks and prepares it itself log : upgrade log and repository definitions diag : pkg versions, crash messages, available tools configure : (re)apply the proxy settings only series : repoint the repository at a specific release pkgs : fix the recorded package series the GUI reads pkgboot : replace a broken pkg with the target release build bootstrap : reinstall every package from a release reboot : reboot the firewall clean : remove the proxy configuration again .EXAMPLE .\Update-Opnsense.ps1 .\Update-Opnsense.ps1 -OpnsenseHost 172.17.238.180 -Action status .\Update-Opnsense.ps1 -OpnsenseHost 172.17.238.180 -Action upgrade #>

Fix ISC DHCP:

mkdir -p /var/dhcpd/dev
mount -t devfs devfs /var/dhcpd/dev
configctl dhcpd restart

Fix Github config back-up:

pkg install -y socat
echo 'Host github.com' > /root/.ssh/config
echo '  Hostname ssh.github.com' >> /root/.ssh/config
echo '  Port 443' >> /root/.ssh/config
echo '  User git' >> /root/.ssh/config
echo '  ProxyCommand socat - PROXY:proxy.ezorg.nl:%h:%p,proxyport=8080' >> /root/.ssh/config
chmod 600 /root/.ssh/config