Skip to main content

starla_controller/
ssh.rs

1//! SSH connection management for RIPE Atlas controller communication
2//!
3//! This module provides:
4//! - SSH tunnel management with automatic reconnection
5//! - Registration protocol (INIT command)
6//! - Keepalive handling (KEEP command)
7//! - Reverse port forwarding for telnet interface
8
9use russh::client::{self, Handle, Msg};
10use russh::keys::ssh_key::{self, Algorithm};
11use russh::keys::{PrivateKey, PrivateKeyWithHashAlg, PublicKey, PublicKeyBase64};
12use russh::{kex, Channel, ChannelMsg, Preferred};
13use std::borrow::Cow;
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17use std::time::Duration;
18use tokio::sync::Mutex;
19use tokio::time::{sleep, timeout};
20use tracing::{debug, error, info, trace, warn};
21
22/// SSH connection configuration
23#[derive(Debug, Clone)]
24pub struct SshConfig {
25    /// Connection timeout
26    pub connect_timeout: Duration,
27    /// Inactivity timeout before disconnect
28    pub inactivity_timeout: Duration,
29    /// Keepalive interval
30    pub keepalive_interval: Duration,
31    /// Reconnection delay (base)
32    pub reconnect_delay: Duration,
33    /// Maximum reconnection delay
34    pub max_reconnect_delay: Duration,
35    /// Number of reconnection attempts before giving up (0 = infinite)
36    pub max_reconnect_attempts: u32,
37}
38
39impl Default for SshConfig {
40    fn default() -> Self {
41        Self {
42            connect_timeout: Duration::from_secs(30),
43            inactivity_timeout: Duration::from_secs(120),
44            keepalive_interval: Duration::from_secs(30),
45            reconnect_delay: Duration::from_secs(5),
46            max_reconnect_delay: Duration::from_secs(300),
47            max_reconnect_attempts: 0, // Infinite
48        }
49    }
50}
51
52/// Controller information returned from INIT
53#[derive(Debug, Clone)]
54pub struct ControllerInfo {
55    pub host: String,
56    pub port: u16,
57    pub probe_id: u32,
58}
59
60/// INIT response variants
61#[derive(Debug, Clone)]
62pub enum InitResponse {
63    /// Registration server responded with controller assignment
64    Controller(ControllerInfo),
65    /// Controller responded with remote port and session ID (ready for KEEP)
66    ControllerReady {
67        remote_port: u16,
68        session_id: String,
69    },
70    /// Probe key is recognized but not yet fully registered (from reg server)
71    Ok,
72    /// Server tells us to wait and retry after timeout seconds
73    Wait { timeout_secs: u32 },
74}
75
76/// Probe information sent during registration INIT
77#[derive(Debug, Clone)]
78pub struct ProbeInitInfo {
79    /// Firmware version (e.g., 5120)
80    pub firmware_version: u32,
81    /// Reason for registration (e.g., "NEW", "REREG_TIMER_EXPIRED")
82    pub reason: String,
83}
84
85impl ProbeInitInfo {
86    /// Create probe init info for a new registration
87    pub fn new(firmware_version: u32) -> Self {
88        Self {
89            firmware_version,
90            reason: "NEW".to_string(),
91        }
92    }
93
94    /// Create probe init info for re-registration
95    pub fn reregister(firmware_version: u32, reason: &str) -> Self {
96        Self {
97            firmware_version,
98            reason: reason.to_string(),
99        }
100    }
101
102    /// Format as P_TO_R_INIT message for INIT command stdin
103    ///
104    /// Uses the software probe format matching the official generic
105    /// software probe: `TOKEN_SPECS fluffy 1000 <fw> <sub_arch>`
106    pub fn to_init_message(&self) -> String {
107        let mut msg = String::new();
108        msg.push_str("P_TO_R_INIT\n");
109
110        let sub_arch = detect_sub_arch();
111
112        msg.push_str(&format!(
113            "TOKEN_SPECS fluffy 1000 {} {}\n",
114            self.firmware_version, sub_arch
115        ));
116
117        msg.push_str(&format!("REASON_FOR_REGISTRATION {}\n", self.reason));
118        msg
119    }
120}
121
122/// Detect the probe sub-architecture string sent during registration.
123///
124/// Format: `<os_id>/<os_version>/<arch>/starla/<starla_version>`,
125/// e.g. `debian/13/x86_64/starla/0.3.0`. Mirrors the original C probe's
126/// `get_sub_arch` (sourced `/etc/os-release`, `uname -m`) with
127/// platform fallbacks so the same binary works on Linux, macOS, and Windows.
128fn detect_sub_arch() -> String {
129    let (id, version_id) = detect_os_id_version();
130    let arch = std::env::consts::ARCH;
131    let starla_version = env!("CARGO_PKG_VERSION");
132    format!("{}/{}/{}/starla/{}", id, version_id, arch, starla_version)
133}
134
135fn detect_os_id_version() -> (String, String) {
136    #[cfg(target_os = "linux")]
137    {
138        let (id, version_id) = read_os_release();
139        return (
140            id.unwrap_or_else(|| "generic".to_string()),
141            version_id.unwrap_or_else(|| "unknown".to_string()),
142        );
143    }
144
145    #[cfg(target_os = "macos")]
146    {
147        let version = std::process::Command::new("sw_vers")
148            .arg("-productVersion")
149            .output()
150            .ok()
151            .and_then(|o| String::from_utf8(o.stdout).ok())
152            .map(|s| s.trim().to_string())
153            .filter(|s| !s.is_empty())
154            .unwrap_or_else(|| "unknown".to_string());
155        return ("macos".to_string(), version);
156    }
157
158    #[cfg(target_os = "windows")]
159    return ("windows".to_string(), "unknown".to_string());
160
161    #[allow(unreachable_code)]
162    ("generic".to_string(), "unknown".to_string())
163}
164
165/// Parse `/etc/os-release`, returning `ID` and `VERSION_ID` independently.
166/// Each is `None` if the file is unreadable or the line is absent, letting
167/// the caller apply per-field defaults (matching the original Bash, which
168/// pre-set ID=generic / VERSION_ID=unknown before sourcing the file).
169#[cfg(target_os = "linux")]
170fn read_os_release() -> (Option<String>, Option<String>) {
171    let Ok(content) = std::fs::read_to_string("/etc/os-release") else {
172        return (None, None);
173    };
174    let mut id = None;
175    let mut version_id = None;
176    for line in content.lines() {
177        let Some((key, value)) = line.split_once('=') else {
178            continue;
179        };
180        let value = value.trim().trim_matches(|c| c == '"' || c == '\'');
181        match key.trim() {
182            "ID" => id = Some(value.to_string()),
183            "VERSION_ID" => version_id = Some(value.to_string()),
184            _ => {}
185        }
186    }
187    (id, version_id)
188}
189
190/// Known SSH host keys for server verification (TOFU model)
191///
192/// On first connection to a server, the key is saved to a known_hosts file.
193/// On subsequent connections, the presented key is verified against the saved
194/// one. This prevents MITM attacks after the initial connection.
195#[derive(Clone)]
196pub struct KnownHosts {
197    path: PathBuf,
198    hosts: Arc<Mutex<HashMap<String, String>>>,
199}
200
201impl KnownHosts {
202    /// Load known hosts from file, or create empty if file doesn't exist
203    pub fn load(path: &Path) -> Self {
204        let mut hosts = HashMap::new();
205
206        if path.exists() {
207            if let Ok(contents) = std::fs::read_to_string(path) {
208                for line in contents.lines() {
209                    let line = line.trim();
210                    if line.is_empty() || line.starts_with('#') {
211                        continue;
212                    }
213                    // Format: "host:port key_type base64_key"
214                    let parts: Vec<&str> = line.splitn(3, ' ').collect();
215                    if parts.len() == 3 {
216                        let host_port = parts[0].to_string();
217                        let key_str = format!("{} {}", parts[1], parts[2]);
218                        hosts.insert(host_port, key_str);
219                    }
220                }
221            }
222        }
223
224        Self {
225            path: path.to_path_buf(),
226            hosts: Arc::new(Mutex::new(hosts)),
227        }
228    }
229
230    /// Check a server's public key against known hosts.
231    /// Returns Ok(true) if the key matches or was newly saved (TOFU).
232    /// Returns Ok(false) if the key does NOT match a previously saved key
233    /// (possible MITM).
234    pub async fn verify(
235        &self,
236        host: &str,
237        port: u16,
238        key: &PublicKey,
239    ) -> Result<bool, anyhow::Error> {
240        let host_port = format!("{}:{}", host, port);
241        let key_algo = key.algorithm();
242        let key_type = key_algo.as_str();
243        let key_b64 = key.public_key_base64();
244        let presented = format!("{} {}", key_type, key_b64);
245
246        let mut hosts = self.hosts.lock().await;
247
248        if let Some(saved) = hosts.get(&host_port) {
249            // Match on key blob only: RFC 8332 reuses the ssh-rsa blob for
250            // rsa-sha2-{256,512}.
251            let saved_blob = saved.split_whitespace().nth(1).unwrap_or("");
252            if saved_blob == key_b64 {
253                debug!("Host key for {} matches known key", host_port);
254                Ok(true)
255            } else {
256                error!(
257                    "HOST KEY MISMATCH for {}! Possible MITM attack.\nExpected: {}\nGot:      {}",
258                    host_port, saved, presented
259                );
260                Ok(false)
261            }
262        } else {
263            // TOFU: first time seeing this host, save the key
264            info!(
265                "New host key for {} ({}), saving to known_hosts (TOFU)",
266                host_port, key_type
267            );
268            hosts.insert(host_port.clone(), presented);
269
270            // Write back to file atomically (write temp then rename)
271            if let Some(parent) = self.path.parent() {
272                let _ = std::fs::create_dir_all(parent);
273                let tmp_path = parent.join(".known_hosts.tmp");
274                let mut lines: Vec<String> = Vec::new();
275                lines.push("# Starla known hosts - do not edit manually".to_string());
276                for (hp, k) in hosts.iter() {
277                    lines.push(format!("{} {}", hp, k));
278                }
279                match std::fs::write(&tmp_path, lines.join("\n") + "\n") {
280                    Ok(()) => {
281                        if let Err(e) = std::fs::rename(&tmp_path, &self.path) {
282                            warn!("Failed to rename known_hosts: {}", e);
283                            let _ = std::fs::remove_file(&tmp_path);
284                        }
285                    }
286                    Err(e) => warn!("Failed to save known_hosts: {}", e),
287                }
288            }
289
290            Ok(true)
291        }
292    }
293}
294
295/// Client handler for russh
296struct AtlasClientHandler {
297    /// Known hosts for server key verification
298    known_hosts: KnownHosts,
299    /// The host we're connecting to (for key verification)
300    connect_host: String,
301    /// The port we're connecting to
302    connect_port: u16,
303    /// Command sender for telnet handler (forwarded connections go here
304    /// directly)
305    command_tx: Option<tokio::sync::mpsc::Sender<crate::telnet::TelnetCommand>>,
306    /// Probe ID for telnet authentication
307    probe_id: u32,
308    /// Session ID for telnet authentication
309    session_id: Arc<tokio::sync::RwLock<Option<String>>>,
310}
311
312impl client::Handler for AtlasClientHandler {
313    type Error = anyhow::Error;
314
315    async fn check_server_key(
316        &mut self,
317        server_public_key: &PublicKey,
318    ) -> Result<bool, Self::Error> {
319        self.known_hosts
320            .verify(&self.connect_host, self.connect_port, server_public_key)
321            .await
322    }
323
324    async fn server_channel_open_forwarded_tcpip(
325        &mut self,
326        channel: Channel<Msg>,
327        connected_address: &str,
328        connected_port: u32,
329        originator_address: &str,
330        originator_port: u32,
331        _session: &mut client::Session,
332    ) -> Result<(), Self::Error> {
333        debug!(
334            "Forwarded connection from {}:{} to {}:{}",
335            originator_address, originator_port, connected_address, connected_port
336        );
337
338        // Convert SSH channel to an async stream and handle directly :
339        // no local TCP port needed
340        let stream = crate::channel_stream::channel_to_stream(channel);
341        let command_tx = self.command_tx.clone();
342        let probe_id = self.probe_id;
343        let session_id = self.session_id.clone();
344
345        tokio::spawn(async move {
346            if let Err(e) =
347                crate::telnet::handle_connection(stream, command_tx, probe_id, session_id).await
348            {
349                error!("Error handling forwarded telnet connection: {}", e);
350            }
351            debug!("Forwarded telnet connection ended");
352        });
353
354        Ok(())
355    }
356}
357
358/// State needed for handling telnet connections directly in the SSH handler
359#[derive(Clone)]
360pub struct TelnetState {
361    pub command_tx: tokio::sync::mpsc::Sender<crate::telnet::TelnetCommand>,
362    pub probe_id: u32,
363    pub session_id: Arc<tokio::sync::RwLock<Option<String>>>,
364}
365
366/// SSH connection to RIPE Atlas controller
367pub struct SshConnection {
368    session: Arc<Mutex<Handle<AtlasClientHandler>>>,
369    host: String,
370    port: u16,
371}
372
373impl SshConnection {
374    /// Connect to a controller server
375    ///
376    /// If `telnet_state` is provided, forwarded SSH connections (reverse
377    /// tunnel) will be handled directly by the telnet command parser
378    /// without a local TCP listener.
379    pub async fn connect(
380        host: &str,
381        port: u16,
382        key: &PrivateKey,
383        config: SshConfig,
384        known_hosts: KnownHosts,
385        telnet_state: Option<TelnetState>,
386    ) -> anyhow::Result<Self> {
387        // RIPE Atlas registration servers only support diffie-hellman-group1-sha1
388        // and diffie-hellman-group-exchange-sha256. Since russh doesn't support
389        // group exchange, we must use group1-sha1 (despite it being considered weak).
390        let preferred = Preferred {
391            kex: Cow::Owned(vec![
392                kex::DH_G1_SHA1,
393                kex::DH_G14_SHA1,
394                kex::DH_G14_SHA256,
395                kex::CURVE25519,
396            ]),
397            ..Preferred::DEFAULT
398        };
399
400        let ssh_config = client::Config {
401            inactivity_timeout: Some(config.inactivity_timeout),
402            keepalive_interval: Some(config.keepalive_interval),
403            preferred,
404            ..Default::default()
405        };
406
407        let (command_tx, probe_id, session_id) = match telnet_state {
408            Some(ts) => (Some(ts.command_tx), ts.probe_id, ts.session_id),
409            None => (None, 0, Arc::new(tokio::sync::RwLock::new(None))),
410        };
411
412        let handler = AtlasClientHandler {
413            known_hosts: known_hosts.clone(),
414            connect_host: host.to_string(),
415            connect_port: port,
416            command_tx,
417            probe_id,
418            session_id,
419        };
420
421        let addr = format!("{}:{}", host, port);
422        debug!("Connecting to SSH controller at {}", addr);
423
424        let session = timeout(
425            config.connect_timeout,
426            client::connect(Arc::new(ssh_config), addr, handler),
427        )
428        .await
429        .map_err(|_| anyhow::anyhow!("Connection timeout"))??;
430
431        let mut session = session;
432
433        // Authenticate. Ed25519 keys don't need an RSA hash; pass None.
434        let auth_res = session
435            .authenticate_publickey(
436                "atlas",
437                PrivateKeyWithHashAlg::new(Arc::new(key.clone()), None),
438            )
439            .await?;
440
441        if !auth_res.success() {
442            anyhow::bail!("SSH authentication failed");
443        }
444
445        debug!("SSH authentication successful");
446
447        Ok(Self {
448            session: Arc::new(Mutex::new(session)),
449            host: host.to_string(),
450            port,
451        })
452    }
453
454    /// Connect with automatic retry
455    pub async fn connect_with_retry(
456        host: &str,
457        port: u16,
458        key: &PrivateKey,
459        config: SshConfig,
460        known_hosts: KnownHosts,
461        telnet_state: Option<TelnetState>,
462    ) -> anyhow::Result<Self> {
463        let mut attempts = 0u32;
464        let mut delay = config.reconnect_delay;
465
466        loop {
467            attempts += 1;
468
469            match Self::connect(
470                host,
471                port,
472                key,
473                config.clone(),
474                known_hosts.clone(),
475                telnet_state.clone(),
476            )
477            .await
478            {
479                Ok(conn) => return Ok(conn),
480                Err(e) => {
481                    if config.max_reconnect_attempts > 0
482                        && attempts >= config.max_reconnect_attempts
483                    {
484                        return Err(anyhow::anyhow!(
485                            "Failed to connect after {} attempts: {}",
486                            attempts,
487                            e
488                        ));
489                    }
490
491                    warn!(
492                        "Connection attempt {} failed: {}. Retrying in {:?}...",
493                        attempts, e, delay
494                    );
495
496                    sleep(delay).await;
497
498                    // Exponential backoff
499                    delay = std::cmp::min(delay * 2, config.max_reconnect_delay);
500                }
501            }
502        }
503    }
504
505    /// Try connecting to multiple servers in order
506    pub async fn connect_to_servers(
507        servers: &[&str],
508        key: &PrivateKey,
509        config: SshConfig,
510        known_hosts: KnownHosts,
511    ) -> anyhow::Result<Self> {
512        fn parse_server(server: &str) -> (&str, u16) {
513            if let Some(rest) = server.strip_prefix('[') {
514                if let Some((host, tail)) = rest.split_once(']') {
515                    if let Some(port_str) = tail.strip_prefix(':') {
516                        if let Ok(port) = port_str.parse() {
517                            return (host, port);
518                        }
519                    }
520                    return (host, 443);
521                }
522            }
523
524            if let Some((host, port_str)) = server.rsplit_once(':') {
525                if let Ok(port) = port_str.parse() {
526                    return (host, port);
527                }
528            }
529
530            (server, 443)
531        }
532
533        for server in servers {
534            let (host, port) = parse_server(server);
535
536            match Self::connect(host, port, key, config.clone(), known_hosts.clone(), None).await {
537                Ok(conn) => {
538                    info!("Connected to {}", server);
539                    return Ok(conn);
540                }
541                Err(e) => {
542                    warn!("Failed to connect to {}: {}", server, e);
543                }
544            }
545        }
546
547        anyhow::bail!("Failed to connect to any server")
548    }
549
550    /// Execute the INIT command and parse response
551    ///
552    /// The RIPE Atlas protocol requires sending probe identification data
553    /// to the server when running INIT on a registration server.
554    pub async fn init(&self, probe_info: Option<&ProbeInitInfo>) -> anyhow::Result<InitResponse> {
555        let output = if let Some(info) = probe_info {
556            // Send probe info to registration server
557            let stdin_data = info.to_init_message();
558            debug!("Sending INIT with probe info:\n{}", stdin_data);
559            self.execute_with_stdin("INIT", &stdin_data).await?
560        } else {
561            // Controller INIT - no stdin data needed
562            self.execute("INIT").await?
563        };
564
565        // Parse response line by line - the actual response may have multiple lines
566        let lines: Vec<&str> = output.lines().collect();
567
568        if lines.is_empty() {
569            anyhow::bail!("Empty INIT response");
570        }
571
572        let first_line = lines[0].trim();
573        debug!("INIT response: {} ({} lines)", first_line, lines.len());
574        for (i, line) in lines.iter().enumerate().skip(1) {
575            trace!("INIT response line {}: {}", i + 1, line);
576        }
577
578        match first_line {
579            "OK" => {
580                // Parse additional lines for various info
581                // Registration server format: OK\nCONTROLLER <host> <port> ssh-rsa
582                // <key>\nREREGISTER <secs> Controller format: OK\nREMOTE_PORT
583                // <port>\nSESSION_ID <id>
584                // Parse all registration response fields before returning
585                let mut controller_host: Option<String> = None;
586                let mut controller_port: Option<u16> = None;
587                let mut probe_id: u32 = 0;
588
589                for line in lines.iter().skip(1) {
590                    let parts: Vec<&str> = line.split_whitespace().collect();
591                    if parts.is_empty() {
592                        continue;
593                    }
594
595                    if parts[0] == "CONTROLLER" && parts.len() >= 4 {
596                        controller_host = Some(parts[1].to_string());
597                        controller_port = parts[2].parse().ok();
598                    }
599
600                    if parts[0] == "PROBE_ID" && parts.len() >= 2 {
601                        if let Ok(id) = parts[1].parse() {
602                            probe_id = id;
603                            debug!("Got probe ID: {}", id);
604                        }
605                    }
606                }
607
608                if let (Some(host), Some(port)) = (controller_host, controller_port) {
609                    debug!("Got controller: {}:{}", host, port);
610                    return Ok(InitResponse::Controller(ControllerInfo {
611                        host,
612                        port,
613                        probe_id,
614                    }));
615                }
616
617                // Parse controller response with REMOTE_PORT and SESSION_ID
618                let mut remote_port: Option<u16> = None;
619                let mut session_id: Option<String> = None;
620
621                for line in lines.iter().skip(1) {
622                    let parts: Vec<&str> = line.split_whitespace().collect();
623                    if parts.is_empty() {
624                        continue;
625                    }
626
627                    if parts[0] == "REMOTE_PORT" && parts.len() >= 2 {
628                        remote_port = parts[1].parse().ok();
629                        if let Some(port) = remote_port {
630                            debug!("Controller assigned remote port: {}", port);
631                        }
632                    }
633
634                    if parts[0] == "SESSION_ID" && parts.len() >= 2 {
635                        session_id = Some(parts[1].to_string());
636                        debug!("Controller assigned session ID: {}", parts[1]);
637                    }
638                }
639
640                if let (Some(port), Some(sid)) = (remote_port, session_id) {
641                    return Ok(InitResponse::ControllerReady {
642                        remote_port: port,
643                        session_id: sid,
644                    });
645                }
646
647                // Just OK with no additional info
648                // This could mean:
649                // 1. From reg server: probe key recognized but not registered yet
650                // 2. From controller: should have REMOTE_PORT but doesn't
651                // Return Ok and let caller decide what to do
652                debug!("Got OK without CONTROLLER or REMOTE_PORT/SESSION_ID info");
653                Ok(InitResponse::Ok)
654            }
655            "WAIT" => {
656                // Parse TIMEOUT from next line
657                // Format: WAIT\nTIMEOUT <seconds>
658                let mut timeout_secs = 60u32; // Default timeout
659                for line in lines.iter().skip(1) {
660                    let parts: Vec<&str> = line.split_whitespace().collect();
661                    if parts.len() >= 2 && parts[0] == "TIMEOUT" {
662                        timeout_secs = parts[1].parse().unwrap_or(60);
663                        break;
664                    }
665                }
666                debug!("Server requested wait: {} seconds", timeout_secs);
667                Ok(InitResponse::Wait { timeout_secs })
668            }
669            _ => {
670                anyhow::bail!("Unknown INIT response: {}", output);
671            }
672        }
673    }
674
675    /// Start the KEEP session and monitor it.
676    ///
677    /// Opens a channel with the KEEP command and blocks until the channel
678    /// closes (which means the controller disconnected). Returns when the
679    /// connection is lost. The caller should use this as the connection
680    /// health signal.
681    pub async fn run_keep_session(&self) -> anyhow::Result<()> {
682        debug!("Starting KEEP session");
683        let session = self.session.lock().await;
684        let mut channel = session.channel_open_session().await?;
685        channel.exec(true, "KEEP").await?;
686        drop(session); // Release lock so other operations can use the session
687
688        debug!("KEEP session started, monitoring channel");
689
690        // Block until the KEEP channel closes: this is our connection health signal
691        while let Some(msg) = channel.wait().await {
692            match msg {
693                ChannelMsg::Data { data } => {
694                    trace!("KEEP channel data: {} bytes", data.len());
695                }
696                ChannelMsg::Eof => {
697                    debug!("KEEP channel EOF: connection lost");
698                    break;
699                }
700                ChannelMsg::ExitStatus { exit_status } => {
701                    debug!("KEEP channel exit status: {}", exit_status);
702                }
703                _ => {}
704            }
705        }
706
707        warn!("KEEP session ended: controller disconnected");
708        anyhow::bail!("KEEP session ended")
709    }
710
711    /// Request reverse port forwarding
712    pub async fn request_reverse_tunnel(&self, bind_port: u16) -> anyhow::Result<()> {
713        debug!("Requesting reverse tunnel on port {}", bind_port);
714
715        let session = self.session.lock().await;
716
717        // Check if session is still connected
718        if session.is_closed() {
719            anyhow::bail!("SSH session is closed, cannot setup tunnel");
720        }
721
722        // Use "localhost" as the bind address, matching OpenSSH behavior
723        // The server will listen on localhost:bind_port
724        session.tcpip_forward("localhost", bind_port as u32).await?;
725
726        Ok(())
727    }
728
729    /// Cancel reverse port forwarding
730    pub async fn cancel_reverse_tunnel(&self, bind_port: u16) -> anyhow::Result<()> {
731        debug!("Cancelling reverse tunnel on port {}", bind_port);
732
733        let session = self.session.lock().await;
734        session
735            .cancel_tcpip_forward("127.0.0.1", bind_port as u32)
736            .await?;
737
738        Ok(())
739    }
740
741    /// Create a direct-tcpip channel to forward data to the controller
742    /// This opens an SSH channel that connects to the specified host:port on
743    /// the server side
744    pub async fn open_direct_tcpip(
745        &self,
746        remote_host: &str,
747        remote_port: u16,
748    ) -> anyhow::Result<Channel<Msg>> {
749        debug!(
750            "Opening direct-tcpip channel to {}:{}",
751            remote_host, remote_port
752        );
753
754        let session = self.session.lock().await;
755
756        // Open a direct-tcpip channel
757        // This tells the SSH server to connect to remote_host:remote_port
758        // and forward data through this channel
759        let channel = session
760            .channel_open_direct_tcpip(
761                remote_host,
762                remote_port as u32,
763                "127.0.0.1", // originator address (us)
764                0,           // originator port (unused)
765            )
766            .await?;
767
768        Ok(channel)
769    }
770
771    /// Get the controller host we're connected to
772    pub fn controller_host(&self) -> &str {
773        &self.host
774    }
775
776    /// Get the controller port we're connected to
777    pub fn controller_port(&self) -> u16 {
778        self.port
779    }
780
781    /// Start a local HTTP proxy that forwards to the controller via SSH
782    /// direct-tcpip
783    ///
784    /// This sets up a local TCP listener on the specified port. When
785    /// connections come in, they are forwarded through the SSH connection
786    /// to the controller's HTTP endpoint.
787    ///
788    /// This mimics the behavior of `ssh -L local_port:127.0.0.1:remote_port`
789    /// The Atlas protocol uses: `-L 8080:127.0.0.1:8080` to forward local:8080
790    /// to controller's 127.0.0.1:8080
791    ///
792    /// The `reconnect_signal` token will be cancelled if the proxy detects the
793    /// SSH session is dead (e.g., consecutive channel open failures or
794    /// timeouts). The caller should watch this token and reconnect when
795    /// it's cancelled.
796    pub async fn start_http_proxy(
797        &self,
798        local_port: u16,
799        remote_port: u16,
800        reconnect_signal: tokio_util::sync::CancellationToken,
801    ) -> anyhow::Result<()> {
802        use std::sync::atomic::{AtomicU32, Ordering};
803        use tokio::io::{AsyncReadExt, AsyncWriteExt};
804        use tokio::net::TcpListener;
805
806        let listener = TcpListener::bind(format!("127.0.0.1:{}", local_port)).await?;
807        debug!(
808            "HTTP proxy started: localhost:{} -> controller:{}",
809            local_port, remote_port
810        );
811
812        let session = self.session.clone();
813        let remote_port = remote_port as u32;
814
815        // Track consecutive failures to detect dead sessions
816        let consecutive_failures = Arc::new(AtomicU32::new(0));
817        const MAX_CONSECUTIVE_FAILURES: u32 = 3;
818
819        tokio::spawn(async move {
820            loop {
821                match listener.accept().await {
822                    Ok((mut local_stream, peer_addr)) => {
823                        debug!(
824                            "HTTP proxy accepted connection from {} (local:{} -> remote:{})",
825                            peer_addr, local_port, remote_port
826                        );
827
828                        let session = session.clone();
829                        let failures = consecutive_failures.clone();
830                        let reconnect = reconnect_signal.clone();
831
832                        tokio::spawn(async move {
833                            // Open SSH direct-tcpip channel to controller's localhost:remote_port
834                            debug!("Opening SSH channel for HTTP forward");
835                            let session_guard = session.lock().await;
836                            if session_guard.is_closed() {
837                                error!(
838                                    "SSH session closed before opening HTTP channel (local:{} -> \
839                                     remote:{})",
840                                    local_port, remote_port
841                                );
842                                let count = failures.fetch_add(1, Ordering::SeqCst) + 1;
843                                if count >= MAX_CONSECUTIVE_FAILURES {
844                                    error!(
845                                        "Too many channel failures ({}), signaling reconnection \
846                                         needed",
847                                        count
848                                    );
849                                    reconnect.cancel();
850                                }
851                                return;
852                            }
853
854                            // Add timeout to channel open to detect dead SSH sessions
855                            let channel_result = tokio::time::timeout(
856                                std::time::Duration::from_secs(10),
857                                session_guard.channel_open_direct_tcpip(
858                                    "127.0.0.1",
859                                    remote_port,
860                                    "127.0.0.1",
861                                    0,
862                                ),
863                            )
864                            .await;
865
866                            let mut channel = match channel_result {
867                                Ok(Ok(ch)) => {
868                                    debug!("SSH channel opened successfully");
869                                    // Reset failure counter on success
870                                    failures.store(0, Ordering::SeqCst);
871                                    ch
872                                }
873                                Ok(Err(e)) => {
874                                    error!("Failed to open SSH channel for HTTP forward: {}", e);
875                                    let count = failures.fetch_add(1, Ordering::SeqCst) + 1;
876                                    if count >= MAX_CONSECUTIVE_FAILURES {
877                                        error!(
878                                            "Too many channel failures ({}), signaling \
879                                             reconnection needed",
880                                            count
881                                        );
882                                        reconnect.cancel();
883                                    }
884                                    return;
885                                }
886                                Err(_) => {
887                                    error!("Timeout opening SSH channel - SSH session may be dead");
888                                    let count = failures.fetch_add(1, Ordering::SeqCst) + 1;
889                                    if count >= MAX_CONSECUTIVE_FAILURES {
890                                        error!(
891                                            "Too many channel timeouts ({}), signaling \
892                                             reconnection needed",
893                                            count
894                                        );
895                                        reconnect.cancel();
896                                    }
897                                    return;
898                                }
899                            };
900                            drop(session_guard);
901
902                            // Bridge local stream with SSH channel.
903                            // Supports TCP half-close: when the local client finishes
904                            // sending (read returns 0), we send EOF to the SSH side
905                            // but keep reading the SSH response back to the client.
906                            let mut local_buf = [0u8; 8192];
907                            let mut local_done = false;
908
909                            loop {
910                                tokio::select! {
911                                    biased; // Prioritize SSH data over local reads
912
913                                    // Read from SSH -> send to local
914                                    msg = channel.wait() => {
915                                        match msg {
916                                            Some(ChannelMsg::Data { data }) => {
917                                                failures.store(0, Ordering::SeqCst);
918                                                if let Err(e) = local_stream.write_all(&data).await {
919                                                    debug!("Local write error: {}", e);
920                                                    break;
921                                                }
922                                            }
923                                            Some(ChannelMsg::Eof) | None => {
924                                                debug!("SSH channel closed");
925                                                break;
926                                            }
927                                            _ => {}
928                                        }
929                                    }
930
931                                    // Read from local -> send to SSH
932                                    result = local_stream.read(&mut local_buf), if !local_done => {
933                                        match result {
934                                            Ok(0) => {
935                                                // Local finished sending: half-close the SSH side
936                                                // but keep looping to read the response
937                                                let _ = channel.eof().await;
938                                                local_done = true;
939                                            }
940                                            Ok(n) => {
941                                                if let Err(e) = channel.data(&local_buf[..n]).await {
942                                                    debug!("SSH write error: {}", e);
943                                                    let count = failures.fetch_add(1, Ordering::SeqCst) + 1;
944                                                    if count >= MAX_CONSECUTIVE_FAILURES {
945                                                        reconnect.cancel();
946                                                    }
947                                                    break;
948                                                }
949                                            }
950                                            Err(e) => {
951                                                debug!("Local read error: {}", e);
952                                                break;
953                                            }
954                                        }
955                                    }
956                                }
957                            }
958                        });
959                    }
960                    Err(e) => {
961                        error!("HTTP proxy accept error: {}", e);
962                    }
963                }
964            }
965        });
966
967        Ok(())
968    }
969
970    /// Execute a command and return output
971    pub async fn execute(&self, command: &str) -> anyhow::Result<String> {
972        self.execute_with_stdin(command, "").await
973    }
974
975    /// Execute a command with stdin data and return output
976    pub async fn execute_with_stdin(
977        &self,
978        command: &str,
979        stdin_data: &str,
980    ) -> anyhow::Result<String> {
981        let session = self.session.lock().await;
982
983        // Check if session is still open
984        if session.is_closed() {
985            anyhow::bail!("SSH session is closed");
986        }
987
988        let mut channel = session
989            .channel_open_session()
990            .await
991            .map_err(|e| anyhow::anyhow!("Failed to open SSH channel: {}", e))?;
992
993        channel
994            .exec(true, command)
995            .await
996            .map_err(|e| anyhow::anyhow!("Failed to execute command '{}': {}", command, e))?;
997
998        // Send stdin data if provided
999        if !stdin_data.is_empty() {
1000            channel
1001                .data(stdin_data.as_bytes())
1002                .await
1003                .map_err(|e| anyhow::anyhow!("Failed to send stdin data: {}", e))?;
1004            channel
1005                .eof()
1006                .await
1007                .map_err(|e| anyhow::anyhow!("Failed to send EOF: {}", e))?;
1008        }
1009
1010        let mut output = String::new();
1011        while let Some(msg) = channel.wait().await {
1012            match msg {
1013                ChannelMsg::Data { ref data } => {
1014                    output.push_str(&String::from_utf8_lossy(data));
1015                }
1016                ChannelMsg::Eof => break,
1017                ChannelMsg::ExitStatus { exit_status } => {
1018                    if exit_status != 0 {
1019                        debug!("Command exited with status {}", exit_status);
1020                    }
1021                }
1022                _ => {}
1023            }
1024        }
1025
1026        Ok(output)
1027    }
1028
1029    /// Check if connection is still alive
1030    pub async fn is_connected(&self) -> bool {
1031        let session = self.session.lock().await;
1032        !session.is_closed()
1033    }
1034
1035    /// Get the host we're connected to
1036    pub fn host(&self) -> &str {
1037        &self.host
1038    }
1039
1040    /// Get the port we're connected to
1041    pub fn port(&self) -> u16 {
1042        self.port
1043    }
1044}
1045
1046/// Compute the SHA256 fingerprint of a public key (e.g., "SHA256:abc123...")
1047pub fn key_fingerprint(key: &PrivateKey) -> anyhow::Result<String> {
1048    use sha2::{Digest, Sha256};
1049
1050    let public_key = key.public_key();
1051    let key_algo = public_key.algorithm();
1052    let key_type = key_algo.as_str();
1053    let key_b64 = public_key.public_key_base64();
1054
1055    // The SSH fingerprint is SHA256 of the raw public key wire format
1056    // (type string length + type string + key data), which is what base64 decodes
1057    // to
1058    use base64::Engine;
1059    let raw_bytes = base64::engine::general_purpose::STANDARD.decode(&key_b64)?;
1060    let hash = Sha256::digest(&raw_bytes);
1061    let fingerprint = base64::engine::general_purpose::STANDARD_NO_PAD.encode(hash);
1062
1063    Ok(format!("{} SHA256:{}", key_type, fingerprint))
1064}
1065
1066/// Load SSH key from file
1067pub async fn load_key(path: &Path) -> anyhow::Result<PrivateKey> {
1068    let key_data = tokio::fs::read(path).await?;
1069    let key = russh::keys::decode_secret_key(&String::from_utf8(key_data)?, None)?;
1070    Ok(key)
1071}
1072
1073/// Load SSH key pair from a PEM string (e.g. from an environment variable)
1074pub fn load_key_from_string(pem: &str) -> anyhow::Result<PrivateKey> {
1075    let key = russh::keys::decode_secret_key(pem, None)?;
1076    Ok(key)
1077}
1078
1079/// Generate a new SSH key pair
1080pub fn generate_key() -> anyhow::Result<PrivateKey> {
1081    let key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519)?;
1082    Ok(key)
1083}
1084
1085/// Save SSH key pair to files
1086pub async fn save_key(key: &PrivateKey, path: &Path) -> anyhow::Result<()> {
1087    if let Some(parent) = path.parent() {
1088        tokio::fs::create_dir_all(parent).await?;
1089    }
1090
1091    // Save public key in OpenSSH format: "<algo> <base64> starla"
1092    let public_key = key.public_key();
1093    let pub_path = path.with_extension("pub");
1094    let pub_algo = public_key.algorithm();
1095    let pub_key_str = format!(
1096        "{} {} starla",
1097        pub_algo.as_str(),
1098        public_key.public_key_base64()
1099    );
1100    tokio::fs::write(&pub_path, pub_key_str.as_bytes()).await?;
1101    debug!("Public key: {}", pub_key_str);
1102
1103    // Save private key in OpenSSH format
1104    let openssh_pem = key.to_openssh(ssh_key::LineEnding::LF)?;
1105    tokio::fs::write(path, openssh_pem.as_bytes()).await?;
1106
1107    // Set restrictive permissions on private key (Unix only)
1108    #[cfg(unix)]
1109    {
1110        use std::os::unix::fs::PermissionsExt;
1111        let mut perms = tokio::fs::metadata(path).await?.permissions();
1112        perms.set_mode(0o600);
1113        tokio::fs::set_permissions(path, perms).await?;
1114    }
1115
1116    Ok(())
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121    use super::*;
1122
1123    #[test]
1124    fn test_default_config() {
1125        let config = SshConfig::default();
1126        assert_eq!(config.connect_timeout, Duration::from_secs(30));
1127        assert_eq!(config.keepalive_interval, Duration::from_secs(30));
1128    }
1129
1130    #[test]
1131    fn test_generate_key() {
1132        let key = generate_key().unwrap();
1133        let public = key.public_key();
1134        // Just verify we can get the public key
1135        assert_eq!(public.algorithm(), Algorithm::Ed25519);
1136    }
1137
1138    fn tmp_path(tag: &str) -> PathBuf {
1139        std::env::temp_dir().join(format!(
1140            "starla-kh-{}-{}-{}",
1141            tag,
1142            std::process::id(),
1143            std::time::SystemTime::now()
1144                .duration_since(std::time::UNIX_EPOCH)
1145                .unwrap()
1146                .as_nanos()
1147        ))
1148    }
1149
1150    #[tokio::test]
1151    async fn test_verify_matches_on_blob_across_algorithm_names() {
1152        let path = tmp_path("xalgo");
1153        let kh = KnownHosts::load(&path);
1154
1155        let priv_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap();
1156        let pub_key = priv_key.public_key();
1157        let blob = pub_key.public_key_base64();
1158
1159        kh.hosts
1160            .lock()
1161            .await
1162            .insert("atlas.example.com:443".into(), format!("ssh-rsa {}", blob));
1163
1164        let ok = kh.verify("atlas.example.com", 443, pub_key).await.unwrap();
1165        assert!(ok, "blob match should win over algorithm-prefix difference");
1166
1167        let _ = std::fs::remove_file(&path);
1168    }
1169
1170    #[tokio::test]
1171    async fn test_verify_rejects_different_blob() {
1172        let path = tmp_path("mitm");
1173        let kh = KnownHosts::load(&path);
1174
1175        let pinned = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap();
1176        let attacker = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap();
1177
1178        kh.hosts.lock().await.insert(
1179            "atlas.example.com:443".into(),
1180            format!("ssh-ed25519 {}", pinned.public_key().public_key_base64()),
1181        );
1182
1183        let ok = kh
1184            .verify("atlas.example.com", 443, attacker.public_key())
1185            .await
1186            .unwrap();
1187        assert!(!ok, "verify must reject a different key blob");
1188
1189        let _ = std::fs::remove_file(&path);
1190    }
1191
1192    #[tokio::test]
1193    async fn test_verify_tofu_on_first_sight() {
1194        let path = tmp_path("tofu");
1195        let kh = KnownHosts::load(&path);
1196
1197        let priv_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap();
1198        let ok = kh
1199            .verify("atlas.example.com", 443, priv_key.public_key())
1200            .await
1201            .unwrap();
1202        assert!(ok, "first sight should TOFU-trust the key");
1203
1204        let ok = kh
1205            .verify("atlas.example.com", 443, priv_key.public_key())
1206            .await
1207            .unwrap();
1208        assert!(ok, "subsequent verifications with the same key must match");
1209
1210        let _ = std::fs::remove_file(&path);
1211    }
1212}