1use std::time::{Duration, Instant};
14
15use crate::error::SailError;
16use crate::exec::{ExecParams, ExecProcess, EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS};
17use crate::Client;
18
19#[derive(Debug, Clone)]
21pub struct EnableSshOptions {
22 pub allowlist: Vec<String>,
25 pub wait: bool,
27 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
41const SSH_USER_CA_PATH: &str = "/etc/ssh/sail_user_ca.pub";
44const SSHD_SETUP: &str = "mkdir -p /etc/ssh /run/sshd && ssh-keygen -A && passwd -d root";
49const SSHD_SETUP_TIMEOUT_SECONDS: u32 = 60;
50const 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;
84const 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#[derive(Debug, Clone)]
104pub struct SshEndpoint {
105 pub host: String,
107 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 #[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 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 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 let mut writer = self.worker().write_file(
173 &exec_endpoint,
174 sailbox_id,
175 SSH_USER_CA_PATH,
176 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 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 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 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 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 async fn wait_for_ssh_listener(
287 &self,
288 sailbox_id: &str,
289 timeout: Duration,
290 ) -> Result<SshEndpoint, SailError> {
291 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
319async 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 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 assert!(SSHD_START.contains("PubkeyAuthentication yes"));
357 assert!(SSHD_START.contains("AuthenticationMethods publickey"));
358 }
359
360 #[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}