#!/bin/sh
# shellcheck shell=dash
# oaf-update.sh — Nightly updater for OAF and family-safe packages
#
# Deployed to the router at /usr/sbin/oaf-update.sh by:
#   - 'make router-install' (from dev machine)
#   - Baked into custom firmware via build-image.sh
#
# Invoked by:
#   - Nightly cron: random 02:xx–03:xx UTC (set per-device at first boot by 99-family-safe)
#   - Manually:     /usr/sbin/oaf-update.sh [--force]
#
# POSIX sh — compatible with OpenWrt busybox ash (no bashisms).
# Uses only: wget, awk, grep, sed, uname, logger, opkg (apk fallback)
#
# Log format: logfmt — every line is space-separated key=value pairs.
#   Mandatory fields on every line: device=  level=  event=
#   Values containing spaces are double-quoted.
#   Parse examples:
#     grep 'event=kmod_skip'              # all kmod ABI mismatches across fleet
#     grep 'device=e4671eaf156c'          # everything from one router
#     awk -F'pkg=' '{print $2}' | awk '{print $1}'   # package names touched
#     grep 'level=warn\|level=error'      # problems only
#
# S3 contract:
#   manifest-oaf.env      (written by OpenAppFilter repo CI)
#   manifest-familysafe.env (written by this repo's CI)
#   packages/             versioned .ipk files
#
# Exit codes:
#   0 — success (or graceful skip due to network/S3 failure)
#   1 — hard error (no package manager found)

# Update channel: the literal path segment under the single router bucket
# (e.g. "dev", "main", "router-prod-tenbay-wr3000k") -- not a dev/prod enum.
# Set at build time to match wherever this build's own package was published,
# so a device keeps tracking its own channel (a WR3000K test unit tracks
# router-prod-tenbay-wr3000k indefinitely, not just "main").
# Override by setting UPDATE_CHANNEL=<path> in /etc/oaf-update.conf.
#
# OAF_CHANNEL is DELIBERATELY separate: the app-filter repo has no
# per-branch/per-tag builds (only dev/main), so a device tracking a
# one-off channel like router-prod-tenbay-wr3000k would never find an OAF
# manifest there and OAF would never install. OAF always resolves against
# its own dev/main channel regardless of what UPDATE_CHANNEL is.
UPDATE_CHANNEL="main"
OAF_CHANNEL="main"
if [ -f /etc/oaf-update.conf ]; then
    # shellcheck disable=SC1091
    . /etc/oaf-update.conf
fi
# Package/firmware CDN host. Matches KAHF_CDN_DOMAIN in config/lab.env.
KAHF_HOST="${KAHF_HOST:-router.kahf.co}"
S3_BASE="https://${KAHF_HOST}/router/${UPDATE_CHANNEL}"
MANIFEST_OAF_URL="https://${KAHF_HOST}/router/${OAF_CHANNEL}/manifest-oaf.env"
# Allow CI / staging override: if MANIFEST_FS_URL is already set in the
# environment (e.g. by verify-image pointing at a staging prefix), honour it.
# Otherwise fall back to the canonical channel URL.
MANIFEST_FS_URL="${MANIFEST_FS_URL:-${S3_BASE}/manifest-familysafe.env}"
TMP_DIR="/tmp/oaf-update"
LOG_TAG="oaf-update"
# Unique device identifier — MAC of br-lan, colons stripped (e.g. "a4b1c2d3e4f5").
# Prefixed on every log line so fleet-wide aggregated syslog can be filtered per router.
DEVICE_ID="$(cat /sys/class/net/br-lan/address 2>/dev/null | tr -d ':')"
[ -z "${DEVICE_ID}" ] && DEVICE_ID="unknown"
WGET_TIMEOUT=30
WGET_DOWNLOAD_TIMEOUT=120

FORCE=0
for arg in "$@"; do
    case "$arg" in
        --force|-f) FORCE=1 ;;
    esac
