Add Mosh parity hardening
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
DuProcess
2026-06-21 10:23:06 -04:00
parent 689d2a9575
commit a029564d80
7 changed files with 509 additions and 46 deletions
+1
View File
@@ -188,6 +188,7 @@ predict = true
predict_mode = "experimental" predict_mode = "experimental"
cache_attach_tickets = true cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials" credential_cache = "~/.local/share/dosh/credentials"
escape_key = "^]"
"@ | Set-Content -NoNewline -Encoding utf8 $clientConfig "@ | Set-Content -NoNewline -Encoding utf8 $clientConfig
} }
+3 -1
View File
@@ -416,8 +416,9 @@ scrollback = 5000
auth_ttl_secs = 30 auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600 attach_ticket_ttl_secs = 3600
allow_attach_tickets = true allow_attach_tickets = true
client_timeout_secs = 86400 client_timeout_secs = 2592000
retransmit_window = 256 retransmit_window = 256
output_frame_interval_ms = 16
default_input_mode = "read-write" default_input_mode = "read-write"
prewarm_sessions = ["default"] prewarm_sessions = ["default"]
create_on_attach = true create_on_attach = true
@@ -492,6 +493,7 @@ known_hosts = "~/.config/dosh/known_hosts"
identity_files = ["~/.ssh/id_ed25519"] identity_files = ["~/.ssh/id_ed25519"]
use_ssh_agent = true use_ssh_agent = true
forward_agent = false forward_agent = false
escape_key = "^]"
EOF EOF
fi fi
+1
View File
@@ -7,6 +7,7 @@ cd "$repo_root"
for test_name in \ for test_name in \
live_output_forwards_terminal_control_sequences \ live_output_forwards_terminal_control_sequences \
tui_control_sequences_survive_transport_verbatim \ tui_control_sequences_survive_transport_verbatim \
unicode_output_survives_transport \
large_tui_paint_is_delivered_in_mtu_safe_frames \ large_tui_paint_is_delivered_in_mtu_safe_frames \
resume_snapshot_preserves_alternate_screen_mode resume_snapshot_preserves_alternate_screen_mode
do do
+255 -37
View File
@@ -110,6 +110,14 @@ struct Args {
#[arg(long)] #[arg(long)]
predict: bool, predict: bool,
#[arg(long)] #[arg(long)]
predict_mode: Option<String>,
#[arg(short = 'a', long = "predict-always")]
predict_always: bool,
#[arg(short = 'n', long = "predict-never")]
predict_never: bool,
#[arg(long)]
escape_key: Option<String>,
#[arg(long)]
ssh_port: Option<u16>, ssh_port: Option<u16>,
#[arg(long)] #[arg(long)]
ssh_auth_command: Option<String>, ssh_auth_command: Option<String>,
@@ -253,6 +261,8 @@ async fn main() -> Result<()> {
|| !dynamic_forwards.is_empty() || !dynamic_forwards.is_empty()
|| forward_agent; || forward_agent;
let predict = args.predict || host.predict.unwrap_or(config.predict); let predict = args.predict || host.predict.unwrap_or(config.predict);
let predict_mode = selected_predict_mode(&args, &config)?;
let escape_key = selected_escape_key(&args, &config)?;
let session = select_session(args.session.as_deref(), args.new); let session = select_session(args.session.as_deref(), args.new);
let mode = if args.forward_only { let mode = if args.forward_only {
"forward-only" "forward-only"
@@ -302,22 +312,17 @@ async fn main() -> Result<()> {
let started = Instant::now(); let started = Instant::now();
let mut resolved_ssh_config = None; let mut resolved_ssh_config = None;
let target_udp_host = if let Some(host) = args let requested_udp_host = args
.dosh_host .dosh_host
.clone() .clone()
.or_else(|| host.dosh_host.clone()) .or_else(|| host.dosh_host.clone())
.or_else(|| config.dosh_host.clone()) .or_else(|| config.dosh_host.clone());
{ let target_udp_host = if args.local_auth {
host requested_udp_host.unwrap_or_else(|| "127.0.0.1".to_string())
} else if args.local_auth {
"127.0.0.1".to_string()
} else { } else {
match ssh_config(&server, ssh_port) { match ssh_config(&server, ssh_port) {
Ok(parsed) => { Ok(parsed) => {
let hostname = parsed let hostname = selected_udp_host(requested_udp_host.as_deref(), &server, &parsed)?;
.hostname
.clone()
.unwrap_or_else(|| ssh_destination_host(&server));
resolved_ssh_config = Some(parsed); resolved_ssh_config = Some(parsed);
hostname hostname
} }
@@ -328,7 +333,11 @@ async fn main() -> Result<()> {
&format!("dosh could not resolve SSH config hostname, using {server}: {err:#}"), &format!("dosh could not resolve SSH config hostname, using {server}: {err:#}"),
); );
resolved_ssh_config = Some(SshConfig::default()); resolved_ssh_config = Some(SshConfig::default());
ssh_destination_host(&server) selected_udp_host(
requested_udp_host.as_deref(),
&server,
&SshConfig::default(),
)?
} }
} }
}; };
@@ -362,6 +371,8 @@ async fn main() -> Result<()> {
cred, cred,
Some(frame), Some(frame),
predict, predict,
predict_mode,
escape_key,
startup_command, startup_command,
config.reconnect_timeout_secs, config.reconnect_timeout_secs,
Vec::new(), Vec::new(),
@@ -398,6 +409,8 @@ async fn main() -> Result<()> {
cred, cred,
Some(frame), Some(frame),
predict, predict,
predict_mode,
escape_key,
startup_command, startup_command,
config.reconnect_timeout_secs, config.reconnect_timeout_secs,
Vec::new(), Vec::new(),
@@ -464,6 +477,8 @@ async fn main() -> Result<()> {
cred, cred,
Some(frame), Some(frame),
predict, predict,
predict_mode,
escape_key,
startup_command, startup_command,
config.reconnect_timeout_secs, config.reconnect_timeout_secs,
local_forwards, local_forwards,
@@ -540,6 +555,8 @@ async fn main() -> Result<()> {
cred, cred,
Some(first), Some(first),
predict, predict,
predict_mode,
escape_key,
startup_command, startup_command,
config.reconnect_timeout_secs, config.reconnect_timeout_secs,
Vec::new(), Vec::new(),
@@ -779,17 +796,19 @@ async fn run_doctor_for_host(
Err(err) => println!("[warn] ssh fallback: {err:#}"), Err(err) => println!("[warn] ssh fallback: {err:#}"),
} }
let udp_host = args let requested_udp_host = args
.dosh_host .dosh_host
.clone() .clone()
.or_else(|| host_config.dosh_host.clone()) .or_else(|| host_config.dosh_host.clone())
.or_else(|| config.dosh_host.clone()) .or_else(|| config.dosh_host.clone());
.unwrap_or_else(|| { let udp_host =
parsed_ssh_config match selected_udp_host(requested_udp_host.as_deref(), &server, &parsed_ssh_config) {
.hostname Ok(host) => host,
.clone() Err(err) => {
.unwrap_or_else(|| ssh_destination_host(&server)) println!("[fail] native endpoint: {err:#}");
}); ssh_destination_host(&server)
}
};
println!("[info] native endpoint: {udp_host}:{dosh_port}"); println!("[info] native endpoint: {udp_host}:{dosh_port}");
match resolve_addr(&udp_host, dosh_port) { match resolve_addr(&udp_host, dosh_port) {
Ok(addr) => println!("[ok] udp resolve: {addr}"), Ok(addr) => println!("[ok] udp resolve: {addr}"),
@@ -1717,6 +1736,30 @@ fn ssh_destination_host(server: &str) -> String {
.to_string() .to_string()
} }
fn selected_udp_host(
explicit: Option<&str>,
server: &str,
ssh_config: &SshConfig,
) -> Result<String> {
let ssh_host = || {
ssh_config
.hostname
.clone()
.unwrap_or_else(|| ssh_destination_host(server))
};
let Some(raw) = explicit.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(ssh_host());
};
match raw.to_ascii_lowercase().as_str() {
"ssh" | "auto" => Ok(ssh_host()),
"localhost" => Ok("127.0.0.1".to_string()),
"any" => Err(anyhow!(
"dosh_host={raw:?} is a server bind policy, not a client destination; use ssh/auto or a host/IP"
)),
_ => Ok(raw.to_string()),
}
}
fn ssh_username(server: &str) -> Option<String> { fn ssh_username(server: &str) -> Option<String> {
let (user, _) = server.rsplit_once('@')?; let (user, _) = server.rsplit_once('@')?;
if user.is_empty() { if user.is_empty() {
@@ -2356,7 +2399,13 @@ async fn bootstrap_attach(
}; };
Ok(protocol::from_body::<AttachOk>(&plain).ok()) Ok(protocol::from_body::<AttachOk>(&plain).ok())
}) })
.await?; .await
.with_context(|| {
format!(
"no Dosh bootstrap response from {}:{}; check UDP reachability and server firewall",
bootstrap.udp_host, bootstrap.udp_port
)
})?;
let cred = CachedCredential { let cred = CachedCredential {
server: server_name.to_string(), server: server_name.to_string(),
session: ok.session.clone(), session: ok.session.clone(),
@@ -2443,7 +2492,13 @@ async fn try_ticket_attach(
}; };
Ok(protocol::from_body::<AttachOk>(&plain).ok()) Ok(protocol::from_body::<AttachOk>(&plain).ok())
}) })
.await?; .await
.with_context(|| {
format!(
"cached ticket attach to {}:{} timed out; check UDP reachability or run `dosh recover {}`",
cached.udp_host, cached.udp_port, cached.server
)
})?;
let frame = Frame { let frame = Frame {
session: ok.session.clone(), session: ok.session.clone(),
output_seq: ok.initial_seq, output_seq: ok.initial_seq,
@@ -2500,7 +2555,13 @@ async fn try_resume_fresh_process(
}; };
Ok(protocol::from_body::<Frame>(&plain).ok()) Ok(protocol::from_body::<Frame>(&plain).ok())
}) })
.await?; .await
.with_context(|| {
format!(
"cached resume to {}:{} timed out; check UDP reachability or run `dosh recover {}`",
cached.udp_host, cached.udp_port, cached.server
)
})?;
let mut next = cached.clone(); let mut next = cached.clone();
next.last_rendered_seq = frame.output_seq; next.last_rendered_seq = frame.output_seq;
Ok((frame, next)) Ok((frame, next))
@@ -2549,7 +2610,13 @@ async fn try_live_resume(
}; };
Ok(protocol::from_body::<Frame>(&plain).ok()) Ok(protocol::from_body::<Frame>(&plain).ok())
}) })
.await?; .await
.with_context(|| {
format!(
"live resume to {}:{} timed out; still waiting for the server to become reachable",
cached.udp_host, cached.udp_port
)
})?;
let mut next = cached.clone(); let mut next = cached.clone();
next.last_rendered_seq = frame.output_seq; next.last_rendered_seq = frame.output_seq;
Ok((frame, next)) Ok((frame, next))
@@ -2584,6 +2651,8 @@ async fn run_terminal(
mut cred: CachedCredential, mut cred: CachedCredential,
first_frame: Option<Frame>, first_frame: Option<Frame>,
predict: bool, predict: bool,
predict_mode: PredictMode,
escape_key: Option<Vec<u8>>,
startup_command: Option<Vec<u8>>, startup_command: Option<Vec<u8>>,
reconnect_timeout_secs: u64, reconnect_timeout_secs: u64,
local_forwards: Vec<LocalForward>, local_forwards: Vec<LocalForward>,
@@ -2618,7 +2687,6 @@ async fn run_terminal(
// env var wins for ad-hoc tuning; otherwise the client config's // env var wins for ad-hoc tuning; otherwise the client config's
// `predict_mode` provides the persistent default. Predictions only run in a // `predict_mode` provides the persistent default. Predictions only run in a
// read-write interactive session. // read-write interactive session.
let predict_mode = resolve_predict_mode();
let mut predictor = Predictor::with_mode( let mut predictor = Predictor::with_mode(
predict && cred.mode != "view-only" && !forward_only, predict && cred.mode != "view-only" && !forward_only,
predict_mode, predict_mode,
@@ -2703,7 +2771,7 @@ async fn run_terminal(
stdin_msg = stdin_rx.recv() => { stdin_msg = stdin_rx.recv() => {
match stdin_msg { match stdin_msg {
Some(bytes) => { Some(bytes) => {
if bytes == [0x1d] { if input_matches_escape(&bytes, escape_key.as_deref()) {
break; break;
} }
if cred.mode != "view-only" && cred.mode != "forward-only" { if cred.mode != "view-only" && cred.mode != "forward-only" {
@@ -4444,17 +4512,81 @@ fn emit_overlay(_bytes: &[u8]) -> Result<()> {
Ok(()) Ok(())
} }
/// Resolve the prediction display policy. `DOSH_PREDICT_MODE` (off / experimental /// Resolve the prediction display policy. `DOSH_PREDICT_MODE` (off /
/// / always) overrides for quick experiments; otherwise the client config's /// experimental / always) overrides for quick experiments; otherwise the
/// `predict_mode` is used, defaulting to the adaptive (experimental) policy. /// already-selected CLI/config policy is used.
fn resolve_predict_mode() -> PredictMode { fn selected_predict_mode(args: &Args, config: &dosh::config::ClientConfig) -> Result<PredictMode> {
if args.predict_always && args.predict_never {
return Err(anyhow!("--predict-always and --predict-never conflict"));
}
if args.predict_always {
return Ok(PredictMode::Always);
}
if args.predict_never {
return Ok(PredictMode::Off);
}
if let Some(mode) = args.predict_mode.as_deref() {
return Ok(PredictMode::parse(mode));
}
if let Ok(env) = std::env::var("DOSH_PREDICT_MODE") { if let Ok(env) = std::env::var("DOSH_PREDICT_MODE") {
return PredictMode::parse(&env); return Ok(PredictMode::parse(&env));
} }
match load_client_config(None) { Ok(PredictMode::parse(&config.predict_mode))
Ok(cfg) => PredictMode::parse(&cfg.predict_mode), }
Err(_) => PredictMode::Experimental,
fn selected_escape_key(
args: &Args,
config: &dosh::config::ClientConfig,
) -> Result<Option<Vec<u8>>> {
if let Some(raw) = args.escape_key.as_deref() {
return parse_escape_key(raw);
} }
if let Ok(raw) = std::env::var("DOSH_ESCAPE_KEY") {
return parse_escape_key(&raw);
}
parse_escape_key(&config.escape_key)
}
fn parse_escape_key(raw: &str) -> Result<Option<Vec<u8>>> {
let value = raw.trim();
if value.eq_ignore_ascii_case("none")
|| value.eq_ignore_ascii_case("off")
|| value.eq_ignore_ascii_case("disabled")
{
return Ok(None);
}
if let Some(rest) = value.strip_prefix("^") {
anyhow::ensure!(
rest.chars().count() == 1,
"invalid control escape key {raw:?}"
);
let ch = rest.as_bytes()[0];
let byte = match ch {
b'?' => 0x7f,
b'@'..=b'_' => ch - b'@',
b'a'..=b'z' => ch - b'a' + 1,
_ => return Err(anyhow!("invalid control escape key {raw:?}")),
};
return Ok(Some(vec![byte]));
}
if let Some(hex) = value.strip_prefix("0x") {
let byte = u8::from_str_radix(hex, 16)
.with_context(|| format!("invalid hex escape key {raw:?}"))?;
return Ok(Some(vec![byte]));
}
if value.len() == 1 {
return Ok(Some(value.as_bytes().to_vec()));
}
Err(anyhow!(
"escape key must be none, ^X, 0xNN, or one ASCII character"
))
}
fn input_matches_escape(bytes: &[u8], escape_key: Option<&[u8]>) -> bool {
let Some(escape_key) = escape_key else {
return false;
};
!escape_key.is_empty() && bytes.windows(escape_key.len()).any(|w| w == escape_key)
} }
/// Resolve whether the non-destructive disconnect status line is enabled. /// Resolve whether the non-destructive disconnect status line is enabled.
@@ -4870,10 +5002,11 @@ mod tests {
DisconnectStatus, DynamicForward, FrameBuffer, LocalForward, MAX_PENDING_USER_INPUT_BYTES, DisconnectStatus, DynamicForward, FrameBuffer, LocalForward, MAX_PENDING_USER_INPUT_BYTES,
PredictMode, Predictor, RemoteForward, SshConfig, StatusAction, auth_allows, cache_key, PredictMode, Predictor, RemoteForward, SshConfig, StatusAction, auth_allows, cache_key,
cache_server_prefix, clear_cached_credentials, ensure_tui_safe_status_overlay, cache_server_prefix, clear_cached_credentials, ensure_tui_safe_status_overlay,
latest_release_download_url, load_first_native_identity_with_prompt, parse_dynamic_forward, input_matches_escape, latest_release_download_url, load_first_native_identity_with_prompt,
parse_local_forward, parse_remote_forward, parse_ssh_config, queue_pending_user_input, parse_dynamic_forward, parse_escape_key, parse_local_forward, parse_remote_forward,
raw_contains_host_table, recv_response_until, render_status_clear, render_status_overlay, parse_ssh_config, queue_pending_user_input, raw_contains_host_table, recv_response_until,
requested_env, resolved_startup_command, rewrite_forward_command, ssh_destination_host, render_status_clear, render_status_overlay, requested_env, resolved_startup_command,
rewrite_forward_command, selected_predict_mode, selected_udp_host, ssh_destination_host,
ssh_username, ssh_with_user, startup_command, toml_bare_key_or_quoted, ssh_username, ssh_with_user, startup_command, toml_bare_key_or_quoted,
update_check_requested, valid_forward_host, update_check_requested, valid_forward_host,
}; };
@@ -4905,6 +5038,10 @@ mod tests {
forward_only: false, forward_only: false,
background: false, background: false,
predict: false, predict: false,
predict_mode: None,
predict_always: false,
predict_never: false,
escape_key: None,
ssh_port: None, ssh_port: None,
ssh_auth_command: None, ssh_auth_command: None,
ssh_key: None, ssh_key: None,
@@ -5046,6 +5183,31 @@ mod tests {
assert_eq!(ssh_destination_host("alice@example.com"), "example.com"); assert_eq!(ssh_destination_host("alice@example.com"), "example.com");
} }
#[test]
fn selected_udp_host_supports_ssh_auto_and_localhost() {
let ssh = SshConfig {
hostname: Some("server.lan".to_string()),
..SshConfig::default()
};
assert_eq!(
selected_udp_host(None, "alias", &ssh).unwrap(),
"server.lan"
);
assert_eq!(
selected_udp_host(Some("ssh"), "alias", &ssh).unwrap(),
"server.lan"
);
assert_eq!(
selected_udp_host(Some("auto"), "alias", &ssh).unwrap(),
"server.lan"
);
assert_eq!(
selected_udp_host(Some("localhost"), "alias", &ssh).unwrap(),
"127.0.0.1"
);
assert!(selected_udp_host(Some("any"), "alias", &ssh).is_err());
}
#[test] #[test]
fn parses_user_from_ssh_destination() { fn parses_user_from_ssh_destination() {
assert_eq!(ssh_username("alice@example.com"), Some("alice".to_string())); assert_eq!(ssh_username("alice@example.com"), Some("alice".to_string()));
@@ -5269,6 +5431,8 @@ mod tests {
assert!(!Predictor::predictable(b"\n")); assert!(!Predictor::predictable(b"\n"));
assert!(!Predictor::predictable(b"\x1b[A")); assert!(!Predictor::predictable(b"\x1b[A"));
assert!(!Predictor::predictable("snowman: \u{2603}".as_bytes())); assert!(!Predictor::predictable("snowman: \u{2603}".as_bytes()));
assert!(!Predictor::predictable("wide: 你".as_bytes()));
assert!(!Predictor::predictable("combine: e\u{301}".as_bytes()));
} }
#[test] #[test]
@@ -5446,6 +5610,42 @@ mod tests {
assert_eq!(PredictMode::parse("anything"), PredictMode::Experimental); assert_eq!(PredictMode::parse("anything"), PredictMode::Experimental);
} }
#[test]
fn cli_prediction_flags_override_config() {
let mut args = test_args("host", &[]);
let mut config = ClientConfig {
predict_mode: "experimental".to_string(),
..ClientConfig::default()
};
args.predict_always = true;
assert_eq!(
selected_predict_mode(&args, &config).unwrap(),
PredictMode::Always
);
args.predict_always = false;
args.predict_never = true;
assert_eq!(
selected_predict_mode(&args, &config).unwrap(),
PredictMode::Off
);
args.predict_never = false;
args.predict_mode = Some("always".to_string());
assert_eq!(
selected_predict_mode(&args, &config).unwrap(),
PredictMode::Always
);
args.predict_mode = None;
config.predict_mode = "off".to_string();
assert_eq!(
selected_predict_mode(&args, &config).unwrap(),
PredictMode::Off
);
}
#[test] #[test]
fn startup_command_joins_args_for_remote_shell() { fn startup_command_joins_args_for_remote_shell() {
assert_eq!(startup_command(&[]), None); assert_eq!(startup_command(&[]), None);
@@ -5699,6 +5899,24 @@ mod tests {
assert_eq!(pending_bytes, MAX_PENDING_USER_INPUT_BYTES); assert_eq!(pending_bytes, MAX_PENDING_USER_INPUT_BYTES);
} }
#[test]
fn escape_key_parser_accepts_mosh_style_controls() {
assert_eq!(parse_escape_key("^]").unwrap(), Some(vec![0x1d]));
assert_eq!(parse_escape_key("^C").unwrap(), Some(vec![0x03]));
assert_eq!(parse_escape_key("^?").unwrap(), Some(vec![0x7f]));
assert_eq!(parse_escape_key("0x1d").unwrap(), Some(vec![0x1d]));
assert_eq!(parse_escape_key("~").unwrap(), Some(vec![b'~']));
assert_eq!(parse_escape_key("none").unwrap(), None);
assert!(parse_escape_key("too-long").is_err());
}
#[test]
fn escape_key_matches_inside_stdin_chunk() {
assert!(input_matches_escape(b"abc\x1d", Some(&[0x1d])));
assert!(!input_matches_escape(b"abc", Some(&[0x1d])));
assert!(!input_matches_escape(b"abc\x1d", None));
}
// --- Item 1: disconnect status line state machine --- // --- Item 1: disconnect status line state machine ---
/// The status line stays hidden while the link is fresh and only appears once /// The status line stays hidden while the link is fresh and only appears once
+189 -1
View File
@@ -203,12 +203,15 @@ async fn serve(config_path: Option<std::path::PathBuf>) -> Result<()> {
let retransmit_state = Arc::clone(&state); let retransmit_state = Arc::clone(&state);
let retransmit_socket = Arc::clone(&socket); let retransmit_socket = Arc::clone(&socket);
tokio::spawn(async move { tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(100)); let mut interval = tokio::time::interval(Duration::from_millis(20));
loop { loop {
interval.tick().await; interval.tick().await;
if let Err(err) = retransmit_pending(&retransmit_state, &retransmit_socket).await { if let Err(err) = retransmit_pending(&retransmit_state, &retransmit_socket).await {
eprintln!("retransmit error: {err:#}"); eprintln!("retransmit error: {err:#}");
} }
if let Err(err) = flush_paced_snapshots(&retransmit_state, &retransmit_socket).await {
eprintln!("paced snapshot error: {err:#}");
}
if let Err(err) = maybe_rekey_clients(&retransmit_state, &retransmit_socket).await { if let Err(err) = maybe_rekey_clients(&retransmit_state, &retransmit_socket).await {
eprintln!("rekey error: {err:#}"); eprintln!("rekey error: {err:#}");
} }
@@ -370,6 +373,8 @@ struct ClientState {
rows: u16, rows: u16,
last_seen: Instant, last_seen: Instant,
pending: VecDeque<PendingFrame>, pending: VecDeque<PendingFrame>,
last_frame_sent: Instant,
paced_snapshot_due: bool,
allowed_forwardings: Vec<ForwardingRequest>, allowed_forwardings: Vec<ForwardingRequest>,
stream_writers: HashMap<u64, mpsc::Sender<Vec<u8>>>, stream_writers: HashMap<u64, mpsc::Sender<Vec<u8>>>,
opened_streams: HashSet<u64>, opened_streams: HashSet<u64>,
@@ -1122,6 +1127,8 @@ async fn handle_native_user_auth(
rows, rows,
last_seen: Instant::now(), last_seen: Instant::now(),
pending: VecDeque::new(), pending: VecDeque::new(),
last_frame_sent: Instant::now() - Duration::from_secs(3600),
paced_snapshot_due: false,
allowed_forwardings: req.auth.requested_forwardings.clone(), allowed_forwardings: req.auth.requested_forwardings.clone(),
stream_writers: HashMap::new(), stream_writers: HashMap::new(),
opened_streams: HashSet::new(), opened_streams: HashSet::new(),
@@ -1271,6 +1278,8 @@ async fn handle_bootstrap_attach(
rows: req.rows, rows: req.rows,
last_seen: Instant::now(), last_seen: Instant::now(),
pending: VecDeque::new(), pending: VecDeque::new(),
last_frame_sent: Instant::now() - Duration::from_secs(3600),
paced_snapshot_due: false,
allowed_forwardings: Vec::new(), allowed_forwardings: Vec::new(),
stream_writers: HashMap::new(), stream_writers: HashMap::new(),
opened_streams: HashSet::new(), opened_streams: HashSet::new(),
@@ -1394,6 +1403,8 @@ async fn handle_ticket_attach(
rows: req.rows, rows: req.rows,
last_seen: Instant::now(), last_seen: Instant::now(),
pending: VecDeque::new(), pending: VecDeque::new(),
last_frame_sent: Instant::now() - Duration::from_secs(3600),
paced_snapshot_due: false,
allowed_forwardings: Vec::new(), allowed_forwardings: Vec::new(),
stream_writers: HashMap::new(), stream_writers: HashMap::new(),
opened_streams: HashSet::new(), opened_streams: HashSet::new(),
@@ -2630,6 +2641,8 @@ async fn broadcast_output(
let mut locked = state.lock().expect("server state poisoned"); let mut locked = state.lock().expect("server state poisoned");
let scrollback = locked.config.scrollback; let scrollback = locked.config.scrollback;
let retransmit_window = locked.config.retransmit_window; let retransmit_window = locked.config.retransmit_window;
let frame_interval = Duration::from_millis(locked.config.output_frame_interval_ms);
let now = Instant::now();
let session = locked let session = locked
.sessions .sessions
.get_mut(&output.session) .get_mut(&output.session)
@@ -2664,6 +2677,15 @@ async fn broadcast_output(
} }
let mut sends = Vec::new(); let mut sends = Vec::new();
for (client_id, client) in session.clients.iter_mut() { for (client_id, client) in session.clients.iter_mut() {
let terminal_control =
output.bytes.contains(&0x1b) || session.parser.screen().alternate_screen();
if !terminal_control
&& !frame_interval.is_zero()
&& now.duration_since(client.last_frame_sent) < frame_interval
{
client.paced_snapshot_due = true;
continue;
}
client.send_seq += 1; client.send_seq += 1;
// Count traffic toward the packet-count rekey trigger (spec §11). // Count traffic toward the packet-count rekey trigger (spec §11).
client.epoch_packets = client.epoch_packets.saturating_add(1); client.epoch_packets = client.epoch_packets.saturating_add(1);
@@ -2702,6 +2724,8 @@ async fn broadcast_output(
last_sent: Instant::now(), last_sent: Instant::now(),
attempts: 0, attempts: 0,
}); });
client.last_frame_sent = now;
client.paced_snapshot_due = false;
sends.push((client.endpoint, packet)); sends.push((client.endpoint, packet));
} }
sends sends
@@ -2852,6 +2876,76 @@ async fn retransmit_pending(
Ok(()) Ok(())
} }
async fn flush_paced_snapshots(
state: &Arc<Mutex<ServerState>>,
socket: &Arc<UdpSocket>,
) -> Result<()> {
let sends = {
let mut locked = state.lock().expect("server state poisoned");
let frame_interval = Duration::from_millis(locked.config.output_frame_interval_ms);
if frame_interval.is_zero() {
return Ok(());
}
let retransmit_window = locked.config.retransmit_window;
let now = Instant::now();
let mut sends = Vec::new();
for (session_name, session) in locked.sessions.iter_mut() {
let due = session.clients.values().any(|client| {
client.paced_snapshot_due
&& now.duration_since(client.last_frame_sent) >= frame_interval
});
if !due {
continue;
}
let snapshot = screen_snapshot(session.parser.screen());
let output_seq = session.output_seq;
for (client_id, client) in session.clients.iter_mut() {
if !client.paced_snapshot_due
|| now.duration_since(client.last_frame_sent) < frame_interval
{
continue;
}
client.send_seq += 1;
client.epoch_packets = client.epoch_packets.saturating_add(1);
let frame = Frame {
session: session_name.clone(),
output_seq,
bytes: snapshot.clone(),
snapshot: true,
closed: false,
};
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.pending.push_back(PendingFrame {
output_seq,
packet: packet.clone(),
last_sent: now,
attempts: 0,
});
client.last_frame_sent = now;
client.paced_snapshot_due = false;
sends.push((client.endpoint, packet));
}
}
sends
};
for (endpoint, packet) in sends {
socket.send_to(&packet, endpoint).await?;
}
Ok(())
}
/// How long the previous epoch's key is retained after a rekey so in-flight /// How long the previous epoch's key is retained after a rekey so in-flight
/// pre-rekey packets still decrypt instead of being dropped as fatal. /// pre-rekey packets still decrypt instead of being dropped as fatal.
const REKEY_PREVIOUS_KEY_GRACE_SECS: u64 = 5; const REKEY_PREVIOUS_KEY_GRACE_SECS: u64 = 5;
@@ -3185,6 +3279,11 @@ mod tests {
assert!(limiter.buckets.is_empty()); assert!(limiter.buckets.is_empty());
} }
#[test]
fn default_client_timeout_supports_long_sleep() {
assert_eq!(ServerConfig::default().client_timeout_secs, 2_592_000);
}
fn test_client_state(session_key: [u8; 32]) -> ClientState { fn test_client_state(session_key: [u8; 32]) -> ClientState {
ClientState { ClientState {
endpoint: "127.0.0.1:9".parse().unwrap(), endpoint: "127.0.0.1:9".parse().unwrap(),
@@ -3197,6 +3296,8 @@ mod tests {
rows: 24, rows: 24,
last_seen: Instant::now(), last_seen: Instant::now(),
pending: VecDeque::new(), pending: VecDeque::new(),
last_frame_sent: Instant::now() - Duration::from_secs(3600),
paced_snapshot_due: false,
allowed_forwardings: Vec::new(), allowed_forwardings: Vec::new(),
stream_writers: HashMap::new(), stream_writers: HashMap::new(),
opened_streams: HashSet::new(), opened_streams: HashSet::new(),
@@ -3397,6 +3498,8 @@ mod tests {
rows: 24, rows: 24,
last_seen: Instant::now(), last_seen: Instant::now(),
pending: VecDeque::new(), pending: VecDeque::new(),
last_frame_sent: Instant::now() - Duration::from_secs(3600),
paced_snapshot_due: false,
allowed_forwardings: Vec::new(), allowed_forwardings: Vec::new(),
stream_writers: HashMap::new(), stream_writers: HashMap::new(),
opened_streams: HashSet::new(), opened_streams: HashSet::new(),
@@ -3490,6 +3593,8 @@ mod tests {
rows: 24, rows: 24,
last_seen: Instant::now(), last_seen: Instant::now(),
pending: VecDeque::new(), pending: VecDeque::new(),
last_frame_sent: Instant::now() - Duration::from_secs(3600),
paced_snapshot_due: false,
allowed_forwardings: Vec::new(), allowed_forwardings: Vec::new(),
stream_writers: HashMap::new(), stream_writers: HashMap::new(),
opened_streams: HashSet::from([42]), opened_streams: HashSet::from([42]),
@@ -3557,6 +3662,89 @@ mod tests {
assert_eq!(frame.bytes, b"terminal-priority"); assert_eq!(frame.bytes, b"terminal-priority");
} }
#[tokio::test]
async fn output_pacing_coalesces_fast_frames_into_snapshot() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let config = ServerConfig {
output_frame_interval_ms: 1000,
..ServerConfig::default()
};
let mut state = ServerState::new(config, [0u8; 32], pty_tx);
state
.ensure_session("test", 80, 24, "forward-only", &[])
.unwrap();
let client_id = [42u8; 16];
let session_key = [7u8; 32];
let receiver = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let sender = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
let mut client = test_client_state(session_key);
client.endpoint = receiver.local_addr().unwrap();
state.insert_client("test", client_id, client);
let state = Arc::new(Mutex::new(state));
broadcast_output(
&state,
&sender,
PtyOutput {
session: "test".to_string(),
bytes: b"first".to_vec(),
exited: false,
},
)
.await
.unwrap();
broadcast_output(
&state,
&sender,
PtyOutput {
session: "test".to_string(),
bytes: b"second".to_vec(),
exited: false,
},
)
.await
.unwrap();
let mut buf = [0u8; 4096];
let (n, _) = tokio::time::timeout(Duration::from_secs(1), receiver.recv_from(&mut buf))
.await
.unwrap()
.unwrap();
let packet = protocol::decode(&buf[..n]).unwrap();
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT).unwrap();
let frame: Frame = protocol::from_body(&plain).unwrap();
assert!(!frame.snapshot);
assert_eq!(frame.bytes, b"first");
assert!(
tokio::time::timeout(Duration::from_millis(30), receiver.recv_from(&mut buf))
.await
.is_err(),
"second frame should be coalesced"
);
{
let mut locked = state.lock().unwrap();
let client = locked
.sessions
.get_mut("test")
.unwrap()
.clients
.get_mut(&client_id)
.unwrap();
client.last_frame_sent = Instant::now() - Duration::from_secs(2);
}
flush_paced_snapshots(&state, &sender).await.unwrap();
let (n, _) = tokio::time::timeout(Duration::from_secs(1), receiver.recv_from(&mut buf))
.await
.unwrap()
.unwrap();
let packet = protocol::decode(&buf[..n]).unwrap();
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT).unwrap();
let frame: Frame = protocol::from_body(&plain).unwrap();
assert!(frame.snapshot);
assert_eq!(frame.output_seq, 2);
}
#[test] #[test]
fn remote_non_loopback_bind_requires_explicit_config() { fn remote_non_loopback_bind_requires_explicit_config() {
let config = ServerConfig::default(); let config = ServerConfig::default();
+15 -1
View File
@@ -14,6 +14,8 @@ pub struct ServerConfig {
pub allow_attach_tickets: bool, pub allow_attach_tickets: bool,
pub client_timeout_secs: u64, pub client_timeout_secs: u64,
pub retransmit_window: usize, pub retransmit_window: usize,
#[serde(default = "default_output_frame_interval_ms")]
pub output_frame_interval_ms: u64,
pub default_input_mode: String, pub default_input_mode: String,
pub prewarm_sessions: Vec<String>, pub prewarm_sessions: Vec<String>,
pub create_on_attach: bool, pub create_on_attach: bool,
@@ -68,8 +70,9 @@ impl Default for ServerConfig {
auth_ttl_secs: 30, auth_ttl_secs: 30,
attach_ticket_ttl_secs: 3600, attach_ticket_ttl_secs: 3600,
allow_attach_tickets: true, allow_attach_tickets: true,
client_timeout_secs: 86400, client_timeout_secs: 2_592_000,
retransmit_window: 256, retransmit_window: 256,
output_frame_interval_ms: default_output_frame_interval_ms(),
default_input_mode: "read-write".to_string(), default_input_mode: "read-write".to_string(),
prewarm_sessions: vec!["default".to_string()], prewarm_sessions: vec!["default".to_string()],
create_on_attach: true, create_on_attach: true,
@@ -132,6 +135,8 @@ pub struct ClientConfig {
/// full-screen TUI, and cleared the instant packets resume. Default on. /// full-screen TUI, and cleared the instant packets resume. Default on.
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub disconnect_status: bool, pub disconnect_status: bool,
#[serde(default = "default_escape_key")]
pub escape_key: String,
#[serde(default = "default_send_env")] #[serde(default = "default_send_env")]
pub send_env: Vec<String>, pub send_env: Vec<String>,
#[serde(default)] #[serde(default)]
@@ -205,6 +210,7 @@ impl Default for ClientConfig {
use_ssh_agent: true, use_ssh_agent: true,
forward_agent: false, forward_agent: false,
disconnect_status: true, disconnect_status: true,
escape_key: default_escape_key(),
send_env: default_send_env(), send_env: default_send_env(),
set_env: HashMap::new(), set_env: HashMap::new(),
extensions: HashMap::new(), extensions: HashMap::new(),
@@ -231,6 +237,10 @@ fn default_native_auth_rate_limit_per_minute() -> u32 {
30 30
} }
fn default_output_frame_interval_ms() -> u64 {
16
}
fn default_rekey_after_packets() -> u64 { fn default_rekey_after_packets() -> u64 {
// Rotate well before any AEAD nonce-reuse concern; ChaCha20-Poly1305 with a // Rotate well before any AEAD nonce-reuse concern; ChaCha20-Poly1305 with a
// per-direction monotonic seq is safe far beyond this, but a bounded epoch // per-direction monotonic seq is safe far beyond this, but a bounded epoch
@@ -259,6 +269,10 @@ fn default_known_hosts() -> String {
"~/.config/dosh/known_hosts".to_string() "~/.config/dosh/known_hosts".to_string()
} }
fn default_escape_key() -> String {
"^]".to_string()
}
fn default_identity_files() -> Vec<String> { fn default_identity_files() -> Vec<String> {
vec!["~/.ssh/id_ed25519".to_string()] vec!["~/.ssh/id_ed25519".to_string()]
} }
+45 -6
View File
@@ -1330,13 +1330,16 @@ fn tui_control_sequences_survive_transport_verbatim() {
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let sequences = concat!( let sequences = concat!(
"\x1b[?1049h", // alternate screen on "\x1b[?1049h", // alternate screen on
"\x1b[?2004h", // bracketed paste on "\x1b[?2004h", // bracketed paste on
"\x1b[?1000h", // mouse tracking on "\x1b[?1000h", // mouse tracking on
"\x1b[?1006h", // SGR mouse encoding on "\x1b[?1006h", // SGR mouse encoding on
"\x1b[?25l", // cursor hidden "\x1b]0;DOSH_TITLE\x07", // OSC title
"\x1b[12;34H", // absolute cursor movement "\x1b[?25l", // cursor hidden
"\x1b[12;34H", // absolute cursor movement
"\x1b[1;31m", // bold red
"DOSH_TUI_VERBATIM", "DOSH_TUI_VERBATIM",
"\x1b[0m",
"\x1b[?25h", "\x1b[?25h",
"\x1b[?1006l", "\x1b[?1006l",
"\x1b[?1000l", "\x1b[?1000l",
@@ -1366,9 +1369,12 @@ fn tui_control_sequences_survive_transport_verbatim() {
"\x1b[?2004h", "\x1b[?2004h",
"\x1b[?1000h", "\x1b[?1000h",
"\x1b[?1006h", "\x1b[?1006h",
"\x1b]0;DOSH_TITLE\x07",
"\x1b[?25l", "\x1b[?25l",
"\x1b[12;34H", "\x1b[12;34H",
"\x1b[1;31m",
"DOSH_TUI_VERBATIM", "DOSH_TUI_VERBATIM",
"\x1b[0m",
"\x1b[?25h", "\x1b[?25h",
"\x1b[?1006l", "\x1b[?1006l",
"\x1b[?1000l", "\x1b[?1000l",
@@ -1382,6 +1388,39 @@ fn tui_control_sequences_survive_transport_verbatim() {
} }
} }
#[test]
fn unicode_output_survives_transport() {
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 marker = "DOSH_UNICODE 你 e\u{301} 🚀";
let input = Input {
bytes: format!("printf '{}\\n'\n", marker).into_bytes(),
};
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(marker),
"expected UTF-8 output marker to survive transport, got {text:?}"
);
}
#[test] #[test]
fn large_tui_paint_is_delivered_in_mtu_safe_frames() { fn large_tui_paint_is_delivered_in_mtu_safe_frames() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();