use std::time::{Duration, Instant};
use crate::error::SailError;
use crate::exec::{ExecParams, ExecProcess, EXEC_TRANSIENT_RETRY_TIMEOUT_SECONDS};
use crate::Client;
const SSH_USER_CA_PATH: &str = "/etc/ssh/sail_user_ca.pub";
const SSHD_SETUP: &str = "mkdir -p /etc/ssh /run/sshd && ssh-keygen -A && passwd -d root";
const SSHD_SETUP_TIMEOUT_SECONDS: u32 = 60;
const SSHD_START: &str = "ino=$(for f in /proc/net/tcp /proc/net/tcp6; do \
[ -r \"$f\" ] && awk '$4==\"0A\" && $2 ~ /:0016$/ {print $10}' \"$f\"; done); \
pids=''; for d in /proc/[0-9]*; do \
[ \"$(cat \"$d/comm\" 2>/dev/null)\" = sshd ] || continue; \
for fd in \"$d\"/fd/*; do \
l=$(readlink \"$fd\" 2>/dev/null) || continue; \
for i in $ino; do [ \"$l\" = \"socket:[$i]\" ] || continue; \
p=${d#/proc/}; case \" $pids \" in *\" $p \"*) ;; *) pids=\"$pids $p\";; esac; \
done; done; done; \
[ -n \"$pids\" ] && kill $pids 2>/dev/null; sleep 1; \
nohup /usr/sbin/sshd -D -e \
-o 'PermitRootLogin prohibit-password' \
-o 'PasswordAuthentication no' \
-o 'PubkeyAuthentication yes' \
-o 'AuthenticationMethods publickey' \
-o 'TrustedUserCAKeys /etc/ssh/sail_user_ca.pub' \
-o 'AuthorizedKeysFile none' \
-o 'AuthorizedKeysCommand none' </dev/null >/dev/null 2>&1 &";
const VERIFY_CA_SSHD_TIMEOUT_SECONDS: u32 = 30;
const VERIFY_CA_SSHD: &str = "for _ in 1 2 3 4 5 6 7 8 9 10; do sleep 1; \
ino=$(for f in /proc/net/tcp /proc/net/tcp6; do \
[ -r \"$f\" ] && awk '$4==\"0A\" && $2 ~ /:0016$/ {print $10}' \"$f\"; done); \
[ -n \"$ino\" ] || continue; \
for d in /proc/[0-9]*; do \
[ \"$(cat \"$d/comm\" 2>/dev/null)\" = sshd ] || continue; \
case \"$(tr '\\0' ' ' < \"$d/cmdline\" 2>/dev/null)\" in *TrustedUserCAKeys*) ;; *) continue;; esac; \
for fd in \"$d\"/fd/*; do l=$(readlink \"$fd\" 2>/dev/null) || continue; \
for i in $ino; do [ \"$l\" = \"socket:[$i]\" ] && exit 0; done; done; \
done; done; \
echo 'CA-only sshd did not take over port 22' >&2; exit 1";
#[derive(Debug, Clone)]
pub struct SshEndpoint {
pub host: String,
pub port: u32,
}
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,
wait: bool,
timeout: Duration,
) -> Result<Option<SshEndpoint>, SailError> {
let ca_public_key = self.org_ssh_ca_public_key().await?;
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,
crate::sailbox::types::IngressProtocol::Tcp,
&[],
)
.await?;
}
Err(err) => return Err(err),
}
self.ssh_exec_check(
&exec_endpoint,
sailbox_id,
SSHD_SETUP,
SSHD_SETUP_TIMEOUT_SECONDS,
"sshd setup",
)
.await?;
let mut writer = self.worker().write_file(
&exec_endpoint,
sailbox_id,
SSH_USER_CA_PATH,
true,
Some(0o644),
);
writer
.write_chunk(format!("{}\n", ca_public_key.trim()).into_bytes())
.await?;
writer.finish().await?;
let proc = ExecProcess::start(
self.worker(),
ssh_exec_params(
&exec_endpoint,
sailbox_id,
vec![
"/bin/sh".to_string(),
"-c".to_string(),
SSHD_START.to_string(),
],
30,
),
)
.await?;
proc.wait().await?;
self.ssh_exec_check(
&exec_endpoint,
sailbox_id,
VERIFY_CA_SSHD,
VERIFY_CA_SSHD_TIMEOUT_SECONDS,
"sshd ownership check",
)
.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().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.is_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 sshd_start_enforces_ca_only_policy() {
assert!(SSHD_START.contains("TrustedUserCAKeys /etc/ssh/sail_user_ca.pub"));
assert!(SSHD_START.contains("AuthorizedKeysFile none"));
assert!(SSHD_START.contains("AuthorizedKeysCommand none"));
assert!(SSHD_START.contains("PasswordAuthentication no"));
assert!(SSHD_START.contains("PubkeyAuthentication yes"));
assert!(SSHD_START.contains("AuthenticationMethods publickey"));
}
#[test]
fn embedded_shell_snippets_are_valid() {
for (name, snippet) in [
("SSHD_START", SSHD_START),
("VERIFY_CA_SSHD", VERIFY_CA_SSHD),
] {
let status = std::process::Command::new("sh")
.args(["-n", "-c", snippet])
.status()
.expect("run sh -n");
assert!(status.success(), "{name} is not valid shell");
}
}
#[test]
fn verify_matches_the_ca_only_daemon() {
assert!(VERIFY_CA_SSHD.contains("TrustedUserCAKeys"));
assert!(SSHD_START.contains("TrustedUserCAKeys"));
}
}