Compare commits

...
74 Commits
Author SHA1 Message Date
DuProcess b393c0e5d5 Allow netlink for terminal network monitors
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-07-02 19:40:30 -04:00
DuProcess d5bc8e3c64 Recover terminal output gaps with resume repaint
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-07-02 19:29:20 -04:00
DuProcess c40f5459ba Keep live TUI output raw under retransmit pressure
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-07-02 19:21:43 -04:00
DuProcess 601510687e Prepare Dosh 0.1.9 release
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-07-02 19:07:46 -04:00
DuProcess 60387e9222 Install Dosh binaries atomically
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-07-02 18:41:02 -04:00
DuProcess a48aa15308 Preserve raw terminal output for TUIs
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-07-02 18:34:09 -04:00
DuProcess 691b58e531 Harden update and release tooling
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-07-01 19:48:46 -04:00
DuProcess 431dcc3997 Prepare Dosh 0.1.7 release
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-07-01 18:03:42 -04:00
DuProcess 3224a9c699 Fix Dosh stdio proxy for VS Code
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-07-01 09:58:26 -04:00
DuProcess 00f5b2e001 Default Windows installer to Dosh repo
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-30 19:54:16 -04:00
DuProcess 03cb5c0f75 Ignore packaged VS Code extension artifacts 2026-06-30 19:43:48 -04:00
DuProcess f8056b03b6 Add VS Code Remote SSH integration 2026-06-30 19:42:45 -04:00
DuProcess 42fbf86ca9 Add Dosh stdio proxy for SSH streams 2026-06-30 19:42:35 -04:00
DuProcess 13f8cb07c5 Document Dosh Rust SDK examples 2026-06-30 19:21:28 -04:00
DuProcess bbf6ffd725 Add native services and embeddable transport SDK 2026-06-30 19:21:23 -04:00
DuProcess 35d9c9c6de Bump version to 0.1.6
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-28 09:31:50 -04:00
DuProcess 51247576f3 Harden server install restart path 2026-06-28 09:31:06 -04:00
DuProcess 255f584545 Add release check gate 2026-06-28 09:27:34 -04:00
DuProcess d947ccd934 Clarify update checks for releases 2026-06-28 09:24:20 -04:00
DuProcess cf27ba7ddf Drop stale mouse reports on reconnect
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-28 08:57:34 -04:00
DuProcess 02607b49b1 Harden reconnect and stream open reliability
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-27 21:16:12 -04:00
DuProcess 48e3de2922 Refresh reconnect send address and guard prebuilts
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-27 20:11:41 -04:00
DuProcess c95a913422 Add short post-submit printable input hold
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-27 19:31:47 -04:00
DuProcess 6292784f3a Keep queued startup control input ordered
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-27 18:54:55 -04:00
DuProcess d2fd7ee346 Make post-submit input gate command agnostic
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-27 18:36:45 -04:00
DuProcess acd22b93e8 Gate manual TUI launch input
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-27 18:05:29 -04:00
DuProcess ef712a79ee Gate startup command input before TUI readiness
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-27 17:53:59 -04:00
DuProcess 48a59496b2 Expose build identity in version output
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-27 13:45:48 -04:00
DuProcess 606fad72e3 Respect update prebuilt override
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-27 11:48:06 -04:00
DuProcess 9d0396dbf9 Fix reconnect stale keys and TUI status rendering
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-27 11:46:26 -04:00
DuProcess f71cd975bc Make update build current source
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-26 19:34:10 -04:00
DuProcess 4842a129f2 Fix startup command and holder fallback
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-26 19:31:33 -04:00
DuProcess 60d73df5be Support cross Windows release packaging
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-22 17:19:16 -04:00
DuProcess f9a378a9cd Harden release readiness gates
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-22 17:14:59 -04:00
DuProcess fb6cf4e7c2 Warn on client server version mismatch
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-22 01:52:34 -04:00
DuProcess c0d83c9133 Add Dosh server restart command
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-22 01:50:39 -04:00
DuProcess a4b3e7c89b Add Dosh status command
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-22 01:48:16 -04:00
DuProcess 44717e4956 Harden release upload scripts
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-22 01:42:17 -04:00
DuProcess 149ea07edf Automate Gitea release publishing
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-22 01:41:34 -04:00
DuProcess 6f40ff9e25 Prefer direct Gitea release assets
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-21 15:52:42 -04:00
DuProcess b21c2eed9d Slim release packaging
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-21 11:21:05 -04:00
DuProcess 29d894b43b Read Gitea release token from config
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-21 11:02:22 -04:00
DuProcess fcc21c7aef Make restart reconnect persistent
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-21 10:51:12 -04:00
DuProcess a029564d80 Add Mosh parity hardening
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-21 10:23:06 -04:00
DuProcess 689d2a9575 Buffer input across reconnect
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-21 09:44:39 -04:00
DuProcess 38e70611c5 Clarify Dosh positioning
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 19:44:40 -04:00
DuProcess 6adcdc01e8 Tighten README
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 19:43:54 -04:00
DuProcess 742477bf6e Add Windows client packaging and recovery tools
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 19:40:33 -04:00
DuProcess a202f97704 Quiet missing release lookup
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 19:20:55 -04:00
DuProcess f6ead86db4 Add setup selftest and diagnostics polish
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 19:19:29 -04:00
DuProcess 013d653f99 Quiet Gitea prebuilt fallback
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 18:10:15 -04:00
DuProcess 4e7e4cff10 Update release artifact checksums
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 18:09:25 -04:00
DuProcess fbad776441 Resolve Gitea release downloads
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 18:08:13 -04:00
DuProcess 90d9b583c0 Try prebuilt installers by default
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 18:03:11 -04:00
DuProcess f7c4ebaaf7 Add macOS release artifact evidence
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 18:00:56 -04:00
DuProcess 27419f4ca8 Fix Gitea release id parsing
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 17:51:36 -04:00
DuProcess ec2422bc3e Remove personal host aliases from defaults
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 17:50:26 -04:00
DuProcess d51cc248e7 Add release evidence and audit packet
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 10:45:55 -04:00
DuProcess b44ff8e773 Improve release and benchmark tooling 2026-06-20 10:37:26 -04:00
DuProcess 7884ea2796 Add prebuilt update path 2026-06-19 16:40:58 -04:00
DuProcess 774da7371e Chunk terminal output below MTU 2026-06-19 16:30:51 -04:00
DuProcess 41cdb0f54f Add optional command extensions 2026-06-19 16:17:38 -04:00
DuProcess 90e53f4b68 Add reliable stream retransmission 2026-06-19 16:00:51 -04:00
DuProcess d0d6f59cdf Add launch hardening gates 2026-06-18 09:45:27 -04:00
DuProcess 25d9a6aefa Expand native auth and benchmark gates 2026-06-18 09:29:06 -04:00
DuProcessandClaude Opus 4.8 2835da76b0 Persist only named/prewarmed sessions, not implicit ones
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
ci / test (push) Has been cancelled
Implicit sessions (dosh host with no --session) get a random
term-<millis>-<pid> name you can never reattach to, so persisting them just
left unreattachable holder processes lingering across restarts. The server
now only uses a persistent holder for prewarmed or explicitly-named
sessions; implicit ones use the in-process shell that dies on restart and
reaps on disconnect, as before. Adds a shared
protocol::{generate,is}_implicit_session_name as the single source of truth
for the name shape, used by both client (generate) and server (detect).

Server-only behavior change; wire VERSION unchanged.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tests: 8 new client unit tests (status state machine + overlay bytes, predictor
spike/recovery/idle). fmt + full test suite green. No wire-format change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:57:40 -04:00
52 changed files with 14145 additions and 3069 deletions
+46 -11
View File
@@ -3,6 +3,9 @@ name: ci
on: on:
push: push:
pull_request: pull_request:
workflow_dispatch:
schedule:
- cron: "17 7 * * 1"
jobs: jobs:
test: test:
@@ -36,21 +39,53 @@ jobs:
if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success' if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success'
run: | run: |
set -e set -e
for target in \ if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
packet_decode \ DOSH_FUZZ_SECONDS="${DOSH_FUZZ_SECONDS:-300}" sh scripts/fuzz-run.sh
from_body \ else
authorized_keys \ sh scripts/fuzz-run.sh 20
known_hosts \ fi
handshake_structs \
attach_ticket; do
echo "== fuzzing $target =="
cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \
-max_total_time=20 -rss_limit_mb=4096
done
- name: Note when fuzzing was skipped - name: Note when fuzzing was skipped
if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success' if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success'
run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run." run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run."
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:
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')
strategy:
matrix:
include:
- os: ubuntu-latest
name: linux-x86_64
- os: macos-14
name: macos-aarch64
- os: macos-13
name: macos-x86_64
- os: windows-latest
name: windows-x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Package release
shell: bash
run: sh scripts/package-release.sh
- uses: actions/upload-artifact@v4
with:
name: dosh-${{ matrix.name }}
path: |
target/dosh-release/dosh-*
!target/dosh-release/stage/**
remote-bench: remote-bench:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
+1
View File
@@ -1,3 +1,4 @@
/target/ /target/
**/*.rs.bk **/*.rs.bk
.DS_Store .DS_Store
vscode-extension/*.vsix
Generated
+17 -1
View File
@@ -397,6 +397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [ dependencies = [
"const-oid", "const-oid",
"pem-rfc7468",
"zeroize", "zeroize",
] ]
@@ -435,7 +436,7 @@ dependencies = [
[[package]] [[package]]
name = "dosh" name = "dosh"
version = "0.1.0" version = "0.1.12"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -446,13 +447,17 @@ dependencies = [
"crossterm", "crossterm",
"dirs", "dirs",
"ed25519-dalek", "ed25519-dalek",
"filetime",
"hkdf", "hkdf",
"hmac", "hmac",
"libc",
"portable-pty", "portable-pty",
"rand", "rand",
"rpassword", "rpassword",
"rsa",
"serde", "serde",
"sha2", "sha2",
"signature",
"ssh-key", "ssh-key",
"tempfile", "tempfile",
"tokio", "tokio",
@@ -573,6 +578,16 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
@@ -1498,6 +1513,7 @@ checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
dependencies = [ dependencies = [
"bcrypt-pbkdf", "bcrypt-pbkdf",
"ed25519-dalek", "ed25519-dalek",
"num-bigint-dig",
"p256", "p256",
"p384", "p384",
"p521", "p521",
+7 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "dosh" name = "dosh"
version = "0.1.0" version = "0.1.12"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
@@ -14,14 +14,18 @@ clap = { version = "4.5", features = ["derive"] }
crossterm = "0.28" crossterm = "0.28"
dirs = "5.0" dirs = "5.0"
ed25519-dalek = "2.1" ed25519-dalek = "2.1"
filetime = "0.2"
hkdf = "0.12" hkdf = "0.12"
hmac = "0.12" hmac = "0.12"
libc = "0.2"
portable-pty = "0.8" portable-pty = "0.8"
rand = "0.8" rand = "0.8"
rsa = { version = "0.9.10", features = ["sha2"] }
rpassword = "7.5.4" rpassword = "7.5.4"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10" sha2 = "0.10"
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] } signature = "2.2"
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption", "p256", "rsa"] }
tokio = { version = "1.41", features = ["full"] } tokio = { version = "1.41", features = ["full"] }
toml = "0.8" toml = "0.8"
vt100 = "0.15" vt100 = "0.15"
@@ -35,4 +39,4 @@ opt-level = 2
codegen-units = 16 codegen-units = 16
incremental = true incremental = true
lto = false lto = false
strip = false strip = "debuginfo"
+41 -2
View File
@@ -1,4 +1,4 @@
.PHONY: build test fmt install bench-local bench-local-json bench-docker-ssh bench-docker-mosh .PHONY: build test fmt clippy release-check 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
@@ -9,8 +9,33 @@ test:
fmt: fmt:
cargo fmt cargo fmt
clippy:
cargo clippy --all-targets -- -D warnings
release-check:
cargo fmt --check
sh -n install.sh scripts/*.sh
cargo clippy --all-targets -- -D warnings
cargo test
$(MAKE) tui-harness
install: install:
sh packaging/install.sh sh install.sh --from-current
package-release:
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:
sh scripts/bench-report.sh
# Safe, self-contained local benchmark matrix (native cold auth, cached attach # Safe, self-contained local benchmark matrix (native cold auth, cached attach
# ticket, local-auth) on a throwaway server bound to 127.0.0.1 on a free port in # ticket, local-auth) on a throwaway server bound to 127.0.0.1 on a free port in
@@ -28,3 +53,17 @@ bench-docker-ssh:
bench-docker-mosh: bench-docker-mosh:
DOSH_BENCH_INCLUDE_MOSH=1 sh scripts/ci-docker-ssh-bench.sh DOSH_BENCH_INCLUDE_MOSH=1 sh scripts/ci-docker-ssh-bench.sh
fuzz-smoke:
sh scripts/fuzz-run.sh 20
# Longer pre-launch fuzz pass. Override with DOSH_FUZZ_SECONDS=NN.
fuzz-deep:
DOSH_FUZZ_SECONDS=$${DOSH_FUZZ_SECONDS:-300} sh scripts/fuzz-run.sh
tui-harness:
sh scripts/tui-harness.sh
# 30-minute launch soak by default. Override with DOSH_SOAK_SECONDS=NN.
soak-local:
DOSH_SOAK_SECONDS=$${DOSH_SOAK_SECONDS:-1800} cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture
+80 -267
View File
@@ -1,299 +1,112 @@
# 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 mosh-shaped, but not a mosh clone: the server is a resident daemon, terminal
sessions stay hot, and repeat connects try encrypted UDP before starting SSH.
The core target is simple: It runs a `dosh-server` on a Unix-like host and a `dosh` client on macOS,
Linux, or Windows. Setup can use SSH, then Dosh connects over encrypted UDP,
keeps sessions alive across disconnects, supports terminal apps, and can carry
TCP forwarding, file copy, and VS Code Remote-SSH streams.
- 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: Linux and other Unix-like systems with PTYs
- Default UDP port: `50000`
- Windows: client only
mosh is excellent at roaming and high-latency interactivity. Its startup path still Dosh is meant to replace Mosh and everyday interactive SSH sessions. It does
has work dosh can avoid: not currently include SFTP, X11 forwarding, or a Windows server.
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
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 Unix/macOS server and client:
`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: Unix/macOS client only:
```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_SERVER=palav DOSH_HOST=git.palav.dev DOSH_PORT=50000 sh -s -- client
``` ```
Update an installed client later: Windows client:
```bash ```powershell
irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
```
## Use
```sh
dosh setup HOST
dosh HOST
dosh HOST COMMAND
dosh update dosh update
``` ```
Install the client on Windows PowerShell: Useful commands:
```powershell ```sh
$env:DOSH_REPO="https://git.palav.dev/Palav/dosh.git"; $env:DOSH_SERVER="palav"; $env:DOSH_HOST="git.palav.dev"; $env:DOSH_PORT="50000"; irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex dosh exec HOST COMMAND
dosh cp SRC DST
dosh ls host:path
dosh cat host:path
dosh mkdir host:path
dosh rm [-r] host:path
dosh forward HOST -L 8080:127.0.0.1:80
dosh forward HOST -D 1080
dosh forward HOST -R 2222:127.0.0.1:22
dosh status HOST
dosh doctor HOST
dosh recover HOST
dosh restart HOST
``` ```
Attach: Agent forwarding is opt-in with `-A` and must also be enabled on the server.
File copy must be enabled by the server config.
```bash ## VS Code
dosh palav
Dosh can carry VS Code Remote-SSH through its transport:
```sh
dosh vscode setup HOST
dosh vscode HOST /remote/path
``` ```
Plain `dosh palav` opens a fresh terminal session. Use named sessions when you want This creates a managed SSH config entry using `ProxyCommand dosh proxy-stdio`.
to reattach to the same persistent terminal from multiple clients: VS Code still uses Remote-SSH and its normal remote server; Dosh carries the
SSH byte stream.
```bash ## Config
dosh --session work palav
dosh --session logs palav ```text
~/.config/dosh/client.toml
~/.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. The old in-band status overlay was removed
because writing directly over the terminal could corrupt TUIs; a non-destructive
status surface is tracked as a public-readiness item.
If SSH and UDP use different public names, specify the UDP address: ```toml
default_session = "new"
```bash predict = true
dosh-client --dosh-host public.example.com --dosh-port 50000 user@host cache_attach_tickets = true
disconnect_status = true
``` ```
Dosh already lets OpenSSH handle SSH aliases, users, keys, ports, known-hosts, ## Rust Library
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 exposes a Rust transport for encrypted, reconnecting application streams.
dosh import-ssh palav homelab Use `dosh::client::DoshClient` and `dosh::server::DoshServer` for the normal
native-auth path. Use `dosh::transport::DoshTransport` only when you already
own session setup and authentication.
Runnable examples:
```text
examples/sdk_echo_client.rs
examples/sdk_echo_server.rs
``` ```
## 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 both cold SSH bootstrap
and ControlMaster-backed SSH bootstrap 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`, and `mosh_start_true_ms` under the same
container, key, DNS, and network path. It also prints `dosh_cached_attach_ms`, which
is the real Dosh fast path after the first SSH-authenticated bootstrap has issued an
attach ticket. See `docs/PUBLIC_READINESS.md` before using the numbers publicly;
Dosh's current strongest claim is fast attach/reconnect, not full Mosh feature
parity yet.
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
```
## 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 support in v0.
- 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 user auth
via ssh-agent or an encrypted OpenSSH key, ChaCha20-Poly1305 transport, and
`authorized_keys` policy enforcement (`from=`, `no-port-forwarding`, `permitopen=`;
unsupported options fail closed).
- **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.
- **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 yet fully verified**. Per-IP token-bucket rate limiting, protocol
VERSION negotiation hardening, fuzzing in CI, ECDSA/RSA user keys, and an external
security review are still open. See `docs/THREAT_MODEL.md` for the published threat
model and accepted residual risks, and the "Native v1 verification checklist status"
table in `docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not yet
claim a fully verified SSH replacement; its defensible claim remains fast encrypted
native attach/reconnect with SSH-equivalent transport security and SSH fallback.
-602
View File
@@ -1,602 +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
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.
+26
View File
@@ -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}");
}
-173
View File
@@ -1,173 +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-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`.
### 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 `dosh_attach_ms`,
`dosh_cached_attach_ms`, and optionally `mosh_start_true_ms` against the same
container, key, and loopback network path. This is the gate CI enforces; it asserts
cold Dosh stays within 500 ms of SSH and that cached attach stays under a small
budget. See `README.md` "Develop" for the exact invocations.
## 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 30`), all times in
milliseconds, 30 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` | 30 | 8.10 | 9.01 | 10.40 | 9.18 | 10.82 |
| `dosh_cached_attach_ms` | 30 | 2.73 | 2.96 | 3.25 | 2.96 | 3.30 |
| `dosh_local_attach_ms` | 30 | 2.66 | 2.78 | 3.24 | 2.87 | 3.33 |
Raw samples (ms):
```
dosh_cold_native_ms:
8.32, 8.23, 10.31, 10.37, 8.83, 8.81, 9.20, 9.04, 10.14, 8.98, 10.82, 9.87,
10.22, 10.42, 9.76, 9.09, 9.19, 8.94, 8.69, 8.67, 8.74, 9.33, 9.25, 8.92, 8.39,
8.55, 8.38, 9.81, 8.17, 8.10
dosh_cached_attach_ms:
2.99, 2.99, 2.93, 2.87, 2.95, 2.80, 2.80, 2.90, 3.10, 2.73, 3.19, 2.80, 2.87,
2.82, 2.89, 3.03, 3.06, 3.21, 2.97, 2.83, 3.00, 2.82, 3.04, 2.97, 3.03, 3.24,
3.30, 2.73, 2.79, 3.25
dosh_local_attach_ms:
3.05, 3.00, 2.84, 3.04, 3.33, 2.89, 2.76, 2.74, 2.73, 3.32, 3.14, 2.75, 2.83,
2.88, 2.99, 2.77, 2.75, 2.79, 2.69, 2.78, 2.75, 2.77, 2.75, 2.77, 3.03, 2.75,
2.94, 2.74, 2.82, 2.66
```
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.
-622
View File
@@ -1,622 +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 encrypted-key Ed25519, and `authorized_keys` verification
> exist. ECDSA/RSA user keys are still pending (Ed25519 only today).
> - Milestone 3 — default native auth: **done.** `auth_preference = "native,ssh"` is
> the default with explicit, visible SSH fallback. Cold-auth benchmark gates are
> pending (Track C / `BENCHMARKS.md`).
> - Milestone 4 — forwarding: **done.** Stream mux, `-L`, `-R`, `-D`, `-N`, `-f`, and
> per-stream flow control are implemented; hostile-network and load tests pending.
> - Milestone 5 — hardening: **in progress.** Full per-IP token-bucket rate limiting,
> protocol VERSION negotiation hardening, fuzzing in CI, and external review are not
> yet complete. The threat model is published (`docs/THREAT_MODEL.md`).
> - 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
```
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.
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.
- Server restart may drop live PTYs in v1 unless session persistence is implemented,
but the client must 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
```
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.
- 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.
## 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 = []
```
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.
- Add external review checklist 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.
-186
View File
@@ -1,186 +0,0 @@
# Dosh Public Readiness
Dosh's defensible public claim is fast terminal attach and reconnect. It should not
claim full Mosh replacement status until the feature matrix below is green and the
comparison benchmark is reproducible outside the author's homelab.
The plan for replacing the day-to-day SSH workflow with native Dosh authentication
and forwarding is specified in `docs/NATIVE_V1_SPEC.md`, and the published threat
model is in `docs/THREAT_MODEL.md`. Native v1 is now substantially implemented:
native key-exchange + user auth, host-key pinning/trust, `-L`/`-R`/`-D` forwarding,
and `dosh doctor` all exist (see the feature matrix and the verification-checklist
status table below). It is **not yet fully verified**: per-IP token-bucket rate
limiting, protocol-version negotiation hardening, fuzzing in CI, and external review
are still open. Until the verification checklist (`NATIVE_V1_SPEC.md` section 16) is
green and that review is done, Dosh's defensible public security claim remains fast
encrypted native attach/reconnect with SSH-equivalent transport security and an
explicit SSH bootstrap fallback — not a fully verified, externally reviewed SSH
replacement.
## 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 via ssh-agent or encrypted key | implemented; pending full verification |
| Dosh host-key pinning and trust | no | yes, `known_hosts` + `dosh trust` + mismatch hard-fail | implemented |
| `authorized_keys` policy enforcement | no | yes, `from=`/`no-port-forwarding`/`permitopen=`, unsupported fail closed | implemented |
| `dosh doctor` diagnostics | no | yes, config/auth/UDP/forwarding-policy check | implemented |
| Encrypted UDP terminal data | yes | yes | ready |
| Roaming by client address change | yes | yes | needs more hostile-network tests |
| Survive sleep or network loss | yes | yes | needs long-running soak tests |
| Fast repeat attach without SSH | no | yes, via attach tickets | core differentiator |
| Resident server daemon | no | yes | core differentiator |
| One UDP port for all sessions | port range by default | yes | ready |
| Fresh session by default | yes | yes | ready |
| Named persistent sessions | no built-in shared session model | yes | ready |
| Multiple clients on one session | no | yes | needs conflict-policy docs |
| View-only clients | no | yes | ready |
| Full-screen TUI correctness | yes | improving | must stay green before public push |
| Predictive local echo | mature | guarded printable-only opt-in | not parity |
| Non-destructive disconnect UI | yes | not currently | needed |
| Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
| X11 forwarding | no | no | non-goal unless tunneled separately |
| SSH agent forwarding | no | no | planned as forwarding channel |
| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; needs hostile-network tests |
| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; needs hostile-network tests |
| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented; needs hostile-network tests |
| Forward-only / background forwarding, `-N` / `-f` | no | yes, `-f` requires `-N` | implemented |
| Per-stream flow control / terminal priority | no | yes, windowed credit per stream | implemented; needs load tests |
## 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 palav homelab
```
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
OpenSSH config parser.
## 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.
- 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:
- A dedicated dropped/reordered/replayed-UDP forwarding test suite.
- Load tests proving large forwarded transfers add no visible terminal input lag.
## 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. |
| Removed authorized key can no longer authenticate | done | Covered by `native_user_auth_accepts_authorized_key_and_rejects_removed_key`. |
| Unsupported authorized-key options fail closed | done | `ensure_native_allowed` bails on any unsupported option. |
| Replayed handshake packets rejected | done | Handshake is transcript-bound and signature-verified; pending entries TTL-evicted. |
| Replayed transport packets rejected | done | `ReplayWindow` (128-wide) over per-direction counter. |
| Stale encrypted packets after reconnect ignored, not fatal | done | `session_key_id` mismatch drops the packet instead of erroring fatally. |
| Client IP/port change preserves session | done | Server matches by `ClientId`/session key id, updates endpoint. |
| Native cold auth beats cold `ssh host true` | pending | Benchmark gate not yet run for native cold path (Track C / `BENCHMARKS.md`). |
| Cached attach near network RTT | pending | Same benchmark dependency. |
| `-L` works without delaying terminal input | in progress | Implemented with per-stream window; load proof pending. |
| `-R` enforces bind and permission policy | done | `remote_bind_allowed` + `start_remote_forwards` policy checks. |
| `-N -L` does not allocate a PTY | done | `forward-only` mode skips PTY allocation. |
| `-f -N -L` backgrounds only after listener readiness | done | `spawn_background_forwarder` waits for a readiness token. |
| Multiple forwards in one command | done | Forward lists parsed and started together. |
| `dosh doctor` identifies UDP-blocked/auth-denied/mismatch/forwarding-denied | done | `run_doctor_command` reports each state. |
| Closing laptop 30+ min does not kill session | in progress | Long `client_timeout_secs` + resume; 30-min soak evidence pending. |
| Three concurrent terminals independent unless named | done | Generated session names per attach; named sessions shared on purpose. |
| Large forwarded transfers add no visible input lag | in progress | Per-stream flow control exists; load test pending. |
| Fuzz targets run in CI | pending | No `fuzz/` dir; CI runs fmt/test/build/bench only. |
| Threat model updated with accepted residual risks | done | `docs/THREAT_MODEL.md`. |
Additional security hardening tracked outside the section 16 list:
- Full per-IP token-bucket rate limiting: in progress (another track). Today only
handshake-map eviction and a static `rate_limit_remaining` hint exist.
- Protocol VERSION negotiation: in progress. Single version, fail-closed reject; no
multi-version negotiation yet.
- ECDSA P-256 / SHA-2 RSA user keys: pending. Ed25519 only today.
## Before Public Launch
- Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green.
- Add a non-destructive disconnect indicator.
- Run scripted TUI tests for alternate-screen apps, arrow keys, resize, mouse mode,
bracketed paste, and terminal cleanup.
- Publish benchmark output with raw samples, not just averages.
- Mark prediction as experimental until it has a real framebuffer model.
- Land full per-IP token-bucket auth rate limiting and wire fuzz targets into CI.
- Complete the native-v1 verification checklist above and an external security review
before making any "native SSH replacement" claim (`NATIVE_V1_SPEC.md` section 17).
-232
View File
@@ -1,232 +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 via ssh-agent or encrypted OpenSSH key; signature binds the full transcript. |
| Forward secrecy | yes | yes | Ephemeral X25519 per connection; long-term host/user keys never derive the traffic key. |
| AEAD on every post-handshake packet | yes | yes | ChaCha20-Poly1305 with per-direction, per-sequence nonces. |
| Replay protection | yes | yes | Sliding replay window over the AEAD packet counter, plus transcript-bound handshake. |
| Host-key pinning with explicit first use | TOFU, weakly tied to transport | yes, with explicit policy | Default refuses unknown host keys; TOFU only when `trust_on_first_use` is set; mismatch hard-fails and never auto-replaces. |
| No plaintext terminal bytes after handshake | yes | yes | All `Frame`/`Input`/stream packets are AEAD-sealed. |
| No custom cryptographic primitives | yes | yes | Standard X25519/HKDF-SHA256/ChaCha20-Poly1305/Ed25519 crates only. |
| 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; 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, 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.
- **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)
- **Per-IP rate limiting is partial.** The server evicts half-finished native
handshakes on a TTL so a flood of `ClientHello` packets cannot grow the pending map
without bound, and it reports a static `rate_limit_remaining` hint in `ServerHello`.
A full per-source token-bucket limiter is **in progress on another track** and is
not yet enforced. Until it lands, sustained auth flooding is mitigated only by the
handshake-eviction TTL and OS-level limits.
- **Protocol VERSION negotiation is being hardened.** The wire format pins a single
protocol version: the packet header rejects any non-matching `VERSION` byte and the
native handshake rejects any non-matching `protocol_version`. There is no
multi-version negotiation yet, so cross-version interop and downgrade-resistance for
future versions are still being designed. Today's behavior is fail-closed (reject),
not silent downgrade.
- **Fuzzing is not yet wired into CI.** CI currently runs format, tests, release
build, and the Docker SSH benchmark gate. Fuzz targets for packet parsing,
authorized-key parsing, known-host parsing, and handshake state (spec milestone 5)
are **being wired in** and are not yet running in CI.
- **User-key algorithm coverage is Ed25519-only today.** The spec permits ECDSA
P-256 and (compatibility-only, SHA-2) RSA, but native auth currently accepts and
produces `ssh-ed25519` only. ECDSA/RSA support is pending. This is a parity gap, not
a weakening of what *is* supported.
- **Hostile-network and long-soak integration tests are partial.** Roaming,
retransmit, resize, and multi-client tests exist; a dedicated adversarial
drop/reorder/replay suite and 30-minute-sleep soak (spec section 16) are still being
expanded.
- **No external security review yet.** The spec's milestone 5 requires an external
review checklist before public security claims. That review has not happened.
### 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 and an external review is complete,
Dosh's defensible public claim remains **fast, encrypted native attach/reconnect with
SSH-equivalent transport security and SSH bootstrap fallback** — not a fully verified,
externally reviewed SSH replacement.
+50
View File
@@ -0,0 +1,50 @@
use anyhow::{Result, bail};
use dosh::client::DoshClient;
use dosh::transport::{SessionEvent, TransportEvent};
#[tokio::main]
async fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
let host = args.next().unwrap_or_else(|| "server".to_string());
let message = args.collect::<Vec<_>>().join(" ");
let message = if message.is_empty() {
"hello from dosh".to_string()
} else {
message
};
let client = DoshClient::load()?;
let mut transport = client
.connect(host)
.service("echo")
.connect()
.await?
.into_transport();
let stream = transport.open_service("echo").await?;
loop {
match transport.recv().await? {
SessionEvent::Stream(TransportEvent::OpenOk { stream_id, .. })
if stream_id == stream =>
{
transport.send(stream, message.as_bytes().to_vec()).await?;
}
SessionEvent::Stream(TransportEvent::Data(data)) if data.stream_id == stream => {
for chunk in data.chunks {
print!("{}", String::from_utf8_lossy(&chunk));
}
println!();
transport.close(stream).await?;
return Ok(());
}
SessionEvent::Stream(TransportEvent::OpenReject { reason, .. }) => {
bail!("server rejected echo stream: {reason}");
}
SessionEvent::Stream(_)
| SessionEvent::Ping
| SessionEvent::Pong
| SessionEvent::Ignored => {}
}
transport.maintenance().await?;
}
}
+37
View File
@@ -0,0 +1,37 @@
use anyhow::Result;
use dosh::server::{DoshServer, DoshServerConfig, DoshServerEvent};
use dosh::transport::{SessionEvent, TransportEvent};
#[tokio::main]
async fn main() -> Result<()> {
let config = DoshServerConfig::default().service("echo")?;
let mut server = DoshServer::bind(config).await?;
eprintln!("listening on {}", server.local_addr()?);
loop {
match server.recv().await? {
DoshServerEvent::Accepted(client) => {
eprintln!(
"accepted user={} session={} conn={:?}",
client.user, client.session, client.conn_id
);
}
DoshServerEvent::Session {
conn_id,
event: SessionEvent::Stream(TransportEvent::Open(open)),
} => {
server.accept_stream(conn_id, open.stream_id).await?;
}
DoshServerEvent::Session {
conn_id,
event: SessionEvent::Stream(TransportEvent::Data(data)),
} => {
for chunk in data.chunks {
server.send(conn_id, data.stream_id, chunk).await?;
}
}
DoshServerEvent::Session { .. } | DoshServerEvent::Ignored => {}
}
server.maintenance().await?;
}
}
-59
View File
@@ -1,59 +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
# 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 briefly (`-max_total_time`). The job is tolerant
if the toolchain/tooling is unavailable so it never blocks the main test gate.
+215 -17
View File
@@ -1,11 +1,17 @@
param( param(
[ValidateSet("client")] [ValidateSet("client")]
[string]$Role = $(if ($env:DOSH_ROLE) { $env:DOSH_ROLE } else { "client" }), [string]$Role = $(if ($env:DOSH_ROLE) { $env:DOSH_ROLE } else { "client" }),
[string]$Repo = $env:DOSH_REPO, [string]$Repo = $(if ($env:DOSH_REPO) { $env:DOSH_REPO } else { "https://git.palav.dev/Palav/dosh.git" }),
[string]$Server = $env:DOSH_SERVER, [string]$Server = $env:DOSH_SERVER,
[string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }), [string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }),
[int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }), [int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }),
[string]$Prefix = $(if ($env:PREFIX) { $env:PREFIX } else { Join-Path $HOME ".local" }), [string]$Prefix = $(if ($env:PREFIX) { $env:PREFIX } else { Join-Path $HOME ".local" }),
[switch]$UsePrebuilt = $(-not $env:DOSH_USE_PREBUILT -or $env:DOSH_USE_PREBUILT -ne "0"),
[string]$BinaryUrl = $env:DOSH_BINARY_URL,
[string]$BinaryBase = $env:DOSH_BINARY_BASE,
[string]$BinaryName = $env:DOSH_BINARY_NAME,
[string]$BinaryVersion = $(if ($env:DOSH_BINARY_VERSION) { $env:DOSH_BINARY_VERSION } else { "latest" }),
[switch]$BinaryRequired = $($env:DOSH_BINARY_REQUIRED -and $env:DOSH_BINARY_REQUIRED -ne "0"),
[switch]$ForceConfig [switch]$ForceConfig
) )
@@ -17,8 +23,183 @@ function Require-Command($Name) {
} }
} }
Require-Command cargo function Normalize-Arch {
switch ($env:PROCESSOR_ARCHITECTURE) {
"AMD64" { "x86_64"; break }
"ARM64" { "aarch64"; break }
default { $env:PROCESSOR_ARCHITECTURE.ToLowerInvariant() }
}
}
function Release-ArtifactName {
if ($BinaryName) {
return $BinaryName
}
"dosh-windows-$(Normalize-Arch).zip"
}
function Repo-WebBase($Value) {
if (-not $Value) {
return $null
}
$base = $Value.TrimEnd("/")
if ($base.EndsWith(".git")) {
$base = $base.Substring(0, $base.Length - 4)
}
if ($base -notmatch "^https?://") {
return $null
}
$base
}
function Release-DownloadUrl {
if ($BinaryUrl) {
return $BinaryUrl
}
$name = Release-ArtifactName
if ($BinaryBase) {
return "$($BinaryBase.TrimEnd('/'))/$name"
}
$web = Repo-WebBase $Repo
if (-not $web) {
return $null
}
if ($BinaryVersion -eq "latest") {
return "$web/releases/latest/download/$name"
}
"$web/releases/download/$BinaryVersion/$name"
}
function 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) {
$checksumPath = "$Archive.sha256"
try {
Invoke-WebRequest -UseBasicParsing -Uri "$Url.sha256" -OutFile $checksumPath
}
catch {
Write-Warning "prebuilt checksum unavailable; continuing without sidecar verification"
return
}
$expected = ((Get-Content $checksumPath -Raw).Trim() -split "\s+")[0].ToLowerInvariant()
$actual = (Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant()
if ($expected -ne $actual) {
throw "prebuilt checksum mismatch for $Url"
}
}
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"
}
}
function Install-Binary($Source, $Destination) {
$dir = Split-Path -Parent $Destination
$name = Split-Path -Leaf $Destination
$tmp = Join-Path $dir ".$name.tmp.$PID"
Copy-Item $Source $tmp -Force
try {
Move-Item $tmp $Destination -Force
}
catch {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
throw
}
}
$bindir = Join-Path $Prefix "bin"
$configDir = Join-Path $HOME ".config\dosh"
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
function Install-Prebuilt {
$url = Release-LatestTagDownloadUrl
if (-not $url) {
$url = Release-DownloadUrl
}
if (-not $url) {
return $false
}
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-bin-" + [guid]::NewGuid())
$zip = Join-Path $tmp (Release-ArtifactName)
$extract = Join-Path $tmp "extract"
try {
New-Item -ItemType Directory -Force -Path $tmp, $extract | Out-Null
Write-Host "Trying Dosh prebuilt $(Release-ArtifactName)"
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $zip
Verify-ArchiveChecksum $url $zip
Expand-Archive -Force -Path $zip -DestinationPath $extract
Verify-ArchiveVersion $extract $url
foreach ($bin in @("dosh-client.exe", "dosh-bench.exe")) {
$found = Get-ChildItem -Path $extract -Recurse -File -Filter $bin | Select-Object -First 1
if (-not $found) {
throw "prebuilt archive missing $bin"
}
Install-Binary $found.FullName (Join-Path $bindir $bin)
}
Install-Binary (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe")
return $true
}
catch {
Write-Warning "prebuilt install failed: $_"
return $false
}
finally {
if (Test-Path $tmp) {
Remove-Item -Recurse -Force $tmp
}
}
}
function Install-FromSource {
Require-Command cargo
$tmp = $null $tmp = $null
if (Test-Path "Cargo.toml") { if (Test-Path "Cargo.toml") {
$src = (Get-Location).Path $src = (Get-Location).Path
@@ -35,14 +216,30 @@ if (Test-Path "Cargo.toml") {
try { try {
Push-Location $src Push-Location $src
cargo build --release --bin dosh-client --bin dosh-bench cargo build --release --bin dosh-client --bin dosh-bench
Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe")
Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe")
Install-Binary "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe")
}
finally {
Pop-Location
if ($tmp) {
Remove-Item -Recurse -Force $tmp
}
}
}
$bindir = Join-Path $Prefix "bin" if ($UsePrebuilt) {
$configDir = Join-Path $HOME ".config\dosh" $ok = Install-Prebuilt
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null if (-not $ok) {
if ($BinaryRequired) {
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force throw "prebuilt install failed and DOSH_BINARY_REQUIRED=1"
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force }
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force Write-Host "Falling back to source build"
Install-FromSource
}
} else {
Install-FromSource
}
$clientConfig = Join-Path $configDir "client.toml" $clientConfig = Join-Path $configDir "client.toml"
if ($ForceConfig -or -not (Test-Path $clientConfig)) { if ($ForceConfig -or -not (Test-Path $clientConfig)) {
@@ -60,8 +257,11 @@ dosh_port = $Port
default_session = "new" default_session = "new"
reconnect_timeout_secs = 5 reconnect_timeout_secs = 5
view_only = false view_only = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials" credential_cache = "~/.local/share/dosh/credentials"
escape_key = "^]"
"@ | Set-Content -NoNewline -Encoding utf8 $clientConfig "@ | Set-Content -NoNewline -Encoding utf8 $clientConfig
} }
@@ -74,14 +274,12 @@ credential_cache = "~/.local/share/dosh/credentials"
Write-Host "Configured UDP port $Port" Write-Host "Configured UDP port $Port"
Write-Host "" Write-Host ""
$displayServer = if ($Server) { $Server } else { "user@host" } $displayServer = if ($Server) { $Server } else { "user@host" }
Write-Host "Client command:" Write-Host "Client commands:"
Write-Host " $bindir\dosh.exe $displayServer" Write-Host " $bindir\dosh.exe $displayServer"
Write-Host " $bindir\dosh.exe setup <ssh-alias>"
Write-Host " $bindir\dosh.exe update --check"
Write-Host ""
Write-Host "Client config:"
Write-Host " $configDir\client.toml"
Write-Host "" Write-Host ""
Write-Host "Open a new terminal for PATH changes to apply." Write-Host "Open a new terminal for PATH changes to apply."
}
finally {
Pop-Location
if ($tmp) {
Remove-Item -Recurse -Force $tmp
}
}
+303 -25
View File
@@ -12,6 +12,12 @@ start_server=1
force_config=0 force_config=0
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}" update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
quiet="${DOSH_UPDATE_QUIET:-0}" quiet="${DOSH_UPDATE_QUIET:-0}"
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
binary_url="${DOSH_BINARY_URL:-}"
binary_base="${DOSH_BINARY_BASE:-}"
binary_name="${DOSH_BINARY_NAME:-}"
binary_version="${DOSH_BINARY_VERSION:-latest}"
binary_required="${DOSH_BINARY_REQUIRED:-0}"
usage() { usage() {
cat <<'EOF' cat <<'EOF'
@@ -32,6 +38,18 @@ Options:
Environment alternatives: Environment alternatives:
DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX, DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX,
DOSH_UPDATE_CACHE DOSH_UPDATE_CACHE
DOSH_USE_PREBUILT=0
Build from source instead of trying a release tarball first
DOSH_BINARY_URL URL
Exact release tarball URL to install
DOSH_BINARY_BASE URL
Release download base; otherwise derived from the latest tag
DOSH_BINARY_NAME NAME
Release tarball name; defaults to dosh-OS-ARCH.tar.gz
DOSH_BINARY_VERSION TAG
Release tag when deriving DOSH_BINARY_BASE; default latest
DOSH_BINARY_REQUIRED=1
Fail instead of falling back to source when binary install fails
EOF EOF
} }
@@ -112,8 +130,6 @@ ensure_cargo() {
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
} }
ensure_cargo
cleanup() { cleanup() {
if [ -n "${tmpdir:-}" ]; then if [ -n "${tmpdir:-}" ]; then
rm -rf "$tmpdir" rm -rf "$tmpdir"
@@ -121,6 +137,218 @@ cleanup() {
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
bindir="$prefix/bin"
config_dir="$HOME/.config/dosh"
data_dir="$HOME/.local/share/dosh"
systemd_user_dir="$HOME/.config/systemd/user"
src_dir=""
mkdir -p "$bindir" "$config_dir" "$data_dir"
normalize_os() {
case "$(uname -s)" in
Darwin) printf '%s\n' macos ;;
Linux) printf '%s\n' linux ;;
FreeBSD) printf '%s\n' freebsd ;;
*) uname -s | tr '[:upper:]' '[:lower:]' ;;
esac
}
normalize_arch() {
case "$(uname -m)" in
x86_64|amd64) printf '%s\n' x86_64 ;;
arm64|aarch64) printf '%s\n' aarch64 ;;
armv7l) printf '%s\n' armv7 ;;
*) uname -m | tr '[:upper:]' '[:lower:]' ;;
esac
}
repo_web_base() {
repo_base="$1"
case "$repo_base" in
http://*|https://*)
repo_base="${repo_base%.git}"
printf '%s\n' "${repo_base%/}"
;;
*)
return 1
;;
esac
}
release_artifact_name() {
if [ -n "$binary_name" ]; then
printf '%s\n' "$binary_name"
else
printf 'dosh-%s-%s.tar.gz\n' "$(normalize_os)" "$(normalize_arch)"
fi
}
release_download_url() {
if [ -n "$binary_url" ]; then
printf '%s\n' "$binary_url"
return 0
fi
if [ -n "$binary_base" ]; then
printf '%s/%s\n' "${binary_base%/}" "$(release_artifact_name)"
return 0
fi
if [ -z "$repo" ]; then
return 1
fi
web_base="$(repo_web_base "$repo")" || return 1
if [ "$binary_version" = "latest" ]; then
printf '%s/releases/latest/download/%s\n' "$web_base" "$(release_artifact_name)"
else
printf '%s/releases/download/%s/%s\n' "$web_base" "$binary_version" "$(release_artifact_name)"
fi
}
release_latest_tag_download_url() {
if [ -n "$binary_url" ] || [ -n "$binary_base" ] || [ "$binary_version" != "latest" ] || [ -z "$repo" ]; then
return 1
fi
web_base="$(repo_web_base "$repo")" || return 1
latest_url="$(curl -fsSL -o /dev/null -w '%{url_effective}' "$web_base/releases/latest" 2>/dev/null || true)"
case "$latest_url" in
"$web_base"/releases/tag/*)
tag="${latest_url##"$web_base"/releases/tag/}"
[ -n "$tag" ] || return 1
printf '%s/releases/download/%s/%s\n' "$web_base" "$tag" "$(release_artifact_name)"
;;
*)
return 1
;;
esac
}
find_extracted_binary() {
find "$1" -type f -name "$2" 2>/dev/null | sed -n '1p'
}
install_binary() {
src="$1"
dst="$2"
dst_dir="$(dirname "$dst")"
dst_base="$(basename "$dst")"
tmp="$dst_dir/.$dst_base.tmp.$$"
install -m 0755 "$src" "$tmp"
if ! mv -f "$tmp" "$dst"; then
rm -f "$tmp"
return 1
fi
}
install_extracted_binary() {
found="$(find_extracted_binary "$1" "$2")"
if [ -z "$found" ]; then
echo "prebuilt archive missing $2" >&2
return 1
fi
install_binary "$found" "$3"
}
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
return 1
fi
}
verify_archive_checksum() {
url="$1"
archive="$2"
checksum_file="$3"
if ! curl -fsL "$url.sha256" -o "$checksum_file"; then
echo "prebuilt checksum unavailable; continuing without sidecar verification" >&2
return 0
fi
expected="$(awk '{print $1}' "$checksum_file" | sed -n '1p')"
actual="$(sha256_file "$archive")" || {
echo "sha256sum/shasum not found for checksum verification" >&2
return 1
}
if [ "$expected" != "$actual" ]; then
echo "prebuilt checksum mismatch for $url" >&2
return 1
fi
}
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() {
download_url="$(release_latest_tag_download_url || release_download_url)" || return 1
tmpdir="$(mktemp -d)"
archive="$tmpdir/$(release_artifact_name)"
checksum_file="$archive.sha256"
[ "$quiet" = "1" ] && echo "Trying Dosh prebuilt $(release_artifact_name)"
need curl
need tar
if ! curl -fsL "$download_url" -o "$archive" 2>/dev/null; then
alt_download_url="$(release_download_url || true)"
if [ -z "$alt_download_url" ] || ! curl -fsL "$alt_download_url" -o "$archive"; then
echo "prebuilt unavailable: $download_url" >&2
[ -z "$alt_download_url" ] || echo "prebuilt unavailable: $alt_download_url" >&2
return 1
fi
download_url="$alt_download_url"
fi
verify_archive_checksum "$download_url" "$archive" "$checksum_file" || return 1
mkdir -p "$tmpdir/extract"
if ! tar -xzf "$archive" -C "$tmpdir/extract"; then
echo "prebuilt archive could not be extracted: $download_url" >&2
return 1
fi
verify_archive_version "$tmpdir/extract" "$download_url" || return 1
install_extracted_binary "$tmpdir/extract" dosh-client "$bindir/dosh-client" || return 1
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install_extracted_binary "$tmpdir/extract" dosh-server "$bindir/dosh-server" || return 1
install_extracted_binary "$tmpdir/extract" dosh-auth "$bindir/dosh-auth" || return 1
fi
if found_bench="$(find_extracted_binary "$tmpdir/extract" dosh-bench)" && [ -n "$found_bench" ]; then
install_binary "$found_bench" "$bindir/dosh-bench"
fi
return 0
}
install_from_source() {
ensure_cargo
if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then
src_dir="$(pwd)" src_dir="$(pwd)"
else else
@@ -173,21 +401,60 @@ else
fi fi
fi fi
bindir="$prefix/bin" install_binary target/release/dosh-client "$bindir/dosh-client"
config_dir="$HOME/.config/dosh"
data_dir="$HOME/.local/share/dosh"
systemd_user_dir="$HOME/.config/systemd/user"
mkdir -p "$bindir" "$config_dir" "$data_dir"
install -m 0755 target/release/dosh-client "$bindir/dosh-client"
ln -sf dosh-client "$bindir/dosh" ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install -m 0755 target/release/dosh-server "$bindir/dosh-server" install_binary target/release/dosh-server "$bindir/dosh-server"
install -m 0755 target/release/dosh-auth "$bindir/dosh-auth" install_binary target/release/dosh-auth "$bindir/dosh-auth"
fi fi
if [ -f target/release/dosh-bench ]; then if [ -f target/release/dosh-bench ]; then
install -m 0755 target/release/dosh-bench "$bindir/dosh-bench" install_binary target/release/dosh-bench "$bindir/dosh-bench"
fi
}
write_systemd_service() {
if [ -n "$src_dir" ] && [ -f "$src_dir/packaging/systemd/dosh-server.service" ]; then
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \
"$src_dir/packaging/systemd/dosh-server.service" >"$systemd_user_dir/dosh-server.service"
else
cat >"$systemd_user_dir/dosh-server.service" <<EOF
[Unit]
Description=dosh dormant shell server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$bindir/dosh-server serve
Restart=on-failure
RestartSec=1
KillMode=process
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=$config_dir $data_dir
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
[Install]
WantedBy=default.target
EOF
fi
}
if [ "$use_prebuilt" != "0" ]; then
if ! try_install_prebuilt; then
if [ "$binary_required" = "1" ]; then
exit 1
fi
[ "$quiet" = "1" ] && echo "Falling back to source build"
install_from_source
fi
else
install_from_source
fi fi
if [ "$role" = "server" ] || [ "$role" = "both" ]; then if [ "$role" = "server" ] || [ "$role" = "both" ]; then
@@ -206,8 +473,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
@@ -221,15 +489,16 @@ 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
if command -v systemctl >/dev/null 2>&1; then if command -v systemctl >/dev/null 2>&1; then
mkdir -p "$systemd_user_dir" mkdir -p "$systemd_user_dir"
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \ write_systemd_service
packaging/systemd/dosh-server.service >"$systemd_user_dir/dosh-server.service"
if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then
systemctl --user enable --now dosh-server.service systemctl --user enable dosh-server.service
systemctl --user restart dosh-server.service
fi fi
elif [ "$start_server" -eq 1 ]; then elif [ "$start_server" -eq 1 ]; then
nohup "$bindir/dosh-server" serve >"$data_dir/dosh-server.log" 2>&1 & nohup "$bindir/dosh-server" serve >"$data_dir/dosh-server.log" 2>&1 &
@@ -272,7 +541,8 @@ dosh_port = $port
default_session = "new" default_session = "new"
reconnect_timeout_secs = 5 reconnect_timeout_secs = 5
view_only = false view_only = false
predict = false predict = true
predict_mode = "experimental"
cache_attach_tickets = true cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials" credential_cache = "~/.local/share/dosh/credentials"
auth_preference = "native,ssh" auth_preference = "native,ssh"
@@ -282,6 +552,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
@@ -295,18 +566,18 @@ EOF
fi fi
cat >"$hosts_config" <<EOF cat >"$hosts_config" <<EOF
# Example: # Example:
# [palav] # [server]
# ssh = "palav" # ssh = "server"
# dosh_host = "palav.dev" # dosh_host = "server.example.com"
# port = 50000 # port = 50000
# default_command = "tm" # default_command = "tm"
# predict = false # predict = true
[default] [default]
ssh = "$default_server" ssh = "$default_server"
dosh_host = "$host_udp" dosh_host = "$host_udp"
port = $port port = $port
predict = false predict = true
EOF EOF
fi fi
fi fi
@@ -317,10 +588,17 @@ Configured UDP port $port
EOF EOF
if [ "$role" = "client" ] || [ "$role" = "both" ]; then if [ "$role" = "client" ] || [ "$role" = "both" ]; then
next_server="${server:-user@host}"
cat <<EOF cat <<EOF
Client command: Client commands:
$bindir/dosh ${server:-user@host} $bindir/dosh $next_server
$bindir/dosh setup <ssh-alias>
$bindir/dosh update --check
Client config:
$config_dir/client.toml
$config_dir/hosts.toml
EOF EOF
fi fi
-201
View File
@@ -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 palav 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.
-6
View File
@@ -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 "$@"
+2 -1
View File
@@ -8,12 +8,13 @@ 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
ProtectHome=read-only ProtectHome=read-only
ReadWritePaths=%h/.config/dosh %h/.local/share/dosh ReadWritePaths=%h/.config/dosh %h/.local/share/dosh
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
RestrictRealtime=true RestrictRealtime=true
LockPersonality=true LockPersonality=true
MemoryDenyWriteExecute=true MemoryDenyWriteExecute=true
+1 -2
View File
@@ -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
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -eu
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
out="${DOSH_BENCH_REPORT:-target/dosh-bench/report.md}"
iters="${DOSH_BENCH_ITERS:-10}"
server="${DOSH_BENCH_SERVER:-${1:-local}}"
label="${DOSH_BENCH_LABEL:-$(uname -s) $(uname -m)}"
cargo build --release >/dev/null
mkdir -p "$(dirname "$out")"
exec target/release/dosh-bench \
--server "$server" \
--iterations "$iters" \
--label "$label" \
--markdown \
--output "$out" \
${DOSH_BENCH_ARGS:-}
+27 -1
View File
@@ -31,7 +31,7 @@ for _ in 1 2 3 4 5; do
done done
ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts" ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts"
mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-cached" "$workdir/home-mosh" mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-native" "$workdir/home-cached" "$workdir/home-mosh"
if ! HOME="$workdir/home" target/release/dosh-bench \ if ! HOME="$workdir/home" target/release/dosh-bench \
--server bench@127.0.0.1 \ --server bench@127.0.0.1 \
@@ -64,6 +64,32 @@ if ! HOME="$workdir/home-controlmaster" target/release/dosh-bench \
exit 1 exit 1
fi fi
if ! HOME="$workdir/home-native" target/release/dosh-client \
--ssh-port "$ssh_port" \
--ssh-key "$workdir/id_ed25519" \
--ssh-known-hosts "$workdir/known_hosts" \
--ssh-auth-command /usr/local/bin/dosh-auth \
trust bench@127.0.0.1 >/dev/null; then
docker logs "$container_id" || true
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
exit 1
fi
if ! HOME="$workdir/home-native" target/release/dosh-bench \
--server bench@127.0.0.1 \
--ssh-port "$ssh_port" \
--dosh-port "$dosh_port" \
--dosh-host 127.0.0.1 \
--ssh-key "$workdir/id_ed25519" \
--ssh-known-hosts "$workdir/known_hosts" \
--iterations 5 \
--cold-native \
--assert-ssh-plus-ms "${DOSH_BENCH_NATIVE_SSH_PLUS_MS:-0}"; then
docker logs "$container_id" || true
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
exit 1
fi
if ! HOME="$workdir/home-cached" target/release/dosh-bench \ if ! HOME="$workdir/home-cached" target/release/dosh-bench \
--server bench@127.0.0.1 \ --server bench@127.0.0.1 \
--ssh-port "$ssh_port" \ --ssh-port "$ssh_port" \
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env sh
set -eu
seconds="${DOSH_FUZZ_SECONDS:-${1:-20}}"
targets="
packet_decode
from_body
authorized_keys
known_hosts
handshake_structs
attach_ticket
"
if ! command -v cargo >/dev/null 2>&1; then
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
fi
for target in $targets; do
echo "== fuzzing $target for ${seconds}s =="
cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \
-max_total_time="$seconds" -rss_limit_mb="${DOSH_FUZZ_RSS_MB:-4096}"
done
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env sh
set -eu
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
out_dir="${DOSH_PACKAGE_DIR:-target/dosh-release}"
version="${DOSH_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | sed -n '1p')}"
normalize_os() {
case "$(uname -s)" in
Darwin) printf '%s\n' macos ;;
Linux) printf '%s\n' linux ;;
FreeBSD) printf '%s\n' freebsd ;;
MINGW*|MSYS*|CYGWIN*) printf '%s\n' windows ;;
*) uname -s | tr '[:upper:]' '[:lower:]' ;;
esac
}
normalize_arch() {
case "$(uname -m)" in
x86_64|amd64) printf '%s\n' x86_64 ;;
arm64|aarch64) printf '%s\n' aarch64 ;;
armv7l) printf '%s\n' armv7 ;;
*) uname -m | tr '[:upper:]' '[:lower:]' ;;
esac
}
host_os="$(normalize_os)"
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
artifact="dosh-$os-$arch.zip"
versioned_artifact="dosh-$version-$os-$arch.zip"
else
artifact="dosh-$os-$arch.tar.gz"
versioned_artifact="dosh-$version-$os-$arch.tar.gz"
fi
stage="$out_dir/stage/dosh"
write_versioned="${DOSH_PACKAGE_VERSIONED:-0}"
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"
mkdir -p "$stage/bin" "$out_dir"
for bin in dosh-client dosh-server dosh-auth dosh-bench; do
if [ -f "$release_dir/$bin" ]; then
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
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"
if [ "$os" = "windows" ]; then
if command -v powershell.exe >/dev/null 2>&1; then
powershell.exe -NoProfile -Command "Compress-Archive -Force -Path '$stage' -DestinationPath '$out_dir/$artifact'"
elif command -v zip >/dev/null 2>&1; then
(cd "$out_dir/stage" && zip -qr "../$artifact" dosh)
else
echo "windows packaging requires powershell.exe or zip" >&2
exit 1
fi
else
tar -C "$out_dir/stage" -czf "$out_dir/$artifact" dosh
fi
write_sha256() {
file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$file" >"$file.sha256"
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" >"$file.sha256"
else
echo "warning: sha256sum/shasum not found; skipping checksum for $file" >&2
fi
}
write_sha256 "$out_dir/$artifact"
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
$out_dir/$artifact
$out_dir/$artifact.sha256
EOF
if [ "$write_versioned" = "1" ]; then
cat <<EOF
$out_dir/$versioned_artifact
$out_dir/$versioned_artifact.sha256
EOF
fi
+145
View File
@@ -0,0 +1,145 @@
#!/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
artifact_version() {
artifact="$1"
case "$artifact" in
*.tar.gz)
tar -xOf "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
;;
*.zip)
unzip -p "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
;;
*)
return 1
;;
esac
}
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
actual="$(artifact_version "$artifact" || true)"
if [ "$actual" != "$version" ]; then
echo "skipping stale artifact $name: version ${actual:-unknown}, expected $version" >&2
continue
fi
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
+17
View File
@@ -0,0 +1,17 @@
#!/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 \
tui_graph_glyphs_survive_fast_repaints_without_snapshot_substitution \
btop_style_graph_output_stays_raw_when_retransmit_history_overflows \
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
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env sh
set -eu
usage() {
cat <<'EOF'
Usage:
GITEA_TOKEN=... scripts/upload-gitea-release.sh TAG [artifact...]
Environment:
GITEA_URL Base URL, default https://git.palav.dev
GITEA_REPO owner/repo, default Palav/dosh
GITEA_TOKEN Token with release write permission
GITEA_TITLE Release title, default TAG
EOF
}
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
usage
exit 0
fi
tag="${1:-}"
if [ -z "$tag" ]; then
usage >&2
exit 2
fi
shift
base="${GITEA_URL:-https://git.palav.dev}"
repo="${GITEA_REPO:-Palav/dosh}"
token="${GITEA_TOKEN:-}"
title="${GITEA_TITLE:-$tag}"
expected_version="${tag#v}"
artifact_version() {
artifact="$1"
case "$artifact" in
*.tar.gz)
tar -xOf "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
;;
*.zip)
unzip -p "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
;;
*)
return 1
;;
esac
}
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
echo "GITEA_TOKEN is required (or put it in ~/.config/dosh/gitea-token)" >&2
exit 2
fi
if [ "$#" -eq 0 ]; then
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
actual="$(artifact_version "$artifact" || true)"
if [ "$actual" != "$expected_version" ]; then
echo "skipping $artifact: version ${actual:-unknown}, expected $expected_version" >&2
continue
fi
if [ -f "$artifact.sha256" ]; then
set -- "$@" "$artifact" "$artifact.sha256"
else
set -- "$@" "$artifact"
fi
done
fi
api="$base/api/v1/repos/$repo"
auth_header="Authorization: token $token"
release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag" 2>/dev/null || true)"
release_id=""
if [ -n "$release_json" ] && command -v jq >/dev/null 2>&1; then
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
elif [ -n "$release_json" ]; then
release_id="$(printf '%s\n' "$release_json" | sed -n 's/^[[:space:]]*{"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
fi
if [ -z "$release_id" ]; then
payload="$(printf '{"tag_name":"%s","target_commitish":"main","name":"%s","body":"Dosh release %s","draft":false,"prerelease":false}\n' "$tag" "$title" "$tag")"
release_json="$(curl -fsS -H "$auth_header" -H 'Content-Type: application/json' -X POST "$api/releases" -d "$payload")"
if command -v jq >/dev/null 2>&1; then
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
else
release_id="$(printf '%s\n' "$release_json" | sed -n 's/^[[:space:]]*{"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
fi
fi
if [ -z "$release_id" ]; then
echo "could not determine Gitea release id for $tag" >&2
exit 1
fi
delete_existing_asset() {
name="$1"
if ! command -v jq >/dev/null 2>&1; then
return 0
fi
printf '%s\n' "$release_json" \
| jq -r --arg name "$name" '.assets[]? | select(.name == $name) | .id' \
| while IFS= read -r asset_id; do
[ -n "$asset_id" ] || continue
echo "replacing $name"
curl -fsS \
-H "$auth_header" \
-X DELETE \
"$api/releases/$release_id/assets/$asset_id" >/dev/null
done
}
verify_release_artifact_version() {
artifact="$1"
name="$(basename "$artifact")"
case "$name" in
*.sha256)
return 0
;;
dosh-*.tar.gz|dosh-*.zip)
actual="$(artifact_version "$artifact" || true)"
if [ -z "$actual" ]; then
echo "release artifact missing dosh/VERSION: $artifact" >&2
exit 1
fi
if [ "$actual" != "$expected_version" ]; then
echo "release artifact version mismatch: $artifact has $actual, expected $expected_version" >&2
exit 1
fi
;;
esac
}
uploaded=0
for artifact in "$@"; do
if [ ! -f "$artifact" ]; then
continue
fi
name="$(basename "$artifact")"
verify_release_artifact_version "$artifact"
delete_existing_asset "$name"
echo "uploading $name"
curl -fsS \
-H "$auth_header" \
-X POST \
-F "attachment=@$artifact" \
"$api/releases/$release_id/assets?name=$name" >/dev/null
uploaded=$((uploaded + 1))
done
if [ "$uploaded" -eq 0 ]; then
echo "no release artifacts uploaded" >&2
exit 1
fi
+9 -4
View File
@@ -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
View File
@@ -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,
+144 -12
View File
@@ -2,6 +2,8 @@ use anyhow::{Context, Result, anyhow};
use clap::Parser; use clap::Parser;
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fmt::Write as _;
use std::fs;
use std::io::Read; use std::io::Read;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
@@ -9,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,
@@ -63,6 +69,12 @@ struct Args {
/// Emit machine-readable JSON (one object per metric, with raw samples). /// Emit machine-readable JSON (one object per metric, with raw samples).
#[arg(long)] #[arg(long)]
json: bool, json: bool,
/// Emit a publishable Markdown benchmark report.
#[arg(long)]
markdown: bool,
/// Write the report to a file instead of stdout.
#[arg(long)]
output: Option<PathBuf>,
/// Optional label printed in summary/JSON output (e.g. machine/OS identifier). /// Optional label printed in summary/JSON output (e.g. machine/OS identifier).
#[arg(long)] #[arg(long)]
label: Option<String>, label: Option<String>,
@@ -179,11 +191,14 @@ fn main() -> Result<()> {
results.push(MetricSamples::new("mosh_start_true_ms", mosh_times)); results.push(MetricSamples::new("mosh_start_true_ms", mosh_times));
} }
if args.json { let report = if args.json {
print_json(&args, &results); render_json(&args, &results)
} else if args.markdown {
render_markdown(&args, &results)
} else { } else {
print_table(&args, &results); render_table(&args, &results)
} };
write_report(&args, &report)?;
run_assertions(&args, &results)?; run_assertions(&args, &results)?;
Ok(()) Ok(())
@@ -560,17 +575,20 @@ fn percentile(sorted: &[f64], pct: f64) -> f64 {
} }
} }
fn print_table(args: &Args, results: &[MetricSamples]) { fn render_table(args: &Args, results: &[MetricSamples]) -> String {
let mut out = String::new();
if let Some(label) = &args.label { if let Some(label) = &args.label {
println!("# label: {label}"); let _ = writeln!(out, "# label: {label}");
} }
println!( let _ = writeln!(
out,
"{:<24} {:>6} {:>9} {:>9} {:>9} {:>9} {:>9}", "{:<24} {:>6} {:>9} {:>9} {:>9} {:>9} {:>9}",
"metric", "n", "min", "median", "p95", "mean", "max" "metric", "n", "min", "median", "p95", "mean", "max"
); );
for metric in results { for metric in results {
let s = metric.stats(); let s = metric.stats();
println!( let _ = writeln!(
out,
"{:<24} {:>6} {:>9.2} {:>9.2} {:>9.2} {:>9.2} {:>9.2}", "{:<24} {:>6} {:>9.2} {:>9.2} {:>9.2} {:>9.2} {:>9.2}",
metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max
); );
@@ -578,11 +596,12 @@ fn print_table(args: &Args, results: &[MetricSamples]) {
// Raw per-iteration samples, so published numbers can include raw data. // Raw per-iteration samples, so published numbers can include raw data.
for metric in results { for metric in results {
let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect(); let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect();
println!("{} samples_ms=[{}]", metric.name, ms.join(", ")); let _ = writeln!(out, "{} samples_ms=[{}]", metric.name, ms.join(", "));
} }
out
} }
fn print_json(args: &Args, results: &[MetricSamples]) { fn render_json(args: &Args, results: &[MetricSamples]) -> String {
let mut entries = Vec::new(); let mut entries = Vec::new();
for metric in results { for metric in results {
let s = metric.stats(); let s = metric.stats();
@@ -603,11 +622,58 @@ fn print_json(args: &Args, results: &[MetricSamples]) {
Some(label) => format!("\"{}\"", label.replace('"', "\\\"")), Some(label) => format!("\"{}\"", label.replace('"', "\\\"")),
None => "null".to_string(), None => "null".to_string(),
}; };
println!( format!(
"{{\"label\":{label},\"iterations\":{},\"metrics\":[{}]}}", "{{\"label\":{label},\"iterations\":{},\"metrics\":[{}]}}",
args.iterations.max(1), args.iterations.max(1),
entries.join(",") entries.join(",")
)
}
fn render_markdown(args: &Args, results: &[MetricSamples]) -> String {
let mut out = String::new();
let label = args.label.as_deref().unwrap_or("Dosh benchmark");
let _ = writeln!(out, "# {label}\n");
let _ = writeln!(out, "- server: `{}`", args.server);
let _ = writeln!(out, "- iterations: `{}`", args.iterations.max(1));
let _ = writeln!(
out,
"- generated_by: `dosh-bench {}`\n",
env!("CARGO_PKG_VERSION")
); );
let _ = writeln!(
out,
"| metric | n | min ms | median ms | p95 ms | mean ms | max ms |"
);
let _ = writeln!(out, "|---|---:|---:|---:|---:|---:|---:|");
for metric in results {
let s = metric.stats();
let _ = writeln!(
out,
"| `{}` | {} | {:.2} | {:.2} | {:.2} | {:.2} | {:.2} |",
metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max
);
}
let _ = writeln!(out, "\n## Raw Samples\n");
for metric in results {
let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect();
let _ = writeln!(out, "- `{}`: [{}]", metric.name, ms.join(", "));
}
out
}
fn write_report(args: &Args, report: &str) -> Result<()> {
if let Some(path) = &args.output {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
fs::write(path, report).with_context(|| format!("write {}", path.display()))?;
println!("wrote {}", path.display());
} else {
println!("{report}");
}
Ok(())
} }
fn metric_mean(results: &[MetricSamples], name: &str) -> Option<f64> { fn metric_mean(results: &[MetricSamples], name: &str) -> Option<f64> {
@@ -677,3 +743,69 @@ fn default_client_path() -> PathBuf {
.and_then(|path| path.parent().map(|parent| parent.join("dosh-client"))) .and_then(|path| path.parent().map(|parent| parent.join("dosh-client")))
.unwrap_or_else(|| PathBuf::from("dosh-client")) .unwrap_or_else(|| PathBuf::from("dosh-client"))
} }
#[cfg(test)]
mod tests {
use super::*;
fn args_for_render() -> Args {
Args {
server: "local".to_string(),
session: "default".to_string(),
ssh_port: 22,
dosh_port: 50000,
dosh_host: None,
iterations: 2,
local_auth: true,
cold_native: false,
cached_ticket: false,
resume: false,
client: None,
ssh_auth_command: "~/.local/bin/dosh-auth".to_string(),
ssh_key: None,
ssh_known_hosts: None,
ssh_control_path: None,
controlmaster: false,
no_cache: false,
warm_cache: false,
skip_ssh_baseline: true,
include_mosh: false,
mosh: PathBuf::from("mosh"),
mosh_server_command: "mosh-server".to_string(),
mosh_port: None,
json: false,
markdown: true,
output: None,
label: Some("test host".to_string()),
assert_ssh_plus_ms: None,
assert_mosh_minus_ms: None,
assert_dosh_max_ms: None,
}
}
#[test]
fn markdown_report_contains_summary_and_samples() {
let args = args_for_render();
let metrics = vec![MetricSamples::new(
"dosh_cached_attach_ms",
vec![Duration::from_millis(3), Duration::from_millis(5)],
)];
let report = render_markdown(&args, &metrics);
assert!(report.contains("# test host"));
assert!(report.contains("| `dosh_cached_attach_ms` | 2 | 3.00"));
assert!(report.contains("- `dosh_cached_attach_ms`: [3.00, 5.00]"));
}
#[test]
fn json_report_contains_metrics() {
let args = args_for_render();
let metrics = vec![MetricSamples::new(
"dosh_local_attach_ms",
vec![Duration::from_millis(7)],
)];
let report = render_json(&args, &metrics);
assert!(report.contains("\"label\":\"test host\""));
assert!(report.contains("\"metric\":\"dosh_local_attach_ms\""));
assert!(report.contains("\"samples_ms\":[7.0000]"));
}
}
+4360 -155
View File
File diff suppressed because it is too large Load Diff
+1959 -175
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -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"),
")"
);
+442
View File
@@ -0,0 +1,442 @@
use crate::config::{
ClientConfig, HostConfig, expand_tilde, load_client_config, load_hosts_config,
};
use crate::crypto;
use crate::native::{
self, EnvVar, ForwardingKind, ForwardingRequest, KnownHostStatus, NativeClientHello,
derive_native_session_key, generate_native_ephemeral, sign_user_auth_with_private_key,
supported_user_key_algorithms, trust_host, verify_known_host, verify_server_hello,
};
use crate::protocol::{
self, AttachReject, CLIENT_TO_SERVER, NativeAuthOkBody, NativeClientHelloBody,
NativeServerHelloBody, NativeUserAuthBody, PacketKind, SERVER_TO_CLIENT,
};
use crate::ssh_agent;
use crate::transport::{DoshTransport, SessionRole, SessionTransportConfig, TransportConfig};
use anyhow::{Context, Result, anyhow, bail};
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::net::UdpSocket;
#[derive(Debug, Clone)]
pub struct DoshClient {
config: ClientConfig,
hosts: crate::config::HostsConfig,
}
impl DoshClient {
pub fn load() -> Result<Self> {
Ok(Self {
config: load_client_config(None)?,
hosts: load_hosts_config(None)?,
})
}
pub fn load_from_paths(
client_config: Option<PathBuf>,
hosts_config: Option<PathBuf>,
) -> Result<Self> {
Ok(Self {
config: load_client_config(client_config)?,
hosts: load_hosts_config(hosts_config)?,
})
}
pub fn with_config(config: ClientConfig, hosts: crate::config::HostsConfig) -> Self {
Self { config, hosts }
}
pub fn connect(&self, host: impl Into<String>) -> DoshClientBuilder {
DoshClientBuilder::new(self.clone(), host.into())
}
}
#[derive(Debug, Clone)]
pub struct DoshClientBuilder {
client: DoshClient,
host: String,
services: Vec<String>,
identity_files: Vec<PathBuf>,
session: Option<String>,
user: Option<String>,
udp_host: Option<String>,
udp_port: Option<u16>,
trust_on_first_use: Option<bool>,
use_ssh_agent: Option<bool>,
timeout: Option<Duration>,
env: Vec<EnvVar>,
}
impl DoshClientBuilder {
pub fn new(client: DoshClient, host: String) -> Self {
Self {
client,
host,
services: Vec::new(),
identity_files: Vec::new(),
session: None,
user: None,
udp_host: None,
udp_port: None,
trust_on_first_use: None,
use_ssh_agent: None,
timeout: None,
env: Vec::new(),
}
}
pub fn service(mut self, name: impl Into<String>) -> Self {
self.services.push(name.into());
self
}
pub fn services(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.services.extend(names.into_iter().map(Into::into));
self
}
pub fn identity_file(mut self, path: impl Into<PathBuf>) -> Self {
self.identity_files.push(path.into());
self
}
pub fn session(mut self, session: impl Into<String>) -> Self {
self.session = Some(session.into());
self
}
pub fn user(mut self, user: impl Into<String>) -> Self {
self.user = Some(user.into());
self
}
pub fn udp_host(mut self, host: impl Into<String>) -> Self {
self.udp_host = Some(host.into());
self
}
pub fn udp_port(mut self, port: u16) -> Self {
self.udp_port = Some(port);
self
}
pub fn trust_on_first_use(mut self, trust: bool) -> Self {
self.trust_on_first_use = Some(trust);
self
}
pub fn use_ssh_agent(mut self, value: bool) -> Self {
self.use_ssh_agent = Some(value);
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn env(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.env.push(EnvVar {
name: name.into(),
value: value.into(),
});
self
}
pub async fn connect(self) -> Result<ConnectedDoshClient> {
let host_config = self
.client
.hosts
.hosts
.get(&self.host)
.cloned()
.unwrap_or_default();
let raw_server = host_config.ssh.clone().unwrap_or_else(|| self.host.clone());
let requested_user = self
.user
.clone()
.or_else(|| host_config.user.clone())
.or_else(|| user_from_destination(&raw_server))
.or_else(|| std::env::var("USER").ok())
.unwrap_or_else(|| "unknown".to_string());
let udp_host = self
.udp_host
.clone()
.or_else(|| host_config.dosh_host.clone())
.or_else(|| self.client.config.dosh_host.clone())
.unwrap_or_else(|| destination_host(&raw_server));
let udp_port = self
.udp_port
.or(host_config.port)
.unwrap_or(self.client.config.dosh_port);
let peer_addr = resolve_addr(&udp_host, udp_port)?;
let timeout = self.timeout.unwrap_or_else(|| {
Duration::from_millis(self.client.config.native_auth_timeout_ms.max(1))
});
let session = self.session.unwrap_or_else(default_sdk_session);
let requested_forwardings = self
.services
.iter()
.map(|service| {
Ok(ForwardingRequest {
kind: ForwardingKind::Local,
bind_host: None,
listen_port: 0,
target_host: Some(crate::transport::service_target(service)?),
target_port: Some(0),
})
})
.collect::<Result<Vec<_>>>()?;
let socket = UdpSocket::bind("0.0.0.0:0").await?;
let (client_secret, client_public) = generate_native_ephemeral();
let hello = NativeClientHello {
protocol_version: native::NATIVE_PROTOCOL_VERSION,
client_random: crypto::random_32(),
client_ephemeral_public: client_public,
requested_host: self.host.clone(),
requested_user,
requested_session: session.clone(),
requested_mode: "forward-only".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: supported_user_key_algorithms(),
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: self.env,
};
let packet = protocol::encode_plain(
PacketKind::NativeClientHello,
[0u8; 16],
1,
0,
&protocol::to_body(&NativeClientHelloBody {
hello: hello.clone(),
})?,
)?;
socket.send_to(&packet, peer_addr).await?;
let mut buf = vec![0u8; 65535];
let (n, _) = tokio::time::timeout(timeout, socket.recv_from(&mut buf)).await??;
let packet = protocol::decode(&buf[..n])?;
if packet.header.kind != PacketKind::NativeServerHello {
if packet.header.kind == PacketKind::AttachReject {
let reject: AttachReject = protocol::from_body(&packet.body)?;
bail!("native auth rejected: {}", reject.reason);
}
bail!("native auth received unexpected server response");
}
let server_hello: NativeServerHelloBody = protocol::from_body(&packet.body)?;
verify_server_hello(&hello, &server_hello.hello)?;
verify_or_trust_host(
&self.client.config,
&self.host,
&server_hello.hello.host_key,
self.trust_on_first_use,
)?;
let session_key = derive_native_session_key(
&client_secret,
server_hello.hello.server_ephemeral_public,
&hello,
&server_hello.hello,
)?;
let auth = sign_auth(
&self.client.config,
&host_config,
&hello,
&server_hello.hello,
requested_forwardings,
self.identity_files,
self.use_ssh_agent,
)?;
let mut pending_id = [0u8; 16];
pending_id.copy_from_slice(&server_hello.hello.auth_challenge[..16]);
let auth_packet = protocol::encode_encrypted(
PacketKind::NativeUserAuth,
pending_id,
2,
1,
&session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&NativeUserAuthBody { auth })?,
)?;
socket.send_to(&auth_packet, peer_addr).await?;
let (n, _) = tokio::time::timeout(timeout, socket.recv_from(&mut buf)).await??;
let packet = protocol::decode(&buf[..n])?;
if packet.header.kind != PacketKind::NativeAuthOk {
if packet.header.kind == PacketKind::AttachReject {
let reject: AttachReject = protocol::from_body(&packet.body)?;
bail!("native auth rejected: {}", reject.reason);
}
bail!("native auth received unexpected auth response");
}
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT)?;
let ok: NativeAuthOkBody = protocol::from_body(&plain)?;
let transport = DoshTransport::new_owned(
socket,
SessionTransportConfig {
role: SessionRole::Client,
conn_id: ok.ok.client_id,
session_key: ok.ok.session_key,
peer_addr,
initial_send_seq: 2,
initial_ack: ok.ok.initial_seq,
stream: TransportConfig::default(),
},
);
Ok(ConnectedDoshClient {
host: self.host,
session: ok.ok.session,
transport,
})
}
}
pub struct ConnectedDoshClient {
pub host: String,
pub session: String,
pub transport: DoshTransport,
}
impl ConnectedDoshClient {
pub fn into_transport(self) -> DoshTransport {
self.transport
}
}
fn verify_or_trust_host(
config: &ClientConfig,
host: &str,
host_key: &native::HostPublicKey,
trust_override: Option<bool>,
) -> Result<()> {
let known_hosts = expand_tilde(&config.known_hosts);
match verify_known_host(&known_hosts, host, host_key)? {
KnownHostStatus::Trusted => Ok(()),
KnownHostStatus::Unknown if trust_override.unwrap_or(config.trust_on_first_use) => {
trust_host(&known_hosts, host, host_key, "sdk-tofu", false)?;
Ok(())
}
KnownHostStatus::Unknown => Err(anyhow!(
"Dosh host key for {host} is not trusted; run `dosh trust {host}` first or enable trust_on_first_use"
)),
KnownHostStatus::Mismatch { expected, actual } => Err(anyhow!(
"Dosh host key mismatch for {host}: expected {expected}, got {actual}"
)),
}
}
fn sign_auth(
config: &ClientConfig,
host_config: &HostConfig,
hello: &NativeClientHello,
server_hello: &native::NativeServerHello,
requested_forwardings: Vec<ForwardingRequest>,
explicit_identity_files: Vec<PathBuf>,
use_ssh_agent: Option<bool>,
) -> Result<native::NativeUserAuth> {
let use_agent = use_ssh_agent.unwrap_or(config.use_ssh_agent);
let mut errors = Vec::new();
if use_agent {
match ssh_agent::sign_user_auth_with_agent(
hello,
server_hello,
requested_forwardings.clone(),
) {
Ok(auth) => return Ok(auth),
Err(err) => errors.push(format!("ssh-agent: {err:#}")),
}
}
let mut paths = explicit_identity_files;
if paths.is_empty() {
paths.extend(config.identity_files.iter().map(|path| expand_tilde(path)));
}
if paths.is_empty() {
paths.extend(default_identity_paths());
}
for path in paths {
match native::load_native_identity(&path).and_then(|identity| {
sign_user_auth_with_private_key(
&identity,
hello,
server_hello,
requested_forwardings.clone(),
)
}) {
Ok(auth) => return Ok(auth),
Err(err) => errors.push(format!("{}: {err:#}", path.display())),
}
}
let _ = host_config;
Err(anyhow!(
"native auth found no usable identity: {}",
errors.join("; ")
))
}
fn default_identity_paths() -> Vec<PathBuf> {
["~/.ssh/id_ed25519", "~/.ssh/id_ecdsa", "~/.ssh/id_rsa"]
.into_iter()
.map(expand_tilde)
.collect()
}
fn resolve_addr(host: &str, port: u16) -> Result<SocketAddr> {
(host, port)
.to_socket_addrs()
.with_context(|| format!("resolve UDP target {host}:{port}"))?
.next()
.ok_or_else(|| anyhow!("no UDP address resolved for {host}:{port}"))
}
fn destination_host(destination: &str) -> String {
destination
.rsplit('@')
.next()
.unwrap_or(destination)
.split(':')
.next()
.unwrap_or(destination)
.to_string()
}
fn user_from_destination(destination: &str) -> Option<String> {
destination
.rsplit_once('@')
.map(|(user, _)| user.to_string())
.filter(|user| !user.is_empty())
}
fn default_sdk_session() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("sdk-{millis}-{}", std::process::id())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_user_and_host_from_destination() {
assert_eq!(
user_from_destination("palav@example.com").as_deref(),
Some("palav")
);
assert_eq!(destination_host("palav@example.com"), "example.com");
assert_eq!(destination_host("example.com:2222"), "example.com");
}
#[test]
fn default_identity_paths_are_expanded() {
assert!(
default_identity_paths()
.iter()
.any(|path| path.ends_with(".ssh/id_ed25519"))
);
}
}
+60 -6
View File
@@ -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,
@@ -44,8 +46,16 @@ pub struct ServerConfig {
pub allow_remote_non_loopback_bind: bool, pub allow_remote_non_loopback_bind: bool,
#[serde(default)] #[serde(default)]
pub allow_agent_forwarding: bool, pub allow_agent_forwarding: bool,
#[serde(default = "default_true")]
pub allow_file_transfer: bool,
#[serde(default = "default_true")]
pub allow_exec_command: bool,
#[serde(default = "default_accept_env")] #[serde(default = "default_accept_env")]
pub accept_env: Vec<String>, pub accept_env: Vec<String>,
/// Run terminal sessions under per-session holder processes so shells
/// survive a quick `dosh-server` restart and clients can reconnect.
#[serde(default = "default_true")]
pub persist_sessions: bool,
} }
impl Default for ServerConfig { impl Default for ServerConfig {
@@ -57,8 +67,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,
@@ -75,7 +86,10 @@ impl Default for ServerConfig {
allow_remote_forwarding: false, allow_remote_forwarding: false,
allow_remote_non_loopback_bind: false, allow_remote_non_loopback_bind: false,
allow_agent_forwarding: false, allow_agent_forwarding: false,
allow_file_transfer: true,
allow_exec_command: true,
accept_env: default_accept_env(), accept_env: default_accept_env(),
persist_sessions: true,
} }
} }
} }
@@ -92,7 +106,7 @@ pub struct ClientConfig {
pub default_session: String, pub default_session: String,
pub reconnect_timeout_secs: u64, pub reconnect_timeout_secs: u64,
pub view_only: bool, pub view_only: bool,
#[serde(default)] #[serde(default = "default_true")]
pub predict: bool, pub predict: bool,
/// Prediction display policy: "off", "experimental" (adaptive, the default), /// Prediction display policy: "off", "experimental" (adaptive, the default),
/// or "always". Controls when speculative local echo is shown. /// or "always". Controls when speculative local echo is shown.
@@ -114,10 +128,23 @@ pub struct ClientConfig {
pub use_ssh_agent: bool, pub use_ssh_agent: bool,
#[serde(default)] #[serde(default)]
pub forward_agent: bool, pub forward_agent: bool,
/// Show a non-destructive, mosh-style status line on the bottom screen row
/// when UDP packets stop arriving ("[dosh] last contact Ns ago …"). Drawn
/// with save/restore cursor so it never moves the app cursor or corrupts a
/// full-screen TUI, and cleared the instant packets resume. Default on.
#[serde(default = "default_true")]
pub disconnect_status: bool,
#[serde(default = "default_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)]
pub set_env: HashMap<String, String>, pub set_env: HashMap<String, String>,
/// Optional client-side command extensions. These are just shell command
/// templates expanded into the remote session's startup input; Dosh does not
/// depend on the tools they name.
#[serde(default)]
pub extensions: HashMap<String, CommandExtension>,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -133,6 +160,22 @@ pub struct HostConfig {
pub send_env: Option<Vec<String>>, pub send_env: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
pub set_env: HashMap<String, String>, pub set_env: HashMap<String, String>,
/// Per-host extension overrides. A host can replace a global extension or set
/// `disabled = true` to opt out of it.
#[serde(default)]
pub extensions: HashMap<String, CommandExtension>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandExtension {
/// Remote shell command template. `{args}` expands to shell-quoted extra
/// words after the extension name. When absent, extra args are appended.
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub disabled: bool,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -154,7 +197,7 @@ impl Default for ClientConfig {
default_session: "new".to_string(), default_session: "new".to_string(),
reconnect_timeout_secs: 5, reconnect_timeout_secs: 5,
view_only: false, view_only: false,
predict: false, predict: true,
predict_mode: default_predict_mode(), predict_mode: default_predict_mode(),
cache_attach_tickets: true, cache_attach_tickets: true,
credential_cache: "~/.local/share/dosh/credentials".to_string(), credential_cache: "~/.local/share/dosh/credentials".to_string(),
@@ -165,8 +208,11 @@ impl Default for ClientConfig {
identity_files: default_identity_files(), identity_files: default_identity_files(),
use_ssh_agent: true, use_ssh_agent: true,
forward_agent: false, forward_agent: false,
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(),
} }
} }
} }
@@ -190,6 +236,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
@@ -218,6 +268,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()]
} }
@@ -263,10 +317,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)
} }
+71
View File
@@ -0,0 +1,71 @@
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
pub const EXEC_STREAM_SENTINEL: &str = "@dosh-exec";
pub const FRAME_MAX_LEN: usize = 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecRequest {
pub command: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ExecResponse {
Stdout(Vec<u8>),
Stderr(Vec<u8>),
Exit { code: Option<i32> },
Error { message: String },
}
#[derive(Default)]
pub struct FrameDecoder {
buf: Vec<u8>,
}
impl FrameDecoder {
pub fn push(&mut self, bytes: &[u8]) -> Result<Vec<Vec<u8>>> {
self.buf.extend_from_slice(bytes);
let mut frames = Vec::new();
loop {
if self.buf.len() < 4 {
break;
}
let len = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize;
if len > FRAME_MAX_LEN {
bail!("exec protocol frame too large: {len} bytes");
}
if self.buf.len() < 4 + len {
break;
}
frames.push(self.buf[4..4 + len].to_vec());
self.buf.drain(..4 + len);
}
Ok(frames)
}
}
pub fn encode_request(request: &ExecRequest) -> Result<Vec<u8>> {
encode_frame(&bincode::serialize(request).context("encode exec request")?)
}
pub fn encode_response(response: &ExecResponse) -> Result<Vec<u8>> {
encode_frame(&bincode::serialize(response).context("encode exec response")?)
}
pub fn decode_request(frame: &[u8]) -> Result<ExecRequest> {
bincode::deserialize(frame).context("decode exec request")
}
pub fn decode_response(frame: &[u8]) -> Result<ExecResponse> {
bincode::deserialize(frame).context("decode exec response")
}
fn encode_frame(payload: &[u8]) -> Result<Vec<u8>> {
if payload.len() > FRAME_MAX_LEN {
bail!("exec protocol frame too large: {} bytes", payload.len());
}
let mut out = Vec::with_capacity(4 + payload.len());
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
out.extend_from_slice(payload);
Ok(out)
}
+250
View File
@@ -0,0 +1,250 @@
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const FILE_STREAM_SENTINEL: &str = "@dosh-file";
pub const FRAME_MAX_LEN: usize = 1024 * 1024;
pub const CHUNK_SIZE: usize = 32 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum FileRequest {
Stat {
path: String,
},
List {
path: String,
},
Mkdir {
path: String,
mode: Option<u32>,
},
Remove {
path: String,
recursive: bool,
},
PutStart {
path: String,
size: u64,
mode: Option<u32>,
modified_secs: Option<u64>,
overwrite: bool,
resume: bool,
},
PutChunk {
offset: u64,
bytes: Vec<u8>,
},
PutFinish {
sha256: [u8; 32],
},
Get {
path: String,
offset: u64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum FileResponse {
Ok,
Resume { offset: u64 },
Stat { meta: FileMeta },
List { entries: Vec<FileEntry> },
Start { meta: FileMeta, offset: u64 },
Chunk { offset: u64, bytes: Vec<u8> },
Done { sha256: [u8; 32], bytes: u64 },
Error { message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileMeta {
pub path: String,
pub kind: FileKind,
pub len: u64,
pub mode: Option<u32>,
pub modified_secs: Option<u64>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum FileKind {
File,
Directory,
Symlink,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileEntry {
pub name: String,
pub meta: FileMeta,
}
#[derive(Default)]
pub struct FrameDecoder {
buf: Vec<u8>,
}
impl FrameDecoder {
pub fn push(&mut self, bytes: &[u8]) -> Result<Vec<Vec<u8>>> {
self.buf.extend_from_slice(bytes);
let mut frames = Vec::new();
loop {
if self.buf.len() < 4 {
break;
}
let len = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize;
if len > FRAME_MAX_LEN {
bail!("file protocol frame too large: {len} bytes");
}
if self.buf.len() < 4 + len {
break;
}
frames.push(self.buf[4..4 + len].to_vec());
self.buf.drain(..4 + len);
}
Ok(frames)
}
}
pub fn encode_request(request: &FileRequest) -> Result<Vec<u8>> {
encode_frame(&bincode::serialize(request).context("encode file request")?)
}
pub fn encode_response(response: &FileResponse) -> Result<Vec<u8>> {
encode_frame(&bincode::serialize(response).context("encode file response")?)
}
pub fn decode_request(frame: &[u8]) -> Result<FileRequest> {
bincode::deserialize(frame).context("decode file request")
}
pub fn decode_response(frame: &[u8]) -> Result<FileResponse> {
bincode::deserialize(frame).context("decode file response")
}
pub fn encode_frame(payload: &[u8]) -> Result<Vec<u8>> {
if payload.len() > FRAME_MAX_LEN {
bail!("file protocol frame too large: {} bytes", payload.len());
}
let mut out = Vec::with_capacity(4 + payload.len());
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
out.extend_from_slice(payload);
Ok(out)
}
pub fn parse_copy_endpoint(raw: &str) -> CopyEndpoint {
if looks_like_windows_path(raw) {
return CopyEndpoint::Local(PathBuf::from(raw));
}
let Some(index) = raw.find(':') else {
return CopyEndpoint::Local(PathBuf::from(raw));
};
let host = &raw[..index];
let path = &raw[index + 1..];
if host.is_empty() || host.contains('/') || host.contains('\\') {
return CopyEndpoint::Local(PathBuf::from(raw));
}
CopyEndpoint::Remote {
host: host.to_string(),
path: if path.is_empty() {
".".to_string()
} else {
path.to_string()
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CopyEndpoint {
Local(PathBuf),
Remote { host: String, path: String },
}
pub fn remote_join(parent: &str, child: &str) -> String {
if parent.is_empty() || parent == "." {
return child.to_string();
}
format!("{}/{}", parent.trim_end_matches('/'), child)
}
pub fn remote_basename(path: &str) -> String {
path.trim_end_matches('/')
.rsplit('/')
.next()
.filter(|value| !value.is_empty())
.unwrap_or(path)
.to_string()
}
pub fn clean_remote_path(path: &str, home: &Path) -> Result<PathBuf> {
if path.as_bytes().contains(&0) {
bail!("remote path contains NUL");
}
let expanded = if path == "~" {
home.to_path_buf()
} else if let Some(rest) = path.strip_prefix("~/") {
home.join(rest)
} else {
let raw = Path::new(path);
if raw.is_absolute() {
raw.to_path_buf()
} else {
home.join(raw)
}
};
Ok(expanded)
}
pub fn ensure_relative_child(path: &Path) -> Result<String> {
let name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow!("path has no final component: {}", path.display()))?;
if name.is_empty() || name == "." || name == ".." {
bail!("invalid path final component: {}", path.display());
}
Ok(name.to_string())
}
fn looks_like_windows_path(raw: &str) -> bool {
let bytes = raw.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'\\' || bytes[2] == b'/')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frames_round_trip_incrementally() {
let first = encode_response(&FileResponse::Ok).unwrap();
let second = encode_response(&FileResponse::Error {
message: "nope".to_string(),
})
.unwrap();
let mut decoder = FrameDecoder::default();
assert!(decoder.push(&first[..2]).unwrap().is_empty());
let frames = decoder.push(&first[2..]).unwrap();
assert_eq!(frames.len(), 1);
assert_eq!(decode_response(&frames[0]).unwrap(), FileResponse::Ok);
let frames = decoder.push(&second).unwrap();
assert_eq!(frames.len(), 1);
}
#[test]
fn copy_endpoint_parses_scp_style_but_not_windows_drive() {
assert_eq!(
parse_copy_endpoint("palav:tmp/file"),
CopyEndpoint::Remote {
host: "palav".to_string(),
path: "tmp/file".to_string()
}
);
assert_eq!(
parse_copy_endpoint("C:\\Users\\palav\\x"),
CopyEndpoint::Local(PathBuf::from("C:\\Users\\palav\\x"))
);
}
}
+18
View File
@@ -1,7 +1,25 @@
//! Dosh is an encrypted UDP transport for remote terminals and embeddable Rust
//! application streams.
//!
//! Use [`client::DoshClient`] and [`server::DoshServer`] when you want Dosh to
//! handle native authentication, host key checks, roaming, keepalives, reliable
//! streams, and service routing. Use [`transport::DoshTransport`] only when an
//! application already owns session establishment and wants direct access to the
//! encrypted stream transport.
pub mod auth; pub mod auth;
pub mod build_info;
pub mod client;
pub mod config; pub mod config;
pub mod crypto; pub mod crypto;
pub mod exec_service;
pub mod file_transfer;
pub mod native; pub mod native;
#[cfg(unix)]
pub mod persist;
pub mod protocol; pub mod protocol;
#[cfg(unix)]
pub mod pty; pub mod pty;
pub mod server;
pub mod ssh_agent; pub mod ssh_agent;
pub mod transport;
+363 -37
View File
@@ -5,10 +5,13 @@ use anyhow::{Context, Result, bail};
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rsa::traits::PublicKeyParts;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use signature::{SignatureEncoding, Signer as SignatureSigner, Verifier as SignatureVerifier};
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::net::IpAddr; use std::net::IpAddr;
#[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;
@@ -91,6 +94,12 @@ pub enum ForwardingKind {
Local, Local,
Remote, Remote,
Dynamic, Dynamic,
/// SSH-agent forwarding: the client opts in and, if the server policy allows
/// (`allow_agent_forwarding`), the server exports a proxy unix socket as
/// `SSH_AUTH_SOCK` in the remote session and tunnels each connection back to
/// the client's local agent over a Dosh stream. Added at the end of the enum
/// so bincode discriminants for the existing variants are unchanged.
Agent,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -145,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())?;
@@ -333,6 +343,92 @@ pub fn sign_user_auth(
Ok(auth) Ok(auth)
} }
pub fn sign_user_auth_with_private_key(
private_key: &ssh_key::PrivateKey,
client: &NativeClientHello,
server: &NativeServerHello,
requested_forwardings: Vec<ForwardingRequest>,
) -> Result<NativeUserAuth> {
anyhow::ensure!(!private_key.is_encrypted(), "OpenSSH identity is encrypted");
let public_key = private_key.public_key();
let key_algorithm = public_key.algorithm().as_str().to_string();
anyhow::ensure!(
is_supported_user_key_algorithm(&key_algorithm),
"unsupported native identity key algorithm {key_algorithm}"
);
let public_key_blob = ssh_public_blob_from_public_key(public_key)?;
let public_key_for_auth = if key_algorithm == "ssh-ed25519" {
parse_ssh_ed25519_public_blob(&public_key_blob)?.to_vec()
} else {
public_key_blob
};
let signature_algorithm = signature_algorithm_for_private_key(&key_algorithm)?;
let mut auth = NativeUserAuth {
public_key_algorithm: signature_algorithm.to_string(),
public_key: public_key_for_auth,
signature: Vec::new(),
requested_forwardings,
};
let transcript = user_auth_transcript(client, server, &auth)?;
auth.signature = if key_algorithm == "ssh-rsa" {
sign_rsa_sha512_private_key(private_key, &transcript)?
} else {
let signature = SignatureSigner::try_sign(private_key, &transcript)
.context("sign native user auth with OpenSSH private key")?;
anyhow::ensure!(
signature.algorithm().as_str() == auth.public_key_algorithm,
"private key produced signature algorithm {}, expected {}",
signature.algorithm().as_str(),
auth.public_key_algorithm
);
signature.as_bytes().to_vec()
};
Ok(auth)
}
fn sign_rsa_sha512_private_key(
private_key: &ssh_key::PrivateKey,
transcript: &[u8],
) -> Result<Vec<u8>> {
let rsa_keypair = private_key
.key_data()
.rsa()
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity is not ssh-rsa"))?;
let rsa_private = rsa_private_key_from_ssh_keypair(rsa_keypair)?;
let signing_key = rsa::pkcs1v15::SigningKey::<sha2::Sha512>::new(rsa_private);
let signature: rsa::pkcs1v15::Signature = SignatureSigner::sign(&signing_key, transcript);
Ok(signature.to_vec())
}
fn rsa_private_key_from_ssh_keypair(
keypair: &ssh_key::private::RsaKeypair,
) -> Result<rsa::RsaPrivateKey> {
let private = rsa::RsaPrivateKey::from_components(
rsa::BigUint::try_from(&keypair.public.n).context("convert RSA modulus")?,
rsa::BigUint::try_from(&keypair.public.e).context("convert RSA public exponent")?,
rsa::BigUint::try_from(&keypair.private.d).context("convert RSA private exponent")?,
vec![
rsa::BigUint::try_from(&keypair.private.p).context("convert RSA p prime")?,
rsa::BigUint::try_from(&keypair.private.q).context("convert RSA q prime")?,
],
)
.context("convert OpenSSH RSA private key")?;
anyhow::ensure!(
private.size().saturating_mul(8) >= 2048,
"OpenSSH RSA identity is smaller than 2048 bits"
);
Ok(private)
}
fn signature_algorithm_for_private_key(key_algorithm: &str) -> Result<&'static str> {
match key_algorithm {
"ssh-ed25519" => Ok("ssh-ed25519"),
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
"ssh-rsa" => Ok("rsa-sha2-512"),
_ => bail!("unsupported native identity key algorithm {key_algorithm}"),
}
}
pub fn verify_native_user_auth( pub fn verify_native_user_auth(
client: &NativeClientHello, client: &NativeClientHello,
server: &NativeServerHello, server: &NativeServerHello,
@@ -341,10 +437,54 @@ pub fn verify_native_user_auth(
source_ip: Option<IpAddr>, source_ip: Option<IpAddr>,
) -> Result<AuthorizedKey> { ) -> Result<AuthorizedKey> {
anyhow::ensure!( anyhow::ensure!(
auth.public_key_algorithm == "ssh-ed25519", is_supported_user_signature_algorithm(&auth.public_key_algorithm),
"unsupported native user key algorithm {}", "unsupported native user key algorithm {}",
auth.public_key_algorithm auth.public_key_algorithm
); );
let authorized_algorithm = authorized_key_algorithm_for_auth(&auth.public_key_algorithm)?;
let authorized = authorized_keys
.iter()
.find(|key| key.algorithm == authorized_algorithm && key.key == auth.public_key)
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
authorized.ensure_native_allowed(auth, source_ip)?;
let transcript = user_auth_transcript(client, server, auth)?;
verify_native_user_signature(auth, &transcript)?;
Ok(authorized.clone())
}
pub fn supported_user_key_algorithms() -> Vec<String> {
vec![
"ssh-ed25519".to_string(),
"ecdsa-sha2-nistp256".to_string(),
"rsa-sha2-512".to_string(),
"rsa-sha2-256".to_string(),
]
}
pub fn is_supported_user_key_algorithm(algorithm: &str) -> bool {
matches!(algorithm, "ssh-ed25519" | "ecdsa-sha2-nistp256" | "ssh-rsa")
}
pub fn is_supported_user_signature_algorithm(algorithm: &str) -> bool {
matches!(
algorithm,
"ssh-ed25519" | "ecdsa-sha2-nistp256" | "rsa-sha2-512" | "rsa-sha2-256"
)
}
fn authorized_key_algorithm_for_auth(signature_algorithm: &str) -> Result<&'static str> {
match signature_algorithm {
"ssh-ed25519" => Ok("ssh-ed25519"),
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
"rsa-sha2-512" | "rsa-sha2-256" => Ok("ssh-rsa"),
_ => bail!("unsupported native user key algorithm {signature_algorithm}"),
}
}
fn verify_native_user_signature(auth: &NativeUserAuth, transcript: &[u8]) -> Result<()> {
match auth.public_key_algorithm.as_str() {
"ssh-ed25519" => {
anyhow::ensure!( anyhow::ensure!(
auth.public_key.len() == 32, auth.public_key.len() == 32,
"ssh-ed25519 public key must be 32 bytes" "ssh-ed25519 public key must be 32 bytes"
@@ -353,23 +493,69 @@ pub fn verify_native_user_auth(
auth.signature.len() == 64, auth.signature.len() == 64,
"ssh-ed25519 signature must be 64 bytes" "ssh-ed25519 signature must be 64 bytes"
); );
let authorized = authorized_keys
.iter()
.find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key)
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
authorized.ensure_native_allowed(auth, source_ip)?;
let mut public_key = [0u8; 32]; let mut public_key = [0u8; 32];
public_key.copy_from_slice(&auth.public_key); public_key.copy_from_slice(&auth.public_key);
let verifying_key = VerifyingKey::from_bytes(&public_key).context("parse user public key")?; let verifying_key =
VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
let mut signature = [0u8; 64]; let mut signature = [0u8; 64];
signature.copy_from_slice(&auth.signature); signature.copy_from_slice(&auth.signature);
let signature = Signature::from_bytes(&signature); let signature = Signature::from_bytes(&signature);
let transcript = user_auth_transcript(client, server, auth)?;
verifying_key verifying_key
.verify(&transcript, &signature) .verify(transcript, &signature)
.context("verify native user signature")?; .context("verify native user signature")?;
Ok(authorized.clone()) }
"ecdsa-sha2-nistp256" => {
let public_key = ssh_public_key_from_blob(&auth.public_key)
.context("parse native user SSH public key")?;
let algorithm =
ssh_key::Algorithm::from_str(&auth.public_key_algorithm).with_context(|| {
format!("parse signature algorithm {}", auth.public_key_algorithm)
})?;
let signature = ssh_key::Signature::new(algorithm, auth.signature.clone())
.context("parse native user SSH signature")?;
SignatureVerifier::verify(public_key.key_data(), transcript, &signature)
.context("verify native user SSH signature")?;
}
"rsa-sha2-512" | "rsa-sha2-256" => {
verify_rsa_sha2_signature(
&auth.public_key,
&auth.public_key_algorithm,
transcript,
&auth.signature,
)?;
}
algorithm => bail!("unsupported native user key algorithm {algorithm}"),
}
Ok(())
}
fn verify_rsa_sha2_signature(
public_key_blob: &[u8],
signature_algorithm: &str,
transcript: &[u8],
signature: &[u8],
) -> Result<()> {
let (e, n) = parse_ssh_rsa_public_blob(public_key_blob)?;
let public = rsa::RsaPublicKey::new(
rsa::BigUint::from_bytes_be(n),
rsa::BigUint::from_bytes_be(e),
)
.context("parse RSA public key")?;
let signature =
rsa::pkcs1v15::Signature::try_from(signature).context("parse RSA PKCS#1v1.5 signature")?;
match signature_algorithm {
"rsa-sha2-256" => {
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha256>::new(public);
SignatureVerifier::verify(&key, transcript, &signature)
.context("verify RSA-SHA256 signature")
}
"rsa-sha2-512" => {
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha512>::new(public);
SignatureVerifier::verify(&key, transcript, &signature)
.context("verify RSA-SHA512 signature")
}
_ => bail!("legacy ssh-rsa/SHA-1 signatures are not accepted"),
}
} }
pub fn verify_native_user_auth_from_config( pub fn verify_native_user_auth_from_config(
@@ -464,6 +650,35 @@ pub fn load_ed25519_identity_with_passphrase(
.with_context(|| format!("parse ssh-ed25519 identity {}", path.display())) .with_context(|| format!("parse ssh-ed25519 identity {}", path.display()))
} }
pub fn load_native_identity(path: &Path) -> Result<ssh_key::PrivateKey> {
load_native_identity_with_passphrase(path, None)
}
pub fn load_native_identity_with_passphrase(
path: &Path,
passphrase: Option<&str>,
) -> Result<ssh_key::PrivateKey> {
let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let private_key = ssh_key::PrivateKey::from_openssh(&raw)
.with_context(|| format!("parse OpenSSH identity {}", path.display()))?;
let private_key = if private_key.is_encrypted() {
let passphrase = passphrase
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity {} is encrypted", path.display()))?;
private_key
.decrypt(passphrase)
.with_context(|| format!("decrypt OpenSSH identity {}", path.display()))?
} else {
private_key
};
let algorithm = private_key.public_key().algorithm().as_str().to_string();
anyhow::ensure!(
is_supported_user_key_algorithm(&algorithm),
"OpenSSH identity {} has unsupported native key algorithm {algorithm}",
path.display()
);
Ok(private_key)
}
impl AuthorizedKey { impl AuthorizedKey {
fn ensure_native_allowed( fn ensure_native_allowed(
&self, &self,
@@ -513,6 +728,10 @@ impl AuthorizedKey {
"authorized key permitopen= cannot authorize remote or dynamic forwarding" "authorized key permitopen= cannot authorize remote or dynamic forwarding"
); );
} }
// Agent forwarding does not open a host:port, so permitopen
// (which restricts which targets may be opened) does not apply;
// it is gated separately by the server's allow_agent_forwarding.
ForwardingKind::Agent => {}
} }
} }
} }
@@ -555,7 +774,7 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
fields.len() >= 2, fields.len() >= 2,
"authorized_keys:{line_number}: expected key fields" "authorized_keys:{line_number}: expected key fields"
); );
let (options, key_type_index) = if fields[0].starts_with("ssh-") { let (options, key_type_index) = if is_supported_user_key_algorithm(&fields[0]) {
(AuthorizedKeyOptions::default(), 0) (AuthorizedKeyOptions::default(), 0)
} else { } else {
(parse_authorized_key_options(&fields[0])?, 1) (parse_authorized_key_options(&fields[0])?, 1)
@@ -566,15 +785,20 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
let key_blob = fields let key_blob = fields
.get(key_type_index + 1) .get(key_type_index + 1)
.with_context(|| format!("authorized_keys:{line_number}: missing key blob"))?; .with_context(|| format!("authorized_keys:{line_number}: missing key blob"))?;
anyhow::ensure!(
algorithm == "ssh-ed25519",
"authorized_keys:{line_number}: unsupported key type {algorithm}"
);
let decoded = STANDARD let decoded = STANDARD
.decode(key_blob) .decode(key_blob)
.with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?; .with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?;
let key = parse_ssh_ed25519_public_blob(&decoded) anyhow::ensure!(
.with_context(|| format!("authorized_keys:{line_number}: parse ssh-ed25519 key"))?; is_supported_user_key_algorithm(algorithm),
"authorized_keys:{line_number}: unsupported key type {algorithm}"
);
validate_supported_public_key_blob(algorithm, &decoded)
.with_context(|| format!("authorized_keys:{line_number}: parse {algorithm} key"))?;
let key = if algorithm == "ssh-ed25519" {
parse_ssh_ed25519_public_blob(&decoded)?.to_vec()
} else {
decoded
};
let comment = if fields.len() > key_type_index + 2 { let comment = if fields.len() > key_type_index + 2 {
Some(fields[key_type_index + 2..].join(" ")) Some(fields[key_type_index + 2..].join(" "))
} else { } else {
@@ -588,6 +812,51 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
}) })
} }
fn validate_supported_public_key_blob(algorithm: &str, blob: &[u8]) -> Result<()> {
let blob_algorithm = ssh_public_key_blob_algorithm(blob)?;
anyhow::ensure!(
blob_algorithm == algorithm,
"key blob type {blob_algorithm} does not match authorized_keys type {algorithm}"
);
match algorithm {
"ssh-ed25519" => {
parse_ssh_ed25519_public_blob(blob)?;
}
"ecdsa-sha2-nistp256" | "ssh-rsa" => {
let _ = ssh_public_key_from_blob(blob)?;
}
_ => bail!("unsupported key type {algorithm}"),
}
Ok(())
}
fn ssh_public_key_blob_algorithm(blob: &[u8]) -> Result<String> {
let mut cursor = blob;
let algorithm = read_ssh_string(&mut cursor)?;
Ok(String::from_utf8_lossy(algorithm).to_string())
}
fn ssh_public_key_from_blob(blob: &[u8]) -> Result<ssh_key::PublicKey> {
let algorithm = ssh_public_key_blob_algorithm(blob)?;
let encoded = STANDARD.encode(blob);
ssh_key::PublicKey::from_openssh(&format!("{algorithm} {encoded}"))
.with_context(|| format!("parse OpenSSH public key blob {algorithm}"))
}
fn ssh_public_blob_from_public_key(public_key: &ssh_key::PublicKey) -> Result<Vec<u8>> {
let line = public_key
.to_openssh()
.context("encode OpenSSH public key")?;
let mut fields = line.split_whitespace();
let _algorithm = fields
.next()
.context("encoded public key missing algorithm")?;
let encoded = fields.next().context("encoded public key missing blob")?;
STANDARD
.decode(encoded)
.context("decode encoded OpenSSH public key blob")
}
fn split_authorized_key_fields(line: &str) -> Vec<String> { fn split_authorized_key_fields(line: &str) -> Vec<String> {
line.split_whitespace().map(ToString::to_string).collect() line.split_whitespace().map(ToString::to_string).collect()
} }
@@ -755,6 +1024,18 @@ pub fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
Ok(out) Ok(out)
} }
fn parse_ssh_rsa_public_blob(blob: &[u8]) -> Result<(&[u8], &[u8])> {
let mut cursor = blob;
let key_type = read_ssh_string(&mut cursor)?;
anyhow::ensure!(key_type == b"ssh-rsa", "key blob type mismatch");
let e = read_ssh_mpint(&mut cursor)?;
let n = read_ssh_mpint(&mut cursor)?;
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-rsa key blob");
anyhow::ensure!(!e.is_empty(), "ssh-rsa exponent is empty");
anyhow::ensure!(!n.is_empty(), "ssh-rsa modulus is empty");
Ok((e, n))
}
pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> { pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
let mut out = Vec::new(); let mut out = Vec::new();
write_ssh_string(&mut out, b"ssh-ed25519"); write_ssh_string(&mut out, b"ssh-ed25519");
@@ -762,6 +1043,12 @@ pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
out out
} }
fn read_ssh_mpint<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
let raw = read_ssh_string(cursor)?;
let raw = raw.strip_prefix(&[0]).unwrap_or(raw);
Ok(raw)
}
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> { fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length"); anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length");
let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize; let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize;
@@ -948,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())
@@ -994,16 +1281,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32])); let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!( assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(), trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::Trusted TrustResult::Trusted
); );
assert_eq!( assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(), trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::AlreadyTrusted TrustResult::AlreadyTrusted
); );
assert!(trust_host(&path, "palav", &second, "ssh", false).is_err()); assert!(trust_host(&path, "homelab", &second, "ssh", false).is_err());
assert_eq!( assert_eq!(
trust_host(&path, "palav", &second, "ssh", true).unwrap(), trust_host(&path, "homelab", &second, "ssh", true).unwrap(),
TrustResult::Trusted TrustResult::Trusted
); );
} }
@@ -1016,16 +1303,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32])); let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!( assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(), verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Unknown KnownHostStatus::Unknown
); );
trust_host(&path, "palav", &first, "ssh", false).unwrap(); trust_host(&path, "homelab", &first, "ssh", false).unwrap();
assert_eq!( assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(), verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Trusted KnownHostStatus::Trusted
); );
assert!(matches!( assert!(matches!(
verify_known_host(&path, "palav", &second).unwrap(), verify_known_host(&path, "homelab", &second).unwrap(),
KnownHostStatus::Mismatch { .. } KnownHostStatus::Mismatch { .. }
)); ));
} }
@@ -1074,6 +1361,45 @@ mod tests {
assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err()); assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err());
} }
#[test]
fn native_user_auth_accepts_ecdsa_p256_private_key() {
let mut rng = rand::rngs::OsRng;
let private = ssh_key::PrivateKey::random(
&mut rng,
ssh_key::Algorithm::Ecdsa {
curve: ssh_key::EcdsaCurve::NistP256,
},
)
.unwrap();
let host_signing = SigningKey::from_bytes(&[10u8; 32]);
let client = test_client_hello();
let mut server = test_server_hello(&host_signing);
sign_server_hello(&host_signing, &client, &mut server).unwrap();
let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap();
let authorized =
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
assert_eq!(auth.public_key_algorithm, "ecdsa-sha2-nistp256");
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
}
#[test]
fn native_user_auth_accepts_rsa_sha2_private_key() {
let mut rng = rand::rngs::OsRng;
let private =
ssh_key::PrivateKey::random(&mut rng, ssh_key::Algorithm::Rsa { hash: None }).unwrap();
let host_signing = SigningKey::from_bytes(&[11u8; 32]);
let client = test_client_hello();
let mut server = test_server_hello(&host_signing);
sign_server_hello(&host_signing, &client, &mut server).unwrap();
let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap();
let authorized =
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
assert_eq!(auth.public_key_algorithm, "rsa-sha2-512");
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
}
#[test] #[test]
fn native_user_auth_rejects_tampered_transcript() { fn native_user_auth_rejects_tampered_transcript() {
let host_signing = SigningKey::from_bytes(&[3u8; 32]); let host_signing = SigningKey::from_bytes(&[3u8; 32]);
@@ -1301,8 +1627,8 @@ mod tests {
protocol_version: NATIVE_PROTOCOL_VERSION, protocol_version: NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32], client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32], client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(), requested_host: "homelab".to_string(),
requested_user: "palav".to_string(), requested_user: "alice".to_string(),
requested_session: "term".to_string(), requested_session: "term".to_string(),
requested_mode: "read-write".to_string(), requested_mode: "read-write".to_string(),
terminal_size: (80, 24), terminal_size: (80, 24),
+662
View File
@@ -0,0 +1,662 @@
//! Session persistence across server restarts.
//!
//! A persistent dosh session's shell does not run as a child of `dosh-server`.
//! Instead the server spawns a tiny per-session *holder* process (the same
//! `dosh-server` binary re-exec'd as `dosh-server hold ...`). The holder calls
//! `setsid()` to leave the server's process group/session, opens a PTY, spawns
//! the shell as ITS own child, and listens on a Unix socket in a per-session
//! runtime directory. The server connects to that socket and the holder hands it
//! the PTY master fd over `SCM_RIGHTS`.
//!
//! Because the shell belongs to the holder (which is in its own session and is
//! not waited on by the server), killing or restarting `dosh-server` leaves the
//! holder + shell alive. On startup the server scans the runtime directory,
//! reconnects to each live holder, receives the master fd again, and rebuilds the
//! in-memory session so clients reattach to the very same shell.
//!
//! Screen / scrollback state lives only in server memory, so it is also mirrored
//! to disk (atomically) and restored on re-adoption, letting a reattaching client
//! repaint the screen exactly as it was before the restart.
use anyhow::{Context, Result, anyhow, bail};
use std::io::{Read, Write};
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, RawFd};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// One-byte commands a server sends to a holder over its control socket after
/// the holder has handed back the master fd.
const HOLDER_CMD_SHUTDOWN: u8 = b'X';
const HOLDER_STARTUP_TIMEOUT: Duration = Duration::from_millis(750);
/// Magic written into a holder's `meta` file, bumped if the on-disk layout
/// changes so a stale holder from an incompatible build is ignored.
const META_MAGIC: &str = "dosh-holder-1";
/// Per-session runtime metadata persisted next to the holder socket.
#[derive(Debug, Clone)]
pub struct HolderMeta {
pub session: String,
pub shell_pid: i32,
}
/// Root runtime directory holding one subdirectory per persistent session.
/// Lives under `sessions_dir/run` so it shares the session storage location and
/// can be wiped with it.
pub fn runtime_root(sessions_dir: &Path) -> PathBuf {
sessions_dir.join("run")
}
/// Map a session name to a filesystem-safe directory name. Session names are
/// user-controlled, so hex-encode them rather than trusting them as path
/// components (avoids traversal, slashes, NULs, length issues).
fn session_dir_name(session: &str) -> String {
let mut out = String::with_capacity(session.len() * 2);
for byte in session.as_bytes() {
out.push(char::from_digit((byte >> 4) as u32, 16).unwrap());
out.push(char::from_digit((byte & 0xf) as u32, 16).unwrap());
}
out
}
fn decode_session_dir_name(name: &str) -> Option<String> {
if !name.len().is_multiple_of(2) {
return None;
}
let mut bytes = Vec::with_capacity(name.len() / 2);
let raw = name.as_bytes();
let mut i = 0;
while i < raw.len() {
let hi = (raw[i] as char).to_digit(16)?;
let lo = (raw[i + 1] as char).to_digit(16)?;
bytes.push(((hi << 4) | lo) as u8);
i += 2;
}
String::from_utf8(bytes).ok()
}
/// Directory for one session's holder runtime state.
pub fn session_runtime_dir(sessions_dir: &Path, session: &str) -> PathBuf {
runtime_root(sessions_dir).join(session_dir_name(session))
}
fn holder_sock_path(dir: &Path) -> PathBuf {
dir.join("holder.sock")
}
fn meta_path(dir: &Path) -> PathBuf {
dir.join("meta")
}
fn screen_path(dir: &Path) -> PathBuf {
dir.join("screen")
}
/// Create the runtime directory tree with private (0700) permissions.
pub fn ensure_runtime_dir(sessions_dir: &Path, session: &str) -> Result<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let root = runtime_root(sessions_dir);
std::fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?;
let _ = std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700));
let dir = session_runtime_dir(sessions_dir, session);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("chmod {}", dir.display()))?;
Ok(dir)
}
/// Atomically write `data` to `path` (write temp + rename), so a concurrent
/// reader (e.g. a restarting server) never sees a half-written file.
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let tmp = path.with_extension("tmp");
{
let mut file =
std::fs::File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
file.write_all(data)
.with_context(|| format!("write {}", tmp.display()))?;
file.sync_all().ok();
}
std::fs::rename(&tmp, path)
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
/// Persist the vt100 screen snapshot plus recent raw output for a session so a
/// post-restart reattach repaints correctly. `snapshot` is the bytes a fresh
/// attach would receive (alt-screen toggle + `state_formatted`).
pub fn save_screen(
sessions_dir: &Path,
session: &str,
cols: u16,
rows: u16,
output_seq: u64,
snapshot: &[u8],
) -> Result<()> {
let dir = session_runtime_dir(sessions_dir, session);
if !dir.exists() {
// No holder runtime for this session (non-persistent): nothing to do.
return Ok(());
}
let mut buf = Vec::with_capacity(snapshot.len() + 32);
buf.extend_from_slice(&cols.to_be_bytes());
buf.extend_from_slice(&rows.to_be_bytes());
buf.extend_from_slice(&output_seq.to_be_bytes());
buf.extend_from_slice(&(snapshot.len() as u32).to_be_bytes());
buf.extend_from_slice(snapshot);
atomic_write(&screen_path(&dir), &buf)
}
/// A restored screen for a re-adopted session.
pub struct SavedScreen {
pub cols: u16,
pub rows: u16,
pub output_seq: u64,
pub snapshot: Vec<u8>,
}
/// Load a previously persisted screen, if any.
pub fn load_screen(sessions_dir: &Path, session: &str) -> Option<SavedScreen> {
let dir = session_runtime_dir(sessions_dir, session);
let data = std::fs::read(screen_path(&dir)).ok()?;
if data.len() < 16 {
return None;
}
let cols = u16::from_be_bytes(data[0..2].try_into().ok()?);
let rows = u16::from_be_bytes(data[2..4].try_into().ok()?);
let output_seq = u64::from_be_bytes(data[4..12].try_into().ok()?);
let len = u32::from_be_bytes(data[12..16].try_into().ok()?) as usize;
if data.len() < 16 + len {
return None;
}
Some(SavedScreen {
cols,
rows,
output_seq,
snapshot: data[16..16 + len].to_vec(),
})
}
fn write_meta(dir: &Path, meta: &HolderMeta) -> Result<()> {
let body = format!("{META_MAGIC}\n{}\n{}\n", meta.shell_pid, meta.session);
atomic_write(&meta_path(dir), body.as_bytes())
}
fn read_meta(dir: &Path) -> Option<HolderMeta> {
let body = std::fs::read_to_string(meta_path(dir)).ok()?;
let mut lines = body.lines();
if lines.next()? != META_MAGIC {
return None;
}
let shell_pid: i32 = lines.next()?.parse().ok()?;
let session = lines.next()?.to_string();
Some(HolderMeta { session, shell_pid })
}
/// Spawn a holder process for `session`: re-exec this binary as `dosh-server
/// hold` with the runtime dir, shell, terminal size, and accepted env. The
/// holder daemonizes (setsid, owns the PTY) and listens on its control socket.
/// Returns once the holder has signalled readiness by creating its socket.
#[allow(clippy::too_many_arguments)]
pub fn spawn_holder(
sessions_dir: &Path,
session: &str,
shell: &str,
cols: u16,
rows: u16,
env: &[(String, String)],
) -> Result<()> {
let dir = ensure_runtime_dir(sessions_dir, session)?;
let sock = holder_sock_path(&dir);
// Clear any stale socket left by a crashed holder with the same name.
let _ = std::fs::remove_file(&sock);
let exe = std::env::current_exe().context("locate current executable")?;
let mut cmd = std::process::Command::new(exe);
cmd.arg("hold")
.arg("--runtime-dir")
.arg(&dir)
.arg("--session")
.arg(session)
.arg("--shell")
.arg(shell)
.arg("--cols")
.arg(cols.to_string())
.arg("--rows")
.arg(rows.to_string());
for (name, value) in env {
cmd.arg("--env").arg(format!("{name}={value}"));
}
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let mut child = cmd.spawn().context("spawn holder process")?;
// Wait (briefly) for the holder to come up. The holder forks/setsids before
// creating the socket; once the socket exists we can connect. We also reap
// the short-lived launcher child so it never becomes a zombie.
let deadline = std::time::Instant::now() + HOLDER_STARTUP_TIMEOUT;
loop {
if sock.exists() {
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 {
let _ = child.kill();
let _ = child.wait();
bail!("holder for session {session} did not come up in time");
}
std::thread::sleep(Duration::from_millis(20));
}
// The launcher exits immediately after the grandchild setsids; reap it.
let _ = child.wait();
Ok(())
}
/// Connect to a session's holder and receive the PTY master fd over SCM_RIGHTS.
/// Returns the raw fd (caller owns it) and the holder control socket, kept open
/// so the server can later ask the holder to shut down. Returns an error if the
/// holder is gone (caller then degrades to a fresh, non-persistent session).
pub fn adopt_holder(sessions_dir: &Path, session: &str) -> Result<(RawFd, UnixStream)> {
let dir = session_runtime_dir(sessions_dir, session);
let sock = holder_sock_path(&dir);
let mut stream = UnixStream::connect(&sock)
.with_context(|| format!("connect holder socket {}", sock.display()))?;
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
let fd = recv_fd(&mut stream).context("receive master fd from holder")?;
Ok((fd, stream))
}
/// Ask a holder to terminate its shell and exit, then clean its runtime dir.
/// Used when reaping a truly-abandoned persistent session.
pub fn request_shutdown(sessions_dir: &Path, session: &str, control: Option<&mut UnixStream>) {
if let Some(stream) = control {
let _ = stream.write_all(&[HOLDER_CMD_SHUTDOWN]);
let _ = stream.flush();
} else {
let dir = session_runtime_dir(sessions_dir, session);
if let Ok(mut stream) = UnixStream::connect(holder_sock_path(&dir)) {
// Drain the fd the holder sends on connect, then send shutdown.
let _ = recv_fd(&mut stream);
let _ = stream.write_all(&[HOLDER_CMD_SHUTDOWN]);
let _ = stream.flush();
}
}
// Give the holder a moment to tear down, then remove its runtime dir.
std::thread::sleep(Duration::from_millis(50));
let dir = session_runtime_dir(sessions_dir, session);
let _ = std::fs::remove_dir_all(&dir);
}
/// Remove a session's runtime directory unconditionally (best effort).
pub fn remove_runtime_dir(sessions_dir: &Path, session: &str) {
let dir = session_runtime_dir(sessions_dir, session);
let _ = std::fs::remove_dir_all(&dir);
}
/// Scan the runtime root for holders left behind by a previous server. Returns
/// `(session_name, meta)` for each one whose holder process still appears alive.
/// Stale entries (no live process) are cleaned up.
pub fn scan_existing_holders(sessions_dir: &Path) -> Vec<HolderMeta> {
let root = runtime_root(sessions_dir);
let mut found = Vec::new();
let Ok(entries) = std::fs::read_dir(&root) else {
return found;
};
for entry in entries.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let Some(name) = dir.file_name().and_then(|n| n.to_str()) else {
continue;
};
// Decode the on-disk name back to a session name; skip junk dirs.
if decode_session_dir_name(name).is_none() {
continue;
}
match read_meta(&dir) {
Some(meta) if process_alive(meta.shell_pid) && holder_sock_path(&dir).exists() => {
found.push(meta);
}
_ => {
// Holder gone or meta unreadable: clean up so we don't try to
// adopt a dead session (degrade to fresh on next attach).
let _ = std::fs::remove_dir_all(&dir);
}
}
}
found
}
/// Whether a pid refers to a live process (signal 0 probe).
fn process_alive(pid: i32) -> bool {
if pid <= 0 {
return false;
}
unsafe {
libc::kill(pid, 0) == 0
|| std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
}
// ---------------------------------------------------------------------------
// Holder process entry point
// ---------------------------------------------------------------------------
/// Run as a holder process. Daemonizes (double-fork + setsid), opens a PTY,
/// spawns the shell as its own child, and serves the control socket: every
/// accepted connection is handed the master fd (SCM_RIGHTS); a subsequent
/// SHUTDOWN byte (or the shell exiting) tears everything down.
///
/// This function does not return on success — it `exit`s the process. Errors
/// before the daemonization point are returned to the launcher.
#[allow(clippy::too_many_arguments)]
pub fn run_holder(
runtime_dir: &Path,
session: &str,
shell: &str,
cols: u16,
rows: u16,
env: &[(String, String)],
) -> Result<()> {
use portable_pty::{NativePtySystem, PtySize, PtySystem};
// Detach from the launching server: own session + process group so a server
// exit (even a process-group kill of the service) does not take us down.
daemonize().context("daemonize holder")?;
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("holder open pty")?;
let cmd = crate::pty::build_shell_command(shell, env);
let mut child = pair
.slave
.spawn_command(cmd)
.context("holder spawn shell")?;
drop(pair.slave);
let master_fd = pair
.master
.as_raw_fd()
.ok_or_else(|| anyhow!("holder master has no raw fd"))?;
let shell_pid = child.process_id().map(|p| p as i32).unwrap_or(-1);
write_meta(
runtime_dir,
&HolderMeta {
session: session.to_string(),
shell_pid,
},
)?;
let sock = holder_sock_path(runtime_dir);
let _ = std::fs::remove_file(&sock);
let listener =
UnixListener::bind(&sock).with_context(|| format!("holder bind {}", sock.display()))?;
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600));
}
listener
.set_nonblocking(false)
.context("holder listener blocking")?;
// A watcher thread reaps the shell: when it exits, the holder cleans up and
// exits too, so an abandoned shell does not linger forever.
let runtime_owned = runtime_dir.to_path_buf();
std::thread::spawn(move || {
let _ = child.wait();
// Shell exited: remove runtime dir and exit the holder process.
let _ = std::fs::remove_dir_all(&runtime_owned);
std::process::exit(0);
});
// Accept loop: each connecting server gets the master fd; a SHUTDOWN byte
// from any of them tears the holder down. Multiple servers never run at once
// in practice (one service), but serving repeated connections lets a server
// restart re-adopt cleanly.
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
if send_fd(&mut stream, master_fd).is_err() {
continue;
}
// Wait for an optional command byte. EOF (server dropped the control
// socket on its own exit) just means "keep running, await re-adoption".
let mut cmd = [0u8; 1];
match stream.read(&mut cmd) {
Ok(1) if cmd[0] == HOLDER_CMD_SHUTDOWN => {
let _ = std::fs::remove_dir_all(runtime_dir);
std::process::exit(0);
}
_ => {
// Server detached (exit/restart) or sent nothing actionable:
// loop back and wait for the next server to re-adopt us.
}
}
}
Ok(())
}
/// Double-fork + `setsid` so the holder runs in its own session, detached from
/// the server's controlling terminal and process group. Without this a
/// `systemctl restart` (which signals the whole service cgroup/process group)
/// could take the holder down with the server.
fn daemonize() -> Result<()> {
// First fork: parent (launcher) returns to reap; child continues.
match unsafe { libc::fork() } {
-1 => bail!("fork: {}", std::io::Error::last_os_error()),
0 => {}
_ => {
// Parent process: exit so the launcher's wait() returns promptly and
// the grandchild is reparented to init.
std::process::exit(0);
}
}
// New session: detaches from controlling tty and the server's process group.
if unsafe { libc::setsid() } == -1 {
bail!("setsid: {}", std::io::Error::last_os_error());
}
// Second fork: ensures we are not a session leader, so we can never
// re-acquire a controlling terminal.
match unsafe { libc::fork() } {
-1 => bail!("fork2: {}", std::io::Error::last_os_error()),
0 => Ok(()),
_ => std::process::exit(0),
}
}
// ---------------------------------------------------------------------------
// SCM_RIGHTS file-descriptor passing over a Unix domain socket
// ---------------------------------------------------------------------------
/// Send a single fd over `stream` using SCM_RIGHTS, with one byte of normal data
/// (sendmsg requires at least one iovec byte for the ancillary data to ride on).
fn send_fd(stream: &mut UnixStream, fd: RawFd) -> Result<()> {
let dummy: [u8; 1] = [0];
let mut iov = libc::iovec {
iov_base: dummy.as_ptr() as *mut libc::c_void,
iov_len: 1,
};
let mut cmsg_buf = [0u8; cmsg_space_one_fd()];
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg_buf.len() as _;
unsafe {
let cmsg = libc::CMSG_FIRSTHDR(&msg);
if cmsg.is_null() {
bail!("CMSG_FIRSTHDR null");
}
(*cmsg).cmsg_level = libc::SOL_SOCKET;
(*cmsg).cmsg_type = libc::SCM_RIGHTS;
(*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::<RawFd>() as u32) as _;
std::ptr::copy_nonoverlapping(
&fd as *const RawFd as *const u8,
libc::CMSG_DATA(cmsg),
std::mem::size_of::<RawFd>(),
);
let n = libc::sendmsg(stream.as_raw_fd(), &msg, 0);
if n < 0 {
return Err(std::io::Error::last_os_error()).context("sendmsg SCM_RIGHTS");
}
}
Ok(())
}
/// Receive a single fd sent via SCM_RIGHTS. Returns a fresh fd owned by the
/// caller.
fn recv_fd(stream: &mut UnixStream) -> Result<RawFd> {
let mut dummy = [0u8; 1];
let mut iov = libc::iovec {
iov_base: dummy.as_mut_ptr() as *mut libc::c_void,
iov_len: 1,
};
let mut cmsg_buf = [0u8; cmsg_space_one_fd()];
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg_buf.len() as _;
unsafe {
let n = libc::recvmsg(stream.as_raw_fd(), &mut msg, 0);
if n < 0 {
return Err(std::io::Error::last_os_error()).context("recvmsg SCM_RIGHTS");
}
if n == 0 {
bail!("holder closed connection before sending fd");
}
let cmsg = libc::CMSG_FIRSTHDR(&msg);
if cmsg.is_null() {
bail!("no ancillary data (fd) received from holder");
}
if (*cmsg).cmsg_level != libc::SOL_SOCKET || (*cmsg).cmsg_type != libc::SCM_RIGHTS {
bail!("unexpected ancillary message from holder");
}
let mut fd: RawFd = -1;
std::ptr::copy_nonoverlapping(
libc::CMSG_DATA(cmsg),
&mut fd as *mut RawFd as *mut u8,
std::mem::size_of::<RawFd>(),
);
if fd < 0 {
bail!("invalid fd received from holder");
}
Ok(fd)
}
}
/// Space, in bytes, needed for a control-message buffer carrying exactly one fd.
const fn cmsg_space_one_fd() -> usize {
// CMSG_SPACE is not const in libc; this is the equivalent for one RawFd.
// cmsghdr is aligned to size_of::<usize>(); add data length rounded up.
let data = std::mem::size_of::<RawFd>();
let hdr = std::mem::size_of::<libc::cmsghdr>();
let align = std::mem::size_of::<usize>();
// round(hdr) + round(data)
((hdr + align - 1) & !(align - 1)) + ((data + align - 1) & !(align - 1))
}
/// Helper used by `adopt_holder` callers to turn the received raw fd into an
/// owned `File`-like object if they need RAII (the server hands it to
/// `pty::adopt_pty_from_fd`, which takes ownership of the raw fd).
pub fn fd_into_file(fd: RawFd) -> std::fs::File {
unsafe { std::fs::File::from_raw_fd(fd) }
}
/// Convert a `File` back into a raw fd it no longer owns (so it can be handed to
/// `adopt_pty_from_fd`). Currently unused outside tests but kept symmetric.
pub fn file_into_fd(file: std::fs::File) -> RawFd {
file.into_raw_fd()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_dir_name_round_trips() {
for name in ["default", "work", "a/b/../c", "weird name", "日本語"] {
let encoded = session_dir_name(name);
assert!(encoded.bytes().all(|b| b.is_ascii_hexdigit()));
assert_eq!(decode_session_dir_name(&encoded).as_deref(), Some(name));
}
}
#[test]
fn decode_rejects_non_hex() {
assert!(decode_session_dir_name("zz").is_none());
assert!(decode_session_dir_name("abc").is_none());
}
#[test]
fn save_and_load_screen_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let sessions_dir = tmp.path();
ensure_runtime_dir(sessions_dir, "work").unwrap();
let snap = b"\x1b[?1049lhello world".to_vec();
save_screen(sessions_dir, "work", 100, 40, 7, &snap).unwrap();
let loaded = load_screen(sessions_dir, "work").expect("screen restored");
assert_eq!(loaded.cols, 100);
assert_eq!(loaded.rows, 40);
assert_eq!(loaded.output_seq, 7);
assert_eq!(loaded.snapshot, snap);
}
#[test]
fn load_screen_absent_is_none() {
let tmp = tempfile::tempdir().unwrap();
assert!(load_screen(tmp.path(), "missing").is_none());
}
#[test]
fn scan_skips_dead_and_junk_entries() {
let tmp = tempfile::tempdir().unwrap();
let sessions_dir = tmp.path();
// A meta pointing at a definitely-dead pid is cleaned up, not returned.
let dir = ensure_runtime_dir(sessions_dir, "ghost").unwrap();
write_meta(
&dir,
&HolderMeta {
session: "ghost".to_string(),
shell_pid: 2_000_000_000, // not a live pid
},
)
.unwrap();
// A junk (non-hex) directory is ignored.
std::fs::create_dir_all(runtime_root(sessions_dir).join("not-hex")).unwrap();
let found = scan_existing_holders(sessions_dir);
assert!(found.is_empty(), "dead/junk holders must be skipped");
assert!(!dir.exists(), "dead holder dir should be cleaned up");
}
#[test]
fn send_and_recv_fd_round_trips() {
use std::io::Seek;
// Pass a temp file's fd across a socketpair and confirm both ends point
// at the same open file (write via one, read via the other).
let (mut a, mut b) = UnixStream::pair().unwrap();
let mut file = tempfile::tempfile().unwrap();
writeln!(file, "shared-fd-marker").unwrap();
file.flush().unwrap();
send_fd(&mut a, file.as_raw_fd()).unwrap();
let received = recv_fd(&mut b).unwrap();
let mut got = fd_into_file(received);
got.rewind().unwrap();
let mut contents = String::new();
got.read_to_string(&mut contents).unwrap();
assert!(contents.contains("shared-fd-marker"));
}
}
+85 -1
View File
@@ -3,9 +3,49 @@ use crate::crypto;
use crate::native::{EnvVar, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth}; use crate::native::{EnvVar, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// Generate a name for an implicit, ephemeral terminal session — the kind
/// created by `dosh host` with no `--session`. Single source of truth shared by
/// the client (which generates it) and the server (which recognizes it via
/// [`is_implicit_session_name`] to decide a session is NOT worth persisting).
pub fn generate_implicit_session_name() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("term-{millis}-{}", std::process::id())
}
/// Whether `name` is a client-generated implicit session name
/// (`term-<digits>-<digits>`). Such sessions are ephemeral: the user can never
/// reattach by name, so the server must not persist them across restarts.
/// Explicitly-named (`--session work`) and prewarmed sessions are not implicit.
pub fn is_implicit_session_name(name: &str) -> bool {
let Some(rest) = name.strip_prefix("term-") else {
return false;
};
let mut parts = rest.split('-');
match (parts.next(), parts.next(), parts.next()) {
(Some(millis), Some(pid), None) => {
!millis.is_empty()
&& millis.bytes().all(|b| b.is_ascii_digit())
&& !pid.is_empty()
&& pid.bytes().all(|b| b.is_ascii_digit())
}
_ => false,
}
}
pub const MAGIC: &[u8; 4] = b"DOSH"; pub const MAGIC: &[u8; 4] = b"DOSH";
pub const VERSION: u8 = 2; // v4: added reliable stream offsets/acks to `StreamData` and
// `StreamWindowAdjust`.
// v3: added `ForwardingKind::Agent` (SSH-agent forwarding). The new variant rides
// inside `NativeUserAuth.requested_forwardings`, so a pre-agent peer would fail to
// deserialize it; bumping the wire version makes such a peer answer with a clear
// version-mismatch reject instead. Existing variants' bincode discriminants are
// unchanged, so the bump is purely a compatibility gate.
pub const VERSION: u8 = 4;
pub const HEADER_LEN: usize = 58; pub const HEADER_LEN: usize = 58;
/// Stable, user-facing reason string the server puts in an `AttachReject` when a /// Stable, user-facing reason string the server puts in an `AttachReject` when a
@@ -317,6 +357,10 @@ pub struct NativeAuthCheckOkBody {
pub allow_tcp_forwarding: bool, pub allow_tcp_forwarding: bool,
pub allow_remote_forwarding: bool, pub allow_remote_forwarding: bool,
pub allow_agent_forwarding: bool, pub allow_agent_forwarding: bool,
#[serde(default)]
pub allow_file_transfer: bool,
#[serde(default)]
pub allow_exec_command: bool,
pub policy_flags: Vec<String>, pub policy_flags: Vec<String>,
#[serde(default)] #[serde(default)]
pub server_version: String, pub server_version: String,
@@ -378,12 +422,14 @@ pub struct StreamOpenReject {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamData { pub struct StreamData {
pub stream_id: u64, pub stream_id: u64,
pub offset: u64,
pub bytes: Vec<u8>, pub bytes: Vec<u8>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamWindowAdjust { pub struct StreamWindowAdjust {
pub stream_id: u64, pub stream_id: u64,
pub received_offset: u64,
pub bytes: u32, pub bytes: u32,
} }
@@ -491,3 +537,41 @@ impl ReplayWindow {
} }
} }
} }
#[cfg(test)]
mod session_name_tests {
use super::{generate_implicit_session_name, is_implicit_session_name};
#[test]
fn generated_names_are_recognized_as_implicit() {
let name = generate_implicit_session_name();
assert!(is_implicit_session_name(&name), "generated: {name}");
}
#[test]
fn explicit_and_prewarm_names_are_not_implicit() {
for name in [
"default",
"work",
"logs",
"term",
"term-",
"term-abc-1",
"term-1-x",
"term-1",
"term-1-2-3",
"",
] {
assert!(
!is_implicit_session_name(name),
"should not be implicit: {name:?}"
);
}
}
#[test]
fn implicit_shape_matches() {
assert!(is_implicit_session_name("term-1781470634216-76685"));
assert!(is_implicit_session_name("term-0-0"));
}
}
+194 -20
View File
@@ -1,15 +1,42 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem}; use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::Path; use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
use tokio::sync::mpsc; use tokio::sync::mpsc;
pub struct PtyHandle { // Keep live terminal output comfortably below common path MTUs after Dosh's
writer: Arc<Mutex<Box<dyn Write + Send>>>, // protocol header, AEAD tag, UDP/IP headers, and bincode framing. Full-screen
// TUIs often write several KiB on the first draw; sending that as one UDP
// datagram can fragment and vanish, leaving only a blank alternate screen.
const PTY_OUTPUT_CHUNK_BYTES: usize = 1024;
/// Backing for a PTY master held by the server.
///
/// `Owned` means this process spawned the shell as a child and is responsible
/// for it: dropping the handle kills the shell. This is the original,
/// non-persistent model and stays the default.
///
/// `Adopted` means the shell lives in a separate holder process and this handle
/// only borrows the master fd (received over a Unix socket via SCM_RIGHTS).
/// Dropping it must NOT kill the shell — it just closes our copy of the fd and
/// stops the reader thread, leaving the holder + shell alive so a server restart
/// can re-adopt them.
enum Backing {
Owned {
child: Mutex<Box<dyn Child + Send + Sync>>, child: Mutex<Box<dyn Child + Send + Sync>>,
_master: Box<dyn MasterPty + Send>, _master: Box<dyn MasterPty + Send>,
},
Adopted {
master: Mutex<std::fs::File>,
},
}
pub struct PtyHandle {
writer: Arc<Mutex<Box<dyn Write + Send>>>,
backing: Backing,
} }
impl PtyHandle { impl PtyHandle {
@@ -21,23 +48,40 @@ impl PtyHandle {
} }
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> { pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
self._master.resize(PtySize { match &self.backing {
Backing::Owned { _master, .. } => {
_master.resize(PtySize {
rows, rows,
cols, cols,
pixel_width: 0, pixel_width: 0,
pixel_height: 0, pixel_height: 0,
})?; })?;
}
Backing::Adopted { master } => {
let file = master.lock().expect("pty master poisoned");
resize_fd(file.as_raw_fd(), cols, rows)?;
}
}
Ok(()) Ok(())
} }
/// True for a handle backed by a separate holder process. Such a handle must
/// be detached (not killed) when the server lets go of a session, so the
/// shell survives a server restart.
pub fn is_persistent(&self) -> bool {
matches!(self.backing, Backing::Adopted { .. })
}
/// Terminate the shell process backing this PTY and reap it. /// Terminate the shell process backing this PTY and reap it.
/// ///
/// Without this the child shell outlives the session: dropping the master /// Only meaningful for an `Owned` backing (the server spawned the shell as a
/// alone is not guaranteed to take the process down, and the `Child` handle /// child). For an `Adopted` backing the shell belongs to the holder process,
/// used to be discarded at spawn time, which leaked one shell per /// so this is a no-op here; reaping a persistent session is done by asking
/// abandoned session. /// the holder to shut down (see `persist::request_shutdown`).
pub fn kill(&self) { pub fn kill(&self) {
if let Ok(mut child) = self.child.lock() { if let Backing::Owned { child, .. } = &self.backing
&& let Ok(mut child) = child.lock()
{
let _ = child.kill(); let _ = child.kill();
let _ = child.wait(); let _ = child.wait();
} }
@@ -46,9 +90,28 @@ impl PtyHandle {
impl Drop for PtyHandle { impl Drop for PtyHandle {
fn drop(&mut self) { fn drop(&mut self) {
// Adopted handles must NOT kill the shell: it lives in the holder so it
// can outlive this server. Dropping just closes our fd / stops the
// reader. Owned handles keep the original kill-on-drop behavior.
if let Backing::Owned { .. } = &self.backing {
self.kill(); self.kill();
} }
} }
}
fn resize_fd(fd: RawFd, cols: u16, rows: u16) -> Result<()> {
let winsize = libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
let rc = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &winsize) };
if rc != 0 {
return Err(std::io::Error::last_os_error()).context("TIOCSWINSZ");
}
Ok(())
}
#[derive(Debug)] #[derive(Debug)]
pub struct PtyOutput { pub struct PtyOutput {
@@ -74,12 +137,82 @@ pub fn spawn_pty_session(
pixel_height: 0, pixel_height: 0,
}) })
.context("open pty")?; .context("open pty")?;
let cmd = build_shell_command(shell, env);
let child = pair.slave.spawn_command(cmd).context("spawn shell")?;
drop(pair.slave);
let writer = pair.master.take_writer().context("take pty writer")?;
let reader = pair.master.try_clone_reader().context("clone pty reader")?;
spawn_reader_thread(session, reader, tx)?;
Ok(PtyHandle {
writer: Arc::new(Mutex::new(writer)),
backing: Backing::Owned {
child: Mutex::new(child),
_master: pair.master,
},
})
}
/// Whether this host has a terminfo entry for `term`, searching the standard
/// ncurses directories. Both the legacy single-letter (`x/xterm`) and the
/// hashed (`78/xterm`) subdirectory layouts are checked.
fn terminfo_available(term: &str) -> bool {
let Some(first) = term.chars().next() else {
return false;
};
let letter = first.to_string();
let hashed = format!("{:x}", first as u32);
let mut dirs: Vec<PathBuf> = Vec::new();
if let Ok(t) = std::env::var("TERMINFO")
&& !t.is_empty()
{
dirs.push(PathBuf::from(t));
}
if let Some(home) = dirs::home_dir() {
dirs.push(home.join(".terminfo"));
}
if let Ok(td) = std::env::var("TERMINFO_DIRS") {
for d in td.split(':').filter(|d| !d.is_empty()) {
dirs.push(PathBuf::from(d));
}
}
for d in [
"/etc/terminfo",
"/lib/terminfo",
"/usr/share/terminfo",
"/usr/lib/terminfo",
"/usr/share/lib/terminfo",
] {
dirs.push(PathBuf::from(d));
}
dirs.iter()
.any(|dir| dir.join(&letter).join(term).exists() || dir.join(&hashed).join(term).exists())
}
/// Build the [`CommandBuilder`] for a dosh shell, identically for the in-process
/// `spawn_pty_session` and the out-of-process holder, so a persistent session's
/// environment matches a non-persistent one.
pub fn build_shell_command(shell: &str, env: &[(String, String)]) -> CommandBuilder {
let mut cmd = CommandBuilder::new(shell); let mut cmd = CommandBuilder::new(shell);
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor"); cmd.env("COLORTERM", "truecolor");
for (name, value) in env { for (name, value) in env {
cmd.env(name, value); cmd.env(name, value);
} }
// The client's TERM is propagated, but a server that lacks that terminal's
// terminfo entry (e.g. xterm-ghostty, xterm-kitty) breaks ncurses apps like
// tmux/vim with "missing or unsuitable terminal". Keep the requested TERM
// only when this host actually has its terminfo; otherwise fall back to a
// universally available entry so remote apps always work.
let term = match env
.iter()
.find(|(n, _)| n == "TERM")
.map(|(_, v)| v.as_str())
{
Some(requested) if terminfo_available(requested) => requested.to_string(),
_ => "xterm-256color".to_string(),
};
cmd.env("TERM", term);
cmd.env("SHELL", shell); cmd.env("SHELL", shell);
if let Some(home) = dirs::home_dir() { if let Some(home) = dirs::home_dir() {
cmd.env("HOME", home.as_os_str()); cmd.env("HOME", home.as_os_str());
@@ -88,11 +221,39 @@ pub fn spawn_pty_session(
} else if let Some(parent) = Path::new(shell).parent() { } else if let Some(parent) = Path::new(shell).parent() {
cmd.env("PWD", parent.as_os_str()); cmd.env("PWD", parent.as_os_str());
} }
let child = pair.slave.spawn_command(cmd).context("spawn shell")?; cmd
drop(pair.slave); }
let writer = pair.master.take_writer().context("take pty writer")?; /// Build a [`PtyHandle`] from a master fd received from a holder process.
let mut reader = pair.master.try_clone_reader().context("clone pty reader")?; ///
/// `master_fd` is an fd this handle takes ownership of (it is wrapped in a
/// `File` and closed on drop). The shell is NOT a child of this process; it
/// belongs to the holder, so dropping this handle leaves it running.
pub fn adopt_pty_from_fd(
session: String,
master_fd: RawFd,
tx: mpsc::UnboundedSender<PtyOutput>,
) -> Result<PtyHandle> {
// Take ownership of the fd. A clone gives us an independent reader so the
// reader thread and the writer/resize side hold separate `File`s and don't
// get closed out from under each other.
let master = unsafe { std::fs::File::from_raw_fd(master_fd) };
let reader_file = master.try_clone().context("clone master for reader")?;
let writer_file = master.try_clone().context("clone master for writer")?;
spawn_reader_thread(session, Box::new(reader_file), tx)?;
Ok(PtyHandle {
writer: Arc::new(Mutex::new(Box::new(writer_file))),
backing: Backing::Adopted {
master: Mutex::new(master),
},
})
}
fn spawn_reader_thread(
session: String,
mut reader: Box<dyn Read + Send>,
tx: mpsc::UnboundedSender<PtyOutput>,
) -> Result<()> {
let reader_session = session.clone(); let reader_session = session.clone();
thread::Builder::new() thread::Builder::new()
.name(format!("dosh-pty-{session}")) .name(format!("dosh-pty-{session}"))
@@ -109,12 +270,14 @@ pub fn spawn_pty_session(
break; break;
} }
Ok(n) => { Ok(n) => {
for chunk in buf[..n].chunks(PTY_OUTPUT_CHUNK_BYTES) {
let _ = tx.send(PtyOutput { let _ = tx.send(PtyOutput {
session: reader_session.clone(), session: reader_session.clone(),
bytes: buf[..n].to_vec(), bytes: chunk.to_vec(),
exited: false, exited: false,
}); });
} }
}
Err(_) => { Err(_) => {
let _ = tx.send(PtyOutput { let _ = tx.send(PtyOutput {
session: reader_session.clone(), session: reader_session.clone(),
@@ -127,10 +290,21 @@ pub fn spawn_pty_session(
} }
}) })
.context("spawn pty reader")?; .context("spawn pty reader")?;
Ok(())
}
Ok(PtyHandle { #[cfg(test)]
writer: Arc::new(Mutex::new(writer)), mod tests {
child: Mutex::new(child), use super::*;
_master: pair.master,
}) const _: () = assert!(PTY_OUTPUT_CHUNK_BYTES <= 1200);
#[test]
fn terminfo_available_detects_known_and_unknown() {
// A near-universal entry should be present on any host with ncurses.
assert!(terminfo_available("xterm") || terminfo_available("xterm-256color"));
// Bogus / empty names must report missing so we fall back.
assert!(!terminfo_available("definitely-not-a-real-terminal-xyz"));
assert!(!terminfo_available(""));
}
} }
+591
View File
@@ -0,0 +1,591 @@
use crate::config::{ServerConfig, load_server_config};
use crate::crypto;
use crate::native::{
self, ForwardingKind, ForwardingRequest, NativeAuthOk, NativeServerHello,
derive_native_session_key, generate_native_ephemeral, host_public_key, load_or_create_host_key,
sign_server_hello, verify_native_user_auth_from_config,
};
use crate::protocol::{
self, AttachReject, CLIENT_TO_SERVER, NativeAuthOkBody, NativeClientHelloBody,
NativeServerHelloBody, NativeUserAuthBody, PacketKind, SERVER_TO_CLIENT,
};
use crate::transport::{
DoshTransport, SessionEvent, SessionRole, SessionTransportConfig, TransportConfig,
service_name_from_target,
};
use anyhow::{Context, Result, anyhow, bail};
use ed25519_dalek::SigningKey;
use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::net::UdpSocket;
#[derive(Debug, Clone)]
pub struct DoshServerConfig {
pub server: ServerConfig,
pub bind_addr: Option<SocketAddr>,
pub services: HashSet<String>,
pub transport: TransportConfig,
pub require_current_user: bool,
pub auth_timeout: Duration,
}
impl DoshServerConfig {
pub fn new(server: ServerConfig) -> Self {
Self {
server,
bind_addr: None,
services: HashSet::new(),
transport: TransportConfig::default(),
require_current_user: true,
auth_timeout: Duration::from_secs(30),
}
}
pub fn bind_addr(mut self, addr: SocketAddr) -> Self {
self.bind_addr = Some(addr);
self
}
pub fn service(mut self, name: impl Into<String>) -> Result<Self> {
self.services.insert(validate_service_name(name.into())?);
Ok(self)
}
pub fn services(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Result<Self> {
for name in names {
self.services.insert(validate_service_name(name.into())?);
}
Ok(self)
}
pub fn require_current_user(mut self, value: bool) -> Self {
self.require_current_user = value;
self
}
pub fn transport(mut self, transport: TransportConfig) -> Self {
self.transport = transport;
self
}
}
impl Default for DoshServerConfig {
fn default() -> Self {
Self::new(ServerConfig::default())
}
}
#[derive(Debug, Clone)]
pub struct DoshAccepted {
pub conn_id: [u8; 16],
pub user: String,
pub session: String,
pub services: Vec<String>,
pub peer_addr: SocketAddr,
}
#[derive(Debug, Clone)]
pub enum DoshServerEvent {
Accepted(DoshAccepted),
Session {
conn_id: [u8; 16],
event: SessionEvent,
},
Ignored,
}
struct PendingServerAuth {
client: native::NativeClientHello,
server: NativeServerHello,
session_key: [u8; 32],
peer: SocketAddr,
created_at: Instant,
}
pub struct DoshServer {
socket: Arc<UdpSocket>,
config: DoshServerConfig,
host_signing: SigningKey,
pending: HashMap<[u8; 16], PendingServerAuth>,
transports: HashMap<[u8; 16], DoshTransport>,
accepted: HashMap<[u8; 16], DoshAccepted>,
}
impl DoshServer {
pub async fn load() -> Result<Self> {
Self::bind(DoshServerConfig::new(load_server_config(None)?)).await
}
pub async fn load_from_path(path: Option<PathBuf>) -> Result<Self> {
Self::bind(DoshServerConfig::new(load_server_config(path)?)).await
}
pub async fn bind(config: DoshServerConfig) -> Result<Self> {
let bind_addr = match config.bind_addr {
Some(addr) => addr,
None => format!("{}:{}", config.server.bind, config.server.port)
.parse()
.with_context(|| {
format!(
"parse Dosh bind address {}:{}",
config.server.bind, config.server.port
)
})?,
};
let host_signing = load_or_create_host_key(&config.server)?;
let socket = Arc::new(UdpSocket::bind(bind_addr).await?);
Ok(Self {
socket,
config,
host_signing,
pending: HashMap::new(),
transports: HashMap::new(),
accepted: HashMap::new(),
})
}
pub fn local_addr(&self) -> Result<SocketAddr> {
Ok(self.socket.local_addr()?)
}
pub fn connection(&self, conn_id: &[u8; 16]) -> Option<&DoshAccepted> {
self.accepted.get(conn_id)
}
pub fn transport(&self, conn_id: &[u8; 16]) -> Option<&DoshTransport> {
self.transports.get(conn_id)
}
pub fn transport_mut(&mut self, conn_id: &[u8; 16]) -> Option<&mut DoshTransport> {
self.transports.get_mut(conn_id)
}
pub async fn recv(&mut self) -> Result<DoshServerEvent> {
let mut buf = vec![0u8; 65535];
loop {
self.expire_pending();
let (n, peer) = self.socket.recv_from(&mut buf).await?;
let packet = match protocol::decode(&buf[..n]) {
Ok(packet) => packet,
Err(_) => continue,
};
if packet.header.kind == PacketKind::NativeClientHello {
self.handle_client_hello(peer, packet.body).await?;
return Ok(DoshServerEvent::Ignored);
}
if packet.header.kind == PacketKind::NativeUserAuth {
return self.handle_user_auth(peer, &packet).await;
}
if let Some(transport) = self.transports.get_mut(&packet.header.conn_id) {
let event = transport.handle_datagram(&buf[..n], peer).await?;
return Ok(DoshServerEvent::Session {
conn_id: packet.header.conn_id,
event,
});
}
self.send_reject(peer, packet.header.conn_id, "unknown Dosh connection")
.await?;
return Ok(DoshServerEvent::Ignored);
}
}
pub async fn accept_stream(&mut self, conn_id: [u8; 16], stream_id: u64) -> Result<()> {
self.transport_mut(&conn_id)
.ok_or_else(|| anyhow!("unknown Dosh connection"))?
.accept_stream(stream_id)
.await
}
pub async fn reject_stream(
&mut self,
conn_id: [u8; 16],
stream_id: u64,
reason: impl Into<String>,
) -> Result<()> {
self.transport_mut(&conn_id)
.ok_or_else(|| anyhow!("unknown Dosh connection"))?
.reject_stream(stream_id, reason)
.await
}
pub async fn send(
&mut self,
conn_id: [u8; 16],
stream_id: u64,
bytes: impl Into<Vec<u8>>,
) -> Result<()> {
self.transport_mut(&conn_id)
.ok_or_else(|| anyhow!("unknown Dosh connection"))?
.send(stream_id, bytes)
.await
}
pub async fn close(&mut self, conn_id: [u8; 16], stream_id: u64) -> Result<()> {
self.transport_mut(&conn_id)
.ok_or_else(|| anyhow!("unknown Dosh connection"))?
.close(stream_id)
.await
}
pub async fn maintenance(&mut self) -> Result<()> {
for transport in self.transports.values_mut() {
transport.maintenance().await?;
}
Ok(())
}
async fn handle_client_hello(&mut self, peer: SocketAddr, body: Vec<u8>) -> Result<()> {
let req: NativeClientHelloBody = protocol::from_body(&body)?;
if let Err(err) =
native::check_native_protocol_version(req.hello.protocol_version, "client")
{
self.send_reject(peer, [0u8; 16], &err.to_string()).await?;
return Ok(());
}
let result = self.build_server_hello(req.hello, peer);
let (pending_id, hello) = match result {
Ok(value) => value,
Err(err) => {
self.send_reject(peer, [0u8; 16], &err.to_string()).await?;
return Ok(());
}
};
let body = protocol::to_body(&NativeServerHelloBody { hello })?;
let out = protocol::encode_plain(PacketKind::NativeServerHello, pending_id, 1, 0, &body)?;
self.socket.send_to(&out, peer).await?;
Ok(())
}
fn build_server_hello(
&mut self,
client: native::NativeClientHello,
peer: SocketAddr,
) -> Result<([u8; 16], NativeServerHello)> {
if !self.config.server.native_auth {
bail!("native auth disabled");
}
if !client
.supported_aead
.iter()
.any(|algorithm| algorithm == "chacha20poly1305")
{
bail!("native auth requires chacha20poly1305");
}
if !client
.supported_user_key_algorithms
.iter()
.any(|algorithm| native::is_supported_user_signature_algorithm(algorithm))
{
bail!("native auth requires a supported user key algorithm");
}
if self.config.require_current_user {
let current_user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string());
if client.requested_user != current_user {
bail!("native auth user mismatch");
}
}
let (server_secret, server_public) = generate_native_ephemeral();
let mut server = NativeServerHello {
protocol_version: native::NATIVE_PROTOCOL_VERSION,
server_random: crypto::random_32(),
server_ephemeral_public: server_public,
host_key: host_public_key(&self.host_signing),
chosen_aead: "chacha20poly1305".to_string(),
server_key_epoch: 1,
auth_challenge: crypto::random_32(),
rate_limit_remaining: None,
host_signature: Vec::new(),
};
sign_server_hello(&self.host_signing, &client, &mut server)?;
let session_key = derive_native_session_key(
&server_secret,
client.client_ephemeral_public,
&client,
&server,
)?;
let mut pending_id = [0u8; 16];
pending_id.copy_from_slice(&server.auth_challenge[..16]);
self.pending.insert(
pending_id,
PendingServerAuth {
client,
server: server.clone(),
session_key,
peer,
created_at: Instant::now(),
},
);
Ok((pending_id, server))
}
async fn handle_user_auth(
&mut self,
peer: SocketAddr,
packet: &protocol::Packet,
) -> Result<DoshServerEvent> {
let Some(pending) = self.pending.remove(&packet.header.conn_id) else {
self.send_reject(peer, packet.header.conn_id, "unknown native auth")
.await?;
return Ok(DoshServerEvent::Ignored);
};
if pending.peer != peer {
self.send_reject(peer, packet.header.conn_id, "native auth peer changed")
.await?;
return Ok(DoshServerEvent::Ignored);
}
if pending.created_at.elapsed() > self.config.auth_timeout {
self.send_reject(peer, packet.header.conn_id, "native auth expired")
.await?;
return Ok(DoshServerEvent::Ignored);
}
let body = protocol::decrypt_body(packet, &pending.session_key, CLIENT_TO_SERVER)?;
let req: NativeUserAuthBody = protocol::from_body(&body)?;
let services = match self.verify_auth_and_services(&pending, &req.auth) {
Ok(services) => services,
Err(err) => {
self.send_reject(peer, packet.header.conn_id, &format!("{err:#}"))
.await?;
return Ok(DoshServerEvent::Ignored);
}
};
let conn_id = crypto::random_16();
let key_id = protocol::session_key_id(&pending.session_key);
let ok = NativeAuthOk {
client_id: conn_id,
session: pending.client.requested_session.clone(),
mode: pending.client.requested_mode.clone(),
session_key: pending.session_key,
session_key_id: key_id,
attach_ticket: Vec::new(),
attach_ticket_psk: crypto::random_32(),
initial_seq: 1,
snapshot: Vec::new(),
policy_flags: services
.iter()
.map(|service| format!("service:{service}"))
.collect(),
};
let body = protocol::to_body(&NativeAuthOkBody { ok })?;
let out = protocol::encode_encrypted(
PacketKind::NativeAuthOk,
conn_id,
1,
packet.header.seq,
&pending.session_key,
SERVER_TO_CLIENT,
&body,
)?;
self.socket.send_to(&out, peer).await?;
let transport = DoshTransport::new(
Arc::clone(&self.socket),
SessionTransportConfig {
role: SessionRole::Server,
conn_id,
session_key: pending.session_key,
peer_addr: peer,
initial_send_seq: 2,
initial_ack: packet.header.seq,
stream: self.config.transport.clone(),
},
);
let accepted = DoshAccepted {
conn_id,
user: pending.client.requested_user,
session: pending.client.requested_session,
services,
peer_addr: peer,
};
self.transports.insert(conn_id, transport);
self.accepted.insert(conn_id, accepted.clone());
Ok(DoshServerEvent::Accepted(accepted))
}
fn verify_auth_and_services(
&self,
pending: &PendingServerAuth,
auth: &native::NativeUserAuth,
) -> Result<Vec<String>> {
verify_native_user_auth_from_config(
&self.config.server,
&pending.client,
&pending.server,
auth,
Some(pending.peer.ip()),
)
.context("verify native user auth")?;
validate_requested_services(&self.config.services, &auth.requested_forwardings)
}
async fn send_reject(&self, peer: SocketAddr, conn_id: [u8; 16], reason: &str) -> Result<()> {
let body = protocol::to_body(&AttachReject {
reason: reason.to_string(),
})?;
let out = protocol::encode_plain(PacketKind::AttachReject, conn_id, 0, 0, &body)?;
self.socket.send_to(&out, peer).await?;
Ok(())
}
fn expire_pending(&mut self) {
let timeout = self.config.auth_timeout;
self.pending
.retain(|_, pending| pending.created_at.elapsed() <= timeout);
}
}
fn validate_requested_services(
allowed_services: &HashSet<String>,
requests: &[ForwardingRequest],
) -> Result<Vec<String>> {
let mut services = Vec::new();
for request in requests {
if request.kind != ForwardingKind::Local {
bail!("embedded Dosh services only accept local service requests");
}
if request.listen_port != 0 || request.target_port != Some(0) {
bail!("embedded Dosh service requests must use port 0");
}
let target = request
.target_host
.as_deref()
.ok_or_else(|| anyhow!("embedded Dosh service request is missing target host"))?;
let service = service_name_from_target(target)
.ok_or_else(|| anyhow!("target {target:?} is not a Dosh service"))?;
if !allowed_services.contains(service) {
bail!("Dosh service {service:?} is not registered");
}
if !services.iter().any(|existing| existing == service) {
services.push(service.to_string());
}
}
Ok(services)
}
fn validate_service_name(name: String) -> Result<String> {
crate::transport::service_target(&name)?;
Ok(name)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::DoshClient;
use crate::config::{ClientConfig, HostsConfig};
use crate::transport::TransportEvent;
use ed25519_dalek::SigningKey;
#[tokio::test]
async fn sdk_client_and_server_exchange_service_stream() {
let dir = tempfile::tempdir().unwrap();
let host_key = dir.path().join("host_key");
let authorized_keys = dir.path().join("authorized_keys");
let identity = dir.path().join("id_ed25519");
let known_hosts = dir.path().join("known_hosts");
let signing = SigningKey::from_bytes(&[91u8; 32]);
let keypair = ssh_key::private::Ed25519Keypair::from(&signing);
let private =
ssh_key::PrivateKey::new(ssh_key::private::KeypairData::from(keypair), "").unwrap();
private
.write_openssh_file(&identity, ssh_key::LineEnding::LF)
.unwrap();
std::fs::write(
&authorized_keys,
format!("{}\n", private.public_key().to_openssh().unwrap()),
)
.unwrap();
let server_config = ServerConfig {
host_key: host_key.to_string_lossy().to_string(),
authorized_keys: vec![authorized_keys.to_string_lossy().to_string()],
..ServerConfig::default()
};
let server_config = DoshServerConfig::new(server_config)
.bind_addr("127.0.0.1:0".parse().unwrap())
.service("echo")
.unwrap()
.require_current_user(false);
let mut server = DoshServer::bind(server_config).await.unwrap();
let server_port = server.local_addr().unwrap().port();
let client_config = ClientConfig {
dosh_port: server_port,
trust_on_first_use: true,
known_hosts: known_hosts.to_string_lossy().to_string(),
identity_files: vec![identity.to_string_lossy().to_string()],
use_ssh_agent: false,
..ClientConfig::default()
};
let client = DoshClient::with_config(client_config, HostsConfig::default());
let connect = client
.connect("127.0.0.1")
.user("sdk-user")
.service("echo")
.connect();
let accept = async {
loop {
if let DoshServerEvent::Accepted(accepted) = server.recv().await.unwrap() {
break accepted;
}
}
};
let (connected, accepted) = tokio::join!(connect, accept);
let mut client_transport = connected.unwrap().into_transport();
let conn_id = accepted.conn_id;
assert_eq!(accepted.services, vec!["echo".to_string()]);
let stream_id = client_transport.open_service("echo").await.unwrap();
match server.recv().await.unwrap() {
DoshServerEvent::Session {
event: SessionEvent::Stream(TransportEvent::Open(open)),
..
} => {
assert_eq!(open.stream_id, stream_id);
server.accept_stream(conn_id, open.stream_id).await.unwrap();
}
other => panic!("unexpected event {other:?}"),
}
assert!(matches!(
client_transport.recv().await.unwrap(),
SessionEvent::Stream(TransportEvent::OpenOk { .. })
));
client_transport
.send(stream_id, b"ping".to_vec())
.await
.unwrap();
loop {
match server.recv().await.unwrap() {
DoshServerEvent::Session {
event: SessionEvent::Stream(TransportEvent::Data(data)),
..
} => {
assert_eq!(data.chunks, vec![b"ping".to_vec()]);
server
.send(conn_id, data.stream_id, b"pong".to_vec())
.await
.unwrap();
break;
}
DoshServerEvent::Session { .. } | DoshServerEvent::Ignored => {}
other => panic!("unexpected event {other:?}"),
}
}
loop {
match client_transport.recv().await.unwrap() {
SessionEvent::Stream(TransportEvent::Data(data)) => {
assert_eq!(data.chunks, vec![b"pong".to_vec()]);
break;
}
SessionEvent::Stream(_) | SessionEvent::Ignored => {}
other => panic!("unexpected event {other:?}"),
}
}
}
}
+111 -26
View File
@@ -1,26 +1,43 @@
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,
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;
#[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)]
pub struct AgentIdentity { pub struct AgentIdentity {
pub key_blob: Vec<u8>, pub key_blob: Vec<u8>,
pub public_key: [u8; 32], pub public_key_algorithm: String,
pub public_key: Vec<u8>,
pub sign_algorithm: String,
pub sign_flags: u32,
pub comment: String, pub comment: String,
} }
#[cfg(unix)]
pub fn sign_user_auth_with_agent( pub fn sign_user_auth_with_agent(
client: &NativeClientHello, client: &NativeClientHello,
server: &NativeServerHello, server: &NativeServerHello,
@@ -30,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,
@@ -38,13 +67,13 @@ pub fn sign_user_auth_with_agent_at(
) -> Result<NativeUserAuth> { ) -> Result<NativeUserAuth> {
let mut agent = UnixStream::connect(socket_path.as_ref()) let mut agent = UnixStream::connect(socket_path.as_ref())
.with_context(|| format!("connect ssh-agent {}", socket_path.as_ref().display()))?; .with_context(|| format!("connect ssh-agent {}", socket_path.as_ref().display()))?;
let identities = request_ed25519_identities(&mut agent)?; let identities = request_supported_identities(&mut agent)?;
let identity = identities let identity = identities
.first() .first()
.ok_or_else(|| anyhow!("ssh-agent has no ssh-ed25519 identities"))?; .ok_or_else(|| anyhow!("ssh-agent has no supported identities"))?;
let mut auth = NativeUserAuth { let mut auth = NativeUserAuth {
public_key_algorithm: "ssh-ed25519".to_string(), public_key_algorithm: identity.sign_algorithm.clone(),
public_key: identity.public_key.to_vec(), public_key: identity.public_key.clone(),
signature: Vec::new(), signature: Vec::new(),
requested_forwardings, requested_forwardings,
}; };
@@ -54,7 +83,20 @@ pub fn sign_user_auth_with_agent_at(
Ok(auth) Ok(auth)
} }
fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> { #[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>> {
write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?; write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?;
let payload = read_agent_packet(agent)?; let payload = read_agent_packet(agent)?;
let mut cursor = payload.as_slice(); let mut cursor = payload.as_slice();
@@ -72,18 +114,51 @@ fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentit
for _ in 0..count { for _ in 0..count {
let key_blob = read_ssh_string(&mut cursor)?.to_vec(); let key_blob = read_ssh_string(&mut cursor)?.to_vec();
let comment = String::from_utf8_lossy(read_ssh_string(&mut cursor)?).to_string(); let comment = String::from_utf8_lossy(read_ssh_string(&mut cursor)?).to_string();
if let Ok(public_key) = parse_ssh_ed25519_public_blob(&key_blob) { if let Some(identity) = supported_identity(key_blob, comment)? {
identities.push(AgentIdentity { identities.push(identity);
key_blob,
public_key,
comment,
});
} }
} }
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities"); anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities");
Ok(identities) Ok(identities)
} }
#[cfg(unix)]
fn supported_identity(key_blob: Vec<u8>, comment: String) -> Result<Option<AgentIdentity>> {
let algorithm = key_blob_algorithm(&key_blob)?;
if !is_supported_user_key_algorithm(&algorithm) {
return Ok(None);
}
let identity = match algorithm.as_str() {
"ssh-ed25519" => AgentIdentity {
public_key_algorithm: algorithm.clone(),
public_key: parse_ssh_ed25519_public_blob(&key_blob)?.to_vec(),
sign_algorithm: "ssh-ed25519".to_string(),
sign_flags: 0,
key_blob,
comment,
},
"ecdsa-sha2-nistp256" => AgentIdentity {
public_key_algorithm: algorithm.clone(),
public_key: key_blob.clone(),
sign_algorithm: algorithm,
sign_flags: 0,
key_blob,
comment,
},
"ssh-rsa" => AgentIdentity {
public_key_algorithm: algorithm,
public_key: key_blob.clone(),
sign_algorithm: "rsa-sha2-512".to_string(),
sign_flags: SSH_AGENT_RSA_SHA2_512,
key_blob,
comment,
},
_ => return Ok(None),
};
Ok(Some(identity))
}
#[cfg(unix)]
fn sign_with_agent( fn sign_with_agent(
agent: &mut UnixStream, agent: &mut UnixStream,
identity: &AgentIdentity, identity: &AgentIdentity,
@@ -93,7 +168,7 @@ fn sign_with_agent(
request.push(SSH2_AGENTC_SIGN_REQUEST); request.push(SSH2_AGENTC_SIGN_REQUEST);
write_ssh_string(&mut request, &identity.key_blob); write_ssh_string(&mut request, &identity.key_blob);
write_ssh_string(&mut request, transcript); write_ssh_string(&mut request, transcript);
request.extend_from_slice(&0u32.to_be_bytes()); request.extend_from_slice(&identity.sign_flags.to_be_bytes());
write_agent_packet(agent, &request)?; write_agent_packet(agent, &request)?;
let payload = read_agent_packet(agent)?; let payload = read_agent_packet(agent)?;
@@ -114,23 +189,21 @@ fn sign_with_agent(
let mut signature_cursor = signature_blob; let mut signature_cursor = signature_blob;
let algorithm = read_ssh_string(&mut signature_cursor)?; let algorithm = read_ssh_string(&mut signature_cursor)?;
let algorithm = String::from_utf8_lossy(algorithm);
anyhow::ensure!( anyhow::ensure!(
algorithm == b"ssh-ed25519", algorithm == identity.sign_algorithm,
"ssh-agent returned unsupported signature algorithm {}", "ssh-agent returned signature algorithm {algorithm}, expected {}",
String::from_utf8_lossy(algorithm) identity.sign_algorithm
); );
let signature = read_ssh_string(&mut signature_cursor)?; let signature = read_ssh_string(&mut signature_cursor)?;
anyhow::ensure!( anyhow::ensure!(
signature_cursor.is_empty(), signature_cursor.is_empty(),
"trailing data in ssh-agent signature blob" "trailing data in ssh-agent signature blob"
); );
anyhow::ensure!(
signature.len() == 64,
"ssh-agent Ed25519 signature was not 64 bytes"
);
Ok(signature.to_vec()) Ok(signature.to_vec())
} }
#[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
@@ -145,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,
@@ -155,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];
@@ -162,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());
@@ -169,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");
@@ -177,12 +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(test)] #[cfg(unix)]
fn key_blob_algorithm(blob: &[u8]) -> Result<String> {
let mut cursor = blob;
let algorithm = read_ssh_string(&mut cursor)?;
Ok(String::from_utf8_lossy(algorithm).to_string())
}
#[cfg(all(test, unix))]
mod tests { mod tests {
use super::*; use super::*;
use crate::native::{ use crate::native::{
@@ -253,8 +338,8 @@ mod tests {
protocol_version: crate::native::NATIVE_PROTOCOL_VERSION, protocol_version: crate::native::NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32], client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32], client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(), requested_host: "homelab".to_string(),
requested_user: "palav".to_string(), requested_user: "alice".to_string(),
requested_session: "term".to_string(), requested_session: "term".to_string(),
requested_mode: "read-write".to_string(), requested_mode: "read-write".to_string(),
terminal_size: (80, 24), terminal_size: (80, 24),
+1099
View File
File diff suppressed because it is too large Load Diff
+504 -4
View File
@@ -18,9 +18,15 @@
//! 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::net::{SocketAddr, UdpSocket}; use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, UdpSocket};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -28,13 +34,18 @@ use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use dosh::auth::{BootstrapResponse, build_bootstrap, load_or_create_server_secret}; use dosh::auth::{BootstrapResponse, build_bootstrap, load_or_create_server_secret};
use dosh::config::load_server_config; use dosh::config::load_server_config;
use dosh::crypto; use dosh::crypto;
use dosh::native::{self, ForwardingKind, ForwardingRequest, NativeAuthOk, NativeClientHello};
use dosh::protocol::{ use dosh::protocol::{
self, AttachOk, CLIENT_TO_SERVER, Frame, Header, Input, PacketKind, ResumeRequest, self, AttachOk, AttachReject, CLIENT_TO_SERVER, Frame, Header, Input, NativeAuthOkBody,
SERVER_TO_CLIENT, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind, ResumeRequest,
SERVER_TO_CLIENT, StreamData, StreamOpen, StreamOpenOk, StreamWindowAdjust,
}; };
use ed25519_dalek::{SigningKey, VerifyingKey};
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::{Rng, SeedableRng}; use rand::{Rng, SeedableRng};
@@ -67,6 +78,7 @@ sessions_dir = "{sessions}"
secret_path = "{secret}" secret_path = "{secret}"
host_key = "{host_key}" host_key = "{host_key}"
authorized_keys = ["{authorized_keys}"] authorized_keys = ["{authorized_keys}"]
persist_sessions = false
"#, "#,
sessions = dir.path().join("sessions").display(), sessions = dir.path().join("sessions").display(),
secret = dir.path().join("secret").display(), secret = dir.path().join("secret").display(),
@@ -78,6 +90,23 @@ authorized_keys = ["{authorized_keys}"]
config config
} }
fn authorize_ed25519_key(dir: &tempfile::TempDir, signing_key: &SigningKey) {
let verifying = VerifyingKey::from(signing_key);
let mut blob = Vec::new();
write_ssh_string(&mut blob, b"ssh-ed25519");
write_ssh_string(&mut blob, verifying.as_bytes());
fs::write(
dir.path().join("authorized_keys"),
format!("ssh-ed25519 {} hostile-test\n", STANDARD.encode(blob)),
)
.unwrap();
}
fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
out.extend_from_slice(value);
}
fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child { fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child {
let server = env!("CARGO_BIN_EXE_dosh-server"); let server = env!("CARGO_BIN_EXE_dosh-server");
let child = Command::new(server) let child = Command::new(server)
@@ -401,6 +430,122 @@ fn attach_through_relay(
} }
} }
struct NativeAttached {
socket: UdpSocket,
ok: NativeAuthOk,
}
fn native_attach_through_relay(
relay: &Relay,
signing_key: &SigningKey,
requested_forwardings: Vec<ForwardingRequest>,
) -> NativeAttached {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(500)))
.unwrap();
let (client_secret, client_public) = native::generate_native_ephemeral();
let hello = NativeClientHello {
protocol_version: native::NATIVE_PROTOCOL_VERSION,
client_random: crypto::random_32(),
client_ephemeral_public: client_public,
requested_host: "127.0.0.1".to_string(),
requested_user: std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()),
requested_session: "default".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: native::supported_user_key_algorithms(),
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: Vec::new(),
};
let packet = protocol::encode_plain(
PacketKind::NativeClientHello,
[0u8; 16],
1,
0,
&protocol::to_body(&NativeClientHelloBody {
hello: hello.clone(),
})
.unwrap(),
)
.unwrap();
let hello_deadline = Instant::now() + Duration::from_secs(5);
let server_hello = loop {
socket.send_to(&packet, relay.front_addr()).unwrap();
let mut buf = [0u8; 65535];
match socket.recv_from(&mut buf) {
Ok((n, _)) => {
let packet = protocol::decode(&buf[..n]).unwrap();
match packet.header.kind {
PacketKind::NativeServerHello => {
let body: NativeServerHelloBody =
protocol::from_body(&packet.body).unwrap();
native::verify_server_hello(&hello, &body.hello).unwrap();
break body.hello;
}
PacketKind::AttachReject => {
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
panic!("native hello rejected: {}", reject.reason);
}
kind => panic!("unexpected native hello response: {kind:?}"),
}
}
Err(_) if Instant::now() < hello_deadline => {}
Err(err) => panic!("native server hello timed out: {err}"),
}
};
let session_key = native::derive_native_session_key(
&client_secret,
server_hello.server_ephemeral_public,
&hello,
&server_hello,
)
.unwrap();
let auth =
native::sign_user_auth(signing_key, &hello, &server_hello, requested_forwardings).unwrap();
let mut pending_id = [0u8; 16];
pending_id.copy_from_slice(&server_hello.auth_challenge[..16]);
let auth_packet = protocol::encode_encrypted(
PacketKind::NativeUserAuth,
pending_id,
2,
1,
&session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&NativeUserAuthBody { auth }).unwrap(),
)
.unwrap();
let auth_deadline = Instant::now() + Duration::from_secs(5);
let ok = loop {
socket.send_to(&auth_packet, relay.front_addr()).unwrap();
let mut buf = [0u8; 65535];
match socket.recv_from(&mut buf) {
Ok((n, _)) => {
let packet = protocol::decode(&buf[..n]).unwrap();
match packet.header.kind {
PacketKind::NativeAuthOk => {
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT)
.unwrap();
let body: NativeAuthOkBody = protocol::from_body(&plain).unwrap();
break body.ok;
}
PacketKind::AttachReject => {
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
panic!("native auth rejected: {}", reject.reason);
}
kind => panic!("unexpected native auth response: {kind:?}"),
}
}
Err(_) if Instant::now() < auth_deadline => {}
Err(err) => panic!("native auth ok timed out: {err}"),
}
};
NativeAttached { socket, ok }
}
fn send_input( fn send_input(
socket: &UdpSocket, socket: &UdpSocket,
relay: &Relay, relay: &Relay,
@@ -430,6 +575,152 @@ fn send_raw(socket: &UdpSocket, relay: &Relay, packet: &[u8]) {
socket.send_to(packet, relay.front_addr()).unwrap(); socket.send_to(packet, relay.front_addr()).unwrap();
} }
fn send_stream_packet(
socket: &UdpSocket,
relay: &Relay,
ok: &NativeAuthOk,
kind: PacketKind,
seq: u64,
ack: u64,
body: Vec<u8>,
) {
let packet = protocol::encode_encrypted(
kind,
ok.client_id,
seq,
ack,
&ok.session_key,
CLIENT_TO_SERVER,
&body,
)
.unwrap();
socket.send_to(&packet, relay.front_addr()).unwrap();
}
fn recv_stream_packet<T: serde::de::DeserializeOwned>(
socket: &UdpSocket,
key: &[u8; 32],
kind: PacketKind,
) -> Option<(Header, T)> {
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).ok()?;
let packet = protocol::decode(&buf[..n]).ok()?;
if packet.header.kind != kind {
return None;
}
let plain = protocol::decrypt_body(&packet, key, SERVER_TO_CLIENT).ok()?;
let body = protocol::from_body(&plain).ok()?;
Some((packet.header, body))
}
fn wait_for_stream_open_ok(
socket: &UdpSocket,
key: &[u8; 32],
stream_id: u64,
millis: u64,
) -> Option<Header> {
let deadline = Instant::now() + Duration::from_millis(millis);
while Instant::now() < deadline {
if let Some((header, ok)) =
recv_stream_packet::<StreamOpenOk>(socket, key, PacketKind::StreamOpenOk)
{
if ok.stream_id == stream_id {
return Some(header);
}
}
}
None
}
fn wait_for_stream_data(
socket: &UdpSocket,
key: &[u8; 32],
stream_id: u64,
millis: u64,
) -> Option<(Header, StreamData)> {
let deadline = Instant::now() + Duration::from_millis(millis);
while Instant::now() < deadline {
if let Some((header, data)) =
recv_stream_packet::<StreamData>(socket, key, PacketKind::StreamData)
{
if data.stream_id == stream_id {
return Some((header, data));
}
}
}
None
}
fn start_tcp_collector() -> (u16, Receiver<Vec<u8>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, rx) = channel();
thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
stream
.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut buf = [0u8; 1024];
while Instant::now() < deadline {
match stream.read(&mut buf) {
Ok(0) => return,
Ok(n) => {
let _ = tx.send(buf[..n].to_vec());
}
Err(ref err)
if err.kind() == std::io::ErrorKind::WouldBlock
|| err.kind() == std::io::ErrorKind::TimedOut => {}
Err(_) => return,
}
}
});
(port, rx)
}
fn start_tcp_echo() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
stream
.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut buf = [0u8; 1024];
while Instant::now() < deadline {
match stream.read(&mut buf) {
Ok(0) => return,
Ok(n) => {
let _ = stream.write_all(&buf[..n]);
}
Err(ref err)
if err.kind() == std::io::ErrorKind::WouldBlock
|| err.kind() == std::io::ErrorKind::TimedOut => {}
Err(_) => return,
}
}
});
port
}
fn collect_tcp(rx: &Receiver<Vec<u8>>, millis: u64) -> Vec<u8> {
let deadline = Instant::now() + Duration::from_millis(millis);
let mut out = Vec::new();
while Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(50)) {
Ok(chunk) => out.extend_from_slice(&chunk),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
out
}
fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(Header, Frame)> { fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(Header, Frame)> {
let mut buf = [0u8; 65535]; let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).ok()?; let (n, _) = socket.recv_from(&mut buf).ok()?;
@@ -482,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);
@@ -602,6 +893,215 @@ fn duplicated_and_replayed_input_is_applied_at_most_once() {
); );
} }
#[test]
fn forwarded_stream_data_survives_reorder_and_rejects_replay() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let signing_key = SigningKey::from_bytes(&[42u8; 32]);
authorize_ed25519_key(&dir, &signing_key);
let (target_port, tcp_rx) = start_tcp_collector();
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0xF0E0D0u64);
let attached = native_attach_through_relay(
&relay,
&signing_key,
vec![ForwardingRequest {
kind: ForwardingKind::Local,
bind_host: Some("127.0.0.1".to_string()),
listen_port: 0,
target_host: Some("127.0.0.1".to_string()),
target_port: Some(target_port),
}],
);
let stream_id = 44;
let open = StreamOpen {
stream_id,
target_host: "127.0.0.1".to_string(),
target_port,
};
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamOpen,
3,
attached.ok.initial_seq,
protocol::to_body(&open).unwrap(),
);
let open_ok =
wait_for_stream_open_ok(&attached.socket, &attached.ok.session_key, stream_id, 3000)
.expect("stream did not open");
// Swap the first two StreamData packets in flight. The server's replay
// window must accept both out-of-order packets and write both to the TCP
// target exactly once.
relay.arm_reorder();
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamData,
4,
open_ok.seq,
protocol::to_body(&StreamData {
stream_id,
offset: 0,
bytes: b"A".to_vec(),
})
.unwrap(),
);
relay.arm_reorder();
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamData,
5,
open_ok.seq,
protocol::to_body(&StreamData {
stream_id,
offset: 1,
bytes: b"B".to_vec(),
})
.unwrap(),
);
let mut seen = collect_tcp(&tcp_rx, 1500);
assert!(
seen.starts_with(b"AB"),
"reordered stream bytes were not delivered once in-order to TCP target: {:?}",
String::from_utf8_lossy(&seen)
);
// Now send one encrypted StreamData packet repeatedly, with relay-level
// duplication enabled too. This is a true replay: same sequence, same nonce,
// same ciphertext. It must be applied to the TCP target at most once.
let replayed = protocol::encode_encrypted(
PacketKind::StreamData,
attached.ok.client_id,
6,
open_ok.seq,
&attached.ok.session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&StreamData {
stream_id,
offset: 2,
bytes: b"X".to_vec(),
})
.unwrap(),
)
.unwrap();
relay.set_dup(100);
for _ in 0..12 {
send_raw(&attached.socket, &relay, &replayed);
thread::sleep(Duration::from_millis(25));
}
relay.set_dup(0);
seen.extend(collect_tcp(&tcp_rx, 1500));
let replay_count = seen.iter().filter(|byte| **byte == b'X').count();
drop(relay);
let _ = server.kill();
let _ = server.wait();
assert!(
replay_count <= 1,
"replayed/duplicated StreamData was applied {replay_count} times: {:?}",
String::from_utf8_lossy(&seen)
);
}
#[test]
fn forwarded_stream_data_recovers_after_server_to_client_loss() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let signing_key = SigningKey::from_bytes(&[43u8; 32]);
authorize_ed25519_key(&dir, &signing_key);
let target_port = start_tcp_echo();
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0xA11CEu64);
let attached = native_attach_through_relay(
&relay,
&signing_key,
vec![ForwardingRequest {
kind: ForwardingKind::Local,
bind_host: Some("127.0.0.1".to_string()),
listen_port: 0,
target_host: Some("127.0.0.1".to_string()),
target_port: Some(target_port),
}],
);
let stream_id = 55;
let open = StreamOpen {
stream_id,
target_host: "127.0.0.1".to_string(),
target_port,
};
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamOpen,
3,
attached.ok.initial_seq,
protocol::to_body(&open).unwrap(),
);
let open_ok =
wait_for_stream_open_ok(&attached.socket, &attached.ok.session_key, stream_id, 3000)
.expect("stream did not open");
// Drop the first server->client echo and at least one retransmit tick. Once
// the link clears, the unacked stream chunk must be re-encrypted with a new
// packet sequence and delivered at the same stream offset.
relay.set_drop_s2c(100);
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamData,
4,
open_ok.seq,
protocol::to_body(&StreamData {
stream_id,
offset: 0,
bytes: b"PING".to_vec(),
})
.unwrap(),
);
thread::sleep(Duration::from_millis(300));
relay.set_drop_s2c(0);
let (_header, data) =
wait_for_stream_data(&attached.socket, &attached.ok.session_key, stream_id, 3000)
.expect("lost server->client stream data was not retransmitted");
assert_eq!(data.offset, 0);
assert_eq!(data.bytes, b"PING");
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamWindowAdjust,
5,
open_ok.seq,
protocol::to_body(&StreamWindowAdjust {
stream_id,
received_offset: 4,
bytes: 4,
})
.unwrap(),
);
drop(relay);
let _ = server.kill();
let _ = server.wait();
}
#[test] #[test]
fn stale_packets_after_resume_are_ignored_not_fatal() { fn stale_packets_after_resume_are_ignored_not_fatal() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
#[test]
fn quiet_update_keeps_prebuilt_enabled_by_default() {
let install = include_str!("../install.sh");
assert!(install.contains("use_prebuilt=\"${DOSH_USE_PREBUILT:-1}\""));
assert!(
!install.contains("use_prebuilt=0"),
"quiet updates must not force source builds"
);
}
#[test]
fn release_scripts_skip_stale_artifacts_when_auto_discovering() {
let upload = include_str!("../scripts/upload-gitea-release.sh");
assert!(upload.contains("skipping $artifact: version"));
assert!(upload.contains("expected_version"));
let publish = include_str!("../scripts/publish-gitea-release.sh");
assert!(publish.contains("skipping stale artifact"));
assert!(publish.contains("actual=\"$(artifact_version \"$artifact\" || true)\""));
}
#[test]
fn systemd_sandbox_allows_netlink_for_terminal_tools() {
let service = include_str!("../packaging/systemd/dosh-server.service");
let install = include_str!("../install.sh");
for raw in [service, install] {
assert!(
raw.contains("RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK"),
"dosh sessions must allow AF_NETLINK so tools like btop can enumerate network interfaces"
);
}
}
+15
View File
@@ -0,0 +1,15 @@
# Dosh Remote
Open VS Code Remote-SSH windows through Dosh.
The extension writes a managed SSH config entry like:
```sshconfig
Host dosh-palav
HostName 127.0.0.1
HostKeyAlias palav
ProxyCommand dosh proxy-stdio palav %h %p
```
VS Code still uses Remote-SSH, so server install, extensions, terminals, and
normal SSH behavior stay intact. Dosh carries the SSH TCP byte stream.
+170
View File
@@ -0,0 +1,170 @@
const vscode = require('vscode');
const fs = require('fs');
const os = require('os');
const path = require('path');
function activate(context) {
context.subscriptions.push(
vscode.commands.registerCommand('dosh.openRemote', openRemote),
vscode.commands.registerCommand('dosh.configureHost', configureHostCommand),
vscode.commands.registerCommand('dosh.showSshConfig', showSshConfig)
);
}
async function openRemote() {
const configured = await configureHost();
if (!configured) {
return;
}
const remotePath = await vscode.window.showInputBox({
title: 'Remote path',
prompt: 'Path to open on the remote host',
value: '~'
});
if (remotePath === undefined) {
return;
}
const suffix = remotePath ? `/${remotePath.replace(/^\/+/, '')}` : '';
await vscode.commands.executeCommand(
'vscode.openFolder',
vscode.Uri.parse(`vscode-remote://ssh-remote+${configured.alias}${suffix}`),
{ forceNewWindow: true }
);
}
async function configureHostCommand() {
const configured = await configureHost();
if (configured) {
vscode.window.showInformationMessage(`Dosh Remote-SSH host ready: ${configured.alias}`);
}
}
async function configureHost() {
const host = await vscode.window.showInputBox({
title: 'Dosh host',
prompt: 'Dosh host alias, e.g. palav',
ignoreFocusOut: true
});
if (!host) {
return undefined;
}
const user = await vscode.window.showInputBox({
title: 'Remote SSH user',
prompt: 'Optional. Leave blank to let SSH config decide.',
ignoreFocusOut: true
});
const config = vscode.workspace.getConfiguration('dosh');
const alias = `dosh-${safeAlias(host)}`;
const block = sshBlock({
alias,
host,
user: user || undefined,
executable: config.get('executable') || 'dosh',
targetHost: config.get('targetHost') || '127.0.0.1',
targetPort: Number(config.get('targetPort') || 22),
doshPort: Number(config.get('doshPort') || 0)
});
const sshDir = path.join(os.homedir(), '.ssh');
fs.mkdirSync(sshDir, { recursive: true });
const generatedPath = config.get('generatedSshConfig') || path.join(sshDir, 'config.dosh');
const mainConfig = path.join(sshDir, 'config');
ensureInclude(mainConfig, path.basename(generatedPath));
upsertBlock(generatedPath, alias, block);
return { alias, generatedPath };
}
function sshBlock(options) {
let proxy = shellQuote(options.executable);
if (options.doshPort > 0) {
proxy += ` --dosh-port ${options.doshPort}`;
}
proxy += ` proxy-stdio ${shellQuote(options.host)} %h %p`;
const lines = [
`# BEGIN DOSH ${options.alias}`,
`Host ${options.alias}`,
` HostName ${options.targetHost}`,
` Port ${options.targetPort}`,
` HostKeyAlias ${options.host}`,
options.user ? ` User ${options.user}` : undefined,
' ClearAllForwardings yes',
' ServerAliveInterval 15',
' ServerAliveCountMax 3',
` ProxyCommand ${proxy}`,
`# END DOSH ${options.alias}`,
''
].filter(Boolean);
return lines.join('\n');
}
function ensureInclude(configPath, includeFile) {
const raw = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '';
if (raw.split(/\r?\n/).some((line) => line.trim().toLowerCase() === `include ${includeFile}`.toLowerCase())) {
return;
}
fs.writeFileSync(configPath, `Include ${includeFile}\n${raw ? `\n${raw}` : ''}`);
}
function upsertBlock(configPath, alias, block) {
const raw = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '';
const begin = `# BEGIN DOSH ${alias}`;
const end = `# END DOSH ${alias}`;
const lines = raw.split(/\r?\n/);
const out = [];
let skipping = false;
let replaced = false;
for (const line of lines) {
if (line.trim() === begin) {
out.push(block.trimEnd());
skipping = true;
replaced = true;
continue;
}
if (skipping) {
if (line.trim() === end) {
skipping = false;
}
continue;
}
if (line.length > 0 || out.length > 0) {
out.push(line);
}
}
if (!replaced) {
if (out.length > 0 && out[out.length - 1] !== '') {
out.push('');
}
out.push(block.trimEnd());
}
fs.writeFileSync(configPath, `${out.join('\n').trimEnd()}\n`);
}
async function showSshConfig() {
const config = vscode.workspace.getConfiguration('dosh');
const sshDir = path.join(os.homedir(), '.ssh');
const generatedPath = config.get('generatedSshConfig') || path.join(sshDir, 'config.dosh');
if (!fs.existsSync(generatedPath)) {
vscode.window.showWarningMessage('No generated Dosh SSH config yet.');
return;
}
const doc = await vscode.workspace.openTextDocument(generatedPath);
await vscode.window.showTextDocument(doc);
}
function safeAlias(value) {
const alias = value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
return alias || 'host';
}
function shellQuote(value) {
if (/^[a-zA-Z0-9_./:-]+$/.test(value)) {
return value;
}
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function deactivate() {}
module.exports = {
activate,
deactivate
};
+69
View File
@@ -0,0 +1,69 @@
{
"name": "dosh-vscode",
"displayName": "Dosh Remote",
"description": "Open VS Code Remote-SSH windows through Dosh.",
"version": "0.1.0",
"publisher": "palav",
"license": "MIT",
"engines": {
"vscode": "^1.90.0"
},
"categories": [
"Other"
],
"extensionDependencies": [
"ms-vscode-remote.remote-ssh"
],
"activationEvents": [
"onCommand:dosh.openRemote",
"onCommand:dosh.configureHost",
"onCommand:dosh.showSshConfig"
],
"main": "./extension.js",
"contributes": {
"commands": [
{
"command": "dosh.openRemote",
"title": "Dosh: Open Remote Folder"
},
{
"command": "dosh.configureHost",
"title": "Dosh: Configure Remote-SSH Host"
},
{
"command": "dosh.showSshConfig",
"title": "Dosh: Show Generated SSH Config"
}
],
"configuration": {
"title": "Dosh Remote",
"properties": {
"dosh.executable": {
"type": "string",
"default": "dosh",
"description": "Path to the local dosh executable."
},
"dosh.targetHost": {
"type": "string",
"default": "127.0.0.1",
"description": "Host opened from the Dosh server side for VS Code's SSH connection."
},
"dosh.targetPort": {
"type": "number",
"default": 22,
"description": "SSH port opened from the Dosh server side."
},
"dosh.doshPort": {
"type": "number",
"default": 0,
"description": "Optional Dosh UDP port override. 0 uses Dosh config."
},
"dosh.generatedSshConfig": {
"type": "string",
"default": "",
"description": "Path for generated SSH config. Empty means ~/.ssh/config.dosh."
}
}
}
}
}