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