Skip to main content

iroh_ssh/
ssh.rs

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