Skip to main content

sail/sailbox/
ssh.rs

1//! SSH enablement for Sailboxes, shared by the SDK and the CLI.
2//!
3//! A box trusts its org's SSH certificate authority rather than individual keys:
4//! enabling SSH installs the org CA public key as `TrustedUserCAKeys`,
5//! (re)starts `sshd`, and exposes guest port 22 as TCP ingress once the CA-only
6//! daemon verifiably owns it. Anyone in the org then connects with a
7//! short-lived certificate the org CA signs for their key (minted via
8//! [`Client::issue_user_cert`]) without per-box key setup; a private box is
9//! the exception, admitting only certificates carrying its creator's user-id
10//! principal. The host key is
11//! generated once with `ssh-keygen -A`, so re-running is safe and a client's
12//! `known_hosts` stays valid. openssh is baked into every base and built image,
13//! so setup only regenerates host keys and starts the server.
14
15use std::time::{Duration, Instant};
16
17use crate::error::SailError;
18use crate::exec::{ExecParams, ExecProcess, EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS};
19use crate::Client;
20
21/// Options for [`Sailbox::enable_ssh`](crate::Sailbox::enable_ssh).
22#[derive(Debug, Clone)]
23pub struct EnableSshOptions {
24    /// Source restriction for the exposed port 22 (CIDRs). Empty means any
25    /// source on a first enable; a re-enable keeps an existing restriction.
26    pub allowlist: Vec<String>,
27    /// Wait for the SSH endpoint to be reachable before returning.
28    pub wait: bool,
29    /// Bound on the reachability wait; 60 seconds is a good default.
30    pub timeout: std::time::Duration,
31}
32
33impl Default for EnableSshOptions {
34    fn default() -> EnableSshOptions {
35        EnableSshOptions {
36            allowlist: Vec::new(),
37            wait: true,
38            timeout: std::time::Duration::from_mins(1),
39        }
40    }
41}
42
43/// Where the org CA public key is installed in the guest. sshd accepts any
44/// certificate this CA signs; no `authorized_keys` is used.
45const SSH_USER_CA_PATH: &str = "/etc/ssh/sail_user_ca.pub";
46/// Principals file written on private boxes: it lists the creator's user id, and
47/// sshd then admits only certificates carrying that principal (the mint path
48/// stamps the requesting user's id alongside "root"). Org-wide boxes have no
49/// principals file, so every org cert's "root" principal keeps working there.
50const SSH_PRINCIPALS_PATH: &str = "/etc/ssh/sail_authorized_principals";
51/// Guest prep before starting sshd: make the config/runtime dirs, generate host
52/// keys once (`ssh-keygen -A` never rotates an existing key), and clear root's
53/// password (base images ship root locked) so cert login as `root` isn't
54/// refused. Password login stays disabled by [`SSHD_START`]'s `-o` flags.
55const SSHD_SETUP: &str = "mkdir -p /etc/ssh /run/sshd && ssh-keygen -A && passwd -d root";
56const SSHD_SETUP_TIMEOUT_SECONDS: u32 = 60;
57/// Kill whichever sshd holds the port-22 listening socket (so the CA-only daemon
58/// below takes over), then start it detached. The listener is identified by the
59/// socket it owns, not by parentage or process title: `/proc/net/tcp{,6}` gives
60/// the inode of the port-22 `LISTEN` socket and `/proc/<pid>/fd` reveals which
61/// sshd holds it. Per-connection session children own established sockets, not
62/// the listening one, so they are preserved and re-running does not drop
63/// connected users; a pre-existing master started any way (our `-D` daemon, or a
64/// custom image's `service ssh start`) is replaced, so a leftover
65/// password/`authorized_keys` daemon is never left serving. CA-only policy is
66/// passed as `-o` options rather than written into `sshd_config` so it cannot be
67/// overridden by an existing config or a `Match` block: certificates are the
68/// only accepted credential. The `-o` set also forces the auth path a cert needs
69/// (`PubkeyAuthentication yes`, `AuthenticationMethods publickey`) so an image
70/// config that disabled public-key auth or required a multi-step chain cannot
71/// make cert logins silently fail.
72const SSHD_START: &str = "ino=$(for f in /proc/net/tcp /proc/net/tcp6; do \
73[ -r \"$f\" ] && awk '$4==\"0A\" && $2 ~ /:0016$/ {print $10}' \"$f\"; done); \
74pids=''; for d in /proc/[0-9]*; do \
75[ \"$(cat \"$d/comm\" 2>/dev/null)\" = sshd ] || continue; \
76for fd in \"$d\"/fd/*; do \
77l=$(readlink \"$fd\" 2>/dev/null) || continue; \
78for i in $ino; do [ \"$l\" = \"socket:[$i]\" ] || continue; \
79p=${d#/proc/}; case \" $pids \" in *\" $p \"*) ;; *) pids=\"$pids $p\";; esac; \
80done; done; done; \
81[ -n \"$pids\" ] && kill $pids 2>/dev/null; sleep 1; \
82nohup /usr/sbin/sshd -D -e \
83-o 'PermitRootLogin prohibit-password' \
84-o 'PasswordAuthentication no' \
85-o 'PubkeyAuthentication yes' \
86-o 'AuthenticationMethods publickey' \
87-o 'TrustedUserCAKeys /etc/ssh/sail_user_ca.pub' \
88__PRINCIPALS_OPT__\
89-o 'AuthorizedKeysFile none' \
90-o 'AuthorizedKeysCommand none' </dev/null >/dev/null 2>&1 &";
91const VERIFY_CA_SSHD_TIMEOUT_SECONDS: u32 = 30;
92/// Confirm the CA-only daemon actually owns port 22 before reporting success.
93/// The daemon is backgrounded, so a failed bind (a non-`sshd` service already on
94/// port 22, or a killed master slow to release the socket) would otherwise go
95/// unnoticed and the box would keep serving a pre-existing password/
96/// `authorized_keys` daemon. Poll until the process holding the port-22 listen
97/// socket is an sshd whose command line carries our `TrustedUserCAKeys` option;
98/// exit non-zero (failing the enable) if it never does.
99const VERIFY_CA_SSHD: &str = "for _ in 1 2 3 4 5 6 7 8 9 10; do sleep 1; \
100ino=$(for f in /proc/net/tcp /proc/net/tcp6; do \
101[ -r \"$f\" ] && awk '$4==\"0A\" && $2 ~ /:0016$/ {print $10}' \"$f\"; done); \
102[ -n \"$ino\" ] || continue; \
103for d in /proc/[0-9]*; do \
104[ \"$(cat \"$d/comm\" 2>/dev/null)\" = sshd ] || continue; \
105cl=\"$(tr '\\0' ' ' < \"$d/cmdline\" 2>/dev/null)\"; \
106case \"$cl\" in *TrustedUserCAKeys*) ;; *) continue;; esac; \
107__PRINCIPALS_CHECK__\
108for fd in \"$d\"/fd/*; do l=$(readlink \"$fd\" 2>/dev/null) || continue; \
109for i in $ino; do [ \"$l\" = \"socket:[$i]\" ] && exit 0; done; done; \
110done; done; \
111echo 'CA-only sshd did not take over port 22' >&2; exit 1";
112/// Render [`SSHD_START`] / [`VERIFY_CA_SSHD`] for the box's access mode.
113/// Private boxes start sshd with `AuthorizedPrincipalsFile` (creator-only
114/// logins) and the verifier requires that option on the daemon owning port
115/// 22; org-wide boxes must NOT carry it, so a leftover private-mode daemon
116/// can't silently keep enforcing (or a stale org daemon keep ignoring) the
117/// principals file after a re-enable.
118fn sshd_start_command(private: bool) -> String {
119    let opt = if private {
120        format!("-o 'AuthorizedPrincipalsFile {SSH_PRINCIPALS_PATH}' ")
121    } else {
122        String::new()
123    };
124    SSHD_START.replace("__PRINCIPALS_OPT__", &opt)
125}
126
127fn verify_ca_sshd_command(private: bool) -> String {
128    let check = if private {
129        "case \"$cl\" in *AuthorizedPrincipalsFile*) ;; *) continue;; esac; "
130    } else {
131        "case \"$cl\" in *AuthorizedPrincipalsFile*) continue;; esac; "
132    };
133    VERIFY_CA_SSHD.replace("__PRINCIPALS_CHECK__", check)
134}
135
136/// The public TCP endpoint a Sailbox's SSH listener is reachable at.
137#[derive(Debug, Clone)]
138#[non_exhaustive]
139pub struct SshEndpoint {
140    /// Hostname to dial.
141    pub host: String,
142    /// Port to dial.
143    pub port: u32,
144}
145
146fn ssh_exec_params(
147    exec_endpoint: &str,
148    sailbox_id: &str,
149    argv: Vec<String>,
150    timeout_seconds: u32,
151) -> ExecParams {
152    ExecParams {
153        sailbox_id: sailbox_id.to_string(),
154        exec_endpoint: exec_endpoint.to_string(),
155        argv,
156        timeout_seconds,
157        idempotency_key: String::new(),
158        open_stdin: false,
159        pty: false,
160        term: String::new(),
161        cols: 0,
162        rows: 0,
163        env: std::collections::HashMap::default(),
164        retry_timeout: EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS,
165        forward_ports: false,
166        forward_browser: false,
167        extra_metadata: Vec::new(),
168        forward_clipboard: false,
169    }
170}
171
172impl Client {
173    /// Id-form of [`Sailbox::enable_ssh`](crate::Sailbox::enable_ssh), which
174    /// documents the full contract.
175    #[doc(hidden)]
176    pub async fn enable_ssh(
177        &self,
178        sailbox_id: &str,
179        allowlist: &[String],
180        wait: bool,
181        timeout: Duration,
182    ) -> Result<Option<SshEndpoint>, SailError> {
183        // Drop blank allowlist entries (the scheduler does the same before
184        // storing) so effectively-empty input counts as omitted rather than
185        // taking the replace branch below and clearing an existing restriction.
186        let allowlist: Vec<String> = allowlist
187            .iter()
188            .map(|entry| entry.trim())
189            .filter(|entry| !entry.is_empty())
190            .map(String::from)
191            .collect();
192
193        // Fetch the org CA (read-only; creates the org's CA on first use) before
194        // touching the box, so a CA outage fails without resuming, exposing a
195        // port, or mutating the guest.
196        let ca_public_key = self.org_ssh_ca_public_key().await?;
197
198        // A private box admits only certificates carrying its creator's user-id
199        // principal, enforced via sshd's AuthorizedPrincipalsFile below.
200        let info = self.get_sailbox(sailbox_id).await?;
201        let private = info.visibility.as_deref() == Some("private");
202        if private && info.created_by_user_id.as_deref().unwrap_or("").is_empty() {
203            return Err(SailError::Internal {
204                message: format!(
205                    "sailbox {sailbox_id} is private but has no creator recorded; cannot configure creator-only SSH"
206                ),
207            });
208        }
209
210        let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
211
212        self.ssh_exec_check(
213            &exec_endpoint,
214            sailbox_id,
215            SSHD_SETUP,
216            SSHD_SETUP_TIMEOUT_SECONDS,
217            "sshd setup",
218        )
219        .await?;
220
221        // Install the org CA public key the guest sshd trusts (the dir now
222        // exists from sshd setup).
223        let mut writer = self.worker().write_file(
224            &exec_endpoint,
225            sailbox_id,
226            SSH_USER_CA_PATH,
227            /* create_parents */ true,
228            Some(0o644),
229        );
230        writer
231            .write_chunk(format!("{}\n", ca_public_key.trim()).into_bytes())
232            .await?;
233        writer.finish().await?;
234
235        if private {
236            let creator = info.created_by_user_id.as_deref().unwrap_or_default();
237            let mut writer = self.worker().write_file(
238                &exec_endpoint,
239                sailbox_id,
240                SSH_PRINCIPALS_PATH,
241                /* create_parents */ true,
242                Some(0o644),
243            );
244            writer
245                .write_chunk(format!("{creator}\n").into_bytes())
246                .await?;
247            writer.finish().await?;
248        }
249
250        // Start sshd detached; the daemon outlives this exec.
251        let proc = ExecProcess::start(
252            self.worker(),
253            ssh_exec_params(
254                &exec_endpoint,
255                sailbox_id,
256                vec![
257                    "/bin/sh".to_string(),
258                    "-c".to_string(),
259                    sshd_start_command(private),
260                ],
261                30,
262            ),
263        )
264        .await?;
265        proc.wait().await?;
266
267        // Confirm the CA-only daemon, not a leftover one, is serving port 22.
268        self.ssh_exec_check(
269            &exec_endpoint,
270            sailbox_id,
271            &verify_ca_sshd_command(private),
272            VERIFY_CA_SSHD_TIMEOUT_SECONDS,
273            "sshd ownership check",
274        )
275        .await?;
276
277        // Expose guest port 22 only now that the CA-only sshd is verified to own
278        // it. Expose is declarative — it replaces the stored allowlist — so a
279        // non-empty allowlist exposes unconditionally (creating or updating the
280        // listener), while an empty one must leave an existing listener
281        // untouched: a plain re-enable must not open a restricted port.
282        if allowlist.is_empty() {
283            match self.get_listener(sailbox_id, 22).await {
284                Ok(_) => {}
285                Err(SailError::NotFound { .. }) => {
286                    self.expose_listener(
287                        sailbox_id,
288                        22,
289                        crate::sailbox::types::IngressProtocol::Tcp,
290                        &[],
291                    )
292                    .await?;
293                }
294                Err(err) => return Err(err),
295            }
296        } else {
297            self.expose_listener(
298                sailbox_id,
299                22,
300                crate::sailbox::types::IngressProtocol::Tcp,
301                &allowlist,
302            )
303            .await?;
304        }
305
306        if !wait {
307            return Ok(None);
308        }
309        self.wait_for_ssh_listener(sailbox_id, timeout)
310            .await
311            .map(Some)
312    }
313
314    /// Run a single shell command in the guest and fail on a non-zero exit.
315    async fn ssh_exec_check(
316        &self,
317        exec_endpoint: &str,
318        sailbox_id: &str,
319        command: &str,
320        timeout_seconds: u32,
321        label: &str,
322    ) -> Result<(), SailError> {
323        let proc = ExecProcess::start(
324            self.worker(),
325            ssh_exec_params(
326                exec_endpoint,
327                sailbox_id,
328                vec!["/bin/sh".to_string(), "-c".to_string(), command.to_string()],
329                timeout_seconds,
330            ),
331        )
332        .await?;
333        let result = proc.wait().await?;
334        if result.exit_code != 0 {
335            let detail = if result.stderr.trim().is_empty() {
336                result.stdout.trim()
337            } else {
338                result.stderr.trim()
339            };
340            return Err(SailError::Internal {
341                message: format!("{label} failed (exit {}): {detail}", result.exit_code),
342            });
343        }
344        Ok(())
345    }
346
347    /// Poll the port-22 listener until the SSH endpoint actually accepts, up to
348    /// `timeout`. The route flips ACTIVE off the Sailbox's running status, not a
349    /// guest-port probe, so it can report a host:port before the freshly-started
350    /// `sshd` is listening. Connecting and reading the SSH banner is the signal
351    /// that `ssh` will work on first use.
352    async fn wait_for_ssh_listener(
353        &self,
354        sailbox_id: &str,
355        timeout: Duration,
356    ) -> Result<SshEndpoint, SailError> {
357        // A saturated "no bound" timeout (Duration::MAX from the bindings)
358        // would overflow Instant addition; no deadline means wait indefinitely.
359        let deadline = Instant::now().checked_add(timeout);
360        loop {
361            if let Ok(listener) = self.get_listener(sailbox_id, 22).await {
362                if !listener.public_host.is_empty()
363                    && listener.public_port != 0
364                    && listener.is_active()
365                    && ssh_endpoint_accepts(&listener.public_host, listener.public_port).await
366                {
367                    return Ok(SshEndpoint {
368                        host: listener.public_host,
369                        port: listener.public_port,
370                    });
371                }
372            }
373            if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
374                return Err(SailError::Transport {
375                    kind: crate::error::TransportKind::Timeout,
376                    message: "timed out waiting for the SSH port to become reachable".to_string(),
377                    source: None,
378                });
379            }
380            tokio::time::sleep(Duration::from_secs(1)).await;
381        }
382    }
383}
384
385/// Whether the public endpoint answers with an SSH identification banner. A
386/// fresh sshd (or the relay before the guest dial succeeds) accepts the TCP
387/// connection but sends nothing, so the banner is the signal that ssh will work.
388async fn ssh_endpoint_accepts(host: &str, port: u32) -> bool {
389    use tokio::io::AsyncReadExt;
390    use tokio::net::TcpStream;
391
392    let probe = Duration::from_secs(5);
393    let addr = format!("{host}:{port}");
394    let Ok(Ok(mut stream)) = tokio::time::timeout(probe, TcpStream::connect(&addr)).await else {
395        return false;
396    };
397    // The banner ("SSH-2.0-...") can arrive split across reads, so accumulate up
398    // to its 4-byte prefix before deciding rather than rejecting a partial read.
399    let mut buf = [0u8; 4];
400    let mut filled = 0;
401    while filled < 4 {
402        match tokio::time::timeout(probe, stream.read(&mut buf[filled..])).await {
403            Ok(Ok(0)) | Err(_) => break,
404            Ok(Ok(n)) => filled += n,
405            Ok(Err(_)) => break,
406        }
407    }
408    buf[..filled].starts_with(b"SSH-")
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn sshd_start_enforces_ca_only_policy() {
417        assert!(SSHD_START.contains("TrustedUserCAKeys /etc/ssh/sail_user_ca.pub"));
418        assert!(SSHD_START.contains("AuthorizedKeysFile none"));
419        assert!(SSHD_START.contains("AuthorizedKeysCommand none"));
420        assert!(SSHD_START.contains("PasswordAuthentication no"));
421        // A CA cert alone must authenticate, regardless of the image's config.
422        assert!(SSHD_START.contains("PubkeyAuthentication yes"));
423        assert!(SSHD_START.contains("AuthenticationMethods publickey"));
424    }
425
426    /// Guard the string escaping: the rendered commands (both access modes)
427    /// must be valid `/bin/sh`.
428    #[test]
429    fn embedded_shell_snippets_are_valid() {
430        for (name, snippet) in [
431            ("SSHD_START(org)", sshd_start_command(/* private */ false)),
432            (
433                "SSHD_START(private)",
434                sshd_start_command(/* private */ true),
435            ),
436            (
437                "VERIFY_CA_SSHD(org)",
438                verify_ca_sshd_command(/* private */ false),
439            ),
440            (
441                "VERIFY_CA_SSHD(private)",
442                verify_ca_sshd_command(/* private */ true),
443            ),
444        ] {
445            let status = std::process::Command::new("sh")
446                .args(["-n", "-c", &snippet])
447                .status()
448                .expect("run sh -n");
449            assert!(status.success(), "{name} is not valid shell");
450        }
451    }
452
453    #[test]
454    fn verify_matches_the_ca_only_daemon() {
455        assert!(VERIFY_CA_SSHD.contains("TrustedUserCAKeys"));
456        assert!(SSHD_START.contains("TrustedUserCAKeys"));
457    }
458
459    /// Private boxes start sshd with the creator principals file and verify the
460    /// daemon carries it; org boxes must NOT carry it, so a stale daemon from
461    /// the other mode can never pass verification. No unrendered placeholder
462    /// may survive into a guest command.
463    #[test]
464    fn principals_mode_renders_correctly() {
465        let private_start = sshd_start_command(/* private */ true);
466        // Guard the escaping: the principals flag stays inline and
467        // space-separated from the adjacent CA-only flags, so the rendered
468        // command is one sshd invocation rather than two.
469        assert!(private_start.contains(&format!(
470            "-o 'AuthorizedPrincipalsFile {SSH_PRINCIPALS_PATH}' -o 'AuthorizedKeysFile none'"
471        )));
472        let org_start = sshd_start_command(/* private */ false);
473        assert!(!org_start.contains("AuthorizedPrincipalsFile"));
474        let private_verify = verify_ca_sshd_command(/* private */ true);
475        assert!(private_verify.contains("*AuthorizedPrincipalsFile*) ;;"));
476        let org_verify = verify_ca_sshd_command(/* private */ false);
477        assert!(org_verify.contains("*AuthorizedPrincipalsFile*) continue;;"));
478        for rendered in [private_start, org_start, private_verify, org_verify] {
479            assert!(!rendered.contains("__PRINCIPALS"));
480        }
481    }
482}