done

# Emit a logfmt line to both syslog and stdout.
# Callers pass bare key=value pairs; device= and level= are prepended automatically.
# Values with spaces must be quoted by the caller: key="some value"
log()   { local _e="device=${DEVICE_ID} level=info $*";  logger -p daemon.info    -t "${LOG_TAG}" "${_e}" 2>/dev/null; echo "${_e}"; }
warn()  { local _e="device=${DEVICE_ID} level=warn $*";  logger -p daemon.warning -t "${LOG_TAG}" "${_e}" 2>/dev/null; echo "${_e}"; }
error() { local _e="device=${DEVICE_ID} level=error $*"; logger -p daemon.err     -t "${LOG_TAG}" "${_e}" 2>/dev/null; echo "${_e}"; }

log "event=start ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) channel=${UPDATE_CHANNEL} s3=${S3_BASE}"

# ------------------------------------------------------------------
# Rollout ring gate (T2.1): derive a stable 0–99 cohort bucket from
# the device's install ID (br-lan MAC).  The manifest carries
# ROLLOUT_PERCENT; if our bucket >= ROLLOUT_PERCENT we defer this run.
# Default 100 = ship to all devices (safe backward-compat fallback).
# ------------------------------------------------------------------
_rollout_bucket() {
    local _mac="${1:-unknown}"
    local _hex
    _hex="$(printf '%s' "${_mac}" | sha256sum 2>/dev/null | cut -c1-4)"
    [ -z "${_hex}" ] && { echo 50; return; }
    printf '%d\n' "0x${_hex}" | awk '{print $1 % 100}'
}

