diff --git a/boards/ssh-stamp-esp/ssh-stamp-esp32-hil/tests/uart.rs b/boards/ssh-stamp-esp/ssh-stamp-esp32-hil/tests/uart.rs index 5474b42..4e6e0ed 100644 --- a/boards/ssh-stamp-esp/ssh-stamp-esp32-hil/tests/uart.rs +++ b/boards/ssh-stamp-esp/ssh-stamp-esp32-hil/tests/uart.rs @@ -54,7 +54,7 @@ mod tests { use core::array::from_fn; use embassy_futures::select::{Either, select}; use embassy_sync::pipe::Pipe; - use ssh_stamp::serial::serial_bridge; + use ssh_stamp::serial::{CtrlD, serial_bridge}; use ssh_stamp_esp32::{ BufferedUart, EspUartPins, UART_SIGNAL, boot, spawn_uart, start_interrupt_executor, }; @@ -109,7 +109,12 @@ mod tests { let to_uart: Pipe<_, 512> = Pipe::new(); let from_uart: Pipe<_, 512> = Pipe::new(); - let bridge = serial_bridge(ChanRead(&to_uart), ChanWrite(&from_uart), uart_buf); + let bridge = serial_bridge( + ChanRead(&to_uart), + ChanWrite(&from_uart), + uart_buf, + CtrlD::Forward, + ); let receive_function = async { to_uart.write_all(b"test").await; diff --git a/boards/ssh-stamp-esp/ssh-stamp-esp32/src/boot.rs b/boards/ssh-stamp-esp/ssh-stamp-esp32/src/boot.rs index 3cc146e..4003c0b 100644 --- a/boards/ssh-stamp-esp/ssh-stamp-esp32/src/boot.rs +++ b/boards/ssh-stamp-esp/ssh-stamp-esp32/src/boot.rs @@ -75,7 +75,7 @@ macro_rules! boot { ($peripherals:ident, $rng:ident, $entropy_source:ident, $sw_int1:ident) => { $crate::init_heap!(); $crate::esp_bootloader_esp_idf::esp_app_desc!(); - $crate::esp_println::logger::init_logger_from_env(); + $crate::logger::init(); $crate::bench::log_heap("boot"); $crate::log::debug!("HSM: initialising peripherals"); diff --git a/boards/ssh-stamp-esp/ssh-stamp-esp32/src/lib.rs b/boards/ssh-stamp-esp/ssh-stamp-esp32/src/lib.rs index f1b3971..5b53ee8 100644 --- a/boards/ssh-stamp-esp/ssh-stamp-esp32/src/lib.rs +++ b/boards/ssh-stamp-esp/ssh-stamp-esp32/src/lib.rs @@ -23,6 +23,7 @@ mod boot; mod can; pub mod flash; mod hash; +pub mod logger; mod network; mod platform; mod rng; diff --git a/boards/ssh-stamp-esp/ssh-stamp-esp32/src/logger.rs b/boards/ssh-stamp-esp/ssh-stamp-esp32/src/logger.rs new file mode 100644 index 0000000..63fefde --- /dev/null +++ b/boards/ssh-stamp-esp/ssh-stamp-esp32/src/logger.rs @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: 2026 Roman Valls Guimera +// +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Console logger printing every record as a pretty-printed JSON `log` event. +//! +//! Installing a logger safely needs `log::set_logger`, which only exists on +//! targets with pointer-sized atomics. The ESP32-C2, C3 and S2 lack them, so +//! they keep esp-println's own logger and plain `LEVEL - message` lines. +//! +//! `@BENCH` records (target [`BENCH_TARGET`]) are printed raw: the benchmark +//! harness parses them as whitespace-separated `key=value` pairs. + +#[cfg(target_has_atomic = "ptr")] +use log::{Level, LevelFilter, Log, Metadata, Record}; +#[cfg(target_has_atomic = "ptr")] +use ssh_stamp::json; +#[cfg(target_has_atomic = "ptr")] +use ssh_stamp::mem_probe::BENCH_TARGET; + +/// Installs the console logger, filtered by `ESP_LOG` at build time. +pub fn init() { + #[cfg(target_has_atomic = "ptr")] + if log::set_logger(&JsonLogger).is_ok() { + log::set_max_level(max_level()); + } + #[cfg(not(target_has_atomic = "ptr"))] + esp_println::logger::init_logger_from_env(); +} + +/// `ESP_LOG`, e.g. `info` or `info,esp_radio=warn`. +#[cfg(target_has_atomic = "ptr")] +const FILTER: &str = match option_env!("ESP_LOG") { + Some(f) => f, + None => "info", +}; + +/// The level for `target`: the longest matching `prefix=level` directive, +/// else the bare `level` one, else `info`. +#[cfg(target_has_atomic = "ptr")] +fn level_for(target: &str) -> LevelFilter { + let mut global = LevelFilter::Info; + let mut best: Option<(usize, LevelFilter)> = None; + for d in FILTER.split(',').map(str::trim) { + match d.split_once('=') { + Some((prefix, level)) => { + if let Ok(level) = level.parse() + && target.starts_with(prefix) + && best.is_none_or(|(len, _)| prefix.len() > len) + { + best = Some((prefix.len(), level)); + } + } + None => { + if let Ok(level) = d.parse() { + global = level; + } + } + } + } + best.map_or(global, |(_, level)| level) +} + +/// The most verbose level any directive enables, so `log` can skip +/// formatting everything below it. +#[cfg(target_has_atomic = "ptr")] +fn max_level() -> LevelFilter { + FILTER + .split(',') + .filter_map(|d| d.rsplit('=').next()?.trim().parse().ok()) + .max() + .unwrap_or(LevelFilter::Info) +} + +#[cfg(target_has_atomic = "ptr")] +struct JsonLogger; + +#[cfg(target_has_atomic = "ptr")] +impl Log for JsonLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= level_for(metadata.target()) + } + + fn log(&self, record: &Record) { + if !self.enabled(record.metadata()) { + return; + } + if record.target() == BENCH_TARGET { + esp_println::println!("{}", record.args()); + return; + } + let level = match record.level() { + Level::Error => "error", + Level::Warn => "warn", + Level::Info => "info", + Level::Debug => "debug", + Level::Trace => "trace", + }; + esp_println::println!( + "{}", + json::Pretty::new(json::Log { + level, + target: record.target(), + msg: record.args(), + }) + ); + } + + fn flush(&self) {} +} diff --git a/boards/ssh-stamp-esp/ssh-stamp-esp32/src/network/wifi.rs b/boards/ssh-stamp-esp/ssh-stamp-esp32/src/network/wifi.rs index d324054..badb539 100644 --- a/boards/ssh-stamp-esp/ssh-stamp-esp32/src/network/wifi.rs +++ b/boards/ssh-stamp-esp/ssh-stamp-esp32/src/network/wifi.rs @@ -16,6 +16,7 @@ use core::net::Ipv4Addr; use core::net::SocketAddrV4; +use ssh_stamp::json; use edge_dhcp::io::{self, DEFAULT_SERVER_PORT}; use edge_dhcp::server::{Server, ServerOptions}; @@ -146,10 +147,13 @@ impl NetworkProviderHal for EspWifi { debug!("Checking if link is up"); if ap_stack.is_link_up() { if let Some(config) = ap_stack.config_v4() { - info!( - "Connect to the AP `{}` with IP {}", - ap_config.ap_ssid.as_str(), - config.address, + esp_println::println!( + "{}", + json::Pretty::new(json::NetUp { + ssid: ap_config.ap_ssid.as_str(), + role: "ap", + ip: config.address.address(), + }) ); } break; @@ -162,9 +166,13 @@ impl NetworkProviderHal for EspWifi { debug!("Checking if station has received IP address"); if ap_stack.is_config_up() { if let Some(config) = ap_stack.config_v4() { - info!( - "Connect to the AP `{}` with IP {}", - sta_ssid_static, config.address, + esp_println::println!( + "{}", + json::Pretty::new(json::NetUp { + ssid: sta_ssid_static, + role: "station", + ip: config.address.address(), + }) ); } break; @@ -300,35 +308,72 @@ pub async fn wifi_up(mut wifi_controller: WifiController<'static>, sta_ssid: &'s .await; match ev { Ok(EventInfo::Connected(info)) => { - info!("Station connected: {info:?}"); + esp_println::println!( + "{}", + json::Pretty::new(format_args!( + r#"{},"mac":"{}","aid":{}}}"#, + json::Head("station_connected"), + json::Mac(info.mac), + info.aid + )) + ); } Ok(EventInfo::Disconnected(info)) => { - info!("Station disconnected: {info:?}"); + esp_println::println!( + "{}", + json::Pretty::new(format_args!( + r#"{},"mac":"{}","aid":{},"reason":"{}"}}"#, + json::Head("station_disconnected"), + json::Mac(info.mac), + info.aid, + json::Esc(format_args!("{:?}", info.reason)) + )) + ); } _ => (), } Timer::after(Duration::from_millis(5000)).await; } - } else { - // Station Mode - // If the connection is lost it will attempt to reconnect. - loop { - debug!("Connecting to access point..."); - - match wifi_controller.connect_async().await { - Ok(info) => { - info!("Wifi connected to {info:?}"); + } - // Wait until we're no longer connected - let info = wifi_controller.wait_for_disconnect_async().await.ok(); - info!("Disconnected: {info:?}"); - } - Err(e) => { - info!("Failed to connect to wifi: {e:?}"); + // Station Mode + // If the connection is lost it will attempt to reconnect. + loop { + debug!("Connecting to access point..."); + + match wifi_controller.connect_async().await { + Ok(info) => { + esp_println::println!( + "{}", + json::Pretty::new(format_args!( + r#"{},"ssid":"{}","bssid":"{}","channel":{}}}"#, + json::Head("wifi_connected"), + json::Esc(info.ssid.as_str()), + json::Mac(info.bssid), + info.channel + )) + ); + + // Wait until we're no longer connected + match wifi_controller.wait_for_disconnect_async().await { + Ok(info) => esp_println::println!( + "{}", + json::Pretty::new(format_args!( + r#"{},"ssid":"{}","bssid":"{}","reason":"{}"}}"#, + json::Head("wifi_disconnected"), + json::Esc(info.ssid.as_str()), + json::Mac(info.bssid), + json::Esc(format_args!("{:?}", info.reason)) + )) + ), + Err(e) => info!("Disconnected: {e:?}"), } } - Timer::after(Duration::from_millis(1000)).await; + Err(e) => { + info!("Failed to connect to wifi: {e:?}"); + } } + Timer::after(Duration::from_millis(1000)).await; } } diff --git a/boards/ssh-stamp-esp/ssh-stamp-esp32/src/platform.rs b/boards/ssh-stamp-esp/ssh-stamp-esp32/src/platform.rs index 44c611d..e678b7e 100644 --- a/boards/ssh-stamp-esp/ssh-stamp-esp32/src/platform.rs +++ b/boards/ssh-stamp-esp/ssh-stamp-esp32/src/platform.rs @@ -74,4 +74,8 @@ impl PlatformServices for EspPlatform { fn activate_uart(&self) { UART_SIGNAL.signal(1); } + + fn console_print(&self, text: core::fmt::Arguments<'_>) { + esp_println::println!("{text}"); + } } diff --git a/docs/EMULATION.md b/docs/EMULATION.md index 131d946..557c1f0 100644 --- a/docs/EMULATION.md +++ b/docs/EMULATION.md @@ -26,13 +26,30 @@ Verified on `esp-emu` 0.38.0, ESP32-C6, ssh-stamp at sunset 0.6: (`authmode: Wpa2Personal`) and takes a DHCP lease on `192.168.4.2`. - **SSH.** With `hostfwd`, an `ssh` client on the host completes a full handshake, and the server host key it presents matches the fingerprint the - firmware logged at boot. - -``` -INFO - SSH server ident: SSH-2.0-Sunset-0.6.0-ssh-stamp-0.3.0 -INFO - SSH hostkey fingerprint: SHA256:VO+Yvf+tm7o39TlcybTUPNv5NAxc/iQFlt9fjrekW6g -INFO - Wifi connected to ConnectedInfo { ssid: "myssid", ..., authmode: Wpa2Personal } -INFO - Connect to the AP `myssid` with IP 192.168.4.2/24 + firmware printed at boot. + +```json +{ + "schema": 1, + "event": "boot", + "ident": "SSH-2.0-Sunset-0.6.0-ssh-stamp-0.3.0", + ... + "hostkey_fingerprint": "SHA256:VO+Yvf+tm7o39TlcybTUPNv5NAxc/iQFlt9fjrekW6g", + ... +} +{ + "schema": 1, + "event": "wifi_connected", + "ssid": "myssid", + ... +} +{ + "schema": 1, + "event": "net_up", + "role": "station", + "ssid": "myssid", + "ip": "192.168.4.2" +} ``` ``` @@ -93,7 +110,7 @@ despite the upstream docs describing an `idf.py merge-bin` flow), and boots it. ## Future directions -- Smoke test in CI: boot, assert on `--exit-on "SSH hostkey fingerprint"`, +- Smoke test in CI: boot, assert on `--exit-on '"event": "net_up"'`, fail on timeout. Cheap, and catches boot regressions no unit test would. - Config-partition fixture (above), unlocking station mode and therefore full SSH integration tests against a real client. diff --git a/docs/USING.md b/docs/USING.md index 9b1c477..d0d4dbb 100644 --- a/docs/USING.md +++ b/docs/USING.md @@ -8,18 +8,43 @@ SPDX-License-Identifier: GPL-3.0-or-later Once the flash process finishes successfully, follow the steps below: -1. On first boot the device generates a random WPA2 PSK and prints it to the serial console with the following (or similar) info messages: - -``` -(...) -INFO - WIFI PSK: -INFO - WIFI MAC: -INFO - SSH hostkey fingerprint: -INFO - Connect to the AP `` as a DHCP client with IP: 192.168.4.1 +1. On first boot the device generates a random WPA2 PSK and prints it to the serial console, together with the SSID, MAC, SSH hostkey fingerprint and, once the network is up, the AP's IP address (see [On the serial console](#on-the-serial-console)): + +```json +{ + "schema": 1, + "event": "boot", + "ident": "SSH-2.0-Sunset-0.6.0-ssh-stamp-1.0.4", + "wifi_ap": { + "ssid": "", + "psk": "", + "band": "2.4GHz", + "channel": 1 + }, + "mac": "", + "hostkey_fingerprint": "", + "first_login": true +} +{ + "schema": 1, + "event": "net_up", + "role": "ap", + "ssid": "", + "ip": "192.168.4.1" +} ``` 2. Connect a laptop/phone to the WiFi AP using the printed SSID and PSK, then SSH into the device at `root@192.168.4.1`. + The session is a pipe to the target's UART. As with a shell, Ctrl-D ends the session and Ctrl-C is sent to the target, so it can interrupt a program running there. To send Ctrl-D to the target as well, make the session fully transparent: + + ``` + export SSH_STAMP_TRANSPARENT=1 + ssh -o SendEnv=SSH_STAMP_TRANSPARENT root@192.168.4.1 + ``` + + and leave it with Enter, then `~.` (OpenSSH's escape sequence). Sessions without a terminal, such as `ssh root@192.168.4.1 < file`, are always transparent, so binary data passes through unchanged. + 3. Provisioning via SSH environment variables You can provision the device by sending these environment variables with your SSH client. Examples below use OpenSSH and `SendEnv` to forward local environment variables to the device. @@ -78,6 +103,72 @@ Notes: If your SSH client doesn't forward environment variables by default, use the `-o SendEnv=VAR` option as shown above or configure `SendEnv` in your SSH client config. +# Machine-readable output + +The device reports on its serial console as JSON, pretty-printed so it reads well on a terminal +and still parses with `jq`. Every object opens with `{` and closes with `}` +alone on their own lines, and its first two keys are always the same: + +```json +{ + "schema": 1, + "event": "boot", + ... +} +``` + +`schema` is the version of this JSON format. It only changes if the meaning of existing +fields changes — new fields get added without bumping it, so **ignore keys you +do not recognise** and your parser will keep working across upgrades. + +## On the serial console + +The console is shared with the ESP32 ROM bootloader and the second-stage +bootloader, neither of which is JSON. Cut the objects out by their framing and +hand them to `jq`: + +``` +espflash monitor | sed -un '/^{$/,/^}$/p' | jq -c --unbuffered . +``` + +Two objects are emitted at startup. `boot` carries the provisioning details, +shown in [First boot](#first-boot--provisioning) above, and `net_up` follows +once the network stack has an address, which is not known at boot. After +that, `station_connected` / `station_disconnected` report clients joining the +AP (`mac`, `aid`, and a `reason` on disconnect), and in station mode +`wifi_connected` / `wifi_disconnected` report the upstream AP (`ssid`, +`bssid`, `channel` or `reason`). + +Everything else the firmware logs arrives as a `log` event: + +```json +{ + "schema": 1, + "event": "log", + "level": "info", + "target": "ssh_stamp_esp32::network::wifi", + "msg": "Wifi configuring Access Point Mode" +} +``` + +`ESP_LOG` (set in `.cargo/config.toml`) chooses which levels are printed, as a +global level plus optional `target=level` overrides, e.g. +`info,esp_radio=warn`. On the ESP32-C2, C3 and S2, which lack the atomics a +custom logger needs, `log` events stay plain `INFO - message` lines; every +other object above is JSON on all chips. + +So a first-boot provisioning script can pick up what it needs directly: + +``` +PSK=$(espflash monitor | sed -un '/^{$/,/^}$/p' \ + | jq -r --unbuffered 'select(.event=="boot") | .wifi_ap.psk' | head -1) +``` + +Note `boot` **does** contain the WPA2 PSK in the clear. It has to: nothing can +associate with the AP without it, and it is generated on first boot and +printed nowhere else. This is a local serial cable rather than a network peer +— but it does mean a captured console log is a credential. + # Pin assignments UART, CAN and I2C pins are defined per-board in `boards/*.toml` files inside diff --git a/src/app.rs b/src/app.rs index 4bb0a02..7d4b09b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -19,12 +19,12 @@ use embassy_net::{ tcp::{AcceptError, TcpSocket}, }; use embassy_sync::{blocking_mutex::raw::NoopRawMutex, channel::Channel}; +use embassy_time::{Duration, with_timeout}; use heapless::String; use log::{debug, error, info, warn}; use ssh_stamp_hal::{BandMode, WifiApConfigStatic}; #[cfg(feature = "can")] use sunset::ChanHandle; -use sunset::SignKey; use sunset_async::SunsetMutex; #[cfg(feature = "mem-probe")] use { @@ -35,16 +35,18 @@ use { use crate::config::SSHStampConfig; use crate::handle::{self, SessionType}; +use crate::json; use crate::mem_probe::{Checkpoint, checkpoint, mark_kex_start, replay_checkpoints}; use crate::platform::PlatformServices; use crate::serial::BufferedSerial; use crate::serve; -use crate::settings::{ - SSH_STAMP_IDENT, TCP_RX_BUF, TCP_TX_BUF, UART_BUFFER_SIZE, WIFI_PASSWORD_CHARS, -}; +use crate::settings::{TCP_RX_BUF, TCP_TX_BUF, UART_BUFFER_SIZE, WIFI_PASSWORD_CHARS}; + +/// How long to wait for the client to acknowledge each step of closing. +const TCP_CLOSE_TIMEOUT: Duration = Duration::from_secs(2); /// Ensures a `WiFi` password exists, persists a freshly-generated one if not, -/// prints the SSH hostkey fingerprint, and returns a ready-to-use +/// prints the `boot` JSON line (see [`json::Boot`]), and returns a ready-to-use /// [`WifiApConfigStatic`] for a [`ssh_stamp_hal::WifiHal`] implementation. /// /// The returned config resolves the `[0xFF; 6]` random-MAC sentinel to a @@ -65,8 +67,6 @@ pub async fn prepare_ap_config( ) -> Result { let mut guard = config.lock().await; - info!("SSH server ident: {SSH_STAMP_IDENT}"); - if guard.wifi_ap_pw.is_empty() { let pw = generate_wifi_password()?; warn!("wifi_pw missing from config, generated new password"); @@ -76,17 +76,10 @@ pub async fn prepare_ap_config( .await .map_err(|_| sunset::error::BadUsage.build())?; } - log_ap_credentials(&guard); let mac = guard .resolve_mac() .map_err(|_| sunset::error::BadUsage.build())?; - info!( - "WIFI MAC: {:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", - mac[0], mac[1], mac[2], mac[3], mac[4], mac[5] - ); - - print_hostkey_fingerprint(&guard.hostkey); // Resolve band mode from the stored u8 (0=2.4G, 1=5G, 2=Auto). // 5GHz is only available on the ESP32-C5; other chips silently fall @@ -95,7 +88,14 @@ pub async fn prepare_ap_config( // Channel 1 for 2.4GHz, channel 36 for 5GHz/Auto (esp-radio default). let channel = if guard.wifi_ap_band == 0 { 1 } else { 36 }; - info!("WIFI AP band: {band:?} (channel {channel})"); + platform.console_print(format_args!( + "{}", + json::Pretty::new(json::Boot { + config: &guard, + mac, + channel, + }) + )); Ok(WifiApConfigStatic { ap_ssid: guard.wifi_ap_ssid.clone(), @@ -109,9 +109,10 @@ pub async fn prepare_ap_config( } /// Logs the access point credentials. +#[cfg(feature = "mem-probe")] fn log_ap_credentials(config: &SSHStampConfig) { - info!("WIFI SSID: {}", config.wifi_ap_ssid); - info!("WIFI PSK: {}", config.wifi_ap_pw); + log::info!("WIFI SSID: {}", config.wifi_ap_ssid); + log::info!("WIFI PSK: {}", config.wifi_ap_pw); } /// Accepts TCP connections for the server loop. @@ -204,6 +205,7 @@ where checkpoint(Checkpoint::TcpListening); replay_checkpoints(); + #[cfg(feature = "mem-probe")] log_ap_credentials(&*config.lock().await); let mut acceptor = SessionAcceptor::new(); loop { @@ -243,16 +245,42 @@ where let (mut rsock, mut wsock) = tcp_socket.split(); let server = ssh_server.run(&mut rsock, &mut wsock); - match select3(server, connection, bridge).await { + let reboot = match select3(server, connection, bridge).await { + Either3::Second(Ok(())) => true, Either3::First(r) | Either3::Second(r) | Either3::Third(r) => { if let Err(e) = r { warn!("Session ended: {e}"); } + false } + }; + + close_tcp(&mut tcp_socket).await; + if reboot { + info!("Rebooting to apply the configuration changes..."); + platform.reset(); } } } +/// Closes the TCP connection so the client sees the session end. +/// +/// Dropping a `TcpSocket` only removes it from the stack, sending neither FIN +/// nor RST, which leaves the client in a session that no longer exists until +/// its own TCP timeout, unable to leave it with Ctrl-C or Ctrl-D. Closing +/// sends a FIN after any unsent data; a peer that does not acknowledge it in +/// time is sent an RST instead. +async fn close_tcp(socket: &mut TcpSocket<'_>) { + socket.close(); + if with_timeout(TCP_CLOSE_TIMEOUT, socket.flush()) + .await + .is_err() + { + socket.abort(); + let _ = with_timeout(TCP_CLOSE_TIMEOUT, socket.flush()).await; + } +} + fn generate_wifi_password() -> Result, sunset::Error> { let mut rnd = [0u8; 24]; getrandom::fill(&mut rnd).map_err(|_| sunset::Error::msg("RNG failed"))?; @@ -262,18 +290,3 @@ fn generate_wifi_password() -> Result, sunset::Error> { } Ok(pw) } - -fn print_hostkey_fingerprint(hostkey: &SignKey) { - match hostkey { - SignKey::Ed25519(_) => { - let pubkey = hostkey.pubkey(); - match pubkey.fingerprint() { - Ok(fp) => info!("SSH hostkey fingerprint: {fp}"), - Err(e) => warn!("Failed to compute fingerprint: {e:?}"), - } - } - SignKey::AgentEd25519(_) => { - warn!("Unsupported key type for fingerprint"); - } - } -} diff --git a/src/handle.rs b/src/handle.rs index 4c9747e..d04bd01 100644 --- a/src/handle.rs +++ b/src/handle.rs @@ -19,7 +19,7 @@ use log::{debug, info, warn}; use crate::config::SSHStampConfig; use crate::platform::PlatformServices; -use crate::serial::{BufferedSerial, serial_bridge}; +use crate::serial::{BufferedSerial, CtrlD, serial_bridge}; #[cfg(feature = "can")] use crate::can::can_bridge; @@ -185,7 +185,7 @@ pub mod env_parser { #[derive(Debug)] pub enum SessionType { - Bridge(ChanHandle), + Bridge(ChanHandle, CtrlD), #[cfg(feature = "sftp-ota")] Sftp(ChanHandle), } @@ -195,6 +195,10 @@ pub struct EventContext<'a> { pub auth_checked: &'a mut bool, pub config_changed: &'a mut bool, pub needs_reset: &'a mut bool, + /// The client asked for a PTY, i.e. this is an interactive terminal. + pub pty: &'a mut bool, + /// The client sent `SSH_STAMP_TRANSPARENT=1`: forward Ctrl-D too. + pub transparent: &'a mut bool, /// Hands accepted `can` subsystem channels to the CAN bridge, which /// runs concurrently with the shell (UART) session. #[cfg(feature = "can")] @@ -283,6 +287,10 @@ pub fn session_subsystem( /// Handles SSH session shell requests. /// +/// Returns `true` when a saved configuration change needs a reboot. The +/// shell is not opened then: the caller ends the session, closing the TCP +/// connection so the client sees it end, and reboots afterwards. +/// /// # Errors /// /// Returns an error if SSH protocol operations fail. @@ -292,7 +300,7 @@ pub async fn session_shell( config: &SunsetMutex, chan_pipe: &Channel, platform: &P, -) -> Result<(), sunset::Error> { +) -> Result { if let ServEvent::SessionShell(a) = ev { debug!("ServEvent::SessionShell"); @@ -309,8 +317,8 @@ pub async fn session_shell( .map_err(|_| sunset::error::BadUsage.build())?; drop(config_guard); if *ctx.needs_reset { - info!("Configuration saved. Rebooting to apply the changes..."); - platform.reset(); + info!("Configuration saved, ending the session to reboot"); + return Ok(true); } } debug_assert_eq!(ch.num(), a.channel()); @@ -318,7 +326,14 @@ pub async fn session_shell( debug!("We got shell"); platform.activate_uart(); debug!("Connection loop: UART activated"); - match chan_pipe.try_send(SessionType::Bridge(ch)) { + // Ctrl-D ends an interactive session, as at a shell prompt. + // Without a PTY the stream may be binary, where 0x04 is data. + let ctrl_d = if *ctx.pty && !*ctx.transparent { + CtrlD::Exit + } else { + CtrlD::Forward + }; + match chan_pipe.try_send(SessionType::Bridge(ch, ctrl_d)) { Ok(()) => *ctx.auth_checked = false, Err(e) => log::error!("Could not send the channel: {e:?}"), } @@ -326,7 +341,7 @@ pub async fn session_shell( a.fail()?; } } - Ok(()) + Ok(false) } /// Handles the first authentication request. @@ -504,6 +519,9 @@ pub async fn session_env( "SSH_STAMP_UART_STOP_BITS" => { uart_env(UartParam::StopBits, a, config, ctx).await?; } + "SSH_STAMP_TRANSPARENT" => { + transparent_env(a, ctx)?; + } _ => { debug!("Ignoring unknown environment variable: {}", a.name()?); a.succeed()?; @@ -513,6 +531,29 @@ pub async fn session_env( Ok(()) } +/// Handles `SSH_STAMP_TRANSPARENT`, which makes this session forward +/// Ctrl-D to the target instead of ending the session on it. +/// +/// Per session and never stored, so it needs no authentication. +/// +/// # Errors +/// +/// Returns an error if SSH protocol operations fail. +pub fn transparent_env( + a: sunset::event::ServEnvironmentRequest<'_, '_>, + ctx: &mut EventContext<'_>, +) -> Result<(), sunset::Error> { + *ctx.transparent = match a.value()? { + "1" | "true" | "yes" | "on" => true, + "0" | "false" | "no" | "off" => false, + _ => { + warn!("SSH_STAMP_TRANSPARENT must be 1 or 0"); + return a.fail(); + } + }; + a.succeed() +} + /// Handles `SSH_STAMP_PUBKEY` environment variable requests. /// /// # Errors @@ -840,6 +881,7 @@ pub async fn session_pty( if *ctx.auth_checked || first_login { debug!("ServEvent::SessionPty: Session granted"); a.succeed()?; + *ctx.pty = true; } else { debug!("ServEvent::SessionPty: No auth not session"); a.fail()?; @@ -901,12 +943,12 @@ where let session_type = chan_pipe.receive().await; debug!("Checking bridge session type"); match session_type { - SessionType::Bridge(ch) => { + SessionType::Bridge(ch, ctrl_d) => { info!("Handling bridge session"); let chan_io: ChanInOut<'_> = ssh_server.stdio(ch).await?; let (stdin, stdout) = chan_io.split(); info!("Starting bridge"); - serial_bridge(stdin, stdout, uart_buff).await?; + serial_bridge(stdin, stdout, uart_buff, ctrl_d).await?; } #[cfg(feature = "sftp-ota")] SessionType::Sftp(ch) => { diff --git a/src/json.rs b/src/json.rs new file mode 100644 index 0000000..2f45e6d --- /dev/null +++ b/src/json.rs @@ -0,0 +1,374 @@ +// SPDX-FileCopyrightText: 2026 Roman Valls Guimera +// +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Minimal JSON emission for machine-readable status output. +//! +//! ssh-stamp reports its state on the serial console: the provisioning +//! details at boot (`WiFi` PSK, hostkey fingerprint), the network coming up, +//! stations joining, and its log. This module provides just enough to emit +//! that as JSON, without pulling in a serialiser — there is one shape per +//! message and no need for reflection. +//! +//! # Conventions +//! +//! Every object is built compact, starting with the same two keys, and +//! printed through [`Pretty`] so it is readable on a terminal: +//! +//! ```text +//! { +//! "schema": 1, +//! "event": "boot", +//! ... +//! } +//! ``` +//! +//! A top-level `{` and `}` are always alone on their lines, which is how a +//! consumer cuts our objects out of unrelated console noise such as the +//! ESP32 ROM bootloader prelude. [`SCHEMA`] is the format version: bumping +//! it signals an incompatible change; adding keys does not, so consumers +//! must ignore unknown ones. +//! +//! # Escaping +//! +//! Values reaching these messages include a user-set SSID, which can hold +//! any printable ASCII, quotes and backslashes included. [`Esc`] handles +//! that; using it for every string value is not optional, since one stray +//! quote turns a parseable document into a broken one. + +use core::fmt::{Display, Formatter, Result, Write as _}; +use core::net::Ipv4Addr; + +use heapless::String; +use sunset::SignKey; + +use crate::config::SSHStampConfig; +use crate::settings::SSH_STAMP_IDENT; + +/// Version of the JSON format, emitted as `"schema"` on every object. +pub const SCHEMA: u32 = 1; + +/// The opening of every ssh-stamp JSON object: the schema version, then the +/// event name. +pub struct Head<'a>(pub &'a str); + +impl Display for Head<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + write!(f, r#"{{"schema":{SCHEMA},"event":"{}""#, Esc(self.0)) + } +} + +/// Escapes a value for use as a JSON string, per RFC 8259. +/// +/// Takes anything `Display` and escapes it as it is written, so formatted +/// text needs no intermediate buffer. Wraps the value only — callers supply +/// the surrounding quotes, so this composes inside a larger `write!`. +pub struct Esc(pub T); + +impl Display for Esc { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + write!(EscWriter(f), "{}", self.0) + } +} + +struct EscWriter<'a, W>(&'a mut W); + +impl core::fmt::Write for EscWriter<'_, W> { + fn write_str(&mut self, s: &str) -> Result { + let f = &mut *self.0; + for c in s.chars() { + match c { + '"' => f.write_str("\\\"")?, + '\\' => f.write_str("\\\\")?, + '\n' => f.write_str("\\n")?, + '\r' => f.write_str("\\r")?, + '\t' => f.write_str("\\t")?, + // Everything below 0x20 must be escaped; \u is the only + // form that covers the ones without a short escape. + c if (c as u32) < 0x20 => write!(f, "\\u{:04x}", c as u32)?, + c => f.write_char(c)?, + } + } + Ok(()) + } +} + +/// Re-indents compact JSON for humans as it streams through. +/// +/// Every object is still emitted compact and then reformatted here, so the +/// emitters stay simple and a top-level object always starts with `{` and +/// ends with `}` alone on their own lines, which is what lets a consumer cut +/// objects out of mixed console output. State is kept between calls to +/// [`feed`](Self::feed), since `write!` hands over an object in pieces. +#[derive(Default)] +struct PrettyState { + depth: u8, + in_str: bool, + escaped: bool, + /// Just opened a `{` or `[`; the newline waits in case it is empty. + opened: bool, +} + +impl PrettyState { + /// Writes `s`, reformatted, to `w`. + fn feed(&mut self, w: &mut W, s: &str) -> Result { + for c in s.chars() { + if self.in_str { + w.write_char(c)?; + if self.escaped { + self.escaped = false; + } else if c == '\\' { + self.escaped = true; + } else if c == '"' { + self.in_str = false; + } + continue; + } + if self.opened { + self.opened = false; + if matches!(c, '}' | ']') { + self.depth = self.depth.saturating_sub(1); + w.write_char(c)?; + continue; + } + self.newline(w)?; + } + match c { + '{' | '[' => { + w.write_char(c)?; + self.depth = self.depth.saturating_add(1); + self.opened = true; + } + '}' | ']' => { + self.depth = self.depth.saturating_sub(1); + self.newline(w)?; + w.write_char(c)?; + } + ',' => { + w.write_char(',')?; + self.newline(w)?; + } + ':' => w.write_str(": ")?, + '"' => { + self.in_str = true; + w.write_char('"')?; + } + c => w.write_char(c)?, + } + } + Ok(()) + } + + fn newline(&self, w: &mut W) -> Result { + w.write_char('\n')?; + for _ in 0..self.depth { + w.write_str(" ")?; + } + Ok(()) + } +} + +/// Displays compact JSON pretty-printed; see [`PrettyState`]. +pub struct Pretty(T); + +impl Pretty { + pub fn new(value: T) -> Self { + Self(value) + } +} + +impl Display for Pretty { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let mut w = PrettyWriter { + out: f, + state: PrettyState::default(), + }; + write!(w, "{}", self.0) + } +} + +struct PrettyWriter<'a, W> { + out: &'a mut W, + state: PrettyState, +} + +impl core::fmt::Write for PrettyWriter<'_, W> { + fn write_str(&mut self, s: &str) -> Result { + self.state.feed(self.out, s) + } +} + +/// A MAC address as lowercase colon-separated hex, e.g. `60:55:f9:f7:00:4c`. +pub struct Mac(pub [u8; 6]); + +impl Display for Mac { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let m = self.0; + write!( + f, + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + m[0], m[1], m[2], m[3], m[4], m[5] + ) + } +} + +/// Human name for the stored band code. +fn band_label(band: u8) -> &'static str { + match band { + 0 => "2.4GHz", + 1 => "5GHz", + 2 => "auto", + _ => "unknown", + } +} + +/// The provisioning details, as the `boot` object printed on the console. +/// +/// Includes the PSK: it is generated on first boot and printed nowhere else, +/// and nobody can associate to the AP without it. The AP address is not +/// known yet; [`NetUp`] follows once the network stack has one. +pub struct Boot<'a> { + pub config: &'a SSHStampConfig, + pub mac: [u8; 6], + pub channel: u8, +} + +impl Display for Boot<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let c = self.config; + let mut fp = String::<64>::new(); + if let SignKey::Ed25519(_) = c.hostkey + && let Ok(k) = c.hostkey.pubkey().fingerprint() + { + let _ = write!(fp, "{k}"); + } + write!( + f, + concat!( + r#"{},"ident":"{}","#, + r#""wifi_ap":{{"ssid":"{}","psk":"{}","band":"{}","channel":{}}},"#, + r#""mac":"{}","hostkey_fingerprint":"{}","first_login":{}}}"# + ), + Head("boot"), + Esc(SSH_STAMP_IDENT), + Esc(&c.wifi_ap_ssid), + Esc(&c.wifi_ap_pw), + band_label(c.wifi_ap_band), + self.channel, + Mac(self.mac), + Esc(&fp), + c.first_login, + ) + } +} + +/// The network details, as the `net_up` object printed on the console. +/// +/// `role` is `"ap"` when the device hosts its own AP, `"station"` when it +/// joined one. +pub struct NetUp<'a> { + pub ssid: &'a str, + pub role: &'a str, + pub ip: Ipv4Addr, +} + +impl Display for NetUp<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + write!( + f, + r#"{},"role":"{}","ssid":"{}","ip":"{}"}}"#, + Head("net_up"), + Esc(self.role), + Esc(self.ssid), + self.ip, + ) + } +} + +/// A log record, as the `log` event the console logger prints. +pub struct Log<'a, M> { + /// Lowercase level name, e.g. `"info"`. + pub level: &'a str, + pub target: &'a str, + pub msg: M, +} + +impl Display for Log<'_, M> { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + write!( + f, + r#"{},"level":"{}","target":"{}","msg":"{}"}}"#, + Head("log"), + Esc(self.level), + Esc(self.target), + Esc(&self.msg), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn esc(s: &str) -> String<128> { + let mut out = String::new(); + core::fmt::write(&mut out, format_args!("{}", Esc(s))).unwrap(); + out + } + + #[test] + fn plain_text_is_unchanged() { + assert_eq!(esc("ssh-stamp-a1b2").as_str(), "ssh-stamp-a1b2"); + } + + #[test] + fn quotes_and_backslashes_are_escaped() { + // A user-set SSID can contain both, and either one unescaped + // breaks the whole document. + assert_eq!(esc(r#"my "ssid""#).as_str(), r#"my \"ssid\""#); + assert_eq!(esc(r"back\slash").as_str(), r"back\\slash"); + } + + #[test] + fn control_characters_are_escaped() { + assert_eq!(esc("a\nb\r\tc").as_str(), "a\\nb\\r\\tc"); + assert_eq!(esc("\x00\x1f").as_str(), "\\u0000\\u001f"); + } + + #[test] + fn head_starts_with_schema_and_event() { + let mut out = String::<128>::new(); + core::fmt::write(&mut out, format_args!("{}", Head("boot"))).unwrap(); + assert_eq!(out.as_str(), r#"{"schema":1,"event":"boot""#); + } + + fn pretty(s: &str) -> String<256> { + let mut out = String::new(); + core::fmt::write(&mut out, format_args!("{}", Pretty::new(s))).unwrap(); + out + } + + #[test] + fn pretty_prints_one_field_per_line() { + assert_eq!( + pretty(r#"{"a":1,"b":{"c":"x"},"d":{}}"#).as_str(), + "{\n \"a\": 1,\n \"b\": {\n \"c\": \"x\"\n },\n \"d\": {}\n}" + ); + } + + #[test] + fn pretty_leaves_strings_alone_and_spans_fragments() { + // Punctuation and escaped quotes inside a string are not structure. + let compact = r#"{"s":"a,b:{c}\"d"}"#; + let expected = "{\n \"s\": \"a,b:{c}\\\"d\"\n}"; + assert_eq!(pretty(compact).as_str(), expected); + + // Split mid-string and mid-escape, as `write!` may hand it over. + let mut st = PrettyState::default(); + let mut out = String::<256>::new(); + for frag in [r#"{"s":"a,b:{c}\"#, r#""d"}"#] { + st.feed(&mut out, frag).unwrap(); + } + assert_eq!(out.as_str(), expected); + } +} diff --git a/src/lib.rs b/src/lib.rs index 2179e8b..5f2e44f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -139,6 +139,7 @@ pub mod can; pub mod config; pub mod errors; pub mod handle; +pub mod json; pub mod mem_probe; pub mod platform; pub mod serial; diff --git a/src/mem_probe.rs b/src/mem_probe.rs index 2a8c5c8..00052b0 100644 --- a/src/mem_probe.rs +++ b/src/mem_probe.rs @@ -12,11 +12,15 @@ use embassy_time::Instant; #[cfg(feature = "mem-probe")] use portable_atomic::{AtomicU64, Ordering}; +/// Log target of `@BENCH` lines, which loggers must print unchanged: the +/// benchmark harness parses them as whitespace-separated `key=value` pairs. +pub const BENCH_TARGET: &str = "bench"; + /// Emits a structured benchmark line. #[macro_export] macro_rules! bench_emit { ($($arg:tt)*) => { - ::log::info!("@BENCH {}", ::core::format_args!($($arg)*)) + ::log::info!(target: $crate::mem_probe::BENCH_TARGET, "@BENCH {}", ::core::format_args!($($arg)*)) }; } diff --git a/src/platform.rs b/src/platform.rs index 3af3aa7..8bb50b2 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -66,4 +66,8 @@ pub trait PlatformServices { /// Signal the platform's buffered UART task that SSH is ready and /// UART transfer may start. Idempotent. fn activate_uart(&self); + + /// Print to the console as-is, without the logger's level prefix, so + /// machine-readable output stays parseable. + fn console_print(&self, text: core::fmt::Arguments<'_>); } diff --git a/src/serial.rs b/src/serial.rs index d7cb0fb..97d6b2a 100644 --- a/src/serial.rs +++ b/src/serial.rs @@ -10,7 +10,20 @@ use core::future::Future; use embassy_futures::select::select; use embedded_io_async::{Read, Write}; -use log::{debug, warn}; +use log::{debug, info, warn}; + +/// Ctrl-D (EOT) as sent by a terminal. +const CTRL_D: u8 = 0x04; + +/// What the bridge does with a Ctrl-D (0x04) from the client. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CtrlD { + /// Ends the session, like Ctrl-D at a shell prompt. Bytes before it are + /// still forwarded. + Exit, + /// Forwards it to the target like any other byte. + Forward, +} /// Platform-agnostic buffered serial bridge. /// @@ -37,16 +50,21 @@ pub trait BufferedSerial: Sync { } /// Forwards an incoming SSH connection to/from the local UART, until -/// the connection drops. +/// the connection drops or, with [`CtrlD::Exit`], the client sends Ctrl-D. /// # Errors /// Returns an error if the SSH connection fails. pub async fn serial_bridge( chan_read: impl Read, chan_write: impl Write, uart: &U, + ctrl_d: CtrlD, ) -> Result<(), sunset::Error> { debug!("Starting serial <--> SSH bridge"); - select(uart_to_ssh(uart, chan_write), ssh_to_uart(chan_read, uart)).await; + select( + uart_to_ssh(uart, chan_write), + ssh_to_uart(chan_read, uart, ctrl_d), + ) + .await; debug!("Stopping serial <--> SSH bridge"); Ok(()) } @@ -69,6 +87,7 @@ async fn uart_to_ssh( async fn ssh_to_uart( mut chan_read: impl Read, uart_buf: &U, + ctrl_d: CtrlD, ) -> Result<(), sunset::Error> { let mut uart_tx_buf = [0u8; 64]; loop { @@ -76,6 +95,76 @@ async fn ssh_to_uart( if n == 0 { return Err(sunset::Error::ChannelEOF); } - uart_buf.write(&uart_tx_buf[..n]).await; + let data = &uart_tx_buf[..n]; + if ctrl_d == CtrlD::Exit + && let Some(i) = data.iter().position(|&b| b == CTRL_D) + { + uart_buf.write(&data[..i]).await; + info!("Ctrl-D from the client, ending the session"); + return Ok(()); + } + uart_buf.write(data).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use core::future::{pending, ready}; + use std::sync::Mutex; + use std::vec::Vec; + + /// Records what the bridge writes to the target. + struct Uart(Mutex>); + + impl BufferedSerial for Uart { + fn read(&self, _buf: &mut [u8]) -> impl Future { + pending() + } + + fn write(&self, buf: &[u8]) -> impl Future { + self.0.lock().unwrap().extend_from_slice(buf); + ready(()) + } + + fn check_dropped_bytes(&self) -> usize { + 0 + } + } + + /// A client that sends `self.0` and then EOF. + struct Client(&'static [u8]); + + impl embedded_io_async::ErrorType for Client { + type Error = sunset::Error; + } + + impl Read for Client { + fn read(&mut self, buf: &mut [u8]) -> impl Future> { + let n = self.0.len().min(buf.len()); + buf[..n].copy_from_slice(&self.0[..n]); + self.0 = &self.0[n..]; + ready(Ok(n)) + } + } + + fn to_uart(input: &'static [u8], ctrl_d: CtrlD) -> (Result<(), sunset::Error>, Vec) { + let uart = Uart(Mutex::new(Vec::new())); + let result = embassy_futures::block_on(ssh_to_uart(Client(input), &uart, ctrl_d)); + (result, uart.0.into_inner().unwrap()) + } + + #[test] + fn ctrl_d_ends_the_session_after_forwarding_what_came_before() { + let (result, sent) = to_uart(b"ls\x04rest", CtrlD::Exit); + assert!(result.is_ok()); + assert_eq!(sent, b"ls"); + } + + #[test] + fn transparent_sessions_forward_ctrl_d() { + let (result, sent) = to_uart(b"ls\x04rest", CtrlD::Forward); + assert!(matches!(result, Err(sunset::Error::ChannelEOF))); + assert_eq!(sent, b"ls\x04rest"); } } diff --git a/src/serve.rs b/src/serve.rs index aed9258..70e2a8d 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -33,6 +33,9 @@ use embassy_sync::channel::Channel; /// Handles the SSH connection loop, processing events from clients. /// +/// Returns `Ok(())` only when a saved configuration change needs a reboot; +/// the caller closes the connection first, then reboots. +/// /// # Errors /// Returns an error if SSH protocol operations fail. /// @@ -48,6 +51,8 @@ pub async fn connection_loop( let mut session: Option = None; let mut config_changed = false; let mut needs_reset = false; + let mut pty = false; + let mut transparent = false; let mut auth_checked = false; #[cfg(all(feature = "sftp-ota", feature = "can"))] let mut can_dispatched = false; @@ -63,6 +68,8 @@ pub async fn connection_loop( auth_checked: &mut auth_checked, config_changed: &mut config_changed, needs_reset: &mut needs_reset, + pty: &mut pty, + transparent: &mut transparent, #[cfg(feature = "can")] can_queue, #[cfg(all(feature = "sftp-ota", feature = "can"))] @@ -77,7 +84,9 @@ pub async fn connection_loop( session_subsystem(ev, &mut ctx)?; } ServEvent::SessionShell(_) => { - session_shell(ev, &mut ctx, config, chan_pipe, platform).await?; + if session_shell(ev, &mut ctx, config, chan_pipe, platform).await? { + return Ok(()); + } } ServEvent::FirstAuth(_) => { checkpoint(Checkpoint::KexComplete); diff --git a/xtask/src/device.rs b/xtask/src/device.rs index f28d3f0..8c686b5 100644 --- a/xtask/src/device.rs +++ b/xtask/src/device.rs @@ -581,7 +581,7 @@ mod tests { RoundTrips::await_echo(&rx, &mut pending, &marker, deadline), Echo::Returned )); - assert!(pending.is_empty()); + assert_eq!(pending, b""); } #[test]