1use std::time::{Duration, Instant};
10
11use crate::error::SailError;
12use crate::exec::{ExecParams, ExecProcess, EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS};
13use crate::Client;
14
15const SSHD_KEY_DIR_SETUP: &str = "mkdir -p /root/.ssh";
16const AUTHORIZED_KEYS_PATH: &str = "/root/.ssh/authorized_keys";
17const SSHD_SETUP: &str = "ssh-keygen -A && \
18chown root:root /root /root/.ssh /root/.ssh/authorized_keys && \
19chmod 700 /root/.ssh && \
20passwd -d root && \
21sed -i 's/^#*PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config && \
22sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config && \
23mkdir -p /run/sshd";
24const SSHD_SETUP_TIMEOUT_SECONDS: u32 = 60;
25const SSHD_START: &str = "/usr/sbin/sshd -D -e";
26const SSH_PUBLIC_KEY_FILENAMES: &[&str] = &["id_ed25519.pub", "id_rsa.pub"];
27const KEY_PREFIXES: &[&str] = &["ssh-", "ecdsa-", "sk-"];
28
29#[derive(Debug, Clone)]
31pub struct SshEndpoint {
32 pub host: String,
34 pub port: u32,
36}
37
38pub fn resolve_public_key(value: Option<&str>) -> Result<String, SailError> {
43 if let Some(raw) = value {
44 let text = raw.trim();
45 if !text.is_empty() {
46 if text.contains(' ') && KEY_PREFIXES.iter().any(|p| text.starts_with(p)) {
47 return validate_key(text);
48 }
49 let path = crate::credentials::expand_user(text);
50 let key = std::fs::read_to_string(&path).map_err(|_| SailError::InvalidArgument {
51 message: format!(
52 "ssh public key {text:?} is neither a recognized public key nor an existing file path"
53 ),
54 })?;
55 let key = key.trim();
56 if key.is_empty() {
57 return Err(SailError::InvalidArgument {
58 message: format!("ssh public key file {text:?} is empty"),
59 });
60 }
61 return validate_key(key);
62 }
63 }
64 let home = dirs::home_dir().unwrap_or_default();
65 let mut searched = Vec::new();
66 for name in SSH_PUBLIC_KEY_FILENAMES {
67 let candidate = home.join(".ssh").join(name);
68 searched.push(candidate.display().to_string());
69 if let Ok(key) = std::fs::read_to_string(&candidate) {
70 let key = key.trim();
71 if !key.is_empty() {
72 return validate_key(key);
73 }
74 }
75 }
76 Err(SailError::InvalidArgument {
77 message: format!(
78 "no SSH public key found at {}; provide one explicitly",
79 searched.join(" or ")
80 ),
81 })
82}
83
84fn validate_key(key: &str) -> Result<String, SailError> {
89 use base64::prelude::{Engine as _, BASE64_STANDARD};
90
91 let invalid = |message: &str| SailError::InvalidArgument {
92 message: message.to_string(),
93 };
94 let mut parts = key.split_whitespace();
95 let key_type = parts.next().unwrap_or_default();
96 let blob_b64 = parts.next().unwrap_or_default();
97 if !KEY_PREFIXES.iter().any(|p| key_type.starts_with(p)) {
98 return Err(invalid(
99 "value does not look like an SSH public key (ssh-ed25519/ssh-rsa/ecdsa-.../sk-...)",
100 ));
101 }
102 let blob = BASE64_STANDARD
103 .decode(blob_b64)
104 .map_err(|_| invalid("SSH public key body is not valid base64"))?;
105 let type_len = blob
106 .get(..4)
107 .map_or(0, |b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize);
108 if blob.get(4..4 + type_len) != Some(key_type.as_bytes()) {
109 return Err(invalid("SSH public key type does not match its body"));
110 }
111 Ok(key.to_string())
112}
113
114fn authorized_keys_merge_command(tmp_path: &str) -> String {
116 let authorized = shell_words::quote(AUTHORIZED_KEYS_PATH);
117 let tmp = shell_words::quote(tmp_path);
118 format!(
119 "set -e; touch {authorized}; \
120if ! grep -qxF -f {tmp} {authorized}; then \
121if [ -s {authorized} ] && [ \"$(tail -c 1 {authorized})\" != '' ]; then \
122printf '\\n' >> {authorized}; \
123fi; \
124cat {tmp} >> {authorized}; \
125fi; \
126rm -f {tmp}; \
127chmod 600 {authorized}"
128 )
129}
130
131fn ssh_exec_params(
132 exec_endpoint: &str,
133 sailbox_id: &str,
134 argv: Vec<String>,
135 timeout_seconds: u32,
136) -> ExecParams {
137 ExecParams {
138 sailbox_id: sailbox_id.to_string(),
139 exec_endpoint: exec_endpoint.to_string(),
140 argv,
141 timeout_seconds,
142 idempotency_key: String::new(),
143 open_stdin: false,
144 pty: false,
145 term: String::new(),
146 cols: 0,
147 rows: 0,
148 retry_timeout: EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS,
149 extra_metadata: Vec::new(),
150 }
151}
152
153impl Client {
154 pub async fn enable_ssh(
159 &self,
160 sailbox_id: &str,
161 public_key: &str,
162 wait: bool,
163 timeout: Duration,
164 ) -> Result<Option<SshEndpoint>, SailError> {
165 let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
166
167 match self.get_listener(sailbox_id, 22).await {
170 Ok(_) => {}
171 Err(SailError::NotFound { .. }) => {
172 self.expose_listener(sailbox_id, 22, "tcp", &[]).await?;
173 }
174 Err(err) => return Err(err),
175 }
176
177 self.ssh_exec_check(
178 &exec_endpoint,
179 sailbox_id,
180 SSHD_KEY_DIR_SETUP,
181 30,
182 "ssh key dir setup",
183 )
184 .await?;
185
186 let tmp_path = format!(
189 "/root/.ssh/.sail-authorized-key-{}.tmp",
190 uuid::Uuid::new_v4().simple()
191 );
192 let mut writer =
193 self.worker()
194 .write_file(&exec_endpoint, sailbox_id, &tmp_path, true, Some(0o600));
195 writer
196 .write_chunk(format!("{public_key}\n").into_bytes())
197 .await?;
198 writer.finish().await?;
199
200 self.ssh_exec_check(
201 &exec_endpoint,
202 sailbox_id,
203 &authorized_keys_merge_command(&tmp_path),
204 30,
205 "ssh key install",
206 )
207 .await?;
208 self.ssh_exec_check(
209 &exec_endpoint,
210 sailbox_id,
211 SSHD_SETUP,
212 SSHD_SETUP_TIMEOUT_SECONDS,
213 "sshd setup",
214 )
215 .await?;
216
217 let start = format!("nohup {SSHD_START} </dev/null >/dev/null 2>&1 &");
219 let proc = ExecProcess::start(
220 self.worker(),
221 ssh_exec_params(
222 &exec_endpoint,
223 sailbox_id,
224 vec!["/bin/sh".to_string(), "-c".to_string(), start],
225 30,
226 ),
227 )
228 .await?;
229 proc.wait(EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS).await?;
230
231 if !wait {
232 return Ok(None);
233 }
234 self.wait_for_ssh_listener(sailbox_id, timeout)
235 .await
236 .map(Some)
237 }
238
239 async fn ssh_exec_check(
241 &self,
242 exec_endpoint: &str,
243 sailbox_id: &str,
244 command: &str,
245 timeout_seconds: u32,
246 label: &str,
247 ) -> Result<(), SailError> {
248 let proc = ExecProcess::start(
249 self.worker(),
250 ssh_exec_params(
251 exec_endpoint,
252 sailbox_id,
253 vec!["/bin/sh".to_string(), "-c".to_string(), command.to_string()],
254 timeout_seconds,
255 ),
256 )
257 .await?;
258 let result = proc.wait(EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS).await?;
259 if result.return_code != 0 {
260 let detail = if result.stderr.trim().is_empty() {
261 result.stdout.trim()
262 } else {
263 result.stderr.trim()
264 };
265 return Err(SailError::Internal {
266 message: format!("{label} failed (exit {}): {detail}", result.return_code),
267 });
268 }
269 Ok(())
270 }
271
272 async fn wait_for_ssh_listener(
278 &self,
279 sailbox_id: &str,
280 timeout: Duration,
281 ) -> Result<SshEndpoint, SailError> {
282 let deadline = Instant::now() + timeout;
283 loop {
284 if let Ok(listener) = self.get_listener(sailbox_id, 22).await {
285 if !listener.public_host.is_empty()
286 && listener.public_port != 0
287 && listener.route_status.contains("ACTIVE")
288 && ssh_endpoint_accepts(&listener.public_host, listener.public_port).await
289 {
290 return Ok(SshEndpoint {
291 host: listener.public_host,
292 port: listener.public_port,
293 });
294 }
295 }
296 if Instant::now() >= deadline {
297 return Err(SailError::Internal {
298 message: "timed out waiting for the SSH port to become reachable".to_string(),
299 });
300 }
301 tokio::time::sleep(Duration::from_secs(1)).await;
302 }
303 }
304}
305
306async fn ssh_endpoint_accepts(host: &str, port: u32) -> bool {
310 use tokio::io::AsyncReadExt;
311 use tokio::net::TcpStream;
312
313 let probe = Duration::from_secs(5);
314 let addr = format!("{host}:{port}");
315 let Ok(Ok(mut stream)) = tokio::time::timeout(probe, TcpStream::connect(&addr)).await else {
316 return false;
317 };
318 let mut buf = [0u8; 4];
321 let mut filled = 0;
322 while filled < 4 {
323 match tokio::time::timeout(probe, stream.read(&mut buf[filled..])).await {
324 Ok(Ok(0)) | Err(_) => break,
325 Ok(Ok(n)) => filled += n,
326 Ok(Err(_)) => break,
327 }
328 }
329 buf[..filled].starts_with(b"SSH-")
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn literal_key_is_accepted() {
338 let key =
339 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f comment";
340 assert_eq!(resolve_public_key(Some(key)).unwrap(), key);
341 }
342
343 #[test]
344 fn non_key_non_path_is_rejected() {
345 assert!(matches!(
346 resolve_public_key(Some("not-a-key")),
347 Err(SailError::InvalidArgument { .. })
348 ));
349 }
350
351 #[test]
352 fn key_with_malformed_body_is_rejected() {
353 assert!(matches!(
354 resolve_public_key(Some("ssh-ed25519 AAAAC3Nz comment")),
355 Err(SailError::InvalidArgument { .. })
356 ));
357 }
358
359 #[test]
360 fn merge_command_quotes_paths_and_chmods() {
361 let cmd = authorized_keys_merge_command("/root/.ssh/.tmp");
362 assert!(cmd.contains("/root/.ssh/authorized_keys"));
363 assert!(cmd.contains("chmod 600"));
364 }
365}