Files
dosh/src/client.rs
T
DuProcess 7e4133b315
ci / test (push) Canceled after 0s
ci / fuzz-smoke (push) Canceled after 0s
ci / macos-client (macos-aarch64, macos-14) (push) Canceled after 0s
ci / macos-client (macos-x86_64, macos-13) (push) Canceled after 0s
ci / windows-client (push) Canceled after 0s
ci / package-release (linux-x86_64, ubuntu-latest, , , ) (push) Canceled after 0s
ci / package-release (macos-aarch64, macos-14, , , ) (push) Canceled after 0s
ci / package-release (macos-x86_64, macos-13, , , ) (push) Canceled after 0s
ci / package-release (windows-aarch64, windows-latest, aarch64, windows, aarch64-pc-windows-msvc) (push) Canceled after 0s
ci / package-release (windows-x86_64, windows-latest, , , ) (push) Canceled after 0s
ci / remote-bench (push) Canceled after 0s
ci / publish-gitea-release (push) Canceled after 0s
Align SDK UDP host selection with CLI
2026-07-16 21:19:02 -04:00

1077 lines
33 KiB
Rust

use crate::config::{
ClientConfig, HostConfig, expand_tilde, load_client_config, load_hosts_config,
};
use crate::crypto;
use crate::native::{
self, EnvVar, ForwardingKind, ForwardingRequest, KnownHostStatus, NativeClientHello,
derive_native_session_key, generate_native_ephemeral, sign_user_auth_with_private_key,
supported_user_key_algorithms, trust_host, verify_known_host, verify_server_hello,
};
use crate::protocol::{
self, AttachReject, CLIENT_TO_SERVER, NativeAuthOkBody, NativeClientHelloBody,
NativeServerHelloBody, NativeUserAuthBody, PacketKind, SERVER_TO_CLIENT,
};
use crate::ssh_agent;
use crate::transport::{DoshTransport, SessionRole, SessionTransportConfig, TransportConfig};
use crate::udp::{bind_udp_for_peer, recv_udp_retrying_transient, send_udp_retrying_transient};
use anyhow::{Context, Result, anyhow, bail};
use std::collections::BTreeMap;
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone)]
pub struct DoshClient {
config: ClientConfig,
hosts: crate::config::HostsConfig,
}
impl DoshClient {
pub fn load() -> Result<Self> {
Ok(Self {
config: load_client_config(None)?,
hosts: load_hosts_config(None)?,
})
}
pub fn load_from_paths(
client_config: Option<PathBuf>,
hosts_config: Option<PathBuf>,
) -> Result<Self> {
Ok(Self {
config: load_client_config(client_config)?,
hosts: load_hosts_config(hosts_config)?,
})
}
pub fn with_config(config: ClientConfig, hosts: crate::config::HostsConfig) -> Self {
Self { config, hosts }
}
pub fn connect(&self, host: impl Into<String>) -> DoshClientBuilder {
DoshClientBuilder::new(self.clone(), host.into())
}
}
#[derive(Debug, Clone)]
pub struct DoshClientBuilder {
client: DoshClient,
host: String,
services: Vec<String>,
identity_files: Vec<PathBuf>,
session: Option<String>,
user: Option<String>,
udp_host: Option<String>,
udp_port: Option<u16>,
trust_on_first_use: Option<bool>,
use_ssh_agent: Option<bool>,
timeout: Option<Duration>,
env: Vec<EnvVar>,
}
impl DoshClientBuilder {
pub fn new(client: DoshClient, host: String) -> Self {
Self {
client,
host,
services: Vec::new(),
identity_files: Vec::new(),
session: None,
user: None,
udp_host: None,
udp_port: None,
trust_on_first_use: None,
use_ssh_agent: None,
timeout: None,
env: Vec::new(),
}
}
pub fn service(mut self, name: impl Into<String>) -> Self {
self.services.push(name.into());
self
}
pub fn services(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.services.extend(names.into_iter().map(Into::into));
self
}
pub fn identity_file(mut self, path: impl Into<PathBuf>) -> Self {
self.identity_files.push(path.into());
self
}
pub fn session(mut self, session: impl Into<String>) -> Self {
self.session = Some(session.into());
self
}
pub fn user(mut self, user: impl Into<String>) -> Self {
self.user = Some(user.into());
self
}
pub fn udp_host(mut self, host: impl Into<String>) -> Self {
self.udp_host = Some(host.into());
self
}
pub fn udp_port(mut self, port: u16) -> Self {
self.udp_port = Some(port);
self
}
pub fn trust_on_first_use(mut self, trust: bool) -> Self {
self.trust_on_first_use = Some(trust);
self
}
pub fn use_ssh_agent(mut self, value: bool) -> Self {
self.use_ssh_agent = Some(value);
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn env(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.env.push(EnvVar {
name: name.into(),
value: value.into(),
});
self
}
pub async fn connect(self) -> Result<ConnectedDoshClient> {
let host_config = self
.client
.hosts
.hosts
.get(&self.host)
.cloned()
.unwrap_or_default();
let raw_server = host_config.ssh.clone().unwrap_or_else(|| self.host.clone());
let ssh_port = host_config.ssh_port.or(self.client.config.ssh_port);
let ssh_config = load_sdk_ssh_config(host_config.ssh_config.as_deref(), ssh_port)?;
let udp_host = selected_sdk_udp_host(
self.udp_host.as_deref(),
&host_config,
&self.client.config,
&raw_server,
&ssh_config,
)?;
let udp_port = self
.udp_port
.or(host_config.port)
.unwrap_or(self.client.config.dosh_port);
let requested_user = self
.user
.clone()
.or_else(|| host_config.user.clone())
.or_else(|| user_from_destination(&raw_server))
.or_else(|| ssh_config.user.clone())
.or_else(local_username)
.unwrap_or_else(|| "unknown".to_string());
let peer_addrs = resolve_addrs(&udp_host, udp_port)?;
let timeout = self.timeout.unwrap_or_else(|| {
Duration::from_millis(self.client.config.native_auth_timeout_ms.max(1))
});
let session = self.session.unwrap_or_else(default_sdk_session);
let requested_env =
sdk_requested_env(&self.client.config, &host_config, &ssh_config, self.env);
let requested_forwardings = self
.services
.iter()
.map(|service| {
Ok(ForwardingRequest {
kind: ForwardingKind::Local,
bind_host: None,
listen_port: 0,
target_host: Some(crate::transport::service_target(service)?),
target_port: Some(0),
})
})
.collect::<Result<Vec<_>>>()?;
let mut errors = Vec::new();
for peer_addr in peer_addrs {
match connect_sdk_peer(
peer_addr,
&self.client.config,
&host_config,
&self.host,
&raw_server,
ssh_port,
&ssh_config,
&requested_user,
&session,
&requested_forwardings,
&self.identity_files,
self.use_ssh_agent,
self.trust_on_first_use,
&requested_env,
timeout,
)
.await
{
Ok(client) => return Ok(client),
Err(err) => errors.push(format!("{peer_addr}: {err:#}")),
}
}
Err(anyhow!(
"native auth failed for all resolved UDP addresses: {}",
errors.join("; ")
))
}
}
pub struct ConnectedDoshClient {
pub host: String,
pub session: String,
pub transport: DoshTransport,
}
impl ConnectedDoshClient {
pub fn into_transport(self) -> DoshTransport {
self.transport
}
}
#[allow(clippy::too_many_arguments)]
async fn connect_sdk_peer(
peer_addr: SocketAddr,
config: &ClientConfig,
host_config: &HostConfig,
host: &str,
server: &str,
ssh_port: Option<u16>,
ssh_config: &SdkSshConfig,
requested_user: &str,
session: &str,
requested_forwardings: &[ForwardingRequest],
identity_files: &[PathBuf],
use_ssh_agent: Option<bool>,
trust_on_first_use: Option<bool>,
requested_env: &[EnvVar],
timeout: Duration,
) -> Result<ConnectedDoshClient> {
let socket = bind_udp_for_peer(peer_addr).await?;
let (client_secret, client_public) = generate_native_ephemeral();
let hello = NativeClientHello {
protocol_version: native::NATIVE_PROTOCOL_VERSION,
client_random: crypto::random_32(),
client_ephemeral_public: client_public,
requested_host: host.to_string(),
requested_user: requested_user.to_string(),
requested_session: session.to_string(),
requested_mode: "forward-only".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: supported_user_key_algorithms(),
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: requested_env.to_vec(),
};
let packet = protocol::encode_plain(
PacketKind::NativeClientHello,
[0u8; 16],
1,
0,
&protocol::to_body(&NativeClientHelloBody {
hello: hello.clone(),
})?,
)?;
send_udp_retrying_transient(&socket, &packet, peer_addr, timeout).await?;
let mut buf = vec![0u8; 65535];
let (n, _) = recv_udp_retrying_transient(&socket, &mut buf, timeout).await?;
let packet = protocol::decode(&buf[..n])?;
if packet.header.kind != PacketKind::NativeServerHello {
if packet.header.kind == PacketKind::AttachReject {
let reject: AttachReject = protocol::from_body(&packet.body)?;
bail!("native auth rejected: {}", reject.reason);
}
bail!("native auth received unexpected server response");
}
let server_hello: NativeServerHelloBody = protocol::from_body(&packet.body)?;
verify_server_hello(&hello, &server_hello.hello)?;
verify_or_trust_host(
config,
host,
&server_hello.hello.host_key,
trust_on_first_use,
)?;
let session_key = derive_native_session_key(
&client_secret,
server_hello.hello.server_ephemeral_public,
&hello,
&server_hello.hello,
)?;
let auth = sign_auth(
config,
host_config,
&hello,
&server_hello.hello,
requested_forwardings.to_vec(),
identity_files.to_vec(),
use_ssh_agent,
server,
ssh_port,
ssh_config,
)?;
let mut pending_id = [0u8; 16];
pending_id.copy_from_slice(&server_hello.hello.auth_challenge[..16]);
let auth_packet = protocol::encode_encrypted(
PacketKind::NativeUserAuth,
pending_id,
2,
1,
&session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&NativeUserAuthBody { auth })?,
)?;
send_udp_retrying_transient(&socket, &auth_packet, peer_addr, timeout).await?;
let (n, _) = recv_udp_retrying_transient(&socket, &mut buf, timeout).await?;
let packet = protocol::decode(&buf[..n])?;
if packet.header.kind != PacketKind::NativeAuthOk {
if packet.header.kind == PacketKind::AttachReject {
let reject: AttachReject = protocol::from_body(&packet.body)?;
bail!("native auth rejected: {}", reject.reason);
}
bail!("native auth received unexpected auth response");
}
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT)?;
let ok: NativeAuthOkBody = protocol::from_body(&plain)?;
let transport = DoshTransport::new_owned(
socket,
SessionTransportConfig {
role: SessionRole::Client,
conn_id: ok.ok.client_id,
session_key: ok.ok.session_key,
peer_addr,
initial_send_seq: 2,
initial_ack: ok.ok.initial_seq,
stream: TransportConfig::default(),
},
);
Ok(ConnectedDoshClient {
host: host.to_string(),
session: ok.ok.session,
transport,
})
}
fn verify_or_trust_host(
config: &ClientConfig,
host: &str,
host_key: &native::HostPublicKey,
trust_override: Option<bool>,
) -> Result<()> {
let known_hosts = expand_tilde(&config.known_hosts);
match verify_known_host(&known_hosts, host, host_key)? {
KnownHostStatus::Trusted => Ok(()),
KnownHostStatus::Unknown if trust_override.unwrap_or(config.trust_on_first_use) => {
trust_host(&known_hosts, host, host_key, "sdk-tofu", false)?;
Ok(())
}
KnownHostStatus::Unknown => Err(anyhow!(
"Dosh host key for {host} is not trusted; run `dosh trust {host}` first or enable trust_on_first_use"
)),
KnownHostStatus::Mismatch { expected, actual } => Err(anyhow!(
"Dosh host key mismatch for {host}: expected {expected}, got {actual}"
)),
}
}
fn sign_auth(
config: &ClientConfig,
_host_config: &HostConfig,
hello: &NativeClientHello,
server_hello: &native::NativeServerHello,
requested_forwardings: Vec<ForwardingRequest>,
explicit_identity_files: Vec<PathBuf>,
use_ssh_agent: Option<bool>,
server: &str,
ssh_port: Option<u16>,
ssh_config: &SdkSshConfig,
) -> Result<native::NativeUserAuth> {
let use_agent = use_ssh_agent.unwrap_or(config.use_ssh_agent);
let mut errors = Vec::new();
if use_agent && !ssh_config.identities_only {
match ssh_agent::sign_user_auth_with_agent(
hello,
server_hello,
requested_forwardings.clone(),
) {
Ok(auth) => return Ok(auth),
Err(err) => errors.push(format!("ssh-agent: {err:#}")),
}
} else if use_agent && ssh_config.identities_only {
errors.push("ssh-agent: skipped because SSH config sets IdentitiesOnly=yes".to_string());
}
let token_context = sdk_ssh_path_token_context(server, ssh_port, ssh_config);
let mut paths = sdk_identity_paths(config, explicit_identity_files, ssh_config, &token_context);
if paths.is_empty() && !ssh_config.identities_only {
paths.extend(default_identity_paths());
}
for path in paths {
match native::load_native_identity(&path).and_then(|identity| {
sign_user_auth_with_private_key(
&identity,
hello,
server_hello,
requested_forwardings.clone(),
)
}) {
Ok(auth) => return Ok(auth),
Err(err) => errors.push(format!("{}: {err:#}", path.display())),
}
}
Err(anyhow!(
"native auth found no usable identity: {}",
errors.join("; ")
))
}
fn sdk_identity_paths(
config: &ClientConfig,
explicit_identity_files: Vec<PathBuf>,
ssh_config: &SdkSshConfig,
token_context: &SshPathTokenContext,
) -> Vec<PathBuf> {
let mut paths = Vec::new();
for path in explicit_identity_files {
push_identity_path(&mut paths, path);
}
for path in &ssh_config.identity_files {
push_identity_path(
&mut paths,
expand_tilde(&expand_ssh_path_tokens(path, token_context)),
);
}
if !ssh_config.identities_only {
for path in &config.identity_files {
push_identity_path(
&mut paths,
expand_tilde(&expand_ssh_path_tokens(path, token_context)),
);
}
}
paths
}
fn push_identity_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
if !paths.iter().any(|existing| existing == &path) {
paths.push(path);
}
}
fn default_identity_paths() -> Vec<PathBuf> {
["~/.ssh/id_ed25519", "~/.ssh/id_ecdsa", "~/.ssh/id_rsa"]
.into_iter()
.map(expand_tilde)
.collect()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct SdkSshConfig {
hostname: Option<String>,
port: Option<u16>,
user: Option<String>,
identity_files: Vec<String>,
identities_only: bool,
send_env: Vec<String>,
set_env: Vec<EnvVar>,
}
fn load_sdk_ssh_config(alias: Option<&str>, ssh_port: Option<u16>) -> Result<SdkSshConfig> {
let Some(alias) = alias else {
return Ok(SdkSshConfig::default());
};
let mut command = Command::new("ssh");
command.arg("-G");
if let Some(ssh_port) = ssh_port {
command.arg("-p").arg(ssh_port.to_string());
}
let output = command
.arg(alias)
.output()
.with_context(|| format!("run ssh -G {alias}"))?;
if !output.status.success() {
bail!("ssh -G failed: {}", String::from_utf8_lossy(&output.stderr));
}
Ok(parse_sdk_ssh_config(&String::from_utf8(output.stdout)?))
}
fn parse_sdk_ssh_config(raw: &str) -> SdkSshConfig {
let mut config = SdkSshConfig::default();
for line in raw.lines() {
let line = line.trim();
let Some((key, value)) = line.split_once(char::is_whitespace) else {
continue;
};
let value = value.trim();
if key.eq_ignore_ascii_case("hostname") && !empty_or_none(value) {
config.hostname = Some(value.to_string());
} else if key.eq_ignore_ascii_case("port") {
config.port = value.parse().ok();
} else if key.eq_ignore_ascii_case("user") && !empty_or_none(value) {
config.user = Some(value.to_string());
} else if key.eq_ignore_ascii_case("identityfile") && !empty_or_none(value) {
config.identity_files.push(value.to_string());
} else if key.eq_ignore_ascii_case("identitiesonly") {
config.identities_only = value.eq_ignore_ascii_case("yes");
} else if key.eq_ignore_ascii_case("sendenv") && !empty_or_none(value) {
config
.send_env
.extend(value.split_whitespace().map(ToString::to_string));
} else if key.eq_ignore_ascii_case("setenv") && !empty_or_none(value) {
config.set_env.extend(parse_set_env_values(value));
}
}
config
}
fn sdk_requested_env(
config: &ClientConfig,
host: &HostConfig,
ssh_config: &SdkSshConfig,
explicit_env: Vec<EnvVar>,
) -> Vec<EnvVar> {
let mut values = BTreeMap::new();
let mut patterns = host
.send_env
.clone()
.unwrap_or_else(|| config.send_env.clone());
patterns.extend(ssh_config.send_env.clone());
for (name, value) in std::env::vars() {
if valid_env_name(&name) && patterns.iter().any(|pattern| glob_matches(pattern, &name)) {
values.insert(name, value);
}
}
for (name, value) in &config.set_env {
if valid_env_name(name) && !value.as_bytes().contains(&0) {
values.insert(name.clone(), value.clone());
}
}
for (name, value) in &host.set_env {
if valid_env_name(name) && !value.as_bytes().contains(&0) {
values.insert(name.clone(), value.clone());
}
}
for env in &ssh_config.set_env {
if valid_env_name(&env.name) && !env.value.as_bytes().contains(&0) {
values.insert(env.name.clone(), env.value.clone());
}
}
for env in explicit_env {
if valid_env_name(&env.name) && !env.value.as_bytes().contains(&0) {
values.insert(env.name, env.value);
}
}
values
.into_iter()
.map(|(name, value)| EnvVar { name, value })
.collect()
}
fn parse_set_env_values(value: &str) -> Vec<EnvVar> {
value
.split_whitespace()
.filter_map(|entry| {
let (name, value) = entry.split_once('=')?;
if valid_env_name(name) && !value.as_bytes().contains(&0) {
Some(EnvVar {
name: name.to_string(),
value: value.to_string(),
})
} else {
None
}
})
.collect()
}
fn valid_env_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first == '_' || first.is_ascii_alphabetic()) {
return false;
}
chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
fn glob_matches(pattern: &str, value: &str) -> bool {
let pattern = pattern.as_bytes();
let value = value.as_bytes();
let (mut p, mut v) = (0, 0);
let mut star = None;
let mut star_value = 0;
while v < value.len() {
if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == value[v]) {
p += 1;
v += 1;
} else if p < pattern.len() && pattern[p] == b'*' {
star = Some(p);
star_value = v;
p += 1;
} else if let Some(s) = star {
p = s + 1;
star_value += 1;
v = star_value;
} else {
return false;
}
}
while p < pattern.len() && pattern[p] == b'*' {
p += 1;
}
p == pattern.len()
}
fn empty_or_none(value: &str) -> bool {
value.is_empty() || value.eq_ignore_ascii_case("none")
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SshPathTokenContext {
original_host: String,
hostname: String,
port: u16,
remote_user: String,
local_user: String,
home_dir: Option<String>,
}
fn sdk_ssh_path_token_context(
server: &str,
ssh_port: Option<u16>,
ssh_config: &SdkSshConfig,
) -> SshPathTokenContext {
let original_host = destination_host(server);
let hostname = ssh_config
.hostname
.clone()
.unwrap_or_else(|| original_host.clone());
let port = ssh_config.port.or(ssh_port).unwrap_or(22);
let remote_user = user_from_destination(server)
.or_else(|| ssh_config.user.clone())
.or_else(local_username)
.unwrap_or_else(|| "unknown".to_string());
let local_user = local_username().unwrap_or_else(|| "unknown".to_string());
let home_dir = dirs::home_dir().map(|path| path.to_string_lossy().to_string());
SshPathTokenContext {
original_host,
hostname,
port,
remote_user,
local_user,
home_dir,
}
}
fn expand_ssh_path_tokens(raw: &str, context: &SshPathTokenContext) -> String {
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars();
while let Some(ch) = chars.next() {
if ch != '%' {
out.push(ch);
continue;
}
match chars.next() {
Some('%') => out.push('%'),
Some('d') => {
if let Some(home_dir) = &context.home_dir {
out.push_str(home_dir);
} else {
out.push('%');
out.push('d');
}
}
Some('h') => out.push_str(&context.hostname),
Some('n') => out.push_str(&context.original_host),
Some('p') => out.push_str(&context.port.to_string()),
Some('r') => out.push_str(&context.remote_user),
Some('u') => out.push_str(&context.local_user),
Some(other) => {
out.push('%');
out.push(other);
}
None => out.push('%'),
}
}
out
}
fn resolve_addrs(host: &str, port: u16) -> Result<Vec<SocketAddr>> {
let addrs = (host, port)
.to_socket_addrs()
.with_context(|| format!("resolve UDP target {host}:{port}"))?
.collect::<Vec<_>>();
if addrs.is_empty() {
return Err(anyhow!("no UDP address resolved for {host}:{port}"));
}
Ok(addrs)
}
#[cfg(test)]
fn first_resolved_addr(addrs: &[SocketAddr], host: &str, port: u16) -> Result<SocketAddr> {
addrs
.first()
.copied()
.ok_or_else(|| anyhow!("no UDP address resolved for {host}:{port}"))
}
fn destination_host(destination: &str) -> String {
destination
.rsplit('@')
.next()
.unwrap_or(destination)
.split(':')
.next()
.unwrap_or(destination)
.to_string()
}
fn selected_sdk_udp_host(
explicit: Option<&str>,
host: &HostConfig,
config: &ClientConfig,
raw_server: &str,
ssh_config: &SdkSshConfig,
) -> Result<String> {
let configured = explicit
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.or_else(|| host.dosh_host.clone())
.or_else(|| config.dosh_host.clone());
let ssh_host = || {
ssh_config
.hostname
.clone()
.unwrap_or_else(|| destination_host(raw_server))
};
let Some(raw) = configured else {
return Ok(ssh_host());
};
match raw.trim().to_ascii_lowercase().as_str() {
"ssh" | "auto" => Ok(ssh_host()),
"localhost" => Ok("127.0.0.1".to_string()),
"any" => bail!(
"dosh_host={raw:?} is a server bind policy, not a client destination; use ssh/auto or a host/IP"
),
_ => Ok(raw),
}
}
fn user_from_destination(destination: &str) -> Option<String> {
destination
.rsplit_once('@')
.map(|(user, _)| user.to_string())
.filter(|user| !user.is_empty())
}
fn local_username() -> Option<String> {
local_username_from_env(|name| std::env::var(name).ok())
}
fn local_username_from_env(mut get: impl FnMut(&str) -> Option<String>) -> Option<String> {
["USER", "USERNAME"]
.into_iter()
.filter_map(|name| get(name))
.find(|value| !value.is_empty())
}
fn default_sdk_session() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("sdk-{millis}-{}", std::process::id())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_user_and_host_from_destination() {
assert_eq!(
user_from_destination("palav@example.com").as_deref(),
Some("palav")
);
assert_eq!(destination_host("palav@example.com"), "example.com");
assert_eq!(destination_host("example.com:2222"), "example.com");
}
#[test]
fn local_username_uses_unix_or_windows_environment_names() {
assert_eq!(
local_username_from_env(|name| match name {
"USER" => Some("palav".to_string()),
_ => None,
})
.as_deref(),
Some("palav")
);
assert_eq!(
local_username_from_env(|name| match name {
"USERNAME" => Some("palav-win".to_string()),
_ => None,
})
.as_deref(),
Some("palav-win")
);
assert_eq!(
local_username_from_env(|name| match name {
"USER" => Some(String::new()),
"USERNAME" => Some("palav-win".to_string()),
_ => None,
})
.as_deref(),
Some("palav-win")
);
}
#[test]
fn first_resolved_addr_uses_dns_order_and_reports_empty_results() {
let addrs = [
"127.0.0.1:50000".parse().unwrap(),
"[::1]:50000".parse().unwrap(),
];
assert_eq!(
first_resolved_addr(&addrs, "example.test", 50000).unwrap(),
addrs[0]
);
assert!(
first_resolved_addr(&[], "example.test", 50000)
.unwrap_err()
.to_string()
.contains("no UDP address resolved for example.test:50000")
);
}
#[test]
fn sdk_udp_host_follows_ssh_config_hostname_by_default() {
let ssh_config = SdkSshConfig {
hostname: Some("10.0.0.5".to_string()),
..SdkSshConfig::default()
};
assert_eq!(
selected_sdk_udp_host(
None,
&HostConfig::default(),
&ClientConfig::default(),
"prod",
&ssh_config,
)
.unwrap(),
"10.0.0.5"
);
}
#[test]
fn sdk_udp_host_honors_cli_compatible_special_values() {
let ssh_config = SdkSshConfig {
hostname: Some("10.0.0.5".to_string()),
..SdkSshConfig::default()
};
let host = HostConfig {
dosh_host: Some("ssh".to_string()),
..HostConfig::default()
};
assert_eq!(
selected_sdk_udp_host(None, &host, &ClientConfig::default(), "prod", &ssh_config)
.unwrap(),
"10.0.0.5"
);
assert_eq!(
selected_sdk_udp_host(
Some("localhost"),
&host,
&ClientConfig::default(),
"prod",
&ssh_config,
)
.unwrap(),
"127.0.0.1"
);
assert!(
selected_sdk_udp_host(
Some("any"),
&host,
&ClientConfig::default(),
"prod",
&ssh_config,
)
.is_err()
);
}
#[test]
fn sdk_parses_ssh_config_identity_settings() {
let parsed = parse_sdk_ssh_config(
"hostname 10.0.0.5\n\
user deploy\n\
port 2222\n\
identityfile ~/.ssh/work\n\
identityfile none\n\
identitiesonly yes\n\
sendenv LANG LC_*\n\
setenv DOSH_MODE=sdk DOSH_COLOR=true BAD-NAME=nope\n",
);
assert_eq!(parsed.hostname.as_deref(), Some("10.0.0.5"));
assert_eq!(parsed.user.as_deref(), Some("deploy"));
assert_eq!(parsed.port, Some(2222));
assert_eq!(parsed.identity_files, vec!["~/.ssh/work"]);
assert!(parsed.identities_only);
assert_eq!(parsed.send_env, vec!["LANG", "LC_*"]);
assert_eq!(
parsed.set_env,
vec![
EnvVar {
name: "DOSH_MODE".to_string(),
value: "sdk".to_string()
},
EnvVar {
name: "DOSH_COLOR".to_string(),
value: "true".to_string()
}
]
);
}
#[test]
fn sdk_ssh_config_without_alias_does_not_shell_out() {
assert_eq!(
load_sdk_ssh_config(None, Some(2222)).unwrap(),
SdkSshConfig::default()
);
}
#[test]
fn sdk_identity_paths_follow_cli_precedence_and_identities_only() {
let dir = tempfile::tempdir().unwrap();
let explicit = dir.path().join("explicit");
let config_identity = dir.path().join("config");
let ssh_config = SdkSshConfig {
hostname: Some("10.0.0.5".to_string()),
port: Some(2222),
user: Some("deploy".to_string()),
identity_files: vec![format!("{}/%r_%h_%p", dir.path().display())],
identities_only: true,
..SdkSshConfig::default()
};
let config = ClientConfig {
identity_files: vec![config_identity.display().to_string()],
..ClientConfig::default()
};
let token_context = sdk_ssh_path_token_context("deploy@prod", None, &ssh_config);
let paths =
sdk_identity_paths(&config, vec![explicit.clone()], &ssh_config, &token_context);
assert_eq!(
paths,
vec![explicit, dir.path().join("deploy_10.0.0.5_2222")]
);
}
#[test]
fn sdk_identity_paths_include_dosh_config_when_not_identities_only() {
let dir = tempfile::tempdir().unwrap();
let config_identity = dir.path().join("config");
let ssh_config = SdkSshConfig {
identity_files: vec![dir.path().join("ssh").display().to_string()],
identities_only: false,
..SdkSshConfig::default()
};
let config = ClientConfig {
identity_files: vec![config_identity.display().to_string()],
..ClientConfig::default()
};
let token_context = sdk_ssh_path_token_context("prod", Some(22), &ssh_config);
let paths = sdk_identity_paths(&config, Vec::new(), &ssh_config, &token_context);
assert_eq!(paths, vec![dir.path().join("ssh"), config_identity]);
}
#[test]
fn sdk_requested_env_merges_host_ssh_and_explicit_overrides() {
let mut config = ClientConfig::default();
config.send_env.clear();
config.set_env.insert("DOSH_MODE".into(), "client".into());
config.set_env.insert("DOSH_KEEP".into(), "yes".into());
let mut host = HostConfig::default();
host.set_env.insert("DOSH_MODE".into(), "host".into());
let ssh_config = SdkSshConfig {
set_env: vec![EnvVar {
name: "DOSH_SSH".to_string(),
value: "true".to_string(),
}],
..SdkSshConfig::default()
};
assert_eq!(
sdk_requested_env(
&config,
&host,
&ssh_config,
vec![
EnvVar {
name: "DOSH_MODE".to_string(),
value: "explicit".to_string(),
},
EnvVar {
name: "BAD-NAME".to_string(),
value: "ignored".to_string(),
}
],
),
vec![
EnvVar {
name: "DOSH_KEEP".to_string(),
value: "yes".to_string()
},
EnvVar {
name: "DOSH_MODE".to_string(),
value: "explicit".to_string()
},
EnvVar {
name: "DOSH_SSH".to_string(),
value: "true".to_string()
}
]
);
}
#[test]
fn sdk_env_globs_match_send_env_patterns() {
assert!(glob_matches("LC_*", "LC_ALL"));
assert!(glob_matches("TERM", "TERM"));
assert!(!glob_matches("LC_*", "LANG"));
}
#[test]
fn default_identity_paths_are_expanded() {
assert!(
default_identity_paths()
.iter()
.any(|path| path.ends_with(".ssh/id_ed25519"))
);
}
}