Compare commits

...
58 Commits
Author SHA1 Message Date
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
34 changed files with 9084 additions and 440 deletions
+1
View File
@@ -1,3 +1,4 @@
/target/
**/*.rs.bk
.DS_Store
vscode-extension/*.vsix
Generated
+12 -1
View File
@@ -436,7 +436,7 @@ dependencies = [
[[package]]
name = "dosh"
version = "0.1.2"
version = "1.0.0-rc15"
dependencies = [
"anyhow",
"base64",
@@ -447,6 +447,7 @@ dependencies = [
"crossterm",
"dirs",
"ed25519-dalek",
"filetime",
"hkdf",
"hmac",
"libc",
@@ -577,6 +578,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "foldhash"
version = "0.1.5"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "dosh"
version = "0.1.2"
version = "1.0.0-rc15"
edition = "2024"
license = "MIT"
@@ -14,6 +14,7 @@ clap = { version = "4.5", features = ["derive"] }
crossterm = "0.28"
dirs = "5.0"
ed25519-dalek = "2.1"
filetime = "0.2"
hkdf = "0.12"
hmac = "0.12"
libc = "0.2"
+30 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build test fmt install package-release package-release-linux package-release-windows publish-release bench-report bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local tui-harness
.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,6 +9,35 @@ test:
fmt:
cargo fmt
clippy:
cargo clippy --all-targets -- -D warnings
release-check:
cargo fmt --check
sh -n install.sh scripts/*.sh
cargo clippy --all-targets -- -D warnings
cargo test
$(MAKE) tui-harness
1.0-check: release-check reconnect-check hostile-network-check persistence-check package-check
reconnect-check:
cargo test --test integration_smoke resume_updates_udp_endpoint_for_roaming -- --nocapture
DOSH_SOAK_SECONDS=$${DOSH_SOAK_SECONDS:-300} cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture
hostile-network-check:
cargo test --test hostile_network -- --nocapture
DOSH_BADNET_SOAK_SECONDS=$${DOSH_BADNET_SOAK_SECONDS:-300} cargo test --test hostile_network bad_network_tui_work_soak_30m -- --ignored --nocapture
persistence-check:
cargo test --test integration_smoke session_survives_server_restart_same_shell_and_screen -- --nocapture
cargo test --test integration_smoke multiple_persistent_named_sessions_survive_restart_independently -- --nocapture
package-check:
$(MAKE) package-release-linux
$(MAKE) package-release-windows
sh scripts/verify-release-artifacts.sh
install:
sh install.sh --from-current
+59 -29
View File
@@ -1,32 +1,31 @@
# Dosh
Dosh is an encrypted remote terminal for fast reconnecting shells. It is meant
to replace Mosh and day-to-day interactive SSH sessions.
Dosh is an encrypted remote terminal for fast reconnecting shells.
It runs a `dosh-server` on the remote machine and a `dosh` client locally. The
first setup can use SSH. After that, Dosh can attach over encrypted UDP with
cached credentials, keep terminal sessions alive, reconnect after network
changes, and forward TCP ports.
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.
## Support
- Client: macOS, Linux, Windows
- Server: Unix-like systems with PTYs
- Server: Linux and other Unix-like systems with PTYs
- Default UDP port: `50000`
- Windows: client only
- UDP port: one configured server port, default `50000`
Dosh is not an SCP/SFTP client and does not implement X11 forwarding. Windows
is client-only.
Dosh is meant to replace Mosh and everyday interactive SSH sessions. It does
not currently include SFTP, X11 forwarding, or a Windows server.
## Install
Server and client on Unix/macOS:
Unix/macOS server and client:
```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
```
Client only on Unix/macOS:
Unix/macOS client only:
```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
@@ -38,30 +37,48 @@ Windows client:
irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
```
## Commands
## Use
```sh
dosh setup HOST # import SSH config and trust the Dosh host key
dosh HOST # connect
dosh HOST COMMAND # connect and run a command
dosh update # update Dosh
dosh status HOST # show remote tmux sessions and Dosh service status
dosh restart HOST # restart dosh-server and show service status
dosh doctor HOST # check config and connectivity
dosh recover HOST # clear cached attach state and re-check
dosh setup HOST
dosh HOST
dosh HOST COMMAND
dosh update
```
Examples:
Useful commands:
```sh
dosh palav
dosh palav tm
dosh forward palav -L 8080:127.0.0.1:80
dosh forward palav -D 1080
dosh forward palav -R 2222:127.0.0.1:22
dosh 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
```
Agent forwarding is opt-in with `-A` and must be enabled on the server.
Agent forwarding is opt-in with `-A` and must also be enabled on the server.
File copy must be enabled by the server config.
## VS Code
Dosh can carry VS Code Remote-SSH through its transport:
```sh
dosh vscode setup HOST
dosh vscode HOST /remote/path
```
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.
## Config
@@ -75,8 +92,21 @@ Common client settings:
```toml
default_session = "new"
auth_preference = "native,ssh"
predict = true
cache_attach_tickets = true
disconnect_status = true
```
## 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
```
+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?;
}
}
+46 -6
View File
@@ -1,7 +1,7 @@
param(
[ValidateSet("client")]
[string]$Role = $(if ($env:DOSH_ROLE) { $env:DOSH_ROLE } else { "client" }),
[string]$Repo = $env:DOSH_REPO,
[string]$Repo = $(if ($env:DOSH_REPO) { $env:DOSH_REPO } else { "https://git.palav.dev/Palav/dosh.git" }),
[string]$Server = $env:DOSH_SERVER,
[string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }),
[int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }),
@@ -116,6 +116,45 @@ function Verify-ArchiveChecksum($Url, $Archive) {
}
}
function Expected-ArchiveVersion($Url) {
if ($Url -match "/releases/download/v([^/]+)/") {
return $Matches[1]
}
if (-not $BinaryUrl -and -not $BinaryBase -and $BinaryVersion -ne "latest") {
return $BinaryVersion.TrimStart("v")
}
return $null
}
function Verify-ArchiveVersion($ExtractDir, $Url) {
$expected = Expected-ArchiveVersion $Url
if (-not $expected) {
return
}
$versionFile = Get-ChildItem -Path $ExtractDir -Recurse -File -Filter VERSION | Select-Object -First 1
if (-not $versionFile) {
throw "prebuilt archive missing VERSION for $Url"
}
$actual = (Get-Content $versionFile.FullName -Raw).Trim()
if ($actual -ne $expected) {
throw "prebuilt archive version mismatch for ${Url}: expected $expected, got $actual"
}
}
function Install-Binary($Source, $Destination) {
$dir = Split-Path -Parent $Destination
$name = Split-Path -Leaf $Destination
$tmp = Join-Path $dir ".$name.tmp.$PID"
Copy-Item $Source $tmp -Force
try {
Move-Item $tmp $Destination -Force
}
catch {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
throw
}
}
$bindir = Join-Path $Prefix "bin"
$configDir = Join-Path $HOME ".config\dosh"
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
@@ -137,14 +176,15 @@ function Install-Prebuilt {
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $zip
Verify-ArchiveChecksum $url $zip
Expand-Archive -Force -Path $zip -DestinationPath $extract
Verify-ArchiveVersion $extract $url
foreach ($bin in @("dosh-client.exe", "dosh-bench.exe")) {
$found = Get-ChildItem -Path $extract -Recurse -File -Filter $bin | Select-Object -First 1
if (-not $found) {
throw "prebuilt archive missing $bin"
}
Copy-Item $found.FullName (Join-Path $bindir $bin) -Force
Install-Binary $found.FullName (Join-Path $bindir $bin)
}
Copy-Item (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe") -Force
Install-Binary (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe")
return $true
}
catch {
@@ -176,9 +216,9 @@ function Install-FromSource {
try {
Push-Location $src
cargo build --release --bin dosh-client --bin dosh-bench
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force
Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe")
Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe")
Install-Binary "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe")
}
finally {
Pop-Location
+61 -16
View File
@@ -12,11 +12,7 @@ start_server=1
force_config=0
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
quiet="${DOSH_UPDATE_QUIET:-0}"
if [ "${DOSH_UPDATE_QUIET:-0}" = "1" ] && [ "${DOSH_BINARY_REQUIRED:-0}" != "1" ]; then
use_prebuilt=0
else
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
fi
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
binary_url="${DOSH_BINARY_URL:-}"
binary_base="${DOSH_BINARY_BASE:-}"
binary_name="${DOSH_BINARY_NAME:-}"
@@ -230,13 +226,26 @@ find_extracted_binary() {
find "$1" -type f -name "$2" 2>/dev/null | sed -n '1p'
}
install_binary() {
src="$1"
dst="$2"
dst_dir="$(dirname "$dst")"
dst_base="$(basename "$dst")"
tmp="$dst_dir/.$dst_base.tmp.$$"
install -m 0755 "$src" "$tmp"
if ! mv -f "$tmp" "$dst"; then
rm -f "$tmp"
return 1
fi
}
install_extracted_binary() {
found="$(find_extracted_binary "$1" "$2")"
if [ -z "$found" ]; then
echo "prebuilt archive missing $2" >&2
return 1
fi
install -m 0755 "$found" "$3"
install_binary "$found" "$3"
}
sha256_file() {
@@ -268,6 +277,40 @@ verify_archive_checksum() {
fi
}
expected_archive_version() {
url="$1"
case "$url" in
*/releases/download/v*/*)
tag="${url#*/releases/download/v}"
tag="${tag%%/*}"
printf '%s\n' "$tag"
return 0
;;
esac
if [ -z "$binary_url" ] && [ -z "$binary_base" ] && [ "$binary_version" != "latest" ]; then
printf '%s\n' "${binary_version#v}"
return 0
fi
return 1
}
verify_archive_version() {
extract_dir="$1"
url="$2"
expected="$(expected_archive_version "$url" || true)"
[ -n "$expected" ] || return 0
version_file="$(find "$extract_dir" -type f -name VERSION 2>/dev/null | sed -n '1p')"
if [ -z "$version_file" ]; then
echo "prebuilt archive missing VERSION for $url" >&2
return 1
fi
actual="$(tr -d '\r\n' <"$version_file")"
if [ "$actual" != "$expected" ]; then
echo "prebuilt archive version mismatch for $url: expected $expected, got $actual" >&2
return 1
fi
}
try_install_prebuilt() {
download_url="$(release_latest_tag_download_url || release_download_url)" || return 1
tmpdir="$(mktemp -d)"
@@ -291,6 +334,7 @@ try_install_prebuilt() {
echo "prebuilt archive could not be extracted: $download_url" >&2
return 1
fi
verify_archive_version "$tmpdir/extract" "$download_url" || return 1
install_extracted_binary "$tmpdir/extract" dosh-client "$bindir/dosh-client" || return 1
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
@@ -298,7 +342,7 @@ try_install_prebuilt() {
install_extracted_binary "$tmpdir/extract" dosh-auth "$bindir/dosh-auth" || return 1
fi
if found_bench="$(find_extracted_binary "$tmpdir/extract" dosh-bench)" && [ -n "$found_bench" ]; then
install -m 0755 "$found_bench" "$bindir/dosh-bench"
install_binary "$found_bench" "$bindir/dosh-bench"
fi
return 0
}
@@ -357,14 +401,14 @@ install_from_source() {
fi
fi
install -m 0755 target/release/dosh-client "$bindir/dosh-client"
install_binary target/release/dosh-client "$bindir/dosh-client"
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install -m 0755 target/release/dosh-server "$bindir/dosh-server"
install -m 0755 target/release/dosh-auth "$bindir/dosh-auth"
install_binary target/release/dosh-server "$bindir/dosh-server"
install_binary target/release/dosh-auth "$bindir/dosh-auth"
fi
if [ -f target/release/dosh-bench ]; then
install -m 0755 target/release/dosh-bench "$bindir/dosh-bench"
install_binary target/release/dosh-bench "$bindir/dosh-bench"
fi
}
@@ -375,14 +419,14 @@ write_systemd_service() {
else
cat >"$systemd_user_dir/dosh-server.service" <<EOF
[Unit]
Description=Dosh server
Description=dosh dormant shell server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$bindir/dosh-server serve
Restart=on-failure
Restart=always
RestartSec=1
KillMode=process
@@ -444,7 +488,8 @@ EOF
mkdir -p "$systemd_user_dir"
write_systemd_service
if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then
systemctl --user enable --now dosh-server.service
systemctl --user enable dosh-server.service
systemctl --user restart dosh-server.service
fi
elif [ "$start_server" -eq 1 ]; then
nohup "$bindir/dosh-server" serve >"$data_dir/dosh-server.log" 2>&1 &
@@ -512,8 +557,8 @@ EOF
fi
cat >"$hosts_config" <<EOF
# Example:
# [homelab]
# ssh = "homelab"
# [server]
# ssh = "server"
# dosh_host = "server.example.com"
# port = 50000
# default_command = "tm"
+1 -10
View File
@@ -6,18 +6,9 @@ Wants=network-online.target
[Service]
Type=simple
ExecStart=%h/.local/bin/dosh-server serve
Restart=on-failure
Restart=always
RestartSec=1
KillMode=process
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
[Install]
WantedBy=default.target
+2
View File
@@ -67,6 +67,7 @@ cleanup() {
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
fi
pkill -f "$server_bin hold --runtime-dir $home/sessions/run" 2>/dev/null || true
rm -rf "$home"
}
trap cleanup EXIT INT TERM
@@ -86,6 +87,7 @@ scrollback = 5000
auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600
allow_attach_tickets = true
persist_sessions = false
client_timeout_secs = 60
retransmit_window = 256
default_input_mode = "read-write"
+20
View File
@@ -99,6 +99,21 @@ 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")"
@@ -107,6 +122,11 @@ for artifact in target/dosh-release/dosh-linux-*.tar.gz target/dosh-release/dosh
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"
+2
View File
@@ -8,6 +8,8 @@ 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
+43
View File
@@ -30,6 +30,22 @@ 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")"
@@ -51,6 +67,11 @@ if [ "$#" -eq 0 ]; then
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
@@ -102,12 +123,34 @@ delete_existing_asset() {
done
}
verify_release_artifact_version() {
artifact="$1"
name="$(basename "$artifact")"
case "$name" in
*.sha256)
return 0
;;
dosh-*.tar.gz|dosh-*.zip)
actual="$(artifact_version "$artifact" || true)"
if [ -z "$actual" ]; then
echo "release artifact missing dosh/VERSION: $artifact" >&2
exit 1
fi
if [ "$actual" != "$expected_version" ]; then
echo "release artifact version mismatch: $artifact has $actual, expected $expected_version" >&2
exit 1
fi
;;
esac
}
uploaded=0
for artifact in "$@"; do
if [ ! -f "$artifact" ]; then
continue
fi
name="$(basename "$artifact")"
verify_release_artifact_version "$artifact"
delete_existing_asset "$name"
echo "uploading $name"
curl -fsS \
+46
View File
@@ -0,0 +1,46 @@
#!/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
}
failed=0
for artifact in \
"$out_dir/dosh-linux-x86_64.tar.gz" \
"$out_dir/dosh-macos-aarch64.tar.gz" \
"$out_dir/dosh-windows-x86_64.zip"; 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"
+2900 -97
View File
File diff suppressed because it is too large Load Diff
+1395 -239
View File
File diff suppressed because it is too large Load Diff
+442
View File
@@ -0,0 +1,442 @@
use crate::config::{
ClientConfig, HostConfig, expand_tilde, load_client_config, load_hosts_config,
};
use crate::crypto;
use crate::native::{
self, EnvVar, ForwardingKind, ForwardingRequest, KnownHostStatus, NativeClientHello,
derive_native_session_key, generate_native_ephemeral, sign_user_auth_with_private_key,
supported_user_key_algorithms, trust_host, verify_known_host, verify_server_hello,
};
use crate::protocol::{
self, AttachReject, CLIENT_TO_SERVER, NativeAuthOkBody, NativeClientHelloBody,
NativeServerHelloBody, NativeUserAuthBody, PacketKind, SERVER_TO_CLIENT,
};
use crate::ssh_agent;
use crate::transport::{DoshTransport, SessionRole, SessionTransportConfig, TransportConfig};
use anyhow::{Context, Result, anyhow, bail};
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::net::UdpSocket;
#[derive(Debug, Clone)]
pub struct DoshClient {
config: ClientConfig,
hosts: crate::config::HostsConfig,
}
impl DoshClient {
pub fn load() -> Result<Self> {
Ok(Self {
config: load_client_config(None)?,
hosts: load_hosts_config(None)?,
})
}
pub fn load_from_paths(
client_config: Option<PathBuf>,
hosts_config: Option<PathBuf>,
) -> Result<Self> {
Ok(Self {
config: load_client_config(client_config)?,
hosts: load_hosts_config(hosts_config)?,
})
}
pub fn with_config(config: ClientConfig, hosts: crate::config::HostsConfig) -> Self {
Self { config, hosts }
}
pub fn connect(&self, host: impl Into<String>) -> DoshClientBuilder {
DoshClientBuilder::new(self.clone(), host.into())
}
}
#[derive(Debug, Clone)]
pub struct DoshClientBuilder {
client: DoshClient,
host: String,
services: Vec<String>,
identity_files: Vec<PathBuf>,
session: Option<String>,
user: Option<String>,
udp_host: Option<String>,
udp_port: Option<u16>,
trust_on_first_use: Option<bool>,
use_ssh_agent: Option<bool>,
timeout: Option<Duration>,
env: Vec<EnvVar>,
}
impl DoshClientBuilder {
pub fn new(client: DoshClient, host: String) -> Self {
Self {
client,
host,
services: Vec::new(),
identity_files: Vec::new(),
session: None,
user: None,
udp_host: None,
udp_port: None,
trust_on_first_use: None,
use_ssh_agent: None,
timeout: None,
env: Vec::new(),
}
}
pub fn service(mut self, name: impl Into<String>) -> Self {
self.services.push(name.into());
self
}
pub fn services(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.services.extend(names.into_iter().map(Into::into));
self
}
pub fn identity_file(mut self, path: impl Into<PathBuf>) -> Self {
self.identity_files.push(path.into());
self
}
pub fn session(mut self, session: impl Into<String>) -> Self {
self.session = Some(session.into());
self
}
pub fn user(mut self, user: impl Into<String>) -> Self {
self.user = Some(user.into());
self
}
pub fn udp_host(mut self, host: impl Into<String>) -> Self {
self.udp_host = Some(host.into());
self
}
pub fn udp_port(mut self, port: u16) -> Self {
self.udp_port = Some(port);
self
}
pub fn trust_on_first_use(mut self, trust: bool) -> Self {
self.trust_on_first_use = Some(trust);
self
}
pub fn use_ssh_agent(mut self, value: bool) -> Self {
self.use_ssh_agent = Some(value);
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn env(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.env.push(EnvVar {
name: name.into(),
value: value.into(),
});
self
}
pub async fn connect(self) -> Result<ConnectedDoshClient> {
let host_config = self
.client
.hosts
.hosts
.get(&self.host)
.cloned()
.unwrap_or_default();
let raw_server = host_config.ssh.clone().unwrap_or_else(|| self.host.clone());
let requested_user = self
.user
.clone()
.or_else(|| host_config.user.clone())
.or_else(|| user_from_destination(&raw_server))
.or_else(|| std::env::var("USER").ok())
.unwrap_or_else(|| "unknown".to_string());
let udp_host = self
.udp_host
.clone()
.or_else(|| host_config.dosh_host.clone())
.or_else(|| self.client.config.dosh_host.clone())
.unwrap_or_else(|| destination_host(&raw_server));
let udp_port = self
.udp_port
.or(host_config.port)
.unwrap_or(self.client.config.dosh_port);
let peer_addr = resolve_addr(&udp_host, udp_port)?;
let timeout = self.timeout.unwrap_or_else(|| {
Duration::from_millis(self.client.config.native_auth_timeout_ms.max(1))
});
let session = self.session.unwrap_or_else(default_sdk_session);
let requested_forwardings = self
.services
.iter()
.map(|service| {
Ok(ForwardingRequest {
kind: ForwardingKind::Local,
bind_host: None,
listen_port: 0,
target_host: Some(crate::transport::service_target(service)?),
target_port: Some(0),
})
})
.collect::<Result<Vec<_>>>()?;
let socket = UdpSocket::bind("0.0.0.0:0").await?;
let (client_secret, client_public) = generate_native_ephemeral();
let hello = NativeClientHello {
protocol_version: native::NATIVE_PROTOCOL_VERSION,
client_random: crypto::random_32(),
client_ephemeral_public: client_public,
requested_host: self.host.clone(),
requested_user,
requested_session: session.clone(),
requested_mode: "forward-only".to_string(),
terminal_size: (80, 24),
supported_aead: vec!["chacha20poly1305".to_string()],
supported_user_key_algorithms: supported_user_key_algorithms(),
cached_host_key_fingerprint: None,
attach_ticket_envelope: None,
requested_env: self.env,
};
let packet = protocol::encode_plain(
PacketKind::NativeClientHello,
[0u8; 16],
1,
0,
&protocol::to_body(&NativeClientHelloBody {
hello: hello.clone(),
})?,
)?;
socket.send_to(&packet, peer_addr).await?;
let mut buf = vec![0u8; 65535];
let (n, _) = tokio::time::timeout(timeout, socket.recv_from(&mut buf)).await??;
let packet = protocol::decode(&buf[..n])?;
if packet.header.kind != PacketKind::NativeServerHello {
if packet.header.kind == PacketKind::AttachReject {
let reject: AttachReject = protocol::from_body(&packet.body)?;
bail!("native auth rejected: {}", reject.reason);
}
bail!("native auth received unexpected server response");
}
let server_hello: NativeServerHelloBody = protocol::from_body(&packet.body)?;
verify_server_hello(&hello, &server_hello.hello)?;
verify_or_trust_host(
&self.client.config,
&self.host,
&server_hello.hello.host_key,
self.trust_on_first_use,
)?;
let session_key = derive_native_session_key(
&client_secret,
server_hello.hello.server_ephemeral_public,
&hello,
&server_hello.hello,
)?;
let auth = sign_auth(
&self.client.config,
&host_config,
&hello,
&server_hello.hello,
requested_forwardings,
self.identity_files,
self.use_ssh_agent,
)?;
let mut pending_id = [0u8; 16];
pending_id.copy_from_slice(&server_hello.hello.auth_challenge[..16]);
let auth_packet = protocol::encode_encrypted(
PacketKind::NativeUserAuth,
pending_id,
2,
1,
&session_key,
CLIENT_TO_SERVER,
&protocol::to_body(&NativeUserAuthBody { auth })?,
)?;
socket.send_to(&auth_packet, peer_addr).await?;
let (n, _) = tokio::time::timeout(timeout, socket.recv_from(&mut buf)).await??;
let packet = protocol::decode(&buf[..n])?;
if packet.header.kind != PacketKind::NativeAuthOk {
if packet.header.kind == PacketKind::AttachReject {
let reject: AttachReject = protocol::from_body(&packet.body)?;
bail!("native auth rejected: {}", reject.reason);
}
bail!("native auth received unexpected auth response");
}
let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT)?;
let ok: NativeAuthOkBody = protocol::from_body(&plain)?;
let transport = DoshTransport::new_owned(
socket,
SessionTransportConfig {
role: SessionRole::Client,
conn_id: ok.ok.client_id,
session_key: ok.ok.session_key,
peer_addr,
initial_send_seq: 2,
initial_ack: ok.ok.initial_seq,
stream: TransportConfig::default(),
},
);
Ok(ConnectedDoshClient {
host: self.host,
session: ok.ok.session,
transport,
})
}
}
pub struct ConnectedDoshClient {
pub host: String,
pub session: String,
pub transport: DoshTransport,
}
impl ConnectedDoshClient {
pub fn into_transport(self) -> DoshTransport {
self.transport
}
}
fn verify_or_trust_host(
config: &ClientConfig,
host: &str,
host_key: &native::HostPublicKey,
trust_override: Option<bool>,
) -> Result<()> {
let known_hosts = expand_tilde(&config.known_hosts);
match verify_known_host(&known_hosts, host, host_key)? {
KnownHostStatus::Trusted => Ok(()),
KnownHostStatus::Unknown if trust_override.unwrap_or(config.trust_on_first_use) => {
trust_host(&known_hosts, host, host_key, "sdk-tofu", false)?;
Ok(())
}
KnownHostStatus::Unknown => Err(anyhow!(
"Dosh host key for {host} is not trusted; run `dosh trust {host}` first or enable trust_on_first_use"
)),
KnownHostStatus::Mismatch { expected, actual } => Err(anyhow!(
"Dosh host key mismatch for {host}: expected {expected}, got {actual}"
)),
}
}
fn sign_auth(
config: &ClientConfig,
host_config: &HostConfig,
hello: &NativeClientHello,
server_hello: &native::NativeServerHello,
requested_forwardings: Vec<ForwardingRequest>,
explicit_identity_files: Vec<PathBuf>,
use_ssh_agent: Option<bool>,
) -> Result<native::NativeUserAuth> {
let use_agent = use_ssh_agent.unwrap_or(config.use_ssh_agent);
let mut errors = Vec::new();
if use_agent {
match ssh_agent::sign_user_auth_with_agent(
hello,
server_hello,
requested_forwardings.clone(),
) {
Ok(auth) => return Ok(auth),
Err(err) => errors.push(format!("ssh-agent: {err:#}")),
}
}
let mut paths = explicit_identity_files;
if paths.is_empty() {
paths.extend(config.identity_files.iter().map(|path| expand_tilde(path)));
}
if paths.is_empty() {
paths.extend(default_identity_paths());
}
for path in paths {
match native::load_native_identity(&path).and_then(|identity| {
sign_user_auth_with_private_key(
&identity,
hello,
server_hello,
requested_forwardings.clone(),
)
}) {
Ok(auth) => return Ok(auth),
Err(err) => errors.push(format!("{}: {err:#}", path.display())),
}
}
let _ = host_config;
Err(anyhow!(
"native auth found no usable identity: {}",
errors.join("; ")
))
}
fn default_identity_paths() -> Vec<PathBuf> {
["~/.ssh/id_ed25519", "~/.ssh/id_ecdsa", "~/.ssh/id_rsa"]
.into_iter()
.map(expand_tilde)
.collect()
}
fn resolve_addr(host: &str, port: u16) -> Result<SocketAddr> {
(host, port)
.to_socket_addrs()
.with_context(|| format!("resolve UDP target {host}:{port}"))?
.next()
.ok_or_else(|| anyhow!("no UDP address resolved for {host}:{port}"))
}
fn destination_host(destination: &str) -> String {
destination
.rsplit('@')
.next()
.unwrap_or(destination)
.split(':')
.next()
.unwrap_or(destination)
.to_string()
}
fn user_from_destination(destination: &str) -> Option<String> {
destination
.rsplit_once('@')
.map(|(user, _)| user.to_string())
.filter(|user| !user.is_empty())
}
fn default_sdk_session() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("sdk-{millis}-{}", std::process::id())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_user_and_host_from_destination() {
assert_eq!(
user_from_destination("palav@example.com").as_deref(),
Some("palav")
);
assert_eq!(destination_host("palav@example.com"), "example.com");
assert_eq!(destination_host("example.com:2222"), "example.com");
}
#[test]
fn default_identity_paths_are_expanded() {
assert!(
default_identity_paths()
.iter()
.any(|path| path.ends_with(".ssh/id_ed25519"))
);
}
}
+6
View File
@@ -46,6 +46,10 @@ 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
@@ -82,6 +86,8 @@ impl Default for ServerConfig {
allow_remote_forwarding: false,
allow_remote_non_loopback_bind: false,
allow_agent_forwarding: false,
allow_file_transfer: true,
allow_exec_command: true,
accept_env: default_accept_env(),
persist_sessions: true,
}
+71
View File
@@ -0,0 +1,71 @@
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
pub const EXEC_STREAM_SENTINEL: &str = "@dosh-exec";
pub const FRAME_MAX_LEN: usize = 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecRequest {
pub command: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ExecResponse {
Stdout(Vec<u8>),
Stderr(Vec<u8>),
Exit { code: Option<i32> },
Error { message: String },
}
#[derive(Default)]
pub struct FrameDecoder {
buf: Vec<u8>,
}
impl FrameDecoder {
pub fn push(&mut self, bytes: &[u8]) -> Result<Vec<Vec<u8>>> {
self.buf.extend_from_slice(bytes);
let mut frames = Vec::new();
loop {
if self.buf.len() < 4 {
break;
}
let len = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize;
if len > FRAME_MAX_LEN {
bail!("exec protocol frame too large: {len} bytes");
}
if self.buf.len() < 4 + len {
break;
}
frames.push(self.buf[4..4 + len].to_vec());
self.buf.drain(..4 + len);
}
Ok(frames)
}
}
pub fn encode_request(request: &ExecRequest) -> Result<Vec<u8>> {
encode_frame(&bincode::serialize(request).context("encode exec request")?)
}
pub fn encode_response(response: &ExecResponse) -> Result<Vec<u8>> {
encode_frame(&bincode::serialize(response).context("encode exec response")?)
}
pub fn decode_request(frame: &[u8]) -> Result<ExecRequest> {
bincode::deserialize(frame).context("decode exec request")
}
pub fn decode_response(frame: &[u8]) -> Result<ExecResponse> {
bincode::deserialize(frame).context("decode exec response")
}
fn encode_frame(payload: &[u8]) -> Result<Vec<u8>> {
if payload.len() > FRAME_MAX_LEN {
bail!("exec protocol frame too large: {} bytes", payload.len());
}
let mut out = Vec::with_capacity(4 + payload.len());
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
out.extend_from_slice(payload);
Ok(out)
}
+250
View File
@@ -0,0 +1,250 @@
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const FILE_STREAM_SENTINEL: &str = "@dosh-file";
pub const FRAME_MAX_LEN: usize = 1024 * 1024;
pub const CHUNK_SIZE: usize = 32 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum FileRequest {
Stat {
path: String,
},
List {
path: String,
},
Mkdir {
path: String,
mode: Option<u32>,
},
Remove {
path: String,
recursive: bool,
},
PutStart {
path: String,
size: u64,
mode: Option<u32>,
modified_secs: Option<u64>,
overwrite: bool,
resume: bool,
},
PutChunk {
offset: u64,
bytes: Vec<u8>,
},
PutFinish {
sha256: [u8; 32],
},
Get {
path: String,
offset: u64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum FileResponse {
Ok,
Resume { offset: u64 },
Stat { meta: FileMeta },
List { entries: Vec<FileEntry> },
Start { meta: FileMeta, offset: u64 },
Chunk { offset: u64, bytes: Vec<u8> },
Done { sha256: [u8; 32], bytes: u64 },
Error { message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileMeta {
pub path: String,
pub kind: FileKind,
pub len: u64,
pub mode: Option<u32>,
pub modified_secs: Option<u64>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum FileKind {
File,
Directory,
Symlink,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileEntry {
pub name: String,
pub meta: FileMeta,
}
#[derive(Default)]
pub struct FrameDecoder {
buf: Vec<u8>,
}
impl FrameDecoder {
pub fn push(&mut self, bytes: &[u8]) -> Result<Vec<Vec<u8>>> {
self.buf.extend_from_slice(bytes);
let mut frames = Vec::new();
loop {
if self.buf.len() < 4 {
break;
}
let len = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize;
if len > FRAME_MAX_LEN {
bail!("file protocol frame too large: {len} bytes");
}
if self.buf.len() < 4 + len {
break;
}
frames.push(self.buf[4..4 + len].to_vec());
self.buf.drain(..4 + len);
}
Ok(frames)
}
}
pub fn encode_request(request: &FileRequest) -> Result<Vec<u8>> {
encode_frame(&bincode::serialize(request).context("encode file request")?)
}
pub fn encode_response(response: &FileResponse) -> Result<Vec<u8>> {
encode_frame(&bincode::serialize(response).context("encode file response")?)
}
pub fn decode_request(frame: &[u8]) -> Result<FileRequest> {
bincode::deserialize(frame).context("decode file request")
}
pub fn decode_response(frame: &[u8]) -> Result<FileResponse> {
bincode::deserialize(frame).context("decode file response")
}
pub fn encode_frame(payload: &[u8]) -> Result<Vec<u8>> {
if payload.len() > FRAME_MAX_LEN {
bail!("file protocol frame too large: {} bytes", payload.len());
}
let mut out = Vec::with_capacity(4 + payload.len());
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
out.extend_from_slice(payload);
Ok(out)
}
pub fn parse_copy_endpoint(raw: &str) -> CopyEndpoint {
if looks_like_windows_path(raw) {
return CopyEndpoint::Local(PathBuf::from(raw));
}
let Some(index) = raw.find(':') else {
return CopyEndpoint::Local(PathBuf::from(raw));
};
let host = &raw[..index];
let path = &raw[index + 1..];
if host.is_empty() || host.contains('/') || host.contains('\\') {
return CopyEndpoint::Local(PathBuf::from(raw));
}
CopyEndpoint::Remote {
host: host.to_string(),
path: if path.is_empty() {
".".to_string()
} else {
path.to_string()
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CopyEndpoint {
Local(PathBuf),
Remote { host: String, path: String },
}
pub fn remote_join(parent: &str, child: &str) -> String {
if parent.is_empty() || parent == "." {
return child.to_string();
}
format!("{}/{}", parent.trim_end_matches('/'), child)
}
pub fn remote_basename(path: &str) -> String {
path.trim_end_matches('/')
.rsplit('/')
.next()
.filter(|value| !value.is_empty())
.unwrap_or(path)
.to_string()
}
pub fn clean_remote_path(path: &str, home: &Path) -> Result<PathBuf> {
if path.as_bytes().contains(&0) {
bail!("remote path contains NUL");
}
let expanded = if path == "~" {
home.to_path_buf()
} else if let Some(rest) = path.strip_prefix("~/") {
home.join(rest)
} else {
let raw = Path::new(path);
if raw.is_absolute() {
raw.to_path_buf()
} else {
home.join(raw)
}
};
Ok(expanded)
}
pub fn ensure_relative_child(path: &Path) -> Result<String> {
let name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow!("path has no final component: {}", path.display()))?;
if name.is_empty() || name == "." || name == ".." {
bail!("invalid path final component: {}", path.display());
}
Ok(name.to_string())
}
fn looks_like_windows_path(raw: &str) -> bool {
let bytes = raw.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'\\' || bytes[2] == b'/')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frames_round_trip_incrementally() {
let first = encode_response(&FileResponse::Ok).unwrap();
let second = encode_response(&FileResponse::Error {
message: "nope".to_string(),
})
.unwrap();
let mut decoder = FrameDecoder::default();
assert!(decoder.push(&first[..2]).unwrap().is_empty());
let frames = decoder.push(&first[2..]).unwrap();
assert_eq!(frames.len(), 1);
assert_eq!(decode_response(&frames[0]).unwrap(), FileResponse::Ok);
let frames = decoder.push(&second).unwrap();
assert_eq!(frames.len(), 1);
}
#[test]
fn copy_endpoint_parses_scp_style_but_not_windows_drive() {
assert_eq!(
parse_copy_endpoint("palav:tmp/file"),
CopyEndpoint::Remote {
host: "palav".to_string(),
path: "tmp/file".to_string()
}
);
assert_eq!(
parse_copy_endpoint("C:\\Users\\palav\\x"),
CopyEndpoint::Local(PathBuf::from("C:\\Users\\palav\\x"))
);
}
}
+15
View File
@@ -1,11 +1,26 @@
//! Dosh is an encrypted UDP transport for remote terminals and embeddable Rust
//! application streams.
//!
//! Use [`client::DoshClient`] and [`server::DoshServer`] when you want Dosh to
//! handle native authentication, host key checks, roaming, keepalives, reliable
//! streams, and service routing. Use [`transport::DoshTransport`] only when an
//! application already owns session establishment and wants direct access to the
//! encrypted stream transport.
pub mod auth;
pub mod build_info;
pub mod client;
pub mod config;
pub mod crypto;
pub mod exec_service;
pub mod file_transfer;
pub mod native;
#[cfg(unix)]
pub mod persist;
pub mod protocol;
#[cfg(unix)]
pub mod pty;
pub mod server;
pub mod ssh_agent;
pub mod transport;
pub mod udp;
+4
View File
@@ -435,6 +435,10 @@ pub fn run_holder(
let mut cmd = [0u8; 1];
match stream.read(&mut cmd) {
Ok(1) if cmd[0] == HOLDER_CMD_SHUTDOWN => {
if shell_pid > 0 {
let _ = unsafe { libc::kill(shell_pid, libc::SIGHUP) };
let _ = unsafe { libc::kill(shell_pid, libc::SIGTERM) };
}
let _ = std::fs::remove_dir_all(runtime_dir);
std::process::exit(0);
}
+5 -1
View File
@@ -280,7 +280,7 @@ pub fn decode(input: &[u8]) -> Result<Packet> {
pub fn decrypt_body(packet: &Packet, key: &[u8; 32], direction: u32) -> Result<Vec<u8>> {
if packet.header.flags & 1 == 0 {
return Ok(packet.body.clone());
bail!("packet body is not encrypted");
}
let nonce = crypto::nonce_from(direction, packet.header.seq);
let aad = packet.header.aad();
@@ -357,6 +357,10 @@ pub struct NativeAuthCheckOkBody {
pub allow_tcp_forwarding: bool,
pub allow_remote_forwarding: bool,
pub allow_agent_forwarding: bool,
#[serde(default)]
pub allow_file_transfer: bool,
#[serde(default)]
pub allow_exec_command: bool,
pub policy_flags: Vec<String>,
#[serde(default)]
pub server_version: String,
+705
View File
@@ -0,0 +1,705 @@
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_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) = self.socket.recv_from(&mut buf).await?;
let packet = match protocol::decode(&buf[..n]) {
Ok(packet) => packet,
Err(_) => continue,
};
if packet.header.kind == PacketKind::NativeClientHello {
self.handle_client_hello(peer, packet.body).await?;
return Ok(DoshServerEvent::Ignored);
}
if packet.header.kind == PacketKind::NativeUserAuth {
return self.handle_user_auth(peer, &packet).await;
}
if let Some(transport) = self.transports.get_mut(&packet.header.conn_id) {
let event = transport.handle_datagram(&buf[..n], peer).await?;
return Ok(DoshServerEvent::Session {
conn_id: packet.header.conn_id,
event,
});
}
self.send_reject(peer, packet.header.conn_id, "unknown Dosh connection")
.await?;
return Ok(DoshServerEvent::Ignored);
}
}
pub async fn accept_stream(&mut self, conn_id: [u8; 16], stream_id: u64) -> Result<()> {
self.transport_mut(&conn_id)
.ok_or_else(|| anyhow!("unknown Dosh connection"))?
.accept_stream(stream_id)
.await
}
pub async fn reject_stream(
&mut self,
conn_id: [u8; 16],
stream_id: u64,
reason: impl Into<String>,
) -> Result<()> {
self.transport_mut(&conn_id)
.ok_or_else(|| anyhow!("unknown Dosh connection"))?
.reject_stream(stream_id, reason)
.await
}
pub async fn send(
&mut self,
conn_id: [u8; 16],
stream_id: u64,
bytes: impl Into<Vec<u8>>,
) -> Result<()> {
self.transport_mut(&conn_id)
.ok_or_else(|| anyhow!("unknown Dosh connection"))?
.send(stream_id, bytes)
.await
}
pub async fn close(&mut self, conn_id: [u8; 16], stream_id: u64) -> Result<()> {
self.transport_mut(&conn_id)
.ok_or_else(|| anyhow!("unknown Dosh connection"))?
.close(stream_id)
.await
}
pub async fn maintenance(&mut self) -> Result<()> {
for transport in self.transports.values_mut() {
transport.maintenance().await?;
}
Ok(())
}
async fn handle_client_hello(&mut self, peer: SocketAddr, body: Vec<u8>) -> Result<()> {
let req: NativeClientHelloBody = protocol::from_body(&body)?;
if let Err(err) =
native::check_native_protocol_version(req.hello.protocol_version, "client")
{
self.send_reject(peer, [0u8; 16], &err.to_string()).await?;
return Ok(());
}
let result = self.build_server_hello(req.hello, peer);
let (pending_id, hello) = match result {
Ok(value) => value,
Err(err) => {
self.send_reject(peer, [0u8; 16], &err.to_string()).await?;
return Ok(());
}
};
let body = protocol::to_body(&NativeServerHelloBody { hello })?;
let out = protocol::encode_plain(PacketKind::NativeServerHello, pending_id, 1, 0, &body)?;
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));
}
}
+1680
View File
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
//! UDP error classification shared by Dosh clients, servers, and embedders.
pub fn is_transient_udp_send_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)
}
#[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_send_error;
#[test]
fn classifies_portable_transient_send_errors() {
for kind in [
std::io::ErrorKind::Interrupted,
std::io::ErrorKind::TimedOut,
std::io::ErrorKind::WouldBlock,
] {
assert!(is_transient_udp_send_error(&std::io::Error::from(kind)));
}
assert!(!is_transient_udp_send_error(&std::io::Error::from(
std::io::ErrorKind::PermissionDenied,
)));
}
#[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,
] {
assert!(is_transient_udp_send_error(
&std::io::Error::from_raw_os_error(code)
));
}
}
#[cfg(windows)]
#[test]
fn classifies_windows_network_churn_as_transient() {
for code in [
10049, 10050, 10051, 10052, 10054, 10060, 10061, 10064, 10065,
] {
assert!(is_transient_udp_send_error(
&std::io::Error::from_raw_os_error(code)
));
}
}
}
+141 -9
View File
@@ -258,7 +258,8 @@ fn relay_loop(
let mut upstream = new_upstream();
let mut client_addr: Option<SocketAddr> = None;
let mut rng = StdRng::seed_from_u64(seed);
let mut held: Option<(Vec<u8>, bool)> = None; // (packet, is_c2s) held for reorder
let mut held_c2s: Option<Vec<u8>> = None;
let mut held_s2c: Option<Vec<u8>> = None;
let mut buf = [0u8; 65535];
loop {
@@ -288,10 +289,9 @@ fn relay_loop(
&upstream,
server_addr,
packet,
true,
&controls,
&mut rng,
&mut held,
&mut held_c2s,
);
}
}
@@ -310,7 +310,12 @@ fn relay_loop(
let drop_pct = controls.drop_s2c_percent.load(Ordering::SeqCst);
if drop_pct == 0 || rng.gen_range(0..100) >= drop_pct {
forward_with_effects(
&front, dst, packet, false, &controls, &mut rng, &mut held,
&front,
dst,
packet,
&controls,
&mut rng,
&mut held_s2c,
);
}
}
@@ -336,23 +341,22 @@ fn forward_with_effects(
out: &UdpSocket,
dst: SocketAddr,
packet: Vec<u8>,
is_c2s: bool,
controls: &RelayControls,
rng: &mut StdRng,
held: &mut Option<(Vec<u8>, bool)>,
held: &mut Option<Vec<u8>>,
) {
// Reorder: if armed, hold this packet and release the previously held one
// afterward (so two consecutive packets swap order).
if controls.reorder_next.swap(false, Ordering::SeqCst) {
if let Some((prev, _)) = held.take() {
if let Some(prev) = held.take() {
let _ = out.send_to(&packet, dst);
let _ = out.send_to(&prev, dst);
return;
}
*held = Some((packet, is_c2s));
*held = Some(packet);
return;
}
if let Some((prev, _)) = held.take() {
if let Some(prev) = held.take() {
let _ = out.send_to(&prev, dst);
}
@@ -767,6 +771,46 @@ fn wait_for_text(socket: &UdpSocket, key: &[u8; 32], needle: &str, millis: u64)
acc.contains(needle)
}
fn wait_for_text_with_ack(
socket: &UdpSocket,
relay: &Relay,
client_id: [u8; 16],
seq: &mut u64,
key: &[u8; 32],
needle: &str,
millis: u64,
) -> bool {
let prev = socket.read_timeout().unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let deadline = Instant::now() + Duration::from_millis(millis);
let mut acc = String::new();
while Instant::now() < deadline {
if let Some((_header, frame)) = recv_frame(socket, key) {
acc.push_str(&String::from_utf8_lossy(&frame.bytes));
let ack = protocol::encode_encrypted(
PacketKind::Ack,
client_id,
*seq,
frame.output_seq,
key,
CLIENT_TO_SERVER,
b"",
)
.unwrap();
*seq += 1;
socket.send_to(&ack, relay.front_addr()).unwrap();
if acc.contains(needle) {
socket.set_read_timeout(prev).unwrap();
return true;
}
}
}
socket.set_read_timeout(prev).unwrap();
acc.contains(needle)
}
#[test]
fn session_survives_packet_loss_and_reorder() {
let dir = tempfile::tempdir().unwrap();
@@ -820,6 +864,94 @@ fn session_survives_packet_loss_and_reorder() {
);
}
#[test]
#[ignore = "30-minute hostile-network TUI soak; run with `DOSH_BADNET_SOAK_SECONDS=1800 cargo test --test hostile_network bad_network_tui_work_soak_30m -- --ignored --nocapture`"]
fn bad_network_tui_work_soak_30m() {
let soak_secs = std::env::var("DOSH_BADNET_SOAK_SECONDS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(30 * 60);
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
let mut server = start_server(&dir, &config);
let relay = Relay::spawn(port, 0xBADC0DEu64);
let (socket, bootstrap, ok) = attach_through_relay(&config, &relay);
relay.set_drop_c2s(15);
relay.set_drop_s2c(20);
relay.set_dup(10);
let deadline = Instant::now() + Duration::from_secs(soak_secs);
let mut seq = 2u64;
let mut iteration = 0u64;
while Instant::now() < deadline {
if iteration.is_multiple_of(2) {
relay.arm_reorder();
}
if iteration > 0 && iteration.is_multiple_of(7) {
let _ = relay.rebind_upstream();
}
if iteration > 0 && iteration.is_multiple_of(11) {
relay.set_drop_s2c(100);
thread::sleep(Duration::from_millis(750));
relay.set_drop_s2c(20);
}
let marker = format!("DOSH_BADNET_TUI_{iteration}");
let command = format!(
"stty -echo; \
printf '\\033[?1049h\\033[?2026h\\033[?25l'; \
for i in 1 2 3 4 5 6; do \
printf '\\033[%s;4H{} %s\\033[0m' \"$i\" \"$i\"; \
done; \
printf '\\033[?25h\\033[?2026l\\033[?1049l'\n",
marker
);
let step_deadline = Instant::now() + Duration::from_secs(8);
let mut seen = false;
let mut attempts = 0;
while Instant::now() < step_deadline && attempts < 12 {
send_input(
&socket,
&relay,
ok.client_id,
seq,
0,
&bootstrap.session_key,
command.as_bytes(),
);
seq += 1;
attempts += 1;
if wait_for_text_with_ack(
&socket,
&relay,
ok.client_id,
&mut seq,
&bootstrap.session_key,
&marker,
500,
) {
seen = true;
break;
}
}
assert!(
seen,
"TUI marker {marker} did not arrive during hostile-network soak"
);
iteration += 1;
thread::sleep(Duration::from_millis(500));
}
relay.clear_impairments();
drop(relay);
let _ = server.kill();
let _ = server.wait();
}
#[test]
fn duplicated_and_replayed_input_is_applied_at_most_once() {
let dir = tempfile::tempdir().unwrap();
+641 -30
View File
@@ -18,7 +18,8 @@ use dosh::crypto;
use dosh::native::{host_public_key, load_or_create_host_key, ssh_ed25519_public_blob, trust_host};
use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT,
PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, TicketAttachEnvelope,
TicketAttachOkEnvelope,
};
use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
@@ -98,6 +99,35 @@ persist_sessions = false
config
}
fn write_server_config_with_output_interval(
dir: &tempfile::TempDir,
port: u16,
output_frame_interval_ms: u64,
) -> std::path::PathBuf {
let config = write_server_config(dir, port);
let mut raw = fs::read_to_string(&config).unwrap();
raw.push_str(&format!(
"output_frame_interval_ms = {output_frame_interval_ms}\n"
));
fs::write(&config, raw).unwrap();
config
}
fn write_server_config_with_retransmit_window(
dir: &tempfile::TempDir,
port: u16,
retransmit_window: u64,
) -> std::path::PathBuf {
let config = write_server_config(dir, port);
let mut raw = fs::read_to_string(&config).unwrap();
raw = raw.replace(
"retransmit_window = 256\n",
&format!("retransmit_window = {retransmit_window}\n"),
);
fs::write(&config, raw).unwrap();
config
}
fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child {
let server = env!("CARGO_BIN_EXE_dosh-server");
let child = Command::new(server)
@@ -501,6 +531,75 @@ fn direct_attach_session(
(socket, bootstrap, ok)
}
fn ticket_attach_session(
socket: &UdpSocket,
port: u16,
bootstrap: &dosh::auth::BootstrapResponse,
session: &str,
mode: &str,
) -> AttachOk {
let client_nonce = crypto::random_12();
let request_key = crypto::hkdf32(
&bootstrap.attach_ticket_psk,
&client_nonce,
b"dosh/ticket-attach-request/v1",
)
.unwrap();
let body = protocol::to_body(&TicketAttachBody {
session: session.to_string(),
mode: mode.to_string(),
cols: 80,
rows: 24,
requested_env: Vec::new(),
})
.unwrap();
let ciphertext = crypto::seal(
&request_key,
&client_nonce,
b"dosh-ticket-attach-request-v1",
&body,
)
.unwrap();
let envelope = TicketAttachEnvelope {
ticket: bootstrap.attach_ticket.clone(),
client_nonce,
ciphertext,
};
let packet = protocol::encode_plain(
PacketKind::TicketAttachRequest,
[0u8; 16],
1,
0,
&protocol::to_body(&envelope).unwrap(),
)
.unwrap();
socket
.send_to(&packet, format!("127.0.0.1:{port}"))
.unwrap();
let mut buf = [0u8; 65535];
let (n, _) = socket.recv_from(&mut buf).unwrap();
let packet = protocol::decode(&buf[..n]).unwrap();
assert_eq!(packet.header.kind, PacketKind::AttachOk);
let envelope: TicketAttachOkEnvelope = protocol::from_body(&packet.body).unwrap();
let mut salt = Vec::with_capacity(24);
salt.extend_from_slice(&client_nonce);
salt.extend_from_slice(&envelope.server_nonce);
let response_key = crypto::hkdf32(
&bootstrap.attach_ticket_psk,
&salt,
b"dosh/ticket-attach-ok/v1",
)
.unwrap();
let plain = crypto::open(
&response_key,
&envelope.server_nonce,
b"dosh-ticket-attach-ok-v1",
&envelope.ciphertext,
)
.unwrap();
protocol::from_body(&plain).unwrap()
}
#[allow(clippy::too_many_arguments)]
fn send_encrypted(
socket: &UdpSocket,
@@ -760,7 +859,9 @@ fn native_doctor_checks_auth_without_opening_terminal() {
assert!(output.status.success(), "stdout={stdout}\nstderr={stderr}");
assert!(stdout.contains("[ok] native udp"), "stdout={stdout}");
assert!(stdout.contains("[ok] native auth"), "stdout={stdout}");
assert!(stdout.contains("[ok] forwarding policy"), "stdout={stdout}");
assert!(stdout.contains("[ok] server policy"), "stdout={stdout}");
assert!(stdout.contains("file_transfer=true"), "stdout={stdout}");
assert!(stdout.contains("exec_command=true"), "stdout={stdout}");
}
#[test]
@@ -868,6 +969,288 @@ fn native_local_forward_background_smoke() {
assert_eq!(&buf[..n], b"dosh-background-forward-ping");
}
#[test]
fn native_file_copy_recursive_round_trip() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let mut server = start_server(&dir, &config);
let src = dir.path().join("srcdir");
fs::create_dir_all(src.join("nested")).unwrap();
fs::write(src.join("root.txt"), b"root file\n").unwrap();
fs::write(src.join("nested/child.txt"), b"child file\n").unwrap();
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let upload = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cp")
.arg("-r")
.arg(&src)
.arg("local:remote-copy")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
upload.status.success(),
"upload failed: stdout={} stderr={}",
String::from_utf8_lossy(&upload.stdout),
String::from_utf8_lossy(&upload.stderr)
);
let remote_copy = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cp")
.arg("-r")
.arg("local:remote-copy")
.arg("local:remote-copy-2")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
remote_copy.status.success(),
"remote copy failed: stdout={} stderr={}",
String::from_utf8_lossy(&remote_copy.stdout),
String::from_utf8_lossy(&remote_copy.stderr)
);
let list = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("ls")
.arg("local:remote-copy-2")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
list.status.success(),
"ls failed: stdout={} stderr={}",
String::from_utf8_lossy(&list.stdout),
String::from_utf8_lossy(&list.stderr)
);
let list_stdout = String::from_utf8_lossy(&list.stdout);
assert!(list_stdout.contains("root.txt"), "stdout={list_stdout}");
assert!(list_stdout.contains("nested"), "stdout={list_stdout}");
let cat = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cat")
.arg("local:remote-copy-2/root.txt")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
cat.status.success(),
"cat failed: stdout={} stderr={}",
String::from_utf8_lossy(&cat.stdout),
String::from_utf8_lossy(&cat.stderr)
);
assert_eq!(String::from_utf8_lossy(&cat.stdout), "root file\n");
let downloaded = dir.path().join("downloaded");
let download = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cp")
.arg("-r")
.arg("local:remote-copy-2")
.arg(&downloaded)
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
download.status.success(),
"download failed: stdout={} stderr={}",
String::from_utf8_lossy(&download.stdout),
String::from_utf8_lossy(&download.stderr)
);
assert_eq!(
fs::read_to_string(downloaded.join("root.txt")).unwrap(),
"root file\n"
);
assert_eq!(
fs::read_to_string(downloaded.join("nested/child.txt")).unwrap(),
"child file\n"
);
let remove = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("rm")
.arg("-r")
.arg("local:remote-copy")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
remove.status.success(),
"rm failed: stdout={} stderr={}",
String::from_utf8_lossy(&remove.stdout),
String::from_utf8_lossy(&remove.stderr)
);
let _ = server.kill();
let _ = server.wait();
}
#[test]
fn native_file_copy_resume_truncates_oversized_destinations() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let mut server = start_server(&dir, &config);
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let local_short = dir.path().join("short.txt");
fs::write(&local_short, b"short\n").unwrap();
let seed_remote = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("exec")
.arg("local")
.arg("printf 'short\\nSTALE' > \"$HOME/resume-upload.txt\"")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
seed_remote.status.success(),
"seed remote failed: stdout={} stderr={}",
String::from_utf8_lossy(&seed_remote.stdout),
String::from_utf8_lossy(&seed_remote.stderr)
);
let resume_upload = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cp")
.arg("--resume")
.arg(&local_short)
.arg("local:resume-upload.txt")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
resume_upload.status.success(),
"resume upload failed: stdout={} stderr={}",
String::from_utf8_lossy(&resume_upload.stdout),
String::from_utf8_lossy(&resume_upload.stderr)
);
let cat_upload = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cat")
.arg("local:resume-upload.txt")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
cat_upload.status.success(),
"cat upload failed: stdout={} stderr={}",
String::from_utf8_lossy(&cat_upload.stdout),
String::from_utf8_lossy(&cat_upload.stderr)
);
assert_eq!(String::from_utf8_lossy(&cat_upload.stdout), "short\n");
let seed_download = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("exec")
.arg("local")
.arg("printf 'tiny\\n' > \"$HOME/resume-download.txt\"")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
seed_download.status.success(),
"seed download failed: stdout={} stderr={}",
String::from_utf8_lossy(&seed_download.stdout),
String::from_utf8_lossy(&seed_download.stderr)
);
let local_download = dir.path().join("resume-download-local.txt");
fs::write(&local_download, b"tiny\nSTALE").unwrap();
let resume_download = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("cp")
.arg("--resume")
.arg("local:resume-download.txt")
.arg(&local_download)
.env("HOME", dir.path())
.output()
.unwrap();
assert!(
resume_download.status.success(),
"resume download failed: stdout={} stderr={}",
String::from_utf8_lossy(&resume_download.stdout),
String::from_utf8_lossy(&resume_download.stderr)
);
assert_eq!(fs::read_to_string(&local_download).unwrap(), "tiny\n");
let _ = server.kill();
let _ = server.wait();
}
#[test]
fn native_exec_command_smoke() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config(&dir, port);
write_native_client_auth(&dir, &config);
let mut server = start_server(&dir, &config);
let client_bin = env!("CARGO_BIN_EXE_dosh-client");
let output = Command::new(client_bin)
.arg("--dosh-host")
.arg("127.0.0.1")
.arg("--dosh-port")
.arg(port.to_string())
.arg("exec")
.arg("local")
.arg("printf out; printf err >&2")
.env("HOME", dir.path())
.output()
.unwrap();
let _ = server.kill();
let _ = server.wait();
assert!(
output.status.success(),
"exec failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(String::from_utf8_lossy(&output.stdout), "out");
assert_eq!(String::from_utf8_lossy(&output.stderr), "err");
}
#[test]
fn native_local_forward_bulk_load_does_not_delay_interactive_terminal() {
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
@@ -1190,18 +1573,10 @@ fn server_retransmits_unacked_output_frames() {
);
let mut seen = Vec::new();
let mut duplicate = None;
let deadline = std::time::Instant::now() + Duration::from_secs(3);
let deadline = std::time::Instant::now() + Duration::from_secs(4);
while std::time::Instant::now() < deadline {
if let Some((header, frame)) = recv_frame(&socket, &bootstrap.session_key) {
if String::from_utf8_lossy(&frame.bytes).contains("DOSH_RETRANSMIT") {
if seen
.iter()
.any(|(_, output_seq)| *output_seq == frame.output_seq)
{
duplicate = Some((header.seq, frame.output_seq));
break;
}
seen.push((header.seq, frame.output_seq));
}
}
@@ -1210,9 +1585,18 @@ fn server_retransmits_unacked_output_frames() {
let _ = server.kill();
let _ = server.wait();
let max_same_output_seq = seen
.iter()
.map(|(_, output_seq)| {
seen.iter()
.filter(|(_, candidate)| candidate == output_seq)
.count()
})
.max()
.unwrap_or(0);
assert!(
duplicate.is_some(),
"expected retransmitted same output seq, seen={seen:?}"
max_same_output_seq >= 10,
"expected retransmission to continue past the old 8-attempt ceiling, seen={seen:?}"
);
}
@@ -1466,6 +1850,110 @@ fn unicode_output_survives_transport() {
);
}
#[test]
fn tui_graph_glyphs_survive_fast_repaints_without_snapshot_substitution() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config_with_output_interval(&dir, port, 1000);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let graph = "DOSH_GRAPH ⣀⣤⣶⣿ █▇▆▅▄▃▂▁ ╭╮╯╰";
let input = Input {
bytes: format!(
"printf '\\033[?1049h\\033[?25l'; for i in 1 2 3 4 5; do printf '\\033[H{} %s\\n' \"$i\"; done; printf '\\033[?25h\\033[?1049l'\n",
graph
)
.into_bytes(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let text = collect_frame_text(&socket, &bootstrap.session_key, 3000);
let _ = server.kill();
let _ = server.wait();
assert!(
text.contains(graph) && text.matches("DOSH_GRAPH").count() >= 5,
"expected repeated raw graph glyph frames, got {text:?}"
);
assert!(
text.contains("\x1b[?25l") && text.contains("\x1b[?25h"),
"expected cursor visibility controls to survive around graph repaint, got {text:?}"
);
}
#[test]
fn btop_style_graph_output_stays_raw_when_retransmit_history_overflows() {
let dir = tempfile::tempdir().unwrap();
let port = free_udp_port();
let config = write_server_config_with_retransmit_window(&dir, port, 3);
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write");
let graph = "DOSH_BTOP_NET ⠀⠁⠃⠇⡇⣇⣧⣷⣿";
let input = Input {
bytes: format!(
"stty -echo; \
printf '\\033[?1049h\\033[?2026h\\033[?25l'; \
for i in 1 2 3 4 5 6 7 8 9 10; do \
printf '\\033[6;4H\\033[38;2;80;220;140m{} %s\\033[0m' \"$i\"; \
done; \
printf '\\033[?25h\\033[?2026l\\033[?1049l'\n",
graph
)
.into_bytes(),
};
send_encrypted(
&socket,
port,
PacketKind::Input,
ok.client_id,
2,
0,
&bootstrap.session_key,
&protocol::to_body(&input).unwrap(),
);
let mut text = String::new();
let mut snapshots = 0usize;
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while std::time::Instant::now() < deadline
&& !(text.matches("DOSH_BTOP_NET").count() >= 10 && text.contains("\x1b[?2026l"))
{
if let Some((_header, frame)) = recv_frame(&socket, &bootstrap.session_key) {
if frame.snapshot {
snapshots += 1;
}
text.push_str(&String::from_utf8_lossy(&frame.bytes));
}
}
let _ = server.kill();
let _ = server.wait();
assert_eq!(
snapshots, 0,
"live btop-style graph output must not be replaced by snapshots; got {snapshots}"
);
assert!(
text.matches("DOSH_BTOP_NET").count() >= 10
&& text.contains("")
&& text.contains("\x1b[38;2;80;220;140m")
&& text.contains("\x1b[?2026h")
&& text.contains("\x1b[?2026l"),
"expected raw btop-style graph output to survive under retransmit pressure, got {text:?}"
);
}
#[test]
fn large_tui_paint_is_delivered_in_mtu_safe_frames() {
let dir = tempfile::tempdir().unwrap();
@@ -2212,6 +2700,9 @@ fn write_persistent_server_config(dir: &tempfile::TempDir, port: u16) -> std::pa
let mut raw = fs::read_to_string(&config).unwrap();
// The base writer hardcodes `persist_sessions = false`; flip it on.
raw = raw.replace("persist_sessions = false", "persist_sessions = true");
// Persistence tests attach the sessions they need explicitly. Leaving the
// base prewarm on here creates a detached default holder in every test.
raw = raw.replace("prewarm_sessions = [\"default\"]", "prewarm_sessions = []");
fs::write(&config, raw).unwrap();
config
}
@@ -2247,6 +2738,45 @@ fn kill_holder(sessions_dir: &Path, session: &str) {
.status();
}
/// Create a holder the way older Dosh builds did for implicit sessions, so
/// startup migration can be tested without depending on the new spawn policy.
fn spawn_legacy_holder(sessions_dir: &Path, session: &str) {
let server = env!("CARGO_BIN_EXE_dosh-server");
let runtime_dir = dosh::persist::ensure_runtime_dir(sessions_dir, session).unwrap();
let mut launcher = Command::new(server)
.arg("hold")
.arg("--runtime-dir")
.arg(&runtime_dir)
.arg("--session")
.arg(session)
.arg("--shell")
.arg("/bin/sh")
.arg("--cols")
.arg("80")
.arg("--rows")
.arg("24")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
if holder_shell_pid(sessions_dir, session).is_some()
&& runtime_dir.join("holder.sock").exists()
{
let _ = launcher.wait();
return;
}
if let Some(status) = launcher.try_wait().unwrap() {
panic!("legacy holder launcher exited before ready: {status}");
}
thread::sleep(Duration::from_millis(20));
}
let _ = launcher.kill();
let _ = launcher.wait();
panic!("legacy holder did not become ready");
}
/// Send one encrypted Input line and give the shell a moment to act.
fn type_line(socket: &UdpSocket, port: u16, ok: &AttachOk, key: &[u8; 32], seq: u64, line: &str) {
let input = Input {
@@ -2384,7 +2914,7 @@ fn session_survives_server_restart_same_shell_and_screen() {
}
#[test]
fn implicit_session_survives_server_restart_for_same_ticket_holder() {
fn implicit_session_survives_update_restart_via_ticket_reattach() {
let dir = tempfile::tempdir().unwrap();
let sessions_dir = dir.path().join("sessions");
let port = free_udp_port();
@@ -2394,59 +2924,140 @@ fn implicit_session_survives_server_restart_for_same_ticket_holder() {
let mut server = start_server(&dir, &config);
let (socket, bootstrap, ok) = direct_attach_session(&config, port, "read-write", &session);
let key = bootstrap.session_key;
type_line(&socket, port, &ok, &key, 2, "cd /tmp\n");
type_line(
&socket,
port,
&ok,
&key,
2,
3,
"export IMPLICIT_MARK=implicit_restart_42\n",
);
let _ = collect_frame_text(&socket, &key, 1000);
type_line(
&socket,
port,
&ok,
&key,
4,
"printf 'IMPLICIT_SCREEN_MARKER\\n'\n",
);
let pre = collect_frame_text(&socket, &key, 1500);
assert!(
pre.contains("IMPLICIT_SCREEN_MARKER"),
"implicit marker missing before restart: {pre:?}"
);
thread::sleep(Duration::from_secs(3));
let shell_pid_before = holder_shell_pid(&sessions_dir, &session);
assert!(
shell_pid_before.is_some(),
"implicit sessions should get a persistent holder when persistence is enabled"
"implicit sessions must get temporary holders so active clients survive updates"
);
let _ = server.kill();
let _ = server.wait();
thread::sleep(Duration::from_millis(300));
let pid = shell_pid_before.unwrap();
assert!(
unsafe { libc::kill(pid, 0) } == 0,
"implicit session shell pid {pid} must survive server restart"
);
let mut server = start_server(&dir, &config);
let (socket2, bootstrap2, ok2) = direct_attach_session(&config, port, "read-write", &session);
let key2 = bootstrap2.session_key;
let resume = ResumeRequest {
session: session.clone(),
last_rendered_seq: ok.initial_seq,
cols: 80,
rows: 24,
};
send_encrypted(
&socket,
port,
PacketKind::ResumeRequest,
ok.client_id,
5,
0,
&key,
&protocol::to_body(&resume).unwrap(),
);
let mut buf = [0u8; 65535];
let deadline = std::time::Instant::now() + Duration::from_secs(2);
loop {
assert!(
std::time::Instant::now() < deadline,
"old live resume did not receive unknown-client reject"
);
let (n, _) = socket.recv_from(&mut buf).unwrap();
let packet = protocol::decode(&buf[..n]).unwrap();
if packet.header.kind == PacketKind::Frame {
continue;
}
assert_eq!(packet.header.kind, PacketKind::AttachReject);
assert_eq!(packet.header.conn_id, ok.client_id);
break;
}
let ok2 = ticket_attach_session(&socket, port, &bootstrap, &session, "read-write");
let key2 = ok2.session_key;
let snapshot = String::from_utf8_lossy(&ok2.snapshot).to_string();
type_line(
&socket2,
&socket,
port,
&ok2,
&key2,
2,
"printf 'IMPLICIT_MARK=%s\\n' \"$IMPLICIT_MARK\"\n",
"printf 'IMPLICIT_MARK=%s PWD=%s\\n' \"$IMPLICIT_MARK\" \"$PWD\"\n",
);
let post = collect_frame_text(&socket2, &key2, 2000);
let post = collect_frame_text(&socket, &key2, 2000);
let shell_pid_after = holder_shell_pid(&sessions_dir, &session);
let _ = server.kill();
let _ = server.wait();
kill_holder(&sessions_dir, &session);
assert!(
snapshot.contains("IMPLICIT_SCREEN_MARKER"),
"ticket reattach snapshot must repaint the exact pre-update screen, got {snapshot:?}"
);
assert_eq!(
shell_pid_after, shell_pid_before,
"implicit session should re-adopt the same shell pid"
"implicit update reconnect must use the same holder shell"
);
assert!(
post.contains("IMPLICIT_MARK=implicit_restart_42"),
"implicit session state must survive restart, got {post:?}"
"implicit session env must survive update reconnect, got {post:?}"
);
assert!(
post.contains("PWD=/tmp"),
"implicit session cwd must survive update reconnect, got {post:?}"
);
}
#[test]
fn startup_readopts_implicit_holders_for_update_reconnect() {
let dir = tempfile::tempdir().unwrap();
let sessions_dir = dir.path().join("sessions");
let port = free_udp_port();
let config = write_persistent_server_config(&dir, port);
let session = protocol::generate_implicit_session_name();
spawn_legacy_holder(&sessions_dir, &session);
let shell_pid = holder_shell_pid(&sessions_dir, &session).expect("legacy holder shell pid");
assert!(
unsafe { libc::kill(shell_pid, 0) } == 0,
"legacy holder shell must be live before startup"
);
let mut server = start_server(&dir, &config);
thread::sleep(Duration::from_millis(300));
let _ = server.kill();
let _ = server.wait();
assert!(
holder_shell_pid(&sessions_dir, &session).is_some(),
"startup must keep live implicit holders so active update reconnects can reattach"
);
assert!(
unsafe { libc::kill(shell_pid, 0) } == 0,
"startup must not shut down a live implicit holder before reconnect grace"
);
kill_holder(&sessions_dir, &session);
}
#[test]
+13
View File
@@ -58,6 +58,19 @@ fn encrypted_packet_round_trips() {
assert_eq!(plain, b"hello");
}
#[test]
fn plaintext_packet_never_decrypts_as_authenticated_body() {
let key = crypto::random_32();
let mut decoded = protocol::decode(
&protocol::encode_plain(PacketKind::Input, crypto::random_16(), 1, 0, b"hello").unwrap(),
)
.unwrap();
decoded.header.session_key_id = protocol::session_key_id(&key);
let err = protocol::decrypt_body(&decoded, &key, CLIENT_TO_SERVER).unwrap_err();
assert!(err.to_string().contains("not encrypted"));
}
#[test]
fn encrypted_packet_rejects_wrong_session_key_id_before_decrypt() {
let key = crypto::random_32();
+52
View File
@@ -0,0 +1,52 @@
#[test]
fn quiet_update_keeps_prebuilt_enabled_by_default() {
let install = include_str!("../install.sh");
assert!(install.contains("use_prebuilt=\"${DOSH_USE_PREBUILT:-1}\""));
assert!(
!install.contains("use_prebuilt=0"),
"quiet updates must not force source builds"
);
}
#[test]
fn release_scripts_skip_stale_artifacts_when_auto_discovering() {
let upload = include_str!("../scripts/upload-gitea-release.sh");
assert!(upload.contains("skipping $artifact: version"));
assert!(upload.contains("expected_version"));
let publish = include_str!("../scripts/publish-gitea-release.sh");
assert!(publish.contains("skipping stale artifact"));
assert!(publish.contains("actual=\"$(artifact_version \"$artifact\" || true)\""));
}
#[test]
fn systemd_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."
}
}
}
}
}