diff --git a/README.md b/README.md index 0d49d93..33f2acf 100644 --- a/README.md +++ b/README.md @@ -118,13 +118,19 @@ Attach: dosh palav ``` -Use named sessions: +Plain `dosh palav` opens a fresh terminal session. Use named sessions when you want +to reattach to the same persistent terminal from multiple clients: ```bash dosh --session work palav +dosh --session logs palav ``` Press `Ctrl-]` to detach the current client while leaving the server session alive. +Typing `exit` in the remote shell closes that Dosh session and returns the client. +If UDP packets stop arriving, Dosh shows a blue status bar at the top of the +terminal with the disconnected duration and sends keepalive pings until traffic +returns. If SSH and UDP use different public names, specify the UDP address: diff --git a/SPEC.md b/SPEC.md index c567823..357710c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -321,14 +321,9 @@ Reliability: Named sessions: ```bash -dosh # attach default -dosh attach # attach default -dosh attach work -dosh attach work --view-only -dosh new work -dosh list -dosh list-clients [session] -dosh kill work +dosh host # open a fresh generated session +dosh --session work host # attach/create named persistent session +dosh --session work --view-only host ``` Session behavior: @@ -336,6 +331,8 @@ Session behavior: - One PTY per session. - Sessions persist until killed or server exits. - If a session has zero clients, the PTY keeps running. +- Plain client attaches use generated session names by default. +- `--session ` intentionally reuses or shares a persistent session. - Configured sessions are prewarmed at daemon startup. - If a requested session does not exist: - `attach` creates it only when `create_on_attach = true`. @@ -457,7 +454,7 @@ server = "user@example.com" ssh_auth_command = "~/.local/bin/dosh-auth" # ssh_port = 22 dosh_port = 50000 -default_session = "default" +default_session = "new" reconnect_timeout_secs = 5 view_only = false cache_attach_tickets = true diff --git a/install.ps1 b/install.ps1 index 34a3bc6..a7ab3e8 100644 --- a/install.ps1 +++ b/install.ps1 @@ -54,7 +54,7 @@ $doshHostLine ssh_auth_command = "~/.local/bin/dosh-auth" # ssh_port = 22 dosh_port = $Port -default_session = "default" +default_session = "new" reconnect_timeout_secs = 5 view_only = false cache_attach_tickets = true diff --git a/install.sh b/install.sh index efba86c..208f3e6 100755 --- a/install.sh +++ b/install.sh @@ -218,7 +218,7 @@ $dosh_host_line ssh_auth_command = "~/.local/bin/dosh-auth" # ssh_port = 22 dosh_port = $port -default_session = "default" +default_session = "new" reconnect_timeout_secs = 5 view_only = false cache_attach_tickets = true diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 8b63e75..3395454 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -18,7 +18,7 @@ use std::fs; use std::io::{Read, Write}; use std::net::{SocketAddr, ToSocketAddrs}; use std::process::Command; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::net::UdpSocket; use tokio::sync::mpsc; @@ -27,8 +27,10 @@ use tokio::sync::mpsc; struct Args { #[arg()] server: Option, - #[arg(long, default_value = "default")] - session: String, + #[arg(long)] + session: Option, + #[arg(long)] + new: bool, #[arg(long)] view_only: bool, #[arg(long)] @@ -73,7 +75,7 @@ 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 session = select_session(args.session.as_deref(), args.new, &config.default_session); let mode = if args.view_only || config.view_only { "view-only" } else { @@ -201,6 +203,7 @@ async fn main() -> Result<()> { output_seq: ok.initial_seq, bytes: ok.snapshot, snapshot: true, + closed: false, }; if args.attach_only { render_frame(&first)?; @@ -235,6 +238,24 @@ fn local_bootstrap( ) } +fn select_session(requested: Option<&str>, force_new: bool, configured_default: &str) -> String { + if let Some(session) = requested { + return session.to_string(); + } + if force_new || configured_default == "new" || configured_default.is_empty() { + return unique_session_name(); + } + configured_default.to_string() +} + +fn unique_session_name() -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + format!("term-{millis}-{}", std::process::id()) +} + fn ssh_bootstrap( server: &str, ssh_port: Option, @@ -426,6 +447,7 @@ async fn try_ticket_attach( output_seq: ok.initial_seq, bytes: ok.snapshot.clone(), snapshot: true, + closed: false, }; let mut next = cached.clone(); next.client_id = ok.client_id; @@ -481,8 +503,14 @@ async fn run_terminal( let _raw = RawMode::enter()?; let addr = resolve_addr(&cred.udp_host, cred.udp_port)?; let mut send_seq = 2u64; + let mut last_packet_at = Instant::now(); + let mut status_visible = false; + let mut status_tick = tokio::time::interval(Duration::from_secs(1)); if let Some(frame) = first_frame { render_frame(&frame)?; + if frame.closed { + return Ok(()); + } cred.last_rendered_seq = frame.output_seq; send_ack(&socket, addr, &cred, &mut send_seq).await?; } @@ -528,6 +556,11 @@ async fn run_terminal( } recv = socket.recv_from(&mut recv_buf) => { let (n, _) = recv?; + last_packet_at = Instant::now(); + if status_visible { + clear_status_bar()?; + status_visible = false; + } let packet = protocol::decode(&recv_buf[..n])?; match packet.header.kind { PacketKind::Frame | PacketKind::ResumeOk => { @@ -536,11 +569,36 @@ async fn run_terminal( render_frame(&frame)?; cred.last_rendered_seq = frame.output_seq; send_ack(&socket, addr, &cred, &mut send_seq).await?; + if frame.closed { + break; + } } PacketKind::Pong => {} _ => {} } } + _ = status_tick.tick() => { + let stale = last_packet_at.elapsed(); + if stale >= Duration::from_secs(2) { + draw_status_bar(&format!( + "DOSH disconnected for {}s | session {} | trying UDP", + stale.as_secs(), + cred.session + ))?; + status_visible = true; + let packet = protocol::encode_encrypted( + PacketKind::Ping, + 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(()) @@ -588,6 +646,24 @@ fn render_frame(frame: &Frame) -> Result<()> { Ok(()) } +fn draw_status_bar(message: &str) -> Result<()> { + let mut stdout = std::io::stdout(); + write!( + stdout, + "\x1b7\x1b[1;1H\x1b[44;97m\x1b[K {} \x1b[0m\x1b8", + message + )?; + stdout.flush()?; + Ok(()) +} + +fn clear_status_bar() -> Result<()> { + let mut stdout = std::io::stdout(); + stdout.write_all(b"\x1b7\x1b[1;1H\x1b[K\x1b8")?; + stdout.flush()?; + Ok(()) +} + fn cache_path(root: &str, server: &str, session: &str, mode: &str) -> std::path::PathBuf { let safe = format!("{server}_{session}_{mode}") .chars() diff --git a/src/bin/dosh-server.rs b/src/bin/dosh-server.rs index 9848725..88b1fb3 100644 --- a/src/bin/dosh-server.rs +++ b/src/bin/dosh-server.rs @@ -465,6 +465,7 @@ async fn handle_resume( output_seq, bytes: snapshot, snapshot: true, + closed: false, }; let body = protocol::to_body(&frame)?; let out = protocol::encode_encrypted( @@ -614,6 +615,10 @@ async fn broadcast_output( socket: &Arc, output: PtyOutput, ) -> Result<()> { + if output.exited { + return broadcast_session_exit(state, socket, output.session).await; + } + let sends = { let mut locked = state.lock().expect("server state poisoned"); let scrollback = locked.config.scrollback; @@ -652,6 +657,7 @@ async fn broadcast_output( output_seq, bytes, snapshot, + closed: false, }; let body = protocol::to_body(&frame)?; let packet = protocol::encode_encrypted( @@ -683,6 +689,48 @@ async fn broadcast_output( Ok(()) } +async fn broadcast_session_exit( + state: &Arc>, + socket: &Arc, + session_name: String, +) -> Result<()> { + let sends = { + let mut locked = state.lock().expect("server state poisoned"); + let Some(mut session) = locked.sessions.remove(&session_name) else { + return Ok(()); + }; + session.output_seq += 1; + let output_seq = session.output_seq; + let mut sends = Vec::new(); + for (client_id, mut client) in session.clients.drain() { + client.send_seq += 1; + let frame = Frame { + session: session_name.clone(), + output_seq, + bytes: b"\r\n[dosh session exited]\r\n".to_vec(), + snapshot: false, + closed: true, + }; + 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, + )?; + sends.push((client.endpoint, packet)); + } + sends + }; + for (endpoint, packet) in sends { + socket.send_to(&packet, endpoint).await?; + } + Ok(()) +} + async fn retransmit_pending( state: &Arc>, socket: &Arc, diff --git a/src/config.rs b/src/config.rs index 02acd9c..97c6aff 100644 --- a/src/config.rs +++ b/src/config.rs @@ -64,7 +64,7 @@ impl Default for ClientConfig { ssh_auth_command: Some("~/.local/bin/dosh-auth".to_string()), ssh_port: None, dosh_port: 50000, - default_session: "default".to_string(), + default_session: "new".to_string(), reconnect_timeout_secs: 5, view_only: false, cache_attach_tickets: true, diff --git a/src/protocol.rs b/src/protocol.rs index 6e13625..3d0822d 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -255,6 +255,7 @@ pub struct Frame { pub output_seq: u64, pub bytes: Vec, pub snapshot: bool, + pub closed: bool, } pub fn to_body(value: &T) -> Result> { diff --git a/src/pty.rs b/src/pty.rs index ae48c5c..99f5b85 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -34,6 +34,7 @@ impl PtyHandle { pub struct PtyOutput { pub session: String, pub bytes: Vec, + pub exited: bool, } pub fn spawn_pty_session( @@ -75,14 +76,29 @@ pub fn spawn_pty_session( let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf) { - Ok(0) => break, + Ok(0) => { + let _ = tx.send(PtyOutput { + session: reader_session.clone(), + bytes: Vec::new(), + exited: true, + }); + break; + } Ok(n) => { let _ = tx.send(PtyOutput { session: reader_session.clone(), bytes: buf[..n].to_vec(), + exited: false, }); } - Err(_) => break, + Err(_) => { + let _ = tx.send(PtyOutput { + session: reader_session.clone(), + bytes: Vec::new(), + exited: true, + }); + break; + } } } }) diff --git a/tests/integration_smoke.rs b/tests/integration_smoke.rs index ecddae7..12afcdf 100644 --- a/tests/integration_smoke.rs +++ b/tests/integration_smoke.rs @@ -68,6 +68,8 @@ fn attach_once(dir: &tempfile::TempDir, port: u16, cache: bool) -> std::process: let mut cmd = Command::new(client); cmd.arg("--local-auth") .arg("--attach-only") + .arg("--session") + .arg("default") .arg("--dosh-port") .arg(port.to_string()) .arg("local") @@ -388,6 +390,77 @@ fn resize_updates_pty_size() { ); } +#[test] +fn pty_exit_sends_closed_frame() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + let mut server = start_server(&dir, &config); + let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); + + let input = Input { + bytes: b"exit\n".to_vec(), + }; + send_encrypted( + &socket, + port, + PacketKind::Input, + ok.client_id, + 2, + 0, + &bootstrap.session_key, + &protocol::to_body(&input).unwrap(), + ); + + let mut closed = false; + let deadline = std::time::Instant::now() + Duration::from_secs(3); + while std::time::Instant::now() < deadline { + if let Some((_header, frame)) = recv_frame(&socket, &bootstrap.session_key) { + if frame.closed { + closed = true; + break; + } + } + } + + let _ = server.kill(); + let _ = server.wait(); + + assert!(closed, "expected closed frame after shell exit"); +} + +#[test] +fn pty_sets_terminal_environment() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + let mut server = start_server(&dir, &config); + let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); + + let input = Input { + bytes: b"printf \"$TERM\\n\"\n".to_vec(), + }; + send_encrypted( + &socket, + port, + PacketKind::Input, + ok.client_id, + 2, + 0, + &bootstrap.session_key, + &protocol::to_body(&input).unwrap(), + ); + let text = collect_frame_text(&socket, &bootstrap.session_key, 2000); + + let _ = server.kill(); + let _ = server.wait(); + + assert!( + text.contains("xterm-256color"), + "expected TERM=xterm-256color, got {text:?}" + ); +} + #[test] fn resume_updates_udp_endpoint_for_roaming() { let dir = tempfile::tempdir().unwrap();