Initial Dosh implementation
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
use std::fs;
|
||||
use std::net::UdpSocket;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use dosh::auth::{build_bootstrap, load_or_create_server_secret};
|
||||
use dosh::config::load_server_config;
|
||||
use dosh::crypto;
|
||||
use dosh::protocol::{
|
||||
self, AttachOk, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, PacketKind, Resize,
|
||||
ResumeRequest, SERVER_TO_CLIENT,
|
||||
};
|
||||
|
||||
fn free_udp_port() -> u16 {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
fn write_server_config(dir: &tempfile::TempDir, port: u16) -> 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 = 30
|
||||
retransmit_window = 256
|
||||
default_input_mode = "read-write"
|
||||
prewarm_sessions = ["default"]
|
||||
create_on_attach = true
|
||||
shell = "/bin/sh"
|
||||
sessions_dir = "{sessions}"
|
||||
secret_path = "{secret}"
|
||||
"#,
|
||||
sessions = dir.path().join("sessions").display(),
|
||||
secret = dir.path().join("secret").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("--dosh-port")
|
||||
.arg(port.to_string())
|
||||
.arg("local")
|
||||
.env("HOME", dir.path());
|
||||
if !cache {
|
||||
cmd.arg("--no-cache");
|
||||
}
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
|
||||
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 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 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 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:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use dosh::auth::{
|
||||
build_bootstrap, decode_bootstrap, encode_bootstrap, load_or_create_server_secret,
|
||||
open_attach_ticket, verify_attach_ticket, verify_bootstrap,
|
||||
};
|
||||
use dosh::config::ServerConfig;
|
||||
use dosh::crypto;
|
||||
use dosh::protocol::{self, CLIENT_TO_SERVER, PacketKind, ReplayWindow};
|
||||
|
||||
#[test]
|
||||
fn bootstrap_round_trips_and_verifies() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = ServerConfig {
|
||||
secret_path: dir.path().join("secret").display().to_string(),
|
||||
..ServerConfig::default()
|
||||
};
|
||||
let secret = load_or_create_server_secret(&config).unwrap();
|
||||
let nonce = crypto::random_12();
|
||||
let resp = build_bootstrap(
|
||||
&config,
|
||||
&secret,
|
||||
"user".to_string(),
|
||||
"default".to_string(),
|
||||
"read-write".to_string(),
|
||||
(80, 24),
|
||||
nonce,
|
||||
"127.0.0.1".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(verify_bootstrap(&resp, &secret).unwrap());
|
||||
let encoded = encode_bootstrap(&resp).unwrap();
|
||||
let decoded = decode_bootstrap(&encoded).unwrap();
|
||||
assert_eq!(decoded.session, "default");
|
||||
assert_eq!(decoded.session_key, resp.session_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_packet_round_trips() {
|
||||
let key = crypto::random_32();
|
||||
let conn_id = crypto::random_16();
|
||||
let packet = protocol::encode_encrypted(
|
||||
PacketKind::Input,
|
||||
conn_id,
|
||||
7,
|
||||
0,
|
||||
&key,
|
||||
CLIENT_TO_SERVER,
|
||||
b"hello",
|
||||
)
|
||||
.unwrap();
|
||||
let decoded = protocol::decode(&packet).unwrap();
|
||||
assert_eq!(decoded.header.kind, PacketKind::Input);
|
||||
assert_eq!(decoded.header.conn_id, conn_id);
|
||||
let plain = protocol::decrypt_body(&decoded, &key, CLIENT_TO_SERVER).unwrap();
|
||||
assert_eq!(plain, b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_ticket_is_sealed_and_verifies_scope() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = ServerConfig {
|
||||
secret_path: dir.path().join("secret").display().to_string(),
|
||||
..ServerConfig::default()
|
||||
};
|
||||
let secret = load_or_create_server_secret(&config).unwrap();
|
||||
let resp = build_bootstrap(
|
||||
&config,
|
||||
&secret,
|
||||
"user".to_string(),
|
||||
"work".to_string(),
|
||||
"view-only".to_string(),
|
||||
(100, 30),
|
||||
crypto::random_12(),
|
||||
"127.0.0.1".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let opened = open_attach_ticket(&secret, &resp.attach_ticket).unwrap();
|
||||
assert_eq!(opened.session, "work");
|
||||
assert_eq!(opened.mode, "view-only");
|
||||
assert_eq!(opened.psk, resp.attach_ticket_psk);
|
||||
|
||||
assert!(
|
||||
verify_attach_ticket(
|
||||
&secret,
|
||||
&resp.attach_ticket,
|
||||
&resp.attach_ticket_psk,
|
||||
"work",
|
||||
"view-only",
|
||||
)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
verify_attach_ticket(
|
||||
&secret,
|
||||
&resp.attach_ticket,
|
||||
&resp.attach_ticket_psk,
|
||||
"default",
|
||||
"view-only",
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_window_rejects_duplicates_but_allows_bounded_out_of_order() {
|
||||
let mut replay = ReplayWindow::new(8);
|
||||
assert!(replay.accept(10));
|
||||
assert!(!replay.accept(10));
|
||||
assert!(replay.accept(12));
|
||||
assert!(replay.accept(11));
|
||||
assert!(!replay.accept(11));
|
||||
assert!(replay.accept(18));
|
||||
assert!(!replay.accept(9));
|
||||
assert!(!replay.accept(0));
|
||||
}
|
||||
Reference in New Issue
Block a user