From d59d6864f0aed3923b5627cfed95e91f4a27fb3d Mon Sep 17 00:00:00 2001 From: Tomaz Zaman Date: Sun, 16 Aug 2026 10:53:59 +0200 Subject: [PATCH 64/72] mono: rework the OTA updater as `mono-update` with a LuCI page Rename mono-update-check -> a single source dir package/mono/updater that builds two packages: - mono-update: CLI, now flag-driven --check report availability (what cron runs) --install verify signature+hash, then sysupgrade (no args) usage - luci-app-mono-update: a System > Updates page with Check / Install buttons, driving the CLI via a small rpcd object. Trust model, anti-rollback floor and RAM guard are unchanged; the flag split just separates "cron notifies" from "install on demand". Cron still honours mono-update.check.mode=auto (runs --install) and drops any stale mono-update-check crontab line on upgrade. Remove the non-functional luci-app-attendedsysupgrade (+ common dep): the public ASU server has no mono_gateway-dk image, so that page only ever spun. Fix a latent e2e assertion ("not newer than floor" vs the client's actual "older than floor"); the full signed-path e2e now passes. Add a uci-defaults hook (99-mono-tailscale-enable) that re-applies tailscale's boot-enable from preserved config after each sysupgrade (the rc.d symlink lives in the flash-wiped rootfs); default on, opt out via tailscale.settings.auto_enable=0. Pin the SELinux policy for both updater entry paths (monoupdatesysagent.cil): confine the rpcd/LuCI path with an rpc.server -> monoupdate subj_type_transition (it was execute_no_trans, running the whole updater in rpc.server.subj), and grant the domain the jsonfilter/uci/functions.sh/urandom/sysinfo/tmp access surfaced by the permissive AVCs. The --install -> sysupgrade transition stays a TODO (can't be exercised until the server offers a newer release than the DUT). Co-Authored-By: Claude Opus 4.8 --- package/mono/mono-update-check/Makefile | 66 ------ .../files/90-mono-update-cron | 10 - .../mono-update-check/files/mono-update-check | 172 -------------- package/mono/updater/Makefile | 93 ++++++++ .../mono/updater/files/90-mono-update-cron | 24 ++ package/mono/updater/files/luci/acl.json | 15 ++ package/mono/updater/files/luci/menu.json | 13 ++ .../mono/updater/files/luci/rpcd-mono-update | 53 +++++ .../updater/files/luci/view/mono-update.js | 144 ++++++++++++ .../files/mono-release.pub | 0 .../files/mono-rotation.pub | 0 package/mono/updater/files/mono-update | 216 ++++++++++++++++++ .../files/mono-update.config | 0 .../files/mono-update.keep | 0 .../tests/test_downgrade_e2e.sh | 30 +-- .../tests/test_publish_gate.sh | 6 +- .../tests/test_version_logic.sh | 0 .../files/cil/monoupdatesysagent.cil | 60 ++++- scripts/mono-publish-release.sh | 2 +- scripts/mono-update.sh | 10 +- .../etc/uci-defaults/99-mono-tailscale-enable | 18 ++ target/linux/layerscape/image/armv8_64b.mk | 4 +- 22 files changed, 652 insertions(+), 284 deletions(-) delete mode 100644 package/mono/mono-update-check/Makefile delete mode 100644 package/mono/mono-update-check/files/90-mono-update-cron delete mode 100644 package/mono/mono-update-check/files/mono-update-check create mode 100644 package/mono/updater/Makefile create mode 100755 package/mono/updater/files/90-mono-update-cron create mode 100644 package/mono/updater/files/luci/acl.json create mode 100644 package/mono/updater/files/luci/menu.json create mode 100755 package/mono/updater/files/luci/rpcd-mono-update create mode 100644 package/mono/updater/files/luci/view/mono-update.js rename package/mono/{mono-update-check => updater}/files/mono-release.pub (100%) rename package/mono/{mono-update-check => updater}/files/mono-rotation.pub (100%) create mode 100755 package/mono/updater/files/mono-update rename package/mono/{mono-update-check => updater}/files/mono-update.config (100%) rename package/mono/{mono-update-check => updater}/files/mono-update.keep (100%) rename package/mono/{mono-update-check => updater}/tests/test_downgrade_e2e.sh (88%) rename package/mono/{mono-update-check => updater}/tests/test_publish_gate.sh (89%) rename package/mono/{mono-update-check => updater}/tests/test_version_logic.sh (100%) create mode 100644 target/linux/layerscape/base-files/etc/uci-defaults/99-mono-tailscale-enable diff --git a/package/mono/mono-update-check/Makefile b/package/mono/mono-update-check/Makefile deleted file mode 100644 index 2c99492f2f..0000000000 --- a/package/mono/mono-update-check/Makefile +++ /dev/null @@ -1,66 +0,0 @@ -include $(TOPDIR)/rules.mk - -PKG_NAME:=mono-update-check -PKG_VERSION:=1.0 -PKG_RELEASE:=5 -PKG_LICENSE:=GPL-2.0 - -include $(INCLUDE_DIR)/package.mk - -define Package/mono-update-check - SECTION:=utils - CATEGORY:=Utilities - TITLE:=Mono Gateway update checker - # usign: verify the release signature. ca-bundle: validate the server - # TLS cert on the https fetch (both are the update trust chain, so they - # are hard deps, not left to transitive luck from other packages). - DEPENDS:=@TARGET_layerscape +usign +ca-bundle +curl -endef - -define Package/mono-update-check/description - Daily check of a mono firmware server (latest.json) against the - image's baked release tag. Disabled until uci sets the server URL: - uci set mono-update.check.url='https://.../openwrt' - mode 'notify' (default) logs availability; mode 'auto' verifies the - release signature (usign) against the baked public key, then the image - hash against the signed hash list, then applies sysupgrade unattended. -endef - -define Build/Compile -endef - -# Bake the release identity this image was built as. The mono-update.sh -# release flow tags the tree before building; developer builds get 'dev' -# and the checker treats any difference as an available update. -define Build/Prepare - mkdir -p $(PKG_BUILD_DIR) - git -C $(TOPDIR) describe --tags --match 'mono-v*' --abbrev=0 \ - > $(PKG_BUILD_DIR)/mono_release 2>/dev/null || \ - echo "dev" > $(PKG_BUILD_DIR)/mono_release -endef - -define Package/mono-update-check/conffiles -/etc/config/mono-update -endef - -define Package/mono-update-check/install - $(INSTALL_DIR) $(1)/etc - $(INSTALL_DATA) $(PKG_BUILD_DIR)/mono_release $(1)/etc/mono_release - $(INSTALL_DIR) $(1)/etc/config - $(INSTALL_CONF) ./files/mono-update.config $(1)/etc/config/mono-update - $(INSTALL_DIR) $(1)/usr/sbin - $(INSTALL_BIN) ./files/mono-update-check $(1)/usr/sbin/ - $(INSTALL_DIR) $(1)/etc/uci-defaults - $(INSTALL_BIN) ./files/90-mono-update-cron $(1)/etc/uci-defaults/ - # Trusted release signing keys (public halves): the primary key used - # nightly, plus an offline rotation key so a lost primary never strands - # the fleet. Private halves never leave the release host / offline media. - $(INSTALL_DIR) $(1)/etc/mono-keys - $(INSTALL_DATA) ./files/mono-release.pub $(1)/etc/mono-keys/mono-release.pub - $(INSTALL_DATA) ./files/mono-rotation.pub $(1)/etc/mono-keys/mono-rotation.pub - # Preserve the anti-rollback floor across sysupgrade. - $(INSTALL_DIR) $(1)/lib/upgrade/keep.d - $(INSTALL_DATA) ./files/mono-update.keep $(1)/lib/upgrade/keep.d/mono-update -endef - -$(eval $(call BuildPackage,mono-update-check)) diff --git a/package/mono/mono-update-check/files/90-mono-update-cron b/package/mono/mono-update-check/files/90-mono-update-cron deleted file mode 100644 index 6c9e2b875b..0000000000 --- a/package/mono/mono-update-check/files/90-mono-update-cron +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -# Daily update check at a fixed-per-device random minute/hour so a fleet -# doesn't stampede the server at midnight. -grep -q mono-update-check /etc/crontabs/root 2>/dev/null && exit 0 -M=$(( $(hexdump -n2 -e '"%u"' /dev/urandom) % 60 )) -H=$(( $(hexdump -n2 -e '"%u"' /dev/urandom) % 24 )) -mkdir -p /etc/crontabs -echo "$M $H * * * /usr/sbin/mono-update-check" >> /etc/crontabs/root -/etc/init.d/cron restart 2>/dev/null -exit 0 diff --git a/package/mono/mono-update-check/files/mono-update-check b/package/mono/mono-update-check/files/mono-update-check deleted file mode 100644 index 428f0e6663..0000000000 --- a/package/mono/mono-update-check/files/mono-update-check +++ /dev/null @@ -1,172 +0,0 @@ -#!/bin/sh -# Compare the image's baked release tag against the server's latest.json. -# notify (default): report availability. No download, no flash. -# auto: verify + sysupgrade unattended. -# -# Trust model: latest.json is SIGNED (usign -> latest.json.sig) and verified -# against the baked public keys in /etc/mono-keys BEFORE any field in it is -# trusted. The freshness / anti-rollback decision AND the image hash all come -# from that verified document - never from unsigned bytes. Signing only a -# per-release hash list would leave the version unbound, letting anyone who -# controls the update URL advertise an inflated tag pointing at an old, -# still-validly-signed image and force a downgrade. A persistent floor -# (/etc/mono-update.state, preserved across sysupgrade) records the newest tag -# ever installed, so a validly-signed OLDER release is refused. -# -# Key rotation / leak recovery is NOT done here: it is a release-side action - -# ship a release signed by the offline rotation key that removes the -# compromised key from /etc/mono-keys and installs a replacement. Devices that -# take it stop trusting the old key; the floor stops them being pulled back -# before it. See scripts/mono-sign-release.sh. -# -# Fetches use curl (uclient-fetch is unreliable on large HTTPS downloads). -. /lib/functions.sh - -URL=$(uci -q get mono-update.check.url) -MODE=$(uci -q get mono-update.check.mode) -STATE=/tmp/mono-update-available -KEYDIR=/etc/mono-keys -FLOORFILE=/etc/mono-update.state - -# Print to the console AND the system log, so an interactive run is never -# silent and cron output still lands in the log. -say() { logger -t mono-update "$*"; echo "mono-update: $*" >&2; } - -[ -n "$URL" ] || { say "checking disabled - set mono-update.check.url"; exit 0; } - -CURRENT=$(cat /etc/mono_release 2>/dev/null || echo dev) -BOARD=$(board_name) -BOARD=${BOARD%-sdboot} - -# Small files (manifest, signature): bounded wall-clock. The image download -# instead uses a stall timeout (below), so a slow-but-progressing link is not -# cut off while a server that just hangs cannot wedge the daily run forever. -fetch() { curl -fsSL --connect-timeout 30 --max-time 120 -o "$2" "$1"; } -fetchbig() { curl -fsSL --connect-timeout 30 --speed-time 60 --speed-limit 2048 \ - --retry 3 --retry-delay 5 -o "$2" "$1"; } - -# mono-vMAJOR.MINOR.PATCH[-rN] -> "MAJOR MINOR PATCH N" -ver_key() { - v=${1#mono-v} - case "$v" in *-r*) r=${v##*-r};; *) r=0;; esac - v=${v%-r*} - IFS=. read -r a b c _ <<-EOF - $v - EOF - echo "${a:-0} ${b:-0} ${c:-0} ${r:-0}" -} -# is_newer A B -> true (0) when tag A is strictly newer than tag B. -# A non-mono B (e.g. "dev", or a corrupt /etc/mono_release) counts as older -# than everything, so dev builds always see a release as an update. -is_newer() { - case "$2" in mono-v*) ;; *) return 0;; esac - set -- $(ver_key "$1") $(ver_key "$2") - i=1 - while [ $i -le 4 ]; do - eval "rr=\$$i; cc=\$$((i + 4))" - [ "$rr" -gt "$cc" ] && return 0 - [ "$rr" -lt "$cc" ] && return 1 - i=$((i + 1)) - done - return 1 -} - -# --- persistent floor: the highest tag ever installed ------------------ -# The floor never moves backward, so a validly-signed but older release is -# refused. /etc/mono_release resets to the running image's tag on every boot; -# the floor file is the durable memory that survives a downgrade attempt. -state_get() { # $1=key -> value (empty if unset) - [ -f "$FLOORFILE" ] || return 0 - sed -n "s/^$1=//p" "$FLOORFILE" | head -1 -} -FLOOR_TAG=$(state_get floor) -case "$FLOOR_TAG" in mono-v*) ;; *) FLOOR_TAG="$CURRENT";; esac -is_newer "$CURRENT" "$FLOOR_TAG" && FLOOR_TAG="$CURRENT" # max(persisted, current) - -# --- fetch + verify the SIGNED manifest before trusting any field ------ -[ -n "$(ls "$KEYDIR"/*.pub 2>/dev/null)" ] || { say "no keys in $KEYDIR, refusing"; exit 1; } -command -v usign >/dev/null 2>&1 || { say "usign missing, refusing"; exit 1; } - -TMP=/tmp/mono-update -rm -rf "$TMP"; mkdir -p "$TMP" -fetch "$URL/latest.json" "$TMP/latest.json" || { say "cannot reach update server ($URL)"; exit 0; } -fetch "$URL/latest.json.sig" "$TMP/latest.json.sig" || { say "manifest not signed on server - refusing (no update)"; exit 0; } - -ok=0 -for k in "$KEYDIR"/*.pub; do - [ -f "$k" ] || continue - if usign -V -m "$TMP/latest.json" -x "$TMP/latest.json.sig" -p "$k" 2>/dev/null; then - ok=1; break - fi -done -[ "$ok" = 1 ] || { say "latest.json signature FAILED against all trusted keys - refusing"; exit 1; } - -# Every field below now comes from a signature-verified document. -JSON=$(cat "$TMP/latest.json") -FMT=$(echo "$JSON" | jsonfilter -e '@.format_version' 2>/dev/null) -[ "$FMT" = "1" ] || { say "unsupported manifest format_version '$FMT' - refusing"; exit 1; } -TAG=$(echo "$JSON" | jsonfilter -e '@.tag') -# Prefer the gzip-compressed image (~10x smaller download); this firmware -# decompresses and verifies it before flashing. The plain "sysupgrade" keys -# stay pointed at an image the OLDEST deployed flasher can install, so -# pre-gzip devices (which only know those keys) always have a workable -# upgrade path. Take the _gz pair only when both keys are present - never -# mix a URL from one pair with a hash from the other. -SYSUPGRADE_URL=$(echo "$JSON" | jsonfilter -e "@.devices['$BOARD'].sysupgrade_gz") -WANT=$(echo "$JSON" | jsonfilter -e "@.devices['$BOARD'].sha256_gz") -if [ -z "$SYSUPGRADE_URL" ] || [ -z "$WANT" ]; then - SYSUPGRADE_URL=$(echo "$JSON" | jsonfilter -e "@.devices['$BOARD'].sysupgrade") - WANT=$(echo "$JSON" | jsonfilter -e "@.devices['$BOARD'].sha256") -fi - -[ -n "$TAG" ] || { say "malformed manifest (no tag)"; exit 1; } -[ -n "$SYSUPGRADE_URL" ] || { say "no image for board $BOARD in latest.json"; exit 0; } - -# Anti-rollback: refuse anything STRICTLY older than the floor. Equal to the -# floor is not a rollback - the floor is advanced right before flashing, so -# after a crashed or refused flash the device still runs an older release -# with floor == offered tag, and that retry must stay allowed: refusing it -# would pin the device on the old release forever after one failed attempt. -if is_newer "$FLOOR_TAG" "$TAG"; then - rm -f "$STATE" - say "server offers $TAG, older than floor $FLOOR_TAG - keeping $CURRENT" - exit 0 -fi -if ! is_newer "$TAG" "$CURRENT"; then - rm -f "$STATE" - say "up to date ($CURRENT)" - exit 0 -fi - -say "update available: $TAG (running $CURRENT, floor $FLOOR_TAG)" -echo "$TAG" > "$STATE" - -if [ "$MODE" != "auto" ]; then - # Deliberately NOT a 'sysupgrade ' command: sysupgrade does no - # signature or hash check, so following it by hand would flash whatever - # the server serves. Verified installation only happens in auto mode. - say "notify mode - $TAG available; install with 'mono-update.check.mode=auto'" \ - "(verifies signature + hash before flashing). Do not sysupgrade the URL by hand." - exit 0 -fi - -# --- auto mode: RAM-guarded flash (manifest already verified) ---------- -AVAIL=$(awk '/MemAvailable/{print $2}' /proc/meminfo) -[ -n "$AVAIL" ] && [ "$AVAIL" -lt 786432 ] && { say "low memory (${AVAIL}KB), skipping auto-flash"; exit 1; } -[ -n "$WANT" ] || { say "manifest has no sha256 for $BOARD - refusing"; exit 1; } - -fail() { say "$1"; rm -rf "$TMP"; exit 1; } - -say "downloading $TAG (this takes a minute)..." -fetchbig "$SYSUPGRADE_URL" "$TMP/image.bin" || fail "image download failed" -GOT=$(sha256sum "$TMP/image.bin" | cut -d' ' -f1) -[ "$GOT" = "$WANT" ] || fail "image hash mismatch against signed manifest - refusing" - -# Advance the floor BEFORE flashing so a crash mid-upgrade can never later -# accept an image older than this one; retrying THIS tag stays allowed (see -# the anti-rollback gate). Preserved across sysupgrade via keep.d. -printf 'floor=%s\n' "$TAG" > "$FLOORFILE" -sync - -say "verified $TAG - applying via sysupgrade, the device will reboot now" -exec sysupgrade "$TMP/image.bin" diff --git a/package/mono/updater/Makefile b/package/mono/updater/Makefile new file mode 100644 index 0000000000..e1acea199f --- /dev/null +++ b/package/mono/updater/Makefile @@ -0,0 +1,93 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=mono-update +PKG_VERSION:=1.1 +PKG_RELEASE:=1 +PKG_LICENSE:=GPL-2.0 + +include $(INCLUDE_DIR)/package.mk + +define Package/mono-update + SECTION:=utils + CATEGORY:=Utilities + TITLE:=Mono Gateway OTA updater (CLI) + # usign: verify the release signature. ca-bundle: validate the server + # TLS cert on the https fetch (both are the update trust chain, so they + # are hard deps, not left to transitive luck from other packages). + DEPENDS:=@TARGET_layerscape +usign +ca-bundle +curl +endef + +define Package/mono-update/description + On-demand and scheduled OTA updater for Mono Gateway. Compares the image's + baked release tag against a SIGNED latest.json on the update server: + mono-update --check report whether a newer signed release exists + mono-update --install verify signature + image hash, then sysupgrade + A daily cron job runs --check (notify). Disabled until uci sets the URL: + uci set mono-update.check.url='https://openwrt.mono.si' + Set mono-update.check.mode='auto' to have cron run --install unattended. +endef + +define Package/luci-app-mono-update + SECTION:=luci + CATEGORY:=LuCI + SUBMENU:=3. Applications + TITLE:=LuCI page for the Mono Gateway updater + DEPENDS:=+mono-update +luci-base +endef + +define Package/luci-app-mono-update/description + A LuCI "Updates" page under the System menu that drives mono-update from the + web UI: shows the running release, checks the server on a button click, and + applies a signature+hash-verified upgrade (reboots) on confirmation. +endef + +define Build/Compile +endef + +# Bake the release identity this image was built as. The mono-update.sh +# release flow tags the tree before building; developer builds get 'dev' +# and the updater treats any difference as an available update. +define Build/Prepare + mkdir -p $(PKG_BUILD_DIR) + git -C $(TOPDIR) describe --tags --match 'mono-v*' --abbrev=0 \ + > $(PKG_BUILD_DIR)/mono_release 2>/dev/null || \ + echo "dev" > $(PKG_BUILD_DIR)/mono_release +endef + +define Package/mono-update/conffiles +/etc/config/mono-update +endef + +define Package/mono-update/install + $(INSTALL_DIR) $(1)/etc + $(INSTALL_DATA) $(PKG_BUILD_DIR)/mono_release $(1)/etc/mono_release + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/mono-update.config $(1)/etc/config/mono-update + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_BIN) ./files/mono-update $(1)/usr/sbin/ + $(INSTALL_DIR) $(1)/etc/uci-defaults + $(INSTALL_BIN) ./files/90-mono-update-cron $(1)/etc/uci-defaults/ + # Trusted release signing keys (public halves): the primary key used + # nightly, plus an offline rotation key so a lost primary never strands + # the fleet. Private halves never leave the release host / offline media. + $(INSTALL_DIR) $(1)/etc/mono-keys + $(INSTALL_DATA) ./files/mono-release.pub $(1)/etc/mono-keys/mono-release.pub + $(INSTALL_DATA) ./files/mono-rotation.pub $(1)/etc/mono-keys/mono-rotation.pub + # Preserve the anti-rollback floor across sysupgrade. + $(INSTALL_DIR) $(1)/lib/upgrade/keep.d + $(INSTALL_DATA) ./files/mono-update.keep $(1)/lib/upgrade/keep.d/mono-update +endef + +define Package/luci-app-mono-update/install + $(INSTALL_DIR) $(1)/www/luci-static/resources/view + $(INSTALL_DATA) ./files/luci/view/mono-update.js $(1)/www/luci-static/resources/view/mono-update.js + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./files/luci/menu.json $(1)/usr/share/luci/menu.d/luci-app-mono-update.json + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./files/luci/acl.json $(1)/usr/share/rpcd/acl.d/luci-app-mono-update.json + $(INSTALL_DIR) $(1)/usr/libexec/rpcd + $(INSTALL_BIN) ./files/luci/rpcd-mono-update $(1)/usr/libexec/rpcd/mono-update +endef + +$(eval $(call BuildPackage,mono-update)) +$(eval $(call BuildPackage,luci-app-mono-update)) diff --git a/package/mono/updater/files/90-mono-update-cron b/package/mono/updater/files/90-mono-update-cron new file mode 100755 index 0000000000..1649d6e325 --- /dev/null +++ b/package/mono/updater/files/90-mono-update-cron @@ -0,0 +1,24 @@ +#!/bin/sh +# Daily update check at a fixed-per-device random minute/hour so a fleet +# doesn't stampede the server at midnight. The installed line honours +# mono-update.check.mode: 'auto' installs unattended, anything else +# (default 'notify') only checks + logs. +CRON=/etc/crontabs/root +mkdir -p /etc/crontabs + +# Migration: drop any line from a pre-rename package that called the old +# /usr/sbin/mono-update-check binary (which no longer exists). +if [ -f "$CRON" ] && grep -q 'mono-update-check' "$CRON"; then + sed -i '/mono-update-check/d' "$CRON" +fi + +# Idempotent: add our line only once. The trailing space distinguishes the +# new 'mono-update ' invocation from the old 'mono-update-check' name. +if ! grep -q '/usr/sbin/mono-update ' "$CRON" 2>/dev/null; then + M=$(( $(hexdump -n2 -e '"%u"' /dev/urandom) % 60 )) + H=$(( $(hexdump -n2 -e '"%u"' /dev/urandom) % 24 )) + printf '%s %s * * * [ "$(uci -q get mono-update.check.mode)" = auto ] && /usr/sbin/mono-update --install || /usr/sbin/mono-update --check\n' \ + "$M" "$H" >> "$CRON" +fi +/etc/init.d/cron restart 2>/dev/null +exit 0 diff --git a/package/mono/updater/files/luci/acl.json b/package/mono/updater/files/luci/acl.json new file mode 100644 index 0000000000..0386a75885 --- /dev/null +++ b/package/mono/updater/files/luci/acl.json @@ -0,0 +1,15 @@ +{ + "luci-app-mono-update": { + "description": "Grant access to the Mono Gateway updater", + "read": { + "ubus": { + "mono-update": [ "status", "check" ] + } + }, + "write": { + "ubus": { + "mono-update": [ "install" ] + } + } + } +} diff --git a/package/mono/updater/files/luci/menu.json b/package/mono/updater/files/luci/menu.json new file mode 100644 index 0000000000..c432ca78e2 --- /dev/null +++ b/package/mono/updater/files/luci/menu.json @@ -0,0 +1,13 @@ +{ + "admin/system/mono-update": { + "title": "Updates", + "order": 1, + "action": { + "type": "view", + "path": "mono-update" + }, + "depends": { + "acl": [ "luci-app-mono-update" ] + } + } +} diff --git a/package/mono/updater/files/luci/rpcd-mono-update b/package/mono/updater/files/luci/rpcd-mono-update new file mode 100755 index 0000000000..2941f7caf4 --- /dev/null +++ b/package/mono/updater/files/luci/rpcd-mono-update @@ -0,0 +1,53 @@ +#!/bin/sh +# rpcd object 'mono-update' - exposes the mono-update CLI to LuCI. +# status : cheap, no network - running release + last-known availability +# check : run 'mono-update --check' (hits the server), report result +# install : run 'mono-update --install' in the background (reboots the box) +. /usr/share/libubox/jshn.sh + +STATE=/tmp/mono-update-available + +case "$1" in + list) + json_init + json_add_object "status"; json_close_object + json_add_object "check"; json_close_object + json_add_object "install"; json_close_object + json_dump + ;; + call) + case "$2" in + status) + avail=0; [ -f "$STATE" ] && avail=1 + json_init + json_add_string current "$(cat /etc/mono_release 2>/dev/null)" + json_add_boolean available "$avail" + json_add_string tag "$(cat "$STATE" 2>/dev/null)" + json_add_string url "$(uci -q get mono-update.check.url)" + json_add_string mode "$(uci -q get mono-update.check.mode)" + json_dump + ;; + check) + out=$(/usr/sbin/mono-update --check 2>&1) + rc=$? + avail=0; [ -f "$STATE" ] && avail=1 + json_init + json_add_int code "$rc" + json_add_boolean available "$avail" + json_add_string tag "$(cat "$STATE" 2>/dev/null)" + json_add_string current "$(cat /etc/mono_release 2>/dev/null)" + json_add_string log "$out" + json_dump + ;; + install) + # --install execs sysupgrade and reboots, so it never returns + # on success. Detach it so rpcd can still answer this call + # before the device goes down. + start-stop-daemon -S -b -x /usr/sbin/mono-update -- --install + json_init + json_add_string status "started" + json_dump + ;; + esac + ;; +esac diff --git a/package/mono/updater/files/luci/view/mono-update.js b/package/mono/updater/files/luci/view/mono-update.js new file mode 100644 index 0000000000..8a204fd0c0 --- /dev/null +++ b/package/mono/updater/files/luci/view/mono-update.js @@ -0,0 +1,144 @@ +'use strict'; +'require view'; +'require dom'; +'require rpc'; +'require ui'; + +var callStatus = rpc.declare({ object: 'mono-update', method: 'status', expect: {} }); +var callCheck = rpc.declare({ object: 'mono-update', method: 'check', expect: {} }); +var callInstall = rpc.declare({ object: 'mono-update', method: 'install', expect: {} }); + +return view.extend({ + // This page drives an external tool - no config form to save/apply. + handleSaveApply: null, + handleSave: null, + handleReset: null, + + load: function() { + return callStatus().catch(function() { return {}; }); + }, + + // Fill the summary node + toggle the Install button from a status object + // ({ current, url, available, tag }). Works on a detached node too, so it + // can run synchronously during render() as well as after a check. + renderStatus: function(st) { + var rows = [ + E('tr', {}, [ + E('td', { 'width': '33%' }, _('Running release')), + E('td', {}, E('strong', {}, st.current || _('unknown'))) + ]), + E('tr', {}, [ + E('td', {}, _('Update server')), + E('td', {}, st.url + ? E('code', {}, st.url) + : E('em', {}, _('not configured (set mono-update.check.url)'))) + ]) + ]; + + if (st.available && st.tag) + rows.push(E('tr', {}, [ + E('td', {}, _('Available')), + E('td', {}, E('strong', { 'style': 'color:#e08000' }, st.tag)) + ])); + + dom.content(this.summaryNode, E('table', { 'class': 'table' }, rows)); + + this.lastTag = (st.available && st.tag) ? st.tag : null; + this.installBtn.style.display = this.lastTag ? '' : 'none'; + }, + + handleCheck: function(ev) { + var self = this; + var b = ev.currentTarget; + b.classList.add('spinning'); + b.disabled = true; + + return callCheck().then(function(res) { + self.renderStatus({ + current: res.current, + url: self.stURL, + available: res.available, + tag: res.tag + }); + self.logNode.style.display = ''; + self.logNode.textContent = (res.log || '').trim() || + (res.available ? _('Update available: %s').format(res.tag) + : _('No update available.')); + ui.addNotification(null, E('p', res.available + ? _('Update available: %s').format(res.tag) + : _('Firmware is up to date.')), 'info'); + }).catch(function(e) { + ui.addNotification(null, E('p', _('Update check failed: %s').format(e.message || e)), 'danger'); + }).finally(function() { + b.classList.remove('spinning'); + b.disabled = false; + }); + }, + + handleInstall: function() { + var self = this; + var tag = self.lastTag; + if (!tag) return; + + ui.showModal(_('Install %s').format(tag), [ + E('p', {}, _('This downloads the signed image, verifies its signature and hash, then applies it with sysupgrade. The device will reboot and be unreachable for one to two minutes.')), + E('p', { 'class': 'alert-message warning' }, _('Do not power off the device during the upgrade.')), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'btn', 'click': ui.hideModal }, _('Cancel')), + ' ', + E('button', { + 'class': 'btn cbi-button-negative', + 'click': ui.createHandlerFn(self, 'doInstall') + }, _('Install and reboot')) + ]) + ]); + }, + + doInstall: function() { + return callInstall().then(function() { + ui.showModal(_('Upgrade in progress'), [ + E('p', { 'class': 'spinning' }, _('The device is verifying and flashing the new image, then rebooting. This page will not respond until it is back — wait one to two minutes and reload.')), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'btn', 'click': function() { location.reload(); } }, _('Reload now')) + ]) + ]); + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', _('Could not start the upgrade: %s').format(e.message || e)), 'danger'); + }); + }, + + render: function(st) { + st = st || {}; + this.stURL = st.url || ''; + + this.summaryNode = E('div', {}); + this.installBtn = E('button', { + 'class': 'btn cbi-button cbi-button-negative', + 'style': 'display:none', + 'click': ui.createHandlerFn(this, 'handleInstall') + }, _('Install update')); + this.logNode = E('pre', { 'style': 'display:none;margin-top:1em;white-space:pre-wrap' }); + + // Populate the initial summary synchronously (detached node is fine). + this.renderStatus({ current: st.current, url: this.stURL, available: st.available, tag: st.tag }); + + return E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, _('Firmware Updates')), + E('div', { 'class': 'cbi-map-descr' }, + _('Check the Mono update server for a newer signed release and install it. Updates are verified by signature and hash before flashing.')), + E('div', { 'class': 'cbi-section' }, [ + this.summaryNode, + E('div', { 'style': 'margin-top:1em' }, [ + E('button', { + 'class': 'btn cbi-button cbi-button-action', + 'click': ui.createHandlerFn(this, 'handleCheck') + }, _('Check for updates')), + ' ', + this.installBtn + ]), + this.logNode + ]) + ]); + } +}); diff --git a/package/mono/mono-update-check/files/mono-release.pub b/package/mono/updater/files/mono-release.pub similarity index 100% rename from package/mono/mono-update-check/files/mono-release.pub rename to package/mono/updater/files/mono-release.pub diff --git a/package/mono/mono-update-check/files/mono-rotation.pub b/package/mono/updater/files/mono-rotation.pub similarity index 100% rename from package/mono/mono-update-check/files/mono-rotation.pub rename to package/mono/updater/files/mono-rotation.pub diff --git a/package/mono/updater/files/mono-update b/package/mono/updater/files/mono-update new file mode 100755 index 0000000000..72086c5b68 --- /dev/null +++ b/package/mono/updater/files/mono-update @@ -0,0 +1,216 @@ +#!/bin/sh +# Mono Gateway OTA updater. Compares the image's baked release tag against the +# server's latest.json and, on request, flashes a newer signed release. +# +# mono-update --check report availability only. No download, no flash. +# mono-update --install verify + sysupgrade to the newest signed release. +# mono-update (no args) print usage. +# +# The daily cron job runs '--check' (notify). Set mono-update.check.mode='auto' +# to have cron run '--install' unattended instead; the LuCI "Updates" page runs +# '--install' on demand. The action is chosen by the flag - this script never +# flashes without being told to. +# +# Trust model: latest.json is SIGNED (usign -> latest.json.sig) and verified +# against the baked public keys in /etc/mono-keys BEFORE any field in it is +# trusted. The freshness / anti-rollback decision AND the image hash all come +# from that verified document - never from unsigned bytes. Signing only a +# per-release hash list would leave the version unbound, letting anyone who +# controls the update URL advertise an inflated tag pointing at an old, +# still-validly-signed image and force a downgrade. A persistent floor +# (/etc/mono-update.state, preserved across sysupgrade) records the newest tag +# ever installed, so a validly-signed OLDER release is refused. +# +# Key rotation / leak recovery is NOT done here: it is a release-side action - +# ship a release signed by the offline rotation key that removes the +# compromised key from /etc/mono-keys and installs a replacement. Devices that +# take it stop trusting the old key; the floor stops them being pulled back +# before it. See scripts/mono-sign-release.sh. +# +# Fetches use curl (uclient-fetch is unreliable on large HTTPS downloads). +. /lib/functions.sh + +URL=$(uci -q get mono-update.check.url) +STATE=/tmp/mono-update-available +KEYDIR=/etc/mono-keys +FLOORFILE=/etc/mono-update.state + +# Print to the console AND the system log, so an interactive run is never +# silent and cron output still lands in the log. +say() { logger -t mono-update "$*"; echo "mono-update: $*" >&2; } + +usage() { + cat >&2 <<-EOF + Usage: mono-update + + --check Check the update server and report whether a newer signed + release is available. Does not download or flash anything. + --install Check, and if a newer signed release is available, verify + its signature + hash and apply it via sysupgrade (reboots). + --help Show this help. + + Server URL comes from 'uci get mono-update.check.url' + (currently: ${URL:-}). + EOF +} + +# mono-vMAJOR.MINOR.PATCH[-rN] -> "MAJOR MINOR PATCH N" +ver_key() { + v=${1#mono-v} + case "$v" in *-r*) r=${v##*-r};; *) r=0;; esac + v=${v%-r*} + IFS=. read -r a b c _ <<-EOF + $v + EOF + echo "${a:-0} ${b:-0} ${c:-0} ${r:-0}" +} +# is_newer A B -> true (0) when tag A is strictly newer than tag B. +# A non-mono B (e.g. "dev", or a corrupt /etc/mono_release) counts as older +# than everything, so dev builds always see a release as an update. +is_newer() { + case "$2" in mono-v*) ;; *) return 0;; esac + set -- $(ver_key "$1") $(ver_key "$2") + i=1 + while [ $i -le 4 ]; do + eval "rr=\$$i; cc=\$$((i + 4))" + [ "$rr" -gt "$cc" ] && return 0 + [ "$rr" -lt "$cc" ] && return 1 + i=$((i + 1)) + done + return 1 +} + +state_get() { # $1=key -> value (empty if unset) + [ -f "$FLOORFILE" ] || return 0 + sed -n "s/^$1=//p" "$FLOORFILE" | head -1 +} + +fetch() { curl -fsSL --connect-timeout 30 --max-time 120 -o "$2" "$1"; } +fetchbig() { curl -fsSL --connect-timeout 30 --speed-time 60 --speed-limit 2048 \ + --retry 3 --retry-delay 5 -o "$2" "$1"; } + +# evaluate: fetch + verify the SIGNED manifest, then decide whether a newer +# release is on offer. On success with an update available it sets the globals +# TAG / SYSUPGRADE_URL / WANT / CURRENT and returns 0. Returns 1 when there is +# nothing to install (up to date, older-than-floor, no image for board, or a +# soft/transient server problem) and 2 on a hard trust failure (bad signature, +# missing keys) that must abort an --install. +TAG= SYSUPGRADE_URL= WANT= CURRENT= +evaluate() { + [ -n "$URL" ] || { say "checking disabled - set mono-update.check.url"; return 1; } + + CURRENT=$(cat /etc/mono_release 2>/dev/null || echo dev) + BOARD=$(board_name) + BOARD=${BOARD%-sdboot} + + # --- persistent floor: the highest tag ever installed -------------- + # The floor never moves backward, so a validly-signed but older release + # is refused. /etc/mono_release resets to the running image's tag on + # every boot; the floor file is the durable memory that survives a + # downgrade attempt. + FLOOR_TAG=$(state_get floor) + case "$FLOOR_TAG" in mono-v*) ;; *) FLOOR_TAG="$CURRENT";; esac + is_newer "$CURRENT" "$FLOOR_TAG" && FLOOR_TAG="$CURRENT" # max(persisted, current) + + # --- fetch + verify the SIGNED manifest before trusting any field -- + [ -n "$(ls "$KEYDIR"/*.pub 2>/dev/null)" ] || { say "no keys in $KEYDIR, refusing"; return 2; } + command -v usign >/dev/null 2>&1 || { say "usign missing, refusing"; return 2; } + + TMP=/tmp/mono-update + rm -rf "$TMP"; mkdir -p "$TMP" + fetch "$URL/latest.json" "$TMP/latest.json" || { say "cannot reach update server ($URL)"; return 1; } + fetch "$URL/latest.json.sig" "$TMP/latest.json.sig" || { say "manifest not signed on server - refusing (no update)"; return 1; } + + ok=0 + for k in "$KEYDIR"/*.pub; do + [ -f "$k" ] || continue + if usign -V -m "$TMP/latest.json" -x "$TMP/latest.json.sig" -p "$k" 2>/dev/null; then + ok=1; break + fi + done + [ "$ok" = 1 ] || { say "latest.json signature FAILED against all trusted keys - refusing"; return 2; } + + # Every field below now comes from a signature-verified document. + JSON=$(cat "$TMP/latest.json") + FMT=$(echo "$JSON" | jsonfilter -e '@.format_version' 2>/dev/null) + [ "$FMT" = "1" ] || { say "unsupported manifest format_version '$FMT' - refusing"; return 2; } + TAG=$(echo "$JSON" | jsonfilter -e '@.tag') + # Prefer the gzip-compressed image (~10x smaller download); this firmware + # decompresses and verifies it before flashing. The plain "sysupgrade" + # keys stay pointed at an image the OLDEST deployed flasher can install, + # so pre-gzip devices (which only know those keys) always have a workable + # upgrade path. Take the _gz pair only when both keys are present - never + # mix a URL from one pair with a hash from the other. + SYSUPGRADE_URL=$(echo "$JSON" | jsonfilter -e "@.devices['$BOARD'].sysupgrade_gz") + WANT=$(echo "$JSON" | jsonfilter -e "@.devices['$BOARD'].sha256_gz") + if [ -z "$SYSUPGRADE_URL" ] || [ -z "$WANT" ]; then + SYSUPGRADE_URL=$(echo "$JSON" | jsonfilter -e "@.devices['$BOARD'].sysupgrade") + WANT=$(echo "$JSON" | jsonfilter -e "@.devices['$BOARD'].sha256") + fi + + [ -n "$TAG" ] || { say "malformed manifest (no tag)"; return 2; } + [ -n "$SYSUPGRADE_URL" ] || { say "no image for board $BOARD in latest.json"; return 1; } + + # Anti-rollback: refuse anything STRICTLY older than the floor. Equal to + # the floor is not a rollback - the floor is advanced right before + # flashing, so after a crashed or refused flash the device still runs an + # older release with floor == offered tag, and that retry must stay + # allowed: refusing it would pin the device on the old release forever + # after one failed attempt. + if is_newer "$FLOOR_TAG" "$TAG"; then + rm -f "$STATE" + say "server offers $TAG, older than floor $FLOOR_TAG - keeping $CURRENT" + return 1 + fi + if ! is_newer "$TAG" "$CURRENT"; then + rm -f "$STATE" + say "up to date ($CURRENT)" + return 1 + fi + + say "update available: $TAG (running $CURRENT, floor $FLOOR_TAG)" + echo "$TAG" > "$STATE" + return 0 +} + +do_check() { + # --check never flashes; a hard trust failure (2) and "nothing to do" (1) + # both simply mean "no update to offer", so only 0 reports availability. + evaluate || return 0 + say "notify only - run 'mono-update --install' (or the LuCI Updates page)" \ + "to verify + flash $TAG. Do not sysupgrade the URL by hand." + return 0 +} + +do_install() { + rc=0; evaluate || rc=$? + [ "$rc" = 0 ] || return 0 # nothing to install / refused - evaluate already logged why + + # --- RAM-guarded flash (manifest already verified) ----------------- + AVAIL=$(awk '/MemAvailable/{print $2}' /proc/meminfo) + [ -n "$AVAIL" ] && [ "$AVAIL" -lt 786432 ] && { say "low memory (${AVAIL}KB), skipping flash"; return 1; } + [ -n "$WANT" ] || { say "manifest has no sha256 for board - refusing"; return 1; } + + fail() { say "$1"; rm -rf "$TMP"; return 1; } + + say "downloading $TAG (this takes a minute)..." + fetchbig "$SYSUPGRADE_URL" "$TMP/image.bin" || { fail "image download failed"; return 1; } + GOT=$(sha256sum "$TMP/image.bin" | cut -d' ' -f1) + [ "$GOT" = "$WANT" ] || { fail "image hash mismatch against signed manifest - refusing"; return 1; } + + # Advance the floor BEFORE flashing so a crash mid-upgrade can never later + # accept an image older than this one; retrying THIS tag stays allowed + # (see the anti-rollback gate). Preserved across sysupgrade via keep.d. + printf 'floor=%s\n' "$TAG" > "$FLOORFILE" + sync + + say "verified $TAG - applying via sysupgrade, the device will reboot now" + exec sysupgrade "$TMP/image.bin" +} + +case "$1" in + --check) do_check ;; + --install) do_install ;; + -h|--help|"") usage ;; + *) say "unknown command '$1'"; usage; exit 2 ;; +esac diff --git a/package/mono/mono-update-check/files/mono-update.config b/package/mono/updater/files/mono-update.config similarity index 100% rename from package/mono/mono-update-check/files/mono-update.config rename to package/mono/updater/files/mono-update.config diff --git a/package/mono/mono-update-check/files/mono-update.keep b/package/mono/updater/files/mono-update.keep similarity index 100% rename from package/mono/mono-update-check/files/mono-update.keep rename to package/mono/updater/files/mono-update.keep diff --git a/package/mono/mono-update-check/tests/test_downgrade_e2e.sh b/package/mono/updater/tests/test_downgrade_e2e.sh similarity index 88% rename from package/mono/mono-update-check/tests/test_downgrade_e2e.sh rename to package/mono/updater/tests/test_downgrade_e2e.sh index 9cfb29ab9d..f9e0a28eb9 100755 --- a/package/mono/mono-update-check/tests/test_downgrade_e2e.sh +++ b/package/mono/updater/tests/test_downgrade_e2e.sh @@ -1,12 +1,12 @@ #!/bin/bash # End-to-end regression test for the signed-manifest update path. Uses real # usign signatures to prove the client: -# - flashes a genuine, newer, release-signed release (auto mode); +# - on --install, flashes a genuine, newer, release-signed release; # - REFUSES a forged high tag signed by an untrusted key; # - REFUSES a replayed genuine-but-older release (anti-rollback floor); # - REFUSES a post-rollback walk-up (durable floor survives current reset); # - REFUSES a tampered image (hash mismatch under a genuine manifest); -# - in notify mode, reports availability WITHOUT emitting an unverified +# - on --check, reports availability WITHOUT emitting an unverified # 'sysupgrade ' and WITHOUT flashing. # Plus a static packaging-consistency check for the anti-rollback floor file. # @@ -15,8 +15,8 @@ set -u HERE=$(cd "$(dirname "$0")" && pwd) REPO=$(cd "$HERE/../../../.." && pwd) # .../source -PKG="$REPO/package/mono/mono-update-check" -CLIENT="$PKG/files/mono-update-check" +PKG="$REPO/package/mono/updater" +CLIENT="$PKG/files/mono-update" USIGN="$REPO/staging_dir/host/bin/usign" fails=0 @@ -100,15 +100,15 @@ for m in r5 r4 r3; do "$USIGN" -S -m "$S/m_$m.json" -s "$S/release.sec" -x "$S/ "$USIGN" -S -m "$S/m_bad.json" -s "$S/attacker.sec" -x "$S/m_bad.json.sig" publish() { cp "$S/$1" "$SRV/latest.json"; cp "$S/$1.sig" "$SRV/latest.json.sig"; } -run() { # current floor mode -> sets $out, $got(flash|refuse) +run() { # current floor action(--check|--install) -> sets $out, $got(flash|refuse) echo "$1" > "$S/etc/mono_release" rm -f "$S/FLASHED" "$S/etc/mono-update.state" [ -n "$2" ] && printf '%s\n' "$2" > "$S/etc/mono-update.state" - out=$(cd "$S" && PATH="$S/bin:$PATH" UCI_MODE="$3" sh "$S/client.sh" 2>&1) + out=$(cd "$S" && PATH="$S/bin:$PATH" sh "$S/client.sh" "$3" 2>&1) [ -f "$S/FLASHED" ] && got=flash || got=refuse } scenario() { # name expect current floor reason manifest - publish "$6"; run "$3" "$4" auto + publish "$6"; run "$3" "$4" --install if [ "$got" != "$2" ] || ! echo "$out" | grep -q "$5"; then echo "FAIL [$1] got=$got want=$2 reason=/$5/"; echo " $out"; fails=$((fails+1)) else echo "ok [$1] $got ($5)"; fi @@ -117,24 +117,24 @@ scenario() { # name expect current floor reason manifest echo "== end-to-end (real usign) ==" scenario genuine-upgrade flash mono-v25.12.5-r3 "" SYSUPGRADE m_r5.json scenario forged-tag-bad-key refuse mono-v25.12.5-r3 "" "signature FAILED" m_bad.json -scenario replay-old-signed refuse mono-v25.12.5-r5 "floor=mono-v25.12.5-r5" "not newer than floor" m_r3.json -scenario floor-walk-up refuse mono-v25.12.5-r3 "floor=mono-v25.12.5-r5" "not newer than floor" m_r4.json +scenario replay-old-signed refuse mono-v25.12.5-r5 "floor=mono-v25.12.5-r5" "older than floor" m_r3.json +scenario floor-walk-up refuse mono-v25.12.5-r3 "floor=mono-v25.12.5-r5" "older than floor" m_r4.json # image tamper: genuine signed r5 manifest, but the served bytes are swapped publish m_r5.json printf 'TAMPERED' > "$SRV/r5/img-sysupgrade.bin" -run mono-v25.12.5-r3 "" auto +run mono-v25.12.5-r3 "" --install if [ "$got" = refuse ] && echo "$out" | grep -q "hash mismatch"; then echo "ok [image-tamper] refuse (hash mismatch)" else echo "FAIL [image-tamper] got=$got: $out"; fails=$((fails+1)); fi printf 'IMAGE-R5' > "$SRV/r5/img-sysupgrade.bin" # restore -# notify mode: a genuine newer release must NOT flash and must NOT print a raw +# --check: a genuine newer release must NOT flash and must NOT print a raw # 'sysupgrade ' (which would skip verification). publish m_r5.json -run mono-v25.12.5-r3 "" notify -if [ "$got" = refuse ] && ! echo "$out" | grep -q "file://" && echo "$out" | grep -q "mode=auto"; then - echo "ok [notify-no-raw-flash] refuse, steers to auto" -else echo "FAIL [notify-no-raw-flash] got=$got: $out"; fails=$((fails+1)); fi +run mono-v25.12.5-r3 "" --check +if [ "$got" = refuse ] && ! echo "$out" | grep -q "file://" && echo "$out" | grep -q -- "--install"; then + echo "ok [check-no-raw-flash] refuse, steers to --install" +else echo "FAIL [check-no-raw-flash] got=$got: $out"; fails=$((fails+1)); fi echo if [ "$fails" -eq 0 ]; then echo "ALL PASS"; else echo "$fails FAILED"; exit 1; fi diff --git a/package/mono/mono-update-check/tests/test_publish_gate.sh b/package/mono/updater/tests/test_publish_gate.sh similarity index 89% rename from package/mono/mono-update-check/tests/test_publish_gate.sh rename to package/mono/updater/tests/test_publish_gate.sh index 5f831b63b3..4b5a6b1628 100755 --- a/package/mono/mono-update-check/tests/test_publish_gate.sh +++ b/package/mono/updater/tests/test_publish_gate.sh @@ -13,12 +13,12 @@ USIGN="$REPO/staging_dir/host/bin/usign" fails=0 T=$(mktemp -d); trap 'rm -rf "$T"' EXIT -mkdir -p "$T/scripts" "$T/package/mono/mono-update-check/files" "$T/releases/mono-v25.12.5-r7" +mkdir -p "$T/scripts" "$T/package/mono/updater/files" "$T/releases/mono-v25.12.5-r7" cp "$REPO/scripts/mono-sign-release.sh" "$REPO/scripts/mono-publish-release.sh" "$T/scripts/" # fleet keys (what devices bake) and an unrelated "wrong" key -"$USIGN" -G -s "$T/fleet.sec" -p "$T/package/mono/mono-update-check/files/mono-release.pub" -c fleet >/dev/null -"$USIGN" -G -s "$T/rot.sec" -p "$T/package/mono/mono-update-check/files/mono-rotation.pub" -c rot >/dev/null +"$USIGN" -G -s "$T/fleet.sec" -p "$T/package/mono/updater/files/mono-release.pub" -c fleet >/dev/null +"$USIGN" -G -s "$T/rot.sec" -p "$T/package/mono/updater/files/mono-rotation.pub" -c rot >/dev/null "$USIGN" -G -s "$T/wrong.sec" -p "$T/wrong.pub" -c wrong >/dev/null printf 'deadbeef img-sysupgrade.bin\n' > "$T/releases/mono-v25.12.5-r7/sha256sums" diff --git a/package/mono/mono-update-check/tests/test_version_logic.sh b/package/mono/updater/tests/test_version_logic.sh similarity index 100% rename from package/mono/mono-update-check/tests/test_version_logic.sh rename to package/mono/updater/tests/test_version_logic.sh diff --git a/package/system/selinux-policy/files/cil/monoupdatesysagent.cil b/package/system/selinux-policy/files/cil/monoupdatesysagent.cil index 3570ce18c1..49db4e21c9 100644 --- a/package/system/selinux-policy/files/cil/monoupdatesysagent.cil +++ b/package/system/selinux-policy/files/cil/monoupdatesysagent.cil @@ -1,8 +1,13 @@ ;; monoupdatesysagent.cil — mono_update_t (HIGH-CONSEQUENCE: can trigger a flash) -;; cron-spawned mono-update-check (/bin/sh: curl -> usign -> sysupgrade). busybox crond has NO dssp -;; domain => runs sys.subj => transition fires by the same template. The binary physically lives at -;; /usr/sbin/mono-update-check, but the filecon MUST use the /usr/bin canonical path: -;; file_contexts.subs_dist maps /usr/sbin -> /usr/bin, so a /usr/sbin/* filecon is never consulted +;; Two entry paths reach /usr/sbin/mono-update and BOTH transition into this domain: +;; - cron / an interactive shell run `mono-update --check|--install`. busybox crond has NO dssp +;; domain => runs sys.subj (in file.unconfined) => transitions via the exec type_transition below. +;; - the LuCI "Updates" page calls it via rpcd (/usr/libexec/rpcd/mono-update -> start-stop-daemon +;; -> mono-update). rpc.server.subj is given a subj_type_transition into monoupdate at the bottom +;; of this file, so the rpcd-triggered run is confined identically (it otherwise execute_no_trans'd +;; the binary and ran the whole updater as rpc.server.subj). +;; The binary physically lives at /usr/sbin/mono-update, but the filecon MUST use the /usr/bin canonical +;; path: file_contexts.subs_dist maps /usr/sbin -> /usr/bin, so a /usr/sbin/* filecon is never consulted ;; (the lookup hits the /usr/bin/.* exec catch-all and the domain never transitions). (block monoupdate @@ -26,10 +31,37 @@ ;; (acme's helper-exec idiom; usign exposes no 1-arg subj_type_transition) (call .usign.exec.execute_file_files (subj)) - ;; TODO(avc): transition into the sysupgrade domain when a flash is triggered. - ;; Real macro is .sysupgrade.exec.subj_type_transition (2-arg; - ;; file/datafile/dataexecfile/sysupgradedataexecfile.cil:22-27). Pin from permissive AVCs. - ;; NOTE: .net.egress_netifs / .net.sendto_nodes intentionally left for AVC discovery on the HTTPS path. + ;; run /usr/bin/curl (a generic exec.file) in-domain for the OTA fetch, the + ;; way acme runs its own helpers. + (call .exec.execute_file_files (subj)) + + ;; Parse the signed manifest (jsonfilter) and read UCI (uci) in their own + ;; confined domains, the way rpc.server and cmm do — not execute_no_trans. + (call .jsonfilter.subj_type_transition (subj)) + (call .uci.subj_type_transition (subj)) + + ;; board_name() sources /lib/functions.sh and reads /tmp/sysinfo/board_name; + ;; the fleet-jitter/anti-rollback bits read /dev/urandom. + (call .functions.client.type (subj)) + (call .random.read_nodedev_chr_files (subj)) + (call .sysinfo.tmp.list_file_dirs (subj)) + (call .sysinfo.tmp.read_file_files (subj)) + + ;; /tmp/mono-update workdir (downloaded manifest, signature, image) and the + ;; /tmp/mono-update-available tag file: created, written and removed here. + (call .tmp.manage_fs_dirs (subj)) + (call .tmp.manage_fs_files (subj)) + + ;; The rpcd/LuCI entry path leaks ucode fds across the exec into monoupdate + ;; (/usr/share/ucode/luci/sys.uc, /usr/share/rpcd/ucode/luci); the updater + ;; never uses them — silence the inherited-fd reads (dontaudit, not a grant). + (call .ucode.data.dontaudit_readinherited_file_files (subj)) + (call .rpc.data.dontaudit_readinherited_file_files (subj)) + + ;; TODO(avc): transition into the sysupgrade domain when `--install` flashes. + ;; Macro is .sysupgrade.exec.subj_type_transition (2-arg; sysupgradedataexecfile.cil:20-27). + ;; Cannot be exercised until the server offers a release newer than the DUT runs (otherwise + ;; --install stops at "up to date" before sysupgrade); pin from those AVCs when it can run. (call .log.server.unix_dgram_send (subj)) @@ -59,8 +91,16 @@ (in after monoupdate.exec - (filecon "/usr/bin/mono-update-check" file file_context) ;; canonical path (binary is /usr/sbin/mono-update-check) + (filecon "/usr/bin/mono-update" file file_context) ;; canonical path (binary is /usr/sbin/mono-update) (macro exec_file_type_transition_file ((type ARG1)) (call .exec.file_type_transition - (ARG1 file file "mono-update-check")))) + (ARG1 file file "mono-update")))) + +;; The LuCI "Updates" page drives the updater through rpcd: /usr/libexec/rpcd/mono-update runs +;; `mono-update --check` and (via start-stop-daemon) `mono-update --install`. Transition rpc.server.subj +;; into monoupdate on that exec — otherwise the whole updater runs in rpc.server.subj — and let the rpcd +;; object read the private /etc/mono_release that its `status`/`check` methods cat directly. +(in rpc.server + (call .monoupdate.subj_type_transition (subj)) + (call .monoupdate.conf.read_file_files (subj))) diff --git a/scripts/mono-publish-release.sh b/scripts/mono-publish-release.sh index 4ebfe44380..07fb2b8470 100755 --- a/scripts/mono-publish-release.sh +++ b/scripts/mono-publish-release.sh @@ -29,7 +29,7 @@ OUT="releases/$RELTAG" USIGN=${MONO_USIGN:-staging_dir/host/bin/usign} [ -x "$USIGN" ] || command -v "$USIGN" >/dev/null 2>&1 || { echo "mono-publish: usign not found ($USIGN); set MONO_USIGN" >&2; exit 1; } -KEYS="package/mono/mono-update-check/files/mono-release.pub package/mono/mono-update-check/files/mono-rotation.pub" +KEYS="package/mono/updater/files/mono-release.pub package/mono/updater/files/mono-rotation.pub" verify() { # : true if any baked key validates it for k in $KEYS; do [ -f "$k" ] || continue diff --git a/scripts/mono-update.sh b/scripts/mono-update.sh index d5e098f0ca..5f13533db3 100755 --- a/scripts/mono-update.sh +++ b/scripts/mono-update.sh @@ -78,7 +78,7 @@ if [ "$LATEST" != "$BASE" ]; then fi # Tag before building so the image can bake its own release identity -# (mono-update-check reads it at build time). Dropped again on failure. +# (mono-update reads it at build time). Dropped again on failure. git tag -f "$RELTAG" # From here on ANY nonzero exit - build, staging, sign, or publish - removes @@ -111,12 +111,12 @@ cp configs/mono_gateway-dk.seed .config # on every machine. Use `nix run` -- NOT `nix develop -c` / `nix-shell --run`, # which hang on the env's shellHook exec. git, publish and signing stay on the # host (their tools are not in the flake's package set). -# mono-update-check is force-rebuilt (clean+compile) so /etc/mono_release carries -# THIS release tag; otherwise every image ships the stale identity of the -# package's first build and auto-mode devices re-flash forever. +# the mono-update package is force-rebuilt (clean+compile) so /etc/mono_release +# carries THIS release tag; otherwise every image ships the stale identity of +# the package's first build and auto-mode devices re-flash forever. nix run . -- -c 'set -e make defconfig - make package/mono/mono-update-check/clean package/mono/mono-update-check/compile + make package/mono/updater/clean package/mono/updater/compile make -j"$(nproc)" world' # Verify the image actually baked THIS release's identity. The OTA client and diff --git a/target/linux/layerscape/base-files/etc/uci-defaults/99-mono-tailscale-enable b/target/linux/layerscape/base-files/etc/uci-defaults/99-mono-tailscale-enable new file mode 100644 index 0000000000..76adab64e2 --- /dev/null +++ b/target/linux/layerscape/base-files/etc/uci-defaults/99-mono-tailscale-enable @@ -0,0 +1,18 @@ +#!/bin/sh +# tailscale's boot-enable (the /etc/rc.d/S*tailscale symlink) lives in the +# rootfs, which sysupgrade wipes - so a manual `/etc/init.d/tailscale enable` +# does not survive a flash. This uci-defaults script ships in every image and +# re-runs on the first boot after each sysupgrade (config restore has already +# happened in preinit by then), reconciling the enable-state from the PRESERVED +# /etc/config/tailscale. Default: enabled. Persistently disable with: +# uci set tailscale.settings.auto_enable=0; uci commit tailscale +[ -x /etc/init.d/tailscale ] || exit 0 +en=$(uci -q get tailscale.settings.auto_enable) +[ -n "$en" ] || en=1 +if [ "$en" = 1 ]; then + /etc/init.d/tailscale enable + /etc/init.d/tailscale start +else + /etc/init.d/tailscale disable +fi +exit 0 diff --git a/target/linux/layerscape/image/armv8_64b.mk b/target/linux/layerscape/image/armv8_64b.mk index 70c27010cb..3775845771 100644 --- a/target/linux/layerscape/image/armv8_64b.mk +++ b/target/linux/layerscape/image/armv8_64b.mk @@ -453,7 +453,7 @@ define Device/mono_gateway-dk DEVICE_PACKAGES := kmod-ask-cdx kmod-ask-fci kmod-ask-auto-bridge \ cmm dpa-app fmc kmod-leds-lp5812 kmod-sfp-led fancontrol \ luci-light libustream-mbedtls px5g-mbedtls -luci-app-package-manager \ - luci-app-statistics luci-app-attendedsysupgrade \ + luci-app-statistics \ kmod-wireguard wireguard-tools luci-proto-wireguard \ strongswan strongswan-default strongswan-mod-openssl openvpn-openssl luci-app-openvpn tailscale \ nlbwmon luci-app-nlbwmon \ @@ -466,7 +466,7 @@ define Device/mono_gateway-dk block-mount kmod-usb-storage-uas kmod-fs-exfat kmod-fs-ntfs3 \ kmod-fs-vfat smartmontools usbutils pciutils i2c-tools \ tmux vim-full curl rsync jq less bind-dig openssh-sftp-server \ - usign ca-bundle file ip-full resize2fs mono-update-check \ + usign ca-bundle file ip-full resize2fs mono-update luci-app-mono-update \ cmmqos \ policycoreutils-setfiles policycoreutils-sestatus KERNEL_NAME := Image -- 2.47.3