# T2.2: version comparison helper — returns 0 if $1 >= $2 (semver).
# Uses sort -V (available in busybox ≥1.30 and GNU coreutils).
_ver_gte() {
    [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$2" ]
}

ROLLOUT_PERCENT=100
DEVICE_BUCKET="$(_rollout_bucket "${DEVICE_ID}")"
log "event=rollout_init bucket=${DEVICE_BUCKET} threshold=${ROLLOUT_PERCENT}"

mkdir -p "${TMP_DIR}"

# ------------------------------------------------------------------
# Detect package manager — opkg preferred (23.05.5/ipk fleet)
# ------------------------------------------------------------------
PKG_MGR=""
if command -v opkg >/dev/null 2>&1; then PKG_MGR="opkg"; fi
if [ -z "${PKG_MGR}" ] && command -v apk >/dev/null 2>&1; then PKG_MGR="apk"; fi

if [ -z "${PKG_MGR}" ]; then
    error "event=abort reason=no_pkgmgr"
    rm -rf "${TMP_DIR}"
    exit 1
fi

log "event=pkgmgr name=${PKG_MGR}"

# ------------------------------------------------------------------
# Cron deduplication: ensure exactly one oaf-update.sh entry exists.
# Config-preserving sysupgrades re-run 99-family-safe which appends a
# new timed entry without removing the old one, accumulating duplicates
# across flashes. Detect and collapse to a single fresh random-time entry.
# ------------------------------------------------------------------
_CRON_FILE="/etc/crontabs/root"
_oaf_cron_count="$(grep -c 'oaf-update\.sh' "${_CRON_FILE}" 2>/dev/null || echo 0)"
if [ "${_oaf_cron_count}" -gt 1 ]; then
    log "event=cron_dedup found=${_oaf_cron_count} action=replacing_with_single_entry"
    sed -i '/oaf-update\.sh/d' "${_CRON_FILE}"
    _cron_hex="$(head -c 4 /dev/urandom 2>/dev/null | md5sum | cut -c1-4)"
    _cron_r="$(printf '%d' "0x${_cron_hex}" 2>/dev/null)"
    [ -z "${_cron_r}" ] && _cron_r=7531
    _cron_offset=$(( _cron_r % 120 ))
    if [ "${_cron_offset}" -lt 30 ]; then
        _cron_hour=23; _cron_min=$(( _cron_offset + 30 ))
    elif [ "${_cron_offset}" -lt 90 ]; then
        _cron_hour=0;  _cron_min=$(( _cron_offset - 30 ))
    else
        _cron_hour=1;  _cron_min=$(( _cron_offset - 90 ))
    fi
    printf '%s\n' "${_cron_min} ${_cron_hour} * * * /usr/sbin/oaf-update.sh >> /var/log/oaf-update.log 2>&1" >> "${_CRON_FILE}"
    /etc/init.d/cron reload 2>/dev/null || true
    log "event=cron_dedup_done new_time=${_cron_hour}:$(printf '%02d' ${_cron_min})"
fi

# ------------------------------------------------------------------
# Step 1: Refresh package lists (do NOT full-upgrade — risky on routers)
# ------------------------------------------------------------------
if [ "${PKG_MGR}" = "opkg" ]; then
    _out="$(opkg update 2>&1)"; _rc=$?
    printf '%s\n' "${_out}"
    if [ "${_rc}" -eq 0 ]; then
        log "event=pkglist_refresh status=ok"
    else
        warn "event=pkglist_refresh status=fail exit=${_rc} detail=\"$(printf '%s' "${_out}" | awk 'END{print}')\""
    fi
elif [ "${PKG_MGR}" = "apk" ]; then
    _out="$(apk update 2>&1)"; _rc=$?
    printf '%s\n' "${_out}"
    if [ "${_rc}" -eq 0 ]; then
        log "event=pkglist_refresh status=ok"
    else
        warn "event=pkglist_refresh status=fail exit=${_rc} detail=\"$(printf '%s' "${_out}" | awk 'END{print}')\""
    fi
fi

# ------------------------------------------------------------------
# Step 2: Backup /etc/config/appfilter (best-effort, before any changes)
# ------------------------------------------------------------------
if [ -f /etc/config/appfilter ]; then
    cp /etc/config/appfilter "${TMP_DIR}/appfilter.bak" 2>/dev/null \
        && log "event=backup path=/etc/config/appfilter status=ok" \
        || warn "event=backup path=/etc/config/appfilter status=fail"
fi

# ------------------------------------------------------------------
# Post-update health check + auto-rollback (T2.3)
# Runs after package installs to verify the router is still healthy.
# On failure: restores appfilter config, restarts services, and alerts.
# Returns 0 (healthy) or 1 (unhealthy — rollback triggered).
# ------------------------------------------------------------------
_health_check() {
    local _ok=1
    # 1. rpcd must be running
    if ! pgrep -f rpcd >/dev/null 2>&1; then
        warn "event=health_fail reason=rpcd_not_running"
        _ok=0
    fi
    # 2. dnsmasq must be running
    if ! pgrep -f dnsmasq >/dev/null 2>&1; then
        warn "event=health_fail reason=dnsmasq_not_running"
        _ok=0
    fi
    # 3. DNS must resolve
    if ! nslookup kahf.co 127.0.0.1 >/dev/null 2>&1; then
        warn "event=health_fail reason=dns_not_resolving"
        _ok=0
    fi
    return $((1 - _ok))
}

_rollback() {
    warn "event=rollback_start reason=health_check_failed"
    # Rollback scope: only /etc/config/appfilter is restored here.
    # dhcp, firewall, and family-safe UCI are NOT rolled back — a failed
    # family-safe package upgrade leaves those configs in their new state.
    # DNS filtering is restored by reloading dnsmasq + firewall from current UCI.
    if [ -f "${TMP_DIR}/appfilter.bak" ]; then
        cp "${TMP_DIR}/appfilter.bak" /etc/config/appfilter 2>/dev/null \
            && log "event=rollback config=appfilter status=ok" \
            || warn "event=rollback config=appfilter status=fail"
    fi
    /etc/init.d/dnsmasq reload 2>/dev/null || true
    /etc/init.d/firewall restart 2>/dev/null || true
    warn "event=rollback_done hint=router_should_be_filtering"
}

# ------------------------------------------------------------------
# Helper: wget with 3 attempts, 5-second delay between retries.
# Prints stderr of the last failed attempt on stdout; returns its exit code.
# ------------------------------------------------------------------
_wget_retry() {
    local _url="$1" _dest="$2" _timeout="$3"
    local _attempt=0 _out _rc=1
    while [ "${_attempt}" -lt 3 ]; do
        _attempt=$(( _attempt + 1 ))
        _out="$(wget -q --timeout="${_timeout}" -O "${_dest}" "${_url}" 2>&1)"
        _rc=$?
        [ "${_rc}" -eq 0 ] && return 0
        [ "${_attempt}" -lt 3 ] && sleep 5
    done
    printf '%s' "${_out}"
    return "${_rc}"
}

# ------------------------------------------------------------------
# Helper: fetch a manifest from S3.  Returns 1 if unreachable/missing.
# ------------------------------------------------------------------
fetch_manifest() {
    local url="$1"
    local dest="$2"
    local _fm_out _fm_rc
    log "event=manifest_fetch url=${url}"
    _fm_out="$(_wget_retry "${url}" "${dest}" "${WGET_TIMEOUT}")"
    _fm_rc=$?
    if [ "${_fm_rc}" -eq 0 ]; then
        return 0
    fi
    warn "event=manifest_fetch_fail url=${url} exit=${_fm_rc}${_fm_out:+ detail=\"${_fm_out}\"}"
    return 1
}

# ------------------------------------------------------------------
# Strict manifest parser — replaces unsafe `. manifest` sourcing.
# Only reads a known allowlist of keys; rejects any value containing
# shell metacharacters or path traversal sequences.
# Usage: parse_manifest <file> <KEY1> [KEY2 ...]
#   Sets each KEY in the current shell environment.
#   Returns 1 if any required key is absent or its value is invalid.
# ------------------------------------------------------------------
# Allowed value pattern: printable ASCII, no shell metacharacters,
# no newlines, no control chars. Max 512 chars per value.
# Note: busybox grep -E does not support {n,m} interval quantifiers.
# Use + (one-or-more) and enforce max length separately with ${#var}.
# Hyphen must be first in the class to avoid range mis-interpretation.
_MANIFEST_VALUE_RE='^[-A-Za-z0-9_./:@=+%,]+$'

parse_manifest() {
    local _pm_file="$1"; shift
    local _pm_key _pm_val _pm_raw _pm_ok
    for _pm_key in "$@"; do
        # Extract raw value: match KEY=VALUE or export KEY=VALUE lines only
        _pm_raw="$(grep -m1 "^export ${_pm_key}=\|^${_pm_key}=" "${_pm_file}" 2>/dev/null \
            | sed "s/^export ${_pm_key}=//;s/^${_pm_key}=//" \
            | tr -d "'\"")"
        if [ -z "${_pm_raw}" ]; then
            warn "event=manifest_parse_fail key=${_pm_key} reason=missing_key"
            return 1
        fi
        # Enforce max length (busybox ${#var} is portable; avoids grep {n,m}).
        if [ "${#_pm_raw}" -gt 512 ]; then
            warn "event=manifest_parse_fail key=${_pm_key} reason=value_too_long"
            return 1
        fi
        # Validate against allowlist pattern (no eval/glob/subshell chars).
        _pm_ok="$(printf '%s' "${_pm_raw}" | grep -cE "${_MANIFEST_VALUE_RE}" 2>/dev/null || echo 0)"
        if [ "${_pm_ok}" != "1" ]; then
            warn "event=manifest_parse_fail key=${_pm_key} reason=invalid_value_chars"
            return 1
        fi
        # Export safely — no eval, no subshell expansion
        export "${_pm_key}=${_pm_raw}"
    done
    return 0
}

