Replies: 2 comments
|
Server-side implementation scope posted: #99 (GitHub firmware + plugin hosting). It builds this RFC's publishing infrastructure ahead of the on-router client: release-hosted images, the signed manifests, and the Rung-2 Firmware Finder as a searchable GitHub Pages site, plus per-arch plugin feeds and a free-tier CI pipeline (self-hosted runner option keeps the usign key on the build host, per this RFC's key policy). |
|
Implementation update (2026-07-17): image signing is detached, not appended — and why The server side of this RFC (build → sign → publish → signed manifests, phases 1–2) is now live in It turns out that slot is already taken. While testing verify tooling against real build output, Appending our OTA signature on top would mean:
What shipped instead: detached Long-term recommendation (a phase-1 build-side decision, not urgent): the architecturally cleanest endgame is to make the official OTA key the buildroot's RFC body updated accordingly (mechanism §1, the manifest schema example, and the verify core). |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Status: Ready for review · Updated: paired with #97 (Settings Profile) for config survival, hardened for fleet reality (hardware floors, EOL policy, allowlist rollout), covering the bootstrap problem (how routers on old firmware reach the OTA era safely), channels and custom images (stable/testing manifests, manifest anti-replay, self-built firmware with private OTA channels, custom plugin sets), and now downgrades — validated history entries, lossless downgrade→re-upgrade round-trips via the preserved profile.
Summary
Gargoyle has no verified one-click firmware upgrade today — you download the right
sysupgrade.binby hand, upload it, and hope you grabbed the correct file. No update check, no authenticity guarantee, no guided progress. This RFC proposes a signed OTA upgrade built entirely from open OpenWrt primitives (usign,fwtool,sysupgrade) plus a small Gargoyle-hosted signed manifest. No proprietary components, no third-party servers, no telemetry.Prior art: the GL.iNet stock
one_click_upgradewas studied on owned hardware to understand the method (not to copy code). Its entire verify/apply path is already stock OpenWrt —fwtool -t -sto extract the appended signature,usign -Vagainst a build pubkey, thensysupgrade -n/-k. Everything GL layers on top (their update API, mirror CDN, cloud telemetry, Vue UI) is exactly what we replace with our own.Mechanism (three parts)
Build-time signing — the build generates a
usignkeypair once; the private key stays on the build host, the public key is baked into every image at/etc/gargoyle/ota.pub. Each*-sysupgrade.binis signed (usign -S) as a detached<image>.sigpublished alongside the image. (Revised from “appended viafwtool” — implementation revealed the buildroot already appends its own signature: whenBUILD_KEYexists,include/image-commands.mksigns every image and appends a ucert chain viafwtool -S. Appending a second trailer would bury the native one in exactly the slot stockfwtool_check_signaturepops expecting a ucert — a no-op on today’s images (no/usr/bin/ucertshipped,REQUIRE_IMAGE_SIGNATUREunset) but broken the day enforcement lands — and it mutates the artifact so its sha256 no longer matches the build’s ownsha256sums. Detached signing keeps every published byte identical to build output. See the dated comment below for the evidence trail.)A signed
manifest.json(static file / GitHub Release asset — no dynamic server) keyed by OpenWrtboard_name. Each entry carries version / url / sha256 / size / changelog, hardware floors, and config-compat info:The manifest is itself signed so a tampered one can't redirect the router to a malicious image, and it is machine-generated from build output — hundreds of models means hundreds of generated entries, not hand-maintained ones. The top level also carries a monotonic
generatedtimestamp: the router remembers the newest it has seen and refuses older manifests, closing the replay attack where a validly-signed stale manifest holds routers on a known-vulnerable version. (Deliberate local downgrades — a channel switch, a manual flash — remain allowed: those are explicit user actions, not manifest-driven.)On-router
ota_upgrade.sh—check → download → verify → export settings profile → apply, writing a pollable status file for the progress UI.The security-critical core is ~8 lines of stock tooling:
(
/tmp/ota.sigis downloaded from the manifest’ssig_urlnext to the image— one fewer verify step than the original appended design, and
fwtooldrops out of the client’s trust path entirely; it remains only in the
pre-flight device-match, where it reads image metadata.)
Note the deliberate absence of
-non the apply line: insysupgrade's ownsource,
-fforcesSAVE_CONFIG=1and overrides-n(verified inbase-files
sbin/sysupgrade: theCONF_IMAGEbranch re-exportsSAVE_CONFIG=1). The stash archive itself defines exactly what ispreserved — and since it contains only the profile and dropbear host keys,
-f <stash>alone is the clean-flash-plus-stash semantics. Passing-nas well would be misleading dead weight.
One pipeline, three entry points
Gargoyle's existing manual upload page (
update.sh/update.js) alreadyperforms real pre-flight today:
fwtooldevice-match,compat_versioncheck, image-size vs storage check, and an explicit force checkbox. The OTA
orchestrator reuses that validation path rather than duplicating it —
manual upload, OTA, and CLI are three entry points into one verify/apply
pipeline. The one gap in the manual path is that its "preserve settings"
checkbox is raw keep-config even across a major version jump — exactly the
breakage #97 exists to prevent — so once the profile lands, the update page
routes a cross-major "preserve settings" through the same profile-stash
export the OTA path uses.
Coping with the real fleet (models, memory sizes, EOL hardware)
Gargoyle's install base spans hundreds of models across wildly different flash/RAM classes, and some hardware is simply past the point where newer firmware fits. The design handles this with one structural inversion and two honest states:
board_namehas no entry simply reports "no update available." We never have to anticipate every bad model/version combination — an unlisted device cannot be offered an image, so it cannot be bricked by one.min_ram_kb/min_flash_kbplus on-router pre-flight: the image must fit in/tmp(RAM) with running headroom, and must fit the target flash partition. Any failure means the upgrade is never offered or never starts — a router cannot crash on an upgrade it never downloads.eol: true+final_versionentry makes the update page say "this device has reached its final supported version" — honest, permanent, and safe.This yields a natural three-tier fleet policy:
final_version, told so honestlyTier B matters: #97's profile export/import works with zero OTA infrastructure, so settings survival ships to the whole buildable fleet even before a board earns its OTA allowlist entry.
Channels and custom images
Stable / testing channels — one signed manifest per channel, not channel fields inside one manifest:
gargoyle.ota.channel='stable'(default); UI dropdown with an honest warning on testing ("pre-release; report problems on GitHub — there is no telemetry, so we only know what you tell us").Custom images — two different cases:
gargoyle-build.shyourself): the build generates its own keypair, so a self-built image carries the builder's pubkey. Two rules follow. First,manifest_urland the trusted-key set become configuration (gargoyle.ota.manifest_url+/etc/gargoyle/ota-keys.d/), defaulting to the official URL and key — and since the phase-2 manifest publisher is a script in the repo, self-builders get the entire server side for free: build, run the publisher, host two static files anywhere, and you have a private OTA channel for your own fleet. Second, the anti-clobber rule: the router knows it runs a custom build (its baked key fingerprint ≠ official) and says so — "Custom build detected — official OTA disabled unless you opt in." An official image must never silently replace someone's custom firmware; the check falls out of the key comparison for free.sysupgradewipes installed packages. Near-term honest answer: the installed-plugin list already recorded by the backup manifest (RFC: Backup coverage overhaul — declarative registry, backup manifest, and safe restore #98) and profile (RFC: Settings Profile — configuration that survives major upgrades (export intent, flash clean, regenerate) #97) drives a post-OTA "reinstall your plugins" prompt against the release's plugin feed. Long-term correct answer: this is exactly what Attended Sysupgrade (Option A) exists for — a server that bakes your package set into the image on demand. This retroactively strengthens the existing decision to keep the manifest schema ASU-compatible: "custom package sets" becomes the natural migration trigger from Option B to Option A, not a rearchitecting event.Explicitly out of scope for v1: per-user image builds on project infrastructure (that's ASU, later, if ever); channels more granular than stable/testing (nightlies demand automation and triage capacity that doesn't exist); any unsigned "dev mode" bypass — self-builders sign with their own key, and the verify path stays fail-closed always.
None of this needs new mechanisms: channels are a second manifest file, self-built support is two hardcoded values made configurable, and custom package sets ride the plugin list #98 already records plus the ASU escape hatch already reserved.
Downgrades
The #1 real-world downgrade isn't a tester returning to stable — it's "the new release broke my wifi." Driver regressions are routine in OpenWrt, and a user whose radio got flaky after an upgrade needs a safe path back or they're stuck choosing between a broken router and TFTP recovery. An upgrade system that can only go forward isn't credible. Design:
historyarray — the last 2–3 stable releases, same url/sha256/size/sig fields as the head entry. Consistent with the allowlist principle: history lists only validated downgrade targets — never below a per-board floor, never across acompat_versionbreak, and a release pulled for a security hole simply doesn't appear. (This composes cleanly with anti-replay: thegeneratedcheck refuses stale manifests, not older versions listed in a fresh manifest — vetted history entries are exactly the legitimate path.)/etc/gargoyle/. Downgrade to escape a bad driver, wait a release, upgrade again — everything comes back, including the settings the old version couldn't read. This one property removes most of the fear from both directions.Bootstrap: reaching the existing fleet (old firmware, no OTA client aboard)
Everything above assumes the router runs firmware that contains the OTA client. The installed base doesn't — it runs 1.10/1.12/1.14 across several OpenWrt eras. Three facts (verified in source) shape the answer:
board_name, version, RAM, flash, plugin set) is already known by the router itself. New firmware self-checks against the manifest; nothing external ever inspects a router. (Server-side SSH probing was considered and rejected: it's a credential-handling anti-pattern, doesn't scale, and — decisively — can learn nothing the router doesn't already know about itself.)sysupgradevalidating — against thesupported_devices/compat_versionlists embedded in the new image (lib/upgrade/fwtool.sh). So old-firmware acceptance is controlled from the build side: keep legacy board names inSUPPORTED_DEVICESwhere the jump is safe; bumpDEVICE_COMPAT_VERSIONwith a message where it isn't, and old sysupgrade refuses by itself. The compatibility gate reaches backward in time, carried by the image — no code on the old router required. (Caveat for the honest floor: pre-17.01-era firmware (≈1.9.x) has no metadata checks at all, and 1.10'sdo_upgrade.shvalidated almost nothing — for those, safety must come from handing the user the right file.)base_on_openwrt_*branches literally enumerate it), each with known board-naming and known sysupgrade behavior. This is five or six well-defined source eras, not hundreds of unknowable firmwares.Three rungs, cheapest-first:
Rung 1 — the manifest learns history (build hygiene, non-optional). Per-board
upgradable_fromrouting pluslegacy_board_names:Images keep legacy
SUPPORTED_DEVICESnames where safe, declare bumped compat where not. Old routers physically cannot flash an unsafe image.Rung 2 — a Firmware Finder for routers that can't ask. Old firmware can't fetch a manifest, but its owner can: a static page (GitHub Pages, generated from the same signed manifest — zero new infrastructure) where you pick model + version, or simply drag in your existing backup tarball (it contains enough of
/etc/configto identify board and version), and it answers with exactly one file and one instruction: "flash this through your System→Update page; settings carry via the profile/legacy converter (RFC: Settings Profile — configuration that survives major upgrades (export intent, flash clean, regenerate) #97)." One guided manual flash onto the first OTA-capable release — every upgrade after that is automatic forever. For Tier C hardware the Finder tells the truth: final supported version, here's your last safe image.Rung 3 (phase-2 candidate, validate first) — a bootstrap plugin instead of a manual flash. Gargoyle's Plugins page (
plugins.sh+ gpkg) exists back to at least 1.6. A script-only, architecture-independentgargoyle-ota-bootstrappackage — POSIX sh over tools old images already ship (wget,sha256sum;fwtool/usignpresent since ~17.01) — installable through the old firmware's own plugin UI, retrofitting check→download→verify→flash onto 1.10+. The one manual step shrinks from "flash a firmware" to "install a plugin." Scoped 17.01+ (usign present); pre-1.10 stays Finder-only. To be validated end-to-end on an old image in the vnet before it's promised (T-OTA-07).Config survival across major versions (companion RFC: #97)
Raw keep-config is exactly what breaks on major OpenWrt bumps (swconfig→DSA, fw3→fw4, ipset→nft...), forcing users to reconfigure from scratch. That gap is owned by #97 (Settings Profile):
ota_upgrade.shexports a versioned, board-agnostic intent profile before flashing, flashes with clean config, and the new firmware regenerates its own native config from the profile at first boot — per-feature, fail-soft, never a brick and never a blank slate.What that changes in this RFC:
sysupgradecontains only the profile + dropbear host keys, nothing else.min_profile_versionlets the upgrade page state before flashing exactly what will carry over — and refuse combinations it cannot honor instead of discovering them at boot.-kkeep-config path remains available for same-major upgrades where it is known-safe; the profile path is the default for cross-major jumps.Anti-brick (the 20% that matters)
Fail-closed verification (any size/hash/sig mismatch aborts before
sysupgrade), a device-match guard (refuse an image whosefwtoolboard name ≠system board— the #1 bricking mistake), the hardware floors + allowlist gating above, free-space/power checks, config survival via #97, and honest scope: single-partition targets (e.g. MT6000) can't auto-rollback a bad flash, so pre-flight verification is the mitigation — we won't promise a revert we can't deliver.Two paths
auc/owut+ ASU server): upstream-maintained, but needs us to host an ASU instance that knows Gargoyle's feed.ota_upgrade.sh+ a haserl progress page, ~150 lines, no build-server infra, matches how we already publish images. Manifest schema kept ASU-compatible for a later migration.Plan
5 phases (build signing → manifest/publisher → orchestrator → web page → opt-in auto-check with no telemetry), rolled out board-by-board via the allowlist — starting with the boards we can validate on real hardware and in the vnet simulator. The bootstrap rungs slot in as: Rung 1 with phase 1 (it's build/manifest hygiene), Rung 2 with phase 2 (the Finder is generated from the manifest publisher), Rung 3 after vnet validation. Channels and the downgrade
historyarray land with phase 2 (they are manifest/publisher features: a second signed file and extra generated entries); the anti-replaygeneratedcheck and the custom-build/anti-clobber detection land with phase 3 (orchestrator logic). New vnet phaseNN-ota_upgrade.test.sh(T-OTA-01…05: manifest verify, tampered-manifest reject, bad-signature reject, wrong-device reject, real older→newer flash happy-path) plus gating tests (below-floor device refused, EOL device shown terminal state, unlisted board offered nothing) and T-OTA-07: boot an old-era image in the vnet, install the bootstrap plugin through its own Plugins page, and drive a full legacy→current OTA. T-OTA-08 covers channels and replay: a router on testing switches back to stable (downgrade offered with profile-carry messaging, not raw keep-config), a stale-but-validly-signed manifest with an oldergeneratedis refused, and a self-built image (non-official key) reports custom-build status and does not offer official images without opt-in. T-OTA-09 covers downgrades: a history entry is offered under Advanced with correct what-won't-carry messaging; after downgrading, the newer profile file is preserved intact; re-upgrading restores the full configuration (the lossless round-trip); a below-floor or cross-compat version is absent from history. The end-to-end settings-survival test lives in #97's plan (T-PROF-03) and exercises this RFC's pipeline as its transport.Full design doc (with schema, phases, risks, legality note):
docs/signed-ota-upgrade-plan.mdinispyisail/gargoyle-tools; the config-survival companion doc isdocs/settings-profile-upgrade-plan.md.Feedback welcome — especially on Option A vs B, where the signed manifest should live (GitHub Releases vs a Gargoyle-owned host), which boards should seed the Tier A allowlist, the signing-key lifecycle (rotation cadence, and how a key compromise would be handled given the pubkey is baked into shipped images — likely a two-key manifest transition scheme, but this deserves review before v1), and whether the Rung-3 bootstrap plugin is worth its validation cost versus telling legacy users "one last manual flash via the Finder".
All reactions