4.1 KiB · text History b85db9e
//! Provisioning HTTP server: serves the config form, accepts the submitted
//! config, and redirects OS captive-portal probes so the form auto-opens.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use anyhow::Result;
use esp_idf_svc::http::server::{Configuration, EspHttpServer};
use esp_idf_svc::http::Method;
use esp_idf_svc::io::Write as _;

use crate::assets::{self, Asset};
use crate::config::PortalConfig;
use crate::form::Form;
use crate::http::{serve_asset, serve_redirect, serve_shell};
use crate::store::ConfigStore;
use crate::wifi::AP_URL;

/// Hard cap on the accepted form body. A config form is a few hundred bytes;
/// anything past this is a client bug or an attempt to exhaust heap.
const MAX_BODY: usize = 8192;

/// OS connectivity-check URLs. Redirecting these to the portal pops the
/// "sign in to network" browser on Android/iOS/Windows.
pub const PROBES: &[&str] = &[
    "/generate_204",
    "/gen_204",
    "/hotspot-detect.html",
    "/library/test/success.html",
    "/ncsi.txt",
    "/connecttest.txt",
    "/canonical.html",
    "/success.txt",
];

/// The two pages the portal serves. Defaults to the crate's built-in pages;
/// override either to ship your own (gzip them in your build script and pass
/// the bytes).
#[derive(Clone, Copy)]
pub struct Pages {
    /// The config form, served at `/`.
    pub portal: Asset,
    /// The confirmation page, returned from `POST /save`.
    pub saved: Asset,
}

impl Default for Pages {
    fn default() -> Self {
        Self {
            portal: assets::PORTAL_HTML,
            saved: assets::SAVED_HTML,
        }
    }
}

/// Start the provisioning HTTP server.
///
/// `saved` is flipped to `true` once a valid config has been persisted; the
/// caller (see [`crate::provision::run`]) watches it and reboots. The returned
/// server **must be kept alive** — dropping it unregisters every handler.
pub fn start<C: PortalConfig>(
    store: ConfigStore,
    saved: Arc<AtomicBool>,
    pages: Pages,
) -> Result<EspHttpServer<'static>> {
    let mut server = EspHttpServer::new(&Configuration::default())?;

    serve_asset(&mut server, "/", pages.portal)?;
    serve_shell(&mut server)?;

    let saved_page = pages.saved;
    server.fn_handler("/save", Method::Post, move |mut req| {
        let mut body = Vec::new();
        let mut buf = [0u8; 512];
        let mut too_large = false;
        loop {
            let n = req.read(&mut buf)?;
            if n == 0 {
                break;
            }
            // Check *before* extending. The original nfc-522 loop tested the
            // already-accumulated length after appending, so it could overshoot
            // by a buffer and — worse — then parsed the truncated body as if it
            // were complete, silently saving a partial config.
            if body.len() + n > MAX_BODY {
                too_large = true;
                break;
            }
            body.extend_from_slice(&buf[..n]);
        }

        if too_large {
            log::warn!("provisioning: form body over {MAX_BODY} bytes; rejected");
            req.into_response(413, Some("Payload Too Large"), &[])?;
            return Ok::<(), anyhow::Error>(());
        }

        let form = Form::parse(&String::from_utf8_lossy(&body));
        let cfg = C::from_form(&form);

        // Refuse to persist a config that cannot boot — otherwise the device
        // reboots, fails to provision, and returns here anyway with the user
        // none the wiser.
        if !cfg.is_provisioned() {
            log::warn!("provisioning: submitted config is incomplete; not saving");
            req.into_response(400, Some("Bad Request"), &[])?
                .write_all(b"incomplete configuration")?;
            return Ok(());
        }

        store.save(&cfg)?;
        // Deliberately logs only non-secret fields.
        log::info!("provisioning saved: wifi='{}'", cfg.wifi().wifi_ssid);

        req.into_response(200, Some("OK"), saved_page.headers)?
            .write_all(saved_page.bytes)?;
        saved.store(true, Ordering::SeqCst);
        Ok(())
    })?;

    for probe in PROBES {
        serve_redirect(&mut server, probe, AP_URL)?;
    }

    Ok(server)
}