NervesGithubUpdater

Hex.pmHex DocsLicense

GitHub Releases OTA firmware updater for Nerves devices, with signed release manifests, per-target SHA-256 pinning, and rollback protection.

What it is

NervesGithubUpdater polls a GitHub repo's Releases for new firmware and flashes it with fwup. There is no server component and no device-management plane: you gh release create (or push a tag and let CI do it), and devices that are online pick it up on their next check.

Contrast with NervesHub: NervesHub gives you a fleet dashboard, staged/canary rollouts, device grouping, and a control plane you (or Nerves Cloud) run. This library gives you none of that — what you get instead is zero infrastructure. If your "fleet" is a handful of devices, a single-vendor appliance, or a hobby project, cutting a signed GitHub Release is a much smaller operational surface than standing up (or paying for) a device management service. If you need staged rollouts, per-device targeting, or a dashboard, use NervesHub instead — this library doesn't grow into that.

Security model

A device trusts a 32-byte Ed25519 public key baked into its firmware. The all-zero key is a "not provisioned yet" sentinel — NervesGithubUpdater.Signature.verify_manifest/3 refuses to validate against it, so an unprovisioned device fails closed rather than silently accepting any signature.

Each release carries a release-manifest.json + release-manifest.sig pair. The signed message is sha512(manifest_bytes), not the raw bytes (so signature size stays constant regardless of manifest size — notably, inside AWS KMS's 4,096-byte RAW cap). The manifest lists, per Nerves target, the exact asset filename, its sha256, and its size; the device downloads only the asset for its own target and verifies the streamed hash before flashing. A monotonic counter in the manifest is persisted (via the host's :kv_get/:kv_put opts) after every successful flash and rejected if a future manifest's counter is lower — this is the rollback guard.

verification_required: false is a deliberate bootstrap mode: no manifest, no signature, just an :asset_matcher-selected .fw asset downloaded and flashed. It's meant for getting a device fleet manifest-aware before you've provisioned real signing keys — every such install logs a Logger.warning so the unverified state is auditable. Flip to verification_required: true once a real public key replaces the sentinel.

See guides/manifest-format.md for the full wire contract, JSON schema, and signer recipes (AWS KMS, GCP Cloud KMS, local Ed25519 key / HSM).

Public-repos-only (v0.1)

Asset downloads use each asset's browser_download_url with no auth — a direct, unauthenticated redirect that works for public repos. Private-repo asset downloads (the GitHub API's asset-by-ID route with an accept: application/octet-stream + Bearer token) are not implemented in v0.1. :github_token, if set, is used only for the release-metadata API call, to lift the anonymous rate limit (60/hr/IP → 5,000/hr).

Installation

def deps do
[
{:nerves_github_updater, "~> 0.1"}
]
end

Not yet published to Hex — until then, pull it from GitHub:

{:nerves_github_updater, github: "bbangert/nerves_github_updater"}

Usage / wiring

The host app child-specs NervesGithubUpdater.Supervisor somewhere in its own supervision tree and supplies all the host-specific glue as opts — the library itself has no Nerves dependency and no idea what "the current device" means until you tell it.

# lib/my_app/application.ex (or a dedicated wiring module)
children = [
# ...
{NervesGithubUpdater.Supervisor,
owner_repo: "myorg/my_app",
github_token: Application.get_env(:my_app, :github_token),
public_key: Application.get_env(:my_app, :fw_public_key),
verification_required: true,
channel: :stable,
allow_downgrade: false,
download_dir: "/data/fw_update",
pubsub: MyApp.PubSub,
pubsub_topic: "firmware_update:progress",
reboot_fn: fn -> Nerves.Runtime.reboot("0 tryboot") end,
devpath_fn: fn -> Nerves.Runtime.KV.get("nerves_fw_devpath") end,
target_fn: fn -> Nerves.Runtime.KV.get_active("nerves_fw_platform") end,
current_version_fn: fn -> Nerves.Runtime.KV.get_active("nerves_fw_version") end,
kv_get: &Nerves.Runtime.KV.get/1,
kv_put: &Nerves.Runtime.KV.put/2,
asset_matcher: &MyApp.FirmwareUpdate.match_asset/2}
]

Opts, from NervesGithubUpdater.Updater:

Subscribers get {:fw_update_progress, %{phase: phase, pct: pct, message: message}} on :pubsub_topic, where phase is one of :checking, :verifying, :downloading, :flashing, :idle, or :error.

Kick off checks and installs from wherever your app's UI or scheduler lives:

NervesGithubUpdater.Updater.check()
NervesGithubUpdater.Updater.install_latest()
NervesGithubUpdater.Updater.state()

(Pass a :name opt to Supervisor/Updater if you're not using the default NervesGithubUpdater.Updater process name.)

The fwup serialize contract

NervesGithubUpdater.Fwup.apply/2 streams a .fw file into fwup --apply --framing over an Erlang Port and blocks until it exits. Callers must serialize calls to it — concurrent apply/2 calls against the same device are undefined. The Updater GenServer gets this for free: the whole install (download → verify → flash) runs inside one handle_info/2, so only one flash can ever be in flight.

Two accepted risks, documented in NervesGithubUpdater.Fwup's moduledoc: no --exit-handshake (it deadlocks under a Port in framing mode), so a failure's error text can occasionally race behind the exit status; and stderr is merged into the framed stdout stream, which is fine because fwup stays quiet on stderr in framing mode in practice.

tryboot / reboot_fn + validation

The library reboots the device only by calling your :reboot_fn, and only after a successful flash. It has no opinion on how you reboot.

On A/B (tryboot) systems, the newly-flashed firmware must validate itself after booting — typically Nerves.Runtime.validate_firmware/0 — or the bootloader considers the boot failed and rolls back to the previous partition. Nerves.Runtime.reboot("0 tryboot") is the usual way to request that the next boot only be given one chance before falling back. If your host has its own startup-guard/heart/watchdog gate on validation, make sure the path that runs after a firmware update can still reach and call validate_firmware/0 — a half-wired guard can leave a perfectly good update stuck rolling back forever.

Channels & downgrade

:channel selects :stable (GitHub's /releases/latest, which already excludes prereleases/drafts) or :prerelease (considers all non-draft releases, picks the highest semver tag — so a prerelease can outrank an older stable tag).

Before any install, the release's tag_name is compared against :current_version_fn.(). An older release is refused unless :allow_downgrade is true. This is deliberately belt-and-braces: on the manifest path the counter is the real rollback control (and the only one that matters); this semver check is what also guards the legacy unverified path, which has no counter at all.

Status

Extracted from a production Nerves application (universal_proxy). v0.1 is host-verified (full test suite passing on MIX_TARGET=host); hardware validation on real Nerves targets is in progress.

Licensed under Apache-2.0 — see LICENSE.