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)]
138pub struct SshEndpoint {
139    /// Hostname to dial.
140    pub host: String,
141    /// Port to dial.
142    pub port: u32,
143}
144
145fn ssh_exec_params(
146    exec_endpoint: &str,
147    sailbox_id: &str,
148    argv: Vec<String>,
149    timeout_seconds: u32,
150) -> ExecParams {
151    ExecParams {
152        sailbox_id: sailbox_id.to_string(),
153        exec_endpoint: exec_endpoint.to_string(),
154        argv,
155        timeout_seconds,
156        idempotency_key: String::new(),
157        open_stdin: false,
158        pty: false,
159        term: String::new(),
160        cols: 0,
161        rows: 0,
162        env: std::collections::HashMap::default(),
163        retry_timeout: EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS,
164        forward_ports: false,
165        forward_browser: false,
166        extra_metadata: Vec::new(),
167    }
168}
169
170impl Client {
171    /// Id-form of [`Sailbox::enable_ssh`](crate::Sailbox::enable_ssh), which
172    /// documents the full contract.
173    #[doc(hidden)]
174    pub async fn enable_ssh(
175        &self,
176        sailbox_id: &str,
177        allowlist: &[String],
178        wait: bool,
179        timeout: Duration,
180    ) -> Result<Option<SshEndpoint>, SailError> {
181        // Drop blank allowlist entries (the scheduler does the same before
182        // storing) so effectively-empty input counts as omitted rather than
183        // taking the replace branch below and clearing an existing restriction.
184        let allowlist: Vec<String> = allowlist
185            .iter()
186            .map(|entry| entry.trim())
187            .filter(|entry| !entry.is_empty())
188            .map(String::from)
189            .collect();
190
191        // Fetch the org CA (read-only; creates the org's CA on first use) before
192        // touching the box, so a CA outage fails without resuming, exposing a
193        // port, or mutating the guest.
194        let ca_public_key = self.org_ssh_ca_public_key().await?;
195
196        // A private box admits only certificates carrying its creator's user-id
197        // principal, enforced via sshd's AuthorizedPrincipalsFile below.
198        let info = self.get_sailbox(sailbox_id).await?;
199        let private = info.visibility.as_deref() == Some("private");
200        if private && info.created_by_user_id.as_deref().unwrap_or("").is_empty() {
201            return Err(SailError::Internal {
202                message: format!(
203                    "sailbox {sailbox_id} is private but has no creator recorded; cannot configure creator-only SSH"
204                ),
205            });
206        }
207
208        let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
209
210        self.ssh_exec_check(
211            &exec_endpoint,
212            sailbox_id,
213            SSHD_SETUP,
214            SSHD_SETUP_TIMEOUT_SECONDS,
215            "sshd setup",
216        )
217        .await?;
218
219        // Install the org CA public key the guest sshd trusts (the dir now
220        // exists from sshd setup).
221        let mut writer = self.worker().write_file(
222            &exec_endpoint,
223            sailbox_id,
224            SSH_USER_CA_PATH,
225            /* create_parents */ true,
226            Some(0o644),
227        );
228        writer
229            .write_chunk(format!("{}\n", ca_public_key.trim()).into_bytes())
230            .await?;
231        writer.finish().await?;
232
233        if private {
234            let creator = info.created_by_user_id.as_deref().unwrap_or_default();
235            let mut writer = self.worker().write_file(
236                &exec_endpoint,
237                sailbox_id,
238                SSH_PRINCIPALS_PATH,
239                /* create_parents */ true,
240                Some(0o644),
241            );
242            writer
243                .write_chunk(format!("{creator}\n").into_bytes())
244                .await?;
245            writer.finish().await?;
246        }
247
248        // Start sshd detached; the daemon outlives this exec.
249        let proc = ExecProcess::start(
250            self.worker(),
251            ssh_exec_params(
252                &exec_endpoint,
253                sailbox_id,
254                vec![
255                    "/bin/sh".to_string(),
256                    "-c".to_string(),
257                    sshd_start_command(private),
258                ],
259                30,
260            ),
261        )
262        .await?;
263        proc.wait().await?;
264
265        // Confirm the CA-only daemon, not a leftover one, is serving port 22.
266        self.ssh_exec_check(
267            &exec_endpoint,
268            sailbox_id,
269            &verify_ca_sshd_command(private),
270            VERIFY_CA_SSHD_TIMEOUT_SECONDS,
271            "sshd ownership check",
272        )
273        .await?;
274
275        // Expose guest port 22 only now that the CA-only sshd is verified to own
276        // it. Expose is declarative — it replaces the stored allowlist — so a
277        // non-empty allowlist exposes unconditionally (creating or updating the
278        // listener), while an empty one must leave an existing listener
279        // untouched: a plain re-enable must not open a restricted port.
280        if allowlist.is_empty() {
281            match self.get_listener(sailbox_id, 22).await {
282                Ok(_) => {}
283                Err(SailError::NotFound { .. }) => {
284                    self.expose_listener(
285                        sailbox_id,
286                        22,
287                        crate::sailbox::types::IngressProtocol::Tcp,
288                        &[],
289                    )
290                    .await?;
291                }
292                Err(err) => return Err(err),
293            }
294        } else {
295            self.expose_listener(
296                sailbox_id,
297                22,
298                crate::sailbox::types::IngressProtocol::Tcp,
299                &allowlist,
300            )
301            .await?;
302        }
303
304        if !wait {
305            return Ok(None);
306        }
307        self.wait_for_ssh_listener(sailbox_id, timeout)
308            .await
309            .map(Some)
310    }
311
312    /// Run a single shell command in the guest and fail on a non-zero exit.
313    async fn ssh_exec_check(
314        &self,
315        exec_endpoint: &str,
316        sailbox_id: &str,
317        command: &str,
318        timeout_seconds: u32,
319        label: &str,
320    ) -> Result<(), SailError> {
321        let proc = ExecProcess::start(
322            self.worker(),
323            ssh_exec_params(
324                exec_endpoint,
325                sailbox_id,
326                vec!["/bin/sh".to_string(), "-c".to_string(), command.to_string()],
327                timeout_seconds,
328            ),
329        )
330        .await?;
331        let result = proc.wait().await?;
332        if result.exit_code != 0 {
333            let detail = if result.stderr.trim().is_empty() {
334                result.stdout.trim()
335            } else {
336                result.stderr.trim()
337            };
338            return Err(SailError::Internal {
339                message: format!("{label} failed (exit {}): {detail}", result.exit_code),
340            });
341        }
342        Ok(())
343    }
344
345    /// Poll the port-22 listener until the SSH endpoint actually accepts, up to
346    /// `timeout`. The route flips ACTIVE off the sailbox's running status, not a
347    /// guest-port probe, so it can report a host:port before the freshly-started
348    /// `sshd` is listening. Connecting and reading the SSH banner is the signal
349    /// that `ssh` will work on first use.
350    async fn wait_for_ssh_listener(
351        &self,
352        sailbox_id: &str,
353        timeout: Duration,
354    ) -> Result<SshEndpoint, SailError> {
355        // A saturated "no bound" timeout (Duration::MAX from the bindings)
356        // would overflow Instant addition; no deadline means wait indefinitely.
357        let deadline = Instant::now().checked_add(timeout);
358        loop {
359            if let Ok(listener) = self.get_listener(sailbox_id, 22).await {
360                if !listener.public_host.is_empty()
361                    && listener.public_port != 0
362                    && listener.is_active()
363                    && ssh_endpoint_accepts(&listener.public_host, listener.public_port).await
364                {
365                    return Ok(SshEndpoint {
366                        host: listener.public_host,
367                        port: listener.public_port,
368                    });
369                }
370            }
371            if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
372                return Err(SailError::Transport {
373                    kind: crate::error::TransportKind::Timeout,
374                    message: "timed out waiting for the SSH port to become reachable".to_string(),
375                    source: None,
376                });
377            }
378            tokio::time::sleep(Duration::from_secs(1)).await;
379        }
380    }
381}
382
383/// Whether the public endpoint answers with an SSH identification banner. A
384/// fresh sshd (or the relay before the guest dial succeeds) accepts the TCP
385/// connection but sends nothing, so the banner is the signal that ssh will work.
386async fn ssh_endpoint_accepts(host: &str, port: u32) -> bool {
387    use tokio::io::AsyncReadExt;
388    use tokio::net::TcpStream;
389
390    let probe = Duration::from_secs(5);
391    let addr = format!("{host}:{port}");
392    let Ok(Ok(mut stream)) = tokio::time::timeout(probe, TcpStream::connect(&addr)).await else {
393        return false;
394    };
395    // The banner ("SSH-2.0-...") can arrive split across reads, so accumulate up
396    // to its 4-byte prefix before deciding rather than rejecting a partial read.
397    let mut buf = [0u8; 4];
398    let mut filled = 0;
399    while filled < 4 {
400        match tokio::time::timeout(probe, stream.read(&mut buf[filled..])).await {
401            Ok(Ok(0)) | Err(_) => break,
402            Ok(Ok(n)) => filled += n,
403            Ok(Err(_)) => break,
404        }
405    }
406    buf[..filled].starts_with(b"SSH-")
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn sshd_start_enforces_ca_only_policy() {
415        assert!(SSHD_START.contains("TrustedUserCAKeys /etc/ssh/sail_user_ca.pub"));
416        assert!(SSHD_START.contains("AuthorizedKeysFile none"));
417        assert!(SSHD_START.contains("AuthorizedKeysCommand none"));
418        assert!(SSHD_START.contains("PasswordAuthentication no"));
419        // A CA cert alone must authenticate, regardless of the image's config.
420        assert!(SSHD_START.contains("PubkeyAuthentication yes"));
421        assert!(SSHD_START.contains("AuthenticationMethods publickey"));
422    }
423
424    /// Guard the string escaping: the rendered commands (both access modes)
425    /// must be valid `/bin/sh`.
426    #[test]
427    fn embedded_shell_snippets_are_valid() {
428        for (name, snippet) in [
429            ("SSHD_START(org)", sshd_start_command(/* private */ false)),
430            (
431                "SSHD_START(private)",
432                sshd_start_command(/* private */ true),
433            ),
434            (
435                "VERIFY_CA_SSHD(org)",
436                verify_ca_sshd_command(/* private */ false),
437            ),
438            (
439                "VERIFY_CA_SSHD(private)",
440                verify_ca_sshd_command(/* private */ true),
441            ),
442        ] {
443            let status = std::process::Command::new("sh")
444                .args(["-n", "-c", &snippet])
445                .status()
446                .expect("run sh -n");
447            assert!(status.success(), "{name} is not valid shell");
448        }
449    }
450
451    #[test]
452    fn verify_matches_the_ca_only_daemon() {
453        assert!(VERIFY_CA_SSHD.contains("TrustedUserCAKeys"));
454        assert!(SSHD_START.contains("TrustedUserCAKeys"));
455    }
456
457    /// Private boxes start sshd with the creator principals file and verify the
458    /// daemon carries it; org boxes must NOT carry it, so a stale daemon from
459    /// the other mode can never pass verification. No unrendered placeholder
460    /// may survive into a guest command.
461    #[test]
462    fn principals_mode_renders_correctly() {
463        let private_start = sshd_start_command(/* private */ true);
464        // Guard the escaping: the principals flag stays inline and
465        // space-separated from the adjacent CA-only flags, so the rendered
466        // command is one sshd invocation rather than two.
467        assert!(private_start.contains(&format!(
468            "-o 'AuthorizedPrincipalsFile {SSH_PRINCIPALS_PATH}' -o 'AuthorizedKeysFile none'"
469        )));
470        let org_start = sshd_start_command(/* private */ false);
471        assert!(!org_start.contains("AuthorizedPrincipalsFile"));
472        let private_verify = verify_ca_sshd_command(/* private */ true);
473        assert!(private_verify.contains("*AuthorizedPrincipalsFile*) ;;"));
474        let org_verify = verify_ca_sshd_command(/* private */ false);
475        assert!(org_verify.contains("*AuthorizedPrincipalsFile*) continue;;"));
476        for rendered in [private_start, org_start, private_verify, org_verify] {
477            assert!(!rendered.contains("__PRINCIPALS"));
478        }
479    }
480}