# ------------------------------------------------------------------
# Helper: get installed version of a package
# ------------------------------------------------------------------
get_installed_version() {
    local pkg="$1"
    if [ "${PKG_MGR}" = "opkg" ]; then
        opkg list-installed 2>/dev/null | awk -v p="${pkg}" '$1==p{print $3}'
    elif [ "${PKG_MGR}" = "apk" ]; then
        apk info "${pkg}" 2>/dev/null | awk 'NR==1{print $1}' | sed "s/^${pkg}-//"
    fi
}

# ------------------------------------------------------------------
# Helper: download and install a package
#   update_pkg <pkgname> <manifest_ver> <url> [extra_opkg_flags]
# ------------------------------------------------------------------
update_pkg() {
    local pkg="$1"
    local manifest_ver="$2"
    local url="$3"
    local extra_flags="${4:-}"
    local pkg_file
    pkg_file="${TMP_DIR}/$(basename "${url}")"

    installed_ver="$(get_installed_version "${pkg}")"

    if [ "${FORCE}" = "0" ] && [ -n "${installed_ver}" ] && [ "${installed_ver}" = "${manifest_ver}" ]; then
        log "event=pkg_check pkg=${pkg} installed=${installed_ver} latest=${manifest_ver} status=current"
        return 0
    fi

    # T9d: monotonic version floor — refuse to install an older version (downgrade
    # protection). _ver_gte returns 0 when installed >= manifest, i.e. a rollback/
    # poisoned manifest would push to a lower version. --force bypasses for lab use.
    if [ "${FORCE}" = "0" ] && [ -n "${installed_ver}" ] \
       && [ "${installed_ver}" != "${manifest_ver}" ] \
       && _ver_gte "${installed_ver}" "${manifest_ver}"; then
        warn "event=downgrade_refused pkg=${pkg} installed=${installed_ver} manifest=${manifest_ver}"
        return 0
    fi

    if [ -n "${installed_ver}" ]; then
        log "event=pkg_update pkg=${pkg} from=${installed_ver} to=${manifest_ver}"
    else
        log "event=pkg_install pkg=${pkg} version=${manifest_ver}"
    fi

    local dl_out dl_rc inst_out inst_rc
    dl_out="$(_wget_retry "${url}" "${pkg_file}" "${WGET_DOWNLOAD_TIMEOUT}")"
    dl_rc=$?
    if [ "${dl_rc}" -ne 0 ]; then
        warn "event=pkg_dl_fail pkg=${pkg} url=${url} exit=${dl_rc}${dl_out:+ detail=\"${dl_out}\"}"
        rm -f "${pkg_file}"
        return 1
    fi

    # T1.5: Verify package signature before install.
    # Pub key baked into firmware at /etc/oaf-pkg-signing.pub (prod key only on prod builds).
    local pkg_pubkey="/etc/oaf-pkg-signing.pub"
    local pkg_sig="${pkg_file}.sig"
    if [ -f "${pkg_pubkey}" ] && command -v usign >/dev/null 2>&1; then
        local sig_out sig_rc
        sig_out="$(_wget_retry "${url}.sig" "${pkg_sig}" 15)"
        sig_rc=$?
        if [ "${sig_rc}" -ne 0 ] || [ ! -s "${pkg_sig}" ]; then
            warn "event=pkg_sig_fetch_fail pkg=${pkg} reason=no_signature_at_${url}.sig"
            rm -f "${pkg_file}" "${pkg_sig}"
            return 1
        fi
        if ! usign -V -m "${pkg_file}" -p "${pkg_pubkey}" -x "${pkg_sig}" >/dev/null 2>&1; then
            warn "event=pkg_sig_invalid pkg=${pkg} url=${url} reason=signature_verification_failed"
            rm -f "${pkg_file}" "${pkg_sig}"
            return 1
        fi
        log "event=pkg_sig_ok pkg=${pkg}"
        rm -f "${pkg_sig}"
    else
        log "event=pkg_sig_skip pkg=${pkg} reason=no_pubkey_or_usign_missing pubkey=${pkg_pubkey}"
    fi

    if [ "${PKG_MGR}" = "opkg" ]; then
        # shellcheck disable=SC2086
        inst_out="$(opkg install --force-reinstall ${extra_flags} "${pkg_file}" 2>&1)"
        inst_rc=$?
        printf '%s\n' "${inst_out}"
        if [ "${inst_rc}" -eq 0 ]; then
            log "event=pkg_ok pkg=${pkg} version=${manifest_ver}"
        else
            warn "event=pkg_install_fail pkg=${pkg} version=${manifest_ver} exit=${inst_rc} detail=\"$(printf '%s' "${inst_out}" | awk 'END{print}')\""
        fi
    elif [ "${PKG_MGR}" = "apk" ]; then
        # --allow-untrusted is ALWAYS required for local .apk installs.
        # Our packages are authenticated by the usign .sig layer (verified above)
        # which is entirely separate from apk's native per-package keystore model
        # (/etc/apk/keys/).  We do NOT sign with apk's native signing format, so
        # apk would reject the package without --allow-untrusted regardless of
        # whether oaf-pkg-signing.pub is present.  The usign verification above
        # is the authenticity gate; --allow-untrusted here only bypasses the
        # apk-native trust check, not our usign layer.
        inst_out="$(apk add --allow-untrusted "${pkg_file}" 2>&1)"
        inst_rc=$?
        printf '%s\n' "${inst_out}"
        if [ "${inst_rc}" -eq 0 ]; then
            log "event=pkg_ok pkg=${pkg} version=${manifest_ver}"
        else
            warn "event=pkg_install_fail pkg=${pkg} version=${manifest_ver} exit=${inst_rc} detail=\"$(printf '%s' "${inst_out}" | awk 'END{print}')\""
        fi
    fi

    rm -f "${pkg_file}"
}

