Compare commits

..
32 Commits
Author SHA1 Message Date
DuProcess c1c672b188 Bump release candidate to rc8
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-07 15:34:49 -04:00
DuProcess a23f610f20 Keep generated sessions alive across updates 2026-07-07 15:34:24 -04:00
DuProcess ff2b7fff2b Bump release candidate to rc7
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-07 14:58:13 -04:00
DuProcess f4879090f6 Keep terminal focus reports local 2026-07-07 14:57:53 -04:00
DuProcess 37ec9fc520 Bump release candidate to rc6
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-07 12:01:03 -04:00
DuProcess f3c7ec225d Repaint terminal after idle tab return 2026-07-07 12:00:40 -04:00
DuProcess d3c40a06ac Bump release candidate to rc5
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-07 11:49:41 -04:00
DuProcess 6b12b3dfe0 Harden terminal reconnect packet handling 2026-07-07 11:48:59 -04:00
DuProcess b90c34dc17 Bump release candidate to rc4
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-05 18:00:59 -04:00
DuProcess f205e0c128 Harden native auth and SDK datagram handling 2026-07-05 18:00:31 -04:00
DuProcess 92c94176bd Bump release candidate to rc3
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-05 09:39:21 -04:00
DuProcess 4b2e9a62d5 Harden embedded transport under UDP churn
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-05 09:37:44 -04:00
DuProcess 689d20f7e7 Bump release candidate to rc2
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-04 23:00:18 -04:00
DuProcess 252f081a61 Suppress stale terminal mouse reports after reconnect
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-04 22:56:32 -04:00
DuProcess b1e8326b0a Stop local benchmark holder leaks 2026-07-04 22:31:14 -04:00
DuProcess 3b4931aa8f Keep server alive on transient UDP send errors 2026-07-04 22:29:52 -04:00
DuProcess 5c54601cdf Prepare Dosh 1.0.0 rc1
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-04 16:58:15 -04:00
DuProcess c085137250 Add hostile network TUI soak gate
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-04 14:37:23 -04:00
DuProcess 237ad52bef Flush queued input after reconnect contact
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-03 16:35:22 -04:00
DuProcess a8ba852f16 Keep Dosh server service alive
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-03 14:51:47 -04:00
DuProcess f1a3de7730 Rate limit terminal gap resync
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-03 10:19:53 -04:00
DuProcess 80699dcf9d Stabilize terminal frame recovery
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-03 10:06:23 -04:00
DuProcess 274c0f505e Stop sandboxing remote shells
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 20:03:42 -04:00
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
24 changed files with 1930 additions and 452 deletions
Generated
+1 -1
View File
@@ -436,7 +436,7 @@ dependencies = [
[[package]] [[package]]
name = "dosh" name = "dosh"
version = "0.1.6" version = "1.0.0-rc8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "dosh" name = "dosh"
version = "0.1.6" version = "1.0.0-rc8"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
+20 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build test fmt clippy release-check install package-release package-release-linux package-release-windows publish-release bench-report bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local tui-harness .PHONY: build test fmt clippy release-check 1.0-check reconnect-check hostile-network-check persistence-check package-check install package-release package-release-linux package-release-windows publish-release bench-report bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local tui-harness
build: build:
cargo build --release cargo build --release
@@ -19,6 +19,25 @@ release-check:
cargo test cargo test
$(MAKE) tui-harness $(MAKE) tui-harness
1.0-check: release-check reconnect-check hostile-network-check persistence-check package-check
reconnect-check:
cargo test --test integration_smoke resume_updates_udp_endpoint_for_roaming -- --nocapture
DOSH_SOAK_SECONDS=$${DOSH_SOAK_SECONDS:-300} cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture
hostile-network-check:
cargo test --test hostile_network -- --nocapture
DOSH_BADNET_SOAK_SECONDS=$${DOSH_BADNET_SOAK_SECONDS:-300} cargo test --test hostile_network bad_network_tui_work_soak_30m -- --ignored --nocapture
persistence-check:
cargo test --test integration_smoke session_survives_server_restart_same_shell_and_screen -- --nocapture
cargo test --test integration_smoke multiple_persistent_named_sessions_survive_restart_independently -- --nocapture
package-check:
$(MAKE) package-release-linux
$(MAKE) package-release-windows
sh scripts/verify-release-artifacts.sh
install: install:
sh install.sh --from-current sh install.sh --from-current
+52 -116
View File
@@ -1,33 +1,31 @@
# Dosh # Dosh
Dosh is an encrypted remote terminal for fast reconnecting shells. It is meant Dosh is an encrypted remote terminal for fast reconnecting shells.
to replace Mosh and day-to-day interactive SSH sessions.
It runs a `dosh-server` on the remote machine and a `dosh` client locally. The It runs a `dosh-server` on a Unix-like host and a `dosh` client on macOS,
first setup can use SSH. After that, Dosh can attach over encrypted UDP with Linux, or Windows. Setup can use SSH, then Dosh connects over encrypted UDP,
cached credentials, keep terminal sessions alive, reconnect after network keeps sessions alive across disconnects, supports terminal apps, and can carry
changes, and forward TCP ports. TCP forwarding, file copy, and VS Code Remote-SSH streams.
It also includes native encrypted file copy over the Dosh stream layer.
## Support ## Support
- Client: macOS, Linux, Windows - 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 - Windows: client only
- UDP port: one configured server port, default `50000`
Dosh does not implement SFTP, X11 forwarding, or a Windows server. Windows is Dosh is meant to replace Mosh and everyday interactive SSH sessions. It does
client-only. not currently include SFTP, X11 forwarding, or a Windows server.
## Install ## Install
Server and client on Unix/macOS: Unix/macOS server and client:
```sh ```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 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 ```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 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 irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
``` ```
## Commands ## Use
```sh ```sh
dosh setup HOST # import SSH config and trust the Dosh host key dosh setup HOST
dosh HOST # connect dosh HOST
dosh HOST COMMAND # connect and run a command dosh HOST COMMAND
dosh exec HOST COMMAND # run a non-interactive remote command dosh update
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
``` ```
Examples: Useful commands:
```sh ```sh
dosh server dosh exec HOST COMMAND
dosh server tm dosh cp SRC DST
dosh exec server 'uname -a' dosh ls host:path
dosh cp file.txt server:tmp/file.txt dosh cat host:path
dosh cp -r server:Projects/app ./app dosh mkdir host:path
dosh cp -r server:Projects/app backup:app dosh rm [-r] host:path
dosh ls server:Projects dosh forward HOST -L 8080:127.0.0.1:80
dosh cat server:tmp/file.txt dosh forward HOST -D 1080
dosh forward server -L 8080:127.0.0.1:80 dosh forward HOST -R 2222:127.0.0.1:22
dosh forward server -D 1080 dosh status HOST
dosh forward server -R 2222:127.0.0.1:22 dosh doctor HOST
dosh proxy-stdio server 127.0.0.1 22 dosh recover HOST
dosh vscode server /home/me/project dosh restart HOST
``` ```
Agent forwarding is opt-in with `-A` and must be enabled on the server. Agent forwarding is opt-in with `-A` and must also be enabled on the server.
File copy is enabled by `allow_file_transfer = true` on the server. File copy must be enabled by the server config.
## VS Code ## 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 ```sh
dosh vscode setup server dosh vscode setup HOST
dosh vscode server /home/me/project dosh vscode HOST /remote/path
``` ```
This writes a managed SSH config entry using: 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
```sshconfig SSH byte stream.
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`.
## Config ## Config
@@ -169,8 +92,21 @@ Common client settings:
```toml ```toml
default_session = "new" default_session = "new"
auth_preference = "native,ssh"
predict = true predict = true
cache_attach_tickets = true cache_attach_tickets = true
disconnect_status = 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" $bindir = Join-Path $Prefix "bin"
$configDir = Join-Path $HOME ".config\dosh" $configDir = Join-Path $HOME ".config\dosh"
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
@@ -168,9 +182,9 @@ function Install-Prebuilt {
if (-not $found) { if (-not $found) {
throw "prebuilt archive missing $bin" 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 return $true
} }
catch { catch {
@@ -202,9 +216,9 @@ function Install-FromSource {
try { try {
Push-Location $src Push-Location $src
cargo build --release --bin dosh-client --bin dosh-bench cargo build --release --bin dosh-client --bin dosh-bench
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe")
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe")
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force Install-Binary "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe")
} }
finally { finally {
Pop-Location Pop-Location
+21 -21
View File
@@ -12,11 +12,7 @@ start_server=1
force_config=0 force_config=0
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}" update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
quiet="${DOSH_UPDATE_QUIET:-0}" quiet="${DOSH_UPDATE_QUIET:-0}"
if [ "${DOSH_UPDATE_QUIET:-0}" = "1" ] && [ "${DOSH_BINARY_REQUIRED:-0}" != "1" ]; then use_prebuilt="${DOSH_USE_PREBUILT:-1}"
use_prebuilt=0
else
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
fi
binary_url="${DOSH_BINARY_URL:-}" binary_url="${DOSH_BINARY_URL:-}"
binary_base="${DOSH_BINARY_BASE:-}" binary_base="${DOSH_BINARY_BASE:-}"
binary_name="${DOSH_BINARY_NAME:-}" binary_name="${DOSH_BINARY_NAME:-}"
@@ -230,13 +226,26 @@ find_extracted_binary() {
find "$1" -type f -name "$2" 2>/dev/null | sed -n '1p' 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() { install_extracted_binary() {
found="$(find_extracted_binary "$1" "$2")" found="$(find_extracted_binary "$1" "$2")"
if [ -z "$found" ]; then if [ -z "$found" ]; then
echo "prebuilt archive missing $2" >&2 echo "prebuilt archive missing $2" >&2
return 1 return 1
fi fi
install -m 0755 "$found" "$3" install_binary "$found" "$3"
} }
sha256_file() { sha256_file() {
@@ -333,7 +342,7 @@ try_install_prebuilt() {
install_extracted_binary "$tmpdir/extract" dosh-auth "$bindir/dosh-auth" || return 1 install_extracted_binary "$tmpdir/extract" dosh-auth "$bindir/dosh-auth" || return 1
fi fi
if found_bench="$(find_extracted_binary "$tmpdir/extract" dosh-bench)" && [ -n "$found_bench" ]; then 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 fi
return 0 return 0
} }
@@ -392,14 +401,14 @@ install_from_source() {
fi fi
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" ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install -m 0755 target/release/dosh-server "$bindir/dosh-server" install_binary target/release/dosh-server "$bindir/dosh-server"
install -m 0755 target/release/dosh-auth "$bindir/dosh-auth" install_binary target/release/dosh-auth "$bindir/dosh-auth"
fi fi
if [ -f target/release/dosh-bench ]; then 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 fi
} }
@@ -417,18 +426,9 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=$bindir/dosh-server serve ExecStart=$bindir/dosh-server serve
Restart=on-failure Restart=always
RestartSec=1 RestartSec=1
KillMode=process KillMode=process
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=$config_dir $data_dir
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
[Install] [Install]
WantedBy=default.target WantedBy=default.target
+1 -10
View File
@@ -6,18 +6,9 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=%h/.local/bin/dosh-server serve ExecStart=%h/.local/bin/dosh-server serve
Restart=on-failure Restart=always
RestartSec=1 RestartSec=1
KillMode=process KillMode=process
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=%h/.config/dosh %h/.local/share/dosh
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
[Install] [Install]
WantedBy=default.target WantedBy=default.target
+2
View File
@@ -67,6 +67,7 @@ cleanup() {
kill "$server_pid" 2>/dev/null || true kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true wait "$server_pid" 2>/dev/null || true
fi fi
pkill -f "$server_bin hold --runtime-dir $home/sessions/run" 2>/dev/null || true
rm -rf "$home" rm -rf "$home"
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
@@ -86,6 +87,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
persist_sessions = false
client_timeout_secs = 60 client_timeout_secs = 60
retransmit_window = 256 retransmit_window = 256
default_input_mode = "read-write" default_input_mode = "read-write"
+20
View File
@@ -99,6 +99,21 @@ fi
web_repo="${base%/}/${repo}" web_repo="${base%/}/${repo}"
failed=0 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 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 [ -f "$artifact" ] || continue
name="$(basename "$artifact")" name="$(basename "$artifact")"
@@ -107,6 +122,11 @@ for artifact in target/dosh-release/dosh-linux-*.tar.gz target/dosh-release/dosh
continue continue
;; ;;
esac 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" url="$web_repo/releases/download/$tag/$name"
if curl -fsSI "$url" >/dev/null; then if curl -fsSI "$url" >/dev/null; then
echo "verified $url" echo "verified $url"
+2
View File
@@ -8,6 +8,8 @@ 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 \ 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 \ large_tui_paint_is_delivered_in_mtu_safe_frames \
resume_snapshot_preserves_alternate_screen_mode resume_snapshot_preserves_alternate_screen_mode
do do
+20 -15
View File
@@ -32,6 +32,21 @@ token="${GITEA_TOKEN:-}"
title="${GITEA_TITLE:-$tag}" title="${GITEA_TITLE:-$tag}"
expected_version="${tag#v}" 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 if [ -z "$token" ] && [ -f "$HOME/.config/dosh/gitea-token" ]; then
token="$(tr -d '\r\n' <"$HOME/.config/dosh/gitea-token")" token="$(tr -d '\r\n' <"$HOME/.config/dosh/gitea-token")"
fi fi
@@ -52,6 +67,11 @@ if [ "$#" -eq 0 ]; then
continue continue
;; ;;
esac 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 if [ -f "$artifact.sha256" ]; then
set -- "$@" "$artifact" "$artifact.sha256" set -- "$@" "$artifact" "$artifact.sha256"
else else
@@ -103,21 +123,6 @@ delete_existing_asset() {
done 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() { verify_release_artifact_version() {
artifact="$1" artifact="$1"
name="$(basename "$artifact")" name="$(basename "$artifact")"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env sh
set -eu
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | sed -n '1p')"
out_dir="${DOSH_PACKAGE_DIR:-target/dosh-release}"
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
}
failed=0
for artifact in \
"$out_dir/dosh-linux-x86_64.tar.gz" \
"$out_dir/dosh-macos-aarch64.tar.gz" \
"$out_dir/dosh-windows-x86_64.zip"; do
if [ ! -f "$artifact" ]; then
echo "missing release artifact: $artifact" >&2
failed=1
continue
fi
if [ ! -f "$artifact.sha256" ]; then
echo "missing release checksum: $artifact.sha256" >&2
failed=1
fi
actual="$(artifact_version "$artifact" || true)"
if [ "$actual" != "$version" ]; then
echo "artifact version mismatch: $artifact has ${actual:-unknown}, expected $version" >&2
failed=1
fi
done
exit "$failed"
+606 -44
View File
File diff suppressed because it is too large Load Diff
+216 -180
View File
@@ -28,6 +28,7 @@ use dosh::protocol::{
TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope, TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope,
}; };
use dosh::pty::{PtyHandle, PtyOutput, adopt_pty_from_fd, spawn_pty_session}; use dosh::pty::{PtyHandle, PtyOutput, adopt_pty_from_fd, spawn_pty_session};
use dosh::udp::is_transient_udp_send_error;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::fs; use std::fs;
@@ -51,6 +52,7 @@ const STREAM_INITIAL_WINDOW: usize = 1024 * 1024;
/// have accumulated since the last mirror. Keeps the atomic write off the /// have accumulated since the last mirror. Keeps the atomic write off the
/// per-packet hot path while bounding how much fresh output a crash can lose. /// per-packet hot path while bounding how much fresh output a crash can lose.
const SCREEN_PERSIST_BYTES: usize = 4096; const SCREEN_PERSIST_BYTES: usize = 4096;
const IMPLICIT_EMPTY_SESSION_GRACE_SECS: u64 = 300;
/// Sentinel `target_host` sent to the client on a server-initiated `StreamOpen` /// Sentinel `target_host` sent to the client on a server-initiated `StreamOpen`
/// that carries an SSH-agent connection (the client splices it into its local /// that carries an SSH-agent connection (the client splices it into its local
@@ -232,19 +234,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); let cleanup_state = Arc::clone(&state);
tokio::spawn(async move { tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(5)); let mut interval = tokio::time::interval(Duration::from_secs(5));
@@ -400,8 +389,6 @@ 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>,
@@ -447,6 +434,7 @@ struct PendingStreamOpen {
attempts: u32, attempts: u32,
} }
#[derive(Clone)]
struct PendingNativeAuth { struct PendingNativeAuth {
client: dosh::native::NativeClientHello, client: dosh::native::NativeClientHello,
server: NativeServerHello, server: NativeServerHello,
@@ -525,12 +513,14 @@ impl ServerState {
Ok(()) Ok(())
} }
/// Whether a session named `name` should be backed by a persistent holder. /// Whether a session should be backed by a persistent holder.
/// ///
/// With persistence enabled, every PTY session gets a detached holder. That /// With persistence enabled, every interactive session gets a detached holder
/// includes implicit `term-<millis>-<pid>` sessions: a reconnecting client has /// so an update/restart of `dosh-server` behaves like a transient network
/// the exact session name in its ticket cache, so it can reattach after a /// break. Named sessions can sit empty for the normal client timeout.
/// quick server restart without forcing users to name sessions manually. /// Generated one-off sessions are also persisted, but cleanup gives them a
/// shorter empty grace period so invisible abandoned holders do not linger
/// indefinitely.
fn should_persist_session(&self, _name: &str) -> bool { fn should_persist_session(&self, _name: &str) -> bool {
self.config.persist_sessions self.config.persist_sessions
} }
@@ -669,6 +659,7 @@ impl ServerState {
fn insert_client(&mut self, session_name: &str, client_id: [u8; 16], client: ClientState) { fn insert_client(&mut self, session_name: &str, client_id: [u8; 16], client: ClientState) {
if let Some(session) = self.sessions.get_mut(session_name) { if let Some(session) = self.sessions.get_mut(session_name) {
session.clients.insert(client_id, client); session.clients.insert(client_id, client);
session.empty_since = None;
self.client_index self.client_index
.insert(client_id, session_name.to_string()); .insert(client_id, session_name.to_string());
} }
@@ -863,6 +854,7 @@ async fn handle_packet(
| PacketKind::StreamEof | PacketKind::StreamEof
| PacketKind::StreamWindowAdjust | PacketKind::StreamWindowAdjust
| PacketKind::RekeyAck | PacketKind::RekeyAck
| PacketKind::Detach
if find_client_decrypt_key(state, &packet.header).is_err() => if find_client_decrypt_key(state, &packet.header).is_err() =>
{ {
send_reject_to_client(socket, peer, packet.header.conn_id, "unknown client").await send_reject_to_client(socket, peer, packet.header.conn_id, "unknown client").await
@@ -980,7 +972,7 @@ async fn handle_native_client_hello(
}; };
let body = protocol::to_body(&NativeServerHelloBody { hello })?; let body = protocol::to_body(&NativeServerHelloBody { hello })?;
let out = protocol::encode_plain(PacketKind::NativeServerHello, pending_id, 1, 0, &body)?; let out = protocol::encode_plain(PacketKind::NativeServerHello, pending_id, 1, 0, &body)?;
socket.send_to(&out, peer).await?; let _ = send_udp(socket, &out, peer).await?;
Ok(()) Ok(())
} }
@@ -991,8 +983,8 @@ async fn handle_native_user_auth(
packet: &protocol::Packet, packet: &protocol::Packet,
) -> Result<()> { ) -> Result<()> {
let pending = { let pending = {
let mut locked = state.lock().expect("server state poisoned"); let locked = state.lock().expect("server state poisoned");
locked.pending_native.remove(&packet.header.conn_id) locked.pending_native.get(&packet.header.conn_id).cloned()
}; };
let Some(pending) = pending else { let Some(pending) = pending else {
return send_reject_to_client(socket, peer, packet.header.conn_id, "unknown native auth") return send_reject_to_client(socket, peer, packet.header.conn_id, "unknown native auth")
@@ -1008,11 +1000,38 @@ async fn handle_native_user_auth(
.await; .await;
} }
if pending.created_at.elapsed() > Duration::from_secs(30) { if pending.created_at.elapsed() > Duration::from_secs(30) {
state
.lock()
.expect("server state poisoned")
.pending_native
.remove(&packet.header.conn_id);
return send_reject_to_client(socket, peer, packet.header.conn_id, "native auth expired") return send_reject_to_client(socket, peer, packet.header.conn_id, "native auth expired")
.await; .await;
} }
let body = protocol::decrypt_body(packet, &pending.session_key, CLIENT_TO_SERVER)?; let body = match protocol::decrypt_body(packet, &pending.session_key, CLIENT_TO_SERVER) {
let req: NativeUserAuthBody = protocol::from_body(&body)?; Ok(body) => body,
Err(_) => {
return send_reject_to_client(
socket,
peer,
packet.header.conn_id,
"native auth decrypt failed",
)
.await;
}
};
let req: NativeUserAuthBody = match protocol::from_body(&body) {
Ok(req) => req,
Err(_) => {
return send_reject_to_client(
socket,
peer,
packet.header.conn_id,
"invalid native auth body",
)
.await;
}
};
if pending.client.requested_mode == "doctor" { if pending.client.requested_mode == "doctor" {
let check_result = { let check_result = {
let locked = state.lock().expect("server state poisoned"); let locked = state.lock().expect("server state poisoned");
@@ -1048,6 +1067,11 @@ async fn handle_native_user_auth(
.await; .await;
} }
}; };
state
.lock()
.expect("server state poisoned")
.pending_native
.remove(&packet.header.conn_id);
let body = protocol::to_body(&check)?; let body = protocol::to_body(&check)?;
let out = protocol::encode_encrypted( let out = protocol::encode_encrypted(
PacketKind::NativeAuthCheckOk, PacketKind::NativeAuthCheckOk,
@@ -1058,7 +1082,7 @@ async fn handle_native_user_auth(
SERVER_TO_CLIENT, SERVER_TO_CLIENT,
&body, &body,
)?; )?;
socket.send_to(&out, peer).await?; let _ = send_udp(socket, &out, peer).await?;
return Ok(()); return Ok(());
} }
@@ -1167,8 +1191,6 @@ 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(),
@@ -1230,6 +1252,11 @@ async fn handle_native_user_auth(
if let Some((listener, path)) = agent_listener { if let Some((listener, path)) = agent_listener {
spawn_agent_forward(state, socket, client_id, listener, path); spawn_agent_forward(state, socket, client_id, listener, path);
} }
state
.lock()
.expect("server state poisoned")
.pending_native
.remove(&packet.header.conn_id);
let ok = NativeAuthOk { let ok = NativeAuthOk {
client_id, client_id,
@@ -1253,7 +1280,7 @@ async fn handle_native_user_auth(
SERVER_TO_CLIENT, SERVER_TO_CLIENT,
&body, &body,
)?; )?;
socket.send_to(&out, peer).await?; let _ = send_udp(socket, &out, peer).await?;
Ok(()) Ok(())
} }
@@ -1311,8 +1338,6 @@ 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(),
@@ -1363,7 +1388,7 @@ async fn handle_bootstrap_attach(
SERVER_TO_CLIENT, SERVER_TO_CLIENT,
&body, &body,
)?; )?;
socket.send_to(&out, peer).await?; let _ = send_udp(socket, &out, peer).await?;
Ok(()) Ok(())
} }
@@ -1447,8 +1472,6 @@ 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(),
@@ -1500,7 +1523,7 @@ async fn handle_ticket_attach(
}; };
let body = protocol::to_body(&envelope)?; let body = protocol::to_body(&envelope)?;
let out = protocol::encode_plain(PacketKind::AttachOk, client_id, 1, 0, &body)?; let out = protocol::encode_plain(PacketKind::AttachOk, client_id, 1, 0, &body)?;
socket.send_to(&out, peer).await?; let _ = send_udp(socket, &out, peer).await?;
Ok(()) Ok(())
} }
@@ -1518,7 +1541,7 @@ async fn send_reject_to_client(
reason: reason.to_string(), reason: reason.to_string(),
})?; })?;
let out = protocol::encode_plain(PacketKind::AttachReject, client_id, 0, 0, &body)?; let out = protocol::encode_plain(PacketKind::AttachReject, client_id, 0, 0, &body)?;
socket.send_to(&out, peer).await?; let _ = send_udp(socket, &out, peer).await?;
Ok(()) Ok(())
} }
@@ -1584,7 +1607,7 @@ async fn handle_resume(
SERVER_TO_CLIENT, SERVER_TO_CLIENT,
&body, &body,
)?; )?;
socket.send_to(&out, peer).await?; let _ = send_udp(socket, &out, peer).await?;
Ok(()) Ok(())
} }
@@ -1696,12 +1719,19 @@ async fn handle_ping(
SERVER_TO_CLIENT, SERVER_TO_CLIENT,
b"", b"",
)?; )?;
socket.send_to(&out, peer).await?; let _ = send_udp(socket, &out, peer).await?;
Ok(()) Ok(())
} }
async fn handle_detach(state: &Arc<Mutex<ServerState>>, packet: &protocol::Packet) -> Result<()> { async fn handle_detach(state: &Arc<Mutex<ServerState>>, packet: &protocol::Packet) -> Result<()> {
let (key, _) = find_client_decrypt_key(state, &packet.header)?;
let _ = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?;
let mut locked = state.lock().expect("server state poisoned"); let mut locked = state.lock().expect("server state poisoned");
if let Some(client) = locked.client_mut(&packet.header.conn_id) {
if !client.replay.accept(packet.header.seq) {
return Ok(());
}
}
locked.remove_client_everywhere(&packet.header.conn_id); locked.remove_client_everywhere(&packet.header.conn_id);
Ok(()) Ok(())
} }
@@ -1711,6 +1741,14 @@ fn remove_client(state: &Arc<Mutex<ServerState>>, client_id: [u8; 16]) {
locked.remove_client_everywhere(&client_id); locked.remove_client_everywhere(&client_id);
} }
async fn send_udp(socket: &UdpSocket, packet: &[u8], peer: SocketAddr) -> Result<bool> {
match socket.send_to(packet, peer).await {
Ok(_) => Ok(true),
Err(err) if is_transient_udp_send_error(&err) => Ok(false),
Err(err) => Err(err).with_context(|| format!("send UDP packet to {peer}")),
}
}
async fn handle_ack( async fn handle_ack(
state: &Arc<Mutex<ServerState>>, state: &Arc<Mutex<ServerState>>,
peer: SocketAddr, peer: SocketAddr,
@@ -3026,7 +3064,7 @@ async fn send_stream_open_to_client(
found found
}; };
if let Some((endpoint, packet)) = send { if let Some((endpoint, packet)) = send {
socket.send_to(&packet, endpoint).await?; let _ = send_udp(socket, &packet, endpoint).await?;
} }
Ok(()) Ok(())
} }
@@ -3120,7 +3158,7 @@ async fn queue_or_send_stream_data_to_client(
send send
}; };
if let Some((endpoint, packet)) = send { if let Some((endpoint, packet)) = send {
socket.send_to(&packet, endpoint).await?; let _ = send_udp(socket, &packet, endpoint).await?;
} }
Ok(()) Ok(())
} }
@@ -3195,7 +3233,7 @@ async fn flush_stream_pending_data_to_client(
let Some((endpoint, packet)) = send else { let Some((endpoint, packet)) = send else {
return Ok(()); return Ok(());
}; };
socket.send_to(&packet, endpoint).await?; let _ = send_udp(socket, &packet, endpoint).await?;
} }
} }
@@ -3339,7 +3377,7 @@ async fn send_stream_packet_to_client(
found found
}; };
if let Some((endpoint, packet)) = send { if let Some((endpoint, packet)) = send {
socket.send_to(&packet, endpoint).await?; let _ = send_udp(socket, &packet, endpoint).await?;
} }
Ok(()) Ok(())
} }
@@ -3357,9 +3395,7 @@ async fn broadcast_output(
let sends = { let sends = {
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.max(1);
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)
@@ -3394,32 +3430,21 @@ 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);
// Only materialize a screen snapshot when this client has fallen too // Live terminal bytes are never rewritten into vt100 snapshots. A
// far behind; the common case sends the raw output bytes and must not // snapshot is useful for attach/resume, but substituting it during a
// clone the whole vt100 grid per client per packet. // running TUI can lose graph/background-cell details that apps like
let (bytes, snapshot) = if client.pending.len() >= retransmit_window { // btop rely on. If retransmit history is full, prune only history.
client.pending.clear(); while client.pending.len() >= retransmit_window {
(screen_snapshot(session.parser.screen()), true) client.pending.pop_front();
} else { }
(output.bytes.clone(), false)
};
let frame = Frame { let frame = Frame {
session: output.session.clone(), session: output.session.clone(),
output_seq, output_seq,
bytes, bytes: output.bytes.clone(),
snapshot, snapshot: false,
closed: false, closed: false,
}; };
let body = protocol::to_body(&frame)?; let body = protocol::to_body(&frame)?;
@@ -3432,17 +3457,12 @@ async fn broadcast_output(
SERVER_TO_CLIENT, SERVER_TO_CLIENT,
&body, &body,
)?; )?;
while client.pending.len() >= retransmit_window {
client.pending.pop_front();
}
client.pending.push_back(PendingFrame { client.pending.push_back(PendingFrame {
output_seq, output_seq,
packet: packet.clone(), packet: packet.clone(),
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
@@ -3459,7 +3479,7 @@ async fn broadcast_output(
} }
} }
for (endpoint, packet) in sends { for (endpoint, packet) in sends {
socket.send_to(&packet, endpoint).await?; let _ = send_udp(socket, &packet, endpoint).await?;
} }
Ok(()) Ok(())
} }
@@ -3512,7 +3532,7 @@ async fn broadcast_session_exit(
persist::remove_runtime_dir(&sessions_dir, &session_name); persist::remove_runtime_dir(&sessions_dir, &session_name);
} }
for (endpoint, packet) in sends { for (endpoint, packet) in sends {
socket.send_to(&packet, endpoint).await?; let _ = send_udp(socket, &packet, endpoint).await?;
} }
Ok(()) Ok(())
} }
@@ -3543,11 +3563,9 @@ async fn retransmit_pending(
if pending.output_seq <= client.last_acked { if pending.output_seq <= client.last_acked {
continue; continue;
} }
if now.duration_since(pending.last_sent) >= Duration::from_millis(200) if now.duration_since(pending.last_sent) >= Duration::from_millis(200) {
&& pending.attempts < 8
{
pending.last_sent = now; pending.last_sent = now;
pending.attempts += 1; pending.attempts = pending.attempts.saturating_add(1);
sends.push((client.endpoint, pending.packet.clone())); sends.push((client.endpoint, pending.packet.clone()));
} }
} }
@@ -3621,77 +3639,7 @@ async fn retransmit_pending(
sends sends
}; };
for (endpoint, packet) in sends { for (endpoint, packet) in sends {
socket.send_to(&packet, endpoint).await?; let _ = send_udp(socket, &packet, endpoint).await?;
}
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(()) Ok(())
} }
@@ -3787,7 +3735,7 @@ async fn maybe_rekey_clients(
sends sends
}; };
for (endpoint, packet) in sends { for (endpoint, packet) in sends {
socket.send_to(&packet, endpoint).await?; let _ = send_udp(socket, &packet, endpoint).await?;
} }
Ok(()) Ok(())
} }
@@ -3883,9 +3831,9 @@ fn cleanup_disconnected_clients(state: &Arc<Mutex<ServerState>>) {
.iter() .iter()
.filter(|(name, session)| { .filter(|(name, session)| {
!prewarm.contains(name.as_str()) !prewarm.contains(name.as_str())
&& session && session.empty_since.is_some_and(|since| {
.empty_since now.duration_since(since) >= empty_session_timeout(name, timeout)
.is_some_and(|since| now.duration_since(since) >= timeout) })
}) })
.map(|(name, _)| name.clone()) .map(|(name, _)| name.clone())
.collect(); .collect();
@@ -3904,6 +3852,14 @@ fn cleanup_disconnected_clients(state: &Arc<Mutex<ServerState>>) {
} }
} }
fn empty_session_timeout(name: &str, configured_timeout: Duration) -> Duration {
if protocol::is_implicit_session_name(name) {
configured_timeout.min(Duration::from_secs(IMPLICIT_EMPTY_SESSION_GRACE_SECS))
} else {
configured_timeout
}
}
/// Select the client key (current or retained previous epoch) matching the /// Select the client key (current or retained previous epoch) matching the
/// packet header's `session_key_id`, so a packet sealed under the pre-rekey key /// packet header's `session_key_id`, so a packet sealed under the pre-rekey key
/// during the grace window still decrypts while an expired/stale epoch is /// during the grace window still decrypts while an expired/stale epoch is
@@ -3950,7 +3906,7 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn persists_all_sessions_when_enabled() { fn persists_terminal_sessions_when_enabled() {
let (pty_tx, _rx) = mpsc::unbounded_channel(); let (pty_tx, _rx) = mpsc::unbounded_channel();
let config = ServerConfig { let config = ServerConfig {
persist_sessions: true, persist_sessions: true,
@@ -3960,7 +3916,7 @@ mod tests {
let state = ServerState::new(config, [0u8; 32], pty_tx); let state = ServerState::new(config, [0u8; 32], pty_tx);
assert!(state.should_persist_session("default")); // prewarmed assert!(state.should_persist_session("default")); // prewarmed
assert!(state.should_persist_session("work")); // explicitly named assert!(state.should_persist_session("work")); // explicitly named
assert!(state.should_persist_session("term-1781470634216-76685")); // implicit reconnect assert!(state.should_persist_session("term-1781470634216-76685")); // one-off update-survivable terminal
} }
#[test] #[test]
@@ -4077,8 +4033,6 @@ 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(),
@@ -4117,6 +4071,12 @@ mod tests {
Some((key, "work".to_string())) Some((key, "work".to_string()))
); );
assert!(state.client_mut(&client_id).is_some()); assert!(state.client_mut(&client_id).is_some());
state.sessions.get_mut("work").unwrap().empty_since = Some(Instant::now());
state.insert_client("work", [7u8; 16], test_client_state([9u8; 32]));
assert!(
state.sessions["work"].empty_since.is_none(),
"reattach must clear empty-since so cleanup cannot reap a live session"
);
// Removing purges both the session map and the index. // Removing purges both the session map and the index.
assert!(state.remove_client_everywhere(&client_id)); assert!(state.remove_client_everywhere(&client_id));
@@ -4156,6 +4116,83 @@ mod tests {
assert!(locked.lookup_client(&client_id).is_none()); assert!(locked.lookup_client(&client_id).is_none());
} }
#[test]
fn implicit_empty_session_timeout_is_bounded_for_update_reconnect() {
let configured = Duration::from_secs(2_592_000);
let implicit = protocol::generate_implicit_session_name();
assert_eq!(
empty_session_timeout(&implicit, configured),
Duration::from_secs(IMPLICIT_EMPTY_SESSION_GRACE_SECS)
);
assert_eq!(
empty_session_timeout("work", configured),
configured,
"named sessions keep the normal long timeout"
);
assert_eq!(
empty_session_timeout(&implicit, Duration::from_secs(1)),
Duration::from_secs(1),
"tests/admins can still configure a shorter timeout"
);
}
#[tokio::test]
async fn forged_plaintext_detach_does_not_remove_client() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
state
.ensure_session("work", 80, 24, "forward-only", &[])
.unwrap();
let client_id = [21u8; 16];
let session_key = [22u8; 32];
state.insert_client("work", client_id, test_client_state(session_key));
let state = Arc::new(Mutex::new(state));
let mut packet = protocol::decode(
&protocol::encode_plain(PacketKind::Detach, client_id, 1, 0, b"").unwrap(),
)
.unwrap();
packet.header.session_key_id = protocol::session_key_id(&session_key);
assert!(handle_detach(&state, &packet).await.is_err());
let locked = state.lock().unwrap();
assert!(
locked.lookup_client(&client_id).is_some(),
"plaintext detach with a copied key id must not remove a live client"
);
}
#[tokio::test]
async fn authenticated_detach_removes_client() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
state
.ensure_session("work", 80, 24, "forward-only", &[])
.unwrap();
let client_id = [23u8; 16];
let session_key = [24u8; 32];
state.insert_client("work", client_id, test_client_state(session_key));
let state = Arc::new(Mutex::new(state));
let packet = protocol::decode(
&protocol::encode_encrypted(
PacketKind::Detach,
client_id,
1,
0,
&session_key,
CLIENT_TO_SERVER,
b"",
)
.unwrap(),
)
.unwrap();
handle_detach(&state, &packet).await.unwrap();
let locked = state.lock().unwrap();
assert!(locked.lookup_client(&client_id).is_none());
}
#[tokio::test] #[tokio::test]
async fn duplicate_stream_open_for_open_stream_resends_ok() { async fn duplicate_stream_open_for_open_stream_resends_ok() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel(); let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
@@ -4395,8 +4432,6 @@ 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(),
@@ -4491,8 +4526,6 @@ 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]),
@@ -4562,7 +4595,7 @@ mod tests {
} }
#[tokio::test] #[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 (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let config = ServerConfig { let config = ServerConfig {
output_frame_interval_ms: 1000, output_frame_interval_ms: 1000,
@@ -4614,25 +4647,6 @@ mod tests {
let frame: Frame = protocol::from_body(&plain).unwrap(); let frame: Frame = protocol::from_body(&plain).unwrap();
assert!(!frame.snapshot); assert!(!frame.snapshot);
assert_eq!(frame.bytes, b"first"); 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)) let (n, _) = tokio::time::timeout(Duration::from_secs(1), receiver.recv_from(&mut buf))
.await .await
.unwrap() .unwrap()
@@ -4640,8 +4654,9 @@ mod tests {
let packet = protocol::decode(&buf[..n]).unwrap(); let packet = protocol::decode(&buf[..n]).unwrap();
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT).unwrap(); let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT).unwrap();
let frame: Frame = protocol::from_body(&plain).unwrap(); let frame: Frame = protocol::from_body(&plain).unwrap();
assert!(frame.snapshot); assert!(!frame.snapshot);
assert_eq!(frame.output_seq, 2); assert_eq!(frame.output_seq, 2);
assert_eq!(frame.bytes, b"second");
} }
#[test] #[test]
@@ -4656,4 +4671,25 @@ mod tests {
}; };
remote_bind_allowed(&config, "0.0.0.0").unwrap(); remote_bind_allowed(&config, "0.0.0.0").unwrap();
} }
#[cfg(unix)]
#[test]
fn server_udp_send_classifier_treats_network_roaming_errors_as_transient() {
for code in [
libc::EADDRNOTAVAIL,
libc::ECONNREFUSED,
libc::ECONNRESET,
libc::EHOSTDOWN,
libc::EHOSTUNREACH,
libc::ENETDOWN,
libc::ENETRESET,
libc::ENETUNREACH,
libc::ETIMEDOUT,
] {
assert!(
is_transient_udp_send_error(&std::io::Error::from_raw_os_error(code)),
"expected errno {code} to be transient"
);
}
}
} }
+1
View File
@@ -23,3 +23,4 @@ pub mod pty;
pub mod server; pub mod server;
pub mod ssh_agent; pub mod ssh_agent;
pub mod transport; pub mod transport;
pub mod udp;
+4
View File
@@ -435,6 +435,10 @@ pub fn run_holder(
let mut cmd = [0u8; 1]; let mut cmd = [0u8; 1];
match stream.read(&mut cmd) { match stream.read(&mut cmd) {
Ok(1) if cmd[0] == HOLDER_CMD_SHUTDOWN => { Ok(1) if cmd[0] == HOLDER_CMD_SHUTDOWN => {
if shell_pid > 0 {
let _ = unsafe { libc::kill(shell_pid, libc::SIGHUP) };
let _ = unsafe { libc::kill(shell_pid, libc::SIGTERM) };
}
let _ = std::fs::remove_dir_all(runtime_dir); let _ = std::fs::remove_dir_all(runtime_dir);
std::process::exit(0); std::process::exit(0);
} }
+1 -1
View File
@@ -280,7 +280,7 @@ pub fn decode(input: &[u8]) -> Result<Packet> {
pub fn decrypt_body(packet: &Packet, key: &[u8; 32], direction: u32) -> Result<Vec<u8>> { pub fn decrypt_body(packet: &Packet, key: &[u8; 32], direction: u32) -> Result<Vec<u8>> {
if packet.header.flags & 1 == 0 { if packet.header.flags & 1 == 0 {
return Ok(packet.body.clone()); bail!("packet body is not encrypted");
} }
let nonce = crypto::nonce_from(direction, packet.header.seq); let nonce = crypto::nonce_from(direction, packet.header.seq);
let aad = packet.header.aad(); let aad = packet.header.aad();
+120 -6
View File
@@ -13,6 +13,7 @@ use crate::transport::{
DoshTransport, SessionEvent, SessionRole, SessionTransportConfig, TransportConfig, DoshTransport, SessionEvent, SessionRole, SessionTransportConfig, TransportConfig,
service_name_from_target, service_name_from_target,
}; };
use crate::udp::is_transient_udp_send_error;
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
use ed25519_dalek::SigningKey; use ed25519_dalek::SigningKey;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@@ -97,6 +98,7 @@ pub enum DoshServerEvent {
Ignored, Ignored,
} }
#[derive(Debug, Clone)]
struct PendingServerAuth { struct PendingServerAuth {
client: native::NativeClientHello, client: native::NativeClientHello,
server: NativeServerHello, server: NativeServerHello,
@@ -255,7 +257,7 @@ impl DoshServer {
}; };
let body = protocol::to_body(&NativeServerHelloBody { hello })?; let body = protocol::to_body(&NativeServerHelloBody { hello })?;
let out = protocol::encode_plain(PacketKind::NativeServerHello, pending_id, 1, 0, &body)?; let out = protocol::encode_plain(PacketKind::NativeServerHello, pending_id, 1, 0, &body)?;
self.socket.send_to(&out, peer).await?; let _ = send_udp(&self.socket, &out, peer).await?;
Ok(()) Ok(())
} }
@@ -327,7 +329,7 @@ impl DoshServer {
peer: SocketAddr, peer: SocketAddr,
packet: &protocol::Packet, packet: &protocol::Packet,
) -> Result<DoshServerEvent> { ) -> Result<DoshServerEvent> {
let Some(pending) = self.pending.remove(&packet.header.conn_id) else { let Some(pending) = self.pending.get(&packet.header.conn_id).cloned() else {
self.send_reject(peer, packet.header.conn_id, "unknown native auth") self.send_reject(peer, packet.header.conn_id, "unknown native auth")
.await?; .await?;
return Ok(DoshServerEvent::Ignored); return Ok(DoshServerEvent::Ignored);
@@ -338,13 +340,28 @@ impl DoshServer {
return Ok(DoshServerEvent::Ignored); return Ok(DoshServerEvent::Ignored);
} }
if pending.created_at.elapsed() > self.config.auth_timeout { if pending.created_at.elapsed() > self.config.auth_timeout {
self.pending.remove(&packet.header.conn_id);
self.send_reject(peer, packet.header.conn_id, "native auth expired") self.send_reject(peer, packet.header.conn_id, "native auth expired")
.await?; .await?;
return Ok(DoshServerEvent::Ignored); return Ok(DoshServerEvent::Ignored);
} }
let body = protocol::decrypt_body(packet, &pending.session_key, CLIENT_TO_SERVER)?; let body = match protocol::decrypt_body(packet, &pending.session_key, CLIENT_TO_SERVER) {
let req: NativeUserAuthBody = protocol::from_body(&body)?; Ok(body) => body,
Err(_) => {
self.send_reject(peer, packet.header.conn_id, "native auth decrypt failed")
.await?;
return Ok(DoshServerEvent::Ignored);
}
};
let req: NativeUserAuthBody = match protocol::from_body(&body) {
Ok(req) => req,
Err(_) => {
self.send_reject(peer, packet.header.conn_id, "invalid native auth body")
.await?;
return Ok(DoshServerEvent::Ignored);
}
};
let services = match self.verify_auth_and_services(&pending, &req.auth) { let services = match self.verify_auth_and_services(&pending, &req.auth) {
Ok(services) => services, Ok(services) => services,
Err(err) => { Err(err) => {
@@ -353,6 +370,7 @@ impl DoshServer {
return Ok(DoshServerEvent::Ignored); return Ok(DoshServerEvent::Ignored);
} }
}; };
self.pending.remove(&packet.header.conn_id);
let conn_id = crypto::random_16(); let conn_id = crypto::random_16();
let key_id = protocol::session_key_id(&pending.session_key); let key_id = protocol::session_key_id(&pending.session_key);
@@ -381,7 +399,7 @@ impl DoshServer {
SERVER_TO_CLIENT, SERVER_TO_CLIENT,
&body, &body,
)?; )?;
self.socket.send_to(&out, peer).await?; let _ = send_udp(&self.socket, &out, peer).await?;
let transport = DoshTransport::new( let transport = DoshTransport::new(
Arc::clone(&self.socket), Arc::clone(&self.socket),
@@ -428,7 +446,7 @@ impl DoshServer {
reason: reason.to_string(), reason: reason.to_string(),
})?; })?;
let out = protocol::encode_plain(PacketKind::AttachReject, conn_id, 0, 0, &body)?; let out = protocol::encode_plain(PacketKind::AttachReject, conn_id, 0, 0, &body)?;
self.socket.send_to(&out, peer).await?; let _ = send_udp(&self.socket, &out, peer).await?;
Ok(()) Ok(())
} }
@@ -439,6 +457,14 @@ impl DoshServer {
} }
} }
async fn send_udp(socket: &UdpSocket, packet: &[u8], peer: SocketAddr) -> Result<bool> {
match socket.send_to(packet, peer).await {
Ok(_) => Ok(true),
Err(err) if is_transient_udp_send_error(&err) => Ok(false),
Err(err) => Err(err).with_context(|| format!("send UDP packet to {peer}")),
}
}
fn validate_requested_services( fn validate_requested_services(
allowed_services: &HashSet<String>, allowed_services: &HashSet<String>,
requests: &[ForwardingRequest], requests: &[ForwardingRequest],
@@ -477,6 +503,7 @@ mod tests {
use super::*; use super::*;
use crate::client::DoshClient; use crate::client::DoshClient;
use crate::config::{ClientConfig, HostsConfig}; use crate::config::{ClientConfig, HostsConfig};
use crate::protocol::{CLIENT_TO_SERVER, NativeUserAuthBody};
use crate::transport::TransportEvent; use crate::transport::TransportEvent;
use ed25519_dalek::SigningKey; use ed25519_dalek::SigningKey;
@@ -588,4 +615,91 @@ mod tests {
} }
} }
} }
#[tokio::test]
async fn bad_native_auth_does_not_consume_pending_challenge() {
let dir = tempfile::tempdir().unwrap();
let host_key = dir.path().join("host_key");
let authorized_keys = dir.path().join("authorized_keys");
let signing = SigningKey::from_bytes(&[93u8; 32]);
let keypair = ssh_key::private::Ed25519Keypair::from(&signing);
let private =
ssh_key::PrivateKey::new(ssh_key::private::KeypairData::from(keypair), "").unwrap();
std::fs::write(
&authorized_keys,
format!("{}\n", private.public_key().to_openssh().unwrap()),
)
.unwrap();
let server_config = ServerConfig {
host_key: host_key.to_string_lossy().to_string(),
authorized_keys: vec![authorized_keys.to_string_lossy().to_string()],
..ServerConfig::default()
};
let server_config = DoshServerConfig::new(server_config)
.bind_addr("127.0.0.1:0".parse().unwrap())
.require_current_user(false);
let mut server = DoshServer::bind(server_config).await.unwrap();
let peer: SocketAddr = "127.0.0.1:9".parse().unwrap();
let (client_secret, client_public) = native::generate_native_ephemeral();
let hello = native::NativeClientHello {
protocol_version: native::NATIVE_PROTOCOL_VERSION,
client_random: crypto::random_32(),
client_ephemeral_public: client_public,
requested_host: "127.0.0.1".to_string(),
requested_user: "sdk-user".to_string(),
requested_session: "test".to_string(),
requested_mode: "forward-only".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: vec!["ssh-ed25519".to_string()],
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: Vec::new(),
};
let (pending_id, server_hello) = server.build_server_hello(hello.clone(), peer).unwrap();
let session_key = native::derive_native_session_key(
&client_secret,
server_hello.server_ephemeral_public,
&hello,
&server_hello,
)
.unwrap();
let auth = native::sign_user_auth(&signing, &hello, &server_hello, Vec::new()).unwrap();
let body = protocol::to_body(&NativeUserAuthBody { auth }).unwrap();
let bad = protocol::encode_encrypted(
PacketKind::NativeUserAuth,
pending_id,
2,
1,
&[7u8; 32],
CLIENT_TO_SERVER,
&body,
)
.unwrap();
let bad = protocol::decode(&bad).unwrap();
assert!(matches!(
server.handle_user_auth(peer, &bad).await.unwrap(),
DoshServerEvent::Ignored
));
assert!(server.pending.contains_key(&pending_id));
let valid = protocol::encode_encrypted(
PacketKind::NativeUserAuth,
pending_id,
2,
1,
&session_key,
CLIENT_TO_SERVER,
&body,
)
.unwrap();
let valid = protocol::decode(&valid).unwrap();
assert!(matches!(
server.handle_user_auth(peer, &valid).await.unwrap(),
DoshServerEvent::Accepted(_)
));
assert!(!server.pending.contains_key(&pending_id));
}
} }
+114 -10
View File
@@ -19,6 +19,7 @@ use crate::protocol::{
self, CLIENT_TO_SERVER, PacketKind, ReplayWindow, SERVER_TO_CLIENT, StreamClose, StreamData, self, CLIENT_TO_SERVER, PacketKind, ReplayWindow, SERVER_TO_CLIENT, StreamClose, StreamData,
StreamOpen, StreamOpenOk, StreamOpenReject, StreamWindowAdjust, StreamOpen, StreamOpenOk, StreamOpenReject, StreamWindowAdjust,
}; };
use crate::udp::is_transient_udp_send_error;
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow, bail};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::net::SocketAddr; use std::net::SocketAddr;
@@ -348,8 +349,8 @@ impl StreamMux {
&mut self, &mut self,
adjust: StreamWindowAdjust, adjust: StreamWindowAdjust,
) -> Result<Vec<OutgoingStreamPacket>> { ) -> Result<Vec<OutgoingStreamPacket>> {
self.ack_data(adjust.stream_id, adjust.received_offset); let acked_bytes = self.ack_data(adjust.stream_id, adjust.received_offset);
self.add_credit(adjust.stream_id, adjust.bytes as usize); self.add_credit(adjust.stream_id, acked_bytes);
self.flush_pending_data(adjust.stream_id) self.flush_pending_data(adjust.stream_id)
} }
@@ -556,9 +557,9 @@ impl StreamMux {
(chunks, consumed, *expected) (chunks, consumed, *expected)
} }
fn ack_data(&mut self, stream_id: u64, received_offset: u64) { fn ack_data(&mut self, stream_id: u64, received_offset: u64) -> usize {
let Some(sent) = self.sent_data.get_mut(&stream_id) else { let Some(sent) = self.sent_data.get_mut(&stream_id) else {
return; return 0;
}; };
let acked_offsets = sent let acked_offsets = sent
.iter() .iter()
@@ -567,12 +568,16 @@ impl StreamMux {
(end <= received_offset).then_some(*offset) (end <= received_offset).then_some(*offset)
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut acked_bytes = 0usize;
for offset in acked_offsets { for offset in acked_offsets {
sent.remove(&offset); if let Some(chunk) = sent.remove(&offset) {
acked_bytes = acked_bytes.saturating_add(chunk.bytes.len());
}
} }
if sent.is_empty() { if sent.is_empty() {
self.sent_data.remove(&stream_id); self.sent_data.remove(&stream_id);
} }
acked_bytes
} }
fn add_credit(&mut self, stream_id: u64, bytes: usize) { fn add_credit(&mut self, stream_id: u64, bytes: usize) {
@@ -732,11 +737,17 @@ impl DoshTransport {
if packet.header.conn_id != self.conn_id { if packet.header.conn_id != self.conn_id {
continue; continue;
} }
let plain = match protocol::decrypt_body(
&packet,
&self.session_key,
self.role.recv_direction(),
) {
Ok(plain) => plain,
Err(_) => continue,
};
if !self.replay.accept(packet.header.seq) { if !self.replay.accept(packet.header.seq) {
continue; continue;
} }
let plain =
protocol::decrypt_body(&packet, &self.session_key, self.role.recv_direction())?;
self.peer_addr = peer; self.peer_addr = peer;
self.ack_seq = self.ack_seq.max(packet.header.seq); self.ack_seq = self.ack_seq.max(packet.header.seq);
self.last_contact = Instant::now(); self.last_contact = Instant::now();
@@ -751,14 +762,21 @@ impl DoshTransport {
datagram: &[u8], datagram: &[u8],
peer: SocketAddr, peer: SocketAddr,
) -> Result<SessionEvent> { ) -> Result<SessionEvent> {
let packet = protocol::decode(datagram)?; let packet = match protocol::decode(datagram) {
Ok(packet) => packet,
Err(_) => return Ok(SessionEvent::Ignored),
};
if packet.header.conn_id != self.conn_id { if packet.header.conn_id != self.conn_id {
return Ok(SessionEvent::Ignored); return Ok(SessionEvent::Ignored);
} }
let plain =
match protocol::decrypt_body(&packet, &self.session_key, self.role.recv_direction()) {
Ok(plain) => plain,
Err(_) => return Ok(SessionEvent::Ignored),
};
if !self.replay.accept(packet.header.seq) { if !self.replay.accept(packet.header.seq) {
return Ok(SessionEvent::Ignored); return Ok(SessionEvent::Ignored);
} }
let plain = protocol::decrypt_body(&packet, &self.session_key, self.role.recv_direction())?;
self.peer_addr = peer; self.peer_addr = peer;
self.ack_seq = self.ack_seq.max(packet.header.seq); self.ack_seq = self.ack_seq.max(packet.header.seq);
self.last_contact = Instant::now(); self.last_contact = Instant::now();
@@ -815,7 +833,12 @@ impl DoshTransport {
async fn send_kind(&mut self, kind: PacketKind, body: &[u8]) -> Result<()> { async fn send_kind(&mut self, kind: PacketKind, body: &[u8]) -> Result<()> {
let encoded = self.encode_kind(kind, body)?; let encoded = self.encode_kind(kind, body)?;
self.socket.send_to(&encoded, self.peer_addr).await?; if let Err(err) = self.socket.send_to(&encoded, self.peer_addr).await {
if is_transient_udp_send_error(&err) {
return Ok(());
}
return Err(err.into());
}
Ok(()) Ok(())
} }
@@ -980,6 +1003,30 @@ mod tests {
assert_eq!(data.bytes, b"!"); assert_eq!(data.bytes, b"!");
} }
#[test]
fn cumulative_window_adjust_restores_credit_even_if_delta_was_lost() {
let mut mux = StreamMux::new(TransportConfig {
initial_window: 5,
retransmit_after: Duration::from_secs(1),
..TransportConfig::default()
});
mux.open_stream(1, "@dosh-test", 0).unwrap();
mux.handle_open_ok(StreamOpenOk { stream_id: 1 }).unwrap();
assert_eq!(mux.send_data(1, b"hello".to_vec()).unwrap().len(), 1);
assert_eq!(mux.send_credit[&1], 0);
let flushed = mux
.handle_window_adjust(StreamWindowAdjust {
stream_id: 1,
received_offset: 5,
bytes: 0,
})
.unwrap();
assert!(flushed.is_empty());
assert_eq!(mux.send_credit[&1], 5);
assert_eq!(mux.send_data(1, b"!".to_vec()).unwrap().len(), 1);
}
#[test] #[test]
fn handle_packet_decodes_and_dispatches_stream_packets() { fn handle_packet_decodes_and_dispatches_stream_packets() {
let mut mux = StreamMux::new(TransportConfig::default()); let mut mux = StreamMux::new(TransportConfig::default());
@@ -1096,4 +1143,61 @@ mod tests {
assert!(matches!(server.recv().await.unwrap(), SessionEvent::Ping)); assert!(matches!(server.recv().await.unwrap(), SessionEvent::Ping));
assert_eq!(server.peer_addr(), roaming_addr); assert_eq!(server.peer_addr(), roaming_addr);
} }
#[tokio::test]
async fn unauthenticated_datagram_does_not_poison_replay_window() {
let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let peer: SocketAddr = "127.0.0.1:9".parse().unwrap();
let key = [11u8; 32];
let wrong_key = [12u8; 32];
let conn_id = [4u8; 16];
let mut transport = DoshTransport::new_owned(
socket,
SessionTransportConfig {
role: SessionRole::Client,
conn_id,
session_key: key,
peer_addr: peer,
initial_send_seq: 1,
initial_ack: 0,
stream: TransportConfig::default(),
},
);
let bad = protocol::encode_encrypted(
PacketKind::Pong,
conn_id,
9,
0,
&wrong_key,
SERVER_TO_CLIENT,
b"",
)
.unwrap();
let valid = protocol::encode_encrypted(
PacketKind::Pong,
conn_id,
9,
0,
&key,
SERVER_TO_CLIENT,
b"",
)
.unwrap();
assert!(matches!(
transport
.handle_datagram(b"not a dosh packet", peer)
.await
.unwrap(),
SessionEvent::Ignored
));
assert!(matches!(
transport.handle_datagram(&bad, peer).await.unwrap(),
SessionEvent::Ignored
));
assert!(matches!(
transport.handle_datagram(&valid, peer).await.unwrap(),
SessionEvent::Pong
));
}
} }
+98
View File
@@ -0,0 +1,98 @@
//! UDP error classification shared by Dosh clients, servers, and embedders.
pub fn is_transient_udp_send_error(err: &std::io::Error) -> bool {
matches!(
err.kind(),
std::io::ErrorKind::Interrupted
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::WouldBlock
) || err.raw_os_error().is_some_and(is_transient_udp_os_error)
}
#[cfg(unix)]
fn is_transient_udp_os_error(code: i32) -> bool {
matches!(
code,
libc::EADDRNOTAVAIL
| libc::ECONNREFUSED
| libc::ECONNRESET
| libc::EHOSTDOWN
| libc::EHOSTUNREACH
| libc::ENETDOWN
| libc::ENETRESET
| libc::ENETUNREACH
| libc::ETIMEDOUT
)
}
#[cfg(windows)]
fn is_transient_udp_os_error(code: i32) -> bool {
matches!(
code,
10049 // WSAEADDRNOTAVAIL
| 10050 // WSAENETDOWN
| 10051 // WSAENETUNREACH
| 10052 // WSAENETRESET
| 10054 // WSAECONNRESET
| 10060 // WSAETIMEDOUT
| 10061 // WSAECONNREFUSED
| 10064 // WSAEHOSTDOWN
| 10065 // WSAEHOSTUNREACH
)
}
#[cfg(not(any(unix, windows)))]
fn is_transient_udp_os_error(_code: i32) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::is_transient_udp_send_error;
#[test]
fn classifies_portable_transient_send_errors() {
for kind in [
std::io::ErrorKind::Interrupted,
std::io::ErrorKind::TimedOut,
std::io::ErrorKind::WouldBlock,
] {
assert!(is_transient_udp_send_error(&std::io::Error::from(kind)));
}
assert!(!is_transient_udp_send_error(&std::io::Error::from(
std::io::ErrorKind::PermissionDenied,
)));
}
#[cfg(unix)]
#[test]
fn classifies_unix_network_churn_as_transient() {
for code in [
libc::EADDRNOTAVAIL,
libc::ECONNREFUSED,
libc::ECONNRESET,
libc::EHOSTDOWN,
libc::EHOSTUNREACH,
libc::ENETDOWN,
libc::ENETRESET,
libc::ENETUNREACH,
libc::ETIMEDOUT,
] {
assert!(is_transient_udp_send_error(
&std::io::Error::from_raw_os_error(code)
));
}
}
#[cfg(windows)]
#[test]
fn classifies_windows_network_churn_as_transient() {
for code in [
10049, 10050, 10051, 10052, 10054, 10060, 10061, 10064, 10065,
] {
assert!(is_transient_udp_send_error(
&std::io::Error::from_raw_os_error(code)
));
}
}
}
+141 -9
View File
@@ -258,7 +258,8 @@ fn relay_loop(
let mut upstream = new_upstream(); let mut upstream = new_upstream();
let mut client_addr: Option<SocketAddr> = None; let mut client_addr: Option<SocketAddr> = None;
let mut rng = StdRng::seed_from_u64(seed); let mut rng = StdRng::seed_from_u64(seed);
let mut held: Option<(Vec<u8>, bool)> = None; // (packet, is_c2s) held for reorder let mut held_c2s: Option<Vec<u8>> = None;
let mut held_s2c: Option<Vec<u8>> = None;
let mut buf = [0u8; 65535]; let mut buf = [0u8; 65535];
loop { loop {
@@ -288,10 +289,9 @@ fn relay_loop(
&upstream, &upstream,
server_addr, server_addr,
packet, packet,
true,
&controls, &controls,
&mut rng, &mut rng,
&mut held, &mut held_c2s,
); );
} }
} }
@@ -310,7 +310,12 @@ fn relay_loop(
let drop_pct = controls.drop_s2c_percent.load(Ordering::SeqCst); let drop_pct = controls.drop_s2c_percent.load(Ordering::SeqCst);
if drop_pct == 0 || rng.gen_range(0..100) >= drop_pct { if drop_pct == 0 || rng.gen_range(0..100) >= drop_pct {
forward_with_effects( forward_with_effects(
&front, dst, packet, false, &controls, &mut rng, &mut held, &front,
dst,
packet,
&controls,
&mut rng,
&mut held_s2c,
); );
} }
} }
@@ -336,23 +341,22 @@ fn forward_with_effects(
out: &UdpSocket, out: &UdpSocket,
dst: SocketAddr, dst: SocketAddr,
packet: Vec<u8>, packet: Vec<u8>,
is_c2s: bool,
controls: &RelayControls, controls: &RelayControls,
rng: &mut StdRng, rng: &mut StdRng,
held: &mut Option<(Vec<u8>, bool)>, held: &mut Option<Vec<u8>>,
) { ) {
// Reorder: if armed, hold this packet and release the previously held one // Reorder: if armed, hold this packet and release the previously held one
// afterward (so two consecutive packets swap order). // afterward (so two consecutive packets swap order).
if controls.reorder_next.swap(false, Ordering::SeqCst) { if controls.reorder_next.swap(false, Ordering::SeqCst) {
if let Some((prev, _)) = held.take() { if let Some(prev) = held.take() {
let _ = out.send_to(&packet, dst); let _ = out.send_to(&packet, dst);
let _ = out.send_to(&prev, dst); let _ = out.send_to(&prev, dst);
return; return;
} }
*held = Some((packet, is_c2s)); *held = Some(packet);
return; return;
} }
if let Some((prev, _)) = held.take() { if let Some(prev) = held.take() {
let _ = out.send_to(&prev, dst); let _ = out.send_to(&prev, dst);
} }
@@ -767,6 +771,46 @@ fn wait_for_text(socket: &UdpSocket, key: &[u8; 32], needle: &str, millis: u64)
acc.contains(needle) acc.contains(needle)
} }
fn wait_for_text_with_ack(
socket: &UdpSocket,
relay: &Relay,
client_id: [u8; 16],
seq: &mut u64,
key: &[u8; 32],
needle: &str,
millis: u64,
) -> bool {
let prev = socket.read_timeout().unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let deadline = Instant::now() + Duration::from_millis(millis);
let mut acc = String::new();
while Instant::now() < deadline {
if let Some((_header, frame)) = recv_frame(socket, key) {
acc.push_str(&String::from_utf8_lossy(&frame.bytes));
let ack = protocol::encode_encrypted(
PacketKind::Ack,
client_id,
*seq,
frame.output_seq,
key,
CLIENT_TO_SERVER,
b"",
)
.unwrap();
*seq += 1;
socket.send_to(&ack, relay.front_addr()).unwrap();
if acc.contains(needle) {
socket.set_read_timeout(prev).unwrap();
return true;
}
}
}
socket.set_read_timeout(prev).unwrap();
acc.contains(needle)
}
#[test] #[test]
fn session_survives_packet_loss_and_reorder() { fn session_survives_packet_loss_and_reorder() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -820,6 +864,94 @@ fn session_survives_packet_loss_and_reorder() {
); );
} }
#[test]
#[ignore = "30-minute hostile-network TUI soak; run with `DOSH_BADNET_SOAK_SECONDS=1800 cargo test --test hostile_network bad_network_tui_work_soak_30m -- --ignored --nocapture`"]
fn bad_network_tui_work_soak_30m() {
let soak_secs = std::env::var("DOSH_BADNET_SOAK_SECONDS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(30 * 60);
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 relay = Relay::spawn(port, 0xBADC0DEu64);
let (socket, bootstrap, ok) = attach_through_relay(&config, &relay);
relay.set_drop_c2s(15);
relay.set_drop_s2c(20);
relay.set_dup(10);
let deadline = Instant::now() + Duration::from_secs(soak_secs);
let mut seq = 2u64;
let mut iteration = 0u64;
while Instant::now() < deadline {
if iteration.is_multiple_of(2) {
relay.arm_reorder();
}
if iteration > 0 && iteration.is_multiple_of(7) {
let _ = relay.rebind_upstream();
}
if iteration > 0 && iteration.is_multiple_of(11) {
relay.set_drop_s2c(100);
thread::sleep(Duration::from_millis(750));
relay.set_drop_s2c(20);
}
let marker = format!("DOSH_BADNET_TUI_{iteration}");
let command = format!(
"stty -echo; \
printf '\\033[?1049h\\033[?2026h\\033[?25l'; \
for i in 1 2 3 4 5 6; do \
printf '\\033[%s;4H{} ⠀⠁⠃⠇⡇⣇⣧⣷⣿ █▇▆▅▄▃▂▁ %s\\033[0m' \"$i\" \"$i\"; \
done; \
printf '\\033[?25h\\033[?2026l\\033[?1049l'\n",
marker
);
let step_deadline = Instant::now() + Duration::from_secs(8);
let mut seen = false;
let mut attempts = 0;
while Instant::now() < step_deadline && attempts < 12 {
send_input(
&socket,
&relay,
ok.client_id,
seq,
0,
&bootstrap.session_key,
command.as_bytes(),
);
seq += 1;
attempts += 1;
if wait_for_text_with_ack(
&socket,
&relay,
ok.client_id,
&mut seq,
&bootstrap.session_key,
&marker,
500,
) {
seen = true;
break;
}
}
assert!(
seen,
"TUI marker {marker} did not arrive during hostile-network soak"
);
iteration += 1;
thread::sleep(Duration::from_millis(500));
}
relay.clear_impairments();
drop(relay);
let _ = server.kill();
let _ = server.wait();
}
#[test] #[test]
fn duplicated_and_replayed_input_is_applied_at_most_once() { fn duplicated_and_replayed_input_is_applied_at_most_once() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+356 -29
View File
@@ -18,7 +18,8 @@ use dosh::crypto;
use dosh::native::{host_public_key, load_or_create_host_key, ssh_ed25519_public_blob, trust_host}; use dosh::native::{host_public_key, load_or_create_host_key, ssh_ed25519_public_blob, trust_host};
use dosh::protocol::{ use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, TicketAttachEnvelope,
TicketAttachOkEnvelope,
}; };
use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
@@ -98,6 +99,35 @@ persist_sessions = false
config 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 { fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child {
let server = env!("CARGO_BIN_EXE_dosh-server"); let server = env!("CARGO_BIN_EXE_dosh-server");
let child = Command::new(server) let child = Command::new(server)
@@ -501,6 +531,75 @@ fn direct_attach_session(
(socket, bootstrap, ok) (socket, bootstrap, ok)
} }
fn ticket_attach_session(
socket: &UdpSocket,
port: u16,
bootstrap: &dosh::auth::BootstrapResponse,
session: &str,
mode: &str,
) -> AttachOk {
let client_nonce = crypto::random_12();
let request_key = crypto::hkdf32(
&bootstrap.attach_ticket_psk,
&client_nonce,
b"dosh/ticket-attach-request/v1",
)
.unwrap();
let body = protocol::to_body(&TicketAttachBody {
session: session.to_string(),
mode: mode.to_string(),
cols: 80,
rows: 24,
requested_env: Vec::new(),
})
.unwrap();
let ciphertext = crypto::seal(
&request_key,
&client_nonce,
b"dosh-ticket-attach-request-v1",
&body,
)
.unwrap();
let envelope = TicketAttachEnvelope {
ticket: bootstrap.attach_ticket.clone(),
client_nonce,
ciphertext,
};
let packet = protocol::encode_plain(
PacketKind::TicketAttachRequest,
[0u8; 16],
1,
0,
&protocol::to_body(&envelope).unwrap(),
)
.unwrap();
socket
.send_to(&packet, format!("127.0.0.1:{port}"))
.unwrap();
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).unwrap();
let packet = protocol::decode(&buf[..n]).unwrap();
assert_eq!(packet.header.kind, PacketKind::AttachOk);
let envelope: TicketAttachOkEnvelope = protocol::from_body(&packet.body).unwrap();
let mut salt = Vec::with_capacity(24);
salt.extend_from_slice(&client_nonce);
salt.extend_from_slice(&envelope.server_nonce);
let response_key = crypto::hkdf32(
&bootstrap.attach_ticket_psk,
&salt,
b"dosh/ticket-attach-ok/v1",
)
.unwrap();
let plain = crypto::open(
&response_key,
&envelope.server_nonce,
b"dosh-ticket-attach-ok-v1",
&envelope.ciphertext,
)
.unwrap();
protocol::from_body(&plain).unwrap()
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn send_encrypted( fn send_encrypted(
socket: &UdpSocket, socket: &UdpSocket,
@@ -1363,18 +1462,10 @@ fn server_retransmits_unacked_output_frames() {
); );
let mut seen = Vec::new(); let mut seen = Vec::new();
let mut duplicate = None; let deadline = std::time::Instant::now() + Duration::from_secs(4);
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while std::time::Instant::now() < deadline { while std::time::Instant::now() < deadline {
if let Some((header, frame)) = recv_frame(&socket, &bootstrap.session_key) { if let Some((header, frame)) = recv_frame(&socket, &bootstrap.session_key) {
if String::from_utf8_lossy(&frame.bytes).contains("DOSH_RETRANSMIT") { if String::from_utf8_lossy(&frame.bytes).contains("DOSH_RETRANSMIT") {
if seen
.iter()
.any(|(_, output_seq)| *output_seq == frame.output_seq)
{
duplicate = Some((header.seq, frame.output_seq));
break;
}
seen.push((header.seq, frame.output_seq)); seen.push((header.seq, frame.output_seq));
} }
} }
@@ -1383,9 +1474,18 @@ fn server_retransmits_unacked_output_frames() {
let _ = server.kill(); let _ = server.kill();
let _ = server.wait(); let _ = server.wait();
let max_same_output_seq = seen
.iter()
.map(|(_, output_seq)| {
seen.iter()
.filter(|(_, candidate)| candidate == output_seq)
.count()
})
.max()
.unwrap_or(0);
assert!( assert!(
duplicate.is_some(), max_same_output_seq >= 10,
"expected retransmitted same output seq, seen={seen:?}" "expected retransmission to continue past the old 8-attempt ceiling, seen={seen:?}"
); );
} }
@@ -1639,6 +1739,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] #[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();
@@ -2385,6 +2589,9 @@ fn write_persistent_server_config(dir: &tempfile::TempDir, port: u16) -> std::pa
let mut raw = fs::read_to_string(&config).unwrap(); let mut raw = fs::read_to_string(&config).unwrap();
// The base writer hardcodes `persist_sessions = false`; flip it on. // The base writer hardcodes `persist_sessions = false`; flip it on.
raw = raw.replace("persist_sessions = false", "persist_sessions = true"); raw = raw.replace("persist_sessions = false", "persist_sessions = true");
// Persistence tests attach the sessions they need explicitly. Leaving the
// base prewarm on here creates a detached default holder in every test.
raw = raw.replace("prewarm_sessions = [\"default\"]", "prewarm_sessions = []");
fs::write(&config, raw).unwrap(); fs::write(&config, raw).unwrap();
config config
} }
@@ -2420,6 +2627,45 @@ fn kill_holder(sessions_dir: &Path, session: &str) {
.status(); .status();
} }
/// Create a holder the way older Dosh builds did for implicit sessions, so
/// startup migration can be tested without depending on the new spawn policy.
fn spawn_legacy_holder(sessions_dir: &Path, session: &str) {
let server = env!("CARGO_BIN_EXE_dosh-server");
let runtime_dir = dosh::persist::ensure_runtime_dir(sessions_dir, session).unwrap();
let mut launcher = Command::new(server)
.arg("hold")
.arg("--runtime-dir")
.arg(&runtime_dir)
.arg("--session")
.arg(session)
.arg("--shell")
.arg("/bin/sh")
.arg("--cols")
.arg("80")
.arg("--rows")
.arg("24")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
if holder_shell_pid(sessions_dir, session).is_some()
&& runtime_dir.join("holder.sock").exists()
{
let _ = launcher.wait();
return;
}
if let Some(status) = launcher.try_wait().unwrap() {
panic!("legacy holder launcher exited before ready: {status}");
}
thread::sleep(Duration::from_millis(20));
}
let _ = launcher.kill();
let _ = launcher.wait();
panic!("legacy holder did not become ready");
}
/// Send one encrypted Input line and give the shell a moment to act. /// Send one encrypted Input line and give the shell a moment to act.
fn type_line(socket: &UdpSocket, port: u16, ok: &AttachOk, key: &[u8; 32], seq: u64, line: &str) { fn type_line(socket: &UdpSocket, port: u16, ok: &AttachOk, key: &[u8; 32], seq: u64, line: &str) {
let input = Input { let input = Input {
@@ -2557,7 +2803,7 @@ fn session_survives_server_restart_same_shell_and_screen() {
} }
#[test] #[test]
fn implicit_session_survives_server_restart_for_same_ticket_holder() { fn implicit_session_survives_update_restart_via_ticket_reattach() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let sessions_dir = dir.path().join("sessions"); let sessions_dir = dir.path().join("sessions");
let port = free_udp_port(); let port = free_udp_port();
@@ -2567,59 +2813,140 @@ fn implicit_session_survives_server_restart_for_same_ticket_holder() {
let mut server = start_server(&dir, &config); let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach_session(&config, port, "read-write", &session); let (socket, bootstrap, ok) = direct_attach_session(&config, port, "read-write", &session);
let key = bootstrap.session_key; let key = bootstrap.session_key;
type_line(&socket, port, &ok, &key, 2, "cd /tmp\n");
type_line( type_line(
&socket, &socket,
port, port,
&ok, &ok,
&key, &key,
2, 3,
"export IMPLICIT_MARK=implicit_restart_42\n", "export IMPLICIT_MARK=implicit_restart_42\n",
); );
let _ = collect_frame_text(&socket, &key, 1000); type_line(
&socket,
port,
&ok,
&key,
4,
"printf 'IMPLICIT_SCREEN_MARKER\\n'\n",
);
let pre = collect_frame_text(&socket, &key, 1500);
assert!(
pre.contains("IMPLICIT_SCREEN_MARKER"),
"implicit marker missing before restart: {pre:?}"
);
thread::sleep(Duration::from_secs(3)); thread::sleep(Duration::from_secs(3));
let shell_pid_before = holder_shell_pid(&sessions_dir, &session); let shell_pid_before = holder_shell_pid(&sessions_dir, &session);
assert!( assert!(
shell_pid_before.is_some(), shell_pid_before.is_some(),
"implicit sessions should get a persistent holder when persistence is enabled" "implicit sessions must get temporary holders so active clients survive updates"
); );
let _ = server.kill(); let _ = server.kill();
let _ = server.wait(); let _ = server.wait();
thread::sleep(Duration::from_millis(300)); thread::sleep(Duration::from_millis(300));
let pid = shell_pid_before.unwrap();
assert!(
unsafe { libc::kill(pid, 0) } == 0,
"implicit session shell pid {pid} must survive server restart"
);
let mut server = start_server(&dir, &config); let mut server = start_server(&dir, &config);
let (socket2, bootstrap2, ok2) = direct_attach_session(&config, port, "read-write", &session);
let key2 = bootstrap2.session_key; let resume = ResumeRequest {
session: session.clone(),
last_rendered_seq: ok.initial_seq,
cols: 80,
rows: 24,
};
send_encrypted(
&socket,
port,
PacketKind::ResumeRequest,
ok.client_id,
5,
0,
&key,
&protocol::to_body(&resume).unwrap(),
);
let mut buf = [0u8; 65535];
let deadline = std::time::Instant::now() + Duration::from_secs(2);
loop {
assert!(
std::time::Instant::now() < deadline,
"old live resume did not receive unknown-client reject"
);
let (n, _) = socket.recv_from(&mut buf).unwrap();
let packet = protocol::decode(&buf[..n]).unwrap();
if packet.header.kind == PacketKind::Frame {
continue;
}
assert_eq!(packet.header.kind, PacketKind::AttachReject);
assert_eq!(packet.header.conn_id, ok.client_id);
break;
}
let ok2 = ticket_attach_session(&socket, port, &bootstrap, &session, "read-write");
let key2 = ok2.session_key;
let snapshot = String::from_utf8_lossy(&ok2.snapshot).to_string();
type_line( type_line(
&socket2, &socket,
port, port,
&ok2, &ok2,
&key2, &key2,
2, 2,
"printf 'IMPLICIT_MARK=%s\\n' \"$IMPLICIT_MARK\"\n", "printf 'IMPLICIT_MARK=%s PWD=%s\\n' \"$IMPLICIT_MARK\" \"$PWD\"\n",
); );
let post = collect_frame_text(&socket2, &key2, 2000); let post = collect_frame_text(&socket, &key2, 2000);
let shell_pid_after = holder_shell_pid(&sessions_dir, &session); let shell_pid_after = holder_shell_pid(&sessions_dir, &session);
let _ = server.kill(); let _ = server.kill();
let _ = server.wait(); let _ = server.wait();
kill_holder(&sessions_dir, &session); kill_holder(&sessions_dir, &session);
assert!(
snapshot.contains("IMPLICIT_SCREEN_MARKER"),
"ticket reattach snapshot must repaint the exact pre-update screen, got {snapshot:?}"
);
assert_eq!( assert_eq!(
shell_pid_after, shell_pid_before, shell_pid_after, shell_pid_before,
"implicit session should re-adopt the same shell pid" "implicit update reconnect must use the same holder shell"
); );
assert!( assert!(
post.contains("IMPLICIT_MARK=implicit_restart_42"), post.contains("IMPLICIT_MARK=implicit_restart_42"),
"implicit session state must survive restart, got {post:?}" "implicit session env must survive update reconnect, got {post:?}"
); );
assert!(
post.contains("PWD=/tmp"),
"implicit session cwd must survive update reconnect, got {post:?}"
);
}
#[test]
fn startup_readopts_implicit_holders_for_update_reconnect() {
let dir = tempfile::tempdir().unwrap();
let sessions_dir = dir.path().join("sessions");
let port = free_udp_port();
let config = write_persistent_server_config(&dir, port);
let session = protocol::generate_implicit_session_name();
spawn_legacy_holder(&sessions_dir, &session);
let shell_pid = holder_shell_pid(&sessions_dir, &session).expect("legacy holder shell pid");
assert!(
unsafe { libc::kill(shell_pid, 0) } == 0,
"legacy holder shell must be live before startup"
);
let mut server = start_server(&dir, &config);
thread::sleep(Duration::from_millis(300));
let _ = server.kill();
let _ = server.wait();
assert!(
holder_shell_pid(&sessions_dir, &session).is_some(),
"startup must keep live implicit holders so active update reconnects can reattach"
);
assert!(
unsafe { libc::kill(shell_pid, 0) } == 0,
"startup must not shut down a live implicit holder before reconnect grace"
);
kill_holder(&sessions_dir, &session);
} }
#[test] #[test]
+13
View File
@@ -58,6 +58,19 @@ fn encrypted_packet_round_trips() {
assert_eq!(plain, b"hello"); assert_eq!(plain, b"hello");
} }
#[test]
fn plaintext_packet_never_decrypts_as_authenticated_body() {
let key = crypto::random_32();
let mut decoded = protocol::decode(
&protocol::encode_plain(PacketKind::Input, crypto::random_16(), 1, 0, b"hello").unwrap(),
)
.unwrap();
decoded.header.session_key_id = protocol::session_key_id(&key);
let err = protocol::decrypt_body(&decoded, &key, CLIENT_TO_SERVER).unwrap_err();
assert!(err.to_string().contains("not encrypted"));
}
#[test] #[test]
fn encrypted_packet_rejects_wrong_session_key_id_before_decrypt() { fn encrypted_packet_rejects_wrong_session_key_id_before_decrypt() {
let key = crypto::random_32(); let key = crypto::random_32();
+52
View File
@@ -0,0 +1,52 @@
#[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_service_does_not_sandbox_remote_shells() {
let service = include_str!("../packaging/systemd/dosh-server.service");
let install = include_str!("../install.sh");
for raw in [service, install] {
assert!(raw.contains("KillMode=process"));
assert!(
raw.contains("Restart=always"),
"dosh-server should come back after accidental SIGTERM while preserving child shells"
);
for directive in [
"NoNewPrivileges=",
"PrivateTmp=",
"ProtectSystem=",
"ProtectHome=",
"RestrictAddressFamilies=",
"RestrictRealtime=",
"LockPersonality=",
"MemoryDenyWriteExecute=",
] {
assert!(
!raw.contains(directive),
"dosh sessions are user shells; systemd sandbox directive {directive} changes terminal/app behavior"
);
}
assert!(
!raw.contains("ReadWritePaths="),
"remote shells must not be restricted to dosh config/data paths"
);
}
}