//! Over-the-air updates, pulled from the forge's raw registry.
//!
//! The device fetches one JSON manifest, compares its `latest` against the
//! version this build was stamped with, and if they differ streams the `.bin`
//! straight into the inactive OTA slot. See `neiam/lambent-flex-c6` on
//! gg.neiam.co — `GitGud.Packages.Formats.Raw` is the other half of this.
//!
//! ```text
//! GET https://gg.neiam.co/pkg/raw/neiam/lambent-flex-c6/manifest.json
//! {"name":"lambent-flex-c6","latest":"0.1.0+ci20260916.g1a2b3c4",
//! "versions":{"0.1.0+ci20260916.g1a2b3c4":{"files":[
//! {"name":"lambent-flex-c6.bin","size":1312768,"sha256":"…","url":"https://…"}]}}}
//! ```
//!
//! ## Different, not newer
//!
//! `latest` is whatever CI published last, and this module updates whenever it
//! differs from what is running. There is no comparison, because the version
//! strings (`0.1.0+ci20260916.g1a2b3c4`) have no ordering worth trusting and a
//! device that refuses to move "backwards" is a device that cannot be rolled
//! back from the forge — which is the only way to roll back a box that is
//! already deployed somewhere inconvenient.
//!
//! ## What is verified, and by whom
//!
//! Three separate things, and it is worth being clear which catches what:
//!
//! - **TLS** (the ESP-IDF certificate bundle) says the bytes came from the
//! forge and not from whatever else is on the network.
//! - **The manifest SHA-256**, checked here as the bytes stream past, says the
//! server sent the bytes the manifest advertised — a truncated transfer, a
//! proxy serving something stale, a half-published artifact.
//! - **`esp_ota_end`**, inside [`EspOtaUpdate::finish`], says the result is a
//! structurally valid app image for this chip. The bootloader checks it
//! again at boot.
//!
//! A failure at any of those aborts the update and leaves the running slot
//! untouched. The device stays on the firmware it has.
//!
//! ## Rollback
//!
//! `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y`, so a freshly installed image
//! boots as `Unverified` and the bootloader will put the *previous* image back
//! on the next reset unless something calls [`mark_running_slot_valid`]. That
//! call lives in `main.rs`, after wifi is up and the UDP handlers and HTTP
//! server are actually serving — an image that panics on the way there
//! un-installs itself, which is the only defence a device with no console has.
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use esp_idf_svc::hal::reset;
use esp_idf_svc::http::client::{Configuration as HttpConfiguration, EspHttpConnection};
use esp_idf_svc::http::Method;
use esp_idf_svc::ota::{EspOta, SlotState};
use esp_idf_svc::sys::{esp_crt_bundle_attach, mbedtls_sha256_context};
use serde::Deserialize;
/// Where to look for updates when nothing is configured.
///
/// The forge repository this firmware is built from, which is public and
/// anonymously readable — so a device carries no credential for it.
pub const DEFAULT_MANIFEST_URL: &str =
"https://gg.neiam.co/pkg/raw/neiam/lambent-flex-c6/manifest.json";
/// How often to look, in minutes, when nothing is configured.
pub const DEFAULT_INTERVAL_MINS: u32 = 360;
/// The file within a published version that is this firmware.
///
/// A version may carry other files (the bootloader and partition table are
/// published alongside it, for the USB migration); this is the only one the
/// device has any business writing to an app slot.
const ARTIFACT: &str = "lambent-flex-c6.bin";
/// Read in 4KB chunks — one flash page, and small enough that the TLS record
/// buffer rather than this is what sizes the transfer.
const CHUNK: usize = 4096;
/// Refuse a manifest that is obviously not one. Ours is a few hundred bytes
/// per version; 64KB is a fleet's worth of history and still nothing.
const MAX_MANIFEST: usize = 64 * 1024;
/// The version this build was stamped with.
///
/// CI sets `LAMBENT_FLEX_VERSION`; a local `cargo build` falls back to the
/// crate version, which is deliberately *not* what CI publishes — so a device
/// flashed from a laptop reads as different from anything on the forge and
/// takes the next published build.
pub fn running_version() -> &'static str {
option_env!("LAMBENT_FLEX_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
}
/// Confirm this image works, cancelling the pending rollback.
///
/// Only meaningful on the first boot of a freshly installed image; afterwards
/// it is a no-op that costs one flash read. Call it once the device is doing
/// its job, never earlier — see the module docs.
///
/// Returns the slot state as it stands afterwards, for the status endpoint.
pub fn mark_running_slot_valid() -> Result<String> {
let mut ota = EspOta::new()?;
let state = ota.get_running_slot()?.state;
if state == SlotState::Unverified {
ota.mark_running_slot_valid()?;
log::info!("ota: running image marked valid, rollback cancelled");
}
Ok(describe(ota.get_running_slot()?.state))
}
/// Which partition is running and what the bootloader thinks of it:
/// `("ota_0", "unverified")`, `("factory", "factory")` on a device that has
/// not been migrated onto the two-slot table.
///
/// Read once and cached by the caller rather than queried per request, because
/// [`EspOta`] is a singleton: an instance held by a download in progress makes
/// every other `EspOta::new()` fail, and a status endpoint that reports
/// "unknown" precisely while an update is running would be reporting the
/// opposite of what is happening.
pub fn running_slot_info() -> (String, String) {
match EspOta::new().and_then(|ota| ota.get_running_slot()) {
Ok(slot) => (slot.label.as_str().to_string(), describe(slot.state)),
Err(err) => {
log::warn!("ota: could not read the running slot: {err}");
("unknown".to_string(), "unknown".to_string())
}
}
}
fn describe(state: SlotState) -> String {
match state {
SlotState::Factory => "factory",
SlotState::Valid => "valid",
SlotState::Invalid => "invalid",
SlotState::Unverified => "unverified",
SlotState::Unknown => "unknown",
}
.to_string()
}
// ── the manifest ────────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct Manifest {
latest: Option<String>,
#[serde(default)]
versions: std::collections::HashMap<String, ManifestVersion>,
}
#[derive(Deserialize)]
struct ManifestVersion {
#[serde(default)]
files: Vec<ManifestFile>,
}
#[derive(Deserialize)]
struct ManifestFile {
name: String,
#[serde(default)]
size: u64,
#[serde(default)]
sha256: String,
url: String,
}
/// A published build that is not the one running.
#[derive(Clone, Debug)]
pub struct Available {
pub version: String,
url: String,
sha256: String,
size: u64,
}
/// What the last check found, published to the status endpoint.
#[derive(Clone, Debug, Default)]
pub struct Report {
/// Version the forge offers, when it differs from the running one.
pub available: Option<String>,
/// Why the last check or install failed, if it did.
pub last_error: Option<String>,
/// Whether a check has completed at all since boot.
pub checked: bool,
}
// ── the updater ─────────────────────────────────────────────────────────────
pub struct Updater {
manifest_url: String,
}
impl Updater {
pub fn new(manifest_url: impl Into<String>) -> Self {
Self {
manifest_url: manifest_url.into(),
}
}
/// Fetch the manifest and report a build worth installing.
pub fn check(&self) -> Result<Option<Available>> {
let body = self
.fetch_manifest()
.with_context(|| format!("fetching {}", self.manifest_url))?;
let manifest: Manifest =
serde_json::from_slice(&body).context("the manifest is not the JSON we expect")?;
let Some(latest) = manifest.latest else {
log::info!("ota: nothing published yet");
return Ok(None);
};
if latest == running_version() {
log::info!("ota: up to date ({latest})");
return Ok(None);
}
let file = manifest
.versions
.get(&latest)
.and_then(|version| version.files.iter().find(|file| file.name == ARTIFACT))
.ok_or_else(|| anyhow!("version {latest} publishes no {ARTIFACT}"))?;
if file.sha256.is_empty() {
bail!("version {latest} publishes {ARTIFACT} with no sha256");
}
log::info!(
"ota: {} available ({} bytes), running {}",
latest,
file.size,
running_version()
);
Ok(Some(Available {
version: latest,
url: file.url.clone(),
sha256: file.sha256.to_lowercase(),
size: file.size,
}))
}
/// Download `available` into the inactive slot and make it the boot slot.
///
/// Returns once the new image is installed; the caller reboots. Any error
/// leaves the running slot untouched.
pub fn apply(&self, available: &Available) -> Result<()> {
let mut connection = connection()?;
connection
.initiate_request(Method::Get, &available.url, &[])
.with_context(|| format!("requesting {}", available.url))?;
connection.initiate_response()?;
let status = connection.status();
if status != 200 {
bail!("{} returned HTTP {status}", available.url);
}
let mut ota = EspOta::new()?;
let mut update = ota.initiate_update()?;
let mut digest = Sha256::new();
let mut written: u64 = 0;
let mut buf = [0u8; CHUNK];
let outcome = loop {
match connection.read(&mut buf) {
Ok(0) => break Ok(()),
Ok(read) => {
let chunk = &buf[..read];
digest.update(chunk);
written += read as u64;
if let Err(err) = update.write(chunk) {
break Err(anyhow!("writing to the update slot: {err}"));
}
// One line per ~64KB rather than per chunk: enough to see
// a stall on the console, not enough to be the stall.
if written % (16 * CHUNK as u64) < CHUNK as u64 {
log::info!("ota: {written} bytes");
}
}
Err(err) => break Err(anyhow!("reading the image: {err}")),
}
};
// From here on, every failure path aborts the update explicitly. The
// Drop impl would too, but saying so keeps the log honest about which
// check rejected the image.
if let Err(err) = outcome {
let _ = update.abort();
return Err(err);
}
if available.size != 0 && written != available.size {
let _ = update.abort();
bail!("expected {} bytes, got {written}", available.size);
}
let got = digest.finish();
if got != available.sha256 {
let _ = update.abort();
bail!("sha256 mismatch: manifest {}, got {got}", available.sha256);
}
update
.finish()
.context("the downloaded image is not a valid app image")?
.activate()
.context("could not set the new image as the boot slot")?;
log::info!(
"ota: {} installed ({written} bytes), rebooting",
available.version
);
Ok(())
}
fn fetch_manifest(&self) -> Result<Vec<u8>> {
let mut connection = connection()?;
connection.initiate_request(Method::Get, &self.manifest_url, &[])?;
connection.initiate_response()?;
let status = connection.status();
if status != 200 {
bail!("HTTP {status}");
}
let mut body = Vec::new();
let mut buf = [0u8; 1024];
loop {
match connection.read(&mut buf)? {
0 => break,
read => {
body.extend_from_slice(&buf[..read]);
if body.len() > MAX_MANIFEST {
bail!("manifest is larger than {MAX_MANIFEST} bytes");
}
}
}
}
Ok(body)
}
}
/// An HTTPS connection that trusts the ESP-IDF certificate bundle.
///
/// `crt_bundle_attach` is what makes this verify anything at all — without it
/// esp-tls has no roots and the handshake fails, which is the right failure but
/// an obscure one to debug. The forge's certificate is Let's Encrypt, whose
/// root is in the bundle.
fn connection() -> Result<EspHttpConnection> {
Ok(EspHttpConnection::new(&HttpConfiguration {
crt_bundle_attach: Some(esp_crt_bundle_attach),
// The registry answers a version's URL directly, but `latest/<file>`
// and any future reverse proxy may not.
follow_redirects_policy: esp_idf_svc::http::client::FollowRedirectsPolicy::FollowGetHead,
timeout: Some(Duration::from_secs(30)),
buffer_size: Some(4096),
..Default::default()
})?)
}
// ── the thread ──────────────────────────────────────────────────────────────
/// Handle held by the rest of the firmware: poke the checker, read the report.
#[derive(Clone)]
pub struct Handle {
tx: Sender<()>,
report: Arc<Mutex<Report>>,
}
impl Handle {
/// Ask for a check now. Returns false only if the OTA thread has died.
pub fn poke(&self) -> bool {
self.tx.send(()).is_ok()
}
pub fn report(&self) -> Report {
self.report.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
}
/// Start the update thread.
///
/// `interval` of zero disables polling; the thread still runs, so `POST /ota`
/// works on a device that is not supposed to update itself unattended.
pub fn spawn(manifest_url: String, interval: Duration) -> Result<Handle> {
let (tx, rx) = mpsc::channel::<()>();
let report = Arc::new(Mutex::new(Report::default()));
let thread_report = report.clone();
std::thread::Builder::new()
// TLS is the reason this is not the 4KB the UDP handlers get: an
// mbedtls handshake wants several KB of stack on its own.
.stack_size(12 * 1024)
.name("ota".into())
.spawn(move || {
let updater = Updater::new(manifest_url);
// Let the device finish coming up before competing with it for
// heap and airtime — and, on a first boot after an update, before
// anything has decided whether this image works.
let mut wait = Duration::from_secs(60);
loop {
match rx.recv_timeout(wait) {
Ok(()) => log::info!("ota: check requested"),
Err(RecvTimeoutError::Timeout) => {}
// Every Handle is gone, which cannot happen while main is
// alive — but exiting beats spinning if it ever does.
Err(RecvTimeoutError::Disconnected) => break,
}
run_once(&updater, &thread_report);
wait = if interval.is_zero() {
// Nothing scheduled: block until somebody pokes. A year is
// "forever" without giving up the timeout arm.
Duration::from_secs(365 * 24 * 60 * 60)
} else {
interval
};
}
})
.context("spawning the OTA thread")?;
Ok(Handle { tx, report })
}
/// One check, and the install if there is one. Never panics: a failed update
/// must leave the device doing its actual job.
fn run_once(updater: &Updater, report: &Arc<Mutex<Report>>) {
let outcome = updater.check().and_then(|available| match available {
None => Ok(None),
Some(available) => {
set(report, |r| r.available = Some(available.version.clone()));
updater.apply(&available).map(|()| Some(available))
}
});
match outcome {
Ok(Some(_)) => {
set(report, |r| r.last_error = None);
// The new image is the boot slot; nothing else here runs on it.
reset::restart();
}
Ok(None) => set(report, |r| {
r.available = None;
r.last_error = None;
r.checked = true;
}),
Err(err) => {
// `{:#}` so the context chain ("fetching …: HTTP 503") survives.
log::warn!("ota: {err:#}");
set(report, |r| {
r.last_error = Some(format!("{err:#}"));
r.checked = true;
});
}
}
}
fn set(report: &Arc<Mutex<Report>>, f: impl FnOnce(&mut Report)) {
f(&mut report.lock().unwrap_or_else(|e| e.into_inner()));
}
// ── sha256 ──────────────────────────────────────────────────────────────────
/// mbedtls's SHA-256, which is already linked (esp-tls uses it) and on the C6
/// is backed by the hardware accelerator — so hashing the image costs nothing
/// worth measuring and adds no dependency.
struct Sha256(Box<mbedtls_sha256_context>);
impl Sha256 {
fn new() -> Self {
// SAFETY: `mbedtls_sha256_init` is the documented initialiser for a
// zeroed context, and `starts(.., 0)` selects SHA-256 over SHA-224.
let mut ctx: Box<mbedtls_sha256_context> = Box::new(unsafe { std::mem::zeroed() });
unsafe {
esp_idf_svc::sys::mbedtls_sha256_init(ctx.as_mut());
esp_idf_svc::sys::mbedtls_sha256_starts(ctx.as_mut(), 0);
}
Self(ctx)
}
fn update(&mut self, data: &[u8]) {
// SAFETY: the context is initialised and `data` is a valid slice.
unsafe {
esp_idf_svc::sys::mbedtls_sha256_update(self.0.as_mut(), data.as_ptr(), data.len());
}
}
fn finish(mut self) -> String {
let mut out = [0u8; 32];
// SAFETY: `out` is the 32 bytes SHA-256 writes.
unsafe {
esp_idf_svc::sys::mbedtls_sha256_finish(self.0.as_mut(), out.as_mut_ptr());
}
out.iter().map(|byte| format!("{byte:02x}")).collect()
}
}
impl Drop for Sha256 {
fn drop(&mut self) {
// SAFETY: frees whatever the context allocated; safe on an
// initialised context, including one already finished.
unsafe { esp_idf_svc::sys::mbedtls_sha256_free(self.0.as_mut()) };
}
}
neiam
/lambent-flex-c6
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
18.8 KiB · text
History
9d79b3d