Skip to main content

sail/sailbox/
ssh.rs

1//! SSH enablement for sailboxes, shared by the SDK and the CLI.
2//!
3//! Resolve a local public key, reserve guest port 22 as TCP ingress, install the
4//! key, and (re)start `sshd`. The host key is generated once with `ssh-keygen
5//! -A`, so re-running is safe and a client's `known_hosts` stays valid. openssh
6//! is baked into every base and built image, so setup only regenerates host keys
7//! and hardens the config; it never installs the server.
8
9use std::time::{Duration, Instant};
10
11use crate::error::SailError;
12use crate::exec::{ExecParams, ExecProcess, EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS};
13use crate::Client;
14
15const SSHD_KEY_DIR_SETUP: &str = "mkdir -p /root/.ssh";
16const AUTHORIZED_KEYS_PATH: &str = "/root/.ssh/authorized_keys";
17const SSHD_SETUP: &str = "ssh-keygen -A && \
18chown root:root /root /root/.ssh /root/.ssh/authorized_keys && \
19chmod 700 /root/.ssh && \
20passwd -d root && \
21sed -i 's/^#*PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config && \
22sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config && \
23mkdir -p /run/sshd";
24const SSHD_SETUP_TIMEOUT_SECONDS: u32 = 60;
25const SSHD_START: &str = "/usr/sbin/sshd -D -e";
26const SSH_PUBLIC_KEY_FILENAMES: &[&str] = &["id_ed25519.pub", "id_rsa.pub"];
27const KEY_PREFIXES: &[&str] = &["ssh-", "ecdsa-", "sk-"];
28
29/// The public TCP endpoint a sailbox's SSH listener is reachable at.
30#[derive(Debug, Clone)]
31pub struct SshEndpoint {
32    /// Hostname to dial.
33    pub host: String,
34    /// Port to dial.
35    pub port: u32,
36}
37
38/// Resolve an SSH public key from an explicit value or `~/.ssh`. A value with a
39/// space and a known key prefix is treated as a literal key; any other non-empty
40/// value is a path to a `.pub` file. With no value, `~/.ssh/id_ed25519.pub` then
41/// `id_rsa.pub` are tried.
42pub fn resolve_public_key(value: Option<&str>) -> Result<String, SailError> {
43    if let Some(raw) = value {
44        let text = raw.trim();
45        if !text.is_empty() {
46            if text.contains(' ') && KEY_PREFIXES.iter().any(|p| text.starts_with(p)) {
47                return validate_key(text);
48            }
49            let path = crate::credentials::expand_user(text);
50            let key = std::fs::read_to_string(&path).map_err(|_| SailError::InvalidArgument {
51                message: format!(
52                    "ssh public key {text:?} is neither a recognized public key nor an existing file path"
53                ),
54            })?;
55            let key = key.trim();
56            if key.is_empty() {
57                return Err(SailError::InvalidArgument {
58                    message: format!("ssh public key file {text:?} is empty"),
59                });
60            }
61            return validate_key(key);
62        }
63    }
64    let home = dirs::home_dir().unwrap_or_default();
65    let mut searched = Vec::new();
66    for name in SSH_PUBLIC_KEY_FILENAMES {
67        let candidate = home.join(".ssh").join(name);
68        searched.push(candidate.display().to_string());
69        if let Ok(key) = std::fs::read_to_string(&candidate) {
70            let key = key.trim();
71            if !key.is_empty() {
72                return validate_key(key);
73            }
74        }
75    }
76    Err(SailError::InvalidArgument {
77        message: format!(
78            "no SSH public key found at {}; provide one explicitly",
79            searched.join(" or ")
80        ),
81    })
82}
83
84/// Verify a value is a well-formed SSH public key: `<type> <base64 blob>` whose
85/// blob begins with a length-prefixed copy of the type. Catching a malformed key
86/// here turns it into a clear error instead of a silent sshd auth failure once
87/// the box is up.
88fn validate_key(key: &str) -> Result<String, SailError> {
89    use base64::prelude::{Engine as _, BASE64_STANDARD};
90
91    let invalid = |message: &str| SailError::InvalidArgument {
92        message: message.to_string(),
93    };
94    let mut parts = key.split_whitespace();
95    let key_type = parts.next().unwrap_or_default();
96    let blob_b64 = parts.next().unwrap_or_default();
97    if !KEY_PREFIXES.iter().any(|p| key_type.starts_with(p)) {
98        return Err(invalid(
99            "value does not look like an SSH public key (ssh-ed25519/ssh-rsa/ecdsa-.../sk-...)",
100        ));
101    }
102    let blob = BASE64_STANDARD
103        .decode(blob_b64)
104        .map_err(|_| invalid("SSH public key body is not valid base64"))?;
105    let type_len = blob
106        .get(..4)
107        .map_or(0, |b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize);
108    if blob.get(4..4 + type_len) != Some(key_type.as_bytes()) {
109        return Err(invalid("SSH public key type does not match its body"));
110    }
111    Ok(key.to_string())
112}
113
114/// Build the idempotent `authorized_keys` merge command for an uploaded temp key.
115fn authorized_keys_merge_command(tmp_path: &str) -> String {
116    let authorized = shell_words::quote(AUTHORIZED_KEYS_PATH);
117    let tmp = shell_words::quote(tmp_path);
118    format!(
119        "set -e; touch {authorized}; \
120if ! grep -qxF -f {tmp} {authorized}; then \
121if [ -s {authorized} ] && [ \"$(tail -c 1 {authorized})\" != '' ]; then \
122printf '\\n' >> {authorized}; \
123fi; \
124cat {tmp} >> {authorized}; \
125fi; \
126rm -f {tmp}; \
127chmod 600 {authorized}"
128    )
129}
130
131fn ssh_exec_params(
132    exec_endpoint: &str,
133    sailbox_id: &str,
134    argv: Vec<String>,
135    timeout_seconds: u32,
136) -> ExecParams {
137    ExecParams {
138        sailbox_id: sailbox_id.to_string(),
139        exec_endpoint: exec_endpoint.to_string(),
140        argv,
141        timeout_seconds,
142        idempotency_key: String::new(),
143        open_stdin: false,
144        pty: false,
145        term: String::new(),
146        cols: 0,
147        rows: 0,
148        retry_timeout: EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS,
149        extra_metadata: Vec::new(),
150    }
151}
152
153impl Client {
154    /// Make a running sailbox reachable over SSH, returning the endpoint when
155    /// `wait` is set (else `None`). Resumes (wakes) the sailbox to reach it,
156    /// reserves guest port 22 as TCP ingress if not already exposed, installs
157    /// `public_key`, and (re)starts `sshd`. Idempotent and safe to re-run.
158    pub async fn enable_ssh(
159        &self,
160        sailbox_id: &str,
161        public_key: &str,
162        wait: bool,
163        timeout: Duration,
164    ) -> Result<Option<SshEndpoint>, SailError> {
165        let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
166
167        // Reuse an existing port-22 listener (preserving any caller allowlist);
168        // expose it as TCP only if absent.
169        match self.get_listener(sailbox_id, 22).await {
170            Ok(_) => {}
171            Err(SailError::NotFound { .. }) => {
172                self.expose_listener(sailbox_id, 22, "tcp", &[]).await?;
173            }
174            Err(err) => return Err(err),
175        }
176
177        self.ssh_exec_check(
178            &exec_endpoint,
179            sailbox_id,
180            SSHD_KEY_DIR_SETUP,
181            30,
182            "ssh key dir setup",
183        )
184        .await?;
185
186        // Upload the key to a temp path, then atomically merge it into
187        // authorized_keys without removing existing keys.
188        let tmp_path = format!(
189            "/root/.ssh/.sail-authorized-key-{}.tmp",
190            uuid::Uuid::new_v4().simple()
191        );
192        let mut writer =
193            self.worker()
194                .write_file(&exec_endpoint, sailbox_id, &tmp_path, true, Some(0o600));
195        writer
196            .write_chunk(format!("{public_key}\n").into_bytes())
197            .await?;
198        writer.finish().await?;
199
200        self.ssh_exec_check(
201            &exec_endpoint,
202            sailbox_id,
203            &authorized_keys_merge_command(&tmp_path),
204            30,
205            "ssh key install",
206        )
207        .await?;
208        self.ssh_exec_check(
209            &exec_endpoint,
210            sailbox_id,
211            SSHD_SETUP,
212            SSHD_SETUP_TIMEOUT_SECONDS,
213            "sshd setup",
214        )
215        .await?;
216
217        // Start sshd detached; the daemon outlives this exec.
218        let start = format!("nohup {SSHD_START} </dev/null >/dev/null 2>&1 &");
219        let proc = ExecProcess::start(
220            self.worker(),
221            ssh_exec_params(
222                &exec_endpoint,
223                sailbox_id,
224                vec!["/bin/sh".to_string(), "-c".to_string(), start],
225                30,
226            ),
227        )
228        .await?;
229        proc.wait(EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS).await?;
230
231        if !wait {
232            return Ok(None);
233        }
234        self.wait_for_ssh_listener(sailbox_id, timeout)
235            .await
236            .map(Some)
237    }
238
239    /// Run a single shell command in the guest and fail on a non-zero exit.
240    async fn ssh_exec_check(
241        &self,
242        exec_endpoint: &str,
243        sailbox_id: &str,
244        command: &str,
245        timeout_seconds: u32,
246        label: &str,
247    ) -> Result<(), SailError> {
248        let proc = ExecProcess::start(
249            self.worker(),
250            ssh_exec_params(
251                exec_endpoint,
252                sailbox_id,
253                vec!["/bin/sh".to_string(), "-c".to_string(), command.to_string()],
254                timeout_seconds,
255            ),
256        )
257        .await?;
258        let result = proc.wait(EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS).await?;
259        if result.return_code != 0 {
260            let detail = if result.stderr.trim().is_empty() {
261                result.stdout.trim()
262            } else {
263                result.stderr.trim()
264            };
265            return Err(SailError::Internal {
266                message: format!("{label} failed (exit {}): {detail}", result.return_code),
267            });
268        }
269        Ok(())
270    }
271
272    /// Poll the port-22 listener until the SSH endpoint actually accepts, up to
273    /// `timeout`. The route flips ACTIVE off the sailbox's running status, not a
274    /// guest-port probe, so it can report a host:port before the freshly-started
275    /// `sshd` is listening. Connecting and reading the SSH banner is the signal
276    /// that the printed command will work on first use.
277    async fn wait_for_ssh_listener(
278        &self,
279        sailbox_id: &str,
280        timeout: Duration,
281    ) -> Result<SshEndpoint, SailError> {
282        let deadline = Instant::now() + timeout;
283        loop {
284            if let Ok(listener) = self.get_listener(sailbox_id, 22).await {
285                if !listener.public_host.is_empty()
286                    && listener.public_port != 0
287                    && listener.route_status.contains("ACTIVE")
288                    && ssh_endpoint_accepts(&listener.public_host, listener.public_port).await
289                {
290                    return Ok(SshEndpoint {
291                        host: listener.public_host,
292                        port: listener.public_port,
293                    });
294                }
295            }
296            if Instant::now() >= deadline {
297                return Err(SailError::Internal {
298                    message: "timed out waiting for the SSH port to become reachable".to_string(),
299                });
300            }
301            tokio::time::sleep(Duration::from_secs(1)).await;
302        }
303    }
304}
305
306/// Whether the public endpoint answers with an SSH identification banner. A
307/// fresh sshd (or the relay before the guest dial succeeds) accepts the TCP
308/// connection but sends nothing, so the banner is the signal that ssh will work.
309async fn ssh_endpoint_accepts(host: &str, port: u32) -> bool {
310    use tokio::io::AsyncReadExt;
311    use tokio::net::TcpStream;
312
313    let probe = Duration::from_secs(5);
314    let addr = format!("{host}:{port}");
315    let Ok(Ok(mut stream)) = tokio::time::timeout(probe, TcpStream::connect(&addr)).await else {
316        return false;
317    };
318    // The banner ("SSH-2.0-...") can arrive split across reads, so accumulate up
319    // to its 4-byte prefix before deciding rather than rejecting a partial read.
320    let mut buf = [0u8; 4];
321    let mut filled = 0;
322    while filled < 4 {
323        match tokio::time::timeout(probe, stream.read(&mut buf[filled..])).await {
324            Ok(Ok(0)) | Err(_) => break,
325            Ok(Ok(n)) => filled += n,
326            Ok(Err(_)) => break,
327        }
328    }
329    buf[..filled].starts_with(b"SSH-")
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn literal_key_is_accepted() {
338        let key =
339            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f comment";
340        assert_eq!(resolve_public_key(Some(key)).unwrap(), key);
341    }
342
343    #[test]
344    fn non_key_non_path_is_rejected() {
345        assert!(matches!(
346            resolve_public_key(Some("not-a-key")),
347            Err(SailError::InvalidArgument { .. })
348        ));
349    }
350
351    #[test]
352    fn key_with_malformed_body_is_rejected() {
353        assert!(matches!(
354            resolve_public_key(Some("ssh-ed25519 AAAAC3Nz comment")),
355            Err(SailError::InvalidArgument { .. })
356        ));
357    }
358
359    #[test]
360    fn merge_command_quotes_paths_and_chmods() {
361        let cmd = authorized_keys_merge_command("/root/.ssh/.tmp");
362        assert!(cmd.contains("/root/.ssh/authorized_keys"));
363        assert!(cmd.contains("chmod 600"));
364    }
365}