1use std::time::{Duration, Instant};
16
17use crate::error::SailError;
18use crate::exec::{ExecParams, ExecProcess, EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS};
19use crate::Client;
20
21#[derive(Debug, Clone)]
23pub struct EnableSshOptions {
24 pub allowlist: Vec<String>,
27 pub wait: bool,
29 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
43const SSH_USER_CA_PATH: &str = "/etc/ssh/sail_user_ca.pub";
46const SSH_PRINCIPALS_PATH: &str = "/etc/ssh/sail_authorized_principals";
51const SSHD_SETUP: &str = "mkdir -p /etc/ssh /run/sshd && ssh-keygen -A && passwd -d root";
56const SSHD_SETUP_TIMEOUT_SECONDS: u32 = 60;
57const 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;
92const 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";
112fn 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#[derive(Debug, Clone)]
138pub struct SshEndpoint {
139 pub host: String,
141 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 #[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 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 let ca_public_key = self.org_ssh_ca_public_key().await?;
195
196 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 let mut writer = self.worker().write_file(
222 &exec_endpoint,
223 sailbox_id,
224 SSH_USER_CA_PATH,
225 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 true,
240 Some(0o644),
241 );
242 writer
243 .write_chunk(format!("{creator}\n").into_bytes())
244 .await?;
245 writer.finish().await?;
246 }
247
248 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 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 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 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 async fn wait_for_ssh_listener(
351 &self,
352 sailbox_id: &str,
353 timeout: Duration,
354 ) -> Result<SshEndpoint, SailError> {
355 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
383async 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 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 assert!(SSHD_START.contains("PubkeyAuthentication yes"));
421 assert!(SSHD_START.contains("AuthenticationMethods publickey"));
422 }
423
424 #[test]
427 fn embedded_shell_snippets_are_valid() {
428 for (name, snippet) in [
429 ("SSHD_START(org)", sshd_start_command(false)),
430 (
431 "SSHD_START(private)",
432 sshd_start_command(true),
433 ),
434 (
435 "VERIFY_CA_SSHD(org)",
436 verify_ca_sshd_command(false),
437 ),
438 (
439 "VERIFY_CA_SSHD(private)",
440 verify_ca_sshd_command(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 #[test]
462 fn principals_mode_renders_correctly() {
463 let private_start = sshd_start_command(true);
464 assert!(private_start.contains(&format!(
468 "-o 'AuthorizedPrincipalsFile {SSH_PRINCIPALS_PATH}' -o 'AuthorizedKeysFile none'"
469 )));
470 let org_start = sshd_start_command(false);
471 assert!(!org_start.contains("AuthorizedPrincipalsFile"));
472 let private_verify = verify_ca_sshd_command(true);
473 assert!(private_verify.contains("*AuthorizedPrincipalsFile*) ;;"));
474 let org_verify = verify_ca_sshd_command(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}