//! 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)
}
neiam
/neiam-portal
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
4.1 KiB · text
History
65cab78