Compare commits

..

100 Commits

Author SHA1 Message Date
DuProcess 4ea05fc14f Clarify proxied SSH UDP requirements
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-11 17:44:43 -04:00
DuProcess bae0d0ed56 Harden stream queues and hostile network tests
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-11 17:33:49 -04:00
DuProcess 99e318ca6b Harden reconnect paths under UDP churn
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-11 12:34:18 -04:00
DuProcess b2be4c2cb7 Retry transport receive during UDP churn
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-11 12:15:25 -04:00
DuProcess dbf96b68d3 Keep sessions alive through UDP churn
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-11 12:08:32 -04:00
DuProcess d74457d198 Preserve OpenSSH alias config for hosts
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-11 11:47:45 -04:00
DuProcess 03f5b88889 Bump version to 1.0.0-rc19
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-11 11:34:42 -04:00
DuProcess 5a29925422 Stamp release builds from exported git metadata 2026-07-11 11:34:23 -04:00
DuProcess 630b8dab30 Bump version to 1.0.0-rc18
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-11 11:29:56 -04:00
DuProcess b87f277e2d Use native installer for Windows updates 2026-07-11 11:29:41 -04:00
DuProcess 732f53f2d0 Keep release clippy gate clean
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-10 23:11:16 -04:00
DuProcess 6aa03cca63 Make package artifact checks deterministic
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-10 23:07:50 -04:00
DuProcess 58d8e34828 Bump version to 1.0.0-rc17
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-10 22:55:45 -04:00
DuProcess 47e446b433 Let dosh update refresh server installs 2026-07-10 22:55:29 -04:00
DuProcess e11ba1a652 Bump version to 1.0.0-rc16
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-10 22:49:14 -04:00
DuProcess 07c4b321d1 Harden persistent screen mirroring
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-10 22:48:32 -04:00
DuProcess 673b8ae16e Bump version to 1.0.0-rc15
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-10 22:26:04 -04:00
DuProcess 262dfaca04 Retire terminal forwarding stream ids 2026-07-10 22:23:34 -04:00
DuProcess 5ca3e98a5c Bump version to 1.0.0-rc14
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-09 20:02:34 -04:00
DuProcess 40810e4ba2 Bound retired stream tombstones 2026-07-09 19:58:39 -04:00
DuProcess 1f01338eb8 Bump version to 1.0.0-rc13
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-09 19:52:08 -04:00
DuProcess 5c8bd68aa6 Retire closed transport stream ids 2026-07-09 19:49:56 -04:00
DuProcess 68cf411143 Bump version to 1.0.0-rc12
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-09 19:34:59 -04:00
DuProcess b84f57ee9d Speed up terminal frame gap recovery 2026-07-09 19:33:13 -04:00
DuProcess 1601d474e7 Strip split stale mouse terminators 2026-07-09 19:30:25 -04:00
DuProcess 1466516053 Harden stream cleanup state 2026-07-09 19:27:45 -04:00
DuProcess bab3f777dc Harden SDK stream ids and duplicate opens
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-09 19:19:07 -04:00
DuProcess 039dc641b3 Fix resumed copy truncation
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-09 19:10:17 -04:00
DuProcess 4c9e31fd16 Harden alternate screen tracking
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-07 20:37:16 -04:00
DuProcess c1c672b188 Bump release candidate to rc8
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-07 15:34:49 -04:00
DuProcess a23f610f20 Keep generated sessions alive across updates 2026-07-07 15:34:24 -04:00
DuProcess ff2b7fff2b Bump release candidate to rc7
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-07 14:58:13 -04:00
DuProcess f4879090f6 Keep terminal focus reports local 2026-07-07 14:57:53 -04:00
DuProcess 37ec9fc520 Bump release candidate to rc6
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-07 12:01:03 -04:00
DuProcess f3c7ec225d Repaint terminal after idle tab return 2026-07-07 12:00:40 -04:00
DuProcess d3c40a06ac Bump release candidate to rc5
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-07 11:49:41 -04:00
DuProcess 6b12b3dfe0 Harden terminal reconnect packet handling 2026-07-07 11:48:59 -04:00
DuProcess b90c34dc17 Bump release candidate to rc4
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-05 18:00:59 -04:00
DuProcess f205e0c128 Harden native auth and SDK datagram handling 2026-07-05 18:00:31 -04:00
DuProcess 92c94176bd Bump release candidate to rc3
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-05 09:39:21 -04:00
DuProcess 4b2e9a62d5 Harden embedded transport under UDP churn
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-05 09:37:44 -04:00
DuProcess 689d20f7e7 Bump release candidate to rc2
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-04 23:00:18 -04:00
DuProcess 252f081a61 Suppress stale terminal mouse reports after 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-07-04 22:56:32 -04:00
DuProcess b1e8326b0a Stop local benchmark holder leaks 2026-07-04 22:31:14 -04:00
DuProcess 3b4931aa8f Keep server alive on transient UDP send errors 2026-07-04 22:29:52 -04:00
DuProcess 5c54601cdf Prepare Dosh 1.0.0 rc1
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-04 16:58:15 -04:00
DuProcess c085137250 Add hostile network TUI soak gate
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-04 14:37:23 -04:00
DuProcess 237ad52bef Flush queued input after reconnect contact
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-03 16:35:22 -04:00
DuProcess a8ba852f16 Keep Dosh server service alive
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-03 14:51:47 -04:00
DuProcess f1a3de7730 Rate limit terminal gap resync
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-03 10:19:53 -04:00
DuProcess 80699dcf9d Stabilize terminal frame recovery
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-03 10:06:23 -04:00
DuProcess 274c0f505e Stop sandboxing remote shells
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 20:03:42 -04:00
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
55 changed files with 11894 additions and 3577 deletions
+11
View File
@@ -48,6 +48,17 @@ jobs:
if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success'
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:
+1
View File
@@ -1,3 +1,4 @@
/target/
**/*.rs.bk
.DS_Store
vscode-extension/*.vsix
Generated
+12 -1
View File
@@ -436,7 +436,7 @@ dependencies = [
[[package]]
name = "dosh"
version = "0.1.1"
version = "1.0.0-rc25"
dependencies = [
"anyhow",
"base64",
@@ -447,6 +447,7 @@ dependencies = [
"crossterm",
"dirs",
"ed25519-dalek",
"filetime",
"hkdf",
"hmac",
"libc",
@@ -577,6 +578,16 @@ dependencies = [
"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]]
name = "foldhash"
version = "0.1.5"
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "dosh"
version = "0.1.1"
version = "1.0.0-rc25"
edition = "2024"
license = "MIT"
@@ -14,6 +14,7 @@ clap = { version = "4.5", features = ["derive"] }
crossterm = "0.28"
dirs = "5.0"
ed25519-dalek = "2.1"
filetime = "0.2"
hkdf = "0.12"
hmac = "0.12"
libc = "0.2"
@@ -38,4 +39,4 @@ opt-level = 2
codegen-units = 16
incremental = true
lto = false
strip = false
strip = "debuginfo"
+45 -2
View File
@@ -1,4 +1,4 @@
.PHONY: build test fmt install package-release bench-report bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local
.PHONY: build test fmt clippy release-check 1.0-check reconnect-check hostile-network-check persistence-check package-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:
cargo build --release
@@ -9,12 +9,52 @@ test:
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
1.0-check: release-check reconnect-check hostile-network-check persistence-check package-check
reconnect-check:
cargo test --test integration_smoke resume_updates_udp_endpoint_for_roaming -- --nocapture
DOSH_SOAK_SECONDS=$${DOSH_SOAK_SECONDS:-300} cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture
hostile-network-check:
cargo test --test hostile_network -- --nocapture
DOSH_BADNET_SOAK_SECONDS=$${DOSH_BADNET_SOAK_SECONDS:-300} cargo test --test hostile_network bad_network_tui_work_soak_30m -- --ignored --nocapture
persistence-check:
cargo test --test integration_smoke session_survives_server_restart_same_shell_and_screen -- --nocapture
cargo test --test integration_smoke multiple_persistent_named_sessions_survive_restart_independently -- --nocapture
package-check:
$(MAKE) package-release-linux
$(MAKE) package-release-windows
sh scripts/verify-release-artifacts.sh \
target/dosh-release/dosh-linux-x86_64.tar.gz \
target/dosh-release/dosh-windows-x86_64.zip
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
@@ -42,6 +82,9 @@ fuzz-smoke:
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
+84 -360
View File
@@ -1,403 +1,127 @@
# dosh - Dormant Shell
# Dosh
dosh is a low-latency remote terminal designed around fast attach and fast reconnect.
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.
Dosh is an encrypted remote terminal for fast reconnecting shells.
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.
- 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.
## Support
## 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
has work dosh can avoid:
1. SSH connects to the host.
2. SSH starts `mosh-server`.
3. The client receives connection material over SSH.
4. SSH exits and the mosh UDP session begins.
dosh keeps `dosh-server` running before the client arrives. Named PTY sessions can
also be prewarmed, so attaching to `default` does not need to spawn a daemon, create
a PTY, or start a shell on the user's critical path.
This is not an encryption argument against mosh. dosh also encrypts its UDP data
channel; the speed difference comes from keeping the server and session hot.
## Fast Path Order
The client always tries the cheapest valid path first:
1. **UDP resume:** existing `ClientId` and session key. No SSH. One encrypted UDP
request, one encrypted UDP reply.
2. **UDP attach ticket:** cached server-issued attach ticket for the same
host/user/session/mode. No SSH. One encrypted UDP request, one encrypted UDP
reply.
3. **SSH bootstrap:** `ssh -T user@host ~/.local/bin/dosh-auth ...`, then one encrypted UDP
attach.
4. **New session:** same as attach, but the server must create the PTY/shell unless
the session was prewarmed.
The fastest path is not a custom SSH replacement. SSH remains the first trust root;
dosh removes SSH from repeat attaches when the server has already issued valid
credentials.
Attach tickets are implemented because they are the way a fresh client process can
skip SSH after a recent successful bootstrap.
## Connection Speed Contract
dosh is measured by terminal-ready time: elapsed time from running `dosh host` to the
first usable terminal screen.
- UDP resume: <= one measured UDP RTT + local render time.
- UDP attach ticket: <= one measured UDP RTT + local render time.
- Warm attach with ControlMaster: <= `ssh host true` over the existing master + one
measured UDP RTT.
- Cold attach without ControlMaster: <= cold `ssh host` terminal-ready time + one
measured UDP RTT.
- New session: measured separately because it may need PTY and shell creation.
Server-side client resume state is kept for a day by default, so a sleeping laptop
can resume the same encrypted client association without depending on a still-valid
attach ticket. Attach tickets remain the fast path for fresh client processes.
The client emits timing spans for credential lookup, SSH bootstrap, UDP resume,
UDP ticket attach, and terminal-ready time.
## Architecture
```text
dosh-server
UDP socket on one configurable port
session table keyed by name
one PTY per named session
optional prewarmed sessions, default ["default"]
terminal parser/screen state per session
client table per session
encrypted UDP protocol
tiny SSH-invoked dosh-auth helper mode
persistent sessions (persist_sessions, currently opt-in): each shell runs in a
detached per-session holder process; the server adopts its PTY master fd via
SCM_RIGHTS and re-adopts it after a restart, so sessions survive crash/upgrade
dosh-client
terminal raw mode
local credential cache
UDP resume/attach first
SSH bootstrap fallback
PTY input/output forwarding
reconnect and roaming state machine
```
Dosh is meant to replace Mosh and everyday interactive SSH sessions. It does
not currently include SFTP, X11 forwarding, or a Windows server.
## Install
Default UDP port: `50000`. This is intentionally inside the common forwarded range
`50000-52000/udp`.
Unix/macOS server and client:
Install on each Linux server you want to attach to:
```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
```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
```
Install the client on macOS:
Unix/macOS client only:
```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 -- client
```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
```
Update an installed client later:
```bash
dosh update
dosh update --check
dosh --version
```
`dosh update` first tries a release tarball named for the platform
(`dosh-macos-aarch64.tar.gz`, `dosh-linux-x86_64.tar.gz`, etc.) from the latest
Gitea/GitHub release. Gitea's concrete tag download route is detected when
`/releases/latest/download/...` is not supported. If that asset is not published
yet, it falls back to the source build path. If the release also publishes
`<artifact>.sha256`, the installer verifies the archive before installing it.
Install the client on Windows PowerShell:
Windows client:
```powershell
$env:DOSH_REPO="https://git.palav.dev/Palav/dosh.git"; $env:DOSH_PORT="50000"; irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
```
Attach:
## Use
```bash
dosh user@server.example.com
```sh
dosh setup HOST
dosh HOST
dosh HOST COMMAND
dosh update
dosh update --client|--server|--both
```
First-time setup for an existing SSH alias:
Useful commands:
```bash
dosh setup homelab
dosh selftest homelab
dosh homelab
```sh
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
```
`dosh setup <ssh-alias>` imports the OpenSSH alias into
`~/.config/dosh/hosts.toml`, pins the Dosh host key over SSH, runs `dosh doctor`,
and prints next commands. `dosh selftest <host>` checks local terminal overlay
safety, forwarding syntax, and the remote doctor path.
Agent forwarding is opt-in with `-A` and must also be enabled on the server.
File copy must be enabled by the server config.
Plain `dosh user@server.example.com` opens a fresh terminal session. Use named
sessions when you want to reattach to the same persistent terminal from multiple
clients:
## VS Code
```bash
dosh --session work user@server.example.com
dosh --session logs user@server.example.com
Dosh can carry VS Code Remote-SSH through its transport:
```sh
dosh vscode setup HOST
dosh vscode HOST /remote/path
```
Press `Ctrl-]` to detach the current client while leaving the server session alive.
Typing `exit` in the remote shell closes that Dosh session and returns the client.
If UDP packets stop arriving, Dosh keeps the terminal open, sends keepalive pings,
and attempts ticket-based reconnect. While the link is silent it shows a single,
non-destructive status line on the bottom screen row (`[dosh] last contact Ns ago
— reconnecting…`), drawn with save/restore cursor so it never moves the app cursor
or corrupts a full-screen TUI, and cleared the instant packets resume. (The old
in-band overlay wrote over the active line and could corrupt TUIs; this draws only
the last row.) Disable it with `disconnect_status = false` in `client.toml` or
`DOSH_DISCONNECT_STATUS=0`.
This creates a managed SSH config entry using `ProxyCommand dosh proxy-stdio`.
VS Code still uses Remote-SSH and its normal remote server; Dosh carries the
SSH byte stream.
If SSH and UDP use different public names, specify the UDP address:
## Config
```bash
dosh-client --dosh-host public.example.com --dosh-port 50000 user@host
```text
~/.config/dosh/client.toml
~/.config/dosh/hosts.toml
~/.config/dosh/server.toml
```
Forwarding can use SSH-shaped flags directly or the forward-only wrapper:
```bash
dosh -N -L 8080:127.0.0.1:80 homelab
dosh forward homelab -L 8080:127.0.0.1:80 -D 1080
```
Dosh already lets OpenSSH handle SSH aliases, users, keys, ports, known-hosts,
ProxyJump, and other SSH config during bootstrap. It also runs `ssh -G` to infer the
UDP host from an SSH alias when `dosh_host` is not configured. To make that explicit
in Dosh's host config:
```bash
dosh import-ssh homelab
dosh homelab
```
Optional command extensions are just config-side startup shortcuts; Dosh has no
compile-time dependency on the tools they run. For example, to make a separately
installed server-side `tm` dashboard easy to open:
Common client settings:
```toml
# ~/.config/dosh/client.toml
[extensions.tm]
command = "tm {args}"
description = "Open the server-side tmux dashboard"
default_session = "new"
predict = true
cache_attach_tickets = true
disconnect_status = true
```
Then `dosh homelab tm` sends `tm`, and `dosh homelab tm dosh` sends `tm 'dosh'`.
Remove that table to remove the integration. Hosts can override or opt out:
Example host entry:
```toml
# ~/.config/dosh/hosts.toml
[homelab.extensions.tm]
command = "/opt/tm/bin/tm {args}"
[other-host.extensions.tm]
disabled = true
[prod]
ssh = "prod"
ssh_config = "prod" # OpenSSH alias for IdentityFile, ProxyJump, SendEnv, etc.
dosh_host = "prod.example.com"
port = 50000
```
## Develop
`ProxyJump` and `ProxyCommand` can be used for SSH setup, but the Dosh UDP
endpoint still has to be reachable directly from the client. Set `dosh_host` to
the reachable UDP hostname or IP when it differs from the SSH alias target.
Build:
## Rust Library
```bash
cargo build
Dosh exposes a Rust transport for encrypted, reconnecting application streams.
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
```
Attach locally, using local bootstrap instead of SSH:
```bash
target/debug/dosh-client --local-auth --no-cache local
```
Benchmark local attach:
```bash
target/debug/dosh-bench --local-auth --server local --iterations 5
```
Benchmark a remote host over SSH bootstrap:
```bash
target/release/dosh-bench --server user@host --ssh-port 22 --iterations 3
```
Benchmark the ControlMaster-backed SSH bootstrap path:
```bash
target/release/dosh-bench --server user@host --controlmaster --iterations 3
```
Run the Docker OpenSSH benchmark gate used by CI. It checks cold SSH bootstrap,
ControlMaster-backed SSH bootstrap, native cold auth after one-time `dosh trust`,
and cached attach against a containerized `sshd` plus resident `dosh-server`:
```bash
make bench-docker-ssh
```
Run the same Docker comparison with Mosh installed in the benchmark container:
```bash
make bench-docker-mosh
```
That prints `ssh_true_ms`, `dosh_attach_ms`, `dosh_cold_native_ms`,
`dosh_cached_attach_ms`, and, for the Mosh target, `mosh_start_true_ms` under the
same container, key, DNS, and network path. `dosh_cached_attach_ms` is the real Dosh
fast path after the first authentication has issued an attach ticket. See
`docs/PUBLIC_READINESS.md` before using the numbers publicly; Dosh's current
strongest claim is fast attach/reconnect plus native encrypted forwarding on
Dosh-installed servers, not generic SSH compatibility.
The latest local release evidence is in
`docs/RELEASE_EVIDENCE_2026-06-20.md`.
Generate a publishable Markdown benchmark report:
```bash
make bench-report
```
Set `DOSH_BENCH_SERVER`, `DOSH_BENCH_ITERS`, `DOSH_BENCH_ARGS`, and
`DOSH_BENCH_REPORT` to target a real host and choose the output path.
Run the explicit pre-launch soak and fuzz gates:
```bash
make soak-local # 30-minute sleep/roaming gate by default
make fuzz-deep # 5 minutes per fuzz target by default
```
Both are configurable for shorter local shakedowns:
```bash
DOSH_SOAK_SECONDS=30 make soak-local
DOSH_FUZZ_SECONDS=60 make fuzz-deep
```
The CI workflow includes an optional remote benchmark job. It runs when
`DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository secrets are
configured.
Install release binaries and the user systemd service:
```bash
make install
```
Build release tarballs for upload to Gitea/GitHub releases:
```bash
make package-release
GITEA_TOKEN=... scripts/upload-gitea-release.sh v0.1.0 target/dosh-release/dosh-*
```
## Performance Rules
The stack is performance-driven, not fixed by taste. Rust is the default because the
likely bottlenecks are network RTT, SSH startup/auth, PTY/shell creation, packet
size, and terminal rendering. Change language or runtime only if measurements show
they are the bottleneck.
Hot-path rules:
- Custom UDP protocol with AEAD for v0; no QUIC handshake on attach.
- Fixed binary packet headers for terminal traffic; no JSON on the protocol path.
- Preallocated buffers; avoid per-packet heap churn.
- Single-thread event loop is preferred for the hot path.
- No PTY allocation, shell spawn, shell rc files, or MOTD on attach to an existing
session.
- Initial snapshot should be sent in the first UDP reply when it fits under the
packet budget.
## Goals
- Connection speed as specified above.
- UDP roaming and reconnect.
- Encrypted terminal data.
- Reuse SSH pubkeys for first trust establishment.
- Named persistent sessions.
- Multiple clients attached to one session.
- Optional view-only clients.
- Single server port, not one port per session.
- Static server and client binaries where practical.
## Non-Goals
- Replacing SSH as the first public-key trust mechanism.
- Multi-user access control.
- Windows server/full parity in v0; the client installer supports prebuilt Windows
client artifacts when published.
- Full mosh compatibility.
- Perfect predictive local echo in the first MVP.
## Status
Rust implementation is present in this repository. It contains `dosh-server`,
`dosh-client`, `dosh-auth`, `dosh-bench`, shared auth/crypto/protocol modules, a
resident PTY server, encrypted UDP bootstrap attach, UDP resume, sealed UDP attach
tickets, client ACKs, server retransmit bookkeeping, sliding replay protection,
server-side `vt100` screen snapshots/diffs, a hardened user systemd unit, an install
script, Docker SSH benchmark gates, CI, and protocol/integration tests.
### Native v1
Beyond the SSH-bootstrap core, native v1 (`docs/NATIVE_V1_SPEC.md`) is substantially
implemented and aims to replace the day-to-day `ssh host` workflow on Dosh-installed
servers:
- **Native UDP auth** with X25519 key exchange; transcript-bound Ed25519, ECDSA
P-256, and RSA-SHA2 user auth via ssh-agent or OpenSSH identity files;
ChaCha20-Poly1305 transport; and `authorized_keys` policy enforcement (`from=`,
`no-port-forwarding`, `permitopen=`; unsupported options fail closed).
- **Dosh host-key trust**: pinned `known_hosts`, `dosh trust [--remove|--replace]`,
TOFU only when explicitly enabled, and hard-fail on host-key mismatch.
- **TCP forwarding**: local `-L`, remote `-R` (loopback bind by default), dynamic
SOCKS5 `-D`, forward-only `-N`, and background `-f`, with per-stream flow control so
bulk transfers do not lag the terminal.
- **Agent forwarding** (opt-in, security-gated): `-A` / `forward_agent` exports your
local `SSH_AUTH_SOCK` to a per-session proxy socket on the server (private dir,
mode 0600) and tunnels each connection back to your agent over a Dosh stream. Only
active on explicit client opt-in **and** server `allow_agent_forwarding = true`; it
applies to freshly spawned shells (not an already-running attached/prewarmed
session, the same constraint ssh/mosh have).
- **Diagnostics**: `dosh doctor host` for config/auth/UDP/forwarding-policy checks.
Native auth is **opt-in alongside SSH fallback** (`auth_preference = "native,ssh"`):
the native authenticated path is tried first and falls back to SSH bootstrap
explicitly when native auth is disabled, unavailable, or rejected. It never silently
degrades to an unauthenticated mode.
Native v1 is **not externally audited yet**. Local 30-minute sleep/roaming soak,
fuzz-smoke, and Docker SSH/Mosh benchmark evidence is captured in
`docs/RELEASE_EVIDENCE_2026-06-20.md`. See `docs/THREAT_MODEL.md` for the
published threat model and accepted residual risks, `docs/PROTOCOL_VERSIONING.md`
for the v1 versioning policy, `docs/AUDIT_PACKET.md` for the external security
review handoff, and the "Native v1 verification checklist status" table in
`docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not claim generic
SSH compatibility; its defensible claim is fast encrypted native attach/reconnect
and forwarding on Dosh-installed servers with SSH bootstrap fallback.
-604
View File
@@ -1,604 +0,0 @@
# dosh - Dormant Shell Spec
**Status:** Implemented Rust build with local and Docker SSH verification
**Default language:** Rust, unless benchmarks prove the stack is the bottleneck
**Binaries:** `dosh-server`, `dosh-client`, `dosh-auth`, `dosh-bench`
**Helper mode:** `dosh-server auth` or `~/.local/bin/dosh-auth`, invoked by SSH with `-T`
**Native v1 plan:** `docs/NATIVE_V1_SPEC.md` (substantially implemented; see its v1 status block)
**Threat model:** `docs/THREAT_MODEL.md`
---
## 1. Product Shape
dosh is a fast-attach remote terminal. It borrows the useful shape of mosh - UDP
transport, roaming, and latency-tolerant terminal rendering - but optimizes a
different first-order problem: getting the user back into an already-running terminal
as quickly as possible.
The daemon is resident. Sessions are named. A session owns one PTY and one
authoritative terminal screen. Clients attach to that session over encrypted UDP.
SSH is used for first trust establishment and as fallback when cached credentials are
missing, expired, or rejected. Native Dosh auth is specified separately in
`docs/NATIVE_V1_SPEC.md` as the path toward replacing the day-to-day SSH workflow on
Dosh-installed servers.
---
## 2. Design Goals
- Connection speed first:
- UDP resume: one encrypted UDP request and one encrypted UDP reply.
- UDP attach ticket: one encrypted UDP request and one encrypted UDP reply.
- Warm SSH bootstrap: existing-ControlMaster SSH command latency plus one UDP RTT.
- Cold SSH bootstrap: cold `ssh host` terminal-ready time plus at most one UDP RTT.
- No daemon spawn, PTY spawn, shell startup, rc file execution, or MOTD on attach to
an existing session.
- Prewarm configured sessions at daemon startup, including `default` by default.
- Encrypted UDP terminal data.
- Single configurable UDP port.
- Multiple clients attached to one session.
- Optional read-only clients.
- Named persistent sessions.
- Reuse existing SSH key infrastructure.
- Instrument connection timing from the first implementation.
---
## 3. Non-Goals
- Replacing SSH as the first public-key trust mechanism.
- Multi-user authorization or ACLs.
- Windows support in v0.
- Full mosh protocol compatibility.
- Perfect local echo/prediction in the first MVP.
- QUIC in v0. QUIC can be revisited if measurements show custom UDP is not enough.
---
## 4. Connection Speed Contract
Measure terminal-ready time: elapsed time from launching `dosh ...` to first usable
terminal render.
Benchmarks must use the same host, network, key, DNS path, and SSH config.
| Path | Acceptance gate |
| --- | --- |
| UDP resume | <= one measured UDP RTT + local render time |
| UDP attach ticket | <= one measured UDP RTT + local render time |
| Warm SSH bootstrap | <= `ssh host true` over existing ControlMaster + one measured UDP RTT |
| Cold SSH bootstrap | <= cold `ssh host` terminal-ready time + one measured UDP RTT |
| New session | Report separately; PTY/shell creation is expected |
Required timing evidence:
- Client stderr spans for credential lookup, SSH bootstrap, UDP resume, UDP ticket
attach, and terminal-ready time.
- `dosh-bench` samples for SSH `true`, Dosh attach, and optional ControlMaster-backed
SSH `true`.
- `make bench-docker-ssh` gates both cold SSH bootstrap and ControlMaster-backed SSH
bootstrap against containerized OpenSSH plus resident `dosh-server`.
---
## 5. Fast Path Order
The client always tries the cheapest path that is valid for the requested
host/user/session/mode:
1. **UDP resume**
- Requires cached `ClientId`, session key, server identity, and unexpired resume
metadata.
- Sends `ResumeRequest`.
- Receives `ResumeOk` with a snapshot or diff.
2. **UDP attach ticket**
- Requires cached attach ticket scoped to server identity, SSH username, session,
mode, and expiry.
- Sends `TicketAttachRequest`.
- Receives `AttachOk` with session key, `ClientId`, and snapshot.
3. **SSH bootstrap**
- Runs `ssh -T user@host ~/.local/bin/dosh-auth ...`.
- Receives attach token, attach ticket, session key material, and server metadata.
- Sends `BootstrapAttachRequest`.
- Receives `AttachOk` with `ClientId` and snapshot.
4. **New session**
- Same as attach, but if the session does not exist and is not prewarmed, the server
creates PTY and shell before first paint.
---
## 6. Architecture
```text
dosh-server
config loader
secret manager
UDP socket on one port
session table: HashMap<SessionName, Session>
optional prewarm of configured sessions
auth helper mode for SSH bootstrap
metrics/timing logger
Session
PTY master
child process/shell
terminal parser
authoritative screen model
scrollback ring
monotonic output sequence
client table: HashMap<ClientId, ClientState>
ClientState
ClientId
UDP endpoint
mode: read-write | view-only
session key id
last acked sequence
terminal size
last seen timestamp
dosh-client
config loader
local credential cache
terminal raw mode
UDP protocol engine
SSH bootstrap runner
reconnect state machine
renderer
```
Server hot-path ownership should avoid locks on every broadcast. A single event-loop
owner per session is preferred. Cross-thread designs are allowed only if benchmarked.
---
## 7. Security Model
SSH is the first trust root and the recovery/bootstrap fallback. The v0 core relies
on SSH for first authentication. Native v1 (`docs/NATIVE_V1_SPEC.md`) adds an opt-in
native public-key login over the Dosh UDP transport that is tried before SSH, with
its assets, attackers, and residual risks documented in `docs/THREAT_MODEL.md`. SSH
remains the explicit fallback and the host-key trust bootstrap path.
The UDP channel uses AEAD. Recommended default: `ChaCha20-Poly1305` for portable
speed, with `AES-GCM` allowed when hardware acceleration is known to be available.
The negotiated algorithm is recorded in the bootstrap response.
All encrypted packets use:
- Unique nonce per `(session_key_id, direction)`.
- Monotonic packet counter.
- Associated data containing protocol version, packet type, session name hash,
client id when known, and sequence numbers.
- Replay rejection using the packet counter window.
Secrets:
- `server_secret`: generated on first server start; stored mode `0600`.
- `session_key`: random 256-bit key per client attachment, rotated on SSH bootstrap
or ticket attach.
- `attach_ticket_key`: derived from `server_secret` and rotated by server key epoch.
No terminal bytes are sent outside AEAD after the attach handshake begins.
---
## 8. SSH Bootstrap Auth
Client command:
```bash
ssh -T user@host ~/.local/bin/dosh-auth \
--protocol 1 \
--nonce <client_nonce> \
--session <name> \
--mode <read-write|view-only> \
--size <cols>x<rows> \
--client-version <version>
```
`dosh-auth` must:
- Not allocate a PTY.
- Not start a shell.
- Not run user shell rc files.
- Read server config and secret directly.
- Return one compact binary or base64url response on stdout.
- Exit immediately.
Bootstrap response fields:
- `protocol_version`
- `server_id`
- `server_key_epoch`
- `issued_at`
- `expires_at`
- `user`
- `session`
- `mode`
- `terminal_size`
- `attach_token`
- `attach_ticket`
- `attach_ticket_psk`
- `session_key`
- `session_key_id`
- `udp_host`
- `udp_port`
- `aead_algorithm`
`attach_token = HMAC-SHA256(server_secret, user || session || mode || terminal_size ||
client_nonce || issued_at || expires_at || session_key_id)`.
The token TTL defaults to 30 seconds. Attach tickets default to 1 hour and are
server-configurable.
---
## 9. Attach Tickets
Attach tickets let a new client process attach without spawning SSH again.
Ticket properties:
- Server-sealed and authenticated by `attach_ticket_key`.
- Paired with a client-held random `attach_ticket_psk` returned during SSH bootstrap.
- Scoped to server identity, SSH username, session, mode, and key epoch.
- Short-lived by default.
- Revoked implicitly when server secret/key epoch changes.
- Stored client-side with mode `0600`, along with `attach_ticket_psk`.
Ticket attach does not prove fresh possession of the SSH private key. It proves recent
possession of a server-issued credential. This is acceptable for speed, configurable,
and can be disabled with `allow_attach_tickets = false`.
Ticket attach flow:
1. Client sends `TicketAttachRequest` containing the sealed ticket, client nonce, and
requested terminal size.
2. The request body is AEAD-encrypted with a key derived from
`HKDF(attach_ticket_psk, client_nonce || "ticket-attach-request")`.
3. Server opens the sealed ticket, validates scope/expiry/key epoch, derives the same
request key, and decrypts the request.
4. Server creates a fresh session key and `ClientId`.
5. `AttachOk` is AEAD-encrypted with
`HKDF(attach_ticket_psk, client_nonce || server_nonce || "ticket-attach-ok")` and
carries the fresh session key metadata plus first snapshot.
6. Subsequent terminal packets use the fresh session key, not the ticket PSK.
---
## 10. UDP Protocol
UDP port defaults to `50000`. One socket handles all sessions and clients.
Hot-path terminal packets use a fixed binary header:
```text
magic 4 bytes "DOSH"
version 1 byte 1
type 1 byte
flags 2 bytes
conn_id 16 bytes zero before client id is assigned
seq 8 bytes sender packet sequence
ack 8 bytes latest received peer sequence
body_len 2 bytes
body body_len bytes
tag AEAD tag, length depends on algorithm
```
Packet types:
| Type | Direction | Encrypted | Purpose |
| --- | --- | --- | --- |
| `BootstrapAttachRequest` | client -> server | token-authenticated | Attach after SSH bootstrap |
| `TicketAttachRequest` | client -> server | ticket PSK | Attach with cached ticket |
| `AttachOk` | server -> client | yes | Assign client id and send first snapshot |
| `AttachReject` | server -> client | no terminal bytes | Reject and require SSH |
| `ResumeRequest` | client -> server | yes | Resume known client |
| `ResumeOk` | server -> client | yes | Endpoint updated; diff/snapshot follows |
| `Input` | client -> server | yes | PTY input bytes |
| `Resize` | client -> server | yes | Terminal size update |
| `Frame` | server -> client | yes | Screen diff or PTY byte frame |
| `Ack` | both | yes | Ack without payload |
| `Ping` / `Pong` | both | yes | Keepalive and RTT |
| `Detach` | client -> server | yes | Remove client, keep session |
MTU target:
- Default payload target: 1200 bytes.
- Larger datagrams may be enabled only after path MTU discovery.
- Snapshots larger than the target are chunked.
Reliability:
- Input packets are reliable and ordered per client.
- Output frames are sequenced; clients ack rendered sequence.
- Server retransmits unacked frames within a bounded window.
- If a client falls too far behind, server sends a fresh snapshot instead of replaying
unlimited diffs.
---
## 11. Sessions and PTYs
Named sessions:
```bash
dosh host # open a fresh generated session
dosh --session work host # attach/create named persistent session
dosh --session work --view-only host
```
Session behavior:
- One PTY per session.
- Sessions persist until killed or server exits.
- If a session has zero clients, the PTY keeps running.
- Plain client attaches use generated session names by default.
- `--session <name>` intentionally reuses or shares a persistent session.
- Configured sessions are prewarmed at daemon startup.
- If a requested session does not exist:
- `attach` creates it only when `create_on_attach = true`.
- `new` always creates it and fails if it already exists.
Resize policy:
- One PTY means one size.
- Read-write clients may resize.
- View-only clients never resize.
- Default policy: latest read-write resize wins.
---
## 12. Screen State
Server maintains the authoritative terminal model:
- Visible grid.
- Cursor position and style.
- Alternate screen.
- Text attributes and colors.
- Scrollback ring.
- Monotonic output sequence.
Initial attach:
- Server sends a snapshot in the first UDP reply if it fits the packet budget.
- If not, server sends a minimal first frame immediately and follows with chunks.
Diffs:
- Diffs are computed per client from that client's last acked rendered sequence.
- Lagging clients may receive larger diffs or a full snapshot.
- Diffs are preferred over raw PTY bytes for reconnect correctness.
Encoding:
- Hot terminal frames use fixed binary headers and compact binary payloads.
- MessagePack is allowed only for non-hot control/list/config responses.
- JSON is not used on the protocol path.
---
## 13. Multi-Client Model
Default mode is shared input. Any read-write client can write to the session PTY.
All clients see the same resulting screen.
View-only mode:
- Client suppresses local input.
- Server rejects `Input` from view-only clients even if a malformed client sends it.
- Promotion/demotion requires reconnect.
Client timeout:
- Clients are removed after `client_timeout_secs` without ack/ping.
- The default is intentionally long enough for laptop sleep/resume, currently one
day. Short timeouts make Dosh fall back to attach tickets or SSH after sleep.
- Removing a client never kills the session.
---
## 14. Local Echo
MVP local echo is conservative:
- Printable keystrokes may be rendered optimistically only when the client is in a
simple shell line-editing state.
- Server output is always authoritative.
- On mismatch, client replaces local prediction with server state.
Full mosh-style predictive display is a later feature. It must not delay the first
implementation of fast attach/resume.
---
## 15. Reconnect and Roaming
Client detects possible disconnect when no server packet arrives for
`reconnect_timeout_secs`.
Reconnect order:
1. Send encrypted `ResumeRequest` to the configured host/port.
2. If accepted, update endpoint server-side and receive diff/snapshot.
3. If rejected, try attach ticket.
4. If ticket attach is rejected, run SSH bootstrap.
The server matches resume by `ClientId` and session key id, not by source address.
Successful resume updates the client's UDP endpoint.
---
## 16. Configuration
Server config: `~/.config/dosh/server.toml`
```toml
port = 50000
bind = "0.0.0.0"
scrollback = 5000
auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600
allow_attach_tickets = true
client_timeout_secs = 86400
retransmit_window = 256
default_input_mode = "read-write"
prewarm_sessions = ["default"]
create_on_attach = true
shell = "/usr/bin/zsh"
sessions_dir = "~/.local/share/dosh/sessions"
secret_path = "~/.config/dosh/secret"
```
Client config: `~/.config/dosh/client.toml`
```toml
server = "user@example.com"
update_repo = "https://git.palav.dev/Palav/dosh.git"
update_port = 50000
ssh_auth_command = "~/.local/bin/dosh-auth"
# ssh_port = 22
dosh_port = 50000
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
```
---
## 17. Performance-First Stack
Default implementation:
```text
# server/client shared
bytes
chacha20poly1305
aes-gcm optional
hmac
hkdf
sha2
rand
serde
toml
# server
mio or tokio # benchmark; single-thread hot path either way
rustix # PTY/process/syscall wrappers where possible
vt100 # authoritative terminal parser/model
# client
mio or tokio
crossterm # raw terminal mode
vt100 optional # only if client-side model is needed for prediction
```
Rules:
- Benchmark `mio` vs single-thread `tokio` before committing to runtime.
- Avoid locks on per-packet session broadcast.
- Preallocate packet buffers.
- Avoid serde on terminal frames.
- Keep `dosh-auth` tiny and static where practical.
- Optimize startup path before throughput.
---
## 18. MVP Scope
MVP must include:
- `dosh-server` daemon.
- `dosh-auth` SSH helper mode.
- `dosh-client`.
- One UDP port.
- Prewarmed `default` session.
- SSH bootstrap attach.
- Attach-ticket UDP attach.
- Encrypted UDP channel.
- UDP resume.
- Raw terminal input/output.
- Basic resize.
- Timing instrumentation.
MVP may defer:
- Sophisticated predictive local echo.
- Per-cell minimal diffs; raw frame plus snapshot fallback is acceptable initially if
reconnect correctness is preserved.
- Multi-session management commands beyond `attach`, `new`, `list`, and `kill`.
---
## 19. Verification Checklist
A build is not done until these are demonstrated:
- Cold attach timing compared against cold `ssh host`.
- Warm attach timing compared against `ssh host true` with ControlMaster.
- UDP resume completes without spawning SSH.
- Existing session attach does not spawn PTY or shell.
- Prewarmed `default` exists before first client.
- Terminal data is encrypted on UDP.
- Replay counters reject duplicate encrypted packets.
- View-only clients cannot write to PTY.
- Multiple clients see the same screen.
- Client survives source port/IP change by resume.
- Snapshot fallback repairs a lagging client.
- `README.md` and `SPEC.md` remain consistent with implemented behavior.
---
## 20. Status
Spec complete. The Rust implementation is present in this repository.
Implemented:
- Rust workspace and binaries: `dosh-server`, `dosh-client`, `dosh-auth`.
- Server config and secret creation.
- SSH/local bootstrap response generation.
- HMAC bootstrap verification.
- ChaCha20-Poly1305 encrypted UDP packets.
- Fixed DOSH packet header.
- Resident server with prewarmed named PTY sessions.
- Authoritative server-side `vt100` terminal parser.
- Full attach/resume snapshots from terminal screen state.
- Per-client screen-state diffs for broadcast frames.
- Raw terminal client attach.
- UDP resume from cached client credentials.
- Sealed attach-ticket UDP attach after server restart or unknown-client resume.
- Client ACKs and server-side bounded pending retransmit window.
- Sliding replay window for encrypted client packet counters.
- View-only server-side input rejection.
- Basic resize handling.
- Timing output for bootstrap and terminal-ready.
- `dosh-bench` benchmark harness for attach timing, SSH key/known-host options, and
ControlMaster-backed SSH measurement.
- Hardened user systemd unit.
- Release install script.
- Docker OpenSSH benchmark gate covering cold SSH bootstrap and ControlMaster-backed
SSH bootstrap.
- GitHub Actions CI for format, tests, release build, and Docker SSH benchmark gate.
- Optional GitHub Actions remote benchmark job gated by repository secrets.
- Auth/protocol tests.
- Integration smoke tests for local attach, ticket attach after server restart, and
view-only input rejection.
- Integration tests for retransmit, resize, multi-client shared screen, and UDP
endpoint roaming.
Optional deployment evidence:
- Configure `DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository
secrets to run the same benchmark against a real remote host in addition to the
Docker OpenSSH gate.
+42
View File
@@ -0,0 +1,42 @@
use std::env;
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");
println!("cargo:rerun-if-env-changed=DOSH_BUILD_GIT_HASH");
println!("cargo:rerun-if-env-changed=DOSH_BUILD_COMMIT_DATE");
println!("cargo:rerun-if-env-changed=DOSH_BUILD_GIT_DIRTY");
let hash = env::var("DOSH_BUILD_GIT_HASH")
.ok()
.filter(|value| !value.is_empty())
.or_else(|| git(&["rev-parse", "--short=12", "HEAD"]))
.unwrap_or_else(|| "unknown".into());
let date = env::var("DOSH_BUILD_COMMIT_DATE")
.ok()
.filter(|value| !value.is_empty())
.or_else(|| git(&["show", "-s", "--format=%cs", "HEAD"]))
.unwrap_or_else(|| "unknown".into());
let dirty = match env::var("DOSH_BUILD_GIT_DIRTY").ok().as_deref() {
Some("1" | "true" | "yes" | "+dirty") => "+dirty",
Some(_) => "",
None => 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}");
}
-75
View File
@@ -1,75 +0,0 @@
# Dosh Native v1 Audit Packet
This is the handoff checklist for an external security review.
## Scope
Review the security properties of Dosh native v1:
- Native UDP authentication and key exchange
- Host key trust and known-host mismatch behavior
- Authorized key parsing and policy enforcement
- Transport encryption, replay protection, rekeying, resume, and stale packet handling
- Attach ticket sealing/opening
- TCP forwarding and agent forwarding authorization boundaries
- Parser robustness for untrusted wire/config data
Primary files:
- `src/native.rs`
- `src/protocol.rs`
- `src/auth.rs`
- `src/ssh_agent.rs`
- `src/bin/dosh-client.rs`
- `src/bin/dosh-server.rs`
- `src/config.rs`
- `tests/parser_robustness.rs`
- `tests/protocol_auth.rs`
- `tests/hostile_network.rs`
- `tests/integration_smoke.rs`
- `fuzz/fuzz_targets/*`
Primary docs:
- `docs/NATIVE_V1_SPEC.md`
- `docs/THREAT_MODEL.md`
- `docs/PROTOCOL_VERSIONING.md`
- `docs/PUBLIC_READINESS.md`
- `docs/RELEASE_EVIDENCE_2026-06-20.md`
## Required Reviewer Questions
1. Is the native handshake transcript complete enough to bind client/server keys,
requested session, mode, env, and forwarding permissions?
2. Are host-key trust transitions fail-closed, especially unknown vs mismatch vs
explicit TOFU?
3. Are authorized-key options parsed conservatively enough, and are unsupported
options rejected safely?
4. Can replayed, reordered, delayed, stale, or cross-epoch packets affect terminal
input or forwarded streams more than once?
5. Does key rotation preserve confidentiality without creating split-brain epoch
states?
6. Do attach tickets create any bearer-token replay or privilege-extension issue?
7. Can UDP endpoint migration be abused for hijack or reflection?
8. Are local/remote/dynamic TCP forwarding and agent forwarding gated correctly by
client opt-in, server config, and authorized-key options?
9. Are parser and packet-size limits sufficient against malicious inputs?
10. Are there any unsafe assumptions in process/session persistence or holder
adoption?
## Current Local Evidence
As of 2026-06-20:
- `cargo test`: `153 passed, 1 ignored`
- `make soak-local`: passed 30-minute sleep/roaming soak
- `make fuzz-smoke`: passed all configured fuzz targets for 20s each
- `make bench-docker-mosh`: passed SSH, ControlMaster, native, cached, and Mosh
comparison gates
## Explicit Non-Claims Before Audit
- No claim of generic SSH protocol replacement.
- No claim of full Mosh compatibility.
- No claim that native v1 is externally audited.
- No claim that Windows server support is production-ready.
-203
View File
@@ -1,203 +0,0 @@
# Dosh Benchmarks
This document explains how to run the Dosh benchmarks, what each metric means,
and how to read the results honestly. Dosh's headline claim is *insane speed on
repeat attach/reconnect* — these benchmarks exist to make that claim measurable
and reproducible.
> **Read first:** `docs/PUBLIC_READINESS.md`. The defensible public claim is fast
> attach/reconnect, not full Mosh feature parity, and **not** cold startup. Cite
> `dosh_cached_attach_ms` for the core speed claim; cite cold metrics only to show
> the fallback path stays competitive with ordinary SSH.
## TL;DR
```bash
make bench-local # safe, self-contained matrix on a throwaway server
make bench-local-json # same, machine-readable JSON with raw samples
make bench-docker-ssh # containerized SSH-vs-Dosh gate used by CI
make bench-docker-mosh # same, with Mosh installed for a three-way comparison
make bench-report # Markdown report wrapper around dosh-bench
```
`make bench-local` never touches a running production server: it builds release
binaries, starts its own `dosh-server` bound to `127.0.0.1` on a random free UDP
port inside a temporary `HOME`, runs the matrix, prints raw samples plus summary
statistics, and tears everything down on exit. It will refuse to bind UDP port
`50000` (the default production port).
## The benchmark binary: `dosh-bench`
`dosh-bench` spawns the real `dosh-client` once per iteration and times the whole
process from launch to terminal-ready-and-detached. That wall-clock cost is the
apples-to-apples comparison against `ssh host true`: it is the startup price each
tool pays before any useful remote work begins.
Every run prints, per metric, a summary line (count, min, median, p95, mean, max
in milliseconds) **and** a raw-samples line. Pass `--json` for one machine-readable
object per run (with the full `samples_ms` array) so published numbers can always
include raw data, as required by `docs/PUBLIC_READINESS.md`. Pass `--markdown` for a
publishable Markdown table, and `--output path/to/report.md` to write it to disk.
### Path matrix
`dosh-bench` can benchmark these Dosh attach paths. Without an explicit path flag
it keeps its legacy single-path behavior (driven by `--local-auth` / `--warm-cache`
/ `--no-cache`) so existing CI scripts keep working.
| Flag | Metric | What it measures |
| --- | --- | --- |
| `--cold-native` | `dosh_cold_native_ms` | Native cold auth: full `--auth native --no-cache` handshake to first frame and detach. The cold fallback when no cache exists. |
| `--cached-ticket` | `dosh_cached_attach_ms` | Cached attach-ticket fast path. Warms the cache once, then measures repeat attaches using cached UDP credentials/tickets. **This is the core speed claim.** |
| `--resume` | `dosh_resume_ms` | UDP resume of a session whose endpoint changes. See the caveat below — only meaningful when a live session is kept open out-of-band. |
| `--local-auth` | `dosh_local_attach_ms` | Self-contained local bootstrap; no SSH, no native handshake. Useful as a lower-bound sanity check on loopback. |
| *(default, no flag)* | `dosh_attach_ms` | Cold SSH bootstrap plus UDP attach (legacy single-path mode). |
Existing baseline/comparison metrics are unchanged:
| Metric | Meaning |
| --- | --- |
| `ssh_true_ms` | `ssh host true` with the same key/options. |
| `mosh_start_true_ms` | `mosh host -- true` bootstrap, run `true`, exit (timed inside a PTY). |
### Assertions / gates
| Flag | Gate |
| --- | --- |
| `--assert-ssh-plus-ms N` | The primary Dosh metric must be ≤ `ssh_true_ms` mean + `N` ms. |
| `--assert-mosh-minus-ms N` | The primary Dosh metric must be at least `N` ms faster than `mosh_start_true_ms`. |
| `--assert-dosh-max-ms N` | The primary Dosh metric mean must be ≤ `N` ms. |
The "primary Dosh metric" for assertions is, in priority order:
`dosh_cached_attach_ms``dosh_resume_ms``dosh_cold_native_ms`
`dosh_attach_ms``dosh_local_attach_ms`.
## What each benchmark does
### `make bench-local` (`scripts/bench-local.sh`)
Self-contained, no SSH, no Docker. It:
1. Builds release binaries (`DOSH_BENCH_PROFILE=debug` for the debug profile).
2. Picks a free UDP port (never `50000`) and a temp `HOME`.
3. Generates a throwaway client identity (`ssh-keygen`), authorizes it on the
server, and writes a throwaway server + client config (native auth,
trust-on-first-use, localhost UDP).
4. Starts `dosh-server serve` bound to `127.0.0.1` on that port.
5. Runs `dosh-bench --cold-native --cached-ticket`, then `--local-auth --no-cache`.
6. Sanity-checks that native cold auth actually trusted the host (so a silent
config-parse fallback can't make the benchmark measure the wrong path).
7. Kills the server and removes the temp `HOME` on exit.
Tunables: `scripts/bench-local.sh [ITERATIONS]` (default 20), `DOSH_BENCH_JSON=1`,
`DOSH_BENCH_PROFILE=release|debug`.
### `make bench-docker-ssh` / `make bench-docker-mosh` (`scripts/ci-docker-ssh-bench.sh`)
Builds one Ubuntu image with OpenSSH, `dosh-server`, `dosh-auth` (and Mosh for the
`-mosh` target), then runs `ssh_true_ms`, cold + ControlMaster SSH-bootstrap
`dosh_attach_ms`, one-time `dosh trust` followed by native-cold
`dosh_cold_native_ms`, `dosh_cached_attach_ms`, and optionally
`mosh_start_true_ms` against the same container, key, and loopback network path.
This is the gate CI enforces; it asserts cold SSH-bootstrap Dosh stays within
500 ms of SSH, native-cold Dosh beats SSH mean by default, and cached attach stays
under a small budget. See `README.md` "Develop" for the exact invocations.
## Methodology and caveats
- **Same machine, same network path.** `bench-local` runs everything on loopback,
so it isolates Dosh's own process/handshake/render overhead and removes network
RTT from the comparison. Real-world cached attach is approximately *network RTT
plus the local overhead these numbers measure*. Publish the machine, OS, CPU,
network path, and sample count alongside any numbers.
- **Wall-clock of the whole client process.** Each sample includes process spawn,
attach, first-frame render, and detach. That is deliberately the same thing
`ssh host true` pays, but it means a few ms is fixed process-startup cost, not
protocol cost.
- **Do not cite cold metrics as the headline.** `dosh_cold_native_ms` and
`dosh_attach_ms` still pay first-authentication cost. They exist to show the
fallback path stays competitive with ordinary SSH. Cite `dosh_cached_attach_ms`
for repeat attach/reconnect, per `docs/PUBLIC_READINESS.md`.
- **UDP resume is the roaming path, not cold reconnect.** Resume reconnects a
client that is *still attached* when its network endpoint changes (sleep/Wi-Fi
switch). The `--attach-only` benchmark model detaches after every iteration,
which removes the client on the server, so a fresh-process resume has nothing
live to resume and is not meaningful in `bench-local`. The cold fresh-process
reconnect fast path is the attach ticket (`dosh_cached_attach_ms`). Roaming
resume correctness is covered by the integration test
`resume_updates_udp_endpoint_for_roaming` in `tests/integration_smoke.rs`.
`dosh-bench --resume` remains available for scenarios that keep a live session
open out-of-band (for example remote soak tests).
- **These are not identical workloads.** SSH/Mosh/Dosh do different work at
startup. The comparison is still useful because it measures the startup tax each
tool charges before useful remote work begins.
## Sample results (loopback, self-contained)
Captured with `make bench-local` (`scripts/bench-local.sh 20`), all times in
milliseconds, 20 samples per metric.
- Machine: Intel Core i5-9500 @ 3.00 GHz, 6 cores
- OS: Ubuntu 24.04.4 LTS, Linux 6.8.0-124-generic, x86_64
- Toolchain: rustc 1.96.0, release profile
- Network path: loopback (`127.0.0.1`), throwaway server on a free UDP port
- Workload: `dosh-client --attach-only` to first frame and detach
| Metric | n | min | median | p95 | mean | max |
| --- | --- | --- | --- | --- | --- | --- |
| `dosh_cold_native_ms` | 20 | 8.32 | 8.77 | 9.34 | 8.82 | 9.52 |
| `dosh_cached_attach_ms` | 20 | 2.83 | 3.23 | 3.75 | 3.24 | 3.77 |
| `dosh_local_attach_ms` | 20 | 2.84 | 3.21 | 3.96 | 3.28 | 3.98 |
Raw samples (ms):
```
dosh_cold_native_ms:
8.82, 8.60, 8.69, 8.71, 9.33, 8.82, 8.90, 8.64, 8.39, 8.75, 8.79, 8.70, 8.64,
9.52, 8.72, 8.32, 9.06, 8.93, 8.96, 9.04
dosh_cached_attach_ms:
3.04, 3.06, 2.98, 2.91, 2.96, 2.95, 3.05, 3.66, 3.37, 2.88, 2.83, 3.16, 3.49,
3.30, 3.77, 3.32, 3.49, 3.75, 3.56, 3.33
dosh_local_attach_ms:
3.08, 3.96, 3.36, 3.30, 2.88, 2.99, 3.96, 3.98, 3.25, 2.84, 3.29, 3.59, 3.65,
3.47, 3.13, 2.92, 2.87, 2.91, 2.98, 3.18
```
Reading these numbers: cold native auth is ~9 ms because it pays the native
handshake; cached attach is ~3 ms because it reuses cached credentials and skips
the handshake entirely. On loopback there is no network RTT, so this is the local
overhead floor. Over a real link, expect cached attach ≈ this floor + one network
round trip — which is exactly the "near network RTT" target in the spec. There is
no SSH/Mosh baseline in this loopback table because those paths need an SSH server;
use `make bench-docker-ssh` / `make bench-docker-mosh` for the head-to-head.
## Sample results (Docker SSH/Mosh comparison)
Captured with `make bench-docker-mosh`, all times in milliseconds. This is the
same-container comparison gate: one generated key, one OpenSSH server, one
`dosh-server`, loopback-published TCP/UDP ports.
| Metric | n | min | median | p95 | mean | max |
| --- | --- | --- | --- | --- | --- | --- |
| `ssh_true_ms` (cold SSH baseline for native-cold gate) | 5 | 214.53 | 216.84 | 220.55 | 217.60 | 220.91 |
| `dosh_cold_native_ms` | 5 | 8.62 | 8.96 | 9.78 | 9.15 | 9.88 |
| `dosh_cached_attach_ms` | 10 | 8.41 | 8.83 | 9.52 | 8.90 | 9.53 |
| `mosh_start_true_ms` | 3 | 528.03 | 530.87 | 539.23 | 533.02 | 540.16 |
Raw samples (ms):
```
ssh_true_ms (native-cold gate):
216.84, 216.61, 214.53, 220.91, 219.09
dosh_cold_native_ms:
9.39, 8.88, 8.96, 8.62, 9.88
dosh_cached_attach_ms:
8.44, 8.41, 8.80, 8.48, 9.53, 9.51, 8.85, 8.69, 9.33, 8.96
mosh_start_true_ms:
540.16, 528.03, 530.87
```
-666
View File
@@ -1,666 +0,0 @@
# Dosh Native v1 Spec
> **v1 status (annotation, not part of the spec text below).** Native v1 is
> substantially implemented and being stabilized; it is not yet fully verified.
> Milestone progress against section 15:
>
> - Milestone 1 — host identity and trust: **done.** Host key generation, `dosh trust`,
> `known_hosts`, and mismatch hard-fail are implemented.
> - Milestone 2 — native user auth: **done.** `ClientHello`/`ServerHello`/`UserAuth`/
> `AuthOk`, ssh-agent and OpenSSH identity-file auth for Ed25519, ECDSA P-256,
> and RSA-SHA2, plus `authorized_keys` verification, exist.
> - Milestone 3 — default native auth: **done.** `auth_preference = "native,ssh"` is
> the default with explicit, visible SSH fallback. Local and Docker benchmark gates
> cover cached attach, SSH fallback, and native cold auth (Track C /
> `BENCHMARKS.md`).
> - Milestone 4 — forwarding: **implemented.** Stream mux, `-L`, `-R`, `-D`, `-N`,
> `-f`, per-stream flow control, ordered stream offsets/ACKs, and stream-data
> retransmission exist. Terminal-priority/load, replay/reorder, and dropped
> server-to-client `StreamData` recovery regressions are covered.
> - Milestone 5 — hardening: **partly done.** Per-IP token-bucket rate limiting,
> fail-closed protocol-version checks with a documented v1 policy, parser fuzz
> targets, fuzz-smoke/deep CI entry points, scripted TUI transport tests,
> forwarding load/priority/replay/loss tests, and independent persistent session
> restart tests exist. Published long-soak evidence is not yet complete. The threat
> model is published (`docs/THREAT_MODEL.md`).
> - Milestone 6 — workflow parity: **mostly done.** `dosh doctor`, host-trust
> management, and the encrypted-key prompt flow exist; cross-OS daily-driver soak is
> ongoing.
>
> The section 16 verification checklist is **not yet fully green** — see the
> item-by-item status table in `docs/PUBLIC_READINESS.md` and the residual-risk list in
> `docs/THREAT_MODEL.md`. The section 17 public-claim gate is therefore **not met**.
Native Dosh is a remote-login protocol for Dosh-installed servers. It is intended to
replace the user's day-to-day `ssh host` workflow for terminals and forwarding while
keeping SSH as a compatibility and recovery fallback.
Native Dosh is not an RFC-compatible SSH implementation. It deliberately avoids the
full SSH transport/channel protocol and implements the smaller set of behavior Dosh
needs: authenticated login, encrypted terminal transport, reconnect/roaming, and TCP
forwarding.
## 1. Product Contract
User-facing commands:
```bash
dosh host
dosh host command...
dosh -L 8080:127.0.0.1:80 host
dosh -R 9000:127.0.0.1:9000 host
dosh --session work host
dosh --view-only --session work host
dosh host tm
```
Compatibility expectations:
- Existing SSH keys and `ssh-agent` are reused.
- Server-side authorization uses `~/.ssh/authorized_keys`.
- Host trust is pinned in a Dosh known-hosts file and can be bootstrapped by SSH.
- `~/.ssh/config` host aliases, `HostName`, `User`, `Port`, `IdentityFile`,
`ProxyJump`, and `UserKnownHostsFile` are honored where practical.
- SSH bootstrap remains available with `--auth=ssh` and is used automatically when
native auth is disabled or cannot complete.
Native v1 is complete only if a normal interactive user can switch their daily
workflow from `ssh` to `dosh` without keeping SSH open in another tab for routine
tasks.
Non-goals for v1:
- RFC-compatible SSH server/client behavior.
- Arbitrary SSH subsystems.
- X11 forwarding.
- SFTP/SCP compatibility.
- Multi-user daemon mode with privileged account switching.
- Replacing OpenSSH on hosts that do not run `dosh-server`.
## 2. SSH Workflow Parity Target
Native v1 targets the SSH workflows interactive users actually use, not the entire
OpenSSH feature universe.
Must work in v1:
- Interactive shell: `dosh host`.
- Remote command: `dosh host command...`.
- Fresh terminal by default.
- Named persistent terminal: `dosh --session work host`.
- Shared/view-only session: `dosh --view-only --session work host`.
- Local forwarding: `dosh -L [bind:]listen:target:target_port host`.
- Remote forwarding: `dosh -R [bind:]listen:target:target_port host`.
- Dynamic forwarding: `dosh -D [bind:]listen host`, if stream flow control is proven;
otherwise it is the first v1.1 item and must be called out honestly.
- Multiple forwards in one connection.
- Forward-only mode: `dosh -N -L ... host`.
- Background forwarding: `dosh -f -N -L ... host`, or an equivalent supervised
user-service mode.
- SSH-agent authentication.
- Encrypted OpenSSH private-key authentication with prompt.
- `~/.ssh/config` host aliases for `Host`, `HostName`, `User`, `Port`,
`IdentityFile`, `ProxyJump`, `UserKnownHostsFile`, and `IdentitiesOnly`.
- Dosh host config overrides for user, Dosh-specific UDP host/port/auth policy.
- Clear host-key trust and mismatch errors.
- Clear auth failure errors that name the attempted key source.
- Stable reconnect after sleep, network switch, NAT rebinding, and server packet loss.
- `dosh update`.
- `dosh doctor host` for config/auth/UDP reachability diagnostics.
- `dosh sessions host` for session visibility.
- Optional command extensions such as `dosh host tm` for companion tools that are
installed separately from Dosh.
Should work in v1 if it does not compromise the transport schedule:
- Agent forwarding with an explicit opt-in flag and clear warning.
- `ProxyJump` through SSH for bootstrap/trust and through Dosh-native relay later.
- `SendEnv`/`SetEnv` equivalent for explicit environment variables.
- Clipboard integration as a separate opt-in channel.
Explicitly not v1:
- X11 forwarding.
- SFTP/SCP wire compatibility.
- Full OpenSSH config language.
- Full `sshd` replacement for arbitrary SSH clients.
- Forced-command subsystems beyond fail-closed enforcement.
## 3. Stability Contract
Native v1 must be boring under real use:
- A closed laptop must not kill the remote session.
- A client crash must not kill the remote session.
- A server restart must not kill the remote session when `persist_sessions` is on
(currently opt-in until stress-tested): each session's shell runs in a detached
per-session *holder*
process whose PTY master fd the server passes back to itself via SCM_RIGHTS, so
the shell + scrollback survive a server crash/upgrade/`systemctl restart` and a
reattaching client lands on the same shell with its screen restored. With
`persist_sessions = false` the old behavior applies: live PTYs drop on restart,
but the client must still fail clearly and reconnect cleanly afterward.
- Decrypt failures from stale packets must be ignored or trigger reconnect, never
terminate the terminal by themselves.
- Terminal cleanup must restore cursor, mouse mode, bracketed paste, alternate
screen, and raw mode on exit.
- Forwarding streams must close cleanly without leaving orphan listeners.
- Every public error must be actionable: host trust, auth failure, UDP blocked,
forwarding denied, version mismatch, or server unavailable.
- `dosh host` must never attach to another active unnamed terminal by accident.
- The default install must be secure without hand-editing configs.
- Upgrades must preserve existing trusted host keys and credentials unless explicitly
rotated.
## 4. Security Contract
Native Dosh must match the security properties users rely on from SSH for this use
case:
- Server authentication before terminal data is trusted.
- User authentication by possession of an authorized private key or agent key.
- Forward secrecy for terminal and forwarding traffic.
- AEAD encryption and authentication for every post-handshake packet.
- Replay protection for handshake and transport packets.
- Host-key pinning with explicit first-use behavior.
- No plaintext terminal bytes after handshake begins.
- No custom cryptographic primitives.
- Clear downgrade behavior: native auth failure must not silently fall back to an
unauthenticated mode.
Native Dosh does not claim SSH's full protocol security surface. It claims equivalent
security for Dosh terminal and forwarding sessions on Dosh-installed servers.
## 5. Threat Model
In scope:
- Passive network observer.
- Active network attacker that can spoof, drop, replay, reorder, or modify packets.
- NAT rebinding and client IP/port changes.
- Stolen attach-ticket cache without the user's private key.
- Server restart and key rotation.
- Malicious unauthenticated client flooding auth attempts.
- Compromised low-privilege local user trying to read Dosh caches on a shared client.
Out of scope:
- Compromised client machine.
- Compromised server account.
- Malicious kernel, terminal emulator, or PTY implementation.
- Protecting against a server that is already authorized and then becomes malicious.
## 6. Cryptographic Building Blocks
Allowed primitives:
- Handshake pattern: Noise `NK` or `XX` through a maintained Rust Noise framework, or
a small audited construction over `x25519-dalek` plus transcript binding.
- KEX: X25519.
- Signatures for user auth: Ed25519 and ECDSA P-256 via SSH-agent and OpenSSH key
formats. RSA may be accepted only for compatibility and must use SHA-2 signatures.
- AEAD: ChaCha20-Poly1305 by default; AES-GCM optional when hardware support is known.
- KDF: HKDF-SHA256.
- Hash/transcript: SHA-256.
- Randomness: OS CSPRNG only.
Disallowed:
- Homegrown ciphers, MACs, padding, or key derivation.
- Reusing a nonce/key pair.
- Unauthenticated encryption.
- MD5/SHA-1 signatures for user auth.
## 7. Identity And Trust
### Server Identity
Each `dosh-server` has a persistent host key:
```text
~/.config/dosh/host_key
~/.config/dosh/host_key.pub
```
Default host-key algorithm: Ed25519.
Client pins host keys in:
```text
~/.config/dosh/known_hosts
```
Entry format:
```text
host-pattern key-type base64-public-key first-seen=unix-seconds source=tofu|ssh|manual
```
First-use policy:
- Default for public internet: refuse unknown native host key and suggest
`dosh trust host` or SSH bootstrap.
- Default for local/private hosts may be TOFU only when `trust_on_first_use = true`.
- `dosh trust host` may verify the Dosh host key over the existing SSH bootstrap path.
Host-key mismatch:
- Hard fail.
- Print old fingerprint, new fingerprint, and known-hosts file path.
- Never auto-replace.
### User Identity
Native auth user identity is the login user resolved from:
1. Explicit CLI user: `user@host`.
2. Dosh host config.
3. `ssh -G host` `user`.
4. Local username.
Server verifies user keys against:
```text
~/.ssh/authorized_keys
~/.config/dosh/authorized_keys
```
`~/.config/dosh/authorized_keys` is optional and may restrict Dosh access without
changing SSH access.
Authorized-key options required in v1:
- `from=`
- `command=` must reject native Dosh terminal login unless explicitly supported later.
- `restrict`
- `no-port-forwarding`
- `permitopen=`
Unsupported restrictive options must fail closed.
## 8. Native Auth Handshake
Native auth runs over UDP on the same Dosh port. It establishes a short-lived
authenticated control channel and returns the same terminal attach material that SSH
bootstrap returns today.
Target path:
```text
client -> server: ClientHello
server -> client: ServerHello
client -> server: UserAuth
server -> client: AuthOk + first terminal snapshot
```
The server may combine `AuthOk` and `AttachOk` to get terminal-ready in the final
handshake flight.
### ClientHello
Fields:
- protocol version
- client random
- client ephemeral X25519 public key
- requested host alias
- requested user
- requested session
- requested mode
- terminal size
- supported AEAD algorithms
- supported user key algorithms
- optional cached host-key id
- optional attach ticket envelope
### ServerHello
Fields:
- protocol version
- server random
- server ephemeral X25519 public key
- server host public key
- server host-key signature over transcript
- chosen AEAD
- server key epoch
- auth challenge
- rate-limit metadata when applicable
The client must verify the host key before sending user authentication.
### UserAuth
Fields:
- selected public key
- key algorithm
- signature over transcript and auth challenge
- optional agent identity metadata
- optional requested forwarding declarations
The signature must bind:
- both ephemeral keys
- both randoms
- server host key
- requested user/session/mode/terminal size
- selected algorithms
- protocol version
### AuthOk
Fields:
- assigned `ClientId`
- session name
- mode
- session key id
- encrypted session key material or derived key confirmation
- attach ticket
- attach ticket PSK encrypted to the handshake key
- initial output sequence
- first snapshot
- server policy flags
`AuthOk` is AEAD-encrypted under the handshake traffic key.
## 9. Key Schedule
Handshake transcript:
```text
H = SHA256(protocol_label || ClientHello || ServerHello || UserAuth)
```
Shared secret:
```text
dh = X25519(client_ephemeral, server_ephemeral)
```
Handshake key:
```text
handshake_key = HKDF-SHA256(dh, H, "dosh/native/handshake/v1")
```
Session traffic keys:
```text
c2s_key = HKDF-SHA256(handshake_key, H, "dosh/native/c2s/v1")
s2c_key = HKDF-SHA256(handshake_key, H, "dosh/native/s2c/v1")
```
Attach ticket PSKs and rotated session keys must be derived independently from server
secret material and fresh randomness. They must not reuse handshake traffic keys.
## 10. Attach Tickets And Cache
Native attach tickets replace most cold auth after first login.
Ticket properties:
- Server-sealed AEAD blob.
- Scoped to server host key, user, session, mode, client key fingerprint, server key
epoch, and policy flags.
- Paired with a client-held random PSK.
- Default TTL: 24 hours for trusted personal machines, configurable down to zero.
- Stored mode `0600`.
- Revoked by server host-key rotation, server secret rotation, or user key removal.
Client cache path:
```text
~/.local/share/dosh/credentials/
```
Cache entries must include:
- host identity fingerprint
- user
- session
- mode
- ticket expiry
- last rendered sequence
- client id
- session key id
- attach ticket
- attach ticket PSK
## 11. Transport
Post-auth terminal traffic continues to use the current Dosh UDP packet model:
- fixed binary header
- AEAD body
- monotonic packet sequence
- ack field
- replay window
- server snapshots for recovery
Required v1 changes:
- Separate packet namespaces for terminal frames, control messages, and forwarding
streams.
- Explicit `key_epoch` or `session_key_id` in packet metadata so stale packets can be
ignored without fatal decrypt errors.
- Rekey command after configurable packet count or wall-clock interval.
- Connection migration must be accepted after any valid encrypted packet from a new
source address.
## 12. Forwarding
Native forwarding is a Dosh stream multiplexer over the encrypted transport.
CLI:
```bash
dosh -L [bind_host:]listen_port:target_host:target_port host
dosh -R [bind_host:]listen_port:target_host:target_port host
dosh -D [bind_host:]listen_port host
dosh -A host # forward the local ssh-agent (opt-in, server-gated)
```
Stream packet types:
- `StreamOpen`
- `StreamOpenOk`
- `StreamOpenReject`
- `StreamData`
- `StreamWindowAdjust`
- `StreamEof`
- `StreamClose`
Forwarding rules:
- Terminal traffic has priority over stream bulk data.
- Each stream has independent flow control.
- `StreamData` carries a per-stream byte offset and is delivered to TCP in order.
- `StreamWindowAdjust` carries a cumulative received byte offset; peers free
retransmit buffers and replenish send credit only for acknowledged contiguous
bytes.
- Unacknowledged stream bytes are retransmitted as newly encrypted transport packets
with fresh packet sequence numbers/nonces.
- Backpressure must not block PTY input or output.
- Server enforces `no-port-forwarding` and `permitopen=`.
- Remote listeners bind to loopback by default.
- Non-loopback remote bind requires explicit config.
- `-N` opens forwarding without creating a PTY.
- `-f` backgrounds only after all requested listeners are successfully active.
- Listener setup failures fail the entire command unless `--partial-forwarding` is
explicitly requested.
- Forwarding reconnect must preserve listeners and re-open streams after network
migration when protocol state allows it.
- Stream packet scheduling must enforce terminal priority; a large port-forward copy
cannot make shell keystrokes lag.
### 12.1 SSH-Agent Forwarding (opt-in, security-gated)
- Activated only on explicit client opt-in (`-A` / `forward_agent = true`) **and**
server `allow_agent_forwarding = true`. Off by default on both ends.
- The client requests a `ForwardingKind::Agent` entry during auth. If the server
policy allows it, the server binds a per-session proxy unix socket in a
process-private directory (dir 0700, socket 0600) and exports its path as the
spawned shell's `SSH_AUTH_SOCK`.
- Each connection from a remote process to that proxy socket is tunneled to the
client over a server-initiated `StreamOpen` carrying the reserved target sentinel
`@dosh-agent` (port 0). The client splices it into its local `SSH_AUTH_SOCK`
(a unix socket, not a TCP target). Data/window/close reuse the existing stream
packets unchanged.
- Only applies to a freshly spawned shell; an already-running attached/prewarmed
session keeps its existing environment (same constraint as ssh/mosh).
- SECURITY: forwarding exposes the local agent to the remote host for the session's
lifetime, which is why it is double-gated and never the default.
## 13. Diagnostics And Operations
Native v1 must include operations commands:
```bash
dosh doctor host
dosh trust host
dosh trust --remove host
dosh sessions host
dosh update
```
`dosh doctor host` checks:
- host alias resolution
- Dosh known-host state
- SSH fallback reachability
- native UDP reachability
- server version
- native auth enabled/disabled
- usable keys from ssh-agent and identity files
- server authorization result without opening a terminal
- configured forwarding policy
`dosh trust host` checks:
- fetch Dosh host key through SSH fallback when available
- show fingerprint before writing trust
- refuse overwrite on mismatch unless `--replace` is explicit
## 14. Config
Client:
```toml
auth_preference = "native,ssh"
trust_on_first_use = false
native_auth_timeout_ms = 700
known_hosts = "~/.config/dosh/known_hosts"
credential_cache = "~/.local/share/dosh/credentials"
identity_files = ["~/.ssh/id_ed25519"]
use_ssh_agent = true
forward_agent = false
send_env = ["LANG", "LC_*", "TERM", "COLORTERM"]
set_env = {}
forwardings = []
[extensions.tm]
command = "tm {args}"
description = "Open an optional server-side tmux dashboard"
```
Server:
```toml
native_auth = true
host_key = "~/.config/dosh/host_key"
authorized_keys = ["~/.ssh/authorized_keys", "~/.config/dosh/authorized_keys"]
native_auth_rate_limit_per_minute = 30
attach_ticket_ttl_secs = 86400
allow_tcp_forwarding = true
allow_remote_forwarding = false
allow_remote_non_loopback_bind = false
allow_agent_forwarding = false
accept_env = ["LANG", "LC_*", "TERM", "COLORTERM"]
```
## 15. Migration Plan
Milestone 1: host identity and trust
- Generate Dosh host key on server install.
- Add `dosh trust host`.
- Add known-hosts file and mismatch handling.
- Keep SSH bootstrap as the only auth path.
Milestone 2: native user auth
- Implement `ClientHello`/`ServerHello`/`UserAuth`/`AuthOk`.
- Support ssh-agent Ed25519 first.
- Verify against `authorized_keys`.
- Add `--auth=native|ssh|auto`.
Milestone 3: default native auth
- Make `auth_preference = "native,ssh"` default.
- Keep SSH fallback explicit and visible.
- Add benchmark gates for native cold auth.
Milestone 4: forwarding
- Add stream mux.
- Add `-L`, `-N`, and `-f`.
- Add `-R`.
- Add `-D` only after flow control is proven.
Milestone 5: hardening
- Fuzz packet parsing, authorized-key parsing, known-host parsing, and handshake state.
- Add hostile-network integration tests.
- Keep a public hardening checklist and threat model before public security claims.
Milestone 6: workflow parity
- Implement `dosh doctor`.
- Implement complete host-trust management.
- Implement private-key prompt flow.
- Implement forwarding policy diagnostics.
- Run daily-driver soak on macOS and Linux clients.
## 16. Verification
Native v1 is not complete until all are true:
- Unknown host key fails by default unless TOFU is explicitly enabled.
- Known host key mismatch hard fails.
- Native Ed25519 auth succeeds via ssh-agent.
- Native Ed25519 auth succeeds via encrypted private key prompt.
- Removed authorized key can no longer authenticate.
- Restrictive unsupported authorized-key options fail closed.
- Replayed handshake packets are rejected.
- Replayed transport packets are rejected.
- Stale encrypted packets after reconnect are ignored, not fatal.
- Client IP/port change preserves the session.
- Native cold auth benchmark beats cold `ssh host true` on the same host.
- Cached attach remains near network RTT plus local render overhead.
- `-L` forwarding works without delaying terminal input.
- `-R` forwarding enforces bind and permission policy.
- `-N -L` forward-only mode does not allocate a PTY.
- `-f -N -L` backgrounds only after listener readiness.
- Multiple forwards in one command work.
- `dosh doctor` identifies UDP-blocked, auth-denied, host-key-mismatch, and
forwarding-denied states.
- Closing the laptop for at least 30 minutes does not kill the remote session.
- Three concurrent Dosh terminals remain independent unless explicitly named.
- Large forwarded transfers do not add visible terminal input lag.
- Fuzz targets run in CI.
- Threat model is updated with any accepted residual risks.
## 17. Public Claim Gate
Dosh may claim "native SSH replacement for Dosh-installed servers" only after:
- Native auth is default on at least one real host.
- SSH fallback remains available.
- The verification checklist is green.
- The threat model is published.
- Benchmarks include raw samples for SSH cold, Dosh native cold, Dosh cached attach,
and Mosh startup.
Dosh must not claim generic SSH compatibility unless it implements the SSH protocol.
-40
View File
@@ -1,40 +0,0 @@
# Dosh Protocol Versioning
Dosh v1 uses a deliberately simple compatibility policy: **single-version,
fail-closed, explicit error**.
The wire header carries `protocol::VERSION`. The native handshake carries
`native::NATIVE_PROTOCOL_VERSION`. A peer that speaks any other version is rejected
before application data is accepted:
- foreign wire `VERSION` packets get an `AttachReject` with
`protocol version mismatch - upgrade dosh`;
- foreign native handshake `protocol_version` values get the same named upgrade
error with local/remote versions;
- there is no silent downgrade, compatibility fallback, or best-effort decoding.
## When To Bump
Bump `protocol::VERSION` when a change affects packet framing, packet kind meaning,
serialized protocol structs carried outside native handshake negotiation, or anything
an older peer could misparse.
Bump `native::NATIVE_PROTOCOL_VERSION` when a change affects native handshake
transcripts, native auth semantics, algorithm negotiation, attach tickets, or any
field that is signed or key-derived by native auth.
If both layers are affected, bump both.
## Compatibility Window
Native v1 supports exactly the current version. That keeps the implementation small
and makes security review tractable. Multi-version negotiation can be added later
only with an explicit downgrade-resistance design:
- negotiated version must be transcript-bound;
- the selected version must be visible in diagnostics;
- tests must prove an active attacker cannot force an older mutually supported
version;
- unsupported peers must still get the same named upgrade error.
Until that exists, the public policy is: upgrade both sides together.
-220
View File
@@ -1,220 +0,0 @@
# Dosh Public Readiness
Dosh's defensible public claim is fast terminal attach/reconnect plus native
encrypted forwarding on Dosh-installed servers. It should not claim generic SSH
compatibility unless it implements the SSH protocol, and public benchmark claims
must be reproducible outside the author's homelab.
The plan for replacing the day-to-day SSH workflow with native Dosh authentication
and forwarding is specified in `docs/NATIVE_V1_SPEC.md`, and the published threat
model is in `docs/THREAT_MODEL.md`. Native v1 is now substantially implemented:
native key-exchange + user auth, host-key pinning/trust, `-L`/`-R`/`-D` forwarding,
`dosh doctor`, token-bucket auth rate limiting, and fuzz-smoke CI all exist (see the
feature matrix and the verification-checklist status table below). It is **not yet
fully verified**: long sleep/roaming soak and deeper fuzzing results are still open
launch evidence. Until the verification checklist (`NATIVE_V1_SPEC.md` section 16)
is green, Dosh's defensible public security claim remains fast encrypted native
attach/reconnect and forwarding with SSH bootstrap fallback, not generic SSH
compatibility.
## Objective Benchmarks
Run the same-host SSH comparison:
```bash
make bench-docker-ssh
```
Run the Dosh, SSH, and Mosh comparison:
```bash
make bench-docker-mosh
```
The Mosh-inclusive target builds one Docker image with OpenSSH, Mosh, `dosh-server`,
and `dosh-auth`. It uses the same generated key, localhost network path, SSH server,
and container for all samples.
Reported metrics:
| Metric | Meaning |
| --- | --- |
| `ssh_true_ms` | Time to run `ssh host true` with the same key/options. |
| `dosh_attach_ms` | Time for `dosh-client --attach-only` to render the first frame and detach on the configured path. With `--no-cache`, this is cold SSH bootstrap plus UDP attach. |
| `dosh_cached_attach_ms` | Time for a warmed Dosh client to attach using cached UDP credentials/tickets, render the first frame, and detach. This is the fast-path claim and should be approximately network RTT plus local process/render overhead. |
| `mosh_start_true_ms` | Time for `mosh host -- true` to bootstrap, run `true`, and exit. |
These are not identical workloads. They are still useful because they measure the
startup path each tool must pay before useful remote work begins. Public numbers
must include the command, machine, OS, CPU, network path, and sample count.
Do not cite cold `dosh_attach_ms` as the core speed claim. Cold Dosh still pays SSH
startup/authentication. Cite `dosh_cached_attach_ms` for repeat attach/reconnect
speed, and cite cold `dosh_attach_ms` only to show that fallback remains competitive
with ordinary SSH.
## Feature Matrix
| Feature | Mosh | Dosh now | Public status |
| --- | --- | --- | --- |
| SSH-based first authentication | yes | yes | ready |
| Native UDP key auth (no SSH per attach) | no | yes, Ed25519, ECDSA P-256, and RSA-SHA2 via ssh-agent or OpenSSH identity files | implemented; pending full verification |
| Dosh host-key pinning and trust | no | yes, `known_hosts` + `dosh trust` + mismatch hard-fail | implemented |
| `authorized_keys` policy enforcement | no | yes, `from=`/`no-port-forwarding`/`permitopen=`, unsupported fail closed | implemented |
| `dosh doctor` diagnostics | no | yes, config/auth/UDP/forwarding-policy check | implemented |
| Encrypted UDP terminal data | yes | yes | ready |
| Roaming by client address change | yes | yes | implemented; hostile-network covered, 30-minute soak gate available |
| Survive sleep or network loss | yes | yes | implemented; run `make soak-local` before public launch |
| Fast repeat attach without SSH | no | yes, via attach tickets | core differentiator |
| Resident server daemon | no | yes | core differentiator |
| One UDP port for all sessions | port range by default | yes | ready |
| Fresh session by default | yes | yes | ready |
| Named persistent sessions | no built-in shared session model | yes, plus opt-in server-restart holders | implemented; holder mode needs stress-testing |
| Multiple clients on one session | no | yes | implemented; needs conflict-policy docs |
| View-only clients | no | yes | ready |
| Full-screen TUI correctness | yes | yes, scripted transport coverage for control sequences | implemented; needs broader app matrix |
| Predictive local echo | mature | guarded printable-only opt-in | not parity |
| Non-destructive disconnect UI | yes | yes, bottom-row save/restore status line | implemented |
| Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
| X11 forwarding | no | no | non-goal unless tunneled separately |
| SSH agent forwarding | no | yes, explicit `-A` / `forward_agent` plus server allow-list | implemented; opt-in only |
| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; load/priority, replay/reorder, and stream retransmit tested |
| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; policy and stream mux covered |
| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented over the same reliable stream mux |
| Forward-only / background forwarding, `-N` / `-f` | no | yes, `-f` requires `-N` | implemented |
| Per-stream flow control / terminal priority | no | yes, windowed credit per stream | implemented; covered by blocked-stream and local-forward load tests |
## SSH Config Inheritance
Dosh intentionally delegates SSH parsing and authentication to OpenSSH. Bootstrap
uses the user's normal `ssh` command, so `~/.ssh/config` options such as `Host`,
`HostName`, `User`, `Port`, `IdentityFile`, `ProxyJump`, and `UserKnownHostsFile`
continue to apply.
Dosh also calls `ssh -G <alias>` to infer the UDP target host when no `dosh_host` is
configured. To write explicit Dosh host entries from SSH aliases:
```bash
dosh import-ssh homelab
```
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
OpenSSH config parser.
## Optional Command Extensions
Dosh can expose companion tools without taking a dependency on them. Command
extensions live in client or host config and expand only the first trailing word:
```toml
[extensions.tm]
command = "tm {args}"
description = "Open the server-side tmux dashboard"
```
With that config, `dosh homelab tm` runs `tm` in the remote Dosh shell and
`dosh homelab tm dosh` runs `tm 'dosh'`. Removing the table removes the integration.
Host config can override a global extension, or disable it:
```toml
[homelab.extensions.tm]
command = "/opt/tm/bin/tm {args}"
[other-host.extensions.tm]
disabled = true
```
## Forwarding (Implemented)
SSH forwarding cannot be copied by keeping the bootstrap SSH connection open,
because that would remove Dosh's fast reconnect advantage and would break after
roaming. Dosh forwarding therefore runs as native encrypted stream channels over the
Dosh transport, and is now implemented.
| CLI | Meaning |
| --- | --- |
| `dosh -L 8080:127.0.0.1:80 host` | Local listener on the client; server connects to target. |
| `dosh -R 8080:127.0.0.1:80 host` | Remote listener on the server (loopback by default); client connects to target. |
| `dosh -D 1080 host` | SOCKS5 listener on the client; server connects to whatever the SOCKS request names. |
Implemented:
- `StreamOpen`/`StreamOpenOk`/`StreamOpenReject`/`StreamData`/`StreamWindowAdjust`/
`StreamEof`/`StreamClose` packet types.
- Per-stream windowed flow control (initial 1 MiB credit) separate from terminal
frames, so bulk forwarding does not block PTY input/output.
- Ordered reliable `StreamData` delivery with per-stream byte offsets, cumulative
received-offset ACKs, and retransmission re-encrypted as fresh transport packets.
- Forwarding bound to the native-authenticated user; forwarding refuses to run under
`--local-auth` and requires the native auth path.
- Server-side policy enforcement: `allow_tcp_forwarding`, `allow_remote_forwarding`,
loopback-only remote bind unless `allow_remote_non_loopback_bind`, plus per-key
`no-port-forwarding` and `permitopen=`.
- `-N` forward-only (no PTY) and `-f` background (requires `-N`, backgrounds only
after listeners are ready).
Still open before claiming forwarding parity:
- More real-host load soak. The integration suite already covers terminal-priority
behavior while a local forward is under blocked-stream pressure, plus hostile
replay/reorder and server-to-client stream retransmission after UDP loss.
## Native v1 Verification Checklist Status
This maps each item in `NATIVE_V1_SPEC.md` section 16 to its status based on what the
code in `src/` actually does. Done = implemented and exercised by tests or obvious
from code; in progress = partially implemented; pending = not yet implemented.
| Spec section 16 item | Status | Evidence / note |
| --- | --- | --- |
| Unknown host key fails unless TOFU explicitly enabled | done | Client refuses `KnownHostStatus::Unknown` unless `trust_on_first_use`. |
| Known host-key mismatch hard fails | done | `KnownHostStatus::Mismatch` aborts; `trust_host` refuses overwrite without `--replace`. |
| Native Ed25519 auth via ssh-agent | done | `src/ssh_agent.rs` signs the user-auth transcript. |
| Native Ed25519 auth via encrypted key prompt | done | `load_ed25519_identity_with_passphrase` decrypts OpenSSH keys. |
| Native ECDSA P-256 auth | done | ssh-agent and OpenSSH identity-file paths are wired; `native_user_auth_accepts_ecdsa_p256_private_key` verifies the direct key path. |
| Native RSA-SHA2 auth | done | ssh-agent requests `rsa-sha2-512`; direct OpenSSH RSA identities sign with `rsa-sha2-512`; legacy SHA-1 `ssh-rsa` signatures are rejected. |
| Removed authorized key can no longer authenticate | done | Covered by `native_user_auth_accepts_authorized_key_and_rejects_removed_key`. |
| Unsupported authorized-key options fail closed | done | `ensure_native_allowed` bails on any unsupported option. |
| Replayed handshake packets rejected | done | Handshake is transcript-bound and signature-verified; pending entries TTL-evicted. |
| Replayed transport packets rejected | done | `ReplayWindow` (128-wide) over per-direction counter. |
| Stale encrypted packets after reconnect ignored, not fatal | done | `session_key_id` mismatch drops the packet instead of erroring fatally. |
| Client IP/port change preserves session | done | Server matches by `ClientId`/session key id, updates endpoint. |
| Native cold auth beats cold `ssh host true` | done | Docker gate runs `dosh-bench --cold-native` after one-time `dosh trust` and requires Dosh mean <= SSH mean. |
| Cached attach near network RTT | done | Local loopback samples are ~3 ms; Docker cached gate is under 25 ms. See `docs/BENCHMARKS.md`. |
| `-L` works without delaying terminal input | done | Per-stream windowing plus `native_local_forward_bulk_load_does_not_delay_interactive_terminal` and blocked-stream priority regression. |
| `-R` enforces bind and permission policy | done | `remote_bind_allowed` + `start_remote_forwards` policy checks. |
| `-N -L` does not allocate a PTY | done | `forward-only` mode skips PTY allocation. |
| `-f -N -L` backgrounds only after listener readiness | done | `spawn_background_forwarder` waits for a readiness token. |
| Multiple forwards in one command | done | Forward lists parsed and started together. |
| `dosh doctor` identifies UDP-blocked/auth-denied/mismatch/forwarding-denied | done | `run_doctor_command` reports each state. |
| Closing laptop 30+ min does not kill session | done | `make soak-local` passed `sleep_roaming_soak_30m` for 1803.54s locally. Keep rerunning before tagged releases. |
| Three concurrent terminals independent unless named | done | Generated session names per attach; named sessions shared on purpose. |
| Large forwarded transfers add no visible input lag | done | Covered by the local-forward bulk-load integration test; still needs real-host soak before launch claims. |
| Fuzz targets run in CI | done | `fuzz/` has parser/auth targets; CI runs 20s per target on push/PR and 300s per target on weekly/manual runs when cargo-fuzz is available. |
| Threat model updated with accepted residual risks | done | `docs/THREAT_MODEL.md`. |
Additional security hardening tracked outside the section 16 list:
- Full per-IP token-bucket rate limiting: implemented for native auth and covered by
unit/integration tests. It still needs real-host tuning under abusive traffic.
- Protocol VERSION handling: v1 policy is single-version, fail-closed reject at the
packet/header and native-handshake layers. See `docs/PROTOCOL_VERSIONING.md`.
- ECDSA P-256 / SHA-2 RSA user keys: implemented for ssh-agent and OpenSSH
identity-file native auth. RSA is compatibility-only and uses SHA-2 signatures;
legacy SHA-1 `ssh-rsa` signatures are not accepted.
## Before Public Launch
- Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green.
- Run `make soak-local` before each tagged release to refresh 30-minute sleep/roaming evidence.
- Run `make fuzz-deep` before launch, or use the scheduled/manual CI fuzz pass, and
publish the target durations.
- Keep scripted TUI tests green and add a broader app matrix for real alternate-screen
tools.
- Publish benchmark output with raw samples, not just averages.
- Mark prediction as experimental until it has a real framebuffer model.
- Stress-test `persist_sessions = true` before making restart-survivable holders the
default.
- Tune the native auth token bucket under abusive real-host traffic.
- Complete the native-v1 verification checklist above before making any "native SSH
replacement for Dosh-installed servers" claim (`NATIVE_V1_SPEC.md` section 17).
-127
View File
@@ -1,127 +0,0 @@
# Dosh Release Evidence - 2026-06-20
Runtime code benchmarked: `b44ff8e Improve release and benchmark tooling`
Release docs/default cleanup after that benchmark removed personal host aliases from
public examples and test fixtures; it did not change release runtime paths.
Environment:
- Host: local homelab runner
- Benchmark harness: `make bench-docker-mosh`
- Container SSH server: OpenSSH 9.6p1 on Ubuntu
- Same container, key, DNS, loopback, and Dosh server path for all Docker samples
## Release Artifacts
Generated locally with:
```bash
sh scripts/package-release.sh
```
Artifacts:
| artifact | sha256 |
| --- | --- |
| `dosh-linux-x86_64.tar.gz` | `9f586852a506cca3caf618743e12967265879ee10f5294d179cdcd668db1e808` |
| `dosh-0.1.0-linux-x86_64.tar.gz` | `9f586852a506cca3caf618743e12967265879ee10f5294d179cdcd668db1e808` |
| `dosh-macos-aarch64.tar.gz` | `dd1151aa2b40be37288dc19a07521b0ec72d1106439eb484cdff01cca3b891c6` |
| `dosh-0.1.0-macos-aarch64.tar.gz` | `dd1151aa2b40be37288dc19a07521b0ec72d1106439eb484cdff01cca3b891c6` |
The installer verifies `<artifact>.sha256` when the sidecar is published.
## Docker SSH/Mosh Benchmark
Command:
```bash
make bench-docker-mosh
```
### Cold SSH Bootstrap
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 3 | 216.58 | 220.27 | 224.64 | 220.66 | 225.12 |
| `dosh_attach_ms` | 3 | 232.37 | 235.56 | 239.13 | 235.82 | 239.53 |
Raw samples:
- `ssh_true_ms`: [216.58, 220.27, 225.12]
- `dosh_attach_ms`: [235.56, 232.37, 239.53]
Gate: `dosh_attach_ms avg 235.82ms <= ssh avg 220.66ms + 500.00ms`
### ControlMaster SSH Bootstrap
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 3 | 7.29 | 7.50 | 47.51 | 22.25 | 51.95 |
| `dosh_attach_ms` | 3 | 16.62 | 17.01 | 17.77 | 17.16 | 17.85 |
Raw samples:
- `ssh_true_ms`: [51.95, 7.29, 7.50]
- `dosh_attach_ms`: [17.01, 16.62, 17.85]
Gate: `dosh_attach_ms avg 17.16ms <= ssh avg 22.25ms + 500.00ms`
### Native Cold Auth
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 5 | 215.72 | 222.68 | 227.05 | 221.32 | 227.82 |
| `dosh_cold_native_ms` | 5 | 8.63 | 8.84 | 9.16 | 8.88 | 9.23 |
Raw samples:
- `ssh_true_ms`: [216.40, 223.99, 227.82, 222.68, 215.72]
- `dosh_cold_native_ms`: [8.84, 8.63, 9.23, 8.88, 8.84]
Gate: `dosh_cold_native_ms avg 8.88ms <= ssh avg 221.32ms + 0.00ms`
### Cached Attach
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `dosh_cached_attach_ms` | 10 | 8.07 | 8.96 | 9.83 | 8.98 | 9.84 |
Raw samples:
- `dosh_cached_attach_ms`: [8.07, 9.09, 9.02, 8.66, 8.71, 8.90, 8.45, 9.84, 9.25, 9.82]
Gate: `dosh_cached_attach_ms avg 8.98ms <= 25.00ms`
### Mosh Comparison
| metric | n | min ms | median ms | p95 ms | mean ms | max ms |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `ssh_true_ms` | 3 | 216.48 | 218.93 | 219.49 | 218.32 | 219.55 |
| `dosh_attach_ms` | 3 | 229.59 | 233.00 | 233.31 | 231.98 | 233.35 |
| `mosh_start_true_ms` | 3 | 527.13 | 563.34 | 573.09 | 554.89 | 574.18 |
Raw samples:
- `ssh_true_ms`: [219.55, 216.48, 218.93]
- `dosh_attach_ms`: [233.00, 229.59, 233.35]
- `mosh_start_true_ms`: [563.34, 527.13, 574.18]
## Soak And Fuzz Evidence
- `cargo test`: `153 passed, 1 ignored`
- `make soak-local`: `sleep_roaming_soak_30m ... ok`, finished in `1803.54s`
- `make fuzz-smoke`: passed all configured targets for 20s each:
`packet_decode`, `from_body`, `authorized_keys`, `known_hosts`,
`handshake_structs`, `attach_ticket`
## Public Claim Boundary
This evidence supports:
- Dosh cached attach and native auth are much faster than cold SSH/Mosh startup in
this benchmark environment.
- Dosh remains mosh-shaped, not mosh-compatible.
- Dosh does not claim generic SSH replacement compatibility.
- Native v1 security still needs external review before making hard public security
claims.
-241
View File
@@ -1,241 +0,0 @@
# Dosh Threat Model
This is the published threat model for Dosh native v1, derived from
`docs/NATIVE_V1_SPEC.md` sections 4-6. It states the assets Dosh protects, the
attackers it does and does not defend against, the security properties Dosh claims
relative to SSH, the cryptographic building blocks actually in use, and an honest
list of accepted residual risks and known gaps.
Dosh is a remote-login transport for Dosh-installed servers. It is intended to
replace the day-to-day `ssh host` workflow for terminals and TCP forwarding while
keeping OpenSSH as a recovery and bootstrap fallback. Dosh is **not** an
RFC-compatible SSH implementation and does not claim SSH's entire protocol security
surface. It claims security that is **equivalent to, and in some respects stronger
than, SSH for the Dosh terminal and forwarding use case** on hosts running
`dosh-server`.
## 1. Assets
Dosh protects:
- **Terminal session contents.** Keystrokes, command output, and the authoritative
screen state for every named or generated session.
- **Forwarded TCP streams.** Bytes carried over `-L`, `-R`, and `-D` channels.
- **User authentication credentials.** The user's SSH/Dosh private keys and any
ssh-agent identities. Dosh never sees private key material in plaintext on the
wire; signatures are produced locally or by the agent.
- **Server-issued credentials.** Session keys, `ClientId` association state,
server-sealed attach tickets, and the client-held attach-ticket PSK.
- **Server identity.** The persistent Dosh host key (`~/.config/dosh/host_key`) and
the server secret used to seal attach tickets and derive bootstrap material.
- **Host-trust state.** The client's pinned known-hosts file
(`~/.config/dosh/known_hosts`).
- **Authorization policy.** `~/.ssh/authorized_keys` /
`~/.config/dosh/authorized_keys` and the forwarding policy they encode.
## 2. Attackers
### In scope (Dosh must defend against these)
- **Passive network observer.** May record all UDP traffic between client and
server.
- **Active network attacker.** May spoof, drop, replay, reorder, or modify any
packet, and may attempt to inject forged packets in either direction.
- **NAT rebinding / roaming.** Client source IP and port may change mid-session,
including across sleep, network switch, and NAT timeout.
- **Stolen attach-ticket cache without the user's private key.** An attacker who
reads a client cache copies a server-sealed ticket plus its PSK but does not have
the user's SSH private key.
- **Server restart and key rotation.** Stale session keys, tickets, and replay
state must not be usable after the server rotates its secret or host key.
- **Malicious unauthenticated client flooding auth attempts.** A peer that has no
authorized key tries to exhaust server resources or guess credentials.
- **Compromised low-privilege local user on a shared client.** A different local
user attempts to read Dosh credential caches on the same machine.
### Out of scope (explicitly not defended against)
- **Compromised client machine.** If the endpoint running `dosh-client` is owned by
the attacker, the attacker has the user's keys and terminal.
- **Compromised server account.** If the login account on the server is owned, the
attacker already has the shell Dosh would have given them.
- **Malicious kernel, terminal emulator, or PTY implementation** on either side.
- **A server that was legitimately authorized and later turns malicious.** Host-key
pinning detects a *substituted* server, not a trusted server that decides to
misbehave.
These exclusions match SSH's own boundaries: SSH likewise cannot protect a
compromised endpoint or a malicious authorized peer.
## 3. Security Properties Claimed vs SSH
| Property | SSH | Dosh native v1 | Notes |
| --- | --- | --- | --- |
| Server authentication before trusting session data | yes | yes | Host key signs the handshake transcript; client verifies before sending user auth or accepting terminal bytes. |
| User authentication by private-key possession | yes | yes | Ed25519, ECDSA P-256, and RSA-SHA2 via ssh-agent or OpenSSH key; signature binds the full transcript. |
| Forward secrecy | yes | yes | Ephemeral X25519 per connection; long-term host/user keys never derive the traffic key. |
| AEAD on every post-handshake packet | yes | yes | ChaCha20-Poly1305 with per-direction, per-sequence nonces. |
| Replay protection | yes | yes | Sliding replay window over the AEAD packet counter, plus transcript-bound handshake. |
| Host-key pinning with explicit first use | TOFU, weakly tied to transport | yes, with explicit policy | Default refuses unknown host keys; TOFU only when `trust_on_first_use` is set; mismatch hard-fails and never auto-replaces. |
| No plaintext terminal bytes after handshake | yes | yes | All `Frame`/`Input`/stream packets are AEAD-sealed. |
| No custom cryptographic primitives | yes | yes | Standard X25519/HKDF-SHA256/ChaCha20-Poly1305/signature crates only. |
| Fail-closed downgrade behavior | yes | yes | Native auth failure surfaces an explicit error and SSH fallback is explicit; it never silently drops to an unauthenticated mode. |
| Fast resumption without re-auth | ControlMaster only | yes, native | Cached session/ticket attach skips a fresh round of public-key proof; this is a deliberate speed/security trade discussed in section 6. |
### Where Dosh aims to *exceed* SSH for this use case
- **Tighter transcript binding.** A user-auth signature binds both ephemeral keys,
both randoms, the server host key, requested user/session/mode/terminal size,
selected algorithms, and protocol version into one transcript. This forecloses
cross-protocol and partial-replay confusion classes for the narrow Dosh surface.
- **Smaller attack surface.** Dosh deliberately omits the full SSH transport/channel
machinery, arbitrary subsystems, X11, SFTP/SCP, and forced-command subsystems.
Fewer features means fewer parsers and fewer reachable states.
- **Explicit, file-pinned host trust.** Host trust is a first-class, inspectable
known-hosts entry with source provenance (`tofu`/`ssh`/`manual`) and a hard-fail
mismatch path, rather than the looser default TOFU behavior most SSH clients ship.
- **Modern primitives only.** Ed25519 and X25519 by default; ECDSA P-256 and
RSA-SHA2 are accepted for SSH-key compatibility; no DSA, no SHA-1 signatures, no
CBC-and-MAC constructions.
Dosh does **not** claim generic SSH compatibility and must not be described as an
SSH-protocol implementation.
## 4. Cryptographic Building Blocks (as implemented)
These reflect the code in `src/crypto.rs`, `src/native.rs`, `src/auth.rs`, and
`src/protocol.rs`, not just the spec.
- **Key exchange:** X25519 ephemeral-ephemeral (`x25519-dalek`). The shared secret
is checked for contributory behaviour; a non-contributory result is rejected.
- **Handshake/transport KDF:** HKDF-SHA256 (`hkdf`), salted with the SHA-256 of the
serialized `ClientHello` and `ServerHello`, binding traffic keys to the transcript.
- **AEAD:** ChaCha20-Poly1305 (`chacha20poly1305`) for every encrypted packet and
for sealed attach tickets. Nonces are derived as `direction || sequence`, giving a
unique nonce per `(key, direction, sequence)`. AES-GCM is reserved for later and is
not selectable today.
- **Host-key signatures:** Ed25519 (`ed25519-dalek`) over the handshake transcript.
- **User-auth signatures:** Ed25519, ECDSA P-256, and RSA-SHA2, produced either by
ssh-agent over a Unix socket (`src/ssh_agent.rs`) or from an encrypted/plaintext
OpenSSH private key (`ssh-key`). The signature covers the user-auth transcript
described above. RSA public keys are matched as `ssh-rsa` authorized keys, but
native signatures must be `rsa-sha2-256` or `rsa-sha2-512`; legacy SHA-1
`ssh-rsa` signatures are rejected.
- **Bootstrap auth (SSH fallback path):** HMAC-SHA256 attach tokens and HKDF-SHA256
derived session keys, with attach tickets sealed under an HKDF-derived
ticket key. Token comparison is constant-time.
- **Hashing/transcript:** SHA-256 (`sha2`).
- **Randomness:** OS CSPRNG via `rand::thread_rng()` / `OsRng`.
No homegrown ciphers, MACs, padding schemes, or key derivation are used. No nonce or
key pair is reused within a direction.
## 5. How the Properties Are Enforced (handshake and transport)
- **Server authentication.** `ServerHello` carries the host public key and an Ed25519
signature over `dosh/native/server-hello/v1 || ClientHello || ServerHello(unsigned)`.
The client verifies this signature *and* checks the host key against its pinned
known-hosts entry before sending user auth or accepting terminal bytes. Unknown
keys are refused unless TOFU is enabled; mismatches hard-fail with both
fingerprints and the file path.
- **User authentication.** `UserAuth` carries an Ed25519 signature over
`dosh/native/user-auth/v1 || ClientHello || ServerHello || UserAuth(unsigned)`. The
server looks the public key up in `authorized_keys`, enforces authorized-key
options, then verifies the signature. A removed key can no longer authenticate.
- **Authorization options.** `from=` (with CIDR, glob, and negation),
`no-port-forwarding`, and `permitopen=` are enforced. `command=` is rejected for
native terminal login. Any unrecognized restrictive option fails closed rather than
being silently ignored.
- **Forwarding policy.** The server enforces `allow_tcp_forwarding`,
`allow_remote_forwarding`, and a loopback-only default for remote binds
(`allow_remote_non_loopback_bind`), in addition to per-key `permitopen=` /
`no-port-forwarding`.
- **Replay protection.** A 128-wide sliding window over the per-direction packet
counter rejects duplicates and stale sequences. Sequence 0 is never accepted.
- **Stale-key tolerance.** Each encrypted packet carries a `session_key_id`; packets
under an old key are dropped as "stale or wrong session key id" rather than treated
as fatal decrypt failures, so reconnect after rotation is non-destructive.
- **Roaming.** The server keys clients by `ClientId` and session key id, not by
source address, and updates the endpoint after any valid encrypted packet from a
new address — without weakening authentication, because the packet must still
decrypt and verify.
## 6. Accepted Residual Risks and Known Gaps
These are stated openly so the public claim gate (`NATIVE_V1_SPEC.md` section 17) can
be evaluated honestly. Items here are *not* yet "green".
### Accepted residual risks (by design)
- **Attach tickets prove recent server-issued possession, not fresh private-key
possession.** A stolen client cache containing both the sealed ticket and its PSK
can attach until the ticket expires (default TTL 24h, configurable down to zero) or
until the server rotates its secret/host key or the user key is removed. This is the
deliberate speed trade. It is bounded by TTL, scoped to host/user/session/mode, and
can be disabled. SSH ControlMaster has an analogous live-socket exposure.
- **Local cache confidentiality relies on filesystem permissions.** Host keys,
server secret, known-hosts, and credential caches are written `0600`. A
same-machine attacker who can already read another user's `0600` files (e.g. via
root) is out of scope, as with SSH's `~/.ssh`.
- **A trusted server that turns malicious is not detected.** Host-key pinning
detects substitution, not betrayal by an already-authorized server. This matches
SSH.
### Known gaps / work in progress (must close before the public claim)
- **Native-auth rate limiting needs tuning, not first implementation.** The server
enforces a per-source token bucket before expensive native-auth work, evicts
half-finished handshakes on a TTL, and reports remaining capacity in
`ServerHello`. It is covered by unit/integration tests, but still needs abusive
real-host tuning before public hardening claims.
- **Protocol VERSION compatibility is intentionally single-version for v1.** The
packet header rejects any non-matching `VERSION` byte and the native handshake
rejects any non-matching `protocol_version`; peers get a named upgrade error, not
a silent downgrade. `docs/PROTOCOL_VERSIONING.md` defines the bump rules and the
post-v1 requirements for any future multi-version negotiation.
- **Deep fuzzing still needs launch evidence.** CI runs parser/auth fuzz targets for
20 seconds per target on push/PR and 300 seconds per target on weekly/manual runs
when nightly/cargo-fuzz is available. That catches obvious panics and parser
robustness regressions; public security claims should cite a completed
`make fuzz-deep` or scheduled CI run with durations.
- **User-key algorithm coverage now matches the v1 target.** Ed25519, ECDSA P-256,
and compatibility RSA-SHA2 native auth are implemented for ssh-agent and OpenSSH
identity files. RSA remains compatibility-only and deliberately rejects legacy
SHA-1 `ssh-rsa` signatures.
- **Forwarded stream data uses ordered retransmission.** Streams carry byte offsets,
cumulative received-offset ACKs, and retransmit unacknowledged chunks as fresh
encrypted transport packets. Hostile-network tests cover replay/reorder and
server-to-client stream recovery after deliberate UDP loss; broader real-host load
soak remains launch evidence.
- **Long-soak evidence is a launch gate.** Roaming, retransmit, resize, and
multi-client tests exist, and `sleep_roaming_soak_30m` / `make soak-local` provide
the 30-minute sleep/roaming gate. That gate should be run and published before
public security/reliability claims.
- **No third-party audit claim.** Dosh maintains a public threat model and hardening
checklist, but should not market itself as externally audited unless that actually
happens.
### Auth posture
Native auth is **opt-in alongside SSH fallback**. `auth_preference` defaults to
`native,ssh`: Dosh tries the native authenticated path first and falls back to SSH
bootstrap explicitly and visibly when native auth is disabled, unavailable, or
rejected. Native auth failure never silently degrades to an unauthenticated mode.
Forwarding (`-L`/`-R`/`-D`) requires the native authenticated path and refuses to run
under `--local-auth`.
## 7. Verification and Public-Claim Status
Dosh may claim "native SSH replacement for Dosh-installed servers" only after the
conditions in `NATIVE_V1_SPEC.md` section 17 are met: native auth default on a real
host, SSH fallback available, the section 16 checklist green, this threat model
published, and benchmarks with raw samples for SSH cold, Dosh native cold, Dosh
cached attach, and Mosh startup.
Current status: this threat model is published (this document). The verification
checklist is **not yet fully green** — see the item-by-item status table in
`docs/PUBLIC_READINESS.md` ("Native v1 verification checklist status") and the known
gaps in section 6 above. Until the gaps close, Dosh's defensible public claim remains
**fast, encrypted native attach/reconnect and forwarding with SSH bootstrap
fallback** on Dosh-installed servers, not generic SSH compatibility or a third-party
audited security product.
+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?;
}
}
-67
View File
@@ -1,67 +0,0 @@
# dosh fuzz targets
cargo-fuzz / libFuzzer harnesses for the `dosh` parsers and handshake verifiers
(spec milestone 5 / §16: "Fuzz packet parsing, authorized-key parsing,
known-host parsing, and handshake state", "Fuzz targets run in CI").
This is a **standalone crate** (its own `[workspace]`) so it never affects the
main crate's `cargo build` / `cargo test` / `cargo fmt --check`.
## Targets
| Target | Parser(s) exercised |
| --- | --- |
| `packet_decode` | `protocol::Header::parse`, `protocol::decode`, `protocol::decrypt_body` |
| `from_body` | `protocol::from_body::<T>` for every protocol & native wire struct |
| `authorized_keys` | `native::parse_authorized_keys`, `native::parse_ssh_ed25519_public_blob` |
| `known_hosts` | `native::parse_known_hosts`, `native::parse_host_public_key_line` |
| `handshake_structs` | handshake struct decode + `verify_server_hello`, `user_auth_transcript`, `verify_native_user_auth` |
| `attach_ticket` | `auth::open_attach_ticket`, `auth::verify_attach_ticket`, `auth::decode_bootstrap` |
Every target's objective is the same: **no panics on any input** (a panic on
untrusted bytes is a robustness/DoS bug per threat model §5).
## Prerequisites
```sh
rustup toolchain install nightly
cargo install cargo-fuzz
```
cargo-fuzz requires a nightly toolchain (it builds with `-Z sanitizer=address`).
## Run
From the repository root:
```sh
# Run all targets for a short smoke pass
make fuzz-smoke
# Run all targets for the pre-launch deep pass (default: 300s each)
make fuzz-deep
# List targets
cargo +nightly fuzz list --fuzz-dir fuzz
# Run a single target indefinitely
cargo +nightly fuzz run --fuzz-dir fuzz packet_decode
# Short, CI-style smoke run of one target (10 seconds)
cargo +nightly fuzz run --fuzz-dir fuzz packet_decode -- -max_total_time=10
```
Or from inside `fuzz/`:
```sh
cd fuzz
cargo +nightly fuzz run packet_decode -- -max_total_time=10
```
## CI
`.github/workflows/ci.yml` has a `fuzz-smoke` job that installs nightly +
cargo-fuzz and runs each target. Push/PR runs use a short 20-second-per-target
smoke pass. Weekly scheduled and manual workflow runs use a 300-second-per-target
deep pass by default. The job is tolerant if the toolchain/tooling is unavailable
so it never blocks the main test gate.
+81 -7
View File
@@ -1,7 +1,7 @@
param(
[ValidateSet("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]$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 }),
@@ -70,6 +70,36 @@ function Release-DownloadUrl {
"$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 {
@@ -86,12 +116,54 @@ function Verify-ArchiveChecksum($Url, $Archive) {
}
}
function Expected-ArchiveVersion($Url) {
if ($Url -match "/releases/download/v([^/]+)/") {
return $Matches[1]
}
if (-not $BinaryUrl -and -not $BinaryBase -and $BinaryVersion -ne "latest") {
return $BinaryVersion.TrimStart("v")
}
return $null
}
function Verify-ArchiveVersion($ExtractDir, $Url) {
$expected = Expected-ArchiveVersion $Url
if (-not $expected) {
return
}
$versionFile = Get-ChildItem -Path $ExtractDir -Recurse -File -Filter VERSION | Select-Object -First 1
if (-not $versionFile) {
throw "prebuilt archive missing VERSION for $Url"
}
$actual = (Get-Content $versionFile.FullName -Raw).Trim()
if ($actual -ne $expected) {
throw "prebuilt archive version mismatch for ${Url}: expected $expected, got $actual"
}
}
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-DownloadUrl
$url = Release-LatestTagDownloadUrl
if (-not $url) {
$url = Release-DownloadUrl
}
if (-not $url) {
return $false
}
@@ -104,14 +176,15 @@ function Install-Prebuilt {
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"
}
Copy-Item $found.FullName (Join-Path $bindir $bin) -Force
Install-Binary $found.FullName (Join-Path $bindir $bin)
}
Copy-Item (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe") -Force
Install-Binary (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe")
return $true
}
catch {
@@ -143,9 +216,9 @@ function Install-FromSource {
try {
Push-Location $src
cargo build --release --bin dosh-client --bin dosh-bench
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force
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
@@ -188,6 +261,7 @@ predict = true
predict_mode = "experimental"
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
escape_key = "^]"
"@ | Set-Content -NoNewline -Encoding utf8 $clientConfig
}
+67 -15
View File
@@ -43,7 +43,7 @@ Environment alternatives:
DOSH_BINARY_URL URL
Exact release tarball URL to install
DOSH_BINARY_BASE URL
Release download base; defaults to REPO/releases/latest/download
Release download base; otherwise derived from the latest tag
DOSH_BINARY_NAME NAME
Release tarball name; defaults to dosh-OS-ARCH.tar.gz
DOSH_BINARY_VERSION TAG
@@ -226,13 +226,26 @@ 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 -m 0755 "$found" "$3"
install_binary "$found" "$3"
}
sha256_file() {
@@ -264,8 +277,42 @@ verify_archive_checksum() {
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_download_url)" || return 1
download_url="$(release_latest_tag_download_url || release_download_url)" || return 1
tmpdir="$(mktemp -d)"
archive="$tmpdir/$(release_artifact_name)"
checksum_file="$archive.sha256"
@@ -273,7 +320,7 @@ try_install_prebuilt() {
need curl
need tar
if ! curl -fsL "$download_url" -o "$archive" 2>/dev/null; then
alt_download_url="$(release_latest_tag_download_url || true)"
alt_download_url="$(release_download_url || true)"
if [ -z "$alt_download_url" ] || ! curl -fsL "$alt_download_url" -o "$archive"; then
echo "prebuilt unavailable: $download_url" >&2
[ -z "$alt_download_url" ] || echo "prebuilt unavailable: $alt_download_url" >&2
@@ -287,6 +334,7 @@ try_install_prebuilt() {
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
@@ -294,7 +342,7 @@ try_install_prebuilt() {
install_extracted_binary "$tmpdir/extract" dosh-auth "$bindir/dosh-auth" || return 1
fi
if found_bench="$(find_extracted_binary "$tmpdir/extract" dosh-bench)" && [ -n "$found_bench" ]; then
install -m 0755 "$found_bench" "$bindir/dosh-bench"
install_binary "$found_bench" "$bindir/dosh-bench"
fi
return 0
}
@@ -353,14 +401,14 @@ install_from_source() {
fi
fi
install -m 0755 target/release/dosh-client "$bindir/dosh-client"
install_binary target/release/dosh-client "$bindir/dosh-client"
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install -m 0755 target/release/dosh-server "$bindir/dosh-server"
install -m 0755 target/release/dosh-auth "$bindir/dosh-auth"
install_binary target/release/dosh-server "$bindir/dosh-server"
install_binary target/release/dosh-auth "$bindir/dosh-auth"
fi
if [ -f target/release/dosh-bench ]; then
install -m 0755 target/release/dosh-bench "$bindir/dosh-bench"
install_binary target/release/dosh-bench "$bindir/dosh-bench"
fi
}
@@ -371,14 +419,14 @@ write_systemd_service() {
else
cat >"$systemd_user_dir/dosh-server.service" <<EOF
[Unit]
Description=Dosh server
Description=dosh dormant shell server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$bindir/dosh-server serve
Restart=on-failure
Restart=always
RestartSec=1
KillMode=process
@@ -416,8 +464,9 @@ scrollback = 5000
auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600
allow_attach_tickets = true
client_timeout_secs = 86400
client_timeout_secs = 2592000
retransmit_window = 256
output_frame_interval_ms = 16
default_input_mode = "read-write"
prewarm_sessions = ["default"]
create_on_attach = true
@@ -431,6 +480,7 @@ native_auth_rate_limit_per_minute = 30
allow_tcp_forwarding = true
allow_remote_forwarding = false
allow_agent_forwarding = false
persist_sessions = true
EOF
fi
@@ -438,7 +488,8 @@ EOF
mkdir -p "$systemd_user_dir"
write_systemd_service
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
elif [ "$start_server" -eq 1 ]; then
nohup "$bindir/dosh-server" serve >"$data_dir/dosh-server.log" 2>&1 &
@@ -492,6 +543,7 @@ known_hosts = "~/.config/dosh/known_hosts"
identity_files = ["~/.ssh/id_ed25519"]
use_ssh_agent = true
forward_agent = false
escape_key = "^]"
EOF
fi
@@ -505,8 +557,8 @@ EOF
fi
cat >"$hosts_config" <<EOF
# Example:
# [homelab]
# ssh = "homelab"
# [server]
# ssh = "server"
# dosh_host = "server.example.com"
# port = 50000
# default_command = "tm"
-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 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 -10
View File
@@ -6,17 +6,9 @@ Wants=network-online.target
[Service]
Type=simple
ExecStart=%h/.local/bin/dosh-server serve
Restart=on-failure
Restart=always
RestartSec=1
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=%h/.config/dosh %h/.local/share/dosh
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
KillMode=process
[Install]
WantedBy=default.target
+3 -2
View File
@@ -67,6 +67,7 @@ cleanup() {
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
fi
pkill -f "$server_bin hold --runtime-dir $home/sessions/run" 2>/dev/null || true
rm -rf "$home"
}
trap cleanup EXIT INT TERM
@@ -86,6 +87,7 @@ scrollback = 5000
auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600
allow_attach_tickets = true
persist_sessions = false
client_timeout_secs = 60
retransmit_window = 256
default_input_mode = "read-write"
@@ -183,8 +185,7 @@ server_pid=""
# meaningful here. The cold fresh-process reconnect fast path is the attach
# ticket (measured above). Roaming resume is validated by the integration test
# `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
# docs/BENCHMARKS.md.
# scenarios that keep a live session out-of-band (e.g. remote soak tests).
rm -rf "$home/.local/share/dosh/credentials"
write_client_config true
start_server
+90 -9
View File
@@ -26,8 +26,16 @@ normalize_arch() {
esac
}
os="$(normalize_os)"
arch="$(normalize_arch)"
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"
@@ -36,16 +44,82 @@ else
versioned_artifact="dosh-$version-$os-$arch.tar.gz"
fi
stage="$out_dir/stage/dosh"
write_versioned="${DOSH_PACKAGE_VERSIONED:-0}"
cargo build --release
if [ -n "$target" ]; then
release_dir="target/$target/release"
else
release_dir="target/release"
fi
if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
if [ -z "${DOSH_BUILD_GIT_HASH:-}" ]; then
export DOSH_BUILD_GIT_HASH="$(git rev-parse --short=12 HEAD)"
fi
if [ -z "${DOSH_BUILD_COMMIT_DATE:-}" ]; then
export DOSH_BUILD_COMMIT_DATE="$(git show -s --format=%cs HEAD)"
fi
if [ -z "${DOSH_BUILD_GIT_DIRTY+x}" ]; then
if git diff --quiet --ignore-submodules --; then
export DOSH_BUILD_GIT_DIRTY=0
else
export DOSH_BUILD_GIT_DIRTY=1
fi
fi
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 "target/release/$bin" ]; then
install -m 0755 "target/release/$bin" "$stage/bin/$bin"
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
@@ -60,8 +134,6 @@ if [ "$os" = "windows" ]; then
else
tar -C "$out_dir/stage" -czf "$out_dir/$artifact" dosh
fi
cp "$out_dir/$artifact" "$out_dir/$versioned_artifact"
write_sha256() {
file="$1"
if command -v sha256sum >/dev/null 2>&1; then
@@ -74,12 +146,21 @@ write_sha256() {
}
write_sha256 "$out_dir/$artifact"
write_sha256 "$out_dir/$versioned_artifact"
if [ "$write_versioned" = "1" ]; then
cp "$out_dir/$artifact" "$out_dir/$versioned_artifact"
write_sha256 "$out_dir/$versioned_artifact"
fi
printf 'Wrote:\n'
cat <<EOF
Wrote:
$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
+71 -2
View File
@@ -30,14 +30,54 @@ 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" >&2
echo "GITEA_TOKEN is required (or put it in ~/.config/dosh/gitea-token)" >&2
exit 2
fi
if [ "$#" -eq 0 ]; then
set -- target/dosh-release/dosh-*
for artifact in \
target/dosh-release/dosh-linux-*.tar.gz \
target/dosh-release/dosh-macos-*.tar.gz \
target/dosh-release/dosh-windows-*.zip; do
[ -f "$artifact" ] || continue
case "$(basename "$artifact")" in
dosh-[0-9]*)
continue
;;
esac
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"
@@ -83,11 +123,34 @@ delete_existing_asset() {
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 \
@@ -95,4 +158,10 @@ for artifact in "$@"; do
-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
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env sh
set -eu
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | sed -n '1p')"
out_dir="${DOSH_PACKAGE_DIR:-target/dosh-release}"
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 [ "$#" -gt 0 ]; then
artifacts="$*"
else
artifacts="$out_dir/dosh-linux-x86_64.tar.gz $out_dir/dosh-macos-aarch64.tar.gz $out_dir/dosh-windows-x86_64.zip"
fi
failed=0
for artifact in $artifacts; do
if [ ! -f "$artifact" ]; then
echo "missing release artifact: $artifact" >&2
failed=1
continue
fi
if [ ! -f "$artifact.sha256" ]; then
echo "missing release checksum: $artifact.sha256" >&2
failed=1
fi
actual="$(artifact_version "$artifact" || true)"
if [ "$actual" != "$version" ]; then
echo "artifact version mismatch: $artifact has ${actual:-unknown}, expected $version" >&2
failed=1
fi
done
exit "$failed"
+9 -4
View File
@@ -7,6 +7,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
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()))?;
}
let secret = crypto::random_32();
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
let mut options = fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options
.open(&path)
.with_context(|| format!("create {}", path.display()))?;
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)
}
#[allow(clippy::too_many_arguments)]
pub fn build_bootstrap(
config: &ServerConfig,
secret: &[u8; 32],
@@ -159,6 +162,7 @@ pub fn build_bootstrap(
})
}
#[allow(clippy::too_many_arguments)]
pub fn attach_token(
secret: &[u8; 32],
user: &str,
@@ -204,6 +208,7 @@ pub fn verify_bootstrap(resp: &BootstrapResponse, secret: &[u8; 32]) -> Result<b
Ok(expected == resp.attach_token)
}
#[allow(clippy::too_many_arguments)]
pub fn build_attach_ticket(
secret: &[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};
#[derive(Debug, Parser)]
#[command(
name = "dosh-auth",
version = dosh::build_info::VERSION,
long_version = dosh::build_info::LONG_VERSION
)]
struct Args {
#[arg(long, default_value_t = 1)]
protocol: u8,
+5 -1
View File
@@ -11,7 +11,11 @@ use std::sync::mpsc;
use std::time::{Duration, Instant};
#[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 {
#[arg(long, default_value = "local")]
server: String,
+4385 -304
View File
File diff suppressed because it is too large Load Diff
+1797 -334
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"),
")"
);
+443
View File
@@ -0,0 +1,443 @@
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 crate::udp::{recv_udp_retrying_transient, send_udp_retrying_transient};
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(),
})?,
)?;
send_udp_retrying_transient(&socket, &packet, peer_addr, timeout).await?;
let mut buf = vec![0u8; 65535];
let (n, _) = recv_udp_retrying_transient(&socket, &mut buf, timeout).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 })?,
)?;
send_udp_retrying_transient(&socket, &auth_packet, peer_addr, timeout).await?;
let (n, _) = recv_udp_retrying_transient(&socket, &mut buf, timeout).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"))
);
}
}
+35 -16
View File
@@ -14,6 +14,8 @@ pub struct ServerConfig {
pub allow_attach_tickets: bool,
pub client_timeout_secs: u64,
pub retransmit_window: usize,
#[serde(default = "default_output_frame_interval_ms")]
pub output_frame_interval_ms: u64,
pub default_input_mode: String,
pub prewarm_sessions: Vec<String>,
pub create_on_attach: bool,
@@ -44,18 +46,15 @@ pub struct ServerConfig {
pub allow_remote_non_loopback_bind: bool,
#[serde(default)]
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")]
pub accept_env: Vec<String>,
/// Run each terminal session's shell under a small per-session *holder*
/// process so it survives a `dosh-server` restart (crash/upgrade/
/// `systemctl restart`). On startup the server re-adopts live holders and
/// reattaching clients land on the same shell with screen state intact. When
/// `false`, sessions behave exactly as before: the shell is a child of the
/// server and dies with it.
// Opt-in for now: this changes the core session model (per-session holder
// processes + fd passing), so it stays OFF by default until it has been
// stress-tested on a real host. Set `persist_sessions = true` to enable.
#[serde(default)]
/// 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,
}
@@ -68,8 +67,9 @@ impl Default for ServerConfig {
auth_ttl_secs: 30,
attach_ticket_ttl_secs: 3600,
allow_attach_tickets: true,
client_timeout_secs: 86400,
client_timeout_secs: 2_592_000,
retransmit_window: 256,
output_frame_interval_ms: default_output_frame_interval_ms(),
default_input_mode: "read-write".to_string(),
prewarm_sessions: vec!["default".to_string()],
create_on_attach: true,
@@ -86,8 +86,10 @@ impl Default for ServerConfig {
allow_remote_forwarding: false,
allow_remote_non_loopback_bind: false,
allow_agent_forwarding: false,
allow_file_transfer: true,
allow_exec_command: true,
accept_env: default_accept_env(),
persist_sessions: false,
persist_sessions: true,
}
}
}
@@ -132,6 +134,8 @@ pub struct ClientConfig {
/// 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")]
pub send_env: Vec<String>,
#[serde(default)]
@@ -146,6 +150,12 @@ pub struct ClientConfig {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HostConfig {
pub ssh: Option<String>,
/// Optional OpenSSH config host name to use for SSH-backed bootstrap,
/// trust/setup/doctor checks, and native-auth identity selection. This lets
/// a Dosh host keep a separate UDP endpoint while still inheriting
/// alias-scoped OpenSSH settings like IdentityFile, ProxyJump, and SendEnv.
#[serde(default)]
pub ssh_config: Option<String>,
pub user: Option<String>,
pub dosh_host: Option<String>,
pub port: Option<u16>,
@@ -205,6 +215,7 @@ impl Default for ClientConfig {
use_ssh_agent: true,
forward_agent: false,
disconnect_status: true,
escape_key: default_escape_key(),
send_env: default_send_env(),
set_env: HashMap::new(),
extensions: HashMap::new(),
@@ -231,6 +242,10 @@ fn default_native_auth_rate_limit_per_minute() -> u32 {
30
}
fn default_output_frame_interval_ms() -> u64 {
16
}
fn default_rekey_after_packets() -> u64 {
// 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
@@ -259,6 +274,10 @@ fn default_known_hosts() -> String {
"~/.config/dosh/known_hosts".to_string()
}
fn default_escape_key() -> String {
"^]".to_string()
}
fn default_identity_files() -> Vec<String> {
vec!["~/.ssh/id_ed25519".to_string()]
}
@@ -304,10 +323,10 @@ pub fn load_hosts_config(path: Option<PathBuf>) -> Result<HostsConfig> {
}
pub fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
if let Some(rest) = path.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest);
}
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,8 +1,26 @@
//! 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 build_info;
pub mod client;
pub mod config;
pub mod crypto;
pub mod exec_service;
pub mod file_transfer;
pub mod native;
#[cfg(unix)]
pub mod persist;
pub mod protocol;
#[cfg(unix)]
pub mod pty;
pub mod server;
pub mod ssh_agent;
pub mod transport;
pub mod udp;
+11 -9
View File
@@ -11,6 +11,7 @@ use signature::{SignatureEncoding, Signer as SignatureSigner, Verifier as Signat
use std::fs;
use std::io::Write;
use std::net::IpAddr;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use std::str::FromStr;
@@ -153,10 +154,11 @@ pub fn load_or_create_host_key(config: &ServerConfig) -> Result<SigningKey> {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let bytes = crypto::random_32();
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
let mut options = fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options
.open(&path)
.with_context(|| format!("create {}", path.display()))?;
file.write_all(URL_SAFE_NO_PAD.encode(bytes).as_bytes())?;
@@ -1233,11 +1235,11 @@ fn write_known_host_entries(path: &Path, entries: &[KnownHost]) -> Result<()> {
));
out.push('\n');
}
let mut file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
let mut options = fs::OpenOptions::new();
options.create(true).truncate(true).write(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options
.open(path)
.with_context(|| format!("write {}", path.display()))?;
file.write_all(out.as_bytes())
+41 -2
View File
@@ -23,11 +23,14 @@ 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::sync::Mutex;
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);
static SCREEN_WRITE_LOCK: Mutex<()> = Mutex::new(());
/// 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.
@@ -139,13 +142,28 @@ pub fn save_screen(
// No holder runtime for this session (non-persistent): nothing to do.
return Ok(());
}
let _guard = SCREEN_WRITE_LOCK
.lock()
.map_err(|_| anyhow!("screen persistence lock poisoned"))?;
let path = screen_path(&dir);
if existing_screen_output_seq(&path).is_some_and(|existing| existing >= output_seq) {
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)
atomic_write(&path, &buf)
}
fn existing_screen_output_seq(path: &Path) -> Option<u64> {
let data = std::fs::read(path).ok()?;
if data.len() < 12 {
return None;
}
Some(u64::from_be_bytes(data[4..12].try_into().ok()?))
}
/// A restored screen for a re-adopted session.
@@ -236,11 +254,14 @@ pub fn spawn_holder(
// Wait (briefly) for the holder to come up. The holder forks/setsids before
// creating the socket; once the socket exists we can connect. We also reap
// the short-lived launcher child so it never becomes a zombie.
let deadline = std::time::Instant::now() + Duration::from_secs(5);
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();
@@ -431,6 +452,10 @@ pub fn run_holder(
let mut cmd = [0u8; 1];
match stream.read(&mut cmd) {
Ok(1) if cmd[0] == HOLDER_CMD_SHUTDOWN => {
if shell_pid > 0 {
let _ = unsafe { libc::kill(shell_pid, libc::SIGHUP) };
let _ = unsafe { libc::kill(shell_pid, libc::SIGTERM) };
}
let _ = std::fs::remove_dir_all(runtime_dir);
std::process::exit(0);
}
@@ -611,6 +636,20 @@ mod tests {
assert_eq!(loaded.snapshot, snap);
}
#[test]
fn save_screen_never_overwrites_newer_snapshot_with_older_seq() {
let tmp = tempfile::tempdir().unwrap();
let sessions_dir = tmp.path();
ensure_runtime_dir(sessions_dir, "work").unwrap();
save_screen(sessions_dir, "work", 80, 24, 10, b"new-screen").unwrap();
save_screen(sessions_dir, "work", 80, 24, 9, b"old-screen").unwrap();
let loaded = load_screen(sessions_dir, "work").expect("screen restored");
assert_eq!(loaded.output_seq, 10);
assert_eq!(loaded.snapshot, b"new-screen");
}
#[test]
fn load_screen_absent_is_none() {
let tmp = tempfile::tempdir().unwrap();
+5 -1
View File
@@ -280,7 +280,7 @@ pub fn decode(input: &[u8]) -> Result<Packet> {
pub fn decrypt_body(packet: &Packet, key: &[u8; 32], direction: u32) -> Result<Vec<u8>> {
if packet.header.flags & 1 == 0 {
return Ok(packet.body.clone());
bail!("packet body is not encrypted");
}
let nonce = crypto::nonce_from(direction, packet.header.seq);
let aad = packet.header.aad();
@@ -357,6 +357,10 @@ pub struct NativeAuthCheckOkBody {
pub allow_tcp_forwarding: bool,
pub allow_remote_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>,
#[serde(default)]
pub server_version: String,
+2 -5
View File
@@ -297,6 +297,8 @@ fn spawn_reader_thread(
mod tests {
use super::*;
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.
@@ -305,9 +307,4 @@ mod tests {
assert!(!terminfo_available("definitely-not-a-real-terminal-xyz"));
assert!(!terminfo_available(""));
}
#[test]
fn pty_output_chunk_size_stays_mtu_safe() {
assert!(PTY_OUTPUT_CHUNK_BYTES <= 1200);
}
}
+709
View File
@@ -0,0 +1,709 @@
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 crate::udp::{is_transient_udp_error, is_transient_udp_send_error};
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,
}
#[derive(Debug, Clone)]
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) = match self.socket.recv_from(&mut buf).await {
Ok(value) => value,
Err(err) if is_transient_udp_error(&err) => continue,
Err(err) => return Err(err.into()),
};
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)?;
let _ = send_udp(&self.socket, &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.get(&packet.header.conn_id).cloned() 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.pending.remove(&packet.header.conn_id);
self.send_reject(peer, packet.header.conn_id, "native auth expired")
.await?;
return Ok(DoshServerEvent::Ignored);
}
let body = match protocol::decrypt_body(packet, &pending.session_key, CLIENT_TO_SERVER) {
Ok(body) => body,
Err(_) => {
self.send_reject(peer, packet.header.conn_id, "native auth decrypt failed")
.await?;
return Ok(DoshServerEvent::Ignored);
}
};
let req: NativeUserAuthBody = match protocol::from_body(&body) {
Ok(req) => req,
Err(_) => {
self.send_reject(peer, packet.header.conn_id, "invalid native auth body")
.await?;
return Ok(DoshServerEvent::Ignored);
}
};
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);
}
};
self.pending.remove(&packet.header.conn_id);
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,
)?;
let _ = send_udp(&self.socket, &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)?;
let _ = send_udp(&self.socket, &out, peer).await?;
Ok(())
}
fn expire_pending(&mut self) {
let timeout = self.config.auth_timeout;
self.pending
.retain(|_, pending| pending.created_at.elapsed() <= timeout);
}
}
async fn send_udp(socket: &UdpSocket, packet: &[u8], peer: SocketAddr) -> Result<bool> {
match socket.send_to(packet, peer).await {
Ok(_) => Ok(true),
Err(err) if is_transient_udp_send_error(&err) => Ok(false),
Err(err) => Err(err).with_context(|| format!("send UDP packet to {peer}")),
}
}
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::protocol::{CLIENT_TO_SERVER, NativeUserAuthBody};
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:?}"),
}
}
}
#[tokio::test]
async fn bad_native_auth_does_not_consume_pending_challenge() {
let dir = tempfile::tempdir().unwrap();
let host_key = dir.path().join("host_key");
let authorized_keys = dir.path().join("authorized_keys");
let signing = SigningKey::from_bytes(&[93u8; 32]);
let keypair = ssh_key::private::Ed25519Keypair::from(&signing);
let private =
ssh_key::PrivateKey::new(ssh_key::private::KeypairData::from(keypair), "").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())
.require_current_user(false);
let mut server = DoshServer::bind(server_config).await.unwrap();
let peer: SocketAddr = "127.0.0.1:9".parse().unwrap();
let (client_secret, client_public) = native::generate_native_ephemeral();
let hello = native::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: "sdk-user".to_string(),
requested_session: "test".to_string(),
requested_mode: "forward-only".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: vec!["ssh-ed25519".to_string()],
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: Vec::new(),
};
let (pending_id, server_hello) = server.build_server_hello(hello.clone(), peer).unwrap();
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, &hello, &server_hello, Vec::new()).unwrap();
let body = protocol::to_body(&NativeUserAuthBody { auth }).unwrap();
let bad = protocol::encode_encrypted(
PacketKind::NativeUserAuth,
pending_id,
2,
1,
&[7u8; 32],
CLIENT_TO_SERVER,
&body,
)
.unwrap();
let bad = protocol::decode(&bad).unwrap();
assert!(matches!(
server.handle_user_auth(peer, &bad).await.unwrap(),
DoshServerEvent::Ignored
));
assert!(server.pending.contains_key(&pending_id));
let valid = protocol::encode_encrypted(
PacketKind::NativeUserAuth,
pending_id,
2,
1,
&session_key,
CLIENT_TO_SERVER,
&body,
)
.unwrap();
let valid = protocol::decode(&valid).unwrap();
assert!(matches!(
server.handle_user_auth(peer, &valid).await.unwrap(),
DoshServerEvent::Accepted(_)
));
assert!(!server.pending.contains_key(&pending_id));
}
}
+50 -3
View File
@@ -1,18 +1,30 @@
use crate::native::{ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth};
#[cfg(unix)]
use crate::native::{
ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth,
is_supported_user_key_algorithm, parse_ssh_ed25519_public_blob, user_auth_transcript,
};
use anyhow::{Context, Result, anyhow, bail};
#[cfg(unix)]
use anyhow::{Context, bail};
use anyhow::{Result, anyhow};
#[cfg(unix)]
use std::io::{Read, Write};
#[cfg(unix)]
use std::os::unix::net::UnixStream;
use std::path::Path;
#[cfg(unix)]
const SSH_AGENT_FAILURE: u8 = 5;
#[cfg(unix)]
const SSH2_AGENTC_REQUEST_IDENTITIES: u8 = 11;
#[cfg(unix)]
const SSH2_AGENT_IDENTITIES_ANSWER: u8 = 12;
#[cfg(unix)]
const SSH2_AGENTC_SIGN_REQUEST: u8 = 13;
#[cfg(unix)]
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;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -25,6 +37,7 @@ pub struct AgentIdentity {
pub comment: String,
}
#[cfg(unix)]
pub fn sign_user_auth_with_agent(
client: &NativeClientHello,
server: &NativeServerHello,
@@ -34,6 +47,18 @@ pub fn sign_user_auth_with_agent(
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(
socket_path: impl AsRef<Path>,
client: &NativeClientHello,
@@ -58,6 +83,19 @@ pub fn sign_user_auth_with_agent_at(
Ok(auth)
}
#[cfg(not(unix))]
pub fn sign_user_auth_with_agent_at(
_socket_path: impl AsRef<Path>,
_client: &NativeClientHello,
_server: &NativeServerHello,
_requested_forwardings: Vec<ForwardingRequest>,
) -> Result<NativeUserAuth> {
Err(anyhow!(
"ssh-agent native auth is not supported on this platform yet; use identity_files"
))
}
#[cfg(unix)]
fn request_supported_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> {
write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?;
let payload = read_agent_packet(agent)?;
@@ -84,6 +122,7 @@ fn request_supported_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdent
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) {
@@ -119,6 +158,7 @@ fn supported_identity(key_blob: Vec<u8>, comment: String) -> Result<Option<Agent
Ok(Some(identity))
}
#[cfg(unix)]
fn sign_with_agent(
agent: &mut UnixStream,
identity: &AgentIdentity,
@@ -163,6 +203,7 @@ fn sign_with_agent(
Ok(signature.to_vec())
}
#[cfg(unix)]
fn read_agent_packet(stream: &mut UnixStream) -> Result<Vec<u8>> {
let mut len = [0u8; 4];
stream
@@ -177,6 +218,7 @@ fn read_agent_packet(stream: &mut UnixStream) -> Result<Vec<u8>> {
Ok(payload)
}
#[cfg(unix)]
fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> {
anyhow::ensure!(
payload.len() <= MAX_AGENT_PACKET,
@@ -187,6 +229,7 @@ fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn read_u8(cursor: &mut &[u8]) -> Result<u8> {
anyhow::ensure!(!cursor.is_empty(), "truncated u8");
let value = cursor[0];
@@ -194,6 +237,7 @@ fn read_u8(cursor: &mut &[u8]) -> Result<u8> {
Ok(value)
}
#[cfg(unix)]
fn read_u32(cursor: &mut &[u8]) -> Result<u32> {
anyhow::ensure!(cursor.len() >= 4, "truncated u32");
let value = u32::from_be_bytes(cursor[..4].try_into().unwrap());
@@ -201,6 +245,7 @@ fn read_u32(cursor: &mut &[u8]) -> Result<u32> {
Ok(value)
}
#[cfg(unix)]
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
let len = read_u32(cursor)? as usize;
anyhow::ensure!(cursor.len() >= len, "truncated SSH string");
@@ -209,18 +254,20 @@ fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
Ok(value)
}
#[cfg(unix)]
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);
}
#[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(test)]
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::native::{
+1719
View File
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
//! UDP error classification shared by Dosh clients, servers, and embedders.
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use tokio::net::UdpSocket;
pub fn is_transient_udp_error(err: &std::io::Error) -> bool {
matches!(
err.kind(),
std::io::ErrorKind::Interrupted
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::WouldBlock
) || err.raw_os_error().is_some_and(is_transient_udp_os_error)
}
pub fn is_transient_udp_send_error(err: &std::io::Error) -> bool {
is_transient_udp_error(err)
}
pub async fn send_udp_retrying_transient(
socket: &UdpSocket,
packet: &[u8],
peer: SocketAddr,
wait: Duration,
) -> std::io::Result<()> {
let deadline = Instant::now() + wait.max(Duration::from_millis(1));
loop {
match socket.send_to(packet, peer).await {
Ok(_) => return Ok(()),
Err(err) if is_transient_udp_send_error(&err) && Instant::now() < deadline => {
sleep_until_retry(deadline).await;
}
Err(err) => return Err(err),
}
}
}
pub async fn recv_udp_retrying_transient(
socket: &UdpSocket,
buf: &mut [u8],
wait: Duration,
) -> std::io::Result<(usize, SocketAddr)> {
let deadline = Instant::now() + wait.max(Duration::from_millis(1));
loop {
let now = Instant::now();
if now >= deadline {
return Err(std::io::Error::from(std::io::ErrorKind::TimedOut));
}
match tokio::time::timeout(deadline - now, socket.recv_from(buf)).await {
Ok(Ok(value)) => return Ok(value),
Ok(Err(err)) if is_transient_udp_error(&err) && Instant::now() < deadline => {
sleep_until_retry(deadline).await;
}
Ok(Err(err)) => return Err(err),
Err(_) => return Err(std::io::Error::from(std::io::ErrorKind::TimedOut)),
}
}
}
async fn sleep_until_retry(deadline: Instant) {
let now = Instant::now();
if now >= deadline {
return;
}
tokio::time::sleep((deadline - now).min(Duration::from_millis(20))).await;
}
#[cfg(unix)]
fn is_transient_udp_os_error(code: i32) -> bool {
matches!(
code,
libc::EADDRNOTAVAIL
| libc::ECONNREFUSED
| libc::ECONNRESET
| libc::EHOSTDOWN
| libc::EHOSTUNREACH
| libc::ENETDOWN
| libc::ENETRESET
| libc::ENETUNREACH
| libc::ETIMEDOUT
)
}
#[cfg(windows)]
fn is_transient_udp_os_error(code: i32) -> bool {
matches!(
code,
10049 // WSAEADDRNOTAVAIL
| 10050 // WSAENETDOWN
| 10051 // WSAENETUNREACH
| 10052 // WSAENETRESET
| 10054 // WSAECONNRESET
| 10060 // WSAETIMEDOUT
| 10061 // WSAECONNREFUSED
| 10064 // WSAEHOSTDOWN
| 10065 // WSAEHOSTUNREACH
)
}
#[cfg(not(any(unix, windows)))]
fn is_transient_udp_os_error(_code: i32) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::{
is_transient_udp_error, is_transient_udp_send_error, recv_udp_retrying_transient,
send_udp_retrying_transient,
};
use std::time::Duration;
use tokio::net::UdpSocket;
#[test]
fn classifies_portable_transient_udp_errors() {
for kind in [
std::io::ErrorKind::Interrupted,
std::io::ErrorKind::TimedOut,
std::io::ErrorKind::WouldBlock,
] {
let err = std::io::Error::from(kind);
assert!(is_transient_udp_error(&err));
assert!(is_transient_udp_send_error(&err));
}
let fatal = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
assert!(!is_transient_udp_error(&fatal));
assert!(!is_transient_udp_send_error(&fatal));
}
#[tokio::test]
async fn retrying_udp_send_and_receive_round_trip() {
let receiver = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let sender = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let addr = receiver.local_addr().unwrap();
send_udp_retrying_transient(&sender, b"hello", addr, Duration::from_millis(50))
.await
.unwrap();
let mut buf = [0u8; 16];
let (n, peer) = recv_udp_retrying_transient(&receiver, &mut buf, Duration::from_millis(50))
.await
.unwrap();
assert_eq!(&buf[..n], b"hello");
assert_eq!(peer, sender.local_addr().unwrap());
}
#[tokio::test]
async fn retrying_udp_receive_times_out_cleanly() {
let receiver = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let mut buf = [0u8; 16];
let err = recv_udp_retrying_transient(&receiver, &mut buf, Duration::from_millis(1))
.await
.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
}
#[cfg(unix)]
#[test]
fn classifies_unix_network_churn_as_transient() {
for code in [
libc::EADDRNOTAVAIL,
libc::ECONNREFUSED,
libc::ECONNRESET,
libc::EHOSTDOWN,
libc::EHOSTUNREACH,
libc::ENETDOWN,
libc::ENETRESET,
libc::ENETUNREACH,
libc::ETIMEDOUT,
] {
let err = std::io::Error::from_raw_os_error(code);
assert!(is_transient_udp_error(&err));
assert!(is_transient_udp_send_error(&err));
}
}
#[cfg(windows)]
#[test]
fn classifies_windows_network_churn_as_transient() {
for code in [
10049, 10050, 10051, 10052, 10054, 10060, 10061, 10064, 10065,
] {
let err = std::io::Error::from_raw_os_error(code);
assert!(is_transient_udp_error(&err));
assert!(is_transient_udp_send_error(&err));
}
}
}
+161 -13
View File
@@ -18,13 +18,18 @@
//! 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
//! tests are reproducible and fast.
#![allow(
clippy::collapsible_if,
clippy::explicit_counter_loop,
clippy::single_match
)]
use std::fs;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, UdpSocket};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread;
use std::time::{Duration, Instant};
@@ -44,9 +49,20 @@ use ed25519_dalek::{SigningKey, VerifyingKey};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
const TEST_UDP_PORT_START: u16 = 31_000;
const TEST_UDP_PORT_END: u16 = 60_999;
static NEXT_UDP_PORT: AtomicU32 = AtomicU32::new(TEST_UDP_PORT_START as u32);
fn free_udp_port() -> u16 {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket.local_addr().unwrap().port()
let span = u32::from(TEST_UDP_PORT_END - TEST_UDP_PORT_START) + 1;
for _ in TEST_UDP_PORT_START..=TEST_UDP_PORT_END {
let offset = NEXT_UDP_PORT.fetch_add(1, Ordering::SeqCst) % span;
let port = TEST_UDP_PORT_START + offset as u16;
if UdpSocket::bind(("127.0.0.1", port)).is_ok() {
return port;
}
}
panic!("no free UDP test port in {TEST_UDP_PORT_START}-{TEST_UDP_PORT_END}");
}
fn write_server_config(dir: &tempfile::TempDir, port: u16) -> std::path::PathBuf {
@@ -253,7 +269,8 @@ fn relay_loop(
let mut upstream = new_upstream();
let mut client_addr: Option<SocketAddr> = None;
let mut rng = StdRng::seed_from_u64(seed);
let mut held: Option<(Vec<u8>, bool)> = None; // (packet, is_c2s) held for reorder
let mut held_c2s: Option<Vec<u8>> = None;
let mut held_s2c: Option<Vec<u8>> = None;
let mut buf = [0u8; 65535];
loop {
@@ -283,10 +300,9 @@ fn relay_loop(
&upstream,
server_addr,
packet,
true,
&controls,
&mut rng,
&mut held,
&mut held_c2s,
);
}
}
@@ -305,7 +321,12 @@ fn relay_loop(
let drop_pct = controls.drop_s2c_percent.load(Ordering::SeqCst);
if drop_pct == 0 || rng.gen_range(0..100) >= drop_pct {
forward_with_effects(
&front, dst, packet, false, &controls, &mut rng, &mut held,
&front,
dst,
packet,
&controls,
&mut rng,
&mut held_s2c,
);
}
}
@@ -331,23 +352,22 @@ fn forward_with_effects(
out: &UdpSocket,
dst: SocketAddr,
packet: Vec<u8>,
is_c2s: bool,
controls: &RelayControls,
rng: &mut StdRng,
held: &mut Option<(Vec<u8>, bool)>,
held: &mut Option<Vec<u8>>,
) {
// Reorder: if armed, hold this packet and release the previously held one
// afterward (so two consecutive packets swap order).
if controls.reorder_next.swap(false, Ordering::SeqCst) {
if let Some((prev, _)) = held.take() {
if let Some(prev) = held.take() {
let _ = out.send_to(&packet, dst);
let _ = out.send_to(&prev, dst);
return;
}
*held = Some((packet, is_c2s));
*held = Some(packet);
return;
}
if let Some((prev, _)) = held.take() {
if let Some(prev) = held.take() {
let _ = out.send_to(&prev, dst);
}
@@ -762,13 +782,53 @@ fn wait_for_text(socket: &UdpSocket, key: &[u8; 32], needle: &str, millis: u64)
acc.contains(needle)
}
fn wait_for_text_with_ack(
socket: &UdpSocket,
relay: &Relay,
client_id: [u8; 16],
seq: &mut u64,
key: &[u8; 32],
needle: &str,
millis: u64,
) -> bool {
let prev = socket.read_timeout().unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let deadline = Instant::now() + Duration::from_millis(millis);
let mut acc = String::new();
while Instant::now() < deadline {
if let Some((_header, frame)) = recv_frame(socket, key) {
acc.push_str(&String::from_utf8_lossy(&frame.bytes));
let ack = protocol::encode_encrypted(
PacketKind::Ack,
client_id,
*seq,
frame.output_seq,
key,
CLIENT_TO_SERVER,
b"",
)
.unwrap();
*seq += 1;
socket.send_to(&ack, relay.front_addr()).unwrap();
if acc.contains(needle) {
socket.set_read_timeout(prev).unwrap();
return true;
}
}
}
socket.set_read_timeout(prev).unwrap();
acc.contains(needle)
}
#[test]
fn session_survives_packet_loss_and_reorder() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0x105_5u64 ^ 0x1111);
let relay = Relay::spawn(port, 0x1055_u64 ^ 0x1111);
let (socket, bootstrap, ok) = attach_through_relay(&config, &relay);
@@ -815,6 +875,94 @@ fn session_survives_packet_loss_and_reorder() {
);
}
#[test]
#[ignore = "30-minute hostile-network TUI soak; run with `DOSH_BADNET_SOAK_SECONDS=1800 cargo test --test hostile_network bad_network_tui_work_soak_30m -- --ignored --nocapture`"]
fn bad_network_tui_work_soak_30m() {
let soak_secs = std::env::var("DOSH_BADNET_SOAK_SECONDS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(30 * 60);
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0xBADC0DEu64);
let (socket, bootstrap, ok) = attach_through_relay(&config, &relay);
relay.set_drop_c2s(15);
relay.set_drop_s2c(20);
relay.set_dup(10);
let deadline = Instant::now() + Duration::from_secs(soak_secs);
let mut seq = 2u64;
let mut iteration = 0u64;
while Instant::now() < deadline {
if iteration.is_multiple_of(2) {
relay.arm_reorder();
}
if iteration > 0 && iteration.is_multiple_of(7) {
let _ = relay.rebind_upstream();
}
if iteration > 0 && iteration.is_multiple_of(11) {
relay.set_drop_s2c(100);
thread::sleep(Duration::from_millis(750));
relay.set_drop_s2c(20);
}
let marker = format!("DOSH_BADNET_TUI_{iteration}");
let command = format!(
"stty -echo; \
printf '\\033[?1049h\\033[?2026h\\033[?25l'; \
for i in 1 2 3 4 5 6; do \
printf '\\033[%s;4H{} %s\\033[0m' \"$i\" \"$i\"; \
done; \
printf '\\033[?25h\\033[?2026l\\033[?1049l'\n",
marker
);
let step_deadline = Instant::now() + Duration::from_secs(8);
let mut seen = false;
let mut attempts = 0;
while Instant::now() < step_deadline && attempts < 12 {
send_input(
&socket,
&relay,
ok.client_id,
seq,
0,
&bootstrap.session_key,
command.as_bytes(),
);
seq += 1;
attempts += 1;
if wait_for_text_with_ack(
&socket,
&relay,
ok.client_id,
&mut seq,
&bootstrap.session_key,
&marker,
500,
) {
seen = true;
break;
}
}
assert!(
seen,
"TUI marker {marker} did not arrive during hostile-network soak"
);
iteration += 1;
thread::sleep(Duration::from_millis(500));
}
relay.clear_impairments();
drop(relay);
let _ = server.kill();
let _ = server.wait();
}
#[test]
fn duplicated_and_replayed_input_is_applied_at_most_once() {
let dir = tempfile::tempdir().unwrap();
+808 -25
View File
@@ -1,9 +1,12 @@
#![allow(clippy::collapsible_if)]
use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream, UdpSocket};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
@@ -16,18 +19,40 @@ use dosh::crypto;
use dosh::native::{host_public_key, load_or_create_host_key, ssh_ed25519_public_blob, trust_host};
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT,
PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, TicketAttachEnvelope,
TicketAttachOkEnvelope,
};
use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
const TEST_PORT_START: u16 = 30_000;
const TEST_PORT_END: u16 = 60_999;
static NEXT_UDP_PORT: AtomicU32 = AtomicU32::new(TEST_PORT_START as u32);
static NEXT_TCP_PORT: AtomicU32 = AtomicU32::new(TEST_PORT_START as u32);
fn next_test_port(counter: &AtomicU32) -> u16 {
let span = u32::from(TEST_PORT_END - TEST_PORT_START) + 1;
let offset = counter.fetch_add(1, Ordering::SeqCst) % span;
TEST_PORT_START + offset as u16
}
fn free_udp_port() -> u16 {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket.local_addr().unwrap().port()
for _ in TEST_PORT_START..=TEST_PORT_END {
let port = next_test_port(&NEXT_UDP_PORT);
if UdpSocket::bind(("127.0.0.1", port)).is_ok() {
return port;
}
}
panic!("no free UDP test port in {TEST_PORT_START}-{TEST_PORT_END}");
}
fn free_tcp_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
for _ in TEST_PORT_START..=TEST_PORT_END {
let port = next_test_port(&NEXT_TCP_PORT);
if TcpListener::bind(("127.0.0.1", port)).is_ok() {
return port;
}
}
panic!("no free TCP test port in {TEST_PORT_START}-{TEST_PORT_END}");
}
fn write_server_config(dir: &tempfile::TempDir, port: u16) -> std::path::PathBuf {
@@ -96,6 +121,35 @@ persist_sessions = false
config
}
fn write_server_config_with_output_interval(
dir: &tempfile::TempDir,
port: u16,
output_frame_interval_ms: u64,
) -> std::path::PathBuf {
let config = write_server_config(dir, port);
let mut raw = fs::read_to_string(&config).unwrap();
raw.push_str(&format!(
"output_frame_interval_ms = {output_frame_interval_ms}\n"
));
fs::write(&config, raw).unwrap();
config
}
fn write_server_config_with_retransmit_window(
dir: &tempfile::TempDir,
port: u16,
retransmit_window: u64,
) -> std::path::PathBuf {
let config = write_server_config(dir, port);
let mut raw = fs::read_to_string(&config).unwrap();
raw = raw.replace(
"retransmit_window = 256\n",
&format!("retransmit_window = {retransmit_window}\n"),
);
fs::write(&config, raw).unwrap();
config
}
fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child {
let server = env!("CARGO_BIN_EXE_dosh-server");
let child = Command::new(server)
@@ -499,6 +553,76 @@ fn direct_attach_session(
(socket, bootstrap, ok)
}
fn ticket_attach_session(
socket: &UdpSocket,
port: u16,
bootstrap: &dosh::auth::BootstrapResponse,
session: &str,
mode: &str,
) -> AttachOk {
let client_nonce = crypto::random_12();
let request_key = crypto::hkdf32(
&bootstrap.attach_ticket_psk,
&client_nonce,
b"dosh/ticket-attach-request/v1",
)
.unwrap();
let body = protocol::to_body(&TicketAttachBody {
session: session.to_string(),
mode: mode.to_string(),
cols: 80,
rows: 24,
requested_env: Vec::new(),
})
.unwrap();
let ciphertext = crypto::seal(
&request_key,
&client_nonce,
b"dosh-ticket-attach-request-v1",
&body,
)
.unwrap();
let envelope = TicketAttachEnvelope {
ticket: bootstrap.attach_ticket.clone(),
client_nonce,
ciphertext,
};
let packet = protocol::encode_plain(
PacketKind::TicketAttachRequest,
[0u8; 16],
1,
0,
&protocol::to_body(&envelope).unwrap(),
)
.unwrap();
socket
.send_to(&packet, format!("127.0.0.1:{port}"))
.unwrap();
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).unwrap();
let packet = protocol::decode(&buf[..n]).unwrap();
assert_eq!(packet.header.kind, PacketKind::AttachOk);
let envelope: TicketAttachOkEnvelope = protocol::from_body(&packet.body).unwrap();
let mut salt = Vec::with_capacity(24);
salt.extend_from_slice(&client_nonce);
salt.extend_from_slice(&envelope.server_nonce);
let response_key = crypto::hkdf32(
&bootstrap.attach_ticket_psk,
&salt,
b"dosh/ticket-attach-ok/v1",
)
.unwrap();
let plain = crypto::open(
&response_key,
&envelope.server_nonce,
b"dosh-ticket-attach-ok-v1",
&envelope.ciphertext,
)
.unwrap();
protocol::from_body(&plain).unwrap()
}
#[allow(clippy::too_many_arguments)]
fn send_encrypted(
socket: &UdpSocket,
port: u16,
@@ -617,6 +741,48 @@ fn disconnected_client_times_out_and_gets_reject() {
assert_eq!(reject.reason, "unknown client");
}
#[test]
fn known_client_with_stale_key_id_gets_reject_not_timeout() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (_bootstrap, ok) = {
let (_socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
(bootstrap, ok)
};
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let stale_key = crypto::random_32();
let packet = protocol::encode_encrypted(
PacketKind::Ping,
ok.client_id,
99,
0,
&stale_key,
CLIENT_TO_SERVER,
b"",
)
.unwrap();
socket
.send_to(&packet, format!("127.0.0.1:{port}"))
.unwrap();
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).unwrap();
let packet = protocol::decode(&buf[..n]).unwrap();
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
let _ = server.kill();
let _ = server.wait();
assert_eq!(packet.header.kind, PacketKind::AttachReject);
assert_eq!(packet.header.conn_id, ok.client_id);
assert_eq!(reject.reason, "unknown client");
}
#[test]
fn local_attach_only_smoke() {
let dir = tempfile::tempdir().unwrap();
@@ -715,7 +881,9 @@ fn native_doctor_checks_auth_without_opening_terminal() {
assert!(output.status.success(), "stdout={stdout}\nstderr={stderr}");
assert!(stdout.contains("[ok] native udp"), "stdout={stdout}");
assert!(stdout.contains("[ok] native auth"), "stdout={stdout}");
assert!(stdout.contains("[ok] forwarding policy"), "stdout={stdout}");
assert!(stdout.contains("[ok] server policy"), "stdout={stdout}");
assert!(stdout.contains("file_transfer=true"), "stdout={stdout}");
assert!(stdout.contains("exec_command=true"), "stdout={stdout}");
}
#[test]
@@ -823,6 +991,288 @@ fn native_local_forward_background_smoke() {
assert_eq!(&buf[..n], b"dosh-background-forward-ping");
}
#[test]
fn native_file_copy_recursive_round_trip() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let mut server = start_server(&dir, &config);
let src = dir.path().join("srcdir");
fs::create_dir_all(src.join("nested")).unwrap();
fs::write(src.join("root.txt"), b"root file\n").unwrap();
fs::write(src.join("nested/child.txt"), b"child file\n").unwrap();
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let upload = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cp")
.arg("-r")
.arg(&src)
.arg("local:remote-copy")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
upload.status.success(),
"upload failed: stdout={} stderr={}",
String::from_utf8_lossy(&upload.stdout),
String::from_utf8_lossy(&upload.stderr)
);
let remote_copy = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cp")
.arg("-r")
.arg("local:remote-copy")
.arg("local:remote-copy-2")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
remote_copy.status.success(),
"remote copy failed: stdout={} stderr={}",
String::from_utf8_lossy(&remote_copy.stdout),
String::from_utf8_lossy(&remote_copy.stderr)
);
let list = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("ls")
.arg("local:remote-copy-2")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
list.status.success(),
"ls failed: stdout={} stderr={}",
String::from_utf8_lossy(&list.stdout),
String::from_utf8_lossy(&list.stderr)
);
let list_stdout = String::from_utf8_lossy(&list.stdout);
assert!(list_stdout.contains("root.txt"), "stdout={list_stdout}");
assert!(list_stdout.contains("nested"), "stdout={list_stdout}");
let cat = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cat")
.arg("local:remote-copy-2/root.txt")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
cat.status.success(),
"cat failed: stdout={} stderr={}",
String::from_utf8_lossy(&cat.stdout),
String::from_utf8_lossy(&cat.stderr)
);
assert_eq!(String::from_utf8_lossy(&cat.stdout), "root file\n");
let downloaded = dir.path().join("downloaded");
let download = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cp")
.arg("-r")
.arg("local:remote-copy-2")
.arg(&downloaded)
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
download.status.success(),
"download failed: stdout={} stderr={}",
String::from_utf8_lossy(&download.stdout),
String::from_utf8_lossy(&download.stderr)
);
assert_eq!(
fs::read_to_string(downloaded.join("root.txt")).unwrap(),
"root file\n"
);
assert_eq!(
fs::read_to_string(downloaded.join("nested/child.txt")).unwrap(),
"child file\n"
);
let remove = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("rm")
.arg("-r")
.arg("local:remote-copy")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
remove.status.success(),
"rm failed: stdout={} stderr={}",
String::from_utf8_lossy(&remove.stdout),
String::from_utf8_lossy(&remove.stderr)
);
let _ = server.kill();
let _ = server.wait();
}
#[test]
fn native_file_copy_resume_truncates_oversized_destinations() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let mut server = start_server(&dir, &config);
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let local_short = dir.path().join("short.txt");
fs::write(&local_short, b"short\n").unwrap();
let seed_remote = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("exec")
.arg("local")
.arg("printf 'short\\nSTALE' > \"$HOME/resume-upload.txt\"")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
seed_remote.status.success(),
"seed remote failed: stdout={} stderr={}",
String::from_utf8_lossy(&seed_remote.stdout),
String::from_utf8_lossy(&seed_remote.stderr)
);
let resume_upload = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cp")
.arg("--resume")
.arg(&local_short)
.arg("local:resume-upload.txt")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
resume_upload.status.success(),
"resume upload failed: stdout={} stderr={}",
String::from_utf8_lossy(&resume_upload.stdout),
String::from_utf8_lossy(&resume_upload.stderr)
);
let cat_upload = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cat")
.arg("local:resume-upload.txt")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
cat_upload.status.success(),
"cat upload failed: stdout={} stderr={}",
String::from_utf8_lossy(&cat_upload.stdout),
String::from_utf8_lossy(&cat_upload.stderr)
);
assert_eq!(String::from_utf8_lossy(&cat_upload.stdout), "short\n");
let seed_download = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("exec")
.arg("local")
.arg("printf 'tiny\\n' > \"$HOME/resume-download.txt\"")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
seed_download.status.success(),
"seed download failed: stdout={} stderr={}",
String::from_utf8_lossy(&seed_download.stdout),
String::from_utf8_lossy(&seed_download.stderr)
);
let local_download = dir.path().join("resume-download-local.txt");
fs::write(&local_download, b"tiny\nSTALE").unwrap();
let resume_download = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cp")
.arg("--resume")
.arg("local:resume-download.txt")
.arg(&local_download)
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
resume_download.status.success(),
"resume download failed: stdout={} stderr={}",
String::from_utf8_lossy(&resume_download.stdout),
String::from_utf8_lossy(&resume_download.stderr)
);
assert_eq!(fs::read_to_string(&local_download).unwrap(), "tiny\n");
let _ = server.kill();
let _ = server.wait();
}
#[test]
fn native_exec_command_smoke() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let mut server = start_server(&dir, &config);
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let output = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("exec")
.arg("local")
.arg("printf out; printf err >&2")
.env("HOME", dir.path())
.output()
.unwrap();
let _ = server.kill();
let _ = server.wait();
assert!(
output.status.success(),
"exec failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(String::from_utf8_lossy(&output.stdout), "out");
assert_eq!(String::from_utf8_lossy(&output.stderr), "err");
}
#[test]
fn native_local_forward_bulk_load_does_not_delay_interactive_terminal() {
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
@@ -1145,18 +1595,10 @@ fn server_retransmits_unacked_output_frames() {
);
let mut seen = Vec::new();
let mut duplicate = None;
let deadline = std::time::Instant::now() + Duration::from_secs(3);
let deadline = std::time::Instant::now() + Duration::from_secs(4);
while std::time::Instant::now() < deadline {
if let Some((header, frame)) = recv_frame(&socket, &bootstrap.session_key) {
if String::from_utf8_lossy(&frame.bytes).contains("DOSH_RETRANSMIT") {
if seen
.iter()
.any(|(_, output_seq)| *output_seq == frame.output_seq)
{
duplicate = Some((header.seq, frame.output_seq));
break;
}
seen.push((header.seq, frame.output_seq));
}
}
@@ -1165,9 +1607,18 @@ fn server_retransmits_unacked_output_frames() {
let _ = server.kill();
let _ = server.wait();
let max_same_output_seq = seen
.iter()
.map(|(_, output_seq)| {
seen.iter()
.filter(|(_, candidate)| candidate == output_seq)
.count()
})
.max()
.unwrap_or(0);
assert!(
duplicate.is_some(),
"expected retransmitted same output seq, seen={seen:?}"
max_same_output_seq >= 10,
"expected retransmission to continue past the old 8-attempt ceiling, seen={seen:?}"
);
}
@@ -1330,13 +1781,16 @@ fn tui_control_sequences_survive_transport_verbatim() {
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let sequences = concat!(
"\x1b[?1049h", // alternate screen on
"\x1b[?2004h", // bracketed paste on
"\x1b[?1000h", // mouse tracking on
"\x1b[?1006h", // SGR mouse encoding on
"\x1b[?25l", // cursor hidden
"\x1b[12;34H", // absolute cursor movement
"\x1b[?1049h", // alternate screen on
"\x1b[?2004h", // bracketed paste on
"\x1b[?1000h", // mouse tracking on
"\x1b[?1006h", // SGR mouse encoding on
"\x1b]0;DOSH_TITLE\x07", // OSC title
"\x1b[?25l", // cursor hidden
"\x1b[12;34H", // absolute cursor movement
"\x1b[1;31m", // bold red
"DOSH_TUI_VERBATIM",
"\x1b[0m",
"\x1b[?25h",
"\x1b[?1006l",
"\x1b[?1000l",
@@ -1366,9 +1820,12 @@ fn tui_control_sequences_survive_transport_verbatim() {
"\x1b[?2004h",
"\x1b[?1000h",
"\x1b[?1006h",
"\x1b]0;DOSH_TITLE\x07",
"\x1b[?25l",
"\x1b[12;34H",
"\x1b[1;31m",
"DOSH_TUI_VERBATIM",
"\x1b[0m",
"\x1b[?25h",
"\x1b[?1006l",
"\x1b[?1000l",
@@ -1382,6 +1839,143 @@ fn tui_control_sequences_survive_transport_verbatim() {
}
}
#[test]
fn unicode_output_survives_transport() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let marker = "DOSH_UNICODE 你 e\u{301} 🚀";
let input = Input {
bytes: format!("printf '{}\\n'\n", marker).into_bytes(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let text = collect_frame_text(&socket, &bootstrap.session_key, 2000);
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains(marker),
"expected UTF-8 output marker to survive transport, got {text:?}"
);
}
#[test]
fn tui_graph_glyphs_survive_fast_repaints_without_snapshot_substitution() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config_with_output_interval(&dir, port, 1000);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let graph = "DOSH_GRAPH ⣀⣤⣶⣿ █▇▆▅▄▃▂▁ ╭╮╯╰";
let input = Input {
bytes: format!(
"printf '\\033[?1049h\\033[?25l'; for i in 1 2 3 4 5; do printf '\\033[H{} %s\\n' \"$i\"; done; printf '\\033[?25h\\033[?1049l'\n",
graph
)
.into_bytes(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let text = collect_frame_text(&socket, &bootstrap.session_key, 3000);
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains(graph) && text.matches("DOSH_GRAPH").count() >= 5,
"expected repeated raw graph glyph frames, got {text:?}"
);
assert!(
text.contains("\x1b[?25l") && text.contains("\x1b[?25h"),
"expected cursor visibility controls to survive around graph repaint, got {text:?}"
);
}
#[test]
fn btop_style_graph_output_stays_raw_when_retransmit_history_overflows() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config_with_retransmit_window(&dir, port, 3);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let graph = "DOSH_BTOP_NET ⠀⠁⠃⠇⡇⣇⣧⣷⣿";
let input = Input {
bytes: format!(
"stty -echo; \
printf '\\033[?1049h\\033[?2026h\\033[?25l'; \
for i in 1 2 3 4 5 6 7 8 9 10; do \
printf '\\033[6;4H\\033[38;2;80;220;140m{} %s\\033[0m' \"$i\"; \
done; \
printf '\\033[?25h\\033[?2026l\\033[?1049l'\n",
graph
)
.into_bytes(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let mut text = String::new();
let mut snapshots = 0usize;
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while std::time::Instant::now() < deadline
&& !(text.matches("DOSH_BTOP_NET").count() >= 10 && text.contains("\x1b[?2026l"))
{
if let Some((_header, frame)) = recv_frame(&socket, &bootstrap.session_key) {
if frame.snapshot {
snapshots += 1;
}
text.push_str(&String::from_utf8_lossy(&frame.bytes));
}
}
let _ = server.kill();
let _ = server.wait();
assert_eq!(
snapshots, 0,
"live btop-style graph output must not be replaced by snapshots; got {snapshots}"
);
assert!(
text.matches("DOSH_BTOP_NET").count() >= 10
&& text.contains("")
&& text.contains("\x1b[38;2;80;220;140m")
&& text.contains("\x1b[?2026h")
&& text.contains("\x1b[?2026l"),
"expected raw btop-style graph output to survive under retransmit pressure, got {text:?}"
);
}
#[test]
fn large_tui_paint_is_delivered_in_mtu_safe_frames() {
let dir = tempfile::tempdir().unwrap();
@@ -1483,8 +2077,8 @@ fn resume_snapshot_preserves_alternate_screen_mode() {
assert!(resume_frame.snapshot);
assert!(
resume_frame.bytes.starts_with(b"\x1b[?1049h"),
"snapshot did not enter alternate screen first: {:?}",
resume_frame.bytes.starts_with(b"\x1b[?1049h\x1b[H\x1b[2J"),
"snapshot did not enter and clear alternate screen first: {:?}",
String::from_utf8_lossy(&resume_frame.bytes)
);
}
@@ -2128,6 +2722,9 @@ fn write_persistent_server_config(dir: &tempfile::TempDir, port: u16) -> std::pa
let mut raw = fs::read_to_string(&config).unwrap();
// The base writer hardcodes `persist_sessions = false`; flip it on.
raw = raw.replace("persist_sessions = false", "persist_sessions = true");
// Persistence tests attach the sessions they need explicitly. Leaving the
// base prewarm on here creates a detached default holder in every test.
raw = raw.replace("prewarm_sessions = [\"default\"]", "prewarm_sessions = []");
fs::write(&config, raw).unwrap();
config
}
@@ -2163,6 +2760,45 @@ fn kill_holder(sessions_dir: &Path, session: &str) {
.status();
}
/// Create a holder the way older Dosh builds did for implicit sessions, so
/// startup migration can be tested without depending on the new spawn policy.
fn spawn_legacy_holder(sessions_dir: &Path, session: &str) {
let server = env!("CARGO_BIN_EXE_dosh-server");
let runtime_dir = dosh::persist::ensure_runtime_dir(sessions_dir, session).unwrap();
let mut launcher = Command::new(server)
.arg("hold")
.arg("--runtime-dir")
.arg(&runtime_dir)
.arg("--session")
.arg(session)
.arg("--shell")
.arg("/bin/sh")
.arg("--cols")
.arg("80")
.arg("--rows")
.arg("24")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
if holder_shell_pid(sessions_dir, session).is_some()
&& runtime_dir.join("holder.sock").exists()
{
let _ = launcher.wait();
return;
}
if let Some(status) = launcher.try_wait().unwrap() {
panic!("legacy holder launcher exited before ready: {status}");
}
thread::sleep(Duration::from_millis(20));
}
let _ = launcher.kill();
let _ = launcher.wait();
panic!("legacy holder did not become ready");
}
/// Send one encrypted Input line and give the shell a moment to act.
fn type_line(socket: &UdpSocket, port: u16, ok: &AttachOk, key: &[u8; 32], seq: u64, line: &str) {
let input = Input {
@@ -2299,6 +2935,153 @@ fn session_survives_server_restart_same_shell_and_screen() {
);
}
#[test]
fn implicit_session_survives_update_restart_via_ticket_reattach() {
let dir = tempfile::tempdir().unwrap();
let sessions_dir = dir.path().join("sessions");
let port = free_udp_port();
let config = write_persistent_server_config(&dir, port);
let session = protocol::generate_implicit_session_name();
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach_session(&config, port, "read-write", &session);
let key = bootstrap.session_key;
type_line(&socket, port, &ok, &key, 2, "cd /tmp\n");
type_line(
&socket,
port,
&ok,
&key,
3,
"export IMPLICIT_MARK=implicit_restart_42\n",
);
type_line(
&socket,
port,
&ok,
&key,
4,
"printf 'IMPLICIT_SCREEN_MARKER\\n'\n",
);
let pre = collect_frame_text(&socket, &key, 1500);
assert!(
pre.contains("IMPLICIT_SCREEN_MARKER"),
"implicit marker missing before restart: {pre:?}"
);
thread::sleep(Duration::from_secs(3));
let shell_pid_before = holder_shell_pid(&sessions_dir, &session);
assert!(
shell_pid_before.is_some(),
"implicit sessions must get temporary holders so active clients survive updates"
);
let _ = server.kill();
let _ = server.wait();
thread::sleep(Duration::from_millis(300));
let mut server = start_server(&dir, &config);
let resume = ResumeRequest {
session: session.clone(),
last_rendered_seq: ok.initial_seq,
cols: 80,
rows: 24,
};
send_encrypted(
&socket,
port,
PacketKind::ResumeRequest,
ok.client_id,
5,
0,
&key,
&protocol::to_body(&resume).unwrap(),
);
let mut buf = [0u8; 65535];
let deadline = std::time::Instant::now() + Duration::from_secs(2);
loop {
assert!(
std::time::Instant::now() < deadline,
"old live resume did not receive unknown-client reject"
);
let (n, _) = socket.recv_from(&mut buf).unwrap();
let packet = protocol::decode(&buf[..n]).unwrap();
if packet.header.kind == PacketKind::Frame {
continue;
}
assert_eq!(packet.header.kind, PacketKind::AttachReject);
assert_eq!(packet.header.conn_id, ok.client_id);
break;
}
let ok2 = ticket_attach_session(&socket, port, &bootstrap, &session, "read-write");
let key2 = ok2.session_key;
let snapshot = String::from_utf8_lossy(&ok2.snapshot).to_string();
type_line(
&socket,
port,
&ok2,
&key2,
2,
"printf 'IMPLICIT_MARK=%s PWD=%s\\n' \"$IMPLICIT_MARK\" \"$PWD\"\n",
);
let post = collect_frame_text(&socket, &key2, 2000);
let shell_pid_after = holder_shell_pid(&sessions_dir, &session);
let _ = server.kill();
let _ = server.wait();
kill_holder(&sessions_dir, &session);
assert!(
snapshot.contains("IMPLICIT_SCREEN_MARKER"),
"ticket reattach snapshot must repaint the exact pre-update screen, got {snapshot:?}"
);
assert_eq!(
shell_pid_after, shell_pid_before,
"implicit update reconnect must use the same holder shell"
);
assert!(
post.contains("IMPLICIT_MARK=implicit_restart_42"),
"implicit session env must survive update reconnect, got {post:?}"
);
assert!(
post.contains("PWD=/tmp"),
"implicit session cwd must survive update reconnect, got {post:?}"
);
}
#[test]
fn startup_readopts_implicit_holders_for_update_reconnect() {
let dir = tempfile::tempdir().unwrap();
let sessions_dir = dir.path().join("sessions");
let port = free_udp_port();
let config = write_persistent_server_config(&dir, port);
let session = protocol::generate_implicit_session_name();
spawn_legacy_holder(&sessions_dir, &session);
let shell_pid = holder_shell_pid(&sessions_dir, &session).expect("legacy holder shell pid");
assert!(
unsafe { libc::kill(shell_pid, 0) } == 0,
"legacy holder shell must be live before startup"
);
let mut server = start_server(&dir, &config);
thread::sleep(Duration::from_millis(300));
let _ = server.kill();
let _ = server.wait();
assert!(
holder_shell_pid(&sessions_dir, &session).is_some(),
"startup must keep live implicit holders so active update reconnects can reattach"
);
assert!(
unsafe { libc::kill(shell_pid, 0) } == 0,
"startup must not shut down a live implicit holder before reconnect grace"
);
kill_holder(&sessions_dir, &session);
}
#[test]
fn multiple_persistent_named_sessions_survive_restart_independently() {
let dir = tempfile::tempdir().unwrap();
+13
View File
@@ -58,6 +58,19 @@ fn encrypted_packet_round_trips() {
assert_eq!(plain, b"hello");
}
#[test]
fn plaintext_packet_never_decrypts_as_authenticated_body() {
let key = crypto::random_32();
let mut decoded = protocol::decode(
&protocol::encode_plain(PacketKind::Input, crypto::random_16(), 1, 0, b"hello").unwrap(),
)
.unwrap();
decoded.header.session_key_id = protocol::session_key_id(&key);
let err = protocol::decrypt_body(&decoded, &key, CLIENT_TO_SERVER).unwrap_err();
assert!(err.to_string().contains("not encrypted"));
}
#[test]
fn encrypted_packet_rejects_wrong_session_key_id_before_decrypt() {
let key = crypto::random_32();
+92
View File
@@ -0,0 +1,92 @@
#[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 package_check_verifies_only_artifacts_it_builds() {
let makefile = include_str!("../Makefile");
let verify = include_str!("../scripts/verify-release-artifacts.sh");
assert!(
verify.contains("if [ \"$#\" -gt 0 ]; then"),
"release verifier must accept an explicit artifact list"
);
let package_check = makefile
.split("package-check:")
.nth(1)
.and_then(|tail| tail.split("\ninstall:").next())
.expect("package-check target");
assert!(package_check.contains("package-release-linux"));
assert!(package_check.contains("package-release-windows"));
assert!(package_check.contains("dosh-linux-x86_64.tar.gz"));
assert!(package_check.contains("dosh-windows-x86_64.zip"));
assert!(
!package_check.contains("dosh-macos-aarch64.tar.gz"),
"Linux package-check does not build macOS artifacts, so it must not depend on one"
);
}
#[test]
fn package_release_stamps_git_build_info() {
let package = include_str!("../scripts/package-release.sh");
let build = include_str!("../build.rs");
for name in [
"DOSH_BUILD_GIT_HASH",
"DOSH_BUILD_COMMIT_DATE",
"DOSH_BUILD_GIT_DIRTY",
] {
assert!(package.contains(name), "package script must export {name}");
assert!(build.contains(name), "build.rs must consume {name}");
}
assert!(package.contains("git rev-parse --short=12 HEAD"));
assert!(package.contains("git show -s --format=%cs HEAD"));
assert!(build.contains("cargo:rerun-if-env-changed=DOSH_BUILD_GIT_HASH"));
}
#[test]
fn systemd_service_does_not_sandbox_remote_shells() {
let service = include_str!("../packaging/systemd/dosh-server.service");
let install = include_str!("../install.sh");
for raw in [service, install] {
assert!(raw.contains("KillMode=process"));
assert!(
raw.contains("Restart=always"),
"dosh-server should come back after accidental SIGTERM while preserving child shells"
);
for directive in [
"NoNewPrivileges=",
"PrivateTmp=",
"ProtectSystem=",
"ProtectHome=",
"RestrictAddressFamilies=",
"RestrictRealtime=",
"LockPersonality=",
"MemoryDenyWriteExecute=",
] {
assert!(
!raw.contains(directive),
"dosh sessions are user shells; systemd sandbox directive {directive} changes terminal/app behavior"
);
}
assert!(
!raw.contains("ReadWritePaths="),
"remote shells must not be restricted to dosh config/data paths"
);
}
}
+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."
}
}
}
}
}