Compare commits

...
21 Commits
Author SHA1 Message Date
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
34 changed files with 5264 additions and 510 deletions
+35 -11
View File
@@ -3,6 +3,9 @@ name: ci
on: on:
push: push:
pull_request: pull_request:
workflow_dispatch:
schedule:
- cron: "17 7 * * 1"
jobs: jobs:
test: test:
@@ -36,21 +39,42 @@ jobs:
if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success' if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success'
run: | run: |
set -e set -e
for target in \ if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
packet_decode \ DOSH_FUZZ_SECONDS="${DOSH_FUZZ_SECONDS:-300}" sh scripts/fuzz-run.sh
from_body \ else
authorized_keys \ sh scripts/fuzz-run.sh 20
known_hosts \ fi
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
- name: Note when fuzzing was skipped - name: Note when fuzzing was skipped
if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success' if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success'
run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run." 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: remote-bench:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
Generated
+5
View File
@@ -397,6 +397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [ dependencies = [
"const-oid", "const-oid",
"pem-rfc7468",
"zeroize", "zeroize",
] ]
@@ -448,11 +449,14 @@ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"hkdf", "hkdf",
"hmac", "hmac",
"libc",
"portable-pty", "portable-pty",
"rand", "rand",
"rpassword", "rpassword",
"rsa",
"serde", "serde",
"sha2", "sha2",
"signature",
"ssh-key", "ssh-key",
"tempfile", "tempfile",
"tokio", "tokio",
@@ -1498,6 +1502,7 @@ checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
dependencies = [ dependencies = [
"bcrypt-pbkdf", "bcrypt-pbkdf",
"ed25519-dalek", "ed25519-dalek",
"num-bigint-dig",
"p256", "p256",
"p384", "p384",
"p521", "p521",
+4 -1
View File
@@ -16,12 +16,15 @@ dirs = "5.0"
ed25519-dalek = "2.1" ed25519-dalek = "2.1"
hkdf = "0.12" hkdf = "0.12"
hmac = "0.12" hmac = "0.12"
libc = "0.2"
portable-pty = "0.8" portable-pty = "0.8"
rand = "0.8" rand = "0.8"
rsa = { version = "0.9.10", features = ["sha2"] }
rpassword = "7.5.4" rpassword = "7.5.4"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10" 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"] } tokio = { version = "1.41", features = ["full"] }
toml = "0.8" toml = "0.8"
vt100 = "0.15" 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: build:
cargo build --release cargo build --release
@@ -12,6 +12,12 @@ fmt:
install: install:
sh packaging/install.sh 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 # 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 # 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. # a temp HOME. Never touches the production server or UDP port 50000.
@@ -28,3 +34,14 @@ bench-docker-ssh:
bench-docker-mosh: bench-docker-mosh:
DOSH_BENCH_INCLUDE_MOSH=1 sh scripts/ci-docker-ssh-bench.sh 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
+102 -29
View File
@@ -81,6 +81,9 @@ dosh-server
client table per session client table per session
encrypted UDP protocol encrypted UDP protocol
tiny SSH-invoked dosh-auth helper mode 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 dosh-client
terminal raw mode terminal raw mode
@@ -107,33 +110,42 @@ Install the client on macOS:
```bash ```bash
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \ 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: Update an installed client later:
```bash ```bash
dosh update 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. 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: Install the client on Windows PowerShell:
```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: Attach:
```bash ```bash
dosh palav dosh user@server.example.com
``` ```
Plain `dosh palav` opens a fresh terminal session. Use named sessions when you want Plain `dosh user@server.example.com` opens a fresh terminal session. Use named
to reattach to the same persistent terminal from multiple clients: sessions when you want to reattach to the same persistent terminal from multiple
clients:
```bash ```bash
dosh --session work palav dosh --session work user@server.example.com
dosh --session logs palav dosh --session logs user@server.example.com
``` ```
Press `Ctrl-]` to detach the current client while leaving the server session alive. Press `Ctrl-]` to detach the current client while leaving the server session alive.
@@ -159,7 +171,31 @@ UDP host from an SSH alias when `dosh_host` is not configured. To make that expl
in Dosh's host config: in Dosh's host config:
```bash ```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 ## Develop
@@ -194,9 +230,9 @@ Benchmark the ControlMaster-backed SSH bootstrap path:
target/release/dosh-bench --server user@host --controlmaster --iterations 3 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 Run the Docker OpenSSH benchmark gate used by CI. It checks cold SSH bootstrap,
and ControlMaster-backed SSH bootstrap against a containerized `sshd` plus resident ControlMaster-backed SSH bootstrap, native cold auth after one-time `dosh trust`,
`dosh-server`: and cached attach against a containerized `sshd` plus resident `dosh-server`:
```bash ```bash
make bench-docker-ssh make bench-docker-ssh
@@ -208,12 +244,39 @@ Run the same Docker comparison with Mosh installed in the benchmark container:
make bench-docker-mosh make bench-docker-mosh
``` ```
That prints `ssh_true_ms`, `dosh_attach_ms`, and `mosh_start_true_ms` under the same That prints `ssh_true_ms`, `dosh_attach_ms`, `dosh_cold_native_ms`,
container, key, DNS, and network path. It also prints `dosh_cached_attach_ms`, which `dosh_cached_attach_ms`, and, for the Mosh target, `mosh_start_true_ms` under the
is the real Dosh fast path after the first SSH-authenticated bootstrap has issued an same container, key, DNS, and network path. `dosh_cached_attach_ms` is the real Dosh
attach ticket. See `docs/PUBLIC_READINESS.md` before using the numbers publicly; fast path after the first authentication has issued an attach ticket. See
Dosh's current strongest claim is fast attach/reconnect, not full Mosh feature `docs/PUBLIC_READINESS.md` before using the numbers publicly; Dosh's current
parity yet. 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 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 `DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository secrets are
@@ -225,6 +288,13 @@ Install release binaries and the user systemd service:
make install 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 ## Performance Rules
The stack is performance-driven, not fixed by taste. Rust is the default because the The stack is performance-driven, not fixed by taste. Rust is the default because the
@@ -259,7 +329,8 @@ Hot-path rules:
- Replacing SSH as the first public-key trust mechanism. - Replacing SSH as the first public-key trust mechanism.
- Multi-user access control. - 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. - Full mosh compatibility.
- Perfect predictive local echo in the first MVP. - Perfect predictive local echo in the first MVP.
@@ -278,10 +349,10 @@ 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 implemented and aims to replace the day-to-day `ssh host` workflow on Dosh-installed
servers: servers:
- **Native UDP auth** with X25519 key exchange, transcript-bound Ed25519 user auth - **Native UDP auth** with X25519 key exchange; transcript-bound Ed25519, ECDSA
via ssh-agent or an encrypted OpenSSH key, ChaCha20-Poly1305 transport, and P-256, and RSA-SHA2 user auth via ssh-agent or OpenSSH identity files;
`authorized_keys` policy enforcement (`from=`, `no-port-forwarding`, `permitopen=`; ChaCha20-Poly1305 transport; and `authorized_keys` policy enforcement (`from=`,
unsupported options fail closed). `no-port-forwarding`, `permitopen=`; unsupported options fail closed).
- **Dosh host-key trust**: pinned `known_hosts`, `dosh trust [--remove|--replace]`, - **Dosh host-key trust**: pinned `known_hosts`, `dosh trust [--remove|--replace]`,
TOFU only when explicitly enabled, and hard-fail on host-key mismatch. TOFU only when explicitly enabled, and hard-fail on host-key mismatch.
- **TCP forwarding**: local `-L`, remote `-R` (loopback bind by default), dynamic - **TCP forwarding**: local `-L`, remote `-R` (loopback bind by default), dynamic
@@ -300,10 +371,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 explicitly when native auth is disabled, unavailable, or rejected. It never silently
degrades to an unauthenticated mode. degrades to an unauthenticated mode.
Native v1 is **not yet fully verified**. Per-IP token-bucket rate limiting, protocol Native v1 is **not externally audited yet**. Local 30-minute sleep/roaming soak,
VERSION negotiation hardening, fuzzing in CI, ECDSA/RSA user keys, and an external fuzz-smoke, and Docker SSH/Mosh benchmark evidence is captured in
security review are still open. See `docs/THREAT_MODEL.md` for the published threat `docs/RELEASE_EVIDENCE_2026-06-20.md`. See `docs/THREAT_MODEL.md` for the
model and accepted residual risks, and the "Native v1 verification checklist status" published threat model and accepted residual risks, `docs/PROTOCOL_VERSIONING.md`
table in `docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not yet for the v1 versioning policy, `docs/AUDIT_PACKET.md` for the external security
claim a fully verified SSH replacement; its defensible claim remains fast encrypted review handoff, and the "Native v1 verification checklist status" table in
native attach/reconnect with SSH-equivalent transport security and SSH fallback. `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" default_session = "new"
reconnect_timeout_secs = 5 reconnect_timeout_secs = 5
view_only = false view_only = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials" 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-local-json # same, machine-readable JSON with raw samples
make bench-docker-ssh # containerized SSH-vs-Dosh gate used by CI 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-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 `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 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 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 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 ### 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`) ### `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 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`, `-mosh` target), then runs `ssh_true_ms`, cold + ControlMaster SSH-bootstrap
`dosh_cached_attach_ms`, and optionally `mosh_start_true_ms` against the same `dosh_attach_ms`, one-time `dosh trust` followed by native-cold
container, key, and loopback network path. This is the gate CI enforces; it asserts `dosh_cold_native_ms`, `dosh_cached_attach_ms`, and optionally
cold Dosh stays within 500 ms of SSH and that cached attach stays under a small `mosh_start_true_ms` against the same container, key, and loopback network path.
budget. See `README.md` "Develop" for the exact invocations. 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 ## Methodology and caveats
@@ -130,8 +134,8 @@ budget. See `README.md` "Develop" for the exact invocations.
## Sample results (loopback, self-contained) ## Sample results (loopback, self-contained)
Captured with `make bench-local` (`scripts/bench-local.sh 30`), all times in Captured with `make bench-local` (`scripts/bench-local.sh 20`), all times in
milliseconds, 30 samples per metric. milliseconds, 20 samples per metric.
- Machine: Intel Core i5-9500 @ 3.00 GHz, 6 cores - Machine: Intel Core i5-9500 @ 3.00 GHz, 6 cores
- OS: Ubuntu 24.04.4 LTS, Linux 6.8.0-124-generic, x86_64 - 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 | | Metric | n | min | median | p95 | mean | max |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| `dosh_cold_native_ms` | 30 | 8.10 | 9.01 | 10.40 | 9.18 | 10.82 | | `dosh_cold_native_ms` | 20 | 8.32 | 8.77 | 9.34 | 8.82 | 9.52 |
| `dosh_cached_attach_ms` | 30 | 2.73 | 2.96 | 3.25 | 2.96 | 3.30 | | `dosh_cached_attach_ms` | 20 | 2.83 | 3.23 | 3.75 | 3.24 | 3.77 |
| `dosh_local_attach_ms` | 30 | 2.66 | 2.78 | 3.24 | 2.87 | 3.33 | | `dosh_local_attach_ms` | 20 | 2.84 | 3.21 | 3.96 | 3.28 | 3.98 |
Raw samples (ms): Raw samples (ms):
``` ```
dosh_cold_native_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, 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,
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, 9.52, 8.72, 8.32, 9.06, 8.93, 8.96, 9.04
8.55, 8.38, 9.81, 8.17, 8.10
dosh_cached_attach_ms: 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, 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,
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, 3.77, 3.32, 3.49, 3.75, 3.56, 3.33
3.30, 2.73, 2.79, 3.25
dosh_local_attach_ms: 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, 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,
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, 3.47, 3.13, 2.92, 2.87, 2.91, 2.98, 3.18
2.94, 2.74, 2.82, 2.66
``` ```
Reading these numbers: cold native auth is ~9 ms because it pays the native 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 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; 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. 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
```
+37 -12
View File
@@ -7,16 +7,22 @@
> - Milestone 1 — host identity and trust: **done.** Host key generation, `dosh trust`, > - Milestone 1 — host identity and trust: **done.** Host key generation, `dosh trust`,
> `known_hosts`, and mismatch hard-fail are implemented. > `known_hosts`, and mismatch hard-fail are implemented.
> - Milestone 2 — native user auth: **done.** `ClientHello`/`ServerHello`/`UserAuth`/ > - Milestone 2 — native user auth: **done.** `ClientHello`/`ServerHello`/`UserAuth`/
> `AuthOk`, ssh-agent and encrypted-key Ed25519, and `authorized_keys` verification > `AuthOk`, ssh-agent and OpenSSH identity-file auth for Ed25519, ECDSA P-256,
> exist. ECDSA/RSA user keys are still pending (Ed25519 only today). > and RSA-SHA2, plus `authorized_keys` verification, exist.
> - Milestone 3 — default native auth: **done.** `auth_preference = "native,ssh"` is > - Milestone 3 — default native auth: **done.** `auth_preference = "native,ssh"` is
> the default with explicit, visible SSH fallback. Cold-auth benchmark gates are > the default with explicit, visible SSH fallback. Local and Docker benchmark gates
> pending (Track C / `BENCHMARKS.md`). > cover cached attach, SSH fallback, and native cold auth (Track C /
> - Milestone 4 — forwarding: **done.** Stream mux, `-L`, `-R`, `-D`, `-N`, `-f`, and > `BENCHMARKS.md`).
> per-stream flow control are implemented; hostile-network and load tests pending. > - Milestone 4 — forwarding: **implemented.** Stream mux, `-L`, `-R`, `-D`, `-N`,
> - Milestone 5 — hardening: **in progress.** Full per-IP token-bucket rate limiting, > `-f`, per-stream flow control, ordered stream offsets/ACKs, and stream-data
> protocol VERSION negotiation hardening, fuzzing in CI, and external review are not > retransmission exist. Terminal-priority/load, replay/reorder, and dropped
> yet complete. The threat model is published (`docs/THREAT_MODEL.md`). > 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 > - Milestone 6 — workflow parity: **mostly done.** `dosh doctor`, host-trust
> management, and the encrypted-key prompt flow exist; cross-OS daily-driver soak is > management, and the encrypted-key prompt flow exist; cross-OS daily-driver soak is
> ongoing. > ongoing.
@@ -45,6 +51,7 @@ dosh -L 8080:127.0.0.1:80 host
dosh -R 9000:127.0.0.1:9000 host dosh -R 9000:127.0.0.1:9000 host
dosh --session work host dosh --session work host
dosh --view-only --session work host dosh --view-only --session work host
dosh host tm
``` ```
Compatibility expectations: Compatibility expectations:
@@ -101,6 +108,8 @@ Must work in v1:
- `dosh update`. - `dosh update`.
- `dosh doctor host` for config/auth/UDP reachability diagnostics. - `dosh doctor host` for config/auth/UDP reachability diagnostics.
- `dosh sessions host` for session visibility. - `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: 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 closed laptop must not kill the remote session.
- A client crash 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, - A server restart must not kill the remote session when `persist_sessions` is on
but the client must fail clearly and reconnect cleanly afterward. (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 - Decrypt failures from stale packets must be ignored or trigger reconnect, never
terminate the terminal by themselves. terminate the terminal by themselves.
- Terminal cleanup must restore cursor, mouse mode, bracketed paste, alternate - Terminal cleanup must restore cursor, mouse mode, bracketed paste, alternate
@@ -461,6 +476,12 @@ Forwarding rules:
- Terminal traffic has priority over stream bulk data. - Terminal traffic has priority over stream bulk data.
- Each stream has independent flow control. - 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. - Backpressure must not block PTY input or output.
- Server enforces `no-port-forwarding` and `permitopen=`. - Server enforces `no-port-forwarding` and `permitopen=`.
- Remote listeners bind to loopback by default. - Remote listeners bind to loopback by default.
@@ -538,6 +559,10 @@ forward_agent = false
send_env = ["LANG", "LC_*", "TERM", "COLORTERM"] send_env = ["LANG", "LC_*", "TERM", "COLORTERM"]
set_env = {} set_env = {}
forwardings = [] forwardings = []
[extensions.tm]
command = "tm {args}"
description = "Open an optional server-side tmux dashboard"
``` ```
Server: Server:
@@ -588,7 +613,7 @@ Milestone 5: hardening
- Fuzz packet parsing, authorized-key parsing, known-host parsing, and handshake state. - Fuzz packet parsing, authorized-key parsing, known-host parsing, and handshake state.
- Add hostile-network integration tests. - 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 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 Public Readiness
Dosh's defensible public claim is fast terminal attach and reconnect. It should not Dosh's defensible public claim is fast terminal attach/reconnect plus native
claim full Mosh replacement status until the feature matrix below is green and the encrypted forwarding on Dosh-installed servers. It should not claim generic SSH
comparison benchmark is reproducible outside the author's homelab. 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 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 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: 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, 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 `dosh doctor`, token-bucket auth rate limiting, and fuzz-smoke CI all exist (see the
status table below). It is **not yet fully verified**: per-IP token-bucket rate feature matrix and the verification-checklist status table below). It is **not yet
limiting, protocol-version negotiation hardening, fuzzing in CI, and external review fully verified**: long sleep/roaming soak and deeper fuzzing results are still open
are still open. Until the verification checklist (`NATIVE_V1_SPEC.md` section 16) is launch evidence. Until the verification checklist (`NATIVE_V1_SPEC.md` section 16)
green and that review is done, Dosh's defensible public security claim remains fast is green, Dosh's defensible public security claim remains fast encrypted native
encrypted native attach/reconnect with SSH-equivalent transport security and an attach/reconnect and forwarding with SSH bootstrap fallback, not generic SSH
explicit SSH bootstrap fallback — not a fully verified, externally reviewed SSH compatibility.
replacement.
## Objective Benchmarks ## Objective Benchmarks
@@ -58,31 +58,31 @@ with ordinary SSH.
| Feature | Mosh | Dosh now | Public status | | Feature | Mosh | Dosh now | Public status |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| SSH-based first authentication | yes | yes | ready | | 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 | | 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 | | `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 | | `dosh doctor` diagnostics | no | yes, config/auth/UDP/forwarding-policy check | implemented |
| Encrypted UDP terminal data | yes | yes | ready | | Encrypted UDP terminal data | yes | yes | ready |
| Roaming by client address change | yes | yes | needs more hostile-network tests | | Roaming by client address change | yes | yes | implemented; hostile-network covered, 30-minute soak gate available |
| Survive sleep or network loss | yes | yes | needs long-running soak tests | | 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 | | Fast repeat attach without SSH | no | yes, via attach tickets | core differentiator |
| Resident server daemon | no | yes | core differentiator | | Resident server daemon | no | yes | core differentiator |
| One UDP port for all sessions | port range by default | yes | ready | | One UDP port for all sessions | port range by default | yes | ready |
| Fresh session by default | yes | yes | ready | | Fresh session by default | yes | yes | ready |
| Named persistent sessions | no built-in shared session model | yes | ready | | 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 | needs conflict-policy docs | | Multiple clients on one session | no | yes | implemented; needs conflict-policy docs |
| View-only clients | no | yes | ready | | 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 | | 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 | | Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
| X11 forwarding | no | no | non-goal unless tunneled separately | | X11 forwarding | no | no | non-goal unless tunneled separately |
| SSH agent forwarding | no | no | planned as forwarding channel | | 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; needs hostile-network tests | | 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; needs hostile-network tests | | 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; needs hostile-network tests | | 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 | | 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 ## 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: configured. To write explicit Dosh host entries from SSH aliases:
```bash ```bash
dosh import-ssh palav homelab dosh import-ssh homelab
``` ```
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
OpenSSH config parser. 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) ## Forwarding (Implemented)
SSH forwarding cannot be copied by keeping the bootstrap SSH connection open, SSH forwarding cannot be copied by keeping the bootstrap SSH connection open,
@@ -120,6 +143,8 @@ Implemented:
`StreamEof`/`StreamClose` packet types. `StreamEof`/`StreamClose` packet types.
- Per-stream windowed flow control (initial 1 MiB credit) separate from terminal - Per-stream windowed flow control (initial 1 MiB credit) separate from terminal
frames, so bulk forwarding does not block PTY input/output. 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 - Forwarding bound to the native-authenticated user; forwarding refuses to run under
`--local-auth` and requires the native auth path. `--local-auth` and requires the native auth path.
- Server-side policy enforcement: `allow_tcp_forwarding`, `allow_remote_forwarding`, - Server-side policy enforcement: `allow_tcp_forwarding`, `allow_remote_forwarding`,
@@ -130,8 +155,9 @@ Implemented:
Still open before claiming forwarding parity: Still open before claiming forwarding parity:
- A dedicated dropped/reordered/replayed-UDP forwarding test suite. - More real-host load soak. The integration suite already covers terminal-priority
- Load tests proving large forwarded transfers add no visible terminal input lag. 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 ## 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`. | | 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 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 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`. | | 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. | | 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 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. | | 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. | | 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. | | 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`). | | 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 | pending | Same benchmark dependency. | | 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 | in progress | Implemented with per-stream window; load proof pending. | | `-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. | | `-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. | | `-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. | | `-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. | | 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. | | `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. | | 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. | | 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 | pending | No `fuzz/` dir; CI runs fmt/test/build/bench only. | | 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`. | | Threat model updated with accepted residual risks | done | `docs/THREAT_MODEL.md`. |
Additional security hardening tracked outside the section 16 list: Additional security hardening tracked outside the section 16 list:
- Full per-IP token-bucket rate limiting: in progress (another track). Today only - Full per-IP token-bucket rate limiting: implemented for native auth and covered by
handshake-map eviction and a static `rate_limit_remaining` hint exist. unit/integration tests. It still needs real-host tuning under abusive traffic.
- Protocol VERSION negotiation: in progress. Single version, fail-closed reject; no - Protocol VERSION handling: v1 policy is single-version, fail-closed reject at the
multi-version negotiation yet. packet/header and native-handshake layers. See `docs/PROTOCOL_VERSIONING.md`.
- ECDSA P-256 / SHA-2 RSA user keys: pending. Ed25519 only today. - 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 ## Before Public Launch
- Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green. - Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green.
- Add a non-destructive disconnect indicator. - Run `make soak-local` before each tagged release to refresh 30-minute sleep/roaming evidence.
- Run scripted TUI tests for alternate-screen apps, arrow keys, resize, mouse mode, - Run `make fuzz-deep` before launch, or use the scheduled/manual CI fuzz pass, and
bracketed paste, and terminal cleanup. 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. - Publish benchmark output with raw samples, not just averages.
- Mark prediction as experimental until it has a real framebuffer model. - 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. - Stress-test `persist_sessions = true` before making restart-survivable holders the
- Complete the native-v1 verification checklist above and an external security review default.
before making any "native SSH replacement" claim (`NATIVE_V1_SPEC.md` section 17). - 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 | | 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. | | 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. | | 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. | | 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. | | 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. | | 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 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. | | 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. | | 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 - **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 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. 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 - **Modern primitives only.** Ed25519 and X25519 by default; ECDSA P-256 and
signatures, no CBC-and-MAC constructions. 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 Dosh does **not** claim generic SSH compatibility and must not be described as an
SSH-protocol implementation. 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 unique nonce per `(key, direction, sequence)`. AES-GCM is reserved for later and is
not selectable today. not selectable today.
- **Host-key signatures:** Ed25519 (`ed25519-dalek`) over the handshake transcript. - **Host-key signatures:** Ed25519 (`ed25519-dalek`) over the handshake transcript.
- **User-auth signatures:** Ed25519, produced either by ssh-agent over a Unix socket - **User-auth signatures:** Ed25519, ECDSA P-256, and RSA-SHA2, produced either by
(`src/ssh_agent.rs`) or from an encrypted/plaintext OpenSSH private key ssh-agent over a Unix socket (`src/ssh_agent.rs`) or from an encrypted/plaintext
(`ssh-key`). The signature covers the user-auth transcript described above. 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 - **Bootstrap auth (SSH fallback path):** HMAC-SHA256 attach tokens and HKDF-SHA256
derived session keys, with attach tickets sealed under an HKDF-derived derived session keys, with attach tickets sealed under an HKDF-derived
ticket key. Token comparison is constant-time. 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) ### Known gaps / work in progress (must close before the public claim)
- **Per-IP rate limiting is partial.** The server evicts half-finished native - **Native-auth rate limiting needs tuning, not first implementation.** The server
handshakes on a TTL so a flood of `ClientHello` packets cannot grow the pending map enforces a per-source token bucket before expensive native-auth work, evicts
without bound, and it reports a static `rate_limit_remaining` hint in `ServerHello`. half-finished handshakes on a TTL, and reports remaining capacity in
A full per-source token-bucket limiter is **in progress on another track** and is `ServerHello`. It is covered by unit/integration tests, but still needs abusive
not yet enforced. Until it lands, sustained auth flooding is mitigated only by the real-host tuning before public hardening claims.
handshake-eviction TTL and OS-level limits. - **Protocol VERSION compatibility is intentionally single-version for v1.** The
- **Protocol VERSION negotiation is being hardened.** The wire format pins a single packet header rejects any non-matching `VERSION` byte and the native handshake
protocol version: the packet header rejects any non-matching `VERSION` byte and the rejects any non-matching `protocol_version`; peers get a named upgrade error, not
native handshake rejects any non-matching `protocol_version`. There is no a silent downgrade. `docs/PROTOCOL_VERSIONING.md` defines the bump rules and the
multi-version negotiation yet, so cross-version interop and downgrade-resistance for post-v1 requirements for any future multi-version negotiation.
future versions are still being designed. Today's behavior is fail-closed (reject), - **Deep fuzzing still needs launch evidence.** CI runs parser/auth fuzz targets for
not silent downgrade. 20 seconds per target on push/PR and 300 seconds per target on weekly/manual runs
- **Fuzzing is not yet wired into CI.** CI currently runs format, tests, release when nightly/cargo-fuzz is available. That catches obvious panics and parser
build, and the Docker SSH benchmark gate. Fuzz targets for packet parsing, robustness regressions; public security claims should cite a completed
authorized-key parsing, known-host parsing, and handshake state (spec milestone 5) `make fuzz-deep` or scheduled CI run with durations.
are **being wired in** and are not yet running in CI. - **User-key algorithm coverage now matches the v1 target.** Ed25519, ECDSA P-256,
- **User-key algorithm coverage is Ed25519-only today.** The spec permits ECDSA and compatibility RSA-SHA2 native auth are implemented for ssh-agent and OpenSSH
P-256 and (compatibility-only, SHA-2) RSA, but native auth currently accepts and identity files. RSA remains compatibility-only and deliberately rejects legacy
produces `ssh-ed25519` only. ECDSA/RSA support is pending. This is a parity gap, not SHA-1 `ssh-rsa` signatures.
a weakening of what *is* supported. - **Forwarded stream data uses ordered retransmission.** Streams carry byte offsets,
- **Hostile-network and long-soak integration tests are partial.** Roaming, cumulative received-offset ACKs, and retransmit unacknowledged chunks as fresh
retransmit, resize, and multi-client tests exist; a dedicated adversarial encrypted transport packets. Hostile-network tests cover replay/reorder and
drop/reorder/replay suite and 30-minute-sleep soak (spec section 16) are still being server-to-client stream recovery after deliberate UDP loss; broader real-host load
expanded. soak remains launch evidence.
- **No external security review yet.** The spec's milestone 5 requires an external - **Long-soak evidence is a launch gate.** Roaming, retransmit, resize, and
review checklist before public security claims. That review has not happened. 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 ### Auth posture
@@ -226,7 +235,7 @@ cached attach, and Mosh startup.
Current status: this threat model is published (this document). The verification 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 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 `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, gaps in section 6 above. Until the gaps close, Dosh's defensible public claim remains
Dosh's defensible public claim remains **fast, encrypted native attach/reconnect with **fast, encrypted native attach/reconnect and forwarding with SSH bootstrap
SSH-equivalent transport security and SSH bootstrap fallback** — not a fully verified, fallback** on Dosh-installed servers, not generic SSH compatibility or a third-party
externally reviewed SSH replacement. 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: From the repository root:
```sh ```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 # List targets
cargo +nightly fuzz list --fuzz-dir fuzz cargo +nightly fuzz list --fuzz-dir fuzz
@@ -55,5 +61,7 @@ cargo +nightly fuzz run packet_decode -- -max_total_time=10
## CI ## CI
`.github/workflows/ci.yml` has a `fuzz-smoke` job that installs nightly + `.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 cargo-fuzz and runs each target. Push/PR runs use a short 20-second-per-target
if the toolchain/tooling is unavailable so it never blocks the main test gate. 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.
+166 -47
View File
@@ -6,6 +6,12 @@ param(
[string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }), [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 }), [int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }),
[string]$Prefix = $(if ($env:PREFIX) { $env:PREFIX } else { Join-Path $HOME ".local" }), [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 [switch]$ForceConfig
) )
@@ -17,39 +23,157 @@ function Require-Command($Name) {
} }
} }
Require-Command cargo function Normalize-Arch {
switch ($env:PROCESSOR_ARCHITECTURE) {
$tmp = $null "AMD64" { "x86_64"; break }
if (Test-Path "Cargo.toml") { "ARM64" { "aarch64"; break }
$src = (Get-Location).Path default { $env:PROCESSOR_ARCHITECTURE.ToLowerInvariant() }
} else {
if (-not $Repo) {
throw "DOSH_REPO is required when running the installer from irm/iex"
} }
Require-Command git
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-" + [guid]::NewGuid())
git clone --depth 1 $Repo $tmp | Out-Null
$src = $tmp
} }
try { function Release-ArtifactName {
Push-Location $src if ($BinaryName) {
cargo build --release --bin dosh-client --bin dosh-bench return $BinaryName
}
"dosh-windows-$(Normalize-Arch).zip"
}
$bindir = Join-Path $Prefix "bin" function Repo-WebBase($Value) {
$configDir = Join-Path $HOME ".config\dosh" if (-not $Value) {
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null return $null
}
$base = $Value.TrimEnd("/")
if ($base.EndsWith(".git")) {
$base = $base.Substring(0, $base.Length - 4)
}
if ($base -notmatch "^https?://") {
return $null
}
$base
}
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force function Release-DownloadUrl {
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force if ($BinaryUrl) {
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force 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"
}
$clientConfig = Join-Path $configDir "client.toml" function Verify-ArchiveChecksum($Url, $Archive) {
if ($ForceConfig -or -not (Test-Path $clientConfig)) { $checksumPath = "$Archive.sha256"
$defaultServer = if ($Server) { $Server } else { "user@example.com" } try {
$updateRepo = if ($Repo) { $Repo } else { "https://git.palav.dev/Palav/dosh.git" } Invoke-WebRequest -UseBasicParsing -Uri "$Url.sha256" -OutFile $checksumPath
$doshHostLine = if ($DoshHost) { "dosh_host = `"$DoshHost`"" } else { "# dosh_host = `"public.example.com`"" } }
@" 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
} else {
if (-not $Repo) {
throw "DOSH_REPO is required when running the installer from irm/iex"
}
Require-Command git
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-" + [guid]::NewGuid())
git clone --depth 1 $Repo $tmp | Out-Null
$src = $tmp
}
try {
Push-Location $src
cargo build --release --bin dosh-client --bin dosh-bench
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force
}
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)) {
$defaultServer = if ($Server) { $Server } else { "user@example.com" }
$updateRepo = if ($Repo) { $Repo } else { "https://git.palav.dev/Palav/dosh.git" }
$doshHostLine = if ($DoshHost) { "dosh_host = `"$DoshHost`"" } else { "# dosh_host = `"public.example.com`"" }
@"
update_repo = "$updateRepo" update_repo = "$updateRepo"
update_port = $Port update_port = $Port
server = "$defaultServer" server = "$defaultServer"
@@ -60,28 +184,23 @@ dosh_port = $Port
default_session = "new" default_session = "new"
reconnect_timeout_secs = 5 reconnect_timeout_secs = 5
view_only = false view_only = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials" credential_cache = "~/.local/share/dosh/credentials"
"@ | Set-Content -NoNewline -Encoding utf8 $clientConfig "@ | Set-Content -NoNewline -Encoding utf8 $clientConfig
}
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if (-not (($userPath -split ';') -contains $bindir)) {
[Environment]::SetEnvironmentVariable("Path", "$userPath;$bindir", "User")
}
Write-Host "Installed Dosh client to $bindir"
Write-Host "Configured UDP port $Port"
Write-Host ""
$displayServer = if ($Server) { $Server } else { "user@host" }
Write-Host "Client command:"
Write-Host " $bindir\dosh.exe $displayServer"
Write-Host ""
Write-Host "Open a new terminal for PATH changes to apply."
} }
finally {
Pop-Location $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($tmp) { if (-not (($userPath -split ';') -contains $bindir)) {
Remove-Item -Recurse -Force $tmp [Environment]::SetEnvironmentVariable("Path", "$userPath;$bindir", "User")
}
} }
Write-Host "Installed Dosh client to $bindir"
Write-Host "Configured UDP port $Port"
Write-Host ""
$displayServer = if ($Server) { $Server } else { "user@host" }
Write-Host "Client command:"
Write-Host " $bindir\dosh.exe $displayServer"
Write-Host ""
Write-Host "Open a new terminal for PATH changes to apply."
+280 -70
View File
@@ -12,6 +12,12 @@ start_server=1
force_config=0 force_config=0
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}" update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
quiet="${DOSH_UPDATE_QUIET:-0}" quiet="${DOSH_UPDATE_QUIET:-0}"
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() { usage() {
cat <<'EOF' cat <<'EOF'
@@ -32,6 +38,18 @@ Options:
Environment alternatives: Environment alternatives:
DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX, DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX,
DOSH_UPDATE_CACHE 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 EOF
} }
@@ -112,8 +130,6 @@ ensure_cargo() {
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
} }
ensure_cargo
cleanup() { cleanup() {
if [ -n "${tmpdir:-}" ]; then if [ -n "${tmpdir:-}" ]; then
rm -rf "$tmpdir" rm -rf "$tmpdir"
@@ -121,73 +137,267 @@ cleanup() {
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then
src_dir="$(pwd)"
else
if [ -z "$repo" ]; then
echo "DOSH_REPO or --repo is required when running the installer from curl" >&2
exit 2
fi
case "$update_cache" in
""|"/"|"$HOME"|"$HOME/"|"$HOME/.cache")
echo "refusing unsafe update cache path: $update_cache" >&2
exit 2
;;
esac
need git
mkdir -p "$(dirname "$update_cache")"
if [ -d "$update_cache/.git" ]; then
[ "$quiet" = "1" ] && echo "Updating Dosh source"
git -C "$update_cache" remote set-url origin "$repo"
if [ "$quiet" = "1" ]; then
git -C "$update_cache" fetch -q --depth 1 origin main
else
git -C "$update_cache" fetch --depth 1 origin main
fi
git -C "$update_cache" checkout -q -B main FETCH_HEAD
else
[ "$quiet" = "1" ] && echo "Downloading Dosh source"
rm -rf "$update_cache"
if [ "$quiet" = "1" ]; then
git clone -q --depth 1 --branch main "$repo" "$update_cache" >/dev/null
else
git clone --depth 1 --branch main "$repo" "$update_cache" >/dev/null
fi
fi
src_dir="$update_cache"
fi
cd "$src_dir"
[ "$quiet" = "1" ] && echo "Building Dosh $role"
if [ "$role" = "client" ]; then
if [ "$quiet" = "1" ]; then
cargo build -q --release --bin dosh-client
else
cargo build --release --bin dosh-client
fi
else
if [ "$quiet" = "1" ]; then
cargo build -q --release
else
cargo build --release
fi
fi
bindir="$prefix/bin" bindir="$prefix/bin"
config_dir="$HOME/.config/dosh" config_dir="$HOME/.config/dosh"
data_dir="$HOME/.local/share/dosh" data_dir="$HOME/.local/share/dosh"
systemd_user_dir="$HOME/.config/systemd/user" systemd_user_dir="$HOME/.config/systemd/user"
src_dir=""
mkdir -p "$bindir" "$config_dir" "$data_dir" mkdir -p "$bindir" "$config_dir" "$data_dir"
install -m 0755 target/release/dosh-client "$bindir/dosh-client" normalize_os() {
ln -sf dosh-client "$bindir/dosh" case "$(uname -s)" in
if [ "$role" = "server" ] || [ "$role" = "both" ]; then Darwin) printf '%s\n' macos ;;
install -m 0755 target/release/dosh-server "$bindir/dosh-server" Linux) printf '%s\n' linux ;;
install -m 0755 target/release/dosh-auth "$bindir/dosh-auth" FreeBSD) printf '%s\n' freebsd ;;
fi *) uname -s | tr '[:upper:]' '[:lower:]' ;;
if [ -f target/release/dosh-bench ]; then esac
install -m 0755 target/release/dosh-bench "$bindir/dosh-bench" }
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
if [ -z "$repo" ]; then
echo "DOSH_REPO or --repo is required when running the installer from curl" >&2
exit 2
fi
case "$update_cache" in
""|"/"|"$HOME"|"$HOME/"|"$HOME/.cache")
echo "refusing unsafe update cache path: $update_cache" >&2
exit 2
;;
esac
need git
mkdir -p "$(dirname "$update_cache")"
if [ -d "$update_cache/.git" ]; then
[ "$quiet" = "1" ] && echo "Updating Dosh source"
git -C "$update_cache" remote set-url origin "$repo"
if [ "$quiet" = "1" ]; then
git -C "$update_cache" fetch -q --depth 1 origin main
else
git -C "$update_cache" fetch --depth 1 origin main
fi
git -C "$update_cache" checkout -q -B main FETCH_HEAD
else
[ "$quiet" = "1" ] && echo "Downloading Dosh source"
rm -rf "$update_cache"
if [ "$quiet" = "1" ]; then
git clone -q --depth 1 --branch main "$repo" "$update_cache" >/dev/null
else
git clone --depth 1 --branch main "$repo" "$update_cache" >/dev/null
fi
fi
src_dir="$update_cache"
fi
cd "$src_dir"
[ "$quiet" = "1" ] && echo "Building Dosh $role"
if [ "$role" = "client" ]; then
if [ "$quiet" = "1" ]; then
cargo build -q --release --bin dosh-client
else
cargo build --release --bin dosh-client
fi
else
if [ "$quiet" = "1" ]; then
cargo build -q --release
else
cargo build --release
fi
fi
install -m 0755 target/release/dosh-client "$bindir/dosh-client"
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install -m 0755 target/release/dosh-server "$bindir/dosh-server"
install -m 0755 target/release/dosh-auth "$bindir/dosh-auth"
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 fi
if [ "$role" = "server" ] || [ "$role" = "both" ]; then if [ "$role" = "server" ] || [ "$role" = "both" ]; then
@@ -226,8 +436,7 @@ EOF
if command -v systemctl >/dev/null 2>&1; then if command -v systemctl >/dev/null 2>&1; then
mkdir -p "$systemd_user_dir" mkdir -p "$systemd_user_dir"
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \ write_systemd_service
packaging/systemd/dosh-server.service >"$systemd_user_dir/dosh-server.service"
if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then
systemctl --user enable --now dosh-server.service systemctl --user enable --now dosh-server.service
fi fi
@@ -272,7 +481,8 @@ dosh_port = $port
default_session = "new" default_session = "new"
reconnect_timeout_secs = 5 reconnect_timeout_secs = 5
view_only = false view_only = false
predict = false predict = true
predict_mode = "experimental"
cache_attach_tickets = true cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials" credential_cache = "~/.local/share/dosh/credentials"
auth_preference = "native,ssh" auth_preference = "native,ssh"
@@ -295,18 +505,18 @@ EOF
fi fi
cat >"$hosts_config" <<EOF cat >"$hosts_config" <<EOF
# Example: # Example:
# [palav] # [homelab]
# ssh = "palav" # ssh = "homelab"
# dosh_host = "palav.dev" # dosh_host = "server.example.com"
# port = 50000 # port = 50000
# default_command = "tm" # default_command = "tm"
# predict = false # predict = true
[default] [default]
ssh = "$default_server" ssh = "$default_server"
dosh_host = "$host_udp" dosh_host = "$host_udp"
port = $port port = $port
predict = false predict = true
EOF EOF
fi fi
fi 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 sessions homelab # list live sessions
dosh trust homelab # fetch + pin the Dosh host key (via SSH fallback) dosh trust homelab # fetch + pin the Dosh host key (via SSH fallback)
dosh trust --remove homelab 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 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 done
ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts" 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 \ if ! HOME="$workdir/home" target/release/dosh-bench \
--server bench@127.0.0.1 \ --server bench@127.0.0.1 \
@@ -64,6 +64,32 @@ if ! HOME="$workdir/home-controlmaster" target/release/dosh-bench \
exit 1 exit 1
fi 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 \ if ! HOME="$workdir/home-cached" target/release/dosh-bench \
--server bench@127.0.0.1 \ --server bench@127.0.0.1 \
--ssh-port "$ssh_port" \ --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" || 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 clap::Parser;
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fmt::Write as _;
use std::fs;
use std::io::Read; use std::io::Read;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
@@ -63,6 +65,12 @@ struct Args {
/// Emit machine-readable JSON (one object per metric, with raw samples). /// Emit machine-readable JSON (one object per metric, with raw samples).
#[arg(long)] #[arg(long)]
json: bool, 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). /// Optional label printed in summary/JSON output (e.g. machine/OS identifier).
#[arg(long)] #[arg(long)]
label: Option<String>, label: Option<String>,
@@ -179,11 +187,14 @@ fn main() -> Result<()> {
results.push(MetricSamples::new("mosh_start_true_ms", mosh_times)); results.push(MetricSamples::new("mosh_start_true_ms", mosh_times));
} }
if args.json { let report = if args.json {
print_json(&args, &results); render_json(&args, &results)
} else if args.markdown {
render_markdown(&args, &results)
} else { } else {
print_table(&args, &results); render_table(&args, &results)
} };
write_report(&args, &report)?;
run_assertions(&args, &results)?; run_assertions(&args, &results)?;
Ok(()) 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 { 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}", "{:<24} {:>6} {:>9} {:>9} {:>9} {:>9} {:>9}",
"metric", "n", "min", "median", "p95", "mean", "max" "metric", "n", "min", "median", "p95", "mean", "max"
); );
for metric in results { for metric in results {
let s = metric.stats(); let s = metric.stats();
println!( let _ = writeln!(
out,
"{:<24} {:>6} {:>9.2} {:>9.2} {:>9.2} {:>9.2} {:>9.2}", "{:<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 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. // Raw per-iteration samples, so published numbers can include raw data.
for metric in results { for metric in results {
let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect(); 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(); let mut entries = Vec::new();
for metric in results { for metric in results {
let s = metric.stats(); let s = metric.stats();
@@ -603,11 +618,58 @@ fn print_json(args: &Args, results: &[MetricSamples]) {
Some(label) => format!("\"{}\"", label.replace('"', "\\\"")), Some(label) => format!("\"{}\"", label.replace('"', "\\\"")),
None => "null".to_string(), None => "null".to_string(),
}; };
println!( format!(
"{{\"label\":{label},\"iterations\":{},\"metrics\":[{}]}}", "{{\"label\":{label},\"iterations\":{},\"metrics\":[{}]}}",
args.iterations.max(1), args.iterations.max(1),
entries.join(",") 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> { 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"))) .and_then(|path| path.parent().map(|parent| parent.join("dosh-client")))
.unwrap_or_else(|| PathBuf::from("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]"));
}
}
+571 -79
View File
File diff suppressed because it is too large Load Diff
+709 -49
View File
File diff suppressed because it is too large Load Diff
+36 -2
View File
@@ -46,6 +46,17 @@ pub struct ServerConfig {
pub allow_agent_forwarding: bool, pub allow_agent_forwarding: bool,
#[serde(default = "default_accept_env")] #[serde(default = "default_accept_env")]
pub accept_env: Vec<String>, 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 { impl Default for ServerConfig {
@@ -76,6 +87,7 @@ impl Default for ServerConfig {
allow_remote_non_loopback_bind: false, allow_remote_non_loopback_bind: false,
allow_agent_forwarding: false, allow_agent_forwarding: false,
accept_env: default_accept_env(), accept_env: default_accept_env(),
persist_sessions: false,
} }
} }
} }
@@ -92,7 +104,7 @@ pub struct ClientConfig {
pub default_session: String, pub default_session: String,
pub reconnect_timeout_secs: u64, pub reconnect_timeout_secs: u64,
pub view_only: bool, pub view_only: bool,
#[serde(default)] #[serde(default = "default_true")]
pub predict: bool, pub predict: bool,
/// Prediction display policy: "off", "experimental" (adaptive, the default), /// Prediction display policy: "off", "experimental" (adaptive, the default),
/// or "always". Controls when speculative local echo is shown. /// or "always". Controls when speculative local echo is shown.
@@ -124,6 +136,11 @@ pub struct ClientConfig {
pub send_env: Vec<String>, pub send_env: Vec<String>,
#[serde(default)] #[serde(default)]
pub set_env: HashMap<String, String>, 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -139,6 +156,22 @@ pub struct HostConfig {
pub send_env: Option<Vec<String>>, pub send_env: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
pub set_env: HashMap<String, String>, 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -160,7 +193,7 @@ impl Default for ClientConfig {
default_session: "new".to_string(), default_session: "new".to_string(),
reconnect_timeout_secs: 5, reconnect_timeout_secs: 5,
view_only: false, view_only: false,
predict: false, predict: true,
predict_mode: default_predict_mode(), predict_mode: default_predict_mode(),
cache_attach_tickets: true, cache_attach_tickets: true,
credential_cache: "~/.local/share/dosh/credentials".to_string(), credential_cache: "~/.local/share/dosh/credentials".to_string(),
@@ -174,6 +207,7 @@ impl Default for ClientConfig {
disconnect_status: true, disconnect_status: true,
send_env: default_send_env(), send_env: default_send_env(),
set_env: HashMap::new(), set_env: HashMap::new(),
extensions: HashMap::new(),
} }
} }
} }
+1
View File
@@ -2,6 +2,7 @@ pub mod auth;
pub mod config; pub mod config;
pub mod crypto; pub mod crypto;
pub mod native; pub mod native;
pub mod persist;
pub mod protocol; pub mod protocol;
pub mod pty; pub mod pty;
pub mod ssh_agent; pub mod ssh_agent;
+350 -36
View File
@@ -5,7 +5,9 @@ use anyhow::{Context, Result, bail};
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rsa::traits::PublicKeyParts;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use signature::{SignatureEncoding, Signer as SignatureSigner, Verifier as SignatureVerifier};
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::net::IpAddr; use std::net::IpAddr;
@@ -339,6 +341,92 @@ pub fn sign_user_auth(
Ok(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( pub fn verify_native_user_auth(
client: &NativeClientHello, client: &NativeClientHello,
server: &NativeServerHello, server: &NativeServerHello,
@@ -347,37 +435,127 @@ pub fn verify_native_user_auth(
source_ip: Option<IpAddr>, source_ip: Option<IpAddr>,
) -> Result<AuthorizedKey> { ) -> Result<AuthorizedKey> {
anyhow::ensure!( anyhow::ensure!(
auth.public_key_algorithm == "ssh-ed25519", is_supported_user_signature_algorithm(&auth.public_key_algorithm),
"unsupported native user key algorithm {}", "unsupported native user key algorithm {}",
auth.public_key_algorithm auth.public_key_algorithm
); );
anyhow::ensure!( let authorized_algorithm = authorized_key_algorithm_for_auth(&auth.public_key_algorithm)?;
auth.public_key.len() == 32,
"ssh-ed25519 public key must be 32 bytes"
);
anyhow::ensure!(
auth.signature.len() == 64,
"ssh-ed25519 signature must be 64 bytes"
);
let authorized = authorized_keys let authorized = authorized_keys
.iter() .iter()
.find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key) .find(|key| key.algorithm == authorized_algorithm && key.key == auth.public_key)
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?; .ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
authorized.ensure_native_allowed(auth, source_ip)?; 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 mut signature = [0u8; 64];
signature.copy_from_slice(&auth.signature);
let signature = Signature::from_bytes(&signature);
let transcript = user_auth_transcript(client, server, auth)?; let transcript = user_auth_transcript(client, server, auth)?;
verifying_key verify_native_user_signature(auth, &transcript)?;
.verify(&transcript, &signature)
.context("verify native user signature")?;
Ok(authorized.clone()) 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"
);
anyhow::ensure!(
auth.signature.len() == 64,
"ssh-ed25519 signature must be 64 bytes"
);
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 mut signature = [0u8; 64];
signature.copy_from_slice(&auth.signature);
let signature = Signature::from_bytes(&signature);
verifying_key
.verify(transcript, &signature)
.context("verify native user signature")?;
}
"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( pub fn verify_native_user_auth_from_config(
config: &ServerConfig, config: &ServerConfig,
client: &NativeClientHello, client: &NativeClientHello,
@@ -470,6 +648,35 @@ pub fn load_ed25519_identity_with_passphrase(
.with_context(|| format!("parse ssh-ed25519 identity {}", path.display())) .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 { impl AuthorizedKey {
fn ensure_native_allowed( fn ensure_native_allowed(
&self, &self,
@@ -565,7 +772,7 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
fields.len() >= 2, fields.len() >= 2,
"authorized_keys:{line_number}: expected key fields" "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) (AuthorizedKeyOptions::default(), 0)
} else { } else {
(parse_authorized_key_options(&fields[0])?, 1) (parse_authorized_key_options(&fields[0])?, 1)
@@ -576,15 +783,20 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
let key_blob = fields let key_blob = fields
.get(key_type_index + 1) .get(key_type_index + 1)
.with_context(|| format!("authorized_keys:{line_number}: missing key blob"))?; .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 let decoded = STANDARD
.decode(key_blob) .decode(key_blob)
.with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?; .with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?;
let key = parse_ssh_ed25519_public_blob(&decoded) anyhow::ensure!(
.with_context(|| format!("authorized_keys:{line_number}: parse ssh-ed25519 key"))?; 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 { let comment = if fields.len() > key_type_index + 2 {
Some(fields[key_type_index + 2..].join(" ")) Some(fields[key_type_index + 2..].join(" "))
} else { } else {
@@ -598,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> { fn split_authorized_key_fields(line: &str) -> Vec<String> {
line.split_whitespace().map(ToString::to_string).collect() line.split_whitespace().map(ToString::to_string).collect()
} }
@@ -765,6 +1022,18 @@ pub fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
Ok(out) 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> { pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
let mut out = Vec::new(); let mut out = Vec::new();
write_ssh_string(&mut out, b"ssh-ed25519"); write_ssh_string(&mut out, b"ssh-ed25519");
@@ -772,6 +1041,12 @@ pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
out 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]> { fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length"); anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length");
let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize; let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize;
@@ -1004,16 +1279,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32])); let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!( assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(), trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::Trusted TrustResult::Trusted
); );
assert_eq!( assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(), trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::AlreadyTrusted TrustResult::AlreadyTrusted
); );
assert!(trust_host(&path, "palav", &second, "ssh", false).is_err()); assert!(trust_host(&path, "homelab", &second, "ssh", false).is_err());
assert_eq!( assert_eq!(
trust_host(&path, "palav", &second, "ssh", true).unwrap(), trust_host(&path, "homelab", &second, "ssh", true).unwrap(),
TrustResult::Trusted TrustResult::Trusted
); );
} }
@@ -1026,16 +1301,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32])); let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!( assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(), verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Unknown KnownHostStatus::Unknown
); );
trust_host(&path, "palav", &first, "ssh", false).unwrap(); trust_host(&path, "homelab", &first, "ssh", false).unwrap();
assert_eq!( assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(), verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Trusted KnownHostStatus::Trusted
); );
assert!(matches!( assert!(matches!(
verify_known_host(&path, "palav", &second).unwrap(), verify_known_host(&path, "homelab", &second).unwrap(),
KnownHostStatus::Mismatch { .. } KnownHostStatus::Mismatch { .. }
)); ));
} }
@@ -1084,6 +1359,45 @@ mod tests {
assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err()); 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] #[test]
fn native_user_auth_rejects_tampered_transcript() { fn native_user_auth_rejects_tampered_transcript() {
let host_signing = SigningKey::from_bytes(&[3u8; 32]); let host_signing = SigningKey::from_bytes(&[3u8; 32]);
@@ -1311,8 +1625,8 @@ mod tests {
protocol_version: NATIVE_PROTOCOL_VERSION, protocol_version: NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32], client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32], client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(), requested_host: "homelab".to_string(),
requested_user: "palav".to_string(), requested_user: "alice".to_string(),
requested_session: "term".to_string(), requested_session: "term".to_string(),
requested_mode: "read-write".to_string(), requested_mode: "read-write".to_string(),
terminal_size: (80, 24), 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"));
}
}
+76 -1
View File
@@ -3,14 +3,49 @@ use crate::crypto;
use crate::native::{EnvVar, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth}; use crate::native::{EnvVar, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize}; 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 MAGIC: &[u8; 4] = b"DOSH";
// v4: added reliable stream offsets/acks to `StreamData` and
// `StreamWindowAdjust`.
// v3: added `ForwardingKind::Agent` (SSH-agent forwarding). The new variant rides // v3: added `ForwardingKind::Agent` (SSH-agent forwarding). The new variant rides
// inside `NativeUserAuth.requested_forwardings`, so a pre-agent peer would fail to // 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 // deserialize it; bumping the wire version makes such a peer answer with a clear
// version-mismatch reject instead. Existing variants' bincode discriminants are // version-mismatch reject instead. Existing variants' bincode discriminants are
// unchanged, so the bump is purely a compatibility gate. // unchanged, so the bump is purely a compatibility gate.
pub const VERSION: u8 = 3; pub const VERSION: u8 = 4;
pub const HEADER_LEN: usize = 58; pub const HEADER_LEN: usize = 58;
/// Stable, user-facing reason string the server puts in an `AttachReject` when a /// Stable, user-facing reason string the server puts in an `AttachReject` when a
@@ -383,12 +418,14 @@ pub struct StreamOpenReject {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamData { pub struct StreamData {
pub stream_id: u64, pub stream_id: u64,
pub offset: u64,
pub bytes: Vec<u8>, pub bytes: Vec<u8>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamWindowAdjust { pub struct StreamWindowAdjust {
pub stream_id: u64, pub stream_id: u64,
pub received_offset: u64,
pub bytes: u32, pub bytes: u32,
} }
@@ -496,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"));
}
}
+207 -30
View File
@@ -1,15 +1,42 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem}; use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use std::io::{Read, Write}; 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::sync::{Arc, Mutex};
use std::thread; use std::thread;
use tokio::sync::mpsc; use tokio::sync::mpsc;
// 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 { pub struct PtyHandle {
writer: Arc<Mutex<Box<dyn Write + Send>>>, writer: Arc<Mutex<Box<dyn Write + Send>>>,
child: Mutex<Box<dyn Child + Send + Sync>>, backing: Backing,
_master: Box<dyn MasterPty + Send>,
} }
impl PtyHandle { impl PtyHandle {
@@ -21,23 +48,40 @@ impl PtyHandle {
} }
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> { pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
self._master.resize(PtySize { match &self.backing {
rows, Backing::Owned { _master, .. } => {
cols, _master.resize(PtySize {
pixel_width: 0, rows,
pixel_height: 0, 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(()) 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. /// Terminate the shell process backing this PTY and reap it.
/// ///
/// Without this the child shell outlives the session: dropping the master /// Only meaningful for an `Owned` backing (the server spawned the shell as a
/// alone is not guaranteed to take the process down, and the `Child` handle /// child). For an `Adopted` backing the shell belongs to the holder process,
/// used to be discarded at spawn time, which leaked one shell per /// so this is a no-op here; reaping a persistent session is done by asking
/// abandoned session. /// the holder to shut down (see `persist::request_shutdown`).
pub fn kill(&self) { 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.kill();
let _ = child.wait(); let _ = child.wait();
} }
@@ -46,10 +90,29 @@ impl PtyHandle {
impl Drop for PtyHandle { impl Drop for PtyHandle {
fn drop(&mut self) { fn drop(&mut self) {
self.kill(); // 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)] #[derive(Debug)]
pub struct PtyOutput { pub struct PtyOutput {
pub session: String, pub session: String,
@@ -74,12 +137,82 @@ pub fn spawn_pty_session(
pixel_height: 0, pixel_height: 0,
}) })
.context("open pty")?; .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); let mut cmd = CommandBuilder::new(shell);
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor"); cmd.env("COLORTERM", "truecolor");
for (name, value) in env { for (name, value) in env {
cmd.env(name, value); 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); cmd.env("SHELL", shell);
if let Some(home) = dirs::home_dir() { if let Some(home) = dirs::home_dir() {
cmd.env("HOME", home.as_os_str()); 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() { } else if let Some(parent) = Path::new(shell).parent() {
cmd.env("PWD", parent.as_os_str()); cmd.env("PWD", parent.as_os_str());
} }
let child = pair.slave.spawn_command(cmd).context("spawn shell")?; cmd
drop(pair.slave); }
let writer = pair.master.take_writer().context("take pty writer")?; /// Build a [`PtyHandle`] from a master fd received from a holder process.
let mut reader = pair.master.try_clone_reader().context("clone pty reader")?; ///
/// `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(); let reader_session = session.clone();
thread::Builder::new() thread::Builder::new()
.name(format!("dosh-pty-{session}")) .name(format!("dosh-pty-{session}"))
@@ -109,11 +270,13 @@ pub fn spawn_pty_session(
break; break;
} }
Ok(n) => { Ok(n) => {
let _ = tx.send(PtyOutput { for chunk in buf[..n].chunks(PTY_OUTPUT_CHUNK_BYTES) {
session: reader_session.clone(), let _ = tx.send(PtyOutput {
bytes: buf[..n].to_vec(), session: reader_session.clone(),
exited: false, bytes: chunk.to_vec(),
}); exited: false,
});
}
} }
Err(_) => { Err(_) => {
let _ = tx.send(PtyOutput { let _ = tx.send(PtyOutput {
@@ -127,10 +290,24 @@ pub fn spawn_pty_session(
} }
}) })
.context("spawn pty reader")?; .context("spawn pty reader")?;
Ok(())
}
Ok(PtyHandle { #[cfg(test)]
writer: Arc::new(Mutex::new(writer)), mod tests {
child: Mutex::new(child), use super::*;
_master: pair.master,
}) #[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::{ use crate::native::{
ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth, 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 anyhow::{Context, Result, anyhow, bail};
use std::io::{Read, Write}; 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_AGENT_IDENTITIES_ANSWER: u8 = 12;
const SSH2_AGENTC_SIGN_REQUEST: u8 = 13; const SSH2_AGENTC_SIGN_REQUEST: u8 = 13;
const SSH2_AGENT_SIGN_RESPONSE: u8 = 14; const SSH2_AGENT_SIGN_RESPONSE: u8 = 14;
const SSH_AGENT_RSA_SHA2_512: u32 = 4;
const MAX_AGENT_PACKET: usize = 256 * 1024; const MAX_AGENT_PACKET: usize = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentIdentity { pub struct AgentIdentity {
pub key_blob: Vec<u8>, 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, pub comment: String,
} }
@@ -38,13 +42,13 @@ pub fn sign_user_auth_with_agent_at(
) -> Result<NativeUserAuth> { ) -> Result<NativeUserAuth> {
let mut agent = UnixStream::connect(socket_path.as_ref()) let mut agent = UnixStream::connect(socket_path.as_ref())
.with_context(|| format!("connect ssh-agent {}", socket_path.as_ref().display()))?; .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 let identity = identities
.first() .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 { let mut auth = NativeUserAuth {
public_key_algorithm: "ssh-ed25519".to_string(), public_key_algorithm: identity.sign_algorithm.clone(),
public_key: identity.public_key.to_vec(), public_key: identity.public_key.clone(),
signature: Vec::new(), signature: Vec::new(),
requested_forwardings, requested_forwardings,
}; };
@@ -54,7 +58,7 @@ pub fn sign_user_auth_with_agent_at(
Ok(auth) 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])?; write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?;
let payload = read_agent_packet(agent)?; let payload = read_agent_packet(agent)?;
let mut cursor = payload.as_slice(); let mut cursor = payload.as_slice();
@@ -72,18 +76,49 @@ fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentit
for _ in 0..count { for _ in 0..count {
let key_blob = read_ssh_string(&mut cursor)?.to_vec(); let key_blob = read_ssh_string(&mut cursor)?.to_vec();
let comment = String::from_utf8_lossy(read_ssh_string(&mut cursor)?).to_string(); 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) { if let Some(identity) = supported_identity(key_blob, comment)? {
identities.push(AgentIdentity { identities.push(identity);
key_blob,
public_key,
comment,
});
} }
} }
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities"); anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities");
Ok(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( fn sign_with_agent(
agent: &mut UnixStream, agent: &mut UnixStream,
identity: &AgentIdentity, identity: &AgentIdentity,
@@ -93,7 +128,7 @@ fn sign_with_agent(
request.push(SSH2_AGENTC_SIGN_REQUEST); request.push(SSH2_AGENTC_SIGN_REQUEST);
write_ssh_string(&mut request, &identity.key_blob); write_ssh_string(&mut request, &identity.key_blob);
write_ssh_string(&mut request, transcript); 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)?; write_agent_packet(agent, &request)?;
let payload = read_agent_packet(agent)?; let payload = read_agent_packet(agent)?;
@@ -114,20 +149,17 @@ fn sign_with_agent(
let mut signature_cursor = signature_blob; let mut signature_cursor = signature_blob;
let algorithm = read_ssh_string(&mut signature_cursor)?; let algorithm = read_ssh_string(&mut signature_cursor)?;
let algorithm = String::from_utf8_lossy(algorithm);
anyhow::ensure!( anyhow::ensure!(
algorithm == b"ssh-ed25519", algorithm == identity.sign_algorithm,
"ssh-agent returned unsupported signature algorithm {}", "ssh-agent returned signature algorithm {algorithm}, expected {}",
String::from_utf8_lossy(algorithm) identity.sign_algorithm
); );
let signature = read_ssh_string(&mut signature_cursor)?; let signature = read_ssh_string(&mut signature_cursor)?;
anyhow::ensure!( anyhow::ensure!(
signature_cursor.is_empty(), signature_cursor.is_empty(),
"trailing data in ssh-agent signature blob" "trailing data in ssh-agent signature blob"
); );
anyhow::ensure!(
signature.len() == 64,
"ssh-agent Ed25519 signature was not 64 bytes"
);
Ok(signature.to_vec()) Ok(signature.to_vec())
} }
@@ -182,6 +214,12 @@ fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(value); 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -253,8 +291,8 @@ mod tests {
protocol_version: crate::native::NATIVE_PROTOCOL_VERSION, protocol_version: crate::native::NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32], client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32], client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(), requested_host: "homelab".to_string(),
requested_user: "palav".to_string(), requested_user: "alice".to_string(),
requested_session: "term".to_string(), requested_session: "term".to_string(),
requested_mode: "read-write".to_string(), requested_mode: "read-write".to_string(),
terminal_size: (80, 24), terminal_size: (80, 24),
+498 -3
View File
@@ -20,7 +20,8 @@
//! tests are reproducible and fast. //! tests are reproducible and fast.
use std::fs; 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::process::{Child, Command, Stdio};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -28,13 +29,18 @@ use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; 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::auth::{BootstrapResponse, build_bootstrap, load_or_create_server_secret};
use dosh::config::load_server_config; use dosh::config::load_server_config;
use dosh::crypto; use dosh::crypto;
use dosh::native::{self, ForwardingKind, ForwardingRequest, NativeAuthOk, NativeClientHello};
use dosh::protocol::{ use dosh::protocol::{
self, AttachOk, CLIENT_TO_SERVER, Frame, Header, Input, PacketKind, ResumeRequest, self, AttachOk, AttachReject, CLIENT_TO_SERVER, Frame, Header, Input, NativeAuthOkBody,
SERVER_TO_CLIENT, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind, ResumeRequest,
SERVER_TO_CLIENT, StreamData, StreamOpen, StreamOpenOk, StreamWindowAdjust,
}; };
use ed25519_dalek::{SigningKey, VerifyingKey};
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::{Rng, SeedableRng}; use rand::{Rng, SeedableRng};
@@ -67,6 +73,7 @@ sessions_dir = "{sessions}"
secret_path = "{secret}" secret_path = "{secret}"
host_key = "{host_key}" host_key = "{host_key}"
authorized_keys = ["{authorized_keys}"] authorized_keys = ["{authorized_keys}"]
persist_sessions = false
"#, "#,
sessions = dir.path().join("sessions").display(), sessions = dir.path().join("sessions").display(),
secret = dir.path().join("secret").display(), secret = dir.path().join("secret").display(),
@@ -78,6 +85,23 @@ authorized_keys = ["{authorized_keys}"]
config 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 { fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child {
let server = env!("CARGO_BIN_EXE_dosh-server"); let server = env!("CARGO_BIN_EXE_dosh-server");
let child = Command::new(server) let child = Command::new(server)
@@ -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( fn send_input(
socket: &UdpSocket, socket: &UdpSocket,
relay: &Relay, relay: &Relay,
@@ -430,6 +570,152 @@ fn send_raw(socket: &UdpSocket, relay: &Relay, packet: &[u8]) {
socket.send_to(packet, relay.front_addr()).unwrap(); 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)> { fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(Header, Frame)> {
let mut buf = [0u8; 65535]; let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).ok()?; 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] #[test]
fn stale_packets_after_resume_are_ignored_not_fatal() { fn stale_packets_after_resume_are_ignored_not_fatal() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+630 -1
View File
@@ -4,6 +4,7 @@ use std::net::{TcpListener, TcpStream, UdpSocket};
use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
@@ -83,6 +84,7 @@ sessions_dir = "{sessions}"
secret_path = "{secret}" secret_path = "{secret}"
host_key = "{host_key}" host_key = "{host_key}"
authorized_keys = ["{authorized_keys}"] authorized_keys = ["{authorized_keys}"]
persist_sessions = false
"#, "#,
sessions = dir.path().join("sessions").display(), sessions = dir.path().join("sessions").display(),
secret = dir.path().join("secret").display(), secret = dir.path().join("secret").display(),
@@ -233,6 +235,37 @@ fn start_echo_server() -> u16 {
port 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) { fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Path) {
write_native_client_auth_with_options(dir, config_path, ""); write_native_client_auth_with_options(dir, config_path, "");
} }
@@ -413,6 +446,15 @@ fn direct_attach(
config: &std::path::Path, config: &std::path::Path,
port: u16, port: u16,
mode: &str, 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) { ) -> (std::net::UdpSocket, dosh::auth::BootstrapResponse, AttachOk) {
let config = load_server_config(Some(config.to_path_buf())).unwrap(); let config = load_server_config(Some(config.to_path_buf())).unwrap();
let secret = load_or_create_server_secret(&config).unwrap(); let secret = load_or_create_server_secret(&config).unwrap();
@@ -420,7 +462,7 @@ fn direct_attach(
&config, &config,
&secret, &secret,
"tester".to_string(), "tester".to_string(),
"default".to_string(), session.to_string(),
mode.to_string(), mode.to_string(),
(80, 24), (80, 24),
crypto::random_12(), crypto::random_12(),
@@ -781,6 +823,106 @@ fn native_local_forward_background_smoke() {
assert_eq!(&buf[..n], b"dosh-background-forward-ping"); 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] #[test]
fn native_remote_forward_echo_smoke() { fn native_remote_forward_echo_smoke() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -1179,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] #[test]
fn resume_snapshot_preserves_alternate_screen_mode() { fn resume_snapshot_preserves_alternate_screen_mode() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -1292,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] #[test]
fn transport_rekey_round_trip_keeps_session_alive() { fn transport_rekey_round_trip_keeps_session_alive() {
use dosh::native::derive_rekey_session_key; use dosh::native::derive_rekey_session_key;
@@ -1800,3 +2116,316 @@ fn native_agent_forwarding_requires_client_opt_in() {
"agent proxy dir {agent_dir:?} must not exist without client -A opt-in" "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:?}"
);
}