Compare commits

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

Server-only behavior change; wire VERSION unchanged.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:03:06 -04:00
32 changed files with 4297 additions and 471 deletions
+35 -11
View File
@@ -3,6 +3,9 @@ name: ci
on:
push:
pull_request:
workflow_dispatch:
schedule:
- cron: "17 7 * * 1"
jobs:
test:
@@ -36,21 +39,42 @@ jobs:
if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success'
run: |
set -e
for target in \
packet_decode \
from_body \
authorized_keys \
known_hosts \
handshake_structs \
attach_ticket; do
echo "== fuzzing $target =="
cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \
-max_total_time=20 -rss_limit_mb=4096
done
if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
DOSH_FUZZ_SECONDS="${DOSH_FUZZ_SECONDS:-300}" sh scripts/fuzz-run.sh
else
sh scripts/fuzz-run.sh 20
fi
- name: Note when fuzzing was skipped
if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success'
run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run."
package-release:
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')
strategy:
matrix:
include:
- os: ubuntu-latest
name: linux-x86_64
- os: macos-14
name: macos-aarch64
- os: macos-13
name: macos-x86_64
- os: windows-latest
name: windows-x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Package release
shell: bash
run: sh scripts/package-release.sh
- uses: actions/upload-artifact@v4
with:
name: dosh-${{ matrix.name }}
path: |
target/dosh-release/dosh-*
!target/dosh-release/stage/**
remote-bench:
runs-on: ubuntu-latest
env:
Generated
+5 -1
View File
@@ -397,6 +397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"pem-rfc7468",
"zeroize",
]
@@ -435,7 +436,7 @@ dependencies = [
[[package]]
name = "dosh"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"base64",
@@ -452,8 +453,10 @@ dependencies = [
"portable-pty",
"rand",
"rpassword",
"rsa",
"serde",
"sha2",
"signature",
"ssh-key",
"tempfile",
"tokio",
@@ -1499,6 +1502,7 @@ checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
dependencies = [
"bcrypt-pbkdf",
"ed25519-dalek",
"num-bigint-dig",
"p256",
"p384",
"p521",
+4 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "dosh"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
license = "MIT"
@@ -19,10 +19,12 @@ hmac = "0.12"
libc = "0.2"
portable-pty = "0.8"
rand = "0.8"
rsa = { version = "0.9.10", features = ["sha2"] }
rpassword = "7.5.4"
serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10"
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] }
signature = "2.2"
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption", "p256", "rsa"] }
tokio = { version = "1.41", features = ["full"] }
toml = "0.8"
vt100 = "0.15"
+18 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build test fmt install bench-local bench-local-json bench-docker-ssh bench-docker-mosh
.PHONY: build test fmt install package-release bench-report bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local
build:
cargo build --release
@@ -12,6 +12,12 @@ fmt:
install:
sh packaging/install.sh
package-release:
sh scripts/package-release.sh
bench-report:
sh scripts/bench-report.sh
# Safe, self-contained local benchmark matrix (native cold auth, cached attach
# ticket, local-auth) on a throwaway server bound to 127.0.0.1 on a free port in
# a temp HOME. Never touches the production server or UDP port 50000.
@@ -28,3 +34,14 @@ bench-docker-ssh:
bench-docker-mosh:
DOSH_BENCH_INCLUDE_MOSH=1 sh scripts/ci-docker-ssh-bench.sh
fuzz-smoke:
sh scripts/fuzz-run.sh 20
# Longer pre-launch fuzz pass. Override with DOSH_FUZZ_SECONDS=NN.
fuzz-deep:
DOSH_FUZZ_SECONDS=$${DOSH_FUZZ_SECONDS:-300} sh scripts/fuzz-run.sh
# 30-minute launch soak by default. Override with DOSH_SOAK_SECONDS=NN.
soak-local:
DOSH_SOAK_SECONDS=$${DOSH_SOAK_SECONDS:-1800} cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture
+121 -30
View File
@@ -81,7 +81,7 @@ dosh-server
client table per session
encrypted UDP protocol
tiny SSH-invoked dosh-auth helper mode
persistent sessions (persist_sessions, default on): each shell runs in a
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
@@ -110,33 +110,56 @@ Install the client on macOS:
```bash
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
| DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_SERVER=palav DOSH_HOST=git.palav.dev DOSH_PORT=50000 sh -s -- client
| DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_PORT=50000 sh -s -- client
```
Update an installed client later:
```bash
dosh update
dosh update --check
dosh --version
```
`dosh update` first tries a release tarball named for the platform
(`dosh-macos-aarch64.tar.gz`, `dosh-linux-x86_64.tar.gz`, etc.) from the latest
Gitea/GitHub release. Gitea's concrete tag download route is detected when
`/releases/latest/download/...` is not supported. If that asset is not published
yet, it falls back to the source build path. If the release also publishes
`<artifact>.sha256`, the installer verifies the archive before installing it.
Install the client on Windows PowerShell:
```powershell
$env:DOSH_REPO="https://git.palav.dev/Palav/dosh.git"; $env:DOSH_SERVER="palav"; $env:DOSH_HOST="git.palav.dev"; $env:DOSH_PORT="50000"; irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
$env:DOSH_REPO="https://git.palav.dev/Palav/dosh.git"; $env:DOSH_PORT="50000"; irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
```
Attach:
```bash
dosh palav
dosh user@server.example.com
```
Plain `dosh palav` opens a fresh terminal session. Use named sessions when you want
to reattach to the same persistent terminal from multiple clients:
First-time setup for an existing SSH alias:
```bash
dosh --session work palav
dosh --session logs palav
dosh setup homelab
dosh selftest homelab
dosh homelab
```
`dosh setup <ssh-alias>` imports the OpenSSH alias into
`~/.config/dosh/hosts.toml`, pins the Dosh host key over SSH, runs `dosh doctor`,
and prints next commands. `dosh selftest <host>` checks local terminal overlay
safety, forwarding syntax, and the remote doctor path.
Plain `dosh user@server.example.com` opens a fresh terminal session. Use named
sessions when you want to reattach to the same persistent terminal from multiple
clients:
```bash
dosh --session work user@server.example.com
dosh --session logs user@server.example.com
```
Press `Ctrl-]` to detach the current client while leaving the server session alive.
@@ -156,13 +179,44 @@ If SSH and UDP use different public names, specify the UDP address:
dosh-client --dosh-host public.example.com --dosh-port 50000 user@host
```
Forwarding can use SSH-shaped flags directly or the forward-only wrapper:
```bash
dosh -N -L 8080:127.0.0.1:80 homelab
dosh forward homelab -L 8080:127.0.0.1:80 -D 1080
```
Dosh already lets OpenSSH handle SSH aliases, users, keys, ports, known-hosts,
ProxyJump, and other SSH config during bootstrap. It also runs `ssh -G` to infer the
UDP host from an SSH alias when `dosh_host` is not configured. To make that explicit
in Dosh's host config:
```bash
dosh import-ssh palav homelab
dosh import-ssh homelab
dosh homelab
```
Optional command extensions are just config-side startup shortcuts; Dosh has no
compile-time dependency on the tools they run. For example, to make a separately
installed server-side `tm` dashboard easy to open:
```toml
# ~/.config/dosh/client.toml
[extensions.tm]
command = "tm {args}"
description = "Open the server-side tmux dashboard"
```
Then `dosh homelab tm` sends `tm`, and `dosh homelab tm dosh` sends `tm 'dosh'`.
Remove that table to remove the integration. Hosts can override or opt out:
```toml
# ~/.config/dosh/hosts.toml
[homelab.extensions.tm]
command = "/opt/tm/bin/tm {args}"
[other-host.extensions.tm]
disabled = true
```
## Develop
@@ -197,9 +251,9 @@ Benchmark the ControlMaster-backed SSH bootstrap path:
target/release/dosh-bench --server user@host --controlmaster --iterations 3
```
Run the Docker OpenSSH benchmark gate used by CI. It checks both cold SSH bootstrap
and ControlMaster-backed SSH bootstrap against a containerized `sshd` plus resident
`dosh-server`:
Run the Docker OpenSSH benchmark gate used by CI. It checks cold SSH bootstrap,
ControlMaster-backed SSH bootstrap, native cold auth after one-time `dosh trust`,
and cached attach against a containerized `sshd` plus resident `dosh-server`:
```bash
make bench-docker-ssh
@@ -211,12 +265,39 @@ Run the same Docker comparison with Mosh installed in the benchmark container:
make bench-docker-mosh
```
That prints `ssh_true_ms`, `dosh_attach_ms`, and `mosh_start_true_ms` under the same
container, key, DNS, and network path. It also prints `dosh_cached_attach_ms`, which
is the real Dosh fast path after the first SSH-authenticated bootstrap has issued an
attach ticket. See `docs/PUBLIC_READINESS.md` before using the numbers publicly;
Dosh's current strongest claim is fast attach/reconnect, not full Mosh feature
parity yet.
That prints `ssh_true_ms`, `dosh_attach_ms`, `dosh_cold_native_ms`,
`dosh_cached_attach_ms`, and, for the Mosh target, `mosh_start_true_ms` under the
same container, key, DNS, and network path. `dosh_cached_attach_ms` is the real Dosh
fast path after the first authentication has issued an attach ticket. See
`docs/PUBLIC_READINESS.md` before using the numbers publicly; Dosh's current
strongest claim is fast attach/reconnect plus native encrypted forwarding on
Dosh-installed servers, not generic SSH compatibility.
The latest local release evidence is in
`docs/RELEASE_EVIDENCE_2026-06-20.md`.
Generate a publishable Markdown benchmark report:
```bash
make bench-report
```
Set `DOSH_BENCH_SERVER`, `DOSH_BENCH_ITERS`, `DOSH_BENCH_ARGS`, and
`DOSH_BENCH_REPORT` to target a real host and choose the output path.
Run the explicit pre-launch soak and fuzz gates:
```bash
make soak-local # 30-minute sleep/roaming gate by default
make fuzz-deep # 5 minutes per fuzz target by default
```
Both are configurable for shorter local shakedowns:
```bash
DOSH_SOAK_SECONDS=30 make soak-local
DOSH_FUZZ_SECONDS=60 make fuzz-deep
```
The CI workflow includes an optional remote benchmark job. It runs when
`DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository secrets are
@@ -228,6 +309,13 @@ Install release binaries and the user systemd service:
make install
```
Build release tarballs for upload to Gitea/GitHub releases:
```bash
make package-release
GITEA_TOKEN=... scripts/upload-gitea-release.sh v0.1.0 target/dosh-release/dosh-*
```
## Performance Rules
The stack is performance-driven, not fixed by taste. Rust is the default because the
@@ -262,7 +350,8 @@ Hot-path rules:
- Replacing SSH as the first public-key trust mechanism.
- Multi-user access control.
- Windows support in v0.
- Windows server/full parity in v0; the client installer supports prebuilt Windows
client artifacts when published.
- Full mosh compatibility.
- Perfect predictive local echo in the first MVP.
@@ -281,10 +370,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
servers:
- **Native UDP auth** with X25519 key exchange, transcript-bound Ed25519 user auth
via ssh-agent or an encrypted OpenSSH key, ChaCha20-Poly1305 transport, and
`authorized_keys` policy enforcement (`from=`, `no-port-forwarding`, `permitopen=`;
unsupported options fail closed).
- **Native UDP auth** with X25519 key exchange; transcript-bound Ed25519, ECDSA
P-256, and RSA-SHA2 user auth via ssh-agent or OpenSSH identity files;
ChaCha20-Poly1305 transport; and `authorized_keys` policy enforcement (`from=`,
`no-port-forwarding`, `permitopen=`; unsupported options fail closed).
- **Dosh host-key trust**: pinned `known_hosts`, `dosh trust [--remove|--replace]`,
TOFU only when explicitly enabled, and hard-fail on host-key mismatch.
- **TCP forwarding**: local `-L`, remote `-R` (loopback bind by default), dynamic
@@ -303,10 +392,12 @@ the native authenticated path is tried first and falls back to SSH bootstrap
explicitly when native auth is disabled, unavailable, or rejected. It never silently
degrades to an unauthenticated mode.
Native v1 is **not yet fully verified**. Per-IP token-bucket rate limiting, protocol
VERSION negotiation hardening, fuzzing in CI, ECDSA/RSA user keys, and an external
security review are still open. See `docs/THREAT_MODEL.md` for the published threat
model and accepted residual risks, and the "Native v1 verification checklist status"
table in `docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not yet
claim a fully verified SSH replacement; its defensible claim remains fast encrypted
native attach/reconnect with SSH-equivalent transport security and SSH fallback.
Native v1 is **not externally audited yet**. Local 30-minute sleep/roaming soak,
fuzz-smoke, and Docker SSH/Mosh benchmark evidence is captured in
`docs/RELEASE_EVIDENCE_2026-06-20.md`. See `docs/THREAT_MODEL.md` for the
published threat model and accepted residual risks, `docs/PROTOCOL_VERSIONING.md`
for the v1 versioning policy, `docs/AUDIT_PACKET.md` for the external security
review handoff, and the "Native v1 verification checklist status" table in
`docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not claim generic
SSH compatibility; its defensible claim is fast encrypted native attach/reconnect
and forwarding on Dosh-installed servers with SSH bootstrap fallback.
+2
View File
@@ -468,6 +468,8 @@ dosh_port = 50000
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
```
+75
View File
@@ -0,0 +1,75 @@
# Dosh Native v1 Audit Packet
This is the handoff checklist for an external security review.
## Scope
Review the security properties of Dosh native v1:
- Native UDP authentication and key exchange
- Host key trust and known-host mismatch behavior
- Authorized key parsing and policy enforcement
- Transport encryption, replay protection, rekeying, resume, and stale packet handling
- Attach ticket sealing/opening
- TCP forwarding and agent forwarding authorization boundaries
- Parser robustness for untrusted wire/config data
Primary files:
- `src/native.rs`
- `src/protocol.rs`
- `src/auth.rs`
- `src/ssh_agent.rs`
- `src/bin/dosh-client.rs`
- `src/bin/dosh-server.rs`
- `src/config.rs`
- `tests/parser_robustness.rs`
- `tests/protocol_auth.rs`
- `tests/hostile_network.rs`
- `tests/integration_smoke.rs`
- `fuzz/fuzz_targets/*`
Primary docs:
- `docs/NATIVE_V1_SPEC.md`
- `docs/THREAT_MODEL.md`
- `docs/PROTOCOL_VERSIONING.md`
- `docs/PUBLIC_READINESS.md`
- `docs/RELEASE_EVIDENCE_2026-06-20.md`
## Required Reviewer Questions
1. Is the native handshake transcript complete enough to bind client/server keys,
requested session, mode, env, and forwarding permissions?
2. Are host-key trust transitions fail-closed, especially unknown vs mismatch vs
explicit TOFU?
3. Are authorized-key options parsed conservatively enough, and are unsupported
options rejected safely?
4. Can replayed, reordered, delayed, stale, or cross-epoch packets affect terminal
input or forwarded streams more than once?
5. Does key rotation preserve confidentiality without creating split-brain epoch
states?
6. Do attach tickets create any bearer-token replay or privilege-extension issue?
7. Can UDP endpoint migration be abused for hijack or reflection?
8. Are local/remote/dynamic TCP forwarding and agent forwarding gated correctly by
client opt-in, server config, and authorized-key options?
9. Are parser and packet-size limits sufficient against malicious inputs?
10. Are there any unsafe assumptions in process/session persistence or holder
adoption?
## Current Local Evidence
As of 2026-06-20:
- `cargo test`: `153 passed, 1 ignored`
- `make soak-local`: passed 30-minute sleep/roaming soak
- `make fuzz-smoke`: passed all configured fuzz targets for 20s each
- `make bench-docker-mosh`: passed SSH, ControlMaster, native, cached, and Mosh
comparison gates
## Explicit Non-Claims Before Audit
- No claim of generic SSH protocol replacement.
- No claim of full Mosh compatibility.
- No claim that native v1 is externally audited.
- No claim that Windows server support is production-ready.
+50 -20
View File
@@ -17,6 +17,7 @@ make bench-local # safe, self-contained matrix on a throwaway server
make bench-local-json # same, machine-readable JSON with raw samples
make bench-docker-ssh # containerized SSH-vs-Dosh gate used by CI
make bench-docker-mosh # same, with Mosh installed for a three-way comparison
make bench-report # Markdown report wrapper around dosh-bench
```
`make bench-local` never touches a running production server: it builds release
@@ -35,7 +36,8 @@ tool pays before any useful remote work begins.
Every run prints, per metric, a summary line (count, min, median, p95, mean, max
in milliseconds) **and** a raw-samples line. Pass `--json` for one machine-readable
object per run (with the full `samples_ms` array) so published numbers can always
include raw data, as required by `docs/PUBLIC_READINESS.md`.
include raw data, as required by `docs/PUBLIC_READINESS.md`. Pass `--markdown` for a
publishable Markdown table, and `--output path/to/report.md` to write it to disk.
### Path matrix
@@ -93,11 +95,13 @@ Tunables: `scripts/bench-local.sh [ITERATIONS]` (default 20), `DOSH_BENCH_JSON=1
### `make bench-docker-ssh` / `make bench-docker-mosh` (`scripts/ci-docker-ssh-bench.sh`)
Builds one Ubuntu image with OpenSSH, `dosh-server`, `dosh-auth` (and Mosh for the
`-mosh` target), then runs `ssh_true_ms`, cold + ControlMaster `dosh_attach_ms`,
`dosh_cached_attach_ms`, and optionally `mosh_start_true_ms` against the same
container, key, and loopback network path. This is the gate CI enforces; it asserts
cold Dosh stays within 500 ms of SSH and that cached attach stays under a small
budget. See `README.md` "Develop" for the exact invocations.
`-mosh` target), then runs `ssh_true_ms`, cold + ControlMaster SSH-bootstrap
`dosh_attach_ms`, one-time `dosh trust` followed by native-cold
`dosh_cold_native_ms`, `dosh_cached_attach_ms`, and optionally
`mosh_start_true_ms` against the same container, key, and loopback network path.
This is the gate CI enforces; it asserts cold SSH-bootstrap Dosh stays within
500 ms of SSH, native-cold Dosh beats SSH mean by default, and cached attach stays
under a small budget. See `README.md` "Develop" for the exact invocations.
## Methodology and caveats
@@ -130,8 +134,8 @@ budget. See `README.md` "Develop" for the exact invocations.
## Sample results (loopback, self-contained)
Captured with `make bench-local` (`scripts/bench-local.sh 30`), all times in
milliseconds, 30 samples per metric.
Captured with `make bench-local` (`scripts/bench-local.sh 20`), all times in
milliseconds, 20 samples per metric.
- Machine: Intel Core i5-9500 @ 3.00 GHz, 6 cores
- OS: Ubuntu 24.04.4 LTS, Linux 6.8.0-124-generic, x86_64
@@ -141,27 +145,24 @@ milliseconds, 30 samples per metric.
| Metric | n | min | median | p95 | mean | max |
| --- | --- | --- | --- | --- | --- | --- |
| `dosh_cold_native_ms` | 30 | 8.10 | 9.01 | 10.40 | 9.18 | 10.82 |
| `dosh_cached_attach_ms` | 30 | 2.73 | 2.96 | 3.25 | 2.96 | 3.30 |
| `dosh_local_attach_ms` | 30 | 2.66 | 2.78 | 3.24 | 2.87 | 3.33 |
| `dosh_cold_native_ms` | 20 | 8.32 | 8.77 | 9.34 | 8.82 | 9.52 |
| `dosh_cached_attach_ms` | 20 | 2.83 | 3.23 | 3.75 | 3.24 | 3.77 |
| `dosh_local_attach_ms` | 20 | 2.84 | 3.21 | 3.96 | 3.28 | 3.98 |
Raw samples (ms):
```
dosh_cold_native_ms:
8.32, 8.23, 10.31, 10.37, 8.83, 8.81, 9.20, 9.04, 10.14, 8.98, 10.82, 9.87,
10.22, 10.42, 9.76, 9.09, 9.19, 8.94, 8.69, 8.67, 8.74, 9.33, 9.25, 8.92, 8.39,
8.55, 8.38, 9.81, 8.17, 8.10
8.82, 8.60, 8.69, 8.71, 9.33, 8.82, 8.90, 8.64, 8.39, 8.75, 8.79, 8.70, 8.64,
9.52, 8.72, 8.32, 9.06, 8.93, 8.96, 9.04
dosh_cached_attach_ms:
2.99, 2.99, 2.93, 2.87, 2.95, 2.80, 2.80, 2.90, 3.10, 2.73, 3.19, 2.80, 2.87,
2.82, 2.89, 3.03, 3.06, 3.21, 2.97, 2.83, 3.00, 2.82, 3.04, 2.97, 3.03, 3.24,
3.30, 2.73, 2.79, 3.25
3.04, 3.06, 2.98, 2.91, 2.96, 2.95, 3.05, 3.66, 3.37, 2.88, 2.83, 3.16, 3.49,
3.30, 3.77, 3.32, 3.49, 3.75, 3.56, 3.33
dosh_local_attach_ms:
3.05, 3.00, 2.84, 3.04, 3.33, 2.89, 2.76, 2.74, 2.73, 3.32, 3.14, 2.75, 2.83,
2.88, 2.99, 2.77, 2.75, 2.79, 2.69, 2.78, 2.75, 2.77, 2.75, 2.77, 3.03, 2.75,
2.94, 2.74, 2.82, 2.66
3.08, 3.96, 3.36, 3.30, 2.88, 2.99, 3.96, 3.98, 3.25, 2.84, 3.29, 3.59, 3.65,
3.47, 3.13, 2.92, 2.87, 2.91, 2.98, 3.18
```
Reading these numbers: cold native auth is ~9 ms because it pays the native
@@ -171,3 +172,32 @@ overhead floor. Over a real link, expect cached attach ≈ this floor + one netw
round trip — which is exactly the "near network RTT" target in the spec. There is
no SSH/Mosh baseline in this loopback table because those paths need an SSH server;
use `make bench-docker-ssh` / `make bench-docker-mosh` for the head-to-head.
## Sample results (Docker SSH/Mosh comparison)
Captured with `make bench-docker-mosh`, all times in milliseconds. This is the
same-container comparison gate: one generated key, one OpenSSH server, one
`dosh-server`, loopback-published TCP/UDP ports.
| Metric | n | min | median | p95 | mean | max |
| --- | --- | --- | --- | --- | --- | --- |
| `ssh_true_ms` (cold SSH baseline for native-cold gate) | 5 | 214.53 | 216.84 | 220.55 | 217.60 | 220.91 |
| `dosh_cold_native_ms` | 5 | 8.62 | 8.96 | 9.78 | 9.15 | 9.88 |
| `dosh_cached_attach_ms` | 10 | 8.41 | 8.83 | 9.52 | 8.90 | 9.53 |
| `mosh_start_true_ms` | 3 | 528.03 | 530.87 | 539.23 | 533.02 | 540.16 |
Raw samples (ms):
```
ssh_true_ms (native-cold gate):
216.84, 216.61, 214.53, 220.91, 219.09
dosh_cold_native_ms:
9.39, 8.88, 8.96, 8.62, 9.88
dosh_cached_attach_ms:
8.44, 8.41, 8.80, 8.48, 9.53, 9.51, 8.85, 8.69, 9.33, 8.96
mosh_start_true_ms:
540.16, 528.03, 530.87
```
+31 -11
View File
@@ -7,16 +7,22 @@
> - Milestone 1 — host identity and trust: **done.** Host key generation, `dosh trust`,
> `known_hosts`, and mismatch hard-fail are implemented.
> - Milestone 2 — native user auth: **done.** `ClientHello`/`ServerHello`/`UserAuth`/
> `AuthOk`, ssh-agent and encrypted-key Ed25519, and `authorized_keys` verification
> exist. ECDSA/RSA user keys are still pending (Ed25519 only today).
> `AuthOk`, ssh-agent and OpenSSH identity-file auth for Ed25519, ECDSA P-256,
> and RSA-SHA2, plus `authorized_keys` verification, exist.
> - Milestone 3 — default native auth: **done.** `auth_preference = "native,ssh"` is
> the default with explicit, visible SSH fallback. Cold-auth benchmark gates are
> pending (Track C / `BENCHMARKS.md`).
> - Milestone 4 — forwarding: **done.** Stream mux, `-L`, `-R`, `-D`, `-N`, `-f`, and
> per-stream flow control are implemented; hostile-network and load tests pending.
> - Milestone 5 — hardening: **in progress.** Full per-IP token-bucket rate limiting,
> protocol VERSION negotiation hardening, fuzzing in CI, and external review are not
> yet complete. The threat model is published (`docs/THREAT_MODEL.md`).
> the default with explicit, visible SSH fallback. Local and Docker benchmark gates
> cover cached attach, SSH fallback, and native cold auth (Track C /
> `BENCHMARKS.md`).
> - Milestone 4 — forwarding: **implemented.** Stream mux, `-L`, `-R`, `-D`, `-N`,
> `-f`, per-stream flow control, ordered stream offsets/ACKs, and stream-data
> retransmission exist. Terminal-priority/load, replay/reorder, and dropped
> server-to-client `StreamData` recovery regressions are covered.
> - Milestone 5 — hardening: **partly done.** Per-IP token-bucket rate limiting,
> fail-closed protocol-version checks with a documented v1 policy, parser fuzz
> targets, fuzz-smoke/deep CI entry points, scripted TUI transport tests,
> forwarding load/priority/replay/loss tests, and independent persistent session
> restart tests exist. Published long-soak evidence is not yet complete. The threat
> model is published (`docs/THREAT_MODEL.md`).
> - Milestone 6 — workflow parity: **mostly done.** `dosh doctor`, host-trust
> management, and the encrypted-key prompt flow exist; cross-OS daily-driver soak is
> ongoing.
@@ -45,6 +51,7 @@ dosh -L 8080:127.0.0.1:80 host
dosh -R 9000:127.0.0.1:9000 host
dosh --session work host
dosh --view-only --session work host
dosh host tm
```
Compatibility expectations:
@@ -101,6 +108,8 @@ Must work in v1:
- `dosh update`.
- `dosh doctor host` for config/auth/UDP reachability diagnostics.
- `dosh sessions host` for session visibility.
- Optional command extensions such as `dosh host tm` for companion tools that are
installed separately from Dosh.
Should work in v1 if it does not compromise the transport schedule:
@@ -124,7 +133,8 @@ Native v1 must be boring under real use:
- A closed laptop must not kill the remote session.
- A client crash must not kill the remote session.
- A server restart must not kill the remote session when `persist_sessions` is on
(the default): each session's shell runs in a detached per-session *holder*
(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
@@ -466,6 +476,12 @@ Forwarding rules:
- Terminal traffic has priority over stream bulk data.
- Each stream has independent flow control.
- `StreamData` carries a per-stream byte offset and is delivered to TCP in order.
- `StreamWindowAdjust` carries a cumulative received byte offset; peers free
retransmit buffers and replenish send credit only for acknowledged contiguous
bytes.
- Unacknowledged stream bytes are retransmitted as newly encrypted transport packets
with fresh packet sequence numbers/nonces.
- Backpressure must not block PTY input or output.
- Server enforces `no-port-forwarding` and `permitopen=`.
- Remote listeners bind to loopback by default.
@@ -543,6 +559,10 @@ forward_agent = false
send_env = ["LANG", "LC_*", "TERM", "COLORTERM"]
set_env = {}
forwardings = []
[extensions.tm]
command = "tm {args}"
description = "Open an optional server-side tmux dashboard"
```
Server:
@@ -593,7 +613,7 @@ Milestone 5: hardening
- Fuzz packet parsing, authorized-key parsing, known-host parsing, and handshake state.
- Add hostile-network integration tests.
- Add external review checklist before public security claims.
- Keep a public hardening checklist and threat model before public security claims.
Milestone 6: workflow parity
+40
View File
@@ -0,0 +1,40 @@
# Dosh Protocol Versioning
Dosh v1 uses a deliberately simple compatibility policy: **single-version,
fail-closed, explicit error**.
The wire header carries `protocol::VERSION`. The native handshake carries
`native::NATIVE_PROTOCOL_VERSION`. A peer that speaks any other version is rejected
before application data is accepted:
- foreign wire `VERSION` packets get an `AttachReject` with
`protocol version mismatch - upgrade dosh`;
- foreign native handshake `protocol_version` values get the same named upgrade
error with local/remote versions;
- there is no silent downgrade, compatibility fallback, or best-effort decoding.
## When To Bump
Bump `protocol::VERSION` when a change affects packet framing, packet kind meaning,
serialized protocol structs carried outside native handshake negotiation, or anything
an older peer could misparse.
Bump `native::NATIVE_PROTOCOL_VERSION` when a change affects native handshake
transcripts, native auth semantics, algorithm negotiation, attach tickets, or any
field that is signed or key-derived by native auth.
If both layers are affected, bump both.
## Compatibility Window
Native v1 supports exactly the current version. That keeps the implementation small
and makes security review tractable. Multi-version negotiation can be added later
only with an explicit downgrade-resistance design:
- negotiated version must be transcript-bound;
- the selected version must be visible in diagnostics;
- tests must prove an active attacker cannot force an older mutually supported
version;
- unsupported peers must still get the same named upgrade error.
Until that exists, the public policy is: upgrade both sides together.
+77 -43
View File
@@ -1,21 +1,21 @@
# Dosh Public Readiness
Dosh's defensible public claim is fast terminal attach and reconnect. It should not
claim full Mosh replacement status until the feature matrix below is green and the
comparison benchmark is reproducible outside the author's homelab.
Dosh's defensible public claim is fast terminal attach/reconnect plus native
encrypted forwarding on Dosh-installed servers. It should not claim generic SSH
compatibility unless it implements the SSH protocol, and public benchmark claims
must be reproducible outside the author's homelab.
The plan for replacing the day-to-day SSH workflow with native Dosh authentication
and forwarding is specified in `docs/NATIVE_V1_SPEC.md`, and the published threat
model is in `docs/THREAT_MODEL.md`. Native v1 is now substantially implemented:
native key-exchange + user auth, host-key pinning/trust, `-L`/`-R`/`-D` forwarding,
and `dosh doctor` all exist (see the feature matrix and the verification-checklist
status table below). It is **not yet fully verified**: per-IP token-bucket rate
limiting, protocol-version negotiation hardening, fuzzing in CI, and external review
are still open. Until the verification checklist (`NATIVE_V1_SPEC.md` section 16) is
green and that review is done, Dosh's defensible public security claim remains fast
encrypted native attach/reconnect with SSH-equivalent transport security and an
explicit SSH bootstrap fallback — not a fully verified, externally reviewed SSH
replacement.
`dosh doctor`, token-bucket auth rate limiting, and fuzz-smoke CI all exist (see the
feature matrix and the verification-checklist status table below). It is **not yet
fully verified**: long sleep/roaming soak and deeper fuzzing results are still open
launch evidence. Until the verification checklist (`NATIVE_V1_SPEC.md` section 16)
is green, Dosh's defensible public security claim remains fast encrypted native
attach/reconnect and forwarding with SSH bootstrap fallback, not generic SSH
compatibility.
## Objective Benchmarks
@@ -58,31 +58,31 @@ with ordinary SSH.
| Feature | Mosh | Dosh now | Public status |
| --- | --- | --- | --- |
| SSH-based first authentication | yes | yes | ready |
| Native UDP key auth (no SSH per attach) | no | yes, Ed25519 via ssh-agent or encrypted key | implemented; pending full verification |
| Native UDP key auth (no SSH per attach) | no | yes, Ed25519, ECDSA P-256, and RSA-SHA2 via ssh-agent or OpenSSH identity files | implemented; pending full verification |
| Dosh host-key pinning and trust | no | yes, `known_hosts` + `dosh trust` + mismatch hard-fail | implemented |
| `authorized_keys` policy enforcement | no | yes, `from=`/`no-port-forwarding`/`permitopen=`, unsupported fail closed | implemented |
| `dosh doctor` diagnostics | no | yes, config/auth/UDP/forwarding-policy check | implemented |
| Encrypted UDP terminal data | yes | yes | ready |
| Roaming by client address change | yes | yes | needs more hostile-network tests |
| Survive sleep or network loss | yes | yes | needs long-running soak tests |
| Roaming by client address change | yes | yes | implemented; hostile-network covered, 30-minute soak gate available |
| Survive sleep or network loss | yes | yes | implemented; run `make soak-local` before public launch |
| Fast repeat attach without SSH | no | yes, via attach tickets | core differentiator |
| Resident server daemon | no | yes | core differentiator |
| One UDP port for all sessions | port range by default | yes | ready |
| Fresh session by default | yes | yes | ready |
| Named persistent sessions | no built-in shared session model | yes | ready |
| Multiple clients on one session | no | yes | needs conflict-policy docs |
| Named persistent sessions | no built-in shared session model | yes, plus opt-in server-restart holders | implemented; holder mode needs stress-testing |
| Multiple clients on one session | no | yes | implemented; needs conflict-policy docs |
| View-only clients | no | yes | ready |
| Full-screen TUI correctness | yes | improving | must stay green before public push |
| Full-screen TUI correctness | yes | yes, scripted transport coverage for control sequences | implemented; needs broader app matrix |
| Predictive local echo | mature | guarded printable-only opt-in | not parity |
| Non-destructive disconnect UI | yes | not currently | needed |
| Non-destructive disconnect UI | yes | yes, bottom-row save/restore status line | implemented |
| Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
| X11 forwarding | no | no | non-goal unless tunneled separately |
| SSH agent forwarding | no | no | planned as forwarding channel |
| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; needs hostile-network tests |
| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; needs hostile-network tests |
| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented; needs hostile-network tests |
| SSH agent forwarding | no | yes, explicit `-A` / `forward_agent` plus server allow-list | implemented; opt-in only |
| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; load/priority, replay/reorder, and stream retransmit tested |
| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; policy and stream mux covered |
| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented over the same reliable stream mux |
| Forward-only / background forwarding, `-N` / `-f` | no | yes, `-f` requires `-N` | implemented |
| Per-stream flow control / terminal priority | no | yes, windowed credit per stream | implemented; needs load tests |
| Per-stream flow control / terminal priority | no | yes, windowed credit per stream | implemented; covered by blocked-stream and local-forward load tests |
## SSH Config Inheritance
@@ -95,12 +95,35 @@ Dosh also calls `ssh -G <alias>` to infer the UDP target host when no `dosh_host
configured. To write explicit Dosh host entries from SSH aliases:
```bash
dosh import-ssh palav homelab
dosh import-ssh homelab
```
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
OpenSSH config parser.
## Optional Command Extensions
Dosh can expose companion tools without taking a dependency on them. Command
extensions live in client or host config and expand only the first trailing word:
```toml
[extensions.tm]
command = "tm {args}"
description = "Open the server-side tmux dashboard"
```
With that config, `dosh homelab tm` runs `tm` in the remote Dosh shell and
`dosh homelab tm dosh` runs `tm 'dosh'`. Removing the table removes the integration.
Host config can override a global extension, or disable it:
```toml
[homelab.extensions.tm]
command = "/opt/tm/bin/tm {args}"
[other-host.extensions.tm]
disabled = true
```
## Forwarding (Implemented)
SSH forwarding cannot be copied by keeping the bootstrap SSH connection open,
@@ -120,6 +143,8 @@ Implemented:
`StreamEof`/`StreamClose` packet types.
- Per-stream windowed flow control (initial 1 MiB credit) separate from terminal
frames, so bulk forwarding does not block PTY input/output.
- Ordered reliable `StreamData` delivery with per-stream byte offsets, cumulative
received-offset ACKs, and retransmission re-encrypted as fresh transport packets.
- Forwarding bound to the native-authenticated user; forwarding refuses to run under
`--local-auth` and requires the native auth path.
- Server-side policy enforcement: `allow_tcp_forwarding`, `allow_remote_forwarding`,
@@ -130,8 +155,9 @@ Implemented:
Still open before claiming forwarding parity:
- A dedicated dropped/reordered/replayed-UDP forwarding test suite.
- Load tests proving large forwarded transfers add no visible terminal input lag.
- More real-host load soak. The integration suite already covers terminal-priority
behavior while a local forward is under blocked-stream pressure, plus hostile
replay/reorder and server-to-client stream retransmission after UDP loss.
## Native v1 Verification Checklist Status
@@ -145,42 +171,50 @@ from code; in progress = partially implemented; pending = not yet implemented.
| Known host-key mismatch hard fails | done | `KnownHostStatus::Mismatch` aborts; `trust_host` refuses overwrite without `--replace`. |
| Native Ed25519 auth via ssh-agent | done | `src/ssh_agent.rs` signs the user-auth transcript. |
| Native Ed25519 auth via encrypted key prompt | done | `load_ed25519_identity_with_passphrase` decrypts OpenSSH keys. |
| Native ECDSA P-256 auth | done | ssh-agent and OpenSSH identity-file paths are wired; `native_user_auth_accepts_ecdsa_p256_private_key` verifies the direct key path. |
| Native RSA-SHA2 auth | done | ssh-agent requests `rsa-sha2-512`; direct OpenSSH RSA identities sign with `rsa-sha2-512`; legacy SHA-1 `ssh-rsa` signatures are rejected. |
| Removed authorized key can no longer authenticate | done | Covered by `native_user_auth_accepts_authorized_key_and_rejects_removed_key`. |
| Unsupported authorized-key options fail closed | done | `ensure_native_allowed` bails on any unsupported option. |
| Replayed handshake packets rejected | done | Handshake is transcript-bound and signature-verified; pending entries TTL-evicted. |
| Replayed transport packets rejected | done | `ReplayWindow` (128-wide) over per-direction counter. |
| Stale encrypted packets after reconnect ignored, not fatal | done | `session_key_id` mismatch drops the packet instead of erroring fatally. |
| Client IP/port change preserves session | done | Server matches by `ClientId`/session key id, updates endpoint. |
| Native cold auth beats cold `ssh host true` | pending | Benchmark gate not yet run for native cold path (Track C / `BENCHMARKS.md`). |
| Cached attach near network RTT | pending | Same benchmark dependency. |
| `-L` works without delaying terminal input | in progress | Implemented with per-stream window; load proof pending. |
| Native cold auth beats cold `ssh host true` | done | Docker gate runs `dosh-bench --cold-native` after one-time `dosh trust` and requires Dosh mean <= SSH mean. |
| Cached attach near network RTT | done | Local loopback samples are ~3 ms; Docker cached gate is under 25 ms. See `docs/BENCHMARKS.md`. |
| `-L` works without delaying terminal input | done | Per-stream windowing plus `native_local_forward_bulk_load_does_not_delay_interactive_terminal` and blocked-stream priority regression. |
| `-R` enforces bind and permission policy | done | `remote_bind_allowed` + `start_remote_forwards` policy checks. |
| `-N -L` does not allocate a PTY | done | `forward-only` mode skips PTY allocation. |
| `-f -N -L` backgrounds only after listener readiness | done | `spawn_background_forwarder` waits for a readiness token. |
| Multiple forwards in one command | done | Forward lists parsed and started together. |
| `dosh doctor` identifies UDP-blocked/auth-denied/mismatch/forwarding-denied | done | `run_doctor_command` reports each state. |
| Closing laptop 30+ min does not kill session | in progress | Long `client_timeout_secs` + resume; 30-min soak evidence pending. |
| Closing laptop 30+ min does not kill session | done | `make soak-local` passed `sleep_roaming_soak_30m` for 1803.54s locally. Keep rerunning before tagged releases. |
| Three concurrent terminals independent unless named | done | Generated session names per attach; named sessions shared on purpose. |
| Large forwarded transfers add no visible input lag | in progress | Per-stream flow control exists; load test pending. |
| Fuzz targets run in CI | pending | No `fuzz/` dir; CI runs fmt/test/build/bench only. |
| Large forwarded transfers add no visible input lag | done | Covered by the local-forward bulk-load integration test; still needs real-host soak before launch claims. |
| Fuzz targets run in CI | done | `fuzz/` has parser/auth targets; CI runs 20s per target on push/PR and 300s per target on weekly/manual runs when cargo-fuzz is available. |
| Threat model updated with accepted residual risks | done | `docs/THREAT_MODEL.md`. |
Additional security hardening tracked outside the section 16 list:
- Full per-IP token-bucket rate limiting: in progress (another track). Today only
handshake-map eviction and a static `rate_limit_remaining` hint exist.
- Protocol VERSION negotiation: in progress. Single version, fail-closed reject; no
multi-version negotiation yet.
- ECDSA P-256 / SHA-2 RSA user keys: pending. Ed25519 only today.
- Full per-IP token-bucket rate limiting: implemented for native auth and covered by
unit/integration tests. It still needs real-host tuning under abusive traffic.
- Protocol VERSION handling: v1 policy is single-version, fail-closed reject at the
packet/header and native-handshake layers. See `docs/PROTOCOL_VERSIONING.md`.
- ECDSA P-256 / SHA-2 RSA user keys: implemented for ssh-agent and OpenSSH
identity-file native auth. RSA is compatibility-only and uses SHA-2 signatures;
legacy SHA-1 `ssh-rsa` signatures are not accepted.
## Before Public Launch
- Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green.
- Add a non-destructive disconnect indicator.
- Run scripted TUI tests for alternate-screen apps, arrow keys, resize, mouse mode,
bracketed paste, and terminal cleanup.
- Run `make soak-local` before each tagged release to refresh 30-minute sleep/roaming evidence.
- Run `make fuzz-deep` before launch, or use the scheduled/manual CI fuzz pass, and
publish the target durations.
- Keep scripted TUI tests green and add a broader app matrix for real alternate-screen
tools.
- Publish benchmark output with raw samples, not just averages.
- Mark prediction as experimental until it has a real framebuffer model.
- Land full per-IP token-bucket auth rate limiting and wire fuzz targets into CI.
- Complete the native-v1 verification checklist above and an external security review
before making any "native SSH replacement" claim (`NATIVE_V1_SPEC.md` section 17).
- Stress-test `persist_sessions = true` before making restart-survivable holders the
default.
- Tune the native auth token bucket under abusive real-host traffic.
- Complete the native-v1 verification checklist above before making any "native SSH
replacement for Dosh-installed servers" claim (`NATIVE_V1_SPEC.md` section 17).
+127
View File
@@ -0,0 +1,127 @@
# Dosh Release Evidence - 2026-06-20
Runtime code benchmarked: `b44ff8e Improve release and benchmark tooling`
Release docs/default cleanup after that benchmark removed personal host aliases from
public examples and test fixtures; it did not change release runtime paths.
Environment:
- Host: local homelab runner
- Benchmark harness: `make bench-docker-mosh`
- Container SSH server: OpenSSH 9.6p1 on Ubuntu
- Same container, key, DNS, loopback, and Dosh server path for all Docker samples
## Release Artifacts
Generated locally with:
```bash
sh scripts/package-release.sh
```
Artifacts:
| artifact | sha256 |
| --- | --- |
| `dosh-linux-x86_64.tar.gz` | `9f586852a506cca3caf618743e12967265879ee10f5294d179cdcd668db1e808` |
| `dosh-0.1.0-linux-x86_64.tar.gz` | `9f586852a506cca3caf618743e12967265879ee10f5294d179cdcd668db1e808` |
| `dosh-macos-aarch64.tar.gz` | `dd1151aa2b40be37288dc19a07521b0ec72d1106439eb484cdff01cca3b891c6` |
| `dosh-0.1.0-macos-aarch64.tar.gz` | `dd1151aa2b40be37288dc19a07521b0ec72d1106439eb484cdff01cca3b891c6` |
The installer verifies `<artifact>.sha256` when the sidecar is published.
## Docker SSH/Mosh Benchmark
Command:
```bash
make bench-docker-mosh
```
### Cold SSH Bootstrap
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 3 | 216.58 | 220.27 | 224.64 | 220.66 | 225.12 |
| `dosh_attach_ms` | 3 | 232.37 | 235.56 | 239.13 | 235.82 | 239.53 |
Raw samples:
- `ssh_true_ms`: [216.58, 220.27, 225.12]
- `dosh_attach_ms`: [235.56, 232.37, 239.53]
Gate: `dosh_attach_ms avg 235.82ms <= ssh avg 220.66ms + 500.00ms`
### ControlMaster SSH Bootstrap
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 3 | 7.29 | 7.50 | 47.51 | 22.25 | 51.95 |
| `dosh_attach_ms` | 3 | 16.62 | 17.01 | 17.77 | 17.16 | 17.85 |
Raw samples:
- `ssh_true_ms`: [51.95, 7.29, 7.50]
- `dosh_attach_ms`: [17.01, 16.62, 17.85]
Gate: `dosh_attach_ms avg 17.16ms <= ssh avg 22.25ms + 500.00ms`
### Native Cold Auth
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 5 | 215.72 | 222.68 | 227.05 | 221.32 | 227.82 |
| `dosh_cold_native_ms` | 5 | 8.63 | 8.84 | 9.16 | 8.88 | 9.23 |
Raw samples:
- `ssh_true_ms`: [216.40, 223.99, 227.82, 222.68, 215.72]
- `dosh_cold_native_ms`: [8.84, 8.63, 9.23, 8.88, 8.84]
Gate: `dosh_cold_native_ms avg 8.88ms <= ssh avg 221.32ms + 0.00ms`
### Cached Attach
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `dosh_cached_attach_ms` | 10 | 8.07 | 8.96 | 9.83 | 8.98 | 9.84 |
Raw samples:
- `dosh_cached_attach_ms`: [8.07, 9.09, 9.02, 8.66, 8.71, 8.90, 8.45, 9.84, 9.25, 9.82]
Gate: `dosh_cached_attach_ms avg 8.98ms <= 25.00ms`
### Mosh Comparison
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 3 | 216.48 | 218.93 | 219.49 | 218.32 | 219.55 |
| `dosh_attach_ms` | 3 | 229.59 | 233.00 | 233.31 | 231.98 | 233.35 |
| `mosh_start_true_ms` | 3 | 527.13 | 563.34 | 573.09 | 554.89 | 574.18 |
Raw samples:
- `ssh_true_ms`: [219.55, 216.48, 218.93]
- `dosh_attach_ms`: [233.00, 229.59, 233.35]
- `mosh_start_true_ms`: [563.34, 527.13, 574.18]
## Soak And Fuzz Evidence
- `cargo test`: `153 passed, 1 ignored`
- `make soak-local`: `sleep_roaming_soak_30m ... ok`, finished in `1803.54s`
- `make fuzz-smoke`: passed all configured targets for 20s each:
`packet_decode`, `from_body`, `authorized_keys`, `known_hosts`,
`handshake_structs`, `attach_ticket`
## Public Claim Boundary
This evidence supports:
- Dosh cached attach and native auth are much faster than cold SSH/Mosh startup in
this benchmark environment.
- Dosh remains mosh-shaped, not mosh-compatible.
- Dosh does not claim generic SSH replacement compatibility.
- Native v1 security still needs external review before making hard public security
claims.
+46 -37
View File
@@ -72,13 +72,13 @@ compromised endpoint or a malicious authorized peer.
| Property | SSH | Dosh native v1 | Notes |
| --- | --- | --- | --- |
| Server authentication before trusting session data | yes | yes | Host key signs the handshake transcript; client verifies before sending user auth or accepting terminal bytes. |
| User authentication by private-key possession | yes | yes | Ed25519 via ssh-agent or encrypted OpenSSH key; signature binds the full transcript. |
| User authentication by private-key possession | yes | yes | Ed25519, ECDSA P-256, and RSA-SHA2 via ssh-agent or OpenSSH key; signature binds the full transcript. |
| Forward secrecy | yes | yes | Ephemeral X25519 per connection; long-term host/user keys never derive the traffic key. |
| AEAD on every post-handshake packet | yes | yes | ChaCha20-Poly1305 with per-direction, per-sequence nonces. |
| Replay protection | yes | yes | Sliding replay window over the AEAD packet counter, plus transcript-bound handshake. |
| Host-key pinning with explicit first use | TOFU, weakly tied to transport | yes, with explicit policy | Default refuses unknown host keys; TOFU only when `trust_on_first_use` is set; mismatch hard-fails and never auto-replaces. |
| No plaintext terminal bytes after handshake | yes | yes | All `Frame`/`Input`/stream packets are AEAD-sealed. |
| No custom cryptographic primitives | yes | yes | Standard X25519/HKDF-SHA256/ChaCha20-Poly1305/Ed25519 crates only. |
| No custom cryptographic primitives | yes | yes | Standard X25519/HKDF-SHA256/ChaCha20-Poly1305/signature crates only. |
| Fail-closed downgrade behavior | yes | yes | Native auth failure surfaces an explicit error and SSH fallback is explicit; it never silently drops to an unauthenticated mode. |
| Fast resumption without re-auth | ControlMaster only | yes, native | Cached session/ticket attach skips a fresh round of public-key proof; this is a deliberate speed/security trade discussed in section 6. |
@@ -94,8 +94,9 @@ compromised endpoint or a malicious authorized peer.
- **Explicit, file-pinned host trust.** Host trust is a first-class, inspectable
known-hosts entry with source provenance (`tofu`/`ssh`/`manual`) and a hard-fail
mismatch path, rather than the looser default TOFU behavior most SSH clients ship.
- **Modern primitives only.** Ed25519 and X25519 by default; no DSA, no SHA-1
signatures, no CBC-and-MAC constructions.
- **Modern primitives only.** Ed25519 and X25519 by default; ECDSA P-256 and
RSA-SHA2 are accepted for SSH-key compatibility; no DSA, no SHA-1 signatures, no
CBC-and-MAC constructions.
Dosh does **not** claim generic SSH compatibility and must not be described as an
SSH-protocol implementation.
@@ -114,9 +115,12 @@ These reflect the code in `src/crypto.rs`, `src/native.rs`, `src/auth.rs`, and
unique nonce per `(key, direction, sequence)`. AES-GCM is reserved for later and is
not selectable today.
- **Host-key signatures:** Ed25519 (`ed25519-dalek`) over the handshake transcript.
- **User-auth signatures:** Ed25519, produced either by ssh-agent over a Unix socket
(`src/ssh_agent.rs`) or from an encrypted/plaintext OpenSSH private key
(`ssh-key`). The signature covers the user-auth transcript described above.
- **User-auth signatures:** Ed25519, ECDSA P-256, and RSA-SHA2, produced either by
ssh-agent over a Unix socket (`src/ssh_agent.rs`) or from an encrypted/plaintext
OpenSSH private key (`ssh-key`). The signature covers the user-auth transcript
described above. RSA public keys are matched as `ssh-rsa` authorized keys, but
native signatures must be `rsa-sha2-256` or `rsa-sha2-512`; legacy SHA-1
`ssh-rsa` signatures are rejected.
- **Bootstrap auth (SSH fallback path):** HMAC-SHA256 attach tokens and HKDF-SHA256
derived session keys, with attach tickets sealed under an HKDF-derived
ticket key. Token comparison is constant-time.
@@ -179,32 +183,37 @@ be evaluated honestly. Items here are *not* yet "green".
### Known gaps / work in progress (must close before the public claim)
- **Per-IP rate limiting is partial.** The server evicts half-finished native
handshakes on a TTL so a flood of `ClientHello` packets cannot grow the pending map
without bound, and it reports a static `rate_limit_remaining` hint in `ServerHello`.
A full per-source token-bucket limiter is **in progress on another track** and is
not yet enforced. Until it lands, sustained auth flooding is mitigated only by the
handshake-eviction TTL and OS-level limits.
- **Protocol VERSION negotiation is being hardened.** The wire format pins a single
protocol version: the packet header rejects any non-matching `VERSION` byte and the
native handshake rejects any non-matching `protocol_version`. There is no
multi-version negotiation yet, so cross-version interop and downgrade-resistance for
future versions are still being designed. Today's behavior is fail-closed (reject),
not silent downgrade.
- **Fuzzing is not yet wired into CI.** CI currently runs format, tests, release
build, and the Docker SSH benchmark gate. Fuzz targets for packet parsing,
authorized-key parsing, known-host parsing, and handshake state (spec milestone 5)
are **being wired in** and are not yet running in CI.
- **User-key algorithm coverage is Ed25519-only today.** The spec permits ECDSA
P-256 and (compatibility-only, SHA-2) RSA, but native auth currently accepts and
produces `ssh-ed25519` only. ECDSA/RSA support is pending. This is a parity gap, not
a weakening of what *is* supported.
- **Hostile-network and long-soak integration tests are partial.** Roaming,
retransmit, resize, and multi-client tests exist; a dedicated adversarial
drop/reorder/replay suite and 30-minute-sleep soak (spec section 16) are still being
expanded.
- **No external security review yet.** The spec's milestone 5 requires an external
review checklist before public security claims. That review has not happened.
- **Native-auth rate limiting needs tuning, not first implementation.** The server
enforces a per-source token bucket before expensive native-auth work, evicts
half-finished handshakes on a TTL, and reports remaining capacity in
`ServerHello`. It is covered by unit/integration tests, but still needs abusive
real-host tuning before public hardening claims.
- **Protocol VERSION compatibility is intentionally single-version for v1.** The
packet header rejects any non-matching `VERSION` byte and the native handshake
rejects any non-matching `protocol_version`; peers get a named upgrade error, not
a silent downgrade. `docs/PROTOCOL_VERSIONING.md` defines the bump rules and the
post-v1 requirements for any future multi-version negotiation.
- **Deep fuzzing still needs launch evidence.** CI runs parser/auth fuzz targets for
20 seconds per target on push/PR and 300 seconds per target on weekly/manual runs
when nightly/cargo-fuzz is available. That catches obvious panics and parser
robustness regressions; public security claims should cite a completed
`make fuzz-deep` or scheduled CI run with durations.
- **User-key algorithm coverage now matches the v1 target.** Ed25519, ECDSA P-256,
and compatibility RSA-SHA2 native auth are implemented for ssh-agent and OpenSSH
identity files. RSA remains compatibility-only and deliberately rejects legacy
SHA-1 `ssh-rsa` signatures.
- **Forwarded stream data uses ordered retransmission.** Streams carry byte offsets,
cumulative received-offset ACKs, and retransmit unacknowledged chunks as fresh
encrypted transport packets. Hostile-network tests cover replay/reorder and
server-to-client stream recovery after deliberate UDP loss; broader real-host load
soak remains launch evidence.
- **Long-soak evidence is a launch gate.** Roaming, retransmit, resize, and
multi-client tests exist, and `sleep_roaming_soak_30m` / `make soak-local` provide
the 30-minute sleep/roaming gate. That gate should be run and published before
public security/reliability claims.
- **No third-party audit claim.** Dosh maintains a public threat model and hardening
checklist, but should not market itself as externally audited unless that actually
happens.
### Auth posture
@@ -226,7 +235,7 @@ cached attach, and Mosh startup.
Current status: this threat model is published (this document). The verification
checklist is **not yet fully green** — see the item-by-item status table in
`docs/PUBLIC_READINESS.md` ("Native v1 verification checklist status") and the known
gaps in section 6 above. Until the gaps close and an external review is complete,
Dosh's defensible public claim remains **fast, encrypted native attach/reconnect with
SSH-equivalent transport security and SSH bootstrap fallback** — not a fully verified,
externally reviewed SSH replacement.
gaps in section 6 above. Until the gaps close, Dosh's defensible public claim remains
**fast, encrypted native attach/reconnect and forwarding with SSH bootstrap
fallback** on Dosh-installed servers, not generic SSH compatibility or a third-party
audited security product.
+10 -2
View File
@@ -35,6 +35,12 @@ cargo-fuzz requires a nightly toolchain (it builds with `-Z sanitizer=address`).
From the repository root:
```sh
# Run all targets for a short smoke pass
make fuzz-smoke
# Run all targets for the pre-launch deep pass (default: 300s each)
make fuzz-deep
# List targets
cargo +nightly fuzz list --fuzz-dir fuzz
@@ -55,5 +61,7 @@ cargo +nightly fuzz run packet_decode -- -max_total_time=10
## CI
`.github/workflows/ci.yml` has a `fuzz-smoke` job that installs nightly +
cargo-fuzz and runs each target briefly (`-max_total_time`). The job is tolerant
if the toolchain/tooling is unavailable so it never blocks the main test gate.
cargo-fuzz and runs each target. Push/PR runs use a short 20-second-per-target
smoke pass. Weekly scheduled and manual workflow runs use a 300-second-per-target
deep pass by default. The job is tolerant if the toolchain/tooling is unavailable
so it never blocks the main test gate.
+156 -32
View File
@@ -6,6 +6,12 @@ param(
[string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }),
[int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }),
[string]$Prefix = $(if ($env:PREFIX) { $env:PREFIX } else { Join-Path $HOME ".local" }),
[switch]$UsePrebuilt = $(-not $env:DOSH_USE_PREBUILT -or $env:DOSH_USE_PREBUILT -ne "0"),
[string]$BinaryUrl = $env:DOSH_BINARY_URL,
[string]$BinaryBase = $env:DOSH_BINARY_BASE,
[string]$BinaryName = $env:DOSH_BINARY_NAME,
[string]$BinaryVersion = $(if ($env:DOSH_BINARY_VERSION) { $env:DOSH_BINARY_VERSION } else { "latest" }),
[switch]$BinaryRequired = $($env:DOSH_BINARY_REQUIRED -and $env:DOSH_BINARY_REQUIRED -ne "0"),
[switch]$ForceConfig
)
@@ -17,12 +23,114 @@ function Require-Command($Name) {
}
}
Require-Command cargo
function Normalize-Arch {
switch ($env:PROCESSOR_ARCHITECTURE) {
"AMD64" { "x86_64"; break }
"ARM64" { "aarch64"; break }
default { $env:PROCESSOR_ARCHITECTURE.ToLowerInvariant() }
}
}
$tmp = $null
if (Test-Path "Cargo.toml") {
function Release-ArtifactName {
if ($BinaryName) {
return $BinaryName
}
"dosh-windows-$(Normalize-Arch).zip"
}
function Repo-WebBase($Value) {
if (-not $Value) {
return $null
}
$base = $Value.TrimEnd("/")
if ($base.EndsWith(".git")) {
$base = $base.Substring(0, $base.Length - 4)
}
if ($base -notmatch "^https?://") {
return $null
}
$base
}
function Release-DownloadUrl {
if ($BinaryUrl) {
return $BinaryUrl
}
$name = Release-ArtifactName
if ($BinaryBase) {
return "$($BinaryBase.TrimEnd('/'))/$name"
}
$web = Repo-WebBase $Repo
if (-not $web) {
return $null
}
if ($BinaryVersion -eq "latest") {
return "$web/releases/latest/download/$name"
}
"$web/releases/download/$BinaryVersion/$name"
}
function Verify-ArchiveChecksum($Url, $Archive) {
$checksumPath = "$Archive.sha256"
try {
Invoke-WebRequest -UseBasicParsing -Uri "$Url.sha256" -OutFile $checksumPath
}
catch {
Write-Warning "prebuilt checksum unavailable; continuing without sidecar verification"
return
}
$expected = ((Get-Content $checksumPath -Raw).Trim() -split "\s+")[0].ToLowerInvariant()
$actual = (Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant()
if ($expected -ne $actual) {
throw "prebuilt checksum mismatch for $Url"
}
}
$bindir = Join-Path $Prefix "bin"
$configDir = Join-Path $HOME ".config\dosh"
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
function Install-Prebuilt {
$url = Release-DownloadUrl
if (-not $url) {
return $false
}
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-bin-" + [guid]::NewGuid())
$zip = Join-Path $tmp (Release-ArtifactName)
$extract = Join-Path $tmp "extract"
try {
New-Item -ItemType Directory -Force -Path $tmp, $extract | Out-Null
Write-Host "Trying Dosh prebuilt $(Release-ArtifactName)"
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $zip
Verify-ArchiveChecksum $url $zip
Expand-Archive -Force -Path $zip -DestinationPath $extract
foreach ($bin in @("dosh-client.exe", "dosh-bench.exe")) {
$found = Get-ChildItem -Path $extract -Recurse -File -Filter $bin | Select-Object -First 1
if (-not $found) {
throw "prebuilt archive missing $bin"
}
Copy-Item $found.FullName (Join-Path $bindir $bin) -Force
}
Copy-Item (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe") -Force
return $true
}
catch {
Write-Warning "prebuilt install failed: $_"
return $false
}
finally {
if (Test-Path $tmp) {
Remove-Item -Recurse -Force $tmp
}
}
}
function Install-FromSource {
Require-Command cargo
$tmp = $null
if (Test-Path "Cargo.toml") {
$src = (Get-Location).Path
} else {
} else {
if (-not $Repo) {
throw "DOSH_REPO is required when running the installer from irm/iex"
}
@@ -30,22 +138,38 @@ if (Test-Path "Cargo.toml") {
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-" + [guid]::NewGuid())
git clone --depth 1 $Repo $tmp | Out-Null
$src = $tmp
}
}
try {
try {
Push-Location $src
cargo build --release --bin dosh-client --bin dosh-bench
$bindir = Join-Path $Prefix "bin"
$configDir = Join-Path $HOME ".config\dosh"
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force
}
finally {
Pop-Location
if ($tmp) {
Remove-Item -Recurse -Force $tmp
}
}
}
$clientConfig = Join-Path $configDir "client.toml"
if ($ForceConfig -or -not (Test-Path $clientConfig)) {
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`"" }
@@ -60,28 +184,28 @@ dosh_port = $Port
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
"@ | Set-Content -NoNewline -Encoding utf8 $clientConfig
}
}
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if (-not (($userPath -split ';') -contains $bindir)) {
$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
if ($tmp) {
Remove-Item -Recurse -Force $tmp
}
}
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 commands:"
Write-Host " $bindir\dosh.exe $displayServer"
Write-Host " $bindir\dosh.exe setup <ssh-alias>"
Write-Host " $bindir\dosh.exe update --check"
Write-Host ""
Write-Host "Client config:"
Write-Host " $configDir\client.toml"
Write-Host ""
Write-Host "Open a new terminal for PATH changes to apply."
+249 -32
View File
@@ -12,6 +12,12 @@ start_server=1
force_config=0
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
quiet="${DOSH_UPDATE_QUIET:-0}"
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
binary_url="${DOSH_BINARY_URL:-}"
binary_base="${DOSH_BINARY_BASE:-}"
binary_name="${DOSH_BINARY_NAME:-}"
binary_version="${DOSH_BINARY_VERSION:-latest}"
binary_required="${DOSH_BINARY_REQUIRED:-0}"
usage() {
cat <<'EOF'
@@ -32,6 +38,18 @@ Options:
Environment alternatives:
DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX,
DOSH_UPDATE_CACHE
DOSH_USE_PREBUILT=0
Build from source instead of trying a release tarball first
DOSH_BINARY_URL URL
Exact release tarball URL to install
DOSH_BINARY_BASE URL
Release download base; defaults to REPO/releases/latest/download
DOSH_BINARY_NAME NAME
Release tarball name; defaults to dosh-OS-ARCH.tar.gz
DOSH_BINARY_VERSION TAG
Release tag when deriving DOSH_BINARY_BASE; default latest
DOSH_BINARY_REQUIRED=1
Fail instead of falling back to source when binary install fails
EOF
}
@@ -112,8 +130,6 @@ ensure_cargo() {
. "$HOME/.cargo/env"
}
ensure_cargo
cleanup() {
if [ -n "${tmpdir:-}" ]; then
rm -rf "$tmpdir"
@@ -121,9 +137,173 @@ cleanup() {
}
trap cleanup EXIT INT TERM
if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then
bindir="$prefix/bin"
config_dir="$HOME/.config/dosh"
data_dir="$HOME/.local/share/dosh"
systemd_user_dir="$HOME/.config/systemd/user"
src_dir=""
mkdir -p "$bindir" "$config_dir" "$data_dir"
normalize_os() {
case "$(uname -s)" in
Darwin) printf '%s\n' macos ;;
Linux) printf '%s\n' linux ;;
FreeBSD) printf '%s\n' freebsd ;;
*) uname -s | tr '[:upper:]' '[:lower:]' ;;
esac
}
normalize_arch() {
case "$(uname -m)" in
x86_64|amd64) printf '%s\n' x86_64 ;;
arm64|aarch64) printf '%s\n' aarch64 ;;
armv7l) printf '%s\n' armv7 ;;
*) uname -m | tr '[:upper:]' '[:lower:]' ;;
esac
}
repo_web_base() {
repo_base="$1"
case "$repo_base" in
http://*|https://*)
repo_base="${repo_base%.git}"
printf '%s\n' "${repo_base%/}"
;;
*)
return 1
;;
esac
}
release_artifact_name() {
if [ -n "$binary_name" ]; then
printf '%s\n' "$binary_name"
else
printf 'dosh-%s-%s.tar.gz\n' "$(normalize_os)" "$(normalize_arch)"
fi
}
release_download_url() {
if [ -n "$binary_url" ]; then
printf '%s\n' "$binary_url"
return 0
fi
if [ -n "$binary_base" ]; then
printf '%s/%s\n' "${binary_base%/}" "$(release_artifact_name)"
return 0
fi
if [ -z "$repo" ]; then
return 1
fi
web_base="$(repo_web_base "$repo")" || return 1
if [ "$binary_version" = "latest" ]; then
printf '%s/releases/latest/download/%s\n' "$web_base" "$(release_artifact_name)"
else
printf '%s/releases/download/%s/%s\n' "$web_base" "$binary_version" "$(release_artifact_name)"
fi
}
release_latest_tag_download_url() {
if [ -n "$binary_url" ] || [ -n "$binary_base" ] || [ "$binary_version" != "latest" ] || [ -z "$repo" ]; then
return 1
fi
web_base="$(repo_web_base "$repo")" || return 1
latest_url="$(curl -fsSL -o /dev/null -w '%{url_effective}' "$web_base/releases/latest" 2>/dev/null || true)"
case "$latest_url" in
"$web_base"/releases/tag/*)
tag="${latest_url##"$web_base"/releases/tag/}"
[ -n "$tag" ] || return 1
printf '%s/releases/download/%s/%s\n' "$web_base" "$tag" "$(release_artifact_name)"
;;
*)
return 1
;;
esac
}
find_extracted_binary() {
find "$1" -type f -name "$2" 2>/dev/null | sed -n '1p'
}
install_extracted_binary() {
found="$(find_extracted_binary "$1" "$2")"
if [ -z "$found" ]; then
echo "prebuilt archive missing $2" >&2
return 1
fi
install -m 0755 "$found" "$3"
}
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
return 1
fi
}
verify_archive_checksum() {
url="$1"
archive="$2"
checksum_file="$3"
if ! curl -fsL "$url.sha256" -o "$checksum_file"; then
echo "prebuilt checksum unavailable; continuing without sidecar verification" >&2
return 0
fi
expected="$(awk '{print $1}' "$checksum_file" | sed -n '1p')"
actual="$(sha256_file "$archive")" || {
echo "sha256sum/shasum not found for checksum verification" >&2
return 1
}
if [ "$expected" != "$actual" ]; then
echo "prebuilt checksum mismatch for $url" >&2
return 1
fi
}
try_install_prebuilt() {
download_url="$(release_download_url)" || return 1
tmpdir="$(mktemp -d)"
archive="$tmpdir/$(release_artifact_name)"
checksum_file="$archive.sha256"
[ "$quiet" = "1" ] && echo "Trying Dosh prebuilt $(release_artifact_name)"
need curl
need tar
if ! curl -fsL "$download_url" -o "$archive" 2>/dev/null; then
alt_download_url="$(release_latest_tag_download_url || true)"
if [ -z "$alt_download_url" ] || ! curl -fsL "$alt_download_url" -o "$archive"; then
echo "prebuilt unavailable: $download_url" >&2
[ -z "$alt_download_url" ] || echo "prebuilt unavailable: $alt_download_url" >&2
return 1
fi
download_url="$alt_download_url"
fi
verify_archive_checksum "$download_url" "$archive" "$checksum_file" || return 1
mkdir -p "$tmpdir/extract"
if ! tar -xzf "$archive" -C "$tmpdir/extract"; then
echo "prebuilt archive could not be extracted: $download_url" >&2
return 1
fi
install_extracted_binary "$tmpdir/extract" dosh-client "$bindir/dosh-client" || return 1
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install_extracted_binary "$tmpdir/extract" dosh-server "$bindir/dosh-server" || return 1
install_extracted_binary "$tmpdir/extract" dosh-auth "$bindir/dosh-auth" || return 1
fi
if found_bench="$(find_extracted_binary "$tmpdir/extract" dosh-bench)" && [ -n "$found_bench" ]; then
install -m 0755 "$found_bench" "$bindir/dosh-bench"
fi
return 0
}
install_from_source() {
ensure_cargo
if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then
src_dir="$(pwd)"
else
else
if [ -z "$repo" ]; then
echo "DOSH_REPO or --repo is required when running the installer from curl" >&2
exit 2
@@ -155,39 +335,69 @@ else
fi
fi
src_dir="$update_cache"
fi
fi
cd "$src_dir"
[ "$quiet" = "1" ] && echo "Building Dosh $role"
if [ "$role" = "client" ]; then
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
else
if [ "$quiet" = "1" ]; then
cargo build -q --release
else
cargo build --release
fi
fi
fi
bindir="$prefix/bin"
config_dir="$HOME/.config/dosh"
data_dir="$HOME/.local/share/dosh"
systemd_user_dir="$HOME/.config/systemd/user"
mkdir -p "$bindir" "$config_dir" "$data_dir"
install -m 0755 target/release/dosh-client "$bindir/dosh-client"
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
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
fi
if [ -f target/release/dosh-bench ]; then
install -m 0755 target/release/dosh-bench "$bindir/dosh-bench"
fi
}
write_systemd_service() {
if [ -n "$src_dir" ] && [ -f "$src_dir/packaging/systemd/dosh-server.service" ]; then
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \
"$src_dir/packaging/systemd/dosh-server.service" >"$systemd_user_dir/dosh-server.service"
else
cat >"$systemd_user_dir/dosh-server.service" <<EOF
[Unit]
Description=Dosh server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$bindir/dosh-server serve
Restart=on-failure
RestartSec=1
KillMode=process
[Install]
WantedBy=default.target
EOF
fi
}
if [ "$use_prebuilt" != "0" ]; then
if ! try_install_prebuilt; then
if [ "$binary_required" = "1" ]; then
exit 1
fi
[ "$quiet" = "1" ] && echo "Falling back to source build"
install_from_source
fi
else
install_from_source
fi
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
@@ -226,8 +436,7 @@ EOF
if command -v systemctl >/dev/null 2>&1; then
mkdir -p "$systemd_user_dir"
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \
packaging/systemd/dosh-server.service >"$systemd_user_dir/dosh-server.service"
write_systemd_service
if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then
systemctl --user enable --now dosh-server.service
fi
@@ -272,7 +481,8 @@ dosh_port = $port
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
predict = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
auth_preference = "native,ssh"
@@ -295,18 +505,18 @@ EOF
fi
cat >"$hosts_config" <<EOF
# Example:
# [palav]
# ssh = "palav"
# dosh_host = "palav.dev"
# [homelab]
# ssh = "homelab"
# dosh_host = "server.example.com"
# port = 50000
# default_command = "tm"
# predict = false
# predict = true
[default]
ssh = "$default_server"
dosh_host = "$host_udp"
port = $port
predict = false
predict = true
EOF
fi
fi
@@ -317,10 +527,17 @@ Configured UDP port $port
EOF
if [ "$role" = "client" ] || [ "$role" = "both" ]; then
next_server="${server:-user@host}"
cat <<EOF
Client command:
$bindir/dosh ${server:-user@host}
Client commands:
$bindir/dosh $next_server
$bindir/dosh setup <ssh-alias>
$bindir/dosh update --check
Client config:
$config_dir/client.toml
$config_dir/hosts.toml
EOF
fi
+1 -1
View File
@@ -171,7 +171,7 @@ dosh doctor homelab # host resolution, trust state, UDP reachability, server
dosh sessions homelab # list live sessions
dosh trust homelab # fetch + pin the Dosh host key (via SSH fallback)
dosh trust --remove homelab
dosh import-ssh palav homelab # write a hosts.toml entry from an SSH alias
dosh import-ssh homelab # write a hosts.toml entry from an SSH alias
dosh update # update the installed client
```
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -eu
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
out="${DOSH_BENCH_REPORT:-target/dosh-bench/report.md}"
iters="${DOSH_BENCH_ITERS:-10}"
server="${DOSH_BENCH_SERVER:-${1:-local}}"
label="${DOSH_BENCH_LABEL:-$(uname -s) $(uname -m)}"
cargo build --release >/dev/null
mkdir -p "$(dirname "$out")"
exec target/release/dosh-bench \
--server "$server" \
--iterations "$iters" \
--label "$label" \
--markdown \
--output "$out" \
${DOSH_BENCH_ARGS:-}
+27 -1
View File
@@ -31,7 +31,7 @@ for _ in 1 2 3 4 5; do
done
ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts"
mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-cached" "$workdir/home-mosh"
mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-native" "$workdir/home-cached" "$workdir/home-mosh"
if ! HOME="$workdir/home" target/release/dosh-bench \
--server bench@127.0.0.1 \
@@ -64,6 +64,32 @@ if ! HOME="$workdir/home-controlmaster" target/release/dosh-bench \
exit 1
fi
if ! HOME="$workdir/home-native" target/release/dosh-client \
--ssh-port "$ssh_port" \
--ssh-key "$workdir/id_ed25519" \
--ssh-known-hosts "$workdir/known_hosts" \
--ssh-auth-command /usr/local/bin/dosh-auth \
trust bench@127.0.0.1 >/dev/null; then
docker logs "$container_id" || true
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
exit 1
fi
if ! HOME="$workdir/home-native" target/release/dosh-bench \
--server bench@127.0.0.1 \
--ssh-port "$ssh_port" \
--dosh-port "$dosh_port" \
--dosh-host 127.0.0.1 \
--ssh-key "$workdir/id_ed25519" \
--ssh-known-hosts "$workdir/known_hosts" \
--iterations 5 \
--cold-native \
--assert-ssh-plus-ms "${DOSH_BENCH_NATIVE_SSH_PLUS_MS:-0}"; then
docker logs "$container_id" || true
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
exit 1
fi
if ! HOME="$workdir/home-cached" target/release/dosh-bench \
--server bench@127.0.0.1 \
--ssh-port "$ssh_port" \
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env sh
set -eu
seconds="${DOSH_FUZZ_SECONDS:-${1:-20}}"
targets="
packet_decode
from_body
authorized_keys
known_hosts
handshake_structs
attach_ticket
"
if ! command -v cargo >/dev/null 2>&1; then
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
fi
for target in $targets; do
echo "== fuzzing $target for ${seconds}s =="
cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \
-max_total_time="$seconds" -rss_limit_mb="${DOSH_FUZZ_RSS_MB:-4096}"
done
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env sh
set -eu
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
out_dir="${DOSH_PACKAGE_DIR:-target/dosh-release}"
version="${DOSH_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | sed -n '1p')}"
normalize_os() {
case "$(uname -s)" in
Darwin) printf '%s\n' macos ;;
Linux) printf '%s\n' linux ;;
FreeBSD) printf '%s\n' freebsd ;;
MINGW*|MSYS*|CYGWIN*) printf '%s\n' windows ;;
*) uname -s | tr '[:upper:]' '[:lower:]' ;;
esac
}
normalize_arch() {
case "$(uname -m)" in
x86_64|amd64) printf '%s\n' x86_64 ;;
arm64|aarch64) printf '%s\n' aarch64 ;;
armv7l) printf '%s\n' armv7 ;;
*) uname -m | tr '[:upper:]' '[:lower:]' ;;
esac
}
os="$(normalize_os)"
arch="$(normalize_arch)"
if [ "$os" = "windows" ]; then
artifact="dosh-$os-$arch.zip"
versioned_artifact="dosh-$version-$os-$arch.zip"
else
artifact="dosh-$os-$arch.tar.gz"
versioned_artifact="dosh-$version-$os-$arch.tar.gz"
fi
stage="$out_dir/stage/dosh"
cargo build --release
rm -rf "$stage"
mkdir -p "$stage/bin" "$out_dir"
for bin in dosh-client dosh-server dosh-auth dosh-bench; do
if [ -f "target/release/$bin" ]; then
install -m 0755 "target/release/$bin" "$stage/bin/$bin"
fi
done
printf '%s\n' "$version" >"$stage/VERSION"
if [ "$os" = "windows" ]; then
if command -v powershell.exe >/dev/null 2>&1; then
powershell.exe -NoProfile -Command "Compress-Archive -Force -Path '$stage' -DestinationPath '$out_dir/$artifact'"
elif command -v zip >/dev/null 2>&1; then
(cd "$out_dir/stage" && zip -qr "../$artifact" dosh)
else
echo "windows packaging requires powershell.exe or zip" >&2
exit 1
fi
else
tar -C "$out_dir/stage" -czf "$out_dir/$artifact" dosh
fi
cp "$out_dir/$artifact" "$out_dir/$versioned_artifact"
write_sha256() {
file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$file" >"$file.sha256"
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" >"$file.sha256"
else
echo "warning: sha256sum/shasum not found; skipping checksum for $file" >&2
fi
}
write_sha256 "$out_dir/$artifact"
write_sha256 "$out_dir/$versioned_artifact"
cat <<EOF
Wrote:
$out_dir/$artifact
$out_dir/$artifact.sha256
$out_dir/$versioned_artifact
$out_dir/$versioned_artifact.sha256
EOF
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env sh
set -eu
usage() {
cat <<'EOF'
Usage:
GITEA_TOKEN=... scripts/upload-gitea-release.sh TAG [artifact...]
Environment:
GITEA_URL Base URL, default https://git.palav.dev
GITEA_REPO owner/repo, default Palav/dosh
GITEA_TOKEN Token with release write permission
GITEA_TITLE Release title, default TAG
EOF
}
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
usage
exit 0
fi
tag="${1:-}"
if [ -z "$tag" ]; then
usage >&2
exit 2
fi
shift
base="${GITEA_URL:-https://git.palav.dev}"
repo="${GITEA_REPO:-Palav/dosh}"
token="${GITEA_TOKEN:-}"
title="${GITEA_TITLE:-$tag}"
if [ -z "$token" ]; then
echo "GITEA_TOKEN is required" >&2
exit 2
fi
if [ "$#" -eq 0 ]; then
set -- target/dosh-release/dosh-*
fi
api="$base/api/v1/repos/$repo"
auth_header="Authorization: token $token"
release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag" 2>/dev/null || true)"
release_id=""
if [ -n "$release_json" ] && command -v jq >/dev/null 2>&1; then
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
elif [ -n "$release_json" ]; then
release_id="$(printf '%s\n' "$release_json" | sed -n 's/^[[:space:]]*{"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
fi
if [ -z "$release_id" ]; then
payload="$(printf '{"tag_name":"%s","target_commitish":"main","name":"%s","body":"Dosh release %s","draft":false,"prerelease":false}\n' "$tag" "$title" "$tag")"
release_json="$(curl -fsS -H "$auth_header" -H 'Content-Type: application/json' -X POST "$api/releases" -d "$payload")"
if command -v jq >/dev/null 2>&1; then
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
else
release_id="$(printf '%s\n' "$release_json" | sed -n 's/^[[:space:]]*{"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
fi
fi
if [ -z "$release_id" ]; then
echo "could not determine Gitea release id for $tag" >&2
exit 1
fi
delete_existing_asset() {
name="$1"
if ! command -v jq >/dev/null 2>&1; then
return 0
fi
printf '%s\n' "$release_json" \
| jq -r --arg name "$name" '.assets[]? | select(.name == $name) | .id' \
| while IFS= read -r asset_id; do
[ -n "$asset_id" ] || continue
echo "replacing $name"
curl -fsS \
-H "$auth_header" \
-X DELETE \
"$api/releases/$release_id/assets/$asset_id" >/dev/null
done
}
for artifact in "$@"; do
if [ ! -f "$artifact" ]; then
continue
fi
name="$(basename "$artifact")"
delete_existing_asset "$name"
echo "uploading $name"
curl -fsS \
-H "$auth_header" \
-X POST \
-F "attachment=@$artifact" \
"$api/releases/$release_id/assets?name=$name" >/dev/null
done
+139 -11
View File
@@ -2,6 +2,8 @@ use anyhow::{Context, Result, anyhow};
use clap::Parser;
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::ffi::OsStr;
use std::fmt::Write as _;
use std::fs;
use std::io::Read;
use std::path::PathBuf;
use std::process::Command;
@@ -63,6 +65,12 @@ struct Args {
/// Emit machine-readable JSON (one object per metric, with raw samples).
#[arg(long)]
json: bool,
/// Emit a publishable Markdown benchmark report.
#[arg(long)]
markdown: bool,
/// Write the report to a file instead of stdout.
#[arg(long)]
output: Option<PathBuf>,
/// Optional label printed in summary/JSON output (e.g. machine/OS identifier).
#[arg(long)]
label: Option<String>,
@@ -179,11 +187,14 @@ fn main() -> Result<()> {
results.push(MetricSamples::new("mosh_start_true_ms", mosh_times));
}
if args.json {
print_json(&args, &results);
let report = if args.json {
render_json(&args, &results)
} else if args.markdown {
render_markdown(&args, &results)
} else {
print_table(&args, &results);
}
render_table(&args, &results)
};
write_report(&args, &report)?;
run_assertions(&args, &results)?;
Ok(())
@@ -560,17 +571,20 @@ fn percentile(sorted: &[f64], pct: f64) -> f64 {
}
}
fn print_table(args: &Args, results: &[MetricSamples]) {
fn render_table(args: &Args, results: &[MetricSamples]) -> String {
let mut out = String::new();
if let Some(label) = &args.label {
println!("# label: {label}");
let _ = writeln!(out, "# label: {label}");
}
println!(
let _ = writeln!(
out,
"{:<24} {:>6} {:>9} {:>9} {:>9} {:>9} {:>9}",
"metric", "n", "min", "median", "p95", "mean", "max"
);
for metric in results {
let s = metric.stats();
println!(
let _ = writeln!(
out,
"{:<24} {:>6} {:>9.2} {:>9.2} {:>9.2} {:>9.2} {:>9.2}",
metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max
);
@@ -578,11 +592,12 @@ fn print_table(args: &Args, results: &[MetricSamples]) {
// Raw per-iteration samples, so published numbers can include raw data.
for metric in results {
let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect();
println!("{} samples_ms=[{}]", metric.name, ms.join(", "));
let _ = writeln!(out, "{} samples_ms=[{}]", metric.name, ms.join(", "));
}
out
}
fn print_json(args: &Args, results: &[MetricSamples]) {
fn render_json(args: &Args, results: &[MetricSamples]) -> String {
let mut entries = Vec::new();
for metric in results {
let s = metric.stats();
@@ -603,11 +618,58 @@ fn print_json(args: &Args, results: &[MetricSamples]) {
Some(label) => format!("\"{}\"", label.replace('"', "\\\"")),
None => "null".to_string(),
};
println!(
format!(
"{{\"label\":{label},\"iterations\":{},\"metrics\":[{}]}}",
args.iterations.max(1),
entries.join(",")
)
}
fn render_markdown(args: &Args, results: &[MetricSamples]) -> String {
let mut out = String::new();
let label = args.label.as_deref().unwrap_or("Dosh benchmark");
let _ = writeln!(out, "# {label}\n");
let _ = writeln!(out, "- server: `{}`", args.server);
let _ = writeln!(out, "- iterations: `{}`", args.iterations.max(1));
let _ = writeln!(
out,
"- generated_by: `dosh-bench {}`\n",
env!("CARGO_PKG_VERSION")
);
let _ = writeln!(
out,
"| metric | n | min ms | median ms | p95 ms | mean ms | max ms |"
);
let _ = writeln!(out, "|---|---:|---:|---:|---:|---:|---:|");
for metric in results {
let s = metric.stats();
let _ = writeln!(
out,
"| `{}` | {} | {:.2} | {:.2} | {:.2} | {:.2} | {:.2} |",
metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max
);
}
let _ = writeln!(out, "\n## Raw Samples\n");
for metric in results {
let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect();
let _ = writeln!(out, "- `{}`: [{}]", metric.name, ms.join(", "));
}
out
}
fn write_report(args: &Args, report: &str) -> Result<()> {
if let Some(path) = &args.output {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
fs::write(path, report).with_context(|| format!("write {}", path.display()))?;
println!("wrote {}", path.display());
} else {
println!("{report}");
}
Ok(())
}
fn metric_mean(results: &[MetricSamples], name: &str) -> Option<f64> {
@@ -677,3 +739,69 @@ fn default_client_path() -> PathBuf {
.and_then(|path| path.parent().map(|parent| parent.join("dosh-client")))
.unwrap_or_else(|| PathBuf::from("dosh-client"))
}
#[cfg(test)]
mod tests {
use super::*;
fn args_for_render() -> Args {
Args {
server: "local".to_string(),
session: "default".to_string(),
ssh_port: 22,
dosh_port: 50000,
dosh_host: None,
iterations: 2,
local_auth: true,
cold_native: false,
cached_ticket: false,
resume: false,
client: None,
ssh_auth_command: "~/.local/bin/dosh-auth".to_string(),
ssh_key: None,
ssh_known_hosts: None,
ssh_control_path: None,
controlmaster: false,
no_cache: false,
warm_cache: false,
skip_ssh_baseline: true,
include_mosh: false,
mosh: PathBuf::from("mosh"),
mosh_server_command: "mosh-server".to_string(),
mosh_port: None,
json: false,
markdown: true,
output: None,
label: Some("test host".to_string()),
assert_ssh_plus_ms: None,
assert_mosh_minus_ms: None,
assert_dosh_max_ms: None,
}
}
#[test]
fn markdown_report_contains_summary_and_samples() {
let args = args_for_render();
let metrics = vec![MetricSamples::new(
"dosh_cached_attach_ms",
vec![Duration::from_millis(3), Duration::from_millis(5)],
)];
let report = render_markdown(&args, &metrics);
assert!(report.contains("# test host"));
assert!(report.contains("| `dosh_cached_attach_ms` | 2 | 3.00"));
assert!(report.contains("- `dosh_cached_attach_ms`: [3.00, 5.00]"));
}
#[test]
fn json_report_contains_metrics() {
let args = args_for_render();
let metrics = vec![MetricSamples::new(
"dosh_local_attach_ms",
vec![Duration::from_millis(7)],
)];
let report = render_json(&args, &metrics);
assert!(report.contains("\"label\":\"test host\""));
assert!(report.contains("\"metric\":\"dosh_local_attach_ms\""));
assert!(report.contains("\"samples_ms\":[7.0000]"));
}
}
+913 -88
View File
File diff suppressed because it is too large Load Diff
+351 -19
View File
@@ -20,7 +20,7 @@ use dosh::protocol::{
TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope,
};
use dosh::pty::{PtyHandle, PtyOutput, adopt_pty_from_fd, spawn_pty_session};
use std::collections::{HashMap, HashSet, VecDeque};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::net::{IpAddr, SocketAddr};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixStream as StdUnixStream;
@@ -375,6 +375,10 @@ struct ClientState {
opened_streams: HashSet<u64>,
stream_send_credit: HashMap<u64, usize>,
stream_pending_data: HashMap<u64, VecDeque<Vec<u8>>>,
stream_next_send_offset: HashMap<u64, u64>,
stream_sent_data: HashMap<u64, BTreeMap<u64, PendingStreamChunk>>,
stream_next_recv_offset: HashMap<u64, u64>,
stream_recv_pending: HashMap<u64, BTreeMap<u64, Vec<u8>>>,
/// Current transport key epoch (0 = original handshake key). Bumped on rekey.
epoch: u64,
/// When the current epoch began, for the wall-clock rekey trigger.
@@ -394,6 +398,14 @@ struct PendingFrame {
attempts: u8,
}
#[derive(Clone)]
struct PendingStreamChunk {
offset: u64,
bytes: Vec<u8>,
last_sent: Instant,
attempts: u32,
}
struct PendingNativeAuth {
client: dosh::native::NativeClientHello,
server: NativeServerHello,
@@ -472,6 +484,20 @@ impl ServerState {
Ok(())
}
/// Whether a session named `name` should be backed by a persistent holder.
///
/// Persistence is only worthwhile for sessions a user can actually reattach
/// to by name: prewarmed sessions and explicitly-named ones (`--session`).
/// Implicit `term-<millis>-<pid>` names (from `dosh host` with no session)
/// are ephemeral — persisting them just leaves unreattachable holders
/// lingering across restarts, so they use the in-process (dies-on-restart)
/// shell that reaps normally on disconnect.
fn should_persist_session(&self, name: &str) -> bool {
self.config.persist_sessions
&& (self.config.prewarm_sessions.iter().any(|s| s == name)
|| !protocol::is_implicit_session_name(name))
}
/// Spawn (or, in the persistent case, spawn-and-adopt) a PTY for a session.
///
/// With `persist_sessions` on, the shell is launched in a detached holder
@@ -488,7 +514,7 @@ impl ServerState {
) -> Result<(PtyHandle, Option<StdUnixStream>, bool)> {
let cols = cols.max(1);
let rows = rows.max(1);
if self.config.persist_sessions {
if self.should_persist_session(name) {
match self.spawn_persistent_pty(name, cols, rows, env) {
Ok(pair) => return Ok((pair.0, Some(pair.1), true)),
Err(err) => {
@@ -864,9 +890,11 @@ async fn handle_native_client_hello(
.hello
.supported_user_key_algorithms
.iter()
.any(|algorithm| algorithm == "ssh-ed25519")
.any(|algorithm| dosh::native::is_supported_user_signature_algorithm(algorithm))
{
Err(anyhow!("native auth requires ssh-ed25519"))
Err(anyhow!(
"native auth requires a supported user key algorithm"
))
} else {
let current_user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
if req.hello.requested_user != current_user {
@@ -1099,6 +1127,10 @@ async fn handle_native_user_auth(
opened_streams: HashSet::new(),
stream_send_credit: HashMap::new(),
stream_pending_data: HashMap::new(),
stream_next_send_offset: HashMap::new(),
stream_sent_data: HashMap::new(),
stream_next_recv_offset: HashMap::new(),
stream_recv_pending: HashMap::new(),
epoch: 0,
epoch_started: Instant::now(),
epoch_packets: 0,
@@ -1244,6 +1276,10 @@ async fn handle_bootstrap_attach(
opened_streams: HashSet::new(),
stream_send_credit: HashMap::new(),
stream_pending_data: HashMap::new(),
stream_next_send_offset: HashMap::new(),
stream_sent_data: HashMap::new(),
stream_next_recv_offset: HashMap::new(),
stream_recv_pending: HashMap::new(),
epoch: 0,
epoch_started: Instant::now(),
epoch_packets: 0,
@@ -1363,6 +1399,10 @@ async fn handle_ticket_attach(
opened_streams: HashSet::new(),
stream_send_credit: HashMap::new(),
stream_pending_data: HashMap::new(),
stream_next_send_offset: HashMap::new(),
stream_sent_data: HashMap::new(),
stream_next_recv_offset: HashMap::new(),
stream_recv_pending: HashMap::new(),
epoch: 0,
epoch_started: Instant::now(),
epoch_packets: 0,
@@ -1729,6 +1769,14 @@ async fn handle_stream_open(
client
.stream_send_credit
.insert(open.stream_id, STREAM_INITIAL_WINDOW);
client
.stream_next_send_offset
.entry(open.stream_id)
.or_insert(0);
client
.stream_next_recv_offset
.entry(open.stream_id)
.or_insert(0);
}
tokio::spawn(async move {
@@ -1782,7 +1830,8 @@ async fn handle_stream_data(
let (key, session_name) = find_client_decrypt_key(state, &packet.header)?;
let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?;
let data: StreamData = protocol::from_body(&body)?;
let writer = {
let stream_id = data.stream_id;
let (writer, writes, consumed, received_offset) = {
let mut locked = state.lock().expect("server state poisoned");
let session = locked
.sessions
@@ -1797,16 +1846,26 @@ async fn handle_stream_data(
}
client.endpoint = peer;
client.last_seen = Instant::now();
client.stream_writers.get(&data.stream_id).cloned()
let writer = client.stream_writers.get(&data.stream_id).cloned();
let (writes, consumed, received_offset) = accept_stream_data(client, data);
(writer, writes, consumed, received_offset)
};
let len = data.bytes.len();
if let Some(writer) = writer {
let _ = writer.send(data.bytes).await;
for bytes in writes {
let _ = writer.send(bytes).await;
}
}
// Always return flow-control credit, even when the stream's writer is already
// gone (closed/unknown). The peer debited its send window for these bytes, so
// skipping the adjust would wedge the stream at a permanent credit deficit.
send_stream_window_adjust_to_client(state, socket, packet.header.conn_id, data.stream_id, len)
send_stream_window_adjust_to_client(
state,
socket,
packet.header.conn_id,
stream_id,
consumed,
received_offset,
)
.await?;
Ok(())
}
@@ -1897,11 +1956,7 @@ async fn handle_stream_window_adjust(
}
client.endpoint = peer;
client.last_seen = Instant::now();
add_stream_credit(
&mut client.stream_send_credit,
adjust.stream_id,
adjust.bytes,
);
ack_stream_data(client, adjust.stream_id, adjust.received_offset);
}
flush_stream_pending_data_to_client(state, socket, packet.header.conn_id, adjust.stream_id)
.await
@@ -1932,6 +1987,10 @@ async fn handle_stream_close(
client.opened_streams.remove(&close.stream_id);
client.stream_send_credit.remove(&close.stream_id);
client.stream_pending_data.remove(&close.stream_id);
client.stream_next_send_offset.remove(&close.stream_id);
client.stream_sent_data.remove(&close.stream_id);
client.stream_next_recv_offset.remove(&close.stream_id);
client.stream_recv_pending.remove(&close.stream_id);
Ok(())
}
@@ -2296,8 +2355,16 @@ async fn queue_or_send_stream_data_to_client(
return Ok(());
}
*client.stream_send_credit.entry(stream_id).or_default() -= bytes.len();
let offset = *client.stream_next_send_offset.entry(stream_id).or_default();
client
.stream_next_send_offset
.insert(stream_id, offset.saturating_add(bytes.len() as u64));
client.send_seq += 1;
let body = protocol::to_body(&StreamData { stream_id, bytes })?;
let body = protocol::to_body(&StreamData {
stream_id,
offset,
bytes: bytes.clone(),
})?;
let packet = protocol::encode_encrypted(
PacketKind::StreamData,
client_id,
@@ -2307,6 +2374,19 @@ async fn queue_or_send_stream_data_to_client(
SERVER_TO_CLIENT,
&body,
)?;
client
.stream_sent_data
.entry(stream_id)
.or_default()
.insert(
offset,
PendingStreamChunk {
offset,
bytes,
last_sent: Instant::now(),
attempts: 1,
},
);
send = Some((client.endpoint, packet));
}
send
@@ -2348,8 +2428,16 @@ async fn flush_stream_pending_data_to_client(
client.stream_pending_data.remove(&stream_id);
}
*client.stream_send_credit.entry(stream_id).or_default() -= bytes.len();
let offset = *client.stream_next_send_offset.entry(stream_id).or_default();
client
.stream_next_send_offset
.insert(stream_id, offset.saturating_add(bytes.len() as u64));
client.send_seq += 1;
let body = protocol::to_body(&StreamData { stream_id, bytes })?;
let body = protocol::to_body(&StreamData {
stream_id,
offset,
bytes: bytes.clone(),
})?;
let packet = protocol::encode_encrypted(
PacketKind::StreamData,
client_id,
@@ -2359,6 +2447,19 @@ async fn flush_stream_pending_data_to_client(
SERVER_TO_CLIENT,
&body,
)?;
client
.stream_sent_data
.entry(stream_id)
.or_default()
.insert(
offset,
PendingStreamChunk {
offset,
bytes,
last_sent: Instant::now(),
attempts: 1,
},
);
send = Some((client.endpoint, packet));
}
send
@@ -2370,9 +2471,72 @@ async fn flush_stream_pending_data_to_client(
}
}
fn add_stream_credit(stream_send_credit: &mut HashMap<u64, usize>, stream_id: u64, bytes: u32) {
fn accept_stream_data(client: &mut ClientState, data: StreamData) -> (Vec<Vec<u8>>, usize, u64) {
let stream_id = data.stream_id;
let expected = client.stream_next_recv_offset.entry(stream_id).or_insert(0);
if data.offset < *expected {
return (Vec::new(), 0, *expected);
}
if data.offset > *expected {
client
.stream_recv_pending
.entry(stream_id)
.or_default()
.entry(data.offset)
.or_insert(data.bytes);
return (Vec::new(), 0, *expected);
}
let mut writes = vec![data.bytes];
let mut consumed = writes[0].len();
*expected = expected.saturating_add(consumed as u64);
while let Some(bytes) = client
.stream_recv_pending
.entry(stream_id)
.or_default()
.remove(expected)
{
consumed += bytes.len();
*expected = expected.saturating_add(bytes.len() as u64);
writes.push(bytes);
}
if client
.stream_recv_pending
.get(&stream_id)
.is_some_and(BTreeMap::is_empty)
{
client.stream_recv_pending.remove(&stream_id);
}
(writes, consumed, *expected)
}
fn ack_stream_data(client: &mut ClientState, stream_id: u64, received_offset: u64) {
let Some(sent) = client.stream_sent_data.get_mut(&stream_id) else {
return;
};
let acked_offsets: Vec<u64> = sent
.iter()
.filter_map(|(offset, chunk)| {
let end = chunk.offset.saturating_add(chunk.bytes.len() as u64);
(end <= received_offset).then_some(*offset)
})
.collect();
let mut acked_bytes = 0usize;
for offset in acked_offsets {
if let Some(chunk) = sent.remove(&offset) {
acked_bytes = acked_bytes.saturating_add(chunk.bytes.len());
}
}
if sent.is_empty() {
client.stream_sent_data.remove(&stream_id);
}
add_stream_credit(&mut client.stream_send_credit, stream_id, acked_bytes);
}
fn add_stream_credit(stream_send_credit: &mut HashMap<u64, usize>, stream_id: u64, bytes: usize) {
let credit = stream_send_credit.entry(stream_id).or_default();
*credit = credit.saturating_add(bytes as usize);
*credit = credit.saturating_add(bytes).min(STREAM_INITIAL_WINDOW);
}
async fn send_stream_window_adjust_to_client(
@@ -2381,9 +2545,11 @@ async fn send_stream_window_adjust_to_client(
client_id: [u8; 16],
stream_id: u64,
bytes: usize,
received_offset: u64,
) -> Result<()> {
let body = protocol::to_body(&StreamWindowAdjust {
stream_id,
received_offset,
bytes: bytes.min(u32::MAX as usize) as u32,
})?;
send_stream_packet_to_client(
@@ -2409,6 +2575,10 @@ async fn send_stream_close_to_client(
client.opened_streams.remove(&stream_id);
client.stream_send_credit.remove(&stream_id);
client.stream_pending_data.remove(&stream_id);
client.stream_next_send_offset.remove(&stream_id);
client.stream_sent_data.remove(&stream_id);
client.stream_next_recv_offset.remove(&stream_id);
client.stream_recv_pending.remove(&stream_id);
}
}
let body = protocol::to_body(&StreamClose { stream_id })?;
@@ -2626,7 +2796,7 @@ async fn retransmit_pending(
let now = Instant::now();
let mut sends = Vec::new();
for session in locked.sessions.values_mut() {
for client in session.clients.values_mut() {
for (client_id, client) in session.clients.iter_mut() {
for pending in client.pending.iter_mut() {
if pending.output_seq <= client.last_acked {
continue;
@@ -2639,6 +2809,39 @@ async fn retransmit_pending(
sends.push((client.endpoint, pending.packet.clone()));
}
}
let stream_ids: Vec<u64> = client.stream_sent_data.keys().copied().collect();
let mut retransmit_chunks = Vec::new();
for stream_id in stream_ids {
let Some(chunks) = client.stream_sent_data.get_mut(&stream_id) else {
continue;
};
for chunk in chunks.values_mut() {
if now.duration_since(chunk.last_sent) < Duration::from_millis(200) {
continue;
}
chunk.last_sent = now;
chunk.attempts = chunk.attempts.saturating_add(1);
retransmit_chunks.push((stream_id, chunk.offset, chunk.bytes.clone()));
}
}
for (stream_id, offset, bytes) in retransmit_chunks {
client.send_seq += 1;
let body = protocol::to_body(&StreamData {
stream_id,
offset,
bytes,
})?;
let packet = protocol::encode_encrypted(
PacketKind::StreamData,
*client_id,
client.send_seq,
client.last_acked,
&client.session_key,
SERVER_TO_CLIENT,
&body,
)?;
sends.push((client.endpoint, packet));
}
}
}
sends
@@ -2911,6 +3114,33 @@ fn parse_env_pairs(raw: &[String]) -> Result<Vec<(String, String)>> {
mod tests {
use super::*;
#[test]
fn persists_named_and_prewarmed_but_not_implicit_sessions() {
let (pty_tx, _rx) = mpsc::unbounded_channel();
let config = ServerConfig {
persist_sessions: true,
prewarm_sessions: vec!["default".to_string()],
..ServerConfig::default()
};
let state = ServerState::new(config, [0u8; 32], pty_tx);
assert!(state.should_persist_session("default")); // prewarmed
assert!(state.should_persist_session("work")); // explicitly named
assert!(!state.should_persist_session("term-1781470634216-76685")); // implicit
}
#[test]
fn persist_disabled_never_persists() {
let (pty_tx, _rx) = mpsc::unbounded_channel();
let config = ServerConfig {
persist_sessions: false,
prewarm_sessions: vec!["default".to_string()],
..ServerConfig::default()
};
let state = ServerState::new(config, [0u8; 32], pty_tx);
assert!(!state.should_persist_session("default"));
assert!(!state.should_persist_session("work"));
}
#[test]
fn native_auth_rate_limiter_throttles_per_ip_and_refills() {
let mut limiter = NativeAuthRateLimiter::new(3);
@@ -2972,6 +3202,10 @@ mod tests {
opened_streams: HashSet::new(),
stream_send_credit: HashMap::new(),
stream_pending_data: HashMap::new(),
stream_next_send_offset: HashMap::new(),
stream_sent_data: HashMap::new(),
stream_next_recv_offset: HashMap::new(),
stream_recv_pending: HashMap::new(),
epoch: 0,
epoch_started: Instant::now(),
epoch_packets: 0,
@@ -3168,6 +3402,10 @@ mod tests {
opened_streams: HashSet::new(),
stream_send_credit: HashMap::new(),
stream_pending_data: HashMap::new(),
stream_next_send_offset: HashMap::new(),
stream_sent_data: HashMap::new(),
stream_next_recv_offset: HashMap::new(),
stream_recv_pending: HashMap::new(),
epoch: 0,
epoch_started: Instant::now(),
epoch_packets: 0,
@@ -3225,6 +3463,100 @@ mod tests {
assert_eq!(data.bytes, b"hello");
}
#[tokio::test]
async fn blocked_stream_data_does_not_block_terminal_frames() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
let client_id = [8u8; 16];
let session_key = [10u8; 32];
let receiver = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let sender = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
let endpoint = receiver.local_addr().unwrap();
state.sessions.insert(
"test".to_string(),
Session {
pty: None,
parser: vt100::Parser::new(24, 80, 100),
clients: HashMap::from([(
client_id,
ClientState {
endpoint,
mode: "read-write".to_string(),
session_key,
last_acked: 0,
replay: ReplayWindow::default(),
send_seq: 1,
cols: 80,
rows: 24,
last_seen: Instant::now(),
pending: VecDeque::new(),
allowed_forwardings: Vec::new(),
stream_writers: HashMap::new(),
opened_streams: HashSet::from([42]),
stream_send_credit: HashMap::from([(42, 0)]),
stream_pending_data: HashMap::new(),
stream_next_send_offset: HashMap::new(),
stream_sent_data: HashMap::new(),
stream_next_recv_offset: HashMap::new(),
stream_recv_pending: HashMap::new(),
epoch: 0,
epoch_started: Instant::now(),
epoch_packets: 0,
previous_session_key: None,
},
)]),
output_seq: 0,
recent: VecDeque::new(),
empty_since: None,
holder_control: None,
persistent: false,
bytes_since_persist: 0,
last_persisted_seq: 0,
},
);
state.client_index.insert(client_id, "test".to_string());
let state = Arc::new(Mutex::new(state));
queue_or_send_stream_data_to_client(
&state,
&sender,
client_id,
42,
b"blocked-forward-bytes".to_vec(),
)
.await
.unwrap();
{
let locked = state.lock().unwrap();
let client = locked.sessions["test"].clients.get(&client_id).unwrap();
assert_eq!(client.stream_pending_data[&42].len(), 1);
assert_eq!(client.stream_send_credit[&42], 0);
}
broadcast_output(
&state,
&sender,
PtyOutput {
session: "test".to_string(),
bytes: b"terminal-priority".to_vec(),
exited: false,
},
)
.await
.unwrap();
let mut buf = [0u8; 2048];
let (n, _) = tokio::time::timeout(Duration::from_secs(1), receiver.recv_from(&mut buf))
.await
.unwrap()
.unwrap();
let packet = protocol::decode(&buf[..n]).unwrap();
assert_eq!(packet.header.kind, PacketKind::Frame);
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT).unwrap();
let frame: Frame = protocol::from_body(&plain).unwrap();
assert_eq!(frame.bytes, b"terminal-priority");
}
#[test]
fn remote_non_loopback_bind_requires_explicit_config() {
let config = ServerConfig::default();
+24 -2
View File
@@ -104,7 +104,7 @@ pub struct ClientConfig {
pub default_session: String,
pub reconnect_timeout_secs: u64,
pub view_only: bool,
#[serde(default)]
#[serde(default = "default_true")]
pub predict: bool,
/// Prediction display policy: "off", "experimental" (adaptive, the default),
/// or "always". Controls when speculative local echo is shown.
@@ -136,6 +136,11 @@ pub struct ClientConfig {
pub send_env: Vec<String>,
#[serde(default)]
pub set_env: HashMap<String, String>,
/// Optional client-side command extensions. These are just shell command
/// templates expanded into the remote session's startup input; Dosh does not
/// depend on the tools they name.
#[serde(default)]
pub extensions: HashMap<String, CommandExtension>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -151,6 +156,22 @@ pub struct HostConfig {
pub send_env: Option<Vec<String>>,
#[serde(default)]
pub set_env: HashMap<String, String>,
/// Per-host extension overrides. A host can replace a global extension or set
/// `disabled = true` to opt out of it.
#[serde(default)]
pub extensions: HashMap<String, CommandExtension>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandExtension {
/// Remote shell command template. `{args}` expands to shell-quoted extra
/// words after the extension name. When absent, extra args are appended.
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub disabled: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -172,7 +193,7 @@ impl Default for ClientConfig {
default_session: "new".to_string(),
reconnect_timeout_secs: 5,
view_only: false,
predict: false,
predict: true,
predict_mode: default_predict_mode(),
cache_attach_tickets: true,
credential_cache: "~/.local/share/dosh/credentials".to_string(),
@@ -186,6 +207,7 @@ impl Default for ClientConfig {
disconnect_status: true,
send_env: default_send_env(),
set_env: HashMap::new(),
extensions: HashMap::new(),
}
}
}
+342 -28
View File
@@ -5,7 +5,9 @@ use anyhow::{Context, Result, bail};
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rsa::traits::PublicKeyParts;
use serde::{Deserialize, Serialize};
use signature::{SignatureEncoding, Signer as SignatureSigner, Verifier as SignatureVerifier};
use std::fs;
use std::io::Write;
use std::net::IpAddr;
@@ -339,6 +341,92 @@ pub fn sign_user_auth(
Ok(auth)
}
pub fn sign_user_auth_with_private_key(
private_key: &ssh_key::PrivateKey,
client: &NativeClientHello,
server: &NativeServerHello,
requested_forwardings: Vec<ForwardingRequest>,
) -> Result<NativeUserAuth> {
anyhow::ensure!(!private_key.is_encrypted(), "OpenSSH identity is encrypted");
let public_key = private_key.public_key();
let key_algorithm = public_key.algorithm().as_str().to_string();
anyhow::ensure!(
is_supported_user_key_algorithm(&key_algorithm),
"unsupported native identity key algorithm {key_algorithm}"
);
let public_key_blob = ssh_public_blob_from_public_key(public_key)?;
let public_key_for_auth = if key_algorithm == "ssh-ed25519" {
parse_ssh_ed25519_public_blob(&public_key_blob)?.to_vec()
} else {
public_key_blob
};
let signature_algorithm = signature_algorithm_for_private_key(&key_algorithm)?;
let mut auth = NativeUserAuth {
public_key_algorithm: signature_algorithm.to_string(),
public_key: public_key_for_auth,
signature: Vec::new(),
requested_forwardings,
};
let transcript = user_auth_transcript(client, server, &auth)?;
auth.signature = if key_algorithm == "ssh-rsa" {
sign_rsa_sha512_private_key(private_key, &transcript)?
} else {
let signature = SignatureSigner::try_sign(private_key, &transcript)
.context("sign native user auth with OpenSSH private key")?;
anyhow::ensure!(
signature.algorithm().as_str() == auth.public_key_algorithm,
"private key produced signature algorithm {}, expected {}",
signature.algorithm().as_str(),
auth.public_key_algorithm
);
signature.as_bytes().to_vec()
};
Ok(auth)
}
fn sign_rsa_sha512_private_key(
private_key: &ssh_key::PrivateKey,
transcript: &[u8],
) -> Result<Vec<u8>> {
let rsa_keypair = private_key
.key_data()
.rsa()
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity is not ssh-rsa"))?;
let rsa_private = rsa_private_key_from_ssh_keypair(rsa_keypair)?;
let signing_key = rsa::pkcs1v15::SigningKey::<sha2::Sha512>::new(rsa_private);
let signature: rsa::pkcs1v15::Signature = SignatureSigner::sign(&signing_key, transcript);
Ok(signature.to_vec())
}
fn rsa_private_key_from_ssh_keypair(
keypair: &ssh_key::private::RsaKeypair,
) -> Result<rsa::RsaPrivateKey> {
let private = rsa::RsaPrivateKey::from_components(
rsa::BigUint::try_from(&keypair.public.n).context("convert RSA modulus")?,
rsa::BigUint::try_from(&keypair.public.e).context("convert RSA public exponent")?,
rsa::BigUint::try_from(&keypair.private.d).context("convert RSA private exponent")?,
vec![
rsa::BigUint::try_from(&keypair.private.p).context("convert RSA p prime")?,
rsa::BigUint::try_from(&keypair.private.q).context("convert RSA q prime")?,
],
)
.context("convert OpenSSH RSA private key")?;
anyhow::ensure!(
private.size().saturating_mul(8) >= 2048,
"OpenSSH RSA identity is smaller than 2048 bits"
);
Ok(private)
}
fn signature_algorithm_for_private_key(key_algorithm: &str) -> Result<&'static str> {
match key_algorithm {
"ssh-ed25519" => Ok("ssh-ed25519"),
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
"ssh-rsa" => Ok("rsa-sha2-512"),
_ => bail!("unsupported native identity key algorithm {key_algorithm}"),
}
}
pub fn verify_native_user_auth(
client: &NativeClientHello,
server: &NativeServerHello,
@@ -347,10 +435,54 @@ pub fn verify_native_user_auth(
source_ip: Option<IpAddr>,
) -> Result<AuthorizedKey> {
anyhow::ensure!(
auth.public_key_algorithm == "ssh-ed25519",
is_supported_user_signature_algorithm(&auth.public_key_algorithm),
"unsupported native user key algorithm {}",
auth.public_key_algorithm
);
let authorized_algorithm = authorized_key_algorithm_for_auth(&auth.public_key_algorithm)?;
let authorized = authorized_keys
.iter()
.find(|key| key.algorithm == authorized_algorithm && key.key == auth.public_key)
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
authorized.ensure_native_allowed(auth, source_ip)?;
let transcript = user_auth_transcript(client, server, auth)?;
verify_native_user_signature(auth, &transcript)?;
Ok(authorized.clone())
}
pub fn supported_user_key_algorithms() -> Vec<String> {
vec![
"ssh-ed25519".to_string(),
"ecdsa-sha2-nistp256".to_string(),
"rsa-sha2-512".to_string(),
"rsa-sha2-256".to_string(),
]
}
pub fn is_supported_user_key_algorithm(algorithm: &str) -> bool {
matches!(algorithm, "ssh-ed25519" | "ecdsa-sha2-nistp256" | "ssh-rsa")
}
pub fn is_supported_user_signature_algorithm(algorithm: &str) -> bool {
matches!(
algorithm,
"ssh-ed25519" | "ecdsa-sha2-nistp256" | "rsa-sha2-512" | "rsa-sha2-256"
)
}
fn authorized_key_algorithm_for_auth(signature_algorithm: &str) -> Result<&'static str> {
match signature_algorithm {
"ssh-ed25519" => Ok("ssh-ed25519"),
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
"rsa-sha2-512" | "rsa-sha2-256" => Ok("ssh-rsa"),
_ => bail!("unsupported native user key algorithm {signature_algorithm}"),
}
}
fn verify_native_user_signature(auth: &NativeUserAuth, transcript: &[u8]) -> Result<()> {
match auth.public_key_algorithm.as_str() {
"ssh-ed25519" => {
anyhow::ensure!(
auth.public_key.len() == 32,
"ssh-ed25519 public key must be 32 bytes"
@@ -359,23 +491,69 @@ pub fn verify_native_user_auth(
auth.signature.len() == 64,
"ssh-ed25519 signature must be 64 bytes"
);
let authorized = authorized_keys
.iter()
.find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key)
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
authorized.ensure_native_allowed(auth, source_ip)?;
let mut public_key = [0u8; 32];
public_key.copy_from_slice(&auth.public_key);
let verifying_key = VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
let verifying_key =
VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
let mut signature = [0u8; 64];
signature.copy_from_slice(&auth.signature);
let signature = Signature::from_bytes(&signature);
let transcript = user_auth_transcript(client, server, auth)?;
verifying_key
.verify(&transcript, &signature)
.verify(transcript, &signature)
.context("verify native user signature")?;
Ok(authorized.clone())
}
"ecdsa-sha2-nistp256" => {
let public_key = ssh_public_key_from_blob(&auth.public_key)
.context("parse native user SSH public key")?;
let algorithm =
ssh_key::Algorithm::from_str(&auth.public_key_algorithm).with_context(|| {
format!("parse signature algorithm {}", auth.public_key_algorithm)
})?;
let signature = ssh_key::Signature::new(algorithm, auth.signature.clone())
.context("parse native user SSH signature")?;
SignatureVerifier::verify(public_key.key_data(), transcript, &signature)
.context("verify native user SSH signature")?;
}
"rsa-sha2-512" | "rsa-sha2-256" => {
verify_rsa_sha2_signature(
&auth.public_key,
&auth.public_key_algorithm,
transcript,
&auth.signature,
)?;
}
algorithm => bail!("unsupported native user key algorithm {algorithm}"),
}
Ok(())
}
fn verify_rsa_sha2_signature(
public_key_blob: &[u8],
signature_algorithm: &str,
transcript: &[u8],
signature: &[u8],
) -> Result<()> {
let (e, n) = parse_ssh_rsa_public_blob(public_key_blob)?;
let public = rsa::RsaPublicKey::new(
rsa::BigUint::from_bytes_be(n),
rsa::BigUint::from_bytes_be(e),
)
.context("parse RSA public key")?;
let signature =
rsa::pkcs1v15::Signature::try_from(signature).context("parse RSA PKCS#1v1.5 signature")?;
match signature_algorithm {
"rsa-sha2-256" => {
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha256>::new(public);
SignatureVerifier::verify(&key, transcript, &signature)
.context("verify RSA-SHA256 signature")
}
"rsa-sha2-512" => {
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha512>::new(public);
SignatureVerifier::verify(&key, transcript, &signature)
.context("verify RSA-SHA512 signature")
}
_ => bail!("legacy ssh-rsa/SHA-1 signatures are not accepted"),
}
}
pub fn verify_native_user_auth_from_config(
@@ -470,6 +648,35 @@ pub fn load_ed25519_identity_with_passphrase(
.with_context(|| format!("parse ssh-ed25519 identity {}", path.display()))
}
pub fn load_native_identity(path: &Path) -> Result<ssh_key::PrivateKey> {
load_native_identity_with_passphrase(path, None)
}
pub fn load_native_identity_with_passphrase(
path: &Path,
passphrase: Option<&str>,
) -> Result<ssh_key::PrivateKey> {
let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let private_key = ssh_key::PrivateKey::from_openssh(&raw)
.with_context(|| format!("parse OpenSSH identity {}", path.display()))?;
let private_key = if private_key.is_encrypted() {
let passphrase = passphrase
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity {} is encrypted", path.display()))?;
private_key
.decrypt(passphrase)
.with_context(|| format!("decrypt OpenSSH identity {}", path.display()))?
} else {
private_key
};
let algorithm = private_key.public_key().algorithm().as_str().to_string();
anyhow::ensure!(
is_supported_user_key_algorithm(&algorithm),
"OpenSSH identity {} has unsupported native key algorithm {algorithm}",
path.display()
);
Ok(private_key)
}
impl AuthorizedKey {
fn ensure_native_allowed(
&self,
@@ -565,7 +772,7 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
fields.len() >= 2,
"authorized_keys:{line_number}: expected key fields"
);
let (options, key_type_index) = if fields[0].starts_with("ssh-") {
let (options, key_type_index) = if is_supported_user_key_algorithm(&fields[0]) {
(AuthorizedKeyOptions::default(), 0)
} else {
(parse_authorized_key_options(&fields[0])?, 1)
@@ -576,15 +783,20 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
let key_blob = fields
.get(key_type_index + 1)
.with_context(|| format!("authorized_keys:{line_number}: missing key blob"))?;
anyhow::ensure!(
algorithm == "ssh-ed25519",
"authorized_keys:{line_number}: unsupported key type {algorithm}"
);
let decoded = STANDARD
.decode(key_blob)
.with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?;
let key = parse_ssh_ed25519_public_blob(&decoded)
.with_context(|| format!("authorized_keys:{line_number}: parse ssh-ed25519 key"))?;
anyhow::ensure!(
is_supported_user_key_algorithm(algorithm),
"authorized_keys:{line_number}: unsupported key type {algorithm}"
);
validate_supported_public_key_blob(algorithm, &decoded)
.with_context(|| format!("authorized_keys:{line_number}: parse {algorithm} key"))?;
let key = if algorithm == "ssh-ed25519" {
parse_ssh_ed25519_public_blob(&decoded)?.to_vec()
} else {
decoded
};
let comment = if fields.len() > key_type_index + 2 {
Some(fields[key_type_index + 2..].join(" "))
} else {
@@ -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> {
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)
}
fn parse_ssh_rsa_public_blob(blob: &[u8]) -> Result<(&[u8], &[u8])> {
let mut cursor = blob;
let key_type = read_ssh_string(&mut cursor)?;
anyhow::ensure!(key_type == b"ssh-rsa", "key blob type mismatch");
let e = read_ssh_mpint(&mut cursor)?;
let n = read_ssh_mpint(&mut cursor)?;
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-rsa key blob");
anyhow::ensure!(!e.is_empty(), "ssh-rsa exponent is empty");
anyhow::ensure!(!n.is_empty(), "ssh-rsa modulus is empty");
Ok((e, n))
}
pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
let mut out = Vec::new();
write_ssh_string(&mut out, b"ssh-ed25519");
@@ -772,6 +1041,12 @@ pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
out
}
fn read_ssh_mpint<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
let raw = read_ssh_string(cursor)?;
let raw = raw.strip_prefix(&[0]).unwrap_or(raw);
Ok(raw)
}
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length");
let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize;
@@ -1004,16 +1279,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(),
trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::Trusted
);
assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(),
trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::AlreadyTrusted
);
assert!(trust_host(&path, "palav", &second, "ssh", false).is_err());
assert!(trust_host(&path, "homelab", &second, "ssh", false).is_err());
assert_eq!(
trust_host(&path, "palav", &second, "ssh", true).unwrap(),
trust_host(&path, "homelab", &second, "ssh", true).unwrap(),
TrustResult::Trusted
);
}
@@ -1026,16 +1301,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(),
verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Unknown
);
trust_host(&path, "palav", &first, "ssh", false).unwrap();
trust_host(&path, "homelab", &first, "ssh", false).unwrap();
assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(),
verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Trusted
);
assert!(matches!(
verify_known_host(&path, "palav", &second).unwrap(),
verify_known_host(&path, "homelab", &second).unwrap(),
KnownHostStatus::Mismatch { .. }
));
}
@@ -1084,6 +1359,45 @@ mod tests {
assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err());
}
#[test]
fn native_user_auth_accepts_ecdsa_p256_private_key() {
let mut rng = rand::rngs::OsRng;
let private = ssh_key::PrivateKey::random(
&mut rng,
ssh_key::Algorithm::Ecdsa {
curve: ssh_key::EcdsaCurve::NistP256,
},
)
.unwrap();
let host_signing = SigningKey::from_bytes(&[10u8; 32]);
let client = test_client_hello();
let mut server = test_server_hello(&host_signing);
sign_server_hello(&host_signing, &client, &mut server).unwrap();
let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap();
let authorized =
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
assert_eq!(auth.public_key_algorithm, "ecdsa-sha2-nistp256");
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
}
#[test]
fn native_user_auth_accepts_rsa_sha2_private_key() {
let mut rng = rand::rngs::OsRng;
let private =
ssh_key::PrivateKey::random(&mut rng, ssh_key::Algorithm::Rsa { hash: None }).unwrap();
let host_signing = SigningKey::from_bytes(&[11u8; 32]);
let client = test_client_hello();
let mut server = test_server_hello(&host_signing);
sign_server_hello(&host_signing, &client, &mut server).unwrap();
let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap();
let authorized =
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
assert_eq!(auth.public_key_algorithm, "rsa-sha2-512");
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
}
#[test]
fn native_user_auth_rejects_tampered_transcript() {
let host_signing = SigningKey::from_bytes(&[3u8; 32]);
@@ -1311,8 +1625,8 @@ mod tests {
protocol_version: NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(),
requested_user: "palav".to_string(),
requested_host: "homelab".to_string(),
requested_user: "alice".to_string(),
requested_session: "term".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
+76 -1
View File
@@ -3,14 +3,49 @@ use crate::crypto;
use crate::native::{EnvVar, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// Generate a name for an implicit, ephemeral terminal session — the kind
/// created by `dosh host` with no `--session`. Single source of truth shared by
/// the client (which generates it) and the server (which recognizes it via
/// [`is_implicit_session_name`] to decide a session is NOT worth persisting).
pub fn generate_implicit_session_name() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("term-{millis}-{}", std::process::id())
}
/// Whether `name` is a client-generated implicit session name
/// (`term-<digits>-<digits>`). Such sessions are ephemeral: the user can never
/// reattach by name, so the server must not persist them across restarts.
/// Explicitly-named (`--session work`) and prewarmed sessions are not implicit.
pub fn is_implicit_session_name(name: &str) -> bool {
let Some(rest) = name.strip_prefix("term-") else {
return false;
};
let mut parts = rest.split('-');
match (parts.next(), parts.next(), parts.next()) {
(Some(millis), Some(pid), None) => {
!millis.is_empty()
&& millis.bytes().all(|b| b.is_ascii_digit())
&& !pid.is_empty()
&& pid.bytes().all(|b| b.is_ascii_digit())
}
_ => false,
}
}
pub const MAGIC: &[u8; 4] = b"DOSH";
// v4: added reliable stream offsets/acks to `StreamData` and
// `StreamWindowAdjust`.
// v3: added `ForwardingKind::Agent` (SSH-agent forwarding). The new variant rides
// inside `NativeUserAuth.requested_forwardings`, so a pre-agent peer would fail to
// deserialize it; bumping the wire version makes such a peer answer with a clear
// version-mismatch reject instead. Existing variants' bincode discriminants are
// unchanged, so the bump is purely a compatibility gate.
pub const VERSION: u8 = 3;
pub const VERSION: u8 = 4;
pub const HEADER_LEN: usize = 58;
/// 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)]
pub struct StreamData {
pub stream_id: u64,
pub offset: u64,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamWindowAdjust {
pub stream_id: u64,
pub received_offset: u64,
pub bytes: u32,
}
@@ -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"));
}
}
+79 -3
View File
@@ -2,11 +2,17 @@ use anyhow::{Context, Result};
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use std::io::{Read, Write};
use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread;
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
@@ -148,16 +154,65 @@ pub fn spawn_pty_session(
})
}
/// Whether this host has a terminfo entry for `term`, searching the standard
/// ncurses directories. Both the legacy single-letter (`x/xterm`) and the
/// hashed (`78/xterm`) subdirectory layouts are checked.
fn terminfo_available(term: &str) -> bool {
let Some(first) = term.chars().next() else {
return false;
};
let letter = first.to_string();
let hashed = format!("{:x}", first as u32);
let mut dirs: Vec<PathBuf> = Vec::new();
if let Ok(t) = std::env::var("TERMINFO")
&& !t.is_empty()
{
dirs.push(PathBuf::from(t));
}
if let Some(home) = dirs::home_dir() {
dirs.push(home.join(".terminfo"));
}
if let Ok(td) = std::env::var("TERMINFO_DIRS") {
for d in td.split(':').filter(|d| !d.is_empty()) {
dirs.push(PathBuf::from(d));
}
}
for d in [
"/etc/terminfo",
"/lib/terminfo",
"/usr/share/terminfo",
"/usr/lib/terminfo",
"/usr/share/lib/terminfo",
] {
dirs.push(PathBuf::from(d));
}
dirs.iter()
.any(|dir| dir.join(&letter).join(term).exists() || dir.join(&hashed).join(term).exists())
}
/// Build the [`CommandBuilder`] for a dosh shell, identically for the in-process
/// `spawn_pty_session` and the out-of-process holder, so a persistent session's
/// environment matches a non-persistent one.
pub fn build_shell_command(shell: &str, env: &[(String, String)]) -> CommandBuilder {
let mut cmd = CommandBuilder::new(shell);
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
for (name, value) in env {
cmd.env(name, value);
}
// The client's TERM is propagated, but a server that lacks that terminal's
// terminfo entry (e.g. xterm-ghostty, xterm-kitty) breaks ncurses apps like
// tmux/vim with "missing or unsuitable terminal". Keep the requested TERM
// only when this host actually has its terminfo; otherwise fall back to a
// universally available entry so remote apps always work.
let term = match env
.iter()
.find(|(n, _)| n == "TERM")
.map(|(_, v)| v.as_str())
{
Some(requested) if terminfo_available(requested) => requested.to_string(),
_ => "xterm-256color".to_string(),
};
cmd.env("TERM", term);
cmd.env("SHELL", shell);
if let Some(home) = dirs::home_dir() {
cmd.env("HOME", home.as_os_str());
@@ -215,12 +270,14 @@ fn spawn_reader_thread(
break;
}
Ok(n) => {
for chunk in buf[..n].chunks(PTY_OUTPUT_CHUNK_BYTES) {
let _ = tx.send(PtyOutput {
session: reader_session.clone(),
bytes: buf[..n].to_vec(),
bytes: chunk.to_vec(),
exited: false,
});
}
}
Err(_) => {
let _ = tx.send(PtyOutput {
session: reader_session.clone(),
@@ -235,3 +292,22 @@ fn spawn_reader_thread(
.context("spawn pty reader")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminfo_available_detects_known_and_unknown() {
// A near-universal entry should be present on any host with ncurses.
assert!(terminfo_available("xterm") || terminfo_available("xterm-256color"));
// Bogus / empty names must report missing so we fall back.
assert!(!terminfo_available("definitely-not-a-real-terminal-xyz"));
assert!(!terminfo_available(""));
}
#[test]
fn pty_output_chunk_size_stays_mtu_safe() {
assert!(PTY_OUTPUT_CHUNK_BYTES <= 1200);
}
}
+61 -23
View File
@@ -1,6 +1,6 @@
use crate::native::{
ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth,
parse_ssh_ed25519_public_blob, user_auth_transcript,
is_supported_user_key_algorithm, parse_ssh_ed25519_public_blob, user_auth_transcript,
};
use anyhow::{Context, Result, anyhow, bail};
use std::io::{Read, Write};
@@ -12,12 +12,16 @@ const SSH2_AGENTC_REQUEST_IDENTITIES: u8 = 11;
const SSH2_AGENT_IDENTITIES_ANSWER: u8 = 12;
const SSH2_AGENTC_SIGN_REQUEST: u8 = 13;
const SSH2_AGENT_SIGN_RESPONSE: u8 = 14;
const SSH_AGENT_RSA_SHA2_512: u32 = 4;
const MAX_AGENT_PACKET: usize = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentIdentity {
pub key_blob: Vec<u8>,
pub public_key: [u8; 32],
pub public_key_algorithm: String,
pub public_key: Vec<u8>,
pub sign_algorithm: String,
pub sign_flags: u32,
pub comment: String,
}
@@ -38,13 +42,13 @@ pub fn sign_user_auth_with_agent_at(
) -> Result<NativeUserAuth> {
let mut agent = UnixStream::connect(socket_path.as_ref())
.with_context(|| format!("connect ssh-agent {}", socket_path.as_ref().display()))?;
let identities = request_ed25519_identities(&mut agent)?;
let identities = request_supported_identities(&mut agent)?;
let identity = identities
.first()
.ok_or_else(|| anyhow!("ssh-agent has no ssh-ed25519 identities"))?;
.ok_or_else(|| anyhow!("ssh-agent has no supported identities"))?;
let mut auth = NativeUserAuth {
public_key_algorithm: "ssh-ed25519".to_string(),
public_key: identity.public_key.to_vec(),
public_key_algorithm: identity.sign_algorithm.clone(),
public_key: identity.public_key.clone(),
signature: Vec::new(),
requested_forwardings,
};
@@ -54,7 +58,7 @@ pub fn sign_user_auth_with_agent_at(
Ok(auth)
}
fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> {
fn request_supported_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> {
write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?;
let payload = read_agent_packet(agent)?;
let mut cursor = payload.as_slice();
@@ -72,18 +76,49 @@ fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentit
for _ in 0..count {
let key_blob = read_ssh_string(&mut cursor)?.to_vec();
let comment = String::from_utf8_lossy(read_ssh_string(&mut cursor)?).to_string();
if let Ok(public_key) = parse_ssh_ed25519_public_blob(&key_blob) {
identities.push(AgentIdentity {
key_blob,
public_key,
comment,
});
if let Some(identity) = supported_identity(key_blob, comment)? {
identities.push(identity);
}
}
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities");
Ok(identities)
}
fn supported_identity(key_blob: Vec<u8>, comment: String) -> Result<Option<AgentIdentity>> {
let algorithm = key_blob_algorithm(&key_blob)?;
if !is_supported_user_key_algorithm(&algorithm) {
return Ok(None);
}
let identity = match algorithm.as_str() {
"ssh-ed25519" => AgentIdentity {
public_key_algorithm: algorithm.clone(),
public_key: parse_ssh_ed25519_public_blob(&key_blob)?.to_vec(),
sign_algorithm: "ssh-ed25519".to_string(),
sign_flags: 0,
key_blob,
comment,
},
"ecdsa-sha2-nistp256" => AgentIdentity {
public_key_algorithm: algorithm.clone(),
public_key: key_blob.clone(),
sign_algorithm: algorithm,
sign_flags: 0,
key_blob,
comment,
},
"ssh-rsa" => AgentIdentity {
public_key_algorithm: algorithm,
public_key: key_blob.clone(),
sign_algorithm: "rsa-sha2-512".to_string(),
sign_flags: SSH_AGENT_RSA_SHA2_512,
key_blob,
comment,
},
_ => return Ok(None),
};
Ok(Some(identity))
}
fn sign_with_agent(
agent: &mut UnixStream,
identity: &AgentIdentity,
@@ -93,7 +128,7 @@ fn sign_with_agent(
request.push(SSH2_AGENTC_SIGN_REQUEST);
write_ssh_string(&mut request, &identity.key_blob);
write_ssh_string(&mut request, transcript);
request.extend_from_slice(&0u32.to_be_bytes());
request.extend_from_slice(&identity.sign_flags.to_be_bytes());
write_agent_packet(agent, &request)?;
let payload = read_agent_packet(agent)?;
@@ -114,20 +149,17 @@ fn sign_with_agent(
let mut signature_cursor = signature_blob;
let algorithm = read_ssh_string(&mut signature_cursor)?;
let algorithm = String::from_utf8_lossy(algorithm);
anyhow::ensure!(
algorithm == b"ssh-ed25519",
"ssh-agent returned unsupported signature algorithm {}",
String::from_utf8_lossy(algorithm)
algorithm == identity.sign_algorithm,
"ssh-agent returned signature algorithm {algorithm}, expected {}",
identity.sign_algorithm
);
let signature = read_ssh_string(&mut signature_cursor)?;
anyhow::ensure!(
signature_cursor.is_empty(),
"trailing data in ssh-agent signature blob"
);
anyhow::ensure!(
signature.len() == 64,
"ssh-agent Ed25519 signature was not 64 bytes"
);
Ok(signature.to_vec())
}
@@ -182,6 +214,12 @@ fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(value);
}
fn key_blob_algorithm(blob: &[u8]) -> Result<String> {
let mut cursor = blob;
let algorithm = read_ssh_string(&mut cursor)?;
Ok(String::from_utf8_lossy(algorithm).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -253,8 +291,8 @@ mod tests {
protocol_version: crate::native::NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(),
requested_user: "palav".to_string(),
requested_host: "homelab".to_string(),
requested_user: "alice".to_string(),
requested_session: "term".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
+497 -3
View File
@@ -20,7 +20,8 @@
//! tests are reproducible and fast.
use std::fs;
use std::net::{SocketAddr, UdpSocket};
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, UdpSocket};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -28,13 +29,18 @@ use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread;
use std::time::{Duration, Instant};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use dosh::auth::{BootstrapResponse, build_bootstrap, load_or_create_server_secret};
use dosh::config::load_server_config;
use dosh::crypto;
use dosh::native::{self, ForwardingKind, ForwardingRequest, NativeAuthOk, NativeClientHello};
use dosh::protocol::{
self, AttachOk, CLIENT_TO_SERVER, Frame, Header, Input, PacketKind, ResumeRequest,
SERVER_TO_CLIENT,
self, AttachOk, AttachReject, CLIENT_TO_SERVER, Frame, Header, Input, NativeAuthOkBody,
NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind, ResumeRequest,
SERVER_TO_CLIENT, StreamData, StreamOpen, StreamOpenOk, StreamWindowAdjust,
};
use ed25519_dalek::{SigningKey, VerifyingKey};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
@@ -79,6 +85,23 @@ persist_sessions = false
config
}
fn authorize_ed25519_key(dir: &tempfile::TempDir, signing_key: &SigningKey) {
let verifying = VerifyingKey::from(signing_key);
let mut blob = Vec::new();
write_ssh_string(&mut blob, b"ssh-ed25519");
write_ssh_string(&mut blob, verifying.as_bytes());
fs::write(
dir.path().join("authorized_keys"),
format!("ssh-ed25519 {} hostile-test\n", STANDARD.encode(blob)),
)
.unwrap();
}
fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
out.extend_from_slice(value);
}
fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child {
let server = env!("CARGO_BIN_EXE_dosh-server");
let child = Command::new(server)
@@ -402,6 +425,122 @@ fn attach_through_relay(
}
}
struct NativeAttached {
socket: UdpSocket,
ok: NativeAuthOk,
}
fn native_attach_through_relay(
relay: &Relay,
signing_key: &SigningKey,
requested_forwardings: Vec<ForwardingRequest>,
) -> NativeAttached {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(500)))
.unwrap();
let (client_secret, client_public) = native::generate_native_ephemeral();
let hello = NativeClientHello {
protocol_version: native::NATIVE_PROTOCOL_VERSION,
client_random: crypto::random_32(),
client_ephemeral_public: client_public,
requested_host: "127.0.0.1".to_string(),
requested_user: std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()),
requested_session: "default".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: native::supported_user_key_algorithms(),
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: Vec::new(),
};
let packet = protocol::encode_plain(
PacketKind::NativeClientHello,
[0u8; 16],
1,
0,
&protocol::to_body(&NativeClientHelloBody {
hello: hello.clone(),
})
.unwrap(),
)
.unwrap();
let hello_deadline = Instant::now() + Duration::from_secs(5);
let server_hello = loop {
socket.send_to(&packet, relay.front_addr()).unwrap();
let mut buf = [0u8; 65535];
match socket.recv_from(&mut buf) {
Ok((n, _)) => {
let packet = protocol::decode(&buf[..n]).unwrap();
match packet.header.kind {
PacketKind::NativeServerHello => {
let body: NativeServerHelloBody =
protocol::from_body(&packet.body).unwrap();
native::verify_server_hello(&hello, &body.hello).unwrap();
break body.hello;
}
PacketKind::AttachReject => {
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
panic!("native hello rejected: {}", reject.reason);
}
kind => panic!("unexpected native hello response: {kind:?}"),
}
}
Err(_) if Instant::now() < hello_deadline => {}
Err(err) => panic!("native server hello timed out: {err}"),
}
};
let session_key = native::derive_native_session_key(
&client_secret,
server_hello.server_ephemeral_public,
&hello,
&server_hello,
)
.unwrap();
let auth =
native::sign_user_auth(signing_key, &hello, &server_hello, requested_forwardings).unwrap();
let mut pending_id = [0u8; 16];
pending_id.copy_from_slice(&server_hello.auth_challenge[..16]);
let auth_packet = protocol::encode_encrypted(
PacketKind::NativeUserAuth,
pending_id,
2,
1,
&session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&NativeUserAuthBody { auth }).unwrap(),
)
.unwrap();
let auth_deadline = Instant::now() + Duration::from_secs(5);
let ok = loop {
socket.send_to(&auth_packet, relay.front_addr()).unwrap();
let mut buf = [0u8; 65535];
match socket.recv_from(&mut buf) {
Ok((n, _)) => {
let packet = protocol::decode(&buf[..n]).unwrap();
match packet.header.kind {
PacketKind::NativeAuthOk => {
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT)
.unwrap();
let body: NativeAuthOkBody = protocol::from_body(&plain).unwrap();
break body.ok;
}
PacketKind::AttachReject => {
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
panic!("native auth rejected: {}", reject.reason);
}
kind => panic!("unexpected native auth response: {kind:?}"),
}
}
Err(_) if Instant::now() < auth_deadline => {}
Err(err) => panic!("native auth ok timed out: {err}"),
}
};
NativeAttached { socket, ok }
}
fn send_input(
socket: &UdpSocket,
relay: &Relay,
@@ -431,6 +570,152 @@ fn send_raw(socket: &UdpSocket, relay: &Relay, packet: &[u8]) {
socket.send_to(packet, relay.front_addr()).unwrap();
}
fn send_stream_packet(
socket: &UdpSocket,
relay: &Relay,
ok: &NativeAuthOk,
kind: PacketKind,
seq: u64,
ack: u64,
body: Vec<u8>,
) {
let packet = protocol::encode_encrypted(
kind,
ok.client_id,
seq,
ack,
&ok.session_key,
CLIENT_TO_SERVER,
&body,
)
.unwrap();
socket.send_to(&packet, relay.front_addr()).unwrap();
}
fn recv_stream_packet<T: serde::de::DeserializeOwned>(
socket: &UdpSocket,
key: &[u8; 32],
kind: PacketKind,
) -> Option<(Header, T)> {
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).ok()?;
let packet = protocol::decode(&buf[..n]).ok()?;
if packet.header.kind != kind {
return None;
}
let plain = protocol::decrypt_body(&packet, key, SERVER_TO_CLIENT).ok()?;
let body = protocol::from_body(&plain).ok()?;
Some((packet.header, body))
}
fn wait_for_stream_open_ok(
socket: &UdpSocket,
key: &[u8; 32],
stream_id: u64,
millis: u64,
) -> Option<Header> {
let deadline = Instant::now() + Duration::from_millis(millis);
while Instant::now() < deadline {
if let Some((header, ok)) =
recv_stream_packet::<StreamOpenOk>(socket, key, PacketKind::StreamOpenOk)
{
if ok.stream_id == stream_id {
return Some(header);
}
}
}
None
}
fn wait_for_stream_data(
socket: &UdpSocket,
key: &[u8; 32],
stream_id: u64,
millis: u64,
) -> Option<(Header, StreamData)> {
let deadline = Instant::now() + Duration::from_millis(millis);
while Instant::now() < deadline {
if let Some((header, data)) =
recv_stream_packet::<StreamData>(socket, key, PacketKind::StreamData)
{
if data.stream_id == stream_id {
return Some((header, data));
}
}
}
None
}
fn start_tcp_collector() -> (u16, Receiver<Vec<u8>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, rx) = channel();
thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
stream
.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut buf = [0u8; 1024];
while Instant::now() < deadline {
match stream.read(&mut buf) {
Ok(0) => return,
Ok(n) => {
let _ = tx.send(buf[..n].to_vec());
}
Err(ref err)
if err.kind() == std::io::ErrorKind::WouldBlock
|| err.kind() == std::io::ErrorKind::TimedOut => {}
Err(_) => return,
}
}
});
(port, rx)
}
fn start_tcp_echo() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
stream
.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut buf = [0u8; 1024];
while Instant::now() < deadline {
match stream.read(&mut buf) {
Ok(0) => return,
Ok(n) => {
let _ = stream.write_all(&buf[..n]);
}
Err(ref err)
if err.kind() == std::io::ErrorKind::WouldBlock
|| err.kind() == std::io::ErrorKind::TimedOut => {}
Err(_) => return,
}
}
});
port
}
fn collect_tcp(rx: &Receiver<Vec<u8>>, millis: u64) -> Vec<u8> {
let deadline = Instant::now() + Duration::from_millis(millis);
let mut out = Vec::new();
while Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(50)) {
Ok(chunk) => out.extend_from_slice(&chunk),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
out
}
fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(Header, Frame)> {
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).ok()?;
@@ -603,6 +888,215 @@ fn duplicated_and_replayed_input_is_applied_at_most_once() {
);
}
#[test]
fn forwarded_stream_data_survives_reorder_and_rejects_replay() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let signing_key = SigningKey::from_bytes(&[42u8; 32]);
authorize_ed25519_key(&dir, &signing_key);
let (target_port, tcp_rx) = start_tcp_collector();
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0xF0E0D0u64);
let attached = native_attach_through_relay(
&relay,
&signing_key,
vec![ForwardingRequest {
kind: ForwardingKind::Local,
bind_host: Some("127.0.0.1".to_string()),
listen_port: 0,
target_host: Some("127.0.0.1".to_string()),
target_port: Some(target_port),
}],
);
let stream_id = 44;
let open = StreamOpen {
stream_id,
target_host: "127.0.0.1".to_string(),
target_port,
};
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamOpen,
3,
attached.ok.initial_seq,
protocol::to_body(&open).unwrap(),
);
let open_ok =
wait_for_stream_open_ok(&attached.socket, &attached.ok.session_key, stream_id, 3000)
.expect("stream did not open");
// Swap the first two StreamData packets in flight. The server's replay
// window must accept both out-of-order packets and write both to the TCP
// target exactly once.
relay.arm_reorder();
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamData,
4,
open_ok.seq,
protocol::to_body(&StreamData {
stream_id,
offset: 0,
bytes: b"A".to_vec(),
})
.unwrap(),
);
relay.arm_reorder();
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamData,
5,
open_ok.seq,
protocol::to_body(&StreamData {
stream_id,
offset: 1,
bytes: b"B".to_vec(),
})
.unwrap(),
);
let mut seen = collect_tcp(&tcp_rx, 1500);
assert!(
seen.starts_with(b"AB"),
"reordered stream bytes were not delivered once in-order to TCP target: {:?}",
String::from_utf8_lossy(&seen)
);
// Now send one encrypted StreamData packet repeatedly, with relay-level
// duplication enabled too. This is a true replay: same sequence, same nonce,
// same ciphertext. It must be applied to the TCP target at most once.
let replayed = protocol::encode_encrypted(
PacketKind::StreamData,
attached.ok.client_id,
6,
open_ok.seq,
&attached.ok.session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&StreamData {
stream_id,
offset: 2,
bytes: b"X".to_vec(),
})
.unwrap(),
)
.unwrap();
relay.set_dup(100);
for _ in 0..12 {
send_raw(&attached.socket, &relay, &replayed);
thread::sleep(Duration::from_millis(25));
}
relay.set_dup(0);
seen.extend(collect_tcp(&tcp_rx, 1500));
let replay_count = seen.iter().filter(|byte| **byte == b'X').count();
drop(relay);
let _ = server.kill();
let _ = server.wait();
assert!(
replay_count <= 1,
"replayed/duplicated StreamData was applied {replay_count} times: {:?}",
String::from_utf8_lossy(&seen)
);
}
#[test]
fn forwarded_stream_data_recovers_after_server_to_client_loss() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let signing_key = SigningKey::from_bytes(&[43u8; 32]);
authorize_ed25519_key(&dir, &signing_key);
let target_port = start_tcp_echo();
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0xA11CEu64);
let attached = native_attach_through_relay(
&relay,
&signing_key,
vec![ForwardingRequest {
kind: ForwardingKind::Local,
bind_host: Some("127.0.0.1".to_string()),
listen_port: 0,
target_host: Some("127.0.0.1".to_string()),
target_port: Some(target_port),
}],
);
let stream_id = 55;
let open = StreamOpen {
stream_id,
target_host: "127.0.0.1".to_string(),
target_port,
};
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamOpen,
3,
attached.ok.initial_seq,
protocol::to_body(&open).unwrap(),
);
let open_ok =
wait_for_stream_open_ok(&attached.socket, &attached.ok.session_key, stream_id, 3000)
.expect("stream did not open");
// Drop the first server->client echo and at least one retransmit tick. Once
// the link clears, the unacked stream chunk must be re-encrypted with a new
// packet sequence and delivered at the same stream offset.
relay.set_drop_s2c(100);
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamData,
4,
open_ok.seq,
protocol::to_body(&StreamData {
stream_id,
offset: 0,
bytes: b"PING".to_vec(),
})
.unwrap(),
);
thread::sleep(Duration::from_millis(300));
relay.set_drop_s2c(0);
let (_header, data) =
wait_for_stream_data(&attached.socket, &attached.ok.session_key, stream_id, 3000)
.expect("lost server->client stream data was not retransmitted");
assert_eq!(data.offset, 0);
assert_eq!(data.bytes, b"PING");
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamWindowAdjust,
5,
open_ok.seq,
protocol::to_body(&StreamWindowAdjust {
stream_id,
received_offset: 4,
bytes: 4,
})
.unwrap(),
);
drop(relay);
let _ = server.kill();
let _ = server.wait();
}
#[test]
fn stale_packets_after_resume_are_ignored_not_fatal() {
let dir = tempfile::tempdir().unwrap();
+447 -1
View File
@@ -4,6 +4,7 @@ use std::net::{TcpListener, TcpStream, UdpSocket};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
@@ -234,6 +235,37 @@ fn start_echo_server() -> u16 {
port
}
fn start_slow_sink_server() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 8192];
loop {
match stream.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(_) => thread::sleep(Duration::from_millis(20)),
}
}
}
});
port
}
fn connect_with_retry_no_child(port: u16, timeout: Duration) -> TcpStream {
let deadline = std::time::Instant::now() + timeout;
loop {
match TcpStream::connect(("127.0.0.1", port)) {
Ok(stream) => return stream,
Err(err) if std::time::Instant::now() < deadline => {
let _ = err;
thread::sleep(Duration::from_millis(50));
}
Err(err) => panic!("connect to local forward 127.0.0.1:{port}: {err}"),
}
}
}
fn write_native_client_auth(dir: &tempfile::TempDir, config_path: &std::path::Path) {
write_native_client_auth_with_options(dir, config_path, "");
}
@@ -414,6 +446,15 @@ fn direct_attach(
config: &std::path::Path,
port: u16,
mode: &str,
) -> (std::net::UdpSocket, dosh::auth::BootstrapResponse, AttachOk) {
direct_attach_session(config, port, mode, "default")
}
fn direct_attach_session(
config: &std::path::Path,
port: u16,
mode: &str,
session: &str,
) -> (std::net::UdpSocket, dosh::auth::BootstrapResponse, AttachOk) {
let config = load_server_config(Some(config.to_path_buf())).unwrap();
let secret = load_or_create_server_secret(&config).unwrap();
@@ -421,7 +462,7 @@ fn direct_attach(
&config,
&secret,
"tester".to_string(),
"default".to_string(),
session.to_string(),
mode.to_string(),
(80, 24),
crypto::random_12(),
@@ -782,6 +823,106 @@ fn native_local_forward_background_smoke() {
assert_eq!(&buf[..n], b"dosh-background-forward-ping");
}
#[test]
fn native_local_forward_bulk_load_does_not_delay_interactive_terminal() {
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let sink_port = start_slow_sink_server();
let local_port = free_tcp_port();
let mut server = start_server(&dir, &config);
let pty = NativePtySystem::default();
let pair = pty
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let mut reader = pair.master.try_clone_reader().unwrap();
let mut writer = pair.master.take_writer().unwrap();
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let mut cmd = CommandBuilder::new(client_bin);
let forward_arg = format!("{local_port}:127.0.0.1:{sink_port}");
let dosh_port = port.to_string();
cmd.args([
"--auth",
"native",
"--no-cache",
"-L",
&forward_arg,
"--session",
"load-session",
"--dosh-host",
"127.0.0.1",
"--dosh-port",
&dosh_port,
"local",
]);
cmd.env("HOME", dir.path().to_string_lossy().to_string());
let mut child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
let mut forward_stream = connect_with_retry_no_child(local_port, Duration::from_secs(5));
let bulk_writer = thread::spawn(move || {
let chunk = vec![b'x'; 8192];
for _ in 0..128 {
if forward_stream.write_all(&chunk).is_err() {
break;
}
}
});
let (seen_tx, seen_rx) = mpsc::channel();
let reader_handle = thread::spawn(move || {
let mut buf = [0u8; 4096];
let mut output = Vec::new();
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
output.extend_from_slice(&buf[..n]);
if output
.windows(b"DOSH_LOAD_TERMINAL_OK".len())
.any(|w| w == b"DOSH_LOAD_TERMINAL_OK")
{
let _ = seen_tx.send(String::from_utf8_lossy(&output).to_string());
break;
}
}
}
}
});
thread::sleep(Duration::from_millis(250));
writer
.write_all(b"printf DOSH_LOAD_TERMINAL_OK\\n\r")
.unwrap();
writer.flush().unwrap();
let seen = seen_rx.recv_timeout(Duration::from_secs(3));
let _ = child.kill();
let _ = child.wait();
drop(writer);
drop(pair.master);
let _ = reader_handle.join();
let _ = bulk_writer.join();
let _ = server.kill();
let _ = server.wait();
assert!(
seen.is_ok(),
"interactive terminal output was delayed behind local-forward bulk load"
);
}
#[test]
fn native_remote_forward_echo_smoke() {
let dir = tempfile::tempdir().unwrap();
@@ -1180,6 +1321,117 @@ fn live_output_forwards_terminal_control_sequences() {
);
}
#[test]
fn tui_control_sequences_survive_transport_verbatim() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let sequences = concat!(
"\x1b[?1049h", // alternate screen on
"\x1b[?2004h", // bracketed paste on
"\x1b[?1000h", // mouse tracking on
"\x1b[?1006h", // SGR mouse encoding on
"\x1b[?25l", // cursor hidden
"\x1b[12;34H", // absolute cursor movement
"DOSH_TUI_VERBATIM",
"\x1b[?25h",
"\x1b[?1006l",
"\x1b[?1000l",
"\x1b[?2004l",
"\x1b[?1049l"
);
let input = Input {
bytes: format!("printf '{sequences}'\n").into_bytes(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let text = collect_frame_text(&socket, &bootstrap.session_key, 2000);
let _ = server.kill();
let _ = server.wait();
for needle in [
"\x1b[?1049h",
"\x1b[?2004h",
"\x1b[?1000h",
"\x1b[?1006h",
"\x1b[?25l",
"\x1b[12;34H",
"DOSH_TUI_VERBATIM",
"\x1b[?25h",
"\x1b[?1006l",
"\x1b[?1000l",
"\x1b[?2004l",
"\x1b[?1049l",
] {
assert!(
text.contains(needle),
"expected TUI control sequence {needle:?} to survive verbatim, got {text:?}"
);
}
}
#[test]
fn large_tui_paint_is_delivered_in_mtu_safe_frames() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let input = Input {
bytes: b"printf '\\033[?1049h'; yes DOSH_TUI_BIG_PAINT | head -n 300 | tr -d '\\n'; printf '\\033[?1049l'\n".to_vec(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let mut text = String::new();
let mut saw_split_frame = false;
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while std::time::Instant::now() < deadline && text.matches("DOSH_TUI_BIG_PAINT").count() < 200 {
if let Some((_header, frame)) = recv_frame(&socket, &bootstrap.session_key) {
if frame.bytes.len() <= 1400 && frame.bytes.len() >= 900 {
saw_split_frame = true;
}
text.push_str(&String::from_utf8_lossy(&frame.bytes));
}
}
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains("\x1b[?1049h") && text.matches("DOSH_TUI_BIG_PAINT").count() >= 200,
"large alternate-screen paint did not survive transport, enter_alt={} markers={} bytes={}",
text.contains("\x1b[?1049h"),
text.matches("DOSH_TUI_BIG_PAINT").count(),
text.len()
);
assert!(
saw_split_frame,
"expected large TUI paint to be split into MTU-safe frames"
);
}
#[test]
fn resume_snapshot_preserves_alternate_screen_mode() {
let dir = tempfile::tempdir().unwrap();
@@ -1293,6 +1545,69 @@ fn resume_updates_udp_endpoint_for_roaming() {
);
}
#[test]
#[ignore = "30-minute launch soak; run with `DOSH_SOAK_SECONDS=1800 cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture`"]
fn sleep_roaming_soak_30m() {
let soak_secs = std::env::var("DOSH_SOAK_SECONDS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(30 * 60);
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config_with_timeout(&dir, port, soak_secs + 60);
let mut server = start_server(&dir, &config);
let (_old_socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
thread::sleep(Duration::from_secs(soak_secs));
let resumed = UdpSocket::bind("127.0.0.1:0").unwrap();
resumed
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
let resume = ResumeRequest {
session: "default".to_string(),
last_rendered_seq: ok.initial_seq,
cols: 80,
rows: 24,
};
send_encrypted(
&resumed,
port,
PacketKind::ResumeRequest,
ok.client_id,
1,
0,
&bootstrap.session_key,
&protocol::to_body(&resume).unwrap(),
);
let (_header, resume_frame) =
recv_frame(&resumed, &bootstrap.session_key).expect("resume response after soak");
assert!(resume_frame.snapshot);
send_encrypted(
&resumed,
port,
PacketKind::Input,
ok.client_id,
2,
resume_frame.output_seq,
&bootstrap.session_key,
&protocol::to_body(&Input {
bytes: b"printf DOSH_SOAK_OK\\n\n".to_vec(),
})
.unwrap(),
);
let text = collect_frame_text(&resumed, &bootstrap.session_key, 3000);
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains("DOSH_SOAK_OK"),
"session did not survive {soak_secs}s idle/roaming soak; output={text:?}"
);
}
#[test]
fn transport_rekey_round_trip_keeps_session_alive() {
use dosh::native::derive_rekey_session_key;
@@ -1983,3 +2298,134 @@ fn session_survives_server_restart_same_shell_and_screen() {
"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:?}"
);
}