From f72ee1d237ccd60398eefe082c4884cf20f2e44a Mon Sep 17 00:00:00 2001 From: brainstorm Date: Sat, 14 Mar 2026 16:03:39 +0100 Subject: [PATCH 01/11] Add WiFi WPA3 logic, removing previous insecure Open wireless. Wireless SSID and password are optionally configured via SSH ENV variables, provided that the user is properly auth'd via pubkeys. --- src/config.rs | 2 +- src/espressif/net.rs | 91 +++++++++++++++++++++++++++++++++++++------- src/main.rs | 1 - src/serve.rs | 77 ++++++++++++++++++++++++++++++++++++- src/settings.rs | 1 + 5 files changed, 155 insertions(+), 17 deletions(-) diff --git a/src/config.rs b/src/config.rs index 8a8559f..b032eef 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,7 +17,7 @@ use sunset::{ }; use crate::errors::Error; -use crate::settings::{DEFAULT_SSID, DEFAULT_UART_RX_PIN, DEFAULT_UART_TX_PIN, KEY_SLOTS}; +use crate::settings::{DEFAULT_UART_RX_PIN, DEFAULT_UART_TX_PIN, KEY_SLOTS, DEFAULT_SSID}; #[derive(Debug, PartialEq)] pub struct SSHStampConfig { diff --git a/src/espressif/net.rs b/src/espressif/net.rs index 0aa136e..b9814d8 100644 --- a/src/espressif/net.rs +++ b/src/espressif/net.rs @@ -23,9 +23,16 @@ use esp_hal::peripherals::WIFI; use esp_hal::rng::Rng; use esp_radio::Controller; use esp_radio::wifi::WifiEvent; -use esp_radio::wifi::{AccessPointConfig, ModeConfig, WifiApState, WifiController}; +use esp_radio::wifi::{AccessPointConfig, ModeConfig, WifiApState, WifiController, AuthMethod::Wpa3Personal}; use heapless::String; +use core::fmt::Write; +extern crate alloc; +use alloc::string::String as AllocString; +use crate::store; +use storage::flash; +use sunset::random; use sunset_async::SunsetMutex; + // When you are okay with using a nightly compiler it's better to use https://docs.rs/static_cell/2.1.0/static_cell/macro.make_static.html macro_rules! mk_static { ($t:ty,$val:expr) => {{ @@ -49,7 +56,9 @@ pub async fn if_up( .map_err(|_| sunset::error::BadUsage.build())?; let ap_config = - ModeConfig::AccessPoint(AccessPointConfig::default().with_ssid(DEFAULT_SSID.into())); + ModeConfig::AccessPoint(AccessPointConfig::default() + .with_ssid(AllocString::from(wifi_ssid(config).await.as_str())) + .with_auth_method(Wpa3Personal)); let res = wifi_controller.set_config(&ap_config); info!("wifi_set_configuration returned {:?}", res); @@ -128,30 +137,86 @@ pub async fn accept_requests<'a>( tcp_socket } +pub async fn wifi_ssid(config: &'static SunsetMutex) -> String<63> { + // Return the configured SSID if present, otherwise the fixed default. + let guard = config.lock().await; + if !guard.wifi_ssid.is_empty() { + return String::<63>::try_from(guard.wifi_ssid.as_str()).unwrap_or_else(|_| { + let mut fallback = String::<63>::new(); + fallback.push_str(DEFAULT_SSID).ok(); + fallback + }); + } + + let mut default = String::<63>::new(); + default.push_str(DEFAULT_SSID).ok(); + default +} + #[embassy_executor::task] pub async fn wifi_up( mut wifi_controller: WifiController<'static>, config: &'static SunsetMutex, ) { info!("Device capabilities: {:?}", wifi_controller.capabilities()); - let wifi_ssid = { + let configured_ssid = { let guard = config.lock().await; guard.wifi_ssid.clone() // drop guard }; - // TODO: No wifi password(s) yet... - //let wifi_password = config.lock().await.wifi_pw; debug!("Starting wifi"); - let ssid_string = String::<63>::try_from(wifi_ssid.as_str()) - .map(|s| s.to_ascii_lowercase()) - .unwrap_or_else(|_| { - warn!("SSID too long, using default"); - DEFAULT_SSID.into() - }); - let client_config = - ModeConfig::AccessPoint(AccessPointConfig::default().with_ssid(ssid_string)); + // Ensure a WPA3 password exists on first boot. Generate a random PSK, + // display it on the console and persist it to flash so it survives reboot. + { + let mut g = config.lock().await; + if g.first_boot && g.wifi_pw.is_none() { + let mut rnd = [0u8; 24]; + if random::fill_random(&mut rnd).is_ok() { + let mut pw = String::<63>::new(); + for b in rnd.iter() { + // hex encoding + let _ = write!(pw, "{:02x}", b); + } + info!("First-boot generated WiFi WPA3 PSK: {}", pw); + g.wifi_pw = Some(pw.clone()); + // Persist to flash immediately + match flash::get_flash_n_buffer() { + Some(flash_storage_guard) => { + let mut flash_storage = flash_storage_guard.lock().await; + if let Err(e) = store::save(&mut flash_storage, &g).await { + warn!("Failed to persist config with generated wifi password: {:?}", e); + } + } + None => { + warn!("Flash storage not initialised; cannot persist wifi password"); + } + } + } else { + warn!("Failed to generate random bytes for wifi password"); + } + } + } + + let ssid_string = match String::<63>::try_from(configured_ssid.as_str()) { + Ok(s) => { + let mut lowered = String::<63>::new(); + for ch in s.as_str().chars() { + let _ = lowered.push(ch.to_ascii_lowercase()); + } + lowered + } + Err(_) => { + warn!("SSID too long, using default"); + wifi_ssid(config).await + } + }; + let client_config = ModeConfig::AccessPoint( + AccessPointConfig::default() + .with_ssid(AllocString::from(ssid_string.as_str())) + .with_auth_method(Wpa3Personal), + ); loop { if esp_radio::wifi::ap_state() == WifiApState::Started { diff --git a/src/main.rs b/src/main.rs index 52b54c6..6f426e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,6 @@ use sunset_async::{SSHServer, SunsetMutex}; use core::result::Result; use core::result::Result::Err; use core::result::Result::Ok; -// use core::error::Error; use core::future::Future; use embassy_executor::Spawner; use embassy_futures::select::{Either3, select3}; diff --git a/src/serve.rs b/src/serve.rs index e00952e..1c994ab 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -5,6 +5,8 @@ use log::{debug, info, trace, warn}; use crate::config::SSHStampConfig; +use esp_hal::system::software_reset; +use heapless::String; use crate::espressif::buffered_uart::UART_SIGNAL; use crate::settings::UART_BUFFER_SIZE; use crate::store; @@ -193,7 +195,7 @@ pub async fn connection_loop( // Ignore, but succeed to avoid client-side warnings // This env variable will always be sent by OpenSSH client. a.succeed()?; - } + }, "SSH_STAMP_PUBKEY" => { let mut config_guard = config.lock().await; // Only allow adding a pubkey via ENV on first-boot-like configs. @@ -214,7 +216,78 @@ pub async fn connection_loop( warn!("Failed to add new pubkey from ENV"); a.fail()?; } - } + }, + "SSH_STAMP_WIFI_SSID" => { + let mut config_guard = config.lock().await; + if !(auth_checked || config_guard.first_boot) { + warn!("SSH_STAMP_WIFI_SSID env received but not authenticated; rejecting"); + a.fail()?; + break Ok(()); + } else { + let mut s = String::<32>::new(); + if s.push_str(a.value()?).is_ok() { + config_guard.wifi_ssid = s; + info!("Set wifi SSID from ENV"); + a.succeed()?; + // Mark provisioned + config_guard.first_boot = false; + // Persist immediately + let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { + warn!("Could not persist wifi SSID: flash not initialized"); + config_changed = true; + continue; + }; + let mut flash_storage = flash_storage_guard.lock().await; + if let Err(e) = store::save(&mut flash_storage, &config_guard).await { + warn!("Failed to persist config with wifi SSID: {:?}", e); + config_changed = true; + } else { + // saved successfully, reboot to apply changes + // unsure if reboot is necessary to apply wifi changes. + drop(config_guard); + software_reset(); + } + } else { + warn!("SSH_STAMP_WIFI_SSID too long"); + a.fail()?; + } + } + }, + "SSH_STAMP_WPA3_PSK" => { + let mut config_guard = config.lock().await; + if !(auth_checked || config_guard.first_boot) { + warn!("SSH_STAMP_WPA3_PSK env received but not authenticated; rejecting"); + a.fail()?; + break Ok(()); + } else { + let mut s = String::<63>::new(); + if s.push_str(a.value()?).is_ok() { + config_guard.wifi_pw = Some(s); + info!("Set wifi WPA3 PSK from ENV"); + a.succeed()?; + // Mark provisioned + config_guard.first_boot = false; + // Persist immediately + let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { + warn!("Could not persist wifi PSK: flash not initialized"); + config_changed = true; + continue; + }; + let mut flash_storage = flash_storage_guard.lock().await; + if let Err(e) = store::save(&mut flash_storage, &config_guard).await { + warn!("Failed to persist config with wifi PSK: {:?}", e); + config_changed = true; + } else { + // saved successfully, reboot to apply changes + drop(config_guard); + software_reset(); + } + } else { + warn!("SSH_STAMP_WPA3_PSK too long"); + a.fail()?; + } + } + }, _ => { warn!("Unsupported environment variable"); a.fail()?; diff --git a/src/settings.rs b/src/settings.rs index e904078..6acab12 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -5,6 +5,7 @@ use core::net::Ipv4Addr; // SSH server settings //pub(crate) const MTU: usize = 1536; //pub(crate) const PORT: u16 = 22; +// Default WiFi SSID used when none configured (via ENV vars) pub(crate) const DEFAULT_SSID: &str = "ssh-stamp"; //pub(crate) const SSH_SERVER_ID: &str = "SSH-2.0-ssh-stamp-0.1"; pub(crate) const KEY_SLOTS: usize = 1; // TODO: Document whether this a "reasonable default"? Justify why? From 6bd3beb64e3a451315a31bcc072a22479c177e5b Mon Sep 17 00:00:00 2001 From: brainstorm Date: Sat, 14 Mar 2026 16:04:09 +0100 Subject: [PATCH 02/11] Updated README.md to reflect the new (more secure) provisioning and first boot --- README.md | 111 ++++++++++++++------------------------ src/config.rs | 33 +++++++----- src/espressif/net.rs | 124 ++++++++++++++++++++++++------------------- src/main.rs | 2 +- src/serve.rs | 114 +++++++++++++++++++-------------------- 5 files changed, 186 insertions(+), 198 deletions(-) diff --git a/README.md b/README.md index c369180..7c3fb7e 100644 --- a/README.md +++ b/README.md @@ -10,104 +10,71 @@ Your everyday SSH secured serial access. ## Description -The **SSH Stamp** is a secure wireless to UART bridge -implemented in Rust (no_std, no_alloc and no_unsafe whenever possible) -with simplicity and robustness as its main design tenets. - -The firmware runs on a microcontroller running Secure SHell Protocol -(RFC 4253 and related IETF standards series). This firmware can be -used for multiple purposes, conveniently avoiding physical -tethering and securely tunneling traffic via SSH by default: easily -add telemetry to a (moving) robot, monitor and operate any (domestic) -appliance remotely, conduct remote cybersecurity audits on -network gear of a company, reverse engineer hardware and software for -right to repair purposes, just to name a few examples. +The **SSH Stamp** is a secure wireless to UART bridge implemented in Rust (no_std, no_alloc and no_unsafe whenever possible) with simplicity and robustness as its main design tenets. + +The firmware runs on a microcontroller running Secure SHell Protocol (RFC 4253 and related IETF standards series). This firmware can be used for multiple purposes, conveniently avoiding physical tethering and securely tunneling traffic via SSH by default: easily add telemetry to a (moving) robot, monitor and operate any (domestic) appliance remotely, conduct remote cybersecurity audits on network gear of a company, reverse engineer hardware and software for right to repair purposes, just to name a few examples. A "low level to SSH Swiss army knife". # Building -Rust versions are controlled via `rust-toolchain.toml` and the equivalent defined on the CI workflow. - -On a fresh system the following should be enough to build and run on the relevant ESP32 dev boards. +Tooling is controlled by `rust-toolchain.toml`. On a fresh host you'll typically need the Rust source component and a flasher (we use `espflash` below as an example): -## Required for all targets: ``` rustup toolchain install stable --component rust-src cargo install espflash --locked ``` -## ESP32-C6 - +Build/flash for your board using the short command pattern (replace ``): ``` -rustup target add riscv32imac-unknown-none-elf -cargo build-esp32c6 -cargo run-esp32c6 +rustup target add +cargo build- # e.g. cargo build-esp32c6, cargo build-esp32c3, cargo build-esp32 +cargo run- # convenience helper (if supported) that builds + flashes ``` -## ESP32-C2 / ESP32-C3 -``` -rustup target add riscv32imc-unknown-none-elf -``` -### ESP32-C2 -``` -cargo build-esp32c2 -cargo run-esp32c2 -``` -### ESP32-C3 -``` -cargo build-esp32c2 -cargo run-esp32c3 -``` +Xtensa targets (ESP32/ESP32-S2/S3) require `espup` — follow esp-rs docs if you target those. If you prefer manual flashing, build `--release` and use `espflash`. +## First boot & provisioning (quick) -## ESP32 / ESP32-S2 / ESP32-S3 (Xtensa Cores) -Install esp toolchain first: https://github.com/esp-rs/espup -``` -cargo install espup -espup install -source $HOME/export-esp.sh -``` +1. Flash the firmware and open the serial console (example): -### ESP32 -``` -cargo +esp build-esp32 -cargo +esp run-esp32 -``` -### ESP32-S2 -``` -cargo +esp build-esp32s2 -cargo +esp run-esp32s2 ``` -### ESP32-S3 -``` -cargo +esp build-esp32s3 -cargo +esp run-esp32s3 +# build & flash (example for esp32c6) +cargo build-esp32c6 --release +cargo run-esp32c6 ``` -### Using rustup toolchain override (Doesn't require `+esp`) -To set rustup override: -``` -rustup override set esp -``` -To remove rustup override: -``` -cargo override unset -``` -Build: +2. On first boot the device generates a random WPA3 PSK and prints it to the serial console with an info message `First-boot generated WiFi WPA3 PSK: `; the default SSID is `ssh-stamp` and the AP IP is `192.168.4.1`. + +3. Connect a laptop/phone to the `ssh-stamp` AP using the printed PSK, then SSH into the device at `root@192.168.4.1`. + +4. 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. + +- Add your public key (first-boot only): + ``` -cargo build-esp32 -cargo build-esp32s2 -cargo build-esp32s3 +export SSH_STAMP_PUBKEY="$(cat ~/.ssh/id_ed25519.pub)" +ssh -o SendEnv=SSH_STAMP_PUBKEY root@192.168.4.1 ``` -Run: + +- Set a custom SSID and WPA3 PSK (allowed on first-boot or any authenticated session): + ``` -cargo run-esp32 -cargo run-esp32s2 -cargo run-esp32s3 +export SSH_STAMP_WIFI_SSID="MyHomeSSID" +export SSH_STAMP_WPA3_PSK="my-super-secret-psk" +ssh -o SendEnv=SSH_STAMP_WIFI_SSID -o SendEnv=SSH_STAMP_WPA3_PSK root@192.168.4.1 ``` +Notes: +- `SSH_STAMP_PUBKEY` is accepted on first-boot to add the initial admin key. +- `SSH_STAMP_WIFI_SSID` and `SSH_STAMP_WPA3_PSK` may be applied while authenticated via pubkey (or on first-boot). After a successful change the device persists the settings and performs a software reset so the new WiFi settings take effect. +- If you prefer a single-step provisioning, export all three env vars locally and forward them with `SendEnv` in the same SSH invocation. + +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. + # Default UART Pins | Target | RX | TX | | ---- | -- | -- | diff --git a/src/config.rs b/src/config.rs index b032eef..77e7767 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,7 +17,7 @@ use sunset::{ }; use crate::errors::Error; -use crate::settings::{DEFAULT_UART_RX_PIN, DEFAULT_UART_TX_PIN, KEY_SLOTS, DEFAULT_SSID}; +use crate::settings::{DEFAULT_SSID, DEFAULT_UART_RX_PIN, DEFAULT_UART_TX_PIN, KEY_SLOTS}; #[derive(Debug, PartialEq)] pub struct SSHStampConfig { @@ -40,8 +40,8 @@ pub struct SSHStampConfig { pub ipv6_static: Option, /// UART pub uart_pins: UartPins, - /// True until the device has been provisioned for the first time. - pub first_boot: bool, + /// True until a pubkey is provisioned. Further changes require authentication. + pub first_login: bool, } #[derive(Debug, PartialEq)] @@ -60,6 +60,8 @@ impl Default for UartPins { } } +const PASSWORD_CHARS: &[u8; 62] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + impl SSHStampConfig { /// Bump this when the format changes pub const CURRENT_VERSION: u8 = 8; @@ -72,7 +74,7 @@ impl SSHStampConfig { let wifi_ssid = Self::default_ssid(); let mac = random_mac()?; - let wifi_pw = None; + let wifi_pw = Some(Self::generate_wifi_password()?); let uart_pins = UartPins::default(); info!( @@ -80,9 +82,6 @@ impl SSHStampConfig { uart_pins.rx, uart_pins.tx ); - // No password config fields nor logic: only pubkey auth supported. - // Leave password fields out (except wifi one). - Ok(SSHStampConfig { hostkey, pubkeys: Default::default(), @@ -93,10 +92,20 @@ impl SSHStampConfig { #[cfg(feature = "ipv6")] ipv6_static: None, uart_pins, - first_boot: true, + first_login: true, }) } + fn generate_wifi_password() -> Result> { + let mut rnd = [0u8; 24]; + sunset::random::fill_random(&mut rnd)?; + let mut pw = String::<63>::new(); + for &byte in rnd.iter() { + let _ = pw.push(PASSWORD_CHARS[(byte as usize) % 62] as char); + } + Ok(pw) + } + // Password functions removed; pubkey-only auth supported. pub(crate) fn default_ssid() -> String<32> { @@ -289,8 +298,8 @@ impl SSHEncode for SSHStampConfig { self.uart_pins.rx.enc(s)?; self.uart_pins.tx.enc(s)?; - // Persist first-boot marker - self.first_boot.enc(s)?; + // Persist first-login marker + self.first_login.enc(s)?; Ok(()) } @@ -323,7 +332,7 @@ impl<'de> SSHDecode<'de> for SSHStampConfig { let tx: u8 = SSHDecode::dec(s)?; let uart_pins = UartPins { rx, tx }; - let first_boot = SSHDecode::dec(s)?; + let first_login = SSHDecode::dec(s)?; Ok(Self { hostkey, @@ -335,7 +344,7 @@ impl<'de> SSHDecode<'de> for SSHStampConfig { #[cfg(feature = "ipv6")] ipv6_static, uart_pins, - first_boot, + first_login, }) } } diff --git a/src/espressif/net.rs b/src/espressif/net.rs index b9814d8..e80e5de 100644 --- a/src/espressif/net.rs +++ b/src/espressif/net.rs @@ -23,16 +23,18 @@ use esp_hal::peripherals::WIFI; use esp_hal::rng::Rng; use esp_radio::Controller; use esp_radio::wifi::WifiEvent; -use esp_radio::wifi::{AccessPointConfig, ModeConfig, WifiApState, WifiController, AuthMethod::Wpa3Personal}; +use esp_radio::wifi::{ + AccessPointConfig, AuthMethod::Wpa2Wpa3Personal, ModeConfig, WifiApState, WifiController, +}; use heapless::String; -use core::fmt::Write; extern crate alloc; -use alloc::string::String as AllocString; use crate::store; +use alloc::string::String as AllocString; use storage::flash; -use sunset::random; use sunset_async::SunsetMutex; +const PASSWORD_CHARS: &[u8; 62] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + // When you are okay with using a nightly compiler it's better to use https://docs.rs/static_cell/2.1.0/static_cell/macro.make_static.html macro_rules! mk_static { ($t:ty,$val:expr) => {{ @@ -55,10 +57,40 @@ pub async fn if_up( esp_radio::wifi::new(wifi_init, wifi, Default::default()) .map_err(|_| sunset::error::BadUsage.build())?; - let ap_config = - ModeConfig::AccessPoint(AccessPointConfig::default() + // Ensure WPA3 PSK exists before applying AP config to avoid esp_wifi_set_config errors + { + let mut guard = config.lock().await; + if guard.wifi_pw.is_none() { + let mut rnd = [0u8; 24]; + for chunk in rnd.chunks_exact_mut(4) { + chunk.copy_from_slice(&rng.random().to_le_bytes()); + } + + let mut pw = String::<63>::new(); + for &byte in rnd.iter() { + let _ = pw.push(PASSWORD_CHARS[(byte as usize) % 62] as char); + } + + warn!("wifi_pw missing from config, generated new password"); + guard.wifi_pw = Some(pw); + + let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { + panic!("Flash storage not initialized; cannot persist wifi password"); + }; + let mut flash_storage = flash_storage_guard.lock().await; + if let Err(e) = store::save(&mut flash_storage, &guard).await { + panic!("Failed to persist generated wifi password: {:?}", e); + } + } + info!("WiFi WPA3 PSK: {}", guard.wifi_pw.as_ref().unwrap()); + } + + let ap_config = ModeConfig::AccessPoint( + AccessPointConfig::default() .with_ssid(AllocString::from(wifi_ssid(config).await.as_str())) - .with_auth_method(Wpa3Personal)); + .with_auth_method(Wpa2Wpa3Personal) + .with_password(AllocString::from(wifi_password(config).await.as_str())), + ); let res = wifi_controller.set_config(&ap_config); info!("wifi_set_configuration returned {:?}", res); @@ -153,6 +185,16 @@ pub async fn wifi_ssid(config: &'static SunsetMutex) -> String<6 default } +pub async fn wifi_password(config: &'static SunsetMutex) -> String<63> { + let guard = config.lock().await; + match &guard.wifi_pw { + Some(pw) => String::<63>::try_from(pw.as_str()).unwrap_or_else(|_| { + panic!("wifi_pw stored value exceeds 63 characters"); + }), + None => panic!("wifi_pw must be set before calling wifi_password()"), + } +} + #[embassy_executor::task] pub async fn wifi_up( mut wifi_controller: WifiController<'static>, @@ -167,56 +209,28 @@ pub async fn wifi_up( debug!("Starting wifi"); - // Ensure a WPA3 password exists on first boot. Generate a random PSK, - // display it on the console and persist it to flash so it survives reboot. - { - let mut g = config.lock().await; - if g.first_boot && g.wifi_pw.is_none() { - let mut rnd = [0u8; 24]; - if random::fill_random(&mut rnd).is_ok() { - let mut pw = String::<63>::new(); - for b in rnd.iter() { - // hex encoding - let _ = write!(pw, "{:02x}", b); - } - info!("First-boot generated WiFi WPA3 PSK: {}", pw); - g.wifi_pw = Some(pw.clone()); - // Persist to flash immediately - match flash::get_flash_n_buffer() { - Some(flash_storage_guard) => { - let mut flash_storage = flash_storage_guard.lock().await; - if let Err(e) = store::save(&mut flash_storage, &g).await { - warn!("Failed to persist config with generated wifi password: {:?}", e); - } - } - None => { - warn!("Flash storage not initialised; cannot persist wifi password"); - } - } - } else { - warn!("Failed to generate random bytes for wifi password"); - } - } - } + // (PSK generation handled in if_up on first boot) - let ssid_string = match String::<63>::try_from(configured_ssid.as_str()) { - Ok(s) => { - let mut lowered = String::<63>::new(); - for ch in s.as_str().chars() { - let _ = lowered.push(ch.to_ascii_lowercase()); - } - lowered + let ssid_string = match String::<63>::try_from(configured_ssid.as_str()) { + Ok(s) => { + let mut lowered = String::<63>::new(); + for ch in s.as_str().chars() { + let _ = lowered.push(ch.to_ascii_lowercase()); } - Err(_) => { - warn!("SSID too long, using default"); - wifi_ssid(config).await - } - }; - let client_config = ModeConfig::AccessPoint( - AccessPointConfig::default() - .with_ssid(AllocString::from(ssid_string.as_str())) - .with_auth_method(Wpa3Personal), - ); + lowered + } + Err(_) => { + warn!("SSID too long, using default"); + wifi_ssid(config).await + } + }; + let pw_string = wifi_password(config).await; + let client_config = ModeConfig::AccessPoint( + AccessPointConfig::default() + .with_ssid(AllocString::from(ssid_string.as_str())) + .with_auth_method(Wpa2Wpa3Personal) + .with_password(AllocString::from(pw_string.as_str())), + ); loop { if esp_radio::wifi::ap_state() == WifiApState::Started { diff --git a/src/main.rs b/src/main.rs index 6f426e5..8576beb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,10 +21,10 @@ use storage::flash; use sunset_async::{SSHServer, SunsetMutex}; +use core::future::Future; use core::result::Result; use core::result::Result::Err; use core::result::Result::Ok; -use core::future::Future; use embassy_executor::Spawner; use embassy_futures::select::{Either3, select3}; use embassy_net::{Stack, tcp::TcpSocket}; diff --git a/src/serve.rs b/src/serve.rs index 1c994ab..280681b 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -5,11 +5,11 @@ use log::{debug, info, trace, warn}; use crate::config::SSHStampConfig; -use esp_hal::system::software_reset; -use heapless::String; use crate::espressif::buffered_uart::UART_SIGNAL; use crate::settings::UART_BUFFER_SIZE; use crate::store; +use esp_hal::system::software_reset; +use heapless::String; use storage::flash; use core::fmt::Debug; @@ -115,23 +115,16 @@ pub async fn connection_loop( } ServEvent::FirstAuth(mut a) => { info!("ServEvent::FirstAuth"); - // Allow the "first auth" behaviour only on first-boot-like configs. - // Consider the device in first-boot state when there is no password - // and no stored client pubkeys. let config_guard = config.lock().await; - // Disable password auth method regardless. a.enable_password_auth(false)?; - // SECURITY: We have no users; enable pubkey auth so the - // provisioner can add a key. a.enable_pubkey_auth(true)?; - if config_guard.first_boot { - a.allow()?; // SECURITY: Controversial (but necessary to provision?) + if config_guard.first_login { + a.allow()?; } else { - // Not first boot: do not auto-allow; reject the first-auth helper. info!( - "FirstAuth received but not first-boot, allowing pubkey auth but rejecting + "FirstAuth received but not first-login, allowing pubkey auth but rejecting additions of new public keys on already provisioned device" ); a.reject()?; @@ -195,32 +188,31 @@ pub async fn connection_loop( // Ignore, but succeed to avoid client-side warnings // This env variable will always be sent by OpenSSH client. a.succeed()?; - }, + } "SSH_STAMP_PUBKEY" => { let mut config_guard = config.lock().await; - // Only allow adding a pubkey via ENV on first-boot-like configs. - if !config_guard.first_boot { - warn!("SSH_STAMP_PUBKEY env received but not first-boot; rejecting"); + if !config_guard.first_login { + warn!("SSH_STAMP_PUBKEY env received but not first-login; rejecting"); a.fail()?; - break Ok(()); // TODO: Do better HSM-flow-wise + break Ok(()); } else if config_guard.add_pubkey(a.value()?).is_ok() { info!("Added new pubkey from ENV"); a.succeed()?; - // Mark that config has changed and clear first_boot so - // future connections are not treated as first-boot. - config_guard.first_boot = false; + config_guard.first_login = false; config_changed = true; auth_checked = true; } else { warn!("Failed to add new pubkey from ENV"); a.fail()?; } - }, + } "SSH_STAMP_WIFI_SSID" => { let mut config_guard = config.lock().await; - if !(auth_checked || config_guard.first_boot) { - warn!("SSH_STAMP_WIFI_SSID env received but not authenticated; rejecting"); + if !(auth_checked || config_guard.first_login) { + warn!( + "SSH_STAMP_WIFI_SSID env received but not authenticated; rejecting" + ); a.fail()?; break Ok(()); } else { @@ -229,21 +221,17 @@ pub async fn connection_loop( config_guard.wifi_ssid = s; info!("Set wifi SSID from ENV"); a.succeed()?; - // Mark provisioned - config_guard.first_boot = false; - // Persist immediately let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { warn!("Could not persist wifi SSID: flash not initialized"); config_changed = true; continue; }; let mut flash_storage = flash_storage_guard.lock().await; - if let Err(e) = store::save(&mut flash_storage, &config_guard).await { + if let Err(e) = store::save(&mut flash_storage, &config_guard).await + { warn!("Failed to persist config with wifi SSID: {:?}", e); config_changed = true; } else { - // saved successfully, reboot to apply changes - // unsure if reboot is necessary to apply wifi changes. drop(config_guard); software_reset(); } @@ -252,42 +240,52 @@ pub async fn connection_loop( a.fail()?; } } - }, + } "SSH_STAMP_WPA3_PSK" => { let mut config_guard = config.lock().await; - if !(auth_checked || config_guard.first_boot) { - warn!("SSH_STAMP_WPA3_PSK env received but not authenticated; rejecting"); + if !(auth_checked || config_guard.first_login) { + warn!( + "SSH_STAMP_WPA3_PSK env received but not authenticated; rejecting" + ); a.fail()?; break Ok(()); } else { - let mut s = String::<63>::new(); - if s.push_str(a.value()?).is_ok() { - config_guard.wifi_pw = Some(s); - info!("Set wifi WPA3 PSK from ENV"); - a.succeed()?; - // Mark provisioned - config_guard.first_boot = false; - // Persist immediately - let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { - warn!("Could not persist wifi PSK: flash not initialized"); - config_changed = true; - continue; - }; - let mut flash_storage = flash_storage_guard.lock().await; - if let Err(e) = store::save(&mut flash_storage, &config_guard).await { - warn!("Failed to persist config with wifi PSK: {:?}", e); - config_changed = true; + let value = a.value()?; + if value.len() < 8 { + warn!("SSH_STAMP_WPA3_PSK too short (min 8 characters)"); + a.fail()?; + } else if value.len() > 63 { + warn!("SSH_STAMP_WPA3_PSK too long (max 63 characters)"); + a.fail()?; + } else { + let mut s = String::<63>::new(); + if s.push_str(value).is_ok() { + config_guard.wifi_pw = Some(s); + info!("Set wifi WPA3 PSK from ENV"); + a.succeed()?; + let Some(flash_storage_guard) = flash::get_flash_n_buffer() + else { + warn!("Could not persist wifi PSK: flash not initialized"); + config_changed = true; + continue; + }; + let mut flash_storage = flash_storage_guard.lock().await; + if let Err(e) = + store::save(&mut flash_storage, &config_guard).await + { + warn!("Failed to persist config with wifi PSK: {:?}", e); + config_changed = true; + } else { + drop(config_guard); + software_reset(); + } } else { - // saved successfully, reboot to apply changes - drop(config_guard); - software_reset(); + warn!("SSH_STAMP_WPA3_PSK push_str failed unexpectedly"); + a.fail()?; } - } else { - warn!("SSH_STAMP_WPA3_PSK too long"); - a.fail()?; } } - }, + } _ => { warn!("Unsupported environment variable"); a.fail()?; @@ -295,9 +293,9 @@ pub async fn connection_loop( } } ServEvent::SessionPty(a) => { - let first_boot = { config.lock().await.first_boot }; + let first_login = { config.lock().await.first_login }; - if auth_checked || first_boot { + if auth_checked || first_login { info!("ServEvent::SessionPty: Session granted"); a.succeed()?; } else { From 95132f70091e78fa014260e525e1c01478f66cde Mon Sep 17 00:00:00 2001 From: brainstorm Date: Sun, 15 Mar 2026 16:46:16 +0100 Subject: [PATCH 03/11] Move wifi password chars const to settings --- src/config.rs | 12 ++++++------ src/espressif/net.rs | 6 ++---- src/settings.rs | 4 ++++ 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/config.rs b/src/config.rs index 77e7767..4695d10 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,14 +10,16 @@ use embassy_net::{Ipv6Cidr, StaticConfigV6}; use heapless::String; use sunset::packets::Ed25519PubKey; -use sunset::{KeyType, Result}; use sunset::{ - SignKey, sshwire::{SSHDecode, SSHEncode, SSHSink, SSHSource, WireError, WireResult}, + SignKey, }; +use sunset::{KeyType, Result}; use crate::errors::Error; -use crate::settings::{DEFAULT_SSID, DEFAULT_UART_RX_PIN, DEFAULT_UART_TX_PIN, KEY_SLOTS}; +use crate::settings::{ + DEFAULT_SSID, DEFAULT_UART_RX_PIN, DEFAULT_UART_TX_PIN, KEY_SLOTS, WIFI_PASSWORD_CHARS, +}; #[derive(Debug, PartialEq)] pub struct SSHStampConfig { @@ -60,8 +62,6 @@ impl Default for UartPins { } } -const PASSWORD_CHARS: &[u8; 62] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - impl SSHStampConfig { /// Bump this when the format changes pub const CURRENT_VERSION: u8 = 8; @@ -101,7 +101,7 @@ impl SSHStampConfig { sunset::random::fill_random(&mut rnd)?; let mut pw = String::<63>::new(); for &byte in rnd.iter() { - let _ = pw.push(PASSWORD_CHARS[(byte as usize) % 62] as char); + let _ = pw.push(WIFI_PASSWORD_CHARS[(byte as usize) % 62] as char); } Ok(pw) } diff --git a/src/espressif/net.rs b/src/espressif/net.rs index e80e5de..a45d780 100644 --- a/src/espressif/net.rs +++ b/src/espressif/net.rs @@ -5,7 +5,7 @@ use log::{debug, error, info, warn}; use crate::config::SSHStampConfig; -use crate::settings::{DEFAULT_IP, DEFAULT_SSID}; +use crate::settings::{DEFAULT_IP, DEFAULT_SSID, WIFI_PASSWORD_CHARS}; use core::net::Ipv4Addr; use core::net::SocketAddrV4; use edge_dhcp; @@ -33,8 +33,6 @@ use alloc::string::String as AllocString; use storage::flash; use sunset_async::SunsetMutex; -const PASSWORD_CHARS: &[u8; 62] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - // When you are okay with using a nightly compiler it's better to use https://docs.rs/static_cell/2.1.0/static_cell/macro.make_static.html macro_rules! mk_static { ($t:ty,$val:expr) => {{ @@ -68,7 +66,7 @@ pub async fn if_up( let mut pw = String::<63>::new(); for &byte in rnd.iter() { - let _ = pw.push(PASSWORD_CHARS[(byte as usize) % 62] as char); + let _ = pw.push(WIFI_PASSWORD_CHARS[(byte as usize) % 62] as char); } warn!("wifi_pw missing from config, generated new password"); diff --git a/src/settings.rs b/src/settings.rs index 6acab12..f871e59 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -11,6 +11,10 @@ pub(crate) const DEFAULT_SSID: &str = "ssh-stamp"; pub(crate) const KEY_SLOTS: usize = 1; // TODO: Document whether this a "reasonable default"? Justify why? pub(crate) const DEFAULT_IP: &Ipv4Addr = &Ipv4Addr::new(192, 168, 4, 1); +// WiFi password generation +pub(crate) const WIFI_PASSWORD_CHARS: &[u8; 62] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + // UART settings //pub(crate) const BAUD_RATE: u32 = 115200; //pub(crate) const UART_SETTINGS: &str = "8N1"; From 0c50354773642f91052c3840fba13d4e17009a53 Mon Sep 17 00:00:00 2001 From: brainstorm Date: Sun, 15 Mar 2026 17:25:10 +0100 Subject: [PATCH 04/11] Reduce USB console verbosity to help out with UX while provisioning: the user should just have to see the generated WIFI PSK, IP and know whether the (UART) bridge has been established successfully. Everything else has been moved to debug log level --- README.md | 10 ++--- src/config.rs | 14 +++---- src/espressif/buffered_uart.rs | 6 +-- src/espressif/net.rs | 35 +++++++++-------- src/main.rs | 68 +++++++++++++++++----------------- src/serial.rs | 5 +-- src/serve.rs | 62 +++++++++++++++---------------- src/store.rs | 20 +++++----- 8 files changed, 109 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index 7c3fb7e..8d41a75 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ cargo build-esp32c6 --release cargo run-esp32c6 ``` -2. On first boot the device generates a random WPA3 PSK and prints it to the serial console with an info message `First-boot generated WiFi WPA3 PSK: `; the default SSID is `ssh-stamp` and the AP IP is `192.168.4.1`. +2. On first boot the device generates a random WPA2 PSK and prints it to the serial console with an info message `First-boot generated WiFi WPA2 PSK: `; the default SSID is `ssh-stamp` and the AP IP is `192.168.4.1`. 3. Connect a laptop/phone to the `ssh-stamp` AP using the printed PSK, then SSH into the device at `root@192.168.4.1`. @@ -60,17 +60,17 @@ export SSH_STAMP_PUBKEY="$(cat ~/.ssh/id_ed25519.pub)" ssh -o SendEnv=SSH_STAMP_PUBKEY root@192.168.4.1 ``` -- Set a custom SSID and WPA3 PSK (allowed on first-boot or any authenticated session): +- Set a custom SSID and WPA2 PSK (allowed on first-boot or any authenticated session): ``` export SSH_STAMP_WIFI_SSID="MyHomeSSID" -export SSH_STAMP_WPA3_PSK="my-super-secret-psk" -ssh -o SendEnv=SSH_STAMP_WIFI_SSID -o SendEnv=SSH_STAMP_WPA3_PSK root@192.168.4.1 +export SSH_STAMP_WIFI_PSK="my-super-secret-psk" +ssh -o SendEnv=SSH_STAMP_WIFI_SSID -o SendEnv=SSH_STAMP_WIFI_PSK root@192.168.4.1 ``` Notes: - `SSH_STAMP_PUBKEY` is accepted on first-boot to add the initial admin key. -- `SSH_STAMP_WIFI_SSID` and `SSH_STAMP_WPA3_PSK` may be applied while authenticated via pubkey (or on first-boot). After a successful change the device persists the settings and performs a software reset so the new WiFi settings take effect. +- `SSH_STAMP_WIFI_SSID` and `SSH_STAMP_WIFI_PSK` may be applied while authenticated via pubkey (or on first-boot). After a successful change the device persists the settings and performs a software reset so the new WiFi settings take effect. - If you prefer a single-step provisioning, export all three env vars locally and forward them with `SendEnv` in the same SSH invocation. 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. diff --git a/src/config.rs b/src/config.rs index 4695d10..bdb9268 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use log::{debug, info, warn}; +use log::{debug, warn}; use core::net::Ipv4Addr; #[cfg(feature = "ipv6")] @@ -10,11 +10,11 @@ use embassy_net::{Ipv6Cidr, StaticConfigV6}; use heapless::String; use sunset::packets::Ed25519PubKey; +use sunset::{KeyType, Result}; use sunset::{ - sshwire::{SSHDecode, SSHEncode, SSHSink, SSHSource, WireError, WireResult}, SignKey, + sshwire::{SSHDecode, SSHEncode, SSHSink, SSHSource, WireError, WireResult}, }; -use sunset::{KeyType, Result}; use crate::errors::Error; use crate::settings::{ @@ -77,7 +77,7 @@ impl SSHStampConfig { let wifi_pw = Some(Self::generate_wifi_password()?); let uart_pins = UartPins::default(); - info!( + debug!( "SSH Stamp Config new() - RX Pin: {} TX Pin: {}", uart_pins.rx, uart_pins.tx ); @@ -119,14 +119,14 @@ impl SSHStampConfig { // validate it is an Ed25519 key. Insert into the first empty slot or // overwrite slot 0 if none empty. - info!( + debug!( "Checking pubkey string passed through ENV: {}", key_str.trim() ); let openssh = ssh_key::PublicKey::from_str(key_str.trim())?; - info!("Public key format valid, continuing to parse"); + debug!("Public key format valid, continuing to parse"); match openssh.key_data() { ssh_key::public::KeyData::Ed25519(k) => { @@ -135,7 +135,7 @@ impl SSHStampConfig { key: sunset::sshwire::Blob(bytes), }; - info!("Parsed Ed25519 public key, adding to config"); + debug!("Parsed Ed25519 public key, adding to config"); for slot in self.pubkeys.iter_mut() { if slot.is_none() { *slot = Some(newk); diff --git a/src/espressif/buffered_uart.rs b/src/espressif/buffered_uart.rs index 84231cb..48b58cc 100644 --- a/src/espressif/buffered_uart.rs +++ b/src/espressif/buffered_uart.rs @@ -21,7 +21,7 @@ use portable_atomic::{AtomicUsize, Ordering}; use static_cell::StaticCell; use sunset_async::SunsetMutex; -use log::info; +use log::debug; // Sizes of the software buffers. Inward is more // important as an overrun here drops bytes. A full outward @@ -132,14 +132,14 @@ impl Default for BufferedUart { pub async fn uart_buffer_disable() -> () { // disable uart buffer - info!("UART buffer disabled: WIP"); + debug!("UART buffer disabled: WIP"); // TODO: Correctly disable/restart UART buffer and/or send messsage to user over SSH } // use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; pub async fn uart_disable() -> () { // disable uart - info!("UART disabled: WIP"); + debug!("UART disabled: WIP"); // TODO: Correctly disable/restart UART and/or send messsage to user over SSH } diff --git a/src/espressif/net.rs b/src/espressif/net.rs index a45d780..ac35103 100644 --- a/src/espressif/net.rs +++ b/src/espressif/net.rs @@ -24,7 +24,7 @@ use esp_hal::rng::Rng; use esp_radio::Controller; use esp_radio::wifi::WifiEvent; use esp_radio::wifi::{ - AccessPointConfig, AuthMethod::Wpa2Wpa3Personal, ModeConfig, WifiApState, WifiController, + AccessPointConfig, AuthMethod::Wpa2Personal, ModeConfig, WifiApState, WifiController, }; use heapless::String; extern crate alloc; @@ -55,7 +55,7 @@ pub async fn if_up( esp_radio::wifi::new(wifi_init, wifi, Default::default()) .map_err(|_| sunset::error::BadUsage.build())?; - // Ensure WPA3 PSK exists before applying AP config to avoid esp_wifi_set_config errors + // Ensure WiFi PSK exists before applying AP config to avoid esp_wifi_set_config errors { let mut guard = config.lock().await; if guard.wifi_pw.is_none() { @@ -80,17 +80,17 @@ pub async fn if_up( panic!("Failed to persist generated wifi password: {:?}", e); } } - info!("WiFi WPA3 PSK: {}", guard.wifi_pw.as_ref().unwrap()); + info!("WiFi WIFI PSK: {}", guard.wifi_pw.as_ref().unwrap()); } let ap_config = ModeConfig::AccessPoint( AccessPointConfig::default() .with_ssid(AllocString::from(wifi_ssid(config).await.as_str())) - .with_auth_method(Wpa2Wpa3Personal) + .with_auth_method(Wpa2Personal) .with_password(AllocString::from(wifi_password(config).await.as_str())), ); let res = wifi_controller.set_config(&ap_config); - info!("wifi_set_configuration returned {:?}", res); + debug!("wifi_set_configuration returned {:?}", res); let gw_ip_addr_ipv4 = *DEFAULT_IP; @@ -122,7 +122,6 @@ pub async fn if_up( Timer::after(Duration::from_millis(500)).await; } - // TODO: Use wifi_manager instead? info!( "Connect to the AP `ssh-stamp` as a DHCP client with IP: {}", gw_ip_addr_ipv4 @@ -133,13 +132,13 @@ pub async fn if_up( pub async fn ap_stack_disable() -> () { // drop ap_stack - info!("AP Stack disabled: WIP"); + debug!("AP Stack disabled: WIP"); // TODO: Correctly disable/restart AP Stack and/or send messsage to user over SSH } pub async fn tcp_socket_disable() -> () { // drop tcp stack - info!("TCP socket disabled: WIP"); + debug!("TCP socket disabled: WIP"); // TODO: Correctly disable/restart tcp socket and/or send messsage to user over SSH } @@ -150,7 +149,7 @@ pub async fn accept_requests<'a>( ) -> TcpSocket<'a> { let mut tcp_socket = TcpSocket::new(tcp_stack, rx_buffer, tx_buffer); - info!("Waiting for SSH client..."); + debug!("Waiting for SSH client..."); if let Err(e) = tcp_socket .accept(IpListenEndpoint { addr: None, @@ -162,7 +161,7 @@ pub async fn accept_requests<'a>( // continue; tcp_socket_disable().await; } - info!("Connected, port 22"); + debug!("Connected, port 22"); tcp_socket } @@ -198,7 +197,7 @@ pub async fn wifi_up( mut wifi_controller: WifiController<'static>, config: &'static SunsetMutex, ) { - info!("Device capabilities: {:?}", wifi_controller.capabilities()); + debug!("Device capabilities: {:?}", wifi_controller.capabilities()); let configured_ssid = { let guard = config.lock().await; guard.wifi_ssid.clone() @@ -226,7 +225,7 @@ pub async fn wifi_up( let client_config = ModeConfig::AccessPoint( AccessPointConfig::default() .with_ssid(AllocString::from(ssid_string.as_str())) - .with_auth_method(Wpa2Wpa3Personal) + .with_auth_method(Wpa2Personal) .with_password(AllocString::from(pw_string.as_str())), ); @@ -238,17 +237,17 @@ pub async fn wifi_up( } if !matches!(wifi_controller.is_started(), Ok(true)) { if let Err(e) = wifi_controller.set_config(&client_config) { - info!("Failed to set wifi config: {:?}", e); + debug!("Failed to set wifi config: {:?}", e); Timer::after(Duration::from_millis(1000)).await; continue; } - info!("Starting wifi"); + debug!("Starting wifi"); if let Err(e) = wifi_controller.start_async().await { - info!("Failed to start wifi: {:?}", e); + debug!("Failed to start wifi: {:?}", e); Timer::after(Duration::from_millis(1000)).await; continue; } - info!("Wifi started!"); + debug!("Wifi started!"); } Timer::after(Duration::from_millis(10)).await; } @@ -260,14 +259,14 @@ pub async fn wifi_controller_disable() -> () { // drop wifi controller // esp_wifi::deinit_unchecked() // wifi_controller.deinit_unchecked() - info!("Disabling wifi: WIP"); + debug!("Disabling wifi: WIP"); //software_reset(); } use esp_radio::wifi::WifiDevice; #[embassy_executor::task] async fn net_up(mut runner: Runner<'static, WifiDevice<'static>>) { - info!("Bringing up network stack...\n"); + debug!("Bringing up network stack...\n"); runner.run().await } diff --git a/src/main.rs b/src/main.rs index 8576beb..c34776d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ // // SPDX-License-Identifier: GPL-3.0-or-later +use log::{debug, error, warn}; use ssh_stamp::{ config::SSHStampConfig, espressif::{ @@ -34,7 +35,6 @@ use esp_hal::system::software_reset; use esp_hal::{peripherals::WIFI, rng::Rng}; use esp_println::logger; -use log::info; use esp_radio::Controller; use esp_rtos::embassy::InterruptExecutor; @@ -49,7 +49,7 @@ cfg_if::cfg_if! { pub async fn peripherals_disable() -> () { // drop peripherals - info!("Disabling peripherals: WIP"); + debug!("Disabling peripherals: WIP"); } pub struct SshStampInit<'a> { @@ -62,7 +62,7 @@ pub struct SshStampInit<'a> { static INT_EXECUTOR: StaticCell> = StaticCell::new(); // 0 is used for esp_rtos #[esp_rtos::main] async fn main(spawner: Spawner) -> ! { - info!("HSM: main"); + debug!("HSM: main"); cfg_if::cfg_if!( if #[cfg(feature = "esp32s2")] { // TODO: This heap size will crash at runtime (only for the ESP32S2), we need to fix this @@ -74,14 +74,14 @@ async fn main(spawner: Spawner) -> ! { ); esp_bootloader_esp_idf::esp_app_desc!(); logger::init_logger_from_env(); - info!("HSM: Initialising peripherals "); + debug!("HSM: Initialising peripherals "); // System init let peripherals = esp_hal::init(esp_hal::Config::default()); let rng = Rng::new(); rng::register_custom_rng(rng); - info!("Initialising flash "); + debug!("Initialising flash "); flash::init(peripherals.FLASH); #[cfg(feature = "sftp-ota")] @@ -91,7 +91,7 @@ async fn main(spawner: Spawner) -> ! { .expect("Failed to validate the current ota partition"); } // Read SSH configuration from Flash (if it exists) - info!("Loading config "); + debug!("Loading config "); let flash_config = { let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { panic!("Could not acquire flash storage lock"); @@ -101,11 +101,11 @@ async fn main(spawner: Spawner) -> ! { } .expect("Could not load or create SSHStampConfig"); - info!("Initialising config "); + debug!("Initialising config "); static CONFIG: StaticCell> = StaticCell::new(); let config: &SunsetMutex = CONFIG.init(SunsetMutex::new(flash_config)); - info!("Initialising gpio "); + debug!("Initialising gpio "); // Only certain GPIO are available for each target. // Pins are selected at compile time based on the target chip. cfg_if::cfg_if!( @@ -133,7 +133,7 @@ async fn main(spawner: Spawner) -> ! { } ); - info!("Initialising timers "); + debug!("Initialising timers "); let sw_int = SoftwareInterruptControl::new(peripherals.SW_INTERRUPT); cfg_if::cfg_if! { if #[cfg(feature = "esp32")] { @@ -182,13 +182,13 @@ async fn main(spawner: Spawner) -> ! { match peripherals_enabled(peripherals_enabled_struct).await { Ok(_) => (), Err(e) => { - info!("Peripheral error: {}", e); + error!("Peripheral error: {}", e); } } peripherals_disable().await; // loop {} - log::warn!("End of Main... Reset!!"); + warn!("End of Main... Reset!!"); software_reset(); } @@ -202,7 +202,7 @@ pub struct PeripheralsEnabled<'a> { } async fn peripherals_enabled(s: SshStampInit<'static>) -> Result<(), sunset::Error> { - info!("HSM: peripherals_enabled"); + debug!("HSM: peripherals_enabled"); let controller = esp_radio::init().map_err(|_| sunset::error::BadUsage.build())?; let peripherals_enabled_struct = PeripheralsEnabled { @@ -216,7 +216,7 @@ async fn peripherals_enabled(s: SshStampInit<'static>) -> Result<(), sunset::Err match wifi_controller_enabled(peripherals_enabled_struct).await { Ok(_) => (), Err(e) => { - info!("Wifi controller error: {}", e); + error!("Wifi controller error: {}", e); } } @@ -232,7 +232,7 @@ pub struct WifiControllerEnabled<'a> { } pub async fn wifi_controller_enabled(s: PeripheralsEnabled<'static>) -> Result<(), sunset::Error> { - info!("HSM: wifi_controller_enabled"); + debug!("HSM: wifi_controller_enabled"); let tcp_stack = net::if_up(s.spawner, s.controller, s.wifi, s.rng, s.config).await?; let wifi_controller_enabled_stack = WifiControllerEnabled { @@ -244,7 +244,7 @@ pub async fn wifi_controller_enabled(s: PeripheralsEnabled<'static>) -> Result<( match tcp_enabled(wifi_controller_enabled_stack).await { Ok(_) => (), Err(e) => { - info!("AP Stack error: {}", e); + error!("AP Stack error: {}", e); } } net::ap_stack_disable().await; @@ -259,7 +259,7 @@ pub struct TCPEnabled<'a> { cfg_if::cfg_if!(if #[cfg(feature = "esp32")] {use embassy_net::IpListenEndpoint;}); async fn tcp_enabled<'a>(s: WifiControllerEnabled<'a>) -> Result<(), sunset::Error> { - info!("HSM: tcp_enabled"); + debug!("HSM: tcp_enabled"); let mut rx_buffer = [0u8; 1536]; let mut tx_buffer = [0u8; 1536]; @@ -276,10 +276,10 @@ async fn tcp_enabled<'a>(s: WifiControllerEnabled<'a>) -> Result<(), sunset::Err }) .await { - info!("connect error: {:?}", e); + error!("connect error: {:?}", e); net::tcp_socket_disable().await; } - info!("Connected, port 22"); + debug!("Connected, port 22"); } else { let tcp_socket = net::accept_requests(s.tcp_stack, &mut rx_buffer, &mut tx_buffer).await; } @@ -292,7 +292,7 @@ async fn tcp_enabled<'a>(s: WifiControllerEnabled<'a>) -> Result<(), sunset::Err match socket_enabled(tcp_enabled_struct).await { Ok(_) => (), Err(e) => { - info!("TCP socket error: {}", e); + error!("TCP socket error: {}", e); } } net::tcp_socket_disable().await; @@ -307,14 +307,14 @@ pub struct SocketEnabled<'a> { } async fn socket_enabled<'a>(s: TCPEnabled<'a>) -> Result<(), sunset::Error> { - info!("HSM: socket_enabled"); + debug!("HSM: socket_enabled"); // loop { // Spawn network tasks to handle incoming connections with demo_common::session() let mut inbuf = [0u8; UART_BUFFER_SIZE]; let mut outbuf = [0u8; UART_BUFFER_SIZE]; - info!("HSM: Starting ssh_server"); + debug!("HSM: Starting ssh_server"); let ssh_server = serve::ssh_wait_for_initialisation(&mut inbuf, &mut outbuf).await; - info!("HSM: Started ssh_server"); + debug!("HSM: Started ssh_server"); let socket_enabled_struct = SocketEnabled { config: s.config, @@ -325,7 +325,7 @@ async fn socket_enabled<'a>(s: TCPEnabled<'a>) -> Result<(), sunset::Error> { match ssh_enabled(socket_enabled_struct).await { Ok(_) => (), Err(e) => { - info!("SSH server error: {}", e); + error!("SSH server error: {}", e); } } @@ -347,13 +347,13 @@ where } async fn ssh_enabled<'a>(s: SocketEnabled<'a>) -> Result<(), sunset::Error> { - info!("HSM: ssh_enabled"); + debug!("HSM: ssh_enabled"); // loop { - info!("HSM: Starting channel pipe"); + debug!("HSM: Starting channel pipe"); let chan_pipe = Channel::::new(); - info!("HSM: Started channel pipe. Calling connection_loop from ssh_enabled"); + debug!("HSM: Started channel pipe. Calling connection_loop from ssh_enabled"); let connection = serve::connection_loop(&s.ssh_server, &chan_pipe, s.config); - info!("HSM: Started connection loop"); + debug!("HSM: Started connection loop"); let ssh_enabled_struct = SshEnabled { tcp_socket: s.tcp_socket, @@ -366,7 +366,7 @@ async fn ssh_enabled<'a>(s: SocketEnabled<'a>) -> Result<(), sunset::Error> { match client_connected(ssh_enabled_struct).await { Ok(_) => (), Err(e) => { - info!("Client connection error: {}", e); + error!("Client connection error: {}", e); } } @@ -391,10 +391,10 @@ where CL: Future>, 'a: 'b, { - info!("HSM: client_connected"); + debug!("HSM: client_connected"); // // loop { - info!("HSM: Setting up serial bridge"); + debug!("HSM: Setting up serial bridge"); let bridge = serve::handle_ssh_client(s.uart_buf, s.ssh_server, s.chan_pipe); let uart_enabled_struct = ClientConnected { @@ -406,7 +406,7 @@ where match bridge_connected(uart_enabled_struct).await { Ok(_) => (), Err(e) => { - info!("Bridge error: {}", e); + debug!("Bridge error: {}", e); } } @@ -424,14 +424,14 @@ where BR: Future>, 'a: 'b, { - info!("HSM: bridge_connected"); + debug!("HSM: bridge_connected"); let mut tcp_socket = s.tcp_socket; let (mut rsock, mut wsock) = tcp_socket.split(); - info!("HSM: Running server from bridge_connected()"); + debug!("HSM: Running server from bridge_connected()"); let server = s.ssh_server.run(&mut rsock, &mut wsock); let connection_loop = s.connection_loop; let bridge = s.bridge; - info!("HSM: Main select() in bridge_connected()"); + debug!("HSM: Main select() in bridge_connected()"); match select3(server, connection_loop, bridge).await { Either3::First(r) => r, Either3::Second(r) => r, diff --git a/src/serial.rs b/src/serial.rs index 11995d9..a447f93 100644 --- a/src/serial.rs +++ b/src/serial.rs @@ -4,7 +4,7 @@ use embassy_futures::select::select; use embedded_io_async::{Read, Write}; -use log::{info, warn}; +use log::{debug, info, warn}; // Espressif specific crates use crate::espressif::buffered_uart::BufferedUart; @@ -17,9 +17,8 @@ pub async fn serial_bridge( uart: &BufferedUart, ) -> Result<(), sunset::Error> { info!("Starting serial <--> SSH bridge"); - select(uart_to_ssh(uart, chanw), ssh_to_uart(chanr, uart)).await; - info!("Stopping serial <--> SSH bridge"); + debug!("Stopping serial <--> SSH bridge"); Ok(()) } diff --git a/src/serve.rs b/src/serve.rs index 280681b..514be4d 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -use log::{debug, info, trace, warn}; +use log::{debug, trace, warn}; use crate::config::SSHStampConfig; use crate::espressif::buffered_uart::UART_SIGNAL; @@ -52,7 +52,7 @@ pub async fn connection_loop( trace!("{:?}", &ev); match ev { ServEvent::SessionSubsystem(a) => { - info!("ServEvent::SessionSubsystem"); + debug!("ServEvent::SessionSubsystem"); if !auth_checked { warn!("Unauthenticated SessionSubsystem rejected"); @@ -64,7 +64,7 @@ pub async fn connection_loop( #[cfg(feature = "sftp-ota")] { a.succeed()?; - info!("We got SFTP subsystem"); + debug!("We got SFTP subsystem"); match chan_pipe.try_send(SessionType::Sftp(ch)) { Ok(_) => auth_checked = false, Err(e) => error!("Could not send the channel: {:?}", e), @@ -81,7 +81,7 @@ pub async fn connection_loop( } } ServEvent::SessionShell(a) => { - info!("ServEvent::SessionShell"); + debug!("ServEvent::SessionShell"); if !auth_checked { warn!("Unauthenticated SessionShell rejected"); @@ -101,10 +101,10 @@ pub async fn connection_loop( } debug_assert!(ch.num() == a.channel()); a.succeed()?; - info!("We got shell"); + debug!("We got shell"); // Signal for uart task to configure pins and run. Value is irrelevant. UART_SIGNAL.signal(1); - info!("Connection loop: UART_SIGNAL sent"); + debug!("Connection loop: UART_SIGNAL sent"); match chan_pipe.try_send(SessionType::Bridge(ch)) { Ok(_) => auth_checked = false, Err(e) => log::error!("Could not send the channel: {:?}", e), @@ -114,7 +114,7 @@ pub async fn connection_loop( } } ServEvent::FirstAuth(mut a) => { - info!("ServEvent::FirstAuth"); + debug!("ServEvent::FirstAuth"); let config_guard = config.lock().await; a.enable_password_auth(false)?; @@ -123,7 +123,7 @@ pub async fn connection_loop( if config_guard.first_login { a.allow()?; } else { - info!( + debug!( "FirstAuth received but not first-login, allowing pubkey auth but rejecting additions of new public keys on already provisioned device" ); @@ -131,7 +131,7 @@ pub async fn connection_loop( } } ServEvent::Hostkeys(h) => { - info!("ServEvent::Hostkeys"); + debug!("ServEvent::Hostkeys"); let config_guard = config.lock().await; // Just take it from config as private hostkey is generated on first boot. h.hostkeys(&[&config_guard.hostkey])?; @@ -141,7 +141,7 @@ pub async fn connection_loop( a.reject()?; } ServEvent::PubkeyAuth(a) => { - info!("ServEvent::PubkeyAuth"); + debug!("ServEvent::PubkeyAuth"); let config_guard = config.lock().await; let client_pubkey = a.pubkey()?; @@ -156,7 +156,7 @@ pub async fn connection_loop( a.allow()?; auth_checked = true; } else { - info!("No matching pubkey slot found"); + debug!("No matching pubkey slot found"); a.reject()?; } } @@ -167,7 +167,7 @@ pub async fn connection_loop( } } ServEvent::OpenSession(a) => { - info!("ServEvent::OpenSession"); + debug!("ServEvent::OpenSession"); match session { Some(_) => { todo!("Can't have two sessions"); @@ -197,7 +197,7 @@ pub async fn connection_loop( a.fail()?; break Ok(()); } else if config_guard.add_pubkey(a.value()?).is_ok() { - info!("Added new pubkey from ENV"); + debug!("Added new pubkey from ENV"); a.succeed()?; config_guard.first_login = false; config_changed = true; @@ -219,7 +219,7 @@ pub async fn connection_loop( let mut s = String::<32>::new(); if s.push_str(a.value()?).is_ok() { config_guard.wifi_ssid = s; - info!("Set wifi SSID from ENV"); + debug!("Set wifi SSID from ENV"); a.succeed()?; let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { warn!("Could not persist wifi SSID: flash not initialized"); @@ -241,27 +241,27 @@ pub async fn connection_loop( } } } - "SSH_STAMP_WPA3_PSK" => { + "SSH_STAMP_WIFI_PSK" => { let mut config_guard = config.lock().await; if !(auth_checked || config_guard.first_login) { warn!( - "SSH_STAMP_WPA3_PSK env received but not authenticated; rejecting" + "SSH_STAMP_WIFI_PSK env received but not authenticated; rejecting" ); a.fail()?; break Ok(()); } else { let value = a.value()?; if value.len() < 8 { - warn!("SSH_STAMP_WPA3_PSK too short (min 8 characters)"); + warn!("SSH_STAMP_WIFI_PSK too short (min 8 characters)"); a.fail()?; } else if value.len() > 63 { - warn!("SSH_STAMP_WPA3_PSK too long (max 63 characters)"); + warn!("SSH_STAMP_WIFI_PSK too long (max 63 characters)"); a.fail()?; } else { let mut s = String::<63>::new(); if s.push_str(value).is_ok() { config_guard.wifi_pw = Some(s); - info!("Set wifi WPA3 PSK from ENV"); + debug!("Set wifi WIFI PSK from ENV"); a.succeed()?; let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { @@ -280,7 +280,7 @@ pub async fn connection_loop( software_reset(); } } else { - warn!("SSH_STAMP_WPA3_PSK push_str failed unexpectedly"); + warn!("SSH_STAMP_WIFI_PSK push_str failed unexpectedly"); a.fail()?; } } @@ -296,10 +296,10 @@ pub async fn connection_loop( let first_login = { config.lock().await.first_login }; if auth_checked || first_login { - info!("ServEvent::SessionPty: Session granted"); + debug!("ServEvent::SessionPty: Session granted"); a.succeed()?; } else { - info!("ServEvent::SessionPty: No auth not session"); + debug!("ServEvent::SessionPty: No auth not session"); a.fail()?; } } @@ -307,7 +307,7 @@ pub async fn connection_loop( a.fail()?; } ServEvent::Defunct => { - info!("Expected caller to handle event"); + debug!("Expected caller to handle event"); error::BadUsage.fail()? } ServEvent::PollAgain => { @@ -318,7 +318,7 @@ pub async fn connection_loop( } pub async fn connection_disable() -> () { - info!("Connection loop disabled: WIP"); + debug!("Connection loop disabled: WIP"); // TODO: Correctly disable/restart Conection loop and/or send messsage to user over SSH } @@ -330,7 +330,7 @@ pub async fn ssh_wait_for_initialisation<'server>( } pub async fn ssh_disable() -> () { - info!("SSH Server disabled: WIP"); + debug!("SSH Server disabled: WIP"); // TODO: Correctly disable/restart SSH Server and/or send messsage to user over SSH } @@ -342,20 +342,20 @@ pub async fn handle_ssh_client<'a, 'b>( ssh_server: &'b SSHServer<'a>, chan_pipe: &'b Channel, ) -> Result<(), sunset::Error> { - info!("Preparing bridge"); + debug!("Preparing bridge"); let session_type = chan_pipe.receive().await; - info!("Checking bridge session type"); + debug!("Checking bridge session type"); match session_type { SessionType::Bridge(ch) => { - info!("Handling bridge session"); + debug!("Handling bridge session"); let (stdin, stdout) = ssh_server.stdio(ch).await?.split(); - info!("Starting bridge"); + debug!("Starting bridge"); serial_bridge(stdin, stdout, uart_buff).await? } #[cfg(feature = "sftp-ota")] SessionType::Sftp(ch) => { { - info!("Handling SFTP session"); + debug!("Handling SFTP session"); let stdio = ssh_server.stdio(ch).await?; // TODO: Use a configuration flag to select the hardware specific OtaActions implementer let ota_writer = storage::esp_ota::OtaWriter::new(); @@ -368,7 +368,7 @@ pub async fn handle_ssh_client<'a, 'b>( pub async fn bridge_disable() -> () { // disable bridge - info!("Bridge disabled: WIP"); + debug!("Bridge disabled: WIP"); // TODO: Correctly disable/restart bridge and/or send message to user over SSH // software_reset(); } diff --git a/src/store.rs b/src/store.rs index 1236bad..91dc790 100644 --- a/src/store.rs +++ b/src/store.rs @@ -5,7 +5,7 @@ use esp_bootloader_esp_idf::partitions; use pretty_hex::PrettyHex; use sha2::Digest; -use log::{debug, error, info}; +use log::{debug, error}; use crate::errors::Error as SSHStampError; use sunset::error::Error as SunsetError; @@ -37,8 +37,8 @@ impl FlashConfig<'_> { // TODO: Rework Error mapping with esp_storage errors /// Finds the NVS partitions and retrieves information about it. pub fn find_config_partition(fb: &mut FlashBuffer) -> Result<(), SSHStampError> { - info!("Flash size = {} Mb", fb.flash.capacity() / (1024 * 1024)); - info!("Flash storage : {:?}", fb.flash); + debug!("Flash size = {} Mb", fb.flash.capacity() / (1024 * 1024)); + debug!("Flash storage : {:?}", fb.flash); let pt = partitions::read_partition_table( &mut fb.flash, &mut fb.buf[..esp_bootloader_esp_idf::partitions::PARTITION_TABLE_MAX_LEN], @@ -60,8 +60,8 @@ impl FlashConfig<'_> { let nvs_partition = nvs.as_embedded_storage(&mut fb.flash); - info!("NVS partition size = {}", nvs_partition.capacity()); - info!("NVS partition offset = 0x{:x}", nvs.offset()); + debug!("NVS partition size = {}", nvs_partition.capacity()); + debug!("NVS partition offset = 0x{:x}", nvs.offset()); Ok(()) } @@ -77,10 +77,10 @@ fn config_hash(config: &SSHStampConfig) -> Result<[u8; 32], SunsetError> { pub async fn load_or_create(flash: &mut FlashBuffer<'_>) -> Result { match load(flash).await { Ok(c) => { - info!("Good existing config"); + debug!("Good existing config"); return Ok(c); } - Err(e) => info!("Existing config bad, making new. {e}"), + Err(e) => debug!("Existing config bad, making new. {e}"), } create(flash).await @@ -89,7 +89,7 @@ pub async fn load_or_create(flash: &mut FlashBuffer<'_>) -> Result) -> Result { let c = SSHStampConfig::new()?; save(flash, &c).await?; - info!("Created new config: {:?}", &c); + debug!("Created new config: {:?}", &c); Ok(c) } @@ -149,7 +149,7 @@ pub async fn save(fl: &mut FlashBuffer<'_>, config: &SSHStampConfig) -> Result<( CONFIG_OFFSET + FlashConfig::BUF_SIZE ); - info!("Erasing flash"); + debug!("Erasing flash"); const { assert!(CONFIG_AREA_SIZE > FlashConfig::BUF_SIZE) }; @@ -168,6 +168,6 @@ pub async fn save(fl: &mut FlashBuffer<'_>, config: &SSHStampConfig) -> Result<( SunsetError::msg("flash write error") })?; - info!("flash save done"); + debug!("flash save done"); Ok(()) } From 07251b13f14f60d34aad13666c9c363a16c3974f Mon Sep 17 00:00:00 2001 From: brainstorm Date: Sun, 15 Mar 2026 17:32:39 +0100 Subject: [PATCH 05/11] Treat ESP32 as special case for now? --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index c34776d..fb01967 100644 --- a/src/main.rs +++ b/src/main.rs @@ -268,6 +268,7 @@ async fn tcp_enabled<'a>(s: WifiControllerEnabled<'a>) -> Result<(), sunset::Err cfg_if::cfg_if!( if #[cfg(feature = "esp32")] { let mut tcp_socket = TcpSocket::new(s.tcp_stack, &mut rx_buffer, &mut tx_buffer); + use log::info; info!("Waiting for SSH client..."); if let Err(e) = tcp_socket .accept(IpListenEndpoint { From 98ae47f415e883d90fc25bd92e6a99bbe5ba9931 Mon Sep 17 00:00:00 2001 From: brainstorm Date: Mon, 16 Mar 2026 21:04:18 +0100 Subject: [PATCH 06/11] Address PR feedback on code --- src/espressif/net.rs | 2 +- src/serve.rs | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/espressif/net.rs b/src/espressif/net.rs index ac35103..1b6daa7 100644 --- a/src/espressif/net.rs +++ b/src/espressif/net.rs @@ -80,7 +80,7 @@ pub async fn if_up( panic!("Failed to persist generated wifi password: {:?}", e); } } - info!("WiFi WIFI PSK: {}", guard.wifi_pw.as_ref().unwrap()); + info!("WIFI PSK: {}", guard.wifi_pw.as_ref().unwrap()); } let ap_config = ModeConfig::AccessPoint( diff --git a/src/serve.rs b/src/serve.rs index 514be4d..341d08e 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -67,7 +67,7 @@ pub async fn connection_loop( debug!("We got SFTP subsystem"); match chan_pipe.try_send(SessionType::Sftp(ch)) { Ok(_) => auth_checked = false, - Err(e) => error!("Could not send the channel: {:?}", e), + Err(e) => log::error!("Could not send the channel: {:?}", e), }; } #[cfg(not(feature = "sftp-ota"))] @@ -222,14 +222,16 @@ pub async fn connection_loop( debug!("Set wifi SSID from ENV"); a.succeed()?; let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { - warn!("Could not persist wifi SSID: flash not initialized"); + log::error!( + "Could not persist wifi SSID: flash not initialized" + ); config_changed = true; continue; }; let mut flash_storage = flash_storage_guard.lock().await; if let Err(e) = store::save(&mut flash_storage, &config_guard).await { - warn!("Failed to persist config with wifi SSID: {:?}", e); + log::error!("Failed to persist config with wifi SSID: {:?}", e); config_changed = true; } else { drop(config_guard); @@ -261,7 +263,7 @@ pub async fn connection_loop( let mut s = String::<63>::new(); if s.push_str(value).is_ok() { config_guard.wifi_pw = Some(s); - debug!("Set wifi WIFI PSK from ENV"); + debug!("Set WIFI PSK from ENV"); a.succeed()?; let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { From 5b612148a29230a8418f19101bd77fe5f474c463 Mon Sep 17 00:00:00 2001 From: brainstorm Date: Mon, 16 Mar 2026 21:04:02 +0100 Subject: [PATCH 07/11] Address feedback on README --- README.md | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8d41a75..bdd7c9a 100644 --- a/README.md +++ b/README.md @@ -25,17 +25,34 @@ rustup toolchain install stable --component rust-src cargo install espflash --locked ``` -Build/flash for your board using the short command pattern (replace ``): +Build/flash for your board using the short command pattern (replace `` with the concrete chip you have): + +| Machine target | Rust toolchain target | +| --- | --- | +| `esp32` | `xtensa-esp32-none-elf` | +| `esp32c2` | `riscv32imc-unknown-none-elf` | +| `esp32c3` | `riscv32imc-unknown-none-elf` | +| `esp32c5` | `riscv32imac-unknown-none-elf` | +| `esp32c6` | `riscv32imac-unknown-none-elf` | +| `esp32c61` | `riscv32imac-unknown-none-elf` | +| `esp32s2` | `xtensa-esp32s2-none-elf` | +| `esp32s3` | `xtensa-esp32s3-none-elf` | ``` -rustup target add -cargo build- # e.g. cargo build-esp32c6, cargo build-esp32c3, cargo build-esp32 -cargo run- # convenience helper (if supported) that builds + flashes +rustup target add +cargo build- # e.g. cargo build-esp32c6, cargo build-esp32c3, cargo build-esp32 +cargo run- # convenience helper (if supported) that builds + flashes ``` -Xtensa targets (ESP32/ESP32-S2/S3) require `espup` — follow esp-rs docs if you target those. If you prefer manual flashing, build `--release` and use `espflash`. +Xtensa targets (ESP32/ESP32-S2/S3) do require `espup` in addition to the `rustup` command above: -## First boot & provisioning (quick) +``` +cargo install espup +espup install +source $HOME/export-esp.sh +``` + +## First boot & provisioning 1. Flash the firmware and open the serial console (example): @@ -45,11 +62,17 @@ cargo build-esp32c6 --release cargo run-esp32c6 ``` -2. On first boot the device generates a random WPA2 PSK and prints it to the serial console with an info message `First-boot generated WiFi WPA2 PSK: `; the default SSID is `ssh-stamp` and the AP IP is `192.168.4.1`. +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 - Connect to the AP `ssh-stamp` as a DHCP client with IP: 192.168.4.1 +``` -3. Connect a laptop/phone to the `ssh-stamp` AP using the printed PSK, then SSH into the device at `root@192.168.4.1`. +2. Connect a laptop/phone to the `ssh-stamp` AP using the printed PSK, then SSH into the device at `root@192.168.4.1`. -4. Provisioning via SSH environment variables +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. From 91a4e4b423c3407dcde4d80c0d8c0c67592441d0 Mon Sep 17 00:00:00 2001 From: brainstorm Date: Mon, 16 Mar 2026 22:12:49 +0100 Subject: [PATCH 08/11] Minor cleanup and fix for bad merge --- src/main.rs | 39 ++++++++++----------------------------- src/serve.rs | 6 ++---- 2 files changed, 12 insertions(+), 33 deletions(-) diff --git a/src/main.rs b/src/main.rs index 283d367..1d0e628 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,7 @@ use static_cell::StaticCell; cfg_if::cfg_if! { if #[cfg(feature = "esp32")] { + use log::info; use esp_hal::timer::timg::TimerGroup; } } @@ -285,36 +286,16 @@ async fn tcp_enabled<'a>(s: WifiControllerEnabled<'a>) -> Result<(), sunset::Err } ); - // TO FIX: ESP32 target needs tcp_socket.accept in main.rs - Gets blocked at bridge connection? - cfg_if::cfg_if!( - if #[cfg(feature = "esp32")] { - let mut tcp_socket = TcpSocket::new(s.tcp_stack, &mut rx_buffer, &mut tx_buffer); - use log::info; - info!("Waiting for SSH client..."); - if let Err(e) = tcp_socket - .accept(IpListenEndpoint { - addr: None, - port: 22, - }) - .await - { - error!("connect error: {:?}", e); - net::tcp_socket_disable().await; + let tcp_enabled_struct = TCPEnabled { + config: s.config, + tcp_socket, + uart_buf: s.uart_buf, + }; + match socket_enabled(tcp_enabled_struct).await { + Ok(_) => (), + Err(e) => { + error!("TCP socket error: {}", e); } - debug!("Connected, port 22"); - } else { - let tcp_socket = net::accept_requests(s.tcp_stack, &mut rx_buffer, &mut tx_buffer).await; - } - ); - let tcp_enabled_struct = TCPEnabled { - config: s.config, - tcp_socket, - uart_buf: s.uart_buf, - }; - match socket_enabled(tcp_enabled_struct).await { - Ok(_) => (), - Err(e) => { - error!("TCP socket error: {}", e); } net::tcp_socket_disable().await; } diff --git a/src/serve.rs b/src/serve.rs index 95b9f89..f2ae42e 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -use log::{debug, trace, warn}; +use log::{debug, info, trace, warn}; use crate::config::SSHStampConfig; use crate::espressif::buffered_uart::UART_SIGNAL; @@ -312,9 +312,7 @@ pub async fn connection_loop( debug!("Expected caller to handle event"); error::BadUsage.fail()? } - ServEvent::PollAgain => { - // info!("ServEvent::PollAgain"); - } + ServEvent::PollAgain => {} } } } From 03b5b6870efd11063460392ebb767087a9b71ea0 Mon Sep 17 00:00:00 2001 From: brainstorm Date: Mon, 16 Mar 2026 22:26:48 +0100 Subject: [PATCH 09/11] Apply suggestion by @jubeormk1 on https://github.com/brainstorm/ssh-stamp/pull/79#discussion_r2938188053 --- src/serve.rs | 41 +++++++++-------------------------------- 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/src/serve.rs b/src/serve.rs index f2ae42e..e1e5a54 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -41,6 +41,7 @@ pub async fn connection_loop( debug!("Entering connection_loop and prog_loop is next..."); let mut config_changed: bool = false; + let mut needs_reset: bool = false; // Will be set in `ev` PubkeyAuth is accepted and cleared once the channel is sent down chan_pipe let mut auth_checked = false; @@ -98,6 +99,10 @@ pub async fn connection_loop( }; let mut flash_storage = flash_storage_guard.lock().await; let _result = store::save(&mut flash_storage, &config_guard).await; + if needs_reset { + drop(config_guard); + software_reset(); + } } debug_assert!(ch.num() == a.channel()); a.succeed()?; @@ -221,22 +226,8 @@ pub async fn connection_loop( config_guard.wifi_ssid = s; debug!("Set wifi SSID from ENV"); a.succeed()?; - let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { - log::error!( - "Could not persist wifi SSID: flash not initialized" - ); - config_changed = true; - continue; - }; - let mut flash_storage = flash_storage_guard.lock().await; - if let Err(e) = store::save(&mut flash_storage, &config_guard).await - { - log::error!("Failed to persist config with wifi SSID: {:?}", e); - config_changed = true; - } else { - drop(config_guard); - software_reset(); - } + config_changed = true; + needs_reset = true; } else { warn!("SSH_STAMP_WIFI_SSID too long"); a.fail()?; @@ -265,22 +256,8 @@ pub async fn connection_loop( config_guard.wifi_pw = Some(s); debug!("Set WIFI PSK from ENV"); a.succeed()?; - let Some(flash_storage_guard) = flash::get_flash_n_buffer() - else { - warn!("Could not persist wifi PSK: flash not initialized"); - config_changed = true; - continue; - }; - let mut flash_storage = flash_storage_guard.lock().await; - if let Err(e) = - store::save(&mut flash_storage, &config_guard).await - { - warn!("Failed to persist config with wifi PSK: {:?}", e); - config_changed = true; - } else { - drop(config_guard); - software_reset(); - } + config_changed = true; + needs_reset = true; } else { warn!("SSH_STAMP_WIFI_PSK push_str failed unexpectedly"); a.fail()?; From 4f3cb201f7e4c9e1558e448a796b5dcc50d9b2d4 Mon Sep 17 00:00:00 2001 From: brainstorm Date: Tue, 17 Mar 2026 21:05:44 +0100 Subject: [PATCH 10/11] Make sure that several environment variables can be passed at once and don't break the loop with 'break Ok(())' --- src/espressif/net.rs | 69 +++++++++++++++++--------------------------- src/main.rs | 25 ++++++++-------- src/serve.rs | 43 +++++++++++++++------------ 3 files changed, 65 insertions(+), 72 deletions(-) diff --git a/src/espressif/net.rs b/src/espressif/net.rs index 1b6daa7..9498da8 100644 --- a/src/espressif/net.rs +++ b/src/espressif/net.rs @@ -24,7 +24,7 @@ use esp_hal::rng::Rng; use esp_radio::Controller; use esp_radio::wifi::WifiEvent; use esp_radio::wifi::{ - AccessPointConfig, AuthMethod::Wpa2Personal, ModeConfig, WifiApState, WifiController, + AccessPointConfig, AuthMethod::Wpa2Wpa3Personal, ModeConfig, WifiApState, WifiController, }; use heapless::String; extern crate alloc; @@ -86,7 +86,7 @@ pub async fn if_up( let ap_config = ModeConfig::AccessPoint( AccessPointConfig::default() .with_ssid(AllocString::from(wifi_ssid(config).await.as_str())) - .with_auth_method(Wpa2Personal) + .with_auth_method(Wpa2Wpa3Personal) .with_password(AllocString::from(wifi_password(config).await.as_str())), ); let res = wifi_controller.set_config(&ap_config); @@ -166,22 +166,24 @@ pub async fn accept_requests<'a>( tcp_socket } +/// Returns the configured WiFi SSID from the config, or the default SSID if not set. pub async fn wifi_ssid(config: &'static SunsetMutex) -> String<63> { - // Return the configured SSID if present, otherwise the fixed default. let guard = config.lock().await; - if !guard.wifi_ssid.is_empty() { - return String::<63>::try_from(guard.wifi_ssid.as_str()).unwrap_or_else(|_| { - let mut fallback = String::<63>::new(); - fallback.push_str(DEFAULT_SSID).ok(); - fallback - }); - } + let ssid_src = if !guard.wifi_ssid.is_empty() { + guard.wifi_ssid.as_str() + } else { + DEFAULT_SSID + }; - let mut default = String::<63>::new(); - default.push_str(DEFAULT_SSID).ok(); - default + String::<63>::try_from(ssid_src).unwrap_or_else(|_| { + let mut fallback = String::<63>::new(); + fallback.push_str(DEFAULT_SSID).ok(); + fallback + }) } +/// Returns the WiFi password from the config. +/// Panics if wifi_pw is not set in the config. pub async fn wifi_password(config: &'static SunsetMutex) -> String<63> { let guard = config.lock().await; match &guard.wifi_pw { @@ -192,46 +194,29 @@ pub async fn wifi_password(config: &'static SunsetMutex) -> Stri } } +/// Manages the WiFi access point lifecycle. +/// Starts the AP with the configured SSID and password from the config. +/// Handles reconnection if the AP stops. #[embassy_executor::task] pub async fn wifi_up( mut wifi_controller: WifiController<'static>, config: &'static SunsetMutex, ) { debug!("Device capabilities: {:?}", wifi_controller.capabilities()); - let configured_ssid = { - let guard = config.lock().await; - guard.wifi_ssid.clone() - // drop guard - }; debug!("Starting wifi"); - // (PSK generation handled in if_up on first boot) - - let ssid_string = match String::<63>::try_from(configured_ssid.as_str()) { - Ok(s) => { - let mut lowered = String::<63>::new(); - for ch in s.as_str().chars() { - let _ = lowered.push(ch.to_ascii_lowercase()); - } - lowered - } - Err(_) => { - warn!("SSID too long, using default"); - wifi_ssid(config).await - } - }; - let pw_string = wifi_password(config).await; - let client_config = ModeConfig::AccessPoint( - AccessPointConfig::default() - .with_ssid(AllocString::from(ssid_string.as_str())) - .with_auth_method(Wpa2Personal) - .with_password(AllocString::from(pw_string.as_str())), - ); - loop { + let ssid_string = wifi_ssid(config).await; + let pw_string = wifi_password(config).await; + let client_config = ModeConfig::AccessPoint( + AccessPointConfig::default() + .with_ssid(AllocString::from(ssid_string.as_str())) + .with_auth_method(Wpa2Wpa3Personal) + .with_password(AllocString::from(pw_string.as_str())), + ); + if esp_radio::wifi::ap_state() == WifiApState::Started { - // wait until we're no longer connected wifi_controller.wait_for_event(WifiEvent::ApStop).await; Timer::after(Duration::from_millis(5000)).await } diff --git a/src/main.rs b/src/main.rs index 1d0e628..d3ef966 100644 --- a/src/main.rs +++ b/src/main.rs @@ -339,7 +339,7 @@ async fn socket_enabled<'a>(s: TCPEnabled<'a>) -> Result<(), sunset::Error> { pub struct SshEnabled<'a, 'b, CL> where - CL: Future>, + CL: Future>, { pub tcp_socket: TcpSocket<'a>, pub ssh_server: &'b SSHServer<'a>, @@ -380,7 +380,7 @@ async fn ssh_enabled<'a>(s: SocketEnabled<'a>) -> Result<(), sunset::Error> { pub struct ClientConnected<'a, 'b, CL, BR> where - CL: Future>, + CL: Future>, BR: Future>, { pub ssh_server: &'b SSHServer<'a>, @@ -391,12 +391,11 @@ where async fn client_connected<'a, 'b, CL>(s: SshEnabled<'a, 'b, CL>) -> Result<(), sunset::Error> where - CL: Future>, + CL: Future>, 'a: 'b, { debug!("HSM: client_connected"); - // // loop { debug!("HSM: Setting up serial bridge"); let bridge = serve::handle_ssh_client(s.uart_buf, s.ssh_server, s.chan_pipe); @@ -413,17 +412,17 @@ where } } - serve::bridge_disable().await; - // } + let should_reset = serve::check_and_clear_reset(); + serve::bridge_disable(should_reset).await; - Ok(()) // todo!() return relevant value + Ok(()) } async fn bridge_connected<'a, 'b, CL, BR>( s: ClientConnected<'a, 'b, CL, BR>, ) -> Result<(), sunset::Error> where - CL: Future>, + CL: Future>, BR: Future>, 'a: 'b, { @@ -436,10 +435,12 @@ where let bridge = s.bridge; debug!("HSM: Main select() in bridge_connected()"); match select3(server, connection_loop, bridge).await { - Either3::First(r) => r, - Either3::Second(r) => r, - Either3::Third(r) => r, - }?; + Either3::First(r) => r?, + Either3::Second(r) => { + r?; + } + Either3::Third(r) => r?, + }; Result::Ok(()) } diff --git a/src/serve.rs b/src/serve.rs index e1e5a54..c9f9f58 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -15,6 +15,7 @@ use storage::flash; use core::fmt::Debug; use core::option::Option::{self, None, Some}; use core::result::Result; +use core::sync::atomic::{AtomicBool, Ordering}; // Embassy use embassy_sync::blocking_mutex::raw::NoopRawMutex; @@ -32,11 +33,21 @@ pub enum SessionType { Sftp(ChanHandle), } +static NEEDS_RESET: AtomicBool = AtomicBool::new(false); + +pub fn check_and_clear_reset() -> bool { + NEEDS_RESET.swap(false, Ordering::SeqCst) +} + +pub struct ConnectionResult { + pub needs_reset: bool, +} + pub async fn connection_loop( serv: &SSHServer<'_>, chan_pipe: &Channel, config: &SunsetMutex, -) -> Result<(), sunset::Error> { +) -> Result { let mut session: Option = None; debug!("Entering connection_loop and prog_loop is next..."); @@ -87,27 +98,26 @@ pub async fn connection_loop( if !auth_checked { warn!("Unauthenticated SessionShell rejected"); a.fail()?; - // TODO: Handle this gracefully - // TODO: Provide a message back to the client and the close the session? } else if let Some(ch) = session.take() { // Save config after connection successful (SessionEnv completed) if config_changed { - config_changed = false; // TODO: Avoid unnecessary "does not neet to be mutable" warnings for now + config_changed = false; let config_guard = config.lock().await; let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { panic!("Could not acquire flash storage lock"); }; let mut flash_storage = flash_storage_guard.lock().await; let _result = store::save(&mut flash_storage, &config_guard).await; + drop(config_guard); if needs_reset { - drop(config_guard); - software_reset(); + needs_reset = false; + NEEDS_RESET.store(true, Ordering::SeqCst); + debug!("Configuration saved. Device will reset after disconnect."); } } debug_assert!(ch.num() == a.channel()); a.succeed()?; debug!("We got shell"); - // Signal for uart task to configure pins and run. Value is irrelevant. UART_SIGNAL.signal(1); debug!("Connection loop: UART_SIGNAL sent"); match chan_pipe.try_send(SessionType::Bridge(ch)) { @@ -200,7 +210,6 @@ pub async fn connection_loop( if !config_guard.first_login { warn!("SSH_STAMP_PUBKEY env received but not first-login; rejecting"); a.fail()?; - break Ok(()); } else if config_guard.add_pubkey(a.value()?).is_ok() { debug!("Added new pubkey from ENV"); a.succeed()?; @@ -219,7 +228,6 @@ pub async fn connection_loop( "SSH_STAMP_WIFI_SSID env received but not authenticated; rejecting" ); a.fail()?; - break Ok(()); } else { let mut s = String::<32>::new(); if s.push_str(a.value()?).is_ok() { @@ -241,7 +249,6 @@ pub async fn connection_loop( "SSH_STAMP_WIFI_PSK env received but not authenticated; rejecting" ); a.fail()?; - break Ok(()); } else { let value = a.value()?; if value.len() < 8 { @@ -266,8 +273,8 @@ pub async fn connection_loop( } } _ => { - warn!("Unsupported environment variable"); - a.fail()?; + debug!("Ignoring unknown environment variable: {}", a.name()?); + a.succeed()?; } } } @@ -296,7 +303,6 @@ pub async fn connection_loop( pub async fn connection_disable() -> () { debug!("Connection loop disabled: WIP"); - // TODO: Correctly disable/restart Conection loop and/or send messsage to user over SSH } pub async fn ssh_wait_for_initialisation<'server>( @@ -345,9 +351,10 @@ pub async fn handle_ssh_client<'a, 'b>( Ok(()) } -pub async fn bridge_disable() -> () { - // disable bridge - debug!("Bridge disabled: WIP"); - // TODO: Correctly disable/restart bridge and/or send message to user over SSH - // software_reset(); +pub async fn bridge_disable(should_reset: bool) -> () { + debug!("Bridge disabled"); + if should_reset { + info!("Configuration changed - resetting device..."); + software_reset(); + } } From c09a1152fa3a5ab7f7ea0da0409c157bb608c6e6 Mon Sep 17 00:00:00 2001 From: brainstorm Date: Tue, 17 Mar 2026 21:31:23 +0100 Subject: [PATCH 11/11] Revert atomic hacks... --- src/main.rs | 22 +++++++++------------- src/serve.rs | 30 +++++++++--------------------- 2 files changed, 18 insertions(+), 34 deletions(-) diff --git a/src/main.rs b/src/main.rs index d3ef966..0a16760 100644 --- a/src/main.rs +++ b/src/main.rs @@ -339,7 +339,7 @@ async fn socket_enabled<'a>(s: TCPEnabled<'a>) -> Result<(), sunset::Error> { pub struct SshEnabled<'a, 'b, CL> where - CL: Future>, + CL: Future>, { pub tcp_socket: TcpSocket<'a>, pub ssh_server: &'b SSHServer<'a>, @@ -380,7 +380,7 @@ async fn ssh_enabled<'a>(s: SocketEnabled<'a>) -> Result<(), sunset::Error> { pub struct ClientConnected<'a, 'b, CL, BR> where - CL: Future>, + CL: Future>, BR: Future>, { pub ssh_server: &'b SSHServer<'a>, @@ -391,7 +391,7 @@ where async fn client_connected<'a, 'b, CL>(s: SshEnabled<'a, 'b, CL>) -> Result<(), sunset::Error> where - CL: Future>, + CL: Future>, 'a: 'b, { debug!("HSM: client_connected"); @@ -412,8 +412,7 @@ where } } - let should_reset = serve::check_and_clear_reset(); - serve::bridge_disable(should_reset).await; + serve::bridge_disable().await; Ok(()) } @@ -422,7 +421,7 @@ async fn bridge_connected<'a, 'b, CL, BR>( s: ClientConnected<'a, 'b, CL, BR>, ) -> Result<(), sunset::Error> where - CL: Future>, + CL: Future>, BR: Future>, 'a: 'b, { @@ -435,13 +434,10 @@ where let bridge = s.bridge; debug!("HSM: Main select() in bridge_connected()"); match select3(server, connection_loop, bridge).await { - Either3::First(r) => r?, - Either3::Second(r) => { - r?; - } - Either3::Third(r) => r?, - }; - Result::Ok(()) + Either3::First(r) => r, + Either3::Second(r) => r, + Either3::Third(r) => r, + } } #[panic_handler] diff --git a/src/serve.rs b/src/serve.rs index c9f9f58..37cd2bf 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -15,7 +15,6 @@ use storage::flash; use core::fmt::Debug; use core::option::Option::{self, None, Some}; use core::result::Result; -use core::sync::atomic::{AtomicBool, Ordering}; // Embassy use embassy_sync::blocking_mutex::raw::NoopRawMutex; @@ -33,21 +32,11 @@ pub enum SessionType { Sftp(ChanHandle), } -static NEEDS_RESET: AtomicBool = AtomicBool::new(false); - -pub fn check_and_clear_reset() -> bool { - NEEDS_RESET.swap(false, Ordering::SeqCst) -} - -pub struct ConnectionResult { - pub needs_reset: bool, -} - pub async fn connection_loop( serv: &SSHServer<'_>, chan_pipe: &Channel, config: &SunsetMutex, -) -> Result { +) -> Result<(), sunset::Error> { let mut session: Option = None; debug!("Entering connection_loop and prog_loop is next..."); @@ -110,9 +99,8 @@ pub async fn connection_loop( let _result = store::save(&mut flash_storage, &config_guard).await; drop(config_guard); if needs_reset { - needs_reset = false; - NEEDS_RESET.store(true, Ordering::SeqCst); - debug!("Configuration saved. Device will reset after disconnect."); + info!("Configuration saved. Rebooting to apply WiFi changes..."); + software_reset(); } } debug_assert!(ch.num() == a.channel()); @@ -303,6 +291,7 @@ pub async fn connection_loop( pub async fn connection_disable() -> () { debug!("Connection loop disabled: WIP"); + // TODO: Correctly disable/restart Conection loop and/or send messsage to user over SSH } pub async fn ssh_wait_for_initialisation<'server>( @@ -351,10 +340,9 @@ pub async fn handle_ssh_client<'a, 'b>( Ok(()) } -pub async fn bridge_disable(should_reset: bool) -> () { - debug!("Bridge disabled"); - if should_reset { - info!("Configuration changed - resetting device..."); - software_reset(); - } +pub async fn bridge_disable() -> () { + // disable bridge + debug!("Bridge disabled: WIP"); + // TODO: Correctly disable/restart bridge and/or send message to user over SSH + // software_reset(); }