Initial Dosh implementation
ci / test (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
Codex
2026-06-11 08:42:28 -04:00
commit 555d738a85
25 changed files with 6039 additions and 0 deletions
+287
View File
@@ -0,0 +1,287 @@
use crate::config::{ServerConfig, expand_tilde};
use crate::crypto;
use anyhow::{Context, Result};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootstrapResponse {
pub protocol_version: u8,
pub server_id: [u8; 32],
pub server_key_epoch: u64,
pub issued_at: u64,
pub expires_at: u64,
pub user: String,
pub session: String,
pub mode: String,
pub terminal_size: (u16, u16),
pub client_nonce: [u8; 12],
pub attach_token: [u8; 32],
pub attach_ticket: Vec<u8>,
pub attach_ticket_psk: [u8; 32],
pub session_key: [u8; 32],
pub session_key_id: [u8; 16],
pub udp_host: String,
pub udp_port: u16,
pub aead_algorithm: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SealedAttachTicket {
pub nonce: [u8; 12],
pub ciphertext: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachTicketPlain {
pub server_id: [u8; 32],
pub server_key_epoch: u64,
pub user: String,
pub session: String,
pub mode: String,
pub issued_at: u64,
pub expires_at: u64,
pub psk: [u8; 32],
}
pub fn now_secs() -> Result<u64> {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock before UNIX epoch")?
.as_secs())
}
pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> {
let path = expand_tilde(&config.secret_path);
if path.exists() {
let raw = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
let decoded = if raw.len() == 32 {
raw
} else {
URL_SAFE_NO_PAD
.decode(String::from_utf8_lossy(&raw).trim())
.context("decode server secret")?
};
let mut out = [0u8; 32];
out.copy_from_slice(&decoded[..32]);
return Ok(out);
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let secret = crypto::random_32();
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(&path)
.with_context(|| format!("create {}", path.display()))?;
file.write_all(URL_SAFE_NO_PAD.encode(secret).as_bytes())?;
file.write_all(b"\n")?;
Ok(secret)
}
pub fn build_bootstrap(
config: &ServerConfig,
secret: &[u8; 32],
user: String,
session: String,
mode: String,
terminal_size: (u16, u16),
client_nonce: [u8; 12],
udp_host: String,
) -> Result<BootstrapResponse> {
let issued_at = now_secs()?;
let expires_at = issued_at + config.auth_ttl_secs;
let ticket_expires = issued_at + config.attach_ticket_ttl_secs;
let server_id = crypto::sha256(secret);
let session_key = crypto::hkdf32(
secret,
&client_nonce,
format!("dosh/session/{user}/{session}/{issued_at}").as_bytes(),
)?;
let session_key_id = {
let digest = crypto::sha256(&session_key);
let mut out = [0u8; 16];
out.copy_from_slice(&digest[..16]);
out
};
let attach_token = attach_token(
secret,
&user,
&session,
&mode,
terminal_size,
&client_nonce,
issued_at,
expires_at,
&session_key_id,
);
let attach_ticket_psk = crypto::random_32();
let attach_ticket = build_attach_ticket(
secret,
server_id,
1,
user.clone(),
session.clone(),
mode.clone(),
issued_at,
ticket_expires,
&attach_ticket_psk,
)?;
Ok(BootstrapResponse {
protocol_version: 1,
server_id,
server_key_epoch: 1,
issued_at,
expires_at,
user,
session,
mode,
terminal_size,
client_nonce,
attach_token,
attach_ticket,
attach_ticket_psk,
session_key,
session_key_id,
udp_host,
udp_port: config.port,
aead_algorithm: "chacha20poly1305".to_string(),
})
}
pub fn attach_token(
secret: &[u8; 32],
user: &str,
session: &str,
mode: &str,
terminal_size: (u16, u16),
client_nonce: &[u8; 12],
issued_at: u64,
expires_at: u64,
session_key_id: &[u8; 16],
) -> [u8; 32] {
crypto::hmac_sha256(
secret,
&[
user.as_bytes(),
session.as_bytes(),
mode.as_bytes(),
&terminal_size.0.to_be_bytes(),
&terminal_size.1.to_be_bytes(),
client_nonce,
&issued_at.to_be_bytes(),
&expires_at.to_be_bytes(),
session_key_id,
],
)
}
pub fn verify_bootstrap(resp: &BootstrapResponse, secret: &[u8; 32]) -> Result<bool> {
if now_secs()? > resp.expires_at {
return Ok(false);
}
let expected = attach_token(
secret,
&resp.user,
&resp.session,
&resp.mode,
resp.terminal_size,
&resp.client_nonce,
resp.issued_at,
resp.expires_at,
&resp.session_key_id,
);
Ok(expected == resp.attach_token)
}
fn build_attach_ticket(
secret: &[u8; 32],
server_id: [u8; 32],
server_key_epoch: u64,
user: String,
session: String,
mode: String,
issued_at: u64,
expires_at: u64,
psk: &[u8; 32],
) -> Result<Vec<u8>> {
let payload = AttachTicketPlain {
server_id,
server_key_epoch,
user,
session,
mode,
issued_at,
expires_at,
psk: *psk,
};
let key = attach_ticket_key(secret)?;
let nonce = crypto::random_12();
let encoded = bincode::serialize(&payload)?;
let ciphertext = crypto::seal(&key, &nonce, b"dosh-sealed-attach-ticket-v1", &encoded)?;
Ok(bincode::serialize(&SealedAttachTicket {
nonce,
ciphertext,
})?)
}
pub fn verify_attach_ticket(
secret: &[u8; 32],
ticket_bytes: &[u8],
psk: &[u8; 32],
session: &str,
mode: &str,
) -> Result<Option<AttachTicketPlain>> {
let sealed: SealedAttachTicket = bincode::deserialize(ticket_bytes)?;
let key = attach_ticket_key(secret)?;
let plain = crypto::open(
&key,
&sealed.nonce,
b"dosh-sealed-attach-ticket-v1",
&sealed.ciphertext,
)?;
let ticket: AttachTicketPlain = bincode::deserialize(&plain)?;
if now_secs()? > ticket.expires_at {
return Ok(None);
}
if ticket.session != session || ticket.mode != mode {
return Ok(None);
}
if ticket.psk != *psk {
return Ok(None);
}
Ok(Some(ticket))
}
pub fn open_attach_ticket(secret: &[u8; 32], ticket_bytes: &[u8]) -> Result<AttachTicketPlain> {
let sealed: SealedAttachTicket = bincode::deserialize(ticket_bytes)?;
let key = attach_ticket_key(secret)?;
let plain = crypto::open(
&key,
&sealed.nonce,
b"dosh-sealed-attach-ticket-v1",
&sealed.ciphertext,
)?;
Ok(bincode::deserialize(&plain)?)
}
fn attach_ticket_key(secret: &[u8; 32]) -> Result<[u8; 32]> {
crypto::hkdf32(secret, b"dosh-ticket-key-salt-v1", b"dosh/attach-ticket/v1")
}
pub fn encode_bootstrap(resp: &BootstrapResponse) -> Result<String> {
Ok(URL_SAFE_NO_PAD.encode(bincode::serialize(resp)?))
}
pub fn decode_bootstrap(raw: &str) -> Result<BootstrapResponse> {
let bytes = URL_SAFE_NO_PAD.decode(raw.trim())?;
Ok(bincode::deserialize(&bytes)?)
}
+59
View File
@@ -0,0 +1,59 @@
use anyhow::{Context, Result};
use clap::Parser;
use dosh::auth::{build_bootstrap, encode_bootstrap, load_or_create_server_secret};
use dosh::config::load_server_config;
#[derive(Debug, Parser)]
struct Args {
#[arg(long, default_value_t = 1)]
protocol: u8,
#[arg(long)]
nonce: String,
#[arg(long, default_value = "default")]
session: String,
#[arg(long, default_value = "read-write")]
mode: String,
#[arg(long, default_value = "80x24")]
size: String,
#[arg(long, default_value = "dev")]
client_version: String,
#[arg(long)]
udp_host: Option<String>,
}
fn main() -> Result<()> {
let args = Args::parse();
anyhow::ensure!(args.protocol == 1, "unsupported protocol {}", args.protocol);
let config = load_server_config(None)?;
let secret = load_or_create_server_secret(&config)?;
let nonce = parse_nonce(&args.nonce)?;
let size = parse_size(&args.size)?;
let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
let udp_host = args.udp_host.unwrap_or_else(|| "127.0.0.1".to_string());
let resp = build_bootstrap(
&config,
&secret,
user,
args.session,
args.mode,
size,
nonce,
udp_host,
)?;
println!("{}", encode_bootstrap(&resp)?);
Ok(())
}
fn parse_nonce(raw: &str) -> Result<[u8; 12]> {
let bytes = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, raw)
.context("decode nonce")?;
anyhow::ensure!(bytes.len() == 12, "nonce must decode to 12 bytes");
let mut out = [0u8; 12];
out.copy_from_slice(&bytes);
Ok(out)
}
fn parse_size(raw: &str) -> Result<(u16, u16)> {
let (cols, rows) = raw.split_once('x').context("size must be COLSxROWS")?;
Ok((cols.parse()?, rows.parse()?))
}
+233
View File
@@ -0,0 +1,233 @@
use anyhow::{Context, Result, anyhow};
use clap::Parser;
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, Instant};
#[derive(Debug, Parser)]
#[command(name = "dosh-bench")]
struct Args {
#[arg(long, default_value = "local")]
server: String,
#[arg(long, default_value = "default")]
session: String,
#[arg(long, default_value_t = 22)]
ssh_port: u16,
#[arg(long, default_value_t = 50000)]
dosh_port: u16,
#[arg(long)]
dosh_host: Option<String>,
#[arg(long, default_value_t = 3)]
iterations: usize,
#[arg(long)]
local_auth: bool,
#[arg(long)]
client: Option<PathBuf>,
#[arg(long, default_value = "dosh-auth")]
ssh_auth_command: String,
#[arg(long)]
ssh_key: Option<PathBuf>,
#[arg(long)]
ssh_known_hosts: Option<PathBuf>,
#[arg(long)]
ssh_control_path: Option<PathBuf>,
#[arg(long)]
controlmaster: bool,
#[arg(long)]
no_cache: bool,
#[arg(long)]
assert_ssh_plus_ms: Option<f64>,
}
fn main() -> Result<()> {
let args = Args::parse();
let client = args.client.clone().unwrap_or_else(default_client_path);
let mut ssh_times = Vec::new();
let mut dosh_times = Vec::new();
let generated_control_path = if args.controlmaster {
Some(std::env::temp_dir().join(format!("dosh-bench-control-{}", std::process::id())))
} else {
None
};
let control_path = generated_control_path
.as_ref()
.or(args.ssh_control_path.as_ref());
let _controlmaster = if let Some(path) = generated_control_path.as_ref() {
Some(ControlMaster::start(&args, path.clone())?)
} else {
None
};
for _ in 0..args.iterations.max(1) {
if !args.local_auth {
let mut ssh = Command::new("ssh");
add_ssh_options(&mut ssh, &args, control_path);
ssh.arg(&args.server).arg("true");
ssh_times.push(time_command(&mut ssh)?);
}
let mut cmd = Command::new(&client);
cmd.arg("--attach-only")
.arg("--session")
.arg(&args.session)
.arg("--dosh-port")
.arg(args.dosh_port.to_string());
if let Some(host) = &args.dosh_host {
cmd.arg("--dosh-host").arg(host);
}
if args.local_auth {
cmd.arg("--local-auth").arg(&args.server);
} else {
cmd.arg("--ssh-port")
.arg(args.ssh_port.to_string())
.arg("--ssh-auth-command")
.arg(&args.ssh_auth_command);
if args.no_cache {
cmd.arg("--no-cache");
}
if let Some(key) = &args.ssh_key {
cmd.arg("--ssh-key").arg(key);
}
if let Some(known_hosts) = &args.ssh_known_hosts {
cmd.arg("--ssh-known-hosts").arg(known_hosts);
}
if let Some(control_path) = control_path {
cmd.arg("--ssh-control-path").arg(control_path);
}
cmd.arg(&args.server);
}
dosh_times.push(time_command(&mut cmd)?);
}
if !ssh_times.is_empty() {
println!(
"ssh_true_ms avg={:.2} samples={:?}",
avg_ms(&ssh_times),
ssh_times
);
}
println!(
"dosh_attach_ms avg={:.2} samples={:?}",
avg_ms(&dosh_times),
dosh_times
);
if let Some(margin) = args.assert_ssh_plus_ms {
if ssh_times.is_empty() {
return Err(anyhow!(
"--assert-ssh-plus-ms requires non-local SSH benchmark"
));
}
let ssh_avg = avg_ms(&ssh_times);
let dosh_avg = avg_ms(&dosh_times);
if dosh_avg > ssh_avg + margin {
return Err(anyhow!(
"dosh attach avg {dosh_avg:.2}ms exceeded ssh avg {ssh_avg:.2}ms + {margin:.2}ms"
));
}
println!("gate ok: dosh avg {dosh_avg:.2}ms <= ssh avg {ssh_avg:.2}ms + {margin:.2}ms");
}
Ok(())
}
fn add_ssh_options(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) {
cmd.arg("-p").arg(args.ssh_port.to_string()).arg("-T");
if let Some(key) = &args.ssh_key {
cmd.arg("-i").arg(key);
}
if let Some(known_hosts) = &args.ssh_known_hosts {
cmd.arg("-o")
.arg(format!("UserKnownHostsFile={}", known_hosts.display()));
}
if let Some(control_path) = control_path {
cmd.arg("-S").arg(control_path);
}
}
struct ControlMaster {
server: String,
ssh_port: u16,
ssh_key: Option<PathBuf>,
ssh_known_hosts: Option<PathBuf>,
control_path: PathBuf,
}
impl ControlMaster {
fn start(args: &Args, control_path: PathBuf) -> Result<Self> {
let mut cmd = Command::new("ssh");
cmd.arg("-p")
.arg(args.ssh_port.to_string())
.arg("-T")
.arg("-M")
.arg("-S")
.arg(&control_path)
.arg("-f")
.arg("-N")
.arg("-o")
.arg("ControlPersist=60")
.arg("-o")
.arg("ExitOnForwardFailure=yes");
if let Some(key) = &args.ssh_key {
cmd.arg("-i").arg(key);
}
if let Some(known_hosts) = &args.ssh_known_hosts {
cmd.arg("-o")
.arg(format!("UserKnownHostsFile={}", known_hosts.display()));
}
cmd.arg(&args.server);
time_command(&mut cmd).context("start SSH ControlMaster")?;
Ok(Self {
server: args.server.clone(),
ssh_port: args.ssh_port,
ssh_key: args.ssh_key.clone(),
ssh_known_hosts: args.ssh_known_hosts.clone(),
control_path,
})
}
}
impl Drop for ControlMaster {
fn drop(&mut self) {
let mut cmd = Command::new("ssh");
cmd.arg("-p")
.arg(self.ssh_port.to_string())
.arg("-T")
.arg("-S")
.arg(&self.control_path)
.arg("-O")
.arg("exit");
if let Some(key) = &self.ssh_key {
cmd.arg("-i").arg(key);
}
if let Some(known_hosts) = &self.ssh_known_hosts {
cmd.arg("-o")
.arg(format!("UserKnownHostsFile={}", known_hosts.display()));
}
let _ = cmd.arg(&self.server).output();
}
}
fn time_command(cmd: &mut Command) -> Result<Duration> {
let start = Instant::now();
let output = cmd.output().with_context(|| format!("run {:?}", cmd))?;
if !output.status.success() {
return Err(anyhow!(
"command failed {:?}\nstdout:\n{}\nstderr:\n{}",
cmd,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
));
}
Ok(start.elapsed())
}
fn avg_ms(samples: &[Duration]) -> f64 {
let total: f64 = samples.iter().map(|d| d.as_secs_f64() * 1000.0).sum();
total / samples.len() as f64
}
fn default_client_path() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(|parent| parent.join("dosh-client")))
.unwrap_or_else(|| PathBuf::from("dosh-client"))
}
+617
View File
@@ -0,0 +1,617 @@
use anyhow::{Context, Result, anyhow};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use clap::Parser;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, size};
use dosh::auth::{
BootstrapResponse, build_bootstrap, decode_bootstrap, load_or_create_server_secret,
};
use dosh::config::{expand_tilde, load_client_config, load_server_config};
use dosh::crypto;
use dosh::protocol::{
self, AttachOk, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, PacketKind,
ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, TicketAttachEnvelope,
TicketAttachOkEnvelope,
};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{Read, Write};
use std::net::{SocketAddr, ToSocketAddrs};
use std::process::Command;
use std::time::{Duration, Instant};
use tokio::net::UdpSocket;
use tokio::sync::mpsc;
#[derive(Debug, Parser)]
#[command(name = "dosh-client")]
struct Args {
#[arg()]
server: Option<String>,
#[arg(long, default_value = "default")]
session: String,
#[arg(long)]
view_only: bool,
#[arg(long)]
local_auth: bool,
#[arg(long)]
no_cache: bool,
#[arg(long)]
attach_only: bool,
#[arg(long)]
ssh_port: Option<u16>,
#[arg(long, default_value = "dosh-auth")]
ssh_auth_command: String,
#[arg(long)]
ssh_key: Option<std::path::PathBuf>,
#[arg(long)]
ssh_known_hosts: Option<std::path::PathBuf>,
#[arg(long)]
ssh_control_path: Option<std::path::PathBuf>,
#[arg(long)]
dosh_port: Option<u16>,
#[arg(long)]
dosh_host: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedCredential {
server: String,
session: String,
mode: String,
udp_host: String,
udp_port: u16,
client_id: [u8; 16],
session_key: [u8; 32],
session_key_id: [u8; 16],
attach_ticket: Vec<u8>,
attach_ticket_psk: [u8; 32],
last_rendered_seq: u64,
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
let args = Args::parse();
let config = load_client_config(None).unwrap_or_default();
let server = args.server.unwrap_or(config.server);
let session = args.session;
let mode = if args.view_only || config.view_only {
"view-only"
} else {
"read-write"
}
.to_string();
let ssh_port = args.ssh_port.unwrap_or(config.ssh_port);
let dosh_port = args.dosh_port.unwrap_or(config.dosh_port);
let cache_path = cache_path(&config.credential_cache, &server, &session, &mode);
let (cols, rows) = size().unwrap_or((80, 24));
let started = Instant::now();
let target_udp_host = args
.dosh_host
.clone()
.or_else(|| config.dosh_host.clone())
.unwrap_or_else(|| {
if args.local_auth {
"127.0.0.1".to_string()
} else {
ssh_destination_host(&server)
}
});
let credential = if !args.no_cache {
load_cache(&cache_path).ok()
} else {
None
};
let socket = UdpSocket::bind("0.0.0.0:0").await?;
if let Some(mut cached) = credential.clone() {
cached.udp_host = target_udp_host.clone();
cached.udp_port = dosh_port;
eprintln!(
"dosh timing credential_lookup_end={}ms",
started.elapsed().as_millis()
);
match try_resume(&socket, &cached, cols, rows).await {
Ok((frame, cred)) => {
eprintln!(
"dosh timing udp_resume_ready={}ms",
started.elapsed().as_millis()
);
if !args.no_cache {
save_cache(&cache_path, &cred)?;
}
if args.attach_only {
render_frame(&frame)?;
detach_once(&socket, &cred, 2).await?;
return Ok(());
}
return run_terminal(socket, cred, Some(frame)).await;
}
Err(err) => {
eprintln!("dosh resume failed, trying ticket attach before SSH: {err:#}");
if config.cache_attach_tickets && !args.no_cache {
match try_ticket_attach(&socket, &cached, cols, rows).await {
Ok((frame, cred)) => {
eprintln!(
"dosh timing udp_ticket_attach_ready={}ms",
started.elapsed().as_millis()
);
save_cache(&cache_path, &cred)?;
if args.attach_only {
render_frame(&frame)?;
detach_once(&socket, &cred, 2).await?;
return Ok(());
}
return run_terminal(socket, cred, Some(frame)).await;
}
Err(err) => {
eprintln!(
"dosh ticket attach failed, falling back to SSH bootstrap: {err:#}"
);
}
}
}
}
}
}
let bootstrap_start = Instant::now();
let bootstrap = if args.local_auth {
local_bootstrap(&session, &mode, cols, rows, dosh_port, target_udp_host)?
} else {
let mut bootstrap = ssh_bootstrap(
&server,
ssh_port,
&args.ssh_auth_command,
args.ssh_key.as_deref(),
args.ssh_known_hosts.as_deref(),
args.ssh_control_path.as_deref(),
&session,
&mode,
cols,
rows,
)?;
bootstrap.udp_host = target_udp_host;
bootstrap.udp_port = dosh_port;
bootstrap
};
eprintln!(
"dosh timing ssh_bootstrap={}ms",
bootstrap_start.elapsed().as_millis()
);
let (ok, mut cred) = bootstrap_attach(&socket, &server, &bootstrap, cols, rows).await?;
cred.last_rendered_seq = ok.initial_seq;
if !args.no_cache {
save_cache(&cache_path, &cred)?;
}
eprintln!(
"dosh timing terminal_ready={}ms",
started.elapsed().as_millis()
);
let first = Frame {
session: ok.session,
output_seq: ok.initial_seq,
bytes: ok.snapshot,
snapshot: true,
};
if args.attach_only {
render_frame(&first)?;
detach_once(&socket, &cred, 2).await?;
return Ok(());
}
run_terminal(socket, cred, Some(first)).await
}
fn local_bootstrap(
session: &str,
mode: &str,
cols: u16,
rows: u16,
port: u16,
host: String,
) -> Result<BootstrapResponse> {
let mut server_config = load_server_config(None)?;
server_config.port = port;
let secret = load_or_create_server_secret(&server_config)?;
let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
let nonce = crypto::random_12();
build_bootstrap(
&server_config,
&secret,
user,
session.to_string(),
mode.to_string(),
(cols, rows),
nonce,
host,
)
}
fn ssh_bootstrap(
server: &str,
ssh_port: u16,
ssh_auth_command: &str,
ssh_key: Option<&std::path::Path>,
ssh_known_hosts: Option<&std::path::Path>,
ssh_control_path: Option<&std::path::Path>,
session: &str,
mode: &str,
cols: u16,
rows: u16,
) -> Result<BootstrapResponse> {
let nonce = crypto::random_12();
let nonce_b64 = URL_SAFE_NO_PAD.encode(nonce);
let size = format!("{cols}x{rows}");
let mut command = Command::new("ssh");
command.arg("-p").arg(ssh_port.to_string()).arg("-T");
if let Some(key) = ssh_key {
command.arg("-i").arg(key);
}
if let Some(known_hosts) = ssh_known_hosts {
command
.arg("-o")
.arg(format!("UserKnownHostsFile={}", known_hosts.display()));
}
if let Some(control_path) = ssh_control_path {
command.arg("-S").arg(control_path);
}
let output = command
.arg(server)
.arg(ssh_auth_command)
.arg("--protocol")
.arg("1")
.arg("--nonce")
.arg(nonce_b64)
.arg("--session")
.arg(session)
.arg("--mode")
.arg(mode)
.arg("--size")
.arg(size)
.output()
.context("run ssh dosh-auth")?;
if !output.status.success() {
return Err(anyhow!(
"ssh bootstrap failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let raw = String::from_utf8(output.stdout)?;
decode_bootstrap(&raw)
}
fn ssh_destination_host(server: &str) -> String {
let without_user = server.rsplit_once('@').map_or(server, |(_, host)| host);
let without_path = without_user
.strip_prefix("ssh://")
.unwrap_or(without_user)
.split('/')
.next()
.unwrap_or(without_user);
if let Some(stripped) = without_path.strip_prefix('[') {
if let Some((host, _)) = stripped.split_once(']') {
return host.to_string();
}
}
without_path
.split_once(':')
.map_or(without_path, |(host, _)| host)
.to_string()
}
fn resolve_addr(host: &str, port: u16) -> Result<SocketAddr> {
(host, port)
.to_socket_addrs()
.with_context(|| format!("resolve UDP target {host}:{port}"))?
.next()
.ok_or_else(|| anyhow!("no UDP address resolved for {host}:{port}"))
}
async fn bootstrap_attach(
socket: &UdpSocket,
server_name: &str,
bootstrap: &BootstrapResponse,
cols: u16,
rows: u16,
) -> Result<(AttachOk, CachedCredential)> {
let addr = resolve_addr(&bootstrap.udp_host, bootstrap.udp_port)?;
let req = BootstrapAttachRequest {
bootstrap: bootstrap.clone(),
cols,
rows,
};
let body = protocol::to_body(&req)?;
let packet =
protocol::encode_plain(PacketKind::BootstrapAttachRequest, [0u8; 16], 1, 0, &body)?;
socket.send_to(&packet, addr).await?;
let mut buf = vec![0u8; 65535];
let (n, _) = tokio::time::timeout(Duration::from_secs(5), socket.recv_from(&mut buf)).await??;
let packet = protocol::decode(&buf[..n])?;
if packet.header.kind != PacketKind::AttachOk {
return Err(anyhow!("attach rejected or unexpected response"));
}
let plain = protocol::decrypt_body(&packet, &bootstrap.session_key, SERVER_TO_CLIENT)?;
let ok: AttachOk = protocol::from_body(&plain)?;
let cred = CachedCredential {
server: server_name.to_string(),
session: ok.session.clone(),
mode: ok.mode.clone(),
udp_host: bootstrap.udp_host.clone(),
udp_port: bootstrap.udp_port,
client_id: ok.client_id,
session_key: ok.session_key,
session_key_id: ok.session_key_id,
attach_ticket: bootstrap.attach_ticket.clone(),
attach_ticket_psk: bootstrap.attach_ticket_psk,
last_rendered_seq: ok.initial_seq,
};
Ok((ok, cred))
}
async fn try_ticket_attach(
socket: &UdpSocket,
cached: &CachedCredential,
cols: u16,
rows: u16,
) -> Result<(Frame, CachedCredential)> {
let addr = resolve_addr(&cached.udp_host, cached.udp_port)?;
let client_nonce = crypto::random_12();
let request_key = crypto::hkdf32(
&cached.attach_ticket_psk,
&client_nonce,
b"dosh/ticket-attach-request/v1",
)?;
let body = protocol::to_body(&TicketAttachBody {
session: cached.session.clone(),
mode: cached.mode.clone(),
cols,
rows,
})?;
let ciphertext = crypto::seal(
&request_key,
&client_nonce,
b"dosh-ticket-attach-request-v1",
&body,
)?;
let envelope = TicketAttachEnvelope {
ticket: cached.attach_ticket.clone(),
client_nonce,
ciphertext,
};
let packet = protocol::encode_plain(
PacketKind::TicketAttachRequest,
[0u8; 16],
1,
0,
&protocol::to_body(&envelope)?,
)?;
socket.send_to(&packet, addr).await?;
let mut buf = vec![0u8; 65535];
let (n, _) =
tokio::time::timeout(Duration::from_millis(700), socket.recv_from(&mut buf)).await??;
let packet = protocol::decode(&buf[..n])?;
if packet.header.kind != PacketKind::AttachOk {
return Err(anyhow!("ticket attach rejected"));
}
let envelope: TicketAttachOkEnvelope = protocol::from_body(&packet.body)?;
let mut salt = Vec::with_capacity(24);
salt.extend_from_slice(&client_nonce);
salt.extend_from_slice(&envelope.server_nonce);
let response_key = crypto::hkdf32(
&cached.attach_ticket_psk,
&salt,
b"dosh/ticket-attach-ok/v1",
)?;
let plain = crypto::open(
&response_key,
&envelope.server_nonce,
b"dosh-ticket-attach-ok-v1",
&envelope.ciphertext,
)?;
let ok: AttachOk = protocol::from_body(&plain)?;
let frame = Frame {
session: ok.session.clone(),
output_seq: ok.initial_seq,
bytes: ok.snapshot.clone(),
snapshot: true,
};
let mut next = cached.clone();
next.client_id = ok.client_id;
next.session_key = ok.session_key;
next.session_key_id = ok.session_key_id;
next.last_rendered_seq = ok.initial_seq;
Ok((frame, next))
}
async fn try_resume(
socket: &UdpSocket,
cached: &CachedCredential,
cols: u16,
rows: u16,
) -> Result<(Frame, CachedCredential)> {
let addr = resolve_addr(&cached.udp_host, cached.udp_port)?;
let req = ResumeRequest {
session: cached.session.clone(),
last_rendered_seq: cached.last_rendered_seq,
cols,
rows,
};
let body = protocol::to_body(&req)?;
let packet = protocol::encode_encrypted(
PacketKind::ResumeRequest,
cached.client_id,
1,
0,
&cached.session_key,
CLIENT_TO_SERVER,
&body,
)?;
socket.send_to(&packet, addr).await?;
let mut buf = vec![0u8; 65535];
let (n, _) =
tokio::time::timeout(Duration::from_millis(700), socket.recv_from(&mut buf)).await??;
let packet = protocol::decode(&buf[..n])?;
if packet.header.kind != PacketKind::ResumeOk {
return Err(anyhow!("resume rejected"));
}
let plain = protocol::decrypt_body(&packet, &cached.session_key, SERVER_TO_CLIENT)?;
let frame: Frame = protocol::from_body(&plain)?;
let mut next = cached.clone();
next.last_rendered_seq = frame.output_seq;
Ok((frame, next))
}
async fn run_terminal(
socket: UdpSocket,
mut cred: CachedCredential,
first_frame: Option<Frame>,
) -> Result<()> {
let _raw = RawMode::enter()?;
let addr = resolve_addr(&cred.udp_host, cred.udp_port)?;
let mut send_seq = 2u64;
if let Some(frame) = first_frame {
render_frame(&frame)?;
cred.last_rendered_seq = frame.output_seq;
send_ack(&socket, addr, &cred, &mut send_seq).await?;
}
let (stdin_tx, mut stdin_rx) = mpsc::unbounded_channel::<Vec<u8>>();
std::thread::Builder::new()
.name("dosh-stdin".to_string())
.spawn(move || {
let mut stdin = std::io::stdin();
let mut buf = [0u8; 4096];
loop {
match stdin.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let _ = stdin_tx.send(buf[..n].to_vec());
}
Err(_) => break,
}
}
})?;
let mut recv_buf = vec![0u8; 65535];
loop {
tokio::select! {
Some(bytes) = stdin_rx.recv() => {
if bytes == [0x1d] {
break;
}
if cred.mode != "view-only" {
let body = protocol::to_body(&Input { bytes })?;
let packet = protocol::encode_encrypted(
PacketKind::Input,
cred.client_id,
send_seq,
cred.last_rendered_seq,
&cred.session_key,
CLIENT_TO_SERVER,
&body,
)?;
send_seq += 1;
socket.send_to(&packet, addr).await?;
}
}
recv = socket.recv_from(&mut recv_buf) => {
let (n, _) = recv?;
let packet = protocol::decode(&recv_buf[..n])?;
match packet.header.kind {
PacketKind::Frame | PacketKind::ResumeOk => {
let plain = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT)?;
let frame: Frame = protocol::from_body(&plain)?;
render_frame(&frame)?;
cred.last_rendered_seq = frame.output_seq;
send_ack(&socket, addr, &cred, &mut send_seq).await?;
}
PacketKind::Pong => {}
_ => {}
}
}
}
}
Ok(())
}
async fn send_ack(
socket: &UdpSocket,
addr: SocketAddr,
cred: &CachedCredential,
send_seq: &mut u64,
) -> Result<()> {
let packet = protocol::encode_encrypted(
PacketKind::Ack,
cred.client_id,
*send_seq,
cred.last_rendered_seq,
&cred.session_key,
CLIENT_TO_SERVER,
b"",
)?;
*send_seq += 1;
socket.send_to(&packet, addr).await?;
Ok(())
}
async fn detach_once(socket: &UdpSocket, cred: &CachedCredential, seq: u64) -> Result<()> {
let addr = resolve_addr(&cred.udp_host, cred.udp_port)?;
let packet = protocol::encode_encrypted(
PacketKind::Detach,
cred.client_id,
seq,
cred.last_rendered_seq,
&cred.session_key,
CLIENT_TO_SERVER,
b"",
)?;
socket.send_to(&packet, addr).await?;
Ok(())
}
fn render_frame(frame: &Frame) -> Result<()> {
let mut stdout = std::io::stdout();
stdout.write_all(&frame.bytes)?;
stdout.flush()?;
Ok(())
}
fn cache_path(root: &str, server: &str, session: &str, mode: &str) -> std::path::PathBuf {
let safe = format!("{server}_{session}_{mode}")
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect::<String>();
expand_tilde(root).join(format!("{safe}.bin"))
}
fn load_cache(path: &std::path::Path) -> Result<CachedCredential> {
let raw = fs::read(path)?;
Ok(bincode::deserialize(&raw)?)
}
fn save_cache(path: &std::path::Path, cred: &CachedCredential) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, bincode::serialize(cred)?)?;
Ok(())
}
struct RawMode;
impl RawMode {
fn enter() -> Result<Self> {
enable_raw_mode()?;
Ok(Self)
}
}
impl Drop for RawMode {
fn drop(&mut self) {
let _ = disable_raw_mode();
}
}
+743
View File
@@ -0,0 +1,743 @@
use anyhow::{Context, Result, anyhow};
use clap::{Parser, Subcommand};
use dosh::auth::{
build_bootstrap, encode_bootstrap, load_or_create_server_secret, open_attach_ticket,
verify_bootstrap,
};
use dosh::config::{ServerConfig, load_server_config};
use dosh::crypto;
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
PacketKind, ReplayWindow, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody,
TicketAttachEnvelope, TicketAttachOkEnvelope,
};
use dosh::pty::{PtyHandle, PtyOutput, spawn_pty_session};
use std::collections::{HashMap, VecDeque};
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::net::UdpSocket;
use tokio::sync::mpsc;
#[derive(Debug, Parser)]
#[command(name = "dosh-server")]
struct Args {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Serve {
#[arg(long)]
config: Option<std::path::PathBuf>,
},
Auth {
#[arg(long, default_value_t = 1)]
protocol: u8,
#[arg(long)]
nonce: String,
#[arg(long, default_value = "default")]
session: String,
#[arg(long, default_value = "read-write")]
mode: String,
#[arg(long, default_value = "80x24")]
size: String,
#[arg(long, default_value = "dev")]
client_version: String,
#[arg(long)]
udp_host: Option<String>,
},
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
let args = Args::parse();
match args.command {
Command::Serve { config } => serve(config).await,
Command::Auth {
protocol,
nonce,
session,
mode,
size,
client_version: _,
udp_host,
} => {
anyhow::ensure!(protocol == 1, "unsupported protocol {protocol}");
let config = load_server_config(None)?;
let secret = load_or_create_server_secret(&config)?;
let nonce = parse_nonce(&nonce)?;
let size = parse_size(&size)?;
let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
let udp_host = udp_host.unwrap_or_else(|| "127.0.0.1".to_string());
let resp =
build_bootstrap(&config, &secret, user, session, mode, size, nonce, udp_host)?;
println!("{}", encode_bootstrap(&resp)?);
Ok(())
}
}
}
async fn serve(config_path: Option<std::path::PathBuf>) -> Result<()> {
let config = load_server_config(config_path)?;
let secret = load_or_create_server_secret(&config)?;
let bind = format!("{}:{}", config.bind, config.port);
let socket = Arc::new(
UdpSocket::bind(&bind)
.await
.with_context(|| format!("bind {bind}"))?,
);
eprintln!("dosh-server listening on {bind}");
let (pty_tx, mut pty_rx) = mpsc::unbounded_channel();
let state = Arc::new(Mutex::new(ServerState::new(
config.clone(),
secret,
pty_tx.clone(),
)));
{
let mut locked = state.lock().expect("server state poisoned");
for session in config.prewarm_sessions.clone() {
locked.ensure_session(&session, 80, 24)?;
}
}
let output_state = Arc::clone(&state);
let output_socket = Arc::clone(&socket);
tokio::spawn(async move {
while let Some(output) = pty_rx.recv().await {
if let Err(err) = broadcast_output(&output_state, &output_socket, output).await {
eprintln!("broadcast error: {err:#}");
}
}
});
let retransmit_state = Arc::clone(&state);
let retransmit_socket = Arc::clone(&socket);
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(100));
loop {
interval.tick().await;
if let Err(err) = retransmit_pending(&retransmit_state, &retransmit_socket).await {
eprintln!("retransmit error: {err:#}");
}
}
});
let mut buf = vec![0u8; 65535];
loop {
let (n, peer) = socket.recv_from(&mut buf).await?;
if let Err(err) = handle_packet(&state, &socket, peer, &buf[..n]).await {
eprintln!("packet from {peer}: {err:#}");
}
}
}
struct ServerState {
config: ServerConfig,
secret: [u8; 32],
pty_tx: mpsc::UnboundedSender<PtyOutput>,
sessions: HashMap<String, Session>,
}
struct Session {
pty: PtyHandle,
parser: vt100::Parser,
clients: HashMap<[u8; 16], ClientState>,
output_seq: u64,
recent: VecDeque<Vec<u8>>,
}
#[derive(Clone)]
struct ClientState {
endpoint: SocketAddr,
mode: String,
session_key: [u8; 32],
last_acked: u64,
replay: ReplayWindow,
send_seq: u64,
cols: u16,
rows: u16,
last_seen: Instant,
pending: VecDeque<PendingFrame>,
last_screen: Option<vt100::Screen>,
}
#[derive(Clone)]
struct PendingFrame {
output_seq: u64,
packet: Vec<u8>,
last_sent: Instant,
attempts: u8,
}
impl ServerState {
fn new(
config: ServerConfig,
secret: [u8; 32],
pty_tx: mpsc::UnboundedSender<PtyOutput>,
) -> Self {
Self {
config,
secret,
pty_tx,
sessions: HashMap::new(),
}
}
fn ensure_session(&mut self, name: &str, cols: u16, rows: u16) -> Result<()> {
if self.sessions.contains_key(name) {
return Ok(());
}
let pty = spawn_pty_session(
name.to_string(),
&self.config.shell,
cols.max(1),
rows.max(1),
self.pty_tx.clone(),
)?;
self.sessions.insert(
name.to_string(),
Session {
pty,
parser: vt100::Parser::new(rows.max(1), cols.max(1), self.config.scrollback),
clients: HashMap::new(),
output_seq: 0,
recent: VecDeque::with_capacity(self.config.scrollback),
},
);
Ok(())
}
}
async fn handle_packet(
state: &Arc<Mutex<ServerState>>,
socket: &Arc<UdpSocket>,
peer: SocketAddr,
raw: &[u8],
) -> Result<()> {
let packet = protocol::decode(raw)?;
match packet.header.kind {
PacketKind::BootstrapAttachRequest => {
handle_bootstrap_attach(state, socket, peer, packet.body).await
}
PacketKind::TicketAttachRequest => {
handle_ticket_attach(state, socket, peer, packet.body).await
}
PacketKind::ResumeRequest => handle_resume(state, socket, peer, &packet).await,
PacketKind::Input => handle_input(state, peer, &packet).await,
PacketKind::Resize => handle_resize(state, peer, &packet).await,
PacketKind::Ping => handle_ping(state, socket, peer, &packet).await,
PacketKind::Ack => handle_ack(state, &packet).await,
PacketKind::Detach => handle_detach(state, &packet).await,
_ => Ok(()),
}
}
async fn handle_bootstrap_attach(
state: &Arc<Mutex<ServerState>>,
socket: &Arc<UdpSocket>,
peer: SocketAddr,
body: Vec<u8>,
) -> Result<()> {
let req: BootstrapAttachRequest = protocol::from_body(&body)?;
let (client_id, key, key_id, session_name, mode, output_seq, snapshot) = {
let mut locked = state.lock().expect("server state poisoned");
if !verify_bootstrap(&req.bootstrap, &locked.secret)? {
return send_reject(socket, peer, "invalid or expired bootstrap").await;
}
if !locked.sessions.contains_key(&req.bootstrap.session) {
if locked.config.create_on_attach {
locked.ensure_session(&req.bootstrap.session, req.cols, req.rows)?;
} else {
return send_reject(socket, peer, "session does not exist").await;
}
}
let session = locked
.sessions
.get_mut(&req.bootstrap.session)
.expect("session exists");
let client_id = crypto::random_16();
let snapshot = session.parser.screen().state_formatted();
let screen = session.parser.screen().clone();
let output_seq = session.output_seq;
session.clients.insert(
client_id,
ClientState {
endpoint: peer,
mode: req.bootstrap.mode.clone(),
session_key: req.bootstrap.session_key,
last_acked: output_seq,
replay: ReplayWindow::default(),
send_seq: 1,
cols: req.cols,
rows: req.rows,
last_seen: Instant::now(),
pending: VecDeque::new(),
last_screen: Some(screen),
},
);
(
client_id,
req.bootstrap.session_key,
req.bootstrap.session_key_id,
req.bootstrap.session.clone(),
req.bootstrap.mode.clone(),
output_seq,
snapshot,
)
};
let ok = AttachOk {
client_id,
session: session_name,
mode,
session_key: key,
session_key_id: key_id,
initial_seq: output_seq,
snapshot,
};
let body = protocol::to_body(&ok)?;
let out = protocol::encode_encrypted(
PacketKind::AttachOk,
client_id,
1,
0,
&key,
SERVER_TO_CLIENT,
&body,
)?;
socket.send_to(&out, peer).await?;
Ok(())
}
async fn handle_ticket_attach(
state: &Arc<Mutex<ServerState>>,
socket: &Arc<UdpSocket>,
peer: SocketAddr,
body: Vec<u8>,
) -> Result<()> {
let env: TicketAttachEnvelope = protocol::from_body(&body)?;
let (ticket, request_plain) = {
let locked = state.lock().expect("server state poisoned");
if !locked.config.allow_attach_tickets {
return send_reject(socket, peer, "attach tickets disabled").await;
}
let ticket = open_attach_ticket(&locked.secret, &env.ticket)?;
let request_key = crypto::hkdf32(
&ticket.psk,
&env.client_nonce,
b"dosh/ticket-attach-request/v1",
)?;
let request_plain = crypto::open(
&request_key,
&env.client_nonce,
b"dosh-ticket-attach-request-v1",
&env.ciphertext,
)?;
(ticket, request_plain)
};
let req: TicketAttachBody = protocol::from_body(&request_plain)?;
if req.session != ticket.session || req.mode != ticket.mode {
return send_reject(socket, peer, "ticket scope mismatch").await;
}
let session_key = crypto::random_32();
let session_key_id = {
let digest = crypto::sha256(&session_key);
let mut out = [0u8; 16];
out.copy_from_slice(&digest[..16]);
out
};
let (client_id, output_seq, snapshot) = {
let mut locked = state.lock().expect("server state poisoned");
if !locked.sessions.contains_key(&req.session) {
if locked.config.create_on_attach {
locked.ensure_session(&req.session, req.cols, req.rows)?;
} else {
return send_reject(socket, peer, "session does not exist").await;
}
}
let session = locked
.sessions
.get_mut(&req.session)
.expect("session exists");
let client_id = crypto::random_16();
let snapshot = session.parser.screen().state_formatted();
let screen = session.parser.screen().clone();
let output_seq = session.output_seq;
session.clients.insert(
client_id,
ClientState {
endpoint: peer,
mode: req.mode.clone(),
session_key,
last_acked: output_seq,
replay: ReplayWindow::default(),
send_seq: 1,
cols: req.cols,
rows: req.rows,
last_seen: Instant::now(),
pending: VecDeque::new(),
last_screen: Some(screen),
},
);
(client_id, output_seq, snapshot)
};
let ok = AttachOk {
client_id,
session: req.session,
mode: req.mode,
session_key,
session_key_id,
initial_seq: output_seq,
snapshot,
};
let ok_plain = protocol::to_body(&ok)?;
let server_nonce = crypto::random_12();
let mut salt = Vec::with_capacity(24);
salt.extend_from_slice(&env.client_nonce);
salt.extend_from_slice(&server_nonce);
let response_key = crypto::hkdf32(&ticket.psk, &salt, b"dosh/ticket-attach-ok/v1")?;
let ciphertext = crypto::seal(
&response_key,
&server_nonce,
b"dosh-ticket-attach-ok-v1",
&ok_plain,
)?;
let envelope = TicketAttachOkEnvelope {
server_nonce,
ciphertext,
};
let body = protocol::to_body(&envelope)?;
let out = protocol::encode_plain(PacketKind::AttachOk, client_id, 1, 0, &body)?;
socket.send_to(&out, peer).await?;
Ok(())
}
async fn send_reject(socket: &UdpSocket, peer: SocketAddr, reason: &str) -> Result<()> {
let body = protocol::to_body(&AttachReject {
reason: reason.to_string(),
})?;
let out = protocol::encode_plain(PacketKind::AttachReject, [0u8; 16], 0, 0, &body)?;
socket.send_to(&out, peer).await?;
Ok(())
}
async fn handle_resume(
state: &Arc<Mutex<ServerState>>,
socket: &Arc<UdpSocket>,
peer: SocketAddr,
packet: &protocol::Packet,
) -> Result<()> {
let (key, session_name) = match find_client_key(state, &packet.header.conn_id) {
Ok(found) => found,
Err(_) => return send_reject(socket, peer, "unknown client").await,
};
let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?;
let req: ResumeRequest = protocol::from_body(&body)?;
let (send_seq, output_seq, snapshot) = {
let mut locked = state.lock().expect("server state poisoned");
let session = locked
.sessions
.get_mut(&req.session)
.ok_or_else(|| anyhow!("unknown session"))?;
let client = session
.clients
.get_mut(&packet.header.conn_id)
.ok_or_else(|| anyhow!("unknown client"))?;
if !client.replay.accept(packet.header.seq) {
return Ok(());
}
client.endpoint = peer;
client.last_acked = req.last_rendered_seq;
client.cols = req.cols;
client.rows = req.rows;
client.last_seen = Instant::now();
client.send_seq += 1;
let snapshot = session.parser.screen().state_formatted();
client.last_screen = Some(session.parser.screen().clone());
(client.send_seq, session.output_seq, snapshot)
};
let frame = Frame {
session: session_name,
output_seq,
bytes: snapshot,
snapshot: true,
};
let body = protocol::to_body(&frame)?;
let out = protocol::encode_encrypted(
PacketKind::ResumeOk,
packet.header.conn_id,
send_seq,
packet.header.seq,
&key,
SERVER_TO_CLIENT,
&body,
)?;
socket.send_to(&out, peer).await?;
Ok(())
}
async fn handle_input(
state: &Arc<Mutex<ServerState>>,
peer: SocketAddr,
packet: &protocol::Packet,
) -> Result<()> {
let (key, session_name) = find_client_key(state, &packet.header.conn_id)?;
let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?;
let input: Input = protocol::from_body(&body)?;
let mut locked = state.lock().expect("server state poisoned");
let session = locked
.sessions
.get_mut(&session_name)
.ok_or_else(|| anyhow!("unknown session"))?;
let client = session
.clients
.get_mut(&packet.header.conn_id)
.ok_or_else(|| anyhow!("unknown client"))?;
if !client.replay.accept(packet.header.seq) {
return Ok(());
}
if client.endpoint != peer {
client.endpoint = peer;
}
client.last_seen = Instant::now();
if client.mode == "view-only" {
return Ok(());
}
session.pty.write_all(&input.bytes)?;
Ok(())
}
async fn handle_resize(
state: &Arc<Mutex<ServerState>>,
peer: SocketAddr,
packet: &protocol::Packet,
) -> Result<()> {
let (key, session_name) = find_client_key(state, &packet.header.conn_id)?;
let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?;
let resize: Resize = protocol::from_body(&body)?;
let mut locked = state.lock().expect("server state poisoned");
let session = locked
.sessions
.get_mut(&session_name)
.ok_or_else(|| anyhow!("unknown session"))?;
let client = session
.clients
.get_mut(&packet.header.conn_id)
.ok_or_else(|| anyhow!("unknown client"))?;
if !client.replay.accept(packet.header.seq) {
return Ok(());
}
if client.mode != "view-only" {
client.endpoint = peer;
client.cols = resize.cols;
client.rows = resize.rows;
session.pty.resize(resize.cols, resize.rows)?;
session.parser.set_size(resize.rows, resize.cols);
}
Ok(())
}
async fn handle_ping(
state: &Arc<Mutex<ServerState>>,
socket: &Arc<UdpSocket>,
peer: SocketAddr,
packet: &protocol::Packet,
) -> Result<()> {
let (key, _) = find_client_key(state, &packet.header.conn_id)?;
let seq = {
let mut locked = state.lock().expect("server state poisoned");
let mut found = None;
for session in locked.sessions.values_mut() {
if let Some(client) = session.clients.get_mut(&packet.header.conn_id) {
if !client.replay.accept(packet.header.seq) {
return Ok(());
}
client.last_seen = Instant::now();
client.send_seq += 1;
found = Some(client.send_seq);
break;
}
}
found.ok_or_else(|| anyhow!("unknown client"))?
};
let out = protocol::encode_encrypted(
PacketKind::Pong,
packet.header.conn_id,
seq,
packet.header.seq,
&key,
SERVER_TO_CLIENT,
b"",
)?;
socket.send_to(&out, peer).await?;
Ok(())
}
async fn handle_detach(state: &Arc<Mutex<ServerState>>, packet: &protocol::Packet) -> Result<()> {
let mut locked = state.lock().expect("server state poisoned");
for session in locked.sessions.values_mut() {
session.clients.remove(&packet.header.conn_id);
}
Ok(())
}
async fn handle_ack(state: &Arc<Mutex<ServerState>>, packet: &protocol::Packet) -> Result<()> {
let (key, _) = find_client_key(state, &packet.header.conn_id)?;
let _ = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?;
let mut locked = state.lock().expect("server state poisoned");
for session in locked.sessions.values_mut() {
if let Some(client) = session.clients.get_mut(&packet.header.conn_id) {
if !client.replay.accept(packet.header.seq) {
return Ok(());
}
client.last_seen = Instant::now();
client.last_acked = packet.header.ack;
while client
.pending
.front()
.is_some_and(|pending| pending.output_seq <= packet.header.ack)
{
client.pending.pop_front();
}
return Ok(());
}
}
Ok(())
}
async fn broadcast_output(
state: &Arc<Mutex<ServerState>>,
socket: &Arc<UdpSocket>,
output: PtyOutput,
) -> Result<()> {
let sends = {
let mut locked = state.lock().expect("server state poisoned");
let scrollback = locked.config.scrollback;
let retransmit_window = locked.config.retransmit_window;
let session = locked
.sessions
.get_mut(&output.session)
.ok_or_else(|| anyhow!("unknown session"))?;
session.parser.process(&output.bytes);
session.output_seq += 1;
let output_seq = session.output_seq;
session.recent.push_back(output.bytes.clone());
while session.recent.len() > scrollback {
session.recent.pop_front();
}
let mut sends = Vec::new();
for (client_id, client) in session.clients.iter_mut() {
client.send_seq += 1;
let current_screen = session.parser.screen().clone();
let mut snapshot = false;
let mut bytes = if client.pending.len() >= retransmit_window {
client.pending.clear();
snapshot = true;
current_screen.state_formatted()
} else if let Some(prev) = &client.last_screen {
current_screen.state_diff(prev)
} else {
snapshot = true;
current_screen.state_formatted()
};
if bytes.is_empty() {
bytes = output.bytes.clone();
}
let frame = Frame {
session: output.session.clone(),
output_seq,
bytes,
snapshot,
};
let body = protocol::to_body(&frame)?;
let packet = protocol::encode_encrypted(
PacketKind::Frame,
*client_id,
client.send_seq,
client.last_acked,
&client.session_key,
SERVER_TO_CLIENT,
&body,
)?;
while client.pending.len() >= retransmit_window {
client.pending.pop_front();
}
client.last_screen = Some(current_screen);
client.pending.push_back(PendingFrame {
output_seq,
packet: packet.clone(),
last_sent: Instant::now(),
attempts: 0,
});
sends.push((client.endpoint, packet));
}
sends
};
for (endpoint, packet) in sends {
socket.send_to(&packet, endpoint).await?;
}
Ok(())
}
async fn retransmit_pending(
state: &Arc<Mutex<ServerState>>,
socket: &Arc<UdpSocket>,
) -> Result<()> {
let sends = {
let mut locked = state.lock().expect("server state poisoned");
let now = Instant::now();
let mut sends = Vec::new();
for session in locked.sessions.values_mut() {
for client in session.clients.values_mut() {
for pending in client.pending.iter_mut() {
if pending.output_seq <= client.last_acked {
continue;
}
if now.duration_since(pending.last_sent) >= Duration::from_millis(200)
&& pending.attempts < 8
{
pending.last_sent = now;
pending.attempts += 1;
sends.push((client.endpoint, pending.packet.clone()));
}
}
}
}
sends
};
for (endpoint, packet) in sends {
socket.send_to(&packet, endpoint).await?;
}
Ok(())
}
fn find_client_key(
state: &Arc<Mutex<ServerState>>,
client_id: &[u8; 16],
) -> Result<([u8; 32], String)> {
let locked = state.lock().expect("server state poisoned");
for (name, session) in &locked.sessions {
if let Some(client) = session.clients.get(client_id) {
return Ok((client.session_key, name.clone()));
}
}
Err(anyhow!("unknown client"))
}
fn parse_nonce(raw: &str) -> Result<[u8; 12]> {
let bytes = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, raw)
.context("decode nonce")?;
anyhow::ensure!(bytes.len() == 12, "nonce must decode to 12 bytes");
let mut out = [0u8; 12];
out.copy_from_slice(&bytes);
Ok(out)
}
fn parse_size(raw: &str) -> Result<(u16, u16)> {
let (cols, rows) = raw.split_once('x').context("size must be COLSxROWS")?;
Ok((cols.parse()?, rows.parse()?))
}
+99
View File
@@ -0,0 +1,99 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub port: u16,
pub bind: String,
pub scrollback: usize,
pub auth_ttl_secs: u64,
pub attach_ticket_ttl_secs: u64,
pub allow_attach_tickets: bool,
pub client_timeout_secs: u64,
pub retransmit_window: usize,
pub default_input_mode: String,
pub prewarm_sessions: Vec<String>,
pub create_on_attach: bool,
pub shell: String,
pub sessions_dir: String,
pub secret_path: String,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
port: 50000,
bind: "0.0.0.0".to_string(),
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".to_string(),
prewarm_sessions: vec!["default".to_string()],
create_on_attach: true,
shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
sessions_dir: "~/.local/share/dosh/sessions".to_string(),
secret_path: "~/.config/dosh/secret".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientConfig {
pub server: String,
pub dosh_host: Option<String>,
pub ssh_port: u16,
pub dosh_port: u16,
pub default_session: String,
pub reconnect_timeout_secs: u64,
pub view_only: bool,
pub cache_attach_tickets: bool,
pub credential_cache: String,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
server: "user@example.com".to_string(),
dosh_host: None,
ssh_port: 22,
dosh_port: 50000,
default_session: "default".to_string(),
reconnect_timeout_secs: 5,
view_only: false,
cache_attach_tickets: true,
credential_cache: "~/.local/share/dosh/credentials".to_string(),
}
}
}
pub fn load_server_config(path: Option<PathBuf>) -> Result<ServerConfig> {
let path = path.unwrap_or_else(|| expand_tilde("~/.config/dosh/server.toml"));
if !path.exists() {
return Ok(ServerConfig::default());
}
let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))
}
pub fn load_client_config(path: Option<PathBuf>) -> Result<ClientConfig> {
let path = path.unwrap_or_else(|| expand_tilde("~/.config/dosh/client.toml"));
if !path.exists() {
return Ok(ClientConfig::default());
}
let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))
}
pub fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
PathBuf::from(path)
}
+96
View File
@@ -0,0 +1,96 @@
use anyhow::{Result, anyhow};
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use rand::RngCore;
use sha2::{Digest, Sha256};
pub type HmacSha256 = Hmac<Sha256>;
pub fn random_32() -> [u8; 32] {
let mut out = [0u8; 32];
rand::thread_rng().fill_bytes(&mut out);
out
}
pub fn random_16() -> [u8; 16] {
let mut out = [0u8; 16];
rand::thread_rng().fill_bytes(&mut out);
out
}
pub fn random_12() -> [u8; 12] {
let mut out = [0u8; 12];
rand::thread_rng().fill_bytes(&mut out);
out
}
pub fn hmac_sha256(key: &[u8], parts: &[&[u8]]) -> [u8; 32] {
let mut mac = <HmacSha256 as Mac>::new_from_slice(key).expect("HMAC accepts any key size");
for part in parts {
mac.update(part);
}
mac.finalize().into_bytes().into()
}
pub fn verify_hmac(key: &[u8], parts: &[&[u8]], expected: &[u8; 32]) -> bool {
let actual = hmac_sha256(key, parts);
constant_time_eq(&actual, expected)
}
pub fn sha256(data: &[u8]) -> [u8; 32] {
Sha256::digest(data).into()
}
pub fn hkdf32(secret: &[u8], salt: &[u8], info: &[u8]) -> Result<[u8; 32]> {
let hk = Hkdf::<Sha256>::new(Some(salt), secret);
let mut out = [0u8; 32];
hk.expand(info, &mut out)
.map_err(|_| anyhow!("HKDF expand failed"))?;
Ok(out)
}
pub fn nonce_from(direction: u32, seq: u64) -> [u8; 12] {
let mut nonce = [0u8; 12];
nonce[..4].copy_from_slice(&direction.to_be_bytes());
nonce[4..].copy_from_slice(&seq.to_be_bytes());
nonce
}
pub fn seal(key: &[u8; 32], nonce: &[u8; 12], aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
cipher
.encrypt(
Nonce::from_slice(nonce),
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| anyhow!("encrypt failed"))
}
pub fn open(key: &[u8; 32], nonce: &[u8; 12], aad: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>> {
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
cipher
.decrypt(
Nonce::from_slice(nonce),
Payload {
msg: ciphertext,
aad,
},
)
.map_err(|_| anyhow!("decrypt failed"))
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
+5
View File
@@ -0,0 +1,5 @@
pub mod auth;
pub mod config;
pub mod crypto;
pub mod protocol;
pub mod pty;
+330
View File
@@ -0,0 +1,330 @@
use crate::auth::BootstrapResponse;
use crate::crypto;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
pub const MAGIC: &[u8; 4] = b"DOSH";
pub const VERSION: u8 = 1;
pub const HEADER_LEN: usize = 42;
pub const CLIENT_TO_SERVER: u32 = 1;
pub const SERVER_TO_CLIENT: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum PacketKind {
BootstrapAttachRequest = 1,
TicketAttachRequest = 2,
AttachOk = 3,
AttachReject = 4,
ResumeRequest = 5,
ResumeOk = 6,
Input = 7,
Resize = 8,
Frame = 9,
Ack = 10,
Ping = 11,
Pong = 12,
Detach = 13,
}
impl TryFrom<u8> for PacketKind {
type Error = anyhow::Error;
fn try_from(value: u8) -> Result<Self> {
Ok(match value {
1 => Self::BootstrapAttachRequest,
2 => Self::TicketAttachRequest,
3 => Self::AttachOk,
4 => Self::AttachReject,
5 => Self::ResumeRequest,
6 => Self::ResumeOk,
7 => Self::Input,
8 => Self::Resize,
9 => Self::Frame,
10 => Self::Ack,
11 => Self::Ping,
12 => Self::Pong,
13 => Self::Detach,
_ => bail!("unknown packet kind {value}"),
})
}
}
#[derive(Debug, Clone)]
pub struct Header {
pub kind: PacketKind,
pub flags: u16,
pub conn_id: [u8; 16],
pub seq: u64,
pub ack: u64,
pub body_len: u16,
}
impl Header {
pub fn aad(&self) -> [u8; HEADER_LEN] {
let mut out = [0u8; HEADER_LEN];
out[..4].copy_from_slice(MAGIC);
out[4] = VERSION;
out[5] = self.kind as u8;
out[6..8].copy_from_slice(&self.flags.to_be_bytes());
out[8..24].copy_from_slice(&self.conn_id);
out[24..32].copy_from_slice(&self.seq.to_be_bytes());
out[32..40].copy_from_slice(&self.ack.to_be_bytes());
out[40..42].copy_from_slice(&self.body_len.to_be_bytes());
out
}
pub fn parse(input: &[u8]) -> Result<Self> {
if input.len() < HEADER_LEN {
bail!("packet too short");
}
if &input[..4] != MAGIC {
bail!("bad magic");
}
if input[4] != VERSION {
bail!("bad protocol version {}", input[4]);
}
let kind = PacketKind::try_from(input[5])?;
let flags = u16::from_be_bytes(input[6..8].try_into().unwrap());
let mut conn_id = [0u8; 16];
conn_id.copy_from_slice(&input[8..24]);
let seq = u64::from_be_bytes(input[24..32].try_into().unwrap());
let ack = u64::from_be_bytes(input[32..40].try_into().unwrap());
let body_len = u16::from_be_bytes(input[40..42].try_into().unwrap());
Ok(Self {
kind,
flags,
conn_id,
seq,
ack,
body_len,
})
}
}
#[derive(Debug, Clone)]
pub struct Packet {
pub header: Header,
pub body: Vec<u8>,
}
pub fn encode_plain(
kind: PacketKind,
conn_id: [u8; 16],
seq: u64,
ack: u64,
body: &[u8],
) -> Result<Vec<u8>> {
if body.len() > u16::MAX as usize {
bail!("packet body too large");
}
let header = Header {
kind,
flags: 0,
conn_id,
seq,
ack,
body_len: body.len() as u16,
};
let mut out = Vec::with_capacity(HEADER_LEN + body.len());
out.extend_from_slice(&header.aad());
out.extend_from_slice(body);
Ok(out)
}
pub fn encode_encrypted(
kind: PacketKind,
conn_id: [u8; 16],
seq: u64,
ack: u64,
key: &[u8; 32],
direction: u32,
plaintext: &[u8],
) -> Result<Vec<u8>> {
let nonce = crypto::nonce_from(direction, seq);
let header = Header {
kind,
flags: 1,
conn_id,
seq,
ack,
body_len: 0,
};
let aad_without_len = header.aad();
let ciphertext = crypto::seal(key, &nonce, &aad_without_len[..40], plaintext)?;
if ciphertext.len() > u16::MAX as usize {
bail!("packet body too large");
}
let header = Header {
body_len: ciphertext.len() as u16,
..header
};
let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len());
out.extend_from_slice(&header.aad());
out.extend_from_slice(&ciphertext);
Ok(out)
}
pub fn decode(input: &[u8]) -> Result<Packet> {
let header = Header::parse(input)?;
let end = HEADER_LEN + header.body_len as usize;
if input.len() < end {
bail!("truncated packet body");
}
Ok(Packet {
header,
body: input[HEADER_LEN..end].to_vec(),
})
}
pub fn decrypt_body(packet: &Packet, key: &[u8; 32], direction: u32) -> Result<Vec<u8>> {
if packet.header.flags & 1 == 0 {
return Ok(packet.body.clone());
}
let nonce = crypto::nonce_from(direction, packet.header.seq);
let aad = packet.header.aad();
crypto::open(key, &nonce, &aad[..40], &packet.body)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootstrapAttachRequest {
pub bootstrap: BootstrapResponse,
pub cols: u16,
pub rows: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TicketAttachEnvelope {
pub ticket: Vec<u8>,
pub client_nonce: [u8; 12],
pub ciphertext: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TicketAttachBody {
pub session: String,
pub mode: String,
pub cols: u16,
pub rows: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TicketAttachOkEnvelope {
pub server_nonce: [u8; 12],
pub ciphertext: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachOk {
pub client_id: [u8; 16],
pub session: String,
pub mode: String,
pub session_key: [u8; 32],
pub session_key_id: [u8; 16],
pub initial_seq: u64,
pub snapshot: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachReject {
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResumeRequest {
pub session: String,
pub last_rendered_seq: u64,
pub cols: u16,
pub rows: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Input {
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Resize {
pub cols: u16,
pub rows: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Frame {
pub session: String,
pub output_seq: u64,
pub bytes: Vec<u8>,
pub snapshot: bool,
}
pub fn to_body<T: Serialize>(value: &T) -> Result<Vec<u8>> {
bincode::serialize(value).context("serialize protocol body")
}
pub fn from_body<T: for<'de> Deserialize<'de>>(body: &[u8]) -> Result<T> {
bincode::deserialize(body).context("deserialize protocol body")
}
#[derive(Debug, Clone)]
pub struct ReplayWindow {
highest: u64,
seen: u128,
width: u32,
}
impl Default for ReplayWindow {
fn default() -> Self {
Self::new(128)
}
}
impl ReplayWindow {
pub fn new(width: u32) -> Self {
assert!((1..=128).contains(&width));
Self {
highest: 0,
seen: 0,
width,
}
}
pub fn accept(&mut self, seq: u64) -> bool {
if seq == 0 {
return false;
}
if self.highest == 0 {
self.highest = seq;
self.seen = 1;
return true;
}
if seq > self.highest {
let shift = (seq - self.highest).min(128) as u32;
self.seen = if shift >= self.width {
1
} else {
((self.seen << shift) | 1) & self.mask()
};
self.highest = seq;
return true;
}
let offset = self.highest - seq;
if offset >= self.width as u64 {
return false;
}
let bit = 1u128 << offset;
if self.seen & bit != 0 {
false
} else {
self.seen |= bit;
true
}
}
fn mask(&self) -> u128 {
if self.width == 128 {
u128::MAX
} else {
(1u128 << self.width) - 1
}
}
}
+84
View File
@@ -0,0 +1,84 @@
use anyhow::{Context, Result};
use portable_pty::{CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use std::thread;
use tokio::sync::mpsc;
pub struct PtyHandle {
writer: Arc<Mutex<Box<dyn Write + Send>>>,
_master: Box<dyn MasterPty + Send>,
}
impl PtyHandle {
pub fn write_all(&self, bytes: &[u8]) -> Result<()> {
let mut writer = self.writer.lock().expect("pty writer poisoned");
writer.write_all(bytes)?;
writer.flush()?;
Ok(())
}
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
self._master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})?;
Ok(())
}
}
#[derive(Debug)]
pub struct PtyOutput {
pub session: String,
pub bytes: Vec<u8>,
}
pub fn spawn_pty_session(
session: String,
shell: &str,
cols: u16,
rows: u16,
tx: mpsc::UnboundedSender<PtyOutput>,
) -> Result<PtyHandle> {
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("open pty")?;
let cmd = CommandBuilder::new(shell);
let _child = pair.slave.spawn_command(cmd).context("spawn shell")?;
drop(pair.slave);
let writer = pair.master.take_writer().context("take pty writer")?;
let mut reader = pair.master.try_clone_reader().context("clone pty reader")?;
let reader_session = session.clone();
thread::Builder::new()
.name(format!("dosh-pty-{session}"))
.spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let _ = tx.send(PtyOutput {
session: reader_session.clone(),
bytes: buf[..n].to_vec(),
});
}
Err(_) => break,
}
}
})
.context("spawn pty reader")?;
Ok(PtyHandle {
writer: Arc::new(Mutex::new(writer)),
_master: pair.master,
})
}