Files
dosh/tests/integration_smoke.rs
T
DuProcess 0575cf678f
ci / test (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
Add native doctor auth diagnostics
2026-06-13 22:01:27 -04:00

1223 lines
37 KiB
Rust

use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream, UdpSocket};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use dosh::auth::{build_bootstrap, load_or_create_server_secret};
use dosh::config::load_server_config;
use dosh::crypto;
use dosh::native::{host_public_key, load_or_create_host_key, ssh_ed25519_public_blob, trust_host};
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT,
};
use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
fn free_udp_port() -> u16 {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket.local_addr().unwrap().port()
}
fn free_tcp_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
}
fn write_server_config(dir: &tempfile::TempDir, port: u16) -> std::path::PathBuf {
write_server_config_with_timeout(dir, port, 30)
}
fn write_server_config_with_remote_forwarding(
dir: &tempfile::TempDir,
port: u16,
) -> std::path::PathBuf {
let config = write_server_config(dir, port);
let mut raw = fs::read_to_string(&config).unwrap();
raw.push_str("allow_remote_forwarding = true\n");
fs::write(&config, raw).unwrap();
config
}
fn write_server_config_with_timeout(
dir: &tempfile::TempDir,
port: u16,
client_timeout_secs: u64,
) -> std::path::PathBuf {
let config_dir = dir.path().join(".config/dosh");
fs::create_dir_all(&config_dir).unwrap();
let config = config_dir.join("server.toml");
fs::write(
&config,
format!(
r#"
port = {port}
bind = "127.0.0.1"
scrollback = 5000
auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600
allow_attach_tickets = true
client_timeout_secs = {client_timeout_secs}
retransmit_window = 256
default_input_mode = "read-write"
prewarm_sessions = ["default"]
create_on_attach = true
shell = "/bin/sh"
sessions_dir = "{sessions}"
secret_path = "{secret}"
host_key = "{host_key}"
authorized_keys = ["{authorized_keys}"]
"#,
sessions = dir.path().join("sessions").display(),
secret = dir.path().join("secret").display(),
host_key = dir.path().join("host_key").display(),
authorized_keys = dir.path().join("authorized_keys").display(),
),
)
.unwrap();
config
}
fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child {
let server = env!("CARGO_BIN_EXE_dosh-server");
let child = Command::new(server)
.arg("serve")
.arg("--config")
.arg(config)
.env("HOME", dir.path())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
thread::sleep(Duration::from_millis(500));
child
}
fn attach_once(dir: &tempfile::TempDir, port: u16, cache: bool) -> std::process::Output {
let client = env!("CARGO_BIN_EXE_dosh-client");
let mut cmd = Command::new(client);
cmd.arg("--local-auth")
.arg("--attach-only")
.arg("-v")
.arg("--session")
.arg("default")
.arg("--dosh-port")
.arg(port.to_string())
.arg("local")
.env("HOME", dir.path());
if !cache {
cmd.arg("--no-cache");
}
cmd.output().unwrap()
}
fn native_attach_once(dir: &tempfile::TempDir, port: u16) -> std::process::Output {
let client = env!("CARGO_BIN_EXE_dosh-client");
Command::new(client)
.arg("--auth")
.arg("native")
.arg("--attach-only")
.arg("-v")
.arg("--session")
.arg("default")
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("local")
.env("HOME", dir.path())
.output()
.unwrap()
}
fn native_attach_once_with_agent(
dir: &tempfile::TempDir,
port: u16,
agent_sock: &Path,
) -> std::process::Output {
let client = env!("CARGO_BIN_EXE_dosh-client");
Command::new(client)
.arg("--auth")
.arg("native")
.arg("--attach-only")
.arg("-v")
.arg("--session")
.arg("default")
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("local")
.env("HOME", dir.path())
.env("SSH_AUTH_SOCK", agent_sock)
.output()
.unwrap()
}
fn connect_with_retry(port: u16, timeout: Duration, child: &mut Child) -> TcpStream {
let deadline = std::time::Instant::now() + timeout;
loop {
if let Some(status) = child.try_wait().unwrap() {
let mut stderr = String::new();
if let Some(mut pipe) = child.stderr.take() {
let _ = pipe.read_to_string(&mut stderr);
}
panic!(
"client exited before local forward was ready: status={status}; stderr={stderr}"
);
}
match TcpStream::connect(("127.0.0.1", port)) {
Ok(stream) => return stream,
Err(err) if std::time::Instant::now() < deadline => {
let _ = err;
thread::sleep(Duration::from_millis(50));
}
Err(err) => panic!("connect to local forward 127.0.0.1:{port}: {err}"),
}
}
}
fn socks5_connect(port: u16, target_port: u16, child: &mut Child) -> TcpStream {
let mut stream = connect_with_retry(port, Duration::from_secs(5), child);
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
stream.write_all(&[5, 1, 0]).unwrap();
let mut response = [0u8; 2];
stream.read_exact(&mut response).unwrap();
assert_eq!(response, [5, 0]);
let mut request = vec![5, 1, 0, 1, 127, 0, 0, 1];
request.extend_from_slice(&target_port.to_be_bytes());
stream.write_all(&request).unwrap();
let mut reply = [0u8; 10];
stream.read_exact(&mut reply).unwrap();
assert_eq!(reply[0], 5);
assert_eq!(reply[1], 0);
stream
}
fn start_echo_server() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 1024];
loop {
match stream.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if stream.write_all(&buf[..n]).is_err() {
break;
}
}
}
}
}
});
port
}
fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Path) {
let ssh_dir = dir.path().join(".ssh");
fs::create_dir_all(&ssh_dir).unwrap();
let signing = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]);
let keypair = ssh_key::private::Ed25519Keypair::from(&signing);
let private =
ssh_key::PrivateKey::new(ssh_key::private::KeypairData::from(keypair), "test").unwrap();
private
.write_openssh_file(&ssh_dir.join("id_ed25519"), ssh_key::LineEnding::LF)
.unwrap();
fs::write(
dir.path().join("authorized_keys"),
format!("{}\n", private.public_key().to_openssh().unwrap()),
)
.unwrap();
let config = load_server_config(Some(config_path.to_path_buf())).unwrap();
let host_signing = load_or_create_host_key(&config).unwrap();
let known_hosts = dir.path().join(".config/dosh/known_hosts");
trust_host(
&known_hosts,
"local",
&host_public_key(&host_signing),
"test",
false,
)
.unwrap();
}
fn write_native_agent_auth(
dir: &tempfile::TempDir,
config_path: &std::path::Path,
signing: &SigningKey,
) {
let public = VerifyingKey::from(signing).to_bytes();
fs::write(
dir.path().join("authorized_keys"),
format!(
"ssh-ed25519 {} agent-key\n",
STANDARD.encode(ssh_ed25519_public_blob(&public))
),
)
.unwrap();
let config = load_server_config(Some(config_path.to_path_buf())).unwrap();
let host_signing = load_or_create_host_key(&config).unwrap();
let known_hosts = dir.path().join(".config/dosh/known_hosts");
trust_host(
&known_hosts,
"local",
&host_public_key(&host_signing),
"test",
false,
)
.unwrap();
}
fn start_fake_ssh_agent(dir: &tempfile::TempDir, signing: SigningKey) -> PathBuf {
let socket_path = dir.path().join("agent.sock");
let public = VerifyingKey::from(&signing).to_bytes();
let key_blob = ssh_ed25519_public_blob(&public);
let listener = UnixListener::bind(&socket_path).unwrap();
thread::spawn(move || fake_ssh_agent(listener, key_blob, signing));
socket_path
}
fn fake_ssh_agent(listener: UnixListener, key_blob: Vec<u8>, signing: SigningKey) {
let (mut stream, _) = listener.accept().unwrap();
let request = read_agent_packet(&mut stream).unwrap();
assert_eq!(request, vec![11]);
let mut response = Vec::new();
response.push(12);
response.extend_from_slice(&1u32.to_be_bytes());
write_ssh_string(&mut response, &key_blob);
write_ssh_string(&mut response, b"integration-agent-key");
write_agent_packet(&mut stream, &response).unwrap();
let request = read_agent_packet(&mut stream).unwrap();
let mut cursor = request.as_slice();
assert_eq!(read_u8(&mut cursor), 13);
assert_eq!(read_ssh_string(&mut cursor), key_blob.as_slice());
let transcript = read_ssh_string(&mut cursor);
assert_eq!(read_u32(&mut cursor), 0);
assert!(cursor.is_empty());
let signature = signing.sign(transcript).to_bytes();
let mut signature_blob = Vec::new();
write_ssh_string(&mut signature_blob, b"ssh-ed25519");
write_ssh_string(&mut signature_blob, &signature);
let mut response = Vec::new();
response.push(14);
write_ssh_string(&mut response, &signature_blob);
write_agent_packet(&mut stream, &response).unwrap();
}
fn read_agent_packet(stream: &mut UnixStream) -> std::io::Result<Vec<u8>> {
let mut len = [0u8; 4];
stream.read_exact(&mut len)?;
let len = u32::from_be_bytes(len) as usize;
let mut payload = vec![0u8; len];
stream.read_exact(&mut payload)?;
Ok(payload)
}
fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> std::io::Result<()> {
stream.write_all(&(payload.len() as u32).to_be_bytes())?;
stream.write_all(payload)
}
fn read_u8(cursor: &mut &[u8]) -> u8 {
let value = cursor[0];
*cursor = &cursor[1..];
value
}
fn read_u32(cursor: &mut &[u8]) -> u32 {
let value = u32::from_be_bytes(cursor[..4].try_into().unwrap());
*cursor = &cursor[4..];
value
}
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> &'a [u8] {
let len = read_u32(cursor) as usize;
let (value, rest) = cursor.split_at(len);
*cursor = rest;
value
}
fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
out.extend_from_slice(value);
}
fn direct_attach(
config: &std::path::Path,
port: u16,
mode: &str,
) -> (std::net::UdpSocket, dosh::auth::BootstrapResponse, AttachOk) {
let config = load_server_config(Some(config.to_path_buf())).unwrap();
let secret = load_or_create_server_secret(&config).unwrap();
let bootstrap = build_bootstrap(
&config,
&secret,
"tester".to_string(),
"default".to_string(),
mode.to_string(),
(80, 24),
crypto::random_12(),
"127.0.0.1".to_string(),
)
.unwrap();
let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let req = BootstrapAttachRequest {
bootstrap: bootstrap.clone(),
cols: 80,
rows: 24,
};
let packet = protocol::encode_plain(
PacketKind::BootstrapAttachRequest,
[0u8; 16],
1,
0,
&protocol::to_body(&req).unwrap(),
)
.unwrap();
socket
.send_to(&packet, format!("127.0.0.1:{port}"))
.unwrap();
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).unwrap();
let packet = protocol::decode(&buf[..n]).unwrap();
assert_eq!(packet.header.kind, PacketKind::AttachOk);
let plain = protocol::decrypt_body(&packet, &bootstrap.session_key, SERVER_TO_CLIENT).unwrap();
let ok: AttachOk = protocol::from_body(&plain).unwrap();
(socket, bootstrap, ok)
}
fn send_encrypted(
socket: &UdpSocket,
port: u16,
kind: PacketKind,
client_id: [u8; 16],
seq: u64,
ack: u64,
key: &[u8; 32],
body: &[u8],
) {
let packet =
protocol::encode_encrypted(kind, client_id, seq, ack, key, CLIENT_TO_SERVER, body).unwrap();
socket
.send_to(&packet, format!("127.0.0.1:{port}"))
.unwrap();
}
fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(protocol::Header, Frame)> {
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).ok()?;
let packet = protocol::decode(&buf[..n]).ok()?;
match packet.header.kind {
PacketKind::Frame | PacketKind::ResumeOk => {
let plain = protocol::decrypt_body(&packet, key, SERVER_TO_CLIENT).ok()?;
let frame: Frame = protocol::from_body(&plain).ok()?;
Some((packet.header, frame))
}
_ => None,
}
}
fn collect_frame_text(socket: &UdpSocket, key: &[u8; 32], millis: u64) -> String {
socket
.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_millis(millis);
let mut text = String::new();
while std::time::Instant::now() < deadline {
if let Some((_header, frame)) = recv_frame(socket, key) {
text.push_str(&String::from_utf8_lossy(&frame.bytes));
}
}
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
text
}
#[test]
fn unknown_live_client_gets_reject() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let client_id = crypto::random_16();
let key = crypto::random_32();
let packet = protocol::encode_encrypted(
PacketKind::Ping,
client_id,
1,
0,
&key,
CLIENT_TO_SERVER,
b"",
)
.unwrap();
socket
.send_to(&packet, format!("127.0.0.1:{port}"))
.unwrap();
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).unwrap();
let packet = protocol::decode(&buf[..n]).unwrap();
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
let _ = server.kill();
let _ = server.wait();
assert_eq!(packet.header.kind, PacketKind::AttachReject);
assert_eq!(packet.header.conn_id, client_id);
assert_eq!(reject.reason, "unknown client");
}
#[test]
fn disconnected_client_times_out_and_gets_reject() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config_with_timeout(&dir, port, 1);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
thread::sleep(Duration::from_secs(6));
send_encrypted(
&socket,
port,
PacketKind::Ping,
ok.client_id,
2,
0,
&bootstrap.session_key,
b"",
);
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).unwrap();
let packet = protocol::decode(&buf[..n]).unwrap();
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
let _ = server.kill();
let _ = server.wait();
assert_eq!(packet.header.kind, PacketKind::AttachReject);
assert_eq!(packet.header.conn_id, ok.client_id);
assert_eq!(reject.reason, "unknown client");
}
#[test]
fn local_attach_only_smoke() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let output = attach_once(&dir, port, false);
let _ = server.kill();
let _ = server.wait();
assert!(
output.status.success(),
"stderr={}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
String::from_utf8_lossy(&output.stderr).contains("terminal_ready"),
"stderr={}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn native_attach_only_smoke() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let mut server = start_server(&dir, &config);
let output = native_attach_once(&dir, port);
let _ = server.kill();
let _ = server.wait();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "stderr={stderr}");
assert!(stderr.contains("native_auth"), "stderr={stderr}");
assert!(stderr.contains("terminal_ready"), "stderr={stderr}");
}
#[test]
fn native_attach_only_smoke_uses_ssh_agent() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let signing = SigningKey::from_bytes(&[53u8; 32]);
write_native_agent_auth(&dir, &config, &signing);
let agent_sock = start_fake_ssh_agent(&dir, signing);
let mut server = start_server(&dir, &config);
let output = native_attach_once_with_agent(&dir, port, &agent_sock);
let _ = server.kill();
let _ = server.wait();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "stderr={stderr}");
assert!(stderr.contains("native_auth"), "stderr={stderr}");
assert!(stderr.contains("terminal_ready"), "stderr={stderr}");
}
#[test]
fn native_doctor_checks_auth_without_opening_terminal() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let mut server = start_server(&dir, &config);
let client = env!("CARGO_BIN_EXE_dosh-client");
let output = Command::new(client)
.arg("doctor")
.arg("--auth")
.arg("native")
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("local")
.env("HOME", dir.path())
.output()
.unwrap();
let _ = server.kill();
let _ = server.wait();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "stdout={stdout}\nstderr={stderr}");
assert!(stdout.contains("[ok] native udp"), "stdout={stdout}");
assert!(stdout.contains("[ok] native auth"), "stdout={stdout}");
assert!(stdout.contains("[ok] forwarding policy"), "stdout={stdout}");
}
#[test]
fn native_local_forward_echo_smoke() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let echo_port = start_echo_server();
let local_port = free_tcp_port();
let mut server = start_server(&dir, &config);
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let mut client = Command::new(client_bin)
.arg("--auth")
.arg("native")
.arg("--no-cache")
.arg("-N")
.arg("-L")
.arg(format!("{local_port}:127.0.0.1:{echo_port}"))
.arg("--session")
.arg("default")
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("local")
.env("HOME", dir.path())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stream = connect_with_retry(local_port, Duration::from_secs(5), &mut client);
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
stream.write_all(b"dosh-forward-ping").unwrap();
let mut buf = [0u8; 64];
let n = stream.read(&mut buf).unwrap();
let _ = client.kill();
let client_output = client.wait_with_output().unwrap();
let _ = server.kill();
let _ = server.wait();
assert_eq!(&buf[..n], b"dosh-forward-ping");
assert!(
client_output.status.success() || client_output.status.code().is_none(),
"stderr={}",
String::from_utf8_lossy(&client_output.stderr)
);
}
#[test]
fn native_local_forward_background_smoke() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let echo_port = start_echo_server();
let local_port = free_tcp_port();
let pid_file = dir.path().join("background.pid");
let mut server = start_server(&dir, &config);
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let output = Command::new(client_bin)
.arg("--auth")
.arg("native")
.arg("--no-cache")
.arg("-f")
.arg("-N")
.arg("-L")
.arg(format!("{local_port}:127.0.0.1:{echo_port}"))
.arg("--session")
.arg("default")
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("local")
.env("HOME", dir.path())
.env("DOSH_BACKGROUND_PID_FILE", &pid_file)
.output()
.unwrap();
assert!(
output.status.success(),
"stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
stream.write_all(b"dosh-background-forward-ping").unwrap();
let mut buf = [0u8; 64];
let n = stream.read(&mut buf).unwrap();
if let Ok(pid) = fs::read_to_string(&pid_file) {
let _ = Command::new("kill").arg(pid.trim()).status();
}
let _ = server.kill();
let _ = server.wait();
assert_eq!(&buf[..n], b"dosh-background-forward-ping");
}
#[test]
fn native_remote_forward_echo_smoke() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config_with_remote_forwarding(&dir, port);
write_native_client_auth(&dir, &config);
let echo_port = start_echo_server();
let remote_port = free_tcp_port();
let mut server = start_server(&dir, &config);
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let mut client = Command::new(client_bin)
.arg("--auth")
.arg("native")
.arg("--no-cache")
.arg("-N")
.arg("-R")
.arg(format!("{remote_port}:127.0.0.1:{echo_port}"))
.arg("--session")
.arg("default")
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("local")
.env("HOME", dir.path())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stream = connect_with_retry(remote_port, Duration::from_secs(5), &mut client);
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
stream.write_all(b"dosh-remote-forward-ping").unwrap();
let mut buf = [0u8; 64];
let n = stream.read(&mut buf).unwrap();
let _ = client.kill();
let client_output = client.wait_with_output().unwrap();
let _ = server.kill();
let _ = server.wait();
assert_eq!(&buf[..n], b"dosh-remote-forward-ping");
assert!(
client_output.status.success() || client_output.status.code().is_none(),
"stderr={}",
String::from_utf8_lossy(&client_output.stderr)
);
}
#[test]
fn native_dynamic_forward_socks_echo_smoke() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let echo_port = start_echo_server();
let socks_port = free_tcp_port();
let mut server = start_server(&dir, &config);
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let mut client = Command::new(client_bin)
.arg("--auth")
.arg("native")
.arg("--no-cache")
.arg("-N")
.arg("-D")
.arg(socks_port.to_string())
.arg("--session")
.arg("default")
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("local")
.env("HOME", dir.path())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stream = socks5_connect(socks_port, echo_port, &mut client);
stream.write_all(b"dosh-dynamic-forward-ping").unwrap();
let mut buf = [0u8; 64];
let n = stream.read(&mut buf).unwrap();
let _ = client.kill();
let client_output = client.wait_with_output().unwrap();
let _ = server.kill();
let _ = server.wait();
assert_eq!(&buf[..n], b"dosh-dynamic-forward-ping");
assert!(
client_output.status.success() || client_output.status.code().is_none(),
"stderr={}",
String::from_utf8_lossy(&client_output.stderr)
);
}
#[test]
fn ticket_attach_after_server_restart_smoke() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let first = attach_once(&dir, port, true);
let _ = server.kill();
let _ = server.wait();
assert!(
first.status.success(),
"stderr={}",
String::from_utf8_lossy(&first.stderr)
);
let mut server = start_server(&dir, &config);
let second = attach_once(&dir, port, true);
let _ = server.kill();
let _ = server.wait();
let stderr = String::from_utf8_lossy(&second.stderr);
assert!(second.status.success(), "stderr={stderr}");
assert!(
stderr.contains("udp_ticket_attach_ready"),
"stderr={stderr}"
);
}
#[test]
fn view_only_input_is_rejected_by_server() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (view_socket, view_bootstrap, view_ok) = direct_attach(&config, port, "view-only");
let input = Input {
bytes: b"echo DOSH_VIEW_ONLY_BUG\n".to_vec(),
};
let packet = protocol::encode_encrypted(
PacketKind::Input,
view_ok.client_id,
2,
0,
&view_bootstrap.session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&input).unwrap(),
)
.unwrap();
view_socket
.send_to(&packet, format!("127.0.0.1:{port}"))
.unwrap();
thread::sleep(Duration::from_millis(250));
let (_rw_socket, _rw_bootstrap, rw_ok) = direct_attach(&config, port, "read-write");
let snapshot = String::from_utf8_lossy(&rw_ok.snapshot);
let _ = server.kill();
let _ = server.wait();
assert!(
!snapshot.contains("DOSH_VIEW_ONLY_BUG"),
"view-only input reached PTY; snapshot={snapshot:?}"
);
}
#[test]
fn multiple_clients_share_one_session_screen() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (writer_socket, writer_bootstrap, writer_ok) = direct_attach(&config, port, "read-write");
let (reader_socket, reader_bootstrap, _reader_ok) = direct_attach(&config, port, "read-write");
let input = Input {
bytes: b"printf DOSH_MULTI_CLIENT\\n\n".to_vec(),
};
send_encrypted(
&writer_socket,
port,
PacketKind::Input,
writer_ok.client_id,
2,
0,
&writer_bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let text = collect_frame_text(&reader_socket, &reader_bootstrap.session_key, 2000);
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains("DOSH_MULTI_CLIENT"),
"expected second client to see first client's output, got {text:?}"
);
}
#[test]
fn server_retransmits_unacked_output_frames() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let input = Input {
bytes: b"printf DOSH_RETRANSMIT\\n\n".to_vec(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let mut seen = Vec::new();
let mut duplicate = None;
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while std::time::Instant::now() < deadline {
if let Some((header, frame)) = recv_frame(&socket, &bootstrap.session_key) {
if String::from_utf8_lossy(&frame.bytes).contains("DOSH_RETRANSMIT") {
if seen
.iter()
.any(|(_, output_seq)| *output_seq == frame.output_seq)
{
duplicate = Some((header.seq, frame.output_seq));
break;
}
seen.push((header.seq, frame.output_seq));
}
}
}
let _ = server.kill();
let _ = server.wait();
assert!(
duplicate.is_some(),
"expected retransmitted same output seq, seen={seen:?}"
);
}
#[test]
fn resize_updates_pty_size() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let resize = Resize {
cols: 100,
rows: 10,
};
send_encrypted(
&socket,
port,
PacketKind::Resize,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&resize).unwrap(),
);
thread::sleep(Duration::from_millis(100));
let input = Input {
bytes: b"stty size\n".to_vec(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
3,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let text = collect_frame_text(&socket, &bootstrap.session_key, 2000);
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains("10 100"),
"expected resized pty to report 10 100, got {text:?}"
);
}
#[test]
fn pty_exit_sends_closed_frame() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let input = Input {
bytes: b"exit\n".to_vec(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let mut closed = false;
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while std::time::Instant::now() < deadline {
if let Some((_header, frame)) = recv_frame(&socket, &bootstrap.session_key) {
if frame.closed {
closed = true;
break;
}
}
}
let _ = server.kill();
let _ = server.wait();
assert!(closed, "expected closed frame after shell exit");
}
#[test]
fn pty_sets_terminal_environment() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let input = Input {
bytes: b"printf \"$TERM\\n\"\n".to_vec(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let text = collect_frame_text(&socket, &bootstrap.session_key, 2000);
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains("xterm-256color"),
"expected TERM=xterm-256color, got {text:?}"
);
}
#[test]
fn live_output_forwards_terminal_control_sequences() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let input = Input {
bytes: b"printf '\\033[?1049hDOSH_ALT_SCREEN\\033[?1049l\\n'\n".to_vec(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let text = collect_frame_text(&socket, &bootstrap.session_key, 2000);
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains("\x1b[?1049h") && text.contains("\x1b[?1049l"),
"expected raw alternate-screen control sequences, got {text:?}"
);
}
#[test]
fn resume_snapshot_preserves_alternate_screen_mode() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let input = Input {
bytes: b"printf '\\033[?1049hALT_SNAPSHOT'\n".to_vec(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let _ = collect_frame_text(&socket, &bootstrap.session_key, 500);
let new_socket = UdpSocket::bind("127.0.0.1:0").unwrap();
new_socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let resume = ResumeRequest {
session: "default".to_string(),
last_rendered_seq: ok.initial_seq,
cols: 80,
rows: 24,
};
send_encrypted(
&new_socket,
port,
PacketKind::ResumeRequest,
ok.client_id,
3,
0,
&bootstrap.session_key,
&protocol::to_body(&resume).unwrap(),
);
let (_header, resume_frame) =
recv_frame(&new_socket, &bootstrap.session_key).expect("resume response");
let _ = server.kill();
let _ = server.wait();
assert!(resume_frame.snapshot);
assert!(
resume_frame.bytes.starts_with(b"\x1b[?1049h"),
"snapshot did not enter alternate screen first: {:?}",
String::from_utf8_lossy(&resume_frame.bytes)
);
}
#[test]
fn resume_updates_udp_endpoint_for_roaming() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (_old_socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let new_socket = UdpSocket::bind("127.0.0.1:0").unwrap();
new_socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let resume = ResumeRequest {
session: "default".to_string(),
last_rendered_seq: ok.initial_seq,
cols: 80,
rows: 24,
};
send_encrypted(
&new_socket,
port,
PacketKind::ResumeRequest,
ok.client_id,
1,
0,
&bootstrap.session_key,
&protocol::to_body(&resume).unwrap(),
);
let (_header, resume_frame) =
recv_frame(&new_socket, &bootstrap.session_key).expect("resume response on new socket");
assert!(resume_frame.snapshot);
let input = Input {
bytes: b"printf DOSH_ROAM\\n\n".to_vec(),
};
send_encrypted(
&new_socket,
port,
PacketKind::Input,
ok.client_id,
2,
resume_frame.output_seq,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let text = collect_frame_text(&new_socket, &bootstrap.session_key, 2000);
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains("DOSH_ROAM"),
"expected output on resumed socket, got {text:?}"
);
}