Skip to content

Instantly share code, notes, and snippets.

@jamesmacwhite
Last active July 19, 2026 10:05
Show Gist options
  • Select an option

  • Save jamesmacwhite/7f6463ae7b307692c26a81be5cddbdcf to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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