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