use std::time::{Duration, Instant};
use crate::error::SailError;
use crate::exec::{ExecParams, ExecProcess, EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS};
use crate::Client;
const SSHD_KEY_DIR_SETUP: &str = "mkdir -p /root/.ssh";
const AUTHORIZED_KEYS_PATH: &str = "/root/.ssh/authorized_keys";
const SSHD_SETUP: &str = "ssh-keygen -A && \
chown root:root /root /root/.ssh /root/.ssh/authorized_keys && \
chmod 700 /root/.ssh && \
passwd -d root && \
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config && \
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config && \
mkdir -p /run/sshd";
const SSHD_SETUP_TIMEOUT_SECONDS: u32 = 60;
const SSHD_START: &str = "/usr/sbin/sshd -D -e";
const SSH_PUBLIC_KEY_FILENAMES: &[&str] = &["id_ed25519.pub", "id_rsa.pub"];
const KEY_PREFIXES: &[&str] = &["ssh-", "ecdsa-", "sk-"];
#[derive(Debug, Clone)]
pub struct SshEndpoint {
pub host: String,
pub port: u32,
}
pub fn resolve_public_key(value: Option<&str>) -> Result<String, SailError> {
if let Some(raw) = value {
let text = raw.trim();
if !text.is_empty() {
if text.contains(' ') && KEY_PREFIXES.iter().any(|p| text.starts_with(p)) {
return validate_key(text);
}
let path = crate::credentials::expand_user(text);
let key = std::fs::read_to_string(&path).map_err(|_| SailError::InvalidArgument {
message: format!(
"ssh public key {text:?} is neither a recognized public key nor an existing file path"
),
})?;
let key = key.trim();
if key.is_empty() {
return Err(SailError::InvalidArgument {
message: format!("ssh public key file {text:?} is empty"),
});
}
return validate_key(key);
}
}
let home = dirs::home_dir().unwrap_or_default();
let mut searched = Vec::new();
for name in SSH_PUBLIC_KEY_FILENAMES {
let candidate = home.join(".ssh").join(name);
searched.push(candidate.display().to_string());
if let Ok(key) = std::fs::read_to_string(&candidate) {
let key = key.trim();
if !key.is_empty() {
return validate_key(key);
}
}
}
Err(SailError::InvalidArgument {
message: format!(
"no SSH public key found at {}; provide one explicitly",
searched.join(" or ")
),
})
}
fn validate_key(key: &str) -> Result<String, SailError> {
use base64::prelude::{Engine as _, BASE64_STANDARD};
let invalid = |message: &str| SailError::InvalidArgument {
message: message.to_string(),
};
let mut parts = key.split_whitespace();
let key_type = parts.next().unwrap_or_default();
let blob_b64 = parts.next().unwrap_or_default();
if !KEY_PREFIXES.iter().any(|p| key_type.starts_with(p)) {
return Err(invalid(
"value does not look like an SSH public key (ssh-ed25519/ssh-rsa/ecdsa-.../sk-...)",
));
}
let blob = BASE64_STANDARD
.decode(blob_b64)
.map_err(|_| invalid("SSH public key body is not valid base64"))?;
let type_len = blob
.get(..4)
.map_or(0, |b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize);
if blob.get(4..4 + type_len) != Some(key_type.as_bytes()) {
return Err(invalid("SSH public key type does not match its body"));
}
Ok(key.to_string())
}
fn authorized_keys_merge_command(tmp_path: &str) -> String {
let authorized = shell_words::quote(AUTHORIZED_KEYS_PATH);
let tmp = shell_words::quote(tmp_path);
format!(
"set -e; touch {authorized}; \
if ! grep -qxF -f {tmp} {authorized}; then \
if [ -s {authorized} ] && [ \"$(tail -c 1 {authorized})\" != '' ]; then \
printf '\\n' >> {authorized}; \
fi; \
cat {tmp} >> {authorized}; \
fi; \
rm -f {tmp}; \
chmod 600 {authorized}"
)
}
fn ssh_exec_params(
exec_endpoint: &str,
sailbox_id: &str,
argv: Vec<String>,
timeout_seconds: u32,
) -> ExecParams {
ExecParams {
sailbox_id: sailbox_id.to_string(),
exec_endpoint: exec_endpoint.to_string(),
argv,
timeout_seconds,
idempotency_key: String::new(),
open_stdin: false,
pty: false,
term: String::new(),
cols: 0,
rows: 0,
retry_timeout: EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS,
extra_metadata: Vec::new(),
}
}
impl Client {
pub async fn enable_ssh(
&self,
sailbox_id: &str,
public_key: &str,
wait: bool,
timeout: Duration,
) -> Result<Option<SshEndpoint>, SailError> {
let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
match self.get_listener(sailbox_id, 22).await {
Ok(_) => {}
Err(SailError::NotFound { .. }) => {
self.expose_listener(sailbox_id, 22, "tcp", &[]).await?;
}
Err(err) => return Err(err),
}
self.ssh_exec_check(
&exec_endpoint,
sailbox_id,
SSHD_KEY_DIR_SETUP,
30,
"ssh key dir setup",
)
.await?;
let tmp_path = format!(
"/root/.ssh/.sail-authorized-key-{}.tmp",
uuid::Uuid::new_v4().simple()
);
let mut writer =
self.worker()
.write_file(&exec_endpoint, sailbox_id, &tmp_path, true, Some(0o600));
writer
.write_chunk(format!("{public_key}\n").into_bytes())
.await?;
writer.finish().await?;
self.ssh_exec_check(
&exec_endpoint,
sailbox_id,
&authorized_keys_merge_command(&tmp_path),
30,
"ssh key install",
)
.await?;
self.ssh_exec_check(
&exec_endpoint,
sailbox_id,
SSHD_SETUP,
SSHD_SETUP_TIMEOUT_SECONDS,
"sshd setup",
)
.await?;
let start = format!("nohup {SSHD_START} </dev/null >/dev/null 2>&1 &");
let proc = ExecProcess::start(
self.worker(),
ssh_exec_params(
&exec_endpoint,
sailbox_id,
vec!["/bin/sh".to_string(), "-c".to_string(), start],
30,
),
)
.await?;
proc.wait(EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS).await?;
if !wait {
return Ok(None);
}
self.wait_for_ssh_listener(sailbox_id, timeout)
.await
.map(Some)
}
async fn ssh_exec_check(
&self,
exec_endpoint: &str,
sailbox_id: &str,
command: &str,
timeout_seconds: u32,
label: &str,
) -> Result<(), SailError> {
let proc = ExecProcess::start(
self.worker(),
ssh_exec_params(
exec_endpoint,
sailbox_id,
vec!["/bin/sh".to_string(), "-c".to_string(), command.to_string()],
timeout_seconds,
),
)
.await?;
let result = proc.wait(EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS).await?;
if result.return_code != 0 {
let detail = if result.stderr.trim().is_empty() {
result.stdout.trim()
} else {
result.stderr.trim()
};
return Err(SailError::Internal {
message: format!("{label} failed (exit {}): {detail}", result.return_code),
});
}
Ok(())
}
async fn wait_for_ssh_listener(
&self,
sailbox_id: &str,
timeout: Duration,
) -> Result<SshEndpoint, SailError> {
let deadline = Instant::now() + timeout;
loop {
if let Ok(listener) = self.get_listener(sailbox_id, 22).await {
if !listener.public_host.is_empty()
&& listener.public_port != 0
&& listener.route_status.contains("ACTIVE")
&& ssh_endpoint_accepts(&listener.public_host, listener.public_port).await
{
return Ok(SshEndpoint {
host: listener.public_host,
port: listener.public_port,
});
}
}
if Instant::now() >= deadline {
return Err(SailError::Internal {
message: "timed out waiting for the SSH port to become reachable".to_string(),
});
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
async fn ssh_endpoint_accepts(host: &str, port: u32) -> bool {
use tokio::io::AsyncReadExt;
use tokio::net::TcpStream;
let probe = Duration::from_secs(5);
let addr = format!("{host}:{port}");
let Ok(Ok(mut stream)) = tokio::time::timeout(probe, TcpStream::connect(&addr)).await else {
return false;
};
let mut buf = [0u8; 4];
let mut filled = 0;
while filled < 4 {
match tokio::time::timeout(probe, stream.read(&mut buf[filled..])).await {
Ok(Ok(0)) | Err(_) => break,
Ok(Ok(n)) => filled += n,
Ok(Err(_)) => break,
}
}
buf[..filled].starts_with(b"SSH-")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn literal_key_is_accepted() {
let key =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f comment";
assert_eq!(resolve_public_key(Some(key)).unwrap(), key);
}
#[test]
fn non_key_non_path_is_rejected() {
assert!(matches!(
resolve_public_key(Some("not-a-key")),
Err(SailError::InvalidArgument { .. })
));
}
#[test]
fn key_with_malformed_body_is_rejected() {
assert!(matches!(
resolve_public_key(Some("ssh-ed25519 AAAAC3Nz comment")),
Err(SailError::InvalidArgument { .. })
));
}
#[test]
fn merge_command_quotes_paths_and_chmods() {
let cmd = authorized_keys_merge_command("/root/.ssh/.tmp");
assert!(cmd.contains("/root/.ssh/authorized_keys"));
assert!(cmd.contains("chmod 600"));
}
}