Compare commits

..
26 Commits
Author SHA1 Message Date
DuProcess a202f97704 Quiet missing release lookup
ci / test (push) Has been cancelled
ci / fuzz-smoke (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-06-20 19:20:55 -04:00
DuProcess f6ead86db4 Add setup selftest and diagnostics polish
ci / test (push) Has been cancelled
ci / fuzz-smoke (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-06-20 19:19:29 -04:00
DuProcess 013d653f99 Quiet Gitea prebuilt fallback
ci / test (push) Has been cancelled
ci / fuzz-smoke (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-06-20 18:10:15 -04:00
DuProcess 4e7e4cff10 Update release artifact checksums
ci / test (push) Has been cancelled
ci / fuzz-smoke (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-06-20 18:09:25 -04:00
DuProcess fbad776441 Resolve Gitea release downloads
ci / test (push) Has been cancelled
ci / fuzz-smoke (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-06-20 18:08:13 -04:00
DuProcess 90d9b583c0 Try prebuilt installers by default
ci / test (push) Has been cancelled
ci / fuzz-smoke (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-06-20 18:03:11 -04:00
DuProcess f7c4ebaaf7 Add macOS release artifact evidence
ci / test (push) Has been cancelled
ci / fuzz-smoke (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-06-20 18:00:56 -04:00
DuProcess 27419f4ca8 Fix Gitea release id parsing
ci / test (push) Has been cancelled
ci / fuzz-smoke (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-06-20 17:51:36 -04:00
DuProcess ec2422bc3e Remove personal host aliases from defaults
ci / test (push) Has been cancelled
ci / fuzz-smoke (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-06-20 17:50:26 -04:00
DuProcess d51cc248e7 Add release evidence and audit packet
ci / test (push) Has been cancelled
ci / fuzz-smoke (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-06-20 10:45:55 -04:00
DuProcess b44ff8e773 Improve release and benchmark tooling 2026-06-20 10:37:26 -04:00
DuProcess 7884ea2796 Add prebuilt update path 2026-06-19 16:40:58 -04:00
DuProcess 774da7371e Chunk terminal output below MTU 2026-06-19 16:30:51 -04:00
DuProcess 41cdb0f54f Add optional command extensions 2026-06-19 16:17:38 -04:00
DuProcess 90e53f4b68 Add reliable stream retransmission 2026-06-19 16:00:51 -04:00
DuProcess d0d6f59cdf Add launch hardening gates 2026-06-18 09:45:27 -04:00
DuProcess 25d9a6aefa Expand native auth and benchmark gates 2026-06-18 09:29:06 -04:00
DuProcessandClaude Opus 4.8 2835da76b0 Persist only named/prewarmed sessions, not implicit ones
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
ci / test (push) Has been cancelled
Implicit sessions (dosh host with no --session) get a random
term-<millis>-<pid> name you can never reattach to, so persisting them just
left unreattachable holder processes lingering across restarts. The server
now only uses a persistent holder for prewarmed or explicitly-named
sessions; implicit ones use the in-process shell that dies on restart and
reaps on disconnect, as before. Adds a shared
protocol::{generate,is}_implicit_session_name as the single source of truth
for the name shape, used by both client (generate) and server (detect).

Server-only behavior change; wire VERSION unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:46:10 -04:00
DuProcessandClaude Opus 4.8 eec8ef0a02 Fall back to xterm-256color when the client TERM isn't on the server
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
dosh propagates the client's TERM, but a server lacking that terminal's
terminfo (e.g. xterm-ghostty, xterm-kitty) made tmux/vim fail with
"missing or unsuitable terminal". build_shell_command now keeps the
requested TERM only when its terminfo exists on this host, otherwise falls
back to xterm-256color so remote apps always work. Affects both the
in-process and holder-spawned shells.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:03:06 -04:00
DuProcessandClaude Opus 4.8 41256b66b7 persist_sessions: default off (opt-in until stress-tested)
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
Restart-survivable sessions change the core model (per-session holder
processes + SCM_RIGHTS fd passing). Keep them OFF by default so deploying
the build can't flip the daily driver's session model unproven; enable with
persist_sessions = true after stress-testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:23:48 -04:00
DuProcess 14b7e75025 Merge persistence: server-restart-survivable sessions (holder + SCM_RIGHTS fd passing) 2026-06-14 14:21:17 -04:00
DuProcessandClaude Opus 4.8 8b1af51bc6 Persist sessions across server restarts via per-session holders
Make dosh terminal sessions survive a dosh-server restart
(crash/upgrade/systemctl restart): a reattaching client lands on the
SAME shell with screen state intact, instead of a fresh one.

Design: when persist_sessions is on (new config, default true) each
terminal session's shell runs in a small detached per-session holder
process (the dosh-server binary re-exec'd as `dosh-server hold`). The
holder double-forks + setsid into its own session, opens the PTY, spawns
the shell as ITS child, and serves the PTY master fd over a per-session
Unix socket via SCM_RIGHTS. The server adopts that fd to build a PtyHandle
that DETACHES (does not kill the shell) on drop, so a server exit leaves
holder + shell alive. On startup the server scans the runtime dir under
sessions_dir/run, reconnects to each live holder, receives the master fd
again, restores the persisted vt100 screen, and rebuilds the Session.

Screen/scrollback (server-memory only) is mirrored to disk atomically:
byte-throttled on the output hot path plus a 2s periodic flush, and
restored on re-adoption so reattach repaints. Truly-abandoned persistent
sessions are still reaped per the existing grace logic by asking the
holder to shut down. Anything in the persistent path failing degrades
gracefully to the original in-process shell.

PtyHandle gains an Owned (kill-on-drop, unchanged default) vs Adopted
(detach-on-drop) backing; resize on an adopted master uses TIOCSWINSZ.

No wire-format change, so protocol::VERSION stays 3. New deps: libc
(direct). New config: persist_sessions (default true); existing
integration tests pin it false to keep exercising the non-persistent
path unchanged.

Adds tests/integration_smoke.rs::session_survives_server_restart_same_
shell_and_screen: attaches, sets `cd /tmp; export MARK=...`, paints a
screen marker, KILLs the server, restarts it on the same config, reattaches
and asserts same shell pid + MARK + PWD + restored screen. Plus persist.rs
unit tests (name round-trip, screen save/load, dead-holder scan cleanup,
SCM_RIGHTS fd round-trip). cargo fmt --check and cargo test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:19:09 -04:00
DuProcessandClaude Opus 4.8 a88d912d2b Client: instant SIGWINCH-driven terminal resize (mosh-style)
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
The client only polled crossterm size() every 250ms, so window resizes
(esp. in VSCode/terminals that signal rather than expecting polling) lagged
or felt unresponsive. Add a SIGWINCH select branch that resizes the instant
the signal fires, matching mosh (which reacts to SIGWINCH + TIOCGWINSZ). Keep
the 250ms poll as a fallback. Client-only; wire VERSION unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:02:08 -04:00
DuProcess 683c3cd01d Merge Wave 2: disconnect status line, prediction tick-policy fix, SSH-agent forwarding (VERSION 3)
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-14 11:11:42 -04:00
DuProcess 9a52d65beb Add opt-in, security-gated SSH-agent forwarding
Item 3: SSH-agent forwarding, double-gated (client -A/forward_agent AND server
allow_agent_forwarding) and off by default on both ends.

Wire: new ForwardingKind::Agent (appended, existing bincode discriminants
unchanged). Bumped protocol VERSION 2 -> 3 so a pre-agent peer answers with a
clear version-mismatch reject instead of a deserialize error.

Client: -A / --forward-agent flag; requires local SSH_AUTH_SOCK. Requests an
Agent forwarding during native auth (agent forwarding requires native auth) and,
on a server-initiated StreamOpen carrying the reserved @dosh-agent sentinel,
splices the stream into the local agent unix socket (separate agent_writers map,
TCP forwarding paths untouched). Rejects agent StreamOpens unless it opted in, so
a server cannot reach the agent without consent.

Server: when the client opted in and allow_agent_forwarding is set, binds a
per-session proxy unix socket (dir 0700, socket 0600) before the shell spawns,
exports its path as the session's SSH_AUTH_SOCK, and tunnels each connection back
to the client over the agent sentinel stream. Applies only to freshly spawned
shells (documented). Socket cleaned up on auth/forward failure and when the
accept loop ends. SECURITY rationale documented in comments.

Tests: 2 end-to-end integration tests (full chain identities round-trip via a
PTY-hosted client + looping fake agent; and "no -A => no proxy socket"). Docs
updated (README forwarding + disconnect-status note, spec §12.1). fmt + full test
suite green (122 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 11:10:44 -04:00
DuProcess 6b2933eb05 Add non-destructive disconnect status line and tick-driven prediction policy
Item 1: mosh-style disconnect status line. When UDP packets stop arriving,
paint a single reverse-video cyan "[dosh] last contact Ns ago — reconnecting…"
on the terminal's bottom row using save/restore cursor (ESC7/ESC8) so the app
cursor never moves and full-screen TUIs are not corrupted. Cleared the instant a
packet resumes and refreshed on the status timer tick. Configurable via
client.toml `disconnect_status` (default on) and `DOSH_DISCONNECT_STATUS` env
override. State machine and rendered escape sequences are pure and unit-tested.

Item 2: re-evaluate the prediction display policy on the select-loop status tick
via `Predictor::refresh_policy`, so a latency spike flips speculation on (or off
on recovery) promptly instead of waiting for the next keystroke. Repaints only
when the on-screen result would change, so idle ticks are no-ops.

Tests: 8 new client unit tests (status state machine + overlay bytes, predictor
spike/recovery/idle). fmt + full test suite green. No wire-format change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:57:40 -04:00
34 changed files with 6651 additions and 539 deletions
+35 -11
View File
@@ -3,6 +3,9 @@ name: ci
on:
push:
pull_request:
workflow_dispatch:
schedule:
- cron: "17 7 * * 1"
jobs:
test:
@@ -36,21 +39,42 @@ jobs:
if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success'
run: |
set -e
for target in \
packet_decode \
from_body \
authorized_keys \
known_hosts \
handshake_structs \
attach_ticket; do
echo "== fuzzing $target =="
cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \
-max_total_time=20 -rss_limit_mb=4096
done
if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
DOSH_FUZZ_SECONDS="${DOSH_FUZZ_SECONDS:-300}" sh scripts/fuzz-run.sh
else
sh scripts/fuzz-run.sh 20
fi
- name: Note when fuzzing was skipped
if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success'
run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run."
package-release:
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')
strategy:
matrix:
include:
- os: ubuntu-latest
name: linux-x86_64
- os: macos-14
name: macos-aarch64
- os: macos-13
name: macos-x86_64
- os: windows-latest
name: windows-x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Package release
shell: bash
run: sh scripts/package-release.sh
- uses: actions/upload-artifact@v4
with:
name: dosh-${{ matrix.name }}
path: |
target/dosh-release/dosh-*
!target/dosh-release/stage/**
remote-bench:
runs-on: ubuntu-latest
env:
Generated
+6 -1
View File
@@ -397,6 +397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"pem-rfc7468",
"zeroize",
]
@@ -435,7 +436,7 @@ dependencies = [
[[package]]
name = "dosh"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"base64",
@@ -448,11 +449,14 @@ dependencies = [
"ed25519-dalek",
"hkdf",
"hmac",
"libc",
"portable-pty",
"rand",
"rpassword",
"rsa",
"serde",
"sha2",
"signature",
"ssh-key",
"tempfile",
"tokio",
@@ -1498,6 +1502,7 @@ checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
dependencies = [
"bcrypt-pbkdf",
"ed25519-dalek",
"num-bigint-dig",
"p256",
"p384",
"p521",
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "dosh"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
license = "MIT"
@@ -16,12 +16,15 @@ dirs = "5.0"
ed25519-dalek = "2.1"
hkdf = "0.12"
hmac = "0.12"
libc = "0.2"
portable-pty = "0.8"
rand = "0.8"
rsa = { version = "0.9.10", features = ["sha2"] }
rpassword = "7.5.4"
serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10"
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] }
signature = "2.2"
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption", "p256", "rsa"] }
tokio = { version = "1.41", features = ["full"] }
toml = "0.8"
vt100 = "0.15"
+18 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build test fmt install bench-local bench-local-json bench-docker-ssh bench-docker-mosh
.PHONY: build test fmt install package-release bench-report bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local
build:
cargo build --release
@@ -12,6 +12,12 @@ fmt:
install:
sh packaging/install.sh
package-release:
sh scripts/package-release.sh
bench-report:
sh scripts/bench-report.sh
# Safe, self-contained local benchmark matrix (native cold auth, cached attach
# ticket, local-auth) on a throwaway server bound to 127.0.0.1 on a free port in
# a temp HOME. Never touches the production server or UDP port 50000.
@@ -28,3 +34,14 @@ bench-docker-ssh:
bench-docker-mosh:
DOSH_BENCH_INCLUDE_MOSH=1 sh scripts/ci-docker-ssh-bench.sh
fuzz-smoke:
sh scripts/fuzz-run.sh 20
# Longer pre-launch fuzz pass. Override with DOSH_FUZZ_SECONDS=NN.
fuzz-deep:
DOSH_FUZZ_SECONDS=$${DOSH_FUZZ_SECONDS:-300} sh scripts/fuzz-run.sh
# 30-minute launch soak by default. Override with DOSH_SOAK_SECONDS=NN.
soak-local:
DOSH_SOAK_SECONDS=$${DOSH_SOAK_SECONDS:-1800} cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture
+136 -32
View File
@@ -81,6 +81,9 @@ dosh-server
client table per session
encrypted UDP protocol
tiny SSH-invoked dosh-auth helper mode
persistent sessions (persist_sessions, currently opt-in): each shell runs in a
detached per-session holder process; the server adopts its PTY master fd via
SCM_RIGHTS and re-adopts it after a restart, so sessions survive crash/upgrade
dosh-client
terminal raw mode
@@ -107,41 +110,68 @@ Install the client on macOS:
```bash
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
| DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_SERVER=palav DOSH_HOST=git.palav.dev DOSH_PORT=50000 sh -s -- client
| DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_PORT=50000 sh -s -- client
```
Update an installed client later:
```bash
dosh update
dosh update --check
dosh --version
```
`dosh update` first tries a release tarball named for the platform
(`dosh-macos-aarch64.tar.gz`, `dosh-linux-x86_64.tar.gz`, etc.) from the latest
Gitea/GitHub release. Gitea's concrete tag download route is detected when
`/releases/latest/download/...` is not supported. If that asset is not published
yet, it falls back to the source build path. If the release also publishes
`<artifact>.sha256`, the installer verifies the archive before installing it.
Install the client on Windows PowerShell:
```powershell
$env:DOSH_REPO="https://git.palav.dev/Palav/dosh.git"; $env:DOSH_SERVER="palav"; $env:DOSH_HOST="git.palav.dev"; $env:DOSH_PORT="50000"; irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
$env:DOSH_REPO="https://git.palav.dev/Palav/dosh.git"; $env:DOSH_PORT="50000"; irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
```
Attach:
```bash
dosh palav
dosh user@server.example.com
```
Plain `dosh palav` opens a fresh terminal session. Use named sessions when you want
to reattach to the same persistent terminal from multiple clients:
First-time setup for an existing SSH alias:
```bash
dosh --session work palav
dosh --session logs palav
dosh setup homelab
dosh selftest homelab
dosh homelab
```
`dosh setup <ssh-alias>` imports the OpenSSH alias into
`~/.config/dosh/hosts.toml`, pins the Dosh host key over SSH, runs `dosh doctor`,
and prints next commands. `dosh selftest <host>` checks local terminal overlay
safety, forwarding syntax, and the remote doctor path.
Plain `dosh user@server.example.com` opens a fresh terminal session. Use named
sessions when you want to reattach to the same persistent terminal from multiple
clients:
```bash
dosh --session work user@server.example.com
dosh --session logs user@server.example.com
```
Press `Ctrl-]` to detach the current client while leaving the server session alive.
Typing `exit` in the remote shell closes that Dosh session and returns the client.
If UDP packets stop arriving, Dosh keeps the terminal open, sends keepalive pings,
and attempts ticket-based reconnect. The old in-band status overlay was removed
because writing directly over the terminal could corrupt TUIs; a non-destructive
status surface is tracked as a public-readiness item.
and attempts ticket-based reconnect. While the link is silent it shows a single,
non-destructive status line on the bottom screen row (`[dosh] last contact Ns ago
— reconnecting…`), drawn with save/restore cursor so it never moves the app cursor
or corrupts a full-screen TUI, and cleared the instant packets resume. (The old
in-band overlay wrote over the active line and could corrupt TUIs; this draws only
the last row.) Disable it with `disconnect_status = false` in `client.toml` or
`DOSH_DISCONNECT_STATUS=0`.
If SSH and UDP use different public names, specify the UDP address:
@@ -149,13 +179,44 @@ If SSH and UDP use different public names, specify the UDP address:
dosh-client --dosh-host public.example.com --dosh-port 50000 user@host
```
Forwarding can use SSH-shaped flags directly or the forward-only wrapper:
```bash
dosh -N -L 8080:127.0.0.1:80 homelab
dosh forward homelab -L 8080:127.0.0.1:80 -D 1080
```
Dosh already lets OpenSSH handle SSH aliases, users, keys, ports, known-hosts,
ProxyJump, and other SSH config during bootstrap. It also runs `ssh -G` to infer the
UDP host from an SSH alias when `dosh_host` is not configured. To make that explicit
in Dosh's host config:
```bash
dosh import-ssh palav homelab
dosh import-ssh homelab
dosh homelab
```
Optional command extensions are just config-side startup shortcuts; Dosh has no
compile-time dependency on the tools they run. For example, to make a separately
installed server-side `tm` dashboard easy to open:
```toml
# ~/.config/dosh/client.toml
[extensions.tm]
command = "tm {args}"
description = "Open the server-side tmux dashboard"
```
Then `dosh homelab tm` sends `tm`, and `dosh homelab tm dosh` sends `tm 'dosh'`.
Remove that table to remove the integration. Hosts can override or opt out:
```toml
# ~/.config/dosh/hosts.toml
[homelab.extensions.tm]
command = "/opt/tm/bin/tm {args}"
[other-host.extensions.tm]
disabled = true
```
## Develop
@@ -190,9 +251,9 @@ Benchmark the ControlMaster-backed SSH bootstrap path:
target/release/dosh-bench --server user@host --controlmaster --iterations 3
```
Run the Docker OpenSSH benchmark gate used by CI. It checks both cold SSH bootstrap
and ControlMaster-backed SSH bootstrap against a containerized `sshd` plus resident
`dosh-server`:
Run the Docker OpenSSH benchmark gate used by CI. It checks cold SSH bootstrap,
ControlMaster-backed SSH bootstrap, native cold auth after one-time `dosh trust`,
and cached attach against a containerized `sshd` plus resident `dosh-server`:
```bash
make bench-docker-ssh
@@ -204,12 +265,39 @@ Run the same Docker comparison with Mosh installed in the benchmark container:
make bench-docker-mosh
```
That prints `ssh_true_ms`, `dosh_attach_ms`, and `mosh_start_true_ms` under the same
container, key, DNS, and network path. It also prints `dosh_cached_attach_ms`, which
is the real Dosh fast path after the first SSH-authenticated bootstrap has issued an
attach ticket. See `docs/PUBLIC_READINESS.md` before using the numbers publicly;
Dosh's current strongest claim is fast attach/reconnect, not full Mosh feature
parity yet.
That prints `ssh_true_ms`, `dosh_attach_ms`, `dosh_cold_native_ms`,
`dosh_cached_attach_ms`, and, for the Mosh target, `mosh_start_true_ms` under the
same container, key, DNS, and network path. `dosh_cached_attach_ms` is the real Dosh
fast path after the first authentication has issued an attach ticket. See
`docs/PUBLIC_READINESS.md` before using the numbers publicly; Dosh's current
strongest claim is fast attach/reconnect plus native encrypted forwarding on
Dosh-installed servers, not generic SSH compatibility.
The latest local release evidence is in
`docs/RELEASE_EVIDENCE_2026-06-20.md`.
Generate a publishable Markdown benchmark report:
```bash
make bench-report
```
Set `DOSH_BENCH_SERVER`, `DOSH_BENCH_ITERS`, `DOSH_BENCH_ARGS`, and
`DOSH_BENCH_REPORT` to target a real host and choose the output path.
Run the explicit pre-launch soak and fuzz gates:
```bash
make soak-local # 30-minute sleep/roaming gate by default
make fuzz-deep # 5 minutes per fuzz target by default
```
Both are configurable for shorter local shakedowns:
```bash
DOSH_SOAK_SECONDS=30 make soak-local
DOSH_FUZZ_SECONDS=60 make fuzz-deep
```
The CI workflow includes an optional remote benchmark job. It runs when
`DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository secrets are
@@ -221,6 +309,13 @@ Install release binaries and the user systemd service:
make install
```
Build release tarballs for upload to Gitea/GitHub releases:
```bash
make package-release
GITEA_TOKEN=... scripts/upload-gitea-release.sh v0.1.0 target/dosh-release/dosh-*
```
## Performance Rules
The stack is performance-driven, not fixed by taste. Rust is the default because the
@@ -255,7 +350,8 @@ Hot-path rules:
- Replacing SSH as the first public-key trust mechanism.
- Multi-user access control.
- Windows support in v0.
- Windows server/full parity in v0; the client installer supports prebuilt Windows
client artifacts when published.
- Full mosh compatibility.
- Perfect predictive local echo in the first MVP.
@@ -274,15 +370,21 @@ Beyond the SSH-bootstrap core, native v1 (`docs/NATIVE_V1_SPEC.md`) is substanti
implemented and aims to replace the day-to-day `ssh host` workflow on Dosh-installed
servers:
- **Native UDP auth** with X25519 key exchange, transcript-bound Ed25519 user auth
via ssh-agent or an encrypted OpenSSH key, ChaCha20-Poly1305 transport, and
`authorized_keys` policy enforcement (`from=`, `no-port-forwarding`, `permitopen=`;
unsupported options fail closed).
- **Native UDP auth** with X25519 key exchange; transcript-bound Ed25519, ECDSA
P-256, and RSA-SHA2 user auth via ssh-agent or OpenSSH identity files;
ChaCha20-Poly1305 transport; and `authorized_keys` policy enforcement (`from=`,
`no-port-forwarding`, `permitopen=`; unsupported options fail closed).
- **Dosh host-key trust**: pinned `known_hosts`, `dosh trust [--remove|--replace]`,
TOFU only when explicitly enabled, and hard-fail on host-key mismatch.
- **TCP forwarding**: local `-L`, remote `-R` (loopback bind by default), dynamic
SOCKS5 `-D`, forward-only `-N`, and background `-f`, with per-stream flow control so
bulk transfers do not lag the terminal.
- **Agent forwarding** (opt-in, security-gated): `-A` / `forward_agent` exports your
local `SSH_AUTH_SOCK` to a per-session proxy socket on the server (private dir,
mode 0600) and tunnels each connection back to your agent over a Dosh stream. Only
active on explicit client opt-in **and** server `allow_agent_forwarding = true`; it
applies to freshly spawned shells (not an already-running attached/prewarmed
session, the same constraint ssh/mosh have).
- **Diagnostics**: `dosh doctor host` for config/auth/UDP/forwarding-policy checks.
Native auth is **opt-in alongside SSH fallback** (`auth_preference = "native,ssh"`):
@@ -290,10 +392,12 @@ the native authenticated path is tried first and falls back to SSH bootstrap
explicitly when native auth is disabled, unavailable, or rejected. It never silently
degrades to an unauthenticated mode.
Native v1 is **not yet fully verified**. Per-IP token-bucket rate limiting, protocol
VERSION negotiation hardening, fuzzing in CI, ECDSA/RSA user keys, and an external
security review are still open. See `docs/THREAT_MODEL.md` for the published threat
model and accepted residual risks, and the "Native v1 verification checklist status"
table in `docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not yet
claim a fully verified SSH replacement; its defensible claim remains fast encrypted
native attach/reconnect with SSH-equivalent transport security and SSH fallback.
Native v1 is **not externally audited yet**. Local 30-minute sleep/roaming soak,
fuzz-smoke, and Docker SSH/Mosh benchmark evidence is captured in
`docs/RELEASE_EVIDENCE_2026-06-20.md`. See `docs/THREAT_MODEL.md` for the
published threat model and accepted residual risks, `docs/PROTOCOL_VERSIONING.md`
for the v1 versioning policy, `docs/AUDIT_PACKET.md` for the external security
review handoff, and the "Native v1 verification checklist status" table in
`docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not claim generic
SSH compatibility; its defensible claim is fast encrypted native attach/reconnect
and forwarding on Dosh-installed servers with SSH bootstrap fallback.
+2
View File
@@ -468,6 +468,8 @@ dosh_port = 50000
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
```
+75
View File
@@ -0,0 +1,75 @@
# Dosh Native v1 Audit Packet
This is the handoff checklist for an external security review.
## Scope
Review the security properties of Dosh native v1:
- Native UDP authentication and key exchange
- Host key trust and known-host mismatch behavior
- Authorized key parsing and policy enforcement
- Transport encryption, replay protection, rekeying, resume, and stale packet handling
- Attach ticket sealing/opening
- TCP forwarding and agent forwarding authorization boundaries
- Parser robustness for untrusted wire/config data
Primary files:
- `src/native.rs`
- `src/protocol.rs`
- `src/auth.rs`
- `src/ssh_agent.rs`
- `src/bin/dosh-client.rs`
- `src/bin/dosh-server.rs`
- `src/config.rs`
- `tests/parser_robustness.rs`
- `tests/protocol_auth.rs`
- `tests/hostile_network.rs`
- `tests/integration_smoke.rs`
- `fuzz/fuzz_targets/*`
Primary docs:
- `docs/NATIVE_V1_SPEC.md`
- `docs/THREAT_MODEL.md`
- `docs/PROTOCOL_VERSIONING.md`
- `docs/PUBLIC_READINESS.md`
- `docs/RELEASE_EVIDENCE_2026-06-20.md`
## Required Reviewer Questions
1. Is the native handshake transcript complete enough to bind client/server keys,
requested session, mode, env, and forwarding permissions?
2. Are host-key trust transitions fail-closed, especially unknown vs mismatch vs
explicit TOFU?
3. Are authorized-key options parsed conservatively enough, and are unsupported
options rejected safely?
4. Can replayed, reordered, delayed, stale, or cross-epoch packets affect terminal
input or forwarded streams more than once?
5. Does key rotation preserve confidentiality without creating split-brain epoch
states?
6. Do attach tickets create any bearer-token replay or privilege-extension issue?
7. Can UDP endpoint migration be abused for hijack or reflection?
8. Are local/remote/dynamic TCP forwarding and agent forwarding gated correctly by
client opt-in, server config, and authorized-key options?
9. Are parser and packet-size limits sufficient against malicious inputs?
10. Are there any unsafe assumptions in process/session persistence or holder
adoption?
## Current Local Evidence
As of 2026-06-20:
- `cargo test`: `153 passed, 1 ignored`
- `make soak-local`: passed 30-minute sleep/roaming soak
- `make fuzz-smoke`: passed all configured fuzz targets for 20s each
- `make bench-docker-mosh`: passed SSH, ControlMaster, native, cached, and Mosh
comparison gates
## Explicit Non-Claims Before Audit
- No claim of generic SSH protocol replacement.
- No claim of full Mosh compatibility.
- No claim that native v1 is externally audited.
- No claim that Windows server support is production-ready.
+50 -20
View File
@@ -17,6 +17,7 @@ make bench-local # safe, self-contained matrix on a throwaway server
make bench-local-json # same, machine-readable JSON with raw samples
make bench-docker-ssh # containerized SSH-vs-Dosh gate used by CI
make bench-docker-mosh # same, with Mosh installed for a three-way comparison
make bench-report # Markdown report wrapper around dosh-bench
```
`make bench-local` never touches a running production server: it builds release
@@ -35,7 +36,8 @@ tool pays before any useful remote work begins.
Every run prints, per metric, a summary line (count, min, median, p95, mean, max
in milliseconds) **and** a raw-samples line. Pass `--json` for one machine-readable
object per run (with the full `samples_ms` array) so published numbers can always
include raw data, as required by `docs/PUBLIC_READINESS.md`.
include raw data, as required by `docs/PUBLIC_READINESS.md`. Pass `--markdown` for a
publishable Markdown table, and `--output path/to/report.md` to write it to disk.
### Path matrix
@@ -93,11 +95,13 @@ Tunables: `scripts/bench-local.sh [ITERATIONS]` (default 20), `DOSH_BENCH_JSON=1
### `make bench-docker-ssh` / `make bench-docker-mosh` (`scripts/ci-docker-ssh-bench.sh`)
Builds one Ubuntu image with OpenSSH, `dosh-server`, `dosh-auth` (and Mosh for the
`-mosh` target), then runs `ssh_true_ms`, cold + ControlMaster `dosh_attach_ms`,
`dosh_cached_attach_ms`, and optionally `mosh_start_true_ms` against the same
container, key, and loopback network path. This is the gate CI enforces; it asserts
cold Dosh stays within 500 ms of SSH and that cached attach stays under a small
budget. See `README.md` "Develop" for the exact invocations.
`-mosh` target), then runs `ssh_true_ms`, cold + ControlMaster SSH-bootstrap
`dosh_attach_ms`, one-time `dosh trust` followed by native-cold
`dosh_cold_native_ms`, `dosh_cached_attach_ms`, and optionally
`mosh_start_true_ms` against the same container, key, and loopback network path.
This is the gate CI enforces; it asserts cold SSH-bootstrap Dosh stays within
500 ms of SSH, native-cold Dosh beats SSH mean by default, and cached attach stays
under a small budget. See `README.md` "Develop" for the exact invocations.
## Methodology and caveats
@@ -130,8 +134,8 @@ budget. See `README.md` "Develop" for the exact invocations.
## Sample results (loopback, self-contained)
Captured with `make bench-local` (`scripts/bench-local.sh 30`), all times in
milliseconds, 30 samples per metric.
Captured with `make bench-local` (`scripts/bench-local.sh 20`), all times in
milliseconds, 20 samples per metric.
- Machine: Intel Core i5-9500 @ 3.00 GHz, 6 cores
- OS: Ubuntu 24.04.4 LTS, Linux 6.8.0-124-generic, x86_64
@@ -141,27 +145,24 @@ milliseconds, 30 samples per metric.
| Metric | n | min | median | p95 | mean | max |
| --- | --- | --- | --- | --- | --- | --- |
| `dosh_cold_native_ms` | 30 | 8.10 | 9.01 | 10.40 | 9.18 | 10.82 |
| `dosh_cached_attach_ms` | 30 | 2.73 | 2.96 | 3.25 | 2.96 | 3.30 |
| `dosh_local_attach_ms` | 30 | 2.66 | 2.78 | 3.24 | 2.87 | 3.33 |
| `dosh_cold_native_ms` | 20 | 8.32 | 8.77 | 9.34 | 8.82 | 9.52 |
| `dosh_cached_attach_ms` | 20 | 2.83 | 3.23 | 3.75 | 3.24 | 3.77 |
| `dosh_local_attach_ms` | 20 | 2.84 | 3.21 | 3.96 | 3.28 | 3.98 |
Raw samples (ms):
```
dosh_cold_native_ms:
8.32, 8.23, 10.31, 10.37, 8.83, 8.81, 9.20, 9.04, 10.14, 8.98, 10.82, 9.87,
10.22, 10.42, 9.76, 9.09, 9.19, 8.94, 8.69, 8.67, 8.74, 9.33, 9.25, 8.92, 8.39,
8.55, 8.38, 9.81, 8.17, 8.10
8.82, 8.60, 8.69, 8.71, 9.33, 8.82, 8.90, 8.64, 8.39, 8.75, 8.79, 8.70, 8.64,
9.52, 8.72, 8.32, 9.06, 8.93, 8.96, 9.04
dosh_cached_attach_ms:
2.99, 2.99, 2.93, 2.87, 2.95, 2.80, 2.80, 2.90, 3.10, 2.73, 3.19, 2.80, 2.87,
2.82, 2.89, 3.03, 3.06, 3.21, 2.97, 2.83, 3.00, 2.82, 3.04, 2.97, 3.03, 3.24,
3.30, 2.73, 2.79, 3.25
3.04, 3.06, 2.98, 2.91, 2.96, 2.95, 3.05, 3.66, 3.37, 2.88, 2.83, 3.16, 3.49,
3.30, 3.77, 3.32, 3.49, 3.75, 3.56, 3.33
dosh_local_attach_ms:
3.05, 3.00, 2.84, 3.04, 3.33, 2.89, 2.76, 2.74, 2.73, 3.32, 3.14, 2.75, 2.83,
2.88, 2.99, 2.77, 2.75, 2.79, 2.69, 2.78, 2.75, 2.77, 2.75, 2.77, 3.03, 2.75,
2.94, 2.74, 2.82, 2.66
3.08, 3.96, 3.36, 3.30, 2.88, 2.99, 3.96, 3.98, 3.25, 2.84, 3.29, 3.59, 3.65,
3.47, 3.13, 2.92, 2.87, 2.91, 2.98, 3.18
```
Reading these numbers: cold native auth is ~9 ms because it pays the native
@@ -171,3 +172,32 @@ overhead floor. Over a real link, expect cached attach ≈ this floor + one netw
round trip — which is exactly the "near network RTT" target in the spec. There is
no SSH/Mosh baseline in this loopback table because those paths need an SSH server;
use `make bench-docker-ssh` / `make bench-docker-mosh` for the head-to-head.
## Sample results (Docker SSH/Mosh comparison)
Captured with `make bench-docker-mosh`, all times in milliseconds. This is the
same-container comparison gate: one generated key, one OpenSSH server, one
`dosh-server`, loopback-published TCP/UDP ports.
| Metric | n | min | median | p95 | mean | max |
| --- | --- | --- | --- | --- | --- | --- |
| `ssh_true_ms` (cold SSH baseline for native-cold gate) | 5 | 214.53 | 216.84 | 220.55 | 217.60 | 220.91 |
| `dosh_cold_native_ms` | 5 | 8.62 | 8.96 | 9.78 | 9.15 | 9.88 |
| `dosh_cached_attach_ms` | 10 | 8.41 | 8.83 | 9.52 | 8.90 | 9.53 |
| `mosh_start_true_ms` | 3 | 528.03 | 530.87 | 539.23 | 533.02 | 540.16 |
Raw samples (ms):
```
ssh_true_ms (native-cold gate):
216.84, 216.61, 214.53, 220.91, 219.09
dosh_cold_native_ms:
9.39, 8.88, 8.96, 8.62, 9.88
dosh_cached_attach_ms:
8.44, 8.41, 8.80, 8.48, 9.53, 9.51, 8.85, 8.69, 9.33, 8.96
mosh_start_true_ms:
540.16, 528.03, 530.87
```
+56 -12
View File
@@ -7,16 +7,22 @@
> - Milestone 1 — host identity and trust: **done.** Host key generation, `dosh trust`,
> `known_hosts`, and mismatch hard-fail are implemented.
> - Milestone 2 — native user auth: **done.** `ClientHello`/`ServerHello`/`UserAuth`/
> `AuthOk`, ssh-agent and encrypted-key Ed25519, and `authorized_keys` verification
> exist. ECDSA/RSA user keys are still pending (Ed25519 only today).
> `AuthOk`, ssh-agent and OpenSSH identity-file auth for Ed25519, ECDSA P-256,
> and RSA-SHA2, plus `authorized_keys` verification, exist.
> - Milestone 3 — default native auth: **done.** `auth_preference = "native,ssh"` is
> the default with explicit, visible SSH fallback. Cold-auth benchmark gates are
> pending (Track C / `BENCHMARKS.md`).
> - Milestone 4 — forwarding: **done.** Stream mux, `-L`, `-R`, `-D`, `-N`, `-f`, and
> per-stream flow control are implemented; hostile-network and load tests pending.
> - Milestone 5 — hardening: **in progress.** Full per-IP token-bucket rate limiting,
> protocol VERSION negotiation hardening, fuzzing in CI, and external review are not
> yet complete. The threat model is published (`docs/THREAT_MODEL.md`).
> the default with explicit, visible SSH fallback. Local and Docker benchmark gates
> cover cached attach, SSH fallback, and native cold auth (Track C /
> `BENCHMARKS.md`).
> - Milestone 4 — forwarding: **implemented.** Stream mux, `-L`, `-R`, `-D`, `-N`,
> `-f`, per-stream flow control, ordered stream offsets/ACKs, and stream-data
> retransmission exist. Terminal-priority/load, replay/reorder, and dropped
> server-to-client `StreamData` recovery regressions are covered.
> - Milestone 5 — hardening: **partly done.** Per-IP token-bucket rate limiting,
> fail-closed protocol-version checks with a documented v1 policy, parser fuzz
> targets, fuzz-smoke/deep CI entry points, scripted TUI transport tests,
> forwarding load/priority/replay/loss tests, and independent persistent session
> restart tests exist. Published long-soak evidence is not yet complete. The threat
> model is published (`docs/THREAT_MODEL.md`).
> - Milestone 6 — workflow parity: **mostly done.** `dosh doctor`, host-trust
> management, and the encrypted-key prompt flow exist; cross-OS daily-driver soak is
> ongoing.
@@ -45,6 +51,7 @@ dosh -L 8080:127.0.0.1:80 host
dosh -R 9000:127.0.0.1:9000 host
dosh --session work host
dosh --view-only --session work host
dosh host tm
```
Compatibility expectations:
@@ -101,6 +108,8 @@ Must work in v1:
- `dosh update`.
- `dosh doctor host` for config/auth/UDP reachability diagnostics.
- `dosh sessions host` for session visibility.
- Optional command extensions such as `dosh host tm` for companion tools that are
installed separately from Dosh.
Should work in v1 if it does not compromise the transport schedule:
@@ -123,8 +132,14 @@ Native v1 must be boring under real use:
- A closed laptop must not kill the remote session.
- A client crash must not kill the remote session.
- Server restart may drop live PTYs in v1 unless session persistence is implemented,
but the client must fail clearly and reconnect cleanly afterward.
- A server restart must not kill the remote session when `persist_sessions` is on
(currently opt-in until stress-tested): each session's shell runs in a detached
per-session *holder*
process whose PTY master fd the server passes back to itself via SCM_RIGHTS, so
the shell + scrollback survive a server crash/upgrade/`systemctl restart` and a
reattaching client lands on the same shell with its screen restored. With
`persist_sessions = false` the old behavior applies: live PTYs drop on restart,
but the client must still fail clearly and reconnect cleanly afterward.
- Decrypt failures from stale packets must be ignored or trigger reconnect, never
terminate the terminal by themselves.
- Terminal cleanup must restore cursor, mouse mode, bracketed paste, alternate
@@ -444,6 +459,7 @@ CLI:
dosh -L [bind_host:]listen_port:target_host:target_port host
dosh -R [bind_host:]listen_port:target_host:target_port host
dosh -D [bind_host:]listen_port host
dosh -A host # forward the local ssh-agent (opt-in, server-gated)
```
Stream packet types:
@@ -460,6 +476,12 @@ Forwarding rules:
- Terminal traffic has priority over stream bulk data.
- Each stream has independent flow control.
- `StreamData` carries a per-stream byte offset and is delivered to TCP in order.
- `StreamWindowAdjust` carries a cumulative received byte offset; peers free
retransmit buffers and replenish send credit only for acknowledged contiguous
bytes.
- Unacknowledged stream bytes are retransmitted as newly encrypted transport packets
with fresh packet sequence numbers/nonces.
- Backpressure must not block PTY input or output.
- Server enforces `no-port-forwarding` and `permitopen=`.
- Remote listeners bind to loopback by default.
@@ -473,6 +495,24 @@ Forwarding rules:
- Stream packet scheduling must enforce terminal priority; a large port-forward copy
cannot make shell keystrokes lag.
### 12.1 SSH-Agent Forwarding (opt-in, security-gated)
- Activated only on explicit client opt-in (`-A` / `forward_agent = true`) **and**
server `allow_agent_forwarding = true`. Off by default on both ends.
- The client requests a `ForwardingKind::Agent` entry during auth. If the server
policy allows it, the server binds a per-session proxy unix socket in a
process-private directory (dir 0700, socket 0600) and exports its path as the
spawned shell's `SSH_AUTH_SOCK`.
- Each connection from a remote process to that proxy socket is tunneled to the
client over a server-initiated `StreamOpen` carrying the reserved target sentinel
`@dosh-agent` (port 0). The client splices it into its local `SSH_AUTH_SOCK`
(a unix socket, not a TCP target). Data/window/close reuse the existing stream
packets unchanged.
- Only applies to a freshly spawned shell; an already-running attached/prewarmed
session keeps its existing environment (same constraint as ssh/mosh).
- SECURITY: forwarding exposes the local agent to the remote host for the session's
lifetime, which is why it is double-gated and never the default.
## 13. Diagnostics And Operations
Native v1 must include operations commands:
@@ -519,6 +559,10 @@ forward_agent = false
send_env = ["LANG", "LC_*", "TERM", "COLORTERM"]
set_env = {}
forwardings = []
[extensions.tm]
command = "tm {args}"
description = "Open an optional server-side tmux dashboard"
```
Server:
@@ -569,7 +613,7 @@ Milestone 5: hardening
- Fuzz packet parsing, authorized-key parsing, known-host parsing, and handshake state.
- Add hostile-network integration tests.
- Add external review checklist before public security claims.
- Keep a public hardening checklist and threat model before public security claims.
Milestone 6: workflow parity
+40
View File
@@ -0,0 +1,40 @@
# Dosh Protocol Versioning
Dosh v1 uses a deliberately simple compatibility policy: **single-version,
fail-closed, explicit error**.
The wire header carries `protocol::VERSION`. The native handshake carries
`native::NATIVE_PROTOCOL_VERSION`. A peer that speaks any other version is rejected
before application data is accepted:
- foreign wire `VERSION` packets get an `AttachReject` with
`protocol version mismatch - upgrade dosh`;
- foreign native handshake `protocol_version` values get the same named upgrade
error with local/remote versions;
- there is no silent downgrade, compatibility fallback, or best-effort decoding.
## When To Bump
Bump `protocol::VERSION` when a change affects packet framing, packet kind meaning,
serialized protocol structs carried outside native handshake negotiation, or anything
an older peer could misparse.
Bump `native::NATIVE_PROTOCOL_VERSION` when a change affects native handshake
transcripts, native auth semantics, algorithm negotiation, attach tickets, or any
field that is signed or key-derived by native auth.
If both layers are affected, bump both.
## Compatibility Window
Native v1 supports exactly the current version. That keeps the implementation small
and makes security review tractable. Multi-version negotiation can be added later
only with an explicit downgrade-resistance design:
- negotiated version must be transcript-bound;
- the selected version must be visible in diagnostics;
- tests must prove an active attacker cannot force an older mutually supported
version;
- unsupported peers must still get the same named upgrade error.
Until that exists, the public policy is: upgrade both sides together.
+77 -43
View File
@@ -1,21 +1,21 @@
# Dosh Public Readiness
Dosh's defensible public claim is fast terminal attach and reconnect. It should not
claim full Mosh replacement status until the feature matrix below is green and the
comparison benchmark is reproducible outside the author's homelab.
Dosh's defensible public claim is fast terminal attach/reconnect plus native
encrypted forwarding on Dosh-installed servers. It should not claim generic SSH
compatibility unless it implements the SSH protocol, and public benchmark claims
must be reproducible outside the author's homelab.
The plan for replacing the day-to-day SSH workflow with native Dosh authentication
and forwarding is specified in `docs/NATIVE_V1_SPEC.md`, and the published threat
model is in `docs/THREAT_MODEL.md`. Native v1 is now substantially implemented:
native key-exchange + user auth, host-key pinning/trust, `-L`/`-R`/`-D` forwarding,
and `dosh doctor` all exist (see the feature matrix and the verification-checklist
status table below). It is **not yet fully verified**: per-IP token-bucket rate
limiting, protocol-version negotiation hardening, fuzzing in CI, and external review
are still open. Until the verification checklist (`NATIVE_V1_SPEC.md` section 16) is
green and that review is done, Dosh's defensible public security claim remains fast
encrypted native attach/reconnect with SSH-equivalent transport security and an
explicit SSH bootstrap fallback — not a fully verified, externally reviewed SSH
replacement.
`dosh doctor`, token-bucket auth rate limiting, and fuzz-smoke CI all exist (see the
feature matrix and the verification-checklist status table below). It is **not yet
fully verified**: long sleep/roaming soak and deeper fuzzing results are still open
launch evidence. Until the verification checklist (`NATIVE_V1_SPEC.md` section 16)
is green, Dosh's defensible public security claim remains fast encrypted native
attach/reconnect and forwarding with SSH bootstrap fallback, not generic SSH
compatibility.
## Objective Benchmarks
@@ -58,31 +58,31 @@ with ordinary SSH.
| Feature | Mosh | Dosh now | Public status |
| --- | --- | --- | --- |
| SSH-based first authentication | yes | yes | ready |
| Native UDP key auth (no SSH per attach) | no | yes, Ed25519 via ssh-agent or encrypted key | implemented; pending full verification |
| Native UDP key auth (no SSH per attach) | no | yes, Ed25519, ECDSA P-256, and RSA-SHA2 via ssh-agent or OpenSSH identity files | implemented; pending full verification |
| Dosh host-key pinning and trust | no | yes, `known_hosts` + `dosh trust` + mismatch hard-fail | implemented |
| `authorized_keys` policy enforcement | no | yes, `from=`/`no-port-forwarding`/`permitopen=`, unsupported fail closed | implemented |
| `dosh doctor` diagnostics | no | yes, config/auth/UDP/forwarding-policy check | implemented |
| Encrypted UDP terminal data | yes | yes | ready |
| Roaming by client address change | yes | yes | needs more hostile-network tests |
| Survive sleep or network loss | yes | yes | needs long-running soak tests |
| Roaming by client address change | yes | yes | implemented; hostile-network covered, 30-minute soak gate available |
| Survive sleep or network loss | yes | yes | implemented; run `make soak-local` before public launch |
| Fast repeat attach without SSH | no | yes, via attach tickets | core differentiator |
| Resident server daemon | no | yes | core differentiator |
| One UDP port for all sessions | port range by default | yes | ready |
| Fresh session by default | yes | yes | ready |
| Named persistent sessions | no built-in shared session model | yes | ready |
| Multiple clients on one session | no | yes | needs conflict-policy docs |
| Named persistent sessions | no built-in shared session model | yes, plus opt-in server-restart holders | implemented; holder mode needs stress-testing |
| Multiple clients on one session | no | yes | implemented; needs conflict-policy docs |
| View-only clients | no | yes | ready |
| Full-screen TUI correctness | yes | improving | must stay green before public push |
| Full-screen TUI correctness | yes | yes, scripted transport coverage for control sequences | implemented; needs broader app matrix |
| Predictive local echo | mature | guarded printable-only opt-in | not parity |
| Non-destructive disconnect UI | yes | not currently | needed |
| Non-destructive disconnect UI | yes | yes, bottom-row save/restore status line | implemented |
| Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
| X11 forwarding | no | no | non-goal unless tunneled separately |
| SSH agent forwarding | no | no | planned as forwarding channel |
| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; needs hostile-network tests |
| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; needs hostile-network tests |
| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented; needs hostile-network tests |
| SSH agent forwarding | no | yes, explicit `-A` / `forward_agent` plus server allow-list | implemented; opt-in only |
| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; load/priority, replay/reorder, and stream retransmit tested |
| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; policy and stream mux covered |
| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented over the same reliable stream mux |
| Forward-only / background forwarding, `-N` / `-f` | no | yes, `-f` requires `-N` | implemented |
| Per-stream flow control / terminal priority | no | yes, windowed credit per stream | implemented; needs load tests |
| Per-stream flow control / terminal priority | no | yes, windowed credit per stream | implemented; covered by blocked-stream and local-forward load tests |
## SSH Config Inheritance
@@ -95,12 +95,35 @@ Dosh also calls `ssh -G <alias>` to infer the UDP target host when no `dosh_host
configured. To write explicit Dosh host entries from SSH aliases:
```bash
dosh import-ssh palav homelab
dosh import-ssh homelab
```
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
OpenSSH config parser.
## Optional Command Extensions
Dosh can expose companion tools without taking a dependency on them. Command
extensions live in client or host config and expand only the first trailing word:
```toml
[extensions.tm]
command = "tm {args}"
description = "Open the server-side tmux dashboard"
```
With that config, `dosh homelab tm` runs `tm` in the remote Dosh shell and
`dosh homelab tm dosh` runs `tm 'dosh'`. Removing the table removes the integration.
Host config can override a global extension, or disable it:
```toml
[homelab.extensions.tm]
command = "/opt/tm/bin/tm {args}"
[other-host.extensions.tm]
disabled = true
```
## Forwarding (Implemented)
SSH forwarding cannot be copied by keeping the bootstrap SSH connection open,
@@ -120,6 +143,8 @@ Implemented:
`StreamEof`/`StreamClose` packet types.
- Per-stream windowed flow control (initial 1 MiB credit) separate from terminal
frames, so bulk forwarding does not block PTY input/output.
- Ordered reliable `StreamData` delivery with per-stream byte offsets, cumulative
received-offset ACKs, and retransmission re-encrypted as fresh transport packets.
- Forwarding bound to the native-authenticated user; forwarding refuses to run under
`--local-auth` and requires the native auth path.
- Server-side policy enforcement: `allow_tcp_forwarding`, `allow_remote_forwarding`,
@@ -130,8 +155,9 @@ Implemented:
Still open before claiming forwarding parity:
- A dedicated dropped/reordered/replayed-UDP forwarding test suite.
- Load tests proving large forwarded transfers add no visible terminal input lag.
- More real-host load soak. The integration suite already covers terminal-priority
behavior while a local forward is under blocked-stream pressure, plus hostile
replay/reorder and server-to-client stream retransmission after UDP loss.
## Native v1 Verification Checklist Status
@@ -145,42 +171,50 @@ from code; in progress = partially implemented; pending = not yet implemented.
| Known host-key mismatch hard fails | done | `KnownHostStatus::Mismatch` aborts; `trust_host` refuses overwrite without `--replace`. |
| Native Ed25519 auth via ssh-agent | done | `src/ssh_agent.rs` signs the user-auth transcript. |
| Native Ed25519 auth via encrypted key prompt | done | `load_ed25519_identity_with_passphrase` decrypts OpenSSH keys. |
| Native ECDSA P-256 auth | done | ssh-agent and OpenSSH identity-file paths are wired; `native_user_auth_accepts_ecdsa_p256_private_key` verifies the direct key path. |
| Native RSA-SHA2 auth | done | ssh-agent requests `rsa-sha2-512`; direct OpenSSH RSA identities sign with `rsa-sha2-512`; legacy SHA-1 `ssh-rsa` signatures are rejected. |
| Removed authorized key can no longer authenticate | done | Covered by `native_user_auth_accepts_authorized_key_and_rejects_removed_key`. |
| Unsupported authorized-key options fail closed | done | `ensure_native_allowed` bails on any unsupported option. |
| Replayed handshake packets rejected | done | Handshake is transcript-bound and signature-verified; pending entries TTL-evicted. |
| Replayed transport packets rejected | done | `ReplayWindow` (128-wide) over per-direction counter. |
| Stale encrypted packets after reconnect ignored, not fatal | done | `session_key_id` mismatch drops the packet instead of erroring fatally. |
| Client IP/port change preserves session | done | Server matches by `ClientId`/session key id, updates endpoint. |
| Native cold auth beats cold `ssh host true` | pending | Benchmark gate not yet run for native cold path (Track C / `BENCHMARKS.md`). |
| Cached attach near network RTT | pending | Same benchmark dependency. |
| `-L` works without delaying terminal input | in progress | Implemented with per-stream window; load proof pending. |
| Native cold auth beats cold `ssh host true` | done | Docker gate runs `dosh-bench --cold-native` after one-time `dosh trust` and requires Dosh mean <= SSH mean. |
| Cached attach near network RTT | done | Local loopback samples are ~3 ms; Docker cached gate is under 25 ms. See `docs/BENCHMARKS.md`. |
| `-L` works without delaying terminal input | done | Per-stream windowing plus `native_local_forward_bulk_load_does_not_delay_interactive_terminal` and blocked-stream priority regression. |
| `-R` enforces bind and permission policy | done | `remote_bind_allowed` + `start_remote_forwards` policy checks. |
| `-N -L` does not allocate a PTY | done | `forward-only` mode skips PTY allocation. |
| `-f -N -L` backgrounds only after listener readiness | done | `spawn_background_forwarder` waits for a readiness token. |
| Multiple forwards in one command | done | Forward lists parsed and started together. |
| `dosh doctor` identifies UDP-blocked/auth-denied/mismatch/forwarding-denied | done | `run_doctor_command` reports each state. |
| Closing laptop 30+ min does not kill session | in progress | Long `client_timeout_secs` + resume; 30-min soak evidence pending. |
| Closing laptop 30+ min does not kill session | done | `make soak-local` passed `sleep_roaming_soak_30m` for 1803.54s locally. Keep rerunning before tagged releases. |
| Three concurrent terminals independent unless named | done | Generated session names per attach; named sessions shared on purpose. |
| Large forwarded transfers add no visible input lag | in progress | Per-stream flow control exists; load test pending. |
| Fuzz targets run in CI | pending | No `fuzz/` dir; CI runs fmt/test/build/bench only. |
| Large forwarded transfers add no visible input lag | done | Covered by the local-forward bulk-load integration test; still needs real-host soak before launch claims. |
| Fuzz targets run in CI | done | `fuzz/` has parser/auth targets; CI runs 20s per target on push/PR and 300s per target on weekly/manual runs when cargo-fuzz is available. |
| Threat model updated with accepted residual risks | done | `docs/THREAT_MODEL.md`. |
Additional security hardening tracked outside the section 16 list:
- Full per-IP token-bucket rate limiting: in progress (another track). Today only
handshake-map eviction and a static `rate_limit_remaining` hint exist.
- Protocol VERSION negotiation: in progress. Single version, fail-closed reject; no
multi-version negotiation yet.
- ECDSA P-256 / SHA-2 RSA user keys: pending. Ed25519 only today.
- Full per-IP token-bucket rate limiting: implemented for native auth and covered by
unit/integration tests. It still needs real-host tuning under abusive traffic.
- Protocol VERSION handling: v1 policy is single-version, fail-closed reject at the
packet/header and native-handshake layers. See `docs/PROTOCOL_VERSIONING.md`.
- ECDSA P-256 / SHA-2 RSA user keys: implemented for ssh-agent and OpenSSH
identity-file native auth. RSA is compatibility-only and uses SHA-2 signatures;
legacy SHA-1 `ssh-rsa` signatures are not accepted.
## Before Public Launch
- Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green.
- Add a non-destructive disconnect indicator.
- Run scripted TUI tests for alternate-screen apps, arrow keys, resize, mouse mode,
bracketed paste, and terminal cleanup.
- Run `make soak-local` before each tagged release to refresh 30-minute sleep/roaming evidence.
- Run `make fuzz-deep` before launch, or use the scheduled/manual CI fuzz pass, and
publish the target durations.
- Keep scripted TUI tests green and add a broader app matrix for real alternate-screen
tools.
- Publish benchmark output with raw samples, not just averages.
- Mark prediction as experimental until it has a real framebuffer model.
- Land full per-IP token-bucket auth rate limiting and wire fuzz targets into CI.
- Complete the native-v1 verification checklist above and an external security review
before making any "native SSH replacement" claim (`NATIVE_V1_SPEC.md` section 17).
- Stress-test `persist_sessions = true` before making restart-survivable holders the
default.
- Tune the native auth token bucket under abusive real-host traffic.
- Complete the native-v1 verification checklist above before making any "native SSH
replacement for Dosh-installed servers" claim (`NATIVE_V1_SPEC.md` section 17).
+127
View File
@@ -0,0 +1,127 @@
# Dosh Release Evidence - 2026-06-20
Runtime code benchmarked: `b44ff8e Improve release and benchmark tooling`
Release docs/default cleanup after that benchmark removed personal host aliases from
public examples and test fixtures; it did not change release runtime paths.
Environment:
- Host: local homelab runner
- Benchmark harness: `make bench-docker-mosh`
- Container SSH server: OpenSSH 9.6p1 on Ubuntu
- Same container, key, DNS, loopback, and Dosh server path for all Docker samples
## Release Artifacts
Generated locally with:
```bash
sh scripts/package-release.sh
```
Artifacts:
| artifact | sha256 |
| --- | --- |
| `dosh-linux-x86_64.tar.gz` | `9f586852a506cca3caf618743e12967265879ee10f5294d179cdcd668db1e808` |
| `dosh-0.1.0-linux-x86_64.tar.gz` | `9f586852a506cca3caf618743e12967265879ee10f5294d179cdcd668db1e808` |
| `dosh-macos-aarch64.tar.gz` | `dd1151aa2b40be37288dc19a07521b0ec72d1106439eb484cdff01cca3b891c6` |
| `dosh-0.1.0-macos-aarch64.tar.gz` | `dd1151aa2b40be37288dc19a07521b0ec72d1106439eb484cdff01cca3b891c6` |
The installer verifies `<artifact>.sha256` when the sidecar is published.
## Docker SSH/Mosh Benchmark
Command:
```bash
make bench-docker-mosh
```
### Cold SSH Bootstrap
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 3 | 216.58 | 220.27 | 224.64 | 220.66 | 225.12 |
| `dosh_attach_ms` | 3 | 232.37 | 235.56 | 239.13 | 235.82 | 239.53 |
Raw samples:
- `ssh_true_ms`: [216.58, 220.27, 225.12]
- `dosh_attach_ms`: [235.56, 232.37, 239.53]
Gate: `dosh_attach_ms avg 235.82ms <= ssh avg 220.66ms + 500.00ms`
### ControlMaster SSH Bootstrap
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 3 | 7.29 | 7.50 | 47.51 | 22.25 | 51.95 |
| `dosh_attach_ms` | 3 | 16.62 | 17.01 | 17.77 | 17.16 | 17.85 |
Raw samples:
- `ssh_true_ms`: [51.95, 7.29, 7.50]
- `dosh_attach_ms`: [17.01, 16.62, 17.85]
Gate: `dosh_attach_ms avg 17.16ms <= ssh avg 22.25ms + 500.00ms`
### Native Cold Auth
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 5 | 215.72 | 222.68 | 227.05 | 221.32 | 227.82 |
| `dosh_cold_native_ms` | 5 | 8.63 | 8.84 | 9.16 | 8.88 | 9.23 |
Raw samples:
- `ssh_true_ms`: [216.40, 223.99, 227.82, 222.68, 215.72]
- `dosh_cold_native_ms`: [8.84, 8.63, 9.23, 8.88, 8.84]
Gate: `dosh_cold_native_ms avg 8.88ms <= ssh avg 221.32ms + 0.00ms`
### Cached Attach
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `dosh_cached_attach_ms` | 10 | 8.07 | 8.96 | 9.83 | 8.98 | 9.84 |
Raw samples:
- `dosh_cached_attach_ms`: [8.07, 9.09, 9.02, 8.66, 8.71, 8.90, 8.45, 9.84, 9.25, 9.82]
Gate: `dosh_cached_attach_ms avg 8.98ms <= 25.00ms`
### Mosh Comparison
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 3 | 216.48 | 218.93 | 219.49 | 218.32 | 219.55 |
| `dosh_attach_ms` | 3 | 229.59 | 233.00 | 233.31 | 231.98 | 233.35 |
| `mosh_start_true_ms` | 3 | 527.13 | 563.34 | 573.09 | 554.89 | 574.18 |
Raw samples:
- `ssh_true_ms`: [219.55, 216.48, 218.93]
- `dosh_attach_ms`: [233.00, 229.59, 233.35]
- `mosh_start_true_ms`: [563.34, 527.13, 574.18]
## Soak And Fuzz Evidence
- `cargo test`: `153 passed, 1 ignored`
- `make soak-local`: `sleep_roaming_soak_30m ... ok`, finished in `1803.54s`
- `make fuzz-smoke`: passed all configured targets for 20s each:
`packet_decode`, `from_body`, `authorized_keys`, `known_hosts`,
`handshake_structs`, `attach_ticket`
## Public Claim Boundary
This evidence supports:
- Dosh cached attach and native auth are much faster than cold SSH/Mosh startup in
this benchmark environment.
- Dosh remains mosh-shaped, not mosh-compatible.
- Dosh does not claim generic SSH replacement compatibility.
- Native v1 security still needs external review before making hard public security
claims.
+46 -37
View File
@@ -72,13 +72,13 @@ compromised endpoint or a malicious authorized peer.
| Property | SSH | Dosh native v1 | Notes |
| --- | --- | --- | --- |
| Server authentication before trusting session data | yes | yes | Host key signs the handshake transcript; client verifies before sending user auth or accepting terminal bytes. |
| User authentication by private-key possession | yes | yes | Ed25519 via ssh-agent or encrypted OpenSSH key; signature binds the full transcript. |
| User authentication by private-key possession | yes | yes | Ed25519, ECDSA P-256, and RSA-SHA2 via ssh-agent or OpenSSH key; signature binds the full transcript. |
| Forward secrecy | yes | yes | Ephemeral X25519 per connection; long-term host/user keys never derive the traffic key. |
| AEAD on every post-handshake packet | yes | yes | ChaCha20-Poly1305 with per-direction, per-sequence nonces. |
| Replay protection | yes | yes | Sliding replay window over the AEAD packet counter, plus transcript-bound handshake. |
| Host-key pinning with explicit first use | TOFU, weakly tied to transport | yes, with explicit policy | Default refuses unknown host keys; TOFU only when `trust_on_first_use` is set; mismatch hard-fails and never auto-replaces. |
| No plaintext terminal bytes after handshake | yes | yes | All `Frame`/`Input`/stream packets are AEAD-sealed. |
| No custom cryptographic primitives | yes | yes | Standard X25519/HKDF-SHA256/ChaCha20-Poly1305/Ed25519 crates only. |
| No custom cryptographic primitives | yes | yes | Standard X25519/HKDF-SHA256/ChaCha20-Poly1305/signature crates only. |
| Fail-closed downgrade behavior | yes | yes | Native auth failure surfaces an explicit error and SSH fallback is explicit; it never silently drops to an unauthenticated mode. |
| Fast resumption without re-auth | ControlMaster only | yes, native | Cached session/ticket attach skips a fresh round of public-key proof; this is a deliberate speed/security trade discussed in section 6. |
@@ -94,8 +94,9 @@ compromised endpoint or a malicious authorized peer.
- **Explicit, file-pinned host trust.** Host trust is a first-class, inspectable
known-hosts entry with source provenance (`tofu`/`ssh`/`manual`) and a hard-fail
mismatch path, rather than the looser default TOFU behavior most SSH clients ship.
- **Modern primitives only.** Ed25519 and X25519 by default; no DSA, no SHA-1
signatures, no CBC-and-MAC constructions.
- **Modern primitives only.** Ed25519 and X25519 by default; ECDSA P-256 and
RSA-SHA2 are accepted for SSH-key compatibility; no DSA, no SHA-1 signatures, no
CBC-and-MAC constructions.
Dosh does **not** claim generic SSH compatibility and must not be described as an
SSH-protocol implementation.
@@ -114,9 +115,12 @@ These reflect the code in `src/crypto.rs`, `src/native.rs`, `src/auth.rs`, and
unique nonce per `(key, direction, sequence)`. AES-GCM is reserved for later and is
not selectable today.
- **Host-key signatures:** Ed25519 (`ed25519-dalek`) over the handshake transcript.
- **User-auth signatures:** Ed25519, produced either by ssh-agent over a Unix socket
(`src/ssh_agent.rs`) or from an encrypted/plaintext OpenSSH private key
(`ssh-key`). The signature covers the user-auth transcript described above.
- **User-auth signatures:** Ed25519, ECDSA P-256, and RSA-SHA2, produced either by
ssh-agent over a Unix socket (`src/ssh_agent.rs`) or from an encrypted/plaintext
OpenSSH private key (`ssh-key`). The signature covers the user-auth transcript
described above. RSA public keys are matched as `ssh-rsa` authorized keys, but
native signatures must be `rsa-sha2-256` or `rsa-sha2-512`; legacy SHA-1
`ssh-rsa` signatures are rejected.
- **Bootstrap auth (SSH fallback path):** HMAC-SHA256 attach tokens and HKDF-SHA256
derived session keys, with attach tickets sealed under an HKDF-derived
ticket key. Token comparison is constant-time.
@@ -179,32 +183,37 @@ be evaluated honestly. Items here are *not* yet "green".
### Known gaps / work in progress (must close before the public claim)
- **Per-IP rate limiting is partial.** The server evicts half-finished native
handshakes on a TTL so a flood of `ClientHello` packets cannot grow the pending map
without bound, and it reports a static `rate_limit_remaining` hint in `ServerHello`.
A full per-source token-bucket limiter is **in progress on another track** and is
not yet enforced. Until it lands, sustained auth flooding is mitigated only by the
handshake-eviction TTL and OS-level limits.
- **Protocol VERSION negotiation is being hardened.** The wire format pins a single
protocol version: the packet header rejects any non-matching `VERSION` byte and the
native handshake rejects any non-matching `protocol_version`. There is no
multi-version negotiation yet, so cross-version interop and downgrade-resistance for
future versions are still being designed. Today's behavior is fail-closed (reject),
not silent downgrade.
- **Fuzzing is not yet wired into CI.** CI currently runs format, tests, release
build, and the Docker SSH benchmark gate. Fuzz targets for packet parsing,
authorized-key parsing, known-host parsing, and handshake state (spec milestone 5)
are **being wired in** and are not yet running in CI.
- **User-key algorithm coverage is Ed25519-only today.** The spec permits ECDSA
P-256 and (compatibility-only, SHA-2) RSA, but native auth currently accepts and
produces `ssh-ed25519` only. ECDSA/RSA support is pending. This is a parity gap, not
a weakening of what *is* supported.
- **Hostile-network and long-soak integration tests are partial.** Roaming,
retransmit, resize, and multi-client tests exist; a dedicated adversarial
drop/reorder/replay suite and 30-minute-sleep soak (spec section 16) are still being
expanded.
- **No external security review yet.** The spec's milestone 5 requires an external
review checklist before public security claims. That review has not happened.
- **Native-auth rate limiting needs tuning, not first implementation.** The server
enforces a per-source token bucket before expensive native-auth work, evicts
half-finished handshakes on a TTL, and reports remaining capacity in
`ServerHello`. It is covered by unit/integration tests, but still needs abusive
real-host tuning before public hardening claims.
- **Protocol VERSION compatibility is intentionally single-version for v1.** The
packet header rejects any non-matching `VERSION` byte and the native handshake
rejects any non-matching `protocol_version`; peers get a named upgrade error, not
a silent downgrade. `docs/PROTOCOL_VERSIONING.md` defines the bump rules and the
post-v1 requirements for any future multi-version negotiation.
- **Deep fuzzing still needs launch evidence.** CI runs parser/auth fuzz targets for
20 seconds per target on push/PR and 300 seconds per target on weekly/manual runs
when nightly/cargo-fuzz is available. That catches obvious panics and parser
robustness regressions; public security claims should cite a completed
`make fuzz-deep` or scheduled CI run with durations.
- **User-key algorithm coverage now matches the v1 target.** Ed25519, ECDSA P-256,
and compatibility RSA-SHA2 native auth are implemented for ssh-agent and OpenSSH
identity files. RSA remains compatibility-only and deliberately rejects legacy
SHA-1 `ssh-rsa` signatures.
- **Forwarded stream data uses ordered retransmission.** Streams carry byte offsets,
cumulative received-offset ACKs, and retransmit unacknowledged chunks as fresh
encrypted transport packets. Hostile-network tests cover replay/reorder and
server-to-client stream recovery after deliberate UDP loss; broader real-host load
soak remains launch evidence.
- **Long-soak evidence is a launch gate.** Roaming, retransmit, resize, and
multi-client tests exist, and `sleep_roaming_soak_30m` / `make soak-local` provide
the 30-minute sleep/roaming gate. That gate should be run and published before
public security/reliability claims.
- **No third-party audit claim.** Dosh maintains a public threat model and hardening
checklist, but should not market itself as externally audited unless that actually
happens.
### Auth posture
@@ -226,7 +235,7 @@ cached attach, and Mosh startup.
Current status: this threat model is published (this document). The verification
checklist is **not yet fully green** — see the item-by-item status table in
`docs/PUBLIC_READINESS.md` ("Native v1 verification checklist status") and the known
gaps in section 6 above. Until the gaps close and an external review is complete,
Dosh's defensible public claim remains **fast, encrypted native attach/reconnect with
SSH-equivalent transport security and SSH bootstrap fallback** — not a fully verified,
externally reviewed SSH replacement.
gaps in section 6 above. Until the gaps close, Dosh's defensible public claim remains
**fast, encrypted native attach/reconnect and forwarding with SSH bootstrap
fallback** on Dosh-installed servers, not generic SSH compatibility or a third-party
audited security product.
+10 -2
View File
@@ -35,6 +35,12 @@ cargo-fuzz requires a nightly toolchain (it builds with `-Z sanitizer=address`).
From the repository root:
```sh
# Run all targets for a short smoke pass
make fuzz-smoke
# Run all targets for the pre-launch deep pass (default: 300s each)
make fuzz-deep
# List targets
cargo +nightly fuzz list --fuzz-dir fuzz
@@ -55,5 +61,7 @@ cargo +nightly fuzz run packet_decode -- -max_total_time=10
## CI
`.github/workflows/ci.yml` has a `fuzz-smoke` job that installs nightly +
cargo-fuzz and runs each target briefly (`-max_total_time`). The job is tolerant
if the toolchain/tooling is unavailable so it never blocks the main test gate.
cargo-fuzz and runs each target. Push/PR runs use a short 20-second-per-target
smoke pass. Weekly scheduled and manual workflow runs use a 300-second-per-target
deep pass by default. The job is tolerant if the toolchain/tooling is unavailable
so it never blocks the main test gate.
+138 -14
View File
@@ -6,6 +6,12 @@ param(
[string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }),
[int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }),
[string]$Prefix = $(if ($env:PREFIX) { $env:PREFIX } else { Join-Path $HOME ".local" }),
[switch]$UsePrebuilt = $(-not $env:DOSH_USE_PREBUILT -or $env:DOSH_USE_PREBUILT -ne "0"),
[string]$BinaryUrl = $env:DOSH_BINARY_URL,
[string]$BinaryBase = $env:DOSH_BINARY_BASE,
[string]$BinaryName = $env:DOSH_BINARY_NAME,
[string]$BinaryVersion = $(if ($env:DOSH_BINARY_VERSION) { $env:DOSH_BINARY_VERSION } else { "latest" }),
[switch]$BinaryRequired = $($env:DOSH_BINARY_REQUIRED -and $env:DOSH_BINARY_REQUIRED -ne "0"),
[switch]$ForceConfig
)
@@ -17,8 +23,110 @@ function Require-Command($Name) {
}
}
Require-Command cargo
function Normalize-Arch {
switch ($env:PROCESSOR_ARCHITECTURE) {
"AMD64" { "x86_64"; break }
"ARM64" { "aarch64"; break }
default { $env:PROCESSOR_ARCHITECTURE.ToLowerInvariant() }
}
}
function Release-ArtifactName {
if ($BinaryName) {
return $BinaryName
}
"dosh-windows-$(Normalize-Arch).zip"
}
function Repo-WebBase($Value) {
if (-not $Value) {
return $null
}
$base = $Value.TrimEnd("/")
if ($base.EndsWith(".git")) {
$base = $base.Substring(0, $base.Length - 4)
}
if ($base -notmatch "^https?://") {
return $null
}
$base
}
function Release-DownloadUrl {
if ($BinaryUrl) {
return $BinaryUrl
}
$name = Release-ArtifactName
if ($BinaryBase) {
return "$($BinaryBase.TrimEnd('/'))/$name"
}
$web = Repo-WebBase $Repo
if (-not $web) {
return $null
}
if ($BinaryVersion -eq "latest") {
return "$web/releases/latest/download/$name"
}
"$web/releases/download/$BinaryVersion/$name"
}
function Verify-ArchiveChecksum($Url, $Archive) {
$checksumPath = "$Archive.sha256"
try {
Invoke-WebRequest -UseBasicParsing -Uri "$Url.sha256" -OutFile $checksumPath
}
catch {
Write-Warning "prebuilt checksum unavailable; continuing without sidecar verification"
return
}
$expected = ((Get-Content $checksumPath -Raw).Trim() -split "\s+")[0].ToLowerInvariant()
$actual = (Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant()
if ($expected -ne $actual) {
throw "prebuilt checksum mismatch for $Url"
}
}
$bindir = Join-Path $Prefix "bin"
$configDir = Join-Path $HOME ".config\dosh"
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
function Install-Prebuilt {
$url = Release-DownloadUrl
if (-not $url) {
return $false
}
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-bin-" + [guid]::NewGuid())
$zip = Join-Path $tmp (Release-ArtifactName)
$extract = Join-Path $tmp "extract"
try {
New-Item -ItemType Directory -Force -Path $tmp, $extract | Out-Null
Write-Host "Trying Dosh prebuilt $(Release-ArtifactName)"
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $zip
Verify-ArchiveChecksum $url $zip
Expand-Archive -Force -Path $zip -DestinationPath $extract
foreach ($bin in @("dosh-client.exe", "dosh-bench.exe")) {
$found = Get-ChildItem -Path $extract -Recurse -File -Filter $bin | Select-Object -First 1
if (-not $found) {
throw "prebuilt archive missing $bin"
}
Copy-Item $found.FullName (Join-Path $bindir $bin) -Force
}
Copy-Item (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe") -Force
return $true
}
catch {
Write-Warning "prebuilt install failed: $_"
return $false
}
finally {
if (Test-Path $tmp) {
Remove-Item -Recurse -Force $tmp
}
}
}
function Install-FromSource {
Require-Command cargo
$tmp = $null
if (Test-Path "Cargo.toml") {
$src = (Get-Location).Path
@@ -35,14 +143,30 @@ if (Test-Path "Cargo.toml") {
try {
Push-Location $src
cargo build --release --bin dosh-client --bin dosh-bench
$bindir = Join-Path $Prefix "bin"
$configDir = Join-Path $HOME ".config\dosh"
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force
}
finally {
Pop-Location
if ($tmp) {
Remove-Item -Recurse -Force $tmp
}
}
}
if ($UsePrebuilt) {
$ok = Install-Prebuilt
if (-not $ok) {
if ($BinaryRequired) {
throw "prebuilt install failed and DOSH_BINARY_REQUIRED=1"
}
Write-Host "Falling back to source build"
Install-FromSource
}
} else {
Install-FromSource
}
$clientConfig = Join-Path $configDir "client.toml"
if ($ForceConfig -or -not (Test-Path $clientConfig)) {
@@ -60,6 +184,8 @@ dosh_port = $Port
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
"@ | Set-Content -NoNewline -Encoding utf8 $clientConfig
@@ -74,14 +200,12 @@ credential_cache = "~/.local/share/dosh/credentials"
Write-Host "Configured UDP port $Port"
Write-Host ""
$displayServer = if ($Server) { $Server } else { "user@host" }
Write-Host "Client command:"
Write-Host "Client commands:"
Write-Host " $bindir\dosh.exe $displayServer"
Write-Host " $bindir\dosh.exe setup <ssh-alias>"
Write-Host " $bindir\dosh.exe update --check"
Write-Host ""
Write-Host "Client config:"
Write-Host " $configDir\client.toml"
Write-Host ""
Write-Host "Open a new terminal for PATH changes to apply."
}
finally {
Pop-Location
if ($tmp) {
Remove-Item -Recurse -Force $tmp
}
}
+236 -19
View File
@@ -12,6 +12,12 @@ start_server=1
force_config=0
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
quiet="${DOSH_UPDATE_QUIET:-0}"
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
binary_url="${DOSH_BINARY_URL:-}"
binary_base="${DOSH_BINARY_BASE:-}"
binary_name="${DOSH_BINARY_NAME:-}"
binary_version="${DOSH_BINARY_VERSION:-latest}"
binary_required="${DOSH_BINARY_REQUIRED:-0}"
usage() {
cat <<'EOF'
@@ -32,6 +38,18 @@ Options:
Environment alternatives:
DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX,
DOSH_UPDATE_CACHE
DOSH_USE_PREBUILT=0
Build from source instead of trying a release tarball first
DOSH_BINARY_URL URL
Exact release tarball URL to install
DOSH_BINARY_BASE URL
Release download base; defaults to REPO/releases/latest/download
DOSH_BINARY_NAME NAME
Release tarball name; defaults to dosh-OS-ARCH.tar.gz
DOSH_BINARY_VERSION TAG
Release tag when deriving DOSH_BINARY_BASE; default latest
DOSH_BINARY_REQUIRED=1
Fail instead of falling back to source when binary install fails
EOF
}
@@ -112,8 +130,6 @@ ensure_cargo() {
. "$HOME/.cargo/env"
}
ensure_cargo
cleanup() {
if [ -n "${tmpdir:-}" ]; then
rm -rf "$tmpdir"
@@ -121,6 +137,170 @@ cleanup() {
}
trap cleanup EXIT INT TERM
bindir="$prefix/bin"
config_dir="$HOME/.config/dosh"
data_dir="$HOME/.local/share/dosh"
systemd_user_dir="$HOME/.config/systemd/user"
src_dir=""
mkdir -p "$bindir" "$config_dir" "$data_dir"
normalize_os() {
case "$(uname -s)" in
Darwin) printf '%s\n' macos ;;
Linux) printf '%s\n' linux ;;
FreeBSD) printf '%s\n' freebsd ;;
*) uname -s | tr '[:upper:]' '[:lower:]' ;;
esac
}
normalize_arch() {
case "$(uname -m)" in
x86_64|amd64) printf '%s\n' x86_64 ;;
arm64|aarch64) printf '%s\n' aarch64 ;;
armv7l) printf '%s\n' armv7 ;;
*) uname -m | tr '[:upper:]' '[:lower:]' ;;
esac
}
repo_web_base() {
repo_base="$1"
case "$repo_base" in
http://*|https://*)
repo_base="${repo_base%.git}"
printf '%s\n' "${repo_base%/}"
;;
*)
return 1
;;
esac
}
release_artifact_name() {
if [ -n "$binary_name" ]; then
printf '%s\n' "$binary_name"
else
printf 'dosh-%s-%s.tar.gz\n' "$(normalize_os)" "$(normalize_arch)"
fi
}
release_download_url() {
if [ -n "$binary_url" ]; then
printf '%s\n' "$binary_url"
return 0
fi
if [ -n "$binary_base" ]; then
printf '%s/%s\n' "${binary_base%/}" "$(release_artifact_name)"
return 0
fi
if [ -z "$repo" ]; then
return 1
fi
web_base="$(repo_web_base "$repo")" || return 1
if [ "$binary_version" = "latest" ]; then
printf '%s/releases/latest/download/%s\n' "$web_base" "$(release_artifact_name)"
else
printf '%s/releases/download/%s/%s\n' "$web_base" "$binary_version" "$(release_artifact_name)"
fi
}
release_latest_tag_download_url() {
if [ -n "$binary_url" ] || [ -n "$binary_base" ] || [ "$binary_version" != "latest" ] || [ -z "$repo" ]; then
return 1
fi
web_base="$(repo_web_base "$repo")" || return 1
latest_url="$(curl -fsSL -o /dev/null -w '%{url_effective}' "$web_base/releases/latest" 2>/dev/null || true)"
case "$latest_url" in
"$web_base"/releases/tag/*)
tag="${latest_url##"$web_base"/releases/tag/}"
[ -n "$tag" ] || return 1
printf '%s/releases/download/%s/%s\n' "$web_base" "$tag" "$(release_artifact_name)"
;;
*)
return 1
;;
esac
}
find_extracted_binary() {
find "$1" -type f -name "$2" 2>/dev/null | sed -n '1p'
}
install_extracted_binary() {
found="$(find_extracted_binary "$1" "$2")"
if [ -z "$found" ]; then
echo "prebuilt archive missing $2" >&2
return 1
fi
install -m 0755 "$found" "$3"
}
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
return 1
fi
}
verify_archive_checksum() {
url="$1"
archive="$2"
checksum_file="$3"
if ! curl -fsL "$url.sha256" -o "$checksum_file"; then
echo "prebuilt checksum unavailable; continuing without sidecar verification" >&2
return 0
fi
expected="$(awk '{print $1}' "$checksum_file" | sed -n '1p')"
actual="$(sha256_file "$archive")" || {
echo "sha256sum/shasum not found for checksum verification" >&2
return 1
}
if [ "$expected" != "$actual" ]; then
echo "prebuilt checksum mismatch for $url" >&2
return 1
fi
}
try_install_prebuilt() {
download_url="$(release_download_url)" || return 1
tmpdir="$(mktemp -d)"
archive="$tmpdir/$(release_artifact_name)"
checksum_file="$archive.sha256"
[ "$quiet" = "1" ] && echo "Trying Dosh prebuilt $(release_artifact_name)"
need curl
need tar
if ! curl -fsL "$download_url" -o "$archive" 2>/dev/null; then
alt_download_url="$(release_latest_tag_download_url || true)"
if [ -z "$alt_download_url" ] || ! curl -fsL "$alt_download_url" -o "$archive"; then
echo "prebuilt unavailable: $download_url" >&2
[ -z "$alt_download_url" ] || echo "prebuilt unavailable: $alt_download_url" >&2
return 1
fi
download_url="$alt_download_url"
fi
verify_archive_checksum "$download_url" "$archive" "$checksum_file" || return 1
mkdir -p "$tmpdir/extract"
if ! tar -xzf "$archive" -C "$tmpdir/extract"; then
echo "prebuilt archive could not be extracted: $download_url" >&2
return 1
fi
install_extracted_binary "$tmpdir/extract" dosh-client "$bindir/dosh-client" || return 1
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install_extracted_binary "$tmpdir/extract" dosh-server "$bindir/dosh-server" || return 1
install_extracted_binary "$tmpdir/extract" dosh-auth "$bindir/dosh-auth" || return 1
fi
if found_bench="$(find_extracted_binary "$tmpdir/extract" dosh-bench)" && [ -n "$found_bench" ]; then
install -m 0755 "$found_bench" "$bindir/dosh-bench"
fi
return 0
}
install_from_source() {
ensure_cargo
if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then
src_dir="$(pwd)"
else
@@ -173,13 +353,6 @@ else
fi
fi
bindir="$prefix/bin"
config_dir="$HOME/.config/dosh"
data_dir="$HOME/.local/share/dosh"
systemd_user_dir="$HOME/.config/systemd/user"
mkdir -p "$bindir" "$config_dir" "$data_dir"
install -m 0755 target/release/dosh-client "$bindir/dosh-client"
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
@@ -189,6 +362,43 @@ fi
if [ -f target/release/dosh-bench ]; then
install -m 0755 target/release/dosh-bench "$bindir/dosh-bench"
fi
}
write_systemd_service() {
if [ -n "$src_dir" ] && [ -f "$src_dir/packaging/systemd/dosh-server.service" ]; then
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \
"$src_dir/packaging/systemd/dosh-server.service" >"$systemd_user_dir/dosh-server.service"
else
cat >"$systemd_user_dir/dosh-server.service" <<EOF
[Unit]
Description=Dosh server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$bindir/dosh-server serve
Restart=on-failure
RestartSec=1
KillMode=process
[Install]
WantedBy=default.target
EOF
fi
}
if [ "$use_prebuilt" != "0" ]; then
if ! try_install_prebuilt; then
if [ "$binary_required" = "1" ]; then
exit 1
fi
[ "$quiet" = "1" ] && echo "Falling back to source build"
install_from_source
fi
else
install_from_source
fi
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
server_config="$config_dir/server.toml"
@@ -226,8 +436,7 @@ EOF
if command -v systemctl >/dev/null 2>&1; then
mkdir -p "$systemd_user_dir"
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \
packaging/systemd/dosh-server.service >"$systemd_user_dir/dosh-server.service"
write_systemd_service
if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then
systemctl --user enable --now dosh-server.service
fi
@@ -272,7 +481,8 @@ dosh_port = $port
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
predict = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
auth_preference = "native,ssh"
@@ -295,18 +505,18 @@ EOF
fi
cat >"$hosts_config" <<EOF
# Example:
# [palav]
# ssh = "palav"
# dosh_host = "palav.dev"
# [homelab]
# ssh = "homelab"
# dosh_host = "server.example.com"
# port = 50000
# default_command = "tm"
# predict = false
# predict = true
[default]
ssh = "$default_server"
dosh_host = "$host_udp"
port = $port
predict = false
predict = true
EOF
fi
fi
@@ -317,10 +527,17 @@ Configured UDP port $port
EOF
if [ "$role" = "client" ] || [ "$role" = "both" ]; then
next_server="${server:-user@host}"
cat <<EOF
Client command:
$bindir/dosh ${server:-user@host}
Client commands:
$bindir/dosh $next_server
$bindir/dosh setup <ssh-alias>
$bindir/dosh update --check
Client config:
$config_dir/client.toml
$config_dir/hosts.toml
EOF
fi
+1 -1
View File
@@ -171,7 +171,7 @@ dosh doctor homelab # host resolution, trust state, UDP reachability, server
dosh sessions homelab # list live sessions
dosh trust homelab # fetch + pin the Dosh host key (via SSH fallback)
dosh trust --remove homelab
dosh import-ssh palav homelab # write a hosts.toml entry from an SSH alias
dosh import-ssh homelab # write a hosts.toml entry from an SSH alias
dosh update # update the installed client
```
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -eu
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
out="${DOSH_BENCH_REPORT:-target/dosh-bench/report.md}"
iters="${DOSH_BENCH_ITERS:-10}"
server="${DOSH_BENCH_SERVER:-${1:-local}}"
label="${DOSH_BENCH_LABEL:-$(uname -s) $(uname -m)}"
cargo build --release >/dev/null
mkdir -p "$(dirname "$out")"
exec target/release/dosh-bench \
--server "$server" \
--iterations "$iters" \
--label "$label" \
--markdown \
--output "$out" \
${DOSH_BENCH_ARGS:-}
+27 -1
View File
@@ -31,7 +31,7 @@ for _ in 1 2 3 4 5; do
done
ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts"
mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-cached" "$workdir/home-mosh"
mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-native" "$workdir/home-cached" "$workdir/home-mosh"
if ! HOME="$workdir/home" target/release/dosh-bench \
--server bench@127.0.0.1 \
@@ -64,6 +64,32 @@ if ! HOME="$workdir/home-controlmaster" target/release/dosh-bench \
exit 1
fi
if ! HOME="$workdir/home-native" target/release/dosh-client \
--ssh-port "$ssh_port" \
--ssh-key "$workdir/id_ed25519" \
--ssh-known-hosts "$workdir/known_hosts" \
--ssh-auth-command /usr/local/bin/dosh-auth \
trust bench@127.0.0.1 >/dev/null; then
docker logs "$container_id" || true
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
exit 1
fi
if ! HOME="$workdir/home-native" target/release/dosh-bench \
--server bench@127.0.0.1 \
--ssh-port "$ssh_port" \
--dosh-port "$dosh_port" \
--dosh-host 127.0.0.1 \
--ssh-key "$workdir/id_ed25519" \
--ssh-known-hosts "$workdir/known_hosts" \
--iterations 5 \
--cold-native \
--assert-ssh-plus-ms "${DOSH_BENCH_NATIVE_SSH_PLUS_MS:-0}"; then
docker logs "$container_id" || true
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
exit 1
fi
if ! HOME="$workdir/home-cached" target/release/dosh-bench \
--server bench@127.0.0.1 \
--ssh-port "$ssh_port" \
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env sh
set -eu
seconds="${DOSH_FUZZ_SECONDS:-${1:-20}}"
targets="
packet_decode
from_body
authorized_keys
known_hosts
handshake_structs
attach_ticket
"
if ! command -v cargo >/dev/null 2>&1; then
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
fi
for target in $targets; do
echo "== fuzzing $target for ${seconds}s =="
cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \
-max_total_time="$seconds" -rss_limit_mb="${DOSH_FUZZ_RSS_MB:-4096}"
done
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env sh
set -eu
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
out_dir="${DOSH_PACKAGE_DIR:-target/dosh-release}"
version="${DOSH_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | sed -n '1p')}"
normalize_os() {
case "$(uname -s)" in
Darwin) printf '%s\n' macos ;;
Linux) printf '%s\n' linux ;;
FreeBSD) printf '%s\n' freebsd ;;
MINGW*|MSYS*|CYGWIN*) printf '%s\n' windows ;;
*) uname -s | tr '[:upper:]' '[:lower:]' ;;
esac
}
normalize_arch() {
case "$(uname -m)" in
x86_64|amd64) printf '%s\n' x86_64 ;;
arm64|aarch64) printf '%s\n' aarch64 ;;
armv7l) printf '%s\n' armv7 ;;
*) uname -m | tr '[:upper:]' '[:lower:]' ;;
esac
}
os="$(normalize_os)"
arch="$(normalize_arch)"
if [ "$os" = "windows" ]; then
artifact="dosh-$os-$arch.zip"
versioned_artifact="dosh-$version-$os-$arch.zip"
else
artifact="dosh-$os-$arch.tar.gz"
versioned_artifact="dosh-$version-$os-$arch.tar.gz"
fi
stage="$out_dir/stage/dosh"
cargo build --release
rm -rf "$stage"
mkdir -p "$stage/bin" "$out_dir"
for bin in dosh-client dosh-server dosh-auth dosh-bench; do
if [ -f "target/release/$bin" ]; then
install -m 0755 "target/release/$bin" "$stage/bin/$bin"
fi
done
printf '%s\n' "$version" >"$stage/VERSION"
if [ "$os" = "windows" ]; then
if command -v powershell.exe >/dev/null 2>&1; then
powershell.exe -NoProfile -Command "Compress-Archive -Force -Path '$stage' -DestinationPath '$out_dir/$artifact'"
elif command -v zip >/dev/null 2>&1; then
(cd "$out_dir/stage" && zip -qr "../$artifact" dosh)
else
echo "windows packaging requires powershell.exe or zip" >&2
exit 1
fi
else
tar -C "$out_dir/stage" -czf "$out_dir/$artifact" dosh
fi
cp "$out_dir/$artifact" "$out_dir/$versioned_artifact"
write_sha256() {
file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$file" >"$file.sha256"
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" >"$file.sha256"
else
echo "warning: sha256sum/shasum not found; skipping checksum for $file" >&2
fi
}
write_sha256 "$out_dir/$artifact"
write_sha256 "$out_dir/$versioned_artifact"
cat <<EOF
Wrote:
$out_dir/$artifact
$out_dir/$artifact.sha256
$out_dir/$versioned_artifact
$out_dir/$versioned_artifact.sha256
EOF
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env sh
set -eu
usage() {
cat <<'EOF'
Usage:
GITEA_TOKEN=... scripts/upload-gitea-release.sh TAG [artifact...]
Environment:
GITEA_URL Base URL, default https://git.palav.dev
GITEA_REPO owner/repo, default Palav/dosh
GITEA_TOKEN Token with release write permission
GITEA_TITLE Release title, default TAG
EOF
}
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
usage
exit 0
fi
tag="${1:-}"
if [ -z "$tag" ]; then
usage >&2
exit 2
fi
shift
base="${GITEA_URL:-https://git.palav.dev}"
repo="${GITEA_REPO:-Palav/dosh}"
token="${GITEA_TOKEN:-}"
title="${GITEA_TITLE:-$tag}"
if [ -z "$token" ]; then
echo "GITEA_TOKEN is required" >&2
exit 2
fi
if [ "$#" -eq 0 ]; then
set -- target/dosh-release/dosh-*
fi
api="$base/api/v1/repos/$repo"
auth_header="Authorization: token $token"
release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag" 2>/dev/null || true)"
release_id=""
if [ -n "$release_json" ] && command -v jq >/dev/null 2>&1; then
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
elif [ -n "$release_json" ]; then
release_id="$(printf '%s\n' "$release_json" | sed -n 's/^[[:space:]]*{"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
fi
if [ -z "$release_id" ]; then
payload="$(printf '{"tag_name":"%s","target_commitish":"main","name":"%s","body":"Dosh release %s","draft":false,"prerelease":false}\n' "$tag" "$title" "$tag")"
release_json="$(curl -fsS -H "$auth_header" -H 'Content-Type: application/json' -X POST "$api/releases" -d "$payload")"
if command -v jq >/dev/null 2>&1; then
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
else
release_id="$(printf '%s\n' "$release_json" | sed -n 's/^[[:space:]]*{"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
fi
fi
if [ -z "$release_id" ]; then
echo "could not determine Gitea release id for $tag" >&2
exit 1
fi
delete_existing_asset() {
name="$1"
if ! command -v jq >/dev/null 2>&1; then
return 0
fi
printf '%s\n' "$release_json" \
| jq -r --arg name "$name" '.assets[]? | select(.name == $name) | .id' \
| while IFS= read -r asset_id; do
[ -n "$asset_id" ] || continue
echo "replacing $name"
curl -fsS \
-H "$auth_header" \
-X DELETE \
"$api/releases/$release_id/assets/$asset_id" >/dev/null
done
}
for artifact in "$@"; do
if [ ! -f "$artifact" ]; then
continue
fi
name="$(basename "$artifact")"
delete_existing_asset "$name"
echo "uploading $name"
curl -fsS \
-H "$auth_header" \
-X POST \
-F "attachment=@$artifact" \
"$api/releases/$release_id/assets?name=$name" >/dev/null
done
+139 -11
View File
@@ -2,6 +2,8 @@ use anyhow::{Context, Result, anyhow};
use clap::Parser;
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::ffi::OsStr;
use std::fmt::Write as _;
use std::fs;
use std::io::Read;
use std::path::PathBuf;
use std::process::Command;
@@ -63,6 +65,12 @@ struct Args {
/// Emit machine-readable JSON (one object per metric, with raw samples).
#[arg(long)]
json: bool,
/// Emit a publishable Markdown benchmark report.
#[arg(long)]
markdown: bool,
/// Write the report to a file instead of stdout.
#[arg(long)]
output: Option<PathBuf>,
/// Optional label printed in summary/JSON output (e.g. machine/OS identifier).
#[arg(long)]
label: Option<String>,
@@ -179,11 +187,14 @@ fn main() -> Result<()> {
results.push(MetricSamples::new("mosh_start_true_ms", mosh_times));
}
if args.json {
print_json(&args, &results);
let report = if args.json {
render_json(&args, &results)
} else if args.markdown {
render_markdown(&args, &results)
} else {
print_table(&args, &results);
}
render_table(&args, &results)
};
write_report(&args, &report)?;
run_assertions(&args, &results)?;
Ok(())
@@ -560,17 +571,20 @@ fn percentile(sorted: &[f64], pct: f64) -> f64 {
}
}
fn print_table(args: &Args, results: &[MetricSamples]) {
fn render_table(args: &Args, results: &[MetricSamples]) -> String {
let mut out = String::new();
if let Some(label) = &args.label {
println!("# label: {label}");
let _ = writeln!(out, "# label: {label}");
}
println!(
let _ = writeln!(
out,
"{:<24} {:>6} {:>9} {:>9} {:>9} {:>9} {:>9}",
"metric", "n", "min", "median", "p95", "mean", "max"
);
for metric in results {
let s = metric.stats();
println!(
let _ = writeln!(
out,
"{:<24} {:>6} {:>9.2} {:>9.2} {:>9.2} {:>9.2} {:>9.2}",
metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max
);
@@ -578,11 +592,12 @@ fn print_table(args: &Args, results: &[MetricSamples]) {
// Raw per-iteration samples, so published numbers can include raw data.
for metric in results {
let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect();
println!("{} samples_ms=[{}]", metric.name, ms.join(", "));
let _ = writeln!(out, "{} samples_ms=[{}]", metric.name, ms.join(", "));
}
out
}
fn print_json(args: &Args, results: &[MetricSamples]) {
fn render_json(args: &Args, results: &[MetricSamples]) -> String {
let mut entries = Vec::new();
for metric in results {
let s = metric.stats();
@@ -603,11 +618,58 @@ fn print_json(args: &Args, results: &[MetricSamples]) {
Some(label) => format!("\"{}\"", label.replace('"', "\\\"")),
None => "null".to_string(),
};
println!(
format!(
"{{\"label\":{label},\"iterations\":{},\"metrics\":[{}]}}",
args.iterations.max(1),
entries.join(",")
)
}
fn render_markdown(args: &Args, results: &[MetricSamples]) -> String {
let mut out = String::new();
let label = args.label.as_deref().unwrap_or("Dosh benchmark");
let _ = writeln!(out, "# {label}\n");
let _ = writeln!(out, "- server: `{}`", args.server);
let _ = writeln!(out, "- iterations: `{}`", args.iterations.max(1));
let _ = writeln!(
out,
"- generated_by: `dosh-bench {}`\n",
env!("CARGO_PKG_VERSION")
);
let _ = writeln!(
out,
"| metric | n | min ms | median ms | p95 ms | mean ms | max ms |"
);
let _ = writeln!(out, "|---|---:|---:|---:|---:|---:|---:|");
for metric in results {
let s = metric.stats();
let _ = writeln!(
out,
"| `{}` | {} | {:.2} | {:.2} | {:.2} | {:.2} | {:.2} |",
metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max
);
}
let _ = writeln!(out, "\n## Raw Samples\n");
for metric in results {
let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect();
let _ = writeln!(out, "- `{}`: [{}]", metric.name, ms.join(", "));
}
out
}
fn write_report(args: &Args, report: &str) -> Result<()> {
if let Some(path) = &args.output {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
fs::write(path, report).with_context(|| format!("write {}", path.display()))?;
println!("wrote {}", path.display());
} else {
println!("{report}");
}
Ok(())
}
fn metric_mean(results: &[MetricSamples], name: &str) -> Option<f64> {
@@ -677,3 +739,69 @@ fn default_client_path() -> PathBuf {
.and_then(|path| path.parent().map(|parent| parent.join("dosh-client")))
.unwrap_or_else(|| PathBuf::from("dosh-client"))
}
#[cfg(test)]
mod tests {
use super::*;
fn args_for_render() -> Args {
Args {
server: "local".to_string(),
session: "default".to_string(),
ssh_port: 22,
dosh_port: 50000,
dosh_host: None,
iterations: 2,
local_auth: true,
cold_native: false,
cached_ticket: false,
resume: false,
client: None,
ssh_auth_command: "~/.local/bin/dosh-auth".to_string(),
ssh_key: None,
ssh_known_hosts: None,
ssh_control_path: None,
controlmaster: false,
no_cache: false,
warm_cache: false,
skip_ssh_baseline: true,
include_mosh: false,
mosh: PathBuf::from("mosh"),
mosh_server_command: "mosh-server".to_string(),
mosh_port: None,
json: false,
markdown: true,
output: None,
label: Some("test host".to_string()),
assert_ssh_plus_ms: None,
assert_mosh_minus_ms: None,
assert_dosh_max_ms: None,
}
}
#[test]
fn markdown_report_contains_summary_and_samples() {
let args = args_for_render();
let metrics = vec![MetricSamples::new(
"dosh_cached_attach_ms",
vec![Duration::from_millis(3), Duration::from_millis(5)],
)];
let report = render_markdown(&args, &metrics);
assert!(report.contains("# test host"));
assert!(report.contains("| `dosh_cached_attach_ms` | 2 | 3.00"));
assert!(report.contains("- `dosh_cached_attach_ms`: [3.00, 5.00]"));
}
#[test]
fn json_report_contains_metrics() {
let args = args_for_render();
let metrics = vec![MetricSamples::new(
"dosh_local_attach_ms",
vec![Duration::from_millis(7)],
)];
let report = render_json(&args, &metrics);
assert!(report.contains("\"label\":\"test host\""));
assert!(report.contains("\"metric\":\"dosh_local_attach_ms\""));
assert!(report.contains("\"samples_ms\":[7.0000]"));
}
}
+1446 -93
View File
File diff suppressed because it is too large Load Diff
+885 -55
View File
File diff suppressed because it is too large Load Diff
+43 -2
View File
@@ -46,6 +46,17 @@ pub struct ServerConfig {
pub allow_agent_forwarding: bool,
#[serde(default = "default_accept_env")]
pub accept_env: Vec<String>,
/// Run each terminal session's shell under a small per-session *holder*
/// process so it survives a `dosh-server` restart (crash/upgrade/
/// `systemctl restart`). On startup the server re-adopts live holders and
/// reattaching clients land on the same shell with screen state intact. When
/// `false`, sessions behave exactly as before: the shell is a child of the
/// server and dies with it.
// Opt-in for now: this changes the core session model (per-session holder
// processes + fd passing), so it stays OFF by default until it has been
// stress-tested on a real host. Set `persist_sessions = true` to enable.
#[serde(default)]
pub persist_sessions: bool,
}
impl Default for ServerConfig {
@@ -76,6 +87,7 @@ impl Default for ServerConfig {
allow_remote_non_loopback_bind: false,
allow_agent_forwarding: false,
accept_env: default_accept_env(),
persist_sessions: false,
}
}
}
@@ -92,7 +104,7 @@ pub struct ClientConfig {
pub default_session: String,
pub reconnect_timeout_secs: u64,
pub view_only: bool,
#[serde(default)]
#[serde(default = "default_true")]
pub predict: bool,
/// Prediction display policy: "off", "experimental" (adaptive, the default),
/// or "always". Controls when speculative local echo is shown.
@@ -114,10 +126,21 @@ pub struct ClientConfig {
pub use_ssh_agent: bool,
#[serde(default)]
pub forward_agent: bool,
/// Show a non-destructive, mosh-style status line on the bottom screen row
/// when UDP packets stop arriving ("[dosh] last contact Ns ago …"). Drawn
/// with save/restore cursor so it never moves the app cursor or corrupts a
/// full-screen TUI, and cleared the instant packets resume. Default on.
#[serde(default = "default_true")]
pub disconnect_status: bool,
#[serde(default = "default_send_env")]
pub send_env: Vec<String>,
#[serde(default)]
pub set_env: HashMap<String, String>,
/// Optional client-side command extensions. These are just shell command
/// templates expanded into the remote session's startup input; Dosh does not
/// depend on the tools they name.
#[serde(default)]
pub extensions: HashMap<String, CommandExtension>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -133,6 +156,22 @@ pub struct HostConfig {
pub send_env: Option<Vec<String>>,
#[serde(default)]
pub set_env: HashMap<String, String>,
/// Per-host extension overrides. A host can replace a global extension or set
/// `disabled = true` to opt out of it.
#[serde(default)]
pub extensions: HashMap<String, CommandExtension>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandExtension {
/// Remote shell command template. `{args}` expands to shell-quoted extra
/// words after the extension name. When absent, extra args are appended.
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub disabled: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -154,7 +193,7 @@ impl Default for ClientConfig {
default_session: "new".to_string(),
reconnect_timeout_secs: 5,
view_only: false,
predict: false,
predict: true,
predict_mode: default_predict_mode(),
cache_attach_tickets: true,
credential_cache: "~/.local/share/dosh/credentials".to_string(),
@@ -165,8 +204,10 @@ impl Default for ClientConfig {
identity_files: default_identity_files(),
use_ssh_agent: true,
forward_agent: false,
disconnect_status: true,
send_env: default_send_env(),
set_env: HashMap::new(),
extensions: HashMap::new(),
}
}
}
+1
View File
@@ -2,6 +2,7 @@ pub mod auth;
pub mod config;
pub mod crypto;
pub mod native;
pub mod persist;
pub mod protocol;
pub mod pty;
pub mod ssh_agent;
+352 -28
View File
@@ -5,7 +5,9 @@ use anyhow::{Context, Result, bail};
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rsa::traits::PublicKeyParts;
use serde::{Deserialize, Serialize};
use signature::{SignatureEncoding, Signer as SignatureSigner, Verifier as SignatureVerifier};
use std::fs;
use std::io::Write;
use std::net::IpAddr;
@@ -91,6 +93,12 @@ pub enum ForwardingKind {
Local,
Remote,
Dynamic,
/// SSH-agent forwarding: the client opts in and, if the server policy allows
/// (`allow_agent_forwarding`), the server exports a proxy unix socket as
/// `SSH_AUTH_SOCK` in the remote session and tunnels each connection back to
/// the client's local agent over a Dosh stream. Added at the end of the enum
/// so bincode discriminants for the existing variants are unchanged.
Agent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -333,6 +341,92 @@ pub fn sign_user_auth(
Ok(auth)
}
pub fn sign_user_auth_with_private_key(
private_key: &ssh_key::PrivateKey,
client: &NativeClientHello,
server: &NativeServerHello,
requested_forwardings: Vec<ForwardingRequest>,
) -> Result<NativeUserAuth> {
anyhow::ensure!(!private_key.is_encrypted(), "OpenSSH identity is encrypted");
let public_key = private_key.public_key();
let key_algorithm = public_key.algorithm().as_str().to_string();
anyhow::ensure!(
is_supported_user_key_algorithm(&key_algorithm),
"unsupported native identity key algorithm {key_algorithm}"
);
let public_key_blob = ssh_public_blob_from_public_key(public_key)?;
let public_key_for_auth = if key_algorithm == "ssh-ed25519" {
parse_ssh_ed25519_public_blob(&public_key_blob)?.to_vec()
} else {
public_key_blob
};
let signature_algorithm = signature_algorithm_for_private_key(&key_algorithm)?;
let mut auth = NativeUserAuth {
public_key_algorithm: signature_algorithm.to_string(),
public_key: public_key_for_auth,
signature: Vec::new(),
requested_forwardings,
};
let transcript = user_auth_transcript(client, server, &auth)?;
auth.signature = if key_algorithm == "ssh-rsa" {
sign_rsa_sha512_private_key(private_key, &transcript)?
} else {
let signature = SignatureSigner::try_sign(private_key, &transcript)
.context("sign native user auth with OpenSSH private key")?;
anyhow::ensure!(
signature.algorithm().as_str() == auth.public_key_algorithm,
"private key produced signature algorithm {}, expected {}",
signature.algorithm().as_str(),
auth.public_key_algorithm
);
signature.as_bytes().to_vec()
};
Ok(auth)
}
fn sign_rsa_sha512_private_key(
private_key: &ssh_key::PrivateKey,
transcript: &[u8],
) -> Result<Vec<u8>> {
let rsa_keypair = private_key
.key_data()
.rsa()
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity is not ssh-rsa"))?;
let rsa_private = rsa_private_key_from_ssh_keypair(rsa_keypair)?;
let signing_key = rsa::pkcs1v15::SigningKey::<sha2::Sha512>::new(rsa_private);
let signature: rsa::pkcs1v15::Signature = SignatureSigner::sign(&signing_key, transcript);
Ok(signature.to_vec())
}
fn rsa_private_key_from_ssh_keypair(
keypair: &ssh_key::private::RsaKeypair,
) -> Result<rsa::RsaPrivateKey> {
let private = rsa::RsaPrivateKey::from_components(
rsa::BigUint::try_from(&keypair.public.n).context("convert RSA modulus")?,
rsa::BigUint::try_from(&keypair.public.e).context("convert RSA public exponent")?,
rsa::BigUint::try_from(&keypair.private.d).context("convert RSA private exponent")?,
vec![
rsa::BigUint::try_from(&keypair.private.p).context("convert RSA p prime")?,
rsa::BigUint::try_from(&keypair.private.q).context("convert RSA q prime")?,
],
)
.context("convert OpenSSH RSA private key")?;
anyhow::ensure!(
private.size().saturating_mul(8) >= 2048,
"OpenSSH RSA identity is smaller than 2048 bits"
);
Ok(private)
}
fn signature_algorithm_for_private_key(key_algorithm: &str) -> Result<&'static str> {
match key_algorithm {
"ssh-ed25519" => Ok("ssh-ed25519"),
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
"ssh-rsa" => Ok("rsa-sha2-512"),
_ => bail!("unsupported native identity key algorithm {key_algorithm}"),
}
}
pub fn verify_native_user_auth(
client: &NativeClientHello,
server: &NativeServerHello,
@@ -341,10 +435,54 @@ pub fn verify_native_user_auth(
source_ip: Option<IpAddr>,
) -> Result<AuthorizedKey> {
anyhow::ensure!(
auth.public_key_algorithm == "ssh-ed25519",
is_supported_user_signature_algorithm(&auth.public_key_algorithm),
"unsupported native user key algorithm {}",
auth.public_key_algorithm
);
let authorized_algorithm = authorized_key_algorithm_for_auth(&auth.public_key_algorithm)?;
let authorized = authorized_keys
.iter()
.find(|key| key.algorithm == authorized_algorithm && key.key == auth.public_key)
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
authorized.ensure_native_allowed(auth, source_ip)?;
let transcript = user_auth_transcript(client, server, auth)?;
verify_native_user_signature(auth, &transcript)?;
Ok(authorized.clone())
}
pub fn supported_user_key_algorithms() -> Vec<String> {
vec![
"ssh-ed25519".to_string(),
"ecdsa-sha2-nistp256".to_string(),
"rsa-sha2-512".to_string(),
"rsa-sha2-256".to_string(),
]
}
pub fn is_supported_user_key_algorithm(algorithm: &str) -> bool {
matches!(algorithm, "ssh-ed25519" | "ecdsa-sha2-nistp256" | "ssh-rsa")
}
pub fn is_supported_user_signature_algorithm(algorithm: &str) -> bool {
matches!(
algorithm,
"ssh-ed25519" | "ecdsa-sha2-nistp256" | "rsa-sha2-512" | "rsa-sha2-256"
)
}
fn authorized_key_algorithm_for_auth(signature_algorithm: &str) -> Result<&'static str> {
match signature_algorithm {
"ssh-ed25519" => Ok("ssh-ed25519"),
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
"rsa-sha2-512" | "rsa-sha2-256" => Ok("ssh-rsa"),
_ => bail!("unsupported native user key algorithm {signature_algorithm}"),
}
}
fn verify_native_user_signature(auth: &NativeUserAuth, transcript: &[u8]) -> Result<()> {
match auth.public_key_algorithm.as_str() {
"ssh-ed25519" => {
anyhow::ensure!(
auth.public_key.len() == 32,
"ssh-ed25519 public key must be 32 bytes"
@@ -353,23 +491,69 @@ pub fn verify_native_user_auth(
auth.signature.len() == 64,
"ssh-ed25519 signature must be 64 bytes"
);
let authorized = authorized_keys
.iter()
.find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key)
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
authorized.ensure_native_allowed(auth, source_ip)?;
let mut public_key = [0u8; 32];
public_key.copy_from_slice(&auth.public_key);
let verifying_key = VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
let verifying_key =
VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
let mut signature = [0u8; 64];
signature.copy_from_slice(&auth.signature);
let signature = Signature::from_bytes(&signature);
let transcript = user_auth_transcript(client, server, auth)?;
verifying_key
.verify(&transcript, &signature)
.verify(transcript, &signature)
.context("verify native user signature")?;
Ok(authorized.clone())
}
"ecdsa-sha2-nistp256" => {
let public_key = ssh_public_key_from_blob(&auth.public_key)
.context("parse native user SSH public key")?;
let algorithm =
ssh_key::Algorithm::from_str(&auth.public_key_algorithm).with_context(|| {
format!("parse signature algorithm {}", auth.public_key_algorithm)
})?;
let signature = ssh_key::Signature::new(algorithm, auth.signature.clone())
.context("parse native user SSH signature")?;
SignatureVerifier::verify(public_key.key_data(), transcript, &signature)
.context("verify native user SSH signature")?;
}
"rsa-sha2-512" | "rsa-sha2-256" => {
verify_rsa_sha2_signature(
&auth.public_key,
&auth.public_key_algorithm,
transcript,
&auth.signature,
)?;
}
algorithm => bail!("unsupported native user key algorithm {algorithm}"),
}
Ok(())
}
fn verify_rsa_sha2_signature(
public_key_blob: &[u8],
signature_algorithm: &str,
transcript: &[u8],
signature: &[u8],
) -> Result<()> {
let (e, n) = parse_ssh_rsa_public_blob(public_key_blob)?;
let public = rsa::RsaPublicKey::new(
rsa::BigUint::from_bytes_be(n),
rsa::BigUint::from_bytes_be(e),
)
.context("parse RSA public key")?;
let signature =
rsa::pkcs1v15::Signature::try_from(signature).context("parse RSA PKCS#1v1.5 signature")?;
match signature_algorithm {
"rsa-sha2-256" => {
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha256>::new(public);
SignatureVerifier::verify(&key, transcript, &signature)
.context("verify RSA-SHA256 signature")
}
"rsa-sha2-512" => {
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha512>::new(public);
SignatureVerifier::verify(&key, transcript, &signature)
.context("verify RSA-SHA512 signature")
}
_ => bail!("legacy ssh-rsa/SHA-1 signatures are not accepted"),
}
}
pub fn verify_native_user_auth_from_config(
@@ -464,6 +648,35 @@ pub fn load_ed25519_identity_with_passphrase(
.with_context(|| format!("parse ssh-ed25519 identity {}", path.display()))
}
pub fn load_native_identity(path: &Path) -> Result<ssh_key::PrivateKey> {
load_native_identity_with_passphrase(path, None)
}
pub fn load_native_identity_with_passphrase(
path: &Path,
passphrase: Option<&str>,
) -> Result<ssh_key::PrivateKey> {
let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let private_key = ssh_key::PrivateKey::from_openssh(&raw)
.with_context(|| format!("parse OpenSSH identity {}", path.display()))?;
let private_key = if private_key.is_encrypted() {
let passphrase = passphrase
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity {} is encrypted", path.display()))?;
private_key
.decrypt(passphrase)
.with_context(|| format!("decrypt OpenSSH identity {}", path.display()))?
} else {
private_key
};
let algorithm = private_key.public_key().algorithm().as_str().to_string();
anyhow::ensure!(
is_supported_user_key_algorithm(&algorithm),
"OpenSSH identity {} has unsupported native key algorithm {algorithm}",
path.display()
);
Ok(private_key)
}
impl AuthorizedKey {
fn ensure_native_allowed(
&self,
@@ -513,6 +726,10 @@ impl AuthorizedKey {
"authorized key permitopen= cannot authorize remote or dynamic forwarding"
);
}
// Agent forwarding does not open a host:port, so permitopen
// (which restricts which targets may be opened) does not apply;
// it is gated separately by the server's allow_agent_forwarding.
ForwardingKind::Agent => {}
}
}
}
@@ -555,7 +772,7 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
fields.len() >= 2,
"authorized_keys:{line_number}: expected key fields"
);
let (options, key_type_index) = if fields[0].starts_with("ssh-") {
let (options, key_type_index) = if is_supported_user_key_algorithm(&fields[0]) {
(AuthorizedKeyOptions::default(), 0)
} else {
(parse_authorized_key_options(&fields[0])?, 1)
@@ -566,15 +783,20 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
let key_blob = fields
.get(key_type_index + 1)
.with_context(|| format!("authorized_keys:{line_number}: missing key blob"))?;
anyhow::ensure!(
algorithm == "ssh-ed25519",
"authorized_keys:{line_number}: unsupported key type {algorithm}"
);
let decoded = STANDARD
.decode(key_blob)
.with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?;
let key = parse_ssh_ed25519_public_blob(&decoded)
.with_context(|| format!("authorized_keys:{line_number}: parse ssh-ed25519 key"))?;
anyhow::ensure!(
is_supported_user_key_algorithm(algorithm),
"authorized_keys:{line_number}: unsupported key type {algorithm}"
);
validate_supported_public_key_blob(algorithm, &decoded)
.with_context(|| format!("authorized_keys:{line_number}: parse {algorithm} key"))?;
let key = if algorithm == "ssh-ed25519" {
parse_ssh_ed25519_public_blob(&decoded)?.to_vec()
} else {
decoded
};
let comment = if fields.len() > key_type_index + 2 {
Some(fields[key_type_index + 2..].join(" "))
} else {
@@ -588,6 +810,51 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
})
}
fn validate_supported_public_key_blob(algorithm: &str, blob: &[u8]) -> Result<()> {
let blob_algorithm = ssh_public_key_blob_algorithm(blob)?;
anyhow::ensure!(
blob_algorithm == algorithm,
"key blob type {blob_algorithm} does not match authorized_keys type {algorithm}"
);
match algorithm {
"ssh-ed25519" => {
parse_ssh_ed25519_public_blob(blob)?;
}
"ecdsa-sha2-nistp256" | "ssh-rsa" => {
let _ = ssh_public_key_from_blob(blob)?;
}
_ => bail!("unsupported key type {algorithm}"),
}
Ok(())
}
fn ssh_public_key_blob_algorithm(blob: &[u8]) -> Result<String> {
let mut cursor = blob;
let algorithm = read_ssh_string(&mut cursor)?;
Ok(String::from_utf8_lossy(algorithm).to_string())
}
fn ssh_public_key_from_blob(blob: &[u8]) -> Result<ssh_key::PublicKey> {
let algorithm = ssh_public_key_blob_algorithm(blob)?;
let encoded = STANDARD.encode(blob);
ssh_key::PublicKey::from_openssh(&format!("{algorithm} {encoded}"))
.with_context(|| format!("parse OpenSSH public key blob {algorithm}"))
}
fn ssh_public_blob_from_public_key(public_key: &ssh_key::PublicKey) -> Result<Vec<u8>> {
let line = public_key
.to_openssh()
.context("encode OpenSSH public key")?;
let mut fields = line.split_whitespace();
let _algorithm = fields
.next()
.context("encoded public key missing algorithm")?;
let encoded = fields.next().context("encoded public key missing blob")?;
STANDARD
.decode(encoded)
.context("decode encoded OpenSSH public key blob")
}
fn split_authorized_key_fields(line: &str) -> Vec<String> {
line.split_whitespace().map(ToString::to_string).collect()
}
@@ -755,6 +1022,18 @@ pub fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
Ok(out)
}
fn parse_ssh_rsa_public_blob(blob: &[u8]) -> Result<(&[u8], &[u8])> {
let mut cursor = blob;
let key_type = read_ssh_string(&mut cursor)?;
anyhow::ensure!(key_type == b"ssh-rsa", "key blob type mismatch");
let e = read_ssh_mpint(&mut cursor)?;
let n = read_ssh_mpint(&mut cursor)?;
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-rsa key blob");
anyhow::ensure!(!e.is_empty(), "ssh-rsa exponent is empty");
anyhow::ensure!(!n.is_empty(), "ssh-rsa modulus is empty");
Ok((e, n))
}
pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
let mut out = Vec::new();
write_ssh_string(&mut out, b"ssh-ed25519");
@@ -762,6 +1041,12 @@ pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
out
}
fn read_ssh_mpint<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
let raw = read_ssh_string(cursor)?;
let raw = raw.strip_prefix(&[0]).unwrap_or(raw);
Ok(raw)
}
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length");
let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize;
@@ -994,16 +1279,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(),
trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::Trusted
);
assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(),
trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::AlreadyTrusted
);
assert!(trust_host(&path, "palav", &second, "ssh", false).is_err());
assert!(trust_host(&path, "homelab", &second, "ssh", false).is_err());
assert_eq!(
trust_host(&path, "palav", &second, "ssh", true).unwrap(),
trust_host(&path, "homelab", &second, "ssh", true).unwrap(),
TrustResult::Trusted
);
}
@@ -1016,16 +1301,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(),
verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Unknown
);
trust_host(&path, "palav", &first, "ssh", false).unwrap();
trust_host(&path, "homelab", &first, "ssh", false).unwrap();
assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(),
verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Trusted
);
assert!(matches!(
verify_known_host(&path, "palav", &second).unwrap(),
verify_known_host(&path, "homelab", &second).unwrap(),
KnownHostStatus::Mismatch { .. }
));
}
@@ -1074,6 +1359,45 @@ mod tests {
assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err());
}
#[test]
fn native_user_auth_accepts_ecdsa_p256_private_key() {
let mut rng = rand::rngs::OsRng;
let private = ssh_key::PrivateKey::random(
&mut rng,
ssh_key::Algorithm::Ecdsa {
curve: ssh_key::EcdsaCurve::NistP256,
},
)
.unwrap();
let host_signing = SigningKey::from_bytes(&[10u8; 32]);
let client = test_client_hello();
let mut server = test_server_hello(&host_signing);
sign_server_hello(&host_signing, &client, &mut server).unwrap();
let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap();
let authorized =
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
assert_eq!(auth.public_key_algorithm, "ecdsa-sha2-nistp256");
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
}
#[test]
fn native_user_auth_accepts_rsa_sha2_private_key() {
let mut rng = rand::rngs::OsRng;
let private =
ssh_key::PrivateKey::random(&mut rng, ssh_key::Algorithm::Rsa { hash: None }).unwrap();
let host_signing = SigningKey::from_bytes(&[11u8; 32]);
let client = test_client_hello();
let mut server = test_server_hello(&host_signing);
sign_server_hello(&host_signing, &client, &mut server).unwrap();
let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap();
let authorized =
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
assert_eq!(auth.public_key_algorithm, "rsa-sha2-512");
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
}
#[test]
fn native_user_auth_rejects_tampered_transcript() {
let host_signing = SigningKey::from_bytes(&[3u8; 32]);
@@ -1301,8 +1625,8 @@ mod tests {
protocol_version: NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(),
requested_user: "palav".to_string(),
requested_host: "homelab".to_string(),
requested_user: "alice".to_string(),
requested_session: "term".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
+658
View File
@@ -0,0 +1,658 @@
//! Session persistence across server restarts.
//!
//! A persistent dosh session's shell does not run as a child of `dosh-server`.
//! Instead the server spawns a tiny per-session *holder* process (the same
//! `dosh-server` binary re-exec'd as `dosh-server hold ...`). The holder calls
//! `setsid()` to leave the server's process group/session, opens a PTY, spawns
//! the shell as ITS own child, and listens on a Unix socket in a per-session
//! runtime directory. The server connects to that socket and the holder hands it
//! the PTY master fd over `SCM_RIGHTS`.
//!
//! Because the shell belongs to the holder (which is in its own session and is
//! not waited on by the server), killing or restarting `dosh-server` leaves the
//! holder + shell alive. On startup the server scans the runtime directory,
//! reconnects to each live holder, receives the master fd again, and rebuilds the
//! in-memory session so clients reattach to the very same shell.
//!
//! Screen / scrollback state lives only in server memory, so it is also mirrored
//! to disk (atomically) and restored on re-adoption, letting a reattaching client
//! repaint the screen exactly as it was before the restart.
use anyhow::{Context, Result, anyhow, bail};
use std::io::{Read, Write};
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, RawFd};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// One-byte commands a server sends to a holder over its control socket after
/// the holder has handed back the master fd.
const HOLDER_CMD_SHUTDOWN: u8 = b'X';
/// Magic written into a holder's `meta` file, bumped if the on-disk layout
/// changes so a stale holder from an incompatible build is ignored.
const META_MAGIC: &str = "dosh-holder-1";
/// Per-session runtime metadata persisted next to the holder socket.
#[derive(Debug, Clone)]
pub struct HolderMeta {
pub session: String,
pub shell_pid: i32,
}
/// Root runtime directory holding one subdirectory per persistent session.
/// Lives under `sessions_dir/run` so it shares the session storage location and
/// can be wiped with it.
pub fn runtime_root(sessions_dir: &Path) -> PathBuf {
sessions_dir.join("run")
}
/// Map a session name to a filesystem-safe directory name. Session names are
/// user-controlled, so hex-encode them rather than trusting them as path
/// components (avoids traversal, slashes, NULs, length issues).
fn session_dir_name(session: &str) -> String {
let mut out = String::with_capacity(session.len() * 2);
for byte in session.as_bytes() {
out.push(char::from_digit((byte >> 4) as u32, 16).unwrap());
out.push(char::from_digit((byte & 0xf) as u32, 16).unwrap());
}
out
}
fn decode_session_dir_name(name: &str) -> Option<String> {
if !name.len().is_multiple_of(2) {
return None;
}
let mut bytes = Vec::with_capacity(name.len() / 2);
let raw = name.as_bytes();
let mut i = 0;
while i < raw.len() {
let hi = (raw[i] as char).to_digit(16)?;
let lo = (raw[i + 1] as char).to_digit(16)?;
bytes.push(((hi << 4) | lo) as u8);
i += 2;
}
String::from_utf8(bytes).ok()
}
/// Directory for one session's holder runtime state.
pub fn session_runtime_dir(sessions_dir: &Path, session: &str) -> PathBuf {
runtime_root(sessions_dir).join(session_dir_name(session))
}
fn holder_sock_path(dir: &Path) -> PathBuf {
dir.join("holder.sock")
}
fn meta_path(dir: &Path) -> PathBuf {
dir.join("meta")
}
fn screen_path(dir: &Path) -> PathBuf {
dir.join("screen")
}
/// Create the runtime directory tree with private (0700) permissions.
pub fn ensure_runtime_dir(sessions_dir: &Path, session: &str) -> Result<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let root = runtime_root(sessions_dir);
std::fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?;
let _ = std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700));
let dir = session_runtime_dir(sessions_dir, session);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("chmod {}", dir.display()))?;
Ok(dir)
}
/// Atomically write `data` to `path` (write temp + rename), so a concurrent
/// reader (e.g. a restarting server) never sees a half-written file.
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let tmp = path.with_extension("tmp");
{
let mut file =
std::fs::File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
file.write_all(data)
.with_context(|| format!("write {}", tmp.display()))?;
file.sync_all().ok();
}
std::fs::rename(&tmp, path)
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
/// Persist the vt100 screen snapshot plus recent raw output for a session so a
/// post-restart reattach repaints correctly. `snapshot` is the bytes a fresh
/// attach would receive (alt-screen toggle + `state_formatted`).
pub fn save_screen(
sessions_dir: &Path,
session: &str,
cols: u16,
rows: u16,
output_seq: u64,
snapshot: &[u8],
) -> Result<()> {
let dir = session_runtime_dir(sessions_dir, session);
if !dir.exists() {
// No holder runtime for this session (non-persistent): nothing to do.
return Ok(());
}
let mut buf = Vec::with_capacity(snapshot.len() + 32);
buf.extend_from_slice(&cols.to_be_bytes());
buf.extend_from_slice(&rows.to_be_bytes());
buf.extend_from_slice(&output_seq.to_be_bytes());
buf.extend_from_slice(&(snapshot.len() as u32).to_be_bytes());
buf.extend_from_slice(snapshot);
atomic_write(&screen_path(&dir), &buf)
}
/// A restored screen for a re-adopted session.
pub struct SavedScreen {
pub cols: u16,
pub rows: u16,
pub output_seq: u64,
pub snapshot: Vec<u8>,
}
/// Load a previously persisted screen, if any.
pub fn load_screen(sessions_dir: &Path, session: &str) -> Option<SavedScreen> {
let dir = session_runtime_dir(sessions_dir, session);
let data = std::fs::read(screen_path(&dir)).ok()?;
if data.len() < 16 {
return None;
}
let cols = u16::from_be_bytes(data[0..2].try_into().ok()?);
let rows = u16::from_be_bytes(data[2..4].try_into().ok()?);
let output_seq = u64::from_be_bytes(data[4..12].try_into().ok()?);
let len = u32::from_be_bytes(data[12..16].try_into().ok()?) as usize;
if data.len() < 16 + len {
return None;
}
Some(SavedScreen {
cols,
rows,
output_seq,
snapshot: data[16..16 + len].to_vec(),
})
}
fn write_meta(dir: &Path, meta: &HolderMeta) -> Result<()> {
let body = format!("{META_MAGIC}\n{}\n{}\n", meta.shell_pid, meta.session);
atomic_write(&meta_path(dir), body.as_bytes())
}
fn read_meta(dir: &Path) -> Option<HolderMeta> {
let body = std::fs::read_to_string(meta_path(dir)).ok()?;
let mut lines = body.lines();
if lines.next()? != META_MAGIC {
return None;
}
let shell_pid: i32 = lines.next()?.parse().ok()?;
let session = lines.next()?.to_string();
Some(HolderMeta { session, shell_pid })
}
/// Spawn a holder process for `session`: re-exec this binary as `dosh-server
/// hold` with the runtime dir, shell, terminal size, and accepted env. The
/// holder daemonizes (setsid, owns the PTY) and listens on its control socket.
/// Returns once the holder has signalled readiness by creating its socket.
#[allow(clippy::too_many_arguments)]
pub fn spawn_holder(
sessions_dir: &Path,
session: &str,
shell: &str,
cols: u16,
rows: u16,
env: &[(String, String)],
) -> Result<()> {
let dir = ensure_runtime_dir(sessions_dir, session)?;
let sock = holder_sock_path(&dir);
// Clear any stale socket left by a crashed holder with the same name.
let _ = std::fs::remove_file(&sock);
let exe = std::env::current_exe().context("locate current executable")?;
let mut cmd = std::process::Command::new(exe);
cmd.arg("hold")
.arg("--runtime-dir")
.arg(&dir)
.arg("--session")
.arg(session)
.arg("--shell")
.arg(shell)
.arg("--cols")
.arg(cols.to_string())
.arg("--rows")
.arg(rows.to_string());
for (name, value) in env {
cmd.arg("--env").arg(format!("{name}={value}"));
}
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let mut child = cmd.spawn().context("spawn holder process")?;
// Wait (briefly) for the holder to come up. The holder forks/setsids before
// creating the socket; once the socket exists we can connect. We also reap
// the short-lived launcher child so it never becomes a zombie.
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
if sock.exists() {
break;
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
bail!("holder for session {session} did not come up in time");
}
std::thread::sleep(Duration::from_millis(20));
}
// The launcher exits immediately after the grandchild setsids; reap it.
let _ = child.wait();
Ok(())
}
/// Connect to a session's holder and receive the PTY master fd over SCM_RIGHTS.
/// Returns the raw fd (caller owns it) and the holder control socket, kept open
/// so the server can later ask the holder to shut down. Returns an error if the
/// holder is gone (caller then degrades to a fresh, non-persistent session).
pub fn adopt_holder(sessions_dir: &Path, session: &str) -> Result<(RawFd, UnixStream)> {
let dir = session_runtime_dir(sessions_dir, session);
let sock = holder_sock_path(&dir);
let mut stream = UnixStream::connect(&sock)
.with_context(|| format!("connect holder socket {}", sock.display()))?;
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
let fd = recv_fd(&mut stream).context("receive master fd from holder")?;
Ok((fd, stream))
}
/// Ask a holder to terminate its shell and exit, then clean its runtime dir.
/// Used when reaping a truly-abandoned persistent session.
pub fn request_shutdown(sessions_dir: &Path, session: &str, control: Option<&mut UnixStream>) {
if let Some(stream) = control {
let _ = stream.write_all(&[HOLDER_CMD_SHUTDOWN]);
let _ = stream.flush();
} else {
let dir = session_runtime_dir(sessions_dir, session);
if let Ok(mut stream) = UnixStream::connect(holder_sock_path(&dir)) {
// Drain the fd the holder sends on connect, then send shutdown.
let _ = recv_fd(&mut stream);
let _ = stream.write_all(&[HOLDER_CMD_SHUTDOWN]);
let _ = stream.flush();
}
}
// Give the holder a moment to tear down, then remove its runtime dir.
std::thread::sleep(Duration::from_millis(50));
let dir = session_runtime_dir(sessions_dir, session);
let _ = std::fs::remove_dir_all(&dir);
}
/// Remove a session's runtime directory unconditionally (best effort).
pub fn remove_runtime_dir(sessions_dir: &Path, session: &str) {
let dir = session_runtime_dir(sessions_dir, session);
let _ = std::fs::remove_dir_all(&dir);
}
/// Scan the runtime root for holders left behind by a previous server. Returns
/// `(session_name, meta)` for each one whose holder process still appears alive.
/// Stale entries (no live process) are cleaned up.
pub fn scan_existing_holders(sessions_dir: &Path) -> Vec<HolderMeta> {
let root = runtime_root(sessions_dir);
let mut found = Vec::new();
let Ok(entries) = std::fs::read_dir(&root) else {
return found;
};
for entry in entries.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let Some(name) = dir.file_name().and_then(|n| n.to_str()) else {
continue;
};
// Decode the on-disk name back to a session name; skip junk dirs.
if decode_session_dir_name(name).is_none() {
continue;
}
match read_meta(&dir) {
Some(meta) if process_alive(meta.shell_pid) && holder_sock_path(&dir).exists() => {
found.push(meta);
}
_ => {
// Holder gone or meta unreadable: clean up so we don't try to
// adopt a dead session (degrade to fresh on next attach).
let _ = std::fs::remove_dir_all(&dir);
}
}
}
found
}
/// Whether a pid refers to a live process (signal 0 probe).
fn process_alive(pid: i32) -> bool {
if pid <= 0 {
return false;
}
unsafe {
libc::kill(pid, 0) == 0
|| std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
}
// ---------------------------------------------------------------------------
// Holder process entry point
// ---------------------------------------------------------------------------
/// Run as a holder process. Daemonizes (double-fork + setsid), opens a PTY,
/// spawns the shell as its own child, and serves the control socket: every
/// accepted connection is handed the master fd (SCM_RIGHTS); a subsequent
/// SHUTDOWN byte (or the shell exiting) tears everything down.
///
/// This function does not return on success — it `exit`s the process. Errors
/// before the daemonization point are returned to the launcher.
#[allow(clippy::too_many_arguments)]
pub fn run_holder(
runtime_dir: &Path,
session: &str,
shell: &str,
cols: u16,
rows: u16,
env: &[(String, String)],
) -> Result<()> {
use portable_pty::{NativePtySystem, PtySize, PtySystem};
// Detach from the launching server: own session + process group so a server
// exit (even a process-group kill of the service) does not take us down.
daemonize().context("daemonize holder")?;
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("holder open pty")?;
let cmd = crate::pty::build_shell_command(shell, env);
let mut child = pair
.slave
.spawn_command(cmd)
.context("holder spawn shell")?;
drop(pair.slave);
let master_fd = pair
.master
.as_raw_fd()
.ok_or_else(|| anyhow!("holder master has no raw fd"))?;
let shell_pid = child.process_id().map(|p| p as i32).unwrap_or(-1);
write_meta(
runtime_dir,
&HolderMeta {
session: session.to_string(),
shell_pid,
},
)?;
let sock = holder_sock_path(runtime_dir);
let _ = std::fs::remove_file(&sock);
let listener =
UnixListener::bind(&sock).with_context(|| format!("holder bind {}", sock.display()))?;
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600));
}
listener
.set_nonblocking(false)
.context("holder listener blocking")?;
// A watcher thread reaps the shell: when it exits, the holder cleans up and
// exits too, so an abandoned shell does not linger forever.
let runtime_owned = runtime_dir.to_path_buf();
std::thread::spawn(move || {
let _ = child.wait();
// Shell exited: remove runtime dir and exit the holder process.
let _ = std::fs::remove_dir_all(&runtime_owned);
std::process::exit(0);
});
// Accept loop: each connecting server gets the master fd; a SHUTDOWN byte
// from any of them tears the holder down. Multiple servers never run at once
// in practice (one service), but serving repeated connections lets a server
// restart re-adopt cleanly.
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
if send_fd(&mut stream, master_fd).is_err() {
continue;
}
// Wait for an optional command byte. EOF (server dropped the control
// socket on its own exit) just means "keep running, await re-adoption".
let mut cmd = [0u8; 1];
match stream.read(&mut cmd) {
Ok(1) if cmd[0] == HOLDER_CMD_SHUTDOWN => {
let _ = std::fs::remove_dir_all(runtime_dir);
std::process::exit(0);
}
_ => {
// Server detached (exit/restart) or sent nothing actionable:
// loop back and wait for the next server to re-adopt us.
}
}
}
Ok(())
}
/// Double-fork + `setsid` so the holder runs in its own session, detached from
/// the server's controlling terminal and process group. Without this a
/// `systemctl restart` (which signals the whole service cgroup/process group)
/// could take the holder down with the server.
fn daemonize() -> Result<()> {
// First fork: parent (launcher) returns to reap; child continues.
match unsafe { libc::fork() } {
-1 => bail!("fork: {}", std::io::Error::last_os_error()),
0 => {}
_ => {
// Parent process: exit so the launcher's wait() returns promptly and
// the grandchild is reparented to init.
std::process::exit(0);
}
}
// New session: detaches from controlling tty and the server's process group.
if unsafe { libc::setsid() } == -1 {
bail!("setsid: {}", std::io::Error::last_os_error());
}
// Second fork: ensures we are not a session leader, so we can never
// re-acquire a controlling terminal.
match unsafe { libc::fork() } {
-1 => bail!("fork2: {}", std::io::Error::last_os_error()),
0 => Ok(()),
_ => std::process::exit(0),
}
}
// ---------------------------------------------------------------------------
// SCM_RIGHTS file-descriptor passing over a Unix domain socket
// ---------------------------------------------------------------------------
/// Send a single fd over `stream` using SCM_RIGHTS, with one byte of normal data
/// (sendmsg requires at least one iovec byte for the ancillary data to ride on).
fn send_fd(stream: &mut UnixStream, fd: RawFd) -> Result<()> {
let dummy: [u8; 1] = [0];
let mut iov = libc::iovec {
iov_base: dummy.as_ptr() as *mut libc::c_void,
iov_len: 1,
};
let mut cmsg_buf = [0u8; cmsg_space_one_fd()];
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg_buf.len() as _;
unsafe {
let cmsg = libc::CMSG_FIRSTHDR(&msg);
if cmsg.is_null() {
bail!("CMSG_FIRSTHDR null");
}
(*cmsg).cmsg_level = libc::SOL_SOCKET;
(*cmsg).cmsg_type = libc::SCM_RIGHTS;
(*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::<RawFd>() as u32) as _;
std::ptr::copy_nonoverlapping(
&fd as *const RawFd as *const u8,
libc::CMSG_DATA(cmsg),
std::mem::size_of::<RawFd>(),
);
let n = libc::sendmsg(stream.as_raw_fd(), &msg, 0);
if n < 0 {
return Err(std::io::Error::last_os_error()).context("sendmsg SCM_RIGHTS");
}
}
Ok(())
}
/// Receive a single fd sent via SCM_RIGHTS. Returns a fresh fd owned by the
/// caller.
fn recv_fd(stream: &mut UnixStream) -> Result<RawFd> {
let mut dummy = [0u8; 1];
let mut iov = libc::iovec {
iov_base: dummy.as_mut_ptr() as *mut libc::c_void,
iov_len: 1,
};
let mut cmsg_buf = [0u8; cmsg_space_one_fd()];
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg_buf.len() as _;
unsafe {
let n = libc::recvmsg(stream.as_raw_fd(), &mut msg, 0);
if n < 0 {
return Err(std::io::Error::last_os_error()).context("recvmsg SCM_RIGHTS");
}
if n == 0 {
bail!("holder closed connection before sending fd");
}
let cmsg = libc::CMSG_FIRSTHDR(&msg);
if cmsg.is_null() {
bail!("no ancillary data (fd) received from holder");
}
if (*cmsg).cmsg_level != libc::SOL_SOCKET || (*cmsg).cmsg_type != libc::SCM_RIGHTS {
bail!("unexpected ancillary message from holder");
}
let mut fd: RawFd = -1;
std::ptr::copy_nonoverlapping(
libc::CMSG_DATA(cmsg),
&mut fd as *mut RawFd as *mut u8,
std::mem::size_of::<RawFd>(),
);
if fd < 0 {
bail!("invalid fd received from holder");
}
Ok(fd)
}
}
/// Space, in bytes, needed for a control-message buffer carrying exactly one fd.
const fn cmsg_space_one_fd() -> usize {
// CMSG_SPACE is not const in libc; this is the equivalent for one RawFd.
// cmsghdr is aligned to size_of::<usize>(); add data length rounded up.
let data = std::mem::size_of::<RawFd>();
let hdr = std::mem::size_of::<libc::cmsghdr>();
let align = std::mem::size_of::<usize>();
// round(hdr) + round(data)
((hdr + align - 1) & !(align - 1)) + ((data + align - 1) & !(align - 1))
}
/// Helper used by `adopt_holder` callers to turn the received raw fd into an
/// owned `File`-like object if they need RAII (the server hands it to
/// `pty::adopt_pty_from_fd`, which takes ownership of the raw fd).
pub fn fd_into_file(fd: RawFd) -> std::fs::File {
unsafe { std::fs::File::from_raw_fd(fd) }
}
/// Convert a `File` back into a raw fd it no longer owns (so it can be handed to
/// `adopt_pty_from_fd`). Currently unused outside tests but kept symmetric.
pub fn file_into_fd(file: std::fs::File) -> RawFd {
file.into_raw_fd()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_dir_name_round_trips() {
for name in ["default", "work", "a/b/../c", "weird name", "日本語"] {
let encoded = session_dir_name(name);
assert!(encoded.bytes().all(|b| b.is_ascii_hexdigit()));
assert_eq!(decode_session_dir_name(&encoded).as_deref(), Some(name));
}
}
#[test]
fn decode_rejects_non_hex() {
assert!(decode_session_dir_name("zz").is_none());
assert!(decode_session_dir_name("abc").is_none());
}
#[test]
fn save_and_load_screen_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let sessions_dir = tmp.path();
ensure_runtime_dir(sessions_dir, "work").unwrap();
let snap = b"\x1b[?1049lhello world".to_vec();
save_screen(sessions_dir, "work", 100, 40, 7, &snap).unwrap();
let loaded = load_screen(sessions_dir, "work").expect("screen restored");
assert_eq!(loaded.cols, 100);
assert_eq!(loaded.rows, 40);
assert_eq!(loaded.output_seq, 7);
assert_eq!(loaded.snapshot, snap);
}
#[test]
fn load_screen_absent_is_none() {
let tmp = tempfile::tempdir().unwrap();
assert!(load_screen(tmp.path(), "missing").is_none());
}
#[test]
fn scan_skips_dead_and_junk_entries() {
let tmp = tempfile::tempdir().unwrap();
let sessions_dir = tmp.path();
// A meta pointing at a definitely-dead pid is cleaned up, not returned.
let dir = ensure_runtime_dir(sessions_dir, "ghost").unwrap();
write_meta(
&dir,
&HolderMeta {
session: "ghost".to_string(),
shell_pid: 2_000_000_000, // not a live pid
},
)
.unwrap();
// A junk (non-hex) directory is ignored.
std::fs::create_dir_all(runtime_root(sessions_dir).join("not-hex")).unwrap();
let found = scan_existing_holders(sessions_dir);
assert!(found.is_empty(), "dead/junk holders must be skipped");
assert!(!dir.exists(), "dead holder dir should be cleaned up");
}
#[test]
fn send_and_recv_fd_round_trips() {
use std::io::Seek;
// Pass a temp file's fd across a socketpair and confirm both ends point
// at the same open file (write via one, read via the other).
let (mut a, mut b) = UnixStream::pair().unwrap();
let mut file = tempfile::tempfile().unwrap();
writeln!(file, "shared-fd-marker").unwrap();
file.flush().unwrap();
send_fd(&mut a, file.as_raw_fd()).unwrap();
let received = recv_fd(&mut b).unwrap();
let mut got = fd_into_file(received);
got.rewind().unwrap();
let mut contents = String::new();
got.read_to_string(&mut contents).unwrap();
assert!(contents.contains("shared-fd-marker"));
}
}
+81 -1
View File
@@ -3,9 +3,49 @@ use crate::crypto;
use crate::native::{EnvVar, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// Generate a name for an implicit, ephemeral terminal session — the kind
/// created by `dosh host` with no `--session`. Single source of truth shared by
/// the client (which generates it) and the server (which recognizes it via
/// [`is_implicit_session_name`] to decide a session is NOT worth persisting).
pub fn generate_implicit_session_name() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("term-{millis}-{}", std::process::id())
}
/// Whether `name` is a client-generated implicit session name
/// (`term-<digits>-<digits>`). Such sessions are ephemeral: the user can never
/// reattach by name, so the server must not persist them across restarts.
/// Explicitly-named (`--session work`) and prewarmed sessions are not implicit.
pub fn is_implicit_session_name(name: &str) -> bool {
let Some(rest) = name.strip_prefix("term-") else {
return false;
};
let mut parts = rest.split('-');
match (parts.next(), parts.next(), parts.next()) {
(Some(millis), Some(pid), None) => {
!millis.is_empty()
&& millis.bytes().all(|b| b.is_ascii_digit())
&& !pid.is_empty()
&& pid.bytes().all(|b| b.is_ascii_digit())
}
_ => false,
}
}
pub const MAGIC: &[u8; 4] = b"DOSH";
pub const VERSION: u8 = 2;
// v4: added reliable stream offsets/acks to `StreamData` and
// `StreamWindowAdjust`.
// v3: added `ForwardingKind::Agent` (SSH-agent forwarding). The new variant rides
// inside `NativeUserAuth.requested_forwardings`, so a pre-agent peer would fail to
// deserialize it; bumping the wire version makes such a peer answer with a clear
// version-mismatch reject instead. Existing variants' bincode discriminants are
// unchanged, so the bump is purely a compatibility gate.
pub const VERSION: u8 = 4;
pub const HEADER_LEN: usize = 58;
/// Stable, user-facing reason string the server puts in an `AttachReject` when a
@@ -378,12 +418,14 @@ pub struct StreamOpenReject {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamData {
pub stream_id: u64,
pub offset: u64,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamWindowAdjust {
pub stream_id: u64,
pub received_offset: u64,
pub bytes: u32,
}
@@ -491,3 +533,41 @@ impl ReplayWindow {
}
}
}
#[cfg(test)]
mod session_name_tests {
use super::{generate_implicit_session_name, is_implicit_session_name};
#[test]
fn generated_names_are_recognized_as_implicit() {
let name = generate_implicit_session_name();
assert!(is_implicit_session_name(&name), "generated: {name}");
}
#[test]
fn explicit_and_prewarm_names_are_not_implicit() {
for name in [
"default",
"work",
"logs",
"term",
"term-",
"term-abc-1",
"term-1-x",
"term-1",
"term-1-2-3",
"",
] {
assert!(
!is_implicit_session_name(name),
"should not be implicit: {name:?}"
);
}
}
#[test]
fn implicit_shape_matches() {
assert!(is_implicit_session_name("term-1781470634216-76685"));
assert!(is_implicit_session_name("term-0-0"));
}
}
+197 -20
View File
@@ -1,15 +1,42 @@
use anyhow::{Context, Result};
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use std::io::{Read, Write};
use std::path::Path;
use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread;
use tokio::sync::mpsc;
pub struct PtyHandle {
writer: Arc<Mutex<Box<dyn Write + Send>>>,
// Keep live terminal output comfortably below common path MTUs after Dosh's
// protocol header, AEAD tag, UDP/IP headers, and bincode framing. Full-screen
// TUIs often write several KiB on the first draw; sending that as one UDP
// datagram can fragment and vanish, leaving only a blank alternate screen.
const PTY_OUTPUT_CHUNK_BYTES: usize = 1024;
/// Backing for a PTY master held by the server.
///
/// `Owned` means this process spawned the shell as a child and is responsible
/// for it: dropping the handle kills the shell. This is the original,
/// non-persistent model and stays the default.
///
/// `Adopted` means the shell lives in a separate holder process and this handle
/// only borrows the master fd (received over a Unix socket via SCM_RIGHTS).
/// Dropping it must NOT kill the shell — it just closes our copy of the fd and
/// stops the reader thread, leaving the holder + shell alive so a server restart
/// can re-adopt them.
enum Backing {
Owned {
child: Mutex<Box<dyn Child + Send + Sync>>,
_master: Box<dyn MasterPty + Send>,
},
Adopted {
master: Mutex<std::fs::File>,
},
}
pub struct PtyHandle {
writer: Arc<Mutex<Box<dyn Write + Send>>>,
backing: Backing,
}
impl PtyHandle {
@@ -21,23 +48,40 @@ impl PtyHandle {
}
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
self._master.resize(PtySize {
match &self.backing {
Backing::Owned { _master, .. } => {
_master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})?;
}
Backing::Adopted { master } => {
let file = master.lock().expect("pty master poisoned");
resize_fd(file.as_raw_fd(), cols, rows)?;
}
}
Ok(())
}
/// True for a handle backed by a separate holder process. Such a handle must
/// be detached (not killed) when the server lets go of a session, so the
/// shell survives a server restart.
pub fn is_persistent(&self) -> bool {
matches!(self.backing, Backing::Adopted { .. })
}
/// Terminate the shell process backing this PTY and reap it.
///
/// Without this the child shell outlives the session: dropping the master
/// alone is not guaranteed to take the process down, and the `Child` handle
/// used to be discarded at spawn time, which leaked one shell per
/// abandoned session.
/// Only meaningful for an `Owned` backing (the server spawned the shell as a
/// child). For an `Adopted` backing the shell belongs to the holder process,
/// so this is a no-op here; reaping a persistent session is done by asking
/// the holder to shut down (see `persist::request_shutdown`).
pub fn kill(&self) {
if let Ok(mut child) = self.child.lock() {
if let Backing::Owned { child, .. } = &self.backing
&& let Ok(mut child) = child.lock()
{
let _ = child.kill();
let _ = child.wait();
}
@@ -46,9 +90,28 @@ impl PtyHandle {
impl Drop for PtyHandle {
fn drop(&mut self) {
// Adopted handles must NOT kill the shell: it lives in the holder so it
// can outlive this server. Dropping just closes our fd / stops the
// reader. Owned handles keep the original kill-on-drop behavior.
if let Backing::Owned { .. } = &self.backing {
self.kill();
}
}
}
fn resize_fd(fd: RawFd, cols: u16, rows: u16) -> Result<()> {
let winsize = libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
let rc = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &winsize) };
if rc != 0 {
return Err(std::io::Error::last_os_error()).context("TIOCSWINSZ");
}
Ok(())
}
#[derive(Debug)]
pub struct PtyOutput {
@@ -74,12 +137,82 @@ pub fn spawn_pty_session(
pixel_height: 0,
})
.context("open pty")?;
let cmd = build_shell_command(shell, env);
let child = pair.slave.spawn_command(cmd).context("spawn shell")?;
drop(pair.slave);
let writer = pair.master.take_writer().context("take pty writer")?;
let reader = pair.master.try_clone_reader().context("clone pty reader")?;
spawn_reader_thread(session, reader, tx)?;
Ok(PtyHandle {
writer: Arc::new(Mutex::new(writer)),
backing: Backing::Owned {
child: Mutex::new(child),
_master: pair.master,
},
})
}
/// Whether this host has a terminfo entry for `term`, searching the standard
/// ncurses directories. Both the legacy single-letter (`x/xterm`) and the
/// hashed (`78/xterm`) subdirectory layouts are checked.
fn terminfo_available(term: &str) -> bool {
let Some(first) = term.chars().next() else {
return false;
};
let letter = first.to_string();
let hashed = format!("{:x}", first as u32);
let mut dirs: Vec<PathBuf> = Vec::new();
if let Ok(t) = std::env::var("TERMINFO")
&& !t.is_empty()
{
dirs.push(PathBuf::from(t));
}
if let Some(home) = dirs::home_dir() {
dirs.push(home.join(".terminfo"));
}
if let Ok(td) = std::env::var("TERMINFO_DIRS") {
for d in td.split(':').filter(|d| !d.is_empty()) {
dirs.push(PathBuf::from(d));
}
}
for d in [
"/etc/terminfo",
"/lib/terminfo",
"/usr/share/terminfo",
"/usr/lib/terminfo",
"/usr/share/lib/terminfo",
] {
dirs.push(PathBuf::from(d));
}
dirs.iter()
.any(|dir| dir.join(&letter).join(term).exists() || dir.join(&hashed).join(term).exists())
}
/// Build the [`CommandBuilder`] for a dosh shell, identically for the in-process
/// `spawn_pty_session` and the out-of-process holder, so a persistent session's
/// environment matches a non-persistent one.
pub fn build_shell_command(shell: &str, env: &[(String, String)]) -> CommandBuilder {
let mut cmd = CommandBuilder::new(shell);
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
for (name, value) in env {
cmd.env(name, value);
}
// The client's TERM is propagated, but a server that lacks that terminal's
// terminfo entry (e.g. xterm-ghostty, xterm-kitty) breaks ncurses apps like
// tmux/vim with "missing or unsuitable terminal". Keep the requested TERM
// only when this host actually has its terminfo; otherwise fall back to a
// universally available entry so remote apps always work.
let term = match env
.iter()
.find(|(n, _)| n == "TERM")
.map(|(_, v)| v.as_str())
{
Some(requested) if terminfo_available(requested) => requested.to_string(),
_ => "xterm-256color".to_string(),
};
cmd.env("TERM", term);
cmd.env("SHELL", shell);
if let Some(home) = dirs::home_dir() {
cmd.env("HOME", home.as_os_str());
@@ -88,11 +221,39 @@ pub fn spawn_pty_session(
} else if let Some(parent) = Path::new(shell).parent() {
cmd.env("PWD", parent.as_os_str());
}
let child = pair.slave.spawn_command(cmd).context("spawn shell")?;
drop(pair.slave);
cmd
}
let writer = pair.master.take_writer().context("take pty writer")?;
let mut reader = pair.master.try_clone_reader().context("clone pty reader")?;
/// Build a [`PtyHandle`] from a master fd received from a holder process.
///
/// `master_fd` is an fd this handle takes ownership of (it is wrapped in a
/// `File` and closed on drop). The shell is NOT a child of this process; it
/// belongs to the holder, so dropping this handle leaves it running.
pub fn adopt_pty_from_fd(
session: String,
master_fd: RawFd,
tx: mpsc::UnboundedSender<PtyOutput>,
) -> Result<PtyHandle> {
// Take ownership of the fd. A clone gives us an independent reader so the
// reader thread and the writer/resize side hold separate `File`s and don't
// get closed out from under each other.
let master = unsafe { std::fs::File::from_raw_fd(master_fd) };
let reader_file = master.try_clone().context("clone master for reader")?;
let writer_file = master.try_clone().context("clone master for writer")?;
spawn_reader_thread(session, Box::new(reader_file), tx)?;
Ok(PtyHandle {
writer: Arc::new(Mutex::new(Box::new(writer_file))),
backing: Backing::Adopted {
master: Mutex::new(master),
},
})
}
fn spawn_reader_thread(
session: String,
mut reader: Box<dyn Read + Send>,
tx: mpsc::UnboundedSender<PtyOutput>,
) -> Result<()> {
let reader_session = session.clone();
thread::Builder::new()
.name(format!("dosh-pty-{session}"))
@@ -109,12 +270,14 @@ pub fn spawn_pty_session(
break;
}
Ok(n) => {
for chunk in buf[..n].chunks(PTY_OUTPUT_CHUNK_BYTES) {
let _ = tx.send(PtyOutput {
session: reader_session.clone(),
bytes: buf[..n].to_vec(),
bytes: chunk.to_vec(),
exited: false,
});
}
}
Err(_) => {
let _ = tx.send(PtyOutput {
session: reader_session.clone(),
@@ -127,10 +290,24 @@ pub fn spawn_pty_session(
}
})
.context("spawn pty reader")?;
Ok(())
}
Ok(PtyHandle {
writer: Arc::new(Mutex::new(writer)),
child: Mutex::new(child),
_master: pair.master,
})
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminfo_available_detects_known_and_unknown() {
// A near-universal entry should be present on any host with ncurses.
assert!(terminfo_available("xterm") || terminfo_available("xterm-256color"));
// Bogus / empty names must report missing so we fall back.
assert!(!terminfo_available("definitely-not-a-real-terminal-xyz"));
assert!(!terminfo_available(""));
}
#[test]
fn pty_output_chunk_size_stays_mtu_safe() {
assert!(PTY_OUTPUT_CHUNK_BYTES <= 1200);
}
}
+61 -23
View File
@@ -1,6 +1,6 @@
use crate::native::{
ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth,
parse_ssh_ed25519_public_blob, user_auth_transcript,
is_supported_user_key_algorithm, parse_ssh_ed25519_public_blob, user_auth_transcript,
};
use anyhow::{Context, Result, anyhow, bail};
use std::io::{Read, Write};
@@ -12,12 +12,16 @@ const SSH2_AGENTC_REQUEST_IDENTITIES: u8 = 11;
const SSH2_AGENT_IDENTITIES_ANSWER: u8 = 12;
const SSH2_AGENTC_SIGN_REQUEST: u8 = 13;
const SSH2_AGENT_SIGN_RESPONSE: u8 = 14;
const SSH_AGENT_RSA_SHA2_512: u32 = 4;
const MAX_AGENT_PACKET: usize = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentIdentity {
pub key_blob: Vec<u8>,
pub public_key: [u8; 32],
pub public_key_algorithm: String,
pub public_key: Vec<u8>,
pub sign_algorithm: String,
pub sign_flags: u32,
pub comment: String,
}
@@ -38,13 +42,13 @@ pub fn sign_user_auth_with_agent_at(
) -> Result<NativeUserAuth> {
let mut agent = UnixStream::connect(socket_path.as_ref())
.with_context(|| format!("connect ssh-agent {}", socket_path.as_ref().display()))?;
let identities = request_ed25519_identities(&mut agent)?;
let identities = request_supported_identities(&mut agent)?;
let identity = identities
.first()
.ok_or_else(|| anyhow!("ssh-agent has no ssh-ed25519 identities"))?;
.ok_or_else(|| anyhow!("ssh-agent has no supported identities"))?;
let mut auth = NativeUserAuth {
public_key_algorithm: "ssh-ed25519".to_string(),
public_key: identity.public_key.to_vec(),
public_key_algorithm: identity.sign_algorithm.clone(),
public_key: identity.public_key.clone(),
signature: Vec::new(),
requested_forwardings,
};
@@ -54,7 +58,7 @@ pub fn sign_user_auth_with_agent_at(
Ok(auth)
}
fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> {
fn request_supported_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> {
write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?;
let payload = read_agent_packet(agent)?;
let mut cursor = payload.as_slice();
@@ -72,18 +76,49 @@ fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentit
for _ in 0..count {
let key_blob = read_ssh_string(&mut cursor)?.to_vec();
let comment = String::from_utf8_lossy(read_ssh_string(&mut cursor)?).to_string();
if let Ok(public_key) = parse_ssh_ed25519_public_blob(&key_blob) {
identities.push(AgentIdentity {
key_blob,
public_key,
comment,
});
if let Some(identity) = supported_identity(key_blob, comment)? {
identities.push(identity);
}
}
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities");
Ok(identities)
}
fn supported_identity(key_blob: Vec<u8>, comment: String) -> Result<Option<AgentIdentity>> {
let algorithm = key_blob_algorithm(&key_blob)?;
if !is_supported_user_key_algorithm(&algorithm) {
return Ok(None);
}
let identity = match algorithm.as_str() {
"ssh-ed25519" => AgentIdentity {
public_key_algorithm: algorithm.clone(),
public_key: parse_ssh_ed25519_public_blob(&key_blob)?.to_vec(),
sign_algorithm: "ssh-ed25519".to_string(),
sign_flags: 0,
key_blob,
comment,
},
"ecdsa-sha2-nistp256" => AgentIdentity {
public_key_algorithm: algorithm.clone(),
public_key: key_blob.clone(),
sign_algorithm: algorithm,
sign_flags: 0,
key_blob,
comment,
},
"ssh-rsa" => AgentIdentity {
public_key_algorithm: algorithm,
public_key: key_blob.clone(),
sign_algorithm: "rsa-sha2-512".to_string(),
sign_flags: SSH_AGENT_RSA_SHA2_512,
key_blob,
comment,
},
_ => return Ok(None),
};
Ok(Some(identity))
}
fn sign_with_agent(
agent: &mut UnixStream,
identity: &AgentIdentity,
@@ -93,7 +128,7 @@ fn sign_with_agent(
request.push(SSH2_AGENTC_SIGN_REQUEST);
write_ssh_string(&mut request, &identity.key_blob);
write_ssh_string(&mut request, transcript);
request.extend_from_slice(&0u32.to_be_bytes());
request.extend_from_slice(&identity.sign_flags.to_be_bytes());
write_agent_packet(agent, &request)?;
let payload = read_agent_packet(agent)?;
@@ -114,20 +149,17 @@ fn sign_with_agent(
let mut signature_cursor = signature_blob;
let algorithm = read_ssh_string(&mut signature_cursor)?;
let algorithm = String::from_utf8_lossy(algorithm);
anyhow::ensure!(
algorithm == b"ssh-ed25519",
"ssh-agent returned unsupported signature algorithm {}",
String::from_utf8_lossy(algorithm)
algorithm == identity.sign_algorithm,
"ssh-agent returned signature algorithm {algorithm}, expected {}",
identity.sign_algorithm
);
let signature = read_ssh_string(&mut signature_cursor)?;
anyhow::ensure!(
signature_cursor.is_empty(),
"trailing data in ssh-agent signature blob"
);
anyhow::ensure!(
signature.len() == 64,
"ssh-agent Ed25519 signature was not 64 bytes"
);
Ok(signature.to_vec())
}
@@ -182,6 +214,12 @@ fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(value);
}
fn key_blob_algorithm(blob: &[u8]) -> Result<String> {
let mut cursor = blob;
let algorithm = read_ssh_string(&mut cursor)?;
Ok(String::from_utf8_lossy(algorithm).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -253,8 +291,8 @@ mod tests {
protocol_version: crate::native::NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(),
requested_user: "palav".to_string(),
requested_host: "homelab".to_string(),
requested_user: "alice".to_string(),
requested_session: "term".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
+498 -3
View File
@@ -20,7 +20,8 @@
//! tests are reproducible and fast.
use std::fs;
use std::net::{SocketAddr, UdpSocket};
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, UdpSocket};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -28,13 +29,18 @@ use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread;
use std::time::{Duration, Instant};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use dosh::auth::{BootstrapResponse, build_bootstrap, load_or_create_server_secret};
use dosh::config::load_server_config;
use dosh::crypto;
use dosh::native::{self, ForwardingKind, ForwardingRequest, NativeAuthOk, NativeClientHello};
use dosh::protocol::{
self, AttachOk, CLIENT_TO_SERVER, Frame, Header, Input, PacketKind, ResumeRequest,
SERVER_TO_CLIENT,
self, AttachOk, AttachReject, CLIENT_TO_SERVER, Frame, Header, Input, NativeAuthOkBody,
NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind, ResumeRequest,
SERVER_TO_CLIENT, StreamData, StreamOpen, StreamOpenOk, StreamWindowAdjust,
};
use ed25519_dalek::{SigningKey, VerifyingKey};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
@@ -67,6 +73,7 @@ sessions_dir = "{sessions}"
secret_path = "{secret}"
host_key = "{host_key}"
authorized_keys = ["{authorized_keys}"]
persist_sessions = false
"#,
sessions = dir.path().join("sessions").display(),
secret = dir.path().join("secret").display(),
@@ -78,6 +85,23 @@ authorized_keys = ["{authorized_keys}"]
config
}
fn authorize_ed25519_key(dir: &tempfile::TempDir, signing_key: &SigningKey) {
let verifying = VerifyingKey::from(signing_key);
let mut blob = Vec::new();
write_ssh_string(&mut blob, b"ssh-ed25519");
write_ssh_string(&mut blob, verifying.as_bytes());
fs::write(
dir.path().join("authorized_keys"),
format!("ssh-ed25519 {} hostile-test\n", STANDARD.encode(blob)),
)
.unwrap();
}
fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
out.extend_from_slice(value);
}
fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child {
let server = env!("CARGO_BIN_EXE_dosh-server");
let child = Command::new(server)
@@ -401,6 +425,122 @@ fn attach_through_relay(
}
}
struct NativeAttached {
socket: UdpSocket,
ok: NativeAuthOk,
}
fn native_attach_through_relay(
relay: &Relay,
signing_key: &SigningKey,
requested_forwardings: Vec<ForwardingRequest>,
) -> NativeAttached {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(500)))
.unwrap();
let (client_secret, client_public) = native::generate_native_ephemeral();
let hello = 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: std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()),
requested_session: "default".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: native::supported_user_key_algorithms(),
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: Vec::new(),
};
let packet = protocol::encode_plain(
PacketKind::NativeClientHello,
[0u8; 16],
1,
0,
&protocol::to_body(&NativeClientHelloBody {
hello: hello.clone(),
})
.unwrap(),
)
.unwrap();
let hello_deadline = Instant::now() + Duration::from_secs(5);
let server_hello = loop {
socket.send_to(&packet, relay.front_addr()).unwrap();
let mut buf = [0u8; 65535];
match socket.recv_from(&mut buf) {
Ok((n, _)) => {
let packet = protocol::decode(&buf[..n]).unwrap();
match packet.header.kind {
PacketKind::NativeServerHello => {
let body: NativeServerHelloBody =
protocol::from_body(&packet.body).unwrap();
native::verify_server_hello(&hello, &body.hello).unwrap();
break body.hello;
}
PacketKind::AttachReject => {
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
panic!("native hello rejected: {}", reject.reason);
}
kind => panic!("unexpected native hello response: {kind:?}"),
}
}
Err(_) if Instant::now() < hello_deadline => {}
Err(err) => panic!("native server hello timed out: {err}"),
}
};
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_key, &hello, &server_hello, requested_forwardings).unwrap();
let mut pending_id = [0u8; 16];
pending_id.copy_from_slice(&server_hello.auth_challenge[..16]);
let auth_packet = protocol::encode_encrypted(
PacketKind::NativeUserAuth,
pending_id,
2,
1,
&session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&NativeUserAuthBody { auth }).unwrap(),
)
.unwrap();
let auth_deadline = Instant::now() + Duration::from_secs(5);
let ok = loop {
socket.send_to(&auth_packet, relay.front_addr()).unwrap();
let mut buf = [0u8; 65535];
match socket.recv_from(&mut buf) {
Ok((n, _)) => {
let packet = protocol::decode(&buf[..n]).unwrap();
match packet.header.kind {
PacketKind::NativeAuthOk => {
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT)
.unwrap();
let body: NativeAuthOkBody = protocol::from_body(&plain).unwrap();
break body.ok;
}
PacketKind::AttachReject => {
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
panic!("native auth rejected: {}", reject.reason);
}
kind => panic!("unexpected native auth response: {kind:?}"),
}
}
Err(_) if Instant::now() < auth_deadline => {}
Err(err) => panic!("native auth ok timed out: {err}"),
}
};
NativeAttached { socket, ok }
}
fn send_input(
socket: &UdpSocket,
relay: &Relay,
@@ -430,6 +570,152 @@ fn send_raw(socket: &UdpSocket, relay: &Relay, packet: &[u8]) {
socket.send_to(packet, relay.front_addr()).unwrap();
}
fn send_stream_packet(
socket: &UdpSocket,
relay: &Relay,
ok: &NativeAuthOk,
kind: PacketKind,
seq: u64,
ack: u64,
body: Vec<u8>,
) {
let packet = protocol::encode_encrypted(
kind,
ok.client_id,
seq,
ack,
&ok.session_key,
CLIENT_TO_SERVER,
&body,
)
.unwrap();
socket.send_to(&packet, relay.front_addr()).unwrap();
}
fn recv_stream_packet<T: serde::de::DeserializeOwned>(
socket: &UdpSocket,
key: &[u8; 32],
kind: PacketKind,
) -> Option<(Header, T)> {
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).ok()?;
let packet = protocol::decode(&buf[..n]).ok()?;
if packet.header.kind != kind {
return None;
}
let plain = protocol::decrypt_body(&packet, key, SERVER_TO_CLIENT).ok()?;
let body = protocol::from_body(&plain).ok()?;
Some((packet.header, body))
}
fn wait_for_stream_open_ok(
socket: &UdpSocket,
key: &[u8; 32],
stream_id: u64,
millis: u64,
) -> Option<Header> {
let deadline = Instant::now() + Duration::from_millis(millis);
while Instant::now() < deadline {
if let Some((header, ok)) =
recv_stream_packet::<StreamOpenOk>(socket, key, PacketKind::StreamOpenOk)
{
if ok.stream_id == stream_id {
return Some(header);
}
}
}
None
}
fn wait_for_stream_data(
socket: &UdpSocket,
key: &[u8; 32],
stream_id: u64,
millis: u64,
) -> Option<(Header, StreamData)> {
let deadline = Instant::now() + Duration::from_millis(millis);
while Instant::now() < deadline {
if let Some((header, data)) =
recv_stream_packet::<StreamData>(socket, key, PacketKind::StreamData)
{
if data.stream_id == stream_id {
return Some((header, data));
}
}
}
None
}
fn start_tcp_collector() -> (u16, Receiver<Vec<u8>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, rx) = channel();
thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
stream
.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut buf = [0u8; 1024];
while Instant::now() < deadline {
match stream.read(&mut buf) {
Ok(0) => return,
Ok(n) => {
let _ = tx.send(buf[..n].to_vec());
}
Err(ref err)
if err.kind() == std::io::ErrorKind::WouldBlock
|| err.kind() == std::io::ErrorKind::TimedOut => {}
Err(_) => return,
}
}
});
(port, rx)
}
fn start_tcp_echo() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
stream
.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut buf = [0u8; 1024];
while Instant::now() < deadline {
match stream.read(&mut buf) {
Ok(0) => return,
Ok(n) => {
let _ = stream.write_all(&buf[..n]);
}
Err(ref err)
if err.kind() == std::io::ErrorKind::WouldBlock
|| err.kind() == std::io::ErrorKind::TimedOut => {}
Err(_) => return,
}
}
});
port
}
fn collect_tcp(rx: &Receiver<Vec<u8>>, millis: u64) -> Vec<u8> {
let deadline = Instant::now() + Duration::from_millis(millis);
let mut out = Vec::new();
while Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(50)) {
Ok(chunk) => out.extend_from_slice(&chunk),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
out
}
fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(Header, Frame)> {
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).ok()?;
@@ -602,6 +888,215 @@ fn duplicated_and_replayed_input_is_applied_at_most_once() {
);
}
#[test]
fn forwarded_stream_data_survives_reorder_and_rejects_replay() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let signing_key = SigningKey::from_bytes(&[42u8; 32]);
authorize_ed25519_key(&dir, &signing_key);
let (target_port, tcp_rx) = start_tcp_collector();
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0xF0E0D0u64);
let attached = native_attach_through_relay(
&relay,
&signing_key,
vec![ForwardingRequest {
kind: ForwardingKind::Local,
bind_host: Some("127.0.0.1".to_string()),
listen_port: 0,
target_host: Some("127.0.0.1".to_string()),
target_port: Some(target_port),
}],
);
let stream_id = 44;
let open = StreamOpen {
stream_id,
target_host: "127.0.0.1".to_string(),
target_port,
};
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamOpen,
3,
attached.ok.initial_seq,
protocol::to_body(&open).unwrap(),
);
let open_ok =
wait_for_stream_open_ok(&attached.socket, &attached.ok.session_key, stream_id, 3000)
.expect("stream did not open");
// Swap the first two StreamData packets in flight. The server's replay
// window must accept both out-of-order packets and write both to the TCP
// target exactly once.
relay.arm_reorder();
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamData,
4,
open_ok.seq,
protocol::to_body(&StreamData {
stream_id,
offset: 0,
bytes: b"A".to_vec(),
})
.unwrap(),
);
relay.arm_reorder();
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamData,
5,
open_ok.seq,
protocol::to_body(&StreamData {
stream_id,
offset: 1,
bytes: b"B".to_vec(),
})
.unwrap(),
);
let mut seen = collect_tcp(&tcp_rx, 1500);
assert!(
seen.starts_with(b"AB"),
"reordered stream bytes were not delivered once in-order to TCP target: {:?}",
String::from_utf8_lossy(&seen)
);
// Now send one encrypted StreamData packet repeatedly, with relay-level
// duplication enabled too. This is a true replay: same sequence, same nonce,
// same ciphertext. It must be applied to the TCP target at most once.
let replayed = protocol::encode_encrypted(
PacketKind::StreamData,
attached.ok.client_id,
6,
open_ok.seq,
&attached.ok.session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&StreamData {
stream_id,
offset: 2,
bytes: b"X".to_vec(),
})
.unwrap(),
)
.unwrap();
relay.set_dup(100);
for _ in 0..12 {
send_raw(&attached.socket, &relay, &replayed);
thread::sleep(Duration::from_millis(25));
}
relay.set_dup(0);
seen.extend(collect_tcp(&tcp_rx, 1500));
let replay_count = seen.iter().filter(|byte| **byte == b'X').count();
drop(relay);
let _ = server.kill();
let _ = server.wait();
assert!(
replay_count <= 1,
"replayed/duplicated StreamData was applied {replay_count} times: {:?}",
String::from_utf8_lossy(&seen)
);
}
#[test]
fn forwarded_stream_data_recovers_after_server_to_client_loss() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let signing_key = SigningKey::from_bytes(&[43u8; 32]);
authorize_ed25519_key(&dir, &signing_key);
let target_port = start_tcp_echo();
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0xA11CEu64);
let attached = native_attach_through_relay(
&relay,
&signing_key,
vec![ForwardingRequest {
kind: ForwardingKind::Local,
bind_host: Some("127.0.0.1".to_string()),
listen_port: 0,
target_host: Some("127.0.0.1".to_string()),
target_port: Some(target_port),
}],
);
let stream_id = 55;
let open = StreamOpen {
stream_id,
target_host: "127.0.0.1".to_string(),
target_port,
};
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamOpen,
3,
attached.ok.initial_seq,
protocol::to_body(&open).unwrap(),
);
let open_ok =
wait_for_stream_open_ok(&attached.socket, &attached.ok.session_key, stream_id, 3000)
.expect("stream did not open");
// Drop the first server->client echo and at least one retransmit tick. Once
// the link clears, the unacked stream chunk must be re-encrypted with a new
// packet sequence and delivered at the same stream offset.
relay.set_drop_s2c(100);
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamData,
4,
open_ok.seq,
protocol::to_body(&StreamData {
stream_id,
offset: 0,
bytes: b"PING".to_vec(),
})
.unwrap(),
);
thread::sleep(Duration::from_millis(300));
relay.set_drop_s2c(0);
let (_header, data) =
wait_for_stream_data(&attached.socket, &attached.ok.session_key, stream_id, 3000)
.expect("lost server->client stream data was not retransmitted");
assert_eq!(data.offset, 0);
assert_eq!(data.bytes, b"PING");
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamWindowAdjust,
5,
open_ok.seq,
protocol::to_body(&StreamWindowAdjust {
stream_id,
received_offset: 4,
bytes: 4,
})
.unwrap(),
);
drop(relay);
let _ = server.kill();
let _ = server.wait();
}
#[test]
fn stale_packets_after_resume_are_ignored_not_fatal() {
let dir = tempfile::tempdir().unwrap();
+873 -1
View File
@@ -4,6 +4,7 @@ use std::net::{TcpListener, TcpStream, UdpSocket};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
@@ -44,6 +45,17 @@ fn write_server_config_with_remote_forwarding(
config
}
fn write_server_config_with_agent_forwarding(
dir: &tempfile::TempDir,
port: u16,
) -> std::path::PathBuf {
let config = write_server_config(dir, port);
let mut raw = fs::read_to_string(&config).unwrap();
raw.push_str("allow_agent_forwarding = true\n");
fs::write(&config, raw).unwrap();
config
}
fn write_server_config_with_timeout(
dir: &tempfile::TempDir,
port: u16,
@@ -72,6 +84,7 @@ sessions_dir = "{sessions}"
secret_path = "{secret}"
host_key = "{host_key}"
authorized_keys = ["{authorized_keys}"]
persist_sessions = false
"#,
sessions = dir.path().join("sessions").display(),
secret = dir.path().join("secret").display(),
@@ -222,6 +235,37 @@ fn start_echo_server() -> u16 {
port
}
fn start_slow_sink_server() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 8192];
loop {
match stream.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(_) => thread::sleep(Duration::from_millis(20)),
}
}
}
});
port
}
fn connect_with_retry_no_child(port: u16, timeout: Duration) -> TcpStream {
let deadline = std::time::Instant::now() + timeout;
loop {
match TcpStream::connect(("127.0.0.1", port)) {
Ok(stream) => return stream,
Err(err) if std::time::Instant::now() < deadline => {
let _ = err;
thread::sleep(Duration::from_millis(50));
}
Err(err) => panic!("connect to local forward 127.0.0.1:{port}: {err}"),
}
}
}
fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Path) {
write_native_client_auth_with_options(dir, config_path, "");
}
@@ -326,6 +370,40 @@ fn fake_ssh_agent(listener: UnixListener, key_blob: Vec<u8>, signing: SigningKey
write_agent_packet(&mut stream, &response).unwrap();
}
/// A fake ssh-agent that serves repeated connections and only answers
/// REQUEST_IDENTITIES (enough to prove the forwarded socket reaches the real
/// local agent). Returns the comment string it advertises so the test can match.
fn start_fake_ssh_agent_looping(socket_path: &Path, comment: &'static str) {
let signing = SigningKey::from_bytes(&[77u8; 32]);
let public = VerifyingKey::from(&signing).to_bytes();
let key_blob = ssh_ed25519_public_blob(&public);
let listener = UnixListener::bind(socket_path).unwrap();
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
let key_blob = key_blob.clone();
thread::spawn(move || {
while let Ok(request) = read_agent_packet(&mut stream) {
// Only the identities request (11) is exercised.
if request.first() == Some(&11) {
let mut response = Vec::new();
response.push(12); // IDENTITIES_ANSWER
response.extend_from_slice(&1u32.to_be_bytes());
write_ssh_string(&mut response, &key_blob);
write_ssh_string(&mut response, comment.as_bytes());
if write_agent_packet(&mut stream, &response).is_err() {
break;
}
} else {
// Unknown request: reply AGENT_FAILURE (5).
let _ = write_agent_packet(&mut stream, &[5]);
}
}
});
}
});
}
fn read_agent_packet(stream: &mut UnixStream) -> std::io::Result<Vec<u8>> {
let mut len = [0u8; 4];
stream.read_exact(&mut len)?;
@@ -368,6 +446,15 @@ fn direct_attach(
config: &std::path::Path,
port: u16,
mode: &str,
) -> (std::net::UdpSocket, dosh::auth::BootstrapResponse, AttachOk) {
direct_attach_session(config, port, mode, "default")
}
fn direct_attach_session(
config: &std::path::Path,
port: u16,
mode: &str,
session: &str,
) -> (std::net::UdpSocket, dosh::auth::BootstrapResponse, AttachOk) {
let config = load_server_config(Some(config.to_path_buf())).unwrap();
let secret = load_or_create_server_secret(&config).unwrap();
@@ -375,7 +462,7 @@ fn direct_attach(
&config,
&secret,
"tester".to_string(),
"default".to_string(),
session.to_string(),
mode.to_string(),
(80, 24),
crypto::random_12(),
@@ -736,6 +823,106 @@ fn native_local_forward_background_smoke() {
assert_eq!(&buf[..n], b"dosh-background-forward-ping");
}
#[test]
fn native_local_forward_bulk_load_does_not_delay_interactive_terminal() {
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let sink_port = start_slow_sink_server();
let local_port = free_tcp_port();
let mut server = start_server(&dir, &config);
let pty = NativePtySystem::default();
let pair = pty
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let mut reader = pair.master.try_clone_reader().unwrap();
let mut writer = pair.master.take_writer().unwrap();
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let mut cmd = CommandBuilder::new(client_bin);
let forward_arg = format!("{local_port}:127.0.0.1:{sink_port}");
let dosh_port = port.to_string();
cmd.args([
"--auth",
"native",
"--no-cache",
"-L",
&forward_arg,
"--session",
"load-session",
"--dosh-host",
"127.0.0.1",
"--dosh-port",
&dosh_port,
"local",
]);
cmd.env("HOME", dir.path().to_string_lossy().to_string());
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
let mut forward_stream = connect_with_retry_no_child(local_port, Duration::from_secs(5));
let bulk_writer = thread::spawn(move || {
let chunk = vec![b'x'; 8192];
for _ in 0..128 {
if forward_stream.write_all(&chunk).is_err() {
break;
}
}
});
let (seen_tx, seen_rx) = mpsc::channel();
let reader_handle = thread::spawn(move || {
let mut buf = [0u8; 4096];
let mut output = Vec::new();
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
output.extend_from_slice(&buf[..n]);
if output
.windows(b"DOSH_LOAD_TERMINAL_OK".len())
.any(|w| w == b"DOSH_LOAD_TERMINAL_OK")
{
let _ = seen_tx.send(String::from_utf8_lossy(&output).to_string());
break;
}
}
}
}
});
thread::sleep(Duration::from_millis(250));
writer
.write_all(b"printf DOSH_LOAD_TERMINAL_OK\\n\r")
.unwrap();
writer.flush().unwrap();
let seen = seen_rx.recv_timeout(Duration::from_secs(3));
let _ = child.kill();
let _ = child.wait();
drop(writer);
drop(pair.master);
let _ = reader_handle.join();
let _ = bulk_writer.join();
let _ = server.kill();
let _ = server.wait();
assert!(
seen.is_ok(),
"interactive terminal output was delayed behind local-forward bulk load"
);
}
#[test]
fn native_remote_forward_echo_smoke() {
let dir = tempfile::tempdir().unwrap();
@@ -1134,6 +1321,117 @@ fn live_output_forwards_terminal_control_sequences() {
);
}
#[test]
fn tui_control_sequences_survive_transport_verbatim() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let sequences = concat!(
"\x1b[?1049h", // alternate screen on
"\x1b[?2004h", // bracketed paste on
"\x1b[?1000h", // mouse tracking on
"\x1b[?1006h", // SGR mouse encoding on
"\x1b[?25l", // cursor hidden
"\x1b[12;34H", // absolute cursor movement
"DOSH_TUI_VERBATIM",
"\x1b[?25h",
"\x1b[?1006l",
"\x1b[?1000l",
"\x1b[?2004l",
"\x1b[?1049l"
);
let input = Input {
bytes: format!("printf '{sequences}'\n").into_bytes(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let text = collect_frame_text(&socket, &bootstrap.session_key, 2000);
let _ = server.kill();
let _ = server.wait();
for needle in [
"\x1b[?1049h",
"\x1b[?2004h",
"\x1b[?1000h",
"\x1b[?1006h",
"\x1b[?25l",
"\x1b[12;34H",
"DOSH_TUI_VERBATIM",
"\x1b[?25h",
"\x1b[?1006l",
"\x1b[?1000l",
"\x1b[?2004l",
"\x1b[?1049l",
] {
assert!(
text.contains(needle),
"expected TUI control sequence {needle:?} to survive verbatim, got {text:?}"
);
}
}
#[test]
fn large_tui_paint_is_delivered_in_mtu_safe_frames() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let input = Input {
bytes: b"printf '\\033[?1049h'; yes DOSH_TUI_BIG_PAINT | head -n 300 | tr -d '\\n'; printf '\\033[?1049l'\n".to_vec(),
};
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 saw_split_frame = false;
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while std::time::Instant::now() < deadline && text.matches("DOSH_TUI_BIG_PAINT").count() < 200 {
if let Some((_header, frame)) = recv_frame(&socket, &bootstrap.session_key) {
if frame.bytes.len() <= 1400 && frame.bytes.len() >= 900 {
saw_split_frame = true;
}
text.push_str(&String::from_utf8_lossy(&frame.bytes));
}
}
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains("\x1b[?1049h") && text.matches("DOSH_TUI_BIG_PAINT").count() >= 200,
"large alternate-screen paint did not survive transport, enter_alt={} markers={} bytes={}",
text.contains("\x1b[?1049h"),
text.matches("DOSH_TUI_BIG_PAINT").count(),
text.len()
);
assert!(
saw_split_frame,
"expected large TUI paint to be split into MTU-safe frames"
);
}
#[test]
fn resume_snapshot_preserves_alternate_screen_mode() {
let dir = tempfile::tempdir().unwrap();
@@ -1247,6 +1545,69 @@ fn resume_updates_udp_endpoint_for_roaming() {
);
}
#[test]
#[ignore = "30-minute launch soak; run with `DOSH_SOAK_SECONDS=1800 cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture`"]
fn sleep_roaming_soak_30m() {
let soak_secs = std::env::var("DOSH_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_with_timeout(&dir, port, soak_secs + 60);
let mut server = start_server(&dir, &config);
let (_old_socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
thread::sleep(Duration::from_secs(soak_secs));
let resumed = UdpSocket::bind("127.0.0.1:0").unwrap();
resumed
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
let resume = ResumeRequest {
session: "default".to_string(),
last_rendered_seq: ok.initial_seq,
cols: 80,
rows: 24,
};
send_encrypted(
&resumed,
port,
PacketKind::ResumeRequest,
ok.client_id,
1,
0,
&bootstrap.session_key,
&protocol::to_body(&resume).unwrap(),
);
let (_header, resume_frame) =
recv_frame(&resumed, &bootstrap.session_key).expect("resume response after soak");
assert!(resume_frame.snapshot);
send_encrypted(
&resumed,
port,
PacketKind::Input,
ok.client_id,
2,
resume_frame.output_seq,
&bootstrap.session_key,
&protocol::to_body(&Input {
bytes: b"printf DOSH_SOAK_OK\\n\n".to_vec(),
})
.unwrap(),
);
let text = collect_frame_text(&resumed, &bootstrap.session_key, 3000);
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains("DOSH_SOAK_OK"),
"session did not survive {soak_secs}s idle/roaming soak; output={text:?}"
);
}
#[test]
fn transport_rekey_round_trip_keeps_session_alive() {
use dosh::native::derive_rekey_session_key;
@@ -1557,3 +1918,514 @@ fn native_hello_with_mismatched_protocol_version_gets_named_reject_not_hang() {
reject.reason
);
}
/// End-to-end SSH-agent forwarding: with -A opt-in AND allow_agent_forwarding on,
/// the server exports a proxy SSH_AUTH_SOCK into the session and tunnels a
/// connection from a "remote process" (here, the test connecting to the proxy
/// socket) all the way back to the client's local ssh-agent. We drive an
/// identities request through the chain and verify the real agent's answer comes
/// back, proving server-proxy -> Dosh stream -> client unix splice -> local agent.
#[test]
fn native_agent_forwarding_round_trip() {
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config_with_agent_forwarding(&dir, port);
write_native_client_auth(&dir, &config);
// Local fake agent the client will splice forwarded connections into.
let agent_sock = dir.path().join("local-agent.sock");
start_fake_ssh_agent_looping(&agent_sock, "forwarded-agent-key");
// Controlled TMPDIR so the server's proxy socket path is deterministic and
// isolated to this test (std::env::temp_dir honors TMPDIR).
let tmpdir = dir.path().join("tmp");
fs::create_dir_all(&tmpdir).unwrap();
let server_bin = env!("CARGO_BIN_EXE_dosh-server");
let mut server = Command::new(server_bin)
.arg("serve")
.arg("--config")
.arg(&config)
.env("HOME", dir.path())
.env("TMPDIR", &tmpdir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
thread::sleep(Duration::from_millis(500));
// Spawn the client inside a real PTY so it can enter raw mode and stay
// attached to an interactive session (agent forwarding needs a spawned shell).
let pty = NativePtySystem::default();
let pair = pty
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let mut cmd = CommandBuilder::new(client_bin);
cmd.args([
"--auth",
"native",
"--no-cache",
"-A",
"--session",
"agent-session",
"--dosh-host",
"127.0.0.1",
"--dosh-port",
&port.to_string(),
"local",
]);
cmd.env("HOME", dir.path().to_string_lossy().to_string());
cmd.env("TMPDIR", tmpdir.to_string_lossy().to_string());
cmd.env("SSH_AUTH_SOCK", agent_sock.to_string_lossy().to_string());
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
// The server's proxy socket is created at session spawn; its path is
// deterministic: TMPDIR/dosh-agent-<server_pid>/agent.<server_pid>.0.sock.
let server_pid = server.id();
let proxy_sock = tmpdir
.join(format!("dosh-agent-{server_pid}"))
.join(format!("agent.{server_pid}.0.sock"));
// Wait for the client to authenticate and the server to spawn the shell +
// bind the proxy socket, then connect as the "remote process" and run an
// identities request through the forwarded chain.
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let answer = loop {
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = server.kill();
let _ = server.wait();
panic!("agent proxy socket {proxy_sock:?} never became usable");
}
if proxy_sock.exists()
&& let Ok(mut stream) = UnixStream::connect(&proxy_sock)
{
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
if write_agent_packet(&mut stream, &[11]).is_ok()
&& let Ok(answer) = read_agent_packet(&mut stream)
{
break answer;
}
}
thread::sleep(Duration::from_millis(100));
};
let _ = child.kill();
let _ = child.wait();
let _ = server.kill();
let _ = server.wait();
// The answer must be an IDENTITIES_ANSWER (12) carrying our agent's comment,
// proving the bytes traversed the full forwarding chain to the local agent.
assert_eq!(
answer.first(),
Some(&12),
"expected IDENTITIES_ANSWER from the forwarded agent, got {:?}",
answer.first()
);
assert!(
answer
.windows(b"forwarded-agent-key".len())
.any(|w| w == b"forwarded-agent-key"),
"forwarded agent answer should carry the local agent's key comment"
);
}
/// Agent forwarding stays off unless the client opts in: with the server allowing
/// it but no -A, no proxy socket is created.
#[test]
fn native_agent_forwarding_requires_client_opt_in() {
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config_with_agent_forwarding(&dir, port);
write_native_client_auth(&dir, &config);
let agent_sock = dir.path().join("local-agent.sock");
start_fake_ssh_agent_looping(&agent_sock, "forwarded-agent-key");
let tmpdir = dir.path().join("tmp");
fs::create_dir_all(&tmpdir).unwrap();
let server_bin = env!("CARGO_BIN_EXE_dosh-server");
let mut server = Command::new(server_bin)
.arg("serve")
.arg("--config")
.arg(&config)
.env("HOME", dir.path())
.env("TMPDIR", &tmpdir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
thread::sleep(Duration::from_millis(500));
let pty = NativePtySystem::default();
let pair = pty
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let mut cmd = CommandBuilder::new(client_bin);
// Note: NO -A flag.
cmd.args([
"--auth",
"native",
"--no-cache",
"--session",
"no-agent-session",
"--dosh-host",
"127.0.0.1",
"--dosh-port",
&port.to_string(),
"local",
]);
cmd.env("HOME", dir.path().to_string_lossy().to_string());
cmd.env("TMPDIR", tmpdir.to_string_lossy().to_string());
cmd.env("SSH_AUTH_SOCK", agent_sock.to_string_lossy().to_string());
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
// Give the client time to authenticate and the shell to spawn.
thread::sleep(Duration::from_secs(2));
let server_pid = server.id();
let agent_dir = tmpdir.join(format!("dosh-agent-{server_pid}"));
let _ = child.kill();
let _ = child.wait();
let _ = server.kill();
let _ = server.wait();
// No client opt-in -> the server must not have created any agent socket dir.
assert!(
!agent_dir.exists(),
"agent proxy dir {agent_dir:?} must not exist without client -A opt-in"
);
}
// ---------------------------------------------------------------------------
// Session persistence across a server restart.
// ---------------------------------------------------------------------------
/// Write a server config with `persist_sessions = true` so each session's shell
/// runs in a detached holder that survives a server restart.
fn write_persistent_server_config(dir: &tempfile::TempDir, port: u16) -> std::path::PathBuf {
let config = write_server_config(dir, port);
let mut raw = fs::read_to_string(&config).unwrap();
// The base writer hardcodes `persist_sessions = false`; flip it on.
raw = raw.replace("persist_sessions = false", "persist_sessions = true");
fs::write(&config, raw).unwrap();
config
}
/// Mirror the server's hex encoding of a session name to its runtime dir.
fn session_runtime_dir(sessions_dir: &Path, session: &str) -> PathBuf {
let mut hex = String::new();
for b in session.as_bytes() {
hex.push_str(&format!("{b:02x}"));
}
sessions_dir.join("run").join(hex)
}
/// Read a holder's recorded shell pid from its meta file, if present.
fn holder_shell_pid(sessions_dir: &Path, session: &str) -> Option<i32> {
let meta = session_runtime_dir(sessions_dir, session).join("meta");
let body = fs::read_to_string(meta).ok()?;
body.lines().nth(1)?.parse().ok()
}
/// Tear down a persistent session's holder + shell so a test never leaks them
/// (the TempDir drop would otherwise leave detached processes running).
fn kill_holder(sessions_dir: &Path, session: &str) {
if let Some(pid) = holder_shell_pid(sessions_dir, session) {
let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status();
}
// Best-effort: also kill any matching holder process for this runtime dir.
let dir = session_runtime_dir(sessions_dir, session);
let _ = Command::new("pkill")
.arg("-9")
.arg("-f")
.arg(format!("dosh-server hold --runtime-dir {}", dir.display()))
.status();
}
/// 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) {
let input = Input {
bytes: line.as_bytes().to_vec(),
};
send_encrypted(
socket,
port,
PacketKind::Input,
ok.client_id,
seq,
0,
key,
&protocol::to_body(&input).unwrap(),
);
thread::sleep(Duration::from_millis(150));
}
/// Proof of survival: a session's shell + state + screen outlive a full
/// `dosh-server` process restart.
///
/// We attach, set durable shell state (`cd /tmp`, `export MARK=...`) and paint a
/// recognizable marker on the screen, wait for the screen to be mirrored to disk,
/// then KILL the server process (simulating a crash/upgrade). We restart the
/// server on the same config/sockets, reattach, and assert we land on the SAME
/// shell — the exported variable and working directory are still there, and the
/// restored attach snapshot still shows the pre-restart screen — not a fresh
/// shell.
#[test]
fn session_survives_server_restart_same_shell_and_screen() {
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 mut server = start_server(&dir, &config);
// First attach: establish durable shell state and a screen marker.
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let key = bootstrap.session_key;
type_line(&socket, port, &ok, &key, 2, "cd /tmp\n");
type_line(
&socket,
port,
&ok,
&key,
3,
"export MARK=zebra_persist_42\n",
);
// Paint a unique, stable marker into the screen (printf, no trailing newline
// scroll surprises) so the restored snapshot is easy to recognize.
type_line(
&socket,
port,
&ok,
&key,
4,
"printf 'PRE_RESTART_SCREEN_MARKER\\n'\n",
);
// Drain output so the parser/screen state and disk mirror catch up.
let pre = collect_frame_text(&socket, &key, 1500);
assert!(
pre.contains("PRE_RESTART_SCREEN_MARKER"),
"expected to see the screen marker before restart, got {pre:?}"
);
// The periodic flush mirrors the screen every ~2s; wait long enough that the
// pre-restart screen is guaranteed on disk before we crash the server.
thread::sleep(Duration::from_secs(3));
let shell_pid_before = holder_shell_pid(&sessions_dir, "default");
assert!(
shell_pid_before.is_some(),
"a holder shell pid should be recorded for a persistent session"
);
// CRASH: kill the server process outright (its in-memory state is lost; only
// the holder + shell + on-disk screen remain).
let _ = server.kill();
let _ = server.wait();
thread::sleep(Duration::from_millis(300));
// The holder + shell must still be alive after the server died.
let pid = shell_pid_before.unwrap();
let alive = unsafe { libc::kill(pid, 0) } == 0;
assert!(
alive,
"shell pid {pid} must survive the server crash (holder keeps it alive)"
);
// RESTART on the same config/sockets; the server re-adopts the holder.
let mut server = start_server(&dir, &config);
// Reattach. A fresh bootstrap attach lands on the EXISTING re-adopted
// session (same name), not a new shell.
let (socket2, bootstrap2, ok2) = direct_attach(&config, port, "read-write");
let key2 = bootstrap2.session_key;
// 1) Screen survived: the restored attach snapshot still shows the marker.
let snapshot = String::from_utf8_lossy(&ok2.snapshot).to_string();
// 2) Shell identity + state survived: ask the SAME shell for its state.
type_line(
&socket2,
port,
&ok2,
&key2,
2,
"printf 'MARK=%s PWD=%s\\n' \"$MARK\" \"$PWD\"\n",
);
let post = collect_frame_text(&socket2, &key2, 2000);
let shell_pid_after = holder_shell_pid(&sessions_dir, "default");
// Clean up BEFORE asserting so a failed assert never leaks the holder.
let _ = server.kill();
let _ = server.wait();
kill_holder(&sessions_dir, "default");
assert_eq!(
shell_pid_after, shell_pid_before,
"re-adopted session must be backed by the same shell pid, not a fresh one"
);
assert!(
post.contains("MARK=zebra_persist_42"),
"exported variable must survive the restart (same shell), got {post:?}"
);
assert!(
post.contains("PWD=/tmp"),
"working directory must survive the restart (same shell), got {post:?}"
);
assert!(
snapshot.contains("PRE_RESTART_SCREEN_MARKER"),
"restored attach snapshot must repaint the pre-restart screen, got {snapshot:?}"
);
}
#[test]
fn multiple_persistent_named_sessions_survive_restart_independently() {
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 mut server = start_server(&dir, &config);
let (work_socket, work_bootstrap, work_ok) =
direct_attach_session(&config, port, "read-write", "work");
let (logs_socket, logs_bootstrap, logs_ok) =
direct_attach_session(&config, port, "read-write", "logs");
let work_key = work_bootstrap.session_key;
let logs_key = logs_bootstrap.session_key;
type_line(
&work_socket,
port,
&work_ok,
&work_key,
2,
"export DOSH_SLOT=work_slot\n",
);
type_line(
&logs_socket,
port,
&logs_ok,
&logs_key,
2,
"export DOSH_SLOT=logs_slot\n",
);
type_line(
&work_socket,
port,
&work_ok,
&work_key,
3,
"printf 'WORK_SCREEN_MARKER\\n'\n",
);
type_line(
&logs_socket,
port,
&logs_ok,
&logs_key,
3,
"printf 'LOGS_SCREEN_MARKER\\n'\n",
);
let work_pre = collect_frame_text(&work_socket, &work_key, 1500);
let logs_pre = collect_frame_text(&logs_socket, &logs_key, 1500);
assert!(
work_pre.contains("WORK_SCREEN_MARKER"),
"work marker missing before restart: {work_pre:?}"
);
assert!(
logs_pre.contains("LOGS_SCREEN_MARKER"),
"logs marker missing before restart: {logs_pre:?}"
);
thread::sleep(Duration::from_secs(3));
let work_pid_before = holder_shell_pid(&sessions_dir, "work");
let logs_pid_before = holder_shell_pid(&sessions_dir, "logs");
assert!(work_pid_before.is_some(), "work holder pid missing");
assert!(logs_pid_before.is_some(), "logs holder pid missing");
assert_ne!(
work_pid_before, logs_pid_before,
"named sessions must have independent holder shells"
);
let _ = server.kill();
let _ = server.wait();
thread::sleep(Duration::from_millis(300));
let mut server = start_server(&dir, &config);
let (work_socket2, work_bootstrap2, work_ok2) =
direct_attach_session(&config, port, "read-write", "work");
let (logs_socket2, logs_bootstrap2, logs_ok2) =
direct_attach_session(&config, port, "read-write", "logs");
let work_key2 = work_bootstrap2.session_key;
let logs_key2 = logs_bootstrap2.session_key;
let work_snapshot = String::from_utf8_lossy(&work_ok2.snapshot).to_string();
let logs_snapshot = String::from_utf8_lossy(&logs_ok2.snapshot).to_string();
type_line(
&work_socket2,
port,
&work_ok2,
&work_key2,
2,
"printf 'WORK_SLOT=%s\\n' \"$DOSH_SLOT\"\n",
);
type_line(
&logs_socket2,
port,
&logs_ok2,
&logs_key2,
2,
"printf 'LOGS_SLOT=%s\\n' \"$DOSH_SLOT\"\n",
);
let work_post = collect_frame_text(&work_socket2, &work_key2, 2000);
let logs_post = collect_frame_text(&logs_socket2, &logs_key2, 2000);
let work_pid_after = holder_shell_pid(&sessions_dir, "work");
let logs_pid_after = holder_shell_pid(&sessions_dir, "logs");
let _ = server.kill();
let _ = server.wait();
kill_holder(&sessions_dir, "work");
kill_holder(&sessions_dir, "logs");
assert_eq!(work_pid_after, work_pid_before);
assert_eq!(logs_pid_after, logs_pid_before);
assert!(
work_snapshot.contains("WORK_SCREEN_MARKER")
&& !work_snapshot.contains("LOGS_SCREEN_MARKER"),
"work snapshot should restore only work screen, got {work_snapshot:?}"
);
assert!(
logs_snapshot.contains("LOGS_SCREEN_MARKER")
&& !logs_snapshot.contains("WORK_SCREEN_MARKER"),
"logs snapshot should restore only logs screen, got {logs_snapshot:?}"
);
assert!(
work_post.contains("WORK_SLOT=work_slot") && !work_post.contains("logs_slot"),
"work state crossed sessions or disappeared: {work_post:?}"
);
assert!(
logs_post.contains("LOGS_SLOT=logs_slot") && !logs_post.contains("work_slot"),
"logs state crossed sessions or disappeared: {logs_post:?}"
);
}