Public Access
Updates from Gitea, step 5: the daily check's announcement, the docs, ADR 0009
The announcement was cut at the notification's 48 bytes; it now reads "v0.11.0 is out: see Settings > Firmware". README: Updates from Gitea and its limits. ADR 0009: the device trusts the two ISRG roots. R1.md: what was built and the checks on the device, with what wasn't checked. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
+4
-1
@@ -141,7 +141,10 @@ The part of the Storage App that deletes in bulk: the card's usage, Storage Clea
|
||||
_Avoid_: Settings > Storage
|
||||
|
||||
**Firmware Update**:
|
||||
Installing a new firmware image without a USB cable: pushed over Wi-Fi from the developer's PC, or read from the SD card.
|
||||
Installing a new firmware image without a USB cable: pushed over Wi-Fi from the developer's PC, read from the SD card, or downloaded from the project's Gitea **Release**.
|
||||
|
||||
**Release**:
|
||||
A tag `v*` on the project's Gitea with a signed **Update File**, a factory image for USB, the ELF to decode crashes and checksums, built and published by CI. The device reads them to look for updates. Debug Builds aren't published.
|
||||
_Avoid_: flash, upgrade (alone)
|
||||
|
||||
**Update File**:
|
||||
|
||||
@@ -69,7 +69,21 @@ The device shows the push address in **Settings → Firmware**. It installs a co
|
||||
|
||||
To install from the SD card instead, copy the `.ota` file from `.pio/build/cardputer-adv/` into `/updates` on the card, then use **Settings → Firmware**. With the Cardputer on USB, the card can stay in: `scripts/sd_put.sh <file.ota>` sends it over the serial console into `/updates` (about 30 s for 1.6 MB, checked with SHA-256 before it's renamed into place; `SD_PUT_DEBUG=1` shows the console while it runs).
|
||||
|
||||
**The private key** lives in `~/.config/roro9stack/ota-key.pem` and must never be committed. If it's lost, generate a new pair and flash once over USB.
|
||||
**The private key** lives in `~/.config/roro9stack/ota-key.pem` and must never be committed. If it's lost, generate a new pair and flash once over USB. (CI signs releases with a copy kept as a repository secret, ADR 0008.)
|
||||
|
||||
### Updates from Gitea
|
||||
|
||||
With no PC and no card, the device can install the project's releases itself (docs/milestones/R1.md). In **Settings → Firmware**:
|
||||
|
||||
- **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update.
|
||||
- **Older releases** lists the last ten, newest first. Opening an older one offers to go back to it, with a different question.
|
||||
- **Settings → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device.
|
||||
|
||||
The connection is checked against the two ISRG roots Let's Encrypt chains end in (ADR 0009), not the usual bundle of about 130 authorities. Whatever the connection, the Update File's own signature is what decides what gets installed.
|
||||
|
||||
**IRC steps aside.** A secure connection takes about 52 KB of memory at its peak, and IRC's own takes 40 KB of the 107 KB there is. A check or an install you ask for makes IRC disconnect for the few seconds it takes and reconnect afterwards. The daily check never does that: with IRC connected it waits for a moment when IRC isn't, so while IRC stays connected for days it doesn't run, and **Latest release** is the way to check.
|
||||
|
||||
**A Debug Build** shows the latest release but doesn't install it: releases have no Debug Console (they aren't built with one, so that nobody's console token is published), and installing one would take it away. A Debug Build is updated from the PC with `scripts/flash.sh --debug --ota`.
|
||||
|
||||
## Networks without DHCP
|
||||
|
||||
@@ -167,6 +181,8 @@ A note holds up to 16 KB while it's edited. A bigger text file opens read-only i
|
||||
| `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
|
||||
| `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does |
|
||||
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one (not on a Debug Build) |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Debug Builds: pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
|
||||
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
|
||||
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
|
||||
| `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# The device trusts the two ISRG roots for what it fetches
|
||||
|
||||
The firmware talks to the project's Gitea over HTTPS (issue #6, and #4 after it). A TLS client has to decide whose certificates it believes. Three ways were possible:
|
||||
|
||||
- **The framework's bundle**, about 130 certificate authorities (about 60 KB of flash). Any of them could vouch for `git.twis.la`.
|
||||
- **Pin the server's certificate**, as the Gemini App does for capsules. The server's certificate is replaced every few months, so a pin would ask the question again at every renewal.
|
||||
- **Carry the roots the server's chain ends in:** `ISRG Root X1` (RSA 4096) and `ISRG Root X2` (ECDSA P-384), the Let's Encrypt roots, about 2.7 KB of flash (`src/platform/ca_roots.h`).
|
||||
|
||||
We chose the third. The chain and the name are checked by mbedTLS during the handshake. It trusts one organisation's two roots, valid until 2035 and 2040, and a renewal changes nothing.
|
||||
|
||||
## What it costs
|
||||
|
||||
- **If the server moves to another CA, the device can no longer reach it**, and the next firmware, carrying that CA's root, has to come from the PC or the SD card. Both still work; they don't use TLS.
|
||||
- The roots are public data checked against the published fingerprints (listed in the file), refreshed by hand if Let's Encrypt ever changes them.
|
||||
|
||||
## What it doesn't change
|
||||
|
||||
The Update File's own signature (ADR 0003) is what decides what gets installed. A hijacked connection could hide a release, or serve an older signed one, but never make the device install firmware that isn't ours. The TLS check matters more for #4, where a token will travel over it.
|
||||
|
||||
## Measured while building it
|
||||
|
||||
A TLS connection to this server peaks at about 52 KB of heap, **the same whether the certificate is checked or not**, so skipping the check would have saved nothing. The cost is the connection itself (record buffers and handshake), not the trust decision.
|
||||
+40
-3
@@ -1,6 +1,6 @@
|
||||
# R1 — Releases
|
||||
|
||||
**Status:** in progress. CI and signed releases on Gitea (issue #5) are in place since 2026-10-06: every tag from v0.1.0 to v0.10.0 has its release. Updates from Gitea (issue #6) is being built, locally, on branch `gitea-updates`. The Issues App (#4) comes after.
|
||||
**Status:** in progress. CI and signed releases on Gitea (issue #5) are in place since 2026-10-06: every tag from v0.1.0 to v0.10.0 has its release. Updates from Gitea (issue #6) is built and checked on the device, on branch `gitea-updates`, not merged yet. The Issues App (#4) comes after.
|
||||
|
||||
**Goal:** a tag is a release, built the same way every time and published where a device can find it.
|
||||
|
||||
@@ -55,7 +55,7 @@ The device looks at the project's Gitea for a newer release, says so, and instal
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q162 | **Trust:** the firmware carries ISRG Root X1 and X2 and checks the server's chain and name against them, not the framework's bundle of about 130 CAs. Shared with #4. If the server moves to another CA, the next firmware comes from the PC. |
|
||||
| Q162 | **Trust:** the firmware carries ISRG Root X1 and X2 and checks the server's chain and name against them, not the framework's bundle of about 130 CAs (ADR 0009). Shared with #4. If the server moves to another CA, the next firmware comes from the PC. |
|
||||
| Q163 | The Update File's own signature stays the real guard. A hijacked connection could hide a release or offer an older signed one, never install firmware that isn't ours. |
|
||||
| Q164 | The source, `git.twis.la` and `twisla/roro9stack`, is a constant in the firmware. A fork changes it, and has its own key. |
|
||||
| Q165 | **When:** on request in Settings > Firmware, and once a day in the background while Wi-Fi is up and the Clock is set (certificate dates need it). A setting, **Check for updates**, on by default. It installs nothing by itself; it skips quietly below the memory floor and never runs during an install. |
|
||||
@@ -65,7 +65,7 @@ The device looks at the project's Gitea for a newer release, says so, and instal
|
||||
| Q169 | **Older releases:** a list of the last ten, newest first, the running one marked. Installing an older one asks with a stronger warning. |
|
||||
| Q170 | Enter on a release shows its version, date, size and the tag message, with Install. |
|
||||
| Q171 | **Debug Builds** check and show the latest release, but don't install it: it would replace the Debug Build and its console (Q153: Debug Builds aren't published). Their updates come from the PC. |
|
||||
| Q172 | The memory floors of Q86: no check or download below 55 KB free. |
|
||||
| Q172 | **Memory, as measured:** a TLS connection peaks at about 52 KB of heap, with or without checking the certificate, so it starts with 80 KB free (Q86's 20 KB spare on top), not 55 KB. **A check, list or install someone asked for makes IRC step aside** and come back after; the daily check never does, and with IRC connected it waits. (First: 55 KB and nothing else. With IRC connected a check left 3 KB and a download 836 bytes.) |
|
||||
| Q173 | Left out, each with its issue: installing automatically (#52), a release channel (#53), resuming a download (#54). |
|
||||
| Q174 | Ships as **v0.11.0**. Tested on the device with the real signed releases; a Debug Build command pretends the device runs an older version, so v0.10.0 counts as an update. |
|
||||
|
||||
@@ -86,3 +86,40 @@ The device looks at the project's Gitea for a newer release, says so, and instal
|
||||
3. **The download:** an HTTPS source for the existing install path.
|
||||
4. **The screens:** the Firmware page's release rows, the release page, Older releases, the setting, the daily check and its Toast.
|
||||
5. **Checks on the device**, recorded here.
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/release`** (host-tested): a streaming JSON scanner, the release reader built on it, HTTP heads and chunked bodies, URLs, and the decisions (which release is an update, whether to announce it, which download URLs are taken). A list of ten releases is 33 KB of JSON and costs a few hundred bytes of memory, because nothing is kept but the path.
|
||||
- **`HttpsGet`** (`src/platform`): one GET, the answer read as a stream, redirects not followed. **`GiteaReleases`** keeps the latest and the list. **The Update Service** serves the requests on its own task (about 5.4 KB of its 7 KB stack at the peak) and installs through the install path that already existed, with an HTTPS source in place of the card or the TCP port.
|
||||
- **The daily check** is scheduled from the Update Service's tick: Wi-Fi up, the Clock set, no Probation, nothing else going on, memory for a connection. The day it last succeeded is kept in flash.
|
||||
- **A version that failed** (rolled back) is remembered as `ota_failed`, and isn't announced again by the daily check.
|
||||
- **The screens:** the Firmware page's Latest release and Older releases rows, a release page with the tag's message, and the install dialog.
|
||||
- **Debug Builds** get knobs to try what can't be tried otherwise: `update pretend`, `probe`, `damage` and `daily`.
|
||||
- **The first message,** `... available: see Settings > Firmware`, was cut at 48 bytes by the notification's own limit; it now reads `v0.11.0 is out: see Settings > Firmware`.
|
||||
|
||||
### Checks on the device (2026-10-06, Debug Builds of branch `gitea-updates`)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Host tests | 456 pass |
|
||||
| Check and list against the live server | The certificate is accepted against the two embedded roots; `releases/latest` read; a list of ten (33 KB) streamed |
|
||||
| Servers that must be refused | github.com, example.com, expired.badssl.com, self-signed.badssl.com, wrong.host.badssl.com, untrusted-root.badssl.com and the router: each "isn't accepted" or a TLS error |
|
||||
| A download cut short at 800,000 bytes | Refused, "update file too short"; the running firmware untouched |
|
||||
| One byte flipped in the signature | Refused after 160 bytes, "bad signature"; the image isn't read further |
|
||||
| One byte flipped in the image | Downloaded in full, refused at its end, "image corrupted (hash mismatch)" |
|
||||
| The real v0.10.0, from the console and then from the screen | Downloaded, restarted, confirmed on Probation: the slot table read `v0.10.0, valid` both times. The Debug Build was pushed back from the PC after each |
|
||||
| The screens | Latest release (checking, then `(current)` or `(new)`), the release page with the tag's message, Older releases with ten rows, the install dialog (Cancel by default, Back cancels), the progress screen at 28% |
|
||||
| IRC connected, before the hold | A check left 3 KB of heap; a full download, 836 bytes |
|
||||
| IRC connected, with the hold | The lowest free heap during a full download: 38 KB. IRC reconnected afterwards (its counters kept growing) |
|
||||
| The daily check | It ran by itself, announced `v0.10.0 is out: see Settings > Firmware` once; with IRC connected (68 KB free) it didn't run |
|
||||
| Speed | 1.9 MB in about 46 s, 40 KB/s, over the guest Wi-Fi at -65 dBm; not investigated further |
|
||||
|
||||
**Not checked:** the certificate's **name** on its own. Connecting by IP makes the server end the handshake before it shows its certificate, so that test proved nothing; the library sets the name it verifies, and OpenSSL on the PC refused the wrong name against the same chain. A failed daily check retrying, the clock not being set, the release that failed before not being announced (host-tested, not on the device), and the hold when IRC isn't connected but Gemini holds memory.
|
||||
|
||||
**Limits worth knowing:**
|
||||
- **With IRC connected for days, the daily check doesn't run.** It would have to take IRC down to make room. Opening Latest release does.
|
||||
- **A server that changes CA can't be reached** until a firmware carrying the new root comes from the PC (ADR 0009).
|
||||
- **No resuming:** a broken download starts over (#54).
|
||||
- **A key press during the hold:** Back on the Firmware page while a check is going doesn't cancel it.
|
||||
|
||||
**Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure.
|
||||
|
||||
@@ -510,6 +510,9 @@ static void updateCommand(const String& args) {
|
||||
args.startsWith("damage cut") ? update->damageNextDownload(n, -1) : update->damageNextDownload(-1, n);
|
||||
console.printf("update: the next download will be %s at byte %ld\n", args.startsWith("damage cut") ? "cut" : "damaged", n);
|
||||
|
||||
} else if (args == "daily") { // forget today's check: the daily one runs again at once, if it may
|
||||
update->forgetToday();
|
||||
console.println("update: the daily check will run at the next tick if Wi-Fi, the clock and memory allow");
|
||||
} else if (args.startsWith("pretend ")) { // update pretend v0.9.0 | off: what the comparisons take as running
|
||||
update->pretendVersion(args.substring(8) == "off" ? "" : args.substring(8).c_str());
|
||||
console.printf("update: running %s\n", update->runningVersion().c_str());
|
||||
|
||||
@@ -28,7 +28,7 @@ std::string whyNotConnected(NetworkClientSecure& tls, const std::string& host) {
|
||||
|
||||
std::string HttpsGet::open(const std::string& host, const std::string& path, const char* accept) {
|
||||
close();
|
||||
if (time(nullptr) < kClockSetAfter) return "The clock isn't set yet: certificates can't be checked";
|
||||
if (time(nullptr) < kClockSetAfter) return "The clock isn't set: can't check certificates";
|
||||
if (esp_get_free_heap_size() < kNeedFree) return "Not enough memory for a secure connection";
|
||||
|
||||
tls_.setCACert(kTrustedRootsPem);
|
||||
|
||||
@@ -285,7 +285,7 @@ void UpdateService::serve() {
|
||||
if (r == Request::BackgroundCheck && gitea_.latest(latest) &&
|
||||
release::shouldAnnounce(latest, runningVersion(), failedVersion(), announced_)) {
|
||||
announced_ = latest.tag;
|
||||
notify("Update " + latest.tag + " available: see Settings > Firmware", NotificationLevel::Info);
|
||||
notify(latest.tag + " is out: see Settings > Firmware", NotificationLevel::Info); // 48 bytes at most
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -307,7 +307,7 @@ void UpdateService::serve() {
|
||||
}
|
||||
// A check or a list someone asked for that failed says so; the daily one stays quiet.
|
||||
if ((r == Request::Check || r == Request::List) && gitea_.status() == GiteaReleases::Status::Failed)
|
||||
notify("Releases: " + gitea_.error(), NotificationLevel::Warning);
|
||||
notify(gitea_.error(), NotificationLevel::Warning);
|
||||
// IRC comes back, unless the device is about to restart into an update.
|
||||
if (held_ && phase_ != Phase::Installed && holdMemory) holdMemory(false);
|
||||
held_ = false;
|
||||
|
||||
@@ -69,6 +69,7 @@ class UpdateService : public Service {
|
||||
void requestProbe(const std::string& host, const std::string& path) { probeHost_ = host; probePath_ = path; probeResult_ = "..."; request_ = Request::Probe; }
|
||||
std::string probeResult() const { return probeResult_; }
|
||||
void damageNextDownload(long cutAfter, long flipAt) { damageCut_ = cutAfter; damageFlip_ = flipAt; }
|
||||
void forgetToday() { store_.putInt("rel_day", 0); nextCheckMs_ = 0; announced_.clear(); } // the daily check runs at the next tick
|
||||
#endif
|
||||
|
||||
// The main loop calls this once it has drawn a frame (part of Probation).
|
||||
|
||||
Reference in New Issue
Block a user