Compare commits

..
145 Commits
Author SHA1 Message Date
DuProcess 01e8870578 Add trace report summarizer
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-12 00:59:15 -04:00
DuProcess 99d54899bd Add trace command for reconnect debugging
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-12 00:52:47 -04:00
DuProcess 80e922957e Trace reconnect mouse input handling
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-12 00:40:28 -04:00
DuProcess 1dcb33550e Expand SSH identity path tokens
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 18:18:43 -04:00
DuProcess 2c5b9ab30d Preserve symlinks in file copy
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 18:07:13 -04:00
DuProcess bfe75dfbd2 Harden file resume prefix validation
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:59:41 -04:00
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
DuProcessandClaude Opus 4.8 41256b66b7 persist_sessions: default off (opt-in until stress-tested)
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
Restart-survivable sessions change the core model (per-session holder
processes + SCM_RIGHTS fd passing). Keep them OFF by default so deploying
the build can't flip the daily driver's session model unproven; enable with
persist_sessions = true after stress-testing.

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

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

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

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

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

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

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

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

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

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

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

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

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

Tests: 8 new client unit tests (status state machine + overlay bytes, predictor
spike/recovery/idle). fmt + full test suite green. No wire-format change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:57:40 -04:00
DuProcessandClaude Opus 4.8 8da98c45e7 Native v1: connection fix, hardening, forwarding robustness, prediction, benchmarks, docs
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
Integrates the stability fixes plus 5 parallel work tracks:
- Connection/panic/leak fixes + audit hardening (earlier on this branch)
- Track A: protocol VERSION 2 w/ clear mismatch reject, rekey, connection
  migration, native-auth rate limiting, O(1) conn_id index
- Track B: hostile-network integration tests + parser fuzzing + CI fuzz job
- Track C: benchmark path matrix + safe local harness + docs/BENCHMARKS.md
- Track D: docs/THREAT_MODEL.md + readiness/verification status
- Track E: clean-room mosh-grade predictive echo
- llms.txt agent overview

~112 tests green; cargo fmt clean. Wire VERSION is now 2: server and client
must be deployed together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:49:33 -04:00
DuProcess 2c7654138b Merge track A: VERSION discipline, rekey, migration, rate limiting, O(1) index 2026-06-14 10:47:14 -04:00
DuProcessandClaude Opus 4.8 f9c1973c13 Harden native protocol: version discipline, rekey, migration, rate limit, O(1) client index
Track A protocol hardening for native-v1.

1. Protocol VERSION discipline (protocol.rs, native.rs, dosh-server.rs,
   dosh-client.rs): bump protocol::VERSION to 2 and NATIVE_PROTOCOL_VERSION to 2.
   Foreign wire-version datagrams now get a clear named "protocol version
   mismatch - upgrade dosh" reject from the server instead of a silent timeout
   (peek_foreign_wire_version + VERSION_MISMATCH_REASON). The native handshake's
   plaintext protocol_version is also checked server-side before any crypto via
   check_native_protocol_version, returning a typed ProtocolVersionMismatch.

2. Transport rekey (spec section 11/9): server rotates a client's traffic key
   after rekey_after_packets OR rekey_after_secs (config knobs). Rotated keys are
   derived independently of the handshake keys from the current key plus fresh
   server CSPRNG material shipped confidentially in an AEAD Rekey packet
   (derive_rekey_session_key). The previous epoch's key is retained briefly so
   in-flight pre-rekey packets still decrypt (matched by session_key_id), and
   stale-epoch packets are ignored, not fatal. Client handles Rekey/RekeyAck.

3. Connection migration (spec section 11): every authenticated, replay-accepted
   packet now migrates client.endpoint to the new source address (input, resize,
   ping, ack, resume, stream*, rekey-ack), not just resume. Ping now verifies its
   AEAD tag before acting so migration cannot be spoofed.

4. Native auth rate limiting: per-source-IP token bucket
   (native_auth_rate_limit_per_minute) enforced in handle_native_client_hello
   BEFORE any X25519/Ed25519 work; over-limit hellos get a non-crypto reject.

5. Speed: replaced O(sessions x clients) per-packet linear scans with an O(1)
   HashMap<conn_id, session_name> index in ServerState, kept in sync on every
   client insert/remove (attach handlers, detach, remove_client, cleanup reap,
   session-exit drain).

New config keys (ServerConfig, with defaults): rekey_after_packets = 100000,
rekey_after_secs = 3600.

Tests: version-mismatch reject (no hang), rate-limit flood reject, rekey
round-trip end-to-end + key-derivation unit tests, source-address migration,
client-index sync + cleanup purge. fmt and full test suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:45:44 -04:00
DuProcess 1306adb558 Merge track E: clean-room mosh-grade predictive echo 2026-06-14 10:42:57 -04:00
DuProcessandClaude Opus 4.8 1e5517dcf1 Add llms.txt agent-oriented overview
Self-contained orientation for AI agents: what dosh is, capabilities,
the bidirectional homelab forwarding patterns (-L/-R/-D with concrete
examples), fast-path order, architecture, security summary, config, and
how to drive dosh non-interactively. Links to the deeper docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:42:03 -04:00
DuProcessandClaude Opus 4.8 cdeba047bc Add mosh-grade predictive local echo
Replace the basic printable-only predictor with a clean-room speculative
local-echo engine modeled on the Mosh paper's prediction overlay design
(no GPL code copied or translated; original Rust written from the
algorithm).

Model:
- Per-line cell model with a local cursor; printable keystrokes echo
  immediately, dimmed (and underlined on a very slow link) via
  save/restore cursor so full-screen TUIs are never corrupted.
- Backspace and in-line Left/Right arrow prediction (CSI and SS3).
- Epoch grouping per keystroke burst; anything unmodelable (CR/LF,
  escapes, control bytes, wide chars, right margin, paste-sized bursts)
  ends the epoch safely rather than mispredicting.