# ------------------------------------------------------------------
# Step 3: OAF packages (manifest-oaf.env)
# ------------------------------------------------------------------
MANIFEST_OAF="${TMP_DIR}/manifest-oaf.env"
if fetch_manifest "${MANIFEST_OAF_URL}" "${MANIFEST_OAF}"; then
    if ! parse_manifest "${MANIFEST_OAF}" \
            APPFILTER_VERSION APPFILTER_URL \
            LUCI_VERSION LUCI_URL \
            KMOD_VERSION KMOD_URL KMOD_KERNEL_ABI \
            OPENWRT_ARCH BUILD_COMMIT BUILD_DATE; then
        warn "event=manifest_invalid url=${MANIFEST_OAF_URL} reason=parse_failed"
    else
        # Rollout gate: parse optional ROLLOUT_PERCENT from manifest.
        _rp_raw="$(grep -m1 '^ROLLOUT_PERCENT=' "${MANIFEST_OAF}" 2>/dev/null | sed 's/^ROLLOUT_PERCENT=//')"
        if [ -n "${_rp_raw}" ] && [ "${_rp_raw}" -ge 0 ] 2>/dev/null && [ "${_rp_raw}" -le 100 ] 2>/dev/null; then
            ROLLOUT_PERCENT="${_rp_raw}"
        fi
        if [ "${FORCE}" = "0" ] && [ "${DEVICE_BUCKET}" -ge "${ROLLOUT_PERCENT}" ]; then
            log "event=rollout_defer bucket=${DEVICE_BUCKET} threshold=${ROLLOUT_PERCENT} hint=will_retry_next_run"
            rm -rf "${TMP_DIR}"
            exit 0
        fi
        log "event=oaf_manifest commit=${BUILD_COMMIT:-unknown} date=${BUILD_DATE:-unknown} kmod_ver=${KMOD_VERSION:-?} kmod_abi=${KMOD_KERNEL_ABI:-?} appfilter_ver=${APPFILTER_VERSION:-?} luci_ver=${LUCI_VERSION:-?} rollout_pct=${ROLLOUT_PERCENT}"

        # T2.2: Version stepping gate — honour MIN_FROM_VERSION so a long-offline
        # router doesn't blind-jump over a known-bad intermediate version.
        SKIP_OAF=0
        _min_from="$(grep -m1 '^MIN_FROM_VERSION=' "${MANIFEST_OAF}" 2>/dev/null | sed 's/^MIN_FROM_VERSION=//')"
        if [ -n "${_min_from}" ]; then
            _cur_oaf="$(opkg list-installed 2>/dev/null | awk '$1=="appfilter"{print $3}')"
            if [ -z "${_cur_oaf}" ] && [ "${PKG_MGR}" = "apk" ]; then
                _cur_oaf="$(apk info 2>/dev/null | awk '/^appfilter-/{gsub(/^appfilter-/,""); print; exit}')"
            fi
            if [ -n "${_cur_oaf}" ] && ! _ver_gte "${_cur_oaf}" "${_min_from}"; then
                warn "event=version_pin_defer installed=${_cur_oaf} min_from_version=${_min_from} hint=need_intermediate_update"
                SKIP_OAF=1
            fi
        fi

        # kmod guard: compare ABI string from manifest against installed kernel
        # KMOD_KERNEL_ABI from manifest is the exact "kernel (= X)" dep value,
        # e.g. "5.15.167-1-03ba5b5fee..."; the router reports the same via opkg.
        ROUTER_KERNEL_VER="$(opkg list-installed 2>/dev/null | awk '$1=="kernel"{print $3}')"
        if [ -z "${ROUTER_KERNEL_VER}" ] && [ "${PKG_MGR}" = "apk" ]; then
            ROUTER_KERNEL_VER="$(uname -r)"
        fi

        # Architecture guard: KMOD_KERNEL_ABI on apk systems is only the bare
        # kernel version (e.g. "6.12.87"), stripped of the target-specific
        # build hash -- the same version number is shared across different
        # architectures/targets on the same OpenWrt release, so it alone
        # cannot distinguish e.g. aarch64 (mediatek/filogic, real WR3000K)
        # from x86_64 (QEMU verify-image test target). Cross-check uname -m
        # against the manifest's OPENWRT_ARCH so a same-version, wrong-arch
        # kmod-oaf is skipped instead of failing apk's dependency resolver.
        ROUTER_ARCH="$(uname -m)"
        case "${OPENWRT_ARCH:-}" in
            "${ROUTER_ARCH}"*) ARCH_OK=1 ;;
            *) ARCH_OK=0 ;;
        esac

        if [ "${SKIP_OAF}" = "0" ]; then
            if [ "${ARCH_OK}" = "1" ] && [ -n "${KMOD_KERNEL_ABI:-}" ] && [ -n "${ROUTER_KERNEL_VER}" ] \
               && [ "${ROUTER_KERNEL_VER}" = "${KMOD_KERNEL_ABI}" ]; then
                update_pkg "kmod-oaf" "${KMOD_VERSION}" "${KMOD_URL}"
            else
                warn "event=kmod_skip reason=abi_mismatch router_kernel=${ROUTER_KERNEL_VER:-unknown} manifest_abi=${KMOD_KERNEL_ABI:-unknown} router_arch=${ROUTER_ARCH} manifest_arch=${OPENWRT_ARCH:-unknown} hint=firmware_upgrade_required"
            fi

            # appfilter and luci-app-oaf are also arch-specific compiled
            # packages (not arch=all) -- same guard applies to both, since
            # they'd fail apk dependency resolution the same way kmod-oaf did.
            if [ "${ARCH_OK}" = "1" ]; then
                update_pkg "appfilter"    "${APPFILTER_VERSION}" "${APPFILTER_URL}"
                update_pkg "luci-app-oaf" "${LUCI_VERSION}"      "${LUCI_URL}"
            else
                warn "event=oaf_userspace_skip reason=arch_mismatch router_arch=${ROUTER_ARCH} manifest_arch=${OPENWRT_ARCH:-unknown} hint=qemu_test_target_or_firmware_upgrade_required"
            fi
        fi

        # Fix: appfilter package ships feature.cfg with CN app IDs (3001=TikTok).
        # Overwrite with the EN version where 3001=YouTube, matching the app catalog.
        if [ -f /etc/appfilter/feature_en.cfg ]; then
            cp /etc/appfilter/feature_en.cfg /etc/appfilter/feature.cfg \
                && log "event=feature_cfg_fix source=feature_en.cfg" \
                || warn "event=feature_cfg_fix status=fail"
        fi

        # Fix: user_mode is not in the default UCI config; writing an empty string
        # to /proc/sys/oaf/user_mode causes EINVAL in oaf_rule reload.
        uci -q get appfilter.global.user_mode >/dev/null 2>&1 \
            || { uci set appfilter.global.user_mode=0; uci commit appfilter; }

        # Restart the appfilter daemon so the new binary takes effect --
        # only if it's actually installed (skipped above on arch mismatch,
        # e.g. the QEMU x86_64 verify-image target).
        if [ -x /etc/init.d/appfilter ]; then
            _restart_out="$(/etc/init.d/appfilter restart 2>&1)"
            _restart_rc=$?
            if [ "${_restart_rc}" -eq 0 ]; then
                log "event=service_restart name=appfilter status=ok"
            else
                warn "event=service_restart name=appfilter status=fail exit=${_restart_rc}${_restart_out:+ detail=\"${_restart_out}\"}"
            fi
        else
            log "event=service_restart name=appfilter status=skip reason=not_installed"
        fi
    fi
