Compare commits

...
9 Commits
Author SHA1 Message Date
DuProcess b393c0e5d5 Allow netlink for terminal network monitors
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
2026-07-02 19:40:30 -04:00
DuProcess d5bc8e3c64 Recover terminal output gaps with resume repaint
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
2026-07-02 19:29:20 -04:00
DuProcess c40f5459ba Keep live TUI output raw under retransmit pressure
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
2026-07-02 19:21:43 -04:00
DuProcess 601510687e Prepare Dosh 0.1.9 release
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
2026-07-02 19:07:46 -04:00
DuProcess 60387e9222 Install Dosh binaries atomically
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
2026-07-02 18:41:02 -04:00
DuProcess a48aa15308 Preserve raw terminal output for TUIs
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
2026-07-02 18:34:09 -04:00
DuProcess 691b58e531 Harden update and release tooling
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
2026-07-01 19:48:46 -04:00
DuProcess 431dcc3997 Prepare Dosh 0.1.7 release
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
2026-07-01 18:03:42 -04:00
DuProcess 3224a9c699 Fix Dosh stdio proxy for VS Code
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
2026-07-01 09:58:26 -04:00
13 changed files with 437 additions and 311 deletions
Generated
+1 -1
View File
@@ -436,7 +436,7 @@ dependencies = [
[[package]]
name = "dosh"
version = "0.1.6"
version = "0.1.12"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "dosh"
version = "0.1.6"
version = "0.1.12"
edition = "2024"
license = "MIT"
+52 -116
View File
@@ -1,33 +1,31 @@
# Dosh
Dosh is an encrypted remote terminal for fast reconnecting shells. It is meant
to replace Mosh and day-to-day interactive SSH sessions.
Dosh is an encrypted remote terminal for fast reconnecting shells.
It runs a `dosh-server` on the remote machine and a `dosh` client locally. The
first setup can use SSH. After that, Dosh can attach over encrypted UDP with
cached credentials, keep terminal sessions alive, reconnect after network
changes, and forward TCP ports.
It also includes native encrypted file copy over the Dosh stream layer.
It runs a `dosh-server` on a Unix-like host and a `dosh` client on macOS,
Linux, or Windows. Setup can use SSH, then Dosh connects over encrypted UDP,
keeps sessions alive across disconnects, supports terminal apps, and can carry
TCP forwarding, file copy, and VS Code Remote-SSH streams.
## Support
- Client: macOS, Linux, Windows
- Server: Unix-like systems with PTYs
- Server: Linux and other Unix-like systems with PTYs
- Default UDP port: `50000`
- Windows: client only
- UDP port: one configured server port, default `50000`
Dosh does not implement SFTP, X11 forwarding, or a Windows server. Windows is
client-only.
Dosh is meant to replace Mosh and everyday interactive SSH sessions. It does
not currently include SFTP, X11 forwarding, or a Windows server.
## Install
Server and client on Unix/macOS:
Unix/macOS server and client:
```sh
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh | sh -s -- both --repo https://git.palav.dev/Palav/dosh.git --port 50000
```
Client only on Unix/macOS:
Unix/macOS client only:
```sh
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh | sh -s -- client --repo https://git.palav.dev/Palav/dosh.git
@@ -39,123 +37,48 @@ Windows client:
irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
```
## Commands
## Use
```sh
dosh setup HOST # import SSH config and trust the Dosh host key
dosh HOST # connect
dosh HOST COMMAND # connect and run a command
dosh exec HOST COMMAND # run a non-interactive remote command
dosh cp SRC DST # copy files; use host:path for a remote side
dosh ls host:path # list a remote path
dosh cat host:path # print a remote file
dosh mkdir host:path # create a remote directory
dosh rm [-r] host:path # remove a remote file or directory
dosh update # update Dosh
dosh status HOST # show remote tmux sessions and Dosh service status
dosh restart HOST # restart dosh-server and show service status
dosh doctor HOST # check config and connectivity
dosh recover HOST # clear cached attach state and re-check
dosh proxy-stdio HOST 127.0.0.1 22
dosh vscode HOST [PATH] # configure/open VS Code Remote-SSH through Dosh
dosh setup HOST
dosh HOST
dosh HOST COMMAND
dosh update
```
Examples:
Useful commands:
```sh
dosh server
dosh server tm
dosh exec server 'uname -a'
dosh cp file.txt server:tmp/file.txt
dosh cp -r server:Projects/app ./app
dosh cp -r server:Projects/app backup:app
dosh ls server:Projects
dosh cat server:tmp/file.txt
dosh forward server -L 8080:127.0.0.1:80
dosh forward server -D 1080
dosh forward server -R 2222:127.0.0.1:22
dosh proxy-stdio server 127.0.0.1 22
dosh vscode server /home/me/project
dosh exec HOST COMMAND
dosh cp SRC DST
dosh ls host:path
dosh cat host:path
dosh mkdir host:path
dosh rm [-r] host:path
dosh forward HOST -L 8080:127.0.0.1:80
dosh forward HOST -D 1080
dosh forward HOST -R 2222:127.0.0.1:22
dosh status HOST
dosh doctor HOST
dosh recover HOST
dosh restart HOST
```
Agent forwarding is opt-in with `-A` and must be enabled on the server.
File copy is enabled by `allow_file_transfer = true` on the server.
Agent forwarding is opt-in with `-A` and must also be enabled on the server.
File copy must be enabled by the server config.
## VS Code
Dosh can carry VS Code Remote-SSH without replacing VS Code's SSH workflow.
Dosh can carry VS Code Remote-SSH through its transport:
```sh
dosh vscode setup server
dosh vscode server /home/me/project
dosh vscode setup HOST
dosh vscode HOST /remote/path
```
This writes a managed SSH config entry using:
```sshconfig
ProxyCommand dosh proxy-stdio server %h %p
```
VS Code still uses Remote-SSH and the normal remote VS Code Server. Dosh carries
the SSH byte stream over its encrypted reconnecting transport.
The optional extension in `vscode-extension/` provides the same setup from the
Command Palette.
## Library
Dosh can also be used as a Rust transport for application protocols that need
encrypted streams, roaming, reconnect behavior, keepalives, retransmission,
flow control, and in-order delivery.
Use `dosh::client::DoshClient` and `dosh::server::DoshServer` for the complete
native-auth path. Use `dosh::transport::DoshTransport` or `StreamMux` only when
you already have your own session/auth layer.
Client-side SDK:
```rust
let client = dosh::client::DoshClient::load()?;
let mut dosh = client
.connect("server")
.service("myapp")
.connect()
.await?
.into_transport();
let stream = dosh.open_service("myapp").await?;
dosh.send(stream, b"hello").await?;
```
Server-side SDK:
```rust
let config = dosh::server::DoshServerConfig::default()
.service("myapp")?;
let mut server = dosh::server::DoshServer::bind(config).await?;
loop {
match server.recv().await? {
dosh::server::DoshServerEvent::Accepted(client) => {
eprintln!("accepted {:?}", client.conn_id);
}
dosh::server::DoshServerEvent::Session { conn_id, event } => {
if let dosh::transport::SessionEvent::Stream(
dosh::transport::TransportEvent::Open(open)
) = event {
server.accept_stream(conn_id, open.stream_id).await?;
}
}
dosh::server::DoshServerEvent::Ignored => {}
}
}
```
The server runtime owns UDP reads and demuxes packets by connection id, so
multiple clients can share one Dosh port without per-session readers racing.
Runnable SDK examples live in `examples/sdk_echo_server.rs` and
`examples/sdk_echo_client.rs`.
This creates a managed SSH config entry using `ProxyCommand dosh proxy-stdio`.
VS Code still uses Remote-SSH and its normal remote server; Dosh carries the
SSH byte stream.
## Config
@@ -169,8 +92,21 @@ Common client settings:
```toml
default_session = "new"
auth_preference = "native,ssh"
predict = true
cache_attach_tickets = true
disconnect_status = true
```
## Rust Library
Dosh exposes a Rust transport for encrypted, reconnecting application streams.
Use `dosh::client::DoshClient` and `dosh::server::DoshServer` for the normal
native-auth path. Use `dosh::transport::DoshTransport` only when you already
own session setup and authentication.
Runnable examples:
```text
examples/sdk_echo_client.rs
examples/sdk_echo_server.rs
```
+19 -5
View File
@@ -141,6 +141,20 @@ function Verify-ArchiveVersion($ExtractDir, $Url) {
}
}
function Install-Binary($Source, $Destination) {
$dir = Split-Path -Parent $Destination
$name = Split-Path -Leaf $Destination
$tmp = Join-Path $dir ".$name.tmp.$PID"
Copy-Item $Source $tmp -Force
try {
Move-Item $tmp $Destination -Force
}
catch {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
throw
}
}
$bindir = Join-Path $Prefix "bin"
$configDir = Join-Path $HOME ".config\dosh"
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
@@ -168,9 +182,9 @@ function Install-Prebuilt {
if (-not $found) {
throw "prebuilt archive missing $bin"
}
Copy-Item $found.FullName (Join-Path $bindir $bin) -Force
Install-Binary $found.FullName (Join-Path $bindir $bin)
}
Copy-Item (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe") -Force
Install-Binary (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe")
return $true
}
catch {
@@ -202,9 +216,9 @@ function Install-FromSource {
try {
Push-Location $src
cargo build --release --bin dosh-client --bin dosh-bench
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force
Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe")
Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe")
Install-Binary "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe")
}
finally {
Pop-Location
+20 -11
View File
@@ -12,11 +12,7 @@ start_server=1
force_config=0
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
quiet="${DOSH_UPDATE_QUIET:-0}"
if [ "${DOSH_UPDATE_QUIET:-0}" = "1" ] && [ "${DOSH_BINARY_REQUIRED:-0}" != "1" ]; then
use_prebuilt=0
else
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
fi
binary_url="${DOSH_BINARY_URL:-}"
binary_base="${DOSH_BINARY_BASE:-}"
binary_name="${DOSH_BINARY_NAME:-}"
@@ -230,13 +226,26 @@ find_extracted_binary() {
find "$1" -type f -name "$2" 2>/dev/null | sed -n '1p'
}
install_binary() {
src="$1"
dst="$2"
dst_dir="$(dirname "$dst")"
dst_base="$(basename "$dst")"
tmp="$dst_dir/.$dst_base.tmp.$$"
install -m 0755 "$src" "$tmp"
if ! mv -f "$tmp" "$dst"; then
rm -f "$tmp"
return 1
fi
}
install_extracted_binary() {
found="$(find_extracted_binary "$1" "$2")"
if [ -z "$found" ]; then
echo "prebuilt archive missing $2" >&2
return 1
fi
install -m 0755 "$found" "$3"
install_binary "$found" "$3"
}
sha256_file() {
@@ -333,7 +342,7 @@ try_install_prebuilt() {
install_extracted_binary "$tmpdir/extract" dosh-auth "$bindir/dosh-auth" || return 1
fi
if found_bench="$(find_extracted_binary "$tmpdir/extract" dosh-bench)" && [ -n "$found_bench" ]; then
install -m 0755 "$found_bench" "$bindir/dosh-bench"
install_binary "$found_bench" "$bindir/dosh-bench"
fi
return 0
}
@@ -392,14 +401,14 @@ install_from_source() {
fi
fi
install -m 0755 target/release/dosh-client "$bindir/dosh-client"
install_binary target/release/dosh-client "$bindir/dosh-client"
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install -m 0755 target/release/dosh-server "$bindir/dosh-server"
install -m 0755 target/release/dosh-auth "$bindir/dosh-auth"
install_binary target/release/dosh-server "$bindir/dosh-server"
install_binary target/release/dosh-auth "$bindir/dosh-auth"
fi
if [ -f target/release/dosh-bench ]; then
install -m 0755 target/release/dosh-bench "$bindir/dosh-bench"
install_binary target/release/dosh-bench "$bindir/dosh-bench"
fi
}
@@ -425,7 +434,7 @@ PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=$config_dir $data_dir
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
+1 -1
View File
@@ -14,7 +14,7 @@ PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=%h/.config/dosh %h/.local/share/dosh
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
+20
View File
@@ -99,6 +99,21 @@ fi
web_repo="${base%/}/${repo}"
failed=0
artifact_version() {
artifact="$1"
case "$artifact" in
*.tar.gz)
tar -xOf "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
;;
*.zip)
unzip -p "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
;;
*)
return 1
;;
esac
}
for artifact in target/dosh-release/dosh-linux-*.tar.gz target/dosh-release/dosh-macos-*.tar.gz target/dosh-release/dosh-windows-*.zip; do
[ -f "$artifact" ] || continue
name="$(basename "$artifact")"
@@ -107,6 +122,11 @@ for artifact in target/dosh-release/dosh-linux-*.tar.gz target/dosh-release/dosh
continue
;;
esac
actual="$(artifact_version "$artifact" || true)"
if [ "$actual" != "$version" ]; then
echo "skipping stale artifact $name: version ${actual:-unknown}, expected $version" >&2
continue
fi
url="$web_repo/releases/download/$tag/$name"
if curl -fsSI "$url" >/dev/null; then
echo "verified $url"
+2
View File
@@ -8,6 +8,8 @@ for test_name in \
live_output_forwards_terminal_control_sequences \
tui_control_sequences_survive_transport_verbatim \
unicode_output_survives_transport \
tui_graph_glyphs_survive_fast_repaints_without_snapshot_substitution \
btop_style_graph_output_stays_raw_when_retransmit_history_overflows \
large_tui_paint_is_delivered_in_mtu_safe_frames \
resume_snapshot_preserves_alternate_screen_mode
do
+20 -15
View File
@@ -32,6 +32,21 @@ token="${GITEA_TOKEN:-}"
title="${GITEA_TITLE:-$tag}"
expected_version="${tag#v}"
artifact_version() {
artifact="$1"
case "$artifact" in
*.tar.gz)
tar -xOf "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
;;
*.zip)
unzip -p "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
;;
*)
return 1
;;
esac
}
if [ -z "$token" ] && [ -f "$HOME/.config/dosh/gitea-token" ]; then
token="$(tr -d '\r\n' <"$HOME/.config/dosh/gitea-token")"
fi
@@ -52,6 +67,11 @@ if [ "$#" -eq 0 ]; then
continue
;;
esac
actual="$(artifact_version "$artifact" || true)"
if [ "$actual" != "$expected_version" ]; then
echo "skipping $artifact: version ${actual:-unknown}, expected $expected_version" >&2
continue
fi
if [ -f "$artifact.sha256" ]; then
set -- "$@" "$artifact" "$artifact.sha256"
else
@@ -103,21 +123,6 @@ delete_existing_asset() {
done
}
artifact_version() {
artifact="$1"
case "$artifact" in
*.tar.gz)
tar -xOf "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
;;
*.zip)
unzip -p "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
;;
*)
return 1
;;
esac
}
verify_release_artifact_version() {
artifact="$1"
name="$(basename "$artifact")"
+122 -14
View File
@@ -1491,6 +1491,7 @@ async fn proxy_stdio_loop(mut transport: DoshTransport, stream_id: u64) -> Resul
for chunk in data.chunks {
stdout.write_all(&chunk).await?;
}
stdout.flush().await?;
}
SessionEvent::Stream(TransportEvent::Close { stream_id: closed }) if closed == stream_id => {
stdout.flush().await?;
@@ -1529,22 +1530,29 @@ fn run_vscode_command(config: &dosh::config::ClientConfig, args: &Args) -> Resul
if tokens.next().is_some() {
return Err(anyhow!("dosh vscode accepts at most one remote path"));
}
let alias = ensure_vscode_ssh_host(config, args, host)?;
println!("[ok] VS Code SSH host: {alias}");
println!("[ok] Dosh proxy: dosh proxy-stdio {host} 127.0.0.1 22");
let generated = ensure_vscode_ssh_host(config, args, host)?;
println!("[ok] VS Code SSH host: {}", generated.alias);
println!("[ok] SSH target: 127.0.0.1:{}", generated.target_ssh_port);
println!("[ok] Dosh proxy: {}", generated.proxy_command);
if launch {
launch_vscode_remote(&alias, remote_path)?;
launch_vscode_remote(&generated.alias, remote_path)?;
} else {
println!("Open in VS Code Remote-SSH as: {alias}");
println!("Open in VS Code Remote-SSH as: {}", generated.alias);
}
Ok(())
}
struct GeneratedVscodeHost {
alias: String,
proxy_command: String,
target_ssh_port: u16,
}
fn ensure_vscode_ssh_host(
config: &dosh::config::ClientConfig,
args: &Args,
requested: &str,
) -> Result<String> {
) -> Result<GeneratedVscodeHost> {
let hosts = load_hosts_config(None).unwrap_or_default();
let host_config = hosts.hosts.get(requested).cloned().unwrap_or_default();
let raw_server = host_config
@@ -1554,6 +1562,7 @@ fn ensure_vscode_ssh_host(
let server = ssh_with_user(&raw_server, host_config.user.as_deref());
let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port);
let ssh_config = ssh_config(&server, ssh_port).unwrap_or_default();
let target_ssh_port = ssh_config.port.or(ssh_port).unwrap_or(22);
let user = host_config
.user
.or(ssh_username(&server))
@@ -1563,11 +1572,22 @@ fn ensure_vscode_ssh_host(
.ok()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "dosh".to_string());
let mut proxy_command = shell_word(&exe);
let mut proxy_command = ssh_config_word(&exe);
if let Some(host) = args
.dosh_host
.clone()
.or_else(|| host_config.dosh_host.clone())
.or_else(|| config.dosh_host.clone())
{
proxy_command.push_str(&format!(" --dosh-host {}", ssh_config_word(&host)));
}
if let Some(port) = args.dosh_port.or(host_config.port) {
proxy_command.push_str(&format!(" --dosh-port {port}"));
}
proxy_command.push_str(&format!(" proxy-stdio {} %h %p", shell_word(requested)));
proxy_command.push_str(&format!(
" proxy-stdio {} %h %p",
ssh_config_word(requested)
));
let ssh_dir = expand_tilde("~/.ssh");
fs::create_dir_all(&ssh_dir).with_context(|| format!("create {}", ssh_dir.display()))?;
let include_path = ssh_dir.join("config.dosh");
@@ -1577,7 +1597,7 @@ fn ensure_vscode_ssh_host(
block.push_str(&format!("# BEGIN DOSH {alias}\n"));
block.push_str(&format!("Host {alias}\n"));
block.push_str(" HostName 127.0.0.1\n");
block.push_str(" Port 22\n");
block.push_str(&format!(" Port {target_ssh_port}\n"));
block.push_str(&format!(" HostKeyAlias {requested}\n"));
if let Some(user) = user {
block.push_str(&format!(" User {user}\n"));
@@ -1588,7 +1608,11 @@ fn ensure_vscode_ssh_host(
block.push_str(&format!(" ProxyCommand {proxy_command}\n"));
block.push_str(&format!("# END DOSH {alias}\n"));
upsert_managed_block(&include_path, &alias, &block)?;
Ok(alias)
Ok(GeneratedVscodeHost {
alias,
proxy_command,
target_ssh_port,
})
}
fn ensure_ssh_include(path: &Path, include: &str) -> Result<()> {
@@ -3145,6 +3169,14 @@ fn shell_word(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn ssh_config_word(value: &str) -> String {
if cfg!(windows) {
format!("\"{}\"", value.replace('"', "\\\""))
} else {
shell_word(value)
}
}
fn local_bootstrap(
session: &str,
mode: &str,
@@ -4239,6 +4271,7 @@ async fn run_terminal(
let mut status_tick = tokio::time::interval(Duration::from_secs(1));
let mut resize_tick = tokio::time::interval(Duration::from_millis(250));
let mut stream_retransmit_tick = tokio::time::interval(Duration::from_millis(200));
let mut frame_gap_tick = tokio::time::interval(Duration::from_millis(100));
let mut last_size = terminal_size();
// React to terminal resize the instant it happens via SIGWINCH (mosh-style),
// instead of waiting up to one `resize_tick`. The 250ms poll below stays as a
@@ -4466,6 +4499,36 @@ async fn run_terminal(
_ = resize_tick.tick() => {
maybe_send_resize(&socket, addr, &cred, &mut send_seq, &mut last_size).await?;
}
_ = frame_gap_tick.tick() => {
if frame_buffer.gap_wait_elapsed(Duration::from_millis(FRAME_GAP_RESYNC_AFTER_MS))
&& let Some(frame) = reconnect(
&socket,
&mut cred,
&mut send_seq,
last_size,
&mut frame_buffer,
&mut predictor,
)
.await?
{
refresh_live_addr(&mut addr, &cred)?;
if !forward_only {
render_frame(&frame)?;
predictor.observe_output(&frame.bytes);
}
last_packet_at = Instant::now();
flush_pending_user_input(
&socket,
addr,
&cred,
&mut send_seq,
&mut predictor,
&mut pending_user_input,
&mut pending_user_input_bytes,
)
.await?;
}
}
recv = socket.recv_from(&mut recv_buf) => {
let (n, _) = recv?;
let Ok(packet) = protocol::decode(&recv_buf[..n]) else {
@@ -6529,14 +6592,18 @@ fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
.any(|window| window == needle)
}
const FRAME_GAP_RESYNC_AFTER_MS: u64 = 500;
#[derive(Default)]
struct FrameBuffer {
pending: BTreeMap<u64, Frame>,
gap_since: Option<Instant>,
}
impl FrameBuffer {
fn clear(&mut self) {
self.pending.clear();
self.gap_since = None;
}
fn accept(&mut self, frame: Frame, last_rendered_seq: &mut u64) -> Vec<Frame> {
@@ -6545,6 +6612,7 @@ impl FrameBuffer {
return Vec::new();
}
self.pending.clear();
self.gap_since = None;
*last_rendered_seq = frame.output_seq;
return vec![frame];
}
@@ -6558,8 +6626,30 @@ impl FrameBuffer {
*last_rendered_seq = frame.output_seq;
ready.push(frame);
}
self.refresh_gap(*last_rendered_seq);
ready
}
fn refresh_gap(&mut self, last_rendered_seq: u64) {
let waiting_on_missing = self
.pending
.first_key_value()
.is_some_and(|(&seq, _)| seq > last_rendered_seq.saturating_add(1));
if waiting_on_missing {
self.gap_since.get_or_insert_with(Instant::now);
} else {
self.gap_since = None;
}
}
fn gap_wait_elapsed(&self, wait: Duration) -> bool {
self.gap_since.is_some_and(|since| since.elapsed() >= wait)
}
#[cfg(test)]
fn backdate_gap_for_test(&mut self, elapsed: Duration) {
self.gap_since = Some(Instant::now() - elapsed);
}
}
/// Read the current terminal size and, if it changed since `last_size`, send a
@@ -6971,10 +7061,10 @@ const TERMINAL_CLEANUP: &[u8] = concat!(
#[cfg(test)]
mod tests {
use super::{
CachedCredential, DisconnectStatus, DynamicForward, FrameBuffer, LocalForward,
MAX_PENDING_USER_INPUT_BYTES, POST_SUBMIT_ALL_INPUT_HOLD, PendingStreamOpen, PredictMode,
Predictor, RESTART_STATUS_SCRIPT, RemoteForward, STARTUP_INPUT_HOLD, SshConfig,
StartupGateMode, StatusAction, auth_allows, cache_key, cache_server_prefix,
CachedCredential, DisconnectStatus, DynamicForward, FRAME_GAP_RESYNC_AFTER_MS, FrameBuffer,
LocalForward, MAX_PENDING_USER_INPUT_BYTES, POST_SUBMIT_ALL_INPUT_HOLD, PendingStreamOpen,
PredictMode, Predictor, RESTART_STATUS_SCRIPT, RemoteForward, STARTUP_INPUT_HOLD,
SshConfig, StartupGateMode, StatusAction, auth_allows, cache_key, cache_server_prefix,
clear_cached_credentials, ensure_tui_safe_status_overlay, input_matches_escape,
is_local_status_target, is_resume_response_for_client, latest_release_download_url,
load_first_native_identity_with_prompt, parse_dynamic_forward, parse_escape_key,
@@ -7393,12 +7483,14 @@ mod tests {
assert!(buffer.accept(test_frame(12, false), &mut last).is_empty());
assert_eq!(last, 10);
assert!(!buffer.gap_wait_elapsed(Duration::from_secs(60)));
let ready = buffer.accept(test_frame(11, false), &mut last);
assert_eq!(ready.len(), 2);
assert_eq!(ready[0].output_seq, 11);
assert_eq!(ready[1].output_seq, 12);
assert_eq!(last, 12);
assert!(!buffer.gap_wait_elapsed(Duration::from_secs(0)));
}
#[test]
@@ -7417,14 +7509,30 @@ mod tests {
let mut last = 10;
assert!(buffer.accept(test_frame(12, false), &mut last).is_empty());
buffer.backdate_gap_for_test(Duration::from_secs(10));
assert!(buffer.gap_wait_elapsed(Duration::from_millis(1)));
let ready = buffer.accept(test_frame(20, true), &mut last);
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].output_seq, 20);
assert_eq!(last, 20);
assert!(!buffer.gap_wait_elapsed(Duration::from_millis(1)));
assert!(buffer.accept(test_frame(12, false), &mut last).is_empty());
}
#[test]
fn frame_buffer_marks_gap_ready_for_resync_after_wait() {
let mut buffer = FrameBuffer::default();
let mut last = 10;
assert!(buffer.accept(test_frame(13, false), &mut last).is_empty());
assert_eq!(last, 10);
assert!(!buffer.gap_wait_elapsed(Duration::from_secs(60)));
buffer.backdate_gap_for_test(Duration::from_secs(1));
assert!(buffer.gap_wait_elapsed(Duration::from_millis(FRAME_GAP_RESYNC_AFTER_MS)));
}
#[test]
fn frame_buffer_ignores_stale_snapshot() {
let mut buffer = FrameBuffer::default();
+13 -146
View File
@@ -232,19 +232,6 @@ async fn serve(config_path: Option<std::path::PathBuf>) -> Result<()> {
}
});
let pacing_state = Arc::clone(&state);
let pacing_socket = Arc::clone(&socket);
let pacing_interval_ms = config.output_frame_interval_ms.max(1);
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(pacing_interval_ms));
loop {
interval.tick().await;
if let Err(err) = flush_paced_snapshots(&pacing_state, &pacing_socket).await {
eprintln!("paced snapshot error: {err:#}");
}
}
});
let cleanup_state = Arc::clone(&state);
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(5));
@@ -400,8 +387,6 @@ struct ClientState {
rows: u16,
last_seen: Instant,
pending: VecDeque<PendingFrame>,
last_frame_sent: Instant,
paced_snapshot_due: bool,
allowed_forwardings: Vec<ForwardingRequest>,
stream_writers: HashMap<u64, mpsc::Sender<Vec<u8>>>,
opened_streams: HashSet<u64>,
@@ -1167,8 +1152,6 @@ async fn handle_native_user_auth(
rows,
last_seen: Instant::now(),
pending: VecDeque::new(),
last_frame_sent: Instant::now() - Duration::from_secs(3600),
paced_snapshot_due: false,
allowed_forwardings: req.auth.requested_forwardings.clone(),
stream_writers: HashMap::new(),
opened_streams: HashSet::new(),
@@ -1311,8 +1294,6 @@ async fn handle_bootstrap_attach(
rows: req.rows,
last_seen: Instant::now(),
pending: VecDeque::new(),
last_frame_sent: Instant::now() - Duration::from_secs(3600),
paced_snapshot_due: false,
allowed_forwardings: Vec::new(),
stream_writers: HashMap::new(),
opened_streams: HashSet::new(),
@@ -1447,8 +1428,6 @@ async fn handle_ticket_attach(
rows: req.rows,
last_seen: Instant::now(),
pending: VecDeque::new(),
last_frame_sent: Instant::now() - Duration::from_secs(3600),
paced_snapshot_due: false,
allowed_forwardings: Vec::new(),
stream_writers: HashMap::new(),
opened_streams: HashSet::new(),
@@ -3357,9 +3336,7 @@ async fn broadcast_output(
let sends = {
let mut locked = state.lock().expect("server state poisoned");
let scrollback = locked.config.scrollback;
let retransmit_window = locked.config.retransmit_window;
let frame_interval = Duration::from_millis(locked.config.output_frame_interval_ms);
let now = Instant::now();
let retransmit_window = locked.config.retransmit_window.max(1);
let session = locked
.sessions
.get_mut(&output.session)
@@ -3394,32 +3371,21 @@ async fn broadcast_output(
}
let mut sends = Vec::new();
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;
// Count traffic toward the packet-count rekey trigger (spec §11).
client.epoch_packets = client.epoch_packets.saturating_add(1);
// Only materialize a screen snapshot when this client has fallen too
// far behind; the common case sends the raw output bytes and must not
// clone the whole vt100 grid per client per packet.
let (bytes, snapshot) = if client.pending.len() >= retransmit_window {
client.pending.clear();
(screen_snapshot(session.parser.screen()), true)
} else {
(output.bytes.clone(), false)
};
// Live terminal bytes are never rewritten into vt100 snapshots. A
// snapshot is useful for attach/resume, but substituting it during a
// running TUI can lose graph/background-cell details that apps like
// btop rely on. If retransmit history is full, prune only history.
while client.pending.len() >= retransmit_window {
client.pending.pop_front();
}
let frame = Frame {
session: output.session.clone(),
output_seq,
bytes,
snapshot,
bytes: output.bytes.clone(),
snapshot: false,
closed: false,
};
let body = protocol::to_body(&frame)?;
@@ -3432,17 +3398,12 @@ async fn broadcast_output(
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: Instant::now(),
attempts: 0,
});
client.last_frame_sent = now;
client.paced_snapshot_due = false;
sends.push((client.endpoint, packet));
}
sends
@@ -3626,76 +3587,6 @@ async fn retransmit_pending(
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
/// pre-rekey packets still decrypt instead of being dropped as fatal.
const REKEY_PREVIOUS_KEY_GRACE_SECS: u64 = 5;
@@ -4077,8 +3968,6 @@ mod tests {
rows: 24,
last_seen: Instant::now(),
pending: VecDeque::new(),
last_frame_sent: Instant::now() - Duration::from_secs(3600),
paced_snapshot_due: false,
allowed_forwardings: Vec::new(),
stream_writers: HashMap::new(),
opened_streams: HashSet::new(),
@@ -4395,8 +4284,6 @@ mod tests {
rows: 24,
last_seen: Instant::now(),
pending: VecDeque::new(),
last_frame_sent: Instant::now() - Duration::from_secs(3600),
paced_snapshot_due: false,
allowed_forwardings: Vec::new(),
stream_writers: HashMap::new(),
opened_streams: HashSet::new(),
@@ -4491,8 +4378,6 @@ mod tests {
rows: 24,
last_seen: Instant::now(),
pending: VecDeque::new(),
last_frame_sent: Instant::now() - Duration::from_secs(3600),
paced_snapshot_due: false,
allowed_forwardings: Vec::new(),
stream_writers: HashMap::new(),
opened_streams: HashSet::from([42]),
@@ -4562,7 +4447,7 @@ mod tests {
}
#[tokio::test]
async fn output_pacing_coalesces_fast_frames_into_snapshot() {
async fn live_terminal_output_is_never_replaced_by_paced_snapshots() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let config = ServerConfig {
output_frame_interval_ms: 1000,
@@ -4614,25 +4499,6 @@ mod tests {
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()
@@ -4640,8 +4506,9 @@ mod tests {
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!(!frame.snapshot);
assert_eq!(frame.output_seq, 2);
assert_eq!(frame.bytes, b"second");
}
#[test]
+133
View File
@@ -98,6 +98,35 @@ persist_sessions = false
config
}
fn write_server_config_with_output_interval(
dir: &tempfile::TempDir,
port: u16,
output_frame_interval_ms: u64,
) -> std::path::PathBuf {
let config = write_server_config(dir, port);
let mut raw = fs::read_to_string(&config).unwrap();
raw.push_str(&format!(
"output_frame_interval_ms = {output_frame_interval_ms}\n"
));
fs::write(&config, raw).unwrap();
config
}
fn write_server_config_with_retransmit_window(
dir: &tempfile::TempDir,
port: u16,
retransmit_window: u64,
) -> std::path::PathBuf {
let config = write_server_config(dir, port);
let mut raw = fs::read_to_string(&config).unwrap();
raw = raw.replace(
"retransmit_window = 256\n",
&format!("retransmit_window = {retransmit_window}\n"),
);
fs::write(&config, raw).unwrap();
config
}
fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child {
let server = env!("CARGO_BIN_EXE_dosh-server");
let child = Command::new(server)
@@ -1639,6 +1668,110 @@ fn unicode_output_survives_transport() {
);
}
#[test]
fn tui_graph_glyphs_survive_fast_repaints_without_snapshot_substitution() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config_with_output_interval(&dir, port, 1000);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let graph = "DOSH_GRAPH ⣀⣤⣶⣿ █▇▆▅▄▃▂▁ ╭╮╯╰";
let input = Input {
bytes: format!(
"printf '\\033[?1049h\\033[?25l'; for i in 1 2 3 4 5; do printf '\\033[H{} %s\\n' \"$i\"; done; printf '\\033[?25h\\033[?1049l'\n",
graph
)
.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, 3000);
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains(graph) && text.matches("DOSH_GRAPH").count() >= 5,
"expected repeated raw graph glyph frames, got {text:?}"
);
assert!(
text.contains("\x1b[?25l") && text.contains("\x1b[?25h"),
"expected cursor visibility controls to survive around graph repaint, got {text:?}"
);
}
#[test]
fn btop_style_graph_output_stays_raw_when_retransmit_history_overflows() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config_with_retransmit_window(&dir, port, 3);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let graph = "DOSH_BTOP_NET ⠀⠁⠃⠇⡇⣇⣧⣷⣿";
let input = Input {
bytes: format!(
"stty -echo; \
printf '\\033[?1049h\\033[?2026h\\033[?25l'; \
for i in 1 2 3 4 5 6 7 8 9 10; do \
printf '\\033[6;4H\\033[38;2;80;220;140m{} %s\\033[0m' \"$i\"; \
done; \
printf '\\033[?25h\\033[?2026l\\033[?1049l'\n",
graph
)
.into_bytes(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let mut text = String::new();
let mut snapshots = 0usize;
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while std::time::Instant::now() < deadline
&& !(text.matches("DOSH_BTOP_NET").count() >= 10 && text.contains("\x1b[?2026l"))
{
if let Some((_header, frame)) = recv_frame(&socket, &bootstrap.session_key) {
if frame.snapshot {
snapshots += 1;
}
text.push_str(&String::from_utf8_lossy(&frame.bytes));
}
}
let _ = server.kill();
let _ = server.wait();
assert_eq!(
snapshots, 0,
"live btop-style graph output must not be replaced by snapshots; got {snapshots}"
);
assert!(
text.matches("DOSH_BTOP_NET").count() >= 10
&& text.contains("")
&& text.contains("\x1b[38;2;80;220;140m")
&& text.contains("\x1b[?2026h")
&& text.contains("\x1b[?2026l"),
"expected raw btop-style graph output to survive under retransmit pressure, got {text:?}"
);
}
#[test]
fn large_tui_paint_is_delivered_in_mtu_safe_frames() {
let dir = tempfile::tempdir().unwrap();
+32
View File
@@ -0,0 +1,32 @@
#[test]
fn quiet_update_keeps_prebuilt_enabled_by_default() {
let install = include_str!("../install.sh");
assert!(install.contains("use_prebuilt=\"${DOSH_USE_PREBUILT:-1}\""));
assert!(
!install.contains("use_prebuilt=0"),
"quiet updates must not force source builds"
);
}
#[test]
fn release_scripts_skip_stale_artifacts_when_auto_discovering() {
let upload = include_str!("../scripts/upload-gitea-release.sh");
assert!(upload.contains("skipping $artifact: version"));
assert!(upload.contains("expected_version"));
let publish = include_str!("../scripts/publish-gitea-release.sh");
assert!(publish.contains("skipping stale artifact"));
assert!(publish.contains("actual=\"$(artifact_version \"$artifact\" || true)\""));
}
#[test]
fn systemd_sandbox_allows_netlink_for_terminal_tools() {
let service = include_str!("../packaging/systemd/dosh-server.service");
let install = include_str!("../install.sh");
for raw in [service, install] {
assert!(
raw.contains("RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK"),
"dosh sessions must allow AF_NETLINK so tools like btop can enumerate network interfaces"
);
}
}