Compare commits
8
Commits
2835da76b0
...
d51cc248e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d51cc248e7 | ||
|
|
b44ff8e773 | ||
|
|
7884ea2796 | ||
|
|
774da7371e | ||
|
|
41cdb0f54f | ||
|
|
90e53f4b68 | ||
|
|
d0d6f59cdf | ||
|
|
25d9a6aefa |
+35
-11
@@ -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
+4
@@ -397,6 +397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
+3
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -117,8 +117,16 @@ 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. 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
|
||||
@@ -165,6 +173,29 @@ in Dosh's host config:
|
||||
dosh import-ssh palav 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 palav tm` sends `tm`, and `dosh palav tm dosh` sends `tm 'dosh'`.
|
||||
Remove that table to remove the integration. Hosts can override or opt out:
|
||||
|
||||
```toml
|
||||
# ~/.config/dosh/hosts.toml
|
||||
[palav.extensions.tm]
|
||||
command = "/opt/tm/bin/tm {args}"
|
||||
|
||||
[other-host.extensions.tm]
|
||||
disabled = true
|
||||
```
|
||||
|
||||
## Develop
|
||||
|
||||
Build:
|
||||
@@ -197,9 +228,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 +242,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 +286,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 +327,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 +347,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 +369,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.
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
+76
-42
@@ -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
|
||||
|
||||
@@ -101,6 +101,29 @@ dosh import-ssh palav 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 palav tm` runs `tm` in the remote Dosh shell and
|
||||
`dosh palav tm dosh` runs `tm 'dosh'`. Removing the table removes the integration.
|
||||
Host config can override a global extension, or disable it:
|
||||
|
||||
```toml
|
||||
[palav.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).
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Dosh Release Evidence - 2026-06-20
|
||||
|
||||
Code benchmarked: `b44ff8e Improve release and benchmark tooling`
|
||||
|
||||
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` | `f421ee56aac61892c9e152d2ef018cf95eb35dec2ca1b2b5cc82eeb1fcc12911` |
|
||||
| `dosh-0.1.0-linux-x86_64.tar.gz` | `f421ee56aac61892c9e152d2ef018cf95eb35dec2ca1b2b5cc82eeb1fcc12911` |
|
||||
|
||||
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
@@ -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
@@ -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.
|
||||
|
||||
+132
-13
@@ -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 = $($env:DOSH_USE_PREBUILT -and $env:DOSH_USE_PREBUILT -ne "0"),
|
||||
[string]$BinaryUrl = $env:DOSH_BINARY_URL,
|
||||
[string]$BinaryBase = $env:DOSH_BINARY_BASE,
|
||||
[string]$BinaryName = $env:DOSH_BINARY_NAME,
|
||||
[string]$BinaryVersion = $(if ($env:DOSH_BINARY_VERSION) { $env:DOSH_BINARY_VERSION } else { "latest" }),
|
||||
[switch]$BinaryRequired = $($env:DOSH_BINARY_REQUIRED -and $env:DOSH_BINARY_REQUIRED -ne "0"),
|
||||
[switch]$ForceConfig
|
||||
)
|
||||
|
||||
@@ -17,8 +23,110 @@ function Require-Command($Name) {
|
||||
}
|
||||
}
|
||||
|
||||
Require-Command cargo
|
||||
function Normalize-Arch {
|
||||
switch ($env:PROCESSOR_ARCHITECTURE) {
|
||||
"AMD64" { "x86_64"; break }
|
||||
"ARM64" { "aarch64"; break }
|
||||
default { $env:PROCESSOR_ARCHITECTURE.ToLowerInvariant() }
|
||||
}
|
||||
}
|
||||
|
||||
function Release-ArtifactName {
|
||||
if ($BinaryName) {
|
||||
return $BinaryName
|
||||
}
|
||||
"dosh-windows-$(Normalize-Arch).zip"
|
||||
}
|
||||
|
||||
function Repo-WebBase($Value) {
|
||||
if (-not $Value) {
|
||||
return $null
|
||||
}
|
||||
$base = $Value.TrimEnd("/")
|
||||
if ($base.EndsWith(".git")) {
|
||||
$base = $base.Substring(0, $base.Length - 4)
|
||||
}
|
||||
if ($base -notmatch "^https?://") {
|
||||
return $null
|
||||
}
|
||||
$base
|
||||
}
|
||||
|
||||
function Release-DownloadUrl {
|
||||
if ($BinaryUrl) {
|
||||
return $BinaryUrl
|
||||
}
|
||||
$name = Release-ArtifactName
|
||||
if ($BinaryBase) {
|
||||
return "$($BinaryBase.TrimEnd('/'))/$name"
|
||||
}
|
||||
$web = Repo-WebBase $Repo
|
||||
if (-not $web) {
|
||||
return $null
|
||||
}
|
||||
if ($BinaryVersion -eq "latest") {
|
||||
return "$web/releases/latest/download/$name"
|
||||
}
|
||||
"$web/releases/download/$BinaryVersion/$name"
|
||||
}
|
||||
|
||||
function Verify-ArchiveChecksum($Url, $Archive) {
|
||||
$checksumPath = "$Archive.sha256"
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri "$Url.sha256" -OutFile $checksumPath
|
||||
}
|
||||
catch {
|
||||
Write-Warning "prebuilt checksum unavailable; continuing without sidecar verification"
|
||||
return
|
||||
}
|
||||
$expected = ((Get-Content $checksumPath -Raw).Trim() -split "\s+")[0].ToLowerInvariant()
|
||||
$actual = (Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant()
|
||||
if ($expected -ne $actual) {
|
||||
throw "prebuilt checksum mismatch for $Url"
|
||||
}
|
||||
}
|
||||
|
||||
$bindir = Join-Path $Prefix "bin"
|
||||
$configDir = Join-Path $HOME ".config\dosh"
|
||||
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
|
||||
|
||||
function Install-Prebuilt {
|
||||
$url = Release-DownloadUrl
|
||||
if (-not $url) {
|
||||
return $false
|
||||
}
|
||||
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-bin-" + [guid]::NewGuid())
|
||||
$zip = Join-Path $tmp (Release-ArtifactName)
|
||||
$extract = Join-Path $tmp "extract"
|
||||
try {
|
||||
New-Item -ItemType Directory -Force -Path $tmp, $extract | Out-Null
|
||||
Write-Host "Trying Dosh prebuilt $(Release-ArtifactName)"
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $zip
|
||||
Verify-ArchiveChecksum $url $zip
|
||||
Expand-Archive -Force -Path $zip -DestinationPath $extract
|
||||
foreach ($bin in @("dosh-client.exe", "dosh-bench.exe")) {
|
||||
$found = Get-ChildItem -Path $extract -Recurse -File -Filter $bin | Select-Object -First 1
|
||||
if (-not $found) {
|
||||
throw "prebuilt archive missing $bin"
|
||||
}
|
||||
Copy-Item $found.FullName (Join-Path $bindir $bin) -Force
|
||||
}
|
||||
Copy-Item (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe") -Force
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Warning "prebuilt install failed: $_"
|
||||
return $false
|
||||
}
|
||||
finally {
|
||||
if (Test-Path $tmp) {
|
||||
Remove-Item -Recurse -Force $tmp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Install-FromSource {
|
||||
Require-Command cargo
|
||||
$tmp = $null
|
||||
if (Test-Path "Cargo.toml") {
|
||||
$src = (Get-Location).Path
|
||||
@@ -35,14 +143,30 @@ if (Test-Path "Cargo.toml") {
|
||||
try {
|
||||
Push-Location $src
|
||||
cargo build --release --bin dosh-client --bin dosh-bench
|
||||
|
||||
$bindir = Join-Path $Prefix "bin"
|
||||
$configDir = Join-Path $HOME ".config\dosh"
|
||||
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
|
||||
|
||||
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force
|
||||
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force
|
||||
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
if ($tmp) {
|
||||
Remove-Item -Recurse -Force $tmp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($UsePrebuilt) {
|
||||
$ok = Install-Prebuilt
|
||||
if (-not $ok) {
|
||||
if ($BinaryRequired) {
|
||||
throw "prebuilt install failed and DOSH_BINARY_REQUIRED=1"
|
||||
}
|
||||
Write-Host "Falling back to source build"
|
||||
Install-FromSource
|
||||
}
|
||||
} else {
|
||||
Install-FromSource
|
||||
}
|
||||
|
||||
$clientConfig = Join-Path $configDir "client.toml"
|
||||
if ($ForceConfig -or -not (Test-Path $clientConfig)) {
|
||||
@@ -60,6 +184,8 @@ dosh_port = $Port
|
||||
default_session = "new"
|
||||
reconnect_timeout_secs = 5
|
||||
view_only = false
|
||||
predict = true
|
||||
predict_mode = "experimental"
|
||||
cache_attach_tickets = true
|
||||
credential_cache = "~/.local/share/dosh/credentials"
|
||||
"@ | Set-Content -NoNewline -Encoding utf8 $clientConfig
|
||||
@@ -78,10 +204,3 @@ credential_cache = "~/.local/share/dosh/credentials"
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+201
-14
@@ -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:-0}"
|
||||
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=1
|
||||
Try a release tarball before building from source
|
||||
DOSH_BINARY_URL URL
|
||||
Exact release tarball URL to install
|
||||
DOSH_BINARY_BASE URL
|
||||
Release download base; defaults to REPO/releases/latest/download
|
||||
DOSH_BINARY_NAME NAME
|
||||
Release tarball name; defaults to dosh-OS-ARCH.tar.gz
|
||||
DOSH_BINARY_VERSION TAG
|
||||
Release tag when deriving DOSH_BINARY_BASE; default latest
|
||||
DOSH_BINARY_REQUIRED=1
|
||||
Fail instead of falling back to source when binary install fails
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -112,8 +130,6 @@ ensure_cargo() {
|
||||
. "$HOME/.cargo/env"
|
||||
}
|
||||
|
||||
ensure_cargo
|
||||
|
||||
cleanup() {
|
||||
if [ -n "${tmpdir:-}" ]; then
|
||||
rm -rf "$tmpdir"
|
||||
@@ -121,6 +137,147 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
bindir="$prefix/bin"
|
||||
config_dir="$HOME/.config/dosh"
|
||||
data_dir="$HOME/.local/share/dosh"
|
||||
systemd_user_dir="$HOME/.config/systemd/user"
|
||||
src_dir=""
|
||||
|
||||
mkdir -p "$bindir" "$config_dir" "$data_dir"
|
||||
|
||||
normalize_os() {
|
||||
case "$(uname -s)" in
|
||||
Darwin) printf '%s\n' macos ;;
|
||||
Linux) printf '%s\n' linux ;;
|
||||
FreeBSD) printf '%s\n' freebsd ;;
|
||||
*) uname -s | tr '[:upper:]' '[:lower:]' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
normalize_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) printf '%s\n' x86_64 ;;
|
||||
arm64|aarch64) printf '%s\n' aarch64 ;;
|
||||
armv7l) printf '%s\n' armv7 ;;
|
||||
*) uname -m | tr '[:upper:]' '[:lower:]' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
repo_web_base() {
|
||||
repo_base="$1"
|
||||
case "$repo_base" in
|
||||
http://*|https://*)
|
||||
repo_base="${repo_base%.git}"
|
||||
printf '%s\n' "${repo_base%/}"
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
release_artifact_name() {
|
||||
if [ -n "$binary_name" ]; then
|
||||
printf '%s\n' "$binary_name"
|
||||
else
|
||||
printf 'dosh-%s-%s.tar.gz\n' "$(normalize_os)" "$(normalize_arch)"
|
||||
fi
|
||||
}
|
||||
|
||||
release_download_url() {
|
||||
if [ -n "$binary_url" ]; then
|
||||
printf '%s\n' "$binary_url"
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$binary_base" ]; then
|
||||
printf '%s/%s\n' "${binary_base%/}" "$(release_artifact_name)"
|
||||
return 0
|
||||
fi
|
||||
if [ -z "$repo" ]; then
|
||||
return 1
|
||||
fi
|
||||
web_base="$(repo_web_base "$repo")" || return 1
|
||||
if [ "$binary_version" = "latest" ]; then
|
||||
printf '%s/releases/latest/download/%s\n' "$web_base" "$(release_artifact_name)"
|
||||
else
|
||||
printf '%s/releases/download/%s/%s\n' "$web_base" "$binary_version" "$(release_artifact_name)"
|
||||
fi
|
||||
}
|
||||
|
||||
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 -fL "$download_url" -o "$archive"; then
|
||||
echo "prebuilt unavailable: $download_url" >&2
|
||||
return 1
|
||||
fi
|
||||
verify_archive_checksum "$download_url" "$archive" "$checksum_file" || return 1
|
||||
mkdir -p "$tmpdir/extract"
|
||||
if ! tar -xzf "$archive" -C "$tmpdir/extract"; then
|
||||
echo "prebuilt archive could not be extracted: $download_url" >&2
|
||||
return 1
|
||||
fi
|
||||
install_extracted_binary "$tmpdir/extract" dosh-client "$bindir/dosh-client" || return 1
|
||||
ln -sf dosh-client "$bindir/dosh"
|
||||
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
|
||||
install_extracted_binary "$tmpdir/extract" dosh-server "$bindir/dosh-server" || return 1
|
||||
install_extracted_binary "$tmpdir/extract" dosh-auth "$bindir/dosh-auth" || return 1
|
||||
fi
|
||||
if found_bench="$(find_extracted_binary "$tmpdir/extract" dosh-bench)" && [ -n "$found_bench" ]; then
|
||||
install -m 0755 "$found_bench" "$bindir/dosh-bench"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
install_from_source() {
|
||||
ensure_cargo
|
||||
if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then
|
||||
src_dir="$(pwd)"
|
||||
else
|
||||
@@ -173,13 +330,6 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
bindir="$prefix/bin"
|
||||
config_dir="$HOME/.config/dosh"
|
||||
data_dir="$HOME/.local/share/dosh"
|
||||
systemd_user_dir="$HOME/.config/systemd/user"
|
||||
|
||||
mkdir -p "$bindir" "$config_dir" "$data_dir"
|
||||
|
||||
install -m 0755 target/release/dosh-client "$bindir/dosh-client"
|
||||
ln -sf dosh-client "$bindir/dosh"
|
||||
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
|
||||
@@ -189,6 +339,43 @@ fi
|
||||
if [ -f target/release/dosh-bench ]; then
|
||||
install -m 0755 target/release/dosh-bench "$bindir/dosh-bench"
|
||||
fi
|
||||
}
|
||||
|
||||
write_systemd_service() {
|
||||
if [ -n "$src_dir" ] && [ -f "$src_dir/packaging/systemd/dosh-server.service" ]; then
|
||||
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \
|
||||
"$src_dir/packaging/systemd/dosh-server.service" >"$systemd_user_dir/dosh-server.service"
|
||||
else
|
||||
cat >"$systemd_user_dir/dosh-server.service" <<EOF
|
||||
[Unit]
|
||||
Description=Dosh server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$bindir/dosh-server serve
|
||||
Restart=on-failure
|
||||
RestartSec=1
|
||||
KillMode=process
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$use_prebuilt" != "0" ]; then
|
||||
if ! try_install_prebuilt; then
|
||||
if [ "$binary_required" = "1" ]; then
|
||||
exit 1
|
||||
fi
|
||||
[ "$quiet" = "1" ] && echo "Falling back to source build"
|
||||
install_from_source
|
||||
fi
|
||||
else
|
||||
install_from_source
|
||||
fi
|
||||
|
||||
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
|
||||
server_config="$config_dir/server.toml"
|
||||
@@ -226,8 +413,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 +458,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"
|
||||
@@ -300,13 +487,13 @@ EOF
|
||||
# dosh_host = "palav.dev"
|
||||
# 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
|
||||
|
||||
Executable
+21
@@ -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:-}
|
||||
@@ -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" \
|
||||
|
||||
Executable
+22
@@ -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
|
||||
Executable
+85
@@ -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
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
GITEA_TOKEN=... scripts/upload-gitea-release.sh TAG [artifact...]
|
||||
|
||||
Environment:
|
||||
GITEA_URL Base URL, default https://git.palav.dev
|
||||
GITEA_REPO owner/repo, default Palav/dosh
|
||||
GITEA_TOKEN Token with release write permission
|
||||
GITEA_TITLE Release title, default TAG
|
||||
EOF
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tag="${1:-}"
|
||||
if [ -z "$tag" ]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
shift
|
||||
|
||||
base="${GITEA_URL:-https://git.palav.dev}"
|
||||
repo="${GITEA_REPO:-Palav/dosh}"
|
||||
token="${GITEA_TOKEN:-}"
|
||||
title="${GITEA_TITLE:-$tag}"
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
echo "GITEA_TOKEN is required" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
set -- target/dosh-release/dosh-*
|
||||
fi
|
||||
|
||||
api="$base/api/v1/repos/$repo"
|
||||
auth_header="Authorization: token $token"
|
||||
|
||||
release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag" || true)"
|
||||
release_id="$(printf '%s\n' "$release_json" | sed -n 's/.*"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
|
||||
|
||||
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")"
|
||||
release_id="$(printf '%s\n' "$release_json" | sed -n 's/.*"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
|
||||
fi
|
||||
|
||||
if [ -z "$release_id" ]; then
|
||||
echo "could not determine Gitea release id for $tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for artifact in "$@"; do
|
||||
if [ ! -f "$artifact" ]; then
|
||||
continue
|
||||
fi
|
||||
name="$(basename "$artifact")"
|
||||
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
@@ -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]"));
|
||||
}
|
||||
}
|
||||
|
||||
+480
-46
@@ -10,9 +10,10 @@ use dosh::config::{expand_tilde, load_client_config, load_hosts_config, load_ser
|
||||
use dosh::crypto;
|
||||
use dosh::native::{
|
||||
EnvVar, ForwardingKind, ForwardingRequest, KnownHostStatus, TrustResult,
|
||||
derive_native_session_key, generate_native_ephemeral, host_fingerprint, load_ed25519_identity,
|
||||
load_ed25519_identity_with_passphrase, parse_host_public_key_line, remove_trusted_host,
|
||||
sign_user_auth, trust_host, verify_known_host, verify_server_hello,
|
||||
derive_native_session_key, generate_native_ephemeral, host_fingerprint, load_native_identity,
|
||||
load_native_identity_with_passphrase, parse_host_public_key_line, remove_trusted_host,
|
||||
sign_user_auth_with_private_key, supported_user_key_algorithms, trust_host, verify_known_host,
|
||||
verify_server_hello,
|
||||
};
|
||||
use dosh::protocol::{
|
||||
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
|
||||
@@ -66,11 +67,11 @@ fn terminal_size() -> (u16, u16) {
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "dosh-client")]
|
||||
#[command(name = "dosh-client", version)]
|
||||
struct Args {
|
||||
#[arg()]
|
||||
server: Option<String>,
|
||||
#[arg()]
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
command: Vec<String>,
|
||||
#[arg(long)]
|
||||
session: Option<String>,
|
||||
@@ -178,12 +179,20 @@ enum ForwardEvent {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PendingStreamChunk {
|
||||
offset: u64,
|
||||
bytes: Vec<u8>,
|
||||
last_sent: Instant,
|
||||
attempts: u32,
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
let config = load_client_config(None).unwrap_or_default();
|
||||
if args.server.as_deref() == Some("update") {
|
||||
return run_update(&config);
|
||||
return run_update(&config, update_check_requested(&args.command)?);
|
||||
}
|
||||
if args.server.as_deref() == Some("import-ssh") {
|
||||
return run_import_ssh(&config, &args.command);
|
||||
@@ -203,11 +212,7 @@ async fn main() -> Result<()> {
|
||||
.unwrap_or_default();
|
||||
let raw_server = host.ssh.clone().unwrap_or_else(|| requested_server.clone());
|
||||
let server = ssh_with_user(&raw_server, host.user.as_deref());
|
||||
let startup_command = startup_command(&args.command).or_else(|| {
|
||||
host.default_command
|
||||
.as_deref()
|
||||
.map(startup_command_from_string)
|
||||
});
|
||||
let startup_command = resolved_startup_command(&args.command, &config, &host);
|
||||
let local_forwards = parse_local_forwards(&args.local_forward)?;
|
||||
let remote_forwards = parse_remote_forwards(&args.remote_forward)?;
|
||||
let dynamic_forwards = parse_dynamic_forwards(&args.dynamic_forward)?;
|
||||
@@ -767,6 +772,73 @@ fn startup_command(args: &[String]) -> Option<Vec<u8>> {
|
||||
Some(command.into_bytes())
|
||||
}
|
||||
|
||||
fn resolved_startup_command(
|
||||
args: &[String],
|
||||
config: &dosh::config::ClientConfig,
|
||||
host: &dosh::config::HostConfig,
|
||||
) -> Option<Vec<u8>> {
|
||||
if args.is_empty() {
|
||||
return host
|
||||
.default_command
|
||||
.as_deref()
|
||||
.map(startup_command_from_string);
|
||||
}
|
||||
match resolve_command_extension(args, config, host) {
|
||||
ExtensionResolution::Command(command) => Some(startup_command_from_string(&command)),
|
||||
ExtensionResolution::Disabled => None,
|
||||
ExtensionResolution::NoMatch => startup_command(args),
|
||||
}
|
||||
}
|
||||
|
||||
enum ExtensionResolution {
|
||||
Command(String),
|
||||
Disabled,
|
||||
NoMatch,
|
||||
}
|
||||
|
||||
fn resolve_command_extension(
|
||||
args: &[String],
|
||||
config: &dosh::config::ClientConfig,
|
||||
host: &dosh::config::HostConfig,
|
||||
) -> ExtensionResolution {
|
||||
let Some(name) = args.first() else {
|
||||
return ExtensionResolution::NoMatch;
|
||||
};
|
||||
if let Some(extension) = host.extensions.get(name) {
|
||||
if extension.disabled {
|
||||
return ExtensionResolution::Disabled;
|
||||
}
|
||||
if let Some(command) = extension.command.as_deref() {
|
||||
return ExtensionResolution::Command(expand_command_extension(command, &args[1..]));
|
||||
}
|
||||
}
|
||||
if let Some(extension) = config.extensions.get(name) {
|
||||
if extension.disabled {
|
||||
return ExtensionResolution::Disabled;
|
||||
}
|
||||
if let Some(command) = extension.command.as_deref() {
|
||||
return ExtensionResolution::Command(expand_command_extension(command, &args[1..]));
|
||||
}
|
||||
}
|
||||
ExtensionResolution::NoMatch
|
||||
}
|
||||
|
||||
fn expand_command_extension(template: &str, args: &[String]) -> String {
|
||||
let quoted_args = args
|
||||
.iter()
|
||||
.map(|arg| shell_word(arg))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if template.contains("{args}") {
|
||||
return template.replace("{args}", "ed_args);
|
||||
}
|
||||
if args.is_empty() {
|
||||
template.to_string()
|
||||
} else {
|
||||
format!("{template} {quoted_args}")
|
||||
}
|
||||
}
|
||||
|
||||
fn startup_command_from_string(command: &str) -> Vec<u8> {
|
||||
let mut command = command.to_string();
|
||||
command.push('\n');
|
||||
@@ -1007,19 +1079,81 @@ fn run_sessions_command(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_update(config: &dosh::config::ClientConfig) -> Result<()> {
|
||||
fn update_check_requested(command: &[String]) -> Result<bool> {
|
||||
match command {
|
||||
[] => Ok(false),
|
||||
[flag] if flag == "--check" || flag == "-n" => Ok(true),
|
||||
_ => Err(anyhow!("usage: dosh update [--check]")),
|
||||
}
|
||||
}
|
||||
|
||||
fn release_artifact_name() -> &'static str {
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("macos", "aarch64") => "dosh-macos-aarch64.tar.gz",
|
||||
("macos", "x86_64") => "dosh-macos-x86_64.tar.gz",
|
||||
("linux", "aarch64") => "dosh-linux-aarch64.tar.gz",
|
||||
("linux", "x86_64") => "dosh-linux-x86_64.tar.gz",
|
||||
("windows", "x86_64") => "dosh-windows-x86_64.zip",
|
||||
("windows", "aarch64") => "dosh-windows-aarch64.zip",
|
||||
_ => "dosh-unknown.tar.gz",
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_release_download_url(repo: &str, artifact: &str) -> Option<String> {
|
||||
let web = repo
|
||||
.strip_suffix(".git")
|
||||
.unwrap_or(repo)
|
||||
.trim_end_matches('/');
|
||||
if !web.starts_with("http://") && !web.starts_with("https://") {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{web}/releases/latest/download/{artifact}"))
|
||||
}
|
||||
|
||||
fn url_reachable(url: &str) -> Result<bool> {
|
||||
let status = Command::new("curl")
|
||||
.arg("-fsIL")
|
||||
.arg(url)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.with_context(|| format!("check {url}"))?;
|
||||
Ok(status.success())
|
||||
}
|
||||
|
||||
fn run_update(config: &dosh::config::ClientConfig, check_only: bool) -> Result<()> {
|
||||
let repo = config
|
||||
.update_repo
|
||||
.clone()
|
||||
.unwrap_or_else(|| "https://git.palav.dev/Palav/dosh.git".to_string());
|
||||
let raw_base = raw_base_from_repo(&repo);
|
||||
let installer = format!("{raw_base}/raw/branch/main/install.sh");
|
||||
let artifact = release_artifact_name();
|
||||
let binary_url = latest_release_download_url(&repo, artifact);
|
||||
if check_only {
|
||||
println!("dosh {}", env!("CARGO_PKG_VERSION"));
|
||||
println!("repo: {repo}");
|
||||
println!("installer: {installer}");
|
||||
if let Some(binary_url) = &binary_url {
|
||||
let status = if url_reachable(binary_url)? {
|
||||
"available"
|
||||
} else {
|
||||
"missing"
|
||||
};
|
||||
println!("prebuilt: {status} ({artifact})");
|
||||
println!("prebuilt_url: {binary_url}");
|
||||
} else {
|
||||
println!("prebuilt: unavailable for non-HTTP repo");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let update_cache = dirs::cache_dir()
|
||||
.unwrap_or_else(|| expand_tilde("~/.cache"))
|
||||
.join("dosh")
|
||||
.join("source");
|
||||
let mut script = format!(
|
||||
"curl -fsSL {} | DOSH_REPO={} DOSH_PORT={} DOSH_UPDATE_CACHE={} DOSH_UPDATE_QUIET=1 sh -s -- client",
|
||||
"curl -fsSL {} | DOSH_REPO={} DOSH_PORT={} DOSH_UPDATE_CACHE={} DOSH_UPDATE_QUIET=1 DOSH_USE_PREBUILT=1 sh -s -- client",
|
||||
shell_word(&installer),
|
||||
shell_word(&repo),
|
||||
config.update_port.unwrap_or(config.dosh_port),
|
||||
@@ -1080,7 +1214,7 @@ fn run_import_ssh(config: &dosh::config::ClientConfig, aliases: &[String]) -> Re
|
||||
{
|
||||
raw.push_str(&format!("ssh_port = {port}\n"));
|
||||
}
|
||||
raw.push_str("predict = false\n");
|
||||
raw.push_str("predict = true\n");
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
@@ -1486,7 +1620,7 @@ async fn try_native_auth(
|
||||
requested_mode: mode.to_string(),
|
||||
terminal_size: (cols, rows),
|
||||
supported_aead: vec!["chacha20poly1305".to_string()],
|
||||
supported_user_key_algorithms: vec!["ssh-ed25519".to_string()],
|
||||
supported_user_key_algorithms: supported_user_key_algorithms(),
|
||||
cached_host_key_fingerprint: None,
|
||||
attach_ticket_envelope: None,
|
||||
requested_env,
|
||||
@@ -1642,7 +1776,7 @@ async fn try_native_auth_check(
|
||||
requested_mode: "doctor".to_string(),
|
||||
terminal_size: (80, 24),
|
||||
supported_aead: vec!["chacha20poly1305".to_string()],
|
||||
supported_user_key_algorithms: vec!["ssh-ed25519".to_string()],
|
||||
supported_user_key_algorithms: supported_user_key_algorithms(),
|
||||
cached_host_key_fingerprint: None,
|
||||
attach_ticket_envelope: None,
|
||||
requested_env: Vec::new(),
|
||||
@@ -1766,7 +1900,9 @@ fn sign_native_user_auth(
|
||||
}
|
||||
|
||||
match load_first_native_identity(config, cli_identity, ssh_config) {
|
||||
Ok(identity) => sign_user_auth(&identity, hello, server_hello, requested_forwardings),
|
||||
Ok(identity) => {
|
||||
sign_user_auth_with_private_key(&identity, hello, server_hello, requested_forwardings)
|
||||
}
|
||||
Err(err) => {
|
||||
errors.push(format!("identity files: {err:#}"));
|
||||
Err(anyhow!(
|
||||
@@ -1781,7 +1917,7 @@ fn load_first_native_identity(
|
||||
config: &dosh::config::ClientConfig,
|
||||
cli_identity: Option<&Path>,
|
||||
ssh_config: &SshConfig,
|
||||
) -> Result<ed25519_dalek::SigningKey> {
|
||||
) -> Result<ssh_key::PrivateKey> {
|
||||
load_first_native_identity_with_prompt(
|
||||
config,
|
||||
cli_identity,
|
||||
@@ -1795,7 +1931,7 @@ fn load_first_native_identity_with_prompt<F>(
|
||||
cli_identity: Option<&Path>,
|
||||
ssh_config: &SshConfig,
|
||||
mut prompt: F,
|
||||
) -> Result<ed25519_dalek::SigningKey>
|
||||
) -> Result<ssh_key::PrivateKey>
|
||||
where
|
||||
F: FnMut(&Path) -> Result<String>,
|
||||
{
|
||||
@@ -1817,11 +1953,11 @@ where
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
match load_ed25519_identity(&path) {
|
||||
match load_native_identity(&path) {
|
||||
Ok(identity) => return Ok(identity),
|
||||
Err(err) if err.to_string().contains(" is encrypted") => {
|
||||
match prompt(&path).and_then(|passphrase| {
|
||||
load_ed25519_identity_with_passphrase(&path, Some(&passphrase))
|
||||
load_native_identity_with_passphrase(&path, Some(&passphrase))
|
||||
}) {
|
||||
Ok(identity) => return Ok(identity),
|
||||
Err(err) => errors.push(format!("{}: {err:#}", path.display())),
|
||||
@@ -1831,10 +1967,10 @@ where
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Err(anyhow!("no usable ssh-ed25519 identity found"))
|
||||
Err(anyhow!("no usable native identity found"))
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"no usable ssh-ed25519 identity found: {}",
|
||||
"no usable native identity found: {}",
|
||||
errors.join("; ")
|
||||
))
|
||||
}
|
||||
@@ -2132,6 +2268,7 @@ async fn run_terminal(
|
||||
let mut last_packet_at = Instant::now();
|
||||
let mut status_tick = tokio::time::interval(Duration::from_secs(1));
|
||||
let mut resize_tick = tokio::time::interval(Duration::from_millis(250));
|
||||
let mut stream_retransmit_tick = tokio::time::interval(Duration::from_millis(200));
|
||||
let mut last_size = terminal_size();
|
||||
// React to terminal resize the instant it happens via SIGWINCH (mosh-style),
|
||||
// instead of waiting up to one `resize_tick`. The 250ms poll below stays as a
|
||||
@@ -2171,6 +2308,10 @@ async fn run_terminal(
|
||||
let mut opened_streams: HashSet<u64> = HashSet::new();
|
||||
let mut stream_send_credit: HashMap<u64, usize> = HashMap::new();
|
||||
let mut stream_pending_data: HashMap<u64, VecDeque<Vec<u8>>> = HashMap::new();
|
||||
let mut stream_next_send_offset: HashMap<u64, u64> = HashMap::new();
|
||||
let mut stream_sent_data: HashMap<u64, BTreeMap<u64, PendingStreamChunk>> = HashMap::new();
|
||||
let mut stream_next_recv_offset: HashMap<u64, u64> = HashMap::new();
|
||||
let mut stream_recv_pending: HashMap<u64, BTreeMap<u64, Vec<u8>>> = HashMap::new();
|
||||
if let Some(frame) = first_frame {
|
||||
if !forward_only {
|
||||
render_frame(&frame)?;
|
||||
@@ -2395,6 +2536,8 @@ async fn run_terminal(
|
||||
agent_writers.insert(open.stream_id, writer);
|
||||
opened_streams.insert(open.stream_id);
|
||||
stream_send_credit.insert(open.stream_id, STREAM_INITIAL_WINDOW);
|
||||
stream_next_send_offset.entry(open.stream_id).or_insert(0);
|
||||
stream_next_recv_offset.entry(open.stream_id).or_insert(0);
|
||||
send_stream_open_ok(&socket, addr, &cred, &mut send_seq, open.stream_id).await?;
|
||||
let forward_tx = remote_forward_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -2437,6 +2580,8 @@ async fn run_terminal(
|
||||
stream_writers.insert(open.stream_id, writer);
|
||||
opened_streams.insert(open.stream_id);
|
||||
stream_send_credit.insert(open.stream_id, STREAM_INITIAL_WINDOW);
|
||||
stream_next_send_offset.entry(open.stream_id).or_insert(0);
|
||||
stream_next_recv_offset.entry(open.stream_id).or_insert(0);
|
||||
send_stream_open_ok(&socket, addr, &cred, &mut send_seq, open.stream_id).await?;
|
||||
let forward_tx = remote_forward_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -2487,6 +2632,8 @@ async fn run_terminal(
|
||||
last_packet_at = Instant::now();
|
||||
opened_streams.insert(ok.stream_id);
|
||||
stream_send_credit.entry(ok.stream_id).or_insert(STREAM_INITIAL_WINDOW);
|
||||
stream_next_send_offset.entry(ok.stream_id).or_insert(0);
|
||||
stream_next_recv_offset.entry(ok.stream_id).or_insert(0);
|
||||
if pending_socks_replies.remove(&ok.stream_id)
|
||||
&& let Some(writer) = stream_writers.get_mut(&ok.stream_id)
|
||||
{
|
||||
@@ -2500,6 +2647,8 @@ async fn run_terminal(
|
||||
ok.stream_id,
|
||||
&mut stream_send_credit,
|
||||
&mut stream_pending_data,
|
||||
&mut stream_next_send_offset,
|
||||
&mut stream_sent_data,
|
||||
).await?;
|
||||
}
|
||||
PacketKind::StreamOpenReject => {
|
||||
@@ -2518,6 +2667,10 @@ async fn run_terminal(
|
||||
opened_streams.remove(&reject.stream_id);
|
||||
stream_send_credit.remove(&reject.stream_id);
|
||||
stream_pending_data.remove(&reject.stream_id);
|
||||
stream_next_send_offset.remove(&reject.stream_id);
|
||||
stream_sent_data.remove(&reject.stream_id);
|
||||
stream_next_recv_offset.remove(&reject.stream_id);
|
||||
stream_recv_pending.remove(&reject.stream_id);
|
||||
stream_writers.remove(&reject.stream_id);
|
||||
}
|
||||
PacketKind::StreamData => {
|
||||
@@ -2528,11 +2681,17 @@ async fn run_terminal(
|
||||
continue;
|
||||
};
|
||||
last_packet_at = Instant::now();
|
||||
let len = data.bytes.len();
|
||||
if let Some(writer) = stream_writers.get_mut(&data.stream_id) {
|
||||
let _ = writer.write_all(&data.bytes).await;
|
||||
} else if let Some(writer) = agent_writers.get_mut(&data.stream_id) {
|
||||
let _ = writer.write_all(&data.bytes).await;
|
||||
let stream_id = data.stream_id;
|
||||
let (writes, consumed, received_offset) =
|
||||
accept_stream_data(&mut stream_next_recv_offset, &mut stream_recv_pending, data);
|
||||
if let Some(writer) = stream_writers.get_mut(&stream_id) {
|
||||
for bytes in &writes {
|
||||
let _ = writer.write_all(bytes).await;
|
||||
}
|
||||
} else if let Some(writer) = agent_writers.get_mut(&stream_id) {
|
||||
for bytes in &writes {
|
||||
let _ = writer.write_all(bytes).await;
|
||||
}
|
||||
}
|
||||
// Always return flow-control credit so a stream whose local
|
||||
// writer is already gone cannot wedge the server's send window.
|
||||
@@ -2541,8 +2700,9 @@ async fn run_terminal(
|
||||
addr,
|
||||
&cred,
|
||||
&mut send_seq,
|
||||
data.stream_id,
|
||||
len,
|
||||
stream_id,
|
||||
consumed,
|
||||
received_offset,
|
||||
).await?;
|
||||
}
|
||||
PacketKind::StreamWindowAdjust => {
|
||||
@@ -2553,7 +2713,12 @@ async fn run_terminal(
|
||||
continue;
|
||||
};
|
||||
last_packet_at = Instant::now();
|
||||
add_stream_credit(&mut stream_send_credit, adjust.stream_id, adjust.bytes);
|
||||
ack_stream_data(
|
||||
&mut stream_sent_data,
|
||||
&mut stream_send_credit,
|
||||
adjust.stream_id,
|
||||
adjust.received_offset,
|
||||
);
|
||||
flush_stream_pending_data(
|
||||
&socket,
|
||||
addr,
|
||||
@@ -2562,6 +2727,8 @@ async fn run_terminal(
|
||||
adjust.stream_id,
|
||||
&mut stream_send_credit,
|
||||
&mut stream_pending_data,
|
||||
&mut stream_next_send_offset,
|
||||
&mut stream_sent_data,
|
||||
).await?;
|
||||
}
|
||||
PacketKind::StreamClose => {
|
||||
@@ -2576,6 +2743,10 @@ async fn run_terminal(
|
||||
opened_streams.remove(&close.stream_id);
|
||||
stream_send_credit.remove(&close.stream_id);
|
||||
stream_pending_data.remove(&close.stream_id);
|
||||
stream_next_send_offset.remove(&close.stream_id);
|
||||
stream_sent_data.remove(&close.stream_id);
|
||||
stream_next_recv_offset.remove(&close.stream_id);
|
||||
stream_recv_pending.remove(&close.stream_id);
|
||||
stream_writers.remove(&close.stream_id);
|
||||
agent_writers.remove(&close.stream_id);
|
||||
}
|
||||
@@ -2587,6 +2758,8 @@ async fn run_terminal(
|
||||
Some(ForwardEvent::Open { stream_id, target_host, target_port, writer, socks5_reply }) => {
|
||||
stream_writers.insert(stream_id, writer);
|
||||
stream_send_credit.insert(stream_id, STREAM_INITIAL_WINDOW);
|
||||
stream_next_send_offset.entry(stream_id).or_insert(0);
|
||||
stream_next_recv_offset.entry(stream_id).or_insert(0);
|
||||
if socks5_reply {
|
||||
pending_socks_replies.insert(stream_id);
|
||||
}
|
||||
@@ -2612,6 +2785,8 @@ async fn run_terminal(
|
||||
&opened_streams,
|
||||
&mut stream_send_credit,
|
||||
&mut stream_pending_data,
|
||||
&mut stream_next_send_offset,
|
||||
&mut stream_sent_data,
|
||||
).await?;
|
||||
}
|
||||
Some(ForwardEvent::Close { stream_id }) => {
|
||||
@@ -2619,6 +2794,10 @@ async fn run_terminal(
|
||||
opened_streams.remove(&stream_id);
|
||||
stream_send_credit.remove(&stream_id);
|
||||
stream_pending_data.remove(&stream_id);
|
||||
stream_next_send_offset.remove(&stream_id);
|
||||
stream_sent_data.remove(&stream_id);
|
||||
stream_next_recv_offset.remove(&stream_id);
|
||||
stream_recv_pending.remove(&stream_id);
|
||||
stream_writers.remove(&stream_id);
|
||||
agent_writers.remove(&stream_id);
|
||||
send_stream_close(&socket, addr, &cred, &mut send_seq, stream_id).await?;
|
||||
@@ -2673,6 +2852,15 @@ async fn run_terminal(
|
||||
disconnect_status.apply(action)?;
|
||||
}
|
||||
}
|
||||
_ = stream_retransmit_tick.tick() => {
|
||||
retransmit_stream_data(
|
||||
&socket,
|
||||
addr,
|
||||
&cred,
|
||||
&mut send_seq,
|
||||
&mut stream_sent_data,
|
||||
).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Leave the terminal clean on exit: erase any lingering status line.
|
||||
@@ -2983,9 +3171,14 @@ async fn send_stream_data(
|
||||
cred: &CachedCredential,
|
||||
send_seq: &mut u64,
|
||||
stream_id: u64,
|
||||
offset: u64,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<()> {
|
||||
let body = protocol::to_body(&StreamData { stream_id, bytes })?;
|
||||
let body = protocol::to_body(&StreamData {
|
||||
stream_id,
|
||||
offset,
|
||||
bytes,
|
||||
})?;
|
||||
send_stream_packet(socket, addr, cred, send_seq, PacketKind::StreamData, &body).await
|
||||
}
|
||||
|
||||
@@ -2999,6 +3192,8 @@ async fn queue_or_send_stream_data(
|
||||
opened_streams: &HashSet<u64>,
|
||||
stream_send_credit: &mut HashMap<u64, usize>,
|
||||
stream_pending_data: &mut HashMap<u64, VecDeque<Vec<u8>>>,
|
||||
stream_next_send_offset: &mut HashMap<u64, u64>,
|
||||
stream_sent_data: &mut HashMap<u64, BTreeMap<u64, PendingStreamChunk>>,
|
||||
) -> Result<()> {
|
||||
if !opened_streams.contains(&stream_id)
|
||||
|| stream_send_credit.get(&stream_id).copied().unwrap_or(0) < bytes.len()
|
||||
@@ -3013,7 +3208,18 @@ async fn queue_or_send_stream_data(
|
||||
return Ok(());
|
||||
}
|
||||
*stream_send_credit.entry(stream_id).or_default() -= bytes.len();
|
||||
send_stream_data(socket, addr, cred, send_seq, stream_id, bytes).await
|
||||
let offset = *stream_next_send_offset.entry(stream_id).or_default();
|
||||
stream_next_send_offset.insert(stream_id, offset.saturating_add(bytes.len() as u64));
|
||||
stream_sent_data.entry(stream_id).or_default().insert(
|
||||
offset,
|
||||
PendingStreamChunk {
|
||||
offset,
|
||||
bytes: bytes.clone(),
|
||||
last_sent: Instant::now(),
|
||||
attempts: 1,
|
||||
},
|
||||
);
|
||||
send_stream_data(socket, addr, cred, send_seq, stream_id, offset, bytes).await
|
||||
}
|
||||
|
||||
async fn flush_stream_pending_data(
|
||||
@@ -3024,6 +3230,8 @@ async fn flush_stream_pending_data(
|
||||
stream_id: u64,
|
||||
stream_send_credit: &mut HashMap<u64, usize>,
|
||||
stream_pending_data: &mut HashMap<u64, VecDeque<Vec<u8>>>,
|
||||
stream_next_send_offset: &mut HashMap<u64, u64>,
|
||||
stream_sent_data: &mut HashMap<u64, BTreeMap<u64, PendingStreamChunk>>,
|
||||
) -> Result<()> {
|
||||
let Some(pending) = stream_pending_data.get_mut(&stream_id) else {
|
||||
return Ok(());
|
||||
@@ -3038,7 +3246,18 @@ async fn flush_stream_pending_data(
|
||||
}
|
||||
let bytes = pending.pop_front().expect("pending front exists");
|
||||
*stream_send_credit.entry(stream_id).or_default() -= bytes.len();
|
||||
send_stream_data(socket, addr, cred, send_seq, stream_id, bytes).await?;
|
||||
let offset = *stream_next_send_offset.entry(stream_id).or_default();
|
||||
stream_next_send_offset.insert(stream_id, offset.saturating_add(bytes.len() as u64));
|
||||
stream_sent_data.entry(stream_id).or_default().insert(
|
||||
offset,
|
||||
PendingStreamChunk {
|
||||
offset,
|
||||
bytes: bytes.clone(),
|
||||
last_sent: Instant::now(),
|
||||
attempts: 1,
|
||||
},
|
||||
);
|
||||
send_stream_data(socket, addr, cred, send_seq, stream_id, offset, bytes).await?;
|
||||
}
|
||||
if pending.is_empty() {
|
||||
stream_pending_data.remove(&stream_id);
|
||||
@@ -3046,9 +3265,103 @@ async fn flush_stream_pending_data(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_stream_credit(stream_send_credit: &mut HashMap<u64, usize>, stream_id: u64, bytes: u32) {
|
||||
async fn retransmit_stream_data(
|
||||
socket: &UdpSocket,
|
||||
addr: SocketAddr,
|
||||
cred: &CachedCredential,
|
||||
send_seq: &mut u64,
|
||||
stream_sent_data: &mut HashMap<u64, BTreeMap<u64, PendingStreamChunk>>,
|
||||
) -> Result<()> {
|
||||
let now = Instant::now();
|
||||
let mut retransmit = Vec::new();
|
||||
for (stream_id, chunks) in stream_sent_data.iter_mut() {
|
||||
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.push((*stream_id, chunk.offset, chunk.bytes.clone()));
|
||||
}
|
||||
}
|
||||
for (stream_id, offset, bytes) in retransmit {
|
||||
send_stream_data(socket, addr, cred, send_seq, stream_id, offset, bytes).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn accept_stream_data(
|
||||
stream_next_recv_offset: &mut HashMap<u64, u64>,
|
||||
stream_recv_pending: &mut HashMap<u64, BTreeMap<u64, Vec<u8>>>,
|
||||
data: StreamData,
|
||||
) -> (Vec<Vec<u8>>, usize, u64) {
|
||||
let stream_id = data.stream_id;
|
||||
let expected = stream_next_recv_offset.entry(stream_id).or_insert(0);
|
||||
if data.offset < *expected {
|
||||
return (Vec::new(), 0, *expected);
|
||||
}
|
||||
if data.offset > *expected {
|
||||
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) = stream_recv_pending
|
||||
.entry(stream_id)
|
||||
.or_default()
|
||||
.remove(expected)
|
||||
{
|
||||
consumed = consumed.saturating_add(bytes.len());
|
||||
*expected = expected.saturating_add(bytes.len() as u64);
|
||||
writes.push(bytes);
|
||||
}
|
||||
if stream_recv_pending
|
||||
.get(&stream_id)
|
||||
.is_some_and(BTreeMap::is_empty)
|
||||
{
|
||||
stream_recv_pending.remove(&stream_id);
|
||||
}
|
||||
(writes, consumed, *expected)
|
||||
}
|
||||
|
||||
fn ack_stream_data(
|
||||
stream_sent_data: &mut HashMap<u64, BTreeMap<u64, PendingStreamChunk>>,
|
||||
stream_send_credit: &mut HashMap<u64, usize>,
|
||||
stream_id: u64,
|
||||
received_offset: u64,
|
||||
) {
|
||||
let Some(sent) = 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() {
|
||||
stream_sent_data.remove(&stream_id);
|
||||
}
|
||||
add_stream_credit(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(
|
||||
@@ -3058,9 +3371,11 @@ async fn send_stream_window_adjust(
|
||||
send_seq: &mut u64,
|
||||
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(
|
||||
@@ -4033,13 +4348,14 @@ const TERMINAL_CLEANUP: &[u8] = concat!(
|
||||
mod tests {
|
||||
use super::{
|
||||
DisconnectStatus, DynamicForward, FrameBuffer, LocalForward, PredictMode, Predictor,
|
||||
RemoteForward, SshConfig, StatusAction, auth_allows,
|
||||
RemoteForward, SshConfig, StatusAction, auth_allows, latest_release_download_url,
|
||||
load_first_native_identity_with_prompt, parse_dynamic_forward, parse_local_forward,
|
||||
parse_remote_forward, parse_ssh_config, raw_contains_host_table, recv_response_until,
|
||||
render_status_clear, render_status_overlay, requested_env, ssh_destination_host,
|
||||
ssh_username, ssh_with_user, startup_command, toml_bare_key_or_quoted,
|
||||
render_status_clear, render_status_overlay, requested_env, resolved_startup_command,
|
||||
ssh_destination_host, ssh_username, ssh_with_user, startup_command,
|
||||
toml_bare_key_or_quoted, update_check_requested,
|
||||
};
|
||||
use dosh::config::{ClientConfig, HostConfig};
|
||||
use dosh::config::{ClientConfig, CommandExtension, HostConfig};
|
||||
use dosh::native::EnvVar;
|
||||
use dosh::protocol::{self, Frame, PacketKind};
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
@@ -4222,8 +4538,8 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
VerifyingKey::from(&loaded).to_bytes(),
|
||||
VerifyingKey::from(&signing).to_bytes()
|
||||
loaded.public_key().key_data().ed25519().unwrap().as_ref(),
|
||||
VerifyingKey::from(&signing).as_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4243,7 +4559,7 @@ mod tests {
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("no usable ssh-ed25519 identity"));
|
||||
assert!(err.to_string().contains("no usable native identity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4263,7 +4579,7 @@ mod tests {
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("no usable ssh-ed25519 identity"));
|
||||
assert!(err.to_string().contains("no usable native identity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4285,8 +4601,8 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
VerifyingKey::from(&loaded).to_bytes(),
|
||||
VerifyingKey::from(&signing).to_bytes()
|
||||
loaded.public_key().key_data().ed25519().unwrap().as_ref(),
|
||||
VerifyingKey::from(&signing).as_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4585,6 +4901,124 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_check_accepts_only_check_flag() {
|
||||
assert!(!update_check_requested(&[]).unwrap());
|
||||
assert!(update_check_requested(&["--check".to_string()]).unwrap());
|
||||
assert!(update_check_requested(&["-n".to_string()]).unwrap());
|
||||
assert!(update_check_requested(&["--wat".to_string()]).is_err());
|
||||
assert!(update_check_requested(&["--check".to_string(), "extra".to_string()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_download_url_uses_latest_release_asset() {
|
||||
assert_eq!(
|
||||
latest_release_download_url(
|
||||
"https://git.palav.dev/Palav/dosh.git",
|
||||
"dosh-linux-x86_64.tar.gz"
|
||||
)
|
||||
.unwrap(),
|
||||
"https://git.palav.dev/Palav/dosh/releases/latest/download/dosh-linux-x86_64.tar.gz"
|
||||
);
|
||||
assert!(
|
||||
latest_release_download_url("git@git.palav.dev:Palav/dosh.git", "artifact").is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_startup_command_expands_global_extension() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.extensions.insert(
|
||||
"tm".to_string(),
|
||||
CommandExtension {
|
||||
command: Some("tm {args}".to_string()),
|
||||
description: Some("server tmux dashboard".to_string()),
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
let host = HostConfig::default();
|
||||
|
||||
assert_eq!(
|
||||
resolved_startup_command(&["tm".into(), "work space".into()], &config, &host),
|
||||
Some(b"tm 'work space'\n".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_startup_command_uses_host_extension_override() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.extensions.insert(
|
||||
"tm".to_string(),
|
||||
CommandExtension {
|
||||
command: Some("tm {args}".to_string()),
|
||||
description: None,
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
let mut host = HostConfig::default();
|
||||
host.extensions.insert(
|
||||
"tm".to_string(),
|
||||
CommandExtension {
|
||||
command: Some("/opt/tm/bin/tm --remote {args}".to_string()),
|
||||
description: None,
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolved_startup_command(&["tm".into(), "dosh".into()], &config, &host),
|
||||
Some(b"/opt/tm/bin/tm --remote 'dosh'\n".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_startup_command_host_can_disable_global_extension() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.extensions.insert(
|
||||
"tm".to_string(),
|
||||
CommandExtension {
|
||||
command: Some("tm {args}".to_string()),
|
||||
description: None,
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
let mut host = HostConfig::default();
|
||||
host.extensions.insert(
|
||||
"tm".to_string(),
|
||||
CommandExtension {
|
||||
command: None,
|
||||
description: None,
|
||||
disabled: true,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolved_startup_command(&["tm".into(), "dosh".into()], &config, &host),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_startup_command_keeps_raw_command_fallback_and_default_command() {
|
||||
let config = ClientConfig::default();
|
||||
let mut host = HostConfig {
|
||||
default_command: Some("tm".to_string()),
|
||||
..HostConfig::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolved_startup_command(&["echo".into(), "ok".into()], &config, &host),
|
||||
Some(b"echo ok\n".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_startup_command(&[], &config, &host),
|
||||
Some(b"tm\n".to_vec())
|
||||
);
|
||||
|
||||
host.default_command = None;
|
||||
assert_eq!(resolved_startup_command(&[], &config, &host), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_local_forward_with_default_bind() {
|
||||
assert_eq!(
|
||||
|
||||
+309
-18
@@ -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,
|
||||
@@ -878,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 {
|
||||
@@ -1113,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,
|
||||
@@ -1258,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,
|
||||
@@ -1377,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,
|
||||
@@ -1743,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 {
|
||||
@@ -1796,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
|
||||
@@ -1811,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(())
|
||||
}
|
||||
@@ -1911,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
|
||||
@@ -1946,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(())
|
||||
}
|
||||
|
||||
@@ -2310,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,
|
||||
@@ -2321,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
|
||||
@@ -2362,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,
|
||||
@@ -2373,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
|
||||
@@ -2384,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(
|
||||
@@ -2395,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(
|
||||
@@ -2423,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 })?;
|
||||
@@ -2640,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;
|
||||
@@ -2653,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
|
||||
@@ -3013,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,
|
||||
@@ -3209,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,
|
||||
@@ -3266,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
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+332
-18
@@ -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;
|
||||
@@ -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]);
|
||||
|
||||
+5
-1
@@ -38,12 +38,14 @@ pub fn is_implicit_session_name(name: &str) -> bool {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -416,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,
|
||||
}
|
||||
|
||||
|
||||
+14
-1
@@ -7,6 +7,12 @@ 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
|
||||
@@ -264,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(),
|
||||
@@ -297,4 +305,9 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+59
-21
@@ -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::*;
|
||||
|
||||
+497
-3
@@ -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
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user