- Frame-sequenced confirmation: an authoritative server frame supersedes
  and erases the overlay (dosh has no client-side emulator, so it cannot
  do Mosh's per-cell content match; frame arrival is the confirmation).
- Glitch detection: contradicting output (e.g. alternate-screen entry)
  discards the epoch.
- SRTT-based display policy with hysteresis and a latency-spike override;
  modes off / experimental(adaptive, default) / always via new
  client config `predict_mode` and `DOSH_PREDICT_MODE` env override.

Improvements over the prior predictor: in-line arrow motion, dim+flag
styling, exact-width erase, paste guard, and faster (frame-arrival)
confirmation. Prediction is display-only and never swallows input.

Adds unit tests: printable run, confirmation-clears, glitch-discards,
backspace, line-wrap budget, paste-skip, newline, inline arrows, and the
experimental SRTT threshold gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:41:42 -04:00
DuProcess c47ae98c68 Merge track C: benchmark path matrix + safe local harness + BENCHMARKS.md 2026-06-14 10:34:30 -04:00
DuProcessandClaude Opus 4.8 c48f2280a0 Add benchmark path matrix and safe local harness
Extend dosh-bench to benchmark the full attach path matrix and emit both raw
per-iteration samples and summary statistics (count/min/median/p95/mean/max):

- --cold-native:   native cold auth terminal-ready (dosh_cold_native_ms)
- --cached-ticket: cached attach-ticket fast path (dosh_cached_attach_ms)
- --resume:        UDP resume path (dosh_resume_ms; roaming-only, see docs)
- --local-auth:    self-contained local bootstrap, no SSH (dosh_local_attach_ms)
- default:         legacy SSH-bootstrap cold attach (dosh_attach_ms)

Existing ssh-bootstrap/local-auth/mosh modes and all assert gates are preserved;
legacy flags (--local-auth/--warm-cache/--no-cache) keep their prior behavior so
the CI docker scripts run unchanged. Add --json (machine-readable raw samples)
and --label. Add a table/JSON renderer and percentile-based summary stats.

Add scripts/bench-local.sh: a safe, self-contained harness that builds release,
spins up a throwaway dosh-server bound to 127.0.0.1 on a random free UDP port in
a temp HOME (never port 50000, never a systemd unit), runs the matrix, prints
results, sanity-checks native auth actually trusted the host, and cleans up.

Point `make bench-local` at the new harness and add `make bench-local-json`;
keep bench-docker-* targets intact.

Add docs/BENCHMARKS.md: how to run each benchmark, metric meanings, methodology
and caveats (resume is the roaming path, not cold reconnect; cite cached attach
as the core claim per PUBLIC_READINESS.md), plus a real loopback sample table
with machine/OS/sample-count and raw samples.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:33:37 -04:00
DuProcess 6bded28d40 Merge track B: hostile-network tests + fuzzing + CI fuzz job 2026-06-14 10:32:41 -04:00
DuProcessandClaude Opus 4.8 9b0f09a8a8 Add hostile-network tests, parser fuzzing, and CI fuzz job
Track B (milestone 5 / §16 hardening):

- tests/hostile_network.rs: in-process UDP relay/shim between a test
  client and a real dosh-server that can drop, reorder, and duplicate
  datagrams, and rebind its upstream socket mid-session. Asserts:
  session survives loss/reorder, duplicated/replayed Input is applied at
  most once, stale packets after resume are ignored (not fatal), and a
  client source-address change preserves the session.
- tests/parser_robustness.rs: deterministic randomized tests throwing
  garbage at every reachable public parser (packet decode, from_body for
  all protocol/native structs, authorized_keys, known_hosts, host-key
  line, ssh-ed25519 blob, bootstrap, attach ticket); asserts none panic.
- fuzz/: standalone cargo-fuzz crate (own [workspace], non-default
  member) with libfuzzer-sys harnesses for packet decode, from_body,
  authorized_keys, known_hosts, handshake structs+verifiers, and attach
  tickets. README documents the run command.
- .github/workflows/ci.yml: add fuzz-smoke job (nightly + cargo-fuzz,
  short -max_total_time per target, tolerant if tooling unavailable);
  existing fmt/test/build/bench steps unchanged.

cargo fmt --check and cargo test are green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:31:47 -04:00
DuProcess c37424eb45 Merge track D: threat model + readiness docs 2026-06-14 10:29:05 -04:00
DuProcessandClaude Opus 4.8 828206f757 Publish threat model and refresh native v1 docs
Add docs/THREAT_MODEL.md deriving a publishable threat model from
NATIVE_V1_SPEC.md sections 4-6: assets, in/out-of-scope attackers,
security properties vs SSH (including where Dosh aims to exceed it),
cryptographic building blocks as implemented, and an honest
accepted-residual-risks / known-gaps section.

Refresh docs/PUBLIC_READINESS.md: feature matrix now reflects native
auth, host-key trust, authorized_keys policy, doctor, and -L/-R/-D
forwarding; add a per-item "Native v1 verification checklist status"
table mapping spec section 16 to done/in-progress/pending from code.

Update README.md status with a native v1 section and honest claim
posture pointing to THREAT_MODEL.md. Annotate NATIVE_V1_SPEC.md with a
v1 status block summarizing milestone progress. Point SPEC.md security
model and header at native auth and the threat model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:27:40 -04:00
DuProcessandClaude Opus 4.8 2a6f28d529 Fix native-v1 connection break, server panic, and resource leaks
The native-v1 work changed the UDP wire format (HEADER_LEN 42->58) without a
version bump, so a current client silently timed out against the older
still-deployed server. The server just needed redeploying; alongside that,
fix the robustness bugs surfaced while restoring the connection:

- Server: clamp terminal size to >=1 at every PTY/parser resize site. A client
  reporting a 0x0 terminal made vt100::Parser::set_size panic ("attempt to
  subtract with overflow") and took down the whole single-threaded daemon.
- Client: treat size() == Ok((0,0)) like a missing size and fall back to 80x24
  instead of sending 0x0 to the server.
- Reap abandoned sessions and their shells. PtyHandle now retains the child and
  kills it on drop, and the cleanup task removes clientless, non-prewarmed
  sessions after the grace period. Previously the shell leaked forever (one
  zsh per abandoned session); prewarmed sessions still stay hot.
- Evict half-finished native handshakes (pending_native) after 30s so an
  unauthenticated ClientHello flood can't grow the map without bound.
- Drop the per-client, per-packet vt100 Screen clone on the output hot path and
  remove the write-only last_screen field.
- Guard load_or_create_server_secret against a <32-byte secret file (was an
  index-out-of-range panic at startup).
- Reconcile stream flow-control credit (client and server) when StreamData hits
  a missing writer, so a closed/unknown stream can't wedge the peer's window.

Adds a unit test for session reaping. cargo fmt + cargo test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:06:27 -04:00
61 changed files with 23574 additions and 2728 deletions
+67
View File
@@ -3,6 +3,9 @@ name: ci
on:
push:
pull_request:
workflow_dispatch:
schedule:
- cron: "17 7 * * 1"
jobs:
test:
@@ -19,6 +22,70 @@ jobs:
- name: Docker SSH benchmark gate
run: sh scripts/ci-docker-ssh-bench.sh
fuzz-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install nightly toolchain
id: nightly
continue-on-error: true
uses: dtolnay/rust-toolchain@nightly
- name: Install cargo-fuzz
id: install
if: steps.nightly.outcome == 'success'
continue-on-error: true
run: cargo install cargo-fuzz --locked
- name: Run fuzz targets briefly
if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success'
run: |
set -e
if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
DOSH_FUZZ_SECONDS="${DOSH_FUZZ_SECONDS:-300}" sh scripts/fuzz-run.sh
else
sh scripts/fuzz-run.sh 20
fi
- name: Note when fuzzing was skipped
if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success'
run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run."
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:
runs-on: ubuntu-latest
env:
+1
View File
@@ -1,3 +1,4 @@
/target/
**/*.rs.bk
.DS_Store
vscode-extension/*.vsix
Generated
+17 -1
View File
@@ -397,6 +397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"pem-rfc7468",
"zeroize",
]
@@ -435,7 +436,7 @@ dependencies = [
[[package]]
name = "dosh"
version = "0.1.0"
version = "1.0.0-rc31"
dependencies = [
"anyhow",
"base64",
@@ -446,13 +447,17 @@ dependencies = [
"crossterm",
"dirs",
"ed25519-dalek",
"filetime",
"hkdf",
"hmac",
"libc",
"portable-pty",
"rand",
"rpassword",
"rsa",
"serde",
"sha2",
"signature",
"ssh-key",
"tempfile",
"tokio",
@@ -573,6 +578,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -1498,6 +1513,7 @@ checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
dependencies = [
"bcrypt-pbkdf",
"ed25519-dalek",
"num-bigint-dig",
"p256",
"p384",
"p521",
+7 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "dosh"
version = "0.1.0"
version = "1.0.0-rc31"
edition = "2024"
license = "MIT"
@@ -14,14 +14,18 @@ clap = { version = "4.5", features = ["derive"] }
crossterm = "0.28"
dirs = "5.0"
ed25519-dalek = "2.1"
filetime = "0.2"
hkdf = "0.12"
hmac = "0.12"
libc = "0.2"
portable-pty = "0.8"
rand = "0.8"
rsa = { version = "0.9.10", features = ["sha2"] }
rpassword = "7.5.4"
serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10"
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] }
signature = "2.2"
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption", "p256", "rsa"] }
tokio = { version = "1.41", features = ["full"] }
toml = "0.8"
vt100 = "0.15"
@@ -35,4 +39,4 @@ opt-level = 2
codegen-units = 16
incremental = true
lto = false
strip = false
strip = "debuginfo"
+72 -10
View File
@@ -1,4 +1,4 @@
.PHONY: build test fmt install bench-local 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:
cargo build --release
@@ -9,20 +9,82 @@ test:
fmt:
cargo fmt
install:
sh packaging/install.sh
clippy:
cargo clippy --all-targets -- -D warnings
release-check:
cargo fmt --check
sh -n install.sh scripts/*.sh
cargo clippy --all-targets -- -D warnings
cargo test
$(MAKE) tui-harness
1.0-check: release-check reconnect-check hostile-network-check persistence-check package-check
reconnect-check:
cargo test --test integration_smoke resume_updates_udp_endpoint_for_roaming -- --nocapture
DOSH_SOAK_SECONDS=$${DOSH_SOAK_SECONDS:-300} cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture
hostile-network-check:
cargo test --test hostile_network -- --nocapture
DOSH_BADNET_SOAK_SECONDS=$${DOSH_BADNET_SOAK_SECONDS:-300} cargo test --test hostile_network bad_network_tui_work_soak_30m -- --ignored --nocapture
persistence-check:
cargo test --test integration_smoke session_survives_server_restart_same_shell_and_screen -- --nocapture
cargo test --test integration_smoke multiple_persistent_named_sessions_survive_restart_independently -- --nocapture
package-check:
$(MAKE) package-release-linux
$(MAKE) package-release-windows
sh scripts/verify-release-artifacts.sh \
target/dosh-release/dosh-linux-x86_64.tar.gz \
target/dosh-release/dosh-windows-x86_64.zip
install:
sh 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
# ticket, local-auth) on a throwaway server bound to 127.0.0.1 on a free port in
# a temp HOME. Never touches the production server or UDP port 50000.
bench-local:
cargo build
tmp="$$(mktemp -d)"; \
HOME="$$tmp" target/debug/dosh-server serve >/tmp/dosh-bench-server.log 2>&1 & \
pid="$$!"; \
trap 'kill "$$pid" 2>/dev/null || true; rm -rf "$$tmp"' EXIT INT TERM; \
sleep 0.5; \
HOME="$$tmp" target/debug/dosh-bench --local-auth --server local --iterations 5
sh scripts/bench-local.sh
# Same matrix, machine-readable JSON output (one object per metric with raw
# samples). Useful for publishing or regression tracking.
bench-local-json:
DOSH_BENCH_JSON=1 sh scripts/bench-local.sh
bench-docker-ssh:
sh scripts/ci-docker-ssh-bench.sh
bench-docker-mosh:
DOSH_BENCH_INCLUDE_MOSH=1 sh scripts/ci-docker-ssh-bench.sh
fuzz-smoke:
sh scripts/fuzz-run.sh 20
# Longer pre-launch fuzz pass. Override with DOSH_FUZZ_SECONDS=NN.
fuzz-deep:
DOSH_FUZZ_SECONDS=$${DOSH_FUZZ_SECONDS:-300} sh scripts/fuzz-run.sh
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
+103 -221
View File
@@ -1,269 +1,151 @@
# dosh - Dormant Shell
# Dosh
dosh is a low-latency remote terminal designed around fast attach and fast reconnect.
It is mosh-shaped, but not a mosh clone: the server is a resident daemon, terminal
sessions stay hot, and repeat connects try encrypted UDP before starting SSH.
Dosh is an encrypted remote terminal for fast reconnecting shells.
The core target is simple:
It runs a `dosh-server` on a Unix-like host and a `dosh` client on macOS,
Linux, or Windows. Setup can use SSH, then Dosh connects over encrypted UDP,
keeps sessions alive across disconnects, supports terminal apps, and can carry
TCP forwarding, file copy, and VS Code Remote-SSH streams.
- First secure trust establishment uses SSH.
- Existing sessions attach in one encrypted UDP exchange whenever cached credentials allow it.
- Reconnect after sleep, roaming, or network change resumes in one encrypted UDP exchange.
- Cold SSH fallback stays competitive with plain `ssh` by doing less after auth.
## Support
## Why not just mosh?
- Client: macOS, Linux, Windows
- Server: Linux and other Unix-like systems with PTYs
- Default UDP port: `50000`
- Windows: client only
mosh is excellent at roaming and high-latency interactivity. Its startup path still
has work dosh can avoid:
1. SSH connects to the host.
2. SSH starts `mosh-server`.
3. The client receives connection material over SSH.
4. SSH exits and the mosh UDP session begins.
dosh keeps `dosh-server` running before the client arrives. Named PTY sessions can
also be prewarmed, so attaching to `default` does not need to spawn a daemon, create
a PTY, or start a shell on the user's critical path.
This is not an encryption argument against mosh. dosh also encrypts its UDP data
channel; the speed difference comes from keeping the server and session hot.
## Fast Path Order
The client always tries the cheapest valid path first:
1. **UDP resume:** existing `ClientId` and session key. No SSH. One encrypted UDP
request, one encrypted UDP reply.
2. **UDP attach ticket:** cached server-issued attach ticket for the same
host/user/session/mode. No SSH. One encrypted UDP request, one encrypted UDP
reply.
3. **SSH bootstrap:** `ssh -T user@host ~/.local/bin/dosh-auth ...`, then one encrypted UDP
attach.
4. **New session:** same as attach, but the server must create the PTY/shell unless
the session was prewarmed.
The fastest path is not a custom SSH replacement. SSH remains the first trust root;
dosh removes SSH from repeat attaches when the server has already issued valid
credentials.
Attach tickets are implemented because they are the way a fresh client process can
skip SSH after a recent successful bootstrap.
## Connection Speed Contract
dosh is measured by terminal-ready time: elapsed time from running `dosh host` to the
first usable terminal screen.
- UDP resume: <= one measured UDP RTT + local render time.
- UDP attach ticket: <= one measured UDP RTT + local render time.
- Warm attach with ControlMaster: <= `ssh host true` over the existing master + one
measured UDP RTT.
- Cold attach without ControlMaster: <= cold `ssh host` terminal-ready time + one
measured UDP RTT.
- New session: measured separately because it may need PTY and shell creation.
Server-side client resume state is kept for a day by default, so a sleeping laptop
can resume the same encrypted client association without depending on a still-valid
attach ticket. Attach tickets remain the fast path for fresh client processes.
The client emits timing spans for credential lookup, SSH bootstrap, UDP resume,
UDP ticket attach, and terminal-ready time.
## Architecture
```text
dosh-server
UDP socket on one configurable port
session table keyed by name
one PTY per named session
optional prewarmed sessions, default ["default"]
terminal parser/screen state per session
client table per session
encrypted UDP protocol
tiny SSH-invoked dosh-auth helper mode
dosh-client
terminal raw mode
local credential cache
UDP resume/attach first
SSH bootstrap fallback
PTY input/output forwarding
reconnect and roaming state machine
```
Dosh is meant to replace Mosh and everyday interactive SSH sessions. It does
not currently include SFTP, X11 forwarding, or a Windows server.
## Install
Default UDP port: `50000`. This is intentionally inside the common forwarded range
`50000-52000/udp`.
Unix/macOS server and client:
Install on each Linux server you want to attach to:
```bash
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
| DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_PORT=50000 sh -s -- server
```sh
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh | sh -s -- both --repo https://git.palav.dev/Palav/dosh.git --port 50000
```
Install the client on macOS:
Unix/macOS client only:
```bash
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
| DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_SERVER=palav DOSH_HOST=git.palav.dev DOSH_PORT=50000 sh -s -- client
```sh
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh | sh -s -- client --repo https://git.palav.dev/Palav/dosh.git
```
Update an installed client later:
```bash
dosh update
```
Install the client on Windows PowerShell:
Windows client:
```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
dosh palav
```sh
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
to reattach to the same persistent terminal from multiple clients:
Useful commands:
```bash
dosh --session work palav
dosh --session logs palav
```sh
dosh exec HOST COMMAND
dosh cp SRC DST
dosh ls host:path
dosh cat host:path
dosh mkdir host:path
dosh rm [-r] host:path
dosh forward HOST -L 8080:127.0.0.1:80
dosh forward HOST -D 1080
dosh forward HOST -R 2222:127.0.0.1:22
dosh status HOST
dosh doctor HOST
dosh recover HOST
dosh restart HOST
```
Press `Ctrl-]` to detach the current client while leaving the server session alive.
Typing `exit` in the remote shell closes that Dosh session and returns the client.
If UDP packets stop arriving, Dosh keeps the terminal open, sends keepalive pings,
and attempts ticket-based reconnect. The old in-band status overlay was removed
because writing directly over the terminal could corrupt TUIs; a non-destructive
status surface is tracked as a public-readiness item.
Agent forwarding is opt-in with `-A` and must also be enabled on the server.
File copy must be enabled by the server config.
If SSH and UDP use different public names, specify the UDP address:
## Tracing
```bash
dosh-client --dosh-host public.example.com --dosh-port 50000 user@host
For terminal/reconnect bugs, run a client with:
```sh
dosh trace HOST
```
Dosh already lets OpenSSH handle SSH aliases, users, keys, ports, known-hosts,
ProxyJump, and other SSH config during bootstrap. It also runs `ssh -G` to infer the
UDP host from an SSH alias when `dosh_host` is not configured. To make that explicit
in Dosh's host config:
The client log path is printed before the session starts. To choose it:
```bash
dosh import-ssh palav homelab
```sh
dosh trace --client-log /tmp/dosh-client.log HOST
```
## Develop
Summarize collected traces with:
Build:
```bash
cargo build
```sh
dosh trace report --client-log /tmp/dosh-client.log
```
Attach locally, using local bootstrap instead of SSH:
Set `DOSH_TRACE=/tmp/dosh-server.log` on `dosh-server` for matching server
events. Trace byte prefixes are enabled for `dosh trace`, so use it only for
short reproductions.
```bash
target/debug/dosh-client --local-auth --no-cache local
## VS Code
Dosh can carry VS Code Remote-SSH through its transport:
```sh
dosh vscode setup HOST
dosh vscode HOST /remote/path
```
Benchmark local attach:
This creates a managed SSH config entry using `ProxyCommand dosh proxy-stdio`.
VS Code still uses Remote-SSH and its normal remote server; Dosh carries the
SSH byte stream.
```bash
target/debug/dosh-bench --local-auth --server local --iterations 5
## Config
```text
~/.config/dosh/client.toml
~/.config/dosh/hosts.toml
~/.config/dosh/server.toml
```
Benchmark a remote host over SSH bootstrap:
Common client settings:
```bash
target/release/dosh-bench --server user@host --ssh-port 22 --iterations 3
```toml
default_session = "new"
predict = true
cache_attach_tickets = true
disconnect_status = true
```
Benchmark the ControlMaster-backed SSH bootstrap path:
Example host entry:
```bash
target/release/dosh-bench --server user@host --controlmaster --iterations 3
```toml
[prod]
ssh = "prod"
ssh_config = "prod" # OpenSSH alias for IdentityFile, ProxyJump, SendEnv, etc.
dosh_host = "prod.example.com"
port = 50000
```
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`:
`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
make bench-docker-ssh
## Rust Library
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
```
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.
-598
View File
@@ -1,598 +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`
---
## 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. dosh does not implement a competing public-key login
system in v0.
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}");
}
-597
View File
@@ -1,597 +0,0 @@
# Dosh Native v1 Spec
Native Dosh is a remote-login protocol for Dosh-installed servers. It is intended to
replace the user's day-to-day `ssh host` workflow for terminals and forwarding while
keeping SSH as a compatibility and recovery fallback.
Native Dosh is not an RFC-compatible SSH implementation. It deliberately avoids the
full SSH transport/channel protocol and implements the smaller set of behavior Dosh
needs: authenticated login, encrypted terminal transport, reconnect/roaming, and TCP
forwarding.
## 1. Product Contract
User-facing commands:
```bash
dosh host
dosh host command...
dosh -L 8080:127.0.0.1:80 host
dosh -R 9000:127.0.0.1:9000 host
dosh --session work host
dosh --view-only --session work host
```
Compatibility expectations:
- Existing SSH keys and `ssh-agent` are reused.
- Server-side authorization uses `~/.ssh/authorized_keys`.
- Host trust is pinned in a Dosh known-hosts file and can be bootstrapped by SSH.
- `~/.ssh/config` host aliases, `HostName`, `User`, `Port`, `IdentityFile`,
`ProxyJump`, and `UserKnownHostsFile` are honored where practical.
- SSH bootstrap remains available with `--auth=ssh` and is used automatically when
native auth is disabled or cannot complete.
Native v1 is complete only if a normal interactive user can switch their daily
workflow from `ssh` to `dosh` without keeping SSH open in another tab for routine
tasks.
Non-goals for v1:
- RFC-compatible SSH server/client behavior.
- Arbitrary SSH subsystems.
- X11 forwarding.
- SFTP/SCP compatibility.
- Multi-user daemon mode with privileged account switching.
- Replacing OpenSSH on hosts that do not run `dosh-server`.
## 2. SSH Workflow Parity Target
Native v1 targets the SSH workflows interactive users actually use, not the entire
OpenSSH feature universe.
Must work in v1:
- Interactive shell: `dosh host`.
- Remote command: `dosh host command...`.
- Fresh terminal by default.
- Named persistent terminal: `dosh --session work host`.
- Shared/view-only session: `dosh --view-only --session work host`.
- Local forwarding: `dosh -L [bind:]listen:target:target_port host`.
- Remote forwarding: `dosh -R [bind:]listen:target:target_port host`.
- Dynamic forwarding: `dosh -D [bind:]listen host`, if stream flow control is proven;
otherwise it is the first v1.1 item and must be called out honestly.
- Multiple forwards in one connection.
- Forward-only mode: `dosh -N -L ... host`.
- Background forwarding: `dosh -f -N -L ... host`, or an equivalent supervised
user-service mode.
- SSH-agent authentication.
- Encrypted OpenSSH private-key authentication with prompt.
- `~/.ssh/config` host aliases for `Host`, `HostName`, `User`, `Port`,
`IdentityFile`, `ProxyJump`, `UserKnownHostsFile`, and `IdentitiesOnly`.
- Dosh host config overrides for user, Dosh-specific UDP host/port/auth policy.
- Clear host-key trust and mismatch errors.
- Clear auth failure errors that name the attempted key source.
- Stable reconnect after sleep, network switch, NAT rebinding, and server packet loss.
- `dosh update`.
- `dosh doctor host` for config/auth/UDP reachability diagnostics.
- `dosh sessions host` for session visibility.
Should work in v1 if it does not compromise the transport schedule:
- Agent forwarding with an explicit opt-in flag and clear warning.
- `ProxyJump` through SSH for bootstrap/trust and through Dosh-native relay later.
- `SendEnv`/`SetEnv` equivalent for explicit environment variables.
- Clipboard integration as a separate opt-in channel.
Explicitly not v1:
- X11 forwarding.
- SFTP/SCP wire compatibility.
- Full OpenSSH config language.
- Full `sshd` replacement for arbitrary SSH clients.
- Forced-command subsystems beyond fail-closed enforcement.
## 3. Stability Contract
Native v1 must be boring under real use:
- A closed laptop must not kill the remote session.
- A client crash must not kill the remote session.
- Server restart may drop live PTYs in v1 unless session persistence is implemented,
but the client must fail clearly and reconnect cleanly afterward.
- Decrypt failures from stale packets must be ignored or trigger reconnect, never
terminate the terminal by themselves.
- Terminal cleanup must restore cursor, mouse mode, bracketed paste, alternate
screen, and raw mode on exit.
- Forwarding streams must close cleanly without leaving orphan listeners.
- Every public error must be actionable: host trust, auth failure, UDP blocked,
forwarding denied, version mismatch, or server unavailable.
- `dosh host` must never attach to another active unnamed terminal by accident.
- The default install must be secure without hand-editing configs.
- Upgrades must preserve existing trusted host keys and credentials unless explicitly
rotated.
## 4. Security Contract
Native Dosh must match the security properties users rely on from SSH for this use
case:
- Server authentication before terminal data is trusted.
- User authentication by possession of an authorized private key or agent key.
- Forward secrecy for terminal and forwarding traffic.
- AEAD encryption and authentication for every post-handshake packet.
- Replay protection for handshake and transport packets.
- Host-key pinning with explicit first-use behavior.
- No plaintext terminal bytes after handshake begins.
- No custom cryptographic primitives.
- Clear downgrade behavior: native auth failure must not silently fall back to an
unauthenticated mode.
Native Dosh does not claim SSH's full protocol security surface. It claims equivalent
security for Dosh terminal and forwarding sessions on Dosh-installed servers.
## 5. Threat Model
In scope:
- Passive network observer.
- Active network attacker that can spoof, drop, replay, reorder, or modify packets.
- NAT rebinding and client IP/port changes.
- Stolen attach-ticket cache without the user's private key.
- Server restart and key rotation.
- Malicious unauthenticated client flooding auth attempts.
- Compromised low-privilege local user trying to read Dosh caches on a shared client.
Out of scope:
- Compromised client machine.
- Compromised server account.
- Malicious kernel, terminal emulator, or PTY implementation.
- Protecting against a server that is already authorized and then becomes malicious.
## 6. Cryptographic Building Blocks
Allowed primitives:
- Handshake pattern: Noise `NK` or `XX` through a maintained Rust Noise framework, or
a small audited construction over `x25519-dalek` plus transcript binding.
- KEX: X25519.
- Signatures for user auth: Ed25519 and ECDSA P-256 via SSH-agent and OpenSSH key
formats. RSA may be accepted only for compatibility and must use SHA-2 signatures.
- AEAD: ChaCha20-Poly1305 by default; AES-GCM optional when hardware support is known.
- KDF: HKDF-SHA256.
- Hash/transcript: SHA-256.
- Randomness: OS CSPRNG only.
Disallowed:
- Homegrown ciphers, MACs, padding, or key derivation.
- Reusing a nonce/key pair.
- Unauthenticated encryption.
- MD5/SHA-1 signatures for user auth.
## 7. Identity And Trust
### Server Identity
Each `dosh-server` has a persistent host key:
```text
~/.config/dosh/host_key
~/.config/dosh/host_key.pub
```
Default host-key algorithm: Ed25519.
Client pins host keys in:
```text
~/.config/dosh/known_hosts
```
Entry format:
```text
host-pattern key-type base64-public-key first-seen=unix-seconds source=tofu|ssh|manual
```
First-use policy:
- Default for public internet: refuse unknown native host key and suggest
`dosh trust host` or SSH bootstrap.
- Default for local/private hosts may be TOFU only when `trust_on_first_use = true`.
- `dosh trust host` may verify the Dosh host key over the existing SSH bootstrap path.
Host-key mismatch:
- Hard fail.
- Print old fingerprint, new fingerprint, and known-hosts file path.
- Never auto-replace.
### User Identity
Native auth user identity is the login user resolved from:
1. Explicit CLI user: `user@host`.
2. Dosh host config.
3. `ssh -G host` `user`.
4. Local username.
Server verifies user keys against:
```text
~/.ssh/authorized_keys
~/.config/dosh/authorized_keys
```
`~/.config/dosh/authorized_keys` is optional and may restrict Dosh access without
changing SSH access.
Authorized-key options required in v1:
- `from=`
- `command=` must reject native Dosh terminal login unless explicitly supported later.
- `restrict`
- `no-port-forwarding`
- `permitopen=`
Unsupported restrictive options must fail closed.
## 8. Native Auth Handshake
Native auth runs over UDP on the same Dosh port. It establishes a short-lived
authenticated control channel and returns the same terminal attach material that SSH
bootstrap returns today.
Target path:
```text
client -> server: ClientHello
server -> client: ServerHello
client -> server: UserAuth
server -> client: AuthOk + first terminal snapshot
```
The server may combine `AuthOk` and `AttachOk` to get terminal-ready in the final
handshake flight.
### ClientHello
Fields:
- protocol version
- client random
- client ephemeral X25519 public key
- requested host alias
- requested user
- requested session
- requested mode
- terminal size
- supported AEAD algorithms
- supported user key algorithms
- optional cached host-key id
- optional attach ticket envelope
### ServerHello
Fields:
- protocol version
- server random
- server ephemeral X25519 public key
- server host public key
- server host-key signature over transcript
- chosen AEAD
- server key epoch
- auth challenge
- rate-limit metadata when applicable
The client must verify the host key before sending user authentication.
### UserAuth
Fields:
- selected public key
- key algorithm
- signature over transcript and auth challenge
- optional agent identity metadata
- optional requested forwarding declarations
The signature must bind:
- both ephemeral keys
- both randoms
- server host key
- requested user/session/mode/terminal size
- selected algorithms
- protocol version
### AuthOk
Fields:
- assigned `ClientId`
- session name
- mode
- session key id
- encrypted session key material or derived key confirmation
- attach ticket
- attach ticket PSK encrypted to the handshake key
- initial output sequence
- first snapshot
- server policy flags
`AuthOk` is AEAD-encrypted under the handshake traffic key.
## 9. Key Schedule
Handshake transcript:
```text
H = SHA256(protocol_label || ClientHello || ServerHello || UserAuth)
```
Shared secret:
```text
dh = X25519(client_ephemeral, server_ephemeral)
```
Handshake key:
```text
handshake_key = HKDF-SHA256(dh, H, "dosh/native/handshake/v1")
```
Session traffic keys:
```text
c2s_key = HKDF-SHA256(handshake_key, H, "dosh/native/c2s/v1")
s2c_key = HKDF-SHA256(handshake_key, H, "dosh/native/s2c/v1")
```
Attach ticket PSKs and rotated session keys must be derived independently from server
secret material and fresh randomness. They must not reuse handshake traffic keys.
## 10. Attach Tickets And Cache
Native attach tickets replace most cold auth after first login.
Ticket properties:
- Server-sealed AEAD blob.
- Scoped to server host key, user, session, mode, client key fingerprint, server key
epoch, and policy flags.
- Paired with a client-held random PSK.
- Default TTL: 24 hours for trusted personal machines, configurable down to zero.
- Stored mode `0600`.
- Revoked by server host-key rotation, server secret rotation, or user key removal.
Client cache path:
```text
~/.local/share/dosh/credentials/
```
Cache entries must include:
- host identity fingerprint
- user
- session
- mode
- ticket expiry
- last rendered sequence
- client id
- session key id
- attach ticket
- attach ticket PSK
## 11. Transport
Post-auth terminal traffic continues to use the current Dosh UDP packet model:
- fixed binary header
- AEAD body
- monotonic packet sequence
- ack field
- replay window
- server snapshots for recovery
Required v1 changes:
- Separate packet namespaces for terminal frames, control messages, and forwarding
streams.
- Explicit `key_epoch` or `session_key_id` in packet metadata so stale packets can be
ignored without fatal decrypt errors.
- Rekey command after configurable packet count or wall-clock interval.
- Connection migration must be accepted after any valid encrypted packet from a new
source address.
## 12. Forwarding
Native forwarding is a Dosh stream multiplexer over the encrypted transport.
CLI:
```bash
dosh -L [bind_host:]listen_port:target_host:target_port host
dosh -R [bind_host:]listen_port:target_host:target_port host
dosh -D [bind_host:]listen_port host
```
Stream packet types:
- `StreamOpen`
- `StreamOpenOk`
- `StreamOpenReject`
- `StreamData`
- `StreamWindowAdjust`
- `StreamEof`
- `StreamClose`
Forwarding rules:
- Terminal traffic has priority over stream bulk data.
- Each stream has independent flow control.
- Backpressure must not block PTY input or output.
- Server enforces `no-port-forwarding` and `permitopen=`.
- Remote listeners bind to loopback by default.
- Non-loopback remote bind requires explicit config.
- `-N` opens forwarding without creating a PTY.
- `-f` backgrounds only after all requested listeners are successfully active.
- Listener setup failures fail the entire command unless `--partial-forwarding` is
explicitly requested.
- Forwarding reconnect must preserve listeners and re-open streams after network
migration when protocol state allows it.
- Stream packet scheduling must enforce terminal priority; a large port-forward copy
cannot make shell keystrokes lag.
## 13. Diagnostics And Operations
Native v1 must include operations commands:
```bash
dosh doctor host
dosh trust host
dosh trust --remove host
dosh sessions host
dosh update
```
`dosh doctor host` checks:
- host alias resolution
- Dosh known-host state
- SSH fallback reachability
- native UDP reachability
- server version
- native auth enabled/disabled
- usable keys from ssh-agent and identity files
- server authorization result without opening a terminal
- configured forwarding policy
`dosh trust host` checks:
- fetch Dosh host key through SSH fallback when available
- show fingerprint before writing trust
- refuse overwrite on mismatch unless `--replace` is explicit
## 14. Config
Client:
```toml
auth_preference = "native,ssh"
trust_on_first_use = false
native_auth_timeout_ms = 700
known_hosts = "~/.config/dosh/known_hosts"
credential_cache = "~/.local/share/dosh/credentials"
identity_files = ["~/.ssh/id_ed25519"]
use_ssh_agent = true
forward_agent = false
send_env = ["LANG", "LC_*", "TERM", "COLORTERM"]
set_env = {}
forwardings = []
```
Server:
```toml
native_auth = true
host_key = "~/.config/dosh/host_key"
authorized_keys = ["~/.ssh/authorized_keys", "~/.config/dosh/authorized_keys"]
native_auth_rate_limit_per_minute = 30
attach_ticket_ttl_secs = 86400
allow_tcp_forwarding = true
allow_remote_forwarding = false
allow_remote_non_loopback_bind = false
allow_agent_forwarding = false
accept_env = ["LANG", "LC_*", "TERM", "COLORTERM"]
```
## 15. Migration Plan
Milestone 1: host identity and trust
- Generate Dosh host key on server install.
- Add `dosh trust host`.
- Add known-hosts file and mismatch handling.
- Keep SSH bootstrap as the only auth path.
Milestone 2: native user auth
- Implement `ClientHello`/`ServerHello`/`UserAuth`/`AuthOk`.
- Support ssh-agent Ed25519 first.
- Verify against `authorized_keys`.
- Add `--auth=native|ssh|auto`.
Milestone 3: default native auth
- Make `auth_preference = "native,ssh"` default.
- Keep SSH fallback explicit and visible.
- Add benchmark gates for native cold auth.
Milestone 4: forwarding
- Add stream mux.
- Add `-L`, `-N`, and `-f`.
- Add `-R`.
- Add `-D` only after flow control is proven.
Milestone 5: hardening
- Fuzz packet parsing, authorized-key parsing, known-host parsing, and handshake state.
- Add hostile-network integration tests.
- Add external review checklist before public security claims.
Milestone 6: workflow parity
- Implement `dosh doctor`.
- Implement complete host-trust management.
- Implement private-key prompt flow.
- Implement forwarding policy diagnostics.
- Run daily-driver soak on macOS and Linux clients.
## 16. Verification
Native v1 is not complete until all are true:
- Unknown host key fails by default unless TOFU is explicitly enabled.
- Known host key mismatch hard fails.
- Native Ed25519 auth succeeds via ssh-agent.
- Native Ed25519 auth succeeds via encrypted private key prompt.
- Removed authorized key can no longer authenticate.
- Restrictive unsupported authorized-key options fail closed.
- Replayed handshake packets are rejected.
- Replayed transport packets are rejected.
- Stale encrypted packets after reconnect are ignored, not fatal.
- Client IP/port change preserves the session.
- Native cold auth benchmark beats cold `ssh host true` on the same host.
- Cached attach remains near network RTT plus local render overhead.
- `-L` forwarding works without delaying terminal input.
- `-R` forwarding enforces bind and permission policy.
- `-N -L` forward-only mode does not allocate a PTY.
- `-f -N -L` backgrounds only after listener readiness.
- Multiple forwards in one command work.
- `dosh doctor` identifies UDP-blocked, auth-denied, host-key-mismatch, and
forwarding-denied states.
- Closing the laptop for at least 30 minutes does not kill the remote session.
- Three concurrent Dosh terminals remain independent unless explicitly named.
- Large forwarded transfers do not add visible terminal input lag.
- Fuzz targets run in CI.
- Threat model is updated with any accepted residual risks.
## 17. Public Claim Gate
Dosh may claim "native SSH replacement for Dosh-installed servers" only after:
- Native auth is default on at least one real host.
- SSH fallback remains available.
- The verification checklist is green.
- The threat model is published.
- Benchmarks include raw samples for SSH cold, Dosh native cold, Dosh cached attach,
and Mosh startup.
Dosh must not claim generic SSH compatibility unless it implements the SSH protocol.
-121
View File
@@ -1,121 +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`. Until that spec is
implemented and verified, Dosh's public security claim remains SSH-bootstrap plus
encrypted Dosh transport.
## 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 |
| 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 | not implemented | planned |
| Remote TCP forwarding, `-R` | no | not implemented | planned |
| Dynamic SOCKS forwarding, `-D` | no | not implemented | later |
## 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 Plan
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 needs native encrypted channels over the Dosh transport.
Minimum viable forwarding design:
| 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; client connects to target. |
Protocol work required:
- Add stream-open, stream-data, stream-ack, and stream-close packet types.
- Use per-stream flow control separate from terminal frame ordering.
- Never let bulk forwarding traffic delay terminal input/output packets.
- Bind forwarding permissions to the same SSH-authenticated user as the terminal.
- Add tests with dropped/reordered UDP packets.
This is a publishable differentiator once implemented, but it should not be claimed
until it exists.
## 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.
+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?;
}
}
+5
View File
@@ -0,0 +1,5 @@
/target
/corpus
/artifacts
/coverage
Cargo.lock
+64
View File
@@ -0,0 +1,64 @@
[package]
name = "dosh-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
# Standalone workspace so this crate is never absorbed by, and never affects,
# the main `dosh` crate's `cargo build` / `cargo test` / `cargo fmt --check`.
[workspace]
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
[dependencies.dosh]
path = ".."
# cargo-fuzz needs unwinding to report panics; keep debug assertions on.
[profile.release]
debug = 1
[[bin]]
name = "packet_decode"
path = "fuzz_targets/packet_decode.rs"
test = false
doc = false
bench = false
[[bin]]
name = "from_body"
path = "fuzz_targets/from_body.rs"
test = false
doc = false
bench = false
[[bin]]
name = "authorized_keys"
path = "fuzz_targets/authorized_keys.rs"
test = false
doc = false
bench = false
[[bin]]
name = "known_hosts"
path = "fuzz_targets/known_hosts.rs"
test = false
doc = false
bench = false
[[bin]]
name = "handshake_structs"
path = "fuzz_targets/handshake_structs.rs"
test = false
doc = false
bench = false
[[bin]]
name = "attach_ticket"
path = "fuzz_targets/attach_ticket.rs"
test = false
doc = false
bench = false
+20
View File
@@ -0,0 +1,20 @@
#![no_main]
//! Fuzz the attach-ticket and bootstrap decoders. These open server-sealed
//! AEAD blobs and base64 bootstrap envelopes from cache / wire material that an
//! attacker may corrupt; none may panic.
use libfuzzer_sys::fuzz_target;
use dosh::auth::{decode_bootstrap, open_attach_ticket, verify_attach_ticket};
fuzz_target!(|data: &[u8]| {
let secret = [0x11u8; 32];
let psk = [0x22u8; 32];
let _ = open_attach_ticket(&secret, data);
let _ = verify_attach_ticket(&secret, data, &psk, "default", "read-write");
if let Ok(text) = std::str::from_utf8(data) {
let _ = decode_bootstrap(text);
}
});
+18
View File
@@ -0,0 +1,18 @@
#![no_main]
//! Fuzz the authorized_keys parser, including its option lexer and the
//! ssh-ed25519 public-key blob parser it depends on. None may panic.
use libfuzzer_sys::fuzz_target;
use dosh::native::{parse_authorized_keys, parse_ssh_ed25519_public_blob};
fuzz_target!(|data: &[u8]| {
// The blob parser operates directly on raw bytes.
let _ = parse_ssh_ed25519_public_blob(data);
// The line parser operates on text; only feed valid UTF-8 (lossless),
// matching how the file is read in production via read_to_string.
if let Ok(text) = std::str::from_utf8(data) {
let _ = parse_authorized_keys(text);
}
});
+62
View File
@@ -0,0 +1,62 @@
#![no_main]
//! Fuzz `protocol::from_body` (bincode deserialization) for every protocol and
//! native struct that is decoded from untrusted wire bytes. None may panic.
use libfuzzer_sys::fuzz_target;
use dosh::auth::{AttachTicketPlain, BootstrapResponse, SealedAttachTicket};
use dosh::native::{
HostPublicKey, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth,
};
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, Frame, Input, NativeAuthCheckOkBody,
NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, Resize,
ResumeRequest, StreamClose, StreamData, StreamEof, StreamOpen, StreamOpenOk, StreamOpenReject,
StreamWindowAdjust, TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope,
};
macro_rules! try_body {
($data:expr, $ty:ty) => {
let _ = protocol::from_body::<$ty>($data);
};
}
fuzz_target!(|data: &[u8]| {
// protocol.rs structs
try_body!(data, BootstrapAttachRequest);
try_body!(data, TicketAttachEnvelope);
try_body!(data, TicketAttachBody);
try_body!(data, TicketAttachOkEnvelope);
try_body!(data, AttachOk);
try_body!(data, AttachReject);
try_body!(data, ResumeRequest);
try_body!(data, Input);
try_body!(data, Resize);
try_body!(data, Frame);
try_body!(data, StreamOpen);
try_body!(data, StreamOpenOk);
try_body!(data, StreamOpenReject);
try_body!(data, StreamData);
try_body!(data, StreamWindowAdjust);
try_body!(data, StreamEof);
try_body!(data, StreamClose);
// native handshake wrapper bodies
try_body!(data, NativeClientHelloBody);
try_body!(data, NativeServerHelloBody);
try_body!(data, NativeUserAuthBody);
try_body!(data, NativeAuthOkBody);
try_body!(data, NativeAuthCheckOkBody);
// bare native handshake structs
try_body!(data, NativeClientHello);
try_body!(data, NativeServerHello);
try_body!(data, NativeUserAuth);
try_body!(data, NativeAuthOk);
try_body!(data, HostPublicKey);
// auth.rs structs
try_body!(data, BootstrapResponse);
try_body!(data, SealedAttachTicket);
try_body!(data, AttachTicketPlain);
});
+44
View File
@@ -0,0 +1,44 @@
#![no_main]
//! Fuzz the native handshake structs and their structural verifiers.
//!
//! Beyond plain deserialization (covered by the `from_body` target), this drives
//! the verifier state machine: if the input happens to decode into the handshake
//! structs, run `verify_server_hello`, `user_auth_transcript`, and
//! `verify_native_user_auth` on them. These run on attacker-controlled material
//! during the handshake and must reject (Err) without panicking.
use libfuzzer_sys::fuzz_target;
use dosh::native::{
NativeClientHello, NativeServerHello, NativeUserAuth, user_auth_transcript,
verify_native_user_auth, verify_server_hello,
};
use dosh::protocol;
fuzz_target!(|data: &[u8]| {
// Split the input into three slices and try to decode each into a handshake
// struct. Use a length prefix scheme that is robust to short inputs.
if data.len() < 3 {
return;
}
let n = data.len();
let a = n / 3;
let b = 2 * n / 3;
let (chunk_client, chunk_server, chunk_auth) = (&data[..a], &data[a..b], &data[b..]);
let client: Option<NativeClientHello> = protocol::from_body(chunk_client).ok();
let server: Option<NativeServerHello> = protocol::from_body(chunk_server).ok();
let auth: Option<NativeUserAuth> = protocol::from_body(chunk_auth).ok();
if let (Some(client), Some(server)) = (&client, &server) {
// Host signature verification over the transcript must not panic.
let _ = verify_server_hello(client, server);
if let Some(auth) = &auth {
// Transcript construction and full user-auth verification (signature
// check + authorized-key matching) must not panic on garbage.
let _ = user_auth_transcript(client, server, auth);
let _ = verify_native_user_auth(client, server, auth, &[], None);
}
}
});
+14
View File
@@ -0,0 +1,14 @@
#![no_main]
//! Fuzz the known_hosts parser and the host-public-key line parser. None may
//! panic on arbitrary input.
use libfuzzer_sys::fuzz_target;
use dosh::native::{parse_host_public_key_line, parse_known_hosts};
fuzz_target!(|data: &[u8]| {
if let Ok(text) = std::str::from_utf8(data) {
let _ = parse_known_hosts(text);
let _ = parse_host_public_key_line(text);
}
});
+23
View File
@@ -0,0 +1,23 @@
#![no_main]
//! Fuzz the wire-packet decoder + decrypt path.
//!
//! Mirrors tests/parser_robustness.rs but driven by libFuzzer so the coverage
//! engine can search for panics in `protocol::decode`, `Header::parse`, and the
//! decode -> decrypt_body pipeline. The objective is: NO PANICS on any input.
use libfuzzer_sys::fuzz_target;
use dosh::protocol::{self, CLIENT_TO_SERVER, SERVER_TO_CLIENT};
fuzz_target!(|data: &[u8]| {
// Header parsing must never panic.
let _ = protocol::Header::parse(data);
// Full decode, then attempt decryption with a fixed key in both directions.
// A real attacker controls these bytes; neither path may panic.
if let Ok(packet) = protocol::decode(data) {
let key = [0x42u8; 32];
let _ = protocol::decrypt_body(&packet, &key, CLIENT_TO_SERVER);
let _ = protocol::decrypt_body(&packet, &key, SERVER_TO_CLIENT);
}
});
+215 -17
View File
@@ -1,11 +1,17 @@
param(
[ValidateSet("client")]
[string]$Role = $(if ($env:DOSH_ROLE) { $env:DOSH_ROLE } else { "client" }),
[string]$Repo = $env:DOSH_REPO,
[string]$Repo = $(if ($env:DOSH_REPO) { $env:DOSH_REPO } else { "https://git.palav.dev/Palav/dosh.git" }),
[string]$Server = $env:DOSH_SERVER,
[string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }),
[int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }),
[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
)
@@ -17,8 +23,183 @@ function Require-Command($Name) {
}
}
Require-Command cargo
function Normalize-Arch {
switch ($env:PROCESSOR_ARCHITECTURE) {
"AMD64" { "x86_64"; break }
"ARM64" { "aarch64"; break }
default { $env:PROCESSOR_ARCHITECTURE.ToLowerInvariant() }
}
}
function Release-ArtifactName {
if ($BinaryName) {
return $BinaryName
}
"dosh-windows-$(Normalize-Arch).zip"
}
function Repo-WebBase($Value) {
if (-not $Value) {
return $null
}
$base = $Value.TrimEnd("/")
if ($base.EndsWith(".git")) {
$base = $base.Substring(0, $base.Length - 4)
}
if ($base -notmatch "^https?://") {
return $null
}
$base
}
function Release-DownloadUrl {
if ($BinaryUrl) {
return $BinaryUrl
}
$name = Release-ArtifactName
if ($BinaryBase) {
return "$($BinaryBase.TrimEnd('/'))/$name"
}
$web = Repo-WebBase $Repo
if (-not $web) {
return $null
}
if ($BinaryVersion -eq "latest") {
return "$web/releases/latest/download/$name"
}
"$web/releases/download/$BinaryVersion/$name"
}
function Release-LatestTagDownloadUrl {
if ($BinaryUrl -or $BinaryBase -or $BinaryVersion -ne "latest") {
return $null
}
$web = Repo-WebBase $Repo
if (-not $web) {
return $null
}
try {
$response = Invoke-WebRequest -UseBasicParsing -Uri "$web/releases/latest" -MaximumRedirection 5
$effective = $null
if ($response.BaseResponse.ResponseUri) {
$effective = $response.BaseResponse.ResponseUri.AbsoluteUri
} elseif ($response.BaseResponse.RequestMessage -and $response.BaseResponse.RequestMessage.RequestUri) {
$effective = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri
}
$prefix = "$web/releases/tag/"
if ($effective -and $effective.StartsWith($prefix)) {
$tag = $effective.Substring($prefix.Length)
if ($tag) {
return "$web/releases/download/$tag/$(Release-ArtifactName)"
}
}
}
catch {
return $null
}
return $null
}
function Verify-ArchiveChecksum($Url, $Archive) {
$checksumPath = "$Archive.sha256"
try {
Invoke-WebRequest -UseBasicParsing -Uri "$Url.sha256" -OutFile $checksumPath
}
catch {
Write-Warning "prebuilt checksum unavailable; continuing without sidecar verification"
return
}
$expected = ((Get-Content $checksumPath -Raw).Trim() -split "\s+")[0].ToLowerInvariant()
$actual = (Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant()
if ($expected -ne $actual) {
throw "prebuilt checksum mismatch for $Url"
}
}
function Expected-ArchiveVersion($Url) {
if ($Url -match "/releases/download/v([^/]+)/") {
return $Matches[1]
}
if (-not $BinaryUrl -and -not $BinaryBase -and $BinaryVersion -ne "latest") {
return $BinaryVersion.TrimStart("v")
}
return $null
}
function Verify-ArchiveVersion($ExtractDir, $Url) {
$expected = Expected-ArchiveVersion $Url
if (-not $expected) {
return
}
$versionFile = Get-ChildItem -Path $ExtractDir -Recurse -File -Filter VERSION | Select-Object -First 1
if (-not $versionFile) {
throw "prebuilt archive missing VERSION for $Url"
}
$actual = (Get-Content $versionFile.FullName -Raw).Trim()
if ($actual -ne $expected) {
throw "prebuilt archive version mismatch for ${Url}: expected $expected, got $actual"
}
}
function Install-Binary($Source, $Destination) {
$dir = Split-Path -Parent $Destination
$name = Split-Path -Leaf $Destination
$tmp = Join-Path $dir ".$name.tmp.$PID"
Copy-Item $Source $tmp -Force
try {
Move-Item $tmp $Destination -Force
}
catch {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
throw
}
}
$bindir = Join-Path $Prefix "bin"
$configDir = Join-Path $HOME ".config\dosh"
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
function Install-Prebuilt {
$url = Release-LatestTagDownloadUrl
if (-not $url) {
$url = Release-DownloadUrl
}
if (-not $url) {
return $false
}
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-bin-" + [guid]::NewGuid())
$zip = Join-Path $tmp (Release-ArtifactName)
$extract = Join-Path $tmp "extract"
try {
New-Item -ItemType Directory -Force -Path $tmp, $extract | Out-Null
Write-Host "Trying Dosh prebuilt $(Release-ArtifactName)"
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $zip
Verify-ArchiveChecksum $url $zip
Expand-Archive -Force -Path $zip -DestinationPath $extract
Verify-ArchiveVersion $extract $url
foreach ($bin in @("dosh-client.exe", "dosh-bench.exe")) {
$found = Get-ChildItem -Path $extract -Recurse -File -Filter $bin | Select-Object -First 1
if (-not $found) {
throw "prebuilt archive missing $bin"
}
Install-Binary $found.FullName (Join-Path $bindir $bin)
}
Install-Binary (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe")
return $true
}
catch {
Write-Warning "prebuilt install failed: $_"
return $false
}
finally {
if (Test-Path $tmp) {
Remove-Item -Recurse -Force $tmp
}
}
}
function Install-FromSource {
Require-Command cargo
$tmp = $null
if (Test-Path "Cargo.toml") {
$src = (Get-Location).Path
@@ -35,14 +216,30 @@ if (Test-Path "Cargo.toml") {
try {
Push-Location $src
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"
$configDir = Join-Path $HOME ".config\dosh"
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force
if ($UsePrebuilt) {
$ok = Install-Prebuilt
if (-not $ok) {
if ($BinaryRequired) {
throw "prebuilt install failed and DOSH_BINARY_REQUIRED=1"
}
Write-Host "Falling back to source build"
Install-FromSource
}
} else {
Install-FromSource
}
$clientConfig = Join-Path $configDir "client.toml"
if ($ForceConfig -or -not (Test-Path $clientConfig)) {
@@ -60,8 +257,11 @@ dosh_port = $Port
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
escape_key = "^]"
"@ | Set-Content -NoNewline -Encoding utf8 $clientConfig
}
@@ -74,14 +274,12 @@ credential_cache = "~/.local/share/dosh/credentials"
Write-Host "Configured UDP port $Port"
Write-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 setup <ssh-alias>"
Write-Host " $bindir\dosh.exe update --check"
Write-Host ""
Write-Host "Client config:"
Write-Host " $configDir\client.toml"
Write-Host ""
Write-Host "Open a new terminal for PATH changes to apply."
}
finally {
Pop-Location
if ($tmp) {
Remove-Item -Recurse -Force $tmp
}
}
+294 -25
View File
@@ -12,6 +12,12 @@ start_server=1
force_config=0
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
quiet="${DOSH_UPDATE_QUIET:-0}"
use_prebuilt="${DOSH_USE_PREBUILT:-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() {
cat <<'EOF'
@@ -32,6 +38,18 @@ Options:
Environment alternatives:
DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX,
DOSH_UPDATE_CACHE
DOSH_USE_PREBUILT=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
}
@@ -112,8 +130,6 @@ ensure_cargo() {
. "$HOME/.cargo/env"
}
ensure_cargo
cleanup() {
if [ -n "${tmpdir:-}" ]; then
rm -rf "$tmpdir"
@@ -121,6 +137,218 @@ cleanup() {
}
trap cleanup EXIT INT TERM
bindir="$prefix/bin"
config_dir="$HOME/.config/dosh"
data_dir="$HOME/.local/share/dosh"
systemd_user_dir="$HOME/.config/systemd/user"
src_dir=""
mkdir -p "$bindir" "$config_dir" "$data_dir"
normalize_os() {
case "$(uname -s)" in
Darwin) printf '%s\n' macos ;;
Linux) printf '%s\n' linux ;;
FreeBSD) printf '%s\n' freebsd ;;
*) uname -s | tr '[:upper:]' '[:lower:]' ;;
esac
}
normalize_arch() {
case "$(uname -m)" in
x86_64|amd64) printf '%s\n' x86_64 ;;
arm64|aarch64) printf '%s\n' aarch64 ;;
armv7l) printf '%s\n' armv7 ;;
*) uname -m | tr '[:upper:]' '[:lower:]' ;;
esac
}
repo_web_base() {
repo_base="$1"
case "$repo_base" in
http://*|https://*)
repo_base="${repo_base%.git}"
printf '%s\n' "${repo_base%/}"
;;
*)
return 1
;;
esac
}
release_artifact_name() {
if [ -n "$binary_name" ]; then
printf '%s\n' "$binary_name"
else
printf 'dosh-%s-%s.tar.gz\n' "$(normalize_os)" "$(normalize_arch)"
fi
}
release_download_url() {
if [ -n "$binary_url" ]; then
printf '%s\n' "$binary_url"
return 0
fi
if [ -n "$binary_base" ]; then
printf '%s/%s\n' "${binary_base%/}" "$(release_artifact_name)"
return 0
fi
if [ -z "$repo" ]; then
return 1
fi
web_base="$(repo_web_base "$repo")" || return 1
if [ "$binary_version" = "latest" ]; then
printf '%s/releases/latest/download/%s\n' "$web_base" "$(release_artifact_name)"
else
printf '%s/releases/download/%s/%s\n' "$web_base" "$binary_version" "$(release_artifact_name)"
fi
}
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)"
else
@@ -173,21 +401,51 @@ else
fi
fi
bindir="$prefix/bin"
config_dir="$HOME/.config/dosh"
data_dir="$HOME/.local/share/dosh"
systemd_user_dir="$HOME/.config/systemd/user"
mkdir -p "$bindir" "$config_dir" "$data_dir"
install -m 0755 target/release/dosh-client "$bindir/dosh-client"
install_binary target/release/dosh-client "$bindir/dosh-client"
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install -m 0755 target/release/dosh-server "$bindir/dosh-server"
install -m 0755 target/release/dosh-auth "$bindir/dosh-auth"
install_binary target/release/dosh-server "$bindir/dosh-server"
install_binary target/release/dosh-auth "$bindir/dosh-auth"
fi
if [ -f target/release/dosh-bench ]; then
install -m 0755 target/release/dosh-bench "$bindir/dosh-bench"
install_binary target/release/dosh-bench "$bindir/dosh-bench"
fi
}
write_systemd_service() {
if [ -n "$src_dir" ] && [ -f "$src_dir/packaging/systemd/dosh-server.service" ]; then
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \
"$src_dir/packaging/systemd/dosh-server.service" >"$systemd_user_dir/dosh-server.service"
else
cat >"$systemd_user_dir/dosh-server.service" <<EOF
[Unit]
Description=dosh dormant shell server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$bindir/dosh-server serve
Restart=always
RestartSec=1
KillMode=process
[Install]
WantedBy=default.target
EOF
fi
}
if [ "$use_prebuilt" != "0" ]; then
if ! try_install_prebuilt; then
if [ "$binary_required" = "1" ]; then
exit 1
fi
[ "$quiet" = "1" ] && echo "Falling back to source build"
install_from_source
fi
else
install_from_source
fi
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
@@ -206,8 +464,9 @@ scrollback = 5000
auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600
allow_attach_tickets = true
client_timeout_secs = 86400
client_timeout_secs = 2592000
retransmit_window = 256
output_frame_interval_ms = 16
default_input_mode = "read-write"
prewarm_sessions = ["default"]
create_on_attach = true
@@ -221,15 +480,16 @@ native_auth_rate_limit_per_minute = 30
allow_tcp_forwarding = true
allow_remote_forwarding = false
allow_agent_forwarding = false
persist_sessions = true
EOF
fi
if command -v systemctl >/dev/null 2>&1; then
mkdir -p "$systemd_user_dir"
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \
packaging/systemd/dosh-server.service >"$systemd_user_dir/dosh-server.service"
write_systemd_service
if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then
systemctl --user enable --now dosh-server.service
systemctl --user enable dosh-server.service
systemctl --user restart dosh-server.service
fi
elif [ "$start_server" -eq 1 ]; then
nohup "$bindir/dosh-server" serve >"$data_dir/dosh-server.log" 2>&1 &
@@ -272,7 +532,8 @@ dosh_port = $port
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
predict = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
auth_preference = "native,ssh"
@@ -282,6 +543,7 @@ known_hosts = "~/.config/dosh/known_hosts"
identity_files = ["~/.ssh/id_ed25519"]
use_ssh_agent = true
forward_agent = false
escape_key = "^]"
EOF
fi
@@ -295,18 +557,18 @@ EOF
fi
cat >"$hosts_config" <<EOF
# Example:
# [palav]
# ssh = "palav"
# dosh_host = "palav.dev"
# [server]
# ssh = "server"
# dosh_host = "server.example.com"
# port = 50000
# default_command = "tm"
# predict = false
# predict = true
[default]
ssh = "$default_server"
dosh_host = "$host_udp"
port = $port
predict = false
predict = true
EOF
fi
fi
@@ -317,10 +579,17 @@ Configured UDP port $port
EOF
if [ "$role" = "client" ] || [ "$role" = "both" ]; then
next_server="${server:-user@host}"
cat <<EOF
Client command:
$bindir/dosh ${server:-user@host}
Client commands:
$bindir/dosh $next_server
$bindir/dosh setup <ssh-alias>
$bindir/dosh update --check
Client config:
$config_dir/client.toml
$config_dir/hosts.toml
EOF
fi
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env sh
set -eu
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$repo_root"
exec sh ./install.sh --from-current "$@"
+2 -10
View File
@@ -6,17 +6,9 @@ Wants=network-online.target
[Service]
Type=simple
ExecStart=%h/.local/bin/dosh-server serve
Restart=on-failure
Restart=always
RestartSec=1
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=%h/.config/dosh %h/.local/share/dosh
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
KillMode=process
[Install]
WantedBy=default.target
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env sh
# Safe, self-contained local Dosh benchmark harness.
#
# Spins up a THROWAWAY dosh-server bound to 127.0.0.1 on a random free port in a
# temp HOME, runs the full path matrix (native cold auth, cached attach-ticket,
# UDP resume, and local-auth), prints raw samples + summary stats, then tears
# everything down.
#
# It NEVER touches the production server: it never uses UDP port 50000, never
# restarts any systemd unit, and never reads or writes the real ~/.config/dosh
# or ~/.local. Everything lives under a mktemp HOME that is removed on exit.
#
# Usage:
# scripts/bench-local.sh [ITERATIONS]
# Environment:
# DOSH_BENCH_ITERS iteration count (default 20; overridden by $1)
# DOSH_BENCH_JSON=1 emit machine-readable JSON instead of the table
# DOSH_BENCH_PROFILE release|debug build profile (default release)
set -eu
iters="${1:-${DOSH_BENCH_ITERS:-20}}"
profile="${DOSH_BENCH_PROFILE:-release}"
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$repo_root"
# Make sure cargo is reachable in non-login shells.
if ! command -v cargo >/dev/null 2>&1; then
# shellcheck disable=SC1090
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
fi
if [ "$profile" = "release" ]; then
cargo build --release >&2
bindir="$repo_root/target/release"
else
cargo build >&2
bindir="$repo_root/target/debug"
fi
server_bin="$bindir/dosh-server"
client_bin="$bindir/dosh-client"
bench_bin="$bindir/dosh-bench"
# Pick a free UDP port that is NOT the production port (50000).
free_udp_port() {
python3 - <<'PY'
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()
PY
}
dosh_port="$(free_udp_port)"
if [ "$dosh_port" = "50000" ]; then
dosh_port="$(free_udp_port)"
fi
if [ "$dosh_port" = "50000" ]; then
echo "refusing to bind production port 50000" >&2
exit 1
fi
home="$(mktemp -d)"
server_pid=""
cleanup() {
if [ -n "$server_pid" ]; then
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
fi
pkill -f "$server_bin hold --runtime-dir $home/sessions/run" 2>/dev/null || true
rm -rf "$home"
}
trap cleanup EXIT INT TERM
mkdir -p "$home/.config/dosh" "$home/.ssh" "$home/.local/share/dosh"
# Client identity used by the native cold-auth path.
ssh-keygen -t ed25519 -N "" -q -f "$home/.ssh/id_ed25519"
client_pub="$(cat "$home/.ssh/id_ed25519.pub")"
printf '%s\n' "$client_pub" > "$home/authorized_keys"
# Throwaway server config: localhost only, throwaway port, attach tickets on.
cat > "$home/.config/dosh/server.toml" <<EOF
port = $dosh_port
bind = "127.0.0.1"
scrollback = 5000
auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600
allow_attach_tickets = true
persist_sessions = false
client_timeout_secs = 60
retransmit_window = 256
default_input_mode = "read-write"
prewarm_sessions = ["default"]
create_on_attach = true
shell = "/bin/sh"
sessions_dir = "$home/sessions"
secret_path = "$home/secret"
host_key = "$home/host_key"
authorized_keys = ["$home/authorized_keys"]
EOF
# Client config: native auth, trust-on-first-use (no SSH needed), localhost UDP.
write_client_config() {
# $1 = cache_attach_tickets (true|false)
cat > "$home/.config/dosh/client.toml" <<EOF
server = "local"
dosh_host = "127.0.0.1"
dosh_port = $dosh_port
default_session = "default"
reconnect_timeout_secs = 5
view_only = false
predict = false
cache_attach_tickets = $1
credential_cache = "$home/.local/share/dosh/credentials"
known_hosts = "$home/.config/dosh/known_hosts"
auth_preference = "native"
trust_on_first_use = true
EOF
}
start_server() {
HOME="$home" "$server_bin" serve --config "$home/.config/dosh/server.toml" \
>"$home/server.log" 2>&1 &
server_pid="$!"
# Wait for the UDP socket to come up.
i=0
while [ "$i" -lt 50 ]; do
if grep -q "listening on" "$home/server.log" 2>/dev/null; then
return 0
fi
if ! kill -0 "$server_pid" 2>/dev/null; then
echo "dosh-server exited early:" >&2
cat "$home/server.log" >&2 || true
exit 1
fi
i=$((i + 1))
sleep 0.1
done
}
run_bench() {
# $@ extra args forwarded to dosh-bench
json_flag=""
[ "${DOSH_BENCH_JSON:-0}" = "1" ] && json_flag="--json"
label="$(uname -s) $(uname -m), profile=$profile"
HOME="$home" "$bench_bin" \
--client "$client_bin" \
--server local \
--dosh-host 127.0.0.1 \
--dosh-port "$dosh_port" \
--session default \
--skip-ssh-baseline \
--iterations "$iters" \
--label "$label" \
$json_flag \
"$@"
}
echo "dosh local benchmark: port=$dosh_port iters=$iters profile=$profile home=$home" >&2
# 1) Native cold auth + cached attach-ticket in one run (ticket cache on).
write_client_config true
start_server
echo "== native cold auth + cached attach-ticket ==" >&2
run_bench --cold-native --cached-ticket
# Sanity: native cold auth must have trusted the host (proves the generated
# client config loaded and the native handshake ran instead of silently
# falling back to a different path).
if [ ! -s "$home/.config/dosh/known_hosts" ]; then
echo "native cold auth did not record a trusted host; check server.log:" >&2
cat "$home/server.log" >&2 || true
exit 1
fi
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
server_pid=""
# 2) Self-contained local-auth path (no SSH, no native handshake).
#
# Note on UDP resume: resume is the roaming path for a client that is STILL
# attached when its network endpoint changes. The `--attach-only` benchmark
# model detaches after each iteration, which tears the client down on the
# server, so a fresh-process resume has nothing live to resume and is not
# meaningful here. The cold fresh-process reconnect fast path is the attach
# ticket (measured above). Roaming resume is validated by the integration test
# `resume_updates_udp_endpoint_for_roaming`. `dosh-bench --resume` exists for
# scenarios that keep a live session out-of-band (e.g. remote soak tests).
rm -rf "$home/.local/share/dosh/credentials"
write_client_config true
start_server
echo "== local-auth (no SSH) ==" >&2
run_bench --local-auth --no-cache
+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
ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts"
mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-cached" "$workdir/home-mosh"
mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-native" "$workdir/home-cached" "$workdir/home-mosh"
if ! HOME="$workdir/home" target/release/dosh-bench \
--server bench@127.0.0.1 \
@@ -64,6 +64,32 @@ if ! HOME="$workdir/home-controlmaster" target/release/dosh-bench \
exit 1
fi
if ! HOME="$workdir/home-native" target/release/dosh-client \
--ssh-port "$ssh_port" \
--ssh-key "$workdir/id_ed25519" \
--ssh-known-hosts "$workdir/known_hosts" \
--ssh-auth-command /usr/local/bin/dosh-auth \
trust bench@127.0.0.1 >/dev/null; then
docker logs "$container_id" || true
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
exit 1
fi
if ! HOME="$workdir/home-native" target/release/dosh-bench \
--server bench@127.0.0.1 \
--ssh-port "$ssh_port" \
--dosh-port "$dosh_port" \
--dosh-host 127.0.0.1 \
--ssh-key "$workdir/id_ed25519" \
--ssh-known-hosts "$workdir/known_hosts" \
--iterations 5 \
--cold-native \
--assert-ssh-plus-ms "${DOSH_BENCH_NATIVE_SSH_PLUS_MS:-0}"; then
docker logs "$container_id" || true
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
exit 1
fi
if ! HOME="$workdir/home-cached" target/release/dosh-bench \
--server bench@127.0.0.1 \
--ssh-port "$ssh_port" \
+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"
+15 -4
View File
@@ -7,6 +7,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -68,6 +69,12 @@ pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> {
.decode(String::from_utf8_lossy(&raw).trim())
.context("decode server secret")?
};
anyhow::ensure!(
decoded.len() == 32,
"server secret at {} must be 32 bytes, got {}",
path.display(),
decoded.len()
);
let mut out = [0u8; 32];
out.copy_from_slice(&decoded[..32]);
return Ok(out);
@@ -76,10 +83,11 @@ pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let secret = crypto::random_32();
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
let mut options = fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options
.open(&path)
.with_context(|| format!("create {}", path.display()))?;
file.write_all(URL_SAFE_NO_PAD.encode(secret).as_bytes())?;
@@ -87,6 +95,7 @@ pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> {
Ok(secret)
}
#[allow(clippy::too_many_arguments)]
pub fn build_bootstrap(
config: &ServerConfig,
secret: &[u8; 32],
@@ -153,6 +162,7 @@ pub fn build_bootstrap(
})
}
#[allow(clippy::too_many_arguments)]
pub fn attach_token(
secret: &[u8; 32],
user: &str,
@@ -198,6 +208,7 @@ pub fn verify_bootstrap(resp: &BootstrapResponse, secret: &[u8; 32]) -> Result<b
Ok(expected == resp.attach_token)
}
#[allow(clippy::too_many_arguments)]
pub fn build_attach_ticket(
secret: &[u8; 32],
server_id: [u8; 32],
+5
View File
@@ -5,6 +5,11 @@ use dosh::config::load_server_config;
use dosh::native::{host_public_key, host_public_key_line, load_or_create_host_key};
#[derive(Debug, Parser)]
#[command(
name = "dosh-auth",
version = dosh::build_info::VERSION,
long_version = dosh::build_info::LONG_VERSION
)]
struct Args {
#[arg(long, default_value_t = 1)]
protocol: u8,
+500 -83
View File
@@ -2,6 +2,8 @@ use anyhow::{Context, Result, anyhow};
use clap::Parser;
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::ffi::OsStr;
use std::fmt::Write as _;
use std::fs;
use std::io::Read;
use std::path::PathBuf;
use std::process::Command;
@@ -9,7 +11,11 @@ use std::sync::mpsc;
use std::time::{Duration, Instant};
#[derive(Debug, Parser)]
#[command(name = "dosh-bench")]
#[command(
name = "dosh-bench",
version = dosh::build_info::VERSION,
long_version = dosh::build_info::LONG_VERSION
)]
struct Args {
#[arg(long, default_value = "local")]
server: String,
@@ -25,6 +31,15 @@ struct Args {
iterations: usize,
#[arg(long)]
local_auth: bool,
/// Benchmark native cold auth (no cache, native handshake) terminal-ready time.
#[arg(long)]
cold_native: bool,
/// Benchmark cached attach-ticket terminal-ready time (warms the cache first).
#[arg(long)]
cached_ticket: bool,
/// Benchmark UDP resume terminal-ready time (warms the cache first).
#[arg(long)]
resume: bool,
#[arg(long)]
client: Option<PathBuf>,
#[arg(long, default_value = "~/.local/bin/dosh-auth")]
@@ -51,6 +66,18 @@ struct Args {
mosh_server_command: String,
#[arg(long)]
mosh_port: Option<String>,
/// Emit machine-readable JSON (one object per metric, with raw samples).
#[arg(long)]
json: bool,
/// Emit a publishable Markdown benchmark report.
#[arg(long)]
markdown: bool,
/// Write the report to a file instead of stdout.
#[arg(long)]
output: Option<PathBuf>,
/// Optional label printed in summary/JSON output (e.g. machine/OS identifier).
#[arg(long)]
label: Option<String>,
#[arg(long)]
assert_ssh_plus_ms: Option<f64>,
#[arg(long)]
@@ -59,12 +86,45 @@ struct Args {
assert_dosh_max_ms: Option<f64>,
}
/// One Dosh attach path to benchmark.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DoshPath {
/// `--auth native --no-cache`: full native handshake, no cache.
ColdNative,
/// Cached attach-ticket fast path (requires a warmed cache).
CachedTicket,
/// UDP resume fast path (requires a warmed cache + `cache_attach_tickets = false`).
Resume,
/// `--local-auth`: self-contained local bootstrap, no SSH.
LocalAuth,
/// SSH-bootstrap cold attach (cache controlled by `--no-cache` / `--warm-cache`).
SshBootstrap,
}
impl DoshPath {
fn metric(self) -> &'static str {
match self {
DoshPath::ColdNative => "dosh_cold_native_ms",
DoshPath::CachedTicket => "dosh_cached_attach_ms",
DoshPath::Resume => "dosh_resume_ms",
DoshPath::LocalAuth => "dosh_local_attach_ms",
DoshPath::SshBootstrap => "dosh_attach_ms",
}
}
/// Whether this path consumes a warmed credential cache.
fn needs_warm(self) -> bool {
matches!(self, DoshPath::CachedTicket | DoshPath::Resume)
}
}
fn main() -> Result<()> {
let args = Args::parse();
let client = args.client.clone().unwrap_or_else(default_client_path);
let mut ssh_times = Vec::new();
let mut dosh_times = Vec::new();
let mut mosh_times = Vec::new();
if args.no_cache && args.warm_cache {
return Err(anyhow!("--warm-cache cannot be used with --no-cache"));
}
let generated_control_path = if args.controlmaster {
Some(std::env::temp_dir().join(format!("dosh-bench-control-{}", std::process::id())))
} else {
@@ -78,96 +138,115 @@ fn main() -> Result<()> {
} else {
None
};
if args.no_cache && args.warm_cache {
return Err(anyhow!("--warm-cache cannot be used with --no-cache"));
}
let dosh_label = if args.warm_cache {
let _ = time_dosh_attach(&client, &args, control_path)?;
"dosh_cached_attach_ms"
// Resolve which Dosh paths to benchmark. Explicit path flags select an
// explicit matrix; otherwise fall back to the legacy single-path behavior
// so existing callers (CI docker scripts) keep working unchanged.
let explicit_paths = explicit_dosh_paths(&args);
let dosh_paths = if explicit_paths.is_empty() {
vec![legacy_dosh_path(&args)]
} else {
"dosh_attach_ms"
explicit_paths
};
let mut results: Vec<MetricSamples> = Vec::new();
// SSH baseline (shared across the matrix; the comparison is per-iteration
// ssh-true vs the dosh path startup that replaces it).
let run_ssh = !args.local_auth && !args.skip_ssh_baseline && !dosh_only(&dosh_paths);
if run_ssh {
let mut ssh_times = Vec::new();
for _ in 0..args.iterations.max(1) {
if !args.local_auth && !args.skip_ssh_baseline {
let mut ssh = Command::new("ssh");
add_ssh_options(&mut ssh, &args, control_path);
ssh.arg(&args.server).arg("true");
ssh_times.push(time_command(&mut ssh)?);
}
results.push(MetricSamples::new("ssh_true_ms", ssh_times));
}
dosh_times.push(time_dosh_attach(&client, &args, control_path)?);
for path in &dosh_paths {
if path.needs_warm() {
// Prime the cache with one attach so the fast path has credentials.
let _ = time_dosh_attach(&client, &args, *path, control_path, true)?;
}
let mut samples = Vec::new();
for _ in 0..args.iterations.max(1) {
samples.push(time_dosh_attach(
&client,
&args,
*path,
control_path,
false,
)?);
}
results.push(MetricSamples::new(path.metric(), samples));
}
if args.include_mosh {
let mut mosh_times = Vec::new();
for _ in 0..args.iterations.max(1) {
mosh_times.push(time_mosh_in_pty(&args)?);
}
results.push(MetricSamples::new("mosh_start_true_ms", mosh_times));
}
let report = if args.json {
render_json(&args, &results)
} else if args.markdown {
render_markdown(&args, &results)
} else {
render_table(&args, &results)
};
write_report(&args, &report)?;
run_assertions(&args, &results)?;
Ok(())
}
/// Paths explicitly requested via flags.
fn explicit_dosh_paths(args: &Args) -> Vec<DoshPath> {
let mut paths = Vec::new();
if args.cold_native {
paths.push(DoshPath::ColdNative);
}
if args.cached_ticket {
paths.push(DoshPath::CachedTicket);
}
if args.resume {
paths.push(DoshPath::Resume);
}
paths
}
/// The single path implied by legacy flags when no explicit path is requested.
fn legacy_dosh_path(args: &Args) -> DoshPath {
if args.local_auth {
DoshPath::LocalAuth
} else if args.warm_cache {
DoshPath::CachedTicket
} else {
DoshPath::SshBootstrap
}
}
if !ssh_times.is_empty() {
println!(
"ssh_true_ms avg={:.2} samples={:?}",
avg_ms(&ssh_times),
ssh_times
);
}
println!(
"{dosh_label} avg={:.2} samples={:?}",
avg_ms(&dosh_times),
dosh_times
);
if !mosh_times.is_empty() {
println!(
"mosh_start_true_ms avg={:.2} samples={:?}",
avg_ms(&mosh_times),
mosh_times
);
}
if let Some(margin) = args.assert_ssh_plus_ms {
if ssh_times.is_empty() {
return Err(anyhow!(
"--assert-ssh-plus-ms requires non-local SSH benchmark"
));
}
let ssh_avg = avg_ms(&ssh_times);
let dosh_avg = avg_ms(&dosh_times);
if dosh_avg > ssh_avg + margin {
return Err(anyhow!(
"dosh attach avg {dosh_avg:.2}ms exceeded ssh avg {ssh_avg:.2}ms + {margin:.2}ms"
));
}
println!("gate ok: dosh avg {dosh_avg:.2}ms <= ssh avg {ssh_avg:.2}ms + {margin:.2}ms");
}
if let Some(margin) = args.assert_mosh_minus_ms {
if mosh_times.is_empty() {
return Err(anyhow!(
"--assert-mosh-minus-ms requires --include-mosh benchmark"
));
}
let dosh_avg = avg_ms(&dosh_times);
let mosh_avg = avg_ms(&mosh_times);
if dosh_avg + margin > mosh_avg {
return Err(anyhow!(
"dosh attach avg {dosh_avg:.2}ms was not at least {margin:.2}ms faster than mosh avg {mosh_avg:.2}ms"
));
}
println!("gate ok: dosh avg {dosh_avg:.2}ms + {margin:.2}ms <= mosh avg {mosh_avg:.2}ms");
}
if let Some(max_ms) = args.assert_dosh_max_ms {
let dosh_avg = avg_ms(&dosh_times);
if dosh_avg > max_ms {
return Err(anyhow!(
"{dosh_label} avg {dosh_avg:.2}ms exceeded max {max_ms:.2}ms"
));
}
println!("gate ok: {dosh_label} avg {dosh_avg:.2}ms <= {max_ms:.2}ms");
}
Ok(())
/// True when none of the selected paths needs an SSH baseline comparison
/// (i.e. all are local-auth or cache fast paths driven without SSH).
fn dosh_only(paths: &[DoshPath]) -> bool {
paths.iter().all(|p| {
matches!(
p,
DoshPath::LocalAuth | DoshPath::CachedTicket | DoshPath::Resume
)
})
}
fn time_dosh_attach(
client: &PathBuf,
args: &Args,
path: DoshPath,
control_path: Option<&PathBuf>,
warm: bool,
) -> Result<Duration> {
let mut cmd = Command::new(client);
cmd.arg("--attach-only")
@@ -178,16 +257,55 @@ fn time_dosh_attach(
if let Some(host) = &args.dosh_host {
cmd.arg("--dosh-host").arg(host);
}
match path {
DoshPath::LocalAuth => {
cmd.arg("--local-auth");
// While warming, write the cache; measured runs honor --no-cache.
if args.no_cache && !warm {
cmd.arg("--no-cache");
}
cmd.arg(&args.server);
}
DoshPath::CachedTicket | DoshPath::Resume => {
// Fast paths must read the warmed cache, so never pass --no-cache here.
// Whether the cache uses tickets vs resume is decided by the client
// config (`cache_attach_tickets`) the harness wrote into HOME.
if args.local_auth {
cmd.arg("--local-auth").arg(&args.server);
} else {
cmd.arg("--local-auth");
}
add_bootstrap_args(&mut cmd, args, control_path);
cmd.arg(&args.server);
}
DoshPath::ColdNative => {
cmd.arg("--auth").arg("native");
// Cold path: never use a warmed cache.
cmd.arg("--no-cache");
add_bootstrap_args(&mut cmd, args, control_path);
cmd.arg(&args.server);
}
DoshPath::SshBootstrap => {
add_bootstrap_args(&mut cmd, args, control_path);
// Cold SSH bootstrap honors --no-cache on measured runs.
if args.no_cache && !warm {
cmd.arg("--no-cache");
}
cmd.arg(&args.server);
}
}
time_command(&mut cmd)
}
/// Append the SSH bootstrap arguments shared by the non-local paths. Native
/// auth reuses the same SSH key/known-hosts/port plumbing.
fn add_bootstrap_args(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) {
if args.local_auth {
return;
}
cmd.arg("--ssh-port")
.arg(args.ssh_port.to_string())
.arg("--ssh-auth-command")
.arg(&args.ssh_auth_command);
if args.no_cache {
cmd.arg("--no-cache");
}
if let Some(key) = &args.ssh_key {
cmd.arg("--ssh-key").arg(key);
}
@@ -197,9 +315,6 @@ fn time_dosh_attach(
if let Some(control_path) = control_path {
cmd.arg("--ssh-control-path").arg(control_path);
}
cmd.arg(&args.server);
}
time_command(&mut cmd)
}
fn add_ssh_options(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) {
@@ -381,9 +496,245 @@ fn time_command(cmd: &mut Command) -> Result<Duration> {
Ok(start.elapsed())
}
fn avg_ms(samples: &[Duration]) -> f64 {
let total: f64 = samples.iter().map(|d| d.as_secs_f64() * 1000.0).sum();
total / samples.len() as f64
/// Per-metric samples with summary statistics.
struct MetricSamples {
name: &'static str,
samples: Vec<Duration>,
}
impl MetricSamples {
fn new(name: &'static str, samples: Vec<Duration>) -> Self {
Self { name, samples }
}
fn ms(&self) -> Vec<f64> {
self.samples
.iter()
.map(|d| d.as_secs_f64() * 1000.0)
.collect()
}
fn stats(&self) -> Stats {
Stats::from_ms(&self.ms())
}
}
/// Summary statistics for a set of millisecond samples.
struct Stats {
count: usize,
min: f64,
median: f64,
p95: f64,
mean: f64,
max: f64,
}
impl Stats {
fn from_ms(values: &[f64]) -> Self {
if values.is_empty() {
return Self {
count: 0,
min: 0.0,
median: 0.0,
p95: 0.0,
mean: 0.0,
max: 0.0,
};
}
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let count = sorted.len();
let mean = sorted.iter().sum::<f64>() / count as f64;
Self {
count,
min: sorted[0],
median: percentile(&sorted, 50.0),
p95: percentile(&sorted, 95.0),
mean,
max: sorted[count - 1],
}
}
}
/// Linear-interpolated percentile over a pre-sorted slice.
fn percentile(sorted: &[f64], pct: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
if sorted.len() == 1 {
return sorted[0];
}
let rank = (pct / 100.0) * (sorted.len() - 1) as f64;
let lo = rank.floor() as usize;
let hi = rank.ceil() as usize;
if lo == hi {
sorted[lo]
} else {
let frac = rank - lo as f64;
sorted[lo] + (sorted[hi] - sorted[lo]) * frac
}
}
fn render_table(args: &Args, results: &[MetricSamples]) -> String {
let mut out = String::new();
if let Some(label) = &args.label {
let _ = writeln!(out, "# label: {label}");
}
let _ = writeln!(
out,
"{:<24} {:>6} {:>9} {:>9} {:>9} {:>9} {:>9}",
"metric", "n", "min", "median", "p95", "mean", "max"
);
for metric in results {
let s = metric.stats();
let _ = writeln!(
out,
"{:<24} {:>6} {:>9.2} {:>9.2} {:>9.2} {:>9.2} {:>9.2}",
metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max
);
}
// Raw per-iteration samples, so published numbers can include raw data.
for metric in results {
let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect();
let _ = writeln!(out, "{} samples_ms=[{}]", metric.name, ms.join(", "));
}
out
}
fn render_json(args: &Args, results: &[MetricSamples]) -> String {
let mut entries = Vec::new();
for metric in results {
let s = metric.stats();
let samples: Vec<String> = metric.ms().iter().map(|v| format!("{v:.4}")).collect();
entries.push(format!(
"{{\"metric\":\"{}\",\"count\":{},\"min\":{:.4},\"median\":{:.4},\"p95\":{:.4},\"mean\":{:.4},\"max\":{:.4},\"samples_ms\":[{}]}}",
metric.name,
s.count,
s.min,
s.median,
s.p95,
s.mean,
s.max,
samples.join(",")
));
}
let label = match &args.label {
Some(label) => format!("\"{}\"", label.replace('"', "\\\"")),
None => "null".to_string(),
};
format!(
"{{\"label\":{label},\"iterations\":{},\"metrics\":[{}]}}",
args.iterations.max(1),
entries.join(",")
)
}
fn render_markdown(args: &Args, results: &[MetricSamples]) -> String {
let mut out = String::new();
let label = args.label.as_deref().unwrap_or("Dosh benchmark");
let _ = writeln!(out, "# {label}\n");
let _ = writeln!(out, "- server: `{}`", args.server);
let _ = writeln!(out, "- iterations: `{}`", args.iterations.max(1));
let _ = writeln!(
out,
"- generated_by: `dosh-bench {}`\n",
env!("CARGO_PKG_VERSION")
);
let _ = writeln!(
out,
"| metric | n | min ms | median ms | p95 ms | mean ms | max ms |"
);
let _ = writeln!(out, "|---|---:|---:|---:|---:|---:|---:|");
for metric in results {
let s = metric.stats();
let _ = writeln!(
out,
"| `{}` | {} | {:.2} | {:.2} | {:.2} | {:.2} | {:.2} |",
metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max
);
}
let _ = writeln!(out, "\n## Raw Samples\n");
for metric in results {
let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect();
let _ = writeln!(out, "- `{}`: [{}]", metric.name, ms.join(", "));
}
out
}
fn write_report(args: &Args, report: &str) -> Result<()> {
if let Some(path) = &args.output {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
fs::write(path, report).with_context(|| format!("write {}", path.display()))?;
println!("wrote {}", path.display());
} else {
println!("{report}");
}
Ok(())
}
fn metric_mean(results: &[MetricSamples], name: &str) -> Option<f64> {
results
.iter()
.find(|m| m.name == name)
.map(|m| m.stats().mean)
}
/// The headline dosh metric for assertions: prefer cached, then resume, then
/// cold native, then ssh-bootstrap, then local-auth.
fn primary_dosh(results: &[MetricSamples]) -> Option<(&'static str, f64)> {
const ORDER: [&str; 5] = [
"dosh_cached_attach_ms",
"dosh_resume_ms",
"dosh_cold_native_ms",
"dosh_attach_ms",
"dosh_local_attach_ms",
];
for name in ORDER {
if let Some(mean) = metric_mean(results, name) {
return Some((name, mean));
}
}
None
}
fn run_assertions(args: &Args, results: &[MetricSamples]) -> Result<()> {
if let Some(margin) = args.assert_ssh_plus_ms {
let ssh = metric_mean(results, "ssh_true_ms")
.ok_or_else(|| anyhow!("--assert-ssh-plus-ms requires non-local SSH benchmark"))?;
let (name, dosh) =
primary_dosh(results).ok_or_else(|| anyhow!("no dosh metric to assert against"))?;
if dosh > ssh + margin {
return Err(anyhow!(
"{name} avg {dosh:.2}ms exceeded ssh avg {ssh:.2}ms + {margin:.2}ms"
));
}
println!("gate ok: {name} avg {dosh:.2}ms <= ssh avg {ssh:.2}ms + {margin:.2}ms");
}
if let Some(margin) = args.assert_mosh_minus_ms {
let mosh = metric_mean(results, "mosh_start_true_ms")
.ok_or_else(|| anyhow!("--assert-mosh-minus-ms requires --include-mosh benchmark"))?;
let (name, dosh) =
primary_dosh(results).ok_or_else(|| anyhow!("no dosh metric to assert against"))?;
if dosh + margin > mosh {
return Err(anyhow!(
"{name} avg {dosh:.2}ms was not at least {margin:.2}ms faster than mosh avg {mosh:.2}ms"
));
}
println!("gate ok: {name} avg {dosh:.2}ms + {margin:.2}ms <= mosh avg {mosh:.2}ms");
}
if let Some(max_ms) = args.assert_dosh_max_ms {
let (name, dosh) =
primary_dosh(results).ok_or_else(|| anyhow!("no dosh metric to assert against"))?;
if dosh > max_ms {
return Err(anyhow!("{name} avg {dosh:.2}ms exceeded max {max_ms:.2}ms"));
}
println!("gate ok: {name} avg {dosh:.2}ms <= {max_ms:.2}ms");
}
Ok(())
}
fn default_client_path() -> PathBuf {
@@ -392,3 +743,69 @@ fn default_client_path() -> PathBuf {
.and_then(|path| path.parent().map(|parent| parent.join("dosh-client")))
.unwrap_or_else(|| PathBuf::from("dosh-client"))
}
#[cfg(test)]
mod tests {
use super::*;
fn args_for_render() -> Args {
Args {
server: "local".to_string(),
session: "default".to_string(),
ssh_port: 22,
dosh_port: 50000,
dosh_host: None,
iterations: 2,
local_auth: true,
cold_native: false,
cached_ticket: false,
resume: false,
client: None,
ssh_auth_command: "~/.local/bin/dosh-auth".to_string(),
ssh_key: None,
ssh_known_hosts: None,
ssh_control_path: None,
controlmaster: false,
no_cache: false,
warm_cache: false,
skip_ssh_baseline: true,
include_mosh: false,
mosh: PathBuf::from("mosh"),
mosh_server_command: "mosh-server".to_string(),
mosh_port: None,
json: false,
markdown: true,
output: None,
label: Some("test host".to_string()),
assert_ssh_plus_ms: None,
assert_mosh_minus_ms: None,
assert_dosh_max_ms: None,
}
}
#[test]
fn markdown_report_contains_summary_and_samples() {
let args = args_for_render();
let metrics = vec![MetricSamples::new(
"dosh_cached_attach_ms",
vec![Duration::from_millis(3), Duration::from_millis(5)],
)];
let report = render_markdown(&args, &metrics);
assert!(report.contains("# test host"));
assert!(report.contains("| `dosh_cached_attach_ms` | 2 | 3.00"));
assert!(report.contains("- `dosh_cached_attach_ms`: [3.00, 5.00]"));
}
#[test]
fn json_report_contains_metrics() {
let args = args_for_render();
let metrics = vec![MetricSamples::new(
"dosh_local_attach_ms",
vec![Duration::from_millis(7)],
)];
let report = render_json(&args, &metrics);
assert!(report.contains("\"label\":\"test host\""));
assert!(report.contains("\"metric\":\"dosh_local_attach_ms\""));
assert!(report.contains("\"samples_ms\":[7.0000]"));
}
}
+7766 -295
View File
File diff suppressed because it is too large Load Diff
+3312 -298
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"))
);
}
}
+97 -6
View File
@@ -14,6 +14,8 @@ pub struct ServerConfig {
pub allow_attach_tickets: bool,
pub client_timeout_secs: u64,
pub retransmit_window: usize,
#[serde(default = "default_output_frame_interval_ms")]
pub output_frame_interval_ms: u64,
pub default_input_mode: String,
pub prewarm_sessions: Vec<String>,
pub create_on_attach: bool,
@@ -28,6 +30,14 @@ pub struct ServerConfig {
pub authorized_keys: Vec<String>,
#[serde(default = "default_native_auth_rate_limit_per_minute")]
pub native_auth_rate_limit_per_minute: u32,
/// Rotate a client's transport traffic key after this many packets in the
/// current epoch (spec §11). `0` disables the packet-count trigger.
#[serde(default = "default_rekey_after_packets")]
pub rekey_after_packets: u64,
/// Rotate a client's transport traffic key after this many wall-clock seconds
/// in the current epoch (spec §11). `0` disables the time trigger.
#[serde(default = "default_rekey_after_secs")]
pub rekey_after_secs: u64,
#[serde(default = "default_true")]
pub allow_tcp_forwarding: bool,
#[serde(default)]
@@ -36,8 +46,16 @@ pub struct ServerConfig {
pub allow_remote_non_loopback_bind: bool,
#[serde(default)]
pub allow_agent_forwarding: bool,
#[serde(default = "default_true")]
pub allow_file_transfer: bool,
#[serde(default = "default_true")]
pub allow_exec_command: bool,
#[serde(default = "default_accept_env")]
pub accept_env: Vec<String>,
/// Run terminal sessions under per-session holder processes so shells
/// survive a quick `dosh-server` restart and clients can reconnect.
#[serde(default = "default_true")]
pub persist_sessions: bool,
}
impl Default for ServerConfig {
@@ -49,8 +67,9 @@ impl Default for ServerConfig {
auth_ttl_secs: 30,
attach_ticket_ttl_secs: 3600,
allow_attach_tickets: true,
client_timeout_secs: 86400,
client_timeout_secs: 2_592_000,
retransmit_window: 256,
output_frame_interval_ms: default_output_frame_interval_ms(),
default_input_mode: "read-write".to_string(),
prewarm_sessions: vec!["default".to_string()],
create_on_attach: true,
@@ -61,11 +80,16 @@ impl Default for ServerConfig {
host_key: default_host_key(),
authorized_keys: default_authorized_keys(),
native_auth_rate_limit_per_minute: default_native_auth_rate_limit_per_minute(),
rekey_after_packets: default_rekey_after_packets(),
rekey_after_secs: default_rekey_after_secs(),
allow_tcp_forwarding: true,
allow_remote_forwarding: false,
allow_remote_non_loopback_bind: false,
allow_agent_forwarding: false,
allow_file_transfer: true,
allow_exec_command: true,
accept_env: default_accept_env(),
persist_sessions: true,
}
}
}
@@ -82,8 +106,12 @@ pub struct ClientConfig {
pub default_session: String,
pub reconnect_timeout_secs: u64,
pub view_only: bool,
#[serde(default)]
#[serde(default = "default_true")]
pub predict: bool,
/// Prediction display policy: "off", "experimental" (adaptive, the default),
/// or "always". Controls when speculative local echo is shown.
#[serde(default = "default_predict_mode")]
pub predict_mode: String,
pub cache_attach_tickets: bool,
pub credential_cache: String,
#[serde(default = "default_auth_preference")]
@@ -100,15 +128,34 @@ pub struct ClientConfig {
pub use_ssh_agent: bool,
#[serde(default)]
pub forward_agent: bool,
/// Show a non-destructive, mosh-style status line on the bottom screen row
/// when UDP packets stop arriving ("[dosh] last contact Ns ago …"). Drawn
/// with save/restore cursor so it never moves the app cursor or corrupts a
/// full-screen TUI, and cleared the instant packets resume. Default on.
#[serde(default = "default_true")]
pub disconnect_status: bool,
#[serde(default = "default_escape_key")]
pub escape_key: String,
#[serde(default = "default_send_env")]
pub send_env: Vec<String>,
#[serde(default)]
pub set_env: HashMap<String, String>,
/// Optional client-side command extensions. These are just shell command
/// templates expanded into the remote session's startup input; Dosh does not
/// depend on the tools they name.
#[serde(default)]
pub extensions: HashMap<String, CommandExtension>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HostConfig {
pub ssh: Option<String>,
/// Optional OpenSSH config host name to use for SSH-backed bootstrap,
/// trust/setup/doctor checks, and native-auth identity selection. This lets
/// a Dosh host keep a separate UDP endpoint while still inheriting
/// alias-scoped OpenSSH settings like IdentityFile, ProxyJump, and SendEnv.
#[serde(default)]
pub ssh_config: Option<String>,
pub user: Option<String>,
pub dosh_host: Option<String>,
pub port: Option<u16>,
@@ -119,6 +166,22 @@ pub struct HostConfig {
pub send_env: Option<Vec<String>>,
#[serde(default)]
pub set_env: HashMap<String, String>,
/// Per-host extension overrides. A host can replace a global extension or set
/// `disabled = true` to opt out of it.
#[serde(default)]
pub extensions: HashMap<String, CommandExtension>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandExtension {
/// Remote shell command template. `{args}` expands to shell-quoted extra
/// words after the extension name. When absent, extra args are appended.
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub disabled: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -140,7 +203,8 @@ impl Default for ClientConfig {
default_session: "new".to_string(),
reconnect_timeout_secs: 5,
view_only: false,
predict: false,
predict: true,
predict_mode: default_predict_mode(),
cache_attach_tickets: true,
credential_cache: "~/.local/share/dosh/credentials".to_string(),
auth_preference: default_auth_preference(),
@@ -150,8 +214,11 @@ impl Default for ClientConfig {
identity_files: default_identity_files(),
use_ssh_agent: true,
forward_agent: false,
disconnect_status: true,
escape_key: default_escape_key(),
send_env: default_send_env(),
set_env: HashMap::new(),
extensions: HashMap::new(),
}
}
}
@@ -175,6 +242,22 @@ fn default_native_auth_rate_limit_per_minute() -> u32 {
30
}
fn default_output_frame_interval_ms() -> u64 {
16
}
fn default_rekey_after_packets() -> u64 {
// Rotate well before any AEAD nonce-reuse concern; ChaCha20-Poly1305 with a
// per-direction monotonic seq is safe far beyond this, but a bounded epoch
// keeps forward-secrecy windows small.
100_000
}
fn default_rekey_after_secs() -> u64 {
// One hour per epoch by default.
3600
}
fn default_auth_preference() -> String {
"native,ssh".to_string()
}
@@ -183,10 +266,18 @@ fn default_native_auth_timeout_ms() -> u64 {
700
}
fn default_predict_mode() -> String {
"experimental".to_string()
}
fn default_known_hosts() -> String {
"~/.config/dosh/known_hosts".to_string()
}
fn default_escape_key() -> String {
"^]".to_string()
}
fn default_identity_files() -> Vec<String> {
vec!["~/.ssh/id_ed25519".to_string()]
}
@@ -232,10 +323,10 @@ pub fn load_hosts_config(path: Option<PathBuf>) -> Result<HostsConfig> {
}
pub fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
if let Some(rest) = path.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest);
}
}
PathBuf::from(path)
}
+71
View File
@@ -0,0 +1,71 @@
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
pub const EXEC_STREAM_SENTINEL: &str = "@dosh-exec";
pub const FRAME_MAX_LEN: usize = 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecRequest {
pub command: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ExecResponse {
Stdout(Vec<u8>),
Stderr(Vec<u8>),
Exit { code: Option<i32> },
Error { message: String },
}
#[derive(Default)]
pub struct FrameDecoder {
buf: Vec<u8>,
}
impl FrameDecoder {
pub fn push(&mut self, bytes: &[u8]) -> Result<Vec<Vec<u8>>> {
self.buf.extend_from_slice(bytes);
let mut frames = Vec::new();
loop {
if self.buf.len() < 4 {
break;
}
let len = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize;
if len > FRAME_MAX_LEN {
bail!("exec protocol frame too large: {len} bytes");
}
if self.buf.len() < 4 + len {
break;
}
frames.push(self.buf[4..4 + len].to_vec());
self.buf.drain(..4 + len);
}
Ok(frames)
}
}
pub fn encode_request(request: &ExecRequest) -> Result<Vec<u8>> {
encode_frame(&bincode::serialize(request).context("encode exec request")?)
}
pub fn encode_response(response: &ExecResponse) -> Result<Vec<u8>> {
encode_frame(&bincode::serialize(response).context("encode exec response")?)
}
pub fn decode_request(frame: &[u8]) -> Result<ExecRequest> {
bincode::deserialize(frame).context("decode exec request")
}
pub fn decode_response(frame: &[u8]) -> Result<ExecResponse> {
bincode::deserialize(frame).context("decode exec response")
}
fn encode_frame(payload: &[u8]) -> Result<Vec<u8>> {
if payload.len() > FRAME_MAX_LEN {
bail!("exec protocol frame too large: {} bytes", payload.len());
}
let mut out = Vec::with_capacity(4 + payload.len());
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
out.extend_from_slice(payload);
Ok(out)
}
+280
View File
@@ -0,0 +1,280 @@
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>,
},
Readlink {
path: String,
},
Symlink {
path: String,
target: String,
overwrite: bool,
},
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,
prefix_sha256: Option<[u8; 32]>,
},
Stat {
meta: FileMeta,
},
List {
entries: Vec<FileEntry>,
},
LinkTarget {
target: String,
},
Start {
meta: FileMeta,
offset: u64,
prefix_sha256: Option<[u8; 32]>,
},
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"))
);
}
}
+20
View File
@@ -1,7 +1,27 @@
//! Dosh is an encrypted UDP transport for remote terminals and embeddable Rust
//! application streams.
//!
//! Use [`client::DoshClient`] and [`server::DoshServer`] when you want Dosh to
//! handle native authentication, host key checks, roaming, keepalives, reliable
//! streams, and service routing. Use [`transport::DoshTransport`] only when an
//! application already owns session establishment and wants direct access to the
//! encrypted stream transport.
pub mod auth;
pub mod build_info;
pub mod client;
pub mod config;
pub mod crypto;
pub mod exec_service;
pub mod file_transfer;
pub mod native;
#[cfg(unix)]
pub mod persist;
pub mod protocol;
#[cfg(unix)]
pub mod pty;
pub mod server;
pub mod ssh_agent;
pub mod trace;
pub mod transport;
pub mod udp;
+442 -48
View File
@@ -5,17 +5,20 @@ use anyhow::{Context, Result, bail};
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rsa::traits::PublicKeyParts;
use serde::{Deserialize, Serialize};
use signature::{SignatureEncoding, Signer as SignatureSigner, Verifier as SignatureVerifier};
use std::fs;
use std::io::Write;
use std::net::IpAddr;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use std::str::FromStr;
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519";
pub const NATIVE_PROTOCOL_VERSION: u8 = 1;
pub const NATIVE_PROTOCOL_VERSION: u8 = 2;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostPublicKey {
@@ -91,6 +94,12 @@ pub enum ForwardingKind {
Local,
Remote,
Dynamic,
/// SSH-agent forwarding: the client opts in and, if the server policy allows
/// (`allow_agent_forwarding`), the server exports a proxy unix socket as
/// `SSH_AUTH_SOCK` in the remote session and tunnels each connection back to
/// the client's local agent over a Dosh stream. Added at the end of the enum
/// so bincode discriminants for the existing variants are unchanged.
Agent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -145,10 +154,11 @@ pub fn load_or_create_host_key(config: &ServerConfig) -> Result<SigningKey> {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let bytes = crypto::random_32();
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
let mut options = fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options
.open(&path)
.with_context(|| format!("create {}", path.display()))?;
file.write_all(URL_SAFE_NO_PAD.encode(bytes).as_bytes())?;
@@ -248,17 +258,55 @@ pub fn sign_server_hello(
Ok(())
}
/// Error raised when a native handshake peer speaks a different protocol version.
///
/// Carries the peer's advertised version so callers can render an actionable,
/// named message ("upgrade dosh") rather than letting a mismatch surface as an
/// opaque decrypt failure or a silent timeout. The `Display` text deliberately
/// embeds [`crate::protocol::VERSION_MISMATCH_REASON`] so the server's reject and
/// the client's local error read the same way.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProtocolVersionMismatch {
pub local: u8,
pub remote: u8,
pub peer: &'static str,
}
impl std::fmt::Display for ProtocolVersionMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} ({} speaks native protocol v{}, this build speaks v{})",
crate::protocol::VERSION_MISMATCH_REASON,
self.peer,
self.remote,
self.local,
)
}
}
impl std::error::Error for ProtocolVersionMismatch {}
/// Verify a peer-advertised native protocol version against this build.
///
/// `peer` names whose version was wrong ("client" or "server") for the error
/// message. Returns a typed [`ProtocolVersionMismatch`] so the caller can both
/// match on it and print an actionable, upgrade-oriented message.
pub fn check_native_protocol_version(version: u8, peer: &'static str) -> Result<()> {
if version != NATIVE_PROTOCOL_VERSION {
return Err(ProtocolVersionMismatch {
local: NATIVE_PROTOCOL_VERSION,
remote: version,
peer,
}
.into());
}
Ok(())
}
pub fn verify_server_hello(client: &NativeClientHello, server: &NativeServerHello) -> Result<()> {
anyhow::ensure!(
client.protocol_version == NATIVE_PROTOCOL_VERSION,
"unsupported native client protocol {}",
client.protocol_version
);
anyhow::ensure!(
server.protocol_version == NATIVE_PROTOCOL_VERSION,
"unsupported native server protocol {}",
server.protocol_version
);
check_native_protocol_version(client.protocol_version, "client")?;
check_native_protocol_version(server.protocol_version, "server")?;
let transcript = server_hello_transcript(client, server)?;
verify_host_signature(&server.host_key, &transcript, &server.host_signature)
}
@@ -295,6 +343,92 @@ pub fn sign_user_auth(
Ok(auth)
}
pub fn sign_user_auth_with_private_key(
private_key: &ssh_key::PrivateKey,
client: &NativeClientHello,
server: &NativeServerHello,
requested_forwardings: Vec<ForwardingRequest>,
) -> Result<NativeUserAuth> {
anyhow::ensure!(!private_key.is_encrypted(), "OpenSSH identity is encrypted");
let public_key = private_key.public_key();
let key_algorithm = public_key.algorithm().as_str().to_string();
anyhow::ensure!(
is_supported_user_key_algorithm(&key_algorithm),
"unsupported native identity key algorithm {key_algorithm}"
);
let public_key_blob = ssh_public_blob_from_public_key(public_key)?;
let public_key_for_auth = if key_algorithm == "ssh-ed25519" {
parse_ssh_ed25519_public_blob(&public_key_blob)?.to_vec()
} else {
public_key_blob
};
let signature_algorithm = signature_algorithm_for_private_key(&key_algorithm)?;
let mut auth = NativeUserAuth {
public_key_algorithm: signature_algorithm.to_string(),
public_key: public_key_for_auth,
signature: Vec::new(),
requested_forwardings,
};
let transcript = user_auth_transcript(client, server, &auth)?;
auth.signature = if key_algorithm == "ssh-rsa" {
sign_rsa_sha512_private_key(private_key, &transcript)?
} else {
let signature = SignatureSigner::try_sign(private_key, &transcript)
.context("sign native user auth with OpenSSH private key")?;
anyhow::ensure!(
signature.algorithm().as_str() == auth.public_key_algorithm,
"private key produced signature algorithm {}, expected {}",
signature.algorithm().as_str(),
auth.public_key_algorithm
);
signature.as_bytes().to_vec()
};
Ok(auth)
}
fn sign_rsa_sha512_private_key(
private_key: &ssh_key::PrivateKey,
transcript: &[u8],
) -> Result<Vec<u8>> {
let rsa_keypair = private_key
.key_data()
.rsa()
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity is not ssh-rsa"))?;
let rsa_private = rsa_private_key_from_ssh_keypair(rsa_keypair)?;
let signing_key = rsa::pkcs1v15::SigningKey::<sha2::Sha512>::new(rsa_private);
let signature: rsa::pkcs1v15::Signature = SignatureSigner::sign(&signing_key, transcript);
Ok(signature.to_vec())
}
fn rsa_private_key_from_ssh_keypair(
keypair: &ssh_key::private::RsaKeypair,
) -> Result<rsa::RsaPrivateKey> {
let private = rsa::RsaPrivateKey::from_components(
rsa::BigUint::try_from(&keypair.public.n).context("convert RSA modulus")?,
rsa::BigUint::try_from(&keypair.public.e).context("convert RSA public exponent")?,
rsa::BigUint::try_from(&keypair.private.d).context("convert RSA private exponent")?,
vec![
rsa::BigUint::try_from(&keypair.private.p).context("convert RSA p prime")?,
rsa::BigUint::try_from(&keypair.private.q).context("convert RSA q prime")?,
],
)
.context("convert OpenSSH RSA private key")?;
anyhow::ensure!(
private.size().saturating_mul(8) >= 2048,
"OpenSSH RSA identity is smaller than 2048 bits"
);
Ok(private)
}
fn signature_algorithm_for_private_key(key_algorithm: &str) -> Result<&'static str> {
match key_algorithm {
"ssh-ed25519" => Ok("ssh-ed25519"),
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
"ssh-rsa" => Ok("rsa-sha2-512"),
_ => bail!("unsupported native identity key algorithm {key_algorithm}"),
}
}
pub fn verify_native_user_auth(
client: &NativeClientHello,
server: &NativeServerHello,
@@ -303,10 +437,54 @@ pub fn verify_native_user_auth(
source_ip: Option<IpAddr>,
) -> Result<AuthorizedKey> {
anyhow::ensure!(
auth.public_key_algorithm == "ssh-ed25519",
is_supported_user_signature_algorithm(&auth.public_key_algorithm),
"unsupported native user key algorithm {}",
auth.public_key_algorithm
);
let authorized_algorithm = authorized_key_algorithm_for_auth(&auth.public_key_algorithm)?;
let authorized = authorized_keys
.iter()
.find(|key| key.algorithm == authorized_algorithm && key.key == auth.public_key)
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
authorized.ensure_native_allowed(auth, source_ip)?;
let transcript = user_auth_transcript(client, server, auth)?;
verify_native_user_signature(auth, &transcript)?;
Ok(authorized.clone())
}
pub fn supported_user_key_algorithms() -> Vec<String> {
vec![
"ssh-ed25519".to_string(),
"ecdsa-sha2-nistp256".to_string(),
"rsa-sha2-512".to_string(),
"rsa-sha2-256".to_string(),
]
}
pub fn is_supported_user_key_algorithm(algorithm: &str) -> bool {
matches!(algorithm, "ssh-ed25519" | "ecdsa-sha2-nistp256" | "ssh-rsa")
}
pub fn is_supported_user_signature_algorithm(algorithm: &str) -> bool {
matches!(
algorithm,
"ssh-ed25519" | "ecdsa-sha2-nistp256" | "rsa-sha2-512" | "rsa-sha2-256"
)
}
fn authorized_key_algorithm_for_auth(signature_algorithm: &str) -> Result<&'static str> {
match signature_algorithm {
"ssh-ed25519" => Ok("ssh-ed25519"),
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
"rsa-sha2-512" | "rsa-sha2-256" => Ok("ssh-rsa"),
_ => bail!("unsupported native user key algorithm {signature_algorithm}"),
}
}
fn verify_native_user_signature(auth: &NativeUserAuth, transcript: &[u8]) -> Result<()> {
match auth.public_key_algorithm.as_str() {
"ssh-ed25519" => {
anyhow::ensure!(
auth.public_key.len() == 32,
"ssh-ed25519 public key must be 32 bytes"
@@ -315,23 +493,69 @@ pub fn verify_native_user_auth(
auth.signature.len() == 64,
"ssh-ed25519 signature must be 64 bytes"
);
let authorized = authorized_keys
.iter()
.find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key)
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
authorized.ensure_native_allowed(auth, source_ip)?;
let mut public_key = [0u8; 32];
public_key.copy_from_slice(&auth.public_key);
let verifying_key = VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
let verifying_key =
VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
let mut signature = [0u8; 64];
signature.copy_from_slice(&auth.signature);
let signature = Signature::from_bytes(&signature);
let transcript = user_auth_transcript(client, server, auth)?;
verifying_key
.verify(&transcript, &signature)
.verify(transcript, &signature)
.context("verify native user signature")?;
Ok(authorized.clone())
}
"ecdsa-sha2-nistp256" => {
let public_key = ssh_public_key_from_blob(&auth.public_key)
.context("parse native user SSH public key")?;
let algorithm =
ssh_key::Algorithm::from_str(&auth.public_key_algorithm).with_context(|| {
format!("parse signature algorithm {}", auth.public_key_algorithm)
})?;
let signature = ssh_key::Signature::new(algorithm, auth.signature.clone())
.context("parse native user SSH signature")?;
SignatureVerifier::verify(public_key.key_data(), transcript, &signature)
.context("verify native user SSH signature")?;
}
"rsa-sha2-512" | "rsa-sha2-256" => {
verify_rsa_sha2_signature(
&auth.public_key,
&auth.public_key_algorithm,
transcript,
&auth.signature,
)?;
}
algorithm => bail!("unsupported native user key algorithm {algorithm}"),
}
Ok(())
}
fn verify_rsa_sha2_signature(
public_key_blob: &[u8],
signature_algorithm: &str,
transcript: &[u8],
signature: &[u8],
) -> Result<()> {
let (e, n) = parse_ssh_rsa_public_blob(public_key_blob)?;
let public = rsa::RsaPublicKey::new(
rsa::BigUint::from_bytes_be(n),
rsa::BigUint::from_bytes_be(e),
)
.context("parse RSA public key")?;
let signature =
rsa::pkcs1v15::Signature::try_from(signature).context("parse RSA PKCS#1v1.5 signature")?;
match signature_algorithm {
"rsa-sha2-256" => {
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha256>::new(public);
SignatureVerifier::verify(&key, transcript, &signature)
.context("verify RSA-SHA256 signature")
}
"rsa-sha2-512" => {
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha512>::new(public);
SignatureVerifier::verify(&key, transcript, &signature)
.context("verify RSA-SHA512 signature")
}
_ => bail!("legacy ssh-rsa/SHA-1 signatures are not accepted"),
}
}
pub fn verify_native_user_auth_from_config(
@@ -369,6 +593,36 @@ pub fn derive_native_session_key(
crypto::hkdf32(shared.as_bytes(), &salt, b"dosh/native/chacha20poly1305/v1")
}
/// Derive a rotated transport key for transport rekey (spec §11 / §9).
///
/// The rotated key is derived **independently of the handshake traffic keys**:
/// the input keying material is the current epoch's key (which both peers already
/// hold) mixed with fresh, server-generated `rekey_material` (32 bytes of CSPRNG
/// output, delivered confidentially inside an AEAD `Rekey` packet sealed under
/// the current key). The new `epoch` and the previous epoch's `session_key_id`
/// salt the derivation so each epoch's key is unique. It never re-derives from
/// the handshake DH output, satisfying the spec requirement that "rotated session
/// keys must be derived independently ... from fresh randomness" and "must not
/// reuse handshake traffic keys."
///
/// Both peers run this identically — the client never needs the server's
/// long-term secret, only the fresh `rekey_material` it receives in the `Rekey`
/// packet plus the current key it already shares.
pub fn derive_rekey_session_key(
current_key: &[u8; 32],
rekey_material: &[u8; 32],
previous_session_key_id: &[u8; 16],
epoch: u64,
) -> Result<[u8; 32]> {
let mut ikm = Vec::with_capacity(64);
ikm.extend_from_slice(current_key);
ikm.extend_from_slice(rekey_material);
let mut salt = b"dosh/native/rekey/v1".to_vec();
salt.extend_from_slice(previous_session_key_id);
salt.extend_from_slice(&epoch.to_be_bytes());
crypto::hkdf32(&ikm, &salt, b"dosh/native/rekey/chacha20poly1305/v1")
}
pub fn load_ed25519_identity(path: &Path) -> Result<SigningKey> {
load_ed25519_identity_with_passphrase(path, None)
}
@@ -396,6 +650,35 @@ pub fn load_ed25519_identity_with_passphrase(
.with_context(|| format!("parse ssh-ed25519 identity {}", path.display()))
}
pub fn load_native_identity(path: &Path) -> Result<ssh_key::PrivateKey> {
load_native_identity_with_passphrase(path, None)
}
pub fn load_native_identity_with_passphrase(
path: &Path,
passphrase: Option<&str>,
) -> Result<ssh_key::PrivateKey> {
let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let private_key = ssh_key::PrivateKey::from_openssh(&raw)
.with_context(|| format!("parse OpenSSH identity {}", path.display()))?;
let private_key = if private_key.is_encrypted() {
let passphrase = passphrase
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity {} is encrypted", path.display()))?;
private_key
.decrypt(passphrase)
.with_context(|| format!("decrypt OpenSSH identity {}", path.display()))?
} else {
private_key
};
let algorithm = private_key.public_key().algorithm().as_str().to_string();
anyhow::ensure!(
is_supported_user_key_algorithm(&algorithm),
"OpenSSH identity {} has unsupported native key algorithm {algorithm}",
path.display()
);
Ok(private_key)
}
impl AuthorizedKey {
fn ensure_native_allowed(
&self,
@@ -445,6 +728,10 @@ impl AuthorizedKey {
"authorized key permitopen= cannot authorize remote or dynamic forwarding"
);
}
// Agent forwarding does not open a host:port, so permitopen
// (which restricts which targets may be opened) does not apply;
// it is gated separately by the server's allow_agent_forwarding.
ForwardingKind::Agent => {}
}
}
}
@@ -487,7 +774,7 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
fields.len() >= 2,
"authorized_keys:{line_number}: expected key fields"
);
let (options, key_type_index) = if fields[0].starts_with("ssh-") {
let (options, key_type_index) = if is_supported_user_key_algorithm(&fields[0]) {
(AuthorizedKeyOptions::default(), 0)
} else {
(parse_authorized_key_options(&fields[0])?, 1)
@@ -498,15 +785,20 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
let key_blob = fields
.get(key_type_index + 1)
.with_context(|| format!("authorized_keys:{line_number}: missing key blob"))?;
anyhow::ensure!(
algorithm == "ssh-ed25519",
"authorized_keys:{line_number}: unsupported key type {algorithm}"
);
let decoded = STANDARD
.decode(key_blob)
.with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?;
let key = parse_ssh_ed25519_public_blob(&decoded)
.with_context(|| format!("authorized_keys:{line_number}: parse ssh-ed25519 key"))?;
anyhow::ensure!(
is_supported_user_key_algorithm(algorithm),
"authorized_keys:{line_number}: unsupported key type {algorithm}"
);
validate_supported_public_key_blob(algorithm, &decoded)
.with_context(|| format!("authorized_keys:{line_number}: parse {algorithm} key"))?;
let key = if algorithm == "ssh-ed25519" {
parse_ssh_ed25519_public_blob(&decoded)?.to_vec()
} else {
decoded
};
let comment = if fields.len() > key_type_index + 2 {
Some(fields[key_type_index + 2..].join(" "))
} else {
@@ -520,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> {
line.split_whitespace().map(ToString::to_string).collect()
}
@@ -687,6 +1024,18 @@ pub fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
Ok(out)
}
fn parse_ssh_rsa_public_blob(blob: &[u8]) -> Result<(&[u8], &[u8])> {
let mut cursor = blob;
let key_type = read_ssh_string(&mut cursor)?;
anyhow::ensure!(key_type == b"ssh-rsa", "key blob type mismatch");
let e = read_ssh_mpint(&mut cursor)?;
let n = read_ssh_mpint(&mut cursor)?;
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-rsa key blob");
anyhow::ensure!(!e.is_empty(), "ssh-rsa exponent is empty");
anyhow::ensure!(!n.is_empty(), "ssh-rsa modulus is empty");
Ok((e, n))
}
pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
let mut out = Vec::new();
write_ssh_string(&mut out, b"ssh-ed25519");
@@ -694,6 +1043,12 @@ pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
out
}
fn read_ssh_mpint<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
let raw = read_ssh_string(cursor)?;
let raw = raw.strip_prefix(&[0]).unwrap_or(raw);
Ok(raw)
}
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length");
let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize;
@@ -880,11 +1235,11 @@ fn write_known_host_entries(path: &Path, entries: &[KnownHost]) -> Result<()> {
));
out.push('\n');
}
let mut file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
let mut options = fs::OpenOptions::new();
options.create(true).truncate(true).write(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options
.open(path)
.with_context(|| format!("write {}", path.display()))?;
file.write_all(out.as_bytes())
@@ -926,16 +1281,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(),
trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::Trusted
);
assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(),
trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::AlreadyTrusted
);
assert!(trust_host(&path, "palav", &second, "ssh", false).is_err());
assert!(trust_host(&path, "homelab", &second, "ssh", false).is_err());
assert_eq!(
trust_host(&path, "palav", &second, "ssh", true).unwrap(),
trust_host(&path, "homelab", &second, "ssh", true).unwrap(),
TrustResult::Trusted
);
}
@@ -948,16 +1303,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(),
verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Unknown
);
trust_host(&path, "palav", &first, "ssh", false).unwrap();
trust_host(&path, "homelab", &first, "ssh", false).unwrap();
assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(),
verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Trusted
);
assert!(matches!(
verify_known_host(&path, "palav", &second).unwrap(),
verify_known_host(&path, "homelab", &second).unwrap(),
KnownHostStatus::Mismatch { .. }
));
}
@@ -1006,6 +1361,45 @@ mod tests {
assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err());
}
#[test]
fn native_user_auth_accepts_ecdsa_p256_private_key() {
let mut rng = rand::rngs::OsRng;
let private = ssh_key::PrivateKey::random(
&mut rng,
ssh_key::Algorithm::Ecdsa {
curve: ssh_key::EcdsaCurve::NistP256,
},
)
.unwrap();
let host_signing = SigningKey::from_bytes(&[10u8; 32]);
let client = test_client_hello();
let mut server = test_server_hello(&host_signing);
sign_server_hello(&host_signing, &client, &mut server).unwrap();
let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap();
let authorized =
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
assert_eq!(auth.public_key_algorithm, "ecdsa-sha2-nistp256");
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
}
#[test]
fn native_user_auth_accepts_rsa_sha2_private_key() {
let mut rng = rand::rngs::OsRng;
let private =
ssh_key::PrivateKey::random(&mut rng, ssh_key::Algorithm::Rsa { hash: None }).unwrap();
let host_signing = SigningKey::from_bytes(&[11u8; 32]);
let client = test_client_hello();
let mut server = test_server_hello(&host_signing);
sign_server_hello(&host_signing, &client, &mut server).unwrap();
let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap();
let authorized =
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
assert_eq!(auth.public_key_algorithm, "rsa-sha2-512");
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
}
#[test]
fn native_user_auth_rejects_tampered_transcript() {
let host_signing = SigningKey::from_bytes(&[3u8; 32]);
@@ -1233,8 +1627,8 @@ mod tests {
protocol_version: NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(),
requested_user: "palav".to_string(),
requested_host: "homelab".to_string(),
requested_user: "alice".to_string(),
requested_session: "term".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
+697
View File
@@ -0,0 +1,697 @@
//! Session persistence across server restarts.
//!
//! A persistent dosh session's shell does not run as a child of `dosh-server`.
//! Instead the server spawns a tiny per-session *holder* process (the same
//! `dosh-server` binary re-exec'd as `dosh-server hold ...`). The holder calls
//! `setsid()` to leave the server's process group/session, opens a PTY, spawns
//! the shell as ITS own child, and listens on a Unix socket in a per-session
//! runtime directory. The server connects to that socket and the holder hands it
//! the PTY master fd over `SCM_RIGHTS`.
//!
//! Because the shell belongs to the holder (which is in its own session and is
//! not waited on by the server), killing or restarting `dosh-server` leaves the
//! holder + shell alive. On startup the server scans the runtime directory,
//! reconnects to each live holder, receives the master fd again, and rebuilds the
//! in-memory session so clients reattach to the very same shell.
//!
//! Screen / scrollback state lives only in server memory, so it is also mirrored
//! to disk (atomically) and restored on re-adoption, letting a reattaching client
//! repaint the screen exactly as it was before the restart.
use anyhow::{Context, Result, anyhow, bail};
use std::io::{Read, Write};
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, RawFd};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;
/// One-byte commands a server sends to a holder over its control socket after
/// the holder has handed back the master fd.
const HOLDER_CMD_SHUTDOWN: u8 = b'X';
const HOLDER_STARTUP_TIMEOUT: Duration = Duration::from_millis(750);
static SCREEN_WRITE_LOCK: Mutex<()> = Mutex::new(());
/// Magic written into a holder's `meta` file, bumped if the on-disk layout
/// changes so a stale holder from an incompatible build is ignored.
const META_MAGIC: &str = "dosh-holder-1";
/// Per-session runtime metadata persisted next to the holder socket.
#[derive(Debug, Clone)]
pub struct HolderMeta {
pub session: String,
pub shell_pid: i32,
}
/// Root runtime directory holding one subdirectory per persistent session.
/// Lives under `sessions_dir/run` so it shares the session storage location and
/// can be wiped with it.
pub fn runtime_root(sessions_dir: &Path) -> PathBuf {
sessions_dir.join("run")
}
/// Map a session name to a filesystem-safe directory name. Session names are
/// user-controlled, so hex-encode them rather than trusting them as path
/// components (avoids traversal, slashes, NULs, length issues).
fn session_dir_name(session: &str) -> String {
let mut out = String::with_capacity(session.len() * 2);
for byte in session.as_bytes() {
out.push(char::from_digit((byte >> 4) as u32, 16).unwrap());
out.push(char::from_digit((byte & 0xf) as u32, 16).unwrap());
}
out
}
fn decode_session_dir_name(name: &str) -> Option<String> {
if !name.len().is_multiple_of(2) {
return None;
}
let mut bytes = Vec::with_capacity(name.len() / 2);
let raw = name.as_bytes();
let mut i = 0;
while i < raw.len() {
let hi = (raw[i] as char).to_digit(16)?;
let lo = (raw[i + 1] as char).to_digit(16)?;
bytes.push(((hi << 4) | lo) as u8);
i += 2;
}
String::from_utf8(bytes).ok()
}
/// Directory for one session's holder runtime state.
pub fn session_runtime_dir(sessions_dir: &Path, session: &str) -> PathBuf {
runtime_root(sessions_dir).join(session_dir_name(session))
}
fn holder_sock_path(dir: &Path) -> PathBuf {
dir.join("holder.sock")
}
fn meta_path(dir: &Path) -> PathBuf {
dir.join("meta")
}
fn screen_path(dir: &Path) -> PathBuf {
dir.join("screen")
}
/// Create the runtime directory tree with private (0700) permissions.
pub fn ensure_runtime_dir(sessions_dir: &Path, session: &str) -> Result<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let root = runtime_root(sessions_dir);
std::fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?;
let _ = std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700));
let dir = session_runtime_dir(sessions_dir, session);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("chmod {}", dir.display()))?;
Ok(dir)
}
/// Atomically write `data` to `path` (write temp + rename), so a concurrent
/// reader (e.g. a restarting server) never sees a half-written file.
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let tmp = path.with_extension("tmp");
{
let mut file =
std::fs::File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
file.write_all(data)
.with_context(|| format!("write {}", tmp.display()))?;
file.sync_all().ok();
}
std::fs::rename(&tmp, path)
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
/// Persist the vt100 screen snapshot plus recent raw output for a session so a
/// post-restart reattach repaints correctly. `snapshot` is the bytes a fresh
/// attach would receive (alt-screen toggle + `state_formatted`).
pub fn save_screen(
sessions_dir: &Path,
session: &str,
cols: u16,
rows: u16,
output_seq: u64,
snapshot: &[u8],
) -> Result<()> {
let dir = session_runtime_dir(sessions_dir, session);
if !dir.exists() {
// No holder runtime for this session (non-persistent): nothing to do.
return Ok(());
}
let _guard = SCREEN_WRITE_LOCK
.lock()
.map_err(|_| anyhow!("screen persistence lock poisoned"))?;
let path = screen_path(&dir);
if existing_screen_output_seq(&path).is_some_and(|existing| existing >= output_seq) {
return Ok(());
}
let mut buf = Vec::with_capacity(snapshot.len() + 32);
buf.extend_from_slice(&cols.to_be_bytes());
buf.extend_from_slice(&rows.to_be_bytes());
buf.extend_from_slice(&output_seq.to_be_bytes());
buf.extend_from_slice(&(snapshot.len() as u32).to_be_bytes());
buf.extend_from_slice(snapshot);
atomic_write(&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.
pub struct SavedScreen {
pub cols: u16,
pub rows: u16,
pub output_seq: u64,
pub snapshot: Vec<u8>,
}
/// Load a previously persisted screen, if any.
pub fn load_screen(sessions_dir: &Path, session: &str) -> Option<SavedScreen> {
let dir = session_runtime_dir(sessions_dir, session);
let data = std::fs::read(screen_path(&dir)).ok()?;
if data.len() < 16 {
return None;
}
let cols = u16::from_be_bytes(data[0..2].try_into().ok()?);
let rows = u16::from_be_bytes(data[2..4].try_into().ok()?);
let output_seq = u64::from_be_bytes(data[4..12].try_into().ok()?);
let len = u32::from_be_bytes(data[12..16].try_into().ok()?) as usize;
if data.len() < 16 + len {
return None;
}
Some(SavedScreen {
cols,
rows,
output_seq,
snapshot: data[16..16 + len].to_vec(),
})
}
fn write_meta(dir: &Path, meta: &HolderMeta) -> Result<()> {
let body = format!("{META_MAGIC}\n{}\n{}\n", meta.shell_pid, meta.session);
atomic_write(&meta_path(dir), body.as_bytes())
}
fn read_meta(dir: &Path) -> Option<HolderMeta> {
let body = std::fs::read_to_string(meta_path(dir)).ok()?;
let mut lines = body.lines();
if lines.next()? != META_MAGIC {
return None;
}
let shell_pid: i32 = lines.next()?.parse().ok()?;
let session = lines.next()?.to_string();
Some(HolderMeta { session, shell_pid })
}
/// Spawn a holder process for `session`: re-exec this binary as `dosh-server
/// hold` with the runtime dir, shell, terminal size, and accepted env. The
/// holder daemonizes (setsid, owns the PTY) and listens on its control socket.
/// Returns once the holder has signalled readiness by creating its socket.
#[allow(clippy::too_many_arguments)]
pub fn spawn_holder(
sessions_dir: &Path,
session: &str,
shell: &str,
cols: u16,
rows: u16,
env: &[(String, String)],
) -> Result<()> {
let dir = ensure_runtime_dir(sessions_dir, session)?;
let sock = holder_sock_path(&dir);
// Clear any stale socket left by a crashed holder with the same name.
let _ = std::fs::remove_file(&sock);
let exe = std::env::current_exe().context("locate current executable")?;
let mut cmd = std::process::Command::new(exe);
cmd.arg("hold")
.arg("--runtime-dir")
.arg(&dir)
.arg("--session")
.arg(session)
.arg("--shell")
.arg(shell)
.arg("--cols")
.arg(cols.to_string())
.arg("--rows")
.arg(rows.to_string());
for (name, value) in env {
cmd.arg("--env").arg(format!("{name}={value}"));
}
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let mut child = cmd.spawn().context("spawn holder process")?;
// Wait (briefly) for the holder to come up. The holder forks/setsids before
// creating the socket; once the socket exists we can connect. We also reap
// the short-lived launcher child so it never becomes a zombie.
let deadline = std::time::Instant::now() + HOLDER_STARTUP_TIMEOUT;
loop {
if sock.exists() {
break;
}
if let Some(status) = child.try_wait().context("wait holder launcher")? {
bail!("holder launcher for session {session} exited before socket was ready: {status}");
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
bail!("holder for session {session} did not come up in time");
}
std::thread::sleep(Duration::from_millis(20));
}
// The launcher exits immediately after the grandchild setsids; reap it.
let _ = child.wait();
Ok(())
}
/// Connect to a session's holder and receive the PTY master fd over SCM_RIGHTS.
/// Returns the raw fd (caller owns it) and the holder control socket, kept open
/// so the server can later ask the holder to shut down. Returns an error if the
/// holder is gone (caller then degrades to a fresh, non-persistent session).
pub fn adopt_holder(sessions_dir: &Path, session: &str) -> Result<(RawFd, UnixStream)> {
let dir = session_runtime_dir(sessions_dir, session);
let sock = holder_sock_path(&dir);
let mut stream = UnixStream::connect(&sock)
.with_context(|| format!("connect holder socket {}", sock.display()))?;
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
let fd = recv_fd(&mut stream).context("receive master fd from holder")?;
Ok((fd, stream))
}
/// Ask a holder to terminate its shell and exit, then clean its runtime dir.
/// Used when reaping a truly-abandoned persistent session.
pub fn request_shutdown(sessions_dir: &Path, session: &str, control: Option<&mut UnixStream>) {
if let Some(stream) = control {
let _ = stream.write_all(&[HOLDER_CMD_SHUTDOWN]);
let _ = stream.flush();
} else {
let dir = session_runtime_dir(sessions_dir, session);
if let Ok(mut stream) = UnixStream::connect(holder_sock_path(&dir)) {
// Drain the fd the holder sends on connect, then send shutdown.
let _ = recv_fd(&mut stream);
let _ = stream.write_all(&[HOLDER_CMD_SHUTDOWN]);
let _ = stream.flush();
}
}
// Give the holder a moment to tear down, then remove its runtime dir.
std::thread::sleep(Duration::from_millis(50));
let dir = session_runtime_dir(sessions_dir, session);
let _ = std::fs::remove_dir_all(&dir);
}
/// Remove a session's runtime directory unconditionally (best effort).
pub fn remove_runtime_dir(sessions_dir: &Path, session: &str) {
let dir = session_runtime_dir(sessions_dir, session);
let _ = std::fs::remove_dir_all(&dir);
}
/// Scan the runtime root for holders left behind by a previous server. Returns
/// `(session_name, meta)` for each one whose holder process still appears alive.
/// Stale entries (no live process) are cleaned up.
pub fn scan_existing_holders(sessions_dir: &Path) -> Vec<HolderMeta> {
let root = runtime_root(sessions_dir);
let mut found = Vec::new();
let Ok(entries) = std::fs::read_dir(&root) else {
return found;
};
for entry in entries.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let Some(name) = dir.file_name().and_then(|n| n.to_str()) else {
continue;
};
// Decode the on-disk name back to a session name; skip junk dirs.
if decode_session_dir_name(name).is_none() {
continue;
}
match read_meta(&dir) {
Some(meta) if process_alive(meta.shell_pid) && holder_sock_path(&dir).exists() => {
found.push(meta);
}
_ => {
// Holder gone or meta unreadable: clean up so we don't try to
// adopt a dead session (degrade to fresh on next attach).
let _ = std::fs::remove_dir_all(&dir);
}
}
}
found
}
/// Whether a pid refers to a live process (signal 0 probe).
fn process_alive(pid: i32) -> bool {
if pid <= 0 {
return false;
}
unsafe {
libc::kill(pid, 0) == 0
|| std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
}
// ---------------------------------------------------------------------------
// Holder process entry point
// ---------------------------------------------------------------------------
/// Run as a holder process. Daemonizes (double-fork + setsid), opens a PTY,
/// spawns the shell as its own child, and serves the control socket: every
/// accepted connection is handed the master fd (SCM_RIGHTS); a subsequent
/// SHUTDOWN byte (or the shell exiting) tears everything down.
///
/// This function does not return on success — it `exit`s the process. Errors
/// before the daemonization point are returned to the launcher.
#[allow(clippy::too_many_arguments)]
pub fn run_holder(
runtime_dir: &Path,
session: &str,
shell: &str,
cols: u16,
rows: u16,
env: &[(String, String)],
) -> Result<()> {
use portable_pty::{NativePtySystem, PtySize, PtySystem};
// Detach from the launching server: own session + process group so a server
// exit (even a process-group kill of the service) does not take us down.
daemonize().context("daemonize holder")?;
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("holder open pty")?;
let cmd = crate::pty::build_shell_command(shell, env);
let mut child = pair
.slave
.spawn_command(cmd)
.context("holder spawn shell")?;
drop(pair.slave);
let master_fd = pair
.master
.as_raw_fd()
.ok_or_else(|| anyhow!("holder master has no raw fd"))?;
let shell_pid = child.process_id().map(|p| p as i32).unwrap_or(-1);
write_meta(
runtime_dir,
&HolderMeta {
session: session.to_string(),
shell_pid,
},
)?;
let sock = holder_sock_path(runtime_dir);
let _ = std::fs::remove_file(&sock);
let listener =
UnixListener::bind(&sock).with_context(|| format!("holder bind {}", sock.display()))?;
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600));
}
listener
.set_nonblocking(false)
.context("holder listener blocking")?;
// A watcher thread reaps the shell: when it exits, the holder cleans up and
// exits too, so an abandoned shell does not linger forever.
let runtime_owned = runtime_dir.to_path_buf();
std::thread::spawn(move || {
let _ = child.wait();
// Shell exited: remove runtime dir and exit the holder process.
let _ = std::fs::remove_dir_all(&runtime_owned);
std::process::exit(0);
});
// Accept loop: each connecting server gets the master fd; a SHUTDOWN byte
// from any of them tears the holder down. Multiple servers never run at once
// in practice (one service), but serving repeated connections lets a server
// restart re-adopt cleanly.
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
if send_fd(&mut stream, master_fd).is_err() {
continue;
}
// Wait for an optional command byte. EOF (server dropped the control
// socket on its own exit) just means "keep running, await re-adoption".
let mut cmd = [0u8; 1];
match stream.read(&mut cmd) {
Ok(1) if cmd[0] == HOLDER_CMD_SHUTDOWN => {
if shell_pid > 0 {
let _ = unsafe { libc::kill(shell_pid, libc::SIGHUP) };
let _ = unsafe { libc::kill(shell_pid, libc::SIGTERM) };
}
let _ = std::fs::remove_dir_all(runtime_dir);
std::process::exit(0);
}
_ => {
// Server detached (exit/restart) or sent nothing actionable:
// loop back and wait for the next server to re-adopt us.
}
}
}
Ok(())
}
/// Double-fork + `setsid` so the holder runs in its own session, detached from
/// the server's controlling terminal and process group. Without this a
/// `systemctl restart` (which signals the whole service cgroup/process group)
/// could take the holder down with the server.
fn daemonize() -> Result<()> {
// First fork: parent (launcher) returns to reap; child continues.
match unsafe { libc::fork() } {
-1 => bail!("fork: {}", std::io::Error::last_os_error()),
0 => {}
_ => {
// Parent process: exit so the launcher's wait() returns promptly and
// the grandchild is reparented to init.
std::process::exit(0);
}
}
// New session: detaches from controlling tty and the server's process group.
if unsafe { libc::setsid() } == -1 {
bail!("setsid: {}", std::io::Error::last_os_error());
}
// Second fork: ensures we are not a session leader, so we can never
// re-acquire a controlling terminal.
match unsafe { libc::fork() } {
-1 => bail!("fork2: {}", std::io::Error::last_os_error()),
0 => Ok(()),
_ => std::process::exit(0),
}
}
// ---------------------------------------------------------------------------
// SCM_RIGHTS file-descriptor passing over a Unix domain socket
// ---------------------------------------------------------------------------
/// Send a single fd over `stream` using SCM_RIGHTS, with one byte of normal data
/// (sendmsg requires at least one iovec byte for the ancillary data to ride on).
fn send_fd(stream: &mut UnixStream, fd: RawFd) -> Result<()> {
let dummy: [u8; 1] = [0];
let mut iov = libc::iovec {
iov_base: dummy.as_ptr() as *mut libc::c_void,
iov_len: 1,
};
let mut cmsg_buf = [0u8; cmsg_space_one_fd()];
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg_buf.len() as _;
unsafe {
let cmsg = libc::CMSG_FIRSTHDR(&msg);
if cmsg.is_null() {
bail!("CMSG_FIRSTHDR null");
}
(*cmsg).cmsg_level = libc::SOL_SOCKET;
(*cmsg).cmsg_type = libc::SCM_RIGHTS;
(*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::<RawFd>() as u32) as _;
std::ptr::copy_nonoverlapping(
&fd as *const RawFd as *const u8,
libc::CMSG_DATA(cmsg),
std::mem::size_of::<RawFd>(),
);
let n = libc::sendmsg(stream.as_raw_fd(), &msg, 0);
if n < 0 {
return Err(std::io::Error::last_os_error()).context("sendmsg SCM_RIGHTS");
}
}
Ok(())
}
/// Receive a single fd sent via SCM_RIGHTS. Returns a fresh fd owned by the
/// caller.
fn recv_fd(stream: &mut UnixStream) -> Result<RawFd> {
let mut dummy = [0u8; 1];
let mut iov = libc::iovec {
iov_base: dummy.as_mut_ptr() as *mut libc::c_void,
iov_len: 1,
};
let mut cmsg_buf = [0u8; cmsg_space_one_fd()];
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg_buf.len() as _;
unsafe {
let n = libc::recvmsg(stream.as_raw_fd(), &mut msg, 0);
if n < 0 {
return Err(std::io::Error::last_os_error()).context("recvmsg SCM_RIGHTS");
}
if n == 0 {
bail!("holder closed connection before sending fd");
}
let cmsg = libc::CMSG_FIRSTHDR(&msg);
if cmsg.is_null() {
bail!("no ancillary data (fd) received from holder");
}
if (*cmsg).cmsg_level != libc::SOL_SOCKET || (*cmsg).cmsg_type != libc::SCM_RIGHTS {
bail!("unexpected ancillary message from holder");
}
let mut fd: RawFd = -1;
std::ptr::copy_nonoverlapping(
libc::CMSG_DATA(cmsg),
&mut fd as *mut RawFd as *mut u8,
std::mem::size_of::<RawFd>(),
);
if fd < 0 {
bail!("invalid fd received from holder");
}
Ok(fd)
}
}
/// Space, in bytes, needed for a control-message buffer carrying exactly one fd.
const fn cmsg_space_one_fd() -> usize {
// CMSG_SPACE is not const in libc; this is the equivalent for one RawFd.
// cmsghdr is aligned to size_of::<usize>(); add data length rounded up.
let data = std::mem::size_of::<RawFd>();
let hdr = std::mem::size_of::<libc::cmsghdr>();
let align = std::mem::size_of::<usize>();
// round(hdr) + round(data)
((hdr + align - 1) & !(align - 1)) + ((data + align - 1) & !(align - 1))
}
/// Helper used by `adopt_holder` callers to turn the received raw fd into an
/// owned `File`-like object if they need RAII (the server hands it to
/// `pty::adopt_pty_from_fd`, which takes ownership of the raw fd).
pub fn fd_into_file(fd: RawFd) -> std::fs::File {
unsafe { std::fs::File::from_raw_fd(fd) }
}
/// Convert a `File` back into a raw fd it no longer owns (so it can be handed to
/// `adopt_pty_from_fd`). Currently unused outside tests but kept symmetric.
pub fn file_into_fd(file: std::fs::File) -> RawFd {
file.into_raw_fd()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_dir_name_round_trips() {
for name in ["default", "work", "a/b/../c", "weird name", "日本語"] {
let encoded = session_dir_name(name);
assert!(encoded.bytes().all(|b| b.is_ascii_hexdigit()));
assert_eq!(decode_session_dir_name(&encoded).as_deref(), Some(name));
}
}
#[test]
fn decode_rejects_non_hex() {
assert!(decode_session_dir_name("zz").is_none());
assert!(decode_session_dir_name("abc").is_none());
}
#[test]
fn save_and_load_screen_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let sessions_dir = tmp.path();
ensure_runtime_dir(sessions_dir, "work").unwrap();
let snap = b"\x1b[?1049lhello world".to_vec();
save_screen(sessions_dir, "work", 100, 40, 7, &snap).unwrap();
let loaded = load_screen(sessions_dir, "work").expect("screen restored");
assert_eq!(loaded.cols, 100);
assert_eq!(loaded.rows, 40);
assert_eq!(loaded.output_seq, 7);
assert_eq!(loaded.snapshot, snap);
}
#[test]
fn save_screen_never_overwrites_newer_snapshot_with_older_seq() {
let tmp = tempfile::tempdir().unwrap();
let sessions_dir = tmp.path();
ensure_runtime_dir(sessions_dir, "work").unwrap();
save_screen(sessions_dir, "work", 80, 24, 10, b"new-screen").unwrap();
save_screen(sessions_dir, "work", 80, 24, 9, b"old-screen").unwrap();
let loaded = load_screen(sessions_dir, "work").expect("screen restored");
assert_eq!(loaded.output_seq, 10);
assert_eq!(loaded.snapshot, b"new-screen");
}
#[test]
fn load_screen_absent_is_none() {
let tmp = tempfile::tempdir().unwrap();
assert!(load_screen(tmp.path(), "missing").is_none());
}
#[test]
fn scan_skips_dead_and_junk_entries() {
let tmp = tempfile::tempdir().unwrap();
let sessions_dir = tmp.path();
// A meta pointing at a definitely-dead pid is cleaned up, not returned.
let dir = ensure_runtime_dir(sessions_dir, "ghost").unwrap();
write_meta(
&dir,
&HolderMeta {
session: "ghost".to_string(),
shell_pid: 2_000_000_000, // not a live pid
},
)
.unwrap();
// A junk (non-hex) directory is ignored.
std::fs::create_dir_all(runtime_root(sessions_dir).join("not-hex")).unwrap();
let found = scan_existing_holders(sessions_dir);
assert!(found.is_empty(), "dead/junk holders must be skipped");
assert!(!dir.exists(), "dead holder dir should be cleaned up");
}
#[test]
fn send_and_recv_fd_round_trips() {
use std::io::Seek;
// Pass a temp file's fd across a socketpair and confirm both ends point
// at the same open file (write via one, read via the other).
let (mut a, mut b) = UnixStream::pair().unwrap();
let mut file = tempfile::tempfile().unwrap();
writeln!(file, "shared-fd-marker").unwrap();
file.flush().unwrap();
send_fd(&mut a, file.as_raw_fd()).unwrap();
let received = recv_fd(&mut b).unwrap();
let mut got = fd_into_file(received);
got.rewind().unwrap();
let mut contents = String::new();
got.read_to_string(&mut contents).unwrap();
assert!(contents.contains("shared-fd-marker"));
}
}
+129 -3
View File
@@ -3,10 +3,56 @@ use crate::crypto;
use crate::native::{EnvVar, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth};
use anyhow::{Context, Result, bail};
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 VERSION: u8 = 1;
// v4: added reliable stream offsets/acks to `StreamData` and
// `StreamWindowAdjust`.
// v3: added `ForwardingKind::Agent` (SSH-agent forwarding). The new variant rides
// inside `NativeUserAuth.requested_forwardings`, so a pre-agent peer would fail to
// deserialize it; bumping the wire version makes such a peer answer with a clear
// version-mismatch reject instead. Existing variants' bincode discriminants are
// unchanged, so the bump is purely a compatibility gate.
pub const VERSION: u8 = 4;
pub const HEADER_LEN: usize = 58;
/// Stable, user-facing reason string the server puts in an `AttachReject` when a
/// native handshake arrives carrying a `protocol_version` it cannot speak. The
/// client recognizes this prefix and surfaces a clear "upgrade dosh" message
/// instead of the generic transport rejection or, worse, a silent timeout.
pub const VERSION_MISMATCH_REASON: &str = "protocol version mismatch — upgrade dosh";
const HEADER_AAD_LEN: usize = HEADER_LEN - 2;
pub const CLIENT_TO_SERVER: u32 = 1;
pub const SERVER_TO_CLIENT: u32 = 2;
@@ -39,6 +85,8 @@ pub enum PacketKind {
StreamEof = 23,
StreamClose = 24,
NativeAuthCheckOk = 25,
Rekey = 26,
RekeyAck = 27,
}
impl TryFrom<u8> for PacketKind {
@@ -71,6 +119,8 @@ impl TryFrom<u8> for PacketKind {
23 => Self::StreamEof,
24 => Self::StreamClose,
25 => Self::NativeAuthCheckOk,
26 => Self::Rekey,
27 => Self::RekeyAck,
_ => bail!("unknown packet kind {value}"),
})
}
@@ -110,7 +160,11 @@ impl Header {
bail!("bad magic");
}
if input[4] != VERSION {
bail!("bad protocol version {}", input[4]);
bail!(
"{} (peer wire protocol v{}, this build speaks v{VERSION})",
VERSION_MISMATCH_REASON,
input[4]
);
}
let kind = PacketKind::try_from(input[5])?;
let flags = u16::from_be_bytes(input[6..8].try_into().unwrap());
@@ -133,6 +187,20 @@ impl Header {
}
}
/// Cheaply inspect a datagram that carries our [`MAGIC`] but whose wire
/// [`VERSION`] byte differs from this build's. Returns the peer's advertised
/// wire version when (and only when) the packet is a Dosh packet we cannot
/// otherwise decode because of a version skew, so the receiver can answer with a
/// clear, named version-mismatch reject instead of dropping it (a silent
/// timeout for the peer). Returns `None` for our own version, foreign magic, or
/// runt packets.
pub fn peek_foreign_wire_version(input: &[u8]) -> Option<u8> {
if input.len() < 5 || &input[..4] != MAGIC || input[4] == VERSION {
return None;
}
Some(input[4])
}
#[derive(Debug, Clone)]
pub struct Packet {
pub header: Header,
@@ -212,7 +280,7 @@ pub fn decode(input: &[u8]) -> Result<Packet> {
pub fn decrypt_body(packet: &Packet, key: &[u8; 32], direction: u32) -> Result<Vec<u8>> {
if packet.header.flags & 1 == 0 {
return Ok(packet.body.clone());
bail!("packet body is not encrypted");
}
let nonce = crypto::nonce_from(direction, packet.header.seq);
let aad = packet.header.aad();
@@ -289,6 +357,10 @@ pub struct NativeAuthCheckOkBody {
pub allow_tcp_forwarding: bool,
pub allow_remote_forwarding: bool,
pub allow_agent_forwarding: bool,
#[serde(default)]
pub allow_file_transfer: bool,
#[serde(default)]
pub allow_exec_command: bool,
pub policy_flags: Vec<String>,
#[serde(default)]
pub server_version: String,
@@ -350,12 +422,14 @@ pub struct StreamOpenReject {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamData {
pub stream_id: u64,
pub offset: u64,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamWindowAdjust {
pub stream_id: u64,
pub received_offset: u64,
pub bytes: u32,
}
@@ -369,6 +443,20 @@ pub struct StreamClose {
pub stream_id: u64,
}
/// Server→client transport rekey, sealed under the *current* session key.
///
/// Carries the fresh server-generated `rekey_material` and the new `epoch`; both
/// peers feed these into [`crate::native::derive_rekey_session_key`] to compute
/// the next traffic key. `new_session_key_id` is the id the next epoch's packets
/// will carry, so the client can recognize and switch atomically. The client
/// replies with a [`PacketKind::RekeyAck`] encrypted under the *new* key.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rekey {
pub epoch: u64,
pub rekey_material: [u8; 32],
pub new_session_key_id: [u8; 16],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Frame {
pub session: String,
@@ -449,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"));
}
}
+209 -14
View File
@@ -1,14 +1,42 @@
use anyhow::{Context, Result};
use portable_pty::{CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use std::io::{Read, Write};
use std::path::Path;
use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread;
use tokio::sync::mpsc;
// Keep live terminal output comfortably below common path MTUs after Dosh's
// protocol header, AEAD tag, UDP/IP headers, and bincode framing. Full-screen
// TUIs often write several KiB on the first draw; sending that as one UDP
// datagram can fragment and vanish, leaving only a blank alternate screen.
const PTY_OUTPUT_CHUNK_BYTES: usize = 1024;
/// Backing for a PTY master held by the server.
///
/// `Owned` means this process spawned the shell as a child and is responsible
/// for it: dropping the handle kills the shell. This is the original,
/// non-persistent model and stays the default.
///
/// `Adopted` means the shell lives in a separate holder process and this handle
/// only borrows the master fd (received over a Unix socket via SCM_RIGHTS).
/// Dropping it must NOT kill the shell — it just closes our copy of the fd and
/// stops the reader thread, leaving the holder + shell alive so a server restart
/// can re-adopt them.
enum Backing {
Owned {
child: Mutex<Box<dyn Child + Send + Sync>>,
_master: Box<dyn MasterPty + Send>,
},
Adopted {
master: Mutex<std::fs::File>,
},
}
pub struct PtyHandle {
writer: Arc<Mutex<Box<dyn Write + Send>>>,
_master: Box<dyn MasterPty + Send>,
backing: Backing,
}
impl PtyHandle {
@@ -20,14 +48,69 @@ impl PtyHandle {
}
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
self._master.resize(PtySize {
match &self.backing {
Backing::Owned { _master, .. } => {
_master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})?;
}
Backing::Adopted { master } => {
let file = master.lock().expect("pty master poisoned");
resize_fd(file.as_raw_fd(), cols, rows)?;
}
}
Ok(())
}
/// True for a handle backed by a separate holder process. Such a handle must
/// be detached (not killed) when the server lets go of a session, so the
/// shell survives a server restart.
pub fn is_persistent(&self) -> bool {
matches!(self.backing, Backing::Adopted { .. })
}
/// Terminate the shell process backing this PTY and reap it.
///
/// Only meaningful for an `Owned` backing (the server spawned the shell as a
/// child). For an `Adopted` backing the shell belongs to the holder process,
/// so this is a no-op here; reaping a persistent session is done by asking
/// the holder to shut down (see `persist::request_shutdown`).
pub fn kill(&self) {
if let Backing::Owned { child, .. } = &self.backing
&& let Ok(mut child) = child.lock()
{
let _ = child.kill();
let _ = child.wait();
}
}
}
impl Drop for PtyHandle {
fn drop(&mut self) {
// Adopted handles must NOT kill the shell: it lives in the holder so it
// can outlive this server. Dropping just closes our fd / stops the
// reader. Owned handles keep the original kill-on-drop behavior.
if let Backing::Owned { .. } = &self.backing {
self.kill();
}
}
}
fn resize_fd(fd: RawFd, cols: u16, rows: u16) -> Result<()> {
let winsize = libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
let rc = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &winsize) };
if rc != 0 {
return Err(std::io::Error::last_os_error()).context("TIOCSWINSZ");
}
Ok(())
}
#[derive(Debug)]
@@ -54,12 +137,82 @@ pub fn spawn_pty_session(
pixel_height: 0,
})
.context("open pty")?;
let cmd = build_shell_command(shell, env);
let child = pair.slave.spawn_command(cmd).context("spawn shell")?;
drop(pair.slave);
let writer = pair.master.take_writer().context("take pty writer")?;
let reader = pair.master.try_clone_reader().context("clone pty reader")?;
spawn_reader_thread(session, reader, tx)?;
Ok(PtyHandle {
writer: Arc::new(Mutex::new(writer)),
backing: Backing::Owned {
child: Mutex::new(child),
_master: pair.master,
},
})
}
/// Whether this host has a terminfo entry for `term`, searching the standard
/// ncurses directories. Both the legacy single-letter (`x/xterm`) and the
/// hashed (`78/xterm`) subdirectory layouts are checked.
fn terminfo_available(term: &str) -> bool {
let Some(first) = term.chars().next() else {
return false;
};
let letter = first.to_string();
let hashed = format!("{:x}", first as u32);
let mut dirs: Vec<PathBuf> = Vec::new();
if let Ok(t) = std::env::var("TERMINFO")
&& !t.is_empty()
{
dirs.push(PathBuf::from(t));
}
if let Some(home) = dirs::home_dir() {
dirs.push(home.join(".terminfo"));
}
if let Ok(td) = std::env::var("TERMINFO_DIRS") {
for d in td.split(':').filter(|d| !d.is_empty()) {
dirs.push(PathBuf::from(d));
}
}
for d in [
"/etc/terminfo",
"/lib/terminfo",
"/usr/share/terminfo",
"/usr/lib/terminfo",
"/usr/share/lib/terminfo",
] {
dirs.push(PathBuf::from(d));
}
dirs.iter()
.any(|dir| dir.join(&letter).join(term).exists() || dir.join(&hashed).join(term).exists())
}
/// Build the [`CommandBuilder`] for a dosh shell, identically for the in-process
/// `spawn_pty_session` and the out-of-process holder, so a persistent session's
/// environment matches a non-persistent one.
pub fn build_shell_command(shell: &str, env: &[(String, String)]) -> CommandBuilder {
let mut cmd = CommandBuilder::new(shell);
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
for (name, value) in env {
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);
if let Some(home) = dirs::home_dir() {
cmd.env("HOME", home.as_os_str());
@@ -68,11 +221,39 @@ pub fn spawn_pty_session(
} else if let Some(parent) = Path::new(shell).parent() {
cmd.env("PWD", parent.as_os_str());
}
let _child = pair.slave.spawn_command(cmd).context("spawn shell")?;
drop(pair.slave);
cmd
}
let writer = pair.master.take_writer().context("take pty writer")?;
let mut reader = pair.master.try_clone_reader().context("clone pty reader")?;
/// Build a [`PtyHandle`] from a master fd received from a holder process.
///
/// `master_fd` is an fd this handle takes ownership of (it is wrapped in a
/// `File` and closed on drop). The shell is NOT a child of this process; it
/// belongs to the holder, so dropping this handle leaves it running.
pub fn adopt_pty_from_fd(
session: String,
master_fd: RawFd,
tx: mpsc::UnboundedSender<PtyOutput>,
) -> Result<PtyHandle> {
// Take ownership of the fd. A clone gives us an independent reader so the
// reader thread and the writer/resize side hold separate `File`s and don't
// get closed out from under each other.
let master = unsafe { std::fs::File::from_raw_fd(master_fd) };
let reader_file = master.try_clone().context("clone master for reader")?;
let writer_file = master.try_clone().context("clone master for writer")?;
spawn_reader_thread(session, Box::new(reader_file), tx)?;
Ok(PtyHandle {
writer: Arc::new(Mutex::new(Box::new(writer_file))),
backing: Backing::Adopted {
master: Mutex::new(master),
},
})
}
fn spawn_reader_thread(
session: String,
mut reader: Box<dyn Read + Send>,
tx: mpsc::UnboundedSender<PtyOutput>,
) -> Result<()> {
let reader_session = session.clone();
thread::Builder::new()
.name(format!("dosh-pty-{session}"))
@@ -89,12 +270,14 @@ pub fn spawn_pty_session(
break;
}
Ok(n) => {
for chunk in buf[..n].chunks(PTY_OUTPUT_CHUNK_BYTES) {
let _ = tx.send(PtyOutput {
session: reader_session.clone(),
bytes: buf[..n].to_vec(),
bytes: chunk.to_vec(),
exited: false,
});
}
}
Err(_) => {
let _ = tx.send(PtyOutput {
session: reader_session.clone(),
@@ -107,9 +290,21 @@ pub fn spawn_pty_session(
}
})
.context("spawn pty reader")?;
Ok(())
}
Ok(PtyHandle {
writer: Arc::new(Mutex::new(writer)),
_master: pair.master,
})
#[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::{
ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth,
parse_ssh_ed25519_public_blob, user_auth_transcript,
is_supported_user_key_algorithm, parse_ssh_ed25519_public_blob, user_auth_transcript,
};
use anyhow::{Context, Result, anyhow, bail};
#[cfg(unix)]
use anyhow::{Context, bail};
use anyhow::{Result, anyhow};
#[cfg(unix)]
use std::io::{Read, Write};
#[cfg(unix)]
use std::os::unix::net::UnixStream;
use std::path::Path;
#[cfg(unix)]
const SSH_AGENT_FAILURE: u8 = 5;
#[cfg(unix)]
const SSH2_AGENTC_REQUEST_IDENTITIES: u8 = 11;
#[cfg(unix)]
const SSH2_AGENT_IDENTITIES_ANSWER: u8 = 12;
#[cfg(unix)]
const SSH2_AGENTC_SIGN_REQUEST: u8 = 13;
#[cfg(unix)]
const SSH2_AGENT_SIGN_RESPONSE: u8 = 14;
#[cfg(unix)]
const SSH_AGENT_RSA_SHA2_512: u32 = 4;
#[cfg(unix)]
const MAX_AGENT_PACKET: usize = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentIdentity {
pub key_blob: Vec<u8>,
pub public_key: [u8; 32],
pub public_key_algorithm: String,
pub public_key: Vec<u8>,
pub sign_algorithm: String,
pub sign_flags: u32,
pub comment: String,
}
#[cfg(unix)]
pub fn sign_user_auth_with_agent(
client: &NativeClientHello,
server: &NativeServerHello,
@@ -30,6 +47,18 @@ pub fn sign_user_auth_with_agent(
sign_user_auth_with_agent_at(sock, client, server, requested_forwardings)
}
#[cfg(not(unix))]
pub fn sign_user_auth_with_agent(
_client: &NativeClientHello,
_server: &NativeServerHello,
_requested_forwardings: Vec<ForwardingRequest>,
) -> Result<NativeUserAuth> {
Err(anyhow!(
"ssh-agent native auth is not supported on this platform yet; use identity_files"
))
}
#[cfg(unix)]
pub fn sign_user_auth_with_agent_at(
socket_path: impl AsRef<Path>,
client: &NativeClientHello,
@@ -38,13 +67,13 @@ pub fn sign_user_auth_with_agent_at(
) -> Result<NativeUserAuth> {
let mut agent = UnixStream::connect(socket_path.as_ref())
.with_context(|| format!("connect ssh-agent {}", socket_path.as_ref().display()))?;
let identities = request_ed25519_identities(&mut agent)?;
let identities = request_supported_identities(&mut agent)?;
let identity = identities
.first()
.ok_or_else(|| anyhow!("ssh-agent has no ssh-ed25519 identities"))?;
.ok_or_else(|| anyhow!("ssh-agent has no supported identities"))?;
let mut auth = NativeUserAuth {
public_key_algorithm: "ssh-ed25519".to_string(),
public_key: identity.public_key.to_vec(),
public_key_algorithm: identity.sign_algorithm.clone(),
public_key: identity.public_key.clone(),
signature: Vec::new(),
requested_forwardings,
};
@@ -54,7 +83,20 @@ pub fn sign_user_auth_with_agent_at(
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])?;
let payload = read_agent_packet(agent)?;
let mut cursor = payload.as_slice();
@@ -72,18 +114,51 @@ fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentit
for _ in 0..count {
let key_blob = read_ssh_string(&mut cursor)?.to_vec();
let comment = String::from_utf8_lossy(read_ssh_string(&mut cursor)?).to_string();
if let Ok(public_key) = parse_ssh_ed25519_public_blob(&key_blob) {
identities.push(AgentIdentity {
key_blob,
public_key,
comment,
});
if let Some(identity) = supported_identity(key_blob, comment)? {
identities.push(identity);
}
}
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities");
Ok(identities)
}
#[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(
agent: &mut UnixStream,
identity: &AgentIdentity,
@@ -93,7 +168,7 @@ fn sign_with_agent(
request.push(SSH2_AGENTC_SIGN_REQUEST);
write_ssh_string(&mut request, &identity.key_blob);
write_ssh_string(&mut request, transcript);
request.extend_from_slice(&0u32.to_be_bytes());
request.extend_from_slice(&identity.sign_flags.to_be_bytes());
write_agent_packet(agent, &request)?;
let payload = read_agent_packet(agent)?;
@@ -114,23 +189,21 @@ fn sign_with_agent(
let mut signature_cursor = signature_blob;
let algorithm = read_ssh_string(&mut signature_cursor)?;
let algorithm = String::from_utf8_lossy(algorithm);
anyhow::ensure!(
algorithm == b"ssh-ed25519",
"ssh-agent returned unsupported signature algorithm {}",
String::from_utf8_lossy(algorithm)
algorithm == identity.sign_algorithm,
"ssh-agent returned signature algorithm {algorithm}, expected {}",
identity.sign_algorithm
);
let signature = read_ssh_string(&mut signature_cursor)?;
anyhow::ensure!(
signature_cursor.is_empty(),
"trailing data in ssh-agent signature blob"
);
anyhow::ensure!(
signature.len() == 64,
"ssh-agent Ed25519 signature was not 64 bytes"
);
Ok(signature.to_vec())
}
#[cfg(unix)]
fn read_agent_packet(stream: &mut UnixStream) -> Result<Vec<u8>> {
let mut len = [0u8; 4];
stream
@@ -145,6 +218,7 @@ fn read_agent_packet(stream: &mut UnixStream) -> Result<Vec<u8>> {
Ok(payload)
}
#[cfg(unix)]
fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> {
anyhow::ensure!(
payload.len() <= MAX_AGENT_PACKET,
@@ -155,6 +229,7 @@ fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn read_u8(cursor: &mut &[u8]) -> Result<u8> {
anyhow::ensure!(!cursor.is_empty(), "truncated u8");
let value = cursor[0];
@@ -162,6 +237,7 @@ fn read_u8(cursor: &mut &[u8]) -> Result<u8> {
Ok(value)
}
#[cfg(unix)]
fn read_u32(cursor: &mut &[u8]) -> Result<u32> {
anyhow::ensure!(cursor.len() >= 4, "truncated u32");
let value = u32::from_be_bytes(cursor[..4].try_into().unwrap());
@@ -169,6 +245,7 @@ fn read_u32(cursor: &mut &[u8]) -> Result<u32> {
Ok(value)
}
#[cfg(unix)]
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
let len = read_u32(cursor)? as usize;
anyhow::ensure!(cursor.len() >= len, "truncated SSH string");
@@ -177,12 +254,20 @@ fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
Ok(value)
}
#[cfg(unix)]
fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
out.extend_from_slice(value);
}
#[cfg(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 {
use super::*;
use crate::native::{
@@ -253,8 +338,8 @@ mod tests {
protocol_version: crate::native::NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(),
requested_user: "palav".to_string(),
requested_host: "homelab".to_string(),
requested_user: "alice".to_string(),
requested_session: "term".to_string(),
requested_mode: "read-write".to_string(),
terminal_size: (80, 24),
+161
View File
@@ -0,0 +1,161 @@
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
static TRACE_FILE: OnceLock<Option<Mutex<File>>> = OnceLock::new();
static TRACE_BYTES: OnceLock<bool> = OnceLock::new();
pub fn enabled() -> bool {
TRACE_FILE.get_or_init(open_trace_file).is_some()
}
pub fn bytes_enabled() -> bool {
*TRACE_BYTES.get_or_init(|| truthy_env("DOSH_TRACE_BYTES"))
}
pub fn event(name: &str, fields: &[(&str, String)]) {
let Some(file) = TRACE_FILE.get_or_init(open_trace_file) else {
return;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
let mut line = format!(
"ts_ms={} pid={} event={}",
now,
std::process::id(),
shell_escape(name)
);
for (key, value) in fields {
line.push(' ');
line.push_str(key);
line.push('=');
line.push_str(&shell_escape(value));
}
line.push('\n');
if let Ok(mut file) = file.lock() {
let _ = file.write_all(line.as_bytes());
let _ = file.flush();
}
}
pub fn bytes_summary(bytes: &[u8]) -> String {
let mut parts = vec![
format!("len={}", bytes.len()),
format!(
"esc={}",
contains_byte(bytes, 0x1b) || contains_byte(bytes, 0x9b)
),
format!("focus={}", has_focus_report(bytes)),
format!("mouseish={}", looks_mouseish(bytes)),
format!("printable={}", printable_count(bytes)),
];
if bytes_enabled() {
parts.push(format!("hex={}", hex_prefix(bytes, 160)));
}
parts.join(",")
}
fn open_trace_file() -> Option<Mutex<File>> {
let raw = std::env::var_os("DOSH_TRACE")?;
let normalized = raw.to_string_lossy().to_ascii_lowercase();
if normalized.is_empty() || matches!(normalized.as_str(), "0" | "false" | "off") {
return None;
}
let path = if matches!(normalized.as_str(), "1" | "true" | "on") {
default_trace_path()
} else {
PathBuf::from(raw)
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.ok()
.map(Mutex::new)
}
fn default_trace_path() -> PathBuf {
let root = dirs::cache_dir()
.unwrap_or_else(std::env::temp_dir)
.join("dosh")
.join("trace");
let exe = std::env::current_exe()
.ok()
.and_then(|path| {
path.file_stem()
.map(|stem| stem.to_string_lossy().to_string())
})
.unwrap_or_else(|| "dosh".to_string());
root.join(format!("{}-{}.log", exe, std::process::id()))
}
fn truthy_env(name: &str) -> bool {
std::env::var_os(name).is_some_and(|value| {
let normalized = value.to_string_lossy().to_ascii_lowercase();
!normalized.is_empty() && !matches!(normalized.as_str(), "0" | "false" | "off")
})
}
fn shell_escape(value: &str) -> String {
if value.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(byte, b'.' | b'-' | b'_' | b':' | b'/' | b',' | b'=')
}) {
return value.to_string();
}
let mut out = String::from("'");
for ch in value.chars() {
if ch == '\'' {
out.push_str("'\\''");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
fn contains_byte(bytes: &[u8], needle: u8) -> bool {
bytes.contains(&needle)
}
fn has_focus_report(bytes: &[u8]) -> bool {
bytes
.windows(3)
.any(|window| matches!(window, b"\x1b[I" | b"\x1b[O"))
|| bytes
.windows(2)
.any(|window| matches!(window, b"\x9bI" | b"\x9bO"))
}
fn looks_mouseish(bytes: &[u8]) -> bool {
bytes.windows(3).any(|window| window == b"\x1b[<")
|| bytes.windows(2).any(|window| window == b"\x9b<")
|| bytes.iter().filter(|byte| **byte == b';').take(3).count() >= 2
}
fn printable_count(bytes: &[u8]) -> usize {
bytes
.iter()
.filter(|byte| byte.is_ascii_graphic() || **byte == b' ')
.count()
}
fn hex_prefix(bytes: &[u8], max: usize) -> String {
let mut out = String::with_capacity(bytes.len().min(max) * 2);
for byte in bytes.iter().take(max) {
use std::fmt::Write as _;
let _ = write!(&mut out, "{byte:02x}");
}
if bytes.len() > max {
out.push_str("...");
}
out
}
+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));
}
}
}
File diff suppressed because it is too large Load Diff
+2071 -20
View File
File diff suppressed because it is too large Load Diff
+415
View File
@@ -0,0 +1,415 @@
//! Parser robustness tests (Track B, spec milestone 5 / §16 "Fuzz packet parsing").
//!
//! These throw arbitrary/garbage bytes at every reachable public parser in the
//! `dosh` library and assert that NONE of them panic. A parser is allowed to
//! return `Ok` (if the bytes happened to be valid) or `Err`, but a panic on
//! untrusted input is a denial-of-service / robustness bug against a hostile
//! network attacker (threat model §5: "Active network attacker that can spoof
//! ... or modify packets").
//!
//! Determinism: a fixed-seed PRNG (`rand::rngs::StdRng`) is used so failures are
//! reproducible. No external dependencies beyond what is already in Cargo.toml.
use std::panic::{self, AssertUnwindSafe};
use dosh::auth::{
AttachTicketPlain, BootstrapResponse, SealedAttachTicket, decode_bootstrap, open_attach_ticket,
verify_attach_ticket,
};
use dosh::native::{
AuthorizedKey, HostPublicKey, KnownHost, NativeAuthOk, NativeClientHello, NativeServerHello,
NativeUserAuth, parse_authorized_keys, parse_host_public_key_line, parse_known_hosts,
parse_ssh_ed25519_public_blob, verify_known_host,
};
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, Frame, Header, Input,
NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody,
NativeUserAuthBody, Packet, Resize, ResumeRequest, StreamClose, StreamData, StreamEof,
StreamOpen, StreamOpenOk, StreamOpenReject, StreamWindowAdjust, TicketAttachBody,
TicketAttachEnvelope, TicketAttachOkEnvelope,
};
use rand::rngs::StdRng;
use rand::{Rng, RngCore, SeedableRng};
const ITERATIONS: usize = 4000;
/// Run `f` and convert a panic into a test failure with a descriptive message.
fn no_panic<F: FnOnce()>(label: &str, input: &[u8], f: F) {
let result = panic::catch_unwind(AssertUnwindSafe(f));
assert!(
result.is_ok(),
"parser `{label}` PANICKED on input ({} bytes): {:02x?}",
input.len(),
input,
);
}
/// Generate a variety of "interesting" byte buffers for a given iteration.
fn fuzz_bytes(rng: &mut StdRng) -> Vec<u8> {
let strategy = rng.gen_range(0..7u8);
match strategy {
0 => {
let len = rng.gen_range(0..1200);
let mut buf = vec![0u8; len];
rng.fill_bytes(&mut buf);
buf
}
1 => {
let len = rng.gen_range(0..16);
let mut buf = vec![0u8; len];
rng.fill_bytes(&mut buf);
buf
}
2 => {
let len = rng.gen_range(0..(protocol::HEADER_LEN + 64));
let mut buf = vec![0u8; len];
rng.fill_bytes(&mut buf);
buf
}
3 => vec![0u8; rng.gen_range(0..256)],
4 => vec![0xffu8; rng.gen_range(0..256)],
5 => {
// A valid-magic prefix followed by garbage to drive deeper paths.
let mut buf = Vec::new();
buf.extend_from_slice(protocol::MAGIC);
buf.push(protocol::VERSION);
let extra = rng.gen_range(0..256);
let mut tail = vec![0u8; extra];
rng.fill_bytes(&mut tail);
buf.extend_from_slice(&tail);
buf
}
_ => {
// Large length prefixes to provoke huge allocations / overflow in
// length fields (a classic deserialization hazard).
let mut buf = Vec::new();
buf.extend_from_slice(&u64::MAX.to_le_bytes());
let extra = rng.gen_range(0..64);
let mut tail = vec![0u8; extra];
rng.fill_bytes(&mut tail);
buf.extend_from_slice(&tail);
buf
}
}
}
/// Generate a possibly-valid UTF-8 string from random bytes (for text parsers).
fn fuzz_text(rng: &mut StdRng) -> String {
let len = rng.gen_range(0..256);
let mut s = String::new();
for _ in 0..len {
let pick = rng.gen_range(0..10u8);
let ch = match pick {
0 => ' ',
1 => '\n',
2 => '\t',
3 => '=',
4 => ',',
5 => '"',
6 => '/',
7 => rng.gen_range(b'a'..=b'z') as char,
8 => rng.gen_range(b'0'..=b'9') as char,
_ => char::from_u32(rng.gen_range(0..0x110000)).unwrap_or('?'),
};
s.push(ch);
}
s
}
#[test]
fn protocol_packet_decode_never_panics() {
let mut rng = StdRng::seed_from_u64(0xD05Au64);
for _ in 0..ITERATIONS {
let input = fuzz_bytes(&mut rng);
no_panic("protocol::decode", &input, || {
let _ = protocol::decode(&input);
});
no_panic("Header::parse", &input, || {
let _ = Header::parse(&input);
});
}
}
/// `from_body` deserializes a bincode body into each protocol/native struct.
/// On the wire this runs on attacker-controlled bytes, so it must never panic.
#[test]
fn protocol_from_body_never_panics() {
let mut rng = StdRng::seed_from_u64(0xBEEFu64);
macro_rules! body_target {
($input:expr, $ty:ty) => {{
let input = $input;
no_panic(concat!("from_body::<", stringify!($ty), ">"), input, || {
let _ = protocol::from_body::<$ty>(input);
});
}};
}
for _ in 0..ITERATIONS {
let input = fuzz_bytes(&mut rng);
let input = input.as_slice();
// protocol.rs structs
body_target!(input, BootstrapAttachRequest);
body_target!(input, TicketAttachEnvelope);
body_target!(input, TicketAttachBody);
body_target!(input, TicketAttachOkEnvelope);
body_target!(input, AttachOk);
body_target!(input, AttachReject);
body_target!(input, ResumeRequest);
body_target!(input, Input);
body_target!(input, Resize);
body_target!(input, Frame);
body_target!(input, StreamOpen);
body_target!(input, StreamOpenOk);
body_target!(input, StreamOpenReject);
body_target!(input, StreamData);
body_target!(input, StreamWindowAdjust);
body_target!(input, StreamEof);
body_target!(input, StreamClose);
// native handshake wrapper bodies
body_target!(input, NativeClientHelloBody);
body_target!(input, NativeServerHelloBody);
body_target!(input, NativeUserAuthBody);
body_target!(input, NativeAuthOkBody);
body_target!(input, NativeAuthCheckOkBody);
// bare native handshake structs
body_target!(input, NativeClientHello);
body_target!(input, NativeServerHello);
body_target!(input, NativeUserAuth);
body_target!(input, NativeAuthOk);
body_target!(input, HostPublicKey);
// auth.rs structs (deserialized from untrusted material too)
body_target!(input, BootstrapResponse);
body_target!(input, SealedAttachTicket);
body_target!(input, AttachTicketPlain);
}
}
/// Full decode -> decrypt_body pipeline on garbage. decrypt should Err (not
/// panic) on bad ciphertext / wrong key id / truncated body.
#[test]
fn protocol_decode_then_decrypt_never_panics() {
let mut rng = StdRng::seed_from_u64(0x1234_5678u64);
let key = [7u8; 32];
for _ in 0..ITERATIONS {
let input = fuzz_bytes(&mut rng);
no_panic("decode+decrypt_body", &input, || {
if let Ok(packet) = protocol::decode(&input) {
let _ = protocol::decrypt_body(&packet, &key, protocol::CLIENT_TO_SERVER);
let _ = protocol::decrypt_body(&packet, &key, protocol::SERVER_TO_CLIENT);
}
});
}
}
/// Mutate a single byte of a valid encrypted packet; decode and decrypt must
/// not panic, and decryption of the mutated packet must fail (no double-apply).
#[test]
fn protocol_bit_flips_on_valid_packet_never_panic() {
let mut rng = StdRng::seed_from_u64(0x900Du64);
let key = [9u8; 32];
let conn_id = [3u8; 16];
for _ in 0..1000 {
let mut plaintext = vec![0u8; rng.gen_range(0..200)];
rng.fill_bytes(&mut plaintext);
let seq = rng.gen_range(1..u64::MAX);
let Ok(mut packet) = protocol::encode_encrypted(
protocol::PacketKind::Input,
conn_id,
seq,
0,
&key,
protocol::CLIENT_TO_SERVER,
&plaintext,
) else {
continue;
};
if packet.is_empty() {
continue;
}
let idx = rng.gen_range(0..packet.len());
packet[idx] ^= 1 << rng.gen_range(0..8);
no_panic("flip+decode+decrypt", &packet, || {
if let Ok(decoded) = protocol::decode(&packet) {
let _ = protocol::decrypt_body(&decoded, &key, protocol::CLIENT_TO_SERVER);
}
});
}
}
#[test]
fn ssh_ed25519_blob_parser_never_panics() {
let mut rng = StdRng::seed_from_u64(0x5511u64);
for _ in 0..ITERATIONS {
let input = fuzz_bytes(&mut rng);
no_panic("parse_ssh_ed25519_public_blob", &input, || {
let _ = parse_ssh_ed25519_public_blob(&input);
});
}
// Targeted: length prefixes that lie about the body length.
for bad_len in [0u32, 1, 31, 32, 33, u32::MAX, u32::MAX - 1] {
let mut buf = Vec::new();
buf.extend_from_slice(&bad_len.to_be_bytes());
buf.extend_from_slice(b"ssh-ed25519");
buf.extend_from_slice(&32u32.to_be_bytes());
buf.extend_from_slice(&[0u8; 16]);
no_panic("parse_ssh_ed25519_public_blob:lying-len", &buf, || {
let _ = parse_ssh_ed25519_public_blob(&buf);
});
}
}
#[test]
fn authorized_keys_parser_never_panics() {
let mut rng = StdRng::seed_from_u64(0xA011u64);
for _ in 0..ITERATIONS {
let text = fuzz_text(&mut rng);
no_panic("parse_authorized_keys", text.as_bytes(), || {
let _ = parse_authorized_keys(&text);
});
}
let crafted = [
"ssh-ed25519",
"ssh-ed25519 ",
"ssh-ed25519 not-base64!!!",
"from= ssh-ed25519 AAAA",
"from=\"unterminated ssh-ed25519 AAAA",
"command=\"x\\\" ssh-ed25519 AAAA",
"permitopen=,,, ssh-ed25519 AAAA",
"restrict,no-port-forwarding,from=\"127.0.0.1\" ssh-ed25519 AAAA comment",
"ssh-rsa AAAA",
"\u{0}\u{0}\u{0} ssh-ed25519 AAAA",
];
for line in crafted {
no_panic("parse_authorized_keys:crafted", line.as_bytes(), || {
let _ = parse_authorized_keys(line);
});
}
}
#[test]
fn known_hosts_parser_never_panics() {
let mut rng = StdRng::seed_from_u64(0xC051u64);
for _ in 0..ITERATIONS {
let text = fuzz_text(&mut rng);
no_panic("parse_known_hosts", text.as_bytes(), || {
let _ = parse_known_hosts(&text);
});
}
let crafted = [
"host",
"host dosh-ed25519",
"host dosh-ed25519 not-base64!!!",
"host wrong-algo AAAA",
"host dosh-ed25519 AAAA first-seen=notnum source=tofu",
"host dosh-ed25519 AAAA first-seen= source=",
"* dosh-ed25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
];
for line in crafted {
no_panic("parse_known_hosts:crafted", line.as_bytes(), || {
let _ = parse_known_hosts(line);
});
}
}
#[test]
fn host_public_key_line_parser_never_panics() {
let mut rng = StdRng::seed_from_u64(0x4002u64);
for _ in 0..ITERATIONS {
let text = fuzz_text(&mut rng);
no_panic("parse_host_public_key_line", text.as_bytes(), || {
let _ = parse_host_public_key_line(&text);
});
}
}
#[test]
fn decode_bootstrap_never_panics() {
let mut rng = StdRng::seed_from_u64(0xB007u64);
for _ in 0..ITERATIONS {
let text = fuzz_text(&mut rng);
no_panic("decode_bootstrap", text.as_bytes(), || {
let _ = decode_bootstrap(&text);
});
// Also feed base64-shaped random for the decode path proper.
let raw = fuzz_bytes(&mut rng);
use base64::Engine;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&raw);
no_panic("decode_bootstrap:b64", b64.as_bytes(), || {
let _ = decode_bootstrap(&b64);
});
}
}
#[test]
fn attach_ticket_open_and_verify_never_panic() {
let mut rng = StdRng::seed_from_u64(0x7CE7u64);
let secret = [42u8; 32];
let psk = [11u8; 32];
for _ in 0..ITERATIONS {
let input = fuzz_bytes(&mut rng);
no_panic("open_attach_ticket", &input, || {
let _ = open_attach_ticket(&secret, &input);
});
no_panic("verify_attach_ticket", &input, || {
let _ = verify_attach_ticket(&secret, &input, &psk, "default", "read-write");
});
}
}
/// Throw garbage at the known-host verifier (file parse + host key compare).
#[test]
fn verify_known_host_with_garbage_keys_never_panics() {
let mut rng = StdRng::seed_from_u64(0x9090u64);
let dir = tempfile::tempdir().unwrap();
for _ in 0..500 {
let text = fuzz_text(&mut rng);
let path = dir.path().join("known_hosts");
std::fs::write(&path, &text).unwrap();
let mut key_bytes = [0u8; 32];
rng.fill_bytes(&mut key_bytes);
let host = HostPublicKey {
algorithm: "dosh-ed25519".to_string(),
key: key_bytes,
};
let host_name = fuzz_text(&mut rng);
no_panic("verify_known_host", text.as_bytes(), || {
let _ = verify_known_host(&path, &host_name, &host);
});
}
}
/// Regression guard: valid inputs still parse, so the fuzz harness isn't
/// accidentally exercising a build where every path simply Errs.
#[test]
fn valid_inputs_still_parse() {
let key = [5u8; 32];
let blob = dosh::native::ssh_ed25519_public_blob(&key);
assert_eq!(parse_ssh_ed25519_public_blob(&blob).unwrap(), key);
let session_key = [1u8; 32];
let packet = protocol::encode_encrypted(
protocol::PacketKind::Input,
[2u8; 16],
1,
0,
&session_key,
protocol::CLIENT_TO_SERVER,
b"hello",
)
.unwrap();
let decoded: Packet = protocol::decode(&packet).unwrap();
let plain = protocol::decrypt_body(&decoded, &session_key, protocol::CLIENT_TO_SERVER).unwrap();
assert_eq!(plain, b"hello");
assert!(parse_authorized_keys("").unwrap().is_empty());
assert!(parse_known_hosts("# just a comment\n").unwrap().is_empty());
// Reference types only otherwise used in macro expansions / signatures.
let _ = std::mem::size_of::<AuthorizedKey>();
let _ = std::mem::size_of::<KnownHost>();
}
+146
View File
@@ -58,6 +58,19 @@ fn encrypted_packet_round_trips() {
assert_eq!(plain, b"hello");
}
#[test]
fn plaintext_packet_never_decrypts_as_authenticated_body() {
let key = crypto::random_32();
let mut decoded = protocol::decode(
&protocol::encode_plain(PacketKind::Input, crypto::random_16(), 1, 0, b"hello").unwrap(),
)
.unwrap();
decoded.header.session_key_id = protocol::session_key_id(&key);
let err = protocol::decrypt_body(&decoded, &key, CLIENT_TO_SERVER).unwrap_err();
assert!(err.to_string().contains("not encrypted"));
}
#[test]
fn encrypted_packet_rejects_wrong_session_key_id_before_decrypt() {
let key = crypto::random_32();
@@ -126,6 +139,139 @@ fn attach_ticket_is_sealed_and_verifies_scope() {
);
}
#[test]
fn peek_foreign_wire_version_flags_only_version_skew() {
let key = crypto::random_32();
let mut packet = protocol::encode_encrypted(
PacketKind::Input,
crypto::random_16(),
1,
0,
&key,
CLIENT_TO_SERVER,
b"hi",
)
.unwrap();
// A correctly framed packet for this build is not "foreign".
assert_eq!(protocol::peek_foreign_wire_version(&packet), None);
// Bumping the wire version byte makes it undecodable but recognizable.
packet[4] = protocol::VERSION.wrapping_add(7);
assert_eq!(
protocol::peek_foreign_wire_version(&packet),
Some(protocol::VERSION.wrapping_add(7))
);
assert!(protocol::decode(&packet).is_err());
// Non-Dosh datagrams and runts are ignored.
assert_eq!(protocol::peek_foreign_wire_version(b"XXXX\x01"), None);
assert_eq!(protocol::peek_foreign_wire_version(b"DOS"), None);
}
#[test]
fn rekey_key_derivation_agrees_and_is_independent_per_epoch() {
use dosh::native::derive_rekey_session_key;
// The handshake/current key both peers already share.
let current_key = crypto::random_32();
let current_id = protocol::session_key_id(&current_key);
// Fresh server-generated material, delivered confidentially in the Rekey.
let material = crypto::random_32();
// Both peers derive identically from shared current key + shipped material.
let server_view = derive_rekey_session_key(&current_key, &material, &current_id, 1).unwrap();
let client_view = derive_rekey_session_key(&current_key, &material, &current_id, 1).unwrap();
assert_eq!(server_view, client_view);
// The rotated key must not equal the handshake/current key.
assert_ne!(server_view, current_key);
// A different epoch (or different material) yields a different key.
let next_epoch = derive_rekey_session_key(&current_key, &material, &current_id, 2).unwrap();
assert_ne!(server_view, next_epoch);
let other_material = crypto::random_32();
let other = derive_rekey_session_key(&current_key, &other_material, &current_id, 1).unwrap();
assert_ne!(server_view, other);
}
#[test]
fn rekey_round_trip_decrypts_old_and_new_epoch_packets() {
use dosh::native::derive_rekey_session_key;
let key_epoch0 = crypto::random_32();
let id0 = protocol::session_key_id(&key_epoch0);
let conn_id = crypto::random_16();
// Pre-rekey packet sealed under epoch-0 key.
let pre = protocol::encode_encrypted(
PacketKind::Frame,
conn_id,
5,
0,
&key_epoch0,
protocol::SERVER_TO_CLIENT,
b"before",
)
.unwrap();
// Rotate to epoch 1.
let material = crypto::random_32();
let key_epoch1 = derive_rekey_session_key(&key_epoch0, &material, &id0, 1).unwrap();
let post = protocol::encode_encrypted(
PacketKind::Frame,
conn_id,
6,
0,
&key_epoch1,
protocol::SERVER_TO_CLIENT,
b"after",
)
.unwrap();
let pre = protocol::decode(&pre).unwrap();
let post = protocol::decode(&post).unwrap();
// Each epoch's key carries its own session_key_id; the receiver picks the
// right one and both decrypt correctly.
assert_eq!(pre.header.session_key_id, id0);
assert_eq!(
pre.header.session_key_id,
protocol::session_key_id(&key_epoch0)
);
assert_eq!(
post.header.session_key_id,
protocol::session_key_id(&key_epoch1)
);
assert_eq!(
protocol::decrypt_body(&pre, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap(),
b"before"
);
assert_eq!(
protocol::decrypt_body(&post, &key_epoch1, protocol::SERVER_TO_CLIENT).unwrap(),
b"after"
);
// A stale-epoch packet under the wrong key is rejected via session_key_id
// BEFORE any AEAD work — ignorable, not a fatal decrypt error.
let err = protocol::decrypt_body(&post, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap_err();
assert!(err.to_string().contains("session key id"));
}
#[test]
fn check_native_protocol_version_names_the_mismatch() {
use dosh::native::{NATIVE_PROTOCOL_VERSION, check_native_protocol_version};
check_native_protocol_version(NATIVE_PROTOCOL_VERSION, "server").unwrap();
let err = check_native_protocol_version(NATIVE_PROTOCOL_VERSION.wrapping_add(1), "server")
.unwrap_err();
let message = err.to_string();
assert!(
message.contains(protocol::VERSION_MISMATCH_REASON),
"expected actionable upgrade message, got {message:?}"
);
assert!(
message.contains("server"),
"should name the wrong peer: {message:?}"
);
}
#[test]
fn replay_window_rejects_duplicates_but_allows_bounded_out_of_order() {
let mut replay = ReplayWindow::new(8);
+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."
}
}
}
}
}