else
    warn "event=oaf_skip reason=manifest_unavailable"
fi

# ------------------------------------------------------------------
# Step 4: family-safe package (manifest-familysafe.env)
# ------------------------------------------------------------------
MANIFEST_FS="${TMP_DIR}/manifest-familysafe.env"
if fetch_manifest "${MANIFEST_FS_URL}" "${MANIFEST_FS}"; then
    if ! parse_manifest "${MANIFEST_FS}" \
            FAMILYSAFE_VERSION FAMILYSAFE_URL \
            BUILD_COMMIT BUILD_DATE; then
        warn "event=manifest_invalid url=${MANIFEST_FS_URL} reason=parse_failed"
    else
        log "event=fs_manifest commit=${BUILD_COMMIT:-unknown} date=${BUILD_DATE:-unknown} version=${FAMILYSAFE_VERSION:-?}"

        # --force-depends: package may depend on dnsmasq-full; avoid blocking
        # if the user has a custom dnsmasq variant that satisfies the function.
        update_pkg "family-safe" "${FAMILYSAFE_VERSION}" "${FAMILYSAFE_URL}"
    fi
else
    warn "event=fs_skip reason=manifest_unavailable"
fi

# ------------------------------------------------------------------
# Step 5: Refresh block page IP in dnsmasq (blockpage.conf)
# ------------------------------------------------------------------
if [ -f /usr/lib/family-safe/lib-family-safe.sh ]; then
    (SCRIPT_DIR=/usr/lib/family-safe \
     && . /usr/lib/family-safe/lib-family-safe.sh \
     && load_config \
     && update_block_page) \
        && log "event=blockpage_refresh status=ok" \
        || warn "event=blockpage_refresh status=fail"
fi

# ------------------------------------------------------------------
# Step 6: Post-update health check
# ------------------------------------------------------------------
if ! _health_check; then
    _rollback
fi

# ------------------------------------------------------------------
# Cleanup
# ------------------------------------------------------------------
rm -rf "${TMP_DIR}"
log "event=finish ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
exit 0
