1use 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#[derive(Debug, Clone)]
24pub struct SshConfig {
25 pub connect_timeout: Duration,
27 pub inactivity_timeout: Duration,
29 pub keepalive_interval: Duration,
31 pub reconnect_delay: Duration,
33 pub max_reconnect_delay: Duration,
35 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, }
49 }
50}
51
52#[derive(Debug, Clone)]
54pub struct ControllerInfo {
55 pub host: String,
56 pub port: u16,
57 pub probe_id: u32,
58}
59
60#[derive(Debug, Clone)]
62pub enum InitResponse {
63 Controller(ControllerInfo),
65 ControllerReady {
67 remote_port: u16,
68 session_id: String,
69 },
70 Ok,
72 Wait { timeout_secs: u32 },
74}
75
76#[derive(Debug, Clone)]
78pub struct ProbeInitInfo {
79 pub firmware_version: u32,
81 pub reason: String,
83}
84
85impl ProbeInitInfo {
86 pub fn new(firmware_version: u32) -> Self {
88 Self {
89 firmware_version,
90 reason: "NEW".to_string(),
91 }
92 }
93
94 pub fn reregister(firmware_version: u32, reason: &str) -> Self {
96 Self {
97 firmware_version,
98 reason: reason.to_string(),
99 }
100 }
101
102 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
122fn 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#[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#[derive(Clone)]
196pub struct KnownHosts {
197 path: PathBuf,
198 hosts: Arc<Mutex<HashMap<String, String>>>,
199}
200
201impl KnownHosts {
202 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 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 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 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 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 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
295struct AtlasClientHandler {
297 known_hosts: KnownHosts,
299 connect_host: String,
301 connect_port: u16,
303 command_tx: Option<tokio::sync::mpsc::Sender<crate::telnet::TelnetCommand>>,
306 probe_id: u32,
308 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 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#[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
366pub struct SshConnection {
368 session: Arc<Mutex<Handle<AtlasClientHandler>>>,
369 host: String,
370 port: u16,
371}
372
373impl SshConnection {
374 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 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 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 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 delay = std::cmp::min(delay * 2, config.max_reconnect_delay);
500 }
501 }
502 }
503 }
504
505 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 pub async fn init(&self, probe_info: Option<&ProbeInitInfo>) -> anyhow::Result<InitResponse> {
555 let output = if let Some(info) = probe_info {
556 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 self.execute("INIT").await?
563 };
564
565 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 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 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 debug!("Got OK without CONTROLLER or REMOTE_PORT/SESSION_ID info");
653 Ok(InitResponse::Ok)
654 }
655 "WAIT" => {
656 let mut timeout_secs = 60u32; 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 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); debug!("KEEP session started, monitoring channel");
689
690 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 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 if session.is_closed() {
719 anyhow::bail!("SSH session is closed, cannot setup tunnel");
720 }
721
722 session.tcpip_forward("localhost", bind_port as u32).await?;
725
726 Ok(())
727 }
728
729 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 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 let channel = session
760 .channel_open_direct_tcpip(
761 remote_host,
762 remote_port as u32,
763 "127.0.0.1", 0, )
766 .await?;
767
768 Ok(channel)
769 }
770
771 pub fn controller_host(&self) -> &str {
773 &self.host
774 }
775
776 pub fn controller_port(&self) -> u16 {
778 self.port
779 }
780
781 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 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 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 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 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 let mut local_buf = [0u8; 8192];
907 let mut local_done = false;
908
909 loop {
910 tokio::select! {
911 biased; 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 result = local_stream.read(&mut local_buf), if !local_done => {
933 match result {
934 Ok(0) => {
935 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 pub async fn execute(&self, command: &str) -> anyhow::Result<String> {
972 self.execute_with_stdin(command, "").await
973 }
974
975 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 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 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 pub async fn is_connected(&self) -> bool {
1031 let session = self.session.lock().await;
1032 !session.is_closed()
1033 }
1034
1035 pub fn host(&self) -> &str {
1037 &self.host
1038 }
1039
1040 pub fn port(&self) -> u16 {
1042 self.port
1043 }
1044}
1045
1046pub 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 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
1066pub 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
1073pub 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
1079pub fn generate_key() -> anyhow::Result<PrivateKey> {
1081 let key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519)?;
1082 Ok(key)
1083}
1084
1085pub 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 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 let openssh_pem = key.to_openssh(ssh_key::LineEnding::LF)?;
1105 tokio::fs::write(path, openssh_pem.as_bytes()).await?;
1106
1107 #[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 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}