Skip to main content

ssh_stamp/
handle.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//! SSH event handlers: authentication, channels, environment variables.
7//!
8//! Every incoming SSH event is dispatched here by the connection loop in
9//! [`serve`](crate::serve). The main entry point is [`session_env`], which
10//! routes environment variable requests to handlers like [`pubkey_env`],
11//! [`wifi_ap_ssid_env`] and [`wifi_sta_ssid_env`].
12//!
13//! First-boot provisioning also flows through here: when `first_login` is true,
14//! the device accepts any SSH connection (empty password) and allows the
15//! client to set `SSH_STAMP_PUBKEY`. Subsequent connections require that key.
16
17use heapless::String;
18use log::{debug, info, warn};
19
20use crate::config::SSHStampConfig;
21use crate::platform::PlatformServices;
22use crate::serial::{BufferedSerial, serial_bridge};
23
24#[cfg(feature = "can")]
25use crate::can::can_bridge;
26
27#[cfg(feature = "can")]
28use embassy_futures::select::{Either, select};
29use embassy_sync::blocking_mutex::raw::NoopRawMutex;
30use embassy_sync::channel::Channel;
31
32use core::result::Result;
33
34use sunset::packets::PubKey;
35use sunset::{ChanFail, ChanHandle, ServEvent};
36use sunset_async::{ChanInOut, SSHServer, SunsetMutex};
37
38pub mod env_parser {
39    use super::String;
40    use core::str::FromStr;
41
42    /// Limit the maximum length accepted for an SSH key, Ed25519 lines
43    /// should be less than this.
44    const PUBKEY_MAX_LEN: usize = 256;
45
46    /// Sanitizes environment variable input by checking for valid ASCII graphic characters.
47    ///
48    /// Returns `true` if the input contains at least one character and all characters
49    /// are ASCII graphic characters (printable characters excluding space).
50    #[must_use]
51    pub fn env_sanitize(s: &str) -> bool {
52        !s.is_empty() && s.bytes().all(|b| b.is_ascii_graphic())
53    }
54
55    /// Validates a public key environment value.
56    ///
57    /// This accepts printable ASCII, including spaces, as the format
58    /// for a key expects `<type> <base64> [comment]`. This would be
59    /// rejected by `env_sanitize` which is stricter, so it is separated
60    /// out here for pubkey environment variables only.
61    #[must_use]
62    pub fn parse_pubkey(value: &str) -> Option<&str> {
63        let trimmed = value.trim();
64
65        if trimmed.is_empty() || trimmed.len() > PUBKEY_MAX_LEN {
66            return None;
67        }
68        if !trimmed.bytes().all(|b| b.is_ascii_graphic() || b == b' ') {
69            return None;
70        }
71
72        Some(trimmed)
73    }
74
75    /// Parses and validates a `WiFi` SSID from an environment variable value.
76    ///
77    /// Returns `None` if the value contains non-ASCII-graphic characters.
78    #[must_use]
79    pub fn parse_wifi_ap_ssid(value: &str) -> Option<String<32>> {
80        if !env_sanitize(value) {
81            return None;
82        }
83        let mut s = String::new();
84        s.push_str(value).ok()?;
85        Some(s)
86    }
87
88    /// Parses and validates a `WiFi` SSID from an environment variable value.
89    ///
90    /// Returns `None` if the value contains non-ASCII-graphic characters.
91    #[must_use]
92    pub fn parse_wifi_station_ssid(value: &str) -> Option<String<32>> {
93        if !value.is_empty() && !env_sanitize(value) {
94            return None;
95        }
96        let mut s = String::new();
97        s.push_str(value).ok()?;
98        Some(s)
99    }
100
101    /// Parses and validates a `WiFi` PSK from an environment variable value.
102    ///
103    /// Returns `None` if the value is not between 8 and 63 characters
104    /// or contains non-ASCII-graphic characters.
105    #[must_use]
106    pub fn parse_wifi_psk(value: &str) -> Option<String<63>> {
107        if value.len() < 8 || value.len() > 63 {
108            return None;
109        }
110        if !env_sanitize(value) {
111            return None;
112        }
113        let mut s = String::new();
114        s.push_str(value).ok()?;
115        Some(s)
116    }
117
118    /// Parses a MAC address from an environment variable value in `XX:XX:XX:XX:XX:XX` format.
119    ///
120    /// Returns `None` if the value is not exactly 17 characters, contains
121    /// non-hex-colon characters, or does not produce exactly 6 octets.
122    #[must_use]
123    pub fn parse_mac_address(value: &str) -> Option<[u8; 6]> {
124        if !env_sanitize(value) {
125            return None;
126        }
127        if value.len() != 17 {
128            return None;
129        }
130        let parts: heapless::Vec<u8, 6> = value
131            .split(':')
132            .filter_map(|p| u8::from_str_radix(p, 16).ok())
133            .collect();
134        if parts.len() != 6 {
135            return None;
136        }
137        Some([parts[0], parts[1], parts[2], parts[3], parts[4], parts[5]])
138    }
139
140    /// Parses a `WiFi` band mode from an environment variable value.
141    ///
142    /// Accepts: `"2.4g"`, `"5g"`, `"auto"` (case-insensitive).
143    /// Returns the band as a `u8`: 0 = 2.4GHz, 1 = 5GHz, 2 = Auto.
144    #[must_use]
145    pub fn parse_wifi_band(value: &str) -> Option<u8> {
146        ssh_stamp_hal::BandMode::from_str(value)
147            .ok()
148            .map(|band| band as u8)
149    }
150
151    // Slowest and fastest baud rates accepted for the bridge. The ceiling is
152    // what the UART peripherals themselves top out at.
153    const UART_BAUD_MIN: u32 = 300;
154    const UART_BAUD_MAX: u32 = 5_000_000;
155
156    /// Parses a UART baud rate, accepting 300 to 5000000 baud.
157    #[must_use]
158    pub fn parse_uart_baud(value: &str) -> Option<u32> {
159        let baud: u32 = value.trim().parse().ok()?;
160        (UART_BAUD_MIN..=UART_BAUD_MAX)
161            .contains(&baud)
162            .then_some(baud)
163    }
164
165    /// Parses the UART data bits per frame, accepting 5 to 8.
166    #[must_use]
167    pub fn parse_uart_data_bits(value: &str) -> Option<u8> {
168        let bits: u8 = value.trim().parse().ok()?;
169        (5..=8).contains(&bits).then_some(bits)
170    }
171
172    /// Parses the UART parity setting, accepting `none`, `even` or `odd`.
173    #[must_use]
174    pub fn parse_uart_parity(value: &str) -> Option<ssh_stamp_hal::Parity> {
175        ssh_stamp_hal::Parity::from_str(value.trim()).ok()
176    }
177
178    /// Parses the UART stop bits per frame, accepting 1 or 2.
179    #[must_use]
180    pub fn parse_uart_stop_bits(value: &str) -> Option<u8> {
181        let bits: u8 = value.trim().parse().ok()?;
182        matches!(bits, 1 | 2).then_some(bits)
183    }
184}
185
186#[derive(Debug)]
187pub enum SessionType {
188    Bridge(ChanHandle),
189    #[cfg(feature = "sftp-ota")]
190    Sftp(ChanHandle),
191}
192
193pub struct EventContext<'a> {
194    pub session: &'a mut Option<ChanHandle>,
195    pub auth_checked: &'a mut bool,
196    pub config_changed: &'a mut bool,
197    pub needs_reset: &'a mut bool,
198    /// Hands accepted `can` subsystem channels to the CAN bridge, which
199    /// runs concurrently with the shell (UART) session.
200    #[cfg(feature = "can")]
201    pub can_queue: &'a Channel<NoopRawMutex, ChanHandle, 1>,
202    /// Set once a CAN session is dispatched on this connection. SFTP (OTA)
203    /// needs the connection's full bandwidth, so it is refused afterwards.
204    #[cfg(all(feature = "sftp-ota", feature = "can"))]
205    pub can_dispatched: &'a mut bool,
206}
207
208/// Handles SSH session subsystem requests (e.g., SFTP, CAN).
209///
210/// # Errors
211///
212/// Returns an error if SSH protocol operations fail.
213pub fn session_subsystem(
214    ev: ServEvent<'_, '_>,
215    ctx: &mut EventContext<'_>,
216    #[cfg(feature = "sftp-ota")] chan_pipe: &Channel<NoopRawMutex, SessionType, 1>,
217) -> Result<(), sunset::Error> {
218    if let ServEvent::SessionSubsystem(a) = ev {
219        debug!("ServEvent::SessionSubsystem");
220
221        if !*ctx.auth_checked {
222            warn!("Unauthenticated SessionSubsystem rejected");
223            a.fail()?;
224        } else if a.command()?.to_lowercase().as_str() == "sftp" {
225            #[cfg(feature = "sftp-ota")]
226            {
227                // SFTP (OTA) is exclusive: it needs the connection's full
228                // bandwidth, so refuse it once a CAN session is active.
229                #[cfg(feature = "can")]
230                let can_active = *ctx.can_dispatched;
231                #[cfg(not(feature = "can"))]
232                let can_active = false;
233                if can_active {
234                    warn!("SFTP subsystem refused: a CAN session is active on this connection");
235                    a.fail()?;
236                } else if let Some(ch) = ctx.session.take() {
237                    debug_assert_eq!(ch.num(), a.channel());
238                    a.succeed()?;
239                    debug!("We got SFTP subsystem");
240                    match chan_pipe.try_send(SessionType::Sftp(ch)) {
241                        Ok(()) => *ctx.auth_checked = false,
242                        Err(e) => log::error!("Could not send the channel: {e:?}"),
243                    }
244                } else {
245                    a.fail()?;
246                }
247            }
248            #[cfg(not(feature = "sftp-ota"))]
249            {
250                warn!("SFTP subsystem requested but not supported in this build");
251                a.fail()?;
252            }
253        } else if a.command()?.to_lowercase().as_str() == "can" {
254            #[cfg(feature = "can")]
255            if let Some(ch) = ctx.session.take() {
256                debug_assert_eq!(ch.num(), a.channel());
257                a.succeed()?;
258                debug!("We got CAN subsystem");
259                // auth_checked is deliberately left untouched so the same
260                // (already authenticated) connection can still request a
261                // shell session and bridge UART concurrently with CAN.
262                if let Err(e) = ctx.can_queue.try_send(ch) {
263                    log::error!("Could not send the CAN channel: {e:?}");
264                }
265                #[cfg(feature = "sftp-ota")]
266                {
267                    *ctx.can_dispatched = true;
268                }
269            } else {
270                a.fail()?;
271            }
272            #[cfg(not(feature = "can"))]
273            {
274                warn!("CAN subsystem requested but not supported in this build");
275                a.fail()?;
276            }
277        } else {
278            a.fail()?;
279        }
280    }
281    Ok(())
282}
283
284/// Handles SSH session shell requests.
285///
286/// # Errors
287///
288/// Returns an error if SSH protocol operations fail.
289pub async fn session_shell<P: PlatformServices>(
290    ev: ServEvent<'_, '_>,
291    ctx: &mut EventContext<'_>,
292    config: &SunsetMutex<SSHStampConfig>,
293    chan_pipe: &Channel<NoopRawMutex, SessionType, 1>,
294    platform: &P,
295) -> Result<(), sunset::Error> {
296    if let ServEvent::SessionShell(a) = ev {
297        debug!("ServEvent::SessionShell");
298
299        if !*ctx.auth_checked {
300            warn!("Unauthenticated SessionShell rejected");
301            a.fail()?;
302        } else if let Some(ch) = ctx.session.take() {
303            if *ctx.config_changed {
304                *ctx.config_changed = false;
305                let config_guard = config.lock().await;
306                platform
307                    .save_config(&config_guard)
308                    .await
309                    .map_err(|_| sunset::error::BadUsage.build())?;
310                drop(config_guard);
311                if *ctx.needs_reset {
312                    info!("Configuration saved. Rebooting to apply the changes...");
313                    platform.reset();
314                }
315            }
316            debug_assert_eq!(ch.num(), a.channel());
317            a.succeed()?;
318            debug!("We got shell");
319            platform.activate_uart();
320            debug!("Connection loop: UART activated");
321            match chan_pipe.try_send(SessionType::Bridge(ch)) {
322                Ok(()) => *ctx.auth_checked = false,
323                Err(e) => log::error!("Could not send the channel: {e:?}"),
324            }
325        } else {
326            a.fail()?;
327        }
328    }
329    Ok(())
330}
331
332/// Handles the first authentication request.
333///
334/// # Errors
335///
336/// Returns an error if SSH protocol operations fail.
337pub async fn first_auth(
338    ev: ServEvent<'_, '_>,
339    config: &SunsetMutex<SSHStampConfig>,
340) -> Result<(), sunset::Error> {
341    if let ServEvent::FirstAuth(mut a) = ev {
342        debug!("ServEvent::FirstAuth");
343        let config_guard = config.lock().await;
344
345        a.enable_password_auth(false)?;
346
347        a.enable_pubkey_auth(true)?;
348        if config_guard.first_login {
349            a.allow()?;
350        } else {
351            debug!("FirstAuth received but not first-login, rejecting");
352            a.reject()?;
353        }
354    }
355    Ok(())
356}
357
358/// Provides host keys to the SSH client.
359///
360/// # Errors
361///
362/// Returns an error if SSH protocol operations fail.
363pub async fn hostkeys(
364    ev: ServEvent<'_, '_>,
365    config: &SunsetMutex<SSHStampConfig>,
366) -> Result<(), sunset::Error> {
367    if let ServEvent::Hostkeys(h) = ev {
368        debug!("ServEvent::Hostkeys");
369        let config_guard = config.lock().await;
370        h.hostkeys(&[&config_guard.hostkey])?;
371    }
372    Ok(())
373}
374
375/// Rejects password authentication requests.
376///
377/// # Errors
378///
379/// Returns an error if SSH protocol operations fail.
380pub fn password_auth(ev: ServEvent<'_, '_>) -> Result<(), sunset::Error> {
381    if let ServEvent::PasswordAuth(a) = ev {
382        warn!("Password auth is not supported, use public key auth instead.");
383        a.reject()?;
384    }
385    Ok(())
386}
387
388/// Handles SSH public key authentication.
389///
390/// # Errors
391///
392/// Returns an error if SSH protocol operations fail.
393pub async fn pubkey_auth(
394    ev: ServEvent<'_, '_>,
395    ctx: &mut EventContext<'_>,
396    config: &SunsetMutex<SSHStampConfig>,
397) -> Result<(), sunset::Error> {
398    if let ServEvent::PubkeyAuth(a) = ev {
399        debug!("ServEvent::PubkeyAuth");
400        let config_guard = config.lock().await;
401        let client_pubkey = a.pubkey()?;
402
403        let matched = match &client_pubkey {
404            PubKey::Ed25519(presented) => config_guard
405                .pubkeys
406                .iter()
407                .any(|slot| slot.as_ref().is_some_and(|stored| stored == presented)),
408            PubKey::Unknown(_) => false,
409        };
410
411        match client_pubkey.fingerprint() {
412            Ok(fingerprint) if matched => info!("Accepted pubkey {fingerprint}"),
413            Ok(fingerprint) => warn!("Rejected pubkey {fingerprint}: not enrolled in any slot"),
414            Err(err) => warn!("Rejected pubkey: {err:?}"),
415        }
416
417        if matched {
418            *ctx.auth_checked = true;
419            a.allow()?;
420        } else {
421            a.reject()?;
422        }
423    }
424    Ok(())
425}
426
427/// Handles SSH session open requests, rejecting duplicates.
428///
429/// # Errors
430///
431/// Returns an error if SSH protocol operations fail.
432pub fn open_session(
433    ev: ServEvent<'_, '_>,
434    ctx: &mut EventContext<'_>,
435) -> Result<(), sunset::Error> {
436    if let ServEvent::OpenSession(a) = ev {
437        debug!("ServEvent::OpenSession");
438        match ctx.session {
439            Some(_) => {
440                warn!("Rejecting duplicate session channel");
441                a.reject(ChanFail::SSH_OPEN_ADMINISTRATIVELY_PROHIBITED)?;
442            }
443            None => {
444                *ctx.session = Some(a.accept()?);
445            }
446        }
447    }
448    Ok(())
449}
450
451/// Handles SSH environment variable requests.
452///
453/// # Errors
454///
455/// Returns an error if SSH protocol operations fail.
456pub async fn session_env(
457    ev: ServEvent<'_, '_>,
458    ctx: &mut EventContext<'_>,
459    config: &SunsetMutex<SSHStampConfig>,
460) -> Result<(), sunset::Error> {
461    if let ServEvent::SessionEnv(a) = ev {
462        debug!("Got ENV request");
463        debug!("ENV name: {}", a.name()?);
464        // Don't log the value: SSH_STAMP_WIFI_AP_PSK / SSH_STAMP_WIFI_STA_PW
465        // and friends carry secrets straight to the serial/RTT log sink.
466
467        match a.name()? {
468            "LANG" => {
469                a.succeed()?;
470            }
471            "SSH_STAMP_PUBKEY" => {
472                pubkey_env(a, config, ctx).await?;
473            }
474            "SSH_STAMP_WIFI_AP_SSID" => {
475                wifi_ap_ssid_env(a, config, ctx).await?;
476            }
477            "SSH_STAMP_WIFI_AP_PSK" => {
478                wifi_ap_psk_env(a, config, ctx).await?;
479            }
480            "SSH_STAMP_WIFI_BAND" => {
481                wifi_band_env(a, config, ctx).await?;
482            }
483            "SSH_STAMP_WIFI_STA_SSID" => {
484                wifi_sta_ssid_env(a, config, ctx).await?;
485            }
486            "SSH_STAMP_WIFI_STA_PW" => {
487                wifi_sta_psk_env(a, config, ctx).await?;
488            }
489            "SSH_STAMP_WIFI_MAC_ADDRESS" => {
490                wifi_mac_address_env(a, config, ctx).await?;
491            }
492            "SSH_STAMP_WIFI_MAC_RANDOM" => {
493                wifi_mac_random_env(a, config, ctx).await?;
494            }
495            "SSH_STAMP_UART_BAUD" => {
496                uart_env(UartParam::Baud, a, config, ctx).await?;
497            }
498            "SSH_STAMP_UART_DATA_BITS" => {
499                uart_env(UartParam::DataBits, a, config, ctx).await?;
500            }
501            "SSH_STAMP_UART_PARITY" => {
502                uart_env(UartParam::Parity, a, config, ctx).await?;
503            }
504            "SSH_STAMP_UART_STOP_BITS" => {
505                uart_env(UartParam::StopBits, a, config, ctx).await?;
506            }
507            _ => {
508                debug!("Ignoring unknown environment variable: {}", a.name()?);
509                a.succeed()?;
510            }
511        }
512    }
513    Ok(())
514}
515
516/// Handles `SSH_STAMP_PUBKEY` environment variable requests.
517///
518/// # Errors
519///
520/// Returns an error if SSH protocol operations fail or if the pubkey cannot be added.
521pub async fn pubkey_env(
522    a: sunset::event::ServEnvironmentRequest<'_, '_>,
523    config: &SunsetMutex<SSHStampConfig>,
524    ctx: &mut EventContext<'_>,
525) -> Result<(), sunset::Error> {
526    let mut config_guard = config.lock().await;
527
528    if config_guard.first_login {
529        match env_parser::parse_pubkey(a.value()?) {
530            None => {
531                warn!("SSH_STAMP_PUBKEY contains invalid characters");
532                a.fail()?;
533            }
534            Some(trimmed) => {
535                if config_guard.add_pubkey(trimmed).is_ok() {
536                    debug!("Added new pubkey from ENV");
537                    a.succeed()?;
538                    if config_guard.first_login {
539                        config_guard.first_login = false;
540                        *ctx.config_changed = true;
541                        *ctx.auth_checked = true;
542                    }
543                } else {
544                    warn!("Failed to add new pubkey from ENV");
545                    a.fail()?;
546                }
547            }
548        }
549    } else {
550        warn!("SSH_STAMP_PUBKEY env received but not first-login; rejecting");
551        a.fail()?;
552    }
553
554    Ok(())
555}
556
557/// Handles `SSH_STAMP_WIFI_AP_SSID` environment variable requests.
558///
559/// # Errors
560/// Returns an error if SSH protocol operations fail or if the SSID is invalid.
561pub async fn wifi_ap_ssid_env(
562    a: sunset::event::ServEnvironmentRequest<'_, '_>,
563    config: &SunsetMutex<SSHStampConfig>,
564    ctx: &mut EventContext<'_>,
565) -> Result<(), sunset::Error> {
566    let mut config_guard = config.lock().await;
567    if *ctx.auth_checked || config_guard.first_login {
568        if let Some(s) = env_parser::parse_wifi_ap_ssid(a.value()?) {
569            config_guard.wifi_ap_ssid = s;
570            debug!("Set wifi Access Point SSID from ENV");
571            a.succeed()?;
572            *ctx.config_changed = true;
573            *ctx.needs_reset = true;
574        } else {
575            warn!("SSH_STAMP_WIFI_AP_SSID invalid and/or too long");
576            a.fail()?;
577        }
578    } else {
579        warn!("SSH_STAMP_WIFI_AP_SSID env received but not authenticated; rejecting");
580        a.fail()?;
581    }
582    Ok(())
583}
584
585/// Handles `SSH_STAMP_WIFI_AP_PSK` environment variable requests.
586///
587/// # Errors
588/// Returns an error if SSH protocol operations fail or if the PSK is invalid.
589pub async fn wifi_ap_psk_env(
590    a: sunset::event::ServEnvironmentRequest<'_, '_>,
591    config: &SunsetMutex<SSHStampConfig>,
592    ctx: &mut EventContext<'_>,
593) -> Result<(), sunset::Error> {
594    let mut config_guard = config.lock().await;
595    if *ctx.auth_checked || config_guard.first_login {
596        if let Some(s) = env_parser::parse_wifi_psk(a.value()?) {
597            config_guard.wifi_ap_pw = s;
598            debug!("Set WIFI AP PSK from ENV");
599            a.succeed()?;
600            *ctx.config_changed = true;
601            *ctx.needs_reset = true;
602        } else {
603            warn!("SSH_STAMP_WIFI_AP_PSK invalid and/or not within 8-63 characters");
604            a.fail()?;
605        }
606    } else {
607        warn!("SSH_STAMP_WIFI_AP_PSK env received but not authenticated; rejecting");
608        a.fail()?;
609    }
610    Ok(())
611}
612
613/// Handles `SSH_STAMP_WIFI_BAND` environment variable requests.
614///
615/// Accepts `2.4g`, `5g`, or `auto` (case-insensitive). Only the ESP32-C5
616/// supports 5GHz; other chips will ignore the setting at runtime.
617/// Triggers a config save + reset on success.
618///
619/// # Errors
620/// Returns an error if SSH protocol operations fail.
621pub async fn wifi_band_env(
622    a: sunset::event::ServEnvironmentRequest<'_, '_>,
623    config: &SunsetMutex<SSHStampConfig>,
624    ctx: &mut EventContext<'_>,
625) -> Result<(), sunset::Error> {
626    let mut config_guard = config.lock().await;
627    if *ctx.auth_checked || config_guard.first_login {
628        if let Some(band) = env_parser::parse_wifi_band(a.value()?) {
629            config_guard.wifi_ap_band = band;
630            debug!("Set WIFI AP band from ENV: {band}");
631            a.succeed()?;
632            *ctx.config_changed = true;
633            *ctx.needs_reset = true;
634        } else {
635            warn!("SSH_STAMP_WIFI_BAND must be 2.4g, 5g, or auto");
636            a.fail()?;
637        }
638    } else {
639        warn!("SSH_STAMP_WIFI_BAND env received but not authenticated; rejecting");
640        a.fail()?;
641    }
642    Ok(())
643}
644
645/// Handles `SSH_STAMP_WIFI_STA_SSID` environment variable requests.
646///
647/// # Errors
648/// Returns an error if SSH protocol operations fail or if the SSID is invalid.
649pub async fn wifi_sta_ssid_env(
650    a: sunset::event::ServEnvironmentRequest<'_, '_>,
651    config: &SunsetMutex<SSHStampConfig>,
652    ctx: &mut EventContext<'_>,
653) -> Result<(), sunset::Error> {
654    let mut config_guard = config.lock().await;
655    if *ctx.auth_checked || config_guard.first_login {
656        if let Some(s) = env_parser::parse_wifi_station_ssid(a.value()?) {
657            config_guard.wifi_sta_ssid = s;
658            debug!("Set wifi STATION SSID from ENV");
659            a.succeed()?;
660            *ctx.config_changed = true;
661            *ctx.needs_reset = true;
662        } else {
663            warn!("SSH_STAMP_WIFI_STA_SSID invalid and/or too long");
664            a.fail()?;
665        }
666    } else {
667        warn!("SSH_STAMP_WIFI_STA_SSID env received but not authenticated; rejecting");
668        a.fail()?;
669    }
670    Ok(())
671}
672
673/// Handles `SSH_STAMP_WIFI_STA_PSK` environment variable requests.
674///
675/// # Errors
676/// Returns an error if SSH protocol operations fail or if the SSID is invalid.
677pub async fn wifi_sta_psk_env(
678    a: sunset::event::ServEnvironmentRequest<'_, '_>,
679    config: &SunsetMutex<SSHStampConfig>,
680    ctx: &mut EventContext<'_>,
681) -> Result<(), sunset::Error> {
682    let mut config_guard = config.lock().await;
683    if *ctx.auth_checked || config_guard.first_login {
684        if let Some(s) = env_parser::parse_wifi_psk(a.value()?) {
685            config_guard.wifi_sta_pw = s;
686            debug!("Set wifi STATION PSK from ENV");
687            a.succeed()?;
688            *ctx.config_changed = true;
689            *ctx.needs_reset = true;
690        } else {
691            warn!("SSH_STAMP_WIFI_STA_PSK invalid and/or not within 8-63 characters");
692            a.fail()?;
693        }
694    } else {
695        warn!("SSH_STAMP_WIFI_STA_PSK env received but not authenticated; rejecting");
696        a.fail()?;
697    }
698    Ok(())
699}
700
701/// Handles `SSH_STAMP_WIFI_MAC_ADDRESS` environment variable requests.
702///
703/// # Errors
704/// Returns an error if SSH protocol operations fail or if the MAC address is invalid.
705pub async fn wifi_mac_address_env(
706    a: sunset::event::ServEnvironmentRequest<'_, '_>,
707    config: &SunsetMutex<SSHStampConfig>,
708    ctx: &mut EventContext<'_>,
709) -> Result<(), sunset::Error> {
710    let mut config_guard = config.lock().await;
711    if *ctx.auth_checked || config_guard.first_login {
712        if let Some(mac) = env_parser::parse_mac_address(a.value()?) {
713            config_guard.mac = mac;
714            debug!("Set MAC address from ENV: {mac:02X?}");
715            a.succeed()?;
716            *ctx.config_changed = true;
717            *ctx.needs_reset = true;
718        } else {
719            warn!("SSH_STAMP_WIFI_MAC_ADDRESS must be XX:XX:XX:XX:XX:XX format");
720            a.fail()?;
721        }
722    } else {
723        warn!("SSH_STAMP_WIFI_MAC_ADDRESS env received but not authenticated; rejecting");
724        a.fail()?;
725    }
726    Ok(())
727}
728
729/// Handles `SSH_STAMP_WIFI_MAC_RANDOM` environment variable requests.
730///
731/// # Errors
732/// Returns an error if SSH protocol operations fail or if authentication is missing.
733pub async fn wifi_mac_random_env(
734    a: sunset::event::ServEnvironmentRequest<'_, '_>,
735    config: &SunsetMutex<SSHStampConfig>,
736    ctx: &mut EventContext<'_>,
737) -> Result<(), sunset::Error> {
738    let mut config_guard = config.lock().await;
739    if *ctx.auth_checked || config_guard.first_login {
740        config_guard.mac = [0xFF; 6];
741        debug!("Set MAC address to random mode");
742        a.succeed()?;
743        *ctx.config_changed = true;
744        *ctx.needs_reset = true;
745    } else {
746        warn!("SSH_STAMP_WIFI_MAC_RANDOM env received but not authenticated; rejecting");
747        a.fail()?;
748    }
749    Ok(())
750}
751
752/// A UART line parameter, one per `SSH_STAMP_UART_*` environment variable.
753#[derive(Clone, Copy, Debug)]
754pub enum UartParam {
755    Baud,
756    DataBits,
757    Parity,
758    StopBits,
759}
760
761impl UartParam {
762    /// The environment variable this parameter is set from.
763    const fn env_name(self) -> &'static str {
764        match self {
765            Self::Baud => "SSH_STAMP_UART_BAUD",
766            Self::DataBits => "SSH_STAMP_UART_DATA_BITS",
767            Self::Parity => "SSH_STAMP_UART_PARITY",
768            Self::StopBits => "SSH_STAMP_UART_STOP_BITS",
769        }
770    }
771
772    /// The values this parameter accepts, for the rejection log line.
773    const fn accepted(self) -> &'static str {
774        match self {
775            Self::Baud => "300-5000000",
776            Self::DataBits => "5-8",
777            Self::Parity => "none, even or odd",
778            Self::StopBits => "1 or 2",
779        }
780    }
781}
782
783/// Handles the `SSH_STAMP_UART_*` environment variable requests.
784///
785/// The bridge configures its UART once at boot, so a change is persisted and
786/// applied after the reset triggered on the next shell request, like the
787/// `WiFi` settings.
788///
789/// # Errors
790/// Returns an error if SSH protocol operations fail.
791pub async fn uart_env(
792    param: UartParam,
793    a: sunset::event::ServEnvironmentRequest<'_, '_>,
794    config: &SunsetMutex<SSHStampConfig>,
795    ctx: &mut EventContext<'_>,
796) -> Result<(), sunset::Error> {
797    let mut config_guard = config.lock().await;
798    if !(*ctx.auth_checked || config_guard.first_login) {
799        warn!(
800            "{} env received but not authenticated; rejecting",
801            param.env_name()
802        );
803        return a.fail();
804    }
805
806    let value = a.value()?;
807    let uart = &mut config_guard.uart_params;
808    let applied = match param {
809        UartParam::Baud => env_parser::parse_uart_baud(value).map(|v| uart.baud = v),
810        UartParam::DataBits => env_parser::parse_uart_data_bits(value).map(|v| uart.data_bits = v),
811        UartParam::Parity => env_parser::parse_uart_parity(value).map(|v| uart.parity = v),
812        UartParam::StopBits => env_parser::parse_uart_stop_bits(value).map(|v| uart.stop_bits = v),
813    };
814
815    if applied.is_some() {
816        debug!("Set UART {param:?} from ENV: {uart:?}");
817        a.succeed()?;
818        *ctx.config_changed = true;
819        *ctx.needs_reset = true;
820    } else {
821        warn!("{} must be {}", param.env_name(), param.accepted());
822        a.fail()?;
823    }
824    Ok(())
825}
826
827/// Handles SSH PTY requests.
828///
829/// # Errors
830///
831/// Returns an error if SSH protocol operations fail.
832pub async fn session_pty(
833    ev: ServEvent<'_, '_>,
834    ctx: &mut EventContext<'_>,
835    config: &SunsetMutex<SSHStampConfig>,
836) -> Result<(), sunset::Error> {
837    if let ServEvent::SessionPty(a) = ev {
838        let first_login = { config.lock().await.first_login };
839
840        if *ctx.auth_checked || first_login {
841            debug!("ServEvent::SessionPty: Session granted");
842            a.succeed()?;
843        } else {
844            debug!("ServEvent::SessionPty: No auth not session");
845            a.fail()?;
846        }
847    }
848    Ok(())
849}
850
851/// Rejects SSH exec requests.
852///
853/// # Errors
854///
855/// Returns an error if SSH protocol operations fail.
856pub fn session_exec(ev: ServEvent<'_, '_>) -> Result<(), sunset::Error> {
857    if let ServEvent::SessionExec(a) = ev {
858        a.fail()?;
859    }
860    Ok(())
861}
862
863/// Returns a `BadUsage` error for unhandled events.
864///
865/// # Errors
866///
867/// Always returns `BadUsage` error.
868pub fn defunct() -> Result<(), sunset::Error> {
869    debug!("Expected caller to handle event");
870    sunset::error::BadUsage.fail()
871}
872
873/// Handles an SSH client connection, bridging UART and SSH.
874///
875#[cfg_attr(
876    feature = "can",
877    doc = "A `can` subsystem channel is bridged concurrently with the shell",
878    doc = "(UART) session on the same connection. The whole connection is",
879    doc = "torn down when either bridge finishes.",
880    doc = ""
881)]
882/// # Errors
883/// Returns an error if SSH protocol operations or I/O fail.
884pub async fn ssh_client<'a, 'b, U, P>(
885    uart_buff: &'a U,
886    ssh_server: &'b SSHServer<'a>,
887    chan_pipe: &'b Channel<NoopRawMutex, SessionType, 1>,
888    #[cfg_attr(
889        not(any(feature = "sftp-ota", feature = "can")),
890        allow(unused_variables)
891    )]
892    platform: &'b P,
893    #[cfg(feature = "can")] can_queue: &'b Channel<NoopRawMutex, ChanHandle, 1>,
894) -> Result<(), sunset::Error>
895where
896    U: BufferedSerial,
897    P: PlatformServices,
898{
899    debug!("Preparing bridge");
900    let session = async {
901        let session_type = chan_pipe.receive().await;
902        debug!("Checking bridge session type");
903        match session_type {
904            SessionType::Bridge(ch) => {
905                info!("Handling bridge session");
906                let chan_io: ChanInOut<'_> = ssh_server.stdio(ch).await?;
907                let (stdin, stdout) = chan_io.split();
908                info!("Starting bridge");
909                serial_bridge(stdin, stdout, uart_buff).await?;
910            }
911            #[cfg(feature = "sftp-ota")]
912            SessionType::Sftp(ch) => {
913                debug!("Handling SFTP session");
914                let stdio = ssh_server.stdio(ch).await?;
915                let ota_writer = platform.ota_writer();
916                ssh_stamp_ota::run_ota_server::<P::OtaWriter>(stdio, ota_writer).await?;
917            }
918        }
919        Ok(())
920    };
921
922    #[cfg(feature = "can")]
923    let result = {
924        let can_session = async {
925            let ch = can_queue.receive().await;
926            info!("Handling CAN session");
927            let chan_io: ChanInOut<'_> = ssh_server.stdio(ch).await?;
928            let (stdin, stdout) = chan_io.split();
929            info!("Starting CAN bridge");
930            can_bridge(stdin, stdout, platform.can()).await
931        };
932        match select(session, can_session).await {
933            Either::First(r) | Either::Second(r) => r,
934        }
935    };
936    #[cfg(not(feature = "can"))]
937    let result = session.await;
938    result
939}
940
941pub fn bridge_disable() {
942    debug!("Bridge disabled: WIP");
943}