1use std::time::{Duration, Instant};
13
14use crate::error::SailError;
15use crate::exec::{ExecParams, ExecProcess, EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS};
16use crate::Client;
17
18const SSH_USER_CA_PATH: &str = "/etc/ssh/sail_user_ca.pub";
21const SSHD_SETUP: &str = "mkdir -p /etc/ssh /run/sshd && ssh-keygen -A && passwd -d root";
26const SSHD_SETUP_TIMEOUT_SECONDS: u32 = 60;
27const 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;
61const 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#[derive(Debug, Clone)]
81pub struct SshEndpoint {
82 pub host: String,
84 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 pub async fn enable_ssh(
118 &self,
119 sailbox_id: &str,
120 wait: bool,
121 timeout: Duration,
122 ) -> Result<Option<SshEndpoint>, SailError> {
123 let ca_public_key = self.org_ssh_ca_public_key().await?;
127
128 let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
129
130 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 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 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 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 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 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
271async 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 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 assert!(SSHD_START.contains("PubkeyAuthentication yes"));
309 assert!(SSHD_START.contains("AuthenticationMethods publickey"));
310 }
311
312 #[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}