Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48e3de2922 | ||
|
|
c95a913422 | ||
|
|
6292784f3a | ||
|
|
d2fd7ee346 | ||
|
|
acd22b93e8 | ||
|
|
ef712a79ee | ||
|
|
48a59496b2 | ||
|
|
606fad72e3 | ||
|
|
9d0396dbf9 | ||
|
|
f71cd975bc | ||
|
|
4842a129f2 | ||
|
|
60d73df5be | ||
|
|
f9a378a9cd | ||
|
|
fb6cf4e7c2 | ||
|
|
c0d83c9133 | ||
|
|
a4b3e7c89b | ||
|
|
44717e4956 | ||
|
|
149ea07edf | ||
|
|
6f40ff9e25 | ||
|
|
b21c2eed9d | ||
|
|
29d894b43b | ||
|
|
fcc21c7aef | ||
|
|
a029564d80 | ||
|
|
689d2a9575 | ||
|
|
38e70611c5 | ||
|
|
6adcdc01e8 | ||
|
|
742477bf6e |
@@ -48,6 +48,17 @@ jobs:
|
|||||||
if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success'
|
if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success'
|
||||||
run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run."
|
run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run."
|
||||||
|
|
||||||
|
windows-client:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- name: Build Windows client
|
||||||
|
run: cargo build --release --bin dosh-client --bin dosh-bench
|
||||||
|
- name: Package Windows client
|
||||||
|
shell: bash
|
||||||
|
run: sh scripts/package-release.sh
|
||||||
|
|
||||||
package-release:
|
package-release:
|
||||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')
|
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
Generated
+1
-1
@@ -436,7 +436,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dosh"
|
name = "dosh"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "dosh"
|
name = "dosh"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
@@ -38,4 +38,4 @@ opt-level = 2
|
|||||||
codegen-units = 16
|
codegen-units = 16
|
||||||
incremental = true
|
incremental = true
|
||||||
lto = false
|
lto = false
|
||||||
strip = false
|
strip = "debuginfo"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.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
|
.PHONY: build test fmt install package-release package-release-linux package-release-windows publish-release bench-report bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local tui-harness
|
||||||
|
|
||||||
build:
|
build:
|
||||||
cargo build --release
|
cargo build --release
|
||||||
@@ -10,11 +10,20 @@ fmt:
|
|||||||
cargo fmt
|
cargo fmt
|
||||||
|
|
||||||
install:
|
install:
|
||||||
sh packaging/install.sh
|
sh install.sh --from-current
|
||||||
|
|
||||||
package-release:
|
package-release:
|
||||||
sh scripts/package-release.sh
|
sh scripts/package-release.sh
|
||||||
|
|
||||||
|
package-release-linux:
|
||||||
|
DOSH_PACKAGE_OS=linux DOSH_PACKAGE_ARCH=x86_64 sh scripts/package-release.sh
|
||||||
|
|
||||||
|
package-release-windows:
|
||||||
|
DOSH_PACKAGE_OS=windows DOSH_PACKAGE_ARCH=x86_64 DOSH_PACKAGE_TARGET=x86_64-pc-windows-gnu sh scripts/package-release.sh
|
||||||
|
|
||||||
|
publish-release:
|
||||||
|
sh scripts/publish-gitea-release.sh
|
||||||
|
|
||||||
bench-report:
|
bench-report:
|
||||||
sh scripts/bench-report.sh
|
sh scripts/bench-report.sh
|
||||||
|
|
||||||
@@ -42,6 +51,9 @@ fuzz-smoke:
|
|||||||
fuzz-deep:
|
fuzz-deep:
|
||||||
DOSH_FUZZ_SECONDS=$${DOSH_FUZZ_SECONDS:-300} sh scripts/fuzz-run.sh
|
DOSH_FUZZ_SECONDS=$${DOSH_FUZZ_SECONDS:-300} sh scripts/fuzz-run.sh
|
||||||
|
|
||||||
|
tui-harness:
|
||||||
|
sh scripts/tui-harness.sh
|
||||||
|
|
||||||
# 30-minute launch soak by default. Override with DOSH_SOAK_SECONDS=NN.
|
# 30-minute launch soak by default. Override with DOSH_SOAK_SECONDS=NN.
|
||||||
soak-local:
|
soak-local:
|
||||||
DOSH_SOAK_SECONDS=$${DOSH_SOAK_SECONDS:-1800} cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture
|
DOSH_SOAK_SECONDS=$${DOSH_SOAK_SECONDS:-1800} cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture
|
||||||
|
|||||||
@@ -1,403 +1,82 @@
|
|||||||
# dosh - Dormant Shell
|
# Dosh
|
||||||
|
|
||||||
dosh is a low-latency remote terminal designed around fast attach and fast reconnect.
|
Dosh is an encrypted remote terminal for fast reconnecting shells. It is meant
|
||||||
It is mosh-shaped, but not a mosh clone: the server is a resident daemon, terminal
|
to replace Mosh and day-to-day interactive SSH sessions.
|
||||||
sessions stay hot, and repeat connects try encrypted UDP before starting SSH.
|
|
||||||
|
|
||||||
The core target is simple:
|
It runs a `dosh-server` on the remote machine and a `dosh` client locally. The
|
||||||
|
first setup can use SSH. After that, Dosh can attach over encrypted UDP with
|
||||||
|
cached credentials, keep terminal sessions alive, reconnect after network
|
||||||
|
changes, and forward TCP ports.
|
||||||
|
|
||||||
- First secure trust establishment uses SSH.
|
## Support
|
||||||
- Existing sessions attach in one encrypted UDP exchange whenever cached credentials allow it.
|
|
||||||
- Reconnect after sleep, roaming, or network change resumes in one encrypted UDP exchange.
|
|
||||||
- Cold SSH fallback stays competitive with plain `ssh` by doing less after auth.
|
|
||||||
|
|
||||||
## Why not just mosh?
|
- Client: macOS, Linux, Windows
|
||||||
|
- Server: Unix-like systems with PTYs
|
||||||
|
- Windows: client only
|
||||||
|
- UDP port: one configured server port, default `50000`
|
||||||
|
|
||||||
mosh is excellent at roaming and high-latency interactivity. Its startup path still
|
Dosh is not an SCP/SFTP client and does not implement X11 forwarding. Windows
|
||||||
has work dosh can avoid:
|
is client-only.
|
||||||
|
|
||||||
1. SSH connects to the host.
|
|
||||||
2. SSH starts `mosh-server`.
|
|
||||||
3. The client receives connection material over SSH.
|
|
||||||
4. SSH exits and the mosh UDP session begins.
|
|
||||||
|
|
||||||
dosh keeps `dosh-server` running before the client arrives. Named PTY sessions can
|
|
||||||
also be prewarmed, so attaching to `default` does not need to spawn a daemon, create
|
|
||||||
a PTY, or start a shell on the user's critical path.
|
|
||||||
|
|
||||||
This is not an encryption argument against mosh. dosh also encrypts its UDP data
|
|
||||||
channel; the speed difference comes from keeping the server and session hot.
|
|
||||||
|
|
||||||
## Fast Path Order
|
|
||||||
|
|
||||||
The client always tries the cheapest valid path first:
|
|
||||||
|
|
||||||
1. **UDP resume:** existing `ClientId` and session key. No SSH. One encrypted UDP
|
|
||||||
request, one encrypted UDP reply.
|
|
||||||
2. **UDP attach ticket:** cached server-issued attach ticket for the same
|
|
||||||
host/user/session/mode. No SSH. One encrypted UDP request, one encrypted UDP
|
|
||||||
reply.
|
|
||||||
3. **SSH bootstrap:** `ssh -T user@host ~/.local/bin/dosh-auth ...`, then one encrypted UDP
|
|
||||||
attach.
|
|
||||||
4. **New session:** same as attach, but the server must create the PTY/shell unless
|
|
||||||
the session was prewarmed.
|
|
||||||
|
|
||||||
The fastest path is not a custom SSH replacement. SSH remains the first trust root;
|
|
||||||
dosh removes SSH from repeat attaches when the server has already issued valid
|
|
||||||
credentials.
|
|
||||||
|
|
||||||
Attach tickets are implemented because they are the way a fresh client process can
|
|
||||||
skip SSH after a recent successful bootstrap.
|
|
||||||
|
|
||||||
## Connection Speed Contract
|
|
||||||
|
|
||||||
dosh is measured by terminal-ready time: elapsed time from running `dosh host` to the
|
|
||||||
first usable terminal screen.
|
|
||||||
|
|
||||||
- UDP resume: <= one measured UDP RTT + local render time.
|
|
||||||
- UDP attach ticket: <= one measured UDP RTT + local render time.
|
|
||||||
- Warm attach with ControlMaster: <= `ssh host true` over the existing master + one
|
|
||||||
measured UDP RTT.
|
|
||||||
- Cold attach without ControlMaster: <= cold `ssh host` terminal-ready time + one
|
|
||||||
measured UDP RTT.
|
|
||||||
- New session: measured separately because it may need PTY and shell creation.
|
|
||||||
|
|
||||||
Server-side client resume state is kept for a day by default, so a sleeping laptop
|
|
||||||
can resume the same encrypted client association without depending on a still-valid
|
|
||||||
attach ticket. Attach tickets remain the fast path for fresh client processes.
|
|
||||||
|
|
||||||
The client emits timing spans for credential lookup, SSH bootstrap, UDP resume,
|
|
||||||
UDP ticket attach, and terminal-ready time.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```text
|
|
||||||
dosh-server
|
|
||||||
UDP socket on one configurable port
|
|
||||||
session table keyed by name
|
|
||||||
one PTY per named session
|
|
||||||
optional prewarmed sessions, default ["default"]
|
|
||||||
terminal parser/screen state per session
|
|
||||||
client table per session
|
|
||||||
encrypted UDP protocol
|
|
||||||
tiny SSH-invoked dosh-auth helper mode
|
|
||||||
persistent sessions (persist_sessions, currently opt-in): each shell runs in a
|
|
||||||
detached per-session holder process; the server adopts its PTY master fd via
|
|
||||||
SCM_RIGHTS and re-adopts it after a restart, so sessions survive crash/upgrade
|
|
||||||
|
|
||||||
dosh-client
|
|
||||||
terminal raw mode
|
|
||||||
local credential cache
|
|
||||||
UDP resume/attach first
|
|
||||||
SSH bootstrap fallback
|
|
||||||
PTY input/output forwarding
|
|
||||||
reconnect and roaming state machine
|
|
||||||
```
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Default UDP port: `50000`. This is intentionally inside the common forwarded range
|
Server and client on Unix/macOS:
|
||||||
`50000-52000/udp`.
|
|
||||||
|
|
||||||
Install on each Linux server you want to attach to:
|
```sh
|
||||||
|
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh | sh -s -- both --repo https://git.palav.dev/Palav/dosh.git --port 50000
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
|
|
||||||
| DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_PORT=50000 sh -s -- server
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Install the client on macOS:
|
Client only on Unix/macOS:
|
||||||
|
|
||||||
```bash
|
```sh
|
||||||
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
|
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh | sh -s -- client --repo https://git.palav.dev/Palav/dosh.git
|
||||||
| DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_PORT=50000 sh -s -- client
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Update an installed client later:
|
Windows client:
|
||||||
|
|
||||||
```bash
|
|
||||||
dosh update
|
|
||||||
dosh update --check
|
|
||||||
dosh --version
|
|
||||||
```
|
|
||||||
|
|
||||||
`dosh update` first tries a release tarball named for the platform
|
|
||||||
(`dosh-macos-aarch64.tar.gz`, `dosh-linux-x86_64.tar.gz`, etc.) from the latest
|
|
||||||
Gitea/GitHub release. Gitea's concrete tag download route is detected when
|
|
||||||
`/releases/latest/download/...` is not supported. If that asset is not published
|
|
||||||
yet, it falls back to the source build path. If the release also publishes
|
|
||||||
`<artifact>.sha256`, the installer verifies the archive before installing it.
|
|
||||||
|
|
||||||
Install the client on Windows PowerShell:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:DOSH_REPO="https://git.palav.dev/Palav/dosh.git"; $env:DOSH_PORT="50000"; irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
|
irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
|
||||||
```
|
```
|
||||||
|
|
||||||
Attach:
|
## Commands
|
||||||
|
|
||||||
```bash
|
```sh
|
||||||
dosh user@server.example.com
|
dosh setup HOST # import SSH config and trust the Dosh host key
|
||||||
|
dosh HOST # connect
|
||||||
|
dosh HOST COMMAND # connect and run a command
|
||||||
|
dosh update # update Dosh
|
||||||
|
dosh status HOST # show remote tmux sessions and Dosh service status
|
||||||
|
dosh restart HOST # restart dosh-server and show service status
|
||||||
|
dosh doctor HOST # check config and connectivity
|
||||||
|
dosh recover HOST # clear cached attach state and re-check
|
||||||
```
|
```
|
||||||
|
|
||||||
First-time setup for an existing SSH alias:
|
Examples:
|
||||||
|
|
||||||
```bash
|
```sh
|
||||||
dosh setup homelab
|
dosh palav
|
||||||
dosh selftest homelab
|
dosh palav tm
|
||||||
dosh homelab
|
dosh forward palav -L 8080:127.0.0.1:80
|
||||||
|
dosh forward palav -D 1080
|
||||||
|
dosh forward palav -R 2222:127.0.0.1:22
|
||||||
```
|
```
|
||||||
|
|
||||||
`dosh setup <ssh-alias>` imports the OpenSSH alias into
|
Agent forwarding is opt-in with `-A` and must be enabled on the server.
|
||||||
`~/.config/dosh/hosts.toml`, pins the Dosh host key over SSH, runs `dosh doctor`,
|
|
||||||
and prints next commands. `dosh selftest <host>` checks local terminal overlay
|
|
||||||
safety, forwarding syntax, and the remote doctor path.
|
|
||||||
|
|
||||||
Plain `dosh user@server.example.com` opens a fresh terminal session. Use named
|
## Config
|
||||||
sessions when you want to reattach to the same persistent terminal from multiple
|
|
||||||
clients:
|
|
||||||
|
|
||||||
```bash
|
```text
|
||||||
dosh --session work user@server.example.com
|
~/.config/dosh/client.toml
|
||||||
dosh --session logs user@server.example.com
|
~/.config/dosh/hosts.toml
|
||||||
|
~/.config/dosh/server.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
Press `Ctrl-]` to detach the current client while leaving the server session alive.
|
Common client settings:
|
||||||
Typing `exit` in the remote shell closes that Dosh session and returns the client.
|
|
||||||
If UDP packets stop arriving, Dosh keeps the terminal open, sends keepalive pings,
|
|
||||||
and attempts ticket-based reconnect. While the link is silent it shows a single,
|
|
||||||
non-destructive status line on the bottom screen row (`[dosh] last contact Ns ago
|
|
||||||
— reconnecting…`), drawn with save/restore cursor so it never moves the app cursor
|
|
||||||
or corrupts a full-screen TUI, and cleared the instant packets resume. (The old
|
|
||||||
in-band overlay wrote over the active line and could corrupt TUIs; this draws only
|
|
||||||
the last row.) Disable it with `disconnect_status = false` in `client.toml` or
|
|
||||||
`DOSH_DISCONNECT_STATUS=0`.
|
|
||||||
|
|
||||||
If SSH and UDP use different public names, specify the UDP address:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dosh-client --dosh-host public.example.com --dosh-port 50000 user@host
|
|
||||||
```
|
|
||||||
|
|
||||||
Forwarding can use SSH-shaped flags directly or the forward-only wrapper:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dosh -N -L 8080:127.0.0.1:80 homelab
|
|
||||||
dosh forward homelab -L 8080:127.0.0.1:80 -D 1080
|
|
||||||
```
|
|
||||||
|
|
||||||
Dosh already lets OpenSSH handle SSH aliases, users, keys, ports, known-hosts,
|
|
||||||
ProxyJump, and other SSH config during bootstrap. It also runs `ssh -G` to infer the
|
|
||||||
UDP host from an SSH alias when `dosh_host` is not configured. To make that explicit
|
|
||||||
in Dosh's host config:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dosh import-ssh homelab
|
|
||||||
dosh homelab
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional command extensions are just config-side startup shortcuts; Dosh has no
|
|
||||||
compile-time dependency on the tools they run. For example, to make a separately
|
|
||||||
installed server-side `tm` dashboard easy to open:
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
# ~/.config/dosh/client.toml
|
default_session = "new"
|
||||||
[extensions.tm]
|
auth_preference = "native,ssh"
|
||||||
command = "tm {args}"
|
predict = true
|
||||||
description = "Open the server-side tmux dashboard"
|
cache_attach_tickets = true
|
||||||
|
disconnect_status = true
|
||||||
```
|
```
|
||||||
|
|
||||||
Then `dosh homelab tm` sends `tm`, and `dosh homelab tm dosh` sends `tm 'dosh'`.
|
|
||||||
Remove that table to remove the integration. Hosts can override or opt out:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# ~/.config/dosh/hosts.toml
|
|
||||||
[homelab.extensions.tm]
|
|
||||||
command = "/opt/tm/bin/tm {args}"
|
|
||||||
|
|
||||||
[other-host.extensions.tm]
|
|
||||||
disabled = true
|
|
||||||
```
|
|
||||||
|
|
||||||
## Develop
|
|
||||||
|
|
||||||
Build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build
|
|
||||||
```
|
|
||||||
|
|
||||||
Attach locally, using local bootstrap instead of SSH:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
target/debug/dosh-client --local-auth --no-cache local
|
|
||||||
```
|
|
||||||
|
|
||||||
Benchmark local attach:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
target/debug/dosh-bench --local-auth --server local --iterations 5
|
|
||||||
```
|
|
||||||
|
|
||||||
Benchmark a remote host over SSH bootstrap:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
target/release/dosh-bench --server user@host --ssh-port 22 --iterations 3
|
|
||||||
```
|
|
||||||
|
|
||||||
Benchmark the ControlMaster-backed SSH bootstrap path:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
target/release/dosh-bench --server user@host --controlmaster --iterations 3
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the same Docker comparison with Mosh installed in the benchmark container:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make bench-docker-mosh
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
configured.
|
|
||||||
|
|
||||||
Install release binaries and the user systemd service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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
|
|
||||||
likely bottlenecks are network RTT, SSH startup/auth, PTY/shell creation, packet
|
|
||||||
size, and terminal rendering. Change language or runtime only if measurements show
|
|
||||||
they are the bottleneck.
|
|
||||||
|
|
||||||
Hot-path rules:
|
|
||||||
|
|
||||||
- Custom UDP protocol with AEAD for v0; no QUIC handshake on attach.
|
|
||||||
- Fixed binary packet headers for terminal traffic; no JSON on the protocol path.
|
|
||||||
- Preallocated buffers; avoid per-packet heap churn.
|
|
||||||
- Single-thread event loop is preferred for the hot path.
|
|
||||||
- No PTY allocation, shell spawn, shell rc files, or MOTD on attach to an existing
|
|
||||||
session.
|
|
||||||
- Initial snapshot should be sent in the first UDP reply when it fits under the
|
|
||||||
packet budget.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Connection speed as specified above.
|
|
||||||
- UDP roaming and reconnect.
|
|
||||||
- Encrypted terminal data.
|
|
||||||
- Reuse SSH pubkeys for first trust establishment.
|
|
||||||
- Named persistent sessions.
|
|
||||||
- Multiple clients attached to one session.
|
|
||||||
- Optional view-only clients.
|
|
||||||
- Single server port, not one port per session.
|
|
||||||
- Static server and client binaries where practical.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Replacing SSH as the first public-key trust mechanism.
|
|
||||||
- Multi-user access control.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Rust implementation is present in this repository. It contains `dosh-server`,
|
|
||||||
`dosh-client`, `dosh-auth`, `dosh-bench`, shared auth/crypto/protocol modules, a
|
|
||||||
resident PTY server, encrypted UDP bootstrap attach, UDP resume, sealed UDP attach
|
|
||||||
tickets, client ACKs, server retransmit bookkeeping, sliding replay protection,
|
|
||||||
server-side `vt100` screen snapshots/diffs, a hardened user systemd unit, an install
|
|
||||||
script, Docker SSH benchmark gates, CI, and protocol/integration tests.
|
|
||||||
|
|
||||||
### Native v1
|
|
||||||
|
|
||||||
Beyond the SSH-bootstrap core, native v1 (`docs/NATIVE_V1_SPEC.md`) is substantially
|
|
||||||
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, ECDSA
|
|
||||||
P-256, and RSA-SHA2 user auth via ssh-agent or OpenSSH identity files;
|
|
||||||
ChaCha20-Poly1305 transport; and `authorized_keys` policy enforcement (`from=`,
|
|
||||||
`no-port-forwarding`, `permitopen=`; unsupported options fail closed).
|
|
||||||
- **Dosh host-key trust**: pinned `known_hosts`, `dosh trust [--remove|--replace]`,
|
|
||||||
TOFU only when explicitly enabled, and hard-fail on host-key mismatch.
|
|
||||||
- **TCP forwarding**: local `-L`, remote `-R` (loopback bind by default), dynamic
|
|
||||||
SOCKS5 `-D`, forward-only `-N`, and background `-f`, with per-stream flow control so
|
|
||||||
bulk transfers do not lag the terminal.
|
|
||||||
- **Agent forwarding** (opt-in, security-gated): `-A` / `forward_agent` exports your
|
|
||||||
local `SSH_AUTH_SOCK` to a per-session proxy socket on the server (private dir,
|
|
||||||
mode 0600) and tunnels each connection back to your agent over a Dosh stream. Only
|
|
||||||
active on explicit client opt-in **and** server `allow_agent_forwarding = true`; it
|
|
||||||
applies to freshly spawned shells (not an already-running attached/prewarmed
|
|
||||||
session, the same constraint ssh/mosh have).
|
|
||||||
- **Diagnostics**: `dosh doctor host` for config/auth/UDP/forwarding-policy checks.
|
|
||||||
|
|
||||||
Native auth is **opt-in alongside SSH fallback** (`auth_preference = "native,ssh"`):
|
|
||||||
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 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.
|
|
||||||
|
|||||||
@@ -1,604 +0,0 @@
|
|||||||
# dosh - Dormant Shell Spec
|
|
||||||
|
|
||||||
**Status:** Implemented Rust build with local and Docker SSH verification
|
|
||||||
**Default language:** Rust, unless benchmarks prove the stack is the bottleneck
|
|
||||||
**Binaries:** `dosh-server`, `dosh-client`, `dosh-auth`, `dosh-bench`
|
|
||||||
**Helper mode:** `dosh-server auth` or `~/.local/bin/dosh-auth`, invoked by SSH with `-T`
|
|
||||||
**Native v1 plan:** `docs/NATIVE_V1_SPEC.md` (substantially implemented; see its v1 status block)
|
|
||||||
**Threat model:** `docs/THREAT_MODEL.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Product Shape
|
|
||||||
|
|
||||||
dosh is a fast-attach remote terminal. It borrows the useful shape of mosh - UDP
|
|
||||||
transport, roaming, and latency-tolerant terminal rendering - but optimizes a
|
|
||||||
different first-order problem: getting the user back into an already-running terminal
|
|
||||||
as quickly as possible.
|
|
||||||
|
|
||||||
The daemon is resident. Sessions are named. A session owns one PTY and one
|
|
||||||
authoritative terminal screen. Clients attach to that session over encrypted UDP.
|
|
||||||
SSH is used for first trust establishment and as fallback when cached credentials are
|
|
||||||
missing, expired, or rejected. Native Dosh auth is specified separately in
|
|
||||||
`docs/NATIVE_V1_SPEC.md` as the path toward replacing the day-to-day SSH workflow on
|
|
||||||
Dosh-installed servers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Design Goals
|
|
||||||
|
|
||||||
- Connection speed first:
|
|
||||||
- UDP resume: one encrypted UDP request and one encrypted UDP reply.
|
|
||||||
- UDP attach ticket: one encrypted UDP request and one encrypted UDP reply.
|
|
||||||
- Warm SSH bootstrap: existing-ControlMaster SSH command latency plus one UDP RTT.
|
|
||||||
- Cold SSH bootstrap: cold `ssh host` terminal-ready time plus at most one UDP RTT.
|
|
||||||
- No daemon spawn, PTY spawn, shell startup, rc file execution, or MOTD on attach to
|
|
||||||
an existing session.
|
|
||||||
- Prewarm configured sessions at daemon startup, including `default` by default.
|
|
||||||
- Encrypted UDP terminal data.
|
|
||||||
- Single configurable UDP port.
|
|
||||||
- Multiple clients attached to one session.
|
|
||||||
- Optional read-only clients.
|
|
||||||
- Named persistent sessions.
|
|
||||||
- Reuse existing SSH key infrastructure.
|
|
||||||
- Instrument connection timing from the first implementation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Non-Goals
|
|
||||||
|
|
||||||
- Replacing SSH as the first public-key trust mechanism.
|
|
||||||
- Multi-user authorization or ACLs.
|
|
||||||
- Windows support in v0.
|
|
||||||
- Full mosh protocol compatibility.
|
|
||||||
- Perfect local echo/prediction in the first MVP.
|
|
||||||
- QUIC in v0. QUIC can be revisited if measurements show custom UDP is not enough.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Connection Speed Contract
|
|
||||||
|
|
||||||
Measure terminal-ready time: elapsed time from launching `dosh ...` to first usable
|
|
||||||
terminal render.
|
|
||||||
|
|
||||||
Benchmarks must use the same host, network, key, DNS path, and SSH config.
|
|
||||||
|
|
||||||
| Path | Acceptance gate |
|
|
||||||
| --- | --- |
|
|
||||||
| UDP resume | <= one measured UDP RTT + local render time |
|
|
||||||
| UDP attach ticket | <= one measured UDP RTT + local render time |
|
|
||||||
| Warm SSH bootstrap | <= `ssh host true` over existing ControlMaster + one measured UDP RTT |
|
|
||||||
| Cold SSH bootstrap | <= cold `ssh host` terminal-ready time + one measured UDP RTT |
|
|
||||||
| New session | Report separately; PTY/shell creation is expected |
|
|
||||||
|
|
||||||
Required timing evidence:
|
|
||||||
|
|
||||||
- Client stderr spans for credential lookup, SSH bootstrap, UDP resume, UDP ticket
|
|
||||||
attach, and terminal-ready time.
|
|
||||||
- `dosh-bench` samples for SSH `true`, Dosh attach, and optional ControlMaster-backed
|
|
||||||
SSH `true`.
|
|
||||||
- `make bench-docker-ssh` gates both cold SSH bootstrap and ControlMaster-backed SSH
|
|
||||||
bootstrap against containerized OpenSSH plus resident `dosh-server`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Fast Path Order
|
|
||||||
|
|
||||||
The client always tries the cheapest path that is valid for the requested
|
|
||||||
host/user/session/mode:
|
|
||||||
|
|
||||||
1. **UDP resume**
|
|
||||||
- Requires cached `ClientId`, session key, server identity, and unexpired resume
|
|
||||||
metadata.
|
|
||||||
- Sends `ResumeRequest`.
|
|
||||||
- Receives `ResumeOk` with a snapshot or diff.
|
|
||||||
|
|
||||||
2. **UDP attach ticket**
|
|
||||||
- Requires cached attach ticket scoped to server identity, SSH username, session,
|
|
||||||
mode, and expiry.
|
|
||||||
- Sends `TicketAttachRequest`.
|
|
||||||
- Receives `AttachOk` with session key, `ClientId`, and snapshot.
|
|
||||||
|
|
||||||
3. **SSH bootstrap**
|
|
||||||
- Runs `ssh -T user@host ~/.local/bin/dosh-auth ...`.
|
|
||||||
- Receives attach token, attach ticket, session key material, and server metadata.
|
|
||||||
- Sends `BootstrapAttachRequest`.
|
|
||||||
- Receives `AttachOk` with `ClientId` and snapshot.
|
|
||||||
|
|
||||||
4. **New session**
|
|
||||||
- Same as attach, but if the session does not exist and is not prewarmed, the server
|
|
||||||
creates PTY and shell before first paint.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Architecture
|
|
||||||
|
|
||||||
```text
|
|
||||||
dosh-server
|
|
||||||
config loader
|
|
||||||
secret manager
|
|
||||||
UDP socket on one port
|
|
||||||
session table: HashMap<SessionName, Session>
|
|
||||||
optional prewarm of configured sessions
|
|
||||||
auth helper mode for SSH bootstrap
|
|
||||||
metrics/timing logger
|
|
||||||
|
|
||||||
Session
|
|
||||||
PTY master
|
|
||||||
child process/shell
|
|
||||||
terminal parser
|
|
||||||
authoritative screen model
|
|
||||||
scrollback ring
|
|
||||||
monotonic output sequence
|
|
||||||
client table: HashMap<ClientId, ClientState>
|
|
||||||
|
|
||||||
ClientState
|
|
||||||
ClientId
|
|
||||||
UDP endpoint
|
|
||||||
mode: read-write | view-only
|
|
||||||
session key id
|
|
||||||
last acked sequence
|
|
||||||
terminal size
|
|
||||||
last seen timestamp
|
|
||||||
|
|
||||||
dosh-client
|
|
||||||
config loader
|
|
||||||
local credential cache
|
|
||||||
terminal raw mode
|
|
||||||
UDP protocol engine
|
|
||||||
SSH bootstrap runner
|
|
||||||
reconnect state machine
|
|
||||||
renderer
|
|
||||||
```
|
|
||||||
|
|
||||||
Server hot-path ownership should avoid locks on every broadcast. A single event-loop
|
|
||||||
owner per session is preferred. Cross-thread designs are allowed only if benchmarked.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Security Model
|
|
||||||
|
|
||||||
SSH is the first trust root and the recovery/bootstrap fallback. The v0 core relies
|
|
||||||
on SSH for first authentication. Native v1 (`docs/NATIVE_V1_SPEC.md`) adds an opt-in
|
|
||||||
native public-key login over the Dosh UDP transport that is tried before SSH, with
|
|
||||||
its assets, attackers, and residual risks documented in `docs/THREAT_MODEL.md`. SSH
|
|
||||||
remains the explicit fallback and the host-key trust bootstrap path.
|
|
||||||
|
|
||||||
The UDP channel uses AEAD. Recommended default: `ChaCha20-Poly1305` for portable
|
|
||||||
speed, with `AES-GCM` allowed when hardware acceleration is known to be available.
|
|
||||||
The negotiated algorithm is recorded in the bootstrap response.
|
|
||||||
|
|
||||||
All encrypted packets use:
|
|
||||||
|
|
||||||
- Unique nonce per `(session_key_id, direction)`.
|
|
||||||
- Monotonic packet counter.
|
|
||||||
- Associated data containing protocol version, packet type, session name hash,
|
|
||||||
client id when known, and sequence numbers.
|
|
||||||
- Replay rejection using the packet counter window.
|
|
||||||
|
|
||||||
Secrets:
|
|
||||||
|
|
||||||
- `server_secret`: generated on first server start; stored mode `0600`.
|
|
||||||
- `session_key`: random 256-bit key per client attachment, rotated on SSH bootstrap
|
|
||||||
or ticket attach.
|
|
||||||
- `attach_ticket_key`: derived from `server_secret` and rotated by server key epoch.
|
|
||||||
|
|
||||||
No terminal bytes are sent outside AEAD after the attach handshake begins.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. SSH Bootstrap Auth
|
|
||||||
|
|
||||||
Client command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh -T user@host ~/.local/bin/dosh-auth \
|
|
||||||
--protocol 1 \
|
|
||||||
--nonce <client_nonce> \
|
|
||||||
--session <name> \
|
|
||||||
--mode <read-write|view-only> \
|
|
||||||
--size <cols>x<rows> \
|
|
||||||
--client-version <version>
|
|
||||||
```
|
|
||||||
|
|
||||||
`dosh-auth` must:
|
|
||||||
|
|
||||||
- Not allocate a PTY.
|
|
||||||
- Not start a shell.
|
|
||||||
- Not run user shell rc files.
|
|
||||||
- Read server config and secret directly.
|
|
||||||
- Return one compact binary or base64url response on stdout.
|
|
||||||
- Exit immediately.
|
|
||||||
|
|
||||||
Bootstrap response fields:
|
|
||||||
|
|
||||||
- `protocol_version`
|
|
||||||
- `server_id`
|
|
||||||
- `server_key_epoch`
|
|
||||||
- `issued_at`
|
|
||||||
- `expires_at`
|
|
||||||
- `user`
|
|
||||||
- `session`
|
|
||||||
- `mode`
|
|
||||||
- `terminal_size`
|
|
||||||
- `attach_token`
|
|
||||||
- `attach_ticket`
|
|
||||||
- `attach_ticket_psk`
|
|
||||||
- `session_key`
|
|
||||||
- `session_key_id`
|
|
||||||
- `udp_host`
|
|
||||||
- `udp_port`
|
|
||||||
- `aead_algorithm`
|
|
||||||
|
|
||||||
`attach_token = HMAC-SHA256(server_secret, user || session || mode || terminal_size ||
|
|
||||||
client_nonce || issued_at || expires_at || session_key_id)`.
|
|
||||||
|
|
||||||
The token TTL defaults to 30 seconds. Attach tickets default to 1 hour and are
|
|
||||||
server-configurable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Attach Tickets
|
|
||||||
|
|
||||||
Attach tickets let a new client process attach without spawning SSH again.
|
|
||||||
|
|
||||||
Ticket properties:
|
|
||||||
|
|
||||||
- Server-sealed and authenticated by `attach_ticket_key`.
|
|
||||||
- Paired with a client-held random `attach_ticket_psk` returned during SSH bootstrap.
|
|
||||||
- Scoped to server identity, SSH username, session, mode, and key epoch.
|
|
||||||
- Short-lived by default.
|
|
||||||
- Revoked implicitly when server secret/key epoch changes.
|
|
||||||
- Stored client-side with mode `0600`, along with `attach_ticket_psk`.
|
|
||||||
|
|
||||||
Ticket attach does not prove fresh possession of the SSH private key. It proves recent
|
|
||||||
possession of a server-issued credential. This is acceptable for speed, configurable,
|
|
||||||
and can be disabled with `allow_attach_tickets = false`.
|
|
||||||
|
|
||||||
Ticket attach flow:
|
|
||||||
|
|
||||||
1. Client sends `TicketAttachRequest` containing the sealed ticket, client nonce, and
|
|
||||||
requested terminal size.
|
|
||||||
2. The request body is AEAD-encrypted with a key derived from
|
|
||||||
`HKDF(attach_ticket_psk, client_nonce || "ticket-attach-request")`.
|
|
||||||
3. Server opens the sealed ticket, validates scope/expiry/key epoch, derives the same
|
|
||||||
request key, and decrypts the request.
|
|
||||||
4. Server creates a fresh session key and `ClientId`.
|
|
||||||
5. `AttachOk` is AEAD-encrypted with
|
|
||||||
`HKDF(attach_ticket_psk, client_nonce || server_nonce || "ticket-attach-ok")` and
|
|
||||||
carries the fresh session key metadata plus first snapshot.
|
|
||||||
6. Subsequent terminal packets use the fresh session key, not the ticket PSK.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. UDP Protocol
|
|
||||||
|
|
||||||
UDP port defaults to `50000`. One socket handles all sessions and clients.
|
|
||||||
|
|
||||||
Hot-path terminal packets use a fixed binary header:
|
|
||||||
|
|
||||||
```text
|
|
||||||
magic 4 bytes "DOSH"
|
|
||||||
version 1 byte 1
|
|
||||||
type 1 byte
|
|
||||||
flags 2 bytes
|
|
||||||
conn_id 16 bytes zero before client id is assigned
|
|
||||||
seq 8 bytes sender packet sequence
|
|
||||||
ack 8 bytes latest received peer sequence
|
|
||||||
body_len 2 bytes
|
|
||||||
body body_len bytes
|
|
||||||
tag AEAD tag, length depends on algorithm
|
|
||||||
```
|
|
||||||
|
|
||||||
Packet types:
|
|
||||||
|
|
||||||
| Type | Direction | Encrypted | Purpose |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `BootstrapAttachRequest` | client -> server | token-authenticated | Attach after SSH bootstrap |
|
|
||||||
| `TicketAttachRequest` | client -> server | ticket PSK | Attach with cached ticket |
|
|
||||||
| `AttachOk` | server -> client | yes | Assign client id and send first snapshot |
|
|
||||||
| `AttachReject` | server -> client | no terminal bytes | Reject and require SSH |
|
|
||||||
| `ResumeRequest` | client -> server | yes | Resume known client |
|
|
||||||
| `ResumeOk` | server -> client | yes | Endpoint updated; diff/snapshot follows |
|
|
||||||
| `Input` | client -> server | yes | PTY input bytes |
|
|
||||||
| `Resize` | client -> server | yes | Terminal size update |
|
|
||||||
| `Frame` | server -> client | yes | Screen diff or PTY byte frame |
|
|
||||||
| `Ack` | both | yes | Ack without payload |
|
|
||||||
| `Ping` / `Pong` | both | yes | Keepalive and RTT |
|
|
||||||
| `Detach` | client -> server | yes | Remove client, keep session |
|
|
||||||
|
|
||||||
MTU target:
|
|
||||||
|
|
||||||
- Default payload target: 1200 bytes.
|
|
||||||
- Larger datagrams may be enabled only after path MTU discovery.
|
|
||||||
- Snapshots larger than the target are chunked.
|
|
||||||
|
|
||||||
Reliability:
|
|
||||||
|
|
||||||
- Input packets are reliable and ordered per client.
|
|
||||||
- Output frames are sequenced; clients ack rendered sequence.
|
|
||||||
- Server retransmits unacked frames within a bounded window.
|
|
||||||
- If a client falls too far behind, server sends a fresh snapshot instead of replaying
|
|
||||||
unlimited diffs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Sessions and PTYs
|
|
||||||
|
|
||||||
Named sessions:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dosh host # open a fresh generated session
|
|
||||||
dosh --session work host # attach/create named persistent session
|
|
||||||
dosh --session work --view-only host
|
|
||||||
```
|
|
||||||
|
|
||||||
Session behavior:
|
|
||||||
|
|
||||||
- One PTY per session.
|
|
||||||
- Sessions persist until killed or server exits.
|
|
||||||
- If a session has zero clients, the PTY keeps running.
|
|
||||||
- Plain client attaches use generated session names by default.
|
|
||||||
- `--session <name>` intentionally reuses or shares a persistent session.
|
|
||||||
- Configured sessions are prewarmed at daemon startup.
|
|
||||||
- If a requested session does not exist:
|
|
||||||
- `attach` creates it only when `create_on_attach = true`.
|
|
||||||
- `new` always creates it and fails if it already exists.
|
|
||||||
|
|
||||||
Resize policy:
|
|
||||||
|
|
||||||
- One PTY means one size.
|
|
||||||
- Read-write clients may resize.
|
|
||||||
- View-only clients never resize.
|
|
||||||
- Default policy: latest read-write resize wins.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Screen State
|
|
||||||
|
|
||||||
Server maintains the authoritative terminal model:
|
|
||||||
|
|
||||||
- Visible grid.
|
|
||||||
- Cursor position and style.
|
|
||||||
- Alternate screen.
|
|
||||||
- Text attributes and colors.
|
|
||||||
- Scrollback ring.
|
|
||||||
- Monotonic output sequence.
|
|
||||||
|
|
||||||
Initial attach:
|
|
||||||
|
|
||||||
- Server sends a snapshot in the first UDP reply if it fits the packet budget.
|
|
||||||
- If not, server sends a minimal first frame immediately and follows with chunks.
|
|
||||||
|
|
||||||
Diffs:
|
|
||||||
|
|
||||||
- Diffs are computed per client from that client's last acked rendered sequence.
|
|
||||||
- Lagging clients may receive larger diffs or a full snapshot.
|
|
||||||
- Diffs are preferred over raw PTY bytes for reconnect correctness.
|
|
||||||
|
|
||||||
Encoding:
|
|
||||||
|
|
||||||
- Hot terminal frames use fixed binary headers and compact binary payloads.
|
|
||||||
- MessagePack is allowed only for non-hot control/list/config responses.
|
|
||||||
- JSON is not used on the protocol path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Multi-Client Model
|
|
||||||
|
|
||||||
Default mode is shared input. Any read-write client can write to the session PTY.
|
|
||||||
All clients see the same resulting screen.
|
|
||||||
|
|
||||||
View-only mode:
|
|
||||||
|
|
||||||
- Client suppresses local input.
|
|
||||||
- Server rejects `Input` from view-only clients even if a malformed client sends it.
|
|
||||||
- Promotion/demotion requires reconnect.
|
|
||||||
|
|
||||||
Client timeout:
|
|
||||||
|
|
||||||
- Clients are removed after `client_timeout_secs` without ack/ping.
|
|
||||||
- The default is intentionally long enough for laptop sleep/resume, currently one
|
|
||||||
day. Short timeouts make Dosh fall back to attach tickets or SSH after sleep.
|
|
||||||
- Removing a client never kills the session.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Local Echo
|
|
||||||
|
|
||||||
MVP local echo is conservative:
|
|
||||||
|
|
||||||
- Printable keystrokes may be rendered optimistically only when the client is in a
|
|
||||||
simple shell line-editing state.
|
|
||||||
- Server output is always authoritative.
|
|
||||||
- On mismatch, client replaces local prediction with server state.
|
|
||||||
|
|
||||||
Full mosh-style predictive display is a later feature. It must not delay the first
|
|
||||||
implementation of fast attach/resume.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Reconnect and Roaming
|
|
||||||
|
|
||||||
Client detects possible disconnect when no server packet arrives for
|
|
||||||
`reconnect_timeout_secs`.
|
|
||||||
|
|
||||||
Reconnect order:
|
|
||||||
|
|
||||||
1. Send encrypted `ResumeRequest` to the configured host/port.
|
|
||||||
2. If accepted, update endpoint server-side and receive diff/snapshot.
|
|
||||||
3. If rejected, try attach ticket.
|
|
||||||
4. If ticket attach is rejected, run SSH bootstrap.
|
|
||||||
|
|
||||||
The server matches resume by `ClientId` and session key id, not by source address.
|
|
||||||
Successful resume updates the client's UDP endpoint.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Configuration
|
|
||||||
|
|
||||||
Server config: `~/.config/dosh/server.toml`
|
|
||||||
|
|
||||||
```toml
|
|
||||||
port = 50000
|
|
||||||
bind = "0.0.0.0"
|
|
||||||
scrollback = 5000
|
|
||||||
auth_ttl_secs = 30
|
|
||||||
attach_ticket_ttl_secs = 3600
|
|
||||||
allow_attach_tickets = true
|
|
||||||
client_timeout_secs = 86400
|
|
||||||
retransmit_window = 256
|
|
||||||
default_input_mode = "read-write"
|
|
||||||
prewarm_sessions = ["default"]
|
|
||||||
create_on_attach = true
|
|
||||||
shell = "/usr/bin/zsh"
|
|
||||||
sessions_dir = "~/.local/share/dosh/sessions"
|
|
||||||
secret_path = "~/.config/dosh/secret"
|
|
||||||
```
|
|
||||||
|
|
||||||
Client config: `~/.config/dosh/client.toml`
|
|
||||||
|
|
||||||
```toml
|
|
||||||
server = "user@example.com"
|
|
||||||
update_repo = "https://git.palav.dev/Palav/dosh.git"
|
|
||||||
update_port = 50000
|
|
||||||
ssh_auth_command = "~/.local/bin/dosh-auth"
|
|
||||||
# ssh_port = 22
|
|
||||||
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"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. Performance-First Stack
|
|
||||||
|
|
||||||
Default implementation:
|
|
||||||
|
|
||||||
```text
|
|
||||||
# server/client shared
|
|
||||||
bytes
|
|
||||||
chacha20poly1305
|
|
||||||
aes-gcm optional
|
|
||||||
hmac
|
|
||||||
hkdf
|
|
||||||
sha2
|
|
||||||
rand
|
|
||||||
serde
|
|
||||||
toml
|
|
||||||
|
|
||||||
# server
|
|
||||||
mio or tokio # benchmark; single-thread hot path either way
|
|
||||||
rustix # PTY/process/syscall wrappers where possible
|
|
||||||
vt100 # authoritative terminal parser/model
|
|
||||||
|
|
||||||
# client
|
|
||||||
mio or tokio
|
|
||||||
crossterm # raw terminal mode
|
|
||||||
vt100 optional # only if client-side model is needed for prediction
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Benchmark `mio` vs single-thread `tokio` before committing to runtime.
|
|
||||||
- Avoid locks on per-packet session broadcast.
|
|
||||||
- Preallocate packet buffers.
|
|
||||||
- Avoid serde on terminal frames.
|
|
||||||
- Keep `dosh-auth` tiny and static where practical.
|
|
||||||
- Optimize startup path before throughput.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 18. MVP Scope
|
|
||||||
|
|
||||||
MVP must include:
|
|
||||||
|
|
||||||
- `dosh-server` daemon.
|
|
||||||
- `dosh-auth` SSH helper mode.
|
|
||||||
- `dosh-client`.
|
|
||||||
- One UDP port.
|
|
||||||
- Prewarmed `default` session.
|
|
||||||
- SSH bootstrap attach.
|
|
||||||
- Attach-ticket UDP attach.
|
|
||||||
- Encrypted UDP channel.
|
|
||||||
- UDP resume.
|
|
||||||
- Raw terminal input/output.
|
|
||||||
- Basic resize.
|
|
||||||
- Timing instrumentation.
|
|
||||||
|
|
||||||
MVP may defer:
|
|
||||||
|
|
||||||
- Sophisticated predictive local echo.
|
|
||||||
- Per-cell minimal diffs; raw frame plus snapshot fallback is acceptable initially if
|
|
||||||
reconnect correctness is preserved.
|
|
||||||
- Multi-session management commands beyond `attach`, `new`, `list`, and `kill`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 19. Verification Checklist
|
|
||||||
|
|
||||||
A build is not done until these are demonstrated:
|
|
||||||
|
|
||||||
- Cold attach timing compared against cold `ssh host`.
|
|
||||||
- Warm attach timing compared against `ssh host true` with ControlMaster.
|
|
||||||
- UDP resume completes without spawning SSH.
|
|
||||||
- Existing session attach does not spawn PTY or shell.
|
|
||||||
- Prewarmed `default` exists before first client.
|
|
||||||
- Terminal data is encrypted on UDP.
|
|
||||||
- Replay counters reject duplicate encrypted packets.
|
|
||||||
- View-only clients cannot write to PTY.
|
|
||||||
- Multiple clients see the same screen.
|
|
||||||
- Client survives source port/IP change by resume.
|
|
||||||
- Snapshot fallback repairs a lagging client.
|
|
||||||
- `README.md` and `SPEC.md` remain consistent with implemented behavior.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 20. Status
|
|
||||||
|
|
||||||
Spec complete. The Rust implementation is present in this repository.
|
|
||||||
|
|
||||||
Implemented:
|
|
||||||
|
|
||||||
- Rust workspace and binaries: `dosh-server`, `dosh-client`, `dosh-auth`.
|
|
||||||
- Server config and secret creation.
|
|
||||||
- SSH/local bootstrap response generation.
|
|
||||||
- HMAC bootstrap verification.
|
|
||||||
- ChaCha20-Poly1305 encrypted UDP packets.
|
|
||||||
- Fixed DOSH packet header.
|
|
||||||
- Resident server with prewarmed named PTY sessions.
|
|
||||||
- Authoritative server-side `vt100` terminal parser.
|
|
||||||
- Full attach/resume snapshots from terminal screen state.
|
|
||||||
- Per-client screen-state diffs for broadcast frames.
|
|
||||||
- Raw terminal client attach.
|
|
||||||
- UDP resume from cached client credentials.
|
|
||||||
- Sealed attach-ticket UDP attach after server restart or unknown-client resume.
|
|
||||||
- Client ACKs and server-side bounded pending retransmit window.
|
|
||||||
- Sliding replay window for encrypted client packet counters.
|
|
||||||
- View-only server-side input rejection.
|
|
||||||
- Basic resize handling.
|
|
||||||
- Timing output for bootstrap and terminal-ready.
|
|
||||||
- `dosh-bench` benchmark harness for attach timing, SSH key/known-host options, and
|
|
||||||
ControlMaster-backed SSH measurement.
|
|
||||||
- Hardened user systemd unit.
|
|
||||||
- Release install script.
|
|
||||||
- Docker OpenSSH benchmark gate covering cold SSH bootstrap and ControlMaster-backed
|
|
||||||
SSH bootstrap.
|
|
||||||
- GitHub Actions CI for format, tests, release build, and Docker SSH benchmark gate.
|
|
||||||
- Optional GitHub Actions remote benchmark job gated by repository secrets.
|
|
||||||
- Auth/protocol tests.
|
|
||||||
- Integration smoke tests for local attach, ticket attach after server restart, and
|
|
||||||
view-only input rejection.
|
|
||||||
- Integration tests for retransmit, resize, multi-client shared screen, and UDP
|
|
||||||
endpoint roaming.
|
|
||||||
|
|
||||||
Optional deployment evidence:
|
|
||||||
|
|
||||||
- Configure `DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository
|
|
||||||
secrets to run the same benchmark against a real remote host in addition to the
|
|
||||||
Docker OpenSSH gate.
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn git(args: &[&str]) -> Option<String> {
|
||||||
|
let output = Command::new("git").args(args).output().ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = String::from_utf8(output.stdout).ok()?;
|
||||||
|
Some(text.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||||
|
println!("cargo:rerun-if-changed=.git/index");
|
||||||
|
|
||||||
|
let hash = git(&["rev-parse", "--short=12", "HEAD"]).unwrap_or_else(|| "unknown".into());
|
||||||
|
let date = git(&["show", "-s", "--format=%cs", "HEAD"]).unwrap_or_else(|| "unknown".into());
|
||||||
|
let dirty = Command::new("git")
|
||||||
|
.args(["diff", "--quiet", "--ignore-submodules", "--"])
|
||||||
|
.status()
|
||||||
|
.map(|status| if status.success() { "" } else { "+dirty" })
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
println!("cargo:rustc-env=DOSH_GIT_HASH={hash}{dirty}");
|
||||||
|
println!("cargo:rustc-env=DOSH_COMMIT_DATE={date}");
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# Dosh Benchmarks
|
|
||||||
|
|
||||||
This document explains how to run the Dosh benchmarks, what each metric means,
|
|
||||||
and how to read the results honestly. Dosh's headline claim is *insane speed on
|
|
||||||
repeat attach/reconnect* — these benchmarks exist to make that claim measurable
|
|
||||||
and reproducible.
|
|
||||||
|
|
||||||
> **Read first:** `docs/PUBLIC_READINESS.md`. The defensible public claim is fast
|
|
||||||
> attach/reconnect, not full Mosh feature parity, and **not** cold startup. Cite
|
|
||||||
> `dosh_cached_attach_ms` for the core speed claim; cite cold metrics only to show
|
|
||||||
> the fallback path stays competitive with ordinary SSH.
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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
|
|
||||||
binaries, starts its own `dosh-server` bound to `127.0.0.1` on a random free UDP
|
|
||||||
port inside a temporary `HOME`, runs the matrix, prints raw samples plus summary
|
|
||||||
statistics, and tears everything down on exit. It will refuse to bind UDP port
|
|
||||||
`50000` (the default production port).
|
|
||||||
|
|
||||||
## The benchmark binary: `dosh-bench`
|
|
||||||
|
|
||||||
`dosh-bench` spawns the real `dosh-client` once per iteration and times the whole
|
|
||||||
process from launch to terminal-ready-and-detached. That wall-clock cost is the
|
|
||||||
apples-to-apples comparison against `ssh host true`: it is the startup price each
|
|
||||||
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`. Pass `--markdown` for a
|
|
||||||
publishable Markdown table, and `--output path/to/report.md` to write it to disk.
|
|
||||||
|
|
||||||
### Path matrix
|
|
||||||
|
|
||||||
`dosh-bench` can benchmark these Dosh attach paths. Without an explicit path flag
|
|
||||||
it keeps its legacy single-path behavior (driven by `--local-auth` / `--warm-cache`
|
|
||||||
/ `--no-cache`) so existing CI scripts keep working.
|
|
||||||
|
|
||||||
| Flag | Metric | What it measures |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `--cold-native` | `dosh_cold_native_ms` | Native cold auth: full `--auth native --no-cache` handshake to first frame and detach. The cold fallback when no cache exists. |
|
|
||||||
| `--cached-ticket` | `dosh_cached_attach_ms` | Cached attach-ticket fast path. Warms the cache once, then measures repeat attaches using cached UDP credentials/tickets. **This is the core speed claim.** |
|
|
||||||
| `--resume` | `dosh_resume_ms` | UDP resume of a session whose endpoint changes. See the caveat below — only meaningful when a live session is kept open out-of-band. |
|
|
||||||
| `--local-auth` | `dosh_local_attach_ms` | Self-contained local bootstrap; no SSH, no native handshake. Useful as a lower-bound sanity check on loopback. |
|
|
||||||
| *(default, no flag)* | `dosh_attach_ms` | Cold SSH bootstrap plus UDP attach (legacy single-path mode). |
|
|
||||||
|
|
||||||
Existing baseline/comparison metrics are unchanged:
|
|
||||||
|
|
||||||
| Metric | Meaning |
|
|
||||||
| --- | --- |
|
|
||||||
| `ssh_true_ms` | `ssh host true` with the same key/options. |
|
|
||||||
| `mosh_start_true_ms` | `mosh host -- true` bootstrap, run `true`, exit (timed inside a PTY). |
|
|
||||||
|
|
||||||
### Assertions / gates
|
|
||||||
|
|
||||||
| Flag | Gate |
|
|
||||||
| --- | --- |
|
|
||||||
| `--assert-ssh-plus-ms N` | The primary Dosh metric must be ≤ `ssh_true_ms` mean + `N` ms. |
|
|
||||||
| `--assert-mosh-minus-ms N` | The primary Dosh metric must be at least `N` ms faster than `mosh_start_true_ms`. |
|
|
||||||
| `--assert-dosh-max-ms N` | The primary Dosh metric mean must be ≤ `N` ms. |
|
|
||||||
|
|
||||||
The "primary Dosh metric" for assertions is, in priority order:
|
|
||||||
`dosh_cached_attach_ms` → `dosh_resume_ms` → `dosh_cold_native_ms` →
|
|
||||||
`dosh_attach_ms` → `dosh_local_attach_ms`.
|
|
||||||
|
|
||||||
## What each benchmark does
|
|
||||||
|
|
||||||
### `make bench-local` (`scripts/bench-local.sh`)
|
|
||||||
|
|
||||||
Self-contained, no SSH, no Docker. It:
|
|
||||||
|
|
||||||
1. Builds release binaries (`DOSH_BENCH_PROFILE=debug` for the debug profile).
|
|
||||||
2. Picks a free UDP port (never `50000`) and a temp `HOME`.
|
|
||||||
3. Generates a throwaway client identity (`ssh-keygen`), authorizes it on the
|
|
||||||
server, and writes a throwaway server + client config (native auth,
|
|
||||||
trust-on-first-use, localhost UDP).
|
|
||||||
4. Starts `dosh-server serve` bound to `127.0.0.1` on that port.
|
|
||||||
5. Runs `dosh-bench --cold-native --cached-ticket`, then `--local-auth --no-cache`.
|
|
||||||
6. Sanity-checks that native cold auth actually trusted the host (so a silent
|
|
||||||
config-parse fallback can't make the benchmark measure the wrong path).
|
|
||||||
7. Kills the server and removes the temp `HOME` on exit.
|
|
||||||
|
|
||||||
Tunables: `scripts/bench-local.sh [ITERATIONS]` (default 20), `DOSH_BENCH_JSON=1`,
|
|
||||||
`DOSH_BENCH_PROFILE=release|debug`.
|
|
||||||
|
|
||||||
### `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 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
|
|
||||||
|
|
||||||
- **Same machine, same network path.** `bench-local` runs everything on loopback,
|
|
||||||
so it isolates Dosh's own process/handshake/render overhead and removes network
|
|
||||||
RTT from the comparison. Real-world cached attach is approximately *network RTT
|
|
||||||
plus the local overhead these numbers measure*. Publish the machine, OS, CPU,
|
|
||||||
network path, and sample count alongside any numbers.
|
|
||||||
- **Wall-clock of the whole client process.** Each sample includes process spawn,
|
|
||||||
attach, first-frame render, and detach. That is deliberately the same thing
|
|
||||||
`ssh host true` pays, but it means a few ms is fixed process-startup cost, not
|
|
||||||
protocol cost.
|
|
||||||
- **Do not cite cold metrics as the headline.** `dosh_cold_native_ms` and
|
|
||||||
`dosh_attach_ms` still pay first-authentication cost. They exist to show the
|
|
||||||
fallback path stays competitive with ordinary SSH. Cite `dosh_cached_attach_ms`
|
|
||||||
for repeat attach/reconnect, per `docs/PUBLIC_READINESS.md`.
|
|
||||||
- **UDP resume is the roaming path, not cold reconnect.** Resume reconnects a
|
|
||||||
client that is *still attached* when its network endpoint changes (sleep/Wi-Fi
|
|
||||||
switch). The `--attach-only` benchmark model detaches after every iteration,
|
|
||||||
which removes the client on the server, so a fresh-process resume has nothing
|
|
||||||
live to resume and is not meaningful in `bench-local`. The cold fresh-process
|
|
||||||
reconnect fast path is the attach ticket (`dosh_cached_attach_ms`). Roaming
|
|
||||||
resume correctness is covered by the integration test
|
|
||||||
`resume_updates_udp_endpoint_for_roaming` in `tests/integration_smoke.rs`.
|
|
||||||
`dosh-bench --resume` remains available for scenarios that keep a live session
|
|
||||||
open out-of-band (for example remote soak tests).
|
|
||||||
- **These are not identical workloads.** SSH/Mosh/Dosh do different work at
|
|
||||||
startup. The comparison is still useful because it measures the startup tax each
|
|
||||||
tool charges before useful remote work begins.
|
|
||||||
|
|
||||||
## Sample results (loopback, self-contained)
|
|
||||||
|
|
||||||
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
|
|
||||||
- Toolchain: rustc 1.96.0, release profile
|
|
||||||
- Network path: loopback (`127.0.0.1`), throwaway server on a free UDP port
|
|
||||||
- Workload: `dosh-client --attach-only` to first frame and detach
|
|
||||||
|
|
||||||
| Metric | n | min | median | p95 | mean | max |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| `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.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:
|
|
||||||
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.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
|
|
||||||
handshake; cached attach is ~3 ms because it reuses cached credentials and skips
|
|
||||||
the handshake entirely. On loopback there is no network RTT, so this is the local
|
|
||||||
overhead floor. Over a real link, expect cached attach ≈ this floor + one network
|
|
||||||
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
|
|
||||||
```
|
|
||||||
@@ -1,666 +0,0 @@
|
|||||||
# Dosh Native v1 Spec
|
|
||||||
|
|
||||||
> **v1 status (annotation, not part of the spec text below).** Native v1 is
|
|
||||||
> substantially implemented and being stabilized; it is not yet fully verified.
|
|
||||||
> Milestone progress against section 15:
|
|
||||||
>
|
|
||||||
> - 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 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. 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.
|
|
||||||
>
|
|
||||||
> The section 16 verification checklist is **not yet fully green** — see the
|
|
||||||
> item-by-item status table in `docs/PUBLIC_READINESS.md` and the residual-risk list in
|
|
||||||
> `docs/THREAT_MODEL.md`. The section 17 public-claim gate is therefore **not met**.
|
|
||||||
|
|
||||||
Native Dosh is a remote-login protocol for Dosh-installed servers. It is intended to
|
|
||||||
replace the user's day-to-day `ssh host` workflow for terminals and forwarding while
|
|
||||||
keeping SSH as a compatibility and recovery fallback.
|
|
||||||
|
|
||||||
Native Dosh is not an RFC-compatible SSH implementation. It deliberately avoids the
|
|
||||||
full SSH transport/channel protocol and implements the smaller set of behavior Dosh
|
|
||||||
needs: authenticated login, encrypted terminal transport, reconnect/roaming, and TCP
|
|
||||||
forwarding.
|
|
||||||
|
|
||||||
## 1. Product Contract
|
|
||||||
|
|
||||||
User-facing commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dosh host
|
|
||||||
dosh host command...
|
|
||||||
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:
|
|
||||||
|
|
||||||
- Existing SSH keys and `ssh-agent` are reused.
|
|
||||||
- Server-side authorization uses `~/.ssh/authorized_keys`.
|
|
||||||
- Host trust is pinned in a Dosh known-hosts file and can be bootstrapped by SSH.
|
|
||||||
- `~/.ssh/config` host aliases, `HostName`, `User`, `Port`, `IdentityFile`,
|
|
||||||
`ProxyJump`, and `UserKnownHostsFile` are honored where practical.
|
|
||||||
- SSH bootstrap remains available with `--auth=ssh` and is used automatically when
|
|
||||||
native auth is disabled or cannot complete.
|
|
||||||
|
|
||||||
Native v1 is complete only if a normal interactive user can switch their daily
|
|
||||||
workflow from `ssh` to `dosh` without keeping SSH open in another tab for routine
|
|
||||||
tasks.
|
|
||||||
|
|
||||||
Non-goals for v1:
|
|
||||||
|
|
||||||
- RFC-compatible SSH server/client behavior.
|
|
||||||
- Arbitrary SSH subsystems.
|
|
||||||
- X11 forwarding.
|
|
||||||
- SFTP/SCP compatibility.
|
|
||||||
- Multi-user daemon mode with privileged account switching.
|
|
||||||
- Replacing OpenSSH on hosts that do not run `dosh-server`.
|
|
||||||
|
|
||||||
## 2. SSH Workflow Parity Target
|
|
||||||
|
|
||||||
Native v1 targets the SSH workflows interactive users actually use, not the entire
|
|
||||||
OpenSSH feature universe.
|
|
||||||
|
|
||||||
Must work in v1:
|
|
||||||
|
|
||||||
- Interactive shell: `dosh host`.
|
|
||||||
- Remote command: `dosh host command...`.
|
|
||||||
- Fresh terminal by default.
|
|
||||||
- Named persistent terminal: `dosh --session work host`.
|
|
||||||
- Shared/view-only session: `dosh --view-only --session work host`.
|
|
||||||
- Local forwarding: `dosh -L [bind:]listen:target:target_port host`.
|
|
||||||
- Remote forwarding: `dosh -R [bind:]listen:target:target_port host`.
|
|
||||||
- Dynamic forwarding: `dosh -D [bind:]listen host`, if stream flow control is proven;
|
|
||||||
otherwise it is the first v1.1 item and must be called out honestly.
|
|
||||||
- Multiple forwards in one connection.
|
|
||||||
- Forward-only mode: `dosh -N -L ... host`.
|
|
||||||
- Background forwarding: `dosh -f -N -L ... host`, or an equivalent supervised
|
|
||||||
user-service mode.
|
|
||||||
- SSH-agent authentication.
|
|
||||||
- Encrypted OpenSSH private-key authentication with prompt.
|
|
||||||
- `~/.ssh/config` host aliases for `Host`, `HostName`, `User`, `Port`,
|
|
||||||
`IdentityFile`, `ProxyJump`, `UserKnownHostsFile`, and `IdentitiesOnly`.
|
|
||||||
- Dosh host config overrides for user, Dosh-specific UDP host/port/auth policy.
|
|
||||||
- Clear host-key trust and mismatch errors.
|
|
||||||
- Clear auth failure errors that name the attempted key source.
|
|
||||||
- Stable reconnect after sleep, network switch, NAT rebinding, and server packet loss.
|
|
||||||
- `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:
|
|
||||||
|
|
||||||
- Agent forwarding with an explicit opt-in flag and clear warning.
|
|
||||||
- `ProxyJump` through SSH for bootstrap/trust and through Dosh-native relay later.
|
|
||||||
- `SendEnv`/`SetEnv` equivalent for explicit environment variables.
|
|
||||||
- Clipboard integration as a separate opt-in channel.
|
|
||||||
|
|
||||||
Explicitly not v1:
|
|
||||||
|
|
||||||
- X11 forwarding.
|
|
||||||
- SFTP/SCP wire compatibility.
|
|
||||||
- Full OpenSSH config language.
|
|
||||||
- Full `sshd` replacement for arbitrary SSH clients.
|
|
||||||
- Forced-command subsystems beyond fail-closed enforcement.
|
|
||||||
|
|
||||||
## 3. Stability Contract
|
|
||||||
|
|
||||||
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
|
|
||||||
(currently opt-in until stress-tested): each session's shell runs in a detached
|
|
||||||
per-session *holder*
|
|
||||||
process whose PTY master fd the server passes back to itself via SCM_RIGHTS, so
|
|
||||||
the shell + scrollback survive a server crash/upgrade/`systemctl restart` and a
|
|
||||||
reattaching client lands on the same shell with its screen restored. With
|
|
||||||
`persist_sessions = false` the old behavior applies: live PTYs drop on restart,
|
|
||||||
but the client must still fail clearly and reconnect cleanly afterward.
|
|
||||||
- Decrypt failures from stale packets must be ignored or trigger reconnect, never
|
|
||||||
terminate the terminal by themselves.
|
|
||||||
- Terminal cleanup must restore cursor, mouse mode, bracketed paste, alternate
|
|
||||||
screen, and raw mode on exit.
|
|
||||||
- Forwarding streams must close cleanly without leaving orphan listeners.
|
|
||||||
- Every public error must be actionable: host trust, auth failure, UDP blocked,
|
|
||||||
forwarding denied, version mismatch, or server unavailable.
|
|
||||||
- `dosh host` must never attach to another active unnamed terminal by accident.
|
|
||||||
- The default install must be secure without hand-editing configs.
|
|
||||||
- Upgrades must preserve existing trusted host keys and credentials unless explicitly
|
|
||||||
rotated.
|
|
||||||
|
|
||||||
## 4. Security Contract
|
|
||||||
|
|
||||||
Native Dosh must match the security properties users rely on from SSH for this use
|
|
||||||
case:
|
|
||||||
|
|
||||||
- Server authentication before terminal data is trusted.
|
|
||||||
- User authentication by possession of an authorized private key or agent key.
|
|
||||||
- Forward secrecy for terminal and forwarding traffic.
|
|
||||||
- AEAD encryption and authentication for every post-handshake packet.
|
|
||||||
- Replay protection for handshake and transport packets.
|
|
||||||
- Host-key pinning with explicit first-use behavior.
|
|
||||||
- No plaintext terminal bytes after handshake begins.
|
|
||||||
- No custom cryptographic primitives.
|
|
||||||
- Clear downgrade behavior: native auth failure must not silently fall back to an
|
|
||||||
unauthenticated mode.
|
|
||||||
|
|
||||||
Native Dosh does not claim SSH's full protocol security surface. It claims equivalent
|
|
||||||
security for Dosh terminal and forwarding sessions on Dosh-installed servers.
|
|
||||||
|
|
||||||
## 5. Threat Model
|
|
||||||
|
|
||||||
In scope:
|
|
||||||
|
|
||||||
- Passive network observer.
|
|
||||||
- Active network attacker that can spoof, drop, replay, reorder, or modify packets.
|
|
||||||
- NAT rebinding and client IP/port changes.
|
|
||||||
- Stolen attach-ticket cache without the user's private key.
|
|
||||||
- Server restart and key rotation.
|
|
||||||
- Malicious unauthenticated client flooding auth attempts.
|
|
||||||
- Compromised low-privilege local user trying to read Dosh caches on a shared client.
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- Compromised client machine.
|
|
||||||
- Compromised server account.
|
|
||||||
- Malicious kernel, terminal emulator, or PTY implementation.
|
|
||||||
- Protecting against a server that is already authorized and then becomes malicious.
|
|
||||||
|
|
||||||
## 6. Cryptographic Building Blocks
|
|
||||||
|
|
||||||
Allowed primitives:
|
|
||||||
|
|
||||||
- Handshake pattern: Noise `NK` or `XX` through a maintained Rust Noise framework, or
|
|
||||||
a small audited construction over `x25519-dalek` plus transcript binding.
|
|
||||||
- KEX: X25519.
|
|
||||||
- Signatures for user auth: Ed25519 and ECDSA P-256 via SSH-agent and OpenSSH key
|
|
||||||
formats. RSA may be accepted only for compatibility and must use SHA-2 signatures.
|
|
||||||
- AEAD: ChaCha20-Poly1305 by default; AES-GCM optional when hardware support is known.
|
|
||||||
- KDF: HKDF-SHA256.
|
|
||||||
- Hash/transcript: SHA-256.
|
|
||||||
- Randomness: OS CSPRNG only.
|
|
||||||
|
|
||||||
Disallowed:
|
|
||||||
|
|
||||||
- Homegrown ciphers, MACs, padding, or key derivation.
|
|
||||||
- Reusing a nonce/key pair.
|
|
||||||
- Unauthenticated encryption.
|
|
||||||
- MD5/SHA-1 signatures for user auth.
|
|
||||||
|
|
||||||
## 7. Identity And Trust
|
|
||||||
|
|
||||||
### Server Identity
|
|
||||||
|
|
||||||
Each `dosh-server` has a persistent host key:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.config/dosh/host_key
|
|
||||||
~/.config/dosh/host_key.pub
|
|
||||||
```
|
|
||||||
|
|
||||||
Default host-key algorithm: Ed25519.
|
|
||||||
|
|
||||||
Client pins host keys in:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.config/dosh/known_hosts
|
|
||||||
```
|
|
||||||
|
|
||||||
Entry format:
|
|
||||||
|
|
||||||
```text
|
|
||||||
host-pattern key-type base64-public-key first-seen=unix-seconds source=tofu|ssh|manual
|
|
||||||
```
|
|
||||||
|
|
||||||
First-use policy:
|
|
||||||
|
|
||||||
- Default for public internet: refuse unknown native host key and suggest
|
|
||||||
`dosh trust host` or SSH bootstrap.
|
|
||||||
- Default for local/private hosts may be TOFU only when `trust_on_first_use = true`.
|
|
||||||
- `dosh trust host` may verify the Dosh host key over the existing SSH bootstrap path.
|
|
||||||
|
|
||||||
Host-key mismatch:
|
|
||||||
|
|
||||||
- Hard fail.
|
|
||||||
- Print old fingerprint, new fingerprint, and known-hosts file path.
|
|
||||||
- Never auto-replace.
|
|
||||||
|
|
||||||
### User Identity
|
|
||||||
|
|
||||||
Native auth user identity is the login user resolved from:
|
|
||||||
|
|
||||||
1. Explicit CLI user: `user@host`.
|
|
||||||
2. Dosh host config.
|
|
||||||
3. `ssh -G host` `user`.
|
|
||||||
4. Local username.
|
|
||||||
|
|
||||||
Server verifies user keys against:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.ssh/authorized_keys
|
|
||||||
~/.config/dosh/authorized_keys
|
|
||||||
```
|
|
||||||
|
|
||||||
`~/.config/dosh/authorized_keys` is optional and may restrict Dosh access without
|
|
||||||
changing SSH access.
|
|
||||||
|
|
||||||
Authorized-key options required in v1:
|
|
||||||
|
|
||||||
- `from=`
|
|
||||||
- `command=` must reject native Dosh terminal login unless explicitly supported later.
|
|
||||||
- `restrict`
|
|
||||||
- `no-port-forwarding`
|
|
||||||
- `permitopen=`
|
|
||||||
|
|
||||||
Unsupported restrictive options must fail closed.
|
|
||||||
|
|
||||||
## 8. Native Auth Handshake
|
|
||||||
|
|
||||||
Native auth runs over UDP on the same Dosh port. It establishes a short-lived
|
|
||||||
authenticated control channel and returns the same terminal attach material that SSH
|
|
||||||
bootstrap returns today.
|
|
||||||
|
|
||||||
Target path:
|
|
||||||
|
|
||||||
```text
|
|
||||||
client -> server: ClientHello
|
|
||||||
server -> client: ServerHello
|
|
||||||
client -> server: UserAuth
|
|
||||||
server -> client: AuthOk + first terminal snapshot
|
|
||||||
```
|
|
||||||
|
|
||||||
The server may combine `AuthOk` and `AttachOk` to get terminal-ready in the final
|
|
||||||
handshake flight.
|
|
||||||
|
|
||||||
### ClientHello
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
|
|
||||||
- protocol version
|
|
||||||
- client random
|
|
||||||
- client ephemeral X25519 public key
|
|
||||||
- requested host alias
|
|
||||||
- requested user
|
|
||||||
- requested session
|
|
||||||
- requested mode
|
|
||||||
- terminal size
|
|
||||||
- supported AEAD algorithms
|
|
||||||
- supported user key algorithms
|
|
||||||
- optional cached host-key id
|
|
||||||
- optional attach ticket envelope
|
|
||||||
|
|
||||||
### ServerHello
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
|
|
||||||
- protocol version
|
|
||||||
- server random
|
|
||||||
- server ephemeral X25519 public key
|
|
||||||
- server host public key
|
|
||||||
- server host-key signature over transcript
|
|
||||||
- chosen AEAD
|
|
||||||
- server key epoch
|
|
||||||
- auth challenge
|
|
||||||
- rate-limit metadata when applicable
|
|
||||||
|
|
||||||
The client must verify the host key before sending user authentication.
|
|
||||||
|
|
||||||
### UserAuth
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
|
|
||||||
- selected public key
|
|
||||||
- key algorithm
|
|
||||||
- signature over transcript and auth challenge
|
|
||||||
- optional agent identity metadata
|
|
||||||
- optional requested forwarding declarations
|
|
||||||
|
|
||||||
The signature must bind:
|
|
||||||
|
|
||||||
- both ephemeral keys
|
|
||||||
- both randoms
|
|
||||||
- server host key
|
|
||||||
- requested user/session/mode/terminal size
|
|
||||||
- selected algorithms
|
|
||||||
- protocol version
|
|
||||||
|
|
||||||
### AuthOk
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
|
|
||||||
- assigned `ClientId`
|
|
||||||
- session name
|
|
||||||
- mode
|
|
||||||
- session key id
|
|
||||||
- encrypted session key material or derived key confirmation
|
|
||||||
- attach ticket
|
|
||||||
- attach ticket PSK encrypted to the handshake key
|
|
||||||
- initial output sequence
|
|
||||||
- first snapshot
|
|
||||||
- server policy flags
|
|
||||||
|
|
||||||
`AuthOk` is AEAD-encrypted under the handshake traffic key.
|
|
||||||
|
|
||||||
## 9. Key Schedule
|
|
||||||
|
|
||||||
Handshake transcript:
|
|
||||||
|
|
||||||
```text
|
|
||||||
H = SHA256(protocol_label || ClientHello || ServerHello || UserAuth)
|
|
||||||
```
|
|
||||||
|
|
||||||
Shared secret:
|
|
||||||
|
|
||||||
```text
|
|
||||||
dh = X25519(client_ephemeral, server_ephemeral)
|
|
||||||
```
|
|
||||||
|
|
||||||
Handshake key:
|
|
||||||
|
|
||||||
```text
|
|
||||||
handshake_key = HKDF-SHA256(dh, H, "dosh/native/handshake/v1")
|
|
||||||
```
|
|
||||||
|
|
||||||
Session traffic keys:
|
|
||||||
|
|
||||||
```text
|
|
||||||
c2s_key = HKDF-SHA256(handshake_key, H, "dosh/native/c2s/v1")
|
|
||||||
s2c_key = HKDF-SHA256(handshake_key, H, "dosh/native/s2c/v1")
|
|
||||||
```
|
|
||||||
|
|
||||||
Attach ticket PSKs and rotated session keys must be derived independently from server
|
|
||||||
secret material and fresh randomness. They must not reuse handshake traffic keys.
|
|
||||||
|
|
||||||
## 10. Attach Tickets And Cache
|
|
||||||
|
|
||||||
Native attach tickets replace most cold auth after first login.
|
|
||||||
|
|
||||||
Ticket properties:
|
|
||||||
|
|
||||||
- Server-sealed AEAD blob.
|
|
||||||
- Scoped to server host key, user, session, mode, client key fingerprint, server key
|
|
||||||
epoch, and policy flags.
|
|
||||||
- Paired with a client-held random PSK.
|
|
||||||
- Default TTL: 24 hours for trusted personal machines, configurable down to zero.
|
|
||||||
- Stored mode `0600`.
|
|
||||||
- Revoked by server host-key rotation, server secret rotation, or user key removal.
|
|
||||||
|
|
||||||
Client cache path:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.local/share/dosh/credentials/
|
|
||||||
```
|
|
||||||
|
|
||||||
Cache entries must include:
|
|
||||||
|
|
||||||
- host identity fingerprint
|
|
||||||
- user
|
|
||||||
- session
|
|
||||||
- mode
|
|
||||||
- ticket expiry
|
|
||||||
- last rendered sequence
|
|
||||||
- client id
|
|
||||||
- session key id
|
|
||||||
- attach ticket
|
|
||||||
- attach ticket PSK
|
|
||||||
|
|
||||||
## 11. Transport
|
|
||||||
|
|
||||||
Post-auth terminal traffic continues to use the current Dosh UDP packet model:
|
|
||||||
|
|
||||||
- fixed binary header
|
|
||||||
- AEAD body
|
|
||||||
- monotonic packet sequence
|
|
||||||
- ack field
|
|
||||||
- replay window
|
|
||||||
- server snapshots for recovery
|
|
||||||
|
|
||||||
Required v1 changes:
|
|
||||||
|
|
||||||
- Separate packet namespaces for terminal frames, control messages, and forwarding
|
|
||||||
streams.
|
|
||||||
- Explicit `key_epoch` or `session_key_id` in packet metadata so stale packets can be
|
|
||||||
ignored without fatal decrypt errors.
|
|
||||||
- Rekey command after configurable packet count or wall-clock interval.
|
|
||||||
- Connection migration must be accepted after any valid encrypted packet from a new
|
|
||||||
source address.
|
|
||||||
|
|
||||||
## 12. Forwarding
|
|
||||||
|
|
||||||
Native forwarding is a Dosh stream multiplexer over the encrypted transport.
|
|
||||||
|
|
||||||
CLI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dosh -L [bind_host:]listen_port:target_host:target_port host
|
|
||||||
dosh -R [bind_host:]listen_port:target_host:target_port host
|
|
||||||
dosh -D [bind_host:]listen_port host
|
|
||||||
dosh -A host # forward the local ssh-agent (opt-in, server-gated)
|
|
||||||
```
|
|
||||||
|
|
||||||
Stream packet types:
|
|
||||||
|
|
||||||
- `StreamOpen`
|
|
||||||
- `StreamOpenOk`
|
|
||||||
- `StreamOpenReject`
|
|
||||||
- `StreamData`
|
|
||||||
- `StreamWindowAdjust`
|
|
||||||
- `StreamEof`
|
|
||||||
- `StreamClose`
|
|
||||||
|
|
||||||
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.
|
|
||||||
- Non-loopback remote bind requires explicit config.
|
|
||||||
- `-N` opens forwarding without creating a PTY.
|
|
||||||
- `-f` backgrounds only after all requested listeners are successfully active.
|
|
||||||
- Listener setup failures fail the entire command unless `--partial-forwarding` is
|
|
||||||
explicitly requested.
|
|
||||||
- Forwarding reconnect must preserve listeners and re-open streams after network
|
|
||||||
migration when protocol state allows it.
|
|
||||||
- Stream packet scheduling must enforce terminal priority; a large port-forward copy
|
|
||||||
cannot make shell keystrokes lag.
|
|
||||||
|
|
||||||
### 12.1 SSH-Agent Forwarding (opt-in, security-gated)
|
|
||||||
|
|
||||||
- Activated only on explicit client opt-in (`-A` / `forward_agent = true`) **and**
|
|
||||||
server `allow_agent_forwarding = true`. Off by default on both ends.
|
|
||||||
- The client requests a `ForwardingKind::Agent` entry during auth. If the server
|
|
||||||
policy allows it, the server binds a per-session proxy unix socket in a
|
|
||||||
process-private directory (dir 0700, socket 0600) and exports its path as the
|
|
||||||
spawned shell's `SSH_AUTH_SOCK`.
|
|
||||||
- Each connection from a remote process to that proxy socket is tunneled to the
|
|
||||||
client over a server-initiated `StreamOpen` carrying the reserved target sentinel
|
|
||||||
`@dosh-agent` (port 0). The client splices it into its local `SSH_AUTH_SOCK`
|
|
||||||
(a unix socket, not a TCP target). Data/window/close reuse the existing stream
|
|
||||||
packets unchanged.
|
|
||||||
- Only applies to a freshly spawned shell; an already-running attached/prewarmed
|
|
||||||
session keeps its existing environment (same constraint as ssh/mosh).
|
|
||||||
- SECURITY: forwarding exposes the local agent to the remote host for the session's
|
|
||||||
lifetime, which is why it is double-gated and never the default.
|
|
||||||
|
|
||||||
## 13. Diagnostics And Operations
|
|
||||||
|
|
||||||
Native v1 must include operations commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dosh doctor host
|
|
||||||
dosh trust host
|
|
||||||
dosh trust --remove host
|
|
||||||
dosh sessions host
|
|
||||||
dosh update
|
|
||||||
```
|
|
||||||
|
|
||||||
`dosh doctor host` checks:
|
|
||||||
|
|
||||||
- host alias resolution
|
|
||||||
- Dosh known-host state
|
|
||||||
- SSH fallback reachability
|
|
||||||
- native UDP reachability
|
|
||||||
- server version
|
|
||||||
- native auth enabled/disabled
|
|
||||||
- usable keys from ssh-agent and identity files
|
|
||||||
- server authorization result without opening a terminal
|
|
||||||
- configured forwarding policy
|
|
||||||
|
|
||||||
`dosh trust host` checks:
|
|
||||||
|
|
||||||
- fetch Dosh host key through SSH fallback when available
|
|
||||||
- show fingerprint before writing trust
|
|
||||||
- refuse overwrite on mismatch unless `--replace` is explicit
|
|
||||||
|
|
||||||
## 14. Config
|
|
||||||
|
|
||||||
Client:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
auth_preference = "native,ssh"
|
|
||||||
trust_on_first_use = false
|
|
||||||
native_auth_timeout_ms = 700
|
|
||||||
known_hosts = "~/.config/dosh/known_hosts"
|
|
||||||
credential_cache = "~/.local/share/dosh/credentials"
|
|
||||||
identity_files = ["~/.ssh/id_ed25519"]
|
|
||||||
use_ssh_agent = true
|
|
||||||
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:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
native_auth = true
|
|
||||||
host_key = "~/.config/dosh/host_key"
|
|
||||||
authorized_keys = ["~/.ssh/authorized_keys", "~/.config/dosh/authorized_keys"]
|
|
||||||
native_auth_rate_limit_per_minute = 30
|
|
||||||
attach_ticket_ttl_secs = 86400
|
|
||||||
allow_tcp_forwarding = true
|
|
||||||
allow_remote_forwarding = false
|
|
||||||
allow_remote_non_loopback_bind = false
|
|
||||||
allow_agent_forwarding = false
|
|
||||||
accept_env = ["LANG", "LC_*", "TERM", "COLORTERM"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 15. Migration Plan
|
|
||||||
|
|
||||||
Milestone 1: host identity and trust
|
|
||||||
|
|
||||||
- Generate Dosh host key on server install.
|
|
||||||
- Add `dosh trust host`.
|
|
||||||
- Add known-hosts file and mismatch handling.
|
|
||||||
- Keep SSH bootstrap as the only auth path.
|
|
||||||
|
|
||||||
Milestone 2: native user auth
|
|
||||||
|
|
||||||
- Implement `ClientHello`/`ServerHello`/`UserAuth`/`AuthOk`.
|
|
||||||
- Support ssh-agent Ed25519 first.
|
|
||||||
- Verify against `authorized_keys`.
|
|
||||||
- Add `--auth=native|ssh|auto`.
|
|
||||||
|
|
||||||
Milestone 3: default native auth
|
|
||||||
|
|
||||||
- Make `auth_preference = "native,ssh"` default.
|
|
||||||
- Keep SSH fallback explicit and visible.
|
|
||||||
- Add benchmark gates for native cold auth.
|
|
||||||
|
|
||||||
Milestone 4: forwarding
|
|
||||||
|
|
||||||
- Add stream mux.
|
|
||||||
- Add `-L`, `-N`, and `-f`.
|
|
||||||
- Add `-R`.
|
|
||||||
- Add `-D` only after flow control is proven.
|
|
||||||
|
|
||||||
Milestone 5: hardening
|
|
||||||
|
|
||||||
- Fuzz packet parsing, authorized-key parsing, known-host parsing, and handshake state.
|
|
||||||
- Add hostile-network integration tests.
|
|
||||||
- Keep a public hardening checklist and threat model before public security claims.
|
|
||||||
|
|
||||||
Milestone 6: workflow parity
|
|
||||||
|
|
||||||
- Implement `dosh doctor`.
|
|
||||||
- Implement complete host-trust management.
|
|
||||||
- Implement private-key prompt flow.
|
|
||||||
- Implement forwarding policy diagnostics.
|
|
||||||
- Run daily-driver soak on macOS and Linux clients.
|
|
||||||
|
|
||||||
## 16. Verification
|
|
||||||
|
|
||||||
Native v1 is not complete until all are true:
|
|
||||||
|
|
||||||
- Unknown host key fails by default unless TOFU is explicitly enabled.
|
|
||||||
- Known host key mismatch hard fails.
|
|
||||||
- Native Ed25519 auth succeeds via ssh-agent.
|
|
||||||
- Native Ed25519 auth succeeds via encrypted private key prompt.
|
|
||||||
- Removed authorized key can no longer authenticate.
|
|
||||||
- Restrictive unsupported authorized-key options fail closed.
|
|
||||||
- Replayed handshake packets are rejected.
|
|
||||||
- Replayed transport packets are rejected.
|
|
||||||
- Stale encrypted packets after reconnect are ignored, not fatal.
|
|
||||||
- Client IP/port change preserves the session.
|
|
||||||
- Native cold auth benchmark beats cold `ssh host true` on the same host.
|
|
||||||
- Cached attach remains near network RTT plus local render overhead.
|
|
||||||
- `-L` forwarding works without delaying terminal input.
|
|
||||||
- `-R` forwarding enforces bind and permission policy.
|
|
||||||
- `-N -L` forward-only mode does not allocate a PTY.
|
|
||||||
- `-f -N -L` backgrounds only after listener readiness.
|
|
||||||
- Multiple forwards in one command work.
|
|
||||||
- `dosh doctor` identifies UDP-blocked, auth-denied, host-key-mismatch, and
|
|
||||||
forwarding-denied states.
|
|
||||||
- Closing the laptop for at least 30 minutes does not kill the remote session.
|
|
||||||
- Three concurrent Dosh terminals remain independent unless explicitly named.
|
|
||||||
- Large forwarded transfers do not add visible terminal input lag.
|
|
||||||
- Fuzz targets run in CI.
|
|
||||||
- Threat model is updated with any accepted residual risks.
|
|
||||||
|
|
||||||
## 17. Public Claim Gate
|
|
||||||
|
|
||||||
Dosh may claim "native SSH replacement for Dosh-installed servers" only after:
|
|
||||||
|
|
||||||
- Native auth is default on at least one real host.
|
|
||||||
- SSH fallback remains available.
|
|
||||||
- The verification checklist is green.
|
|
||||||
- The threat model is published.
|
|
||||||
- Benchmarks include raw samples for SSH cold, Dosh native cold, Dosh cached attach,
|
|
||||||
and Mosh startup.
|
|
||||||
|
|
||||||
Dosh must not claim generic SSH compatibility unless it implements the SSH protocol.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
# Dosh Public Readiness
|
|
||||||
|
|
||||||
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,
|
|
||||||
`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
|
|
||||||
|
|
||||||
Run the same-host SSH comparison:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make bench-docker-ssh
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the Dosh, SSH, and Mosh comparison:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make bench-docker-mosh
|
|
||||||
```
|
|
||||||
|
|
||||||
The Mosh-inclusive target builds one Docker image with OpenSSH, Mosh, `dosh-server`,
|
|
||||||
and `dosh-auth`. It uses the same generated key, localhost network path, SSH server,
|
|
||||||
and container for all samples.
|
|
||||||
|
|
||||||
Reported metrics:
|
|
||||||
|
|
||||||
| Metric | Meaning |
|
|
||||||
| --- | --- |
|
|
||||||
| `ssh_true_ms` | Time to run `ssh host true` with the same key/options. |
|
|
||||||
| `dosh_attach_ms` | Time for `dosh-client --attach-only` to render the first frame and detach on the configured path. With `--no-cache`, this is cold SSH bootstrap plus UDP attach. |
|
|
||||||
| `dosh_cached_attach_ms` | Time for a warmed Dosh client to attach using cached UDP credentials/tickets, render the first frame, and detach. This is the fast-path claim and should be approximately network RTT plus local process/render overhead. |
|
|
||||||
| `mosh_start_true_ms` | Time for `mosh host -- true` to bootstrap, run `true`, and exit. |
|
|
||||||
|
|
||||||
These are not identical workloads. They are still useful because they measure the
|
|
||||||
startup path each tool must pay before useful remote work begins. Public numbers
|
|
||||||
must include the command, machine, OS, CPU, network path, and sample count.
|
|
||||||
|
|
||||||
Do not cite cold `dosh_attach_ms` as the core speed claim. Cold Dosh still pays SSH
|
|
||||||
startup/authentication. Cite `dosh_cached_attach_ms` for repeat attach/reconnect
|
|
||||||
speed, and cite cold `dosh_attach_ms` only to show that fallback remains competitive
|
|
||||||
with ordinary SSH.
|
|
||||||
|
|
||||||
## Feature Matrix
|
|
||||||
|
|
||||||
| Feature | Mosh | Dosh now | Public status |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| SSH-based first authentication | yes | yes | ready |
|
|
||||||
| 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 | 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, 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 | 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 | 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 | 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; covered by blocked-stream and local-forward load tests |
|
|
||||||
|
|
||||||
## SSH Config Inheritance
|
|
||||||
|
|
||||||
Dosh intentionally delegates SSH parsing and authentication to OpenSSH. Bootstrap
|
|
||||||
uses the user's normal `ssh` command, so `~/.ssh/config` options such as `Host`,
|
|
||||||
`HostName`, `User`, `Port`, `IdentityFile`, `ProxyJump`, and `UserKnownHostsFile`
|
|
||||||
continue to apply.
|
|
||||||
|
|
||||||
Dosh also calls `ssh -G <alias>` to infer the UDP target host when no `dosh_host` is
|
|
||||||
configured. To write explicit Dosh host entries from SSH aliases:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dosh import-ssh homelab
|
|
||||||
```
|
|
||||||
|
|
||||||
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
|
|
||||||
OpenSSH config parser.
|
|
||||||
|
|
||||||
## Optional Command Extensions
|
|
||||||
|
|
||||||
Dosh can expose companion tools without taking a dependency on them. Command
|
|
||||||
extensions live in client or host config and expand only the first trailing word:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[extensions.tm]
|
|
||||||
command = "tm {args}"
|
|
||||||
description = "Open the server-side tmux dashboard"
|
|
||||||
```
|
|
||||||
|
|
||||||
With that config, `dosh homelab tm` runs `tm` in the remote Dosh shell and
|
|
||||||
`dosh homelab tm dosh` runs `tm 'dosh'`. Removing the table removes the integration.
|
|
||||||
Host config can override a global extension, or disable it:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[homelab.extensions.tm]
|
|
||||||
command = "/opt/tm/bin/tm {args}"
|
|
||||||
|
|
||||||
[other-host.extensions.tm]
|
|
||||||
disabled = true
|
|
||||||
```
|
|
||||||
|
|
||||||
## Forwarding (Implemented)
|
|
||||||
|
|
||||||
SSH forwarding cannot be copied by keeping the bootstrap SSH connection open,
|
|
||||||
because that would remove Dosh's fast reconnect advantage and would break after
|
|
||||||
roaming. Dosh forwarding therefore runs as native encrypted stream channels over the
|
|
||||||
Dosh transport, and is now implemented.
|
|
||||||
|
|
||||||
| CLI | Meaning |
|
|
||||||
| --- | --- |
|
|
||||||
| `dosh -L 8080:127.0.0.1:80 host` | Local listener on the client; server connects to target. |
|
|
||||||
| `dosh -R 8080:127.0.0.1:80 host` | Remote listener on the server (loopback by default); client connects to target. |
|
|
||||||
| `dosh -D 1080 host` | SOCKS5 listener on the client; server connects to whatever the SOCKS request names. |
|
|
||||||
|
|
||||||
Implemented:
|
|
||||||
|
|
||||||
- `StreamOpen`/`StreamOpenOk`/`StreamOpenReject`/`StreamData`/`StreamWindowAdjust`/
|
|
||||||
`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`,
|
|
||||||
loopback-only remote bind unless `allow_remote_non_loopback_bind`, plus per-key
|
|
||||||
`no-port-forwarding` and `permitopen=`.
|
|
||||||
- `-N` forward-only (no PTY) and `-f` background (requires `-N`, backgrounds only
|
|
||||||
after listeners are ready).
|
|
||||||
|
|
||||||
Still open before claiming forwarding parity:
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
This maps each item in `NATIVE_V1_SPEC.md` section 16 to its status based on what the
|
|
||||||
code in `src/` actually does. Done = implemented and exercised by tests or obvious
|
|
||||||
from code; in progress = partially implemented; pending = not yet implemented.
|
|
||||||
|
|
||||||
| Spec section 16 item | Status | Evidence / note |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Unknown host key fails unless TOFU explicitly enabled | done | Client refuses `KnownHostStatus::Unknown` unless `trust_on_first_use`. |
|
|
||||||
| 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` | 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 | 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 | 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: 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.
|
|
||||||
- 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.
|
|
||||||
- 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).
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
# Dosh Release Evidence - 2026-06-20
|
|
||||||
|
|
||||||
Runtime code benchmarked: `b44ff8e Improve release and benchmark tooling`
|
|
||||||
|
|
||||||
Release docs/default cleanup after that benchmark removed personal host aliases from
|
|
||||||
public examples and test fixtures; it did not change release runtime paths.
|
|
||||||
|
|
||||||
Environment:
|
|
||||||
|
|
||||||
- Host: local homelab runner
|
|
||||||
- Benchmark harness: `make bench-docker-mosh`
|
|
||||||
- Container SSH server: OpenSSH 9.6p1 on Ubuntu
|
|
||||||
- Same container, key, DNS, loopback, and Dosh server path for all Docker samples
|
|
||||||
|
|
||||||
## Release Artifacts
|
|
||||||
|
|
||||||
Generated locally with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sh scripts/package-release.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Artifacts:
|
|
||||||
|
|
||||||
| artifact | sha256 |
|
|
||||||
| --- | --- |
|
|
||||||
| `dosh-linux-x86_64.tar.gz` | `9f586852a506cca3caf618743e12967265879ee10f5294d179cdcd668db1e808` |
|
|
||||||
| `dosh-0.1.0-linux-x86_64.tar.gz` | `9f586852a506cca3caf618743e12967265879ee10f5294d179cdcd668db1e808` |
|
|
||||||
| `dosh-macos-aarch64.tar.gz` | `dd1151aa2b40be37288dc19a07521b0ec72d1106439eb484cdff01cca3b891c6` |
|
|
||||||
| `dosh-0.1.0-macos-aarch64.tar.gz` | `dd1151aa2b40be37288dc19a07521b0ec72d1106439eb484cdff01cca3b891c6` |
|
|
||||||
|
|
||||||
The installer verifies `<artifact>.sha256` when the sidecar is published.
|
|
||||||
|
|
||||||
## Docker SSH/Mosh Benchmark
|
|
||||||
|
|
||||||
Command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make bench-docker-mosh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cold SSH Bootstrap
|
|
||||||
|
|
||||||
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
|
|
||||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
|
||||||
| `ssh_true_ms` | 3 | 216.58 | 220.27 | 224.64 | 220.66 | 225.12 |
|
|
||||||
| `dosh_attach_ms` | 3 | 232.37 | 235.56 | 239.13 | 235.82 | 239.53 |
|
|
||||||
|
|
||||||
Raw samples:
|
|
||||||
|
|
||||||
- `ssh_true_ms`: [216.58, 220.27, 225.12]
|
|
||||||
- `dosh_attach_ms`: [235.56, 232.37, 239.53]
|
|
||||||
|
|
||||||
Gate: `dosh_attach_ms avg 235.82ms <= ssh avg 220.66ms + 500.00ms`
|
|
||||||
|
|
||||||
### ControlMaster SSH Bootstrap
|
|
||||||
|
|
||||||
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
|
|
||||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
|
||||||
| `ssh_true_ms` | 3 | 7.29 | 7.50 | 47.51 | 22.25 | 51.95 |
|
|
||||||
| `dosh_attach_ms` | 3 | 16.62 | 17.01 | 17.77 | 17.16 | 17.85 |
|
|
||||||
|
|
||||||
Raw samples:
|
|
||||||
|
|
||||||
- `ssh_true_ms`: [51.95, 7.29, 7.50]
|
|
||||||
- `dosh_attach_ms`: [17.01, 16.62, 17.85]
|
|
||||||
|
|
||||||
Gate: `dosh_attach_ms avg 17.16ms <= ssh avg 22.25ms + 500.00ms`
|
|
||||||
|
|
||||||
### Native Cold Auth
|
|
||||||
|
|
||||||
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
|
|
||||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
|
||||||
| `ssh_true_ms` | 5 | 215.72 | 222.68 | 227.05 | 221.32 | 227.82 |
|
|
||||||
| `dosh_cold_native_ms` | 5 | 8.63 | 8.84 | 9.16 | 8.88 | 9.23 |
|
|
||||||
|
|
||||||
Raw samples:
|
|
||||||
|
|
||||||
- `ssh_true_ms`: [216.40, 223.99, 227.82, 222.68, 215.72]
|
|
||||||
- `dosh_cold_native_ms`: [8.84, 8.63, 9.23, 8.88, 8.84]
|
|
||||||
|
|
||||||
Gate: `dosh_cold_native_ms avg 8.88ms <= ssh avg 221.32ms + 0.00ms`
|
|
||||||
|
|
||||||
### Cached Attach
|
|
||||||
|
|
||||||
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
|
|
||||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
|
||||||
| `dosh_cached_attach_ms` | 10 | 8.07 | 8.96 | 9.83 | 8.98 | 9.84 |
|
|
||||||
|
|
||||||
Raw samples:
|
|
||||||
|
|
||||||
- `dosh_cached_attach_ms`: [8.07, 9.09, 9.02, 8.66, 8.71, 8.90, 8.45, 9.84, 9.25, 9.82]
|
|
||||||
|
|
||||||
Gate: `dosh_cached_attach_ms avg 8.98ms <= 25.00ms`
|
|
||||||
|
|
||||||
### Mosh Comparison
|
|
||||||
|
|
||||||
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
|
|
||||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
|
||||||
| `ssh_true_ms` | 3 | 216.48 | 218.93 | 219.49 | 218.32 | 219.55 |
|
|
||||||
| `dosh_attach_ms` | 3 | 229.59 | 233.00 | 233.31 | 231.98 | 233.35 |
|
|
||||||
| `mosh_start_true_ms` | 3 | 527.13 | 563.34 | 573.09 | 554.89 | 574.18 |
|
|
||||||
|
|
||||||
Raw samples:
|
|
||||||
|
|
||||||
- `ssh_true_ms`: [219.55, 216.48, 218.93]
|
|
||||||
- `dosh_attach_ms`: [233.00, 229.59, 233.35]
|
|
||||||
- `mosh_start_true_ms`: [563.34, 527.13, 574.18]
|
|
||||||
|
|
||||||
## Soak And Fuzz Evidence
|
|
||||||
|
|
||||||
- `cargo test`: `153 passed, 1 ignored`
|
|
||||||
- `make soak-local`: `sleep_roaming_soak_30m ... ok`, finished in `1803.54s`
|
|
||||||
- `make fuzz-smoke`: passed all configured targets for 20s each:
|
|
||||||
`packet_decode`, `from_body`, `authorized_keys`, `known_hosts`,
|
|
||||||
`handshake_structs`, `attach_ticket`
|
|
||||||
|
|
||||||
## Public Claim Boundary
|
|
||||||
|
|
||||||
This evidence supports:
|
|
||||||
|
|
||||||
- Dosh cached attach and native auth are much faster than cold SSH/Mosh startup in
|
|
||||||
this benchmark environment.
|
|
||||||
- Dosh remains mosh-shaped, not mosh-compatible.
|
|
||||||
- Dosh does not claim generic SSH replacement compatibility.
|
|
||||||
- Native v1 security still needs external review before making hard public security
|
|
||||||
claims.
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
# Dosh Threat Model
|
|
||||||
|
|
||||||
This is the published threat model for Dosh native v1, derived from
|
|
||||||
`docs/NATIVE_V1_SPEC.md` sections 4-6. It states the assets Dosh protects, the
|
|
||||||
attackers it does and does not defend against, the security properties Dosh claims
|
|
||||||
relative to SSH, the cryptographic building blocks actually in use, and an honest
|
|
||||||
list of accepted residual risks and known gaps.
|
|
||||||
|
|
||||||
Dosh is a remote-login transport for Dosh-installed servers. It is intended to
|
|
||||||
replace the day-to-day `ssh host` workflow for terminals and TCP forwarding while
|
|
||||||
keeping OpenSSH as a recovery and bootstrap fallback. Dosh is **not** an
|
|
||||||
RFC-compatible SSH implementation and does not claim SSH's entire protocol security
|
|
||||||
surface. It claims security that is **equivalent to, and in some respects stronger
|
|
||||||
than, SSH for the Dosh terminal and forwarding use case** on hosts running
|
|
||||||
`dosh-server`.
|
|
||||||
|
|
||||||
## 1. Assets
|
|
||||||
|
|
||||||
Dosh protects:
|
|
||||||
|
|
||||||
- **Terminal session contents.** Keystrokes, command output, and the authoritative
|
|
||||||
screen state for every named or generated session.
|
|
||||||
- **Forwarded TCP streams.** Bytes carried over `-L`, `-R`, and `-D` channels.
|
|
||||||
- **User authentication credentials.** The user's SSH/Dosh private keys and any
|
|
||||||
ssh-agent identities. Dosh never sees private key material in plaintext on the
|
|
||||||
wire; signatures are produced locally or by the agent.
|
|
||||||
- **Server-issued credentials.** Session keys, `ClientId` association state,
|
|
||||||
server-sealed attach tickets, and the client-held attach-ticket PSK.
|
|
||||||
- **Server identity.** The persistent Dosh host key (`~/.config/dosh/host_key`) and
|
|
||||||
the server secret used to seal attach tickets and derive bootstrap material.
|
|
||||||
- **Host-trust state.** The client's pinned known-hosts file
|
|
||||||
(`~/.config/dosh/known_hosts`).
|
|
||||||
- **Authorization policy.** `~/.ssh/authorized_keys` /
|
|
||||||
`~/.config/dosh/authorized_keys` and the forwarding policy they encode.
|
|
||||||
|
|
||||||
## 2. Attackers
|
|
||||||
|
|
||||||
### In scope (Dosh must defend against these)
|
|
||||||
|
|
||||||
- **Passive network observer.** May record all UDP traffic between client and
|
|
||||||
server.
|
|
||||||
- **Active network attacker.** May spoof, drop, replay, reorder, or modify any
|
|
||||||
packet, and may attempt to inject forged packets in either direction.
|
|
||||||
- **NAT rebinding / roaming.** Client source IP and port may change mid-session,
|
|
||||||
including across sleep, network switch, and NAT timeout.
|
|
||||||
- **Stolen attach-ticket cache without the user's private key.** An attacker who
|
|
||||||
reads a client cache copies a server-sealed ticket plus its PSK but does not have
|
|
||||||
the user's SSH private key.
|
|
||||||
- **Server restart and key rotation.** Stale session keys, tickets, and replay
|
|
||||||
state must not be usable after the server rotates its secret or host key.
|
|
||||||
- **Malicious unauthenticated client flooding auth attempts.** A peer that has no
|
|
||||||
authorized key tries to exhaust server resources or guess credentials.
|
|
||||||
- **Compromised low-privilege local user on a shared client.** A different local
|
|
||||||
user attempts to read Dosh credential caches on the same machine.
|
|
||||||
|
|
||||||
### Out of scope (explicitly not defended against)
|
|
||||||
|
|
||||||
- **Compromised client machine.** If the endpoint running `dosh-client` is owned by
|
|
||||||
the attacker, the attacker has the user's keys and terminal.
|
|
||||||
- **Compromised server account.** If the login account on the server is owned, the
|
|
||||||
attacker already has the shell Dosh would have given them.
|
|
||||||
- **Malicious kernel, terminal emulator, or PTY implementation** on either side.
|
|
||||||
- **A server that was legitimately authorized and later turns malicious.** Host-key
|
|
||||||
pinning detects a *substituted* server, not a trusted server that decides to
|
|
||||||
misbehave.
|
|
||||||
|
|
||||||
These exclusions match SSH's own boundaries: SSH likewise cannot protect a
|
|
||||||
compromised endpoint or a malicious authorized peer.
|
|
||||||
|
|
||||||
## 3. Security Properties Claimed vs SSH
|
|
||||||
|
|
||||||
| 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, 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/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. |
|
|
||||||
|
|
||||||
### Where Dosh aims to *exceed* SSH for this use case
|
|
||||||
|
|
||||||
- **Tighter transcript binding.** A user-auth signature binds both ephemeral keys,
|
|
||||||
both randoms, the server host key, requested user/session/mode/terminal size,
|
|
||||||
selected algorithms, and protocol version into one transcript. This forecloses
|
|
||||||
cross-protocol and partial-replay confusion classes for the narrow Dosh surface.
|
|
||||||
- **Smaller attack surface.** Dosh deliberately omits the full SSH transport/channel
|
|
||||||
machinery, arbitrary subsystems, X11, SFTP/SCP, and forced-command subsystems.
|
|
||||||
Fewer features means fewer parsers and fewer reachable states.
|
|
||||||
- **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; 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.
|
|
||||||
|
|
||||||
## 4. Cryptographic Building Blocks (as implemented)
|
|
||||||
|
|
||||||
These reflect the code in `src/crypto.rs`, `src/native.rs`, `src/auth.rs`, and
|
|
||||||
`src/protocol.rs`, not just the spec.
|
|
||||||
|
|
||||||
- **Key exchange:** X25519 ephemeral-ephemeral (`x25519-dalek`). The shared secret
|
|
||||||
is checked for contributory behaviour; a non-contributory result is rejected.
|
|
||||||
- **Handshake/transport KDF:** HKDF-SHA256 (`hkdf`), salted with the SHA-256 of the
|
|
||||||
serialized `ClientHello` and `ServerHello`, binding traffic keys to the transcript.
|
|
||||||
- **AEAD:** ChaCha20-Poly1305 (`chacha20poly1305`) for every encrypted packet and
|
|
||||||
for sealed attach tickets. Nonces are derived as `direction || sequence`, giving a
|
|
||||||
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, 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.
|
|
||||||
- **Hashing/transcript:** SHA-256 (`sha2`).
|
|
||||||
- **Randomness:** OS CSPRNG via `rand::thread_rng()` / `OsRng`.
|
|
||||||
|
|
||||||
No homegrown ciphers, MACs, padding schemes, or key derivation are used. No nonce or
|
|
||||||
key pair is reused within a direction.
|
|
||||||
|
|
||||||
## 5. How the Properties Are Enforced (handshake and transport)
|
|
||||||
|
|
||||||
- **Server authentication.** `ServerHello` carries the host public key and an Ed25519
|
|
||||||
signature over `dosh/native/server-hello/v1 || ClientHello || ServerHello(unsigned)`.
|
|
||||||
The client verifies this signature *and* checks the host key against its pinned
|
|
||||||
known-hosts entry before sending user auth or accepting terminal bytes. Unknown
|
|
||||||
keys are refused unless TOFU is enabled; mismatches hard-fail with both
|
|
||||||
fingerprints and the file path.
|
|
||||||
- **User authentication.** `UserAuth` carries an Ed25519 signature over
|
|
||||||
`dosh/native/user-auth/v1 || ClientHello || ServerHello || UserAuth(unsigned)`. The
|
|
||||||
server looks the public key up in `authorized_keys`, enforces authorized-key
|
|
||||||
options, then verifies the signature. A removed key can no longer authenticate.
|
|
||||||
- **Authorization options.** `from=` (with CIDR, glob, and negation),
|
|
||||||
`no-port-forwarding`, and `permitopen=` are enforced. `command=` is rejected for
|
|
||||||
native terminal login. Any unrecognized restrictive option fails closed rather than
|
|
||||||
being silently ignored.
|
|
||||||
- **Forwarding policy.** The server enforces `allow_tcp_forwarding`,
|
|
||||||
`allow_remote_forwarding`, and a loopback-only default for remote binds
|
|
||||||
(`allow_remote_non_loopback_bind`), in addition to per-key `permitopen=` /
|
|
||||||
`no-port-forwarding`.
|
|
||||||
- **Replay protection.** A 128-wide sliding window over the per-direction packet
|
|
||||||
counter rejects duplicates and stale sequences. Sequence 0 is never accepted.
|
|
||||||
- **Stale-key tolerance.** Each encrypted packet carries a `session_key_id`; packets
|
|
||||||
under an old key are dropped as "stale or wrong session key id" rather than treated
|
|
||||||
as fatal decrypt failures, so reconnect after rotation is non-destructive.
|
|
||||||
- **Roaming.** The server keys clients by `ClientId` and session key id, not by
|
|
||||||
source address, and updates the endpoint after any valid encrypted packet from a
|
|
||||||
new address — without weakening authentication, because the packet must still
|
|
||||||
decrypt and verify.
|
|
||||||
|
|
||||||
## 6. Accepted Residual Risks and Known Gaps
|
|
||||||
|
|
||||||
These are stated openly so the public claim gate (`NATIVE_V1_SPEC.md` section 17) can
|
|
||||||
be evaluated honestly. Items here are *not* yet "green".
|
|
||||||
|
|
||||||
### Accepted residual risks (by design)
|
|
||||||
|
|
||||||
- **Attach tickets prove recent server-issued possession, not fresh private-key
|
|
||||||
possession.** A stolen client cache containing both the sealed ticket and its PSK
|
|
||||||
can attach until the ticket expires (default TTL 24h, configurable down to zero) or
|
|
||||||
until the server rotates its secret/host key or the user key is removed. This is the
|
|
||||||
deliberate speed trade. It is bounded by TTL, scoped to host/user/session/mode, and
|
|
||||||
can be disabled. SSH ControlMaster has an analogous live-socket exposure.
|
|
||||||
- **Local cache confidentiality relies on filesystem permissions.** Host keys,
|
|
||||||
server secret, known-hosts, and credential caches are written `0600`. A
|
|
||||||
same-machine attacker who can already read another user's `0600` files (e.g. via
|
|
||||||
root) is out of scope, as with SSH's `~/.ssh`.
|
|
||||||
- **A trusted server that turns malicious is not detected.** Host-key pinning
|
|
||||||
detects substitution, not betrayal by an already-authorized server. This matches
|
|
||||||
SSH.
|
|
||||||
|
|
||||||
### Known gaps / work in progress (must close before the public claim)
|
|
||||||
|
|
||||||
- **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
|
|
||||||
|
|
||||||
Native auth is **opt-in alongside SSH fallback**. `auth_preference` defaults to
|
|
||||||
`native,ssh`: Dosh tries the native authenticated path first and falls back to SSH
|
|
||||||
bootstrap explicitly and visibly when native auth is disabled, unavailable, or
|
|
||||||
rejected. Native auth failure never silently degrades to an unauthenticated mode.
|
|
||||||
Forwarding (`-L`/`-R`/`-D`) requires the native authenticated path and refuses to run
|
|
||||||
under `--local-auth`.
|
|
||||||
|
|
||||||
## 7. Verification and Public-Claim Status
|
|
||||||
|
|
||||||
Dosh may claim "native SSH replacement for Dosh-installed servers" only after the
|
|
||||||
conditions in `NATIVE_V1_SPEC.md` section 17 are met: native auth default on a real
|
|
||||||
host, SSH fallback available, the section 16 checklist green, this threat model
|
|
||||||
published, and benchmarks with raw samples for SSH cold, Dosh native cold, Dosh
|
|
||||||
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, 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.
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# dosh fuzz targets
|
|
||||||
|
|
||||||
cargo-fuzz / libFuzzer harnesses for the `dosh` parsers and handshake verifiers
|
|
||||||
(spec milestone 5 / §16: "Fuzz packet parsing, authorized-key parsing,
|
|
||||||
known-host parsing, and handshake state", "Fuzz targets run in CI").
|
|
||||||
|
|
||||||
This is a **standalone crate** (its own `[workspace]`) so it never affects the
|
|
||||||
main crate's `cargo build` / `cargo test` / `cargo fmt --check`.
|
|
||||||
|
|
||||||
## Targets
|
|
||||||
|
|
||||||
| Target | Parser(s) exercised |
|
|
||||||
| --- | --- |
|
|
||||||
| `packet_decode` | `protocol::Header::parse`, `protocol::decode`, `protocol::decrypt_body` |
|
|
||||||
| `from_body` | `protocol::from_body::<T>` for every protocol & native wire struct |
|
|
||||||
| `authorized_keys` | `native::parse_authorized_keys`, `native::parse_ssh_ed25519_public_blob` |
|
|
||||||
| `known_hosts` | `native::parse_known_hosts`, `native::parse_host_public_key_line` |
|
|
||||||
| `handshake_structs` | handshake struct decode + `verify_server_hello`, `user_auth_transcript`, `verify_native_user_auth` |
|
|
||||||
| `attach_ticket` | `auth::open_attach_ticket`, `auth::verify_attach_ticket`, `auth::decode_bootstrap` |
|
|
||||||
|
|
||||||
Every target's objective is the same: **no panics on any input** (a panic on
|
|
||||||
untrusted bytes is a robustness/DoS bug per threat model §5).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
```sh
|
|
||||||
rustup toolchain install nightly
|
|
||||||
cargo install cargo-fuzz
|
|
||||||
```
|
|
||||||
|
|
||||||
cargo-fuzz requires a nightly toolchain (it builds with `-Z sanitizer=address`).
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Run a single target indefinitely
|
|
||||||
cargo +nightly fuzz run --fuzz-dir fuzz packet_decode
|
|
||||||
|
|
||||||
# Short, CI-style smoke run of one target (10 seconds)
|
|
||||||
cargo +nightly fuzz run --fuzz-dir fuzz packet_decode -- -max_total_time=10
|
|
||||||
```
|
|
||||||
|
|
||||||
Or from inside `fuzz/`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd fuzz
|
|
||||||
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. 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.
|
|
||||||
+60
@@ -70,6 +70,36 @@ function Release-DownloadUrl {
|
|||||||
"$web/releases/download/$BinaryVersion/$name"
|
"$web/releases/download/$BinaryVersion/$name"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Release-LatestTagDownloadUrl {
|
||||||
|
if ($BinaryUrl -or $BinaryBase -or $BinaryVersion -ne "latest") {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
$web = Repo-WebBase $Repo
|
||||||
|
if (-not $web) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$response = Invoke-WebRequest -UseBasicParsing -Uri "$web/releases/latest" -MaximumRedirection 5
|
||||||
|
$effective = $null
|
||||||
|
if ($response.BaseResponse.ResponseUri) {
|
||||||
|
$effective = $response.BaseResponse.ResponseUri.AbsoluteUri
|
||||||
|
} elseif ($response.BaseResponse.RequestMessage -and $response.BaseResponse.RequestMessage.RequestUri) {
|
||||||
|
$effective = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri
|
||||||
|
}
|
||||||
|
$prefix = "$web/releases/tag/"
|
||||||
|
if ($effective -and $effective.StartsWith($prefix)) {
|
||||||
|
$tag = $effective.Substring($prefix.Length)
|
||||||
|
if ($tag) {
|
||||||
|
return "$web/releases/download/$tag/$(Release-ArtifactName)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
function Verify-ArchiveChecksum($Url, $Archive) {
|
function Verify-ArchiveChecksum($Url, $Archive) {
|
||||||
$checksumPath = "$Archive.sha256"
|
$checksumPath = "$Archive.sha256"
|
||||||
try {
|
try {
|
||||||
@@ -86,12 +116,40 @@ function Verify-ArchiveChecksum($Url, $Archive) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Expected-ArchiveVersion($Url) {
|
||||||
|
if ($Url -match "/releases/download/v([^/]+)/") {
|
||||||
|
return $Matches[1]
|
||||||
|
}
|
||||||
|
if (-not $BinaryUrl -and -not $BinaryBase -and $BinaryVersion -ne "latest") {
|
||||||
|
return $BinaryVersion.TrimStart("v")
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Verify-ArchiveVersion($ExtractDir, $Url) {
|
||||||
|
$expected = Expected-ArchiveVersion $Url
|
||||||
|
if (-not $expected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$versionFile = Get-ChildItem -Path $ExtractDir -Recurse -File -Filter VERSION | Select-Object -First 1
|
||||||
|
if (-not $versionFile) {
|
||||||
|
throw "prebuilt archive missing VERSION for $Url"
|
||||||
|
}
|
||||||
|
$actual = (Get-Content $versionFile.FullName -Raw).Trim()
|
||||||
|
if ($actual -ne $expected) {
|
||||||
|
throw "prebuilt archive version mismatch for ${Url}: expected $expected, got $actual"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$bindir = Join-Path $Prefix "bin"
|
$bindir = Join-Path $Prefix "bin"
|
||||||
$configDir = Join-Path $HOME ".config\dosh"
|
$configDir = Join-Path $HOME ".config\dosh"
|
||||||
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
|
||||||
|
|
||||||
function Install-Prebuilt {
|
function Install-Prebuilt {
|
||||||
|
$url = Release-LatestTagDownloadUrl
|
||||||
|
if (-not $url) {
|
||||||
$url = Release-DownloadUrl
|
$url = Release-DownloadUrl
|
||||||
|
}
|
||||||
if (-not $url) {
|
if (-not $url) {
|
||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
@@ -104,6 +162,7 @@ function Install-Prebuilt {
|
|||||||
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $zip
|
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $zip
|
||||||
Verify-ArchiveChecksum $url $zip
|
Verify-ArchiveChecksum $url $zip
|
||||||
Expand-Archive -Force -Path $zip -DestinationPath $extract
|
Expand-Archive -Force -Path $zip -DestinationPath $extract
|
||||||
|
Verify-ArchiveVersion $extract $url
|
||||||
foreach ($bin in @("dosh-client.exe", "dosh-bench.exe")) {
|
foreach ($bin in @("dosh-client.exe", "dosh-bench.exe")) {
|
||||||
$found = Get-ChildItem -Path $extract -Recurse -File -Filter $bin | Select-Object -First 1
|
$found = Get-ChildItem -Path $extract -Recurse -File -Filter $bin | Select-Object -First 1
|
||||||
if (-not $found) {
|
if (-not $found) {
|
||||||
@@ -188,6 +247,7 @@ predict = true
|
|||||||
predict_mode = "experimental"
|
predict_mode = "experimental"
|
||||||
cache_attach_tickets = true
|
cache_attach_tickets = true
|
||||||
credential_cache = "~/.local/share/dosh/credentials"
|
credential_cache = "~/.local/share/dosh/credentials"
|
||||||
|
escape_key = "^]"
|
||||||
"@ | Set-Content -NoNewline -Encoding utf8 $clientConfig
|
"@ | Set-Content -NoNewline -Encoding utf8 $clientConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-5
@@ -12,7 +12,11 @@ start_server=1
|
|||||||
force_config=0
|
force_config=0
|
||||||
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
|
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
|
||||||
quiet="${DOSH_UPDATE_QUIET:-0}"
|
quiet="${DOSH_UPDATE_QUIET:-0}"
|
||||||
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
|
if [ "${DOSH_UPDATE_QUIET:-0}" = "1" ] && [ "${DOSH_BINARY_REQUIRED:-0}" != "1" ]; then
|
||||||
|
use_prebuilt=0
|
||||||
|
else
|
||||||
|
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
|
||||||
|
fi
|
||||||
binary_url="${DOSH_BINARY_URL:-}"
|
binary_url="${DOSH_BINARY_URL:-}"
|
||||||
binary_base="${DOSH_BINARY_BASE:-}"
|
binary_base="${DOSH_BINARY_BASE:-}"
|
||||||
binary_name="${DOSH_BINARY_NAME:-}"
|
binary_name="${DOSH_BINARY_NAME:-}"
|
||||||
@@ -43,7 +47,7 @@ Environment alternatives:
|
|||||||
DOSH_BINARY_URL URL
|
DOSH_BINARY_URL URL
|
||||||
Exact release tarball URL to install
|
Exact release tarball URL to install
|
||||||
DOSH_BINARY_BASE URL
|
DOSH_BINARY_BASE URL
|
||||||
Release download base; defaults to REPO/releases/latest/download
|
Release download base; otherwise derived from the latest tag
|
||||||
DOSH_BINARY_NAME NAME
|
DOSH_BINARY_NAME NAME
|
||||||
Release tarball name; defaults to dosh-OS-ARCH.tar.gz
|
Release tarball name; defaults to dosh-OS-ARCH.tar.gz
|
||||||
DOSH_BINARY_VERSION TAG
|
DOSH_BINARY_VERSION TAG
|
||||||
@@ -264,8 +268,42 @@ verify_archive_checksum() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expected_archive_version() {
|
||||||
|
url="$1"
|
||||||
|
case "$url" in
|
||||||
|
*/releases/download/v*/*)
|
||||||
|
tag="${url#*/releases/download/v}"
|
||||||
|
tag="${tag%%/*}"
|
||||||
|
printf '%s\n' "$tag"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [ -z "$binary_url" ] && [ -z "$binary_base" ] && [ "$binary_version" != "latest" ]; then
|
||||||
|
printf '%s\n' "${binary_version#v}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_archive_version() {
|
||||||
|
extract_dir="$1"
|
||||||
|
url="$2"
|
||||||
|
expected="$(expected_archive_version "$url" || true)"
|
||||||
|
[ -n "$expected" ] || return 0
|
||||||
|
version_file="$(find "$extract_dir" -type f -name VERSION 2>/dev/null | sed -n '1p')"
|
||||||
|
if [ -z "$version_file" ]; then
|
||||||
|
echo "prebuilt archive missing VERSION for $url" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
actual="$(tr -d '\r\n' <"$version_file")"
|
||||||
|
if [ "$actual" != "$expected" ]; then
|
||||||
|
echo "prebuilt archive version mismatch for $url: expected $expected, got $actual" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
try_install_prebuilt() {
|
try_install_prebuilt() {
|
||||||
download_url="$(release_download_url)" || return 1
|
download_url="$(release_latest_tag_download_url || release_download_url)" || return 1
|
||||||
tmpdir="$(mktemp -d)"
|
tmpdir="$(mktemp -d)"
|
||||||
archive="$tmpdir/$(release_artifact_name)"
|
archive="$tmpdir/$(release_artifact_name)"
|
||||||
checksum_file="$archive.sha256"
|
checksum_file="$archive.sha256"
|
||||||
@@ -273,7 +311,7 @@ try_install_prebuilt() {
|
|||||||
need curl
|
need curl
|
||||||
need tar
|
need tar
|
||||||
if ! curl -fsL "$download_url" -o "$archive" 2>/dev/null; then
|
if ! curl -fsL "$download_url" -o "$archive" 2>/dev/null; then
|
||||||
alt_download_url="$(release_latest_tag_download_url || true)"
|
alt_download_url="$(release_download_url || true)"
|
||||||
if [ -z "$alt_download_url" ] || ! curl -fsL "$alt_download_url" -o "$archive"; then
|
if [ -z "$alt_download_url" ] || ! curl -fsL "$alt_download_url" -o "$archive"; then
|
||||||
echo "prebuilt unavailable: $download_url" >&2
|
echo "prebuilt unavailable: $download_url" >&2
|
||||||
[ -z "$alt_download_url" ] || echo "prebuilt unavailable: $alt_download_url" >&2
|
[ -z "$alt_download_url" ] || echo "prebuilt unavailable: $alt_download_url" >&2
|
||||||
@@ -287,6 +325,7 @@ try_install_prebuilt() {
|
|||||||
echo "prebuilt archive could not be extracted: $download_url" >&2
|
echo "prebuilt archive could not be extracted: $download_url" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
verify_archive_version "$tmpdir/extract" "$download_url" || return 1
|
||||||
install_extracted_binary "$tmpdir/extract" dosh-client "$bindir/dosh-client" || return 1
|
install_extracted_binary "$tmpdir/extract" dosh-client "$bindir/dosh-client" || return 1
|
||||||
ln -sf dosh-client "$bindir/dosh"
|
ln -sf dosh-client "$bindir/dosh"
|
||||||
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
|
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
|
||||||
@@ -416,8 +455,9 @@ scrollback = 5000
|
|||||||
auth_ttl_secs = 30
|
auth_ttl_secs = 30
|
||||||
attach_ticket_ttl_secs = 3600
|
attach_ticket_ttl_secs = 3600
|
||||||
allow_attach_tickets = true
|
allow_attach_tickets = true
|
||||||
client_timeout_secs = 86400
|
client_timeout_secs = 2592000
|
||||||
retransmit_window = 256
|
retransmit_window = 256
|
||||||
|
output_frame_interval_ms = 16
|
||||||
default_input_mode = "read-write"
|
default_input_mode = "read-write"
|
||||||
prewarm_sessions = ["default"]
|
prewarm_sessions = ["default"]
|
||||||
create_on_attach = true
|
create_on_attach = true
|
||||||
@@ -431,6 +471,7 @@ native_auth_rate_limit_per_minute = 30
|
|||||||
allow_tcp_forwarding = true
|
allow_tcp_forwarding = true
|
||||||
allow_remote_forwarding = false
|
allow_remote_forwarding = false
|
||||||
allow_agent_forwarding = false
|
allow_agent_forwarding = false
|
||||||
|
persist_sessions = true
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -492,6 +533,7 @@ known_hosts = "~/.config/dosh/known_hosts"
|
|||||||
identity_files = ["~/.ssh/id_ed25519"]
|
identity_files = ["~/.ssh/id_ed25519"]
|
||||||
use_ssh_agent = true
|
use_ssh_agent = true
|
||||||
forward_agent = false
|
forward_agent = false
|
||||||
|
escape_key = "^]"
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
# dosh (Dormant Shell)
|
|
||||||
|
|
||||||
> dosh is a low-latency remote terminal for homelab/personal servers. It is mosh-shaped
|
|
||||||
> but not a mosh clone: `dosh-server` is a resident daemon that keeps terminal sessions
|
|
||||||
> hot, and the client reconnects over encrypted UDP — so attach and reconnect are
|
|
||||||
> near-instant (~3 ms of local overhead + one network RTT). SSH is used once to
|
|
||||||
> establish trust; after that, repeat attaches skip SSH entirely. It also does SSH-style
|
|
||||||
> TCP port forwarding (`-L`/`-R`/`-D`) over the same encrypted transport, which is what
|
|
||||||
> makes back-and-forth client↔server homelab comms easy.
|
|
||||||
|
|
||||||
This file orients an AI agent (or a human) on what dosh is, what it can do, and how to
|
|
||||||
drive it. It is intentionally self-contained. For deeper detail, see the linked docs at
|
|
||||||
the bottom.
|
|
||||||
|
|
||||||
## What dosh is for
|
|
||||||
|
|
||||||
Use dosh instead of `ssh`/`mosh` for **interactive shells and TCP forwarding** to a
|
|
||||||
server where you control both ends and have installed `dosh-server` (typically a homelab
|
|
||||||
box, VPS, or workstation). It shines when you:
|
|
||||||
|
|
||||||
- want a terminal that survives laptop sleep, Wi-Fi changes, and NAT rebinding, and
|
|
||||||
resumes instantly instead of hanging;
|
|
||||||
- reconnect to the same box many times a day and don't want to pay SSH startup each time;
|
|
||||||
- need to reach services on the server from your laptop (or vice-versa) without standing
|
|
||||||
up a VPN.
|
|
||||||
|
|
||||||
dosh is **not** a drop-in for every SSH use. It does not do `scp`/`sftp` file transfer,
|
|
||||||
X11, or act as an `sshd` for arbitrary SSH clients. Keep `ssh` installed for those.
|
|
||||||
|
|
||||||
## Core capabilities
|
|
||||||
|
|
||||||
- **Encrypted UDP terminal transport** — AEAD-encrypted, with packet sequencing, a
|
|
||||||
sliding replay window, ACKs, and server-side retransmit of unacked output.
|
|
||||||
- **Resident server + hot sessions** — `dosh-server` runs as a daemon; named sessions
|
|
||||||
(and a prewarmed `default`) stay alive across client disconnects.
|
|
||||||
- **Fast attach / reconnect** — see "Fast path order" below; cached attach is ~one RTT.
|
|
||||||
- **Roaming** — the session follows the client across IP/port changes.
|
|
||||||
- **Named & shared sessions** — reattach the same persistent terminal from multiple
|
|
||||||
clients; optional **view-only** clients.
|
|
||||||
- **TCP port forwarding** — local (`-L`), remote (`-R`), and dynamic SOCKS (`-D`) over
|
|
||||||
the encrypted transport, with per-stream flow control and terminal-priority
|
|
||||||
scheduling (bulk transfers don't lag your keystrokes).
|
|
||||||
- **Native UDP auth** — Ed25519 user auth via ssh-agent or an (optionally encrypted)
|
|
||||||
OpenSSH key, verified against `authorized_keys`. Falls back to SSH bootstrap when
|
|
||||||
native auth isn't available. SSH host config (`HostName`, `User`, `Port`,
|
|
||||||
`IdentityFile`, `ProxyJump`, etc.) is honored.
|
|
||||||
- **Host-key pinning** — TOFU/known-hosts with hard-fail on mismatch.
|
|
||||||
- **Speculative local echo** — optional predictive echo for laggy links (display-only;
|
|
||||||
real input is always sent to the server).
|
|
||||||
- **Ops commands** — `doctor`, `sessions`, `trust`, `import-ssh`, `update`.
|
|
||||||
|
|
||||||
## Quickstart
|
|
||||||
|
|
||||||
Install the server on each box you want to reach (default UDP port `50000`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
|
|
||||||
| DOSH_PORT=50000 sh -s -- server
|
|
||||||
```
|
|
||||||
|
|
||||||
Install the client (macOS/Linux), then attach:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
|
|
||||||
| DOSH_SERVER=homelab DOSH_HOST=homelab.example.com DOSH_PORT=50000 sh -s -- client
|
|
||||||
|
|
||||||
dosh homelab # fresh interactive shell
|
|
||||||
dosh homelab uptime # run one command
|
|
||||||
dosh --session work homelab # named, persistent, reattachable session
|
|
||||||
```
|
|
||||||
|
|
||||||
- Detach (leave the server session running): **Ctrl-]**.
|
|
||||||
- End the session: type `exit` in the remote shell.
|
|
||||||
- If UDP stalls, dosh keeps the terminal open, sends keepalives, and ticket-reconnects.
|
|
||||||
|
|
||||||
## Client↔server homelab comms (the back-and-forth)
|
|
||||||
|
|
||||||
Forwarding is the key to "make homelab comms easy." Three directions:
|
|
||||||
|
|
||||||
| Command | Direction | Effect |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `dosh -L [bind:]LPORT:THOST:TPORT host` | pull server→you | A listener on **your machine** (`bind`, default localhost) forwards to `THOST:TPORT` reached **from the server**. |
|
|
||||||
| `dosh -R [bind:]LPORT:THOST:TPORT host` | push you→server | A listener on **the server** (loopback by default) forwards to `THOST:TPORT` reached **from your machine**. |
|
|
||||||
| `dosh -D [bind:]LPORT host` | SOCKS via server | A SOCKS5 proxy on **your machine**; traffic egresses **from the server**. |
|
|
||||||
|
|
||||||
Concrete homelab patterns:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Reach the homelab's internal Grafana (server-side :3000) from your laptop browser:
|
|
||||||
dosh -L 3000:127.0.0.1:3000 homelab # open http://localhost:3000
|
|
||||||
|
|
||||||
# Reach a DB that only listens on the homelab LAN:
|
|
||||||
dosh -L 5432:10.0.0.5:5432 homelab # psql -h 127.0.0.1 -p 5432
|
|
||||||
|
|
||||||
# Let the homelab hit a dev server running on your laptop (e.g. a webhook target):
|
|
||||||
dosh -R 9000:127.0.0.1:8080 homelab # homelab curls http://127.0.0.1:9000
|
|
||||||
|
|
||||||
# Route browser traffic out through the homelab's network:
|
|
||||||
dosh -D 1080 homelab # SOCKS5 proxy at 127.0.0.1:1080
|
|
||||||
|
|
||||||
# Forward-only, no shell; multiple forwards; background after listeners are up:
|
|
||||||
dosh -N -L 3000:127.0.0.1:3000 -L 5432:10.0.0.5:5432 homelab
|
|
||||||
dosh -f -N -L 3000:127.0.0.1:3000 homelab
|
|
||||||
```
|
|
||||||
|
|
||||||
Server policy controls forwarding: `allow_tcp_forwarding`, `allow_remote_forwarding`,
|
|
||||||
`allow_remote_non_loopback_bind`, and per-key `permitopen=`/`no-port-forwarding` in
|
|
||||||
`authorized_keys`. Remote listeners bind to loopback unless explicitly allowed.
|
|
||||||
|
|
||||||
## Fast path order (why it's quick)
|
|
||||||
|
|
||||||
The client tries the cheapest valid path first:
|
|
||||||
|
|
||||||
1. **UDP resume** — existing client id + session key; one encrypted UDP round-trip.
|
|
||||||
2. **UDP attach ticket** — cached server-issued ticket; one round-trip, no SSH.
|
|
||||||
3. **Native UDP auth** — Ed25519 handshake (ssh-agent/key) when enabled.
|
|
||||||
4. **SSH bootstrap** — `ssh user@host dosh-auth …` once, then a UDP attach.
|
|
||||||
|
|
||||||
Measured locally (loopback, release): cached attach ≈ **3 ms**, cold native auth ≈ 9 ms.
|
|
||||||
Over a real link, add one RTT. See `docs/BENCHMARKS.md`.
|
|
||||||
|
|
||||||
## Architecture (1-minute model)
|
|
||||||
|
|
||||||
- **dosh-server** — single UDP socket on one port; a session table keyed by name; one
|
|
||||||
PTY per named session; per-session terminal screen state (vt100) for snapshots/diffs;
|
|
||||||
a per-session client table; encrypted UDP protocol; a small SSH-invoked `dosh-auth`
|
|
||||||
helper. Abandoned (clientless, non-prewarmed) sessions and their shells are reaped
|
|
||||||
after a grace period; prewarmed sessions stay hot.
|
|
||||||
- **dosh-client** — raw-mode terminal; local credential cache; tries resume/ticket/native
|
|
||||||
before SSH; forwards PTY I/O; reconnect/roaming state machine; optional predictive echo.
|
|
||||||
- **Binaries** — `dosh-server`, `dosh-client` (symlinked as `dosh`), `dosh-auth`
|
|
||||||
(SSH-invoked trust helper), `dosh-bench` (benchmarks).
|
|
||||||
|
|
||||||
## Security model (summary)
|
|
||||||
|
|
||||||
- First trust via SSH; thereafter encrypted Dosh transport. Native auth available.
|
|
||||||
- KEX X25519; AEAD ChaCha20-Poly1305; KDF HKDF-SHA256; host & user auth Ed25519;
|
|
||||||
SHA-256 transcript binding. Per-direction, per-sequence nonces; replay window.
|
|
||||||
- Host keys pinned in `~/.config/dosh/known_hosts`; mismatch hard-fails.
|
|
||||||
- User auth against `~/.ssh/authorized_keys` (+ optional `~/.config/dosh/authorized_keys`);
|
|
||||||
removed keys can't authenticate; unsupported restrictive options fail closed.
|
|
||||||
- Forward secrecy from ephemeral X25519; attach tickets are server-sealed and scoped.
|
|
||||||
|
|
||||||
dosh claims security **equivalent to, and in places stronger than, SSH for the dosh
|
|
||||||
terminal/forwarding use case** — not full SSH-protocol parity. Read `docs/THREAT_MODEL.md`
|
|
||||||
for the honest stance, residual risks, and what's still pending before public claims.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
- **Client** `~/.config/dosh/client.toml` — `auth_preference` (e.g. `"native,ssh"`),
|
|
||||||
`trust_on_first_use`, `identity_files`, `use_ssh_agent`, `forward_agent`, `send_env`,
|
|
||||||
`set_env`, `dosh_port`, `default_session`, `predict`, `reconnect_timeout_secs`,
|
|
||||||
`credential_cache`, `known_hosts`.
|
|
||||||
- **Hosts** `~/.config/dosh/hosts.toml` — per-alias `[name]` with `ssh`, `dosh_host`,
|
|
||||||
`port`, `user`, `default_command`, `predict`. Generate from SSH aliases with
|
|
||||||
`dosh import-ssh <alias> <name>`.
|
|
||||||
- **Server** `~/.config/dosh/server.toml` — `port`, `bind`, `shell`, `prewarm_sessions`,
|
|
||||||
`native_auth`, `host_key`, `authorized_keys`, `attach_ticket_ttl_secs`,
|
|
||||||
`client_timeout_secs`, `allow_tcp_forwarding`, `allow_remote_forwarding`,
|
|
||||||
`allow_remote_non_loopback_bind`, `allow_agent_forwarding`, `accept_env`,
|
|
||||||
`native_auth_rate_limit_per_minute`.
|
|
||||||
|
|
||||||
Paths: host key `~/.config/dosh/host_key`; credential cache
|
|
||||||
`~/.local/share/dosh/credentials/`.
|
|
||||||
|
|
||||||
## Operating & diagnostics
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dosh doctor homelab # host resolution, trust state, UDP reachability, server
|
|
||||||
# version, usable keys, auth result, forwarding policy
|
|
||||||
dosh sessions homelab # list live sessions
|
|
||||||
dosh trust homelab # fetch + pin the Dosh host key (via SSH fallback)
|
|
||||||
dosh trust --remove homelab
|
|
||||||
dosh import-ssh homelab # write a hosts.toml entry from an SSH alias
|
|
||||||
dosh update # update the installed client
|
|
||||||
```
|
|
||||||
|
|
||||||
If something fails, `dosh doctor <host>` is the first stop — every public error is meant
|
|
||||||
to be actionable (host trust, auth failure, UDP blocked, forwarding denied, version
|
|
||||||
mismatch, server unavailable).
|
|
||||||
|
|
||||||
## For agents driving dosh
|
|
||||||
|
|
||||||
- Run one command and exit: `dosh <host> <cmd...>`. Render one frame and detach:
|
|
||||||
`dosh --attach-only <host>`. Both are non-interactive-friendly.
|
|
||||||
- A real terminal size is needed for full-screen rendering; with no TTY the client falls
|
|
||||||
back to 80×24. Pass `-v`/`-vv` for timing/diagnostic logs on stderr.
|
|
||||||
- Prefer `--session <name>` to reattach a known session; bare `dosh <host>` opens a
|
|
||||||
fresh, uniquely-named session each time.
|
|
||||||
- Never assume file transfer or X11 — use `ssh`/`scp` for those.
|
|
||||||
- Diagnostics are scriptable via `dosh doctor <host>`.
|
|
||||||
|
|
||||||
## Reference docs
|
|
||||||
|
|
||||||
- `README.md` — overview, install, develop, performance rules.
|
|
||||||
- `docs/NATIVE_V1_SPEC.md` — the native auth + forwarding v1 contract and verification
|
|
||||||
checklist (with current status).
|
|
||||||
- `docs/THREAT_MODEL.md` — security claims, threat model, residual risks.
|
|
||||||
- `docs/PUBLIC_READINESS.md` — feature matrix and §16 verification status.
|
|
||||||
- `docs/BENCHMARKS.md` — how to benchmark; metric definitions; sample numbers.
|
|
||||||
- `SPEC.md` — protocol/wire details.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
|
||||||
cd "$repo_root"
|
|
||||||
exec sh ./install.sh --from-current "$@"
|
|
||||||
@@ -8,6 +8,7 @@ Type=simple
|
|||||||
ExecStart=%h/.local/bin/dosh-server serve
|
ExecStart=%h/.local/bin/dosh-server serve
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=1
|
RestartSec=1
|
||||||
|
KillMode=process
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
|
|||||||
@@ -183,8 +183,7 @@ server_pid=""
|
|||||||
# meaningful here. The cold fresh-process reconnect fast path is the attach
|
# meaningful here. The cold fresh-process reconnect fast path is the attach
|
||||||
# ticket (measured above). Roaming resume is validated by the integration test
|
# ticket (measured above). Roaming resume is validated by the integration test
|
||||||
# `resume_updates_udp_endpoint_for_roaming`. `dosh-bench --resume` exists for
|
# `resume_updates_udp_endpoint_for_roaming`. `dosh-bench --resume` exists for
|
||||||
# scenarios that keep a live session out-of-band (e.g. remote soak tests); see
|
# scenarios that keep a live session out-of-band (e.g. remote soak tests).
|
||||||
# docs/BENCHMARKS.md.
|
|
||||||
rm -rf "$home/.local/share/dosh/credentials"
|
rm -rf "$home/.local/share/dosh/credentials"
|
||||||
write_client_config true
|
write_client_config true
|
||||||
start_server
|
start_server
|
||||||
|
|||||||
@@ -26,8 +26,16 @@ normalize_arch() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
os="$(normalize_os)"
|
host_os="$(normalize_os)"
|
||||||
arch="$(normalize_arch)"
|
os="${DOSH_PACKAGE_OS:-$host_os}"
|
||||||
|
arch="${DOSH_PACKAGE_ARCH:-$(normalize_arch)}"
|
||||||
|
target="${DOSH_PACKAGE_TARGET:-}"
|
||||||
|
if [ -z "$target" ] && [ "$os" = "windows" ] && [ "$host_os" != "windows" ]; then
|
||||||
|
case "$arch" in
|
||||||
|
x86_64) target="x86_64-pc-windows-gnu" ;;
|
||||||
|
*) echo "set DOSH_PACKAGE_TARGET for windows/$arch" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
if [ "$os" = "windows" ]; then
|
if [ "$os" = "windows" ]; then
|
||||||
artifact="dosh-$os-$arch.zip"
|
artifact="dosh-$os-$arch.zip"
|
||||||
versioned_artifact="dosh-$version-$os-$arch.zip"
|
versioned_artifact="dosh-$version-$os-$arch.zip"
|
||||||
@@ -36,16 +44,66 @@ else
|
|||||||
versioned_artifact="dosh-$version-$os-$arch.tar.gz"
|
versioned_artifact="dosh-$version-$os-$arch.tar.gz"
|
||||||
fi
|
fi
|
||||||
stage="$out_dir/stage/dosh"
|
stage="$out_dir/stage/dosh"
|
||||||
|
write_versioned="${DOSH_PACKAGE_VERSIONED:-0}"
|
||||||
|
|
||||||
cargo build --release
|
if [ -n "$target" ]; then
|
||||||
|
release_dir="target/$target/release"
|
||||||
|
else
|
||||||
|
release_dir="target/release"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$os" = "windows" ]; then
|
||||||
|
if [ -n "$target" ]; then
|
||||||
|
cargo build --release --target "$target" --bin dosh-client --bin dosh-bench
|
||||||
|
else
|
||||||
|
cargo build --release --bin dosh-client --bin dosh-bench
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ -n "$target" ]; then
|
||||||
|
cargo build --release --target "$target"
|
||||||
|
else
|
||||||
|
cargo build --release
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
rm -rf "$stage"
|
rm -rf "$stage"
|
||||||
mkdir -p "$stage/bin" "$out_dir"
|
mkdir -p "$stage/bin" "$out_dir"
|
||||||
for bin in dosh-client dosh-server dosh-auth dosh-bench; do
|
for bin in dosh-client dosh-server dosh-auth dosh-bench; do
|
||||||
if [ -f "target/release/$bin" ]; then
|
if [ -f "$release_dir/$bin" ]; then
|
||||||
install -m 0755 "target/release/$bin" "$stage/bin/$bin"
|
install -m 0755 "$release_dir/$bin" "$stage/bin/$bin"
|
||||||
|
elif [ -f "$release_dir/$bin.exe" ]; then
|
||||||
|
install -m 0755 "$release_dir/$bin.exe" "$stage/bin/$bin.exe"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
if [ -f "$stage/bin/dosh-client.exe" ]; then
|
||||||
|
cp "$stage/bin/dosh-client.exe" "$stage/bin/dosh.exe"
|
||||||
|
elif [ -f "$stage/bin/dosh-client" ]; then
|
||||||
|
cp "$stage/bin/dosh-client" "$stage/bin/dosh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
strip_bin() {
|
||||||
|
file="$1"
|
||||||
|
[ -f "$file" ] || return 0
|
||||||
|
case "$file" in
|
||||||
|
*.exe)
|
||||||
|
if command -v x86_64-w64-mingw32-strip >/dev/null 2>&1; then
|
||||||
|
x86_64-w64-mingw32-strip "$file" 2>/dev/null || true
|
||||||
|
elif command -v strip >/dev/null 2>&1; then
|
||||||
|
strip "$file" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if command -v strip >/dev/null 2>&1; then
|
||||||
|
strip "$file" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
for bin in "$stage"/bin/*; do
|
||||||
|
strip_bin "$bin"
|
||||||
|
done
|
||||||
|
|
||||||
printf '%s\n' "$version" >"$stage/VERSION"
|
printf '%s\n' "$version" >"$stage/VERSION"
|
||||||
|
|
||||||
if [ "$os" = "windows" ]; then
|
if [ "$os" = "windows" ]; then
|
||||||
@@ -60,8 +118,6 @@ if [ "$os" = "windows" ]; then
|
|||||||
else
|
else
|
||||||
tar -C "$out_dir/stage" -czf "$out_dir/$artifact" dosh
|
tar -C "$out_dir/stage" -czf "$out_dir/$artifact" dosh
|
||||||
fi
|
fi
|
||||||
cp "$out_dir/$artifact" "$out_dir/$versioned_artifact"
|
|
||||||
|
|
||||||
write_sha256() {
|
write_sha256() {
|
||||||
file="$1"
|
file="$1"
|
||||||
if command -v sha256sum >/dev/null 2>&1; then
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
@@ -74,12 +130,21 @@ write_sha256() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
write_sha256 "$out_dir/$artifact"
|
write_sha256 "$out_dir/$artifact"
|
||||||
write_sha256 "$out_dir/$versioned_artifact"
|
|
||||||
|
|
||||||
|
if [ "$write_versioned" = "1" ]; then
|
||||||
|
cp "$out_dir/$artifact" "$out_dir/$versioned_artifact"
|
||||||
|
write_sha256 "$out_dir/$versioned_artifact"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Wrote:\n'
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Wrote:
|
|
||||||
$out_dir/$artifact
|
$out_dir/$artifact
|
||||||
$out_dir/$artifact.sha256
|
$out_dir/$artifact.sha256
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ "$write_versioned" = "1" ]; then
|
||||||
|
cat <<EOF
|
||||||
$out_dir/$versioned_artifact
|
$out_dir/$versioned_artifact
|
||||||
$out_dir/$versioned_artifact.sha256
|
$out_dir/$versioned_artifact.sha256
|
||||||
EOF
|
EOF
|
||||||
|
fi
|
||||||
|
|||||||
Executable
+125
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
scripts/publish-gitea-release.sh [TAG]
|
||||||
|
|
||||||
|
Publishes the current Dosh release to Gitea.
|
||||||
|
|
||||||
|
Defaults:
|
||||||
|
TAG v<Cargo.toml version>
|
||||||
|
DOSH_PUBLISH_BUILD 1: run scripts/package-release.sh first
|
||||||
|
DOSH_PUBLISH_PUSH 1: push TAG to origin
|
||||||
|
DOSH_PUBLISH_PRUNE 1: remove stale dosh-<version>-* duplicate assets
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
GITEA_URL, GITEA_REPO, GITEA_TOKEN: same as upload-gitea-release.sh
|
||||||
|
DOSH_PUBLISH_ALLOW_DIRTY=1 allow uncommitted changes
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | sed -n '1p')"
|
||||||
|
tag="${1:-v$version}"
|
||||||
|
base="${GITEA_URL:-https://git.palav.dev}"
|
||||||
|
repo="${GITEA_REPO:-Palav/dosh}"
|
||||||
|
token="${GITEA_TOKEN:-}"
|
||||||
|
build="${DOSH_PUBLISH_BUILD:-1}"
|
||||||
|
push_tag="${DOSH_PUBLISH_PUSH:-1}"
|
||||||
|
prune="${DOSH_PUBLISH_PRUNE:-1}"
|
||||||
|
|
||||||
|
if [ -z "$token" ] && [ -f "$HOME/.config/dosh/gitea-token" ]; then
|
||||||
|
token="$(tr -d '\r\n' <"$HOME/.config/dosh/gitea-token")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
need() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
echo "missing required command: $1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
need git
|
||||||
|
need curl
|
||||||
|
|
||||||
|
if [ "${DOSH_PUBLISH_ALLOW_DIRTY:-0}" != "1" ] && [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo "refusing to publish with uncommitted changes" >&2
|
||||||
|
echo "commit first, or set DOSH_PUBLISH_ALLOW_DIRTY=1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
head="$(git rev-parse HEAD)"
|
||||||
|
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
|
||||||
|
tagged="$(git rev-list -n 1 "$tag")"
|
||||||
|
if [ "$tagged" != "$head" ]; then
|
||||||
|
echo "tag $tag points at $tagged, not HEAD $head" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
git tag "$tag" "$head"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$push_tag" = "1" ]; then
|
||||||
|
git push origin "$tag"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$build" = "1" ]; then
|
||||||
|
sh scripts/package-release.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
scripts/upload-gitea-release.sh "$tag"
|
||||||
|
|
||||||
|
api="$base/api/v1/repos/$repo"
|
||||||
|
auth_header="Authorization: token $token"
|
||||||
|
release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag")"
|
||||||
|
release_id=""
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$prune" = "1" ] && [ -n "$release_id" ] && command -v jq >/dev/null 2>&1; then
|
||||||
|
printf '%s\n' "$release_json" \
|
||||||
|
| jq -r '.assets[]? | select(.name | test("^dosh-[0-9]")) | [.id, .name] | @tsv' \
|
||||||
|
| while IFS="$(printf '\t')" read -r asset_id name; do
|
||||||
|
[ -n "$asset_id" ] || continue
|
||||||
|
echo "deleting duplicate $name"
|
||||||
|
curl -fsS -H "$auth_header" -X DELETE "$api/releases/$release_id/assets/$asset_id" >/dev/null
|
||||||
|
done
|
||||||
|
release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
web_repo="${base%/}/${repo}"
|
||||||
|
failed=0
|
||||||
|
for artifact in target/dosh-release/dosh-linux-*.tar.gz target/dosh-release/dosh-macos-*.tar.gz target/dosh-release/dosh-windows-*.zip; do
|
||||||
|
[ -f "$artifact" ] || continue
|
||||||
|
name="$(basename "$artifact")"
|
||||||
|
case "$name" in
|
||||||
|
dosh-[0-9]*)
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
url="$web_repo/releases/download/$tag/$name"
|
||||||
|
if curl -fsSI "$url" >/dev/null; then
|
||||||
|
echo "verified $url"
|
||||||
|
else
|
||||||
|
echo "missing release asset: $url" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$failed" != "0" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
printf '%s\n' "$release_json" | jq -r '"assets=\(.assets|length)", (.assets[] | "\(.name) \(.size)")'
|
||||||
|
fi
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
for test_name in \
|
||||||
|
live_output_forwards_terminal_control_sequences \
|
||||||
|
tui_control_sequences_survive_transport_verbatim \
|
||||||
|
unicode_output_survives_transport \
|
||||||
|
large_tui_paint_is_delivered_in_mtu_safe_frames \
|
||||||
|
resume_snapshot_preserves_alternate_screen_mode
|
||||||
|
do
|
||||||
|
cargo test --test integration_smoke "$test_name" -- --nocapture
|
||||||
|
done
|
||||||
@@ -31,13 +31,32 @@ repo="${GITEA_REPO:-Palav/dosh}"
|
|||||||
token="${GITEA_TOKEN:-}"
|
token="${GITEA_TOKEN:-}"
|
||||||
title="${GITEA_TITLE:-$tag}"
|
title="${GITEA_TITLE:-$tag}"
|
||||||
|
|
||||||
|
if [ -z "$token" ] && [ -f "$HOME/.config/dosh/gitea-token" ]; then
|
||||||
|
token="$(tr -d '\r\n' <"$HOME/.config/dosh/gitea-token")"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -z "$token" ]; then
|
if [ -z "$token" ]; then
|
||||||
echo "GITEA_TOKEN is required" >&2
|
echo "GITEA_TOKEN is required (or put it in ~/.config/dosh/gitea-token)" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$#" -eq 0 ]; then
|
if [ "$#" -eq 0 ]; then
|
||||||
set -- target/dosh-release/dosh-*
|
for artifact in \
|
||||||
|
target/dosh-release/dosh-linux-*.tar.gz \
|
||||||
|
target/dosh-release/dosh-macos-*.tar.gz \
|
||||||
|
target/dosh-release/dosh-windows-*.zip; do
|
||||||
|
[ -f "$artifact" ] || continue
|
||||||
|
case "$(basename "$artifact")" in
|
||||||
|
dosh-[0-9]*)
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [ -f "$artifact.sha256" ]; then
|
||||||
|
set -- "$@" "$artifact" "$artifact.sha256"
|
||||||
|
else
|
||||||
|
set -- "$@" "$artifact"
|
||||||
|
fi
|
||||||
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
api="$base/api/v1/repos/$repo"
|
api="$base/api/v1/repos/$repo"
|
||||||
@@ -83,6 +102,7 @@ delete_existing_asset() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uploaded=0
|
||||||
for artifact in "$@"; do
|
for artifact in "$@"; do
|
||||||
if [ ! -f "$artifact" ]; then
|
if [ ! -f "$artifact" ]; then
|
||||||
continue
|
continue
|
||||||
@@ -95,4 +115,10 @@ for artifact in "$@"; do
|
|||||||
-X POST \
|
-X POST \
|
||||||
-F "attachment=@$artifact" \
|
-F "attachment=@$artifact" \
|
||||||
"$api/releases/$release_id/assets?name=$name" >/dev/null
|
"$api/releases/$release_id/assets?name=$name" >/dev/null
|
||||||
|
uploaded=$((uploaded + 1))
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [ "$uploaded" -eq 0 ]; then
|
||||||
|
echo "no release artifacts uploaded" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
+9
-4
@@ -7,6 +7,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -82,10 +83,11 @@ pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> {
|
|||||||
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||||
}
|
}
|
||||||
let secret = crypto::random_32();
|
let secret = crypto::random_32();
|
||||||
let mut file = fs::OpenOptions::new()
|
let mut options = fs::OpenOptions::new();
|
||||||
.create_new(true)
|
options.create_new(true).write(true);
|
||||||
.write(true)
|
#[cfg(unix)]
|
||||||
.mode(0o600)
|
options.mode(0o600);
|
||||||
|
let mut file = options
|
||||||
.open(&path)
|
.open(&path)
|
||||||
.with_context(|| format!("create {}", path.display()))?;
|
.with_context(|| format!("create {}", path.display()))?;
|
||||||
file.write_all(URL_SAFE_NO_PAD.encode(secret).as_bytes())?;
|
file.write_all(URL_SAFE_NO_PAD.encode(secret).as_bytes())?;
|
||||||
@@ -93,6 +95,7 @@ pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> {
|
|||||||
Ok(secret)
|
Ok(secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_bootstrap(
|
pub fn build_bootstrap(
|
||||||
config: &ServerConfig,
|
config: &ServerConfig,
|
||||||
secret: &[u8; 32],
|
secret: &[u8; 32],
|
||||||
@@ -159,6 +162,7 @@ pub fn build_bootstrap(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn attach_token(
|
pub fn attach_token(
|
||||||
secret: &[u8; 32],
|
secret: &[u8; 32],
|
||||||
user: &str,
|
user: &str,
|
||||||
@@ -204,6 +208,7 @@ pub fn verify_bootstrap(resp: &BootstrapResponse, secret: &[u8; 32]) -> Result<b
|
|||||||
Ok(expected == resp.attach_token)
|
Ok(expected == resp.attach_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_attach_ticket(
|
pub fn build_attach_ticket(
|
||||||
secret: &[u8; 32],
|
secret: &[u8; 32],
|
||||||
server_id: [u8; 32],
|
server_id: [u8; 32],
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ use dosh::config::load_server_config;
|
|||||||
use dosh::native::{host_public_key, host_public_key_line, load_or_create_host_key};
|
use dosh::native::{host_public_key, host_public_key_line, load_or_create_host_key};
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "dosh-auth",
|
||||||
|
version = dosh::build_info::VERSION,
|
||||||
|
long_version = dosh::build_info::LONG_VERSION
|
||||||
|
)]
|
||||||
struct Args {
|
struct Args {
|
||||||
#[arg(long, default_value_t = 1)]
|
#[arg(long, default_value_t = 1)]
|
||||||
protocol: u8,
|
protocol: u8,
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ use std::sync::mpsc;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(name = "dosh-bench")]
|
#[command(
|
||||||
|
name = "dosh-bench",
|
||||||
|
version = dosh::build_info::VERSION,
|
||||||
|
long_version = dosh::build_info::LONG_VERSION
|
||||||
|
)]
|
||||||
struct Args {
|
struct Args {
|
||||||
#[arg(long, default_value = "local")]
|
#[arg(long, default_value = "local")]
|
||||||
server: String,
|
server: String,
|
||||||
|
|||||||
+1068
-122
File diff suppressed because it is too large
Load Diff
+296
-93
@@ -51,7 +51,11 @@ static AGENT_SOCK_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|||||||
const NATIVE_HANDSHAKE_TTL_SECS: u64 = 30;
|
const NATIVE_HANDSHAKE_TTL_SECS: u64 = 30;
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(name = "dosh-server")]
|
#[command(
|
||||||
|
name = "dosh-server",
|
||||||
|
version = dosh::build_info::VERSION,
|
||||||
|
long_version = dosh::build_info::LONG_VERSION
|
||||||
|
)]
|
||||||
struct Args {
|
struct Args {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Command,
|
command: Command,
|
||||||
@@ -215,6 +219,19 @@ async fn serve(config_path: Option<std::path::PathBuf>) -> Result<()> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let pacing_state = Arc::clone(&state);
|
||||||
|
let pacing_socket = Arc::clone(&socket);
|
||||||
|
let pacing_interval_ms = config.output_frame_interval_ms.max(1);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_millis(pacing_interval_ms));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if let Err(err) = flush_paced_snapshots(&pacing_state, &pacing_socket).await {
|
||||||
|
eprintln!("paced snapshot error: {err:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let cleanup_state = Arc::clone(&state);
|
let cleanup_state = Arc::clone(&state);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||||
@@ -370,6 +387,8 @@ struct ClientState {
|
|||||||
rows: u16,
|
rows: u16,
|
||||||
last_seen: Instant,
|
last_seen: Instant,
|
||||||
pending: VecDeque<PendingFrame>,
|
pending: VecDeque<PendingFrame>,
|
||||||
|
last_frame_sent: Instant,
|
||||||
|
paced_snapshot_due: bool,
|
||||||
allowed_forwardings: Vec<ForwardingRequest>,
|
allowed_forwardings: Vec<ForwardingRequest>,
|
||||||
stream_writers: HashMap<u64, mpsc::Sender<Vec<u8>>>,
|
stream_writers: HashMap<u64, mpsc::Sender<Vec<u8>>>,
|
||||||
opened_streams: HashSet<u64>,
|
opened_streams: HashSet<u64>,
|
||||||
@@ -486,16 +505,12 @@ impl ServerState {
|
|||||||
|
|
||||||
/// Whether a session named `name` should be backed by a persistent holder.
|
/// Whether a session named `name` should be backed by a persistent holder.
|
||||||
///
|
///
|
||||||
/// Persistence is only worthwhile for sessions a user can actually reattach
|
/// With persistence enabled, every PTY session gets a detached holder. That
|
||||||
/// to by name: prewarmed sessions and explicitly-named ones (`--session`).
|
/// includes implicit `term-<millis>-<pid>` sessions: a reconnecting client has
|
||||||
/// Implicit `term-<millis>-<pid>` names (from `dosh host` with no session)
|
/// the exact session name in its ticket cache, so it can reattach after a
|
||||||
/// are ephemeral — persisting them just leaves unreattachable holders
|
/// quick server restart without forcing users to name sessions manually.
|
||||||
/// lingering across restarts, so they use the in-process (dies-on-restart)
|
fn should_persist_session(&self, _name: &str) -> bool {
|
||||||
/// shell that reaps normally on disconnect.
|
|
||||||
fn should_persist_session(&self, name: &str) -> bool {
|
|
||||||
self.config.persist_sessions
|
self.config.persist_sessions
|
||||||
&& (self.config.prewarm_sessions.iter().any(|s| s == name)
|
|
||||||
|| !protocol::is_implicit_session_name(name))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn (or, in the persistent case, spawn-and-adopt) a PTY for a session.
|
/// Spawn (or, in the persistent case, spawn-and-adopt) a PTY for a session.
|
||||||
@@ -640,11 +655,11 @@ impl ServerState {
|
|||||||
/// Remove a client from whichever session holds it, keeping the index in
|
/// Remove a client from whichever session holds it, keeping the index in
|
||||||
/// sync. Returns true when a client was actually removed.
|
/// sync. Returns true when a client was actually removed.
|
||||||
fn remove_client_everywhere(&mut self, client_id: &[u8; 16]) -> bool {
|
fn remove_client_everywhere(&mut self, client_id: &[u8; 16]) -> bool {
|
||||||
if let Some(session_name) = self.client_index.remove(client_id) {
|
if let Some(session_name) = self.client_index.remove(client_id)
|
||||||
if let Some(session) = self.sessions.get_mut(&session_name) {
|
&& let Some(session) = self.sessions.get_mut(&session_name)
|
||||||
|
{
|
||||||
return session.clients.remove(client_id).is_some();
|
return session.clients.remove(client_id).is_some();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,8 +678,9 @@ impl ServerState {
|
|||||||
/// previous epoch's key must still decrypt instead of being dropped as fatal
|
/// previous epoch's key must still decrypt instead of being dropped as fatal
|
||||||
/// (spec: "stale encrypted packets ... ignored, not fatal"). We pick whichever
|
/// (spec: "stale encrypted packets ... ignored, not fatal"). We pick whichever
|
||||||
/// of the current or retained-previous key matches the packet's
|
/// of the current or retained-previous key matches the packet's
|
||||||
/// `session_key_id`, falling back to the current key when neither matches (so
|
/// `session_key_id`. If neither key id matches, the client is on a stale
|
||||||
/// a genuinely stale/forged packet still fails the AEAD check, not silently).
|
/// epoch and should be forced through the normal reconnect path instead of
|
||||||
|
/// waiting for a silent timeout.
|
||||||
fn lookup_client_key_for(
|
fn lookup_client_key_for(
|
||||||
&self,
|
&self,
|
||||||
client_id: &[u8; 16],
|
client_id: &[u8; 16],
|
||||||
@@ -681,7 +697,7 @@ impl ServerState {
|
|||||||
{
|
{
|
||||||
return Some((previous, session_name.clone()));
|
return Some((previous, session_name.clone()));
|
||||||
}
|
}
|
||||||
Some((client.session_key, session_name.clone()))
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// O(1) mutable access to a live client via the conn_id index.
|
/// O(1) mutable access to a live client via the conn_id index.
|
||||||
@@ -825,7 +841,7 @@ async fn handle_packet(
|
|||||||
| PacketKind::StreamEof
|
| PacketKind::StreamEof
|
||||||
| PacketKind::StreamWindowAdjust
|
| PacketKind::StreamWindowAdjust
|
||||||
| PacketKind::RekeyAck
|
| PacketKind::RekeyAck
|
||||||
if find_client_key(state, &packet.header.conn_id).is_err() =>
|
if find_client_decrypt_key(state, &packet.header).is_err() =>
|
||||||
{
|
{
|
||||||
send_reject_to_client(socket, peer, packet.header.conn_id, "unknown client").await
|
send_reject_to_client(socket, peer, packet.header.conn_id, "unknown client").await
|
||||||
}
|
}
|
||||||
@@ -976,9 +992,9 @@ async fn handle_native_user_auth(
|
|||||||
let body = protocol::decrypt_body(packet, &pending.session_key, CLIENT_TO_SERVER)?;
|
let body = protocol::decrypt_body(packet, &pending.session_key, CLIENT_TO_SERVER)?;
|
||||||
let req: NativeUserAuthBody = protocol::from_body(&body)?;
|
let req: NativeUserAuthBody = protocol::from_body(&body)?;
|
||||||
if pending.client.requested_mode == "doctor" {
|
if pending.client.requested_mode == "doctor" {
|
||||||
let check = {
|
let check_result = {
|
||||||
let locked = state.lock().expect("server state poisoned");
|
let locked = state.lock().expect("server state poisoned");
|
||||||
if let Err(err) = verify_native_user_auth_from_config(
|
verify_native_user_auth_from_config(
|
||||||
&locked.config,
|
&locked.config,
|
||||||
&pending.client,
|
&pending.client,
|
||||||
&pending.server,
|
&pending.server,
|
||||||
@@ -986,16 +1002,7 @@ async fn handle_native_user_auth(
|
|||||||
Some(peer.ip()),
|
Some(peer.ip()),
|
||||||
)
|
)
|
||||||
.context("verify native user auth")
|
.context("verify native user auth")
|
||||||
{
|
.map(|_| NativeAuthCheckOkBody {
|
||||||
return send_reject_to_client(
|
|
||||||
socket,
|
|
||||||
peer,
|
|
||||||
packet.header.conn_id,
|
|
||||||
&format!("{err:#}"),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
NativeAuthCheckOkBody {
|
|
||||||
requested_user: pending.client.requested_user.clone(),
|
requested_user: pending.client.requested_user.clone(),
|
||||||
native_auth_enabled: locked.config.native_auth,
|
native_auth_enabled: locked.config.native_auth,
|
||||||
allow_tcp_forwarding: locked.config.allow_tcp_forwarding,
|
allow_tcp_forwarding: locked.config.allow_tcp_forwarding,
|
||||||
@@ -1003,6 +1010,18 @@ async fn handle_native_user_auth(
|
|||||||
allow_agent_forwarding: locked.config.allow_agent_forwarding,
|
allow_agent_forwarding: locked.config.allow_agent_forwarding,
|
||||||
policy_flags: Vec::new(),
|
policy_flags: Vec::new(),
|
||||||
server_version: env!("CARGO_PKG_VERSION").to_string(),
|
server_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let check = match check_result {
|
||||||
|
Ok(check) => check,
|
||||||
|
Err(err) => {
|
||||||
|
return send_reject_to_client(
|
||||||
|
socket,
|
||||||
|
peer,
|
||||||
|
packet.header.conn_id,
|
||||||
|
&format!("{err:#}"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let body = protocol::to_body(&check)?;
|
let body = protocol::to_body(&check)?;
|
||||||
@@ -1054,17 +1073,19 @@ async fn handle_native_user_auth(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let attached: Result<(
|
struct NativeAttachResult {
|
||||||
[u8; 16],
|
client_id: [u8; 16],
|
||||||
[u8; 16],
|
key_id: [u8; 16],
|
||||||
Vec<u8>,
|
attach_ticket: Vec<u8>,
|
||||||
[u8; 32],
|
attach_ticket_psk: [u8; 32],
|
||||||
String,
|
session_name: String,
|
||||||
String,
|
mode: String,
|
||||||
u64,
|
output_seq: u64,
|
||||||
Vec<u8>,
|
snapshot: Vec<u8>,
|
||||||
Vec<ForwardingRequest>,
|
requested_forwardings: Vec<ForwardingRequest>,
|
||||||
)> = {
|
}
|
||||||
|
|
||||||
|
let attached: Result<NativeAttachResult> = {
|
||||||
let mut locked = state.lock().expect("server state poisoned");
|
let mut locked = state.lock().expect("server state poisoned");
|
||||||
verify_native_user_auth_from_config(
|
verify_native_user_auth_from_config(
|
||||||
&locked.config,
|
&locked.config,
|
||||||
@@ -1122,6 +1143,8 @@ async fn handle_native_user_auth(
|
|||||||
rows,
|
rows,
|
||||||
last_seen: Instant::now(),
|
last_seen: Instant::now(),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
|
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
||||||
|
paced_snapshot_due: false,
|
||||||
allowed_forwardings: req.auth.requested_forwardings.clone(),
|
allowed_forwardings: req.auth.requested_forwardings.clone(),
|
||||||
stream_writers: HashMap::new(),
|
stream_writers: HashMap::new(),
|
||||||
opened_streams: HashSet::new(),
|
opened_streams: HashSet::new(),
|
||||||
@@ -1137,7 +1160,7 @@ async fn handle_native_user_auth(
|
|||||||
previous_session_key: None,
|
previous_session_key: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Ok((
|
Ok(NativeAttachResult {
|
||||||
client_id,
|
client_id,
|
||||||
key_id,
|
key_id,
|
||||||
attach_ticket,
|
attach_ticket,
|
||||||
@@ -1146,22 +1169,12 @@ async fn handle_native_user_auth(
|
|||||||
mode,
|
mode,
|
||||||
output_seq,
|
output_seq,
|
||||||
snapshot,
|
snapshot,
|
||||||
req.auth.requested_forwardings.clone(),
|
requested_forwardings: req.auth.requested_forwardings.clone(),
|
||||||
))
|
})
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let (
|
let attached = match attached {
|
||||||
client_id,
|
|
||||||
key_id,
|
|
||||||
attach_ticket,
|
|
||||||
attach_ticket_psk,
|
|
||||||
session_name,
|
|
||||||
mode,
|
|
||||||
output_seq,
|
|
||||||
snapshot,
|
|
||||||
requested_forwardings,
|
|
||||||
) = match attached {
|
|
||||||
Ok(attached) => attached,
|
Ok(attached) => attached,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
// Auth/session setup failed: clean up the agent socket we bound early.
|
// Auth/session setup failed: clean up the agent socket we bound early.
|
||||||
@@ -1172,11 +1185,12 @@ async fn handle_native_user_auth(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let client_id = attached.client_id;
|
||||||
if let Err(err) = start_remote_forwards(
|
if let Err(err) = start_remote_forwards(
|
||||||
state,
|
state,
|
||||||
socket,
|
socket,
|
||||||
client_id,
|
client_id,
|
||||||
requested_forwardings.clone(),
|
attached.requested_forwardings.clone(),
|
||||||
pending.client.requested_session.clone(),
|
pending.client.requested_session.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1194,14 +1208,14 @@ async fn handle_native_user_auth(
|
|||||||
|
|
||||||
let ok = NativeAuthOk {
|
let ok = NativeAuthOk {
|
||||||
client_id,
|
client_id,
|
||||||
session: session_name,
|
session: attached.session_name,
|
||||||
mode,
|
mode: attached.mode,
|
||||||
session_key: pending.session_key,
|
session_key: pending.session_key,
|
||||||
session_key_id: key_id,
|
session_key_id: attached.key_id,
|
||||||
attach_ticket,
|
attach_ticket: attached.attach_ticket,
|
||||||
attach_ticket_psk,
|
attach_ticket_psk: attached.attach_ticket_psk,
|
||||||
initial_seq: output_seq,
|
initial_seq: attached.output_seq,
|
||||||
snapshot,
|
snapshot: attached.snapshot,
|
||||||
policy_flags: Vec::new(),
|
policy_flags: Vec::new(),
|
||||||
};
|
};
|
||||||
let body = protocol::to_body(&NativeAuthOkBody { ok })?;
|
let body = protocol::to_body(&NativeAuthOkBody { ok })?;
|
||||||
@@ -1225,15 +1239,16 @@ async fn handle_bootstrap_attach(
|
|||||||
body: Vec<u8>,
|
body: Vec<u8>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let req: BootstrapAttachRequest = protocol::from_body(&body)?;
|
let req: BootstrapAttachRequest = protocol::from_body(&body)?;
|
||||||
let (client_id, key, key_id, session_name, mode, output_seq, snapshot) = {
|
type BootstrapAttachResult = ([u8; 16], [u8; 32], [u8; 16], String, String, u64, Vec<u8>);
|
||||||
|
let attached: Result<BootstrapAttachResult> = {
|
||||||
let mut locked = state.lock().expect("server state poisoned");
|
let mut locked = state.lock().expect("server state poisoned");
|
||||||
if !verify_bootstrap(&req.bootstrap, &locked.secret)? {
|
if !verify_bootstrap(&req.bootstrap, &locked.secret)? {
|
||||||
return send_reject(socket, peer, "invalid or expired bootstrap").await;
|
Err(anyhow!("invalid or expired bootstrap"))
|
||||||
}
|
} else if !locked.sessions.contains_key(&req.bootstrap.session)
|
||||||
if !locked.sessions.contains_key(&req.bootstrap.session) && !locked.config.create_on_attach
|
&& !locked.config.create_on_attach
|
||||||
{
|
{
|
||||||
return send_reject(socket, peer, "session does not exist").await;
|
Err(anyhow!("session does not exist"))
|
||||||
}
|
} else {
|
||||||
locked.ensure_session(
|
locked.ensure_session(
|
||||||
&req.bootstrap.session,
|
&req.bootstrap.session,
|
||||||
req.cols,
|
req.cols,
|
||||||
@@ -1271,6 +1286,8 @@ async fn handle_bootstrap_attach(
|
|||||||
rows: req.rows,
|
rows: req.rows,
|
||||||
last_seen: Instant::now(),
|
last_seen: Instant::now(),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
|
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
||||||
|
paced_snapshot_due: false,
|
||||||
allowed_forwardings: Vec::new(),
|
allowed_forwardings: Vec::new(),
|
||||||
stream_writers: HashMap::new(),
|
stream_writers: HashMap::new(),
|
||||||
opened_streams: HashSet::new(),
|
opened_streams: HashSet::new(),
|
||||||
@@ -1286,7 +1303,7 @@ async fn handle_bootstrap_attach(
|
|||||||
previous_session_key: None,
|
previous_session_key: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
(
|
Ok((
|
||||||
client_id,
|
client_id,
|
||||||
req.bootstrap.session_key,
|
req.bootstrap.session_key,
|
||||||
req.bootstrap.session_key_id,
|
req.bootstrap.session_key_id,
|
||||||
@@ -1294,7 +1311,12 @@ async fn handle_bootstrap_attach(
|
|||||||
req.bootstrap.mode.clone(),
|
req.bootstrap.mode.clone(),
|
||||||
output_seq,
|
output_seq,
|
||||||
snapshot,
|
snapshot,
|
||||||
)
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (client_id, key, key_id, session_name, mode, output_seq, snapshot) = match attached {
|
||||||
|
Ok(attached) => attached,
|
||||||
|
Err(err) => return send_reject(socket, peer, &err.to_string()).await,
|
||||||
};
|
};
|
||||||
let ok = AttachOk {
|
let ok = AttachOk {
|
||||||
client_id,
|
client_id,
|
||||||
@@ -1326,11 +1348,11 @@ async fn handle_ticket_attach(
|
|||||||
body: Vec<u8>,
|
body: Vec<u8>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let env: TicketAttachEnvelope = protocol::from_body(&body)?;
|
let env: TicketAttachEnvelope = protocol::from_body(&body)?;
|
||||||
let (ticket, request_plain) = {
|
let opened = {
|
||||||
let locked = state.lock().expect("server state poisoned");
|
let locked = state.lock().expect("server state poisoned");
|
||||||
if !locked.config.allow_attach_tickets {
|
if !locked.config.allow_attach_tickets {
|
||||||
return send_reject(socket, peer, "attach tickets disabled").await;
|
Err(anyhow!("attach tickets disabled"))
|
||||||
}
|
} else {
|
||||||
let ticket = open_attach_ticket(&locked.secret, &env.ticket)?;
|
let ticket = open_attach_ticket(&locked.secret, &env.ticket)?;
|
||||||
let request_key = crypto::hkdf32(
|
let request_key = crypto::hkdf32(
|
||||||
&ticket.psk,
|
&ticket.psk,
|
||||||
@@ -1343,7 +1365,12 @@ async fn handle_ticket_attach(
|
|||||||
b"dosh-ticket-attach-request-v1",
|
b"dosh-ticket-attach-request-v1",
|
||||||
&env.ciphertext,
|
&env.ciphertext,
|
||||||
)?;
|
)?;
|
||||||
(ticket, request_plain)
|
Ok((ticket, request_plain))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (ticket, request_plain) = match opened {
|
||||||
|
Ok(opened) => opened,
|
||||||
|
Err(err) => return send_reject(socket, peer, &err.to_string()).await,
|
||||||
};
|
};
|
||||||
let req: TicketAttachBody = protocol::from_body(&request_plain)?;
|
let req: TicketAttachBody = protocol::from_body(&request_plain)?;
|
||||||
if req.session != ticket.session || req.mode != ticket.mode {
|
if req.session != ticket.session || req.mode != ticket.mode {
|
||||||
@@ -1352,11 +1379,11 @@ async fn handle_ticket_attach(
|
|||||||
|
|
||||||
let session_key = crypto::random_32();
|
let session_key = crypto::random_32();
|
||||||
let session_key_id = protocol::session_key_id(&session_key);
|
let session_key_id = protocol::session_key_id(&session_key);
|
||||||
let (client_id, output_seq, snapshot) = {
|
let attached = {
|
||||||
let mut locked = state.lock().expect("server state poisoned");
|
let mut locked = state.lock().expect("server state poisoned");
|
||||||
if !locked.sessions.contains_key(&req.session) && !locked.config.create_on_attach {
|
if !locked.sessions.contains_key(&req.session) && !locked.config.create_on_attach {
|
||||||
return send_reject(socket, peer, "session does not exist").await;
|
Err(anyhow!("session does not exist"))
|
||||||
}
|
} else {
|
||||||
locked.ensure_session(
|
locked.ensure_session(
|
||||||
&req.session,
|
&req.session,
|
||||||
req.cols,
|
req.cols,
|
||||||
@@ -1394,6 +1421,8 @@ async fn handle_ticket_attach(
|
|||||||
rows: req.rows,
|
rows: req.rows,
|
||||||
last_seen: Instant::now(),
|
last_seen: Instant::now(),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
|
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
||||||
|
paced_snapshot_due: false,
|
||||||
allowed_forwardings: Vec::new(),
|
allowed_forwardings: Vec::new(),
|
||||||
stream_writers: HashMap::new(),
|
stream_writers: HashMap::new(),
|
||||||
opened_streams: HashSet::new(),
|
opened_streams: HashSet::new(),
|
||||||
@@ -1409,7 +1438,12 @@ async fn handle_ticket_attach(
|
|||||||
previous_session_key: None,
|
previous_session_key: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
(client_id, output_seq, snapshot)
|
Ok((client_id, output_seq, snapshot))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (client_id, output_seq, snapshot) = match attached {
|
||||||
|
Ok(attached) => attached,
|
||||||
|
Err(err) => return send_reject(socket, peer, &err.to_string()).await,
|
||||||
};
|
};
|
||||||
|
|
||||||
let ok = AttachOk {
|
let ok = AttachOk {
|
||||||
@@ -2630,6 +2664,8 @@ async fn broadcast_output(
|
|||||||
let mut locked = state.lock().expect("server state poisoned");
|
let mut locked = state.lock().expect("server state poisoned");
|
||||||
let scrollback = locked.config.scrollback;
|
let scrollback = locked.config.scrollback;
|
||||||
let retransmit_window = locked.config.retransmit_window;
|
let retransmit_window = locked.config.retransmit_window;
|
||||||
|
let frame_interval = Duration::from_millis(locked.config.output_frame_interval_ms);
|
||||||
|
let now = Instant::now();
|
||||||
let session = locked
|
let session = locked
|
||||||
.sessions
|
.sessions
|
||||||
.get_mut(&output.session)
|
.get_mut(&output.session)
|
||||||
@@ -2664,6 +2700,15 @@ async fn broadcast_output(
|
|||||||
}
|
}
|
||||||
let mut sends = Vec::new();
|
let mut sends = Vec::new();
|
||||||
for (client_id, client) in session.clients.iter_mut() {
|
for (client_id, client) in session.clients.iter_mut() {
|
||||||
|
let terminal_control =
|
||||||
|
output.bytes.contains(&0x1b) || session.parser.screen().alternate_screen();
|
||||||
|
if !terminal_control
|
||||||
|
&& !frame_interval.is_zero()
|
||||||
|
&& now.duration_since(client.last_frame_sent) < frame_interval
|
||||||
|
{
|
||||||
|
client.paced_snapshot_due = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
client.send_seq += 1;
|
client.send_seq += 1;
|
||||||
// Count traffic toward the packet-count rekey trigger (spec §11).
|
// Count traffic toward the packet-count rekey trigger (spec §11).
|
||||||
client.epoch_packets = client.epoch_packets.saturating_add(1);
|
client.epoch_packets = client.epoch_packets.saturating_add(1);
|
||||||
@@ -2702,6 +2747,8 @@ async fn broadcast_output(
|
|||||||
last_sent: Instant::now(),
|
last_sent: Instant::now(),
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
});
|
});
|
||||||
|
client.last_frame_sent = now;
|
||||||
|
client.paced_snapshot_due = false;
|
||||||
sends.push((client.endpoint, packet));
|
sends.push((client.endpoint, packet));
|
||||||
}
|
}
|
||||||
sends
|
sends
|
||||||
@@ -2780,6 +2827,7 @@ fn screen_snapshot(screen: &vt100::Screen) -> Vec<u8> {
|
|||||||
let mut bytes = Vec::new();
|
let mut bytes = Vec::new();
|
||||||
if screen.alternate_screen() {
|
if screen.alternate_screen() {
|
||||||
bytes.extend_from_slice(b"\x1b[?1049h");
|
bytes.extend_from_slice(b"\x1b[?1049h");
|
||||||
|
bytes.extend_from_slice(b"\x1b[H\x1b[2J");
|
||||||
} else {
|
} else {
|
||||||
bytes.extend_from_slice(b"\x1b[?1049l");
|
bytes.extend_from_slice(b"\x1b[?1049l");
|
||||||
}
|
}
|
||||||
@@ -2852,6 +2900,76 @@ async fn retransmit_pending(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn flush_paced_snapshots(
|
||||||
|
state: &Arc<Mutex<ServerState>>,
|
||||||
|
socket: &Arc<UdpSocket>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let sends = {
|
||||||
|
let mut locked = state.lock().expect("server state poisoned");
|
||||||
|
let frame_interval = Duration::from_millis(locked.config.output_frame_interval_ms);
|
||||||
|
if frame_interval.is_zero() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let retransmit_window = locked.config.retransmit_window;
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut sends = Vec::new();
|
||||||
|
for (session_name, session) in locked.sessions.iter_mut() {
|
||||||
|
let due = session.clients.values().any(|client| {
|
||||||
|
client.paced_snapshot_due
|
||||||
|
&& now.duration_since(client.last_frame_sent) >= frame_interval
|
||||||
|
});
|
||||||
|
if !due {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let snapshot = screen_snapshot(session.parser.screen());
|
||||||
|
let output_seq = session.output_seq;
|
||||||
|
for (client_id, client) in session.clients.iter_mut() {
|
||||||
|
if !client.paced_snapshot_due
|
||||||
|
|| now.duration_since(client.last_frame_sent) < frame_interval
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
client.send_seq += 1;
|
||||||
|
client.epoch_packets = client.epoch_packets.saturating_add(1);
|
||||||
|
let frame = Frame {
|
||||||
|
session: session_name.clone(),
|
||||||
|
output_seq,
|
||||||
|
bytes: snapshot.clone(),
|
||||||
|
snapshot: true,
|
||||||
|
closed: false,
|
||||||
|
};
|
||||||
|
let body = protocol::to_body(&frame)?;
|
||||||
|
let packet = protocol::encode_encrypted(
|
||||||
|
PacketKind::Frame,
|
||||||
|
*client_id,
|
||||||
|
client.send_seq,
|
||||||
|
client.last_acked,
|
||||||
|
&client.session_key,
|
||||||
|
SERVER_TO_CLIENT,
|
||||||
|
&body,
|
||||||
|
)?;
|
||||||
|
while client.pending.len() >= retransmit_window {
|
||||||
|
client.pending.pop_front();
|
||||||
|
}
|
||||||
|
client.pending.push_back(PendingFrame {
|
||||||
|
output_seq,
|
||||||
|
packet: packet.clone(),
|
||||||
|
last_sent: now,
|
||||||
|
attempts: 0,
|
||||||
|
});
|
||||||
|
client.last_frame_sent = now;
|
||||||
|
client.paced_snapshot_due = false;
|
||||||
|
sends.push((client.endpoint, packet));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sends
|
||||||
|
};
|
||||||
|
for (endpoint, packet) in sends {
|
||||||
|
socket.send_to(&packet, endpoint).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// How long the previous epoch's key is retained after a rekey so in-flight
|
/// How long the previous epoch's key is retained after a rekey so in-flight
|
||||||
/// pre-rekey packets still decrypt instead of being dropped as fatal.
|
/// pre-rekey packets still decrypt instead of being dropped as fatal.
|
||||||
const REKEY_PREVIOUS_KEY_GRACE_SECS: u64 = 5;
|
const REKEY_PREVIOUS_KEY_GRACE_SECS: u64 = 5;
|
||||||
@@ -3060,19 +3178,10 @@ fn cleanup_disconnected_clients(state: &Arc<Mutex<ServerState>>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_client_key(
|
/// Select the client key (current or retained previous epoch) matching the
|
||||||
state: &Arc<Mutex<ServerState>>,
|
/// packet header's `session_key_id`, so a packet sealed under the pre-rekey key
|
||||||
client_id: &[u8; 16],
|
/// during the grace window still decrypts while an expired/stale epoch is
|
||||||
) -> Result<([u8; 32], String)> {
|
/// treated as an unknown client and triggers a client reconnect.
|
||||||
let locked = state.lock().expect("server state poisoned");
|
|
||||||
locked
|
|
||||||
.lookup_client(client_id)
|
|
||||||
.ok_or_else(|| anyhow!("unknown client"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like [`find_client_key`] but selects the key (current or retained previous
|
|
||||||
/// epoch) matching the packet header's `session_key_id`, so a packet sealed
|
|
||||||
/// under the pre-rekey key during the grace window still decrypts.
|
|
||||||
fn find_client_decrypt_key(
|
fn find_client_decrypt_key(
|
||||||
state: &Arc<Mutex<ServerState>>,
|
state: &Arc<Mutex<ServerState>>,
|
||||||
header: &protocol::Header,
|
header: &protocol::Header,
|
||||||
@@ -3115,7 +3224,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn persists_named_and_prewarmed_but_not_implicit_sessions() {
|
fn persists_all_sessions_when_enabled() {
|
||||||
let (pty_tx, _rx) = mpsc::unbounded_channel();
|
let (pty_tx, _rx) = mpsc::unbounded_channel();
|
||||||
let config = ServerConfig {
|
let config = ServerConfig {
|
||||||
persist_sessions: true,
|
persist_sessions: true,
|
||||||
@@ -3125,7 +3234,7 @@ mod tests {
|
|||||||
let state = ServerState::new(config, [0u8; 32], pty_tx);
|
let state = ServerState::new(config, [0u8; 32], pty_tx);
|
||||||
assert!(state.should_persist_session("default")); // prewarmed
|
assert!(state.should_persist_session("default")); // prewarmed
|
||||||
assert!(state.should_persist_session("work")); // explicitly named
|
assert!(state.should_persist_session("work")); // explicitly named
|
||||||
assert!(!state.should_persist_session("term-1781470634216-76685")); // implicit
|
assert!(state.should_persist_session("term-1781470634216-76685")); // implicit reconnect
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3185,6 +3294,11 @@ mod tests {
|
|||||||
assert!(limiter.buckets.is_empty());
|
assert!(limiter.buckets.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_client_timeout_supports_long_sleep() {
|
||||||
|
assert_eq!(ServerConfig::default().client_timeout_secs, 2_592_000);
|
||||||
|
}
|
||||||
|
|
||||||
fn test_client_state(session_key: [u8; 32]) -> ClientState {
|
fn test_client_state(session_key: [u8; 32]) -> ClientState {
|
||||||
ClientState {
|
ClientState {
|
||||||
endpoint: "127.0.0.1:9".parse().unwrap(),
|
endpoint: "127.0.0.1:9".parse().unwrap(),
|
||||||
@@ -3197,6 +3311,8 @@ mod tests {
|
|||||||
rows: 24,
|
rows: 24,
|
||||||
last_seen: Instant::now(),
|
last_seen: Instant::now(),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
|
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
||||||
|
paced_snapshot_due: false,
|
||||||
allowed_forwardings: Vec::new(),
|
allowed_forwardings: Vec::new(),
|
||||||
stream_writers: HashMap::new(),
|
stream_writers: HashMap::new(),
|
||||||
opened_streams: HashSet::new(),
|
opened_streams: HashSet::new(),
|
||||||
@@ -3397,6 +3513,8 @@ mod tests {
|
|||||||
rows: 24,
|
rows: 24,
|
||||||
last_seen: Instant::now(),
|
last_seen: Instant::now(),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
|
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
||||||
|
paced_snapshot_due: false,
|
||||||
allowed_forwardings: Vec::new(),
|
allowed_forwardings: Vec::new(),
|
||||||
stream_writers: HashMap::new(),
|
stream_writers: HashMap::new(),
|
||||||
opened_streams: HashSet::new(),
|
opened_streams: HashSet::new(),
|
||||||
@@ -3490,6 +3608,8 @@ mod tests {
|
|||||||
rows: 24,
|
rows: 24,
|
||||||
last_seen: Instant::now(),
|
last_seen: Instant::now(),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
|
last_frame_sent: Instant::now() - Duration::from_secs(3600),
|
||||||
|
paced_snapshot_due: false,
|
||||||
allowed_forwardings: Vec::new(),
|
allowed_forwardings: Vec::new(),
|
||||||
stream_writers: HashMap::new(),
|
stream_writers: HashMap::new(),
|
||||||
opened_streams: HashSet::from([42]),
|
opened_streams: HashSet::from([42]),
|
||||||
@@ -3557,6 +3677,89 @@ mod tests {
|
|||||||
assert_eq!(frame.bytes, b"terminal-priority");
|
assert_eq!(frame.bytes, b"terminal-priority");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn output_pacing_coalesces_fast_frames_into_snapshot() {
|
||||||
|
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
|
||||||
|
let config = ServerConfig {
|
||||||
|
output_frame_interval_ms: 1000,
|
||||||
|
..ServerConfig::default()
|
||||||
|
};
|
||||||
|
let mut state = ServerState::new(config, [0u8; 32], pty_tx);
|
||||||
|
state
|
||||||
|
.ensure_session("test", 80, 24, "forward-only", &[])
|
||||||
|
.unwrap();
|
||||||
|
let client_id = [42u8; 16];
|
||||||
|
let session_key = [7u8; 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 mut client = test_client_state(session_key);
|
||||||
|
client.endpoint = receiver.local_addr().unwrap();
|
||||||
|
state.insert_client("test", client_id, client);
|
||||||
|
let state = Arc::new(Mutex::new(state));
|
||||||
|
|
||||||
|
broadcast_output(
|
||||||
|
&state,
|
||||||
|
&sender,
|
||||||
|
PtyOutput {
|
||||||
|
session: "test".to_string(),
|
||||||
|
bytes: b"first".to_vec(),
|
||||||
|
exited: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
broadcast_output(
|
||||||
|
&state,
|
||||||
|
&sender,
|
||||||
|
PtyOutput {
|
||||||
|
session: "test".to_string(),
|
||||||
|
bytes: b"second".to_vec(),
|
||||||
|
exited: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
let (n, _) = tokio::time::timeout(Duration::from_secs(1), receiver.recv_from(&mut buf))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let packet = protocol::decode(&buf[..n]).unwrap();
|
||||||
|
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT).unwrap();
|
||||||
|
let frame: Frame = protocol::from_body(&plain).unwrap();
|
||||||
|
assert!(!frame.snapshot);
|
||||||
|
assert_eq!(frame.bytes, b"first");
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(30), receiver.recv_from(&mut buf))
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"second frame should be coalesced"
|
||||||
|
);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut locked = state.lock().unwrap();
|
||||||
|
let client = locked
|
||||||
|
.sessions
|
||||||
|
.get_mut("test")
|
||||||
|
.unwrap()
|
||||||
|
.clients
|
||||||
|
.get_mut(&client_id)
|
||||||
|
.unwrap();
|
||||||
|
client.last_frame_sent = Instant::now() - Duration::from_secs(2);
|
||||||
|
}
|
||||||
|
flush_paced_snapshots(&state, &sender).await.unwrap();
|
||||||
|
let (n, _) = tokio::time::timeout(Duration::from_secs(1), receiver.recv_from(&mut buf))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let packet = protocol::decode(&buf[..n]).unwrap();
|
||||||
|
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT).unwrap();
|
||||||
|
let frame: Frame = protocol::from_body(&plain).unwrap();
|
||||||
|
assert!(frame.snapshot);
|
||||||
|
assert_eq!(frame.output_seq, 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_non_loopback_bind_requires_explicit_config() {
|
fn remote_non_loopback_bind_requires_explicit_config() {
|
||||||
let config = ServerConfig::default();
|
let config = ServerConfig::default();
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
pub const GIT_HASH: &str = env!("DOSH_GIT_HASH");
|
||||||
|
pub const COMMIT_DATE: &str = env!("DOSH_COMMIT_DATE");
|
||||||
|
pub const LONG_VERSION: &str = concat!(
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
" (",
|
||||||
|
env!("DOSH_GIT_HASH"),
|
||||||
|
", ",
|
||||||
|
env!("DOSH_COMMIT_DATE"),
|
||||||
|
")"
|
||||||
|
);
|
||||||
+22
-15
@@ -14,6 +14,8 @@ pub struct ServerConfig {
|
|||||||
pub allow_attach_tickets: bool,
|
pub allow_attach_tickets: bool,
|
||||||
pub client_timeout_secs: u64,
|
pub client_timeout_secs: u64,
|
||||||
pub retransmit_window: usize,
|
pub retransmit_window: usize,
|
||||||
|
#[serde(default = "default_output_frame_interval_ms")]
|
||||||
|
pub output_frame_interval_ms: u64,
|
||||||
pub default_input_mode: String,
|
pub default_input_mode: String,
|
||||||
pub prewarm_sessions: Vec<String>,
|
pub prewarm_sessions: Vec<String>,
|
||||||
pub create_on_attach: bool,
|
pub create_on_attach: bool,
|
||||||
@@ -46,16 +48,9 @@ pub struct ServerConfig {
|
|||||||
pub allow_agent_forwarding: bool,
|
pub allow_agent_forwarding: bool,
|
||||||
#[serde(default = "default_accept_env")]
|
#[serde(default = "default_accept_env")]
|
||||||
pub accept_env: Vec<String>,
|
pub accept_env: Vec<String>,
|
||||||
/// Run each terminal session's shell under a small per-session *holder*
|
/// Run terminal sessions under per-session holder processes so shells
|
||||||
/// process so it survives a `dosh-server` restart (crash/upgrade/
|
/// survive a quick `dosh-server` restart and clients can reconnect.
|
||||||
/// `systemctl restart`). On startup the server re-adopts live holders and
|
#[serde(default = "default_true")]
|
||||||
/// reattaching clients land on the same shell with screen state intact. When
|
|
||||||
/// `false`, sessions behave exactly as before: the shell is a child of the
|
|
||||||
/// server and dies with it.
|
|
||||||
// Opt-in for now: this changes the core session model (per-session holder
|
|
||||||
// processes + fd passing), so it stays OFF by default until it has been
|
|
||||||
// stress-tested on a real host. Set `persist_sessions = true` to enable.
|
|
||||||
#[serde(default)]
|
|
||||||
pub persist_sessions: bool,
|
pub persist_sessions: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,8 +63,9 @@ impl Default for ServerConfig {
|
|||||||
auth_ttl_secs: 30,
|
auth_ttl_secs: 30,
|
||||||
attach_ticket_ttl_secs: 3600,
|
attach_ticket_ttl_secs: 3600,
|
||||||
allow_attach_tickets: true,
|
allow_attach_tickets: true,
|
||||||
client_timeout_secs: 86400,
|
client_timeout_secs: 2_592_000,
|
||||||
retransmit_window: 256,
|
retransmit_window: 256,
|
||||||
|
output_frame_interval_ms: default_output_frame_interval_ms(),
|
||||||
default_input_mode: "read-write".to_string(),
|
default_input_mode: "read-write".to_string(),
|
||||||
prewarm_sessions: vec!["default".to_string()],
|
prewarm_sessions: vec!["default".to_string()],
|
||||||
create_on_attach: true,
|
create_on_attach: true,
|
||||||
@@ -87,7 +83,7 @@ impl Default for ServerConfig {
|
|||||||
allow_remote_non_loopback_bind: false,
|
allow_remote_non_loopback_bind: false,
|
||||||
allow_agent_forwarding: false,
|
allow_agent_forwarding: false,
|
||||||
accept_env: default_accept_env(),
|
accept_env: default_accept_env(),
|
||||||
persist_sessions: false,
|
persist_sessions: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,6 +128,8 @@ pub struct ClientConfig {
|
|||||||
/// full-screen TUI, and cleared the instant packets resume. Default on.
|
/// full-screen TUI, and cleared the instant packets resume. Default on.
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub disconnect_status: bool,
|
pub disconnect_status: bool,
|
||||||
|
#[serde(default = "default_escape_key")]
|
||||||
|
pub escape_key: String,
|
||||||
#[serde(default = "default_send_env")]
|
#[serde(default = "default_send_env")]
|
||||||
pub send_env: Vec<String>,
|
pub send_env: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -205,6 +203,7 @@ impl Default for ClientConfig {
|
|||||||
use_ssh_agent: true,
|
use_ssh_agent: true,
|
||||||
forward_agent: false,
|
forward_agent: false,
|
||||||
disconnect_status: true,
|
disconnect_status: true,
|
||||||
|
escape_key: default_escape_key(),
|
||||||
send_env: default_send_env(),
|
send_env: default_send_env(),
|
||||||
set_env: HashMap::new(),
|
set_env: HashMap::new(),
|
||||||
extensions: HashMap::new(),
|
extensions: HashMap::new(),
|
||||||
@@ -231,6 +230,10 @@ fn default_native_auth_rate_limit_per_minute() -> u32 {
|
|||||||
30
|
30
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_output_frame_interval_ms() -> u64 {
|
||||||
|
16
|
||||||
|
}
|
||||||
|
|
||||||
fn default_rekey_after_packets() -> u64 {
|
fn default_rekey_after_packets() -> u64 {
|
||||||
// Rotate well before any AEAD nonce-reuse concern; ChaCha20-Poly1305 with a
|
// Rotate well before any AEAD nonce-reuse concern; ChaCha20-Poly1305 with a
|
||||||
// per-direction monotonic seq is safe far beyond this, but a bounded epoch
|
// per-direction monotonic seq is safe far beyond this, but a bounded epoch
|
||||||
@@ -259,6 +262,10 @@ fn default_known_hosts() -> String {
|
|||||||
"~/.config/dosh/known_hosts".to_string()
|
"~/.config/dosh/known_hosts".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_escape_key() -> String {
|
||||||
|
"^]".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_identity_files() -> Vec<String> {
|
fn default_identity_files() -> Vec<String> {
|
||||||
vec!["~/.ssh/id_ed25519".to_string()]
|
vec!["~/.ssh/id_ed25519".to_string()]
|
||||||
}
|
}
|
||||||
@@ -304,10 +311,10 @@ pub fn load_hosts_config(path: Option<PathBuf>) -> Result<HostsConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn expand_tilde(path: &str) -> PathBuf {
|
pub fn expand_tilde(path: &str) -> PathBuf {
|
||||||
if let Some(rest) = path.strip_prefix("~/") {
|
if let Some(rest) = path.strip_prefix("~/")
|
||||||
if let Some(home) = dirs::home_dir() {
|
&& let Some(home) = dirs::home_dir()
|
||||||
|
{
|
||||||
return home.join(rest);
|
return home.join(rest);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
PathBuf::from(path)
|
PathBuf::from(path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod build_info;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod native;
|
pub mod native;
|
||||||
|
#[cfg(unix)]
|
||||||
pub mod persist;
|
pub mod persist;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
|
#[cfg(unix)]
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod ssh_agent;
|
pub mod ssh_agent;
|
||||||
|
|||||||
+11
-9
@@ -11,6 +11,7 @@ use signature::{SignatureEncoding, Signer as SignatureSigner, Verifier as Signat
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
@@ -153,10 +154,11 @@ pub fn load_or_create_host_key(config: &ServerConfig) -> Result<SigningKey> {
|
|||||||
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||||
}
|
}
|
||||||
let bytes = crypto::random_32();
|
let bytes = crypto::random_32();
|
||||||
let mut file = fs::OpenOptions::new()
|
let mut options = fs::OpenOptions::new();
|
||||||
.create_new(true)
|
options.create_new(true).write(true);
|
||||||
.write(true)
|
#[cfg(unix)]
|
||||||
.mode(0o600)
|
options.mode(0o600);
|
||||||
|
let mut file = options
|
||||||
.open(&path)
|
.open(&path)
|
||||||
.with_context(|| format!("create {}", path.display()))?;
|
.with_context(|| format!("create {}", path.display()))?;
|
||||||
file.write_all(URL_SAFE_NO_PAD.encode(bytes).as_bytes())?;
|
file.write_all(URL_SAFE_NO_PAD.encode(bytes).as_bytes())?;
|
||||||
@@ -1233,11 +1235,11 @@ fn write_known_host_entries(path: &Path, entries: &[KnownHost]) -> Result<()> {
|
|||||||
));
|
));
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
let mut file = fs::OpenOptions::new()
|
let mut options = fs::OpenOptions::new();
|
||||||
.create(true)
|
options.create(true).truncate(true).write(true);
|
||||||
.truncate(true)
|
#[cfg(unix)]
|
||||||
.write(true)
|
options.mode(0o600);
|
||||||
.mode(0o600)
|
let mut file = options
|
||||||
.open(path)
|
.open(path)
|
||||||
.with_context(|| format!("write {}", path.display()))?;
|
.with_context(|| format!("write {}", path.display()))?;
|
||||||
file.write_all(out.as_bytes())
|
file.write_all(out.as_bytes())
|
||||||
|
|||||||
+5
-1
@@ -28,6 +28,7 @@ use std::time::Duration;
|
|||||||
/// One-byte commands a server sends to a holder over its control socket after
|
/// One-byte commands a server sends to a holder over its control socket after
|
||||||
/// the holder has handed back the master fd.
|
/// the holder has handed back the master fd.
|
||||||
const HOLDER_CMD_SHUTDOWN: u8 = b'X';
|
const HOLDER_CMD_SHUTDOWN: u8 = b'X';
|
||||||
|
const HOLDER_STARTUP_TIMEOUT: Duration = Duration::from_millis(750);
|
||||||
|
|
||||||
/// Magic written into a holder's `meta` file, bumped if the on-disk layout
|
/// Magic written into a holder's `meta` file, bumped if the on-disk layout
|
||||||
/// changes so a stale holder from an incompatible build is ignored.
|
/// changes so a stale holder from an incompatible build is ignored.
|
||||||
@@ -236,11 +237,14 @@ pub fn spawn_holder(
|
|||||||
// Wait (briefly) for the holder to come up. The holder forks/setsids before
|
// Wait (briefly) for the holder to come up. The holder forks/setsids before
|
||||||
// creating the socket; once the socket exists we can connect. We also reap
|
// creating the socket; once the socket exists we can connect. We also reap
|
||||||
// the short-lived launcher child so it never becomes a zombie.
|
// the short-lived launcher child so it never becomes a zombie.
|
||||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
let deadline = std::time::Instant::now() + HOLDER_STARTUP_TIMEOUT;
|
||||||
loop {
|
loop {
|
||||||
if sock.exists() {
|
if sock.exists() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if let Some(status) = child.try_wait().context("wait holder launcher")? {
|
||||||
|
bail!("holder launcher for session {session} exited before socket was ready: {status}");
|
||||||
|
}
|
||||||
if std::time::Instant::now() >= deadline {
|
if std::time::Instant::now() >= deadline {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
|
|||||||
+2
-5
@@ -297,6 +297,8 @@ fn spawn_reader_thread(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
const _: () = assert!(PTY_OUTPUT_CHUNK_BYTES <= 1200);
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn terminfo_available_detects_known_and_unknown() {
|
fn terminfo_available_detects_known_and_unknown() {
|
||||||
// A near-universal entry should be present on any host with ncurses.
|
// A near-universal entry should be present on any host with ncurses.
|
||||||
@@ -305,9 +307,4 @@ mod tests {
|
|||||||
assert!(!terminfo_available("definitely-not-a-real-terminal-xyz"));
|
assert!(!terminfo_available("definitely-not-a-real-terminal-xyz"));
|
||||||
assert!(!terminfo_available(""));
|
assert!(!terminfo_available(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pty_output_chunk_size_stays_mtu_safe() {
|
|
||||||
assert!(PTY_OUTPUT_CHUNK_BYTES <= 1200);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-3
@@ -1,18 +1,30 @@
|
|||||||
|
use crate::native::{ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth};
|
||||||
|
#[cfg(unix)]
|
||||||
use crate::native::{
|
use crate::native::{
|
||||||
ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth,
|
|
||||||
is_supported_user_key_algorithm, 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};
|
#[cfg(unix)]
|
||||||
|
use anyhow::{Context, bail};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
#[cfg(unix)]
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
|
#[cfg(unix)]
|
||||||
use std::os::unix::net::UnixStream;
|
use std::os::unix::net::UnixStream;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
const SSH_AGENT_FAILURE: u8 = 5;
|
const SSH_AGENT_FAILURE: u8 = 5;
|
||||||
|
#[cfg(unix)]
|
||||||
const SSH2_AGENTC_REQUEST_IDENTITIES: u8 = 11;
|
const SSH2_AGENTC_REQUEST_IDENTITIES: u8 = 11;
|
||||||
|
#[cfg(unix)]
|
||||||
const SSH2_AGENT_IDENTITIES_ANSWER: u8 = 12;
|
const SSH2_AGENT_IDENTITIES_ANSWER: u8 = 12;
|
||||||
|
#[cfg(unix)]
|
||||||
const SSH2_AGENTC_SIGN_REQUEST: u8 = 13;
|
const SSH2_AGENTC_SIGN_REQUEST: u8 = 13;
|
||||||
|
#[cfg(unix)]
|
||||||
const SSH2_AGENT_SIGN_RESPONSE: u8 = 14;
|
const SSH2_AGENT_SIGN_RESPONSE: u8 = 14;
|
||||||
|
#[cfg(unix)]
|
||||||
const SSH_AGENT_RSA_SHA2_512: u32 = 4;
|
const SSH_AGENT_RSA_SHA2_512: u32 = 4;
|
||||||
|
#[cfg(unix)]
|
||||||
const MAX_AGENT_PACKET: usize = 256 * 1024;
|
const MAX_AGENT_PACKET: usize = 256 * 1024;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -25,6 +37,7 @@ pub struct AgentIdentity {
|
|||||||
pub comment: String,
|
pub comment: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
pub fn sign_user_auth_with_agent(
|
pub fn sign_user_auth_with_agent(
|
||||||
client: &NativeClientHello,
|
client: &NativeClientHello,
|
||||||
server: &NativeServerHello,
|
server: &NativeServerHello,
|
||||||
@@ -34,6 +47,18 @@ pub fn sign_user_auth_with_agent(
|
|||||||
sign_user_auth_with_agent_at(sock, client, server, requested_forwardings)
|
sign_user_auth_with_agent_at(sock, client, server, requested_forwardings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
pub fn sign_user_auth_with_agent(
|
||||||
|
_client: &NativeClientHello,
|
||||||
|
_server: &NativeServerHello,
|
||||||
|
_requested_forwardings: Vec<ForwardingRequest>,
|
||||||
|
) -> Result<NativeUserAuth> {
|
||||||
|
Err(anyhow!(
|
||||||
|
"ssh-agent native auth is not supported on this platform yet; use identity_files"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
pub fn sign_user_auth_with_agent_at(
|
pub fn sign_user_auth_with_agent_at(
|
||||||
socket_path: impl AsRef<Path>,
|
socket_path: impl AsRef<Path>,
|
||||||
client: &NativeClientHello,
|
client: &NativeClientHello,
|
||||||
@@ -58,6 +83,19 @@ pub fn sign_user_auth_with_agent_at(
|
|||||||
Ok(auth)
|
Ok(auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
pub fn sign_user_auth_with_agent_at(
|
||||||
|
_socket_path: impl AsRef<Path>,
|
||||||
|
_client: &NativeClientHello,
|
||||||
|
_server: &NativeServerHello,
|
||||||
|
_requested_forwardings: Vec<ForwardingRequest>,
|
||||||
|
) -> Result<NativeUserAuth> {
|
||||||
|
Err(anyhow!(
|
||||||
|
"ssh-agent native auth is not supported on this platform yet; use identity_files"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn request_supported_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> {
|
fn request_supported_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> {
|
||||||
write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?;
|
write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?;
|
||||||
let payload = read_agent_packet(agent)?;
|
let payload = read_agent_packet(agent)?;
|
||||||
@@ -84,6 +122,7 @@ fn request_supported_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdent
|
|||||||
Ok(identities)
|
Ok(identities)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn supported_identity(key_blob: Vec<u8>, comment: String) -> Result<Option<AgentIdentity>> {
|
fn supported_identity(key_blob: Vec<u8>, comment: String) -> Result<Option<AgentIdentity>> {
|
||||||
let algorithm = key_blob_algorithm(&key_blob)?;
|
let algorithm = key_blob_algorithm(&key_blob)?;
|
||||||
if !is_supported_user_key_algorithm(&algorithm) {
|
if !is_supported_user_key_algorithm(&algorithm) {
|
||||||
@@ -119,6 +158,7 @@ fn supported_identity(key_blob: Vec<u8>, comment: String) -> Result<Option<Agent
|
|||||||
Ok(Some(identity))
|
Ok(Some(identity))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn sign_with_agent(
|
fn sign_with_agent(
|
||||||
agent: &mut UnixStream,
|
agent: &mut UnixStream,
|
||||||
identity: &AgentIdentity,
|
identity: &AgentIdentity,
|
||||||
@@ -163,6 +203,7 @@ fn sign_with_agent(
|
|||||||
Ok(signature.to_vec())
|
Ok(signature.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn read_agent_packet(stream: &mut UnixStream) -> Result<Vec<u8>> {
|
fn read_agent_packet(stream: &mut UnixStream) -> Result<Vec<u8>> {
|
||||||
let mut len = [0u8; 4];
|
let mut len = [0u8; 4];
|
||||||
stream
|
stream
|
||||||
@@ -177,6 +218,7 @@ fn read_agent_packet(stream: &mut UnixStream) -> Result<Vec<u8>> {
|
|||||||
Ok(payload)
|
Ok(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> {
|
fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
payload.len() <= MAX_AGENT_PACKET,
|
payload.len() <= MAX_AGENT_PACKET,
|
||||||
@@ -187,6 +229,7 @@ fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn read_u8(cursor: &mut &[u8]) -> Result<u8> {
|
fn read_u8(cursor: &mut &[u8]) -> Result<u8> {
|
||||||
anyhow::ensure!(!cursor.is_empty(), "truncated u8");
|
anyhow::ensure!(!cursor.is_empty(), "truncated u8");
|
||||||
let value = cursor[0];
|
let value = cursor[0];
|
||||||
@@ -194,6 +237,7 @@ fn read_u8(cursor: &mut &[u8]) -> Result<u8> {
|
|||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn read_u32(cursor: &mut &[u8]) -> Result<u32> {
|
fn read_u32(cursor: &mut &[u8]) -> Result<u32> {
|
||||||
anyhow::ensure!(cursor.len() >= 4, "truncated u32");
|
anyhow::ensure!(cursor.len() >= 4, "truncated u32");
|
||||||
let value = u32::from_be_bytes(cursor[..4].try_into().unwrap());
|
let value = u32::from_be_bytes(cursor[..4].try_into().unwrap());
|
||||||
@@ -201,6 +245,7 @@ fn read_u32(cursor: &mut &[u8]) -> Result<u32> {
|
|||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
|
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
|
||||||
let len = read_u32(cursor)? as usize;
|
let len = read_u32(cursor)? as usize;
|
||||||
anyhow::ensure!(cursor.len() >= len, "truncated SSH string");
|
anyhow::ensure!(cursor.len() >= len, "truncated SSH string");
|
||||||
@@ -209,18 +254,20 @@ fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
|
|||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
|
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.len() as u32).to_be_bytes());
|
||||||
out.extend_from_slice(value);
|
out.extend_from_slice(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn key_blob_algorithm(blob: &[u8]) -> Result<String> {
|
fn key_blob_algorithm(blob: &[u8]) -> Result<String> {
|
||||||
let mut cursor = blob;
|
let mut cursor = blob;
|
||||||
let algorithm = read_ssh_string(&mut cursor)?;
|
let algorithm = read_ssh_string(&mut cursor)?;
|
||||||
Ok(String::from_utf8_lossy(algorithm).to_string())
|
Ok(String::from_utf8_lossy(algorithm).to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, unix))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::native::{
|
use crate::native::{
|
||||||
|
|||||||
@@ -18,6 +18,11 @@
|
|||||||
//! Determinism: the relay's drop/reorder/dup behavior is driven by a fixed-seed
|
//! Determinism: the relay's drop/reorder/dup behavior is driven by a fixed-seed
|
||||||
//! PRNG and by explicit one-shot toggles, never by wall-clock timing, so the
|
//! PRNG and by explicit one-shot toggles, never by wall-clock timing, so the
|
||||||
//! tests are reproducible and fast.
|
//! tests are reproducible and fast.
|
||||||
|
#![allow(
|
||||||
|
clippy::collapsible_if,
|
||||||
|
clippy::explicit_counter_loop,
|
||||||
|
clippy::single_match
|
||||||
|
)]
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
@@ -768,7 +773,7 @@ fn session_survives_packet_loss_and_reorder() {
|
|||||||
let port = free_udp_port();
|
let port = free_udp_port();
|
||||||
let config = write_server_config(&dir, port);
|
let config = write_server_config(&dir, port);
|
||||||
let mut server = start_server(&dir, &config);
|
let mut server = start_server(&dir, &config);
|
||||||
let relay = Relay::spawn(port, 0x105_5u64 ^ 0x1111);
|
let relay = Relay::spawn(port, 0x1055_u64 ^ 0x1111);
|
||||||
|
|
||||||
let (socket, bootstrap, ok) = attach_through_relay(&config, &relay);
|
let (socket, bootstrap, ok) = attach_through_relay(&config, &relay);
|
||||||
|
|
||||||
|
|||||||
+152
-2
@@ -1,3 +1,5 @@
|
|||||||
|
#![allow(clippy::collapsible_if)]
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::{TcpListener, TcpStream, UdpSocket};
|
use std::net::{TcpListener, TcpStream, UdpSocket};
|
||||||
@@ -499,6 +501,7 @@ fn direct_attach_session(
|
|||||||
(socket, bootstrap, ok)
|
(socket, bootstrap, ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn send_encrypted(
|
fn send_encrypted(
|
||||||
socket: &UdpSocket,
|
socket: &UdpSocket,
|
||||||
port: u16,
|
port: u16,
|
||||||
@@ -617,6 +620,48 @@ fn disconnected_client_times_out_and_gets_reject() {
|
|||||||
assert_eq!(reject.reason, "unknown client");
|
assert_eq!(reject.reason, "unknown client");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_client_with_stale_key_id_gets_reject_not_timeout() {
|
||||||
|
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 (_bootstrap, ok) = {
|
||||||
|
let (_socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
|
||||||
|
(bootstrap, ok)
|
||||||
|
};
|
||||||
|
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
|
socket
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||||
|
.unwrap();
|
||||||
|
let stale_key = crypto::random_32();
|
||||||
|
let packet = protocol::encode_encrypted(
|
||||||
|
PacketKind::Ping,
|
||||||
|
ok.client_id,
|
||||||
|
99,
|
||||||
|
0,
|
||||||
|
&stale_key,
|
||||||
|
CLIENT_TO_SERVER,
|
||||||
|
b"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
socket
|
||||||
|
.send_to(&packet, format!("127.0.0.1:{port}"))
|
||||||
|
.unwrap();
|
||||||
|
let mut buf = [0u8; 65535];
|
||||||
|
let (n, _) = socket.recv_from(&mut buf).unwrap();
|
||||||
|
let packet = protocol::decode(&buf[..n]).unwrap();
|
||||||
|
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
|
||||||
|
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
|
||||||
|
assert_eq!(packet.header.kind, PacketKind::AttachReject);
|
||||||
|
assert_eq!(packet.header.conn_id, ok.client_id);
|
||||||
|
assert_eq!(reject.reason, "unknown client");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_attach_only_smoke() {
|
fn local_attach_only_smoke() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -1334,9 +1379,12 @@ fn tui_control_sequences_survive_transport_verbatim() {
|
|||||||
"\x1b[?2004h", // bracketed paste on
|
"\x1b[?2004h", // bracketed paste on
|
||||||
"\x1b[?1000h", // mouse tracking on
|
"\x1b[?1000h", // mouse tracking on
|
||||||
"\x1b[?1006h", // SGR mouse encoding on
|
"\x1b[?1006h", // SGR mouse encoding on
|
||||||
|
"\x1b]0;DOSH_TITLE\x07", // OSC title
|
||||||
"\x1b[?25l", // cursor hidden
|
"\x1b[?25l", // cursor hidden
|
||||||
"\x1b[12;34H", // absolute cursor movement
|
"\x1b[12;34H", // absolute cursor movement
|
||||||
|
"\x1b[1;31m", // bold red
|
||||||
"DOSH_TUI_VERBATIM",
|
"DOSH_TUI_VERBATIM",
|
||||||
|
"\x1b[0m",
|
||||||
"\x1b[?25h",
|
"\x1b[?25h",
|
||||||
"\x1b[?1006l",
|
"\x1b[?1006l",
|
||||||
"\x1b[?1000l",
|
"\x1b[?1000l",
|
||||||
@@ -1366,9 +1414,12 @@ fn tui_control_sequences_survive_transport_verbatim() {
|
|||||||
"\x1b[?2004h",
|
"\x1b[?2004h",
|
||||||
"\x1b[?1000h",
|
"\x1b[?1000h",
|
||||||
"\x1b[?1006h",
|
"\x1b[?1006h",
|
||||||
|
"\x1b]0;DOSH_TITLE\x07",
|
||||||
"\x1b[?25l",
|
"\x1b[?25l",
|
||||||
"\x1b[12;34H",
|
"\x1b[12;34H",
|
||||||
|
"\x1b[1;31m",
|
||||||
"DOSH_TUI_VERBATIM",
|
"DOSH_TUI_VERBATIM",
|
||||||
|
"\x1b[0m",
|
||||||
"\x1b[?25h",
|
"\x1b[?25h",
|
||||||
"\x1b[?1006l",
|
"\x1b[?1006l",
|
||||||
"\x1b[?1000l",
|
"\x1b[?1000l",
|
||||||
@@ -1382,6 +1433,39 @@ fn tui_control_sequences_survive_transport_verbatim() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unicode_output_survives_transport() {
|
||||||
|
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 marker = "DOSH_UNICODE 你 e\u{301} 🚀";
|
||||||
|
let input = Input {
|
||||||
|
bytes: format!("printf '{}\\n'\n", marker).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();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
text.contains(marker),
|
||||||
|
"expected UTF-8 output marker to survive transport, got {text:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn large_tui_paint_is_delivered_in_mtu_safe_frames() {
|
fn large_tui_paint_is_delivered_in_mtu_safe_frames() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -1483,8 +1567,8 @@ fn resume_snapshot_preserves_alternate_screen_mode() {
|
|||||||
|
|
||||||
assert!(resume_frame.snapshot);
|
assert!(resume_frame.snapshot);
|
||||||
assert!(
|
assert!(
|
||||||
resume_frame.bytes.starts_with(b"\x1b[?1049h"),
|
resume_frame.bytes.starts_with(b"\x1b[?1049h\x1b[H\x1b[2J"),
|
||||||
"snapshot did not enter alternate screen first: {:?}",
|
"snapshot did not enter and clear alternate screen first: {:?}",
|
||||||
String::from_utf8_lossy(&resume_frame.bytes)
|
String::from_utf8_lossy(&resume_frame.bytes)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2299,6 +2383,72 @@ fn session_survives_server_restart_same_shell_and_screen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn implicit_session_survives_server_restart_for_same_ticket_holder() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let sessions_dir = dir.path().join("sessions");
|
||||||
|
let port = free_udp_port();
|
||||||
|
let config = write_persistent_server_config(&dir, port);
|
||||||
|
let session = protocol::generate_implicit_session_name();
|
||||||
|
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
let (socket, bootstrap, ok) = direct_attach_session(&config, port, "read-write", &session);
|
||||||
|
let key = bootstrap.session_key;
|
||||||
|
type_line(
|
||||||
|
&socket,
|
||||||
|
port,
|
||||||
|
&ok,
|
||||||
|
&key,
|
||||||
|
2,
|
||||||
|
"export IMPLICIT_MARK=implicit_restart_42\n",
|
||||||
|
);
|
||||||
|
let _ = collect_frame_text(&socket, &key, 1000);
|
||||||
|
thread::sleep(Duration::from_secs(3));
|
||||||
|
|
||||||
|
let shell_pid_before = holder_shell_pid(&sessions_dir, &session);
|
||||||
|
assert!(
|
||||||
|
shell_pid_before.is_some(),
|
||||||
|
"implicit sessions should get a persistent holder when persistence is enabled"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
|
||||||
|
let pid = shell_pid_before.unwrap();
|
||||||
|
assert!(
|
||||||
|
unsafe { libc::kill(pid, 0) } == 0,
|
||||||
|
"implicit session shell pid {pid} must survive server restart"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
let (socket2, bootstrap2, ok2) = direct_attach_session(&config, port, "read-write", &session);
|
||||||
|
let key2 = bootstrap2.session_key;
|
||||||
|
type_line(
|
||||||
|
&socket2,
|
||||||
|
port,
|
||||||
|
&ok2,
|
||||||
|
&key2,
|
||||||
|
2,
|
||||||
|
"printf 'IMPLICIT_MARK=%s\\n' \"$IMPLICIT_MARK\"\n",
|
||||||
|
);
|
||||||
|
let post = collect_frame_text(&socket2, &key2, 2000);
|
||||||
|
let shell_pid_after = holder_shell_pid(&sessions_dir, &session);
|
||||||
|
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
kill_holder(&sessions_dir, &session);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
shell_pid_after, shell_pid_before,
|
||||||
|
"implicit session should re-adopt the same shell pid"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
post.contains("IMPLICIT_MARK=implicit_restart_42"),
|
||||||
|
"implicit session state must survive restart, got {post:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multiple_persistent_named_sessions_survive_restart_independently() {
|
fn multiple_persistent_named_sessions_survive_restart_independently() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user