Keep Dosh alive across stale UDP reconnects
This commit is contained in:
@@ -62,6 +62,10 @@ first usable terminal screen.
|
|||||||
measured UDP RTT.
|
measured UDP RTT.
|
||||||
- New session: measured separately because it may need PTY and shell creation.
|
- New session: measured separately because it may need PTY and shell creation.
|
||||||
|
|
||||||
|
Server-side client resume state is kept for a day by default, so a sleeping laptop
|
||||||
|
can resume the same encrypted client association without depending on a still-valid
|
||||||
|
attach ticket. Attach tickets remain the fast path for fresh client processes.
|
||||||
|
|
||||||
The client emits timing spans for credential lookup, SSH bootstrap, UDP resume,
|
The client emits timing spans for credential lookup, SSH bootstrap, UDP resume,
|
||||||
UDP ticket attach, and terminal-ready time.
|
UDP ticket attach, and terminal-ready time.
|
||||||
|
|
||||||
|
|||||||
@@ -391,6 +391,8 @@ View-only mode:
|
|||||||
Client timeout:
|
Client timeout:
|
||||||
|
|
||||||
- Clients are removed after `client_timeout_secs` without ack/ping.
|
- Clients are removed after `client_timeout_secs` without ack/ping.
|
||||||
|
- The default is intentionally long enough for laptop sleep/resume, currently one
|
||||||
|
day. Short timeouts make Dosh fall back to attach tickets or SSH after sleep.
|
||||||
- Removing a client never kills the session.
|
- Removing a client never kills the session.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -437,7 +439,7 @@ 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 = 30
|
client_timeout_secs = 86400
|
||||||
retransmit_window = 256
|
retransmit_window = 256
|
||||||
default_input_mode = "read-write"
|
default_input_mode = "read-write"
|
||||||
prewarm_sessions = ["default"]
|
prewarm_sessions = ["default"]
|
||||||
|
|||||||
+1
-1
@@ -206,7 +206,7 @@ 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 = 30
|
client_timeout_secs = 86400
|
||||||
retransmit_window = 256
|
retransmit_window = 256
|
||||||
default_input_mode = "read-write"
|
default_input_mode = "read-write"
|
||||||
prewarm_sessions = ["default"]
|
prewarm_sessions = ["default"]
|
||||||
|
|||||||
+152
-64
@@ -164,63 +164,64 @@ async fn main() -> Result<()> {
|
|||||||
cached.udp_host = target_udp_host.clone();
|
cached.udp_host = target_udp_host.clone();
|
||||||
cached.udp_port = dosh_port;
|
cached.udp_port = dosh_port;
|
||||||
log_timing(args.verbose, "credential_lookup_end", started.elapsed());
|
log_timing(args.verbose, "credential_lookup_end", started.elapsed());
|
||||||
match try_resume(&socket, &cached, cols, rows).await {
|
if config.cache_attach_tickets && !args.no_cache {
|
||||||
Ok((frame, cred)) => {
|
match try_ticket_attach(&socket, &cached, cols, rows).await {
|
||||||
log_timing(args.verbose, "udp_resume_ready", started.elapsed());
|
Ok((frame, cred)) => {
|
||||||
if !args.no_cache {
|
log_timing(args.verbose, "udp_ticket_attach_ready", started.elapsed());
|
||||||
save_cache(&cache_path, &cred)?;
|
save_cache(&cache_path, &cred)?;
|
||||||
}
|
if args.attach_only {
|
||||||
if args.attach_only {
|
render_frame(&frame)?;
|
||||||
render_frame(&frame)?;
|
detach_once(&socket, &cred, 2).await?;
|
||||||
detach_once(&socket, &cred, 2).await?;
|
return Ok(());
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
return run_terminal(
|
|
||||||
socket,
|
|
||||||
cred,
|
|
||||||
Some(frame),
|
|
||||||
predict,
|
|
||||||
startup_command,
|
|
||||||
config.reconnect_timeout_secs,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
log_debug(
|
|
||||||
args.verbose,
|
|
||||||
2,
|
|
||||||
&format!("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)) => {
|
|
||||||
log_timing(args.verbose, "udp_ticket_attach_ready", started.elapsed());
|
|
||||||
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),
|
|
||||||
predict,
|
|
||||||
startup_command,
|
|
||||||
config.reconnect_timeout_secs,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
log_debug(
|
|
||||||
args.verbose,
|
|
||||||
2,
|
|
||||||
&format!(
|
|
||||||
"dosh ticket attach failed, falling back to SSH bootstrap: {err:#}"
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return run_terminal(
|
||||||
|
socket,
|
||||||
|
cred,
|
||||||
|
Some(frame),
|
||||||
|
predict,
|
||||||
|
startup_command,
|
||||||
|
config.reconnect_timeout_secs,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log_debug(
|
||||||
|
args.verbose,
|
||||||
|
2,
|
||||||
|
&format!(
|
||||||
|
"dosh ticket attach failed, falling back to SSH bootstrap: {err:#}"
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match try_resume_fresh_process(&socket, &cached, cols, rows).await {
|
||||||
|
Ok((frame, cred)) => {
|
||||||
|
log_timing(args.verbose, "udp_resume_ready", started.elapsed());
|
||||||
|
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),
|
||||||
|
predict,
|
||||||
|
startup_command,
|
||||||
|
config.reconnect_timeout_secs,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log_debug(
|
||||||
|
args.verbose,
|
||||||
|
2,
|
||||||
|
&format!("dosh resume failed, falling back to SSH bootstrap: {err:#}"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -745,7 +746,7 @@ async fn try_ticket_attach(
|
|||||||
Ok((frame, next))
|
Ok((frame, next))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn try_resume(
|
async fn try_resume_fresh_process(
|
||||||
socket: &UdpSocket,
|
socket: &UdpSocket,
|
||||||
cached: &CachedCredential,
|
cached: &CachedCredential,
|
||||||
cols: u16,
|
cols: u16,
|
||||||
@@ -787,6 +788,53 @@ async fn try_resume(
|
|||||||
Ok((frame, next))
|
Ok((frame, next))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn try_live_resume(
|
||||||
|
socket: &UdpSocket,
|
||||||
|
cached: &CachedCredential,
|
||||||
|
send_seq: &mut u64,
|
||||||
|
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,
|
||||||
|
*send_seq,
|
||||||
|
cached.last_rendered_seq,
|
||||||
|
&cached.session_key,
|
||||||
|
CLIENT_TO_SERVER,
|
||||||
|
&body,
|
||||||
|
)?;
|
||||||
|
*send_seq += 1;
|
||||||
|
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.conn_id != cached.client_id {
|
||||||
|
return Err(anyhow!("resume received stale client packet"));
|
||||||
|
}
|
||||||
|
if packet.header.kind != PacketKind::ResumeOk {
|
||||||
|
if packet.header.kind == PacketKind::AttachReject {
|
||||||
|
let reject: AttachReject = protocol::from_body(&packet.body)?;
|
||||||
|
return Err(anyhow!("resume rejected: {}", reject.reason));
|
||||||
|
}
|
||||||
|
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(
|
async fn run_terminal(
|
||||||
socket: UdpSocket,
|
socket: UdpSocket,
|
||||||
mut cred: CachedCredential,
|
mut cred: CachedCredential,
|
||||||
@@ -865,12 +913,35 @@ async fn run_terminal(
|
|||||||
}
|
}
|
||||||
recv = socket.recv_from(&mut recv_buf) => {
|
recv = socket.recv_from(&mut recv_buf) => {
|
||||||
let (n, _) = recv?;
|
let (n, _) = recv?;
|
||||||
last_packet_at = Instant::now();
|
let Ok(packet) = protocol::decode(&recv_buf[..n]) else {
|
||||||
let packet = protocol::decode(&recv_buf[..n])?;
|
continue;
|
||||||
|
};
|
||||||
|
if packet.header.conn_id != [0u8; 16] && packet.header.conn_id != cred.client_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match packet.header.kind {
|
match packet.header.kind {
|
||||||
PacketKind::Frame | PacketKind::ResumeOk => {
|
PacketKind::Frame | PacketKind::ResumeOk => {
|
||||||
let plain = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT)?;
|
let Ok(plain) = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT) else {
|
||||||
let frame: Frame = protocol::from_body(&plain)?;
|
if let Some(frame) = reconnect(
|
||||||
|
&socket,
|
||||||
|
&mut cred,
|
||||||
|
&mut send_seq,
|
||||||
|
last_size,
|
||||||
|
&mut frame_buffer,
|
||||||
|
&mut predictor,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
render_frame(&frame)?;
|
||||||
|
predictor.observe_output(&frame.bytes);
|
||||||
|
last_packet_at = Instant::now();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(frame) = protocol::from_body::<Frame>(&plain) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
last_packet_at = Instant::now();
|
||||||
let frames = frame_buffer.accept(frame, &mut cred.last_rendered_seq);
|
let frames = frame_buffer.accept(frame, &mut cred.last_rendered_seq);
|
||||||
for frame in frames {
|
for frame in frames {
|
||||||
predictor.clear_pending()?;
|
predictor.clear_pending()?;
|
||||||
@@ -883,11 +954,13 @@ async fn run_terminal(
|
|||||||
}
|
}
|
||||||
send_ack(&socket, addr, &cred, &mut send_seq).await?;
|
send_ack(&socket, addr, &cred, &mut send_seq).await?;
|
||||||
}
|
}
|
||||||
PacketKind::Pong => {}
|
PacketKind::Pong => {
|
||||||
|
last_packet_at = Instant::now();
|
||||||
|
}
|
||||||
PacketKind::AttachReject => {
|
PacketKind::AttachReject => {
|
||||||
let reject: AttachReject = protocol::from_body(&packet.body)?;
|
let reject: AttachReject = protocol::from_body(&packet.body)?;
|
||||||
if reject.reason == "unknown client" {
|
if reject.reason == "unknown client" {
|
||||||
if let Some(frame) = reconnect_with_ticket(
|
if let Some(frame) = reconnect(
|
||||||
&socket,
|
&socket,
|
||||||
&mut cred,
|
&mut cred,
|
||||||
&mut send_seq,
|
&mut send_seq,
|
||||||
@@ -911,7 +984,7 @@ async fn run_terminal(
|
|||||||
_ = status_tick.tick() => {
|
_ = status_tick.tick() => {
|
||||||
let stale = last_packet_at.elapsed();
|
let stale = last_packet_at.elapsed();
|
||||||
if stale >= Duration::from_secs(reconnect_timeout_secs.max(1)) {
|
if stale >= Duration::from_secs(reconnect_timeout_secs.max(1)) {
|
||||||
if let Some(frame) = reconnect_with_ticket(
|
if let Some(frame) = reconnect(
|
||||||
&socket,
|
&socket,
|
||||||
&mut cred,
|
&mut cred,
|
||||||
&mut send_seq,
|
&mut send_seq,
|
||||||
@@ -945,7 +1018,7 @@ async fn run_terminal(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reconnect_with_ticket(
|
async fn reconnect(
|
||||||
socket: &UdpSocket,
|
socket: &UdpSocket,
|
||||||
cred: &mut CachedCredential,
|
cred: &mut CachedCredential,
|
||||||
send_seq: &mut u64,
|
send_seq: &mut u64,
|
||||||
@@ -953,6 +1026,21 @@ async fn reconnect_with_ticket(
|
|||||||
frame_buffer: &mut FrameBuffer,
|
frame_buffer: &mut FrameBuffer,
|
||||||
predictor: &mut Predictor,
|
predictor: &mut Predictor,
|
||||||
) -> Result<Option<Frame>> {
|
) -> Result<Option<Frame>> {
|
||||||
|
if let Ok((frame, next)) = try_live_resume(socket, cred, send_seq, size.0, size.1).await {
|
||||||
|
*cred = next;
|
||||||
|
frame_buffer.clear();
|
||||||
|
predictor.reset();
|
||||||
|
cred.last_rendered_seq = frame.output_seq;
|
||||||
|
send_ack(
|
||||||
|
socket,
|
||||||
|
resolve_addr(&cred.udp_host, cred.udp_port)?,
|
||||||
|
cred,
|
||||||
|
send_seq,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(Some(frame));
|
||||||
|
}
|
||||||
|
|
||||||
let Ok((frame, next)) = try_ticket_attach(socket, cred, size.0, size.1).await else {
|
let Ok((frame, next)) = try_ticket_attach(socket, cred, size.0, size.1).await else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ 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: 30,
|
client_timeout_secs: 86400,
|
||||||
retransmit_window: 256,
|
retransmit_window: 256,
|
||||||
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()],
|
||||||
|
|||||||
Reference in New Issue
Block a user