iroh_ssh/
ssh.rs

1use crate::{Builder, Inner, IrohSsh, cli::SshOpts};
2use std::{ffi::OsString, process::Stdio};
3
4use anyhow::bail;
5use ed25519_dalek::SECRET_KEY_LENGTH;
6use homedir::my_home;
7use iroh::{
8    endpoint::Connection, protocol::{ProtocolHandler, Router}, Endpoint, EndpointId, SecretKey
9};
10use tokio::{
11    net::TcpStream,
12    process::{Child, Command},
13};
14
15impl Builder {
16    pub fn new() -> Self {
17        Self {
18            secret_key: SecretKey::generate(&mut rand::rng()).to_bytes(),
19            accept_incoming: false,
20            accept_port: None,
21        }
22    }
23
24    pub fn accept_incoming(mut self, accept_incoming: bool) -> Self {
25        self.accept_incoming = accept_incoming;
26        self
27    }
28
29    pub fn accept_port(mut self, accept_port: u16) -> Self {
30        self.accept_port = Some(accept_port);
31        self
32    }
33
34    pub fn secret_key(mut self, secret_key: &[u8; SECRET_KEY_LENGTH]) -> Self {
35        self.secret_key = *secret_key;
36        self
37    }
38
39    pub fn dot_ssh_integration(mut self, persist: bool, service: bool) -> Self {
40        tracing::info!(
41            "dot_ssh_integration: persist={}, service={}",
42            persist,
43            service
44        );
45
46        match dot_ssh(&SecretKey::from_bytes(&self.secret_key), persist, service) {
47            Ok(secret_key) => {
48                tracing::info!("dot_ssh_integration: Successfully loaded/created SSH keys");
49                self.secret_key = secret_key.to_bytes();
50            }
51            Err(e) => {
52                tracing::error!(
53                    "dot_ssh_integration: Failed to load/create SSH keys: {:#}",
54                    e
55                );
56                eprintln!(
57                    "Warning: Failed to load/create persistent SSH keys: {e:#}"
58                );
59                eprintln!("Continuing with ephemeral keys...");
60            }
61        }
62        self
63    }
64
65    pub async fn build(&mut self) -> anyhow::Result<IrohSsh> {
66        // Iroh setup
67        let secret_key = SecretKey::from_bytes(&self.secret_key);
68        let endpoint = Endpoint::builder()
69            .secret_key(secret_key)
70            .bind()
71            .await?;
72
73        let mut iroh_ssh = IrohSsh {
74            public_key: *endpoint.id().as_bytes(),
75            secret_key: self.secret_key,
76            inner: None,
77            ssh_port: self.accept_port.unwrap_or(22),
78        };
79
80        let router = if self.accept_incoming {
81            Router::builder(endpoint.clone()).accept(IrohSsh::ALPN(), iroh_ssh.clone())
82        } else {
83            Router::builder(endpoint.clone())
84        }
85        .spawn();
86
87        iroh_ssh.add_inner(endpoint, router);
88
89        Ok(iroh_ssh)
90    }
91}
92
93impl Default for Builder {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl IrohSsh {
100    pub fn builder() -> Builder {
101        Builder::new()
102    }
103
104    #[allow(non_snake_case)]
105    pub fn ALPN() -> Vec<u8> {
106        b"/iroh/ssh".to_vec()
107    }
108
109    fn add_inner(&mut self, endpoint: Endpoint, router: Router) {
110        self.inner = Some(Inner { endpoint, router });
111    }
112
113    pub async fn start_ssh(
114        &self,
115        target: String,
116        ssh_opts: SshOpts,
117        remote_cmd: Vec<OsString>,
118    ) -> anyhow::Result<Child> {
119        let c_exe = std::env::current_exe()?;
120        let cmd = &mut Command::new("ssh");
121
122        cmd.arg("-o")
123            .arg(format!("ProxyCommand={} proxy %h", c_exe.display()));
124
125        if let Some(p) = ssh_opts.port {
126            cmd.arg("-p").arg(p.to_string());
127        }
128        if let Some(id) = &ssh_opts.identity_file {
129            cmd.arg("-i").arg(id);
130        }
131        for l in &ssh_opts.local_forward {
132            cmd.arg("-L").arg(l);
133        }
134        for r in &ssh_opts.remote_forward {
135            cmd.arg("-R").arg(r);
136        }
137        for o in &ssh_opts.options {
138            cmd.arg("-o").arg(o);
139        }
140        if ssh_opts.agent {
141            cmd.arg("-A");
142        }
143        if ssh_opts.no_agent {
144            cmd.arg("-a");
145        }
146        if ssh_opts.x11_trusted {
147            cmd.arg("-Y");
148        } else if ssh_opts.x11 {
149            cmd.arg("-X");
150        }
151        if ssh_opts.no_cmd {
152            cmd.arg("-N");
153        }
154        if ssh_opts.force_tty {
155            cmd.arg("-t");
156        }
157        if ssh_opts.no_tty {
158            cmd.arg("-T");
159        }
160        for _ in 0..ssh_opts.verbose {
161            cmd.arg("-v");
162        }
163        if ssh_opts.quiet {
164            cmd.arg("-q");
165        }
166
167        cmd.arg(target);
168
169        if !remote_cmd.is_empty() {
170            cmd.args(remote_cmd.iter());
171        }
172
173        let ssh_process = cmd
174            .stdin(Stdio::inherit())
175            .stdout(Stdio::inherit())
176            .stderr(Stdio::inherit())
177            .spawn()?;
178
179        Ok(ssh_process)
180    }
181
182    pub async fn connect(&self, endpoint_id: EndpointId) -> anyhow::Result<()> {
183        let inner = self.inner.as_ref().expect("inner not set");
184        let conn = inner.endpoint.connect(endpoint_id, &IrohSsh::ALPN()).await?;
185        let (mut iroh_send, mut iroh_recv) = conn.open_bi().await?;
186        let (mut local_read, mut local_write) = (tokio::io::stdin(), tokio::io::stdout());
187        let a_to_b = async move { tokio::io::copy(&mut local_read, &mut iroh_send).await };
188        let b_to_a = async move { tokio::io::copy(&mut iroh_recv, &mut local_write).await };
189
190        tokio::select! {
191            result = a_to_b => {
192                let _ = result;
193            },
194            result = b_to_a => {
195                let _ = result;
196            },
197        };
198        Ok(())
199    }
200
201    pub fn node_id(&self) -> EndpointId {
202        self.inner
203            .as_ref()
204            .expect("inner not set")
205            .endpoint
206            .id()
207    }
208}
209
210impl ProtocolHandler for IrohSsh {
211    async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {
212        let endpoint_id = connection.remote_id()?;
213
214        match connection.accept_bi().await {
215            Ok((mut iroh_send, mut iroh_recv)) => {
216                println!("Accepted bidirectional stream from {endpoint_id}");
217
218                match TcpStream::connect(format!("127.0.0.1:{}", self.ssh_port)).await {
219                    Ok(mut ssh_stream) => {
220                        println!("Connected to local SSH server on port {}", self.ssh_port);
221
222                        let (mut local_read, mut local_write) = ssh_stream.split();
223
224                        let a_to_b =
225                            async move { tokio::io::copy(&mut local_read, &mut iroh_send).await };
226                        let b_to_a =
227                            async move { tokio::io::copy(&mut iroh_recv, &mut local_write).await };
228
229                        tokio::select! {
230                            result = a_to_b => {
231                                println!("SSH->Iroh stream ended: {result:?}");
232                            },
233                            result = b_to_a => {
234                                println!("Iroh->SSH stream ended: {result:?}");
235                            },
236                        };
237                    }
238                    Err(e) => {
239                        println!("Failed to connect to SSH server: {e}");
240                    }
241                }
242            }
243            Err(e) => {
244                println!("Failed to accept bidirectional stream: {e}");
245            }
246        }
247
248        Ok(())
249    }
250}
251
252pub fn dot_ssh(
253    default_secret_key: &SecretKey,
254    persist: bool,
255    _service: bool,
256) -> anyhow::Result<SecretKey> {
257    tracing::info!(
258        "dot_ssh: Function called, persist={}, service={}",
259        persist,
260        _service
261    );
262
263    let distro_home = my_home()?.ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
264    #[allow(unused_mut)]
265    let mut ssh_dir = distro_home.join(".ssh");
266
267    // For now linux services are installed as "sudo'er" so
268    // we need to use the root .ssh directory
269    #[cfg(target_os = "linux")]
270    if _service {
271        ssh_dir = std::path::PathBuf::from("/root/.ssh");
272    }
273
274    // Windows virtual service account profile location for NT SERVICE\iroh-ssh
275    #[cfg(target_os = "windows")]
276    if _service {
277        ssh_dir = std::path::PathBuf::from(crate::service::WindowsService::SERVICE_SSH_DIR);
278        tracing::info!("dot_ssh: Using service SSH dir: {}", ssh_dir.display());
279
280        // Ensure directory exists when running as service
281        if !ssh_dir.exists() {
282            tracing::info!("dot_ssh: Service SSH dir doesn't exist, creating it");
283            std::fs::create_dir_all(&ssh_dir)?;
284        }
285    }
286
287    let pub_key = ssh_dir.join("irohssh_ed25519.pub");
288    let priv_key = ssh_dir.join("irohssh_ed25519");
289
290    tracing::debug!("dot_ssh: ssh_dir exists = {}", ssh_dir.exists());
291    tracing::debug!("dot_ssh: pub_key path = {}", pub_key.display());
292    tracing::debug!("dot_ssh: priv_key path = {}", priv_key.display());
293
294    match (ssh_dir.exists(), persist) {
295        (false, false) => {
296            tracing::error!(
297                "dot_ssh: ssh_dir does not exist and persist=false: {}",
298                ssh_dir.display()
299            );
300            bail!(
301                "no .ssh folder found in {}, use --persist flag to create it",
302                distro_home.display()
303            )
304        }
305        (false, true) => {
306            tracing::info!("dot_ssh: Creating ssh_dir: {}", ssh_dir.display());
307            std::fs::create_dir_all(&ssh_dir)?;
308            println!("[INFO] created .ssh folder: {}", ssh_dir.display());
309            dot_ssh(default_secret_key, persist, _service)
310        }
311        (true, true) => {
312            tracing::info!("dot_ssh: Branch (true, true) - directory exists, persist enabled");
313            tracing::debug!("dot_ssh: pub_key.exists() = {}", pub_key.exists());
314            tracing::debug!("dot_ssh: priv_key.exists() = {}", priv_key.exists());
315
316            // check pub and priv key already exists
317            if pub_key.exists() && priv_key.exists() {
318                tracing::info!("dot_ssh: Keys exist, reading them");
319                // read secret key
320                if let Ok(secret_key) = std::fs::read(priv_key.clone()) {
321                    let mut sk_bytes = [0u8; SECRET_KEY_LENGTH];
322                    sk_bytes.copy_from_slice(z32::decode(secret_key.as_slice())?.as_slice());
323                    Ok(SecretKey::from_bytes(&sk_bytes))
324                } else {
325                    bail!("failed to read secret key from {}", priv_key.display())
326                }
327            } else {
328                tracing::info!("dot_ssh: Keys don't exist, creating new keys");
329                tracing::debug!("dot_ssh: Writing to pub_key: {}", pub_key.display());
330                tracing::debug!("dot_ssh: Writing to priv_key: {}", priv_key.display());
331
332                let secret_key = default_secret_key.clone();
333                let public_key = secret_key.public();
334
335                match std::fs::write(&pub_key, z32::encode(public_key.as_bytes())) {
336                    Ok(_) => {
337                        tracing::info!("dot_ssh: Successfully wrote pub_key");
338                    }
339                    Err(e) => {
340                        tracing::error!(
341                            "dot_ssh: Failed to write pub_key: {} (error kind: {:?})",
342                            e,
343                            e.kind()
344                        );
345                        return Err(e.into());
346                    }
347                }
348
349                match std::fs::write(&priv_key, z32::encode(&secret_key.to_bytes())) {
350                    Ok(_) => {
351                        tracing::info!("dot_ssh: Successfully wrote priv_key");
352                    }
353                    Err(e) => {
354                        tracing::error!(
355                            "dot_ssh: Failed to write priv_key: {} (error kind: {:?})",
356                            e,
357                            e.kind()
358                        );
359                        return Err(e.into());
360                    }
361                }
362
363                Ok(secret_key)
364            }
365        }
366        (true, false) => {
367            // check pub and priv key already exists
368            if pub_key.exists() && priv_key.exists() {
369                // read secret key
370                if let Ok(secret_key) = std::fs::read(priv_key.clone()) {
371                    let mut sk_bytes = [0u8; SECRET_KEY_LENGTH];
372                    sk_bytes.copy_from_slice(z32::decode(secret_key.as_slice())?.as_slice());
373                    return Ok(SecretKey::from_bytes(&sk_bytes));
374                }
375            }
376            bail!(
377                "no iroh-ssh keys found in {}, use --persist flag to create it",
378                ssh_dir.display()
379            )
380        }
381    }
382}