diff --git a/.github/workflows/openwrt-package.yml b/.github/workflows/openwrt-package.yml index ab160d29..e108c001 100644 --- a/.github/workflows/openwrt-package.yml +++ b/.github/workflows/openwrt-package.yml @@ -249,7 +249,9 @@ jobs: needs: [package, package-luci] runs-on: ubuntu-24.04 container: - image: alpine:3.21 + # apk index needs apk-tools 3.x to read OpenWrt 25 .apk packages; + # alpine:3.21 ships 2.14 and fails every package with IO ERROR (#296). + image: alpine:edge if: github.event_name != 'workflow_dispatch' || inputs.version != '' permissions: contents: write @@ -257,7 +259,7 @@ jobs: steps: - name: Install dependencies run: | - apk add --no-cache git nodejs npm openssl abuild apk-tools curl github-cli + apk add --no-cache git nodejs npm openssl curl github-cli zstd tar bash - uses: actions/checkout@v4 @@ -285,21 +287,30 @@ jobs: - name: Set up signing key env: - APK_SIGN_KEY: ${{ secrets.APK_SIGN_KEY }} + NETGRIP_FEED_KEY: ${{ secrets.NETGRIP_FEED_KEY }} run: | - if [ -z "$APK_SIGN_KEY" ]; then - echo "::error::APK_SIGN_KEY secret not set. Generate a key with: abuild-keygen -n" - echo "::error::Then add the private key content as APK_SIGN_KEY in repo secrets." + if [ -z "$NETGRIP_FEED_KEY" ]; then + echo "::error::NETGRIP_FEED_KEY secret not set. Generate a key with:" + echo "::error:: usign -G -s netgrip-feed-sec -p deploy/openwrt/keys/netgrip-feed.pub" + echo "::error::Then add the secret key content as NETGRIP_FEED_KEY in repo secrets." exit 1 fi mkdir -p ~/.abuild - echo "$APK_SIGN_KEY" > ~/.abuild/netgrip-signing-key.rsa - chmod 600 ~/.abuild/netgrip-signing-key.rsa + echo "$NETGRIP_FEED_KEY" > ~/.abuild/netgrip-feed-sec + chmod 600 ~/.abuild/netgrip-feed-sec - name: Sign APKs and generate index run: | chmod +x deploy/openwrt/sign-apk.sh - ./deploy/openwrt/sign-apk.sh feed ~/.abuild/netgrip-signing-key.rsa + # The feed index must be built with the OpenWrt apk-tools: the + # Alpine one (also 3.x) speaks a different package format and + # fails on every apk with "file format not supported" (#296). + SDK_URL_DIR="https://downloads.openwrt.org/releases/25.12.5/targets/x86/64/" + SDK_NAME="$(curl -fsSL "$SDK_URL_DIR" | grep -o 'openwrt-sdk-[^"]*\.tar\.zst' | head -1)" + curl -fsSL "${SDK_URL_DIR}${SDK_NAME}" -o sdk.tar.zst + tar --zstd -xf sdk.tar.zst + export PATH="${PWD}/${SDK_NAME%.tar.zst}/staging_dir/host/bin:$PATH" + ./deploy/openwrt/sign-apk.sh feed ~/.abuild/netgrip-feed-sec deploy/openwrt/keys/netgrip-feed.pub - name: Upload feed artifact uses: actions/upload-artifact@v4 diff --git a/cmd/netgrip/main.go b/cmd/netgrip/main.go index ff90acab..7d038a4f 100644 --- a/cmd/netgrip/main.go +++ b/cmd/netgrip/main.go @@ -16,9 +16,14 @@ var version = "dev" func main() { listen := flag.String("listen", "0.0.0.0", "listen address") + showVersion := flag.Bool("version", false, "print version and exit") port := flag.Int("port", 8090, "listen port") rpcdURL := flag.String("rpcd-url", auth.DefaultRPCdURL, "rpcd JSON-RPC endpoint used for login validation") flag.Parse() + if *showVersion { + fmt.Println(version) + return + } // The flag always has a value (its default), so only treat it as an // explicit override when it differs from the default endpoint. diff --git a/deploy/openwrt/keys/netgrip-feed.pub b/deploy/openwrt/keys/netgrip-feed.pub new file mode 100644 index 00000000..606775b2 --- /dev/null +++ b/deploy/openwrt/keys/netgrip-feed.pub @@ -0,0 +1,2 @@ +untrusted comment: netgrip apk feed +RWT74wNSfNVjV5+i6KacA0hd9nHVN17YDGuZ0nH/Jx5rgjadiww/WyQv diff --git a/deploy/openwrt/netgrip/Makefile b/deploy/openwrt/netgrip/Makefile index 33600113..a7e14c9b 100644 --- a/deploy/openwrt/netgrip/Makefile +++ b/deploy/openwrt/netgrip/Makefile @@ -32,6 +32,7 @@ define Package/netgrip/install $(INSTALL_BIN) ./files/etc/init.d/netgrip $(1)/etc/init.d/netgrip $(INSTALL_DIR) $(1)/usr/libexec $(INSTALL_BIN) ./files/usr/libexec/netgrip-restore-rules $(1)/usr/libexec/netgrip-restore-rules + $(INSTALL_BIN) ./files/usr/libexec/netgrip-heal-register $(1)/usr/libexec/netgrip-heal-register endef define Package/netgrip/postinst @@ -75,7 +76,7 @@ if [ -z "$${IPKG_INSTROOT}" ]; then # Survive sysupgrades: the apk registry does not survive, but the # preserved files do, so procd starts the panel on first boot. # /etc/netgrip/ keeps the netpulse embedded-agent env across upgrades. - for f in /usr/sbin/netgrip /etc/init.d/netgrip /etc/rc.d/S99netgrip /usr/libexec/netgrip-restore-rules /etc/netgrip/; do + for f in /usr/sbin/netgrip /etc/init.d/netgrip /etc/rc.d/S99netgrip /usr/libexec/netgrip-restore-rules /usr/libexec/netgrip-heal-register /etc/netgrip/; do grep -qxF "$$f" /etc/sysupgrade.conf 2>/dev/null || echo "$$f" >> /etc/sysupgrade.conf done fi diff --git a/deploy/openwrt/netgrip/files/netgrip.init b/deploy/openwrt/netgrip/files/netgrip.init index a014228b..55ca3b18 100644 --- a/deploy/openwrt/netgrip/files/netgrip.init +++ b/deploy/openwrt/netgrip/files/netgrip.init @@ -25,4 +25,8 @@ start() { start_service # Reapply MAC ACL and storm control rules after boot [ -x /usr/libexec/netgrip-restore-rules ] && /usr/libexec/netgrip-restore-rules & + # Restore the package registry entry after attended sysupgrades (#296): + # owut/ASU drops packages outside official feeds from the image, files + # survive via /etc/sysupgrade.conf but the apk/opkg registry does not. + [ -x /usr/libexec/netgrip-heal-register ] && /usr/libexec/netgrip-heal-register & } diff --git a/deploy/openwrt/netgrip/files/usr/libexec/netgrip-heal-register b/deploy/openwrt/netgrip/files/usr/libexec/netgrip-heal-register new file mode 100755 index 00000000..fb23a390 --- /dev/null +++ b/deploy/openwrt/netgrip/files/usr/libexec/netgrip-heal-register @@ -0,0 +1,114 @@ +#!/bin/sh +# netgrip-heal-register: restore the package registry entry after an +# attended sysupgrade (#296). +# +# owut/ASU only builds images with packages from official feeds, so an +# attended upgrade drops netgrip from the image. The files survive the +# flash via /etc/sysupgrade.conf (postinst adds them) and procd starts the +# panel on first boot, but the apk/opkg registry entry is gone: the package +# stops being "installed" and package managers never upgrade it again. +# +# This script runs in the background from the init script and reinstalls +# the running version from the latest GitHub release: +# - apk (OpenWrt 25): the matching netgrip--r1-.apk asset, +# installed with --allow-untrusted (custom feeds would need a +# PGP-signed packages.adb, which the CI cannot produce yet) +# - opkg (OpenWrt 24): the matching netgrip_-1_.ipk asset +# Dev builds newer than the latest release are left alone. Failures are +# logged and retried on the next boot. Never blocks the panel. + +PKG=netgrip +BIN=/usr/sbin/netgrip +RELEASES_API=https://api.github.com/repos/gnacho/netgrip/releases/latest + +log() { logger -t netgrip-heal "$*"; } + +lock=/var/run/netgrip-heal.pid +if [ -f "$lock" ] && kill -0 "$(cat "$lock" 2>/dev/null)" 2>/dev/null; then + exit 0 +fi +echo $$ > "$lock" +trap 'rm -f "$lock"' EXIT INT TERM + +registered() { + if command -v apk >/dev/null 2>&1; then + apk info "$PKG" >/dev/null 2>&1 + else + opkg list-installed "$PKG" 2>/dev/null | grep -q . + fi +} + +if registered; then + exit 0 +fi + +ver=$("$BIN" -version 2>/dev/null) +log "registry entry lost (running ${ver:-unknown}): restoring" + +# Resolve the release asset for this flavor and architecture. +if command -v apk >/dev/null 2>&1; then + arch=$(apk --print-arch 2>/dev/null) + case "$arch" in + aarch64*) asset=netgrip-*-arm64.apk ;; + x86_64*) asset=netgrip-*-amd64.apk ;; + *) log "no release asset for apk arch $arch"; exit 1 ;; + esac + pkgfile=/tmp/netgrip-heal.apk +else + arch=$(opkg print-architecture 2>/dev/null | awk 'tolower($1)=="arch"{print $2; exit}') + [ -n "$arch" ] || arch=$(opkg status 2>/dev/null | sed -n 's/^Architecture: *//p' | head -n1) + [ -n "$arch" ] || { log "cannot determine architecture"; exit 1; } + asset="netgrip*_${arch}.ipk" + pkgfile=/tmp/netgrip-heal.ipk +fi + +# Wait for WAN, up to ~3 minutes. +i=0 +while [ "$i" -lt 36 ]; do + if wget -q -O /dev/null -T 5 https://api.github.com 2>/dev/null; then + break + fi + sleep 5 + i=$((i + 1)) +done + +json=$(wget -q -O - -T 15 "$RELEASES_API" 2>/dev/null) +[ -n "$json" ] || { log "cannot reach GitHub releases API"; exit 1; } + +url=$(printf '%s' "$json" | tr ',' '\n' | grep -o '"browser_download_url": *"[^"]*"' | cut -d'"' -f4 | grep -E "/$(printf '%s' "$asset" | sed 's/\*/[^/]*/g')$" | head -n1) +[ -n "$url" ] || { log "no release asset matching $asset"; exit 1; } + +# Never downgrade a dev build: only heal when the asset matches the +# running version. +if [ -n "$ver" ]; then + case "$url" in + *"netgrip-${ver}-"*|*"netgrip_${ver}-"*) ;; + *) + log "latest release does not match running $ver: skipping (reinstall manually)" + exit 0 + ;; + esac +fi + +if ! wget -q -O "$pkgfile" -T 120 "$url"; then + rm -f "$pkgfile" + log "download failed: $url" + exit 1 +fi + +if command -v apk >/dev/null 2>&1; then + if apk add --allow-untrusted "$pkgfile" >/dev/null 2>&1; then + rm -f "$pkgfile" + log "restored from $url" + exit 0 + fi +else + if opkg install "$pkgfile" >/dev/null 2>&1; then + rm -f "$pkgfile" + log "restored from $url" + exit 0 + fi +fi +rm -f "$pkgfile" +log "install failed" +exit 1 diff --git a/deploy/openwrt/package.sh b/deploy/openwrt/package.sh index d58aa2c8..bba579ee 100755 --- a/deploy/openwrt/package.sh +++ b/deploy/openwrt/package.sh @@ -69,6 +69,8 @@ cp "$REPO_ROOT/deploy/openwrt/netgrip/files/netgrip.init" "$PKG_DIR/etc/init.d/n chmod 755 "$PKG_DIR/etc/init.d/netgrip" cp "$REPO_ROOT/deploy/openwrt/netgrip/files/netgrip-restore-rules" "$PKG_DIR/usr/libexec/netgrip-restore-rules" chmod 755 "$PKG_DIR/usr/libexec/netgrip-restore-rules" +cp "$REPO_ROOT/deploy/openwrt/netgrip/files/usr/libexec/netgrip-heal-register" "$PKG_DIR/usr/libexec/netgrip-heal-register" +chmod 755 "$PKG_DIR/usr/libexec/netgrip-heal-register" # CONTROL files cat > "$PKG_DIR/CONTROL/control" << CTRL @@ -125,7 +127,7 @@ fi # Survive sysupgrades: the apk registry does not survive, but the # preserved files do, so procd starts the panel on first boot. # /etc/netgrip/ keeps the netpulse embedded-agent env across upgrades. -for f in /usr/sbin/netgrip /etc/init.d/netgrip /etc/rc.d/S99netgrip /usr/libexec/netgrip-restore-rules /etc/netgrip/; do +for f in /usr/sbin/netgrip /etc/init.d/netgrip /etc/rc.d/S99netgrip /usr/libexec/netgrip-restore-rules /usr/libexec/netgrip-heal-register /etc/netgrip/; do grep -qxF "$f" /etc/sysupgrade.conf 2>/dev/null || echo "$f" >> /etc/sysupgrade.conf done exit 0 diff --git a/deploy/openwrt/sign-apk.sh b/deploy/openwrt/sign-apk.sh index 36a25837..b1d255be 100755 --- a/deploy/openwrt/sign-apk.sh +++ b/deploy/openwrt/sign-apk.sh @@ -1,22 +1,34 @@ #!/bin/sh -# sign-apk.sh - Generates and signs a signed APKINDEX for OpenWrt .apk feeds. +# sign-apk.sh - Builds the OpenWrt 25 .apk feed index and signs it. # # Usage: -# ./deploy/openwrt/sign-apk.sh [output-dir] [private-key] +# sign-apk.sh [output-dir] [usign-secret-key] [public-key] # -# Requires: apk-tools (3.x), abuild-sign +# Requires: apk-tools 3 built for OpenWrt (the SDK host build; the Alpine +# apk-tools speaks a different package format) and usign, both under +# staging_dir/host/bin of an extracted OpenWrt SDK. # -# For apk v3 (OpenWrt 25.12+) packages are pre-built by the SDK and do not need -# per-package repacking/signature. The feed only needs a signed APKINDEX.tar.gz. -# This script: -# 1. Generates APKINDEX.tar.gz from all *.apk in output-dir -# 2. Signs APKINDEX.tar.gz with the private key -# 3. Extracts the matching public key into output-dir +# Produces in output-dir: +# packages.adb - repository index (v3), the file repositories.d +# entries must point at +# packages.adb.asc - usign detached signature of the index +# - the matching public key, for /etc/apk/keys +# +# The release apks are signed by the SDK build keys, which this run does +# not trust; the index is built with --allow-untrusted and the trust lives +# in the usign signature of packages.adb. +# +# NOTE (#296): OpenWrt 25.12 routers currently only verify indexes signed +# with the official OpenWrt build-system PGP key, so third-party adb feeds +# are not verifiable on-device yet. The structure published here is the +# correct feed layout; routers can still install the apks directly with +# `apk add --allow-untrusted`, which is what netgrip-heal-register does. set -eu out_dir="${1:-feed}" -privkey="${2:-~/.abuild/netgrip-signing-key.rsa}" +privkey="${2:?usage: sign-apk.sh }" +pubkey="${3:?usage: sign-apk.sh }" if [ ! -d "$out_dir" ]; then echo "Error: output directory not found: $out_dir" >&2 @@ -24,23 +36,31 @@ if [ ! -d "$out_dir" ]; then fi if [ ! -f "$privkey" ]; then - echo "Error: Private key not found: $privkey" >&2 + echo "Error: usign secret key not found: $privkey" >&2 + exit 1 +fi + +if [ ! -f "$pubkey" ]; then + echo "Error: public key not found: $pubkey" >&2 exit 1 fi +# Resolve inputs to absolute paths before changing directory. +out_dir="$(cd "$(dirname "$out_dir")" && pwd)/$(basename "$out_dir")" +pubkey="$(cd "$(dirname "$pubkey")" && pwd)/$(basename "$pubkey")" + cd "$out_dir" -echo "==> Generating APKINDEX" -apk index -o APKINDEX.tar.gz --description "NetGrip $(date +%Y-%m-%d)" *.apk || { - echo "Warning: apk index failed, continuing without index" >&2 - exit 0 -} +echo "==> Generating packages.adb" +# Fail closed: a feed without a signed index is a broken feed. This used +# to warn and exit 0, which published apk files nobody could install (#296). +apk mkndx --allow-untrusted -o packages.adb *.apk -echo "==> Signing APKINDEX" -abuild-sign -k "$privkey" APKINDEX.tar.gz +echo "==> Signing packages.adb" +usign -S -s "$privkey" -m packages.adb -x packages.adb.asc -echo "==> Extracting public key" -openssl rsa -in "$privkey" -pubout -out netgrip.rsa.pub 2>/dev/null +echo "==> Publishing public key" +cp "$pubkey" . echo "==> Done" -echo "Signed index: $out_dir/APKINDEX.tar.gz" +echo "Index: $out_dir/packages.adb (signed: packages.adb.asc)" diff --git a/netgrip b/netgrip new file mode 100755 index 00000000..ee8ebb8c Binary files /dev/null and b/netgrip differ