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)]
138#[non_exhaustive]
139pub struct SshEndpoint {
140 pub host: String,
142 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 #[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 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 let ca_public_key = self.org_ssh_ca_public_key().await?;
197
198 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 let mut writer = self.worker().write_file(
224 &exec_endpoint,
225 sailbox_id,
226 SSH_USER_CA_PATH,
227 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 true,
242 Some(0o644),
243 );
244 writer
245 .write_chunk(format!("{creator}\n").into_bytes())
246 .await?;
247 writer.finish().await?;
248 }
249
250 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 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 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 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 async fn wait_for_ssh_listener(
353 &self,
354 sailbox_id: &str,
355 timeout: Duration,
356 ) -> Result<SshEndpoint, SailError> {
357 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
385async 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 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 assert!(SSHD_START.contains("PubkeyAuthentication yes"));
423 assert!(SSHD_START.contains("AuthenticationMethods publickey"));
424 }
425
426 #[test]
429 fn embedded_shell_snippets_are_valid() {
430 for (name, snippet) in [
431 ("SSHD_START(org)", sshd_start_command(false)),
432 (
433 "SSHD_START(private)",
434 sshd_start_command(true),
435 ),
436 (
437 "VERIFY_CA_SSHD(org)",
438 verify_ca_sshd_command(false),
439 ),
440 (
441 "VERIFY_CA_SSHD(private)",
442 verify_ca_sshd_command(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 #[test]
464 fn principals_mode_renders_correctly() {
465 let private_start = sshd_start_command(true);
466 assert!(private_start.contains(&format!(
470 "-o 'AuthorizedPrincipalsFile {SSH_PRINCIPALS_PATH}' -o 'AuthorizedKeysFile none'"
471 )));
472 let org_start = sshd_start_command(false);
473 assert!(!org_start.contains("AuthorizedPrincipalsFile"));
474 let private_verify = verify_ca_sshd_command(true);
475 assert!(private_verify.contains("*AuthorizedPrincipalsFile*) ;;"));
476 let org_verify = verify_ca_sshd_command(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}