Compare commits

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

Server-only behavior change; wire VERSION unchanged.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:03:06 -04:00
55 changed files with 15460 additions and 3317 deletions
+46 -11
View File
@@ -3,6 +3,9 @@ name: ci
on: on:
push: push:
pull_request: pull_request:
workflow_dispatch:
schedule:
- cron: "17 7 * * 1"
jobs: jobs:
test: test:
@@ -36,21 +39,53 @@ jobs:
if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success' if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success'
run: | run: |
set -e set -e
for target in \ if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
packet_decode \ DOSH_FUZZ_SECONDS="${DOSH_FUZZ_SECONDS:-300}" sh scripts/fuzz-run.sh
from_body \ else
authorized_keys \ sh scripts/fuzz-run.sh 20
known_hosts \ fi
handshake_structs \
attach_ticket; do
echo "== fuzzing $target =="
cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \
-max_total_time=20 -rss_limit_mb=4096
done
- name: Note when fuzzing was skipped - name: Note when fuzzing was skipped
if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success' if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success'
run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run." run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run."
windows-client:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Build Windows client
run: cargo build --release --bin dosh-client --bin dosh-bench
- name: Package Windows client
shell: bash
run: sh scripts/package-release.sh
package-release:
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')
strategy:
matrix:
include:
- os: ubuntu-latest
name: linux-x86_64
- os: macos-14
name: macos-aarch64
- os: macos-13
name: macos-x86_64
- os: windows-latest
name: windows-x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Package release
shell: bash
run: sh scripts/package-release.sh
- uses: actions/upload-artifact@v4
with:
name: dosh-${{ matrix.name }}
path: |
target/dosh-release/dosh-*
!target/dosh-release/stage/**
remote-bench: remote-bench:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
+1
View File
@@ -1,3 +1,4 @@
/target/ /target/
**/*.rs.bk **/*.rs.bk
.DS_Store .DS_Store
vscode-extension/*.vsix
Generated
+16 -1
View File
@@ -397,6 +397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [ dependencies = [
"const-oid", "const-oid",
"pem-rfc7468",
"zeroize", "zeroize",
] ]
@@ -435,7 +436,7 @@ dependencies = [
[[package]] [[package]]
name = "dosh" name = "dosh"
version = "0.1.0" version = "1.0.0-rc25"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -446,14 +447,17 @@ dependencies = [
"crossterm", "crossterm",
"dirs", "dirs",
"ed25519-dalek", "ed25519-dalek",
"filetime",
"hkdf", "hkdf",
"hmac", "hmac",
"libc", "libc",
"portable-pty", "portable-pty",
"rand", "rand",
"rpassword", "rpassword",
"rsa",
"serde", "serde",
"sha2", "sha2",
"signature",
"ssh-key", "ssh-key",
"tempfile", "tempfile",
"tokio", "tokio",
@@ -574,6 +578,16 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
@@ -1499,6 +1513,7 @@ checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
dependencies = [ dependencies = [
"bcrypt-pbkdf", "bcrypt-pbkdf",
"ed25519-dalek", "ed25519-dalek",
"num-bigint-dig",
"p256", "p256",
"p384", "p384",
"p521", "p521",
+6 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "dosh" name = "dosh"
version = "0.1.0" version = "1.0.0-rc25"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
@@ -14,15 +14,18 @@ clap = { version = "4.5", features = ["derive"] }
crossterm = "0.28" crossterm = "0.28"
dirs = "5.0" dirs = "5.0"
ed25519-dalek = "2.1" ed25519-dalek = "2.1"
filetime = "0.2"
hkdf = "0.12" hkdf = "0.12"
hmac = "0.12" hmac = "0.12"
libc = "0.2" libc = "0.2"
portable-pty = "0.8" portable-pty = "0.8"
rand = "0.8" rand = "0.8"
rsa = { version = "0.9.10", features = ["sha2"] }
rpassword = "7.5.4" rpassword = "7.5.4"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10" sha2 = "0.10"
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] } signature = "2.2"
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption", "p256", "rsa"] }
tokio = { version = "1.41", features = ["full"] } tokio = { version = "1.41", features = ["full"] }
toml = "0.8" toml = "0.8"
vt100 = "0.15" vt100 = "0.15"
@@ -36,4 +39,4 @@ opt-level = 2
codegen-units = 16 codegen-units = 16
incremental = true incremental = true
lto = false lto = false
strip = false strip = "debuginfo"
+62 -2
View File
@@ -1,4 +1,4 @@
.PHONY: build test fmt install bench-local bench-local-json bench-docker-ssh bench-docker-mosh .PHONY: build test fmt clippy release-check 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: build:
cargo build --release cargo build --release
@@ -9,8 +9,54 @@ test:
fmt: fmt:
cargo fmt cargo fmt
clippy:
cargo clippy --all-targets -- -D warnings
release-check:
cargo fmt --check
sh -n install.sh scripts/*.sh
cargo clippy --all-targets -- -D warnings
cargo test
$(MAKE) tui-harness
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: install:
sh packaging/install.sh sh install.sh --from-current
package-release:
sh scripts/package-release.sh
package-release-linux:
DOSH_PACKAGE_OS=linux DOSH_PACKAGE_ARCH=x86_64 sh scripts/package-release.sh
package-release-windows:
DOSH_PACKAGE_OS=windows DOSH_PACKAGE_ARCH=x86_64 DOSH_PACKAGE_TARGET=x86_64-pc-windows-gnu sh scripts/package-release.sh
publish-release:
sh scripts/publish-gitea-release.sh
bench-report:
sh scripts/bench-report.sh
# Safe, self-contained local benchmark matrix (native cold auth, cached attach # Safe, self-contained local benchmark matrix (native cold auth, cached attach
# ticket, local-auth) on a throwaway server bound to 127.0.0.1 on a free port in # ticket, local-auth) on a throwaway server bound to 127.0.0.1 on a free port in
@@ -28,3 +74,17 @@ bench-docker-ssh:
bench-docker-mosh: bench-docker-mosh:
DOSH_BENCH_INCLUDE_MOSH=1 sh scripts/ci-docker-ssh-bench.sh DOSH_BENCH_INCLUDE_MOSH=1 sh scripts/ci-docker-ssh-bench.sh
fuzz-smoke:
sh scripts/fuzz-run.sh 20
# Longer pre-launch fuzz pass. Override with DOSH_FUZZ_SECONDS=NN.
fuzz-deep:
DOSH_FUZZ_SECONDS=$${DOSH_FUZZ_SECONDS:-300} sh scripts/fuzz-run.sh
tui-harness:
sh scripts/tui-harness.sh
# 30-minute launch soak by default. Override with DOSH_SOAK_SECONDS=NN.
soak-local:
DOSH_SOAK_SECONDS=$${DOSH_SOAK_SECONDS:-1800} cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture
+88 -273
View File
@@ -1,312 +1,127 @@
# dosh - Dormant Shell # Dosh
dosh is a low-latency remote terminal designed around fast attach and fast reconnect. Dosh is an encrypted remote terminal for fast reconnecting shells.
It is mosh-shaped, but not a mosh clone: the server is a resident daemon, terminal
sessions stay hot, and repeat connects try encrypted UDP before starting SSH.
The core target is simple: It runs a `dosh-server` on a Unix-like host and a `dosh` client on macOS,
Linux, or Windows. Setup can use SSH, then Dosh connects over encrypted UDP,
keeps sessions alive across disconnects, supports terminal apps, and can carry
TCP forwarding, file copy, and VS Code Remote-SSH streams.
- First secure trust establishment uses SSH. ## Support
- Existing sessions attach in one encrypted UDP exchange whenever cached credentials allow it.
- Reconnect after sleep, roaming, or network change resumes in one encrypted UDP exchange.
- Cold SSH fallback stays competitive with plain `ssh` by doing less after auth.
## Why not just mosh? - Client: macOS, Linux, Windows
- Server: Linux and other Unix-like systems with PTYs
- Default UDP port: `50000`
- Windows: client only
mosh is excellent at roaming and high-latency interactivity. Its startup path still Dosh is meant to replace Mosh and everyday interactive SSH sessions. It does
has work dosh can avoid: not currently include SFTP, X11 forwarding, or a Windows server.
1. SSH connects to the host.
2. SSH starts `mosh-server`.
3. The client receives connection material over SSH.
4. SSH exits and the mosh UDP session begins.
dosh keeps `dosh-server` running before the client arrives. Named PTY sessions can
also be prewarmed, so attaching to `default` does not need to spawn a daemon, create
a PTY, or start a shell on the user's critical path.
This is not an encryption argument against mosh. dosh also encrypts its UDP data
channel; the speed difference comes from keeping the server and session hot.
## Fast Path Order
The client always tries the cheapest valid path first:
1. **UDP resume:** existing `ClientId` and session key. No SSH. One encrypted UDP
request, one encrypted UDP reply.
2. **UDP attach ticket:** cached server-issued attach ticket for the same
host/user/session/mode. No SSH. One encrypted UDP request, one encrypted UDP
reply.
3. **SSH bootstrap:** `ssh -T user@host ~/.local/bin/dosh-auth ...`, then one encrypted UDP
attach.
4. **New session:** same as attach, but the server must create the PTY/shell unless
the session was prewarmed.
The fastest path is not a custom SSH replacement. SSH remains the first trust root;
dosh removes SSH from repeat attaches when the server has already issued valid
credentials.
Attach tickets are implemented because they are the way a fresh client process can
skip SSH after a recent successful bootstrap.
## Connection Speed Contract
dosh is measured by terminal-ready time: elapsed time from running `dosh host` to the
first usable terminal screen.
- UDP resume: <= one measured UDP RTT + local render time.
- UDP attach ticket: <= one measured UDP RTT + local render time.
- Warm attach with ControlMaster: <= `ssh host true` over the existing master + one
measured UDP RTT.
- Cold attach without ControlMaster: <= cold `ssh host` terminal-ready time + one
measured UDP RTT.
- New session: measured separately because it may need PTY and shell creation.
Server-side client resume state is kept for a day by default, so a sleeping laptop
can resume the same encrypted client association without depending on a still-valid
attach ticket. Attach tickets remain the fast path for fresh client processes.
The client emits timing spans for credential lookup, SSH bootstrap, UDP resume,
UDP ticket attach, and terminal-ready time.
## Architecture
```text
dosh-server
UDP socket on one configurable port
session table keyed by name
one PTY per named session
optional prewarmed sessions, default ["default"]
terminal parser/screen state per session
client table per session
encrypted UDP protocol
tiny SSH-invoked dosh-auth helper mode
persistent sessions (persist_sessions, default on): each shell runs in a
detached per-session holder process; the server adopts its PTY master fd via
SCM_RIGHTS and re-adopts it after a restart, so sessions survive crash/upgrade
dosh-client
terminal raw mode
local credential cache
UDP resume/attach first
SSH bootstrap fallback
PTY input/output forwarding
reconnect and roaming state machine
```
## Install ## Install
Default UDP port: `50000`. This is intentionally inside the common forwarded range Unix/macOS server and client:
`50000-52000/udp`.
Install on each Linux server you want to attach to: ```sh
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh | sh -s -- both --repo https://git.palav.dev/Palav/dosh.git --port 50000
```bash
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
| DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_PORT=50000 sh -s -- server
``` ```
Install the client on macOS: Unix/macOS client only:
```bash ```sh
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \ curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh | sh -s -- client --repo https://git.palav.dev/Palav/dosh.git
| DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_SERVER=palav DOSH_HOST=git.palav.dev DOSH_PORT=50000 sh -s -- client
``` ```
Update an installed client later: Windows client:
```bash
dosh update
```
Install the client on Windows PowerShell:
```powershell ```powershell
$env:DOSH_REPO="https://git.palav.dev/Palav/dosh.git"; $env:DOSH_SERVER="palav"; $env:DOSH_HOST="git.palav.dev"; $env:DOSH_PORT="50000"; irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
``` ```
Attach: ## Use
```bash ```sh
dosh palav dosh setup HOST
dosh HOST
dosh HOST COMMAND
dosh update
dosh update --client|--server|--both
``` ```
Plain `dosh palav` opens a fresh terminal session. Use named sessions when you want Useful commands:
to reattach to the same persistent terminal from multiple clients:
```bash ```sh
dosh --session work palav dosh exec HOST COMMAND
dosh --session logs palav 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
``` ```
Press `Ctrl-]` to detach the current client while leaving the server session alive. Agent forwarding is opt-in with `-A` and must also be enabled on the server.
Typing `exit` in the remote shell closes that Dosh session and returns the client. File copy must be enabled by the server config.
If UDP packets stop arriving, Dosh keeps the terminal open, sends keepalive pings,
and attempts ticket-based reconnect. While the link is silent it shows a single,
non-destructive status line on the bottom screen row (`[dosh] last contact Ns ago
— reconnecting…`), drawn with save/restore cursor so it never moves the app cursor
or corrupts a full-screen TUI, and cleared the instant packets resume. (The old
in-band overlay wrote over the active line and could corrupt TUIs; this draws only
the last row.) Disable it with `disconnect_status = false` in `client.toml` or
`DOSH_DISCONNECT_STATUS=0`.
If SSH and UDP use different public names, specify the UDP address: ## VS Code
```bash Dosh can carry VS Code Remote-SSH through its transport:
dosh-client --dosh-host public.example.com --dosh-port 50000 user@host
```sh
dosh vscode setup HOST
dosh vscode HOST /remote/path
``` ```
Dosh already lets OpenSSH handle SSH aliases, users, keys, ports, known-hosts, This creates a managed SSH config entry using `ProxyCommand dosh proxy-stdio`.
ProxyJump, and other SSH config during bootstrap. It also runs `ssh -G` to infer the VS Code still uses Remote-SSH and its normal remote server; Dosh carries the
UDP host from an SSH alias when `dosh_host` is not configured. To make that explicit SSH byte stream.
in Dosh's host config:
```bash ## Config
dosh import-ssh palav homelab
```text
~/.config/dosh/client.toml
~/.config/dosh/hosts.toml
~/.config/dosh/server.toml
``` ```
## Develop Common client settings:
Build: ```toml
default_session = "new"
```bash predict = true
cargo build cache_attach_tickets = true
disconnect_status = true
``` ```
Attach locally, using local bootstrap instead of SSH: Example host entry:
```bash ```toml
target/debug/dosh-client --local-auth --no-cache local [prod]
ssh = "prod"
ssh_config = "prod" # OpenSSH alias for IdentityFile, ProxyJump, SendEnv, etc.
dosh_host = "prod.example.com"
port = 50000
``` ```
Benchmark local attach: `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.
```bash ## Rust Library
target/debug/dosh-bench --local-auth --server local --iterations 5
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
``` ```
Benchmark a remote host over SSH bootstrap:
```bash
target/release/dosh-bench --server user@host --ssh-port 22 --iterations 3
```
Benchmark the ControlMaster-backed SSH bootstrap path:
```bash
target/release/dosh-bench --server user@host --controlmaster --iterations 3
```
Run the Docker OpenSSH benchmark gate used by CI. It checks both cold SSH bootstrap
and ControlMaster-backed SSH bootstrap against a containerized `sshd` plus resident
`dosh-server`:
```bash
make bench-docker-ssh
```
Run the same Docker comparison with Mosh installed in the benchmark container:
```bash
make bench-docker-mosh
```
That prints `ssh_true_ms`, `dosh_attach_ms`, and `mosh_start_true_ms` under the same
container, key, DNS, and network path. It also prints `dosh_cached_attach_ms`, which
is the real Dosh fast path after the first SSH-authenticated bootstrap has issued an
attach ticket. See `docs/PUBLIC_READINESS.md` before using the numbers publicly;
Dosh's current strongest claim is fast attach/reconnect, not full Mosh feature
parity yet.
The CI workflow includes an optional remote benchmark job. It runs when
`DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository secrets are
configured.
Install release binaries and the user systemd service:
```bash
make install
```
## Performance Rules
The stack is performance-driven, not fixed by taste. Rust is the default because the
likely bottlenecks are network RTT, SSH startup/auth, PTY/shell creation, packet
size, and terminal rendering. Change language or runtime only if measurements show
they are the bottleneck.
Hot-path rules:
- Custom UDP protocol with AEAD for v0; no QUIC handshake on attach.
- Fixed binary packet headers for terminal traffic; no JSON on the protocol path.
- Preallocated buffers; avoid per-packet heap churn.
- Single-thread event loop is preferred for the hot path.
- No PTY allocation, shell spawn, shell rc files, or MOTD on attach to an existing
session.
- Initial snapshot should be sent in the first UDP reply when it fits under the
packet budget.
## Goals
- Connection speed as specified above.
- UDP roaming and reconnect.
- Encrypted terminal data.
- Reuse SSH pubkeys for first trust establishment.
- Named persistent sessions.
- Multiple clients attached to one session.
- Optional view-only clients.
- Single server port, not one port per session.
- Static server and client binaries where practical.
## Non-Goals
- Replacing SSH as the first public-key trust mechanism.
- Multi-user access control.
- Windows support in v0.
- Full mosh compatibility.
- Perfect predictive local echo in the first MVP.
## Status
Rust implementation is present in this repository. It contains `dosh-server`,
`dosh-client`, `dosh-auth`, `dosh-bench`, shared auth/crypto/protocol modules, a
resident PTY server, encrypted UDP bootstrap attach, UDP resume, sealed UDP attach
tickets, client ACKs, server retransmit bookkeeping, sliding replay protection,
server-side `vt100` screen snapshots/diffs, a hardened user systemd unit, an install
script, Docker SSH benchmark gates, CI, and protocol/integration tests.
### Native v1
Beyond the SSH-bootstrap core, native v1 (`docs/NATIVE_V1_SPEC.md`) is substantially
implemented and aims to replace the day-to-day `ssh host` workflow on Dosh-installed
servers:
- **Native UDP auth** with X25519 key exchange, transcript-bound Ed25519 user auth
via ssh-agent or an encrypted OpenSSH key, ChaCha20-Poly1305 transport, and
`authorized_keys` policy enforcement (`from=`, `no-port-forwarding`, `permitopen=`;
unsupported options fail closed).
- **Dosh host-key trust**: pinned `known_hosts`, `dosh trust [--remove|--replace]`,
TOFU only when explicitly enabled, and hard-fail on host-key mismatch.
- **TCP forwarding**: local `-L`, remote `-R` (loopback bind by default), dynamic
SOCKS5 `-D`, forward-only `-N`, and background `-f`, with per-stream flow control so
bulk transfers do not lag the terminal.
- **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 yet fully verified**. Per-IP token-bucket rate limiting, protocol
VERSION negotiation hardening, fuzzing in CI, ECDSA/RSA user keys, and an external
security review are still open. See `docs/THREAT_MODEL.md` for the published threat
model and accepted residual risks, and the "Native v1 verification checklist status"
table in `docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not yet
claim a fully verified SSH replacement; its defensible claim remains fast encrypted
native attach/reconnect with SSH-equivalent transport security and SSH fallback.
-602
View File
@@ -1,602 +0,0 @@
# dosh - Dormant Shell Spec
**Status:** Implemented Rust build with local and Docker SSH verification
**Default language:** Rust, unless benchmarks prove the stack is the bottleneck
**Binaries:** `dosh-server`, `dosh-client`, `dosh-auth`, `dosh-bench`
**Helper mode:** `dosh-server auth` or `~/.local/bin/dosh-auth`, invoked by SSH with `-T`
**Native v1 plan:** `docs/NATIVE_V1_SPEC.md` (substantially implemented; see its v1 status block)
**Threat model:** `docs/THREAT_MODEL.md`
---
## 1. Product Shape
dosh is a fast-attach remote terminal. It borrows the useful shape of mosh - UDP
transport, roaming, and latency-tolerant terminal rendering - but optimizes a
different first-order problem: getting the user back into an already-running terminal
as quickly as possible.
The daemon is resident. Sessions are named. A session owns one PTY and one
authoritative terminal screen. Clients attach to that session over encrypted UDP.
SSH is used for first trust establishment and as fallback when cached credentials are
missing, expired, or rejected. Native Dosh auth is specified separately in
`docs/NATIVE_V1_SPEC.md` as the path toward replacing the day-to-day SSH workflow on
Dosh-installed servers.
---
## 2. Design Goals
- Connection speed first:
- UDP resume: one encrypted UDP request and one encrypted UDP reply.
- UDP attach ticket: one encrypted UDP request and one encrypted UDP reply.
- Warm SSH bootstrap: existing-ControlMaster SSH command latency plus one UDP RTT.
- Cold SSH bootstrap: cold `ssh host` terminal-ready time plus at most one UDP RTT.
- No daemon spawn, PTY spawn, shell startup, rc file execution, or MOTD on attach to
an existing session.
- Prewarm configured sessions at daemon startup, including `default` by default.
- Encrypted UDP terminal data.
- Single configurable UDP port.
- Multiple clients attached to one session.
- Optional read-only clients.
- Named persistent sessions.
- Reuse existing SSH key infrastructure.
- Instrument connection timing from the first implementation.
---
## 3. Non-Goals
- Replacing SSH as the first public-key trust mechanism.
- Multi-user authorization or ACLs.
- Windows support in v0.
- Full mosh protocol compatibility.
- Perfect local echo/prediction in the first MVP.
- QUIC in v0. QUIC can be revisited if measurements show custom UDP is not enough.
---
## 4. Connection Speed Contract
Measure terminal-ready time: elapsed time from launching `dosh ...` to first usable
terminal render.
Benchmarks must use the same host, network, key, DNS path, and SSH config.
| Path | Acceptance gate |
| --- | --- |
| UDP resume | <= one measured UDP RTT + local render time |
| UDP attach ticket | <= one measured UDP RTT + local render time |
| Warm SSH bootstrap | <= `ssh host true` over existing ControlMaster + one measured UDP RTT |
| Cold SSH bootstrap | <= cold `ssh host` terminal-ready time + one measured UDP RTT |
| New session | Report separately; PTY/shell creation is expected |
Required timing evidence:
- Client stderr spans for credential lookup, SSH bootstrap, UDP resume, UDP ticket
attach, and terminal-ready time.
- `dosh-bench` samples for SSH `true`, Dosh attach, and optional ControlMaster-backed
SSH `true`.
- `make bench-docker-ssh` gates both cold SSH bootstrap and ControlMaster-backed SSH
bootstrap against containerized OpenSSH plus resident `dosh-server`.
---
## 5. Fast Path Order
The client always tries the cheapest path that is valid for the requested
host/user/session/mode:
1. **UDP resume**
- Requires cached `ClientId`, session key, server identity, and unexpired resume
metadata.
- Sends `ResumeRequest`.
- Receives `ResumeOk` with a snapshot or diff.
2. **UDP attach ticket**
- Requires cached attach ticket scoped to server identity, SSH username, session,
mode, and expiry.
- Sends `TicketAttachRequest`.
- Receives `AttachOk` with session key, `ClientId`, and snapshot.
3. **SSH bootstrap**
- Runs `ssh -T user@host ~/.local/bin/dosh-auth ...`.
- Receives attach token, attach ticket, session key material, and server metadata.
- Sends `BootstrapAttachRequest`.
- Receives `AttachOk` with `ClientId` and snapshot.
4. **New session**
- Same as attach, but if the session does not exist and is not prewarmed, the server
creates PTY and shell before first paint.
---
## 6. Architecture
```text
dosh-server
config loader
secret manager
UDP socket on one port
session table: HashMap<SessionName, Session>
optional prewarm of configured sessions
auth helper mode for SSH bootstrap
metrics/timing logger
Session
PTY master
child process/shell
terminal parser
authoritative screen model
scrollback ring
monotonic output sequence
client table: HashMap<ClientId, ClientState>
ClientState
ClientId
UDP endpoint
mode: read-write | view-only
session key id
last acked sequence
terminal size
last seen timestamp
dosh-client
config loader
local credential cache
terminal raw mode
UDP protocol engine
SSH bootstrap runner
reconnect state machine
renderer
```
Server hot-path ownership should avoid locks on every broadcast. A single event-loop
owner per session is preferred. Cross-thread designs are allowed only if benchmarked.
---
## 7. Security Model
SSH is the first trust root and the recovery/bootstrap fallback. The v0 core relies
on SSH for first authentication. Native v1 (`docs/NATIVE_V1_SPEC.md`) adds an opt-in
native public-key login over the Dosh UDP transport that is tried before SSH, with
its assets, attackers, and residual risks documented in `docs/THREAT_MODEL.md`. SSH
remains the explicit fallback and the host-key trust bootstrap path.
The UDP channel uses AEAD. Recommended default: `ChaCha20-Poly1305` for portable
speed, with `AES-GCM` allowed when hardware acceleration is known to be available.
The negotiated algorithm is recorded in the bootstrap response.
All encrypted packets use:
- Unique nonce per `(session_key_id, direction)`.
- Monotonic packet counter.
- Associated data containing protocol version, packet type, session name hash,
client id when known, and sequence numbers.
- Replay rejection using the packet counter window.
Secrets:
- `server_secret`: generated on first server start; stored mode `0600`.
- `session_key`: random 256-bit key per client attachment, rotated on SSH bootstrap
or ticket attach.
- `attach_ticket_key`: derived from `server_secret` and rotated by server key epoch.
No terminal bytes are sent outside AEAD after the attach handshake begins.
---
## 8. SSH Bootstrap Auth
Client command:
```bash
ssh -T user@host ~/.local/bin/dosh-auth \
--protocol 1 \
--nonce <client_nonce> \
--session <name> \
--mode <read-write|view-only> \
--size <cols>x<rows> \
--client-version <version>
```
`dosh-auth` must:
- Not allocate a PTY.
- Not start a shell.
- Not run user shell rc files.
- Read server config and secret directly.
- Return one compact binary or base64url response on stdout.
- Exit immediately.
Bootstrap response fields:
- `protocol_version`
- `server_id`
- `server_key_epoch`
- `issued_at`
- `expires_at`
- `user`
- `session`
- `mode`
- `terminal_size`
- `attach_token`
- `attach_ticket`
- `attach_ticket_psk`
- `session_key`
- `session_key_id`
- `udp_host`
- `udp_port`
- `aead_algorithm`
`attach_token = HMAC-SHA256(server_secret, user || session || mode || terminal_size ||
client_nonce || issued_at || expires_at || session_key_id)`.
The token TTL defaults to 30 seconds. Attach tickets default to 1 hour and are
server-configurable.
---
## 9. Attach Tickets
Attach tickets let a new client process attach without spawning SSH again.
Ticket properties:
- Server-sealed and authenticated by `attach_ticket_key`.
- Paired with a client-held random `attach_ticket_psk` returned during SSH bootstrap.
- Scoped to server identity, SSH username, session, mode, and key epoch.
- Short-lived by default.
- Revoked implicitly when server secret/key epoch changes.
- Stored client-side with mode `0600`, along with `attach_ticket_psk`.
Ticket attach does not prove fresh possession of the SSH private key. It proves recent
possession of a server-issued credential. This is acceptable for speed, configurable,
and can be disabled with `allow_attach_tickets = false`.
Ticket attach flow:
1. Client sends `TicketAttachRequest` containing the sealed ticket, client nonce, and
requested terminal size.
2. The request body is AEAD-encrypted with a key derived from
`HKDF(attach_ticket_psk, client_nonce || "ticket-attach-request")`.
3. Server opens the sealed ticket, validates scope/expiry/key epoch, derives the same
request key, and decrypts the request.
4. Server creates a fresh session key and `ClientId`.
5. `AttachOk` is AEAD-encrypted with
`HKDF(attach_ticket_psk, client_nonce || server_nonce || "ticket-attach-ok")` and
carries the fresh session key metadata plus first snapshot.
6. Subsequent terminal packets use the fresh session key, not the ticket PSK.
---
## 10. UDP Protocol
UDP port defaults to `50000`. One socket handles all sessions and clients.
Hot-path terminal packets use a fixed binary header:
```text
magic 4 bytes "DOSH"
version 1 byte 1
type 1 byte
flags 2 bytes
conn_id 16 bytes zero before client id is assigned
seq 8 bytes sender packet sequence
ack 8 bytes latest received peer sequence
body_len 2 bytes
body body_len bytes
tag AEAD tag, length depends on algorithm
```
Packet types:
| Type | Direction | Encrypted | Purpose |
| --- | --- | --- | --- |
| `BootstrapAttachRequest` | client -> server | token-authenticated | Attach after SSH bootstrap |
| `TicketAttachRequest` | client -> server | ticket PSK | Attach with cached ticket |
| `AttachOk` | server -> client | yes | Assign client id and send first snapshot |
| `AttachReject` | server -> client | no terminal bytes | Reject and require SSH |
| `ResumeRequest` | client -> server | yes | Resume known client |
| `ResumeOk` | server -> client | yes | Endpoint updated; diff/snapshot follows |
| `Input` | client -> server | yes | PTY input bytes |
| `Resize` | client -> server | yes | Terminal size update |
| `Frame` | server -> client | yes | Screen diff or PTY byte frame |
| `Ack` | both | yes | Ack without payload |
| `Ping` / `Pong` | both | yes | Keepalive and RTT |
| `Detach` | client -> server | yes | Remove client, keep session |
MTU target:
- Default payload target: 1200 bytes.
- Larger datagrams may be enabled only after path MTU discovery.
- Snapshots larger than the target are chunked.
Reliability:
- Input packets are reliable and ordered per client.
- Output frames are sequenced; clients ack rendered sequence.
- Server retransmits unacked frames within a bounded window.
- If a client falls too far behind, server sends a fresh snapshot instead of replaying
unlimited diffs.
---
## 11. Sessions and PTYs
Named sessions:
```bash
dosh host # open a fresh generated session
dosh --session work host # attach/create named persistent session
dosh --session work --view-only host
```
Session behavior:
- One PTY per session.
- Sessions persist until killed or server exits.
- If a session has zero clients, the PTY keeps running.
- Plain client attaches use generated session names by default.
- `--session <name>` intentionally reuses or shares a persistent session.
- Configured sessions are prewarmed at daemon startup.
- If a requested session does not exist:
- `attach` creates it only when `create_on_attach = true`.
- `new` always creates it and fails if it already exists.
Resize policy:
- One PTY means one size.
- Read-write clients may resize.
- View-only clients never resize.
- Default policy: latest read-write resize wins.
---
## 12. Screen State
Server maintains the authoritative terminal model:
- Visible grid.
- Cursor position and style.
- Alternate screen.
- Text attributes and colors.
- Scrollback ring.
- Monotonic output sequence.
Initial attach:
- Server sends a snapshot in the first UDP reply if it fits the packet budget.
- If not, server sends a minimal first frame immediately and follows with chunks.
Diffs:
- Diffs are computed per client from that client's last acked rendered sequence.
- Lagging clients may receive larger diffs or a full snapshot.
- Diffs are preferred over raw PTY bytes for reconnect correctness.
Encoding:
- Hot terminal frames use fixed binary headers and compact binary payloads.
- MessagePack is allowed only for non-hot control/list/config responses.
- JSON is not used on the protocol path.
---
## 13. Multi-Client Model
Default mode is shared input. Any read-write client can write to the session PTY.
All clients see the same resulting screen.
View-only mode:
- Client suppresses local input.
- Server rejects `Input` from view-only clients even if a malformed client sends it.
- Promotion/demotion requires reconnect.
Client timeout:
- Clients are removed after `client_timeout_secs` without ack/ping.
- The default is intentionally long enough for laptop sleep/resume, currently one
day. Short timeouts make Dosh fall back to attach tickets or SSH after sleep.
- Removing a client never kills the session.
---
## 14. Local Echo
MVP local echo is conservative:
- Printable keystrokes may be rendered optimistically only when the client is in a
simple shell line-editing state.
- Server output is always authoritative.
- On mismatch, client replaces local prediction with server state.
Full mosh-style predictive display is a later feature. It must not delay the first
implementation of fast attach/resume.
---
## 15. Reconnect and Roaming
Client detects possible disconnect when no server packet arrives for
`reconnect_timeout_secs`.
Reconnect order:
1. Send encrypted `ResumeRequest` to the configured host/port.
2. If accepted, update endpoint server-side and receive diff/snapshot.
3. If rejected, try attach ticket.
4. If ticket attach is rejected, run SSH bootstrap.
The server matches resume by `ClientId` and session key id, not by source address.
Successful resume updates the client's UDP endpoint.
---
## 16. Configuration
Server config: `~/.config/dosh/server.toml`
```toml
port = 50000
bind = "0.0.0.0"
scrollback = 5000
auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600
allow_attach_tickets = true
client_timeout_secs = 86400
retransmit_window = 256
default_input_mode = "read-write"
prewarm_sessions = ["default"]
create_on_attach = true
shell = "/usr/bin/zsh"
sessions_dir = "~/.local/share/dosh/sessions"
secret_path = "~/.config/dosh/secret"
```
Client config: `~/.config/dosh/client.toml`
```toml
server = "user@example.com"
update_repo = "https://git.palav.dev/Palav/dosh.git"
update_port = 50000
ssh_auth_command = "~/.local/bin/dosh-auth"
# ssh_port = 22
dosh_port = 50000
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
```
---
## 17. Performance-First Stack
Default implementation:
```text
# server/client shared
bytes
chacha20poly1305
aes-gcm optional
hmac
hkdf
sha2
rand
serde
toml
# server
mio or tokio # benchmark; single-thread hot path either way
rustix # PTY/process/syscall wrappers where possible
vt100 # authoritative terminal parser/model
# client
mio or tokio
crossterm # raw terminal mode
vt100 optional # only if client-side model is needed for prediction
```
Rules:
- Benchmark `mio` vs single-thread `tokio` before committing to runtime.
- Avoid locks on per-packet session broadcast.
- Preallocate packet buffers.
- Avoid serde on terminal frames.
- Keep `dosh-auth` tiny and static where practical.
- Optimize startup path before throughput.
---
## 18. MVP Scope
MVP must include:
- `dosh-server` daemon.
- `dosh-auth` SSH helper mode.
- `dosh-client`.
- One UDP port.
- Prewarmed `default` session.
- SSH bootstrap attach.
- Attach-ticket UDP attach.
- Encrypted UDP channel.
- UDP resume.
- Raw terminal input/output.
- Basic resize.
- Timing instrumentation.
MVP may defer:
- Sophisticated predictive local echo.
- Per-cell minimal diffs; raw frame plus snapshot fallback is acceptable initially if
reconnect correctness is preserved.
- Multi-session management commands beyond `attach`, `new`, `list`, and `kill`.
---
## 19. Verification Checklist
A build is not done until these are demonstrated:
- Cold attach timing compared against cold `ssh host`.
- Warm attach timing compared against `ssh host true` with ControlMaster.
- UDP resume completes without spawning SSH.
- Existing session attach does not spawn PTY or shell.
- Prewarmed `default` exists before first client.
- Terminal data is encrypted on UDP.
- Replay counters reject duplicate encrypted packets.
- View-only clients cannot write to PTY.
- Multiple clients see the same screen.
- Client survives source port/IP change by resume.
- Snapshot fallback repairs a lagging client.
- `README.md` and `SPEC.md` remain consistent with implemented behavior.
---
## 20. Status
Spec complete. The Rust implementation is present in this repository.
Implemented:
- Rust workspace and binaries: `dosh-server`, `dosh-client`, `dosh-auth`.
- Server config and secret creation.
- SSH/local bootstrap response generation.
- HMAC bootstrap verification.
- ChaCha20-Poly1305 encrypted UDP packets.
- Fixed DOSH packet header.
- Resident server with prewarmed named PTY sessions.
- Authoritative server-side `vt100` terminal parser.
- Full attach/resume snapshots from terminal screen state.
- Per-client screen-state diffs for broadcast frames.
- Raw terminal client attach.
- UDP resume from cached client credentials.
- Sealed attach-ticket UDP attach after server restart or unknown-client resume.
- Client ACKs and server-side bounded pending retransmit window.
- Sliding replay window for encrypted client packet counters.
- View-only server-side input rejection.
- Basic resize handling.
- Timing output for bootstrap and terminal-ready.
- `dosh-bench` benchmark harness for attach timing, SSH key/known-host options, and
ControlMaster-backed SSH measurement.
- Hardened user systemd unit.
- Release install script.
- Docker OpenSSH benchmark gate covering cold SSH bootstrap and ControlMaster-backed
SSH bootstrap.
- GitHub Actions CI for format, tests, release build, and Docker SSH benchmark gate.
- Optional GitHub Actions remote benchmark job gated by repository secrets.
- Auth/protocol tests.
- Integration smoke tests for local attach, ticket attach after server restart, and
view-only input rejection.
- Integration tests for retransmit, resize, multi-client shared screen, and UDP
endpoint roaming.
Optional deployment evidence:
- Configure `DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository
secrets to run the same benchmark against a real remote host in addition to the
Docker OpenSSH gate.
+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}");
}
-173
View File
@@ -1,173 +0,0 @@
# Dosh Benchmarks
This document explains how to run the Dosh benchmarks, what each metric means,
and how to read the results honestly. Dosh's headline claim is *insane speed on
repeat attach/reconnect* — these benchmarks exist to make that claim measurable
and reproducible.
> **Read first:** `docs/PUBLIC_READINESS.md`. The defensible public claim is fast
> attach/reconnect, not full Mosh feature parity, and **not** cold startup. Cite
> `dosh_cached_attach_ms` for the core speed claim; cite cold metrics only to show
> the fallback path stays competitive with ordinary SSH.
## TL;DR
```bash
make bench-local # safe, self-contained matrix on a throwaway server
make bench-local-json # same, machine-readable JSON with raw samples
make bench-docker-ssh # containerized SSH-vs-Dosh gate used by CI
make bench-docker-mosh # same, with Mosh installed for a three-way comparison
```
`make bench-local` never touches a running production server: it builds release
binaries, starts its own `dosh-server` bound to `127.0.0.1` on a random free UDP
port inside a temporary `HOME`, runs the matrix, prints raw samples plus summary
statistics, and tears everything down on exit. It will refuse to bind UDP port
`50000` (the default production port).
## The benchmark binary: `dosh-bench`
`dosh-bench` spawns the real `dosh-client` once per iteration and times the whole
process from launch to terminal-ready-and-detached. That wall-clock cost is the
apples-to-apples comparison against `ssh host true`: it is the startup price each
tool pays before any useful remote work begins.
Every run prints, per metric, a summary line (count, min, median, p95, mean, max
in milliseconds) **and** a raw-samples line. Pass `--json` for one machine-readable
object per run (with the full `samples_ms` array) so published numbers can always
include raw data, as required by `docs/PUBLIC_READINESS.md`.
### Path matrix
`dosh-bench` can benchmark these Dosh attach paths. Without an explicit path flag
it keeps its legacy single-path behavior (driven by `--local-auth` / `--warm-cache`
/ `--no-cache`) so existing CI scripts keep working.
| Flag | Metric | What it measures |
| --- | --- | --- |
| `--cold-native` | `dosh_cold_native_ms` | Native cold auth: full `--auth native --no-cache` handshake to first frame and detach. The cold fallback when no cache exists. |
| `--cached-ticket` | `dosh_cached_attach_ms` | Cached attach-ticket fast path. Warms the cache once, then measures repeat attaches using cached UDP credentials/tickets. **This is the core speed claim.** |
| `--resume` | `dosh_resume_ms` | UDP resume of a session whose endpoint changes. See the caveat below — only meaningful when a live session is kept open out-of-band. |
| `--local-auth` | `dosh_local_attach_ms` | Self-contained local bootstrap; no SSH, no native handshake. Useful as a lower-bound sanity check on loopback. |
| *(default, no flag)* | `dosh_attach_ms` | Cold SSH bootstrap plus UDP attach (legacy single-path mode). |
Existing baseline/comparison metrics are unchanged:
| Metric | Meaning |
| --- | --- |
| `ssh_true_ms` | `ssh host true` with the same key/options. |
| `mosh_start_true_ms` | `mosh host -- true` bootstrap, run `true`, exit (timed inside a PTY). |
### Assertions / gates
| Flag | Gate |
| --- | --- |
| `--assert-ssh-plus-ms N` | The primary Dosh metric must be ≤ `ssh_true_ms` mean + `N` ms. |
| `--assert-mosh-minus-ms N` | The primary Dosh metric must be at least `N` ms faster than `mosh_start_true_ms`. |
| `--assert-dosh-max-ms N` | The primary Dosh metric mean must be ≤ `N` ms. |
The "primary Dosh metric" for assertions is, in priority order:
`dosh_cached_attach_ms``dosh_resume_ms``dosh_cold_native_ms`
`dosh_attach_ms``dosh_local_attach_ms`.
## What each benchmark does
### `make bench-local` (`scripts/bench-local.sh`)
Self-contained, no SSH, no Docker. It:
1. Builds release binaries (`DOSH_BENCH_PROFILE=debug` for the debug profile).
2. Picks a free UDP port (never `50000`) and a temp `HOME`.
3. Generates a throwaway client identity (`ssh-keygen`), authorizes it on the
server, and writes a throwaway server + client config (native auth,
trust-on-first-use, localhost UDP).
4. Starts `dosh-server serve` bound to `127.0.0.1` on that port.
5. Runs `dosh-bench --cold-native --cached-ticket`, then `--local-auth --no-cache`.
6. Sanity-checks that native cold auth actually trusted the host (so a silent
config-parse fallback can't make the benchmark measure the wrong path).
7. Kills the server and removes the temp `HOME` on exit.
Tunables: `scripts/bench-local.sh [ITERATIONS]` (default 20), `DOSH_BENCH_JSON=1`,
`DOSH_BENCH_PROFILE=release|debug`.
### `make bench-docker-ssh` / `make bench-docker-mosh` (`scripts/ci-docker-ssh-bench.sh`)
Builds one Ubuntu image with OpenSSH, `dosh-server`, `dosh-auth` (and Mosh for the
`-mosh` target), then runs `ssh_true_ms`, cold + ControlMaster `dosh_attach_ms`,
`dosh_cached_attach_ms`, and optionally `mosh_start_true_ms` against the same
container, key, and loopback network path. This is the gate CI enforces; it asserts
cold Dosh stays within 500 ms of SSH and that cached attach stays under a small
budget. See `README.md` "Develop" for the exact invocations.
## Methodology and caveats
- **Same machine, same network path.** `bench-local` runs everything on loopback,
so it isolates Dosh's own process/handshake/render overhead and removes network
RTT from the comparison. Real-world cached attach is approximately *network RTT
plus the local overhead these numbers measure*. Publish the machine, OS, CPU,
network path, and sample count alongside any numbers.
- **Wall-clock of the whole client process.** Each sample includes process spawn,
attach, first-frame render, and detach. That is deliberately the same thing
`ssh host true` pays, but it means a few ms is fixed process-startup cost, not
protocol cost.
- **Do not cite cold metrics as the headline.** `dosh_cold_native_ms` and
`dosh_attach_ms` still pay first-authentication cost. They exist to show the
fallback path stays competitive with ordinary SSH. Cite `dosh_cached_attach_ms`
for repeat attach/reconnect, per `docs/PUBLIC_READINESS.md`.
- **UDP resume is the roaming path, not cold reconnect.** Resume reconnects a
client that is *still attached* when its network endpoint changes (sleep/Wi-Fi
switch). The `--attach-only` benchmark model detaches after every iteration,
which removes the client on the server, so a fresh-process resume has nothing
live to resume and is not meaningful in `bench-local`. The cold fresh-process
reconnect fast path is the attach ticket (`dosh_cached_attach_ms`). Roaming
resume correctness is covered by the integration test
`resume_updates_udp_endpoint_for_roaming` in `tests/integration_smoke.rs`.
`dosh-bench --resume` remains available for scenarios that keep a live session
open out-of-band (for example remote soak tests).
- **These are not identical workloads.** SSH/Mosh/Dosh do different work at
startup. The comparison is still useful because it measures the startup tax each
tool charges before useful remote work begins.
## Sample results (loopback, self-contained)
Captured with `make bench-local` (`scripts/bench-local.sh 30`), all times in
milliseconds, 30 samples per metric.
- Machine: Intel Core i5-9500 @ 3.00 GHz, 6 cores
- OS: Ubuntu 24.04.4 LTS, Linux 6.8.0-124-generic, x86_64
- Toolchain: rustc 1.96.0, release profile
- Network path: loopback (`127.0.0.1`), throwaway server on a free UDP port
- Workload: `dosh-client --attach-only` to first frame and detach
| Metric | n | min | median | p95 | mean | max |
| --- | --- | --- | --- | --- | --- | --- |
| `dosh_cold_native_ms` | 30 | 8.10 | 9.01 | 10.40 | 9.18 | 10.82 |
| `dosh_cached_attach_ms` | 30 | 2.73 | 2.96 | 3.25 | 2.96 | 3.30 |
| `dosh_local_attach_ms` | 30 | 2.66 | 2.78 | 3.24 | 2.87 | 3.33 |
Raw samples (ms):
```
dosh_cold_native_ms:
8.32, 8.23, 10.31, 10.37, 8.83, 8.81, 9.20, 9.04, 10.14, 8.98, 10.82, 9.87,
10.22, 10.42, 9.76, 9.09, 9.19, 8.94, 8.69, 8.67, 8.74, 9.33, 9.25, 8.92, 8.39,
8.55, 8.38, 9.81, 8.17, 8.10
dosh_cached_attach_ms:
2.99, 2.99, 2.93, 2.87, 2.95, 2.80, 2.80, 2.90, 3.10, 2.73, 3.19, 2.80, 2.87,
2.82, 2.89, 3.03, 3.06, 3.21, 2.97, 2.83, 3.00, 2.82, 3.04, 2.97, 3.03, 3.24,
3.30, 2.73, 2.79, 3.25
dosh_local_attach_ms:
3.05, 3.00, 2.84, 3.04, 3.33, 2.89, 2.76, 2.74, 2.73, 3.32, 3.14, 2.75, 2.83,
2.88, 2.99, 2.77, 2.75, 2.79, 2.69, 2.78, 2.75, 2.77, 2.75, 2.77, 3.03, 2.75,
2.94, 2.74, 2.82, 2.66
```
Reading these numbers: cold native auth is ~9 ms because it pays the native
handshake; cached attach is ~3 ms because it reuses cached credentials and skips
the handshake entirely. On loopback there is no network RTT, so this is the local
overhead floor. Over a real link, expect cached attach ≈ this floor + one network
round trip — which is exactly the "near network RTT" target in the spec. There is
no SSH/Mosh baseline in this loopback table because those paths need an SSH server;
use `make bench-docker-ssh` / `make bench-docker-mosh` for the head-to-head.
-646
View File
@@ -1,646 +0,0 @@
# Dosh Native v1 Spec
> **v1 status (annotation, not part of the spec text below).** Native v1 is
> substantially implemented and being stabilized; it is not yet fully verified.
> Milestone progress against section 15:
>
> - Milestone 1 — host identity and trust: **done.** Host key generation, `dosh trust`,
> `known_hosts`, and mismatch hard-fail are implemented.
> - Milestone 2 — native user auth: **done.** `ClientHello`/`ServerHello`/`UserAuth`/
> `AuthOk`, ssh-agent and encrypted-key Ed25519, and `authorized_keys` verification
> exist. ECDSA/RSA user keys are still pending (Ed25519 only today).
> - Milestone 3 — default native auth: **done.** `auth_preference = "native,ssh"` is
> the default with explicit, visible SSH fallback. Cold-auth benchmark gates are
> pending (Track C / `BENCHMARKS.md`).
> - Milestone 4 — forwarding: **done.** Stream mux, `-L`, `-R`, `-D`, `-N`, `-f`, and
> per-stream flow control are implemented; hostile-network and load tests pending.
> - Milestone 5 — hardening: **in progress.** Full per-IP token-bucket rate limiting,
> protocol VERSION negotiation hardening, fuzzing in CI, and external review are not
> yet complete. The threat model is published (`docs/THREAT_MODEL.md`).
> - Milestone 6 — workflow parity: **mostly done.** `dosh doctor`, host-trust
> management, and the encrypted-key prompt flow exist; cross-OS daily-driver soak is
> ongoing.
>
> The section 16 verification checklist is **not yet fully green** — see the
> item-by-item status table in `docs/PUBLIC_READINESS.md` and the residual-risk list in
> `docs/THREAT_MODEL.md`. The section 17 public-claim gate is therefore **not met**.
Native Dosh is a remote-login protocol for Dosh-installed servers. It is intended to
replace the user's day-to-day `ssh host` workflow for terminals and forwarding while
keeping SSH as a compatibility and recovery fallback.
Native Dosh is not an RFC-compatible SSH implementation. It deliberately avoids the
full SSH transport/channel protocol and implements the smaller set of behavior Dosh
needs: authenticated login, encrypted terminal transport, reconnect/roaming, and TCP
forwarding.
## 1. Product Contract
User-facing commands:
```bash
dosh host
dosh host command...
dosh -L 8080:127.0.0.1:80 host
dosh -R 9000:127.0.0.1:9000 host
dosh --session work host
dosh --view-only --session work host
```
Compatibility expectations:
- Existing SSH keys and `ssh-agent` are reused.
- Server-side authorization uses `~/.ssh/authorized_keys`.
- Host trust is pinned in a Dosh known-hosts file and can be bootstrapped by SSH.
- `~/.ssh/config` host aliases, `HostName`, `User`, `Port`, `IdentityFile`,
`ProxyJump`, and `UserKnownHostsFile` are honored where practical.
- SSH bootstrap remains available with `--auth=ssh` and is used automatically when
native auth is disabled or cannot complete.
Native v1 is complete only if a normal interactive user can switch their daily
workflow from `ssh` to `dosh` without keeping SSH open in another tab for routine
tasks.
Non-goals for v1:
- RFC-compatible SSH server/client behavior.
- Arbitrary SSH subsystems.
- X11 forwarding.
- SFTP/SCP compatibility.
- Multi-user daemon mode with privileged account switching.
- Replacing OpenSSH on hosts that do not run `dosh-server`.
## 2. SSH Workflow Parity Target
Native v1 targets the SSH workflows interactive users actually use, not the entire
OpenSSH feature universe.
Must work in v1:
- Interactive shell: `dosh host`.
- Remote command: `dosh host command...`.
- Fresh terminal by default.
- Named persistent terminal: `dosh --session work host`.
- Shared/view-only session: `dosh --view-only --session work host`.
- Local forwarding: `dosh -L [bind:]listen:target:target_port host`.
- Remote forwarding: `dosh -R [bind:]listen:target:target_port host`.
- Dynamic forwarding: `dosh -D [bind:]listen host`, if stream flow control is proven;
otherwise it is the first v1.1 item and must be called out honestly.
- Multiple forwards in one connection.
- Forward-only mode: `dosh -N -L ... host`.
- Background forwarding: `dosh -f -N -L ... host`, or an equivalent supervised
user-service mode.
- SSH-agent authentication.
- Encrypted OpenSSH private-key authentication with prompt.
- `~/.ssh/config` host aliases for `Host`, `HostName`, `User`, `Port`,
`IdentityFile`, `ProxyJump`, `UserKnownHostsFile`, and `IdentitiesOnly`.
- Dosh host config overrides for user, Dosh-specific UDP host/port/auth policy.
- Clear host-key trust and mismatch errors.
- Clear auth failure errors that name the attempted key source.
- Stable reconnect after sleep, network switch, NAT rebinding, and server packet loss.
- `dosh update`.
- `dosh doctor host` for config/auth/UDP reachability diagnostics.
- `dosh sessions host` for session visibility.
Should work in v1 if it does not compromise the transport schedule:
- Agent forwarding with an explicit opt-in flag and clear warning.
- `ProxyJump` through SSH for bootstrap/trust and through Dosh-native relay later.
- `SendEnv`/`SetEnv` equivalent for explicit environment variables.
- Clipboard integration as a separate opt-in channel.
Explicitly not v1:
- X11 forwarding.
- SFTP/SCP wire compatibility.
- Full OpenSSH config language.
- Full `sshd` replacement for arbitrary SSH clients.
- Forced-command subsystems beyond fail-closed enforcement.
## 3. Stability Contract
Native v1 must be boring under real use:
- A closed laptop must not kill the remote session.
- A client crash must not kill the remote session.
- A server restart must not kill the remote session when `persist_sessions` is on
(the default): each session's shell runs in a detached per-session *holder*
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.
- 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 = []
```
Server:
```toml
native_auth = true
host_key = "~/.config/dosh/host_key"
authorized_keys = ["~/.ssh/authorized_keys", "~/.config/dosh/authorized_keys"]
native_auth_rate_limit_per_minute = 30
attach_ticket_ttl_secs = 86400
allow_tcp_forwarding = true
allow_remote_forwarding = false
allow_remote_non_loopback_bind = false
allow_agent_forwarding = false
accept_env = ["LANG", "LC_*", "TERM", "COLORTERM"]
```
## 15. Migration Plan
Milestone 1: host identity and trust
- Generate Dosh host key on server install.
- Add `dosh trust host`.
- Add known-hosts file and mismatch handling.
- Keep SSH bootstrap as the only auth path.
Milestone 2: native user auth
- Implement `ClientHello`/`ServerHello`/`UserAuth`/`AuthOk`.
- Support ssh-agent Ed25519 first.
- Verify against `authorized_keys`.
- Add `--auth=native|ssh|auto`.
Milestone 3: default native auth
- Make `auth_preference = "native,ssh"` default.
- Keep SSH fallback explicit and visible.
- Add benchmark gates for native cold auth.
Milestone 4: forwarding
- Add stream mux.
- Add `-L`, `-N`, and `-f`.
- Add `-R`.
- Add `-D` only after flow control is proven.
Milestone 5: hardening
- Fuzz packet parsing, authorized-key parsing, known-host parsing, and handshake state.
- Add hostile-network integration tests.
- Add external review checklist before public security claims.
Milestone 6: workflow parity
- Implement `dosh doctor`.
- Implement complete host-trust management.
- Implement private-key prompt flow.
- Implement forwarding policy diagnostics.
- Run daily-driver soak on macOS and Linux clients.
## 16. Verification
Native v1 is not complete until all are true:
- Unknown host key fails by default unless TOFU is explicitly enabled.
- Known host key mismatch hard fails.
- Native Ed25519 auth succeeds via ssh-agent.
- Native Ed25519 auth succeeds via encrypted private key prompt.
- Removed authorized key can no longer authenticate.
- Restrictive unsupported authorized-key options fail closed.
- Replayed handshake packets are rejected.
- Replayed transport packets are rejected.
- Stale encrypted packets after reconnect are ignored, not fatal.
- Client IP/port change preserves the session.
- Native cold auth benchmark beats cold `ssh host true` on the same host.
- Cached attach remains near network RTT plus local render overhead.
- `-L` forwarding works without delaying terminal input.
- `-R` forwarding enforces bind and permission policy.
- `-N -L` forward-only mode does not allocate a PTY.
- `-f -N -L` backgrounds only after listener readiness.
- Multiple forwards in one command work.
- `dosh doctor` identifies UDP-blocked, auth-denied, host-key-mismatch, and
forwarding-denied states.
- Closing the laptop for at least 30 minutes does not kill the remote session.
- Three concurrent Dosh terminals remain independent unless explicitly named.
- Large forwarded transfers do not add visible terminal input lag.
- Fuzz targets run in CI.
- Threat model is updated with any accepted residual risks.
## 17. Public Claim Gate
Dosh may claim "native SSH replacement for Dosh-installed servers" only after:
- Native auth is default on at least one real host.
- SSH fallback remains available.
- The verification checklist is green.
- The threat model is published.
- Benchmarks include raw samples for SSH cold, Dosh native cold, Dosh cached attach,
and Mosh startup.
Dosh must not claim generic SSH compatibility unless it implements the SSH protocol.
-186
View File
@@ -1,186 +0,0 @@
# Dosh Public Readiness
Dosh's defensible public claim is fast terminal attach and reconnect. It should not
claim full Mosh replacement status until the feature matrix below is green and the
comparison benchmark is reproducible outside the author's homelab.
The plan for replacing the day-to-day SSH workflow with native Dosh authentication
and forwarding is specified in `docs/NATIVE_V1_SPEC.md`, and the published threat
model is in `docs/THREAT_MODEL.md`. Native v1 is now substantially implemented:
native key-exchange + user auth, host-key pinning/trust, `-L`/`-R`/`-D` forwarding,
and `dosh doctor` all exist (see the feature matrix and the verification-checklist
status table below). It is **not yet fully verified**: per-IP token-bucket rate
limiting, protocol-version negotiation hardening, fuzzing in CI, and external review
are still open. Until the verification checklist (`NATIVE_V1_SPEC.md` section 16) is
green and that review is done, Dosh's defensible public security claim remains fast
encrypted native attach/reconnect with SSH-equivalent transport security and an
explicit SSH bootstrap fallback — not a fully verified, externally reviewed SSH
replacement.
## Objective Benchmarks
Run the same-host SSH comparison:
```bash
make bench-docker-ssh
```
Run the Dosh, SSH, and Mosh comparison:
```bash
make bench-docker-mosh
```
The Mosh-inclusive target builds one Docker image with OpenSSH, Mosh, `dosh-server`,
and `dosh-auth`. It uses the same generated key, localhost network path, SSH server,
and container for all samples.
Reported metrics:
| Metric | Meaning |
| --- | --- |
| `ssh_true_ms` | Time to run `ssh host true` with the same key/options. |
| `dosh_attach_ms` | Time for `dosh-client --attach-only` to render the first frame and detach on the configured path. With `--no-cache`, this is cold SSH bootstrap plus UDP attach. |
| `dosh_cached_attach_ms` | Time for a warmed Dosh client to attach using cached UDP credentials/tickets, render the first frame, and detach. This is the fast-path claim and should be approximately network RTT plus local process/render overhead. |
| `mosh_start_true_ms` | Time for `mosh host -- true` to bootstrap, run `true`, and exit. |
These are not identical workloads. They are still useful because they measure the
startup path each tool must pay before useful remote work begins. Public numbers
must include the command, machine, OS, CPU, network path, and sample count.
Do not cite cold `dosh_attach_ms` as the core speed claim. Cold Dosh still pays SSH
startup/authentication. Cite `dosh_cached_attach_ms` for repeat attach/reconnect
speed, and cite cold `dosh_attach_ms` only to show that fallback remains competitive
with ordinary SSH.
## Feature Matrix
| Feature | Mosh | Dosh now | Public status |
| --- | --- | --- | --- |
| SSH-based first authentication | yes | yes | ready |
| Native UDP key auth (no SSH per attach) | no | yes, Ed25519 via ssh-agent or encrypted key | implemented; pending full verification |
| Dosh host-key pinning and trust | no | yes, `known_hosts` + `dosh trust` + mismatch hard-fail | implemented |
| `authorized_keys` policy enforcement | no | yes, `from=`/`no-port-forwarding`/`permitopen=`, unsupported fail closed | implemented |
| `dosh doctor` diagnostics | no | yes, config/auth/UDP/forwarding-policy check | implemented |
| Encrypted UDP terminal data | yes | yes | ready |
| Roaming by client address change | yes | yes | needs more hostile-network tests |
| Survive sleep or network loss | yes | yes | needs long-running soak tests |
| Fast repeat attach without SSH | no | yes, via attach tickets | core differentiator |
| Resident server daemon | no | yes | core differentiator |
| One UDP port for all sessions | port range by default | yes | ready |
| Fresh session by default | yes | yes | ready |
| Named persistent sessions | no built-in shared session model | yes | ready |
| Multiple clients on one session | no | yes | needs conflict-policy docs |
| View-only clients | no | yes | ready |
| Full-screen TUI correctness | yes | improving | must stay green before public push |
| Predictive local echo | mature | guarded printable-only opt-in | not parity |
| Non-destructive disconnect UI | yes | not currently | needed |
| Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
| X11 forwarding | no | no | non-goal unless tunneled separately |
| SSH agent forwarding | no | no | planned as forwarding channel |
| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; needs hostile-network tests |
| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; needs hostile-network tests |
| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented; needs hostile-network tests |
| Forward-only / background forwarding, `-N` / `-f` | no | yes, `-f` requires `-N` | implemented |
| Per-stream flow control / terminal priority | no | yes, windowed credit per stream | implemented; needs load tests |
## SSH Config Inheritance
Dosh intentionally delegates SSH parsing and authentication to OpenSSH. Bootstrap
uses the user's normal `ssh` command, so `~/.ssh/config` options such as `Host`,
`HostName`, `User`, `Port`, `IdentityFile`, `ProxyJump`, and `UserKnownHostsFile`
continue to apply.
Dosh also calls `ssh -G <alias>` to infer the UDP target host when no `dosh_host` is
configured. To write explicit Dosh host entries from SSH aliases:
```bash
dosh import-ssh palav homelab
```
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
OpenSSH config parser.
## Forwarding (Implemented)
SSH forwarding cannot be copied by keeping the bootstrap SSH connection open,
because that would remove Dosh's fast reconnect advantage and would break after
roaming. Dosh forwarding therefore runs as native encrypted stream channels over the
Dosh transport, and is now implemented.
| CLI | Meaning |
| --- | --- |
| `dosh -L 8080:127.0.0.1:80 host` | Local listener on the client; server connects to target. |
| `dosh -R 8080:127.0.0.1:80 host` | Remote listener on the server (loopback by default); client connects to target. |
| `dosh -D 1080 host` | SOCKS5 listener on the client; server connects to whatever the SOCKS request names. |
Implemented:
- `StreamOpen`/`StreamOpenOk`/`StreamOpenReject`/`StreamData`/`StreamWindowAdjust`/
`StreamEof`/`StreamClose` packet types.
- Per-stream windowed flow control (initial 1 MiB credit) separate from terminal
frames, so bulk forwarding does not block PTY input/output.
- Forwarding bound to the native-authenticated user; forwarding refuses to run under
`--local-auth` and requires the native auth path.
- Server-side policy enforcement: `allow_tcp_forwarding`, `allow_remote_forwarding`,
loopback-only remote bind unless `allow_remote_non_loopback_bind`, plus per-key
`no-port-forwarding` and `permitopen=`.
- `-N` forward-only (no PTY) and `-f` background (requires `-N`, backgrounds only
after listeners are ready).
Still open before claiming forwarding parity:
- A dedicated dropped/reordered/replayed-UDP forwarding test suite.
- Load tests proving large forwarded transfers add no visible terminal input lag.
## Native v1 Verification Checklist Status
This maps each item in `NATIVE_V1_SPEC.md` section 16 to its status based on what the
code in `src/` actually does. Done = implemented and exercised by tests or obvious
from code; in progress = partially implemented; pending = not yet implemented.
| Spec section 16 item | Status | Evidence / note |
| --- | --- | --- |
| Unknown host key fails unless TOFU explicitly enabled | done | Client refuses `KnownHostStatus::Unknown` unless `trust_on_first_use`. |
| Known host-key mismatch hard fails | done | `KnownHostStatus::Mismatch` aborts; `trust_host` refuses overwrite without `--replace`. |
| Native Ed25519 auth via ssh-agent | done | `src/ssh_agent.rs` signs the user-auth transcript. |
| Native Ed25519 auth via encrypted key prompt | done | `load_ed25519_identity_with_passphrase` decrypts OpenSSH keys. |
| Removed authorized key can no longer authenticate | done | Covered by `native_user_auth_accepts_authorized_key_and_rejects_removed_key`. |
| Unsupported authorized-key options fail closed | done | `ensure_native_allowed` bails on any unsupported option. |
| Replayed handshake packets rejected | done | Handshake is transcript-bound and signature-verified; pending entries TTL-evicted. |
| Replayed transport packets rejected | done | `ReplayWindow` (128-wide) over per-direction counter. |
| Stale encrypted packets after reconnect ignored, not fatal | done | `session_key_id` mismatch drops the packet instead of erroring fatally. |
| Client IP/port change preserves session | done | Server matches by `ClientId`/session key id, updates endpoint. |
| Native cold auth beats cold `ssh host true` | pending | Benchmark gate not yet run for native cold path (Track C / `BENCHMARKS.md`). |
| Cached attach near network RTT | pending | Same benchmark dependency. |
| `-L` works without delaying terminal input | in progress | Implemented with per-stream window; load proof pending. |
| `-R` enforces bind and permission policy | done | `remote_bind_allowed` + `start_remote_forwards` policy checks. |
| `-N -L` does not allocate a PTY | done | `forward-only` mode skips PTY allocation. |
| `-f -N -L` backgrounds only after listener readiness | done | `spawn_background_forwarder` waits for a readiness token. |
| Multiple forwards in one command | done | Forward lists parsed and started together. |
| `dosh doctor` identifies UDP-blocked/auth-denied/mismatch/forwarding-denied | done | `run_doctor_command` reports each state. |
| Closing laptop 30+ min does not kill session | in progress | Long `client_timeout_secs` + resume; 30-min soak evidence pending. |
| Three concurrent terminals independent unless named | done | Generated session names per attach; named sessions shared on purpose. |
| Large forwarded transfers add no visible input lag | in progress | Per-stream flow control exists; load test pending. |
| Fuzz targets run in CI | pending | No `fuzz/` dir; CI runs fmt/test/build/bench only. |
| Threat model updated with accepted residual risks | done | `docs/THREAT_MODEL.md`. |
Additional security hardening tracked outside the section 16 list:
- Full per-IP token-bucket rate limiting: in progress (another track). Today only
handshake-map eviction and a static `rate_limit_remaining` hint exist.
- Protocol VERSION negotiation: in progress. Single version, fail-closed reject; no
multi-version negotiation yet.
- ECDSA P-256 / SHA-2 RSA user keys: pending. Ed25519 only today.
## Before Public Launch
- Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green.
- Add a non-destructive disconnect indicator.
- Run scripted TUI tests for alternate-screen apps, arrow keys, resize, mouse mode,
bracketed paste, and terminal cleanup.
- Publish benchmark output with raw samples, not just averages.
- Mark prediction as experimental until it has a real framebuffer model.
- Land full per-IP token-bucket auth rate limiting and wire fuzz targets into CI.
- Complete the native-v1 verification checklist above and an external security review
before making any "native SSH replacement" claim (`NATIVE_V1_SPEC.md` section 17).
-232
View File
@@ -1,232 +0,0 @@
# Dosh Threat Model
This is the published threat model for Dosh native v1, derived from
`docs/NATIVE_V1_SPEC.md` sections 4-6. It states the assets Dosh protects, the
attackers it does and does not defend against, the security properties Dosh claims
relative to SSH, the cryptographic building blocks actually in use, and an honest
list of accepted residual risks and known gaps.
Dosh is a remote-login transport for Dosh-installed servers. It is intended to
replace the day-to-day `ssh host` workflow for terminals and TCP forwarding while
keeping OpenSSH as a recovery and bootstrap fallback. Dosh is **not** an
RFC-compatible SSH implementation and does not claim SSH's entire protocol security
surface. It claims security that is **equivalent to, and in some respects stronger
than, SSH for the Dosh terminal and forwarding use case** on hosts running
`dosh-server`.
## 1. Assets
Dosh protects:
- **Terminal session contents.** Keystrokes, command output, and the authoritative
screen state for every named or generated session.
- **Forwarded TCP streams.** Bytes carried over `-L`, `-R`, and `-D` channels.
- **User authentication credentials.** The user's SSH/Dosh private keys and any
ssh-agent identities. Dosh never sees private key material in plaintext on the
wire; signatures are produced locally or by the agent.
- **Server-issued credentials.** Session keys, `ClientId` association state,
server-sealed attach tickets, and the client-held attach-ticket PSK.
- **Server identity.** The persistent Dosh host key (`~/.config/dosh/host_key`) and
the server secret used to seal attach tickets and derive bootstrap material.
- **Host-trust state.** The client's pinned known-hosts file
(`~/.config/dosh/known_hosts`).
- **Authorization policy.** `~/.ssh/authorized_keys` /
`~/.config/dosh/authorized_keys` and the forwarding policy they encode.
## 2. Attackers
### In scope (Dosh must defend against these)
- **Passive network observer.** May record all UDP traffic between client and
server.
- **Active network attacker.** May spoof, drop, replay, reorder, or modify any
packet, and may attempt to inject forged packets in either direction.
- **NAT rebinding / roaming.** Client source IP and port may change mid-session,
including across sleep, network switch, and NAT timeout.
- **Stolen attach-ticket cache without the user's private key.** An attacker who
reads a client cache copies a server-sealed ticket plus its PSK but does not have
the user's SSH private key.
- **Server restart and key rotation.** Stale session keys, tickets, and replay
state must not be usable after the server rotates its secret or host key.
- **Malicious unauthenticated client flooding auth attempts.** A peer that has no
authorized key tries to exhaust server resources or guess credentials.
- **Compromised low-privilege local user on a shared client.** A different local
user attempts to read Dosh credential caches on the same machine.
### Out of scope (explicitly not defended against)
- **Compromised client machine.** If the endpoint running `dosh-client` is owned by
the attacker, the attacker has the user's keys and terminal.
- **Compromised server account.** If the login account on the server is owned, the
attacker already has the shell Dosh would have given them.
- **Malicious kernel, terminal emulator, or PTY implementation** on either side.
- **A server that was legitimately authorized and later turns malicious.** Host-key
pinning detects a *substituted* server, not a trusted server that decides to
misbehave.
These exclusions match SSH's own boundaries: SSH likewise cannot protect a
compromised endpoint or a malicious authorized peer.
## 3. Security Properties Claimed vs SSH
| Property | SSH | Dosh native v1 | Notes |
| --- | --- | --- | --- |
| Server authentication before trusting session data | yes | yes | Host key signs the handshake transcript; client verifies before sending user auth or accepting terminal bytes. |
| User authentication by private-key possession | yes | yes | Ed25519 via ssh-agent or encrypted OpenSSH key; signature binds the full transcript. |
| Forward secrecy | yes | yes | Ephemeral X25519 per connection; long-term host/user keys never derive the traffic key. |
| AEAD on every post-handshake packet | yes | yes | ChaCha20-Poly1305 with per-direction, per-sequence nonces. |
| Replay protection | yes | yes | Sliding replay window over the AEAD packet counter, plus transcript-bound handshake. |
| Host-key pinning with explicit first use | TOFU, weakly tied to transport | yes, with explicit policy | Default refuses unknown host keys; TOFU only when `trust_on_first_use` is set; mismatch hard-fails and never auto-replaces. |
| No plaintext terminal bytes after handshake | yes | yes | All `Frame`/`Input`/stream packets are AEAD-sealed. |
| No custom cryptographic primitives | yes | yes | Standard X25519/HKDF-SHA256/ChaCha20-Poly1305/Ed25519 crates only. |
| Fail-closed downgrade behavior | yes | yes | Native auth failure surfaces an explicit error and SSH fallback is explicit; it never silently drops to an unauthenticated mode. |
| Fast resumption without re-auth | ControlMaster only | yes, native | Cached session/ticket attach skips a fresh round of public-key proof; this is a deliberate speed/security trade discussed in section 6. |
### Where Dosh aims to *exceed* SSH for this use case
- **Tighter transcript binding.** A user-auth signature binds both ephemeral keys,
both randoms, the server host key, requested user/session/mode/terminal size,
selected algorithms, and protocol version into one transcript. This forecloses
cross-protocol and partial-replay confusion classes for the narrow Dosh surface.
- **Smaller attack surface.** Dosh deliberately omits the full SSH transport/channel
machinery, arbitrary subsystems, X11, SFTP/SCP, and forced-command subsystems.
Fewer features means fewer parsers and fewer reachable states.
- **Explicit, file-pinned host trust.** Host trust is a first-class, inspectable
known-hosts entry with source provenance (`tofu`/`ssh`/`manual`) and a hard-fail
mismatch path, rather than the looser default TOFU behavior most SSH clients ship.
- **Modern primitives only.** Ed25519 and X25519 by default; no DSA, no SHA-1
signatures, no CBC-and-MAC constructions.
Dosh does **not** claim generic SSH compatibility and must not be described as an
SSH-protocol implementation.
## 4. Cryptographic Building Blocks (as implemented)
These reflect the code in `src/crypto.rs`, `src/native.rs`, `src/auth.rs`, and
`src/protocol.rs`, not just the spec.
- **Key exchange:** X25519 ephemeral-ephemeral (`x25519-dalek`). The shared secret
is checked for contributory behaviour; a non-contributory result is rejected.
- **Handshake/transport KDF:** HKDF-SHA256 (`hkdf`), salted with the SHA-256 of the
serialized `ClientHello` and `ServerHello`, binding traffic keys to the transcript.
- **AEAD:** ChaCha20-Poly1305 (`chacha20poly1305`) for every encrypted packet and
for sealed attach tickets. Nonces are derived as `direction || sequence`, giving a
unique nonce per `(key, direction, sequence)`. AES-GCM is reserved for later and is
not selectable today.
- **Host-key signatures:** Ed25519 (`ed25519-dalek`) over the handshake transcript.
- **User-auth signatures:** Ed25519, produced either by ssh-agent over a Unix socket
(`src/ssh_agent.rs`) or from an encrypted/plaintext OpenSSH private key
(`ssh-key`). The signature covers the user-auth transcript described above.
- **Bootstrap auth (SSH fallback path):** HMAC-SHA256 attach tokens and HKDF-SHA256
derived session keys, with attach tickets sealed under an HKDF-derived
ticket key. Token comparison is constant-time.
- **Hashing/transcript:** SHA-256 (`sha2`).
- **Randomness:** OS CSPRNG via `rand::thread_rng()` / `OsRng`.
No homegrown ciphers, MACs, padding schemes, or key derivation are used. No nonce or
key pair is reused within a direction.
## 5. How the Properties Are Enforced (handshake and transport)
- **Server authentication.** `ServerHello` carries the host public key and an Ed25519
signature over `dosh/native/server-hello/v1 || ClientHello || ServerHello(unsigned)`.
The client verifies this signature *and* checks the host key against its pinned
known-hosts entry before sending user auth or accepting terminal bytes. Unknown
keys are refused unless TOFU is enabled; mismatches hard-fail with both
fingerprints and the file path.
- **User authentication.** `UserAuth` carries an Ed25519 signature over
`dosh/native/user-auth/v1 || ClientHello || ServerHello || UserAuth(unsigned)`. The
server looks the public key up in `authorized_keys`, enforces authorized-key
options, then verifies the signature. A removed key can no longer authenticate.
- **Authorization options.** `from=` (with CIDR, glob, and negation),
`no-port-forwarding`, and `permitopen=` are enforced. `command=` is rejected for
native terminal login. Any unrecognized restrictive option fails closed rather than
being silently ignored.
- **Forwarding policy.** The server enforces `allow_tcp_forwarding`,
`allow_remote_forwarding`, and a loopback-only default for remote binds
(`allow_remote_non_loopback_bind`), in addition to per-key `permitopen=` /
`no-port-forwarding`.
- **Replay protection.** A 128-wide sliding window over the per-direction packet
counter rejects duplicates and stale sequences. Sequence 0 is never accepted.
- **Stale-key tolerance.** Each encrypted packet carries a `session_key_id`; packets
under an old key are dropped as "stale or wrong session key id" rather than treated
as fatal decrypt failures, so reconnect after rotation is non-destructive.
- **Roaming.** The server keys clients by `ClientId` and session key id, not by
source address, and updates the endpoint after any valid encrypted packet from a
new address — without weakening authentication, because the packet must still
decrypt and verify.
## 6. Accepted Residual Risks and Known Gaps
These are stated openly so the public claim gate (`NATIVE_V1_SPEC.md` section 17) can
be evaluated honestly. Items here are *not* yet "green".
### Accepted residual risks (by design)
- **Attach tickets prove recent server-issued possession, not fresh private-key
possession.** A stolen client cache containing both the sealed ticket and its PSK
can attach until the ticket expires (default TTL 24h, configurable down to zero) or
until the server rotates its secret/host key or the user key is removed. This is the
deliberate speed trade. It is bounded by TTL, scoped to host/user/session/mode, and
can be disabled. SSH ControlMaster has an analogous live-socket exposure.
- **Local cache confidentiality relies on filesystem permissions.** Host keys,
server secret, known-hosts, and credential caches are written `0600`. A
same-machine attacker who can already read another user's `0600` files (e.g. via
root) is out of scope, as with SSH's `~/.ssh`.
- **A trusted server that turns malicious is not detected.** Host-key pinning
detects substitution, not betrayal by an already-authorized server. This matches
SSH.
### Known gaps / work in progress (must close before the public claim)
- **Per-IP rate limiting is partial.** The server evicts half-finished native
handshakes on a TTL so a flood of `ClientHello` packets cannot grow the pending map
without bound, and it reports a static `rate_limit_remaining` hint in `ServerHello`.
A full per-source token-bucket limiter is **in progress on another track** and is
not yet enforced. Until it lands, sustained auth flooding is mitigated only by the
handshake-eviction TTL and OS-level limits.
- **Protocol VERSION negotiation is being hardened.** The wire format pins a single
protocol version: the packet header rejects any non-matching `VERSION` byte and the
native handshake rejects any non-matching `protocol_version`. There is no
multi-version negotiation yet, so cross-version interop and downgrade-resistance for
future versions are still being designed. Today's behavior is fail-closed (reject),
not silent downgrade.
- **Fuzzing is not yet wired into CI.** CI currently runs format, tests, release
build, and the Docker SSH benchmark gate. Fuzz targets for packet parsing,
authorized-key parsing, known-host parsing, and handshake state (spec milestone 5)
are **being wired in** and are not yet running in CI.
- **User-key algorithm coverage is Ed25519-only today.** The spec permits ECDSA
P-256 and (compatibility-only, SHA-2) RSA, but native auth currently accepts and
produces `ssh-ed25519` only. ECDSA/RSA support is pending. This is a parity gap, not
a weakening of what *is* supported.
- **Hostile-network and long-soak integration tests are partial.** Roaming,
retransmit, resize, and multi-client tests exist; a dedicated adversarial
drop/reorder/replay suite and 30-minute-sleep soak (spec section 16) are still being
expanded.
- **No external security review yet.** The spec's milestone 5 requires an external
review checklist before public security claims. That review has not happened.
### Auth posture
Native auth is **opt-in alongside SSH fallback**. `auth_preference` defaults to
`native,ssh`: Dosh tries the native authenticated path first and falls back to SSH
bootstrap explicitly and visibly when native auth is disabled, unavailable, or
rejected. Native auth failure never silently degrades to an unauthenticated mode.
Forwarding (`-L`/`-R`/`-D`) requires the native authenticated path and refuses to run
under `--local-auth`.
## 7. Verification and Public-Claim Status
Dosh may claim "native SSH replacement for Dosh-installed servers" only after the
conditions in `NATIVE_V1_SPEC.md` section 17 are met: native auth default on a real
host, SSH fallback available, the section 16 checklist green, this threat model
published, and benchmarks with raw samples for SSH cold, Dosh native cold, Dosh
cached attach, and Mosh startup.
Current status: this threat model is published (this document). The verification
checklist is **not yet fully green** — see the item-by-item status table in
`docs/PUBLIC_READINESS.md` ("Native v1 verification checklist status") and the known
gaps in section 6 above. Until the gaps close and an external review is complete,
Dosh's defensible public claim remains **fast, encrypted native attach/reconnect with
SSH-equivalent transport security and SSH bootstrap fallback** — not a fully verified,
externally reviewed SSH replacement.
+50
View File
@@ -0,0 +1,50 @@
use anyhow::{Result, bail};
use dosh::client::DoshClient;
use dosh::transport::{SessionEvent, TransportEvent};
#[tokio::main]
async fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
let host = args.next().unwrap_or_else(|| "server".to_string());
let message = args.collect::<Vec<_>>().join(" ");
let message = if message.is_empty() {
"hello from dosh".to_string()
} else {
message
};
let client = DoshClient::load()?;
let mut transport = client
.connect(host)
.service("echo")
.connect()
.await?
.into_transport();
let stream = transport.open_service("echo").await?;
loop {
match transport.recv().await? {
SessionEvent::Stream(TransportEvent::OpenOk { stream_id, .. })
if stream_id == stream =>
{
transport.send(stream, message.as_bytes().to_vec()).await?;
}
SessionEvent::Stream(TransportEvent::Data(data)) if data.stream_id == stream => {
for chunk in data.chunks {
print!("{}", String::from_utf8_lossy(&chunk));
}
println!();
transport.close(stream).await?;
return Ok(());
}
SessionEvent::Stream(TransportEvent::OpenReject { reason, .. }) => {
bail!("server rejected echo stream: {reason}");
}
SessionEvent::Stream(_)
| SessionEvent::Ping
| SessionEvent::Pong
| SessionEvent::Ignored => {}
}
transport.maintenance().await?;
}
}
+37
View File
@@ -0,0 +1,37 @@
use anyhow::Result;
use dosh::server::{DoshServer, DoshServerConfig, DoshServerEvent};
use dosh::transport::{SessionEvent, TransportEvent};
#[tokio::main]
async fn main() -> Result<()> {
let config = DoshServerConfig::default().service("echo")?;
let mut server = DoshServer::bind(config).await?;
eprintln!("listening on {}", server.local_addr()?);
loop {
match server.recv().await? {
DoshServerEvent::Accepted(client) => {
eprintln!(
"accepted user={} session={} conn={:?}",
client.user, client.session, client.conn_id
);
}
DoshServerEvent::Session {
conn_id,
event: SessionEvent::Stream(TransportEvent::Open(open)),
} => {
server.accept_stream(conn_id, open.stream_id).await?;
}
DoshServerEvent::Session {
conn_id,
event: SessionEvent::Stream(TransportEvent::Data(data)),
} => {
for chunk in data.chunks {
server.send(conn_id, data.stream_id, chunk).await?;
}
}
DoshServerEvent::Session { .. } | DoshServerEvent::Ignored => {}
}
server.maintenance().await?;
}
}
-59
View File
@@ -1,59 +0,0 @@
# dosh fuzz targets
cargo-fuzz / libFuzzer harnesses for the `dosh` parsers and handshake verifiers
(spec milestone 5 / §16: "Fuzz packet parsing, authorized-key parsing,
known-host parsing, and handshake state", "Fuzz targets run in CI").
This is a **standalone crate** (its own `[workspace]`) so it never affects the
main crate's `cargo build` / `cargo test` / `cargo fmt --check`.
## Targets
| Target | Parser(s) exercised |
| --- | --- |
| `packet_decode` | `protocol::Header::parse`, `protocol::decode`, `protocol::decrypt_body` |
| `from_body` | `protocol::from_body::<T>` for every protocol & native wire struct |
| `authorized_keys` | `native::parse_authorized_keys`, `native::parse_ssh_ed25519_public_blob` |
| `known_hosts` | `native::parse_known_hosts`, `native::parse_host_public_key_line` |
| `handshake_structs` | handshake struct decode + `verify_server_hello`, `user_auth_transcript`, `verify_native_user_auth` |
| `attach_ticket` | `auth::open_attach_ticket`, `auth::verify_attach_ticket`, `auth::decode_bootstrap` |
Every target's objective is the same: **no panics on any input** (a panic on
untrusted bytes is a robustness/DoS bug per threat model §5).
## Prerequisites
```sh
rustup toolchain install nightly
cargo install cargo-fuzz
```
cargo-fuzz requires a nightly toolchain (it builds with `-Z sanitizer=address`).
## Run
From the repository root:
```sh
# List targets
cargo +nightly fuzz list --fuzz-dir fuzz
# Run a single target indefinitely
cargo +nightly fuzz run --fuzz-dir fuzz packet_decode
# Short, CI-style smoke run of one target (10 seconds)
cargo +nightly fuzz run --fuzz-dir fuzz packet_decode -- -max_total_time=10
```
Or from inside `fuzz/`:
```sh
cd fuzz
cargo +nightly fuzz run packet_decode -- -max_total_time=10
```
## CI
`.github/workflows/ci.yml` has a `fuzz-smoke` job that installs nightly +
cargo-fuzz and runs each target briefly (`-max_total_time`). The job is tolerant
if the toolchain/tooling is unavailable so it never blocks the main test gate.
+233 -35
View File
@@ -1,11 +1,17 @@
param( param(
[ValidateSet("client")] [ValidateSet("client")]
[string]$Role = $(if ($env:DOSH_ROLE) { $env:DOSH_ROLE } else { "client" }), [string]$Role = $(if ($env:DOSH_ROLE) { $env:DOSH_ROLE } else { "client" }),
[string]$Repo = $env:DOSH_REPO, [string]$Repo = $(if ($env:DOSH_REPO) { $env:DOSH_REPO } else { "https://git.palav.dev/Palav/dosh.git" }),
[string]$Server = $env:DOSH_SERVER, [string]$Server = $env:DOSH_SERVER,
[string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }), [string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }),
[int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }), [int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }),
[string]$Prefix = $(if ($env:PREFIX) { $env:PREFIX } else { Join-Path $HOME ".local" }), [string]$Prefix = $(if ($env:PREFIX) { $env:PREFIX } else { Join-Path $HOME ".local" }),
[switch]$UsePrebuilt = $(-not $env:DOSH_USE_PREBUILT -or $env:DOSH_USE_PREBUILT -ne "0"),
[string]$BinaryUrl = $env:DOSH_BINARY_URL,
[string]$BinaryBase = $env:DOSH_BINARY_BASE,
[string]$BinaryName = $env:DOSH_BINARY_NAME,
[string]$BinaryVersion = $(if ($env:DOSH_BINARY_VERSION) { $env:DOSH_BINARY_VERSION } else { "latest" }),
[switch]$BinaryRequired = $($env:DOSH_BINARY_REQUIRED -and $env:DOSH_BINARY_REQUIRED -ne "0"),
[switch]$ForceConfig [switch]$ForceConfig
) )
@@ -17,12 +23,187 @@ function Require-Command($Name) {
} }
} }
Require-Command cargo function Normalize-Arch {
switch ($env:PROCESSOR_ARCHITECTURE) {
"AMD64" { "x86_64"; break }
"ARM64" { "aarch64"; break }
default { $env:PROCESSOR_ARCHITECTURE.ToLowerInvariant() }
}
}
$tmp = $null function Release-ArtifactName {
if (Test-Path "Cargo.toml") { if ($BinaryName) {
return $BinaryName
}
"dosh-windows-$(Normalize-Arch).zip"
}
function Repo-WebBase($Value) {
if (-not $Value) {
return $null
}
$base = $Value.TrimEnd("/")
if ($base.EndsWith(".git")) {
$base = $base.Substring(0, $base.Length - 4)
}
if ($base -notmatch "^https?://") {
return $null
}
$base
}
function Release-DownloadUrl {
if ($BinaryUrl) {
return $BinaryUrl
}
$name = Release-ArtifactName
if ($BinaryBase) {
return "$($BinaryBase.TrimEnd('/'))/$name"
}
$web = Repo-WebBase $Repo
if (-not $web) {
return $null
}
if ($BinaryVersion -eq "latest") {
return "$web/releases/latest/download/$name"
}
"$web/releases/download/$BinaryVersion/$name"
}
function Release-LatestTagDownloadUrl {
if ($BinaryUrl -or $BinaryBase -or $BinaryVersion -ne "latest") {
return $null
}
$web = Repo-WebBase $Repo
if (-not $web) {
return $null
}
try {
$response = Invoke-WebRequest -UseBasicParsing -Uri "$web/releases/latest" -MaximumRedirection 5
$effective = $null
if ($response.BaseResponse.ResponseUri) {
$effective = $response.BaseResponse.ResponseUri.AbsoluteUri
} elseif ($response.BaseResponse.RequestMessage -and $response.BaseResponse.RequestMessage.RequestUri) {
$effective = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri
}
$prefix = "$web/releases/tag/"
if ($effective -and $effective.StartsWith($prefix)) {
$tag = $effective.Substring($prefix.Length)
if ($tag) {
return "$web/releases/download/$tag/$(Release-ArtifactName)"
}
}
}
catch {
return $null
}
return $null
}
function Verify-ArchiveChecksum($Url, $Archive) {
$checksumPath = "$Archive.sha256"
try {
Invoke-WebRequest -UseBasicParsing -Uri "$Url.sha256" -OutFile $checksumPath
}
catch {
Write-Warning "prebuilt checksum unavailable; continuing without sidecar verification"
return
}
$expected = ((Get-Content $checksumPath -Raw).Trim() -split "\s+")[0].ToLowerInvariant()
$actual = (Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant()
if ($expected -ne $actual) {
throw "prebuilt checksum mismatch for $Url"
}
}
function Expected-ArchiveVersion($Url) {
if ($Url -match "/releases/download/v([^/]+)/") {
return $Matches[1]
}
if (-not $BinaryUrl -and -not $BinaryBase -and $BinaryVersion -ne "latest") {
return $BinaryVersion.TrimStart("v")
}
return $null
}
function Verify-ArchiveVersion($ExtractDir, $Url) {
$expected = Expected-ArchiveVersion $Url
if (-not $expected) {
return
}
$versionFile = Get-ChildItem -Path $ExtractDir -Recurse -File -Filter VERSION | Select-Object -First 1
if (-not $versionFile) {
throw "prebuilt archive missing VERSION for $Url"
}
$actual = (Get-Content $versionFile.FullName -Raw).Trim()
if ($actual -ne $expected) {
throw "prebuilt archive version mismatch for ${Url}: expected $expected, got $actual"
}
}
function Install-Binary($Source, $Destination) {
$dir = Split-Path -Parent $Destination
$name = Split-Path -Leaf $Destination
$tmp = Join-Path $dir ".$name.tmp.$PID"
Copy-Item $Source $tmp -Force
try {
Move-Item $tmp $Destination -Force
}
catch {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
throw
}
}
$bindir = Join-Path $Prefix "bin"
$configDir = Join-Path $HOME ".config\dosh"
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
function Install-Prebuilt {
$url = Release-LatestTagDownloadUrl
if (-not $url) {
$url = Release-DownloadUrl
}
if (-not $url) {
return $false
}
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-bin-" + [guid]::NewGuid())
$zip = Join-Path $tmp (Release-ArtifactName)
$extract = Join-Path $tmp "extract"
try {
New-Item -ItemType Directory -Force -Path $tmp, $extract | Out-Null
Write-Host "Trying Dosh prebuilt $(Release-ArtifactName)"
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $zip
Verify-ArchiveChecksum $url $zip
Expand-Archive -Force -Path $zip -DestinationPath $extract
Verify-ArchiveVersion $extract $url
foreach ($bin in @("dosh-client.exe", "dosh-bench.exe")) {
$found = Get-ChildItem -Path $extract -Recurse -File -Filter $bin | Select-Object -First 1
if (-not $found) {
throw "prebuilt archive missing $bin"
}
Install-Binary $found.FullName (Join-Path $bindir $bin)
}
Install-Binary (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe")
return $true
}
catch {
Write-Warning "prebuilt install failed: $_"
return $false
}
finally {
if (Test-Path $tmp) {
Remove-Item -Recurse -Force $tmp
}
}
}
function Install-FromSource {
Require-Command cargo
$tmp = $null
if (Test-Path "Cargo.toml") {
$src = (Get-Location).Path $src = (Get-Location).Path
} else { } else {
if (-not $Repo) { if (-not $Repo) {
throw "DOSH_REPO is required when running the installer from irm/iex" throw "DOSH_REPO is required when running the installer from irm/iex"
} }
@@ -30,22 +211,38 @@ if (Test-Path "Cargo.toml") {
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-" + [guid]::NewGuid()) $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-" + [guid]::NewGuid())
git clone --depth 1 $Repo $tmp | Out-Null git clone --depth 1 $Repo $tmp | Out-Null
$src = $tmp $src = $tmp
} }
try { try {
Push-Location $src Push-Location $src
cargo build --release --bin dosh-client --bin dosh-bench cargo build --release --bin dosh-client --bin dosh-bench
Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe")
Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe")
Install-Binary "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe")
}
finally {
Pop-Location
if ($tmp) {
Remove-Item -Recurse -Force $tmp
}
}
}
$bindir = Join-Path $Prefix "bin" if ($UsePrebuilt) {
$configDir = Join-Path $HOME ".config\dosh" $ok = Install-Prebuilt
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null if (-not $ok) {
if ($BinaryRequired) {
throw "prebuilt install failed and DOSH_BINARY_REQUIRED=1"
}
Write-Host "Falling back to source build"
Install-FromSource
}
} else {
Install-FromSource
}
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force $clientConfig = Join-Path $configDir "client.toml"
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force if ($ForceConfig -or -not (Test-Path $clientConfig)) {
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force
$clientConfig = Join-Path $configDir "client.toml"
if ($ForceConfig -or -not (Test-Path $clientConfig)) {
$defaultServer = if ($Server) { $Server } else { "user@example.com" } $defaultServer = if ($Server) { $Server } else { "user@example.com" }
$updateRepo = if ($Repo) { $Repo } else { "https://git.palav.dev/Palav/dosh.git" } $updateRepo = if ($Repo) { $Repo } else { "https://git.palav.dev/Palav/dosh.git" }
$doshHostLine = if ($DoshHost) { "dosh_host = `"$DoshHost`"" } else { "# dosh_host = `"public.example.com`"" } $doshHostLine = if ($DoshHost) { "dosh_host = `"$DoshHost`"" } else { "# dosh_host = `"public.example.com`"" }
@@ -60,28 +257,29 @@ dosh_port = $Port
default_session = "new" default_session = "new"
reconnect_timeout_secs = 5 reconnect_timeout_secs = 5
view_only = false view_only = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials" credential_cache = "~/.local/share/dosh/credentials"
escape_key = "^]"
"@ | Set-Content -NoNewline -Encoding utf8 $clientConfig "@ | Set-Content -NoNewline -Encoding utf8 $clientConfig
} }
$userPath = [Environment]::GetEnvironmentVariable("Path", "User") $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if (-not (($userPath -split ';') -contains $bindir)) { if (-not (($userPath -split ';') -contains $bindir)) {
[Environment]::SetEnvironmentVariable("Path", "$userPath;$bindir", "User") [Environment]::SetEnvironmentVariable("Path", "$userPath;$bindir", "User")
} }
Write-Host "Installed Dosh client to $bindir" Write-Host "Installed Dosh client to $bindir"
Write-Host "Configured UDP port $Port" Write-Host "Configured UDP port $Port"
Write-Host "" Write-Host ""
$displayServer = if ($Server) { $Server } else { "user@host" } $displayServer = if ($Server) { $Server } else { "user@host" }
Write-Host "Client command:" Write-Host "Client commands:"
Write-Host " $bindir\dosh.exe $displayServer" Write-Host " $bindir\dosh.exe $displayServer"
Write-Host "" Write-Host " $bindir\dosh.exe setup <ssh-alias>"
Write-Host "Open a new terminal for PATH changes to apply." Write-Host " $bindir\dosh.exe update --check"
} Write-Host ""
finally { Write-Host "Client config:"
Pop-Location Write-Host " $configDir\client.toml"
if ($tmp) { Write-Host ""
Remove-Item -Recurse -Force $tmp Write-Host "Open a new terminal for PATH changes to apply."
}
}
+304 -35
View File
@@ -12,6 +12,12 @@ start_server=1
force_config=0 force_config=0
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}" update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
quiet="${DOSH_UPDATE_QUIET:-0}" quiet="${DOSH_UPDATE_QUIET:-0}"
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
binary_url="${DOSH_BINARY_URL:-}"
binary_base="${DOSH_BINARY_BASE:-}"
binary_name="${DOSH_BINARY_NAME:-}"
binary_version="${DOSH_BINARY_VERSION:-latest}"
binary_required="${DOSH_BINARY_REQUIRED:-0}"
usage() { usage() {
cat <<'EOF' cat <<'EOF'
@@ -32,6 +38,18 @@ Options:
Environment alternatives: Environment alternatives:
DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX, DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX,
DOSH_UPDATE_CACHE DOSH_UPDATE_CACHE
DOSH_USE_PREBUILT=0
Build from source instead of trying a release tarball first
DOSH_BINARY_URL URL
Exact release tarball URL to install
DOSH_BINARY_BASE URL
Release download base; otherwise derived from the latest tag
DOSH_BINARY_NAME NAME
Release tarball name; defaults to dosh-OS-ARCH.tar.gz
DOSH_BINARY_VERSION TAG
Release tag when deriving DOSH_BINARY_BASE; default latest
DOSH_BINARY_REQUIRED=1
Fail instead of falling back to source when binary install fails
EOF EOF
} }
@@ -112,8 +130,6 @@ ensure_cargo() {
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
} }
ensure_cargo
cleanup() { cleanup() {
if [ -n "${tmpdir:-}" ]; then if [ -n "${tmpdir:-}" ]; then
rm -rf "$tmpdir" rm -rf "$tmpdir"
@@ -121,9 +137,221 @@ cleanup() {
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then bindir="$prefix/bin"
config_dir="$HOME/.config/dosh"
data_dir="$HOME/.local/share/dosh"
systemd_user_dir="$HOME/.config/systemd/user"
src_dir=""
mkdir -p "$bindir" "$config_dir" "$data_dir"
normalize_os() {
case "$(uname -s)" in
Darwin) printf '%s\n' macos ;;
Linux) printf '%s\n' linux ;;
FreeBSD) printf '%s\n' freebsd ;;
*) uname -s | tr '[:upper:]' '[:lower:]' ;;
esac
}
normalize_arch() {
case "$(uname -m)" in
x86_64|amd64) printf '%s\n' x86_64 ;;
arm64|aarch64) printf '%s\n' aarch64 ;;
armv7l) printf '%s\n' armv7 ;;
*) uname -m | tr '[:upper:]' '[:lower:]' ;;
esac
}
repo_web_base() {
repo_base="$1"
case "$repo_base" in
http://*|https://*)
repo_base="${repo_base%.git}"
printf '%s\n' "${repo_base%/}"
;;
*)
return 1
;;
esac
}
release_artifact_name() {
if [ -n "$binary_name" ]; then
printf '%s\n' "$binary_name"
else
printf 'dosh-%s-%s.tar.gz\n' "$(normalize_os)" "$(normalize_arch)"
fi
}
release_download_url() {
if [ -n "$binary_url" ]; then
printf '%s\n' "$binary_url"
return 0
fi
if [ -n "$binary_base" ]; then
printf '%s/%s\n' "${binary_base%/}" "$(release_artifact_name)"
return 0
fi
if [ -z "$repo" ]; then
return 1
fi
web_base="$(repo_web_base "$repo")" || return 1
if [ "$binary_version" = "latest" ]; then
printf '%s/releases/latest/download/%s\n' "$web_base" "$(release_artifact_name)"
else
printf '%s/releases/download/%s/%s\n' "$web_base" "$binary_version" "$(release_artifact_name)"
fi
}
release_latest_tag_download_url() {
if [ -n "$binary_url" ] || [ -n "$binary_base" ] || [ "$binary_version" != "latest" ] || [ -z "$repo" ]; then
return 1
fi
web_base="$(repo_web_base "$repo")" || return 1
latest_url="$(curl -fsSL -o /dev/null -w '%{url_effective}' "$web_base/releases/latest" 2>/dev/null || true)"
case "$latest_url" in
"$web_base"/releases/tag/*)
tag="${latest_url##"$web_base"/releases/tag/}"
[ -n "$tag" ] || return 1
printf '%s/releases/download/%s/%s\n' "$web_base" "$tag" "$(release_artifact_name)"
;;
*)
return 1
;;
esac
}
find_extracted_binary() {
find "$1" -type f -name "$2" 2>/dev/null | sed -n '1p'
}
install_binary() {
src="$1"
dst="$2"
dst_dir="$(dirname "$dst")"
dst_base="$(basename "$dst")"
tmp="$dst_dir/.$dst_base.tmp.$$"
install -m 0755 "$src" "$tmp"
if ! mv -f "$tmp" "$dst"; then
rm -f "$tmp"
return 1
fi
}
install_extracted_binary() {
found="$(find_extracted_binary "$1" "$2")"
if [ -z "$found" ]; then
echo "prebuilt archive missing $2" >&2
return 1
fi
install_binary "$found" "$3"
}
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
return 1
fi
}
verify_archive_checksum() {
url="$1"
archive="$2"
checksum_file="$3"
if ! curl -fsL "$url.sha256" -o "$checksum_file"; then
echo "prebuilt checksum unavailable; continuing without sidecar verification" >&2
return 0
fi
expected="$(awk '{print $1}' "$checksum_file" | sed -n '1p')"
actual="$(sha256_file "$archive")" || {
echo "sha256sum/shasum not found for checksum verification" >&2
return 1
}
if [ "$expected" != "$actual" ]; then
echo "prebuilt checksum mismatch for $url" >&2
return 1
fi
}
expected_archive_version() {
url="$1"
case "$url" in
*/releases/download/v*/*)
tag="${url#*/releases/download/v}"
tag="${tag%%/*}"
printf '%s\n' "$tag"
return 0
;;
esac
if [ -z "$binary_url" ] && [ -z "$binary_base" ] && [ "$binary_version" != "latest" ]; then
printf '%s\n' "${binary_version#v}"
return 0
fi
return 1
}
verify_archive_version() {
extract_dir="$1"
url="$2"
expected="$(expected_archive_version "$url" || true)"
[ -n "$expected" ] || return 0
version_file="$(find "$extract_dir" -type f -name VERSION 2>/dev/null | sed -n '1p')"
if [ -z "$version_file" ]; then
echo "prebuilt archive missing VERSION for $url" >&2
return 1
fi
actual="$(tr -d '\r\n' <"$version_file")"
if [ "$actual" != "$expected" ]; then
echo "prebuilt archive version mismatch for $url: expected $expected, got $actual" >&2
return 1
fi
}
try_install_prebuilt() {
download_url="$(release_latest_tag_download_url || release_download_url)" || return 1
tmpdir="$(mktemp -d)"
archive="$tmpdir/$(release_artifact_name)"
checksum_file="$archive.sha256"
[ "$quiet" = "1" ] && echo "Trying Dosh prebuilt $(release_artifact_name)"
need curl
need tar
if ! curl -fsL "$download_url" -o "$archive" 2>/dev/null; then
alt_download_url="$(release_download_url || true)"
if [ -z "$alt_download_url" ] || ! curl -fsL "$alt_download_url" -o "$archive"; then
echo "prebuilt unavailable: $download_url" >&2
[ -z "$alt_download_url" ] || echo "prebuilt unavailable: $alt_download_url" >&2
return 1
fi
download_url="$alt_download_url"
fi
verify_archive_checksum "$download_url" "$archive" "$checksum_file" || return 1
mkdir -p "$tmpdir/extract"
if ! tar -xzf "$archive" -C "$tmpdir/extract"; then
echo "prebuilt archive could not be extracted: $download_url" >&2
return 1
fi
verify_archive_version "$tmpdir/extract" "$download_url" || return 1
install_extracted_binary "$tmpdir/extract" dosh-client "$bindir/dosh-client" || return 1
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install_extracted_binary "$tmpdir/extract" dosh-server "$bindir/dosh-server" || return 1
install_extracted_binary "$tmpdir/extract" dosh-auth "$bindir/dosh-auth" || return 1
fi
if found_bench="$(find_extracted_binary "$tmpdir/extract" dosh-bench)" && [ -n "$found_bench" ]; then
install_binary "$found_bench" "$bindir/dosh-bench"
fi
return 0
}
install_from_source() {
ensure_cargo
if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then
src_dir="$(pwd)" src_dir="$(pwd)"
else else
if [ -z "$repo" ]; then if [ -z "$repo" ]; then
echo "DOSH_REPO or --repo is required when running the installer from curl" >&2 echo "DOSH_REPO or --repo is required when running the installer from curl" >&2
exit 2 exit 2
@@ -155,39 +383,69 @@ else
fi fi
fi fi
src_dir="$update_cache" src_dir="$update_cache"
fi fi
cd "$src_dir" cd "$src_dir"
[ "$quiet" = "1" ] && echo "Building Dosh $role" [ "$quiet" = "1" ] && echo "Building Dosh $role"
if [ "$role" = "client" ]; then if [ "$role" = "client" ]; then
if [ "$quiet" = "1" ]; then if [ "$quiet" = "1" ]; then
cargo build -q --release --bin dosh-client cargo build -q --release --bin dosh-client
else else
cargo build --release --bin dosh-client cargo build --release --bin dosh-client
fi fi
else else
if [ "$quiet" = "1" ]; then if [ "$quiet" = "1" ]; then
cargo build -q --release cargo build -q --release
else else
cargo build --release cargo build --release
fi fi
fi fi
bindir="$prefix/bin" install_binary target/release/dosh-client "$bindir/dosh-client"
config_dir="$HOME/.config/dosh" ln -sf dosh-client "$bindir/dosh"
data_dir="$HOME/.local/share/dosh" if [ "$role" = "server" ] || [ "$role" = "both" ]; then
systemd_user_dir="$HOME/.config/systemd/user" 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_binary target/release/dosh-bench "$bindir/dosh-bench"
fi
}
mkdir -p "$bindir" "$config_dir" "$data_dir" write_systemd_service() {
if [ -n "$src_dir" ] && [ -f "$src_dir/packaging/systemd/dosh-server.service" ]; then
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \
"$src_dir/packaging/systemd/dosh-server.service" >"$systemd_user_dir/dosh-server.service"
else
cat >"$systemd_user_dir/dosh-server.service" <<EOF
[Unit]
Description=dosh dormant shell server
After=network-online.target
Wants=network-online.target
install -m 0755 target/release/dosh-client "$bindir/dosh-client" [Service]
ln -sf dosh-client "$bindir/dosh" Type=simple
if [ "$role" = "server" ] || [ "$role" = "both" ]; then ExecStart=$bindir/dosh-server serve
install -m 0755 target/release/dosh-server "$bindir/dosh-server" Restart=always
install -m 0755 target/release/dosh-auth "$bindir/dosh-auth" RestartSec=1
fi KillMode=process
if [ -f target/release/dosh-bench ]; then
install -m 0755 target/release/dosh-bench "$bindir/dosh-bench" [Install]
WantedBy=default.target
EOF
fi
}
if [ "$use_prebuilt" != "0" ]; then
if ! try_install_prebuilt; then
if [ "$binary_required" = "1" ]; then
exit 1
fi
[ "$quiet" = "1" ] && echo "Falling back to source build"
install_from_source
fi
else
install_from_source
fi fi
if [ "$role" = "server" ] || [ "$role" = "both" ]; then if [ "$role" = "server" ] || [ "$role" = "both" ]; then
@@ -206,8 +464,9 @@ scrollback = 5000
auth_ttl_secs = 30 auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600 attach_ticket_ttl_secs = 3600
allow_attach_tickets = true allow_attach_tickets = true
client_timeout_secs = 86400 client_timeout_secs = 2592000
retransmit_window = 256 retransmit_window = 256
output_frame_interval_ms = 16
default_input_mode = "read-write" default_input_mode = "read-write"
prewarm_sessions = ["default"] prewarm_sessions = ["default"]
create_on_attach = true create_on_attach = true
@@ -221,15 +480,16 @@ native_auth_rate_limit_per_minute = 30
allow_tcp_forwarding = true allow_tcp_forwarding = true
allow_remote_forwarding = false allow_remote_forwarding = false
allow_agent_forwarding = false allow_agent_forwarding = false
persist_sessions = true
EOF EOF
fi fi
if command -v systemctl >/dev/null 2>&1; then if command -v systemctl >/dev/null 2>&1; then
mkdir -p "$systemd_user_dir" mkdir -p "$systemd_user_dir"
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \ write_systemd_service
packaging/systemd/dosh-server.service >"$systemd_user_dir/dosh-server.service"
if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then
systemctl --user enable --now dosh-server.service systemctl --user enable dosh-server.service
systemctl --user restart dosh-server.service
fi fi
elif [ "$start_server" -eq 1 ]; then elif [ "$start_server" -eq 1 ]; then
nohup "$bindir/dosh-server" serve >"$data_dir/dosh-server.log" 2>&1 & nohup "$bindir/dosh-server" serve >"$data_dir/dosh-server.log" 2>&1 &
@@ -272,7 +532,8 @@ dosh_port = $port
default_session = "new" default_session = "new"
reconnect_timeout_secs = 5 reconnect_timeout_secs = 5
view_only = false view_only = false
predict = false predict = true
predict_mode = "experimental"
cache_attach_tickets = true cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials" credential_cache = "~/.local/share/dosh/credentials"
auth_preference = "native,ssh" auth_preference = "native,ssh"
@@ -282,6 +543,7 @@ known_hosts = "~/.config/dosh/known_hosts"
identity_files = ["~/.ssh/id_ed25519"] identity_files = ["~/.ssh/id_ed25519"]
use_ssh_agent = true use_ssh_agent = true
forward_agent = false forward_agent = false
escape_key = "^]"
EOF EOF
fi fi
@@ -295,18 +557,18 @@ EOF
fi fi
cat >"$hosts_config" <<EOF cat >"$hosts_config" <<EOF
# Example: # Example:
# [palav] # [server]
# ssh = "palav" # ssh = "server"
# dosh_host = "palav.dev" # dosh_host = "server.example.com"
# port = 50000 # port = 50000
# default_command = "tm" # default_command = "tm"
# predict = false # predict = true
[default] [default]
ssh = "$default_server" ssh = "$default_server"
dosh_host = "$host_udp" dosh_host = "$host_udp"
port = $port port = $port
predict = false predict = true
EOF EOF
fi fi
fi fi
@@ -317,10 +579,17 @@ Configured UDP port $port
EOF EOF
if [ "$role" = "client" ] || [ "$role" = "both" ]; then if [ "$role" = "client" ] || [ "$role" = "both" ]; then
next_server="${server:-user@host}"
cat <<EOF cat <<EOF
Client command: Client commands:
$bindir/dosh ${server:-user@host} $bindir/dosh $next_server
$bindir/dosh setup <ssh-alias>
$bindir/dosh update --check
Client config:
$config_dir/client.toml
$config_dir/hosts.toml
EOF EOF
fi fi
-201
View File
@@ -1,201 +0,0 @@
# dosh (Dormant Shell)
> dosh is a low-latency remote terminal for homelab/personal servers. It is mosh-shaped
> but not a mosh clone: `dosh-server` is a resident daemon that keeps terminal sessions
> hot, and the client reconnects over encrypted UDP — so attach and reconnect are
> near-instant (~3 ms of local overhead + one network RTT). SSH is used once to
> establish trust; after that, repeat attaches skip SSH entirely. It also does SSH-style
> TCP port forwarding (`-L`/`-R`/`-D`) over the same encrypted transport, which is what
> makes back-and-forth client↔server homelab comms easy.
This file orients an AI agent (or a human) on what dosh is, what it can do, and how to
drive it. It is intentionally self-contained. For deeper detail, see the linked docs at
the bottom.
## What dosh is for
Use dosh instead of `ssh`/`mosh` for **interactive shells and TCP forwarding** to a
server where you control both ends and have installed `dosh-server` (typically a homelab
box, VPS, or workstation). It shines when you:
- want a terminal that survives laptop sleep, Wi-Fi changes, and NAT rebinding, and
resumes instantly instead of hanging;
- reconnect to the same box many times a day and don't want to pay SSH startup each time;
- need to reach services on the server from your laptop (or vice-versa) without standing
up a VPN.
dosh is **not** a drop-in for every SSH use. It does not do `scp`/`sftp` file transfer,
X11, or act as an `sshd` for arbitrary SSH clients. Keep `ssh` installed for those.
## Core capabilities
- **Encrypted UDP terminal transport** — AEAD-encrypted, with packet sequencing, a
sliding replay window, ACKs, and server-side retransmit of unacked output.
- **Resident server + hot sessions** — `dosh-server` runs as a daemon; named sessions
(and a prewarmed `default`) stay alive across client disconnects.
- **Fast attach / reconnect** — see "Fast path order" below; cached attach is ~one RTT.
- **Roaming** — the session follows the client across IP/port changes.
- **Named & shared sessions** — reattach the same persistent terminal from multiple
clients; optional **view-only** clients.
- **TCP port forwarding** — local (`-L`), remote (`-R`), and dynamic SOCKS (`-D`) over
the encrypted transport, with per-stream flow control and terminal-priority
scheduling (bulk transfers don't lag your keystrokes).
- **Native UDP auth** — Ed25519 user auth via ssh-agent or an (optionally encrypted)
OpenSSH key, verified against `authorized_keys`. Falls back to SSH bootstrap when
native auth isn't available. SSH host config (`HostName`, `User`, `Port`,
`IdentityFile`, `ProxyJump`, etc.) is honored.
- **Host-key pinning** — TOFU/known-hosts with hard-fail on mismatch.
- **Speculative local echo** — optional predictive echo for laggy links (display-only;
real input is always sent to the server).
- **Ops commands** — `doctor`, `sessions`, `trust`, `import-ssh`, `update`.
## Quickstart
Install the server on each box you want to reach (default UDP port `50000`):
```bash
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
| DOSH_PORT=50000 sh -s -- server
```
Install the client (macOS/Linux), then attach:
```bash
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
| DOSH_SERVER=homelab DOSH_HOST=homelab.example.com DOSH_PORT=50000 sh -s -- client
dosh homelab # fresh interactive shell
dosh homelab uptime # run one command
dosh --session work homelab # named, persistent, reattachable session
```
- Detach (leave the server session running): **Ctrl-]**.
- End the session: type `exit` in the remote shell.
- If UDP stalls, dosh keeps the terminal open, sends keepalives, and ticket-reconnects.
## Client↔server homelab comms (the back-and-forth)
Forwarding is the key to "make homelab comms easy." Three directions:
| Command | Direction | Effect |
| --- | --- | --- |
| `dosh -L [bind:]LPORT:THOST:TPORT host` | pull server→you | A listener on **your machine** (`bind`, default localhost) forwards to `THOST:TPORT` reached **from the server**. |
| `dosh -R [bind:]LPORT:THOST:TPORT host` | push you→server | A listener on **the server** (loopback by default) forwards to `THOST:TPORT` reached **from your machine**. |
| `dosh -D [bind:]LPORT host` | SOCKS via server | A SOCKS5 proxy on **your machine**; traffic egresses **from the server**. |
Concrete homelab patterns:
```bash
# Reach the homelab's internal Grafana (server-side :3000) from your laptop browser:
dosh -L 3000:127.0.0.1:3000 homelab # open http://localhost:3000
# Reach a DB that only listens on the homelab LAN:
dosh -L 5432:10.0.0.5:5432 homelab # psql -h 127.0.0.1 -p 5432
# Let the homelab hit a dev server running on your laptop (e.g. a webhook target):
dosh -R 9000:127.0.0.1:8080 homelab # homelab curls http://127.0.0.1:9000
# Route browser traffic out through the homelab's network:
dosh -D 1080 homelab # SOCKS5 proxy at 127.0.0.1:1080
# Forward-only, no shell; multiple forwards; background after listeners are up:
dosh -N -L 3000:127.0.0.1:3000 -L 5432:10.0.0.5:5432 homelab
dosh -f -N -L 3000:127.0.0.1:3000 homelab
```
Server policy controls forwarding: `allow_tcp_forwarding`, `allow_remote_forwarding`,
`allow_remote_non_loopback_bind`, and per-key `permitopen=`/`no-port-forwarding` in
`authorized_keys`. Remote listeners bind to loopback unless explicitly allowed.
## Fast path order (why it's quick)
The client tries the cheapest valid path first:
1. **UDP resume** — existing client id + session key; one encrypted UDP round-trip.
2. **UDP attach ticket** — cached server-issued ticket; one round-trip, no SSH.
3. **Native UDP auth** — Ed25519 handshake (ssh-agent/key) when enabled.
4. **SSH bootstrap** — `ssh user@host dosh-auth …` once, then a UDP attach.
Measured locally (loopback, release): cached attach ≈ **3 ms**, cold native auth ≈ 9 ms.
Over a real link, add one RTT. See `docs/BENCHMARKS.md`.
## Architecture (1-minute model)
- **dosh-server** — single UDP socket on one port; a session table keyed by name; one
PTY per named session; per-session terminal screen state (vt100) for snapshots/diffs;
a per-session client table; encrypted UDP protocol; a small SSH-invoked `dosh-auth`
helper. Abandoned (clientless, non-prewarmed) sessions and their shells are reaped
after a grace period; prewarmed sessions stay hot.
- **dosh-client** — raw-mode terminal; local credential cache; tries resume/ticket/native
before SSH; forwards PTY I/O; reconnect/roaming state machine; optional predictive echo.
- **Binaries** — `dosh-server`, `dosh-client` (symlinked as `dosh`), `dosh-auth`
(SSH-invoked trust helper), `dosh-bench` (benchmarks).
## Security model (summary)
- First trust via SSH; thereafter encrypted Dosh transport. Native auth available.
- KEX X25519; AEAD ChaCha20-Poly1305; KDF HKDF-SHA256; host & user auth Ed25519;
SHA-256 transcript binding. Per-direction, per-sequence nonces; replay window.
- Host keys pinned in `~/.config/dosh/known_hosts`; mismatch hard-fails.
- User auth against `~/.ssh/authorized_keys` (+ optional `~/.config/dosh/authorized_keys`);
removed keys can't authenticate; unsupported restrictive options fail closed.
- Forward secrecy from ephemeral X25519; attach tickets are server-sealed and scoped.
dosh claims security **equivalent to, and in places stronger than, SSH for the dosh
terminal/forwarding use case** — not full SSH-protocol parity. Read `docs/THREAT_MODEL.md`
for the honest stance, residual risks, and what's still pending before public claims.
## Configuration
- **Client** `~/.config/dosh/client.toml` — `auth_preference` (e.g. `"native,ssh"`),
`trust_on_first_use`, `identity_files`, `use_ssh_agent`, `forward_agent`, `send_env`,
`set_env`, `dosh_port`, `default_session`, `predict`, `reconnect_timeout_secs`,
`credential_cache`, `known_hosts`.
- **Hosts** `~/.config/dosh/hosts.toml` — per-alias `[name]` with `ssh`, `dosh_host`,
`port`, `user`, `default_command`, `predict`. Generate from SSH aliases with
`dosh import-ssh <alias> <name>`.
- **Server** `~/.config/dosh/server.toml` — `port`, `bind`, `shell`, `prewarm_sessions`,
`native_auth`, `host_key`, `authorized_keys`, `attach_ticket_ttl_secs`,
`client_timeout_secs`, `allow_tcp_forwarding`, `allow_remote_forwarding`,
`allow_remote_non_loopback_bind`, `allow_agent_forwarding`, `accept_env`,
`native_auth_rate_limit_per_minute`.
Paths: host key `~/.config/dosh/host_key`; credential cache
`~/.local/share/dosh/credentials/`.
## Operating & diagnostics
```bash
dosh doctor homelab # host resolution, trust state, UDP reachability, server
# version, usable keys, auth result, forwarding policy
dosh sessions homelab # list live sessions
dosh trust homelab # fetch + pin the Dosh host key (via SSH fallback)
dosh trust --remove homelab
dosh import-ssh palav homelab # write a hosts.toml entry from an SSH alias
dosh update # update the installed client
```
If something fails, `dosh doctor <host>` is the first stop — every public error is meant
to be actionable (host trust, auth failure, UDP blocked, forwarding denied, version
mismatch, server unavailable).
## For agents driving dosh
- Run one command and exit: `dosh <host> <cmd...>`. Render one frame and detach:
`dosh --attach-only <host>`. Both are non-interactive-friendly.
- A real terminal size is needed for full-screen rendering; with no TTY the client falls
back to 80×24. Pass `-v`/`-vv` for timing/diagnostic logs on stderr.
- Prefer `--session <name>` to reattach a known session; bare `dosh <host>` opens a
fresh, uniquely-named session each time.
- Never assume file transfer or X11 — use `ssh`/`scp` for those.
- Diagnostics are scriptable via `dosh doctor <host>`.
## Reference docs
- `README.md` — overview, install, develop, performance rules.
- `docs/NATIVE_V1_SPEC.md` — the native auth + forwarding v1 contract and verification
checklist (with current status).
- `docs/THREAT_MODEL.md` — security claims, threat model, residual risks.
- `docs/PUBLIC_READINESS.md` — feature matrix and §16 verification status.
- `docs/BENCHMARKS.md` — how to benchmark; metric definitions; sample numbers.
- `SPEC.md` — protocol/wire details.
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env sh
set -eu
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
exec sh ./install.sh --from-current "$@"
+2 -10
View File
@@ -6,17 +6,9 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=%h/.local/bin/dosh-server serve ExecStart=%h/.local/bin/dosh-server serve
Restart=on-failure Restart=always
RestartSec=1 RestartSec=1
NoNewPrivileges=true KillMode=process
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
[Install] [Install]
WantedBy=default.target WantedBy=default.target
+3 -2
View File
@@ -67,6 +67,7 @@ cleanup() {
kill "$server_pid" 2>/dev/null || true kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true wait "$server_pid" 2>/dev/null || true
fi fi
pkill -f "$server_bin hold --runtime-dir $home/sessions/run" 2>/dev/null || true
rm -rf "$home" rm -rf "$home"
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
@@ -86,6 +87,7 @@ scrollback = 5000
auth_ttl_secs = 30 auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600 attach_ticket_ttl_secs = 3600
allow_attach_tickets = true allow_attach_tickets = true
persist_sessions = false
client_timeout_secs = 60 client_timeout_secs = 60
retransmit_window = 256 retransmit_window = 256
default_input_mode = "read-write" default_input_mode = "read-write"
@@ -183,8 +185,7 @@ server_pid=""
# meaningful here. The cold fresh-process reconnect fast path is the attach # meaningful here. The cold fresh-process reconnect fast path is the attach
# ticket (measured above). Roaming resume is validated by the integration test # ticket (measured above). Roaming resume is validated by the integration test
# `resume_updates_udp_endpoint_for_roaming`. `dosh-bench --resume` exists for # `resume_updates_udp_endpoint_for_roaming`. `dosh-bench --resume` exists for
# scenarios that keep a live session out-of-band (e.g. remote soak tests); see # scenarios that keep a live session out-of-band (e.g. remote soak tests).
# docs/BENCHMARKS.md.
rm -rf "$home/.local/share/dosh/credentials" rm -rf "$home/.local/share/dosh/credentials"
write_client_config true write_client_config true
start_server start_server
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -eu
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
out="${DOSH_BENCH_REPORT:-target/dosh-bench/report.md}"
iters="${DOSH_BENCH_ITERS:-10}"
server="${DOSH_BENCH_SERVER:-${1:-local}}"
label="${DOSH_BENCH_LABEL:-$(uname -s) $(uname -m)}"
cargo build --release >/dev/null
mkdir -p "$(dirname "$out")"
exec target/release/dosh-bench \
--server "$server" \
--iterations "$iters" \
--label "$label" \
--markdown \
--output "$out" \
${DOSH_BENCH_ARGS:-}
+27 -1
View File
@@ -31,7 +31,7 @@ for _ in 1 2 3 4 5; do
done done
ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts" ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts"
mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-cached" "$workdir/home-mosh" mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-native" "$workdir/home-cached" "$workdir/home-mosh"
if ! HOME="$workdir/home" target/release/dosh-bench \ if ! HOME="$workdir/home" target/release/dosh-bench \
--server bench@127.0.0.1 \ --server bench@127.0.0.1 \
@@ -64,6 +64,32 @@ if ! HOME="$workdir/home-controlmaster" target/release/dosh-bench \
exit 1 exit 1
fi fi
if ! HOME="$workdir/home-native" target/release/dosh-client \
--ssh-port "$ssh_port" \
--ssh-key "$workdir/id_ed25519" \
--ssh-known-hosts "$workdir/known_hosts" \
--ssh-auth-command /usr/local/bin/dosh-auth \
trust bench@127.0.0.1 >/dev/null; then
docker logs "$container_id" || true
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
exit 1
fi
if ! HOME="$workdir/home-native" target/release/dosh-bench \
--server bench@127.0.0.1 \
--ssh-port "$ssh_port" \
--dosh-port "$dosh_port" \
--dosh-host 127.0.0.1 \
--ssh-key "$workdir/id_ed25519" \
--ssh-known-hosts "$workdir/known_hosts" \
--iterations 5 \
--cold-native \
--assert-ssh-plus-ms "${DOSH_BENCH_NATIVE_SSH_PLUS_MS:-0}"; then
docker logs "$container_id" || true
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
exit 1
fi
if ! HOME="$workdir/home-cached" target/release/dosh-bench \ if ! HOME="$workdir/home-cached" target/release/dosh-bench \
--server bench@127.0.0.1 \ --server bench@127.0.0.1 \
--ssh-port "$ssh_port" \ --ssh-port "$ssh_port" \
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env sh
set -eu
seconds="${DOSH_FUZZ_SECONDS:-${1:-20}}"
targets="
packet_decode
from_body
authorized_keys
known_hosts
handshake_structs
attach_ticket
"
if ! command -v cargo >/dev/null 2>&1; then
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
fi
for target in $targets; do
echo "== fuzzing $target for ${seconds}s =="
cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \
-max_total_time="$seconds" -rss_limit_mb="${DOSH_FUZZ_RSS_MB:-4096}"
done
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env sh
set -eu
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
out_dir="${DOSH_PACKAGE_DIR:-target/dosh-release}"
version="${DOSH_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | sed -n '1p')}"
normalize_os() {
case "$(uname -s)" in
Darwin) printf '%s\n' macos ;;
Linux) printf '%s\n' linux ;;
FreeBSD) printf '%s\n' freebsd ;;
MINGW*|MSYS*|CYGWIN*) printf '%s\n' windows ;;
*) uname -s | tr '[:upper:]' '[:lower:]' ;;
esac
}
normalize_arch() {
case "$(uname -m)" in
x86_64|amd64) printf '%s\n' x86_64 ;;
arm64|aarch64) printf '%s\n' aarch64 ;;
armv7l) printf '%s\n' armv7 ;;
*) uname -m | tr '[:upper:]' '[:lower:]' ;;
esac
}
host_os="$(normalize_os)"
os="${DOSH_PACKAGE_OS:-$host_os}"
arch="${DOSH_PACKAGE_ARCH:-$(normalize_arch)}"
target="${DOSH_PACKAGE_TARGET:-}"
if [ -z "$target" ] && [ "$os" = "windows" ] && [ "$host_os" != "windows" ]; then
case "$arch" in
x86_64) target="x86_64-pc-windows-gnu" ;;
*) echo "set DOSH_PACKAGE_TARGET for windows/$arch" >&2; exit 1 ;;
esac
fi
if [ "$os" = "windows" ]; then
artifact="dosh-$os-$arch.zip"
versioned_artifact="dosh-$version-$os-$arch.zip"
else
artifact="dosh-$os-$arch.tar.gz"
versioned_artifact="dosh-$version-$os-$arch.tar.gz"
fi
stage="$out_dir/stage/dosh"
write_versioned="${DOSH_PACKAGE_VERSIONED:-0}"
if [ -n "$target" ]; then
release_dir="target/$target/release"
else
release_dir="target/release"
fi
if 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 "$release_dir/$bin" ]; then
install -m 0755 "$release_dir/$bin" "$stage/bin/$bin"
elif [ -f "$release_dir/$bin.exe" ]; then
install -m 0755 "$release_dir/$bin.exe" "$stage/bin/$bin.exe"
fi
done
if [ -f "$stage/bin/dosh-client.exe" ]; then
cp "$stage/bin/dosh-client.exe" "$stage/bin/dosh.exe"
elif [ -f "$stage/bin/dosh-client" ]; then
cp "$stage/bin/dosh-client" "$stage/bin/dosh"
fi
strip_bin() {
file="$1"
[ -f "$file" ] || return 0
case "$file" in
*.exe)
if command -v x86_64-w64-mingw32-strip >/dev/null 2>&1; then
x86_64-w64-mingw32-strip "$file" 2>/dev/null || true
elif command -v strip >/dev/null 2>&1; then
strip "$file" 2>/dev/null || true
fi
;;
*)
if command -v strip >/dev/null 2>&1; then
strip "$file" 2>/dev/null || true
fi
;;
esac
}
for bin in "$stage"/bin/*; do
strip_bin "$bin"
done
printf '%s\n' "$version" >"$stage/VERSION"
if [ "$os" = "windows" ]; then
if command -v powershell.exe >/dev/null 2>&1; then
powershell.exe -NoProfile -Command "Compress-Archive -Force -Path '$stage' -DestinationPath '$out_dir/$artifact'"
elif command -v zip >/dev/null 2>&1; then
(cd "$out_dir/stage" && zip -qr "../$artifact" dosh)
else
echo "windows packaging requires powershell.exe or zip" >&2
exit 1
fi
else
tar -C "$out_dir/stage" -czf "$out_dir/$artifact" dosh
fi
write_sha256() {
file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$file" >"$file.sha256"
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" >"$file.sha256"
else
echo "warning: sha256sum/shasum not found; skipping checksum for $file" >&2
fi
}
write_sha256 "$out_dir/$artifact"
if [ "$write_versioned" = "1" ]; then
cp "$out_dir/$artifact" "$out_dir/$versioned_artifact"
write_sha256 "$out_dir/$versioned_artifact"
fi
printf 'Wrote:\n'
cat <<EOF
$out_dir/$artifact
$out_dir/$artifact.sha256
EOF
if [ "$write_versioned" = "1" ]; then
cat <<EOF
$out_dir/$versioned_artifact
$out_dir/$versioned_artifact.sha256
EOF
fi
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env sh
set -eu
usage() {
cat <<'EOF'
Usage:
scripts/publish-gitea-release.sh [TAG]
Publishes the current Dosh release to Gitea.
Defaults:
TAG v<Cargo.toml version>
DOSH_PUBLISH_BUILD 1: run scripts/package-release.sh first
DOSH_PUBLISH_PUSH 1: push TAG to origin
DOSH_PUBLISH_PRUNE 1: remove stale dosh-<version>-* duplicate assets
Environment:
GITEA_URL, GITEA_REPO, GITEA_TOKEN: same as upload-gitea-release.sh
DOSH_PUBLISH_ALLOW_DIRTY=1 allow uncommitted changes
EOF
}
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
usage
exit 0
fi
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | sed -n '1p')"
tag="${1:-v$version}"
base="${GITEA_URL:-https://git.palav.dev}"
repo="${GITEA_REPO:-Palav/dosh}"
token="${GITEA_TOKEN:-}"
build="${DOSH_PUBLISH_BUILD:-1}"
push_tag="${DOSH_PUBLISH_PUSH:-1}"
prune="${DOSH_PUBLISH_PRUNE:-1}"
if [ -z "$token" ] && [ -f "$HOME/.config/dosh/gitea-token" ]; then
token="$(tr -d '\r\n' <"$HOME/.config/dosh/gitea-token")"
fi
need() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "missing required command: $1" >&2
exit 1
fi
}
need git
need curl
if [ "${DOSH_PUBLISH_ALLOW_DIRTY:-0}" != "1" ] && [ -n "$(git status --porcelain)" ]; then
echo "refusing to publish with uncommitted changes" >&2
echo "commit first, or set DOSH_PUBLISH_ALLOW_DIRTY=1" >&2
exit 1
fi
head="$(git rev-parse HEAD)"
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
tagged="$(git rev-list -n 1 "$tag")"
if [ "$tagged" != "$head" ]; then
echo "tag $tag points at $tagged, not HEAD $head" >&2
exit 1
fi
else
git tag "$tag" "$head"
fi
if [ "$push_tag" = "1" ]; then
git push origin "$tag"
fi
if [ "$build" = "1" ]; then
sh scripts/package-release.sh
fi
scripts/upload-gitea-release.sh "$tag"
api="$base/api/v1/repos/$repo"
auth_header="Authorization: token $token"
release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag")"
release_id=""
if command -v jq >/dev/null 2>&1; then
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
fi
if [ "$prune" = "1" ] && [ -n "$release_id" ] && command -v jq >/dev/null 2>&1; then
printf '%s\n' "$release_json" \
| jq -r '.assets[]? | select(.name | test("^dosh-[0-9]")) | [.id, .name] | @tsv' \
| while IFS="$(printf '\t')" read -r asset_id name; do
[ -n "$asset_id" ] || continue
echo "deleting duplicate $name"
curl -fsS -H "$auth_header" -X DELETE "$api/releases/$release_id/assets/$asset_id" >/dev/null
done
release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag")"
fi
web_repo="${base%/}/${repo}"
failed=0
artifact_version() {
artifact="$1"
case "$artifact" in
*.tar.gz)
tar -xOf "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
;;
*.zip)
unzip -p "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
;;
*)
return 1
;;
esac
}
for artifact in target/dosh-release/dosh-linux-*.tar.gz target/dosh-release/dosh-macos-*.tar.gz target/dosh-release/dosh-windows-*.zip; do
[ -f "$artifact" ] || continue
name="$(basename "$artifact")"
case "$name" in
dosh-[0-9]*)
continue
;;
esac
actual="$(artifact_version "$artifact" || true)"
if [ "$actual" != "$version" ]; then
echo "skipping stale artifact $name: version ${actual:-unknown}, expected $version" >&2
continue
fi
url="$web_repo/releases/download/$tag/$name"
if curl -fsSI "$url" >/dev/null; then
echo "verified $url"
else
echo "missing release asset: $url" >&2
failed=1
fi
done
if [ "$failed" != "0" ]; then
exit 1
fi
if command -v jq >/dev/null 2>&1; then
printf '%s\n' "$release_json" | jq -r '"assets=\(.assets|length)", (.assets[] | "\(.name) \(.size)")'
fi
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env sh
set -eu
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
for test_name in \
live_output_forwards_terminal_control_sequences \
tui_control_sequences_survive_transport_verbatim \
unicode_output_survives_transport \
tui_graph_glyphs_survive_fast_repaints_without_snapshot_substitution \
btop_style_graph_output_stays_raw_when_retransmit_history_overflows \
large_tui_paint_is_delivered_in_mtu_safe_frames \
resume_snapshot_preserves_alternate_screen_mode
do
cargo test --test integration_smoke "$test_name" -- --nocapture
done
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env sh
set -eu
usage() {
cat <<'EOF'
Usage:
GITEA_TOKEN=... scripts/upload-gitea-release.sh TAG [artifact...]
Environment:
GITEA_URL Base URL, default https://git.palav.dev
GITEA_REPO owner/repo, default Palav/dosh
GITEA_TOKEN Token with release write permission
GITEA_TITLE Release title, default TAG
EOF
}
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
usage
exit 0
fi
tag="${1:-}"
if [ -z "$tag" ]; then
usage >&2
exit 2
fi
shift
base="${GITEA_URL:-https://git.palav.dev}"
repo="${GITEA_REPO:-Palav/dosh}"
token="${GITEA_TOKEN:-}"
title="${GITEA_TITLE:-$tag}"
expected_version="${tag#v}"
artifact_version() {
artifact="$1"
case "$artifact" in
*.tar.gz)
tar -xOf "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
;;
*.zip)
unzip -p "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
;;
*)
return 1
;;
esac
}
if [ -z "$token" ] && [ -f "$HOME/.config/dosh/gitea-token" ]; then
token="$(tr -d '\r\n' <"$HOME/.config/dosh/gitea-token")"
fi
if [ -z "$token" ]; then
echo "GITEA_TOKEN is required (or put it in ~/.config/dosh/gitea-token)" >&2
exit 2
fi
if [ "$#" -eq 0 ]; then
for artifact in \
target/dosh-release/dosh-linux-*.tar.gz \
target/dosh-release/dosh-macos-*.tar.gz \
target/dosh-release/dosh-windows-*.zip; do
[ -f "$artifact" ] || continue
case "$(basename "$artifact")" in
dosh-[0-9]*)
continue
;;
esac
actual="$(artifact_version "$artifact" || true)"
if [ "$actual" != "$expected_version" ]; then
echo "skipping $artifact: version ${actual:-unknown}, expected $expected_version" >&2
continue
fi
if [ -f "$artifact.sha256" ]; then
set -- "$@" "$artifact" "$artifact.sha256"
else
set -- "$@" "$artifact"
fi
done
fi
api="$base/api/v1/repos/$repo"
auth_header="Authorization: token $token"
release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag" 2>/dev/null || true)"
release_id=""
if [ -n "$release_json" ] && command -v jq >/dev/null 2>&1; then
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
elif [ -n "$release_json" ]; then
release_id="$(printf '%s\n' "$release_json" | sed -n 's/^[[:space:]]*{"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
fi
if [ -z "$release_id" ]; then
payload="$(printf '{"tag_name":"%s","target_commitish":"main","name":"%s","body":"Dosh release %s","draft":false,"prerelease":false}\n' "$tag" "$title" "$tag")"
release_json="$(curl -fsS -H "$auth_header" -H 'Content-Type: application/json' -X POST "$api/releases" -d "$payload")"
if command -v jq >/dev/null 2>&1; then
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
else
release_id="$(printf '%s\n' "$release_json" | sed -n 's/^[[:space:]]*{"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
fi
fi
if [ -z "$release_id" ]; then
echo "could not determine Gitea release id for $tag" >&2
exit 1
fi
delete_existing_asset() {
name="$1"
if ! command -v jq >/dev/null 2>&1; then
return 0
fi
printf '%s\n' "$release_json" \
| jq -r --arg name "$name" '.assets[]? | select(.name == $name) | .id' \
| while IFS= read -r asset_id; do
[ -n "$asset_id" ] || continue
echo "replacing $name"
curl -fsS \
-H "$auth_header" \
-X DELETE \
"$api/releases/$release_id/assets/$asset_id" >/dev/null
done
}
verify_release_artifact_version() {
artifact="$1"
name="$(basename "$artifact")"
case "$name" in
*.sha256)
return 0
;;
dosh-*.tar.gz|dosh-*.zip)
actual="$(artifact_version "$artifact" || true)"
if [ -z "$actual" ]; then
echo "release artifact missing dosh/VERSION: $artifact" >&2
exit 1
fi
if [ "$actual" != "$expected_version" ]; then
echo "release artifact version mismatch: $artifact has $actual, expected $expected_version" >&2
exit 1
fi
;;
esac
}
uploaded=0
for artifact in "$@"; do
if [ ! -f "$artifact" ]; then
continue
fi
name="$(basename "$artifact")"
verify_release_artifact_version "$artifact"
delete_existing_asset "$name"
echo "uploading $name"
curl -fsS \
-H "$auth_header" \
-X POST \
-F "attachment=@$artifact" \
"$api/releases/$release_id/assets?name=$name" >/dev/null
uploaded=$((uploaded + 1))
done
if [ "$uploaded" -eq 0 ]; then
echo "no release artifacts uploaded" >&2
exit 1
fi
+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 serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -82,10 +83,11 @@ pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
} }
let secret = crypto::random_32(); let secret = crypto::random_32();
let mut file = fs::OpenOptions::new() let mut options = fs::OpenOptions::new();
.create_new(true) options.create_new(true).write(true);
.write(true) #[cfg(unix)]
.mode(0o600) options.mode(0o600);
let mut file = options
.open(&path) .open(&path)
.with_context(|| format!("create {}", path.display()))?; .with_context(|| format!("create {}", path.display()))?;
file.write_all(URL_SAFE_NO_PAD.encode(secret).as_bytes())?; file.write_all(URL_SAFE_NO_PAD.encode(secret).as_bytes())?;
@@ -93,6 +95,7 @@ pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> {
Ok(secret) Ok(secret)
} }
#[allow(clippy::too_many_arguments)]
pub fn build_bootstrap( pub fn build_bootstrap(
config: &ServerConfig, config: &ServerConfig,
secret: &[u8; 32], secret: &[u8; 32],
@@ -159,6 +162,7 @@ pub fn build_bootstrap(
}) })
} }
#[allow(clippy::too_many_arguments)]
pub fn attach_token( pub fn attach_token(
secret: &[u8; 32], secret: &[u8; 32],
user: &str, user: &str,
@@ -204,6 +208,7 @@ pub fn verify_bootstrap(resp: &BootstrapResponse, secret: &[u8; 32]) -> Result<b
Ok(expected == resp.attach_token) Ok(expected == resp.attach_token)
} }
#[allow(clippy::too_many_arguments)]
pub fn build_attach_ticket( pub fn build_attach_ticket(
secret: &[u8; 32], secret: &[u8; 32],
server_id: [u8; 32], server_id: [u8; 32],
+5
View File
@@ -5,6 +5,11 @@ use dosh::config::load_server_config;
use dosh::native::{host_public_key, host_public_key_line, load_or_create_host_key}; use dosh::native::{host_public_key, host_public_key_line, load_or_create_host_key};
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command(
name = "dosh-auth",
version = dosh::build_info::VERSION,
long_version = dosh::build_info::LONG_VERSION
)]
struct Args { struct Args {
#[arg(long, default_value_t = 1)] #[arg(long, default_value_t = 1)]
protocol: u8, protocol: u8,
+144 -12
View File
@@ -2,6 +2,8 @@ use anyhow::{Context, Result, anyhow};
use clap::Parser; use clap::Parser;
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fmt::Write as _;
use std::fs;
use std::io::Read; use std::io::Read;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
@@ -9,7 +11,11 @@ use std::sync::mpsc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command(name = "dosh-bench")] #[command(
name = "dosh-bench",
version = dosh::build_info::VERSION,
long_version = dosh::build_info::LONG_VERSION
)]
struct Args { struct Args {
#[arg(long, default_value = "local")] #[arg(long, default_value = "local")]
server: String, server: String,
@@ -63,6 +69,12 @@ struct Args {
/// Emit machine-readable JSON (one object per metric, with raw samples). /// Emit machine-readable JSON (one object per metric, with raw samples).
#[arg(long)] #[arg(long)]
json: bool, json: bool,
/// Emit a publishable Markdown benchmark report.
#[arg(long)]
markdown: bool,
/// Write the report to a file instead of stdout.
#[arg(long)]
output: Option<PathBuf>,
/// Optional label printed in summary/JSON output (e.g. machine/OS identifier). /// Optional label printed in summary/JSON output (e.g. machine/OS identifier).
#[arg(long)] #[arg(long)]
label: Option<String>, label: Option<String>,
@@ -179,11 +191,14 @@ fn main() -> Result<()> {
results.push(MetricSamples::new("mosh_start_true_ms", mosh_times)); results.push(MetricSamples::new("mosh_start_true_ms", mosh_times));
} }
if args.json { let report = if args.json {
print_json(&args, &results); render_json(&args, &results)
} else if args.markdown {
render_markdown(&args, &results)
} else { } else {
print_table(&args, &results); render_table(&args, &results)
} };
write_report(&args, &report)?;
run_assertions(&args, &results)?; run_assertions(&args, &results)?;
Ok(()) Ok(())
@@ -560,17 +575,20 @@ fn percentile(sorted: &[f64], pct: f64) -> f64 {
} }
} }
fn print_table(args: &Args, results: &[MetricSamples]) { fn render_table(args: &Args, results: &[MetricSamples]) -> String {
let mut out = String::new();
if let Some(label) = &args.label { if let Some(label) = &args.label {
println!("# label: {label}"); let _ = writeln!(out, "# label: {label}");
} }
println!( let _ = writeln!(
out,
"{:<24} {:>6} {:>9} {:>9} {:>9} {:>9} {:>9}", "{:<24} {:>6} {:>9} {:>9} {:>9} {:>9} {:>9}",
"metric", "n", "min", "median", "p95", "mean", "max" "metric", "n", "min", "median", "p95", "mean", "max"
); );
for metric in results { for metric in results {
let s = metric.stats(); let s = metric.stats();
println!( let _ = writeln!(
out,
"{:<24} {:>6} {:>9.2} {:>9.2} {:>9.2} {:>9.2} {:>9.2}", "{:<24} {:>6} {:>9.2} {:>9.2} {:>9.2} {:>9.2} {:>9.2}",
metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max
); );
@@ -578,11 +596,12 @@ fn print_table(args: &Args, results: &[MetricSamples]) {
// Raw per-iteration samples, so published numbers can include raw data. // Raw per-iteration samples, so published numbers can include raw data.
for metric in results { for metric in results {
let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect(); let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect();
println!("{} samples_ms=[{}]", metric.name, ms.join(", ")); let _ = writeln!(out, "{} samples_ms=[{}]", metric.name, ms.join(", "));
} }
out
} }
fn print_json(args: &Args, results: &[MetricSamples]) { fn render_json(args: &Args, results: &[MetricSamples]) -> String {
let mut entries = Vec::new(); let mut entries = Vec::new();
for metric in results { for metric in results {
let s = metric.stats(); let s = metric.stats();
@@ -603,11 +622,58 @@ fn print_json(args: &Args, results: &[MetricSamples]) {
Some(label) => format!("\"{}\"", label.replace('"', "\\\"")), Some(label) => format!("\"{}\"", label.replace('"', "\\\"")),
None => "null".to_string(), None => "null".to_string(),
}; };
println!( format!(
"{{\"label\":{label},\"iterations\":{},\"metrics\":[{}]}}", "{{\"label\":{label},\"iterations\":{},\"metrics\":[{}]}}",
args.iterations.max(1), args.iterations.max(1),
entries.join(",") entries.join(",")
)
}
fn render_markdown(args: &Args, results: &[MetricSamples]) -> String {
let mut out = String::new();
let label = args.label.as_deref().unwrap_or("Dosh benchmark");
let _ = writeln!(out, "# {label}\n");
let _ = writeln!(out, "- server: `{}`", args.server);
let _ = writeln!(out, "- iterations: `{}`", args.iterations.max(1));
let _ = writeln!(
out,
"- generated_by: `dosh-bench {}`\n",
env!("CARGO_PKG_VERSION")
); );
let _ = writeln!(
out,
"| metric | n | min ms | median ms | p95 ms | mean ms | max ms |"
);
let _ = writeln!(out, "|---|---:|---:|---:|---:|---:|---:|");
for metric in results {
let s = metric.stats();
let _ = writeln!(
out,
"| `{}` | {} | {:.2} | {:.2} | {:.2} | {:.2} | {:.2} |",
metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max
);
}
let _ = writeln!(out, "\n## Raw Samples\n");
for metric in results {
let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect();
let _ = writeln!(out, "- `{}`: [{}]", metric.name, ms.join(", "));
}
out
}
fn write_report(args: &Args, report: &str) -> Result<()> {
if let Some(path) = &args.output {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
fs::write(path, report).with_context(|| format!("write {}", path.display()))?;
println!("wrote {}", path.display());
} else {
println!("{report}");
}
Ok(())
} }
fn metric_mean(results: &[MetricSamples], name: &str) -> Option<f64> { fn metric_mean(results: &[MetricSamples], name: &str) -> Option<f64> {
@@ -677,3 +743,69 @@ fn default_client_path() -> PathBuf {
.and_then(|path| path.parent().map(|parent| parent.join("dosh-client"))) .and_then(|path| path.parent().map(|parent| parent.join("dosh-client")))
.unwrap_or_else(|| PathBuf::from("dosh-client")) .unwrap_or_else(|| PathBuf::from("dosh-client"))
} }
#[cfg(test)]
mod tests {
use super::*;
fn args_for_render() -> Args {
Args {
server: "local".to_string(),
session: "default".to_string(),
ssh_port: 22,
dosh_port: 50000,
dosh_host: None,
iterations: 2,
local_auth: true,
cold_native: false,
cached_ticket: false,
resume: false,
client: None,
ssh_auth_command: "~/.local/bin/dosh-auth".to_string(),
ssh_key: None,
ssh_known_hosts: None,
ssh_control_path: None,
controlmaster: false,
no_cache: false,
warm_cache: false,
skip_ssh_baseline: true,
include_mosh: false,
mosh: PathBuf::from("mosh"),
mosh_server_command: "mosh-server".to_string(),
mosh_port: None,
json: false,
markdown: true,
output: None,
label: Some("test host".to_string()),
assert_ssh_plus_ms: None,
assert_mosh_minus_ms: None,
assert_dosh_max_ms: None,
}
}
#[test]
fn markdown_report_contains_summary_and_samples() {
let args = args_for_render();
let metrics = vec![MetricSamples::new(
"dosh_cached_attach_ms",
vec![Duration::from_millis(3), Duration::from_millis(5)],
)];
let report = render_markdown(&args, &metrics);
assert!(report.contains("# test host"));
assert!(report.contains("| `dosh_cached_attach_ms` | 2 | 3.00"));
assert!(report.contains("- `dosh_cached_attach_ms`: [3.00, 5.00]"));
}
#[test]
fn json_report_contains_metrics() {
let args = args_for_render();
let metrics = vec![MetricSamples::new(
"dosh_local_attach_ms",
vec![Duration::from_millis(7)],
)];
let report = render_json(&args, &metrics);
assert!(report.contains("\"label\":\"test host\""));
assert!(report.contains("\"metric\":\"dosh_local_attach_ms\""));
assert!(report.contains("\"samples_ms\":[7.0000]"));
}
}
+5186 -280
View File
File diff suppressed because it is too large Load Diff
+1991 -196
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"))
);
}
}
+58 -17
View File
@@ -14,6 +14,8 @@ pub struct ServerConfig {
pub allow_attach_tickets: bool, pub allow_attach_tickets: bool,
pub client_timeout_secs: u64, pub client_timeout_secs: u64,
pub retransmit_window: usize, pub retransmit_window: usize,
#[serde(default = "default_output_frame_interval_ms")]
pub output_frame_interval_ms: u64,
pub default_input_mode: String, pub default_input_mode: String,
pub prewarm_sessions: Vec<String>, pub prewarm_sessions: Vec<String>,
pub create_on_attach: bool, pub create_on_attach: bool,
@@ -44,18 +46,15 @@ pub struct ServerConfig {
pub allow_remote_non_loopback_bind: bool, pub allow_remote_non_loopback_bind: bool,
#[serde(default)] #[serde(default)]
pub allow_agent_forwarding: bool, pub allow_agent_forwarding: bool,
#[serde(default = "default_true")]
pub allow_file_transfer: bool,
#[serde(default = "default_true")]
pub allow_exec_command: bool,
#[serde(default = "default_accept_env")] #[serde(default = "default_accept_env")]
pub accept_env: Vec<String>, pub accept_env: Vec<String>,
/// Run each terminal session's shell under a small per-session *holder* /// Run terminal sessions under per-session holder processes so shells
/// process so it survives a `dosh-server` restart (crash/upgrade/ /// survive a quick `dosh-server` restart and clients can reconnect.
/// `systemctl restart`). On startup the server re-adopts live holders and #[serde(default = "default_true")]
/// reattaching clients land on the same shell with screen state intact. When
/// `false`, sessions behave exactly as before: the shell is a child of the
/// server and dies with it.
// Opt-in for now: this changes the core session model (per-session holder
// processes + fd passing), so it stays OFF by default until it has been
// stress-tested on a real host. Set `persist_sessions = true` to enable.
#[serde(default)]
pub persist_sessions: bool, pub persist_sessions: bool,
} }
@@ -68,8 +67,9 @@ impl Default for ServerConfig {
auth_ttl_secs: 30, auth_ttl_secs: 30,
attach_ticket_ttl_secs: 3600, attach_ticket_ttl_secs: 3600,
allow_attach_tickets: true, allow_attach_tickets: true,
client_timeout_secs: 86400, client_timeout_secs: 2_592_000,
retransmit_window: 256, retransmit_window: 256,
output_frame_interval_ms: default_output_frame_interval_ms(),
default_input_mode: "read-write".to_string(), default_input_mode: "read-write".to_string(),
prewarm_sessions: vec!["default".to_string()], prewarm_sessions: vec!["default".to_string()],
create_on_attach: true, create_on_attach: true,
@@ -86,8 +86,10 @@ impl Default for ServerConfig {
allow_remote_forwarding: false, allow_remote_forwarding: false,
allow_remote_non_loopback_bind: false, allow_remote_non_loopback_bind: false,
allow_agent_forwarding: false, allow_agent_forwarding: false,
allow_file_transfer: true,
allow_exec_command: true,
accept_env: default_accept_env(), accept_env: default_accept_env(),
persist_sessions: false, persist_sessions: true,
} }
} }
} }
@@ -104,7 +106,7 @@ pub struct ClientConfig {
pub default_session: String, pub default_session: String,
pub reconnect_timeout_secs: u64, pub reconnect_timeout_secs: u64,
pub view_only: bool, pub view_only: bool,
#[serde(default)] #[serde(default = "default_true")]
pub predict: bool, pub predict: bool,
/// Prediction display policy: "off", "experimental" (adaptive, the default), /// Prediction display policy: "off", "experimental" (adaptive, the default),
/// or "always". Controls when speculative local echo is shown. /// or "always". Controls when speculative local echo is shown.
@@ -132,15 +134,28 @@ pub struct ClientConfig {
/// full-screen TUI, and cleared the instant packets resume. Default on. /// full-screen TUI, and cleared the instant packets resume. Default on.
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub disconnect_status: bool, pub disconnect_status: bool,
#[serde(default = "default_escape_key")]
pub escape_key: String,
#[serde(default = "default_send_env")] #[serde(default = "default_send_env")]
pub send_env: Vec<String>, pub send_env: Vec<String>,
#[serde(default)] #[serde(default)]
pub set_env: HashMap<String, String>, pub set_env: HashMap<String, String>,
/// Optional client-side command extensions. These are just shell command
/// templates expanded into the remote session's startup input; Dosh does not
/// depend on the tools they name.
#[serde(default)]
pub extensions: HashMap<String, CommandExtension>,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HostConfig { pub struct HostConfig {
pub ssh: Option<String>, 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 user: Option<String>,
pub dosh_host: Option<String>, pub dosh_host: Option<String>,
pub port: Option<u16>, pub port: Option<u16>,
@@ -151,6 +166,22 @@ pub struct HostConfig {
pub send_env: Option<Vec<String>>, pub send_env: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
pub set_env: HashMap<String, String>, pub set_env: HashMap<String, String>,
/// Per-host extension overrides. A host can replace a global extension or set
/// `disabled = true` to opt out of it.
#[serde(default)]
pub extensions: HashMap<String, CommandExtension>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandExtension {
/// Remote shell command template. `{args}` expands to shell-quoted extra
/// words after the extension name. When absent, extra args are appended.
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub disabled: bool,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -172,7 +203,7 @@ impl Default for ClientConfig {
default_session: "new".to_string(), default_session: "new".to_string(),
reconnect_timeout_secs: 5, reconnect_timeout_secs: 5,
view_only: false, view_only: false,
predict: false, predict: true,
predict_mode: default_predict_mode(), predict_mode: default_predict_mode(),
cache_attach_tickets: true, cache_attach_tickets: true,
credential_cache: "~/.local/share/dosh/credentials".to_string(), credential_cache: "~/.local/share/dosh/credentials".to_string(),
@@ -184,8 +215,10 @@ impl Default for ClientConfig {
use_ssh_agent: true, use_ssh_agent: true,
forward_agent: false, forward_agent: false,
disconnect_status: true, disconnect_status: true,
escape_key: default_escape_key(),
send_env: default_send_env(), send_env: default_send_env(),
set_env: HashMap::new(), set_env: HashMap::new(),
extensions: HashMap::new(),
} }
} }
} }
@@ -209,6 +242,10 @@ fn default_native_auth_rate_limit_per_minute() -> u32 {
30 30
} }
fn default_output_frame_interval_ms() -> u64 {
16
}
fn default_rekey_after_packets() -> u64 { fn default_rekey_after_packets() -> u64 {
// Rotate well before any AEAD nonce-reuse concern; ChaCha20-Poly1305 with a // Rotate well before any AEAD nonce-reuse concern; ChaCha20-Poly1305 with a
// per-direction monotonic seq is safe far beyond this, but a bounded epoch // per-direction monotonic seq is safe far beyond this, but a bounded epoch
@@ -237,6 +274,10 @@ fn default_known_hosts() -> String {
"~/.config/dosh/known_hosts".to_string() "~/.config/dosh/known_hosts".to_string()
} }
fn default_escape_key() -> String {
"^]".to_string()
}
fn default_identity_files() -> Vec<String> { fn default_identity_files() -> Vec<String> {
vec!["~/.ssh/id_ed25519".to_string()] vec!["~/.ssh/id_ed25519".to_string()]
} }
@@ -282,10 +323,10 @@ pub fn load_hosts_config(path: Option<PathBuf>) -> Result<HostsConfig> {
} }
pub fn expand_tilde(path: &str) -> PathBuf { pub fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") { if let Some(rest) = path.strip_prefix("~/")
if let Some(home) = dirs::home_dir() { && let Some(home) = dirs::home_dir()
{
return home.join(rest); return home.join(rest);
} }
}
PathBuf::from(path) PathBuf::from(path)
} }
+71
View File
@@ -0,0 +1,71 @@
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
pub const EXEC_STREAM_SENTINEL: &str = "@dosh-exec";
pub const FRAME_MAX_LEN: usize = 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecRequest {
pub command: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ExecResponse {
Stdout(Vec<u8>),
Stderr(Vec<u8>),
Exit { code: Option<i32> },
Error { message: String },
}
#[derive(Default)]
pub struct FrameDecoder {
buf: Vec<u8>,
}
impl FrameDecoder {
pub fn push(&mut self, bytes: &[u8]) -> Result<Vec<Vec<u8>>> {
self.buf.extend_from_slice(bytes);
let mut frames = Vec::new();
loop {
if self.buf.len() < 4 {
break;
}
let len = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize;
if len > FRAME_MAX_LEN {
bail!("exec protocol frame too large: {len} bytes");
}
if self.buf.len() < 4 + len {
break;
}
frames.push(self.buf[4..4 + len].to_vec());
self.buf.drain(..4 + len);
}
Ok(frames)
}
}
pub fn encode_request(request: &ExecRequest) -> Result<Vec<u8>> {
encode_frame(&bincode::serialize(request).context("encode exec request")?)
}
pub fn encode_response(response: &ExecResponse) -> Result<Vec<u8>> {
encode_frame(&bincode::serialize(response).context("encode exec response")?)
}
pub fn decode_request(frame: &[u8]) -> Result<ExecRequest> {
bincode::deserialize(frame).context("decode exec request")
}
pub fn decode_response(frame: &[u8]) -> Result<ExecResponse> {
bincode::deserialize(frame).context("decode exec response")
}
fn encode_frame(payload: &[u8]) -> Result<Vec<u8>> {
if payload.len() > FRAME_MAX_LEN {
bail!("exec protocol frame too large: {} bytes", payload.len());
}
let mut out = Vec::with_capacity(4 + payload.len());
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
out.extend_from_slice(payload);
Ok(out)
}
+250
View File
@@ -0,0 +1,250 @@
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const FILE_STREAM_SENTINEL: &str = "@dosh-file";
pub const FRAME_MAX_LEN: usize = 1024 * 1024;
pub const CHUNK_SIZE: usize = 32 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum FileRequest {
Stat {
path: String,
},
List {
path: String,
},
Mkdir {
path: String,
mode: Option<u32>,
},
Remove {
path: String,
recursive: bool,
},
PutStart {
path: String,
size: u64,
mode: Option<u32>,
modified_secs: Option<u64>,
overwrite: bool,
resume: bool,
},
PutChunk {
offset: u64,
bytes: Vec<u8>,
},
PutFinish {
sha256: [u8; 32],
},
Get {
path: String,
offset: u64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum FileResponse {
Ok,
Resume { offset: u64 },
Stat { meta: FileMeta },
List { entries: Vec<FileEntry> },
Start { meta: FileMeta, offset: u64 },
Chunk { offset: u64, bytes: Vec<u8> },
Done { sha256: [u8; 32], bytes: u64 },
Error { message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileMeta {
pub path: String,
pub kind: FileKind,
pub len: u64,
pub mode: Option<u32>,
pub modified_secs: Option<u64>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum FileKind {
File,
Directory,
Symlink,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileEntry {
pub name: String,
pub meta: FileMeta,
}
#[derive(Default)]
pub struct FrameDecoder {
buf: Vec<u8>,
}
impl FrameDecoder {
pub fn push(&mut self, bytes: &[u8]) -> Result<Vec<Vec<u8>>> {
self.buf.extend_from_slice(bytes);
let mut frames = Vec::new();
loop {
if self.buf.len() < 4 {
break;
}
let len = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize;
if len > FRAME_MAX_LEN {
bail!("file protocol frame too large: {len} bytes");
}
if self.buf.len() < 4 + len {
break;
}
frames.push(self.buf[4..4 + len].to_vec());
self.buf.drain(..4 + len);
}
Ok(frames)
}
}
pub fn encode_request(request: &FileRequest) -> Result<Vec<u8>> {
encode_frame(&bincode::serialize(request).context("encode file request")?)
}
pub fn encode_response(response: &FileResponse) -> Result<Vec<u8>> {
encode_frame(&bincode::serialize(response).context("encode file response")?)
}
pub fn decode_request(frame: &[u8]) -> Result<FileRequest> {
bincode::deserialize(frame).context("decode file request")
}
pub fn decode_response(frame: &[u8]) -> Result<FileResponse> {
bincode::deserialize(frame).context("decode file response")
}
pub fn encode_frame(payload: &[u8]) -> Result<Vec<u8>> {
if payload.len() > FRAME_MAX_LEN {
bail!("file protocol frame too large: {} bytes", payload.len());
}
let mut out = Vec::with_capacity(4 + payload.len());
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
out.extend_from_slice(payload);
Ok(out)
}
pub fn parse_copy_endpoint(raw: &str) -> CopyEndpoint {
if looks_like_windows_path(raw) {
return CopyEndpoint::Local(PathBuf::from(raw));
}
let Some(index) = raw.find(':') else {
return CopyEndpoint::Local(PathBuf::from(raw));
};
let host = &raw[..index];
let path = &raw[index + 1..];
if host.is_empty() || host.contains('/') || host.contains('\\') {
return CopyEndpoint::Local(PathBuf::from(raw));
}
CopyEndpoint::Remote {
host: host.to_string(),
path: if path.is_empty() {
".".to_string()
} else {
path.to_string()
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CopyEndpoint {
Local(PathBuf),
Remote { host: String, path: String },
}
pub fn remote_join(parent: &str, child: &str) -> String {
if parent.is_empty() || parent == "." {
return child.to_string();
}
format!("{}/{}", parent.trim_end_matches('/'), child)
}
pub fn remote_basename(path: &str) -> String {
path.trim_end_matches('/')
.rsplit('/')
.next()
.filter(|value| !value.is_empty())
.unwrap_or(path)
.to_string()
}
pub fn clean_remote_path(path: &str, home: &Path) -> Result<PathBuf> {
if path.as_bytes().contains(&0) {
bail!("remote path contains NUL");
}
let expanded = if path == "~" {
home.to_path_buf()
} else if let Some(rest) = path.strip_prefix("~/") {
home.join(rest)
} else {
let raw = Path::new(path);
if raw.is_absolute() {
raw.to_path_buf()
} else {
home.join(raw)
}
};
Ok(expanded)
}
pub fn ensure_relative_child(path: &Path) -> Result<String> {
let name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow!("path has no final component: {}", path.display()))?;
if name.is_empty() || name == "." || name == ".." {
bail!("invalid path final component: {}", path.display());
}
Ok(name.to_string())
}
fn looks_like_windows_path(raw: &str) -> bool {
let bytes = raw.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'\\' || bytes[2] == b'/')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frames_round_trip_incrementally() {
let first = encode_response(&FileResponse::Ok).unwrap();
let second = encode_response(&FileResponse::Error {
message: "nope".to_string(),
})
.unwrap();
let mut decoder = FrameDecoder::default();
assert!(decoder.push(&first[..2]).unwrap().is_empty());
let frames = decoder.push(&first[2..]).unwrap();
assert_eq!(frames.len(), 1);
assert_eq!(decode_response(&frames[0]).unwrap(), FileResponse::Ok);
let frames = decoder.push(&second).unwrap();
assert_eq!(frames.len(), 1);
}
#[test]
fn copy_endpoint_parses_scp_style_but_not_windows_drive() {
assert_eq!(
parse_copy_endpoint("palav:tmp/file"),
CopyEndpoint::Remote {
host: "palav".to_string(),
path: "tmp/file".to_string()
}
);
assert_eq!(
parse_copy_endpoint("C:\\Users\\palav\\x"),
CopyEndpoint::Local(PathBuf::from("C:\\Users\\palav\\x"))
);
}
}
+18
View File
@@ -1,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 auth;
pub mod build_info;
pub mod client;
pub mod config; pub mod config;
pub mod crypto; pub mod crypto;
pub mod exec_service;
pub mod file_transfer;
pub mod native; pub mod native;
#[cfg(unix)]
pub mod persist; pub mod persist;
pub mod protocol; pub mod protocol;
#[cfg(unix)]
pub mod pty; pub mod pty;
pub mod server;
pub mod ssh_agent; pub mod ssh_agent;
pub mod transport;
pub mod udp;
+353 -37
View File
@@ -5,10 +5,13 @@ use anyhow::{Context, Result, bail};
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rsa::traits::PublicKeyParts;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use signature::{SignatureEncoding, Signer as SignatureSigner, Verifier as SignatureVerifier};
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::net::IpAddr; use std::net::IpAddr;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
use std::path::Path; use std::path::Path;
use std::str::FromStr; use std::str::FromStr;
@@ -151,10 +154,11 @@ pub fn load_or_create_host_key(config: &ServerConfig) -> Result<SigningKey> {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
} }
let bytes = crypto::random_32(); let bytes = crypto::random_32();
let mut file = fs::OpenOptions::new() let mut options = fs::OpenOptions::new();
.create_new(true) options.create_new(true).write(true);
.write(true) #[cfg(unix)]
.mode(0o600) options.mode(0o600);
let mut file = options
.open(&path) .open(&path)
.with_context(|| format!("create {}", path.display()))?; .with_context(|| format!("create {}", path.display()))?;
file.write_all(URL_SAFE_NO_PAD.encode(bytes).as_bytes())?; file.write_all(URL_SAFE_NO_PAD.encode(bytes).as_bytes())?;
@@ -339,6 +343,92 @@ pub fn sign_user_auth(
Ok(auth) Ok(auth)
} }
pub fn sign_user_auth_with_private_key(
private_key: &ssh_key::PrivateKey,
client: &NativeClientHello,
server: &NativeServerHello,
requested_forwardings: Vec<ForwardingRequest>,
) -> Result<NativeUserAuth> {
anyhow::ensure!(!private_key.is_encrypted(), "OpenSSH identity is encrypted");
let public_key = private_key.public_key();
let key_algorithm = public_key.algorithm().as_str().to_string();
anyhow::ensure!(
is_supported_user_key_algorithm(&key_algorithm),
"unsupported native identity key algorithm {key_algorithm}"
);
let public_key_blob = ssh_public_blob_from_public_key(public_key)?;
let public_key_for_auth = if key_algorithm == "ssh-ed25519" {
parse_ssh_ed25519_public_blob(&public_key_blob)?.to_vec()
} else {
public_key_blob
};
let signature_algorithm = signature_algorithm_for_private_key(&key_algorithm)?;
let mut auth = NativeUserAuth {
public_key_algorithm: signature_algorithm.to_string(),
public_key: public_key_for_auth,
signature: Vec::new(),
requested_forwardings,
};
let transcript = user_auth_transcript(client, server, &auth)?;
auth.signature = if key_algorithm == "ssh-rsa" {
sign_rsa_sha512_private_key(private_key, &transcript)?
} else {
let signature = SignatureSigner::try_sign(private_key, &transcript)
.context("sign native user auth with OpenSSH private key")?;
anyhow::ensure!(
signature.algorithm().as_str() == auth.public_key_algorithm,
"private key produced signature algorithm {}, expected {}",
signature.algorithm().as_str(),
auth.public_key_algorithm
);
signature.as_bytes().to_vec()
};
Ok(auth)
}
fn sign_rsa_sha512_private_key(
private_key: &ssh_key::PrivateKey,
transcript: &[u8],
) -> Result<Vec<u8>> {
let rsa_keypair = private_key
.key_data()
.rsa()
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity is not ssh-rsa"))?;
let rsa_private = rsa_private_key_from_ssh_keypair(rsa_keypair)?;
let signing_key = rsa::pkcs1v15::SigningKey::<sha2::Sha512>::new(rsa_private);
let signature: rsa::pkcs1v15::Signature = SignatureSigner::sign(&signing_key, transcript);
Ok(signature.to_vec())
}
fn rsa_private_key_from_ssh_keypair(
keypair: &ssh_key::private::RsaKeypair,
) -> Result<rsa::RsaPrivateKey> {
let private = rsa::RsaPrivateKey::from_components(
rsa::BigUint::try_from(&keypair.public.n).context("convert RSA modulus")?,
rsa::BigUint::try_from(&keypair.public.e).context("convert RSA public exponent")?,
rsa::BigUint::try_from(&keypair.private.d).context("convert RSA private exponent")?,
vec![
rsa::BigUint::try_from(&keypair.private.p).context("convert RSA p prime")?,
rsa::BigUint::try_from(&keypair.private.q).context("convert RSA q prime")?,
],
)
.context("convert OpenSSH RSA private key")?;
anyhow::ensure!(
private.size().saturating_mul(8) >= 2048,
"OpenSSH RSA identity is smaller than 2048 bits"
);
Ok(private)
}
fn signature_algorithm_for_private_key(key_algorithm: &str) -> Result<&'static str> {
match key_algorithm {
"ssh-ed25519" => Ok("ssh-ed25519"),
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
"ssh-rsa" => Ok("rsa-sha2-512"),
_ => bail!("unsupported native identity key algorithm {key_algorithm}"),
}
}
pub fn verify_native_user_auth( pub fn verify_native_user_auth(
client: &NativeClientHello, client: &NativeClientHello,
server: &NativeServerHello, server: &NativeServerHello,
@@ -347,10 +437,54 @@ pub fn verify_native_user_auth(
source_ip: Option<IpAddr>, source_ip: Option<IpAddr>,
) -> Result<AuthorizedKey> { ) -> Result<AuthorizedKey> {
anyhow::ensure!( anyhow::ensure!(
auth.public_key_algorithm == "ssh-ed25519", is_supported_user_signature_algorithm(&auth.public_key_algorithm),
"unsupported native user key algorithm {}", "unsupported native user key algorithm {}",
auth.public_key_algorithm auth.public_key_algorithm
); );
let authorized_algorithm = authorized_key_algorithm_for_auth(&auth.public_key_algorithm)?;
let authorized = authorized_keys
.iter()
.find(|key| key.algorithm == authorized_algorithm && key.key == auth.public_key)
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
authorized.ensure_native_allowed(auth, source_ip)?;
let transcript = user_auth_transcript(client, server, auth)?;
verify_native_user_signature(auth, &transcript)?;
Ok(authorized.clone())
}
pub fn supported_user_key_algorithms() -> Vec<String> {
vec![
"ssh-ed25519".to_string(),
"ecdsa-sha2-nistp256".to_string(),
"rsa-sha2-512".to_string(),
"rsa-sha2-256".to_string(),
]
}
pub fn is_supported_user_key_algorithm(algorithm: &str) -> bool {
matches!(algorithm, "ssh-ed25519" | "ecdsa-sha2-nistp256" | "ssh-rsa")
}
pub fn is_supported_user_signature_algorithm(algorithm: &str) -> bool {
matches!(
algorithm,
"ssh-ed25519" | "ecdsa-sha2-nistp256" | "rsa-sha2-512" | "rsa-sha2-256"
)
}
fn authorized_key_algorithm_for_auth(signature_algorithm: &str) -> Result<&'static str> {
match signature_algorithm {
"ssh-ed25519" => Ok("ssh-ed25519"),
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
"rsa-sha2-512" | "rsa-sha2-256" => Ok("ssh-rsa"),
_ => bail!("unsupported native user key algorithm {signature_algorithm}"),
}
}
fn verify_native_user_signature(auth: &NativeUserAuth, transcript: &[u8]) -> Result<()> {
match auth.public_key_algorithm.as_str() {
"ssh-ed25519" => {
anyhow::ensure!( anyhow::ensure!(
auth.public_key.len() == 32, auth.public_key.len() == 32,
"ssh-ed25519 public key must be 32 bytes" "ssh-ed25519 public key must be 32 bytes"
@@ -359,23 +493,69 @@ pub fn verify_native_user_auth(
auth.signature.len() == 64, auth.signature.len() == 64,
"ssh-ed25519 signature must be 64 bytes" "ssh-ed25519 signature must be 64 bytes"
); );
let authorized = authorized_keys
.iter()
.find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key)
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
authorized.ensure_native_allowed(auth, source_ip)?;
let mut public_key = [0u8; 32]; let mut public_key = [0u8; 32];
public_key.copy_from_slice(&auth.public_key); public_key.copy_from_slice(&auth.public_key);
let verifying_key = VerifyingKey::from_bytes(&public_key).context("parse user public key")?; let verifying_key =
VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
let mut signature = [0u8; 64]; let mut signature = [0u8; 64];
signature.copy_from_slice(&auth.signature); signature.copy_from_slice(&auth.signature);
let signature = Signature::from_bytes(&signature); let signature = Signature::from_bytes(&signature);
let transcript = user_auth_transcript(client, server, auth)?;
verifying_key verifying_key
.verify(&transcript, &signature) .verify(transcript, &signature)
.context("verify native user signature")?; .context("verify native user signature")?;
Ok(authorized.clone()) }
"ecdsa-sha2-nistp256" => {
let public_key = ssh_public_key_from_blob(&auth.public_key)
.context("parse native user SSH public key")?;
let algorithm =
ssh_key::Algorithm::from_str(&auth.public_key_algorithm).with_context(|| {
format!("parse signature algorithm {}", auth.public_key_algorithm)
})?;
let signature = ssh_key::Signature::new(algorithm, auth.signature.clone())
.context("parse native user SSH signature")?;
SignatureVerifier::verify(public_key.key_data(), transcript, &signature)
.context("verify native user SSH signature")?;
}
"rsa-sha2-512" | "rsa-sha2-256" => {
verify_rsa_sha2_signature(
&auth.public_key,
&auth.public_key_algorithm,
transcript,
&auth.signature,
)?;
}
algorithm => bail!("unsupported native user key algorithm {algorithm}"),
}
Ok(())
}
fn verify_rsa_sha2_signature(
public_key_blob: &[u8],
signature_algorithm: &str,
transcript: &[u8],
signature: &[u8],
) -> Result<()> {
let (e, n) = parse_ssh_rsa_public_blob(public_key_blob)?;
let public = rsa::RsaPublicKey::new(
rsa::BigUint::from_bytes_be(n),
rsa::BigUint::from_bytes_be(e),
)
.context("parse RSA public key")?;
let signature =
rsa::pkcs1v15::Signature::try_from(signature).context("parse RSA PKCS#1v1.5 signature")?;
match signature_algorithm {
"rsa-sha2-256" => {
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha256>::new(public);
SignatureVerifier::verify(&key, transcript, &signature)
.context("verify RSA-SHA256 signature")
}
"rsa-sha2-512" => {
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha512>::new(public);
SignatureVerifier::verify(&key, transcript, &signature)
.context("verify RSA-SHA512 signature")
}
_ => bail!("legacy ssh-rsa/SHA-1 signatures are not accepted"),
}
} }
pub fn verify_native_user_auth_from_config( pub fn verify_native_user_auth_from_config(
@@ -470,6 +650,35 @@ pub fn load_ed25519_identity_with_passphrase(
.with_context(|| format!("parse ssh-ed25519 identity {}", path.display())) .with_context(|| format!("parse ssh-ed25519 identity {}", path.display()))
} }
pub fn load_native_identity(path: &Path) -> Result<ssh_key::PrivateKey> {
load_native_identity_with_passphrase(path, None)
}
pub fn load_native_identity_with_passphrase(
path: &Path,
passphrase: Option<&str>,
) -> Result<ssh_key::PrivateKey> {
let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let private_key = ssh_key::PrivateKey::from_openssh(&raw)
.with_context(|| format!("parse OpenSSH identity {}", path.display()))?;
let private_key = if private_key.is_encrypted() {
let passphrase = passphrase
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity {} is encrypted", path.display()))?;
private_key
.decrypt(passphrase)
.with_context(|| format!("decrypt OpenSSH identity {}", path.display()))?
} else {
private_key
};
let algorithm = private_key.public_key().algorithm().as_str().to_string();
anyhow::ensure!(
is_supported_user_key_algorithm(&algorithm),
"OpenSSH identity {} has unsupported native key algorithm {algorithm}",
path.display()
);
Ok(private_key)
}
impl AuthorizedKey { impl AuthorizedKey {
fn ensure_native_allowed( fn ensure_native_allowed(
&self, &self,
@@ -565,7 +774,7 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
fields.len() >= 2, fields.len() >= 2,
"authorized_keys:{line_number}: expected key fields" "authorized_keys:{line_number}: expected key fields"
); );
let (options, key_type_index) = if fields[0].starts_with("ssh-") { let (options, key_type_index) = if is_supported_user_key_algorithm(&fields[0]) {
(AuthorizedKeyOptions::default(), 0) (AuthorizedKeyOptions::default(), 0)
} else { } else {
(parse_authorized_key_options(&fields[0])?, 1) (parse_authorized_key_options(&fields[0])?, 1)
@@ -576,15 +785,20 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
let key_blob = fields let key_blob = fields
.get(key_type_index + 1) .get(key_type_index + 1)
.with_context(|| format!("authorized_keys:{line_number}: missing key blob"))?; .with_context(|| format!("authorized_keys:{line_number}: missing key blob"))?;
anyhow::ensure!(
algorithm == "ssh-ed25519",
"authorized_keys:{line_number}: unsupported key type {algorithm}"
);
let decoded = STANDARD let decoded = STANDARD
.decode(key_blob) .decode(key_blob)
.with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?; .with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?;
let key = parse_ssh_ed25519_public_blob(&decoded) anyhow::ensure!(
.with_context(|| format!("authorized_keys:{line_number}: parse ssh-ed25519 key"))?; is_supported_user_key_algorithm(algorithm),
"authorized_keys:{line_number}: unsupported key type {algorithm}"
);
validate_supported_public_key_blob(algorithm, &decoded)
.with_context(|| format!("authorized_keys:{line_number}: parse {algorithm} key"))?;
let key = if algorithm == "ssh-ed25519" {
parse_ssh_ed25519_public_blob(&decoded)?.to_vec()
} else {
decoded
};
let comment = if fields.len() > key_type_index + 2 { let comment = if fields.len() > key_type_index + 2 {
Some(fields[key_type_index + 2..].join(" ")) Some(fields[key_type_index + 2..].join(" "))
} else { } else {
@@ -598,6 +812,51 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
}) })
} }
fn validate_supported_public_key_blob(algorithm: &str, blob: &[u8]) -> Result<()> {
let blob_algorithm = ssh_public_key_blob_algorithm(blob)?;
anyhow::ensure!(
blob_algorithm == algorithm,
"key blob type {blob_algorithm} does not match authorized_keys type {algorithm}"
);
match algorithm {
"ssh-ed25519" => {
parse_ssh_ed25519_public_blob(blob)?;
}
"ecdsa-sha2-nistp256" | "ssh-rsa" => {
let _ = ssh_public_key_from_blob(blob)?;
}
_ => bail!("unsupported key type {algorithm}"),
}
Ok(())
}
fn ssh_public_key_blob_algorithm(blob: &[u8]) -> Result<String> {
let mut cursor = blob;
let algorithm = read_ssh_string(&mut cursor)?;
Ok(String::from_utf8_lossy(algorithm).to_string())
}
fn ssh_public_key_from_blob(blob: &[u8]) -> Result<ssh_key::PublicKey> {
let algorithm = ssh_public_key_blob_algorithm(blob)?;
let encoded = STANDARD.encode(blob);
ssh_key::PublicKey::from_openssh(&format!("{algorithm} {encoded}"))
.with_context(|| format!("parse OpenSSH public key blob {algorithm}"))
}
fn ssh_public_blob_from_public_key(public_key: &ssh_key::PublicKey) -> Result<Vec<u8>> {
let line = public_key
.to_openssh()
.context("encode OpenSSH public key")?;
let mut fields = line.split_whitespace();
let _algorithm = fields
.next()
.context("encoded public key missing algorithm")?;
let encoded = fields.next().context("encoded public key missing blob")?;
STANDARD
.decode(encoded)
.context("decode encoded OpenSSH public key blob")
}
fn split_authorized_key_fields(line: &str) -> Vec<String> { fn split_authorized_key_fields(line: &str) -> Vec<String> {
line.split_whitespace().map(ToString::to_string).collect() line.split_whitespace().map(ToString::to_string).collect()
} }
@@ -765,6 +1024,18 @@ pub fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
Ok(out) Ok(out)
} }
fn parse_ssh_rsa_public_blob(blob: &[u8]) -> Result<(&[u8], &[u8])> {
let mut cursor = blob;
let key_type = read_ssh_string(&mut cursor)?;
anyhow::ensure!(key_type == b"ssh-rsa", "key blob type mismatch");
let e = read_ssh_mpint(&mut cursor)?;
let n = read_ssh_mpint(&mut cursor)?;
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-rsa key blob");
anyhow::ensure!(!e.is_empty(), "ssh-rsa exponent is empty");
anyhow::ensure!(!n.is_empty(), "ssh-rsa modulus is empty");
Ok((e, n))
}
pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> { pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
let mut out = Vec::new(); let mut out = Vec::new();
write_ssh_string(&mut out, b"ssh-ed25519"); write_ssh_string(&mut out, b"ssh-ed25519");
@@ -772,6 +1043,12 @@ pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
out out
} }
fn read_ssh_mpint<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
let raw = read_ssh_string(cursor)?;
let raw = raw.strip_prefix(&[0]).unwrap_or(raw);
Ok(raw)
}
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> { fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length"); anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length");
let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize; let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize;
@@ -958,11 +1235,11 @@ fn write_known_host_entries(path: &Path, entries: &[KnownHost]) -> Result<()> {
)); ));
out.push('\n'); out.push('\n');
} }
let mut file = fs::OpenOptions::new() let mut options = fs::OpenOptions::new();
.create(true) options.create(true).truncate(true).write(true);
.truncate(true) #[cfg(unix)]
.write(true) options.mode(0o600);
.mode(0o600) let mut file = options
.open(path) .open(path)
.with_context(|| format!("write {}", path.display()))?; .with_context(|| format!("write {}", path.display()))?;
file.write_all(out.as_bytes()) file.write_all(out.as_bytes())
@@ -1004,16 +1281,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32])); let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!( assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(), trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::Trusted TrustResult::Trusted
); );
assert_eq!( assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(), trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::AlreadyTrusted TrustResult::AlreadyTrusted
); );
assert!(trust_host(&path, "palav", &second, "ssh", false).is_err()); assert!(trust_host(&path, "homelab", &second, "ssh", false).is_err());
assert_eq!( assert_eq!(
trust_host(&path, "palav", &second, "ssh", true).unwrap(), trust_host(&path, "homelab", &second, "ssh", true).unwrap(),
TrustResult::Trusted TrustResult::Trusted
); );
} }
@@ -1026,16 +1303,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32])); let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!( assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(), verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Unknown KnownHostStatus::Unknown
); );
trust_host(&path, "palav", &first, "ssh", false).unwrap(); trust_host(&path, "homelab", &first, "ssh", false).unwrap();
assert_eq!( assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(), verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Trusted KnownHostStatus::Trusted
); );
assert!(matches!( assert!(matches!(
verify_known_host(&path, "palav", &second).unwrap(), verify_known_host(&path, "homelab", &second).unwrap(),
KnownHostStatus::Mismatch { .. } KnownHostStatus::Mismatch { .. }
)); ));
} }
@@ -1084,6 +1361,45 @@ mod tests {
assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err()); assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err());
} }
#[test]
fn native_user_auth_accepts_ecdsa_p256_private_key() {
let mut rng = rand::rngs::OsRng;
let private = ssh_key::PrivateKey::random(
&mut rng,
ssh_key::Algorithm::Ecdsa {
curve: ssh_key::EcdsaCurve::NistP256,
},
)
.unwrap();
let host_signing = SigningKey::from_bytes(&[10u8; 32]);
let client = test_client_hello();
let mut server = test_server_hello(&host_signing);
sign_server_hello(&host_signing, &client, &mut server).unwrap();
let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap();
let authorized =
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
assert_eq!(auth.public_key_algorithm, "ecdsa-sha2-nistp256");
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
}
#[test]
fn native_user_auth_accepts_rsa_sha2_private_key() {
let mut rng = rand::rngs::OsRng;
let private =
ssh_key::PrivateKey::random(&mut rng, ssh_key::Algorithm::Rsa { hash: None }).unwrap();
let host_signing = SigningKey::from_bytes(&[11u8; 32]);
let client = test_client_hello();
let mut server = test_server_hello(&host_signing);
sign_server_hello(&host_signing, &client, &mut server).unwrap();
let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap();
let authorized =
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
assert_eq!(auth.public_key_algorithm, "rsa-sha2-512");
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
}
#[test] #[test]
fn native_user_auth_rejects_tampered_transcript() { fn native_user_auth_rejects_tampered_transcript() {
let host_signing = SigningKey::from_bytes(&[3u8; 32]); let host_signing = SigningKey::from_bytes(&[3u8; 32]);
@@ -1311,8 +1627,8 @@ mod tests {
protocol_version: NATIVE_PROTOCOL_VERSION, protocol_version: NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32], client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32], client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(), requested_host: "homelab".to_string(),
requested_user: "palav".to_string(), requested_user: "alice".to_string(),
requested_session: "term".to_string(), requested_session: "term".to_string(),
requested_mode: "read-write".to_string(), requested_mode: "read-write".to_string(),
terminal_size: (80, 24), terminal_size: (80, 24),
+41 -2
View File
@@ -23,11 +23,14 @@ use std::io::{Read, Write};
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, RawFd}; use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, RawFd};
use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration; use std::time::Duration;
/// One-byte commands a server sends to a holder over its control socket after /// One-byte commands a server sends to a holder over its control socket after
/// the holder has handed back the master fd. /// the holder has handed back the master fd.
const HOLDER_CMD_SHUTDOWN: u8 = b'X'; const HOLDER_CMD_SHUTDOWN: u8 = b'X';
const HOLDER_STARTUP_TIMEOUT: Duration = Duration::from_millis(750);
static SCREEN_WRITE_LOCK: Mutex<()> = Mutex::new(());
/// Magic written into a holder's `meta` file, bumped if the on-disk layout /// Magic written into a holder's `meta` file, bumped if the on-disk layout
/// changes so a stale holder from an incompatible build is ignored. /// changes so a stale holder from an incompatible build is ignored.
@@ -139,13 +142,28 @@ pub fn save_screen(
// No holder runtime for this session (non-persistent): nothing to do. // No holder runtime for this session (non-persistent): nothing to do.
return Ok(()); 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); let mut buf = Vec::with_capacity(snapshot.len() + 32);
buf.extend_from_slice(&cols.to_be_bytes()); buf.extend_from_slice(&cols.to_be_bytes());
buf.extend_from_slice(&rows.to_be_bytes()); buf.extend_from_slice(&rows.to_be_bytes());
buf.extend_from_slice(&output_seq.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.len() as u32).to_be_bytes());
buf.extend_from_slice(snapshot); 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. /// 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 // Wait (briefly) for the holder to come up. The holder forks/setsids before
// creating the socket; once the socket exists we can connect. We also reap // creating the socket; once the socket exists we can connect. We also reap
// the short-lived launcher child so it never becomes a zombie. // the short-lived launcher child so it never becomes a zombie.
let deadline = std::time::Instant::now() + Duration::from_secs(5); let deadline = std::time::Instant::now() + HOLDER_STARTUP_TIMEOUT;
loop { loop {
if sock.exists() { if sock.exists() {
break; break;
} }
if let Some(status) = child.try_wait().context("wait holder launcher")? {
bail!("holder launcher for session {session} exited before socket was ready: {status}");
}
if std::time::Instant::now() >= deadline { if std::time::Instant::now() >= deadline {
let _ = child.kill(); let _ = child.kill();
let _ = child.wait(); let _ = child.wait();
@@ -431,6 +452,10 @@ pub fn run_holder(
let mut cmd = [0u8; 1]; let mut cmd = [0u8; 1];
match stream.read(&mut cmd) { match stream.read(&mut cmd) {
Ok(1) if cmd[0] == HOLDER_CMD_SHUTDOWN => { 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); let _ = std::fs::remove_dir_all(runtime_dir);
std::process::exit(0); std::process::exit(0);
} }
@@ -611,6 +636,20 @@ mod tests {
assert_eq!(loaded.snapshot, snap); 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] #[test]
fn load_screen_absent_is_none() { fn load_screen_absent_is_none() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
+81 -2
View File
@@ -3,14 +3,49 @@ use crate::crypto;
use crate::native::{EnvVar, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth}; use crate::native::{EnvVar, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// Generate a name for an implicit, ephemeral terminal session — the kind
/// created by `dosh host` with no `--session`. Single source of truth shared by
/// the client (which generates it) and the server (which recognizes it via
/// [`is_implicit_session_name`] to decide a session is NOT worth persisting).
pub fn generate_implicit_session_name() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("term-{millis}-{}", std::process::id())
}
/// Whether `name` is a client-generated implicit session name
/// (`term-<digits>-<digits>`). Such sessions are ephemeral: the user can never
/// reattach by name, so the server must not persist them across restarts.
/// Explicitly-named (`--session work`) and prewarmed sessions are not implicit.
pub fn is_implicit_session_name(name: &str) -> bool {
let Some(rest) = name.strip_prefix("term-") else {
return false;
};
let mut parts = rest.split('-');
match (parts.next(), parts.next(), parts.next()) {
(Some(millis), Some(pid), None) => {
!millis.is_empty()
&& millis.bytes().all(|b| b.is_ascii_digit())
&& !pid.is_empty()
&& pid.bytes().all(|b| b.is_ascii_digit())
}
_ => false,
}
}
pub const MAGIC: &[u8; 4] = b"DOSH"; pub const MAGIC: &[u8; 4] = b"DOSH";
// v4: added reliable stream offsets/acks to `StreamData` and
// `StreamWindowAdjust`.
// v3: added `ForwardingKind::Agent` (SSH-agent forwarding). The new variant rides // v3: added `ForwardingKind::Agent` (SSH-agent forwarding). The new variant rides
// inside `NativeUserAuth.requested_forwardings`, so a pre-agent peer would fail to // inside `NativeUserAuth.requested_forwardings`, so a pre-agent peer would fail to
// deserialize it; bumping the wire version makes such a peer answer with a clear // deserialize it; bumping the wire version makes such a peer answer with a clear
// version-mismatch reject instead. Existing variants' bincode discriminants are // version-mismatch reject instead. Existing variants' bincode discriminants are
// unchanged, so the bump is purely a compatibility gate. // unchanged, so the bump is purely a compatibility gate.
pub const VERSION: u8 = 3; pub const VERSION: u8 = 4;
pub const HEADER_LEN: usize = 58; pub const HEADER_LEN: usize = 58;
/// Stable, user-facing reason string the server puts in an `AttachReject` when a /// Stable, user-facing reason string the server puts in an `AttachReject` when a
@@ -245,7 +280,7 @@ pub fn decode(input: &[u8]) -> Result<Packet> {
pub fn decrypt_body(packet: &Packet, key: &[u8; 32], direction: u32) -> Result<Vec<u8>> { pub fn decrypt_body(packet: &Packet, key: &[u8; 32], direction: u32) -> Result<Vec<u8>> {
if packet.header.flags & 1 == 0 { 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 nonce = crypto::nonce_from(direction, packet.header.seq);
let aad = packet.header.aad(); let aad = packet.header.aad();
@@ -322,6 +357,10 @@ pub struct NativeAuthCheckOkBody {
pub allow_tcp_forwarding: bool, pub allow_tcp_forwarding: bool,
pub allow_remote_forwarding: bool, pub allow_remote_forwarding: bool,
pub allow_agent_forwarding: bool, pub allow_agent_forwarding: bool,
#[serde(default)]
pub allow_file_transfer: bool,
#[serde(default)]
pub allow_exec_command: bool,
pub policy_flags: Vec<String>, pub policy_flags: Vec<String>,
#[serde(default)] #[serde(default)]
pub server_version: String, pub server_version: String,
@@ -383,12 +422,14 @@ pub struct StreamOpenReject {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamData { pub struct StreamData {
pub stream_id: u64, pub stream_id: u64,
pub offset: u64,
pub bytes: Vec<u8>, pub bytes: Vec<u8>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamWindowAdjust { pub struct StreamWindowAdjust {
pub stream_id: u64, pub stream_id: u64,
pub received_offset: u64,
pub bytes: u32, pub bytes: u32,
} }
@@ -496,3 +537,41 @@ impl ReplayWindow {
} }
} }
} }
#[cfg(test)]
mod session_name_tests {
use super::{generate_implicit_session_name, is_implicit_session_name};
#[test]
fn generated_names_are_recognized_as_implicit() {
let name = generate_implicit_session_name();
assert!(is_implicit_session_name(&name), "generated: {name}");
}
#[test]
fn explicit_and_prewarm_names_are_not_implicit() {
for name in [
"default",
"work",
"logs",
"term",
"term-",
"term-abc-1",
"term-1-x",
"term-1",
"term-1-2-3",
"",
] {
assert!(
!is_implicit_session_name(name),
"should not be implicit: {name:?}"
);
}
}
#[test]
fn implicit_shape_matches() {
assert!(is_implicit_session_name("term-1781470634216-76685"));
assert!(is_implicit_session_name("term-0-0"));
}
}
+76 -3
View File
@@ -2,11 +2,17 @@ use anyhow::{Context, Result};
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem}; use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::unix::io::{AsRawFd, FromRawFd, RawFd}; use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
use std::path::Path; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
use tokio::sync::mpsc; use tokio::sync::mpsc;
// Keep live terminal output comfortably below common path MTUs after Dosh's
// protocol header, AEAD tag, UDP/IP headers, and bincode framing. Full-screen
// TUIs often write several KiB on the first draw; sending that as one UDP
// datagram can fragment and vanish, leaving only a blank alternate screen.
const PTY_OUTPUT_CHUNK_BYTES: usize = 1024;
/// Backing for a PTY master held by the server. /// Backing for a PTY master held by the server.
/// ///
/// `Owned` means this process spawned the shell as a child and is responsible /// `Owned` means this process spawned the shell as a child and is responsible
@@ -148,16 +154,65 @@ pub fn spawn_pty_session(
}) })
} }
/// Whether this host has a terminfo entry for `term`, searching the standard
/// ncurses directories. Both the legacy single-letter (`x/xterm`) and the
/// hashed (`78/xterm`) subdirectory layouts are checked.
fn terminfo_available(term: &str) -> bool {
let Some(first) = term.chars().next() else {
return false;
};
let letter = first.to_string();
let hashed = format!("{:x}", first as u32);
let mut dirs: Vec<PathBuf> = Vec::new();
if let Ok(t) = std::env::var("TERMINFO")
&& !t.is_empty()
{
dirs.push(PathBuf::from(t));
}
if let Some(home) = dirs::home_dir() {
dirs.push(home.join(".terminfo"));
}
if let Ok(td) = std::env::var("TERMINFO_DIRS") {
for d in td.split(':').filter(|d| !d.is_empty()) {
dirs.push(PathBuf::from(d));
}
}
for d in [
"/etc/terminfo",
"/lib/terminfo",
"/usr/share/terminfo",
"/usr/lib/terminfo",
"/usr/share/lib/terminfo",
] {
dirs.push(PathBuf::from(d));
}
dirs.iter()
.any(|dir| dir.join(&letter).join(term).exists() || dir.join(&hashed).join(term).exists())
}
/// Build the [`CommandBuilder`] for a dosh shell, identically for the in-process /// Build the [`CommandBuilder`] for a dosh shell, identically for the in-process
/// `spawn_pty_session` and the out-of-process holder, so a persistent session's /// `spawn_pty_session` and the out-of-process holder, so a persistent session's
/// environment matches a non-persistent one. /// environment matches a non-persistent one.
pub fn build_shell_command(shell: &str, env: &[(String, String)]) -> CommandBuilder { pub fn build_shell_command(shell: &str, env: &[(String, String)]) -> CommandBuilder {
let mut cmd = CommandBuilder::new(shell); let mut cmd = CommandBuilder::new(shell);
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor"); cmd.env("COLORTERM", "truecolor");
for (name, value) in env { for (name, value) in env {
cmd.env(name, value); cmd.env(name, value);
} }
// The client's TERM is propagated, but a server that lacks that terminal's
// terminfo entry (e.g. xterm-ghostty, xterm-kitty) breaks ncurses apps like
// tmux/vim with "missing or unsuitable terminal". Keep the requested TERM
// only when this host actually has its terminfo; otherwise fall back to a
// universally available entry so remote apps always work.
let term = match env
.iter()
.find(|(n, _)| n == "TERM")
.map(|(_, v)| v.as_str())
{
Some(requested) if terminfo_available(requested) => requested.to_string(),
_ => "xterm-256color".to_string(),
};
cmd.env("TERM", term);
cmd.env("SHELL", shell); cmd.env("SHELL", shell);
if let Some(home) = dirs::home_dir() { if let Some(home) = dirs::home_dir() {
cmd.env("HOME", home.as_os_str()); cmd.env("HOME", home.as_os_str());
@@ -215,12 +270,14 @@ fn spawn_reader_thread(
break; break;
} }
Ok(n) => { Ok(n) => {
for chunk in buf[..n].chunks(PTY_OUTPUT_CHUNK_BYTES) {
let _ = tx.send(PtyOutput { let _ = tx.send(PtyOutput {
session: reader_session.clone(), session: reader_session.clone(),
bytes: buf[..n].to_vec(), bytes: chunk.to_vec(),
exited: false, exited: false,
}); });
} }
}
Err(_) => { Err(_) => {
let _ = tx.send(PtyOutput { let _ = tx.send(PtyOutput {
session: reader_session.clone(), session: reader_session.clone(),
@@ -235,3 +292,19 @@ fn spawn_reader_thread(
.context("spawn pty reader")?; .context("spawn pty reader")?;
Ok(()) Ok(())
} }
#[cfg(test)]
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.
assert!(terminfo_available("xterm") || terminfo_available("xterm-256color"));
// Bogus / empty names must report missing so we fall back.
assert!(!terminfo_available("definitely-not-a-real-terminal-xyz"));
assert!(!terminfo_available(""));
}
}
+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));
}
}
+111 -26
View File
@@ -1,26 +1,43 @@
use crate::native::{ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth};
#[cfg(unix)]
use crate::native::{ use crate::native::{
ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth, is_supported_user_key_algorithm, parse_ssh_ed25519_public_blob, user_auth_transcript,
parse_ssh_ed25519_public_blob, user_auth_transcript,
}; };
use anyhow::{Context, Result, anyhow, bail}; #[cfg(unix)]
use anyhow::{Context, bail};
use anyhow::{Result, anyhow};
#[cfg(unix)]
use std::io::{Read, Write}; use std::io::{Read, Write};
#[cfg(unix)]
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
use std::path::Path; use std::path::Path;
#[cfg(unix)]
const SSH_AGENT_FAILURE: u8 = 5; const SSH_AGENT_FAILURE: u8 = 5;
#[cfg(unix)]
const SSH2_AGENTC_REQUEST_IDENTITIES: u8 = 11; const SSH2_AGENTC_REQUEST_IDENTITIES: u8 = 11;
#[cfg(unix)]
const SSH2_AGENT_IDENTITIES_ANSWER: u8 = 12; const SSH2_AGENT_IDENTITIES_ANSWER: u8 = 12;
#[cfg(unix)]
const SSH2_AGENTC_SIGN_REQUEST: u8 = 13; const SSH2_AGENTC_SIGN_REQUEST: u8 = 13;
#[cfg(unix)]
const SSH2_AGENT_SIGN_RESPONSE: u8 = 14; const SSH2_AGENT_SIGN_RESPONSE: u8 = 14;
#[cfg(unix)]
const SSH_AGENT_RSA_SHA2_512: u32 = 4;
#[cfg(unix)]
const MAX_AGENT_PACKET: usize = 256 * 1024; const MAX_AGENT_PACKET: usize = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentIdentity { pub struct AgentIdentity {
pub key_blob: Vec<u8>, pub key_blob: Vec<u8>,
pub public_key: [u8; 32], pub public_key_algorithm: String,
pub public_key: Vec<u8>,
pub sign_algorithm: String,
pub sign_flags: u32,
pub comment: String, pub comment: String,
} }
#[cfg(unix)]
pub fn sign_user_auth_with_agent( pub fn sign_user_auth_with_agent(
client: &NativeClientHello, client: &NativeClientHello,
server: &NativeServerHello, server: &NativeServerHello,
@@ -30,6 +47,18 @@ pub fn sign_user_auth_with_agent(
sign_user_auth_with_agent_at(sock, client, server, requested_forwardings) sign_user_auth_with_agent_at(sock, client, server, requested_forwardings)
} }
#[cfg(not(unix))]
pub fn sign_user_auth_with_agent(
_client: &NativeClientHello,
_server: &NativeServerHello,
_requested_forwardings: Vec<ForwardingRequest>,
) -> Result<NativeUserAuth> {
Err(anyhow!(
"ssh-agent native auth is not supported on this platform yet; use identity_files"
))
}
#[cfg(unix)]
pub fn sign_user_auth_with_agent_at( pub fn sign_user_auth_with_agent_at(
socket_path: impl AsRef<Path>, socket_path: impl AsRef<Path>,
client: &NativeClientHello, client: &NativeClientHello,
@@ -38,13 +67,13 @@ pub fn sign_user_auth_with_agent_at(
) -> Result<NativeUserAuth> { ) -> Result<NativeUserAuth> {
let mut agent = UnixStream::connect(socket_path.as_ref()) let mut agent = UnixStream::connect(socket_path.as_ref())
.with_context(|| format!("connect ssh-agent {}", socket_path.as_ref().display()))?; .with_context(|| format!("connect ssh-agent {}", socket_path.as_ref().display()))?;
let identities = request_ed25519_identities(&mut agent)?; let identities = request_supported_identities(&mut agent)?;
let identity = identities let identity = identities
.first() .first()
.ok_or_else(|| anyhow!("ssh-agent has no ssh-ed25519 identities"))?; .ok_or_else(|| anyhow!("ssh-agent has no supported identities"))?;
let mut auth = NativeUserAuth { let mut auth = NativeUserAuth {
public_key_algorithm: "ssh-ed25519".to_string(), public_key_algorithm: identity.sign_algorithm.clone(),
public_key: identity.public_key.to_vec(), public_key: identity.public_key.clone(),
signature: Vec::new(), signature: Vec::new(),
requested_forwardings, requested_forwardings,
}; };
@@ -54,7 +83,20 @@ pub fn sign_user_auth_with_agent_at(
Ok(auth) Ok(auth)
} }
fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> { #[cfg(not(unix))]
pub fn sign_user_auth_with_agent_at(
_socket_path: impl AsRef<Path>,
_client: &NativeClientHello,
_server: &NativeServerHello,
_requested_forwardings: Vec<ForwardingRequest>,
) -> Result<NativeUserAuth> {
Err(anyhow!(
"ssh-agent native auth is not supported on this platform yet; use identity_files"
))
}
#[cfg(unix)]
fn request_supported_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> {
write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?; write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?;
let payload = read_agent_packet(agent)?; let payload = read_agent_packet(agent)?;
let mut cursor = payload.as_slice(); let mut cursor = payload.as_slice();
@@ -72,18 +114,51 @@ fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentit
for _ in 0..count { for _ in 0..count {
let key_blob = read_ssh_string(&mut cursor)?.to_vec(); let key_blob = read_ssh_string(&mut cursor)?.to_vec();
let comment = String::from_utf8_lossy(read_ssh_string(&mut cursor)?).to_string(); let comment = String::from_utf8_lossy(read_ssh_string(&mut cursor)?).to_string();
if let Ok(public_key) = parse_ssh_ed25519_public_blob(&key_blob) { if let Some(identity) = supported_identity(key_blob, comment)? {
identities.push(AgentIdentity { identities.push(identity);
key_blob,
public_key,
comment,
});
} }
} }
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities"); anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities");
Ok(identities) Ok(identities)
} }
#[cfg(unix)]
fn supported_identity(key_blob: Vec<u8>, comment: String) -> Result<Option<AgentIdentity>> {
let algorithm = key_blob_algorithm(&key_blob)?;
if !is_supported_user_key_algorithm(&algorithm) {
return Ok(None);
}
let identity = match algorithm.as_str() {
"ssh-ed25519" => AgentIdentity {
public_key_algorithm: algorithm.clone(),
public_key: parse_ssh_ed25519_public_blob(&key_blob)?.to_vec(),
sign_algorithm: "ssh-ed25519".to_string(),
sign_flags: 0,
key_blob,
comment,
},
"ecdsa-sha2-nistp256" => AgentIdentity {
public_key_algorithm: algorithm.clone(),
public_key: key_blob.clone(),
sign_algorithm: algorithm,
sign_flags: 0,
key_blob,
comment,
},
"ssh-rsa" => AgentIdentity {
public_key_algorithm: algorithm,
public_key: key_blob.clone(),
sign_algorithm: "rsa-sha2-512".to_string(),
sign_flags: SSH_AGENT_RSA_SHA2_512,
key_blob,
comment,
},
_ => return Ok(None),
};
Ok(Some(identity))
}
#[cfg(unix)]
fn sign_with_agent( fn sign_with_agent(
agent: &mut UnixStream, agent: &mut UnixStream,
identity: &AgentIdentity, identity: &AgentIdentity,
@@ -93,7 +168,7 @@ fn sign_with_agent(
request.push(SSH2_AGENTC_SIGN_REQUEST); request.push(SSH2_AGENTC_SIGN_REQUEST);
write_ssh_string(&mut request, &identity.key_blob); write_ssh_string(&mut request, &identity.key_blob);
write_ssh_string(&mut request, transcript); write_ssh_string(&mut request, transcript);
request.extend_from_slice(&0u32.to_be_bytes()); request.extend_from_slice(&identity.sign_flags.to_be_bytes());
write_agent_packet(agent, &request)?; write_agent_packet(agent, &request)?;
let payload = read_agent_packet(agent)?; let payload = read_agent_packet(agent)?;
@@ -114,23 +189,21 @@ fn sign_with_agent(
let mut signature_cursor = signature_blob; let mut signature_cursor = signature_blob;
let algorithm = read_ssh_string(&mut signature_cursor)?; let algorithm = read_ssh_string(&mut signature_cursor)?;
let algorithm = String::from_utf8_lossy(algorithm);
anyhow::ensure!( anyhow::ensure!(
algorithm == b"ssh-ed25519", algorithm == identity.sign_algorithm,
"ssh-agent returned unsupported signature algorithm {}", "ssh-agent returned signature algorithm {algorithm}, expected {}",
String::from_utf8_lossy(algorithm) identity.sign_algorithm
); );
let signature = read_ssh_string(&mut signature_cursor)?; let signature = read_ssh_string(&mut signature_cursor)?;
anyhow::ensure!( anyhow::ensure!(
signature_cursor.is_empty(), signature_cursor.is_empty(),
"trailing data in ssh-agent signature blob" "trailing data in ssh-agent signature blob"
); );
anyhow::ensure!(
signature.len() == 64,
"ssh-agent Ed25519 signature was not 64 bytes"
);
Ok(signature.to_vec()) Ok(signature.to_vec())
} }
#[cfg(unix)]
fn read_agent_packet(stream: &mut UnixStream) -> Result<Vec<u8>> { fn read_agent_packet(stream: &mut UnixStream) -> Result<Vec<u8>> {
let mut len = [0u8; 4]; let mut len = [0u8; 4];
stream stream
@@ -145,6 +218,7 @@ fn read_agent_packet(stream: &mut UnixStream) -> Result<Vec<u8>> {
Ok(payload) Ok(payload)
} }
#[cfg(unix)]
fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> { fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> {
anyhow::ensure!( anyhow::ensure!(
payload.len() <= MAX_AGENT_PACKET, payload.len() <= MAX_AGENT_PACKET,
@@ -155,6 +229,7 @@ fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> {
Ok(()) Ok(())
} }
#[cfg(unix)]
fn read_u8(cursor: &mut &[u8]) -> Result<u8> { fn read_u8(cursor: &mut &[u8]) -> Result<u8> {
anyhow::ensure!(!cursor.is_empty(), "truncated u8"); anyhow::ensure!(!cursor.is_empty(), "truncated u8");
let value = cursor[0]; let value = cursor[0];
@@ -162,6 +237,7 @@ fn read_u8(cursor: &mut &[u8]) -> Result<u8> {
Ok(value) Ok(value)
} }
#[cfg(unix)]
fn read_u32(cursor: &mut &[u8]) -> Result<u32> { fn read_u32(cursor: &mut &[u8]) -> Result<u32> {
anyhow::ensure!(cursor.len() >= 4, "truncated u32"); anyhow::ensure!(cursor.len() >= 4, "truncated u32");
let value = u32::from_be_bytes(cursor[..4].try_into().unwrap()); let value = u32::from_be_bytes(cursor[..4].try_into().unwrap());
@@ -169,6 +245,7 @@ fn read_u32(cursor: &mut &[u8]) -> Result<u32> {
Ok(value) Ok(value)
} }
#[cfg(unix)]
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> { fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
let len = read_u32(cursor)? as usize; let len = read_u32(cursor)? as usize;
anyhow::ensure!(cursor.len() >= len, "truncated SSH string"); anyhow::ensure!(cursor.len() >= len, "truncated SSH string");
@@ -177,12 +254,20 @@ fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
Ok(value) Ok(value)
} }
#[cfg(unix)]
fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) { fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(&(value.len() as u32).to_be_bytes()); out.extend_from_slice(&(value.len() as u32).to_be_bytes());
out.extend_from_slice(value); out.extend_from_slice(value);
} }
#[cfg(test)] #[cfg(unix)]
fn key_blob_algorithm(blob: &[u8]) -> Result<String> {
let mut cursor = blob;
let algorithm = read_ssh_string(&mut cursor)?;
Ok(String::from_utf8_lossy(algorithm).to_string())
}
#[cfg(all(test, unix))]
mod tests { mod tests {
use super::*; use super::*;
use crate::native::{ use crate::native::{
@@ -253,8 +338,8 @@ mod tests {
protocol_version: crate::native::NATIVE_PROTOCOL_VERSION, protocol_version: crate::native::NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32], client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32], client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(), requested_host: "homelab".to_string(),
requested_user: "palav".to_string(), requested_user: "alice".to_string(),
requested_session: "term".to_string(), requested_session: "term".to_string(),
requested_mode: "read-write".to_string(), requested_mode: "read-write".to_string(),
terminal_size: (80, 24), terminal_size: (80, 24),
+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));
}
}
}
+658 -16
View File
@@ -18,29 +18,51 @@
//! Determinism: the relay's drop/reorder/dup behavior is driven by a fixed-seed //! Determinism: the relay's drop/reorder/dup behavior is driven by a fixed-seed
//! PRNG and by explicit one-shot toggles, never by wall-clock timing, so the //! PRNG and by explicit one-shot toggles, never by wall-clock timing, so the
//! tests are reproducible and fast. //! tests are reproducible and fast.
#![allow(
clippy::collapsible_if,
clippy::explicit_counter_loop,
clippy::single_match
)]
use std::fs; use std::fs;
use std::net::{SocketAddr, UdpSocket}; use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, UdpSocket};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use dosh::auth::{BootstrapResponse, build_bootstrap, load_or_create_server_secret}; use dosh::auth::{BootstrapResponse, build_bootstrap, load_or_create_server_secret};
use dosh::config::load_server_config; use dosh::config::load_server_config;
use dosh::crypto; use dosh::crypto;
use dosh::native::{self, ForwardingKind, ForwardingRequest, NativeAuthOk, NativeClientHello};
use dosh::protocol::{ use dosh::protocol::{
self, AttachOk, CLIENT_TO_SERVER, Frame, Header, Input, PacketKind, ResumeRequest, self, AttachOk, AttachReject, CLIENT_TO_SERVER, Frame, Header, Input, NativeAuthOkBody,
SERVER_TO_CLIENT, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind, ResumeRequest,
SERVER_TO_CLIENT, StreamData, StreamOpen, StreamOpenOk, StreamWindowAdjust,
}; };
use ed25519_dalek::{SigningKey, VerifyingKey};
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::{Rng, SeedableRng}; use rand::{Rng, SeedableRng};
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 { fn free_udp_port() -> u16 {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); let span = u32::from(TEST_UDP_PORT_END - TEST_UDP_PORT_START) + 1;
socket.local_addr().unwrap().port() 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 { fn write_server_config(dir: &tempfile::TempDir, port: u16) -> std::path::PathBuf {
@@ -79,6 +101,23 @@ persist_sessions = false
config config
} }
fn authorize_ed25519_key(dir: &tempfile::TempDir, signing_key: &SigningKey) {
let verifying = VerifyingKey::from(signing_key);
let mut blob = Vec::new();
write_ssh_string(&mut blob, b"ssh-ed25519");
write_ssh_string(&mut blob, verifying.as_bytes());
fs::write(
dir.path().join("authorized_keys"),
format!("ssh-ed25519 {} hostile-test\n", STANDARD.encode(blob)),
)
.unwrap();
}
fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
out.extend_from_slice(value);
}
fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child { fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child {
let server = env!("CARGO_BIN_EXE_dosh-server"); let server = env!("CARGO_BIN_EXE_dosh-server");
let child = Command::new(server) let child = Command::new(server)
@@ -230,7 +269,8 @@ fn relay_loop(
let mut upstream = new_upstream(); let mut upstream = new_upstream();
let mut client_addr: Option<SocketAddr> = None; let mut client_addr: Option<SocketAddr> = None;
let mut rng = StdRng::seed_from_u64(seed); 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]; let mut buf = [0u8; 65535];
loop { loop {
@@ -260,10 +300,9 @@ fn relay_loop(
&upstream, &upstream,
server_addr, server_addr,
packet, packet,
true,
&controls, &controls,
&mut rng, &mut rng,
&mut held, &mut held_c2s,
); );
} }
} }
@@ -282,7 +321,12 @@ fn relay_loop(
let drop_pct = controls.drop_s2c_percent.load(Ordering::SeqCst); let drop_pct = controls.drop_s2c_percent.load(Ordering::SeqCst);
if drop_pct == 0 || rng.gen_range(0..100) >= drop_pct { if drop_pct == 0 || rng.gen_range(0..100) >= drop_pct {
forward_with_effects( forward_with_effects(
&front, dst, packet, false, &controls, &mut rng, &mut held, &front,
dst,
packet,
&controls,
&mut rng,
&mut held_s2c,
); );
} }
} }
@@ -308,23 +352,22 @@ fn forward_with_effects(
out: &UdpSocket, out: &UdpSocket,
dst: SocketAddr, dst: SocketAddr,
packet: Vec<u8>, packet: Vec<u8>,
is_c2s: bool,
controls: &RelayControls, controls: &RelayControls,
rng: &mut StdRng, 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 // Reorder: if armed, hold this packet and release the previously held one
// afterward (so two consecutive packets swap order). // afterward (so two consecutive packets swap order).
if controls.reorder_next.swap(false, Ordering::SeqCst) { 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(&packet, dst);
let _ = out.send_to(&prev, dst); let _ = out.send_to(&prev, dst);
return; return;
} }
*held = Some((packet, is_c2s)); *held = Some(packet);
return; return;
} }
if let Some((prev, _)) = held.take() { if let Some(prev) = held.take() {
let _ = out.send_to(&prev, dst); let _ = out.send_to(&prev, dst);
} }
@@ -402,6 +445,122 @@ fn attach_through_relay(
} }
} }
struct NativeAttached {
socket: UdpSocket,
ok: NativeAuthOk,
}
fn native_attach_through_relay(
relay: &Relay,
signing_key: &SigningKey,
requested_forwardings: Vec<ForwardingRequest>,
) -> NativeAttached {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(500)))
.unwrap();
let (client_secret, client_public) = native::generate_native_ephemeral();
let hello = NativeClientHello {
protocol_version: native::NATIVE_PROTOCOL_VERSION,
client_random: crypto::random_32(),
client_ephemeral_public: client_public,
requested_host: "127.0.0.1".to_string(),
requested_user: std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()),
requested_session: "default".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: native::supported_user_key_algorithms(),
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: Vec::new(),
};
let packet = protocol::encode_plain(
PacketKind::NativeClientHello,
[0u8; 16],
1,
0,
&protocol::to_body(&NativeClientHelloBody {
hello: hello.clone(),
})
.unwrap(),
)
.unwrap();
let hello_deadline = Instant::now() + Duration::from_secs(5);
let server_hello = loop {
socket.send_to(&packet, relay.front_addr()).unwrap();
let mut buf = [0u8; 65535];
match socket.recv_from(&mut buf) {
Ok((n, _)) => {
let packet = protocol::decode(&buf[..n]).unwrap();
match packet.header.kind {
PacketKind::NativeServerHello => {
let body: NativeServerHelloBody =
protocol::from_body(&packet.body).unwrap();
native::verify_server_hello(&hello, &body.hello).unwrap();
break body.hello;
}
PacketKind::AttachReject => {
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
panic!("native hello rejected: {}", reject.reason);
}
kind => panic!("unexpected native hello response: {kind:?}"),
}
}
Err(_) if Instant::now() < hello_deadline => {}
Err(err) => panic!("native server hello timed out: {err}"),
}
};
let session_key = native::derive_native_session_key(
&client_secret,
server_hello.server_ephemeral_public,
&hello,
&server_hello,
)
.unwrap();
let auth =
native::sign_user_auth(signing_key, &hello, &server_hello, requested_forwardings).unwrap();
let mut pending_id = [0u8; 16];
pending_id.copy_from_slice(&server_hello.auth_challenge[..16]);
let auth_packet = protocol::encode_encrypted(
PacketKind::NativeUserAuth,
pending_id,
2,
1,
&session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&NativeUserAuthBody { auth }).unwrap(),
)
.unwrap();
let auth_deadline = Instant::now() + Duration::from_secs(5);
let ok = loop {
socket.send_to(&auth_packet, relay.front_addr()).unwrap();
let mut buf = [0u8; 65535];
match socket.recv_from(&mut buf) {
Ok((n, _)) => {
let packet = protocol::decode(&buf[..n]).unwrap();
match packet.header.kind {
PacketKind::NativeAuthOk => {
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT)
.unwrap();
let body: NativeAuthOkBody = protocol::from_body(&plain).unwrap();
break body.ok;
}
PacketKind::AttachReject => {
let reject: AttachReject = protocol::from_body(&packet.body).unwrap();
panic!("native auth rejected: {}", reject.reason);
}
kind => panic!("unexpected native auth response: {kind:?}"),
}
}
Err(_) if Instant::now() < auth_deadline => {}
Err(err) => panic!("native auth ok timed out: {err}"),
}
};
NativeAttached { socket, ok }
}
fn send_input( fn send_input(
socket: &UdpSocket, socket: &UdpSocket,
relay: &Relay, relay: &Relay,
@@ -431,6 +590,152 @@ fn send_raw(socket: &UdpSocket, relay: &Relay, packet: &[u8]) {
socket.send_to(packet, relay.front_addr()).unwrap(); socket.send_to(packet, relay.front_addr()).unwrap();
} }
fn send_stream_packet(
socket: &UdpSocket,
relay: &Relay,
ok: &NativeAuthOk,
kind: PacketKind,
seq: u64,
ack: u64,
body: Vec<u8>,
) {
let packet = protocol::encode_encrypted(
kind,
ok.client_id,
seq,
ack,
&ok.session_key,
CLIENT_TO_SERVER,
&body,
)
.unwrap();
socket.send_to(&packet, relay.front_addr()).unwrap();
}
fn recv_stream_packet<T: serde::de::DeserializeOwned>(
socket: &UdpSocket,
key: &[u8; 32],
kind: PacketKind,
) -> Option<(Header, T)> {
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).ok()?;
let packet = protocol::decode(&buf[..n]).ok()?;
if packet.header.kind != kind {
return None;
}
let plain = protocol::decrypt_body(&packet, key, SERVER_TO_CLIENT).ok()?;
let body = protocol::from_body(&plain).ok()?;
Some((packet.header, body))
}
fn wait_for_stream_open_ok(
socket: &UdpSocket,
key: &[u8; 32],
stream_id: u64,
millis: u64,
) -> Option<Header> {
let deadline = Instant::now() + Duration::from_millis(millis);
while Instant::now() < deadline {
if let Some((header, ok)) =
recv_stream_packet::<StreamOpenOk>(socket, key, PacketKind::StreamOpenOk)
{
if ok.stream_id == stream_id {
return Some(header);
}
}
}
None
}
fn wait_for_stream_data(
socket: &UdpSocket,
key: &[u8; 32],
stream_id: u64,
millis: u64,
) -> Option<(Header, StreamData)> {
let deadline = Instant::now() + Duration::from_millis(millis);
while Instant::now() < deadline {
if let Some((header, data)) =
recv_stream_packet::<StreamData>(socket, key, PacketKind::StreamData)
{
if data.stream_id == stream_id {
return Some((header, data));
}
}
}
None
}
fn start_tcp_collector() -> (u16, Receiver<Vec<u8>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, rx) = channel();
thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
stream
.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut buf = [0u8; 1024];
while Instant::now() < deadline {
match stream.read(&mut buf) {
Ok(0) => return,
Ok(n) => {
let _ = tx.send(buf[..n].to_vec());
}
Err(ref err)
if err.kind() == std::io::ErrorKind::WouldBlock
|| err.kind() == std::io::ErrorKind::TimedOut => {}
Err(_) => return,
}
}
});
(port, rx)
}
fn start_tcp_echo() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
stream
.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut buf = [0u8; 1024];
while Instant::now() < deadline {
match stream.read(&mut buf) {
Ok(0) => return,
Ok(n) => {
let _ = stream.write_all(&buf[..n]);
}
Err(ref err)
if err.kind() == std::io::ErrorKind::WouldBlock
|| err.kind() == std::io::ErrorKind::TimedOut => {}
Err(_) => return,
}
}
});
port
}
fn collect_tcp(rx: &Receiver<Vec<u8>>, millis: u64) -> Vec<u8> {
let deadline = Instant::now() + Duration::from_millis(millis);
let mut out = Vec::new();
while Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(50)) {
Ok(chunk) => out.extend_from_slice(&chunk),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
out
}
fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(Header, Frame)> { fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(Header, Frame)> {
let mut buf = [0u8; 65535]; let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).ok()?; let (n, _) = socket.recv_from(&mut buf).ok()?;
@@ -477,13 +782,53 @@ fn wait_for_text(socket: &UdpSocket, key: &[u8; 32], needle: &str, millis: u64)
acc.contains(needle) 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] #[test]
fn session_survives_packet_loss_and_reorder() { fn session_survives_packet_loss_and_reorder() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let port = free_udp_port(); let port = free_udp_port();
let config = write_server_config(&dir, port); let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config); let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0x105_5u64 ^ 0x1111); let relay = Relay::spawn(port, 0x1055_u64 ^ 0x1111);
let (socket, bootstrap, ok) = attach_through_relay(&config, &relay); let (socket, bootstrap, ok) = attach_through_relay(&config, &relay);
@@ -530,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] #[test]
fn duplicated_and_replayed_input_is_applied_at_most_once() { fn duplicated_and_replayed_input_is_applied_at_most_once() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -603,6 +1036,215 @@ fn duplicated_and_replayed_input_is_applied_at_most_once() {
); );
} }
#[test]
fn forwarded_stream_data_survives_reorder_and_rejects_replay() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let signing_key = SigningKey::from_bytes(&[42u8; 32]);
authorize_ed25519_key(&dir, &signing_key);
let (target_port, tcp_rx) = start_tcp_collector();
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0xF0E0D0u64);
let attached = native_attach_through_relay(
&relay,
&signing_key,
vec![ForwardingRequest {
kind: ForwardingKind::Local,
bind_host: Some("127.0.0.1".to_string()),
listen_port: 0,
target_host: Some("127.0.0.1".to_string()),
target_port: Some(target_port),
}],
);
let stream_id = 44;
let open = StreamOpen {
stream_id,
target_host: "127.0.0.1".to_string(),
target_port,
};
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamOpen,
3,
attached.ok.initial_seq,
protocol::to_body(&open).unwrap(),
);
let open_ok =
wait_for_stream_open_ok(&attached.socket, &attached.ok.session_key, stream_id, 3000)
.expect("stream did not open");
// Swap the first two StreamData packets in flight. The server's replay
// window must accept both out-of-order packets and write both to the TCP
// target exactly once.
relay.arm_reorder();
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamData,
4,
open_ok.seq,
protocol::to_body(&StreamData {
stream_id,
offset: 0,
bytes: b"A".to_vec(),
})
.unwrap(),
);
relay.arm_reorder();
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamData,
5,
open_ok.seq,
protocol::to_body(&StreamData {
stream_id,
offset: 1,
bytes: b"B".to_vec(),
})
.unwrap(),
);
let mut seen = collect_tcp(&tcp_rx, 1500);
assert!(
seen.starts_with(b"AB"),
"reordered stream bytes were not delivered once in-order to TCP target: {:?}",
String::from_utf8_lossy(&seen)
);
// Now send one encrypted StreamData packet repeatedly, with relay-level
// duplication enabled too. This is a true replay: same sequence, same nonce,
// same ciphertext. It must be applied to the TCP target at most once.
let replayed = protocol::encode_encrypted(
PacketKind::StreamData,
attached.ok.client_id,
6,
open_ok.seq,
&attached.ok.session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&StreamData {
stream_id,
offset: 2,
bytes: b"X".to_vec(),
})
.unwrap(),
)
.unwrap();
relay.set_dup(100);
for _ in 0..12 {
send_raw(&attached.socket, &relay, &replayed);
thread::sleep(Duration::from_millis(25));
}
relay.set_dup(0);
seen.extend(collect_tcp(&tcp_rx, 1500));
let replay_count = seen.iter().filter(|byte| **byte == b'X').count();
drop(relay);
let _ = server.kill();
let _ = server.wait();
assert!(
replay_count <= 1,
"replayed/duplicated StreamData was applied {replay_count} times: {:?}",
String::from_utf8_lossy(&seen)
);
}
#[test]
fn forwarded_stream_data_recovers_after_server_to_client_loss() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let signing_key = SigningKey::from_bytes(&[43u8; 32]);
authorize_ed25519_key(&dir, &signing_key);
let target_port = start_tcp_echo();
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0xA11CEu64);
let attached = native_attach_through_relay(
&relay,
&signing_key,
vec![ForwardingRequest {
kind: ForwardingKind::Local,
bind_host: Some("127.0.0.1".to_string()),
listen_port: 0,
target_host: Some("127.0.0.1".to_string()),
target_port: Some(target_port),
}],
);
let stream_id = 55;
let open = StreamOpen {
stream_id,
target_host: "127.0.0.1".to_string(),
target_port,
};
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamOpen,
3,
attached.ok.initial_seq,
protocol::to_body(&open).unwrap(),
);
let open_ok =
wait_for_stream_open_ok(&attached.socket, &attached.ok.session_key, stream_id, 3000)
.expect("stream did not open");
// Drop the first server->client echo and at least one retransmit tick. Once
// the link clears, the unacked stream chunk must be re-encrypted with a new
// packet sequence and delivered at the same stream offset.
relay.set_drop_s2c(100);
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamData,
4,
open_ok.seq,
protocol::to_body(&StreamData {
stream_id,
offset: 0,
bytes: b"PING".to_vec(),
})
.unwrap(),
);
thread::sleep(Duration::from_millis(300));
relay.set_drop_s2c(0);
let (_header, data) =
wait_for_stream_data(&attached.socket, &attached.ok.session_key, stream_id, 3000)
.expect("lost server->client stream data was not retransmitted");
assert_eq!(data.offset, 0);
assert_eq!(data.bytes, b"PING");
send_stream_packet(
&attached.socket,
&relay,
&attached.ok,
PacketKind::StreamWindowAdjust,
5,
open_ok.seq,
protocol::to_body(&StreamWindowAdjust {
stream_id,
received_offset: 4,
bytes: 4,
})
.unwrap(),
);
drop(relay);
let _ = server.kill();
let _ = server.wait();
}
#[test] #[test]
fn stale_packets_after_resume_are_ignored_not_fatal() { fn stale_packets_after_resume_are_ignored_not_fatal() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+1249 -20
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -58,6 +58,19 @@ fn encrypted_packet_round_trips() {
assert_eq!(plain, b"hello"); 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] #[test]
fn encrypted_packet_rejects_wrong_session_key_id_before_decrypt() { fn encrypted_packet_rejects_wrong_session_key_id_before_decrypt() {
let key = crypto::random_32(); 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."
}
}
}
}
}