Skip to main content

guides/getting-started.md

# Getting started

A task-oriented walkthrough for wiring `nerves_github_updater` into a
Nerves app, cutting your first (unsigned) release, then graduating to
signed releases with rollback protection. For the full opt reference
see the [README](../README.md); for the manifest wire format see
[`guides/manifest-format.md`](manifest-format.md).

## 1. Add + wire the library

```elixir
# mix.exs
def deps do
  [
    {:nerves_github_updater, "~> 0.1"}
  ]
end
```

Child-spec `NervesGithubUpdater.Supervisor` somewhere in your app's
supervision tree. This is the minimal set of opts to get a device
checking and installing:

```elixir
# lib/my_app/application.ex
children = [
  # ...
  {NervesGithubUpdater.Supervisor,
   owner_repo: "myorg/my_app",
   verification_required: false,
   download_dir: "/data/fw_update",
   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}
]
```

`asset_matcher` is your own `(tag_name, assets) -> {:ok, fw_asset} |
{:error, reason}` function — see step 2 for what it needs to match.
The README's ["Usage / wiring"](../README.md#usage--wiring) section
has the complete opt list (PubSub, `:channel`, `:allow_downgrade`,
etc.); this guide only calls out what each task needs.

A common pattern (see `NervesGithubUpdater.Updater`'s moduledoc) is to
put all of this behind a small host-owned facade module — one that
owns the child-spec, exposes `check/0` / `install_latest/0` /
`update_config/1`, and translates your app's own config surface
(env vars, a `Nerves.Runtime.KV`-backed store, a LiveView settings
page — whatever it is) into these opts. That facade is also where
`reboot_fn`, `devpath_fn`, `target_fn`, `kv_get`/`kv_put`, and
`asset_matcher` live, since they all reach into `Nerves.Runtime` in
ways the library itself never does.

## 2. Cutting an unsigned release (bootstrap)

`verification_required: false` (the default) is the simplest path:
no manifest, no signature — just a `.fw` asset the device finds by
filename and flashes.

**Name the firmware asset** so your `asset_matcher` can find it. The
convention (also used by the manifest path) is `<app>_<target>.fw`,
e.g. `my_app_rpi3.fw`:

```elixir
def match_asset(_tag_name, assets) do
  target = Nerves.Runtime.KV.get_active("nerves_fw_platform")
  fw_name = "my_app_#{target}.fw"

  case Enum.find(assets, fn a -> a.name == fw_name end) do
    nil -> {:error, :no_fw_asset}
    asset -> {:ok, asset}
  end
end
```

**Cut the release:**

```sh
mix firmware  # or your Nerves build step, producing _build/.../my_app.fw
cp _build/rpi3_dev/nerves/images/my_app.fw my_app_rpi3.fw
gh release create v0.2.0 my_app_rpi3.fw --title "v0.2.0"
```

**The device picks it up** the next time it calls `check/0` — the
`NervesGithubUpdater.Updater` state machine goes
`:idle → :checking → :idle` and caches the release. Call
`install_latest/0` to actually download and flash it
(`:downloading → :flashing → :idle`).

The release's `tag_name` (`v0.2.0` above) must compare as newer than
`:current_version_fn.()`, or the install is refused with
`{:downgrade_refused, ...}` — set `allow_downgrade: true` if you
genuinely need to reinstall an older tag.

**This path has no rollback protection and no authenticity check** —
anyone who can attach an asset named right to *any* release you poll
gets flashed, unverified. It's a deliberate bootstrap convenience for
getting a fleet manifest-aware before you've provisioned a signing
key; every such install fires a `Logger.warning` so the exposure is
auditable in your logs. Move to signed releases (below) before this
matters in production.

## 3. Switching on signed releases

### a. Generate a signing key

```sh
openssl genpkey -algorithm ed25519 -out fw_signing.pem       # keep private!
openssl pkey -in fw_signing.pem -pubout -outform DER \
  | tail -c 32 > firmware_signing.pub                         # 32 raw bytes
```

`firmware_signing.pub` is the raw 32-byte Ed25519 public key every
device trusts. How it gets onto the device is up to you — the
library only takes it as the `:public_key` opt, as binary bytes, and
compares it byte-for-byte against a manifest's signature. A common
approach is baking it into the firmware image itself, e.g. a file
under `rootfs_overlay/` that your host code reads at boot and passes
through:

```elixir
public_key: File.read!("/etc/firmware_signing.pub")
```

Until a real key is provisioned, the device holds the all-zero
sentinel key — `NervesGithubUpdater.Signature.verify_manifest/3`
refuses to validate against it (`{:error, :missing_public_key}`),
so an unprovisioned device fails closed rather than trusting anything.

For production signing you generally don't want the private key
sitting in a file at all — see
[`guides/manifest-format.md`](manifest-format.md#signer-backends) for
signer-agnostic recipes (AWS KMS, GCP Cloud KMS, a local key/HSM in
CI) and the reusable
[`.github/workflows/sign-firmware.yml`](../.github/workflows/sign-firmware.yml)
workflow that wraps all of them.

### b. Cutting a signed release

A signed release adds `release-manifest.json` + `release-manifest.sig`
alongside the `.fw` asset(s). The manifest schema is documented in
full in [`guides/manifest-format.md`](manifest-format.md#json-schema);
a minimal example for one target:

```json
{
  "version": 1,
  "counter": 1789475200,
  "signed_at": "2026-07-15T12:00:00Z",
  "expires_at": null,
  "targets": {
    "rpi3": {
      "asset": "my_app_rpi3.fw",
      "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
      "size": 47185920
    }
  },
  "deltas": {}
}
```

Sign `sha512(manifest_bytes)` — not the raw manifest — and write the
raw 64-byte signature to `release-manifest.sig`:

```sh
sha512sum release-manifest.json | cut -d' ' -f1 | xxd -r -p > digest.bin
openssl pkeyutl -sign -rawin -inkey fw_signing.pem \
  -in digest.bin -out release-manifest.sig
```

In CI, call the reusable
[`sign-firmware.yml`](../.github/workflows/sign-firmware.yml) workflow
instead of hand-rolling this — it downloads the release's `.fw`
assets, builds the manifest (including the `counter`), signs it via
whichever `signing-backend` you configure (`aws-kms` / `gcp-kms` /
`local-key` / `manual`), and uploads both files back to the release.

Attach all three files to the release:

```sh
gh release create v0.3.0 my_app_rpi3.fw
gh release upload v0.3.0 release-manifest.json release-manifest.sig
```

### c. Flip the device to signed verification

Once a real public key has replaced the sentinel on-device, turn on
enforcement — through whatever config surface your host facade
exposes (see step 4), which under the hood calls:

```elixir
NervesGithubUpdater.Updater.update_config(verification_required: true)
```

From then on, `check/0` + `install_latest/0` take the manifest path
(`:verifying → :downloading → :flashing`): signature, schema, expiry
(if enabled), and rollback counter are all checked before a single
byte of firmware is downloaded.

### d. The monotonic counter

Every manifest carries a `counter`. After a successful flash, the
device persists it via your `:kv_put` opt (key
`fw_manifest_counter`, typically backed by `Nerves.Runtime.KV`) —
**only after the flash succeeds**, so a failed install never moves
the rollback floor. A future manifest with a **lower** counter is
refused (`{:manifest_rollback, manifest_counter, stored_counter}`);
an **equal** counter is allowed (reinstalling the current release);
a device with no stored counter yet accepts and sets it
(first-contact trust, same model as the public key). CI's
`sign-firmware.yml` uses Unix epoch seconds at signing time, which is
monotonic by construction as long as you don't sign two releases with
manually-forced counters out of order.

## 4. Operator runtime controls

Everything below maps directly onto
`NervesGithubUpdater.Updater.update_config/2`'s mutable opts — the
library itself only exposes that one call; **it's up to your host app
to expose a corresponding control** (an IEx/SSH helper, a LiveView
settings page, a config file reload, etc.) that persists the change
somewhere durable and forwards it. A common pattern is the single
`update_config/1` entry point on the host's facade module: it writes
to the app's own config store first, then best-effort propagates the
same sanitized values to the live `Updater` process so an in-flight
device picks them up without a restart.

Runtime-mutable opts, and what to expose for each:

  * **Turn signed validation off/on** —
    `verification_required: false` / `true`. Turning it off drops
    back to the unverified legacy path (step 2) — no signature, no
    rollback counter, only the semver downgrade gate. Treat this as
    an emergency/bootstrap switch, not a steady state; flip it back
    to `true` as soon as whatever forced it off is resolved.
  * **Change the repo** — `owner_repo: "neworg/new_repo"`.
  * **Switch channel** — `channel: :stable` or `:prerelease`.
  * **Toggle downgrade** — `allow_downgrade: true` / `false`.
  * **Toggle expiry enforcement** — `enforce_expiry: true` / `false`.
  * **Point at a different target** — this one is a function opt, not
    a simple value: `target_fn: fn -> "rpi4" end`. Since `target_fn`
    is itself an opt, your host facade decides whether "switch
    target" is even a runtime-exposed control or a compile-time
    constant.

`update_config/2` returns `{:error, :immutable}` for opts that are
fixed at supervisor start (`:pubsub`, `:pubsub_topic`,
`:download_dir`, `:fwup_devpath`, `:fwup_task`) and `{:error,
:unknown}` for anything it doesn't recognize. Changes take effect on
the **next** `check/1`/`install_latest/1` — an install already in
flight keeps running with the opts it started with.

## 5. Observing progress

Subscribe to your configured `:pubsub_topic` (requires `:pubsub` +
`:pubsub_topic` both set, and `:phoenix_pubsub` as a dependency):

```elixir
Phoenix.PubSub.subscribe(MyApp.PubSub, "firmware_update:progress")
```

Every phase transition broadcasts:

```elixir
{:fw_update_progress, %{phase: phase, pct: pct, message: message}}
```

`phase` is one of `:checking`, `:verifying`, `:downloading`,
`:flashing`, `:idle`, or `:error`. Which phases you see depends on
the install path:

  * **Unsigned (legacy)** — `:downloading → :flashing` (no
    `:verifying`).
  * **Signed (manifest)** — `:verifying → :downloading → :flashing`.

`pct` updates on phase transitions and via intermediate download/flash
progress callbacks; a `state/1` snapshot taken mid-download or
mid-flash can lag briefly behind the most recent PubSub event (see
`NervesGithubUpdater.Updater`'s moduledoc "Progress snapshot caveat").

## 6. Troubleshooting

  * **`{:error, :missing_public_key}`** — `:public_key` is `nil`,
    not 32 bytes, or still the all-zero sentinel. Provision a real
    key (step 3a) before enabling `verification_required: true`.
  * **`{:error, :invalid_signature}`** — the signature doesn't verify
    against the manifest bytes with the configured public key. Check
    you signed `sha512(manifest_bytes)`, not the raw manifest, and
    that the public key on-device matches the private key that
    signed it.
  * **`{:error, {:manifest_rollback, manifest_counter,
    stored_counter}}`** — the release's `counter` is lower than what
    the device already persisted. You're trying to install an older
    signed release than one it's already run; this is the rollback
    guard doing its job.
  * **`{:error, {:sha256_mismatch, expected: _, actual: _}}`** — the
    downloaded asset's hash doesn't match the manifest's `sha256` for
    that target. Usually a stale/wrong asset attached to the release,
    or a manifest built against different bytes than what's uploaded.
  * **`{:error, :no_fw_asset}`** (legacy path) / `{:error,
    {:target_asset_missing, asset_name}}` (manifest path) — no asset
    on the release matched what `:asset_matcher` (legacy) or the
    manifest's `targets` entry (signed) expected. Check the asset
    filename against your `target_fn`'s value and the release.
  * **`{:error, :rate_limited}`** — GitHub's anonymous API quota
    (60/hr/IP) is exhausted. Set `:github_token` to lift it to
    5,000/hr, and make sure you're reusing the ETag `check/1` already
    tracks — a matching `If-None-Match` returns `304`/`:not_modified`
    without counting against quota.