Strux OS Documentation
Home
Guide
Concepts
BSP Development
Reference
GitHub
Home
Guide
Concepts
BSP Development
Reference
GitHub
  • Concepts

    • Architecture Overview
    • Build Pipeline
    • Caching
    • Board Support Packages
    • Artifacts
    • Display Stack
    • Update System

Update System

This page explains how Strux OTA updates work internally: the .struxb bundle format, the signing and verification model, and how a device installs an update and recovers from a bad one. For the step-by-step workflow, see the Updates guide.

Experimental

Right now, System Updates are an experimental feature. We have major plans regarding this feature, so stay tuned!

The mental model

A Strux update is the whole OS, replaced atomically. There is no package manager on the device and no incremental patching — a bundle contains a complete root filesystem image, and the device keeps two copies of the OS side by side:

┌─────────────────┬─────────────────┬──────────────┐
│  strux-rootfs-a │  strux-rootfs-b │  strux-data  │
│  (running OS)   │  (idle slot)    │  (FAT32)     │
└─────────────────┴─────────────────┴──────────────┘
        ▲                  ▲                ▲
        │                  │                │
   booted from      update written     boot script +
   this slot        here, then         slot selection
                    becomes active     state (read by
                                       the bootloader)

An update is written to the idle slot while the device keeps running. Only after the payload is fully verified on disk does the device ask the bootloader to try the new slot — with a bounded number of attempts, so a broken update rolls back by itself.

The bundle format (.struxb)

A .struxb file is a gzip-compressed tar archive with exactly three members:

update.struxb (tar.gz)
├── manifest.json    # what's in the bundle, and how it's signed
├── manifest.sig     # base64 RSA-PSS signature over manifest.json
└── rootfs.img       # the complete root filesystem (ext4)

The manifest is small and human-readable:

{
  "schema": "dev.strux.update.bundle.v1",
  "bsp": "hd215-rk3576",
  "version": "1.2.0",
  "projectVersion": "1.2.0",
  "struxVersion": "0.3.0",
  "createdAt": "2026-06-12T15:04:05Z",
  "payload": {
    "type": "full-rootfs",
    "file": "rootfs.img",
    "size": 2147483648,
    "sha256": "..."
  },
  "signing": {
    "algorithm": "rsa-pss-sha512",
    "keyBits": 4096,
    "saltLength": "hash",
    "signedBytes": "manifest.json"
  }
}

The signature covers the manifest, and the manifest pins the payload by size and SHA-256 — so verifying the small manifest transitively verifies the multi-gigabyte payload. The member order in the archive (manifest before payload) is what makes streaming installation possible: by the time the payload bytes arrive, the device already knows their expected hash.

Bundles are created by strux update bundle (or automatically at the end of a build — see semantics below), which runs inside the builder container and signs with OpenSSL.

Signing and verification

The trust model is a single project-owned RSA keypair:

  • Private key (strux-update.key): generated by strux init or strux update gen-keypair, 4096-bit, kept out of git, used by the CLI to sign manifests with RSA-PSS/SHA-512 (salt length = hash length).
  • Public key (strux-update.pub): committed with the project and installed into every update-enabled image at /etc/strux/update.pub.

On the device, the Strux client rejects a bundle unless all of these hold:

  1. The signature over manifest.json verifies against /etc/strux/update.pub (RSA keys under 4096 bits are refused outright).
  2. The manifest declares the supported schema (dev.strux.update.bundle.v1), payload type (full-rootfs), and signing algorithm (rsa-pss-sha512).
  3. The manifest's bsp matches the BSP recorded in the image at /etc/strux/project.json — a bundle built for one board cannot be installed on another.
  4. The payload's size and SHA-256 match the manifest, checked twice: once while writing, and again by reading the slot back from disk.

There is deliberately no unsigned mode — a device without a valid public key cannot install updates at all.

How the client applies an update

The on-device Strux client receives an update either as a URL to download or a local file path (today, both arrive via the dev server connection — strux update send). Installation is a streaming pipeline:

  1. Identify the idle slot. The kernel command line carries strux.slot=A or strux.slot=B; the client targets the other one, at /dev/disk/by-partlabel/strux-rootfs-a or -b.
  2. Stream and verify. The tar stream is decompressed on the fly: manifest and signature are read and verified first, then the rootfs payload is written directly to the idle slot's block device while being hashed. No temporary copy of the payload ever exists.
  3. Re-verify on disk. The slot is read back and its size and SHA-256 compared against the manifest — catching any write or storage error.
  4. Relabel. The new filesystem is relabeled (e2label) to its slot's label (strux-rootfs-a/strux-rootfs-b), since the bundled image carries the build-time label.
  5. Arm the bootloader. The client writes the boot environment on the shared data partition: the idle slot becomes pending with strux_tries=3, and the generation counter increments. The environment is written redundantly (BOOTENV.TXT and BOOTBAK.TXT) so a power cut mid-write can't lose the boot state.
  6. Reboot. On each boot attempt, the bootloader decrements the try counter before booting the pending slot. When the new OS comes up and the Strux client starts, it marks the slot active and clears the pending state. If the counter reaches zero without that ever happening, the bootloader boots the old slot — automatic rollback.

Throughout the install, the client writes progress to /run/strux/update-progress.json and persists slot state to /strux-data/strux/update-state.json. The Go runtime exposes both to your app under the update API namespace (progress and state queries), so your frontend can render an update screen — see the Go runtime reference.

update.enabled and auto_bundle

update:
  enabled: true
  auto_bundle: true
KeyTypeDefaultDescription
enabledbooleanfalseMakes the build update-capable. BSP lifecycle scripts receive STRUX_UPDATE_ENABLED=true and switch to the A/B image layout; the public key gets baked into the image.
auto_bundlebooleanfalseAfter the BSP's make_image step, automatically sign dist/output/<bsp>/rootfs.ext4 into a .struxb bundle.

The two are independent switches but bundling requires both: the build pipeline only generates a bundle when enabled and auto_bundle are true. enabled: true with auto_bundle: false builds an update-capable image and leaves bundling to an explicit strux update bundle.

Note that enabled changes the image layout, not just a feature flag — flipping it produces a different partition table, so devices flashed without it can't be updated over the air.

Experimental — A/B dual-rootfs conventions

The dual-rootfs design is experimental and may change. However, BSPs targeting Strux v0.3.0 that want update support must follow these conventions today — the on-device client and bootloader contract depend on them:

  • Partitions: a GPT disk with two equally-sized ext4 rootfs partitions whose GPT partition labels (and discoverable paths) are strux-rootfs-a (populated) and strux-rootfs-b (left blank), plus a shared FAT32 partition labeled strux-data. The client resolves slots via /dev/disk/by-partlabel/.
  • Filesystem labels: the rootfs filesystems are labeled strux-rootfs-a / strux-rootfs-b to match their slots.
  • strux-data contents: FAT32 (so the bootloader can read and write it), containing /strux/boot.scr (the compiled A/B boot script), /strux/BOOTENV.TXT + /strux/BOOTBAK.TXT (redundant text boot environment), and /strux/update-state.json.
  • Boot environment keys: strux_active, strux_pending, strux_tries, strux_generation — the bootloader selects the slot, decrements strux_tries for a pending slot before booting it, and falls back to the active slot when tries are exhausted.
  • Kernel command line: must carry strux.slot=<A|B> so the client knows which slot it booted from, and the root device is selected by partition label (root=PARTLABEL=strux-rootfs-a or -b).
  • Rootfs configuration: the image mounts the data partition at /strux-data via fstab, installs the update public key at /etc/strux/update.pub (the build must fail if strux-update.pub is missing), and keeps a migration copy of the boot script at /etc/strux/boot.scr. First-boot partition resizing must be disabled — slot boundaries are fixed.
  • Update payload export: the make_image step must also export the bare rootfs image to dist/output/<bsp>/rootfs.ext4, which is what strux update bundle signs by default.

For the bootloader scripting, image generation, and a complete worked example, see Dual Rootfs in the BSP docs.

Where to go next

  • Updates guide — the end-to-end workflow with commands.
  • Dual Rootfs — implementing the conventions in a BSP.
  • Build Pipeline — where image creation and auto-bundling sit in the build.
Last Updated:: 6/13/26, 2:20 AM
Contributors: Miguel Medeiros
Prev
Display Stack