Skip to main content

ssh_stamp/
app.rs

1// SPDX-FileCopyrightText: 2026 Roman Valls Guimera <brainstorm@nopcode.org>
2// SPDX-FileCopyrightText: 2026 Marko Malenic <mmalenic1@gmail.com>
3//
4// SPDX-License-Identifier: GPL-3.0-or-later
5
6//! Platform-agnostic application entry points.
7//!
8//! Once a platform crate has brought up its peripherals, loaded the
9//! [`SSHStampConfig`] from flash, and raised an `embassy_net::Stack` via a
10//! [`ssh_stamp_hal::NetworkProviderHal`] implementation, it hands control
11//! here. Everything from "accept a TCP connection" downward is the same on
12//! every MCU.
13
14use core::result::Result;
15
16use embassy_futures::select::{Either3, select3};
17use embassy_net::{
18    IpListenEndpoint, Stack,
19    tcp::{AcceptError, TcpSocket},
20};
21use embassy_sync::{blocking_mutex::raw::NoopRawMutex, channel::Channel};
22use heapless::String;
23use log::{debug, error, info, warn};
24use ssh_stamp_hal::{BandMode, WifiApConfigStatic};
25#[cfg(feature = "can")]
26use sunset::ChanHandle;
27use sunset::SignKey;
28use sunset_async::SunsetMutex;
29#[cfg(feature = "mem-probe")]
30use {
31    core::pin::pin,
32    embassy_futures::select::{Either, select},
33    embassy_time::Timer,
34};
35
36use crate::config::SSHStampConfig;
37use crate::handle::{self, SessionType};
38use crate::mem_probe::{Checkpoint, checkpoint, mark_kex_start, replay_checkpoints};
39use crate::platform::PlatformServices;
40use crate::serial::BufferedSerial;
41use crate::serve;
42use crate::settings::{
43    SSH_STAMP_IDENT, TCP_RX_BUF, TCP_TX_BUF, UART_BUFFER_SIZE, WIFI_PASSWORD_CHARS,
44};
45
46/// Ensures a `WiFi` password exists, persists a freshly-generated one if not,
47/// prints the SSH hostkey fingerprint, and returns a ready-to-use
48/// [`WifiApConfigStatic`] for a [`ssh_stamp_hal::WifiHal`] implementation.
49///
50/// The returned config resolves the `[0xFF; 6]` random-MAC sentinel to a
51/// freshly-generated locally-administered MAC.
52///
53/// # Errors
54///
55/// Returns an error if persisting a freshly-minted `WiFi` password fails or
56/// MAC resolution fails.
57///
58/// # Panics
59///
60/// Panics if `wifi_pw` is unexpectedly empty after the guard block above
61/// ensures it is populated. This is an internal invariant violation.
62pub async fn prepare_ap_config<P: PlatformServices>(
63    config: &SunsetMutex<SSHStampConfig>,
64    platform: &P,
65) -> Result<WifiApConfigStatic, sunset::Error> {
66    let mut guard = config.lock().await;
67
68    info!("SSH server ident: {SSH_STAMP_IDENT}");
69
70    if guard.wifi_ap_pw.is_empty() {
71        let pw = generate_wifi_password()?;
72        warn!("wifi_pw missing from config, generated new password");
73        guard.wifi_ap_pw = pw;
74        platform
75            .save_config(&guard)
76            .await
77            .map_err(|_| sunset::error::BadUsage.build())?;
78    }
79    log_ap_credentials(&guard);
80
81    let mac = guard
82        .resolve_mac()
83        .map_err(|_| sunset::error::BadUsage.build())?;
84    info!(
85        "WIFI MAC: {:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
86        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
87    );
88
89    print_hostkey_fingerprint(&guard.hostkey);
90
91    // Resolve band mode from the stored u8 (0=2.4G, 1=5G, 2=Auto).
92    // 5GHz is only available on the ESP32-C5; other chips silently fall
93    // back to 2.4GHz at the radio level.
94    let band = BandMode::from(guard.wifi_ap_band);
95    // Channel 1 for 2.4GHz, channel 36 for 5GHz/Auto (esp-radio default).
96    let channel = if guard.wifi_ap_band == 0 { 1 } else { 36 };
97
98    info!("WIFI AP band: {band:?} (channel {channel})");
99
100    Ok(WifiApConfigStatic {
101        ap_ssid: guard.wifi_ap_ssid.clone(),
102        ap_password: guard.wifi_ap_pw.clone(),
103        sta_ssid: guard.wifi_sta_ssid.clone(),
104        sta_password: guard.wifi_sta_pw.clone(),
105        channel,
106        band,
107        mac,
108    })
109}
110
111/// Logs the access point credentials.
112fn log_ap_credentials(config: &SSHStampConfig) {
113    info!("WIFI SSID: {}", config.wifi_ap_ssid);
114    info!("WIFI PSK: {}", config.wifi_ap_pw);
115}
116
117/// Accepts TCP connections for the server loop.
118///
119/// When running the benchmarking code, the device should wait for the host to connect before
120/// replaying checkpoints, otherwise a race condition occurs and frequently the bench fails
121/// and is flaky. This does not actually affect any of the checkpoint measurement times.
122#[cfg(feature = "mem-probe")]
123struct SessionAcceptor {
124    awaiting_first_session: bool,
125}
126
127#[cfg(feature = "mem-probe")]
128impl SessionAcceptor {
129    fn new() -> Self {
130        Self {
131            awaiting_first_session: true,
132        }
133    }
134
135    /// Accepts a TCP connection on the socket. This will wait until the host
136    /// connects in a loop.
137    async fn accept(
138        &mut self,
139        tcp_socket: &mut TcpSocket<'_>,
140        endpoint: IpListenEndpoint,
141        config: &SunsetMutex<SSHStampConfig>,
142    ) -> Result<(), AcceptError> {
143        if !self.awaiting_first_session {
144            return tcp_socket.accept(endpoint).await;
145        }
146
147        let mut accept = pin!(tcp_socket.accept(endpoint));
148        let accepted = loop {
149            match select(&mut accept, Timer::after_secs(3)).await {
150                Either::First(accepted) => break accepted,
151                Either::Second(()) => {
152                    replay_checkpoints();
153                    log_ap_credentials(&*config.lock().await);
154                }
155            }
156        };
157
158        if accepted.is_ok() {
159            self.awaiting_first_session = false;
160        }
161        accepted
162    }
163}
164
165/// Accepts TCP connections for the server loop.
166#[cfg(not(feature = "mem-probe"))]
167struct SessionAcceptor;
168
169#[cfg(not(feature = "mem-probe"))]
170impl SessionAcceptor {
171    fn new() -> Self {
172        Self
173    }
174
175    /// Accepts a TCP connection on the socket.
176    async fn accept(
177        &mut self,
178        tcp_socket: &mut TcpSocket<'_>,
179        endpoint: IpListenEndpoint,
180        _config: &SunsetMutex<SSHStampConfig>,
181    ) -> Result<(), AcceptError> {
182        tcp_socket.accept(endpoint).await
183    }
184}
185
186/// Runs the SSH server loop forever: accept TCP, run SSH, bridge to UART,
187/// then go round again. Does not return under normal operation.
188///
189/// # Errors
190///
191/// Returns an error only on unrecoverable TCP socket initialisation failure.
192pub async fn run_app<U, P>(
193    stack: Stack<'static>,
194    uart: &U,
195    config: &'static SunsetMutex<SSHStampConfig>,
196    platform: &P,
197) -> Result<(), sunset::Error>
198where
199    U: BufferedSerial,
200    P: PlatformServices,
201{
202    let mut rx_buffer = [0u8; TCP_RX_BUF];
203    let mut tx_buffer = [0u8; TCP_TX_BUF];
204
205    checkpoint(Checkpoint::TcpListening);
206    replay_checkpoints();
207    log_ap_credentials(&*config.lock().await);
208    let mut acceptor = SessionAcceptor::new();
209    loop {
210        debug!("HSM: accepting TCP on port 22");
211        let mut tcp_socket = TcpSocket::new(stack, &mut rx_buffer, &mut tx_buffer);
212        let endpoint = IpListenEndpoint {
213            addr: None,
214            port: 22,
215        };
216
217        if let Err(e) = acceptor.accept(&mut tcp_socket, endpoint, config).await {
218            error!("TCP accept error: {e:?}");
219            continue;
220        }
221        debug!("HSM: TCP connected on port 22");
222
223        mark_kex_start();
224        checkpoint(Checkpoint::TcpAccept);
225
226        let mut inbuf = [0u8; UART_BUFFER_SIZE];
227        let mut outbuf = [0u8; UART_BUFFER_SIZE];
228        let ssh_server = serve::ssh_wait_for_initialisation(&mut inbuf, &mut outbuf);
229
230        let chan_pipe = Channel::<NoopRawMutex, SessionType, 1>::new();
231        #[cfg(feature = "can")]
232        let can_queue = Channel::<NoopRawMutex, ChanHandle, 1>::new();
233        #[cfg(feature = "can")]
234        let connection =
235            serve::connection_loop(&ssh_server, &chan_pipe, config, platform, &can_queue);
236        #[cfg(not(feature = "can"))]
237        let connection = serve::connection_loop(&ssh_server, &chan_pipe, config, platform);
238        #[cfg(feature = "can")]
239        let bridge = handle::ssh_client(uart, &ssh_server, &chan_pipe, platform, &can_queue);
240        #[cfg(not(feature = "can"))]
241        let bridge = handle::ssh_client(uart, &ssh_server, &chan_pipe, platform);
242
243        let (mut rsock, mut wsock) = tcp_socket.split();
244        let server = ssh_server.run(&mut rsock, &mut wsock);
245
246        match select3(server, connection, bridge).await {
247            Either3::First(r) | Either3::Second(r) | Either3::Third(r) => {
248                if let Err(e) = r {
249                    warn!("Session ended: {e}");
250                }
251            }
252        }
253    }
254}
255
256fn generate_wifi_password() -> Result<String<63>, sunset::Error> {
257    let mut rnd = [0u8; 24];
258    getrandom::fill(&mut rnd).map_err(|_| sunset::Error::msg("RNG failed"))?;
259    let mut pw = String::<63>::new();
260    for &byte in &rnd {
261        let _ = pw.push(WIFI_PASSWORD_CHARS[(byte as usize) % 62] as char);
262    }
263    Ok(pw)
264}
265
266fn print_hostkey_fingerprint(hostkey: &SignKey) {
267    match hostkey {
268        SignKey::Ed25519(_) => {
269            let pubkey = hostkey.pubkey();
270            match pubkey.fingerprint() {
271                Ok(fp) => info!("SSH hostkey fingerprint: {fp}"),
272                Err(e) => warn!("Failed to compute fingerprint: {e:?}"),
273            }
274        }
275        SignKey::AgentEd25519(_) => {
276            warn!("Unsupported key type for fingerprint");
277        }
278    }
279}