Install and update system¶
ForgeFIRM installs, updates, and restores through the factory's own A/B slot
scheme, with signed .fw packages applied by fwup. This page is the
design: the decisions, the invariants every flash path obeys, the slot
installer, the legacy migration, the release pipeline, the update manager
in forgectrl, the recovery design, and the contracts.
The operator procedures are on Install,
Updating,
Back to the factory firmware,
Recovery, and
Legacy migration. The factory eMMC
layout, the boot0 and boot1 maps, the U-Boot environment, the factory
recovery, and the .fw format are on
Boot and storage. The release procedure
is on Release flow; the acceptance gate
is on Acceptance.
Design decisions¶
- Factory partition scheme, unmodified. ForgeFIRM lives in the two
200 MiB rootfs slots (
mmcblk2p1andp2);/data(p3) keeps its full factory size. No repartitioning at install, ever. - A replacement, not a dual boot. ForgeFIRM replaces the factory firmware; the way back is a factory restore from the archive. The slot the factory firmware ran from is reused by the first ForgeFIRM update, so the two are never operated side by side, and the factory updater's behavior toward foreign slot contents is irrelevant.
- fwup is the universal package and apply format (the factory's own
mechanism). ForgeFIRM upgrades, factory restore, and provisioning all use
signed
.fwarchives applied to the inactive slot, followed by a U-Boot env flip: exactly the factory update flow. - Factory firmware is archived to
/databefore any factory slot is overwritten. Restore-to-factory never depends on Glowforge's servers; the cloud path (GET /update/current, implemented in gfutilities) is the optional "restore to latest" upgrade. - Release artifacts are built and signed locally and uploaded as GitHub releases. The Ed25519 private key never leaves the build host, so GitHub is untrusted hosting: machines verify signatures before applying. CI does compile checks only, never artifacts.
- Reinstalling ForgeFIRM from factory = run the installer again. The planned recovery refresh subsumes this.
- The recovery refresh is squashfs-only in its first version: the factory U-Boot, DTB, and the 3.14.28 recovery kernel stay in place; only the recovery userspace is replaced (Recovery).
Invariants (every flash path)¶
- Never write the active (running) slot.
- Machine idle; one flash operation at a time (lock file); no flash or reboot while a job runs.
- Archive factory content before the write that would destroy the last copy of it (rootfs slots; boot0 and boot1 before a recovery refresh).
- Env flips are atomic: one
fw_setenv -stransaction setting all four ofmmcdev,mmchwpart,mmcpart,mmcroot. - Automatic paths (the release updater, the cloud restore) require a valid signature, ForgeFIRM's or Glowforge's respectively. Manual uploads may be unsigned behind an explicit "unsigned dev image" warning.
- The image must fit the 200 MiB slot; the build fails past the size gate rather than producing an unflashable release.
- Boot selection refuses targets that fail the content probe (no kernel, no recognizable rootfs).
- Verify a written slot (fwup on-the-fly hashes, or an explicit readback or mount check for raw writes) before flipping boot to it.
- The product gate. A signature says who made an archive, never what it
is, and
fwup -aapplies whatever task it finds. Firmware and extension packages are the same container, so every firmware path (the upload, the release download, and the apply, beforefwup -aruns) reads the archive'smeta-productand takes firmware only:ForgeFIRM firmwareunder the release key or unsigned,Glowforge firmwareunder a factory key or unsigned. An extension package is refused in words that say what it is ("this archive is an extension package, not firmware") and is not kept. The extension host holds the other door the same way. forgectrl'sfwproduct_testholds every product class, andtests/host_update.shthe upload route over a stand-in fwup: nothing refused is staged.
Slot-agnostic images and ffboot¶
One image boots unmodified from p1, p2, or SD, steered only by the saved
U-Boot environment: mmcargs takes root=${mmcroot} from the env, and
/boot/uEnv.txt carries only entries that are not per-location (fdt_file
and the like). No flash path mounts the slot to edit it.
ffboot performs the atomic env flip (invariant 4) and the slot inventory:
ffboot -l lists every candidate (eMMC p1 and p2, legacy p4, SD) by
read-only mount, reading the factory /etc/version or
/etc/forgefirm-version and the kernel presence, plus the current env
selection, in machine-parsable output. The panel and the installer both
reuse this probe. ffboot also ships fw_env.config. The operator's use of
it is on Recovery.
The slot installer¶
install-forgefirm.sh is a single-stage script run from factory firmware
(the procedure is on Install):
- Sanity. The factory 3-partition layout, both slots 200 MiB, the
active slot detected (
rdev), enough/dataspace. - Archive. Every factory slot version not already archived:
dd | gzipto/data/forgefirm/archive/factory-rootfs-<ver>.img.gzwith a manifest line (slot, version, date, md5); also boot0 and boot1 (32 MiB) into the archive, ahead of the recovery refresh. With both slots archived, any later overwrite needs no second archive step. - Fetch
forgefirm.fwfrom GitHub releases (fixed asset name; thereleases/latest/download/URL needs one, and the version lives in the fwup metadata and the release tag), or take a local file argument for offline and dev installs. The download makes up to five tries, spaced 5, 15, 30 and 60 seconds apart; each try resumes the partial file (curl -C -) and is bounded (20 s to connect, and a stall below 1 KB/s for 30 s ends the try). A full disk and a missing release (HTTP 404) end the tries at once. The file takes its name only when curl finished. Verify the signature against the ForgeFIRM public key embedded in the installer (raw 32-byte form for the factory's fwup). - Apply to the inactive slot (fwup plus the ForgeFIRM public key). The booted factory install is not written; the first ForgeFIRM update reuses its slot.
- Atomic env flip (the flip logic is embedded, because the factory rootfs has no ffboot), then reboot.
Every run appends its steps to /data/log/forgefirm/install/install.log:
the factory version and the slots, the operator's answers, each archive,
each download try with curl's exit code and the reason in words, the state
of the network after a failed try (address, default route, resolver,
whether the release host resolves), the signature and identity checks, the
write, the boot selection, and the reason for any failure. The line carries
the installer's own md5, which names the revision that ran. Logging never
fails the install: with no writable log the lines go nowhere. The file's
place in the log tree and in the export is on
Logging.
No repartitioning, no /data backup and restore, no second stage. ffboot
returns the machine to the intact factory slot, and /data (factory state,
credentials, logs) is untouched.
Legacy p4 migration¶
slotmigrate is a boot-time init script that runs before /data mounts. It
is gated on: booted from mmcblk2p1 or p2 (never SD, never p4) AND the
legacy geometry present (p4 exists, or p3 ends short of the disk). Actions:
delete p4, extend p3's end to the disk (its start unchanged), resize2fs.
It is idempotent and power-safe: every step is keyed off actual disk state
and re-runnable after interruption.
A machine on the legacy p4 layout reaches the slot scheme by running the
installer from its running ForgeFIRM (the same flow as above; with both
factory slots intact, the newer is archived and the older overwritten), and
the boot-time check reclaims p4 into p3 on the first slot boot. /data
contents stay intact and grow to full size; a further boot is a no-op. The
operator procedure is on
Legacy migration.
The release pipeline¶
scripts/release.sh runs on the build host: gates, kas build, pack .fw,
sign, sha256sums.txt, staged assets, and the gh release create command
(--publish runs it where gh is authenticated). The gates are: a clean
tree, the version single-source, rootfs-vs-slot size (warn at 170 MiB or
more, fail at 195 MiB or more, under bitbake's own hard cap), the
installer-embedded public key must match the signing key, factory-era
fwup (0.14.2) verification of the packed archive, and the release
acceptance gate: releases/v<version>/acceptance.json (exported by
forgetest on the bench) must authorize the built rootfs. The gate recomputes
every catalog test's domain fingerprint from /etc/forgefirm-manifest.json
inside the release ext4. The artifact is attached to the GitHub release. The
release build also writes the source of every recipe of the image, and the
pipeline attaches it as forgefirm-source-v<version>.tar.gz; a recipe whose
license makes source necessary and that has no source stops the release. The
procedure and the variables are on
Release flow; the gate's contract is on
Acceptance.
One version source: FORGEFIRM_RELEASE = git tag = /etc/forgefirm-version
= .fw meta-version; the script enforces agreement. A component's version
is its pin file's PV (it moves with every SRCREV bump); version strings
inside the component sources are informational.
release.sh --dev packs forgefirm-dev.fw from the dev image (forgetest
and the bench tools included), signed with the dev key so it is never an
unsigned file in transit. The machine holds the release key and the Glowforge
keyring only, so a dev archive classifies as unsigned there and takes the
operator-present path: the unsigned confirmation with the machine button
held. No gate runs on it.
The dev image is SD-only. forgetest and the bench tools put the dev
rootfs at about 368 MiB, well past the 200 MiB slot, so it is never installed
into one: it goes on an SD card, which the boot selector carries as a
first-class location. Write forgefirm-image-dev-glowforge.rootfs.wic.gz to
the card, or point fwup at the card rather than a slot. The slot size gate
in release.sh therefore applies to the release rootfs alone.
Cloud-mode compatibility baseline. The cloud client's connect-time probe
records {latest_gf_version, tested_against_gf} to
/data/forgefirm/gf-latest.json, and forgectrl's panel warns when the live
Glowforge service has moved past the tested version (cloud mode may break).
tested_against_gf is the cloud client's configured firmware version
(FACTORY_FIRMWARE.FW_VERSION, the same value it advertises to the
service). It is not release metadata: neither release.sh nor the .fw
meta carries such a field (Cloud mode).
GitHub Actions run per-push compile checks for grblHAL-glowforge and
forgectrl (minutes, no Yocto), and an optional workflow_dispatch
cold-Yocto reproducibility build whose only product is a checksum.
The update manager¶
The endpoints are in forgectrl's src/update.c, driven from the panel's
System tab. Trust anchors live in /etc/forgefirm/keys (the
forgefirm-keys recipe: the release public key plus the Glowforge keyring).
The release check reads the latest published release from the GitHub
releases API (GET /repos/openglow-org/forgefirm/releases/latest, one
unauthenticated read, allowed 60 times an hour per address): the tag, the
notes, the assets and their sizes. It never requests the firmware file's
URL, which GitHub counts as a download. The daemon checks two minutes after
it starts, then every 24 hours; a check that got no answer is retried after
an hour. The download requests
.../releases/download/<tag>/forgefirm.fw once, for the tag the check
found. A probe download (probe=1) runs the same job on the release's
acceptance.json instead, into a file of its own: the signature check
refuses it, the job ends with the file discarded, and a download already
staged is left as it was. It is how a test drives the download without
adding to the firmware file's count, which counts installs. new in the check's answer is the version order of
v<major>.<minor>.<patch> tags; an installed version that is not one (a
development build's stamp) is older than every release. The dismissed
release is the settings key update_dismissed.
All slot writes run on one background job (polled through
GET /update/status), take the installer's /data/forgefirm/update.lock,
require idle plus no diagnostic, refuse the booted root slot, verify the
signature before writing, and re-verify the written filesystem.
| Route | Purpose |
|---|---|
GET /slots |
The slot inventory (the ffboot probe) |
POST /boot |
Set the boot target; probe-gated, refuses unprobeable targets |
GET /update/release |
The last answer of the release check: available, version, current, new, published, bytes, notes, detail, checked, dismissed |
POST /update/check |
Check the latest release now; answers as GET /update/release |
POST /update/dismiss?version=<tag> |
Dismiss the alert for that release (an empty version undoes it) |
POST /update/download |
Download the .fw to /data; probe=1 fetches the release's acceptance record instead, which is refused and discarded |
POST /update/apply |
Verify and apply to the inactive slot, verify the written slot |
POST /update/upload |
Streamed multipart upload to /data |
GET /update/status |
The background job's state |
POST /restore/factory |
Factory restore from the archive (md5 checked); source=cloud answers 501 until it ships |
POST /restore/factory-return?confirm=1 |
The setup's factory-return exit (below) |
POST /system/reboot |
Reboot |
Every state-changing call is behind forgectrl's auth layer (a login session, the panel token, and origin checks); unsigned installs additionally require the physical button held (forgectrl).
The record in the log. Every forgectrl start logs the version that
runs, its slot, and its root device as the kernel command line names it
(update: running <version> from slot <slot> (root=<device>)), so the
forgectrl log shows each change of firmware, whatever route it took: the
panel, or fwup and ffboot at the console. Each update job logs what it
sets out to do and how it ended: update: the apply finished: slot b,
version …, or update: the apply failed: … with the reason. A boot
selection, a reboot, and an upload log a line of their own. The log and
its export are on Logging.
The factory return. Every screen of the first-run setup carries a
footer link, "Go back to the factory firmware"
(Setup).
After a confirmation, POST /restore/factory-return?confirm=1 runs. It
restores the archived factory image into the other slot, when that slot no
longer holds one. Then it moves the boot selection and reboots. It runs as
the update manager's background job, under the same lock and interlocks.
Reinstalling ForgeFIRM afterward is the installer again from the console.
What /data/forgefirm/ holds. Beside the archive and the update lock:
- the setup record (
setup.json); - the sheet salt (
sheet.salt); - the TLS key and certificate;
- the account record (
users) and the home directories (home/); - the panel token (
panel.token); - the settings store of the GRBL controller (
EEPROM-glowforge.DAT, the$settings); - the sshd host keys (
ssh/), the boot timestamp (timestamp), and the random seed (random-seed): the state a read-only rootfs cannot hold (Image and BSP).
They live on /data, outside both slots, so every update and the factory
return leave them in place.
Functions of the panel page:
- Inventory: slot contents (the probe), current and next boot selection, archive presence and version.
- Update check against the GitHub releases API (daily, plus a manual button; offline-tolerant), with an alert on every tab for a newer release, dismissable per release.
- Apply release: one dialog with the release notes and one button:
download the
.fwto/data, verify the signature, apply to the inactive slot, verify, select that slot for the next boot, and restart; the page reloads when the machine is back. - Upload: streamed multipart to
/datathrough the framework's upload sink; the framework's own copy of a request body is capped at 64 KiB. Accepts a.fw(verified; warns if unsigned) and nothing else. - Boot selector including SD, with warnings, most prominently on switch-to-factory: the factory updater may auto-update and overwrite the other slot. Refuses unprobeable targets.
- Factory restore: from the
/dataarchive (offline, md5 checked), to the inactive slot, then flip. A restore from the cloud latest and a cleanup of ForgeFIRM's files in/dataare open items. - Interlocks throughout: idle-only, the update lock, never the active slot, never the slot already selected for the next boot (a write there has no revert behind it), and rollback = flip back to the previous slot.
The operator's view is on Updating.
Recovery¶
The recovery ladder is: the previous slot, then the factory button-hold recovery, then an SD card, then the serial console (Recovery). The factory recovery userspace in boot0 is what the button-hold path runs (Boot and storage).
A recovery refresh is planned. Its design:
- Replace only the boot0 recovery squashfs (boot1
/usronly if needed). Never write below offset 0xC0000 in boot0; U-Boot is physically untouchable by the refresh tool. The factory DTB and kernel 3.14.28 stay. - Userspace: static busybox, fwup, a small C web app (ulfius), hostapd and wpa_supplicant. No Python. It must carry 3.14.28-matched WiFi modules (an open decision: lift from the factory recovery, or rebuild from Glowforge's published GPL kernel source).
- Functions: button-hold starts an AP and a web UI (the factory UX): upload
a
.fw(verified against the ForgeFIRM and the Glowforge public keys, so either firmware is installable), install from the/dataarchive, set the boot target, export logs. - Flash tool: boot0 and boot1 archived first (the installer already does),
force_rounlock, write high regions only, readback verify; if both partitions are written, boot1 first, boot0 last. - First flashes are bench-gated on an attached serial console.
- A
completefull-provisioning task joins the.fwwith this work.
The refresh is independent of the update manager and builds on the stable slot scheme.
Contracts¶
- Artifacts (consumers: the installer, the update manager, recovery):
forgefirm.fw(fixed asset name; signed; the version in the fwup metadata = the release tagv<semver>; tasksupgrade.aandupgrade.b,completewith the recovery refresh),sha256sums.txt,forgefirm-image-glowforge.rootfs.wic.gz(SD burns). - Env: SD =
0/0/1//dev/mmcblk1p1; slot N =1/0/N//dev/mmcblk2pN(mmcdev/mmchwpart/mmcpart/mmcroot, always one transaction). - Archive layout:
/data/forgefirm/archive/holdsfactory-rootfs-<ver>.img.gz,recovery-boot0.img.gz,recovery-boot1.img.gz, andmanifest(slot versions, dates, checksums).
Decisions¶
uEnv.txtkeeps itsmmcargsoverride withroot=${mmcroot}; the image is slot-agnostic, steered only by the saved env.- Modern-fwup-packed signed archives apply with the factory 0.14.2 binary (raw 32-byte public key form); no shipped fwup is needed on the factory side.
- Size gates live in two layers: bitbake fails past the 200 MiB slot;
release.shwarns at 170 MiB and fails at 195 MiB. - Dev archives (
release.sh --dev) carry the dev key's signature, which no machine holds: they take the button-held unsigned path. The dev rootfs is too large for a slot, so it runs from an SD card and no size gate applies to it. - Production signing key: held offline by the operator (never in the repo, CI, or cloud-synced plaintext), public key embedded in the installer. Production-signed archives verify with fwup 1.16 and the factory's 0.14.2 (raw public key form); a dev-signed archive verifies against no key the machine has and takes the button-held path. Custody optimizes against compromise over loss: loss means users re-run a fresh installer; compromise means attacker-signed firmware on fielded machines.
- U-Boot bootcount and auto-revert are out of scope; the recovery ladder covers bad flips.
Open items¶
- Recovery kernel modules: carried from the factory image, or rebuilt from the GPL source (a recovery-refresh decision).