Last active
July 19, 2026 10:05
-
-
Save jamesmacwhite/7f6463ae7b307692c26a81be5cddbdcf to your computer and use it in GitHub Desktop.
Shell script to help with updating/installing mwan3-nft without a OpenWrt packages feed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/sh | |
| # mwan3-nft-update.sh | |
| # | |
| # Query GitHub releases for https://github.com/dl12345/mwan3 (and, | |
| # optionally, its companion https://github.com/dl12345/luci-app-mwan3), | |
| # find the .apk asset(s) matching this router, download to /tmp and | |
| # install with apk. | |
| # | |
| # Target: OpenWrt 25.12 (apk-based package manager). POSIX/ash only, | |
| # no bash-isms, no jq/curl assumed present. | |
| # | |
| # mwan3 release assets are per-arch, e.g.: | |
| # mwan3-3.6.11-1_openwrt-25.12_aarch64_cortex-a53.apk | |
| # mwan3-3.6.11-1_openwrt-25.12_x86_64.apk | |
| # mwan3-3.6.11-1_openwrt-25.12_arm_cortex-a7.apk | |
| # mwan3-3.6.11-1_openwrt-25.12_arm_cortex-a7_neon-vfpv4.apk | |
| # ... | |
| # | |
| # luci-app-mwan3 release assets are arch-independent (LuCI is Lua/JS), | |
| # one per release, e.g.: | |
| # luci-app-mwan3_26.999.3.6.10.apk | |
| # | |
| # Usage: | |
| # mwan3-nft-update.sh [options] | |
| # | |
| # Options: | |
| # -c, --check Only check for updates, don't download/install | |
| # -f, --force Reinstall even if already up to date, and allow | |
| # downgrading if the installed version (e.g. a | |
| # beta) is newer than the latest matched release | |
| # -k, --keep Keep the downloaded .apk file(s) in /tmp after install | |
| # -p, --pre-release Also consider pre-releases (default: stable releases only) | |
| # -l, --luci Also fetch and install the companion luci-app-mwan3 package | |
| # -a, --arch ARCH Override detected arch (skip /etc/apk/arch lookup) | |
| # -o, --openwrt-ver VER OpenWrt version string to match in the mwan3 asset name (default: auto-detected, falls back to 25.12) | |
| # -r, --repo OWNER/REPO Override the mwan3 GitHub repo (default: dl12345/mwan3) | |
| # --luci-repo OWNER/REPO Override the luci-app-mwan3 GitHub repo (default: dl12345/luci-app-mwan3) | |
| # -h, --help Show this help and exit | |
| # | |
| # Notes: | |
| # - These packages are not part of the official OpenWrt feed, so they | |
| # are unsigned as far as this router's apk keyring is concerned. | |
| # The install step uses `apk add --allow-untrusted`. Only run this | |
| # against repos you trust. | |
| # - Unauthenticated GitHub API calls are rate-limited to 60/hour per | |
| # source IP. Set GITHUB_TOKEN in the environment to raise that limit | |
| # if you're scheduling this via cron. | |
| set -eu | |
| REPO="dl12345/mwan3" | |
| LUCI_REPO="dl12345/luci-app-mwan3" | |
| TMP_DIR="/tmp" | |
| CHECK_ONLY=0 | |
| FORCE=0 | |
| KEEP_FILE=0 | |
| PRE_RELEASE=0 | |
| INSTALL_LUCI=0 | |
| ARCH_OVERRIDE="" | |
| OPENWRT_VER_OVERRIDE="" | |
| PKG_NAME="mwan3" | |
| LUCI_PKG_NAME="luci-app-mwan3" | |
| log() { printf '%s\n' "$*" >&2; } | |
| die() { log "ERROR: $*"; exit 1; } | |
| usage() { | |
| sed -n '2,/^set -eu/p' "$0" | sed 's/^# \{0,1\}//' | sed '$d' | |
| } | |
| # --------------------------------------------------------------------- | |
| # Argument parsing | |
| # --------------------------------------------------------------------- | |
| while [ $# -gt 0 ]; do | |
| case "$1" in | |
| -c|--check) CHECK_ONLY=1 ;; | |
| -f|--force) FORCE=1 ;; | |
| -k|--keep) KEEP_FILE=1 ;; | |
| -p|--pre-release) PRE_RELEASE=1 ;; | |
| -l|--luci) INSTALL_LUCI=1 ;; | |
| -a|--arch) ARCH_OVERRIDE="${2:?--arch requires a value}"; shift ;; | |
| -o|--openwrt-ver) OPENWRT_VER_OVERRIDE="${2:?--openwrt-ver requires a value}"; shift ;; | |
| -r|--repo) REPO="${2:?--repo requires a value}"; shift ;; | |
| --luci-repo) LUCI_REPO="${2:?--luci-repo requires a value}"; shift ;; | |
| -h|--help) usage; exit 0 ;; | |
| *) die "Unknown argument: $1 (use -h for help)" ;; | |
| esac | |
| shift | |
| done | |
| # --------------------------------------------------------------------- | |
| # Pick a downloader: curl > wget (GNU/wget-ssl) > uclient-fetch | |
| # --------------------------------------------------------------------- | |
| if command -v curl >/dev/null 2>&1; then | |
| FETCHER="curl" | |
| elif command -v wget >/dev/null 2>&1; then | |
| FETCHER="wget" | |
| elif command -v uclient-fetch >/dev/null 2>&1; then | |
| FETCHER="uclient-fetch" | |
| else | |
| die "No downloader found (need curl, wget, or uclient-fetch)." | |
| fi | |
| # fetch_stdout URL -> prints response body to stdout | |
| fetch_stdout() { | |
| url="$1" | |
| case "$FETCHER" in | |
| curl) | |
| curl_hdrs="" | |
| [ -n "${GITHUB_TOKEN:-}" ] && curl_hdrs="-H Authorization:\ token\ $GITHUB_TOKEN" | |
| # shellcheck disable=SC2086 | |
| curl -fsSL -H "Accept: application/vnd.github+json" $curl_hdrs "$url" | |
| ;; | |
| wget) | |
| wget_hdrs="" | |
| [ -n "${GITHUB_TOKEN:-}" ] && wget_hdrs="--header=Authorization:\ token\ $GITHUB_TOKEN" | |
| # shellcheck disable=SC2086 | |
| wget -q -O - --header="Accept: application/vnd.github+json" $wget_hdrs "$url" | |
| ;; | |
| uclient-fetch) | |
| uclient-fetch -qO - "$url" | |
| ;; | |
| esac | |
| } | |
| # vercmp A B -> echoes "lt", "eq", "gt" (A vs B) or "unknown" if it can't | |
| # be determined. Delegates to `apk version -t`, the same comparator apk | |
| # itself uses (handles suffixes like _beta/_rc/-rN correctly, so e.g. | |
| # 3.7_beta-r1 correctly compares as newer than 3.6.11-1). | |
| vercmp() { | |
| a="$1" | |
| b="$2" | |
| if command -v apk >/dev/null 2>&1; then | |
| r=$(apk version -t "$a" "$b" 2>/dev/null) || r="" | |
| case "$r" in | |
| "<") echo "lt"; return ;; | |
| "=") echo "eq"; return ;; | |
| ">") echo "gt"; return ;; | |
| esac | |
| fi | |
| if [ "$a" = "$b" ]; then | |
| echo "eq" | |
| else | |
| echo "unknown" | |
| fi | |
| } | |
| # is_prerelease_str VERSION -> true if VERSION looks like an | |
| # alpha/beta/pre/rc build. | |
| # | |
| # These repos tag stable releases as "<ver>-<n>" (e.g. 3.6.11-1), which | |
| # is NOT apk's own "-r<n>" revision syntax, so it doesn't reliably parse | |
| # as a well-formed apk version. Beta/dev builds installed out-of-band | |
| # (e.g. 3.7_beta-r1) DO happen to match apk's format. Comparing a | |
| # well-formed version against a malformed one via `apk version -t` gives | |
| # undefined/inconsistent results - in practice this can make a beta look | |
| # "older" than a subsequent stable release and get silently downgraded. | |
| # To stay safe, a pre-release marker in the installed version always | |
| # wins over a freshly-fetched stable release, regardless of what the | |
| # numeric comparison says. | |
| is_prerelease_str() { | |
| case "$1" in | |
| *_alpha*|*_beta*|*_pre*|*_rc*|*-alpha*|*-beta*|*-rc*) return 0 ;; | |
| *) return 1 ;; | |
| esac | |
| } | |
| # fetch_file URL DEST -> downloads to DEST, returns non-zero on failure | |
| fetch_file() { | |
| url="$1" | |
| dest="$2" | |
| case "$FETCHER" in | |
| curl) | |
| curl -fsSL -o "$dest" "$url" | |
| ;; | |
| wget) | |
| wget -q -O "$dest" "$url" | |
| ;; | |
| uclient-fetch) | |
| uclient-fetch -qO "$dest" "$url" | |
| ;; | |
| esac | |
| } | |
| # --------------------------------------------------------------------- | |
| # Detect router arch (from apk, the 25.12 package manager) | |
| # --------------------------------------------------------------------- | |
| detect_arch() { | |
| if [ -n "$ARCH_OVERRIDE" ]; then | |
| printf '%s' "$ARCH_OVERRIDE" | |
| return | |
| fi | |
| if [ -r /etc/apk/arch ]; then | |
| cat /etc/apk/arch | |
| return | |
| fi | |
| die "Could not read /etc/apk/arch. Pass --arch explicitly (e.g. aarch64_cortex-a53)." | |
| } | |
| # --------------------------------------------------------------------- | |
| # Detect OpenWrt major.minor version, e.g. "25.12" | |
| # --------------------------------------------------------------------- | |
| detect_openwrt_ver() { | |
| if [ -n "$OPENWRT_VER_OVERRIDE" ]; then | |
| printf '%s' "$OPENWRT_VER_OVERRIDE" | |
| return | |
| fi | |
| if [ -r /etc/openwrt_release ]; then | |
| rel=$(sed -n "s/^DISTRIB_RELEASE='\{0,1\}\([^'\"]*\)'\{0,1\}/\1/p" /etc/openwrt_release | head -n1) | |
| [ -n "$rel" ] && { echo "$rel" | cut -d. -f1,2; return; } | |
| fi | |
| # fallback: assume the version this script was written for | |
| printf '25.12' | |
| } | |
| ARCH=$(detect_arch) | |
| OPENWRT_VER=$(detect_openwrt_ver) | |
| [ -n "$ARCH" ] || die "Detected arch is empty." | |
| log "Router arch: $ARCH" | |
| log "OpenWrt version: $OPENWRT_VER" | |
| # --------------------------------------------------------------------- | |
| # process_pkg REPO PKG_NAME MODE LABEL | |
| # | |
| # REPO owner/repo on GitHub | |
| # PKG_NAME apk package name (used to match the installed version and, | |
| # for noarch packages, to match the release asset) | |
| # MODE "arch" - match asset by "_<ARCH>.apk" (mwan3 core pkg) | |
| # "noarch" - match the single arch-independent asset | |
| # (luci-app-mwan3) | |
| # LABEL human-readable label for log output | |
| # | |
| # /releases/latest only ever returns the newest non-prerelease, | |
| # non-draft release. To also consider pre-releases we instead pull the | |
| # single newest entry from /releases (sorted newest-first, drafts are | |
| # never visible unauthenticated), which may be a prerelease. | |
| # --------------------------------------------------------------------- | |
| process_pkg() { | |
| repo="$1" | |
| pkg_name="$2" | |
| mode="$3" | |
| label="$4" | |
| log "" | |
| log "== $label ($repo) ==" | |
| if [ "$PRE_RELEASE" -eq 1 ]; then | |
| api_url="https://api.github.com/repos/${repo}/releases?per_page=1" | |
| log "Querying $api_url (including pre-releases) ..." | |
| else | |
| api_url="https://api.github.com/repos/${repo}/releases/latest" | |
| log "Querying $api_url ..." | |
| fi | |
| rel_json=$(fetch_stdout "$api_url") || die "Failed to query GitHub API for $repo." | |
| [ -n "$rel_json" ] || die "Empty response from GitHub API for $repo." | |
| trimmed_json=$(printf '%s' "$rel_json" | tr -d ' \t\n\r') | |
| if [ "$trimmed_json" = "[]" ]; then | |
| die "No releases found for $repo." | |
| fi | |
| # GitHub errors come back as JSON with a "message" field and no assets. | |
| if ! printf '%s' "$rel_json" | grep -q '"tag_name"'; then | |
| err_msg=$(printf '%s' "$rel_json" | sed -n 's/.*"message": *"\([^"]*\)".*/\1/p' | head -n1) | |
| die "GitHub API did not return a release for $repo. ${err_msg:-Unexpected response.}" | |
| fi | |
| tag=$(printf '%s' "$rel_json" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n1) | |
| [ -n "$tag" ] || die "Could not parse tag_name from release JSON for $repo." | |
| is_prerelease=$(printf '%s' "$rel_json" | sed -n 's/.*"prerelease": *\(true\|false\).*/\1/p' | head -n1) | |
| if [ "$is_prerelease" = "true" ]; then | |
| log "Latest release: $tag (pre-release)" | |
| else | |
| log "Latest release: $tag" | |
| fi | |
| if [ "$mode" = "arch" ]; then | |
| # Match on "_<arch>.apk" at the end of the filename so e.g. | |
| # arm_cortex-a7 doesn't also match arm_cortex-a7_neon-vfpv4. | |
| asset_url=$(printf '%s' "$rel_json" \ | |
| | grep '"browser_download_url"' \ | |
| | grep "openwrt-${OPENWRT_VER}_${ARCH}\.apk\"" \ | |
| | sed -n 's/.*"browser_download_url": *"\([^"]*\)".*/\1/p' \ | |
| | head -n1) | |
| if [ -z "$asset_url" ]; then | |
| log "No asset matched 'openwrt-${OPENWRT_VER}_${ARCH}.apk', retrying without the OpenWrt version filter..." | |
| asset_url=$(printf '%s' "$rel_json" \ | |
| | grep '"browser_download_url"' \ | |
| | grep "_${ARCH}\.apk\"" \ | |
| | sed -n 's/.*"browser_download_url": *"\([^"]*\)".*/\1/p' \ | |
| | head -n1) | |
| fi | |
| [ -n "$asset_url" ] || die "No release asset found for arch '$ARCH' in $repo $tag." | |
| new_ver=$(printf '%s' "$tag" | sed 's/^v//') | |
| else | |
| # noarch: exactly one asset, named "<pkg_name>_<version>.apk" | |
| asset_url=$(printf '%s' "$rel_json" \ | |
| | grep '"browser_download_url"' \ | |
| | grep "${pkg_name}_.*\.apk\"" \ | |
| | sed -n 's/.*"browser_download_url": *"\([^"]*\)".*/\1/p' \ | |
| | head -n1) | |
| [ -n "$asset_url" ] || die "No release asset found for $repo $tag (expected a single arch-independent .apk)." | |
| asset_base=$(basename "$asset_url") | |
| new_ver=$(printf '%s' "$asset_base" | sed "s/^${pkg_name}_//; s/\.apk\$//") | |
| fi | |
| asset_name=$(basename "$asset_url") | |
| log "Matched asset: $asset_name" | |
| installed_ver="" | |
| if command -v apk >/dev/null 2>&1; then | |
| installed_ver=$(apk list -I 2>/dev/null | sed -n "s/^${pkg_name}-\([^ ]*\) .*/\1/p" | head -n1) | |
| fi | |
| if [ -n "$installed_ver" ]; then | |
| log "Installed version: $installed_ver" | |
| else | |
| log "Installed version: (not installed)" | |
| fi | |
| if [ -z "$installed_ver" ]; then | |
| cmp="lt" | |
| elif is_prerelease_str "$installed_ver" && [ "$is_prerelease" != "true" ]; then | |
| # Installed looks like an alpha/beta/rc build and the fetched | |
| # release is a stable one - never auto-"update" over that (see | |
| # is_prerelease_str for why we don't trust vercmp here). | |
| log "Installed version ($installed_ver) looks like a pre-release build; the fetched release ($new_ver) is stable, so it's not being treated as an update." | |
| cmp="gt" | |
| else | |
| cmp=$(vercmp "$installed_ver" "$new_ver") | |
| fi | |
| case "$cmp" in | |
| eq) | |
| log "Already up to date." | |
| if [ "$CHECK_ONLY" -eq 1 ]; then | |
| return 0 | |
| fi | |
| if [ "$FORCE" -ne 1 ]; then | |
| log "Skipping install (use --force to reinstall anyway)." | |
| return 0 | |
| fi | |
| log "--force given: reinstalling $new_ver." | |
| ;; | |
| gt) | |
| log "Installed version ($installed_ver) is newer than the latest release ($new_ver) - not downgrading." | |
| if [ "$CHECK_ONLY" -eq 1 ]; then | |
| return 0 | |
| fi | |
| if [ "$FORCE" -ne 1 ]; then | |
| log "Skipping install (use --force to install anyway)." | |
| return 0 | |
| fi | |
| log "--force given: proceeding with downgrade to $new_ver." | |
| ;; | |
| lt|unknown) | |
| if [ "$cmp" = "unknown" ]; then | |
| log "Could not determine version ordering (apk not available?) - treating as update available." | |
| fi | |
| if [ "$CHECK_ONLY" -eq 1 ]; then | |
| log "Update available: ${installed_ver:-none} -> $new_ver" | |
| return 0 | |
| fi | |
| ;; | |
| esac | |
| dest="${TMP_DIR}/${asset_name}" | |
| log "Downloading to $dest ..." | |
| fetch_file "$asset_url" "$dest" || die "Download failed for $repo." | |
| [ -s "$dest" ] || die "Downloaded file is empty: $dest" | |
| log "Installing $asset_name (unsigned/third-party package: using --allow-untrusted) ..." | |
| install_ok=1 | |
| apk add --allow-untrusted "$dest" || install_ok=0 | |
| if [ "$KEEP_FILE" -ne 1 ]; then | |
| rm -f "$dest" | |
| fi | |
| [ "$install_ok" -eq 1 ] || die "apk install failed for $repo." | |
| log "Done. Installed $pkg_name $new_ver." | |
| } | |
| process_pkg "$REPO" "$PKG_NAME" "arch" "mwan3 (core)" | |
| if [ "$INSTALL_LUCI" -eq 1 ]; then | |
| process_pkg "$LUCI_REPO" "$LUCI_PKG_NAME" "noarch" "luci-app-mwan3 (LuCI UI)" | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment