18.8 KiB · text History 9d79b3d
//! 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()) };
    }
}