Compare commits
123
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
673b8ae16e | ||
|
|
262dfaca04 | ||
|
|
5ca3e98a5c | ||
|
|
40810e4ba2 | ||
|
|
1f01338eb8 | ||
|
|
5c8bd68aa6 | ||
|
|
68cf411143 | ||
|
|
b84f57ee9d | ||
|
|
1601d474e7 | ||
|
|
1466516053 | ||
|
|
bab3f777dc | ||
|
|
039dc641b3 | ||
|
|
4c9e31fd16 | ||
|
|
c1c672b188 | ||
|
|
a23f610f20 | ||
|
|
ff2b7fff2b | ||
|
|
f4879090f6 | ||
|
|
37ec9fc520 | ||
|
|
f3c7ec225d | ||
|
|
d3c40a06ac | ||
|
|
6b12b3dfe0 | ||
|
|
b90c34dc17 | ||
|
|
f205e0c128 | ||
|
|
92c94176bd | ||
|
|
4b2e9a62d5 | ||
|
|
689d20f7e7 | ||
|
|
252f081a61 | ||
|
|
b1e8326b0a | ||
|
|
3b4931aa8f | ||
|
|
5c54601cdf | ||
|
|
c085137250 | ||
|
|
237ad52bef | ||
|
|
a8ba852f16 | ||
|
|
f1a3de7730 | ||
|
|
80699dcf9d | ||
|
|
274c0f505e | ||
|
|
b393c0e5d5 | ||
|
|
d5bc8e3c64 | ||
|
|
c40f5459ba | ||
|
|
601510687e | ||
|
|
60387e9222 | ||
|
|
a48aa15308 | ||
|
|
691b58e531 | ||
|
|
431dcc3997 | ||
|
|
3224a9c699 | ||
|
|
00f5b2e001 | ||
|
|
03cb5c0f75 | ||
|
|
f8056b03b6 | ||
|
|
42fbf86ca9 | ||
|
|
13f8cb07c5 | ||
|
|
bbf6ffd725 | ||
|
|
35d9c9c6de | ||
|
|
51247576f3 | ||
|
|
255f584545 | ||
|
|
d947ccd934 | ||
|
|
cf27ba7ddf | ||
|
|
02607b49b1 | ||
|
|
48e3de2922 | ||
|
|
c95a913422 | ||
|
|
6292784f3a | ||
|
|
d2fd7ee346 | ||
|
|
acd22b93e8 | ||
|
|
ef712a79ee | ||
|
|
48a59496b2 | ||
|
|
606fad72e3 | ||
|
|
9d0396dbf9 | ||
|
|
f71cd975bc | ||
|
|
4842a129f2 | ||
|
|
60d73df5be | ||
|
|
f9a378a9cd | ||
|
|
fb6cf4e7c2 | ||
|
|
c0d83c9133 | ||
|
|
a4b3e7c89b | ||
|
|
44717e4956 | ||
|
|
149ea07edf | ||
|
|
6f40ff9e25 | ||
|
|
b21c2eed9d | ||
|
|
29d894b43b | ||
|
|
fcc21c7aef | ||
|
|
a029564d80 | ||
|
|
689d2a9575 | ||
|
|
38e70611c5 | ||
|
|
6adcdc01e8 | ||
|
|
742477bf6e | ||
|
|
a202f97704 | ||
|
|
f6ead86db4 | ||
|
|
013d653f99 | ||
|
|
4e7e4cff10 | ||
|
|
fbad776441 | ||
|
|
90d9b583c0 | ||
|
|
f7c4ebaaf7 | ||
|
|
27419f4ca8 | ||
|
|
ec2422bc3e | ||
|
|
d51cc248e7 | ||
|
|
b44ff8e773 | ||
|
|
7884ea2796 | ||
|
|
774da7371e | ||
|
|
41cdb0f54f | ||
|
|
90e53f4b68 | ||
|
|
d0d6f59cdf | ||
|
|
25d9a6aefa | ||
|
|
2835da76b0 | ||
|
|
eec8ef0a02 | ||
|
|
41256b66b7 | ||
|
|
14b7e75025 | ||
|
|
8b1af51bc6 | ||
|
|
a88d912d2b | ||
|
|
683c3cd01d | ||
|
|
9a52d65beb | ||
|
|
6b2933eb05 | ||
|
|
8da98c45e7 | ||
|
|
2c7654138b | ||
|
|
f9c1973c13 | ||
|
|
1306adb558 | ||
|
|
1e5517dcf1 | ||
|
|
cdeba047bc | ||
|
|
c47ae98c68 | ||
|
|
c48f2280a0 | ||
|
|
6bded28d40 | ||
|
|
9b0f09a8a8 | ||
|
|
c37424eb45 | ||
|
|
828206f757 | ||
|
|
2a6f28d529 |
@@ -3,6 +3,9 @@ name: ci
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 7 * * 1"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@@ -19,6 +22,70 @@ jobs:
|
|||||||
- name: Docker SSH benchmark gate
|
- name: Docker SSH benchmark gate
|
||||||
run: sh scripts/ci-docker-ssh-bench.sh
|
run: sh scripts/ci-docker-ssh-bench.sh
|
||||||
|
|
||||||
|
fuzz-smoke:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install nightly toolchain
|
||||||
|
id: nightly
|
||||||
|
continue-on-error: true
|
||||||
|
uses: dtolnay/rust-toolchain@nightly
|
||||||
|
- name: Install cargo-fuzz
|
||||||
|
id: install
|
||||||
|
if: steps.nightly.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
run: cargo install cargo-fuzz --locked
|
||||||
|
- name: Run fuzz targets briefly
|
||||||
|
if: steps.nightly.outcome == 'success' && steps.install.outcome == 'success'
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
DOSH_FUZZ_SECONDS="${DOSH_FUZZ_SECONDS:-300}" sh scripts/fuzz-run.sh
|
||||||
|
else
|
||||||
|
sh scripts/fuzz-run.sh 20
|
||||||
|
fi
|
||||||
|
- name: Note when fuzzing was skipped
|
||||||
|
if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success'
|
||||||
|
run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run."
|
||||||
|
|
||||||
|
windows-client:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- name: Build Windows client
|
||||||
|
run: cargo build --release --bin dosh-client --bin dosh-bench
|
||||||
|
- name: Package Windows client
|
||||||
|
shell: bash
|
||||||
|
run: sh scripts/package-release.sh
|
||||||
|
|
||||||
|
package-release:
|
||||||
|
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
name: linux-x86_64
|
||||||
|
- os: macos-14
|
||||||
|
name: macos-aarch64
|
||||||
|
- os: macos-13
|
||||||
|
name: macos-x86_64
|
||||||
|
- os: windows-latest
|
||||||
|
name: windows-x86_64
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- name: Package release
|
||||||
|
shell: bash
|
||||||
|
run: sh scripts/package-release.sh
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dosh-${{ matrix.name }}
|
||||||
|
path: |
|
||||||
|
target/dosh-release/dosh-*
|
||||||
|
!target/dosh-release/stage/**
|
||||||
|
|
||||||
remote-bench:
|
remote-bench:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
/target/
|
/target/
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
vscode-extension/*.vsix
|
||||||
|
|||||||
Generated
+17
-1
@@ -397,6 +397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid",
|
"const-oid",
|
||||||
|
"pem-rfc7468",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -435,7 +436,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dosh"
|
name = "dosh"
|
||||||
version = "0.1.0"
|
version = "1.0.0-rc15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -446,13 +447,17 @@ dependencies = [
|
|||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs",
|
"dirs",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
|
"filetime",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"hmac",
|
"hmac",
|
||||||
|
"libc",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
"rand",
|
"rand",
|
||||||
"rpassword",
|
"rpassword",
|
||||||
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"signature",
|
||||||
"ssh-key",
|
"ssh-key",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -573,6 +578,16 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filetime"
|
||||||
|
version = "0.2.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1498,6 +1513,7 @@ checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bcrypt-pbkdf",
|
"bcrypt-pbkdf",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
|
"num-bigint-dig",
|
||||||
"p256",
|
"p256",
|
||||||
"p384",
|
"p384",
|
||||||
"p521",
|
"p521",
|
||||||
|
|||||||
+7
-3
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "dosh"
|
name = "dosh"
|
||||||
version = "0.1.0"
|
version = "1.0.0-rc15"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
@@ -14,14 +14,18 @@ clap = { version = "4.5", features = ["derive"] }
|
|||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
ed25519-dalek = "2.1"
|
ed25519-dalek = "2.1"
|
||||||
|
filetime = "0.2"
|
||||||
hkdf = "0.12"
|
hkdf = "0.12"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
|
libc = "0.2"
|
||||||
portable-pty = "0.8"
|
portable-pty = "0.8"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
rsa = { version = "0.9.10", features = ["sha2"] }
|
||||||
rpassword = "7.5.4"
|
rpassword = "7.5.4"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] }
|
signature = "2.2"
|
||||||
|
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption", "p256", "rsa"] }
|
||||||
tokio = { version = "1.41", features = ["full"] }
|
tokio = { version = "1.41", features = ["full"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
vt100 = "0.15"
|
vt100 = "0.15"
|
||||||
@@ -35,4 +39,4 @@ opt-level = 2
|
|||||||
codegen-units = 16
|
codegen-units = 16
|
||||||
incremental = true
|
incremental = true
|
||||||
lto = false
|
lto = false
|
||||||
strip = false
|
strip = "debuginfo"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: build test fmt install bench-local bench-docker-ssh bench-docker-mosh
|
.PHONY: build test fmt clippy release-check 1.0-check reconnect-check hostile-network-check persistence-check package-check install package-release package-release-linux package-release-windows publish-release bench-report bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local tui-harness
|
||||||
|
|
||||||
build:
|
build:
|
||||||
cargo build --release
|
cargo build --release
|
||||||
@@ -9,20 +9,80 @@ test:
|
|||||||
fmt:
|
fmt:
|
||||||
cargo fmt
|
cargo fmt
|
||||||
|
|
||||||
install:
|
clippy:
|
||||||
sh packaging/install.sh
|
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
|
||||||
|
|
||||||
|
package-release:
|
||||||
|
sh scripts/package-release.sh
|
||||||
|
|
||||||
|
package-release-linux:
|
||||||
|
DOSH_PACKAGE_OS=linux DOSH_PACKAGE_ARCH=x86_64 sh scripts/package-release.sh
|
||||||
|
|
||||||
|
package-release-windows:
|
||||||
|
DOSH_PACKAGE_OS=windows DOSH_PACKAGE_ARCH=x86_64 DOSH_PACKAGE_TARGET=x86_64-pc-windows-gnu sh scripts/package-release.sh
|
||||||
|
|
||||||
|
publish-release:
|
||||||
|
sh scripts/publish-gitea-release.sh
|
||||||
|
|
||||||
|
bench-report:
|
||||||
|
sh scripts/bench-report.sh
|
||||||
|
|
||||||
|
# Safe, self-contained local benchmark matrix (native cold auth, cached attach
|
||||||
|
# ticket, local-auth) on a throwaway server bound to 127.0.0.1 on a free port in
|
||||||
|
# a temp HOME. Never touches the production server or UDP port 50000.
|
||||||
bench-local:
|
bench-local:
|
||||||
cargo build
|
sh scripts/bench-local.sh
|
||||||
tmp="$$(mktemp -d)"; \
|
|
||||||
HOME="$$tmp" target/debug/dosh-server serve >/tmp/dosh-bench-server.log 2>&1 & \
|
# Same matrix, machine-readable JSON output (one object per metric with raw
|
||||||
pid="$$!"; \
|
# samples). Useful for publishing or regression tracking.
|
||||||
trap 'kill "$$pid" 2>/dev/null || true; rm -rf "$$tmp"' EXIT INT TERM; \
|
bench-local-json:
|
||||||
sleep 0.5; \
|
DOSH_BENCH_JSON=1 sh scripts/bench-local.sh
|
||||||
HOME="$$tmp" target/debug/dosh-bench --local-auth --server local --iterations 5
|
|
||||||
|
|
||||||
bench-docker-ssh:
|
bench-docker-ssh:
|
||||||
sh scripts/ci-docker-ssh-bench.sh
|
sh scripts/ci-docker-ssh-bench.sh
|
||||||
|
|
||||||
bench-docker-mosh:
|
bench-docker-mosh:
|
||||||
DOSH_BENCH_INCLUDE_MOSH=1 sh scripts/ci-docker-ssh-bench.sh
|
DOSH_BENCH_INCLUDE_MOSH=1 sh scripts/ci-docker-ssh-bench.sh
|
||||||
|
|
||||||
|
fuzz-smoke:
|
||||||
|
sh scripts/fuzz-run.sh 20
|
||||||
|
|
||||||
|
# Longer pre-launch fuzz pass. Override with DOSH_FUZZ_SECONDS=NN.
|
||||||
|
fuzz-deep:
|
||||||
|
DOSH_FUZZ_SECONDS=$${DOSH_FUZZ_SECONDS:-300} sh scripts/fuzz-run.sh
|
||||||
|
|
||||||
|
tui-harness:
|
||||||
|
sh scripts/tui-harness.sh
|
||||||
|
|
||||||
|
# 30-minute launch soak by default. Override with DOSH_SOAK_SECONDS=NN.
|
||||||
|
soak-local:
|
||||||
|
DOSH_SOAK_SECONDS=$${DOSH_SOAK_SECONDS:-1800} cargo test --test integration_smoke sleep_roaming_soak_30m -- --ignored --nocapture
|
||||||
|
|||||||
@@ -1,269 +1,112 @@
|
|||||||
# dosh - Dormant Shell
|
# Dosh
|
||||||
|
|
||||||
dosh is a low-latency remote terminal designed around fast attach and fast reconnect.
|
Dosh is an encrypted remote terminal for fast reconnecting shells.
|
||||||
It is mosh-shaped, but not a mosh clone: the server is a resident daemon, terminal
|
|
||||||
sessions stay hot, and repeat connects try encrypted UDP before starting SSH.
|
|
||||||
|
|
||||||
The core target is simple:
|
It runs a `dosh-server` on a Unix-like host and a `dosh` client on macOS,
|
||||||
|
Linux, or Windows. Setup can use SSH, then Dosh connects over encrypted UDP,
|
||||||
|
keeps sessions alive across disconnects, supports terminal apps, and can carry
|
||||||
|
TCP forwarding, file copy, and VS Code Remote-SSH streams.
|
||||||
|
|
||||||
- First secure trust establishment uses SSH.
|
## Support
|
||||||
- Existing sessions attach in one encrypted UDP exchange whenever cached credentials allow it.
|
|
||||||
- Reconnect after sleep, roaming, or network change resumes in one encrypted UDP exchange.
|
|
||||||
- Cold SSH fallback stays competitive with plain `ssh` by doing less after auth.
|
|
||||||
|
|
||||||
## Why not just mosh?
|
- Client: macOS, Linux, Windows
|
||||||
|
- Server: Linux and other Unix-like systems with PTYs
|
||||||
|
- Default UDP port: `50000`
|
||||||
|
- Windows: client only
|
||||||
|
|
||||||
mosh is excellent at roaming and high-latency interactivity. Its startup path still
|
Dosh is meant to replace Mosh and everyday interactive SSH sessions. It does
|
||||||
has work dosh can avoid:
|
not currently include SFTP, X11 forwarding, or a Windows server.
|
||||||
|
|
||||||
1. SSH connects to the host.
|
|
||||||
2. SSH starts `mosh-server`.
|
|
||||||
3. The client receives connection material over SSH.
|
|
||||||
4. SSH exits and the mosh UDP session begins.
|
|
||||||
|
|
||||||
dosh keeps `dosh-server` running before the client arrives. Named PTY sessions can
|
|
||||||
also be prewarmed, so attaching to `default` does not need to spawn a daemon, create
|
|
||||||
a PTY, or start a shell on the user's critical path.
|
|
||||||
|
|
||||||
This is not an encryption argument against mosh. dosh also encrypts its UDP data
|
|
||||||
channel; the speed difference comes from keeping the server and session hot.
|
|
||||||
|
|
||||||
## Fast Path Order
|
|
||||||
|
|
||||||
The client always tries the cheapest valid path first:
|
|
||||||
|
|
||||||
1. **UDP resume:** existing `ClientId` and session key. No SSH. One encrypted UDP
|
|
||||||
request, one encrypted UDP reply.
|
|
||||||
2. **UDP attach ticket:** cached server-issued attach ticket for the same
|
|
||||||
host/user/session/mode. No SSH. One encrypted UDP request, one encrypted UDP
|
|
||||||
reply.
|
|
||||||
3. **SSH bootstrap:** `ssh -T user@host ~/.local/bin/dosh-auth ...`, then one encrypted UDP
|
|
||||||
attach.
|
|
||||||
4. **New session:** same as attach, but the server must create the PTY/shell unless
|
|
||||||
the session was prewarmed.
|
|
||||||
|
|
||||||
The fastest path is not a custom SSH replacement. SSH remains the first trust root;
|
|
||||||
dosh removes SSH from repeat attaches when the server has already issued valid
|
|
||||||
credentials.
|
|
||||||
|
|
||||||
Attach tickets are implemented because they are the way a fresh client process can
|
|
||||||
skip SSH after a recent successful bootstrap.
|
|
||||||
|
|
||||||
## Connection Speed Contract
|
|
||||||
|
|
||||||
dosh is measured by terminal-ready time: elapsed time from running `dosh host` to the
|
|
||||||
first usable terminal screen.
|
|
||||||
|
|
||||||
- UDP resume: <= one measured UDP RTT + local render time.
|
|
||||||
- UDP attach ticket: <= one measured UDP RTT + local render time.
|
|
||||||
- Warm attach with ControlMaster: <= `ssh host true` over the existing master + one
|
|
||||||
measured UDP RTT.
|
|
||||||
- Cold attach without ControlMaster: <= cold `ssh host` terminal-ready time + one
|
|
||||||
measured UDP RTT.
|
|
||||||
- New session: measured separately because it may need PTY and shell creation.
|
|
||||||
|
|
||||||
Server-side client resume state is kept for a day by default, so a sleeping laptop
|
|
||||||
can resume the same encrypted client association without depending on a still-valid
|
|
||||||
attach ticket. Attach tickets remain the fast path for fresh client processes.
|
|
||||||
|
|
||||||
The client emits timing spans for credential lookup, SSH bootstrap, UDP resume,
|
|
||||||
UDP ticket attach, and terminal-ready time.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```text
|
|
||||||
dosh-server
|
|
||||||
UDP socket on one configurable port
|
|
||||||
session table keyed by name
|
|
||||||
one PTY per named session
|
|
||||||
optional prewarmed sessions, default ["default"]
|
|
||||||
terminal parser/screen state per session
|
|
||||||
client table per session
|
|
||||||
encrypted UDP protocol
|
|
||||||
tiny SSH-invoked dosh-auth helper mode
|
|
||||||
|
|
||||||
dosh-client
|
|
||||||
terminal raw mode
|
|
||||||
local credential cache
|
|
||||||
UDP resume/attach first
|
|
||||||
SSH bootstrap fallback
|
|
||||||
PTY input/output forwarding
|
|
||||||
reconnect and roaming state machine
|
|
||||||
```
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Default UDP port: `50000`. This is intentionally inside the common forwarded range
|
Unix/macOS server and client:
|
||||||
`50000-52000/udp`.
|
|
||||||
|
|
||||||
Install on each Linux server you want to attach to:
|
```sh
|
||||||
|
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh | sh -s -- both --repo https://git.palav.dev/Palav/dosh.git --port 50000
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
|
|
||||||
| DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_PORT=50000 sh -s -- server
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Install the client on macOS:
|
Unix/macOS client only:
|
||||||
|
|
||||||
```bash
|
```sh
|
||||||
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
|
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh | sh -s -- client --repo https://git.palav.dev/Palav/dosh.git
|
||||||
| DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_SERVER=palav DOSH_HOST=git.palav.dev DOSH_PORT=50000 sh -s -- client
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Update an installed client later:
|
Windows client:
|
||||||
|
|
||||||
```bash
|
```powershell
|
||||||
|
irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dosh setup HOST
|
||||||
|
dosh HOST
|
||||||
|
dosh HOST COMMAND
|
||||||
dosh update
|
dosh update
|
||||||
```
|
```
|
||||||
|
|
||||||
Install the client on Windows PowerShell:
|
Useful commands:
|
||||||
|
|
||||||
```powershell
|
```sh
|
||||||
$env:DOSH_REPO="https://git.palav.dev/Palav/dosh.git"; $env:DOSH_SERVER="palav"; $env:DOSH_HOST="git.palav.dev"; $env:DOSH_PORT="50000"; irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
|
dosh exec HOST COMMAND
|
||||||
|
dosh cp SRC DST
|
||||||
|
dosh ls host:path
|
||||||
|
dosh cat host:path
|
||||||
|
dosh mkdir host:path
|
||||||
|
dosh rm [-r] host:path
|
||||||
|
dosh forward HOST -L 8080:127.0.0.1:80
|
||||||
|
dosh forward HOST -D 1080
|
||||||
|
dosh forward HOST -R 2222:127.0.0.1:22
|
||||||
|
dosh status HOST
|
||||||
|
dosh doctor HOST
|
||||||
|
dosh recover HOST
|
||||||
|
dosh restart HOST
|
||||||
```
|
```
|
||||||
|
|
||||||
Attach:
|
Agent forwarding is opt-in with `-A` and must also be enabled on the server.
|
||||||
|
File copy must be enabled by the server config.
|
||||||
|
|
||||||
```bash
|
## VS Code
|
||||||
dosh palav
|
|
||||||
|
Dosh can carry VS Code Remote-SSH through its transport:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dosh vscode setup HOST
|
||||||
|
dosh vscode HOST /remote/path
|
||||||
```
|
```
|
||||||
|
|
||||||
Plain `dosh palav` opens a fresh terminal session. Use named sessions when you want
|
This creates a managed SSH config entry using `ProxyCommand dosh proxy-stdio`.
|
||||||
to reattach to the same persistent terminal from multiple clients:
|
VS Code still uses Remote-SSH and its normal remote server; Dosh carries the
|
||||||
|
SSH byte stream.
|
||||||
|
|
||||||
```bash
|
## Config
|
||||||
dosh --session work palav
|
|
||||||
dosh --session logs palav
|
```text
|
||||||
|
~/.config/dosh/client.toml
|
||||||
|
~/.config/dosh/hosts.toml
|
||||||
|
~/.config/dosh/server.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
Press `Ctrl-]` to detach the current client while leaving the server session alive.
|
Common client settings:
|
||||||
Typing `exit` in the remote shell closes that Dosh session and returns the client.
|
|
||||||
If UDP packets stop arriving, Dosh keeps the terminal open, sends keepalive pings,
|
|
||||||
and attempts ticket-based reconnect. The old in-band status overlay was removed
|
|
||||||
because writing directly over the terminal could corrupt TUIs; a non-destructive
|
|
||||||
status surface is tracked as a public-readiness item.
|
|
||||||
|
|
||||||
If SSH and UDP use different public names, specify the UDP address:
|
```toml
|
||||||
|
default_session = "new"
|
||||||
```bash
|
predict = true
|
||||||
dosh-client --dosh-host public.example.com --dosh-port 50000 user@host
|
cache_attach_tickets = true
|
||||||
|
disconnect_status = true
|
||||||
```
|
```
|
||||||
|
|
||||||
Dosh already lets OpenSSH handle SSH aliases, users, keys, ports, known-hosts,
|
## Rust Library
|
||||||
ProxyJump, and other SSH config during bootstrap. It also runs `ssh -G` to infer the
|
|
||||||
UDP host from an SSH alias when `dosh_host` is not configured. To make that explicit
|
|
||||||
in Dosh's host config:
|
|
||||||
|
|
||||||
```bash
|
Dosh exposes a Rust transport for encrypted, reconnecting application streams.
|
||||||
dosh import-ssh palav homelab
|
Use `dosh::client::DoshClient` and `dosh::server::DoshServer` for the normal
|
||||||
|
native-auth path. Use `dosh::transport::DoshTransport` only when you already
|
||||||
|
own session setup and authentication.
|
||||||
|
|
||||||
|
Runnable examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
examples/sdk_echo_client.rs
|
||||||
|
examples/sdk_echo_server.rs
|
||||||
```
|
```
|
||||||
|
|
||||||
## Develop
|
|
||||||
|
|
||||||
Build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build
|
|
||||||
```
|
|
||||||
|
|
||||||
Attach locally, using local bootstrap instead of SSH:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
target/debug/dosh-client --local-auth --no-cache local
|
|
||||||
```
|
|
||||||
|
|
||||||
Benchmark local attach:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
target/debug/dosh-bench --local-auth --server local --iterations 5
|
|
||||||
```
|
|
||||||
|
|
||||||
Benchmark a remote host over SSH bootstrap:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
target/release/dosh-bench --server user@host --ssh-port 22 --iterations 3
|
|
||||||
```
|
|
||||||
|
|
||||||
Benchmark the ControlMaster-backed SSH bootstrap path:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
target/release/dosh-bench --server user@host --controlmaster --iterations 3
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the Docker OpenSSH benchmark gate used by CI. It checks both cold SSH bootstrap
|
|
||||||
and ControlMaster-backed SSH bootstrap against a containerized `sshd` plus resident
|
|
||||||
`dosh-server`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make bench-docker-ssh
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the same Docker comparison with Mosh installed in the benchmark container:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make bench-docker-mosh
|
|
||||||
```
|
|
||||||
|
|
||||||
That prints `ssh_true_ms`, `dosh_attach_ms`, and `mosh_start_true_ms` under the same
|
|
||||||
container, key, DNS, and network path. It also prints `dosh_cached_attach_ms`, which
|
|
||||||
is the real Dosh fast path after the first SSH-authenticated bootstrap has issued an
|
|
||||||
attach ticket. See `docs/PUBLIC_READINESS.md` before using the numbers publicly;
|
|
||||||
Dosh's current strongest claim is fast attach/reconnect, not full Mosh feature
|
|
||||||
parity yet.
|
|
||||||
|
|
||||||
The CI workflow includes an optional remote benchmark job. It runs when
|
|
||||||
`DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository secrets are
|
|
||||||
configured.
|
|
||||||
|
|
||||||
Install release binaries and the user systemd service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Rules
|
|
||||||
|
|
||||||
The stack is performance-driven, not fixed by taste. Rust is the default because the
|
|
||||||
likely bottlenecks are network RTT, SSH startup/auth, PTY/shell creation, packet
|
|
||||||
size, and terminal rendering. Change language or runtime only if measurements show
|
|
||||||
they are the bottleneck.
|
|
||||||
|
|
||||||
Hot-path rules:
|
|
||||||
|
|
||||||
- Custom UDP protocol with AEAD for v0; no QUIC handshake on attach.
|
|
||||||
- Fixed binary packet headers for terminal traffic; no JSON on the protocol path.
|
|
||||||
- Preallocated buffers; avoid per-packet heap churn.
|
|
||||||
- Single-thread event loop is preferred for the hot path.
|
|
||||||
- No PTY allocation, shell spawn, shell rc files, or MOTD on attach to an existing
|
|
||||||
session.
|
|
||||||
- Initial snapshot should be sent in the first UDP reply when it fits under the
|
|
||||||
packet budget.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Connection speed as specified above.
|
|
||||||
- UDP roaming and reconnect.
|
|
||||||
- Encrypted terminal data.
|
|
||||||
- Reuse SSH pubkeys for first trust establishment.
|
|
||||||
- Named persistent sessions.
|
|
||||||
- Multiple clients attached to one session.
|
|
||||||
- Optional view-only clients.
|
|
||||||
- Single server port, not one port per session.
|
|
||||||
- Static server and client binaries where practical.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Replacing SSH as the first public-key trust mechanism.
|
|
||||||
- Multi-user access control.
|
|
||||||
- Windows support in v0.
|
|
||||||
- Full mosh compatibility.
|
|
||||||
- Perfect predictive local echo in the first MVP.
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Rust implementation is present in this repository. It contains `dosh-server`,
|
|
||||||
`dosh-client`, `dosh-auth`, `dosh-bench`, shared auth/crypto/protocol modules, a
|
|
||||||
resident PTY server, encrypted UDP bootstrap attach, UDP resume, sealed UDP attach
|
|
||||||
tickets, client ACKs, server retransmit bookkeeping, sliding replay protection,
|
|
||||||
server-side `vt100` screen snapshots/diffs, a hardened user systemd unit, an install
|
|
||||||
script, Docker SSH benchmark gates, CI, and protocol/integration tests.
|
|
||||||
|
|||||||
@@ -1,598 +0,0 @@
|
|||||||
# dosh - Dormant Shell Spec
|
|
||||||
|
|
||||||
**Status:** Implemented Rust build with local and Docker SSH verification
|
|
||||||
**Default language:** Rust, unless benchmarks prove the stack is the bottleneck
|
|
||||||
**Binaries:** `dosh-server`, `dosh-client`, `dosh-auth`, `dosh-bench`
|
|
||||||
**Helper mode:** `dosh-server auth` or `~/.local/bin/dosh-auth`, invoked by SSH with `-T`
|
|
||||||
**Native v1 plan:** `docs/NATIVE_V1_SPEC.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Product Shape
|
|
||||||
|
|
||||||
dosh is a fast-attach remote terminal. It borrows the useful shape of mosh - UDP
|
|
||||||
transport, roaming, and latency-tolerant terminal rendering - but optimizes a
|
|
||||||
different first-order problem: getting the user back into an already-running terminal
|
|
||||||
as quickly as possible.
|
|
||||||
|
|
||||||
The daemon is resident. Sessions are named. A session owns one PTY and one
|
|
||||||
authoritative terminal screen. Clients attach to that session over encrypted UDP.
|
|
||||||
SSH is used for first trust establishment and as fallback when cached credentials are
|
|
||||||
missing, expired, or rejected. Native Dosh auth is specified separately in
|
|
||||||
`docs/NATIVE_V1_SPEC.md` as the path toward replacing the day-to-day SSH workflow on
|
|
||||||
Dosh-installed servers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Design Goals
|
|
||||||
|
|
||||||
- Connection speed first:
|
|
||||||
- UDP resume: one encrypted UDP request and one encrypted UDP reply.
|
|
||||||
- UDP attach ticket: one encrypted UDP request and one encrypted UDP reply.
|
|
||||||
- Warm SSH bootstrap: existing-ControlMaster SSH command latency plus one UDP RTT.
|
|
||||||
- Cold SSH bootstrap: cold `ssh host` terminal-ready time plus at most one UDP RTT.
|
|
||||||
- No daemon spawn, PTY spawn, shell startup, rc file execution, or MOTD on attach to
|
|
||||||
an existing session.
|
|
||||||
- Prewarm configured sessions at daemon startup, including `default` by default.
|
|
||||||
- Encrypted UDP terminal data.
|
|
||||||
- Single configurable UDP port.
|
|
||||||
- Multiple clients attached to one session.
|
|
||||||
- Optional read-only clients.
|
|
||||||
- Named persistent sessions.
|
|
||||||
- Reuse existing SSH key infrastructure.
|
|
||||||
- Instrument connection timing from the first implementation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Non-Goals
|
|
||||||
|
|
||||||
- Replacing SSH as the first public-key trust mechanism.
|
|
||||||
- Multi-user authorization or ACLs.
|
|
||||||
- Windows support in v0.
|
|
||||||
- Full mosh protocol compatibility.
|
|
||||||
- Perfect local echo/prediction in the first MVP.
|
|
||||||
- QUIC in v0. QUIC can be revisited if measurements show custom UDP is not enough.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Connection Speed Contract
|
|
||||||
|
|
||||||
Measure terminal-ready time: elapsed time from launching `dosh ...` to first usable
|
|
||||||
terminal render.
|
|
||||||
|
|
||||||
Benchmarks must use the same host, network, key, DNS path, and SSH config.
|
|
||||||
|
|
||||||
| Path | Acceptance gate |
|
|
||||||
| --- | --- |
|
|
||||||
| UDP resume | <= one measured UDP RTT + local render time |
|
|
||||||
| UDP attach ticket | <= one measured UDP RTT + local render time |
|
|
||||||
| Warm SSH bootstrap | <= `ssh host true` over existing ControlMaster + one measured UDP RTT |
|
|
||||||
| Cold SSH bootstrap | <= cold `ssh host` terminal-ready time + one measured UDP RTT |
|
|
||||||
| New session | Report separately; PTY/shell creation is expected |
|
|
||||||
|
|
||||||
Required timing evidence:
|
|
||||||
|
|
||||||
- Client stderr spans for credential lookup, SSH bootstrap, UDP resume, UDP ticket
|
|
||||||
attach, and terminal-ready time.
|
|
||||||
- `dosh-bench` samples for SSH `true`, Dosh attach, and optional ControlMaster-backed
|
|
||||||
SSH `true`.
|
|
||||||
- `make bench-docker-ssh` gates both cold SSH bootstrap and ControlMaster-backed SSH
|
|
||||||
bootstrap against containerized OpenSSH plus resident `dosh-server`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Fast Path Order
|
|
||||||
|
|
||||||
The client always tries the cheapest path that is valid for the requested
|
|
||||||
host/user/session/mode:
|
|
||||||
|
|
||||||
1. **UDP resume**
|
|
||||||
- Requires cached `ClientId`, session key, server identity, and unexpired resume
|
|
||||||
metadata.
|
|
||||||
- Sends `ResumeRequest`.
|
|
||||||
- Receives `ResumeOk` with a snapshot or diff.
|
|
||||||
|
|
||||||
2. **UDP attach ticket**
|
|
||||||
- Requires cached attach ticket scoped to server identity, SSH username, session,
|
|
||||||
mode, and expiry.
|
|
||||||
- Sends `TicketAttachRequest`.
|
|
||||||
- Receives `AttachOk` with session key, `ClientId`, and snapshot.
|
|
||||||
|
|
||||||
3. **SSH bootstrap**
|
|
||||||
- Runs `ssh -T user@host ~/.local/bin/dosh-auth ...`.
|
|
||||||
- Receives attach token, attach ticket, session key material, and server metadata.
|
|
||||||
- Sends `BootstrapAttachRequest`.
|
|
||||||
- Receives `AttachOk` with `ClientId` and snapshot.
|
|
||||||
|
|
||||||
4. **New session**
|
|
||||||
- Same as attach, but if the session does not exist and is not prewarmed, the server
|
|
||||||
creates PTY and shell before first paint.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Architecture
|
|
||||||
|
|
||||||
```text
|
|
||||||
dosh-server
|
|
||||||
config loader
|
|
||||||
secret manager
|
|
||||||
UDP socket on one port
|
|
||||||
session table: HashMap<SessionName, Session>
|
|
||||||
optional prewarm of configured sessions
|
|
||||||
auth helper mode for SSH bootstrap
|
|
||||||
metrics/timing logger
|
|
||||||
|
|
||||||
Session
|
|
||||||
PTY master
|
|
||||||
child process/shell
|
|
||||||
terminal parser
|
|
||||||
authoritative screen model
|
|
||||||
scrollback ring
|
|
||||||
monotonic output sequence
|
|
||||||
client table: HashMap<ClientId, ClientState>
|
|
||||||
|
|
||||||
ClientState
|
|
||||||
ClientId
|
|
||||||
UDP endpoint
|
|
||||||
mode: read-write | view-only
|
|
||||||
session key id
|
|
||||||
last acked sequence
|
|
||||||
terminal size
|
|
||||||
last seen timestamp
|
|
||||||
|
|
||||||
dosh-client
|
|
||||||
config loader
|
|
||||||
local credential cache
|
|
||||||
terminal raw mode
|
|
||||||
UDP protocol engine
|
|
||||||
SSH bootstrap runner
|
|
||||||
reconnect state machine
|
|
||||||
renderer
|
|
||||||
```
|
|
||||||
|
|
||||||
Server hot-path ownership should avoid locks on every broadcast. A single event-loop
|
|
||||||
owner per session is preferred. Cross-thread designs are allowed only if benchmarked.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Security Model
|
|
||||||
|
|
||||||
SSH is the first trust root. dosh does not implement a competing public-key login
|
|
||||||
system in v0.
|
|
||||||
|
|
||||||
The UDP channel uses AEAD. Recommended default: `ChaCha20-Poly1305` for portable
|
|
||||||
speed, with `AES-GCM` allowed when hardware acceleration is known to be available.
|
|
||||||
The negotiated algorithm is recorded in the bootstrap response.
|
|
||||||
|
|
||||||
All encrypted packets use:
|
|
||||||
|
|
||||||
- Unique nonce per `(session_key_id, direction)`.
|
|
||||||
- Monotonic packet counter.
|
|
||||||
- Associated data containing protocol version, packet type, session name hash,
|
|
||||||
client id when known, and sequence numbers.
|
|
||||||
- Replay rejection using the packet counter window.
|
|
||||||
|
|
||||||
Secrets:
|
|
||||||
|
|
||||||
- `server_secret`: generated on first server start; stored mode `0600`.
|
|
||||||
- `session_key`: random 256-bit key per client attachment, rotated on SSH bootstrap
|
|
||||||
or ticket attach.
|
|
||||||
- `attach_ticket_key`: derived from `server_secret` and rotated by server key epoch.
|
|
||||||
|
|
||||||
No terminal bytes are sent outside AEAD after the attach handshake begins.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. SSH Bootstrap Auth
|
|
||||||
|
|
||||||
Client command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh -T user@host ~/.local/bin/dosh-auth \
|
|
||||||
--protocol 1 \
|
|
||||||
--nonce <client_nonce> \
|
|
||||||
--session <name> \
|
|
||||||
--mode <read-write|view-only> \
|
|
||||||
--size <cols>x<rows> \
|
|
||||||
--client-version <version>
|
|
||||||
```
|
|
||||||
|
|
||||||
`dosh-auth` must:
|
|
||||||
|
|
||||||
- Not allocate a PTY.
|
|
||||||
- Not start a shell.
|
|
||||||
- Not run user shell rc files.
|
|
||||||
- Read server config and secret directly.
|
|
||||||
- Return one compact binary or base64url response on stdout.
|
|
||||||
- Exit immediately.
|
|
||||||
|
|
||||||
Bootstrap response fields:
|
|
||||||
|
|
||||||
- `protocol_version`
|
|
||||||
- `server_id`
|
|
||||||
- `server_key_epoch`
|
|
||||||
- `issued_at`
|
|
||||||
- `expires_at`
|
|
||||||
- `user`
|
|
||||||
- `session`
|
|
||||||
- `mode`
|
|
||||||
- `terminal_size`
|
|
||||||
- `attach_token`
|
|
||||||
- `attach_ticket`
|
|
||||||
- `attach_ticket_psk`
|
|
||||||
- `session_key`
|
|
||||||
- `session_key_id`
|
|
||||||
- `udp_host`
|
|
||||||
- `udp_port`
|
|
||||||
- `aead_algorithm`
|
|
||||||
|
|
||||||
`attach_token = HMAC-SHA256(server_secret, user || session || mode || terminal_size ||
|
|
||||||
client_nonce || issued_at || expires_at || session_key_id)`.
|
|
||||||
|
|
||||||
The token TTL defaults to 30 seconds. Attach tickets default to 1 hour and are
|
|
||||||
server-configurable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Attach Tickets
|
|
||||||
|
|
||||||
Attach tickets let a new client process attach without spawning SSH again.
|
|
||||||
|
|
||||||
Ticket properties:
|
|
||||||
|
|
||||||
- Server-sealed and authenticated by `attach_ticket_key`.
|
|
||||||
- Paired with a client-held random `attach_ticket_psk` returned during SSH bootstrap.
|
|
||||||
- Scoped to server identity, SSH username, session, mode, and key epoch.
|
|
||||||
- Short-lived by default.
|
|
||||||
- Revoked implicitly when server secret/key epoch changes.
|
|
||||||
- Stored client-side with mode `0600`, along with `attach_ticket_psk`.
|
|
||||||
|
|
||||||
Ticket attach does not prove fresh possession of the SSH private key. It proves recent
|
|
||||||
possession of a server-issued credential. This is acceptable for speed, configurable,
|
|
||||||
and can be disabled with `allow_attach_tickets = false`.
|
|
||||||
|
|
||||||
Ticket attach flow:
|
|
||||||
|
|
||||||
1. Client sends `TicketAttachRequest` containing the sealed ticket, client nonce, and
|
|
||||||
requested terminal size.
|
|
||||||
2. The request body is AEAD-encrypted with a key derived from
|
|
||||||
`HKDF(attach_ticket_psk, client_nonce || "ticket-attach-request")`.
|
|
||||||
3. Server opens the sealed ticket, validates scope/expiry/key epoch, derives the same
|
|
||||||
request key, and decrypts the request.
|
|
||||||
4. Server creates a fresh session key and `ClientId`.
|
|
||||||
5. `AttachOk` is AEAD-encrypted with
|
|
||||||
`HKDF(attach_ticket_psk, client_nonce || server_nonce || "ticket-attach-ok")` and
|
|
||||||
carries the fresh session key metadata plus first snapshot.
|
|
||||||
6. Subsequent terminal packets use the fresh session key, not the ticket PSK.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. UDP Protocol
|
|
||||||
|
|
||||||
UDP port defaults to `50000`. One socket handles all sessions and clients.
|
|
||||||
|
|
||||||
Hot-path terminal packets use a fixed binary header:
|
|
||||||
|
|
||||||
```text
|
|
||||||
magic 4 bytes "DOSH"
|
|
||||||
version 1 byte 1
|
|
||||||
type 1 byte
|
|
||||||
flags 2 bytes
|
|
||||||
conn_id 16 bytes zero before client id is assigned
|
|
||||||
seq 8 bytes sender packet sequence
|
|
||||||
ack 8 bytes latest received peer sequence
|
|
||||||
body_len 2 bytes
|
|
||||||
body body_len bytes
|
|
||||||
tag AEAD tag, length depends on algorithm
|
|
||||||
```
|
|
||||||
|
|
||||||
Packet types:
|
|
||||||
|
|
||||||
| Type | Direction | Encrypted | Purpose |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `BootstrapAttachRequest` | client -> server | token-authenticated | Attach after SSH bootstrap |
|
|
||||||
| `TicketAttachRequest` | client -> server | ticket PSK | Attach with cached ticket |
|
|
||||||
| `AttachOk` | server -> client | yes | Assign client id and send first snapshot |
|
|
||||||
| `AttachReject` | server -> client | no terminal bytes | Reject and require SSH |
|
|
||||||
| `ResumeRequest` | client -> server | yes | Resume known client |
|
|
||||||
| `ResumeOk` | server -> client | yes | Endpoint updated; diff/snapshot follows |
|
|
||||||
| `Input` | client -> server | yes | PTY input bytes |
|
|
||||||
| `Resize` | client -> server | yes | Terminal size update |
|
|
||||||
| `Frame` | server -> client | yes | Screen diff or PTY byte frame |
|
|
||||||
| `Ack` | both | yes | Ack without payload |
|
|
||||||
| `Ping` / `Pong` | both | yes | Keepalive and RTT |
|
|
||||||
| `Detach` | client -> server | yes | Remove client, keep session |
|
|
||||||
|
|
||||||
MTU target:
|
|
||||||
|
|
||||||
- Default payload target: 1200 bytes.
|
|
||||||
- Larger datagrams may be enabled only after path MTU discovery.
|
|
||||||
- Snapshots larger than the target are chunked.
|
|
||||||
|
|
||||||
Reliability:
|
|
||||||
|
|
||||||
- Input packets are reliable and ordered per client.
|
|
||||||
- Output frames are sequenced; clients ack rendered sequence.
|
|
||||||
- Server retransmits unacked frames within a bounded window.
|
|
||||||
- If a client falls too far behind, server sends a fresh snapshot instead of replaying
|
|
||||||
unlimited diffs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Sessions and PTYs
|
|
||||||
|
|
||||||
Named sessions:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dosh host # open a fresh generated session
|
|
||||||
dosh --session work host # attach/create named persistent session
|
|
||||||
dosh --session work --view-only host
|
|
||||||
```
|
|
||||||
|
|
||||||
Session behavior:
|
|
||||||
|
|
||||||
- One PTY per session.
|
|
||||||
- Sessions persist until killed or server exits.
|
|
||||||
- If a session has zero clients, the PTY keeps running.
|
|
||||||
- Plain client attaches use generated session names by default.
|
|
||||||
- `--session <name>` intentionally reuses or shares a persistent session.
|
|
||||||
- Configured sessions are prewarmed at daemon startup.
|
|
||||||
- If a requested session does not exist:
|
|
||||||
- `attach` creates it only when `create_on_attach = true`.
|
|
||||||
- `new` always creates it and fails if it already exists.
|
|
||||||
|
|
||||||
Resize policy:
|
|
||||||
|
|
||||||
- One PTY means one size.
|
|
||||||
- Read-write clients may resize.
|
|
||||||
- View-only clients never resize.
|
|
||||||
- Default policy: latest read-write resize wins.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Screen State
|
|
||||||
|
|
||||||
Server maintains the authoritative terminal model:
|
|
||||||
|
|
||||||
- Visible grid.
|
|
||||||
- Cursor position and style.
|
|
||||||
- Alternate screen.
|
|
||||||
- Text attributes and colors.
|
|
||||||
- Scrollback ring.
|
|
||||||
- Monotonic output sequence.
|
|
||||||
|
|
||||||
Initial attach:
|
|
||||||
|
|
||||||
- Server sends a snapshot in the first UDP reply if it fits the packet budget.
|
|
||||||
- If not, server sends a minimal first frame immediately and follows with chunks.
|
|
||||||
|
|
||||||
Diffs:
|
|
||||||
|
|
||||||
- Diffs are computed per client from that client's last acked rendered sequence.
|
|
||||||
- Lagging clients may receive larger diffs or a full snapshot.
|
|
||||||
- Diffs are preferred over raw PTY bytes for reconnect correctness.
|
|
||||||
|
|
||||||
Encoding:
|
|
||||||
|
|
||||||
- Hot terminal frames use fixed binary headers and compact binary payloads.
|
|
||||||
- MessagePack is allowed only for non-hot control/list/config responses.
|
|
||||||
- JSON is not used on the protocol path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Multi-Client Model
|
|
||||||
|
|
||||||
Default mode is shared input. Any read-write client can write to the session PTY.
|
|
||||||
All clients see the same resulting screen.
|
|
||||||
|
|
||||||
View-only mode:
|
|
||||||
|
|
||||||
- Client suppresses local input.
|
|
||||||
- Server rejects `Input` from view-only clients even if a malformed client sends it.
|
|
||||||
- Promotion/demotion requires reconnect.
|
|
||||||
|
|
||||||
Client timeout:
|
|
||||||
|
|
||||||
- Clients are removed after `client_timeout_secs` without ack/ping.
|
|
||||||
- The default is intentionally long enough for laptop sleep/resume, currently one
|
|
||||||
day. Short timeouts make Dosh fall back to attach tickets or SSH after sleep.
|
|
||||||
- Removing a client never kills the session.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Local Echo
|
|
||||||
|
|
||||||
MVP local echo is conservative:
|
|
||||||
|
|
||||||
- Printable keystrokes may be rendered optimistically only when the client is in a
|
|
||||||
simple shell line-editing state.
|
|
||||||
- Server output is always authoritative.
|
|
||||||
- On mismatch, client replaces local prediction with server state.
|
|
||||||
|
|
||||||
Full mosh-style predictive display is a later feature. It must not delay the first
|
|
||||||
implementation of fast attach/resume.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Reconnect and Roaming
|
|
||||||
|
|
||||||
Client detects possible disconnect when no server packet arrives for
|
|
||||||
`reconnect_timeout_secs`.
|
|
||||||
|
|
||||||
Reconnect order:
|
|
||||||
|
|
||||||
1. Send encrypted `ResumeRequest` to the configured host/port.
|
|
||||||
2. If accepted, update endpoint server-side and receive diff/snapshot.
|
|
||||||
3. If rejected, try attach ticket.
|
|
||||||
4. If ticket attach is rejected, run SSH bootstrap.
|
|
||||||
|
|
||||||
The server matches resume by `ClientId` and session key id, not by source address.
|
|
||||||
Successful resume updates the client's UDP endpoint.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Configuration
|
|
||||||
|
|
||||||
Server config: `~/.config/dosh/server.toml`
|
|
||||||
|
|
||||||
```toml
|
|
||||||
port = 50000
|
|
||||||
bind = "0.0.0.0"
|
|
||||||
scrollback = 5000
|
|
||||||
auth_ttl_secs = 30
|
|
||||||
attach_ticket_ttl_secs = 3600
|
|
||||||
allow_attach_tickets = true
|
|
||||||
client_timeout_secs = 86400
|
|
||||||
retransmit_window = 256
|
|
||||||
default_input_mode = "read-write"
|
|
||||||
prewarm_sessions = ["default"]
|
|
||||||
create_on_attach = true
|
|
||||||
shell = "/usr/bin/zsh"
|
|
||||||
sessions_dir = "~/.local/share/dosh/sessions"
|
|
||||||
secret_path = "~/.config/dosh/secret"
|
|
||||||
```
|
|
||||||
|
|
||||||
Client config: `~/.config/dosh/client.toml`
|
|
||||||
|
|
||||||
```toml
|
|
||||||
server = "user@example.com"
|
|
||||||
update_repo = "https://git.palav.dev/Palav/dosh.git"
|
|
||||||
update_port = 50000
|
|
||||||
ssh_auth_command = "~/.local/bin/dosh-auth"
|
|
||||||
# ssh_port = 22
|
|
||||||
dosh_port = 50000
|
|
||||||
default_session = "new"
|
|
||||||
reconnect_timeout_secs = 5
|
|
||||||
view_only = false
|
|
||||||
cache_attach_tickets = true
|
|
||||||
credential_cache = "~/.local/share/dosh/credentials"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. Performance-First Stack
|
|
||||||
|
|
||||||
Default implementation:
|
|
||||||
|
|
||||||
```text
|
|
||||||
# server/client shared
|
|
||||||
bytes
|
|
||||||
chacha20poly1305
|
|
||||||
aes-gcm optional
|
|
||||||
hmac
|
|
||||||
hkdf
|
|
||||||
sha2
|
|
||||||
rand
|
|
||||||
serde
|
|
||||||
toml
|
|
||||||
|
|
||||||
# server
|
|
||||||
mio or tokio # benchmark; single-thread hot path either way
|
|
||||||
rustix # PTY/process/syscall wrappers where possible
|
|
||||||
vt100 # authoritative terminal parser/model
|
|
||||||
|
|
||||||
# client
|
|
||||||
mio or tokio
|
|
||||||
crossterm # raw terminal mode
|
|
||||||
vt100 optional # only if client-side model is needed for prediction
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Benchmark `mio` vs single-thread `tokio` before committing to runtime.
|
|
||||||
- Avoid locks on per-packet session broadcast.
|
|
||||||
- Preallocate packet buffers.
|
|
||||||
- Avoid serde on terminal frames.
|
|
||||||
- Keep `dosh-auth` tiny and static where practical.
|
|
||||||
- Optimize startup path before throughput.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 18. MVP Scope
|
|
||||||
|
|
||||||
MVP must include:
|
|
||||||
|
|
||||||
- `dosh-server` daemon.
|
|
||||||
- `dosh-auth` SSH helper mode.
|
|
||||||
- `dosh-client`.
|
|
||||||
- One UDP port.
|
|
||||||
- Prewarmed `default` session.
|
|
||||||
- SSH bootstrap attach.
|
|
||||||
- Attach-ticket UDP attach.
|
|
||||||
- Encrypted UDP channel.
|
|
||||||
- UDP resume.
|
|
||||||
- Raw terminal input/output.
|
|
||||||
- Basic resize.
|
|
||||||
- Timing instrumentation.
|
|
||||||
|
|
||||||
MVP may defer:
|
|
||||||
|
|
||||||
- Sophisticated predictive local echo.
|
|
||||||
- Per-cell minimal diffs; raw frame plus snapshot fallback is acceptable initially if
|
|
||||||
reconnect correctness is preserved.
|
|
||||||
- Multi-session management commands beyond `attach`, `new`, `list`, and `kill`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 19. Verification Checklist
|
|
||||||
|
|
||||||
A build is not done until these are demonstrated:
|
|
||||||
|
|
||||||
- Cold attach timing compared against cold `ssh host`.
|
|
||||||
- Warm attach timing compared against `ssh host true` with ControlMaster.
|
|
||||||
- UDP resume completes without spawning SSH.
|
|
||||||
- Existing session attach does not spawn PTY or shell.
|
|
||||||
- Prewarmed `default` exists before first client.
|
|
||||||
- Terminal data is encrypted on UDP.
|
|
||||||
- Replay counters reject duplicate encrypted packets.
|
|
||||||
- View-only clients cannot write to PTY.
|
|
||||||
- Multiple clients see the same screen.
|
|
||||||
- Client survives source port/IP change by resume.
|
|
||||||
- Snapshot fallback repairs a lagging client.
|
|
||||||
- `README.md` and `SPEC.md` remain consistent with implemented behavior.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 20. Status
|
|
||||||
|
|
||||||
Spec complete. The Rust implementation is present in this repository.
|
|
||||||
|
|
||||||
Implemented:
|
|
||||||
|
|
||||||
- Rust workspace and binaries: `dosh-server`, `dosh-client`, `dosh-auth`.
|
|
||||||
- Server config and secret creation.
|
|
||||||
- SSH/local bootstrap response generation.
|
|
||||||
- HMAC bootstrap verification.
|
|
||||||
- ChaCha20-Poly1305 encrypted UDP packets.
|
|
||||||
- Fixed DOSH packet header.
|
|
||||||
- Resident server with prewarmed named PTY sessions.
|
|
||||||
- Authoritative server-side `vt100` terminal parser.
|
|
||||||
- Full attach/resume snapshots from terminal screen state.
|
|
||||||
- Per-client screen-state diffs for broadcast frames.
|
|
||||||
- Raw terminal client attach.
|
|
||||||
- UDP resume from cached client credentials.
|
|
||||||
- Sealed attach-ticket UDP attach after server restart or unknown-client resume.
|
|
||||||
- Client ACKs and server-side bounded pending retransmit window.
|
|
||||||
- Sliding replay window for encrypted client packet counters.
|
|
||||||
- View-only server-side input rejection.
|
|
||||||
- Basic resize handling.
|
|
||||||
- Timing output for bootstrap and terminal-ready.
|
|
||||||
- `dosh-bench` benchmark harness for attach timing, SSH key/known-host options, and
|
|
||||||
ControlMaster-backed SSH measurement.
|
|
||||||
- Hardened user systemd unit.
|
|
||||||
- Release install script.
|
|
||||||
- Docker OpenSSH benchmark gate covering cold SSH bootstrap and ControlMaster-backed
|
|
||||||
SSH bootstrap.
|
|
||||||
- GitHub Actions CI for format, tests, release build, and Docker SSH benchmark gate.
|
|
||||||
- Optional GitHub Actions remote benchmark job gated by repository secrets.
|
|
||||||
- Auth/protocol tests.
|
|
||||||
- Integration smoke tests for local attach, ticket attach after server restart, and
|
|
||||||
view-only input rejection.
|
|
||||||
- Integration tests for retransmit, resize, multi-client shared screen, and UDP
|
|
||||||
endpoint roaming.
|
|
||||||
|
|
||||||
Optional deployment evidence:
|
|
||||||
|
|
||||||
- Configure `DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository
|
|
||||||
secrets to run the same benchmark against a real remote host in addition to the
|
|
||||||
Docker OpenSSH gate.
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn git(args: &[&str]) -> Option<String> {
|
||||||
|
let output = Command::new("git").args(args).output().ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = String::from_utf8(output.stdout).ok()?;
|
||||||
|
Some(text.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||||
|
println!("cargo:rerun-if-changed=.git/index");
|
||||||
|
|
||||||
|
let hash = git(&["rev-parse", "--short=12", "HEAD"]).unwrap_or_else(|| "unknown".into());
|
||||||
|
let date = git(&["show", "-s", "--format=%cs", "HEAD"]).unwrap_or_else(|| "unknown".into());
|
||||||
|
let dirty = Command::new("git")
|
||||||
|
.args(["diff", "--quiet", "--ignore-submodules", "--"])
|
||||||
|
.status()
|
||||||
|
.map(|status| if status.success() { "" } else { "+dirty" })
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
println!("cargo:rustc-env=DOSH_GIT_HASH={hash}{dirty}");
|
||||||
|
println!("cargo:rustc-env=DOSH_COMMIT_DATE={date}");
|
||||||
|
}
|
||||||
@@ -1,597 +0,0 @@
|
|||||||
# Dosh Native v1 Spec
|
|
||||||
|
|
||||||
Native Dosh is a remote-login protocol for Dosh-installed servers. It is intended to
|
|
||||||
replace the user's day-to-day `ssh host` workflow for terminals and forwarding while
|
|
||||||
keeping SSH as a compatibility and recovery fallback.
|
|
||||||
|
|
||||||
Native Dosh is not an RFC-compatible SSH implementation. It deliberately avoids the
|
|
||||||
full SSH transport/channel protocol and implements the smaller set of behavior Dosh
|
|
||||||
needs: authenticated login, encrypted terminal transport, reconnect/roaming, and TCP
|
|
||||||
forwarding.
|
|
||||||
|
|
||||||
## 1. Product Contract
|
|
||||||
|
|
||||||
User-facing commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dosh host
|
|
||||||
dosh host command...
|
|
||||||
dosh -L 8080:127.0.0.1:80 host
|
|
||||||
dosh -R 9000:127.0.0.1:9000 host
|
|
||||||
dosh --session work host
|
|
||||||
dosh --view-only --session work host
|
|
||||||
```
|
|
||||||
|
|
||||||
Compatibility expectations:
|
|
||||||
|
|
||||||
- Existing SSH keys and `ssh-agent` are reused.
|
|
||||||
- Server-side authorization uses `~/.ssh/authorized_keys`.
|
|
||||||
- Host trust is pinned in a Dosh known-hosts file and can be bootstrapped by SSH.
|
|
||||||
- `~/.ssh/config` host aliases, `HostName`, `User`, `Port`, `IdentityFile`,
|
|
||||||
`ProxyJump`, and `UserKnownHostsFile` are honored where practical.
|
|
||||||
- SSH bootstrap remains available with `--auth=ssh` and is used automatically when
|
|
||||||
native auth is disabled or cannot complete.
|
|
||||||
|
|
||||||
Native v1 is complete only if a normal interactive user can switch their daily
|
|
||||||
workflow from `ssh` to `dosh` without keeping SSH open in another tab for routine
|
|
||||||
tasks.
|
|
||||||
|
|
||||||
Non-goals for v1:
|
|
||||||
|
|
||||||
- RFC-compatible SSH server/client behavior.
|
|
||||||
- Arbitrary SSH subsystems.
|
|
||||||
- X11 forwarding.
|
|
||||||
- SFTP/SCP compatibility.
|
|
||||||
- Multi-user daemon mode with privileged account switching.
|
|
||||||
- Replacing OpenSSH on hosts that do not run `dosh-server`.
|
|
||||||
|
|
||||||
## 2. SSH Workflow Parity Target
|
|
||||||
|
|
||||||
Native v1 targets the SSH workflows interactive users actually use, not the entire
|
|
||||||
OpenSSH feature universe.
|
|
||||||
|
|
||||||
Must work in v1:
|
|
||||||
|
|
||||||
- Interactive shell: `dosh host`.
|
|
||||||
- Remote command: `dosh host command...`.
|
|
||||||
- Fresh terminal by default.
|
|
||||||
- Named persistent terminal: `dosh --session work host`.
|
|
||||||
- Shared/view-only session: `dosh --view-only --session work host`.
|
|
||||||
- Local forwarding: `dosh -L [bind:]listen:target:target_port host`.
|
|
||||||
- Remote forwarding: `dosh -R [bind:]listen:target:target_port host`.
|
|
||||||
- Dynamic forwarding: `dosh -D [bind:]listen host`, if stream flow control is proven;
|
|
||||||
otherwise it is the first v1.1 item and must be called out honestly.
|
|
||||||
- Multiple forwards in one connection.
|
|
||||||
- Forward-only mode: `dosh -N -L ... host`.
|
|
||||||
- Background forwarding: `dosh -f -N -L ... host`, or an equivalent supervised
|
|
||||||
user-service mode.
|
|
||||||
- SSH-agent authentication.
|
|
||||||
- Encrypted OpenSSH private-key authentication with prompt.
|
|
||||||
- `~/.ssh/config` host aliases for `Host`, `HostName`, `User`, `Port`,
|
|
||||||
`IdentityFile`, `ProxyJump`, `UserKnownHostsFile`, and `IdentitiesOnly`.
|
|
||||||
- Dosh host config overrides for user, Dosh-specific UDP host/port/auth policy.
|
|
||||||
- Clear host-key trust and mismatch errors.
|
|
||||||
- Clear auth failure errors that name the attempted key source.
|
|
||||||
- Stable reconnect after sleep, network switch, NAT rebinding, and server packet loss.
|
|
||||||
- `dosh update`.
|
|
||||||
- `dosh doctor host` for config/auth/UDP reachability diagnostics.
|
|
||||||
- `dosh sessions host` for session visibility.
|
|
||||||
|
|
||||||
Should work in v1 if it does not compromise the transport schedule:
|
|
||||||
|
|
||||||
- Agent forwarding with an explicit opt-in flag and clear warning.
|
|
||||||
- `ProxyJump` through SSH for bootstrap/trust and through Dosh-native relay later.
|
|
||||||
- `SendEnv`/`SetEnv` equivalent for explicit environment variables.
|
|
||||||
- Clipboard integration as a separate opt-in channel.
|
|
||||||
|
|
||||||
Explicitly not v1:
|
|
||||||
|
|
||||||
- X11 forwarding.
|
|
||||||
- SFTP/SCP wire compatibility.
|
|
||||||
- Full OpenSSH config language.
|
|
||||||
- Full `sshd` replacement for arbitrary SSH clients.
|
|
||||||
- Forced-command subsystems beyond fail-closed enforcement.
|
|
||||||
|
|
||||||
## 3. Stability Contract
|
|
||||||
|
|
||||||
Native v1 must be boring under real use:
|
|
||||||
|
|
||||||
- A closed laptop must not kill the remote session.
|
|
||||||
- A client crash must not kill the remote session.
|
|
||||||
- Server restart may drop live PTYs in v1 unless session persistence is implemented,
|
|
||||||
but the client must fail clearly and reconnect cleanly afterward.
|
|
||||||
- Decrypt failures from stale packets must be ignored or trigger reconnect, never
|
|
||||||
terminate the terminal by themselves.
|
|
||||||
- Terminal cleanup must restore cursor, mouse mode, bracketed paste, alternate
|
|
||||||
screen, and raw mode on exit.
|
|
||||||
- Forwarding streams must close cleanly without leaving orphan listeners.
|
|
||||||
- Every public error must be actionable: host trust, auth failure, UDP blocked,
|
|
||||||
forwarding denied, version mismatch, or server unavailable.
|
|
||||||
- `dosh host` must never attach to another active unnamed terminal by accident.
|
|
||||||
- The default install must be secure without hand-editing configs.
|
|
||||||
- Upgrades must preserve existing trusted host keys and credentials unless explicitly
|
|
||||||
rotated.
|
|
||||||
|
|
||||||
## 4. Security Contract
|
|
||||||
|
|
||||||
Native Dosh must match the security properties users rely on from SSH for this use
|
|
||||||
case:
|
|
||||||
|
|
||||||
- Server authentication before terminal data is trusted.
|
|
||||||
- User authentication by possession of an authorized private key or agent key.
|
|
||||||
- Forward secrecy for terminal and forwarding traffic.
|
|
||||||
- AEAD encryption and authentication for every post-handshake packet.
|
|
||||||
- Replay protection for handshake and transport packets.
|
|
||||||
- Host-key pinning with explicit first-use behavior.
|
|
||||||
- No plaintext terminal bytes after handshake begins.
|
|
||||||
- No custom cryptographic primitives.
|
|
||||||
- Clear downgrade behavior: native auth failure must not silently fall back to an
|
|
||||||
unauthenticated mode.
|
|
||||||
|
|
||||||
Native Dosh does not claim SSH's full protocol security surface. It claims equivalent
|
|
||||||
security for Dosh terminal and forwarding sessions on Dosh-installed servers.
|
|
||||||
|
|
||||||
## 5. Threat Model
|
|
||||||
|
|
||||||
In scope:
|
|
||||||
|
|
||||||
- Passive network observer.
|
|
||||||
- Active network attacker that can spoof, drop, replay, reorder, or modify packets.
|
|
||||||
- NAT rebinding and client IP/port changes.
|
|
||||||
- Stolen attach-ticket cache without the user's private key.
|
|
||||||
- Server restart and key rotation.
|
|
||||||
- Malicious unauthenticated client flooding auth attempts.
|
|
||||||
- Compromised low-privilege local user trying to read Dosh caches on a shared client.
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- Compromised client machine.
|
|
||||||
- Compromised server account.
|
|
||||||
- Malicious kernel, terminal emulator, or PTY implementation.
|
|
||||||
- Protecting against a server that is already authorized and then becomes malicious.
|
|
||||||
|
|
||||||
## 6. Cryptographic Building Blocks
|
|
||||||
|
|
||||||
Allowed primitives:
|
|
||||||
|
|
||||||
- Handshake pattern: Noise `NK` or `XX` through a maintained Rust Noise framework, or
|
|
||||||
a small audited construction over `x25519-dalek` plus transcript binding.
|
|
||||||
- KEX: X25519.
|
|
||||||
- Signatures for user auth: Ed25519 and ECDSA P-256 via SSH-agent and OpenSSH key
|
|
||||||
formats. RSA may be accepted only for compatibility and must use SHA-2 signatures.
|
|
||||||
- AEAD: ChaCha20-Poly1305 by default; AES-GCM optional when hardware support is known.
|
|
||||||
- KDF: HKDF-SHA256.
|
|
||||||
- Hash/transcript: SHA-256.
|
|
||||||
- Randomness: OS CSPRNG only.
|
|
||||||
|
|
||||||
Disallowed:
|
|
||||||
|
|
||||||
- Homegrown ciphers, MACs, padding, or key derivation.
|
|
||||||
- Reusing a nonce/key pair.
|
|
||||||
- Unauthenticated encryption.
|
|
||||||
- MD5/SHA-1 signatures for user auth.
|
|
||||||
|
|
||||||
## 7. Identity And Trust
|
|
||||||
|
|
||||||
### Server Identity
|
|
||||||
|
|
||||||
Each `dosh-server` has a persistent host key:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.config/dosh/host_key
|
|
||||||
~/.config/dosh/host_key.pub
|
|
||||||
```
|
|
||||||
|
|
||||||
Default host-key algorithm: Ed25519.
|
|
||||||
|
|
||||||
Client pins host keys in:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.config/dosh/known_hosts
|
|
||||||
```
|
|
||||||
|
|
||||||
Entry format:
|
|
||||||
|
|
||||||
```text
|
|
||||||
host-pattern key-type base64-public-key first-seen=unix-seconds source=tofu|ssh|manual
|
|
||||||
```
|
|
||||||
|
|
||||||
First-use policy:
|
|
||||||
|
|
||||||
- Default for public internet: refuse unknown native host key and suggest
|
|
||||||
`dosh trust host` or SSH bootstrap.
|
|
||||||
- Default for local/private hosts may be TOFU only when `trust_on_first_use = true`.
|
|
||||||
- `dosh trust host` may verify the Dosh host key over the existing SSH bootstrap path.
|
|
||||||
|
|
||||||
Host-key mismatch:
|
|
||||||
|
|
||||||
- Hard fail.
|
|
||||||
- Print old fingerprint, new fingerprint, and known-hosts file path.
|
|
||||||
- Never auto-replace.
|
|
||||||
|
|
||||||
### User Identity
|
|
||||||
|
|
||||||
Native auth user identity is the login user resolved from:
|
|
||||||
|
|
||||||
1. Explicit CLI user: `user@host`.
|
|
||||||
2. Dosh host config.
|
|
||||||
3. `ssh -G host` `user`.
|
|
||||||
4. Local username.
|
|
||||||
|
|
||||||
Server verifies user keys against:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.ssh/authorized_keys
|
|
||||||
~/.config/dosh/authorized_keys
|
|
||||||
```
|
|
||||||
|
|
||||||
`~/.config/dosh/authorized_keys` is optional and may restrict Dosh access without
|
|
||||||
changing SSH access.
|
|
||||||
|
|
||||||
Authorized-key options required in v1:
|
|
||||||
|
|
||||||
- `from=`
|
|
||||||
- `command=` must reject native Dosh terminal login unless explicitly supported later.
|
|
||||||
- `restrict`
|
|
||||||
- `no-port-forwarding`
|
|
||||||
- `permitopen=`
|
|
||||||
|
|
||||||
Unsupported restrictive options must fail closed.
|
|
||||||
|
|
||||||
## 8. Native Auth Handshake
|
|
||||||
|
|
||||||
Native auth runs over UDP on the same Dosh port. It establishes a short-lived
|
|
||||||
authenticated control channel and returns the same terminal attach material that SSH
|
|
||||||
bootstrap returns today.
|
|
||||||
|
|
||||||
Target path:
|
|
||||||
|
|
||||||
```text
|
|
||||||
client -> server: ClientHello
|
|
||||||
server -> client: ServerHello
|
|
||||||
client -> server: UserAuth
|
|
||||||
server -> client: AuthOk + first terminal snapshot
|
|
||||||
```
|
|
||||||
|
|
||||||
The server may combine `AuthOk` and `AttachOk` to get terminal-ready in the final
|
|
||||||
handshake flight.
|
|
||||||
|
|
||||||
### ClientHello
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
|
|
||||||
- protocol version
|
|
||||||
- client random
|
|
||||||
- client ephemeral X25519 public key
|
|
||||||
- requested host alias
|
|
||||||
- requested user
|
|
||||||
- requested session
|
|
||||||
- requested mode
|
|
||||||
- terminal size
|
|
||||||
- supported AEAD algorithms
|
|
||||||
- supported user key algorithms
|
|
||||||
- optional cached host-key id
|
|
||||||
- optional attach ticket envelope
|
|
||||||
|
|
||||||
### ServerHello
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
|
|
||||||
- protocol version
|
|
||||||
- server random
|
|
||||||
- server ephemeral X25519 public key
|
|
||||||
- server host public key
|
|
||||||
- server host-key signature over transcript
|
|
||||||
- chosen AEAD
|
|
||||||
- server key epoch
|
|
||||||
- auth challenge
|
|
||||||
- rate-limit metadata when applicable
|
|
||||||
|
|
||||||
The client must verify the host key before sending user authentication.
|
|
||||||
|
|
||||||
### UserAuth
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
|
|
||||||
- selected public key
|
|
||||||
- key algorithm
|
|
||||||
- signature over transcript and auth challenge
|
|
||||||
- optional agent identity metadata
|
|
||||||
- optional requested forwarding declarations
|
|
||||||
|
|
||||||
The signature must bind:
|
|
||||||
|
|
||||||
- both ephemeral keys
|
|
||||||
- both randoms
|
|
||||||
- server host key
|
|
||||||
- requested user/session/mode/terminal size
|
|
||||||
- selected algorithms
|
|
||||||
- protocol version
|
|
||||||
|
|
||||||
### AuthOk
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
|
|
||||||
- assigned `ClientId`
|
|
||||||
- session name
|
|
||||||
- mode
|
|
||||||
- session key id
|
|
||||||
- encrypted session key material or derived key confirmation
|
|
||||||
- attach ticket
|
|
||||||
- attach ticket PSK encrypted to the handshake key
|
|
||||||
- initial output sequence
|
|
||||||
- first snapshot
|
|
||||||
- server policy flags
|
|
||||||
|
|
||||||
`AuthOk` is AEAD-encrypted under the handshake traffic key.
|
|
||||||
|
|
||||||
## 9. Key Schedule
|
|
||||||
|
|
||||||
Handshake transcript:
|
|
||||||
|
|
||||||
```text
|
|
||||||
H = SHA256(protocol_label || ClientHello || ServerHello || UserAuth)
|
|
||||||
```
|
|
||||||
|
|
||||||
Shared secret:
|
|
||||||
|
|
||||||
```text
|
|
||||||
dh = X25519(client_ephemeral, server_ephemeral)
|
|
||||||
```
|
|
||||||
|
|
||||||
Handshake key:
|
|
||||||
|
|
||||||
```text
|
|
||||||
handshake_key = HKDF-SHA256(dh, H, "dosh/native/handshake/v1")
|
|
||||||
```
|
|
||||||
|
|
||||||
Session traffic keys:
|
|
||||||
|
|
||||||
```text
|
|
||||||
c2s_key = HKDF-SHA256(handshake_key, H, "dosh/native/c2s/v1")
|
|
||||||
s2c_key = HKDF-SHA256(handshake_key, H, "dosh/native/s2c/v1")
|
|
||||||
```
|
|
||||||
|
|
||||||
Attach ticket PSKs and rotated session keys must be derived independently from server
|
|
||||||
secret material and fresh randomness. They must not reuse handshake traffic keys.
|
|
||||||
|
|
||||||
## 10. Attach Tickets And Cache
|
|
||||||
|
|
||||||
Native attach tickets replace most cold auth after first login.
|
|
||||||
|
|
||||||
Ticket properties:
|
|
||||||
|
|
||||||
- Server-sealed AEAD blob.
|
|
||||||
- Scoped to server host key, user, session, mode, client key fingerprint, server key
|
|
||||||
epoch, and policy flags.
|
|
||||||
- Paired with a client-held random PSK.
|
|
||||||
- Default TTL: 24 hours for trusted personal machines, configurable down to zero.
|
|
||||||
- Stored mode `0600`.
|
|
||||||
- Revoked by server host-key rotation, server secret rotation, or user key removal.
|
|
||||||
|
|
||||||
Client cache path:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.local/share/dosh/credentials/
|
|
||||||
```
|
|
||||||
|
|
||||||
Cache entries must include:
|
|
||||||
|
|
||||||
- host identity fingerprint
|
|
||||||
- user
|
|
||||||
- session
|
|
||||||
- mode
|
|
||||||
- ticket expiry
|
|
||||||
- last rendered sequence
|
|
||||||
- client id
|
|
||||||
- session key id
|
|
||||||
- attach ticket
|
|
||||||
- attach ticket PSK
|
|
||||||
|
|
||||||
## 11. Transport
|
|
||||||
|
|
||||||
Post-auth terminal traffic continues to use the current Dosh UDP packet model:
|
|
||||||
|
|
||||||
- fixed binary header
|
|
||||||
- AEAD body
|
|
||||||
- monotonic packet sequence
|
|
||||||
- ack field
|
|
||||||
- replay window
|
|
||||||
- server snapshots for recovery
|
|
||||||
|
|
||||||
Required v1 changes:
|
|
||||||
|
|
||||||
- Separate packet namespaces for terminal frames, control messages, and forwarding
|
|
||||||
streams.
|
|
||||||
- Explicit `key_epoch` or `session_key_id` in packet metadata so stale packets can be
|
|
||||||
ignored without fatal decrypt errors.
|
|
||||||
- Rekey command after configurable packet count or wall-clock interval.
|
|
||||||
- Connection migration must be accepted after any valid encrypted packet from a new
|
|
||||||
source address.
|
|
||||||
|
|
||||||
## 12. Forwarding
|
|
||||||
|
|
||||||
Native forwarding is a Dosh stream multiplexer over the encrypted transport.
|
|
||||||
|
|
||||||
CLI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dosh -L [bind_host:]listen_port:target_host:target_port host
|
|
||||||
dosh -R [bind_host:]listen_port:target_host:target_port host
|
|
||||||
dosh -D [bind_host:]listen_port host
|
|
||||||
```
|
|
||||||
|
|
||||||
Stream packet types:
|
|
||||||
|
|
||||||
- `StreamOpen`
|
|
||||||
- `StreamOpenOk`
|
|
||||||
- `StreamOpenReject`
|
|
||||||
- `StreamData`
|
|
||||||
- `StreamWindowAdjust`
|
|
||||||
- `StreamEof`
|
|
||||||
- `StreamClose`
|
|
||||||
|
|
||||||
Forwarding rules:
|
|
||||||
|
|
||||||
- Terminal traffic has priority over stream bulk data.
|
|
||||||
- Each stream has independent flow control.
|
|
||||||
- Backpressure must not block PTY input or output.
|
|
||||||
- Server enforces `no-port-forwarding` and `permitopen=`.
|
|
||||||
- Remote listeners bind to loopback by default.
|
|
||||||
- Non-loopback remote bind requires explicit config.
|
|
||||||
- `-N` opens forwarding without creating a PTY.
|
|
||||||
- `-f` backgrounds only after all requested listeners are successfully active.
|
|
||||||
- Listener setup failures fail the entire command unless `--partial-forwarding` is
|
|
||||||
explicitly requested.
|
|
||||||
- Forwarding reconnect must preserve listeners and re-open streams after network
|
|
||||||
migration when protocol state allows it.
|
|
||||||
- Stream packet scheduling must enforce terminal priority; a large port-forward copy
|
|
||||||
cannot make shell keystrokes lag.
|
|
||||||
|
|
||||||
## 13. Diagnostics And Operations
|
|
||||||
|
|
||||||
Native v1 must include operations commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dosh doctor host
|
|
||||||
dosh trust host
|
|
||||||
dosh trust --remove host
|
|
||||||
dosh sessions host
|
|
||||||
dosh update
|
|
||||||
```
|
|
||||||
|
|
||||||
`dosh doctor host` checks:
|
|
||||||
|
|
||||||
- host alias resolution
|
|
||||||
- Dosh known-host state
|
|
||||||
- SSH fallback reachability
|
|
||||||
- native UDP reachability
|
|
||||||
- server version
|
|
||||||
- native auth enabled/disabled
|
|
||||||
- usable keys from ssh-agent and identity files
|
|
||||||
- server authorization result without opening a terminal
|
|
||||||
- configured forwarding policy
|
|
||||||
|
|
||||||
`dosh trust host` checks:
|
|
||||||
|
|
||||||
- fetch Dosh host key through SSH fallback when available
|
|
||||||
- show fingerprint before writing trust
|
|
||||||
- refuse overwrite on mismatch unless `--replace` is explicit
|
|
||||||
|
|
||||||
## 14. Config
|
|
||||||
|
|
||||||
Client:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
auth_preference = "native,ssh"
|
|
||||||
trust_on_first_use = false
|
|
||||||
native_auth_timeout_ms = 700
|
|
||||||
known_hosts = "~/.config/dosh/known_hosts"
|
|
||||||
credential_cache = "~/.local/share/dosh/credentials"
|
|
||||||
identity_files = ["~/.ssh/id_ed25519"]
|
|
||||||
use_ssh_agent = true
|
|
||||||
forward_agent = false
|
|
||||||
send_env = ["LANG", "LC_*", "TERM", "COLORTERM"]
|
|
||||||
set_env = {}
|
|
||||||
forwardings = []
|
|
||||||
```
|
|
||||||
|
|
||||||
Server:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
native_auth = true
|
|
||||||
host_key = "~/.config/dosh/host_key"
|
|
||||||
authorized_keys = ["~/.ssh/authorized_keys", "~/.config/dosh/authorized_keys"]
|
|
||||||
native_auth_rate_limit_per_minute = 30
|
|
||||||
attach_ticket_ttl_secs = 86400
|
|
||||||
allow_tcp_forwarding = true
|
|
||||||
allow_remote_forwarding = false
|
|
||||||
allow_remote_non_loopback_bind = false
|
|
||||||
allow_agent_forwarding = false
|
|
||||||
accept_env = ["LANG", "LC_*", "TERM", "COLORTERM"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 15. Migration Plan
|
|
||||||
|
|
||||||
Milestone 1: host identity and trust
|
|
||||||
|
|
||||||
- Generate Dosh host key on server install.
|
|
||||||
- Add `dosh trust host`.
|
|
||||||
- Add known-hosts file and mismatch handling.
|
|
||||||
- Keep SSH bootstrap as the only auth path.
|
|
||||||
|
|
||||||
Milestone 2: native user auth
|
|
||||||
|
|
||||||
- Implement `ClientHello`/`ServerHello`/`UserAuth`/`AuthOk`.
|
|
||||||
- Support ssh-agent Ed25519 first.
|
|
||||||
- Verify against `authorized_keys`.
|
|
||||||
- Add `--auth=native|ssh|auto`.
|
|
||||||
|
|
||||||
Milestone 3: default native auth
|
|
||||||
|
|
||||||
- Make `auth_preference = "native,ssh"` default.
|
|
||||||
- Keep SSH fallback explicit and visible.
|
|
||||||
- Add benchmark gates for native cold auth.
|
|
||||||
|
|
||||||
Milestone 4: forwarding
|
|
||||||
|
|
||||||
- Add stream mux.
|
|
||||||
- Add `-L`, `-N`, and `-f`.
|
|
||||||
- Add `-R`.
|
|
||||||
- Add `-D` only after flow control is proven.
|
|
||||||
|
|
||||||
Milestone 5: hardening
|
|
||||||
|
|
||||||
- Fuzz packet parsing, authorized-key parsing, known-host parsing, and handshake state.
|
|
||||||
- Add hostile-network integration tests.
|
|
||||||
- Add external review checklist before public security claims.
|
|
||||||
|
|
||||||
Milestone 6: workflow parity
|
|
||||||
|
|
||||||
- Implement `dosh doctor`.
|
|
||||||
- Implement complete host-trust management.
|
|
||||||
- Implement private-key prompt flow.
|
|
||||||
- Implement forwarding policy diagnostics.
|
|
||||||
- Run daily-driver soak on macOS and Linux clients.
|
|
||||||
|
|
||||||
## 16. Verification
|
|
||||||
|
|
||||||
Native v1 is not complete until all are true:
|
|
||||||
|
|
||||||
- Unknown host key fails by default unless TOFU is explicitly enabled.
|
|
||||||
- Known host key mismatch hard fails.
|
|
||||||
- Native Ed25519 auth succeeds via ssh-agent.
|
|
||||||
- Native Ed25519 auth succeeds via encrypted private key prompt.
|
|
||||||
- Removed authorized key can no longer authenticate.
|
|
||||||
- Restrictive unsupported authorized-key options fail closed.
|
|
||||||
- Replayed handshake packets are rejected.
|
|
||||||
- Replayed transport packets are rejected.
|
|
||||||
- Stale encrypted packets after reconnect are ignored, not fatal.
|
|
||||||
- Client IP/port change preserves the session.
|
|
||||||
- Native cold auth benchmark beats cold `ssh host true` on the same host.
|
|
||||||
- Cached attach remains near network RTT plus local render overhead.
|
|
||||||
- `-L` forwarding works without delaying terminal input.
|
|
||||||
- `-R` forwarding enforces bind and permission policy.
|
|
||||||
- `-N -L` forward-only mode does not allocate a PTY.
|
|
||||||
- `-f -N -L` backgrounds only after listener readiness.
|
|
||||||
- Multiple forwards in one command work.
|
|
||||||
- `dosh doctor` identifies UDP-blocked, auth-denied, host-key-mismatch, and
|
|
||||||
forwarding-denied states.
|
|
||||||
- Closing the laptop for at least 30 minutes does not kill the remote session.
|
|
||||||
- Three concurrent Dosh terminals remain independent unless explicitly named.
|
|
||||||
- Large forwarded transfers do not add visible terminal input lag.
|
|
||||||
- Fuzz targets run in CI.
|
|
||||||
- Threat model is updated with any accepted residual risks.
|
|
||||||
|
|
||||||
## 17. Public Claim Gate
|
|
||||||
|
|
||||||
Dosh may claim "native SSH replacement for Dosh-installed servers" only after:
|
|
||||||
|
|
||||||
- Native auth is default on at least one real host.
|
|
||||||
- SSH fallback remains available.
|
|
||||||
- The verification checklist is green.
|
|
||||||
- The threat model is published.
|
|
||||||
- Benchmarks include raw samples for SSH cold, Dosh native cold, Dosh cached attach,
|
|
||||||
and Mosh startup.
|
|
||||||
|
|
||||||
Dosh must not claim generic SSH compatibility unless it implements the SSH protocol.
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
# Dosh Public Readiness
|
|
||||||
|
|
||||||
Dosh's defensible public claim is fast terminal attach and reconnect. It should not
|
|
||||||
claim full Mosh replacement status until the feature matrix below is green and the
|
|
||||||
comparison benchmark is reproducible outside the author's homelab.
|
|
||||||
|
|
||||||
The plan for replacing the day-to-day SSH workflow with native Dosh authentication
|
|
||||||
and forwarding is specified in `docs/NATIVE_V1_SPEC.md`. Until that spec is
|
|
||||||
implemented and verified, Dosh's public security claim remains SSH-bootstrap plus
|
|
||||||
encrypted Dosh transport.
|
|
||||||
|
|
||||||
## Objective Benchmarks
|
|
||||||
|
|
||||||
Run the same-host SSH comparison:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make bench-docker-ssh
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the Dosh, SSH, and Mosh comparison:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make bench-docker-mosh
|
|
||||||
```
|
|
||||||
|
|
||||||
The Mosh-inclusive target builds one Docker image with OpenSSH, Mosh, `dosh-server`,
|
|
||||||
and `dosh-auth`. It uses the same generated key, localhost network path, SSH server,
|
|
||||||
and container for all samples.
|
|
||||||
|
|
||||||
Reported metrics:
|
|
||||||
|
|
||||||
| Metric | Meaning |
|
|
||||||
| --- | --- |
|
|
||||||
| `ssh_true_ms` | Time to run `ssh host true` with the same key/options. |
|
|
||||||
| `dosh_attach_ms` | Time for `dosh-client --attach-only` to render the first frame and detach on the configured path. With `--no-cache`, this is cold SSH bootstrap plus UDP attach. |
|
|
||||||
| `dosh_cached_attach_ms` | Time for a warmed Dosh client to attach using cached UDP credentials/tickets, render the first frame, and detach. This is the fast-path claim and should be approximately network RTT plus local process/render overhead. |
|
|
||||||
| `mosh_start_true_ms` | Time for `mosh host -- true` to bootstrap, run `true`, and exit. |
|
|
||||||
|
|
||||||
These are not identical workloads. They are still useful because they measure the
|
|
||||||
startup path each tool must pay before useful remote work begins. Public numbers
|
|
||||||
must include the command, machine, OS, CPU, network path, and sample count.
|
|
||||||
|
|
||||||
Do not cite cold `dosh_attach_ms` as the core speed claim. Cold Dosh still pays SSH
|
|
||||||
startup/authentication. Cite `dosh_cached_attach_ms` for repeat attach/reconnect
|
|
||||||
speed, and cite cold `dosh_attach_ms` only to show that fallback remains competitive
|
|
||||||
with ordinary SSH.
|
|
||||||
|
|
||||||
## Feature Matrix
|
|
||||||
|
|
||||||
| Feature | Mosh | Dosh now | Public status |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| SSH-based first authentication | yes | yes | ready |
|
|
||||||
| Encrypted UDP terminal data | yes | yes | ready |
|
|
||||||
| Roaming by client address change | yes | yes | needs more hostile-network tests |
|
|
||||||
| Survive sleep or network loss | yes | yes | needs long-running soak tests |
|
|
||||||
| Fast repeat attach without SSH | no | yes, via attach tickets | core differentiator |
|
|
||||||
| Resident server daemon | no | yes | core differentiator |
|
|
||||||
| One UDP port for all sessions | port range by default | yes | ready |
|
|
||||||
| Fresh session by default | yes | yes | ready |
|
|
||||||
| Named persistent sessions | no built-in shared session model | yes | ready |
|
|
||||||
| Multiple clients on one session | no | yes | needs conflict-policy docs |
|
|
||||||
| View-only clients | no | yes | ready |
|
|
||||||
| Full-screen TUI correctness | yes | improving | must stay green before public push |
|
|
||||||
| Predictive local echo | mature | guarded printable-only opt-in | not parity |
|
|
||||||
| Non-destructive disconnect UI | yes | not currently | needed |
|
|
||||||
| Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
|
|
||||||
| X11 forwarding | no | no | non-goal unless tunneled separately |
|
|
||||||
| SSH agent forwarding | no | no | planned as forwarding channel |
|
|
||||||
| Local TCP forwarding, `-L` | no | not implemented | planned |
|
|
||||||
| Remote TCP forwarding, `-R` | no | not implemented | planned |
|
|
||||||
| Dynamic SOCKS forwarding, `-D` | no | not implemented | later |
|
|
||||||
|
|
||||||
## SSH Config Inheritance
|
|
||||||
|
|
||||||
Dosh intentionally delegates SSH parsing and authentication to OpenSSH. Bootstrap
|
|
||||||
uses the user's normal `ssh` command, so `~/.ssh/config` options such as `Host`,
|
|
||||||
`HostName`, `User`, `Port`, `IdentityFile`, `ProxyJump`, and `UserKnownHostsFile`
|
|
||||||
continue to apply.
|
|
||||||
|
|
||||||
Dosh also calls `ssh -G <alias>` to infer the UDP target host when no `dosh_host` is
|
|
||||||
configured. To write explicit Dosh host entries from SSH aliases:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dosh import-ssh palav homelab
|
|
||||||
```
|
|
||||||
|
|
||||||
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
|
|
||||||
OpenSSH config parser.
|
|
||||||
|
|
||||||
## Forwarding Plan
|
|
||||||
|
|
||||||
SSH forwarding cannot be copied by keeping the bootstrap SSH connection open,
|
|
||||||
because that would remove Dosh's fast reconnect advantage and would break after
|
|
||||||
roaming. Dosh forwarding needs native encrypted channels over the Dosh transport.
|
|
||||||
|
|
||||||
Minimum viable forwarding design:
|
|
||||||
|
|
||||||
| CLI | Meaning |
|
|
||||||
| --- | --- |
|
|
||||||
| `dosh -L 8080:127.0.0.1:80 host` | Local listener on the client; server connects to target. |
|
|
||||||
| `dosh -R 8080:127.0.0.1:80 host` | Remote listener on the server; client connects to target. |
|
|
||||||
|
|
||||||
Protocol work required:
|
|
||||||
|
|
||||||
- Add stream-open, stream-data, stream-ack, and stream-close packet types.
|
|
||||||
- Use per-stream flow control separate from terminal frame ordering.
|
|
||||||
- Never let bulk forwarding traffic delay terminal input/output packets.
|
|
||||||
- Bind forwarding permissions to the same SSH-authenticated user as the terminal.
|
|
||||||
- Add tests with dropped/reordered UDP packets.
|
|
||||||
|
|
||||||
This is a publishable differentiator once implemented, but it should not be claimed
|
|
||||||
until it exists.
|
|
||||||
|
|
||||||
## Before Public Launch
|
|
||||||
|
|
||||||
- Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green.
|
|
||||||
- Add a non-destructive disconnect indicator.
|
|
||||||
- Run scripted TUI tests for alternate-screen apps, arrow keys, resize, mouse mode,
|
|
||||||
bracketed paste, and terminal cleanup.
|
|
||||||
- Publish benchmark output with raw samples, not just averages.
|
|
||||||
- Mark prediction as experimental until it has a real framebuffer model.
|
|
||||||
@@ -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?;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/target
|
||||||
|
/corpus
|
||||||
|
/artifacts
|
||||||
|
/coverage
|
||||||
|
Cargo.lock
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
[package]
|
||||||
|
name = "dosh-fuzz"
|
||||||
|
version = "0.0.0"
|
||||||
|
publish = false
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# Standalone workspace so this crate is never absorbed by, and never affects,
|
||||||
|
# the main `dosh` crate's `cargo build` / `cargo test` / `cargo fmt --check`.
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
cargo-fuzz = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libfuzzer-sys = "0.4"
|
||||||
|
|
||||||
|
[dependencies.dosh]
|
||||||
|
path = ".."
|
||||||
|
|
||||||
|
# cargo-fuzz needs unwinding to report panics; keep debug assertions on.
|
||||||
|
[profile.release]
|
||||||
|
debug = 1
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "packet_decode"
|
||||||
|
path = "fuzz_targets/packet_decode.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "from_body"
|
||||||
|
path = "fuzz_targets/from_body.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "authorized_keys"
|
||||||
|
path = "fuzz_targets/authorized_keys.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "known_hosts"
|
||||||
|
path = "fuzz_targets/known_hosts.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "handshake_structs"
|
||||||
|
path = "fuzz_targets/handshake_structs.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "attach_ticket"
|
||||||
|
path = "fuzz_targets/attach_ticket.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#![no_main]
|
||||||
|
//! Fuzz the attach-ticket and bootstrap decoders. These open server-sealed
|
||||||
|
//! AEAD blobs and base64 bootstrap envelopes from cache / wire material that an
|
||||||
|
//! attacker may corrupt; none may panic.
|
||||||
|
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
use dosh::auth::{decode_bootstrap, open_attach_ticket, verify_attach_ticket};
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
let secret = [0x11u8; 32];
|
||||||
|
let psk = [0x22u8; 32];
|
||||||
|
|
||||||
|
let _ = open_attach_ticket(&secret, data);
|
||||||
|
let _ = verify_attach_ticket(&secret, data, &psk, "default", "read-write");
|
||||||
|
|
||||||
|
if let Ok(text) = std::str::from_utf8(data) {
|
||||||
|
let _ = decode_bootstrap(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#![no_main]
|
||||||
|
//! Fuzz the authorized_keys parser, including its option lexer and the
|
||||||
|
//! ssh-ed25519 public-key blob parser it depends on. None may panic.
|
||||||
|
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
use dosh::native::{parse_authorized_keys, parse_ssh_ed25519_public_blob};
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
// The blob parser operates directly on raw bytes.
|
||||||
|
let _ = parse_ssh_ed25519_public_blob(data);
|
||||||
|
|
||||||
|
// The line parser operates on text; only feed valid UTF-8 (lossless),
|
||||||
|
// matching how the file is read in production via read_to_string.
|
||||||
|
if let Ok(text) = std::str::from_utf8(data) {
|
||||||
|
let _ = parse_authorized_keys(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#![no_main]
|
||||||
|
//! Fuzz `protocol::from_body` (bincode deserialization) for every protocol and
|
||||||
|
//! native struct that is decoded from untrusted wire bytes. None may panic.
|
||||||
|
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
use dosh::auth::{AttachTicketPlain, BootstrapResponse, SealedAttachTicket};
|
||||||
|
use dosh::native::{
|
||||||
|
HostPublicKey, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth,
|
||||||
|
};
|
||||||
|
use dosh::protocol::{
|
||||||
|
self, AttachOk, AttachReject, BootstrapAttachRequest, Frame, Input, NativeAuthCheckOkBody,
|
||||||
|
NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, Resize,
|
||||||
|
ResumeRequest, StreamClose, StreamData, StreamEof, StreamOpen, StreamOpenOk, StreamOpenReject,
|
||||||
|
StreamWindowAdjust, TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope,
|
||||||
|
};
|
||||||
|
|
||||||
|
macro_rules! try_body {
|
||||||
|
($data:expr, $ty:ty) => {
|
||||||
|
let _ = protocol::from_body::<$ty>($data);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
// protocol.rs structs
|
||||||
|
try_body!(data, BootstrapAttachRequest);
|
||||||
|
try_body!(data, TicketAttachEnvelope);
|
||||||
|
try_body!(data, TicketAttachBody);
|
||||||
|
try_body!(data, TicketAttachOkEnvelope);
|
||||||
|
try_body!(data, AttachOk);
|
||||||
|
try_body!(data, AttachReject);
|
||||||
|
try_body!(data, ResumeRequest);
|
||||||
|
try_body!(data, Input);
|
||||||
|
try_body!(data, Resize);
|
||||||
|
try_body!(data, Frame);
|
||||||
|
try_body!(data, StreamOpen);
|
||||||
|
try_body!(data, StreamOpenOk);
|
||||||
|
try_body!(data, StreamOpenReject);
|
||||||
|
try_body!(data, StreamData);
|
||||||
|
try_body!(data, StreamWindowAdjust);
|
||||||
|
try_body!(data, StreamEof);
|
||||||
|
try_body!(data, StreamClose);
|
||||||
|
|
||||||
|
// native handshake wrapper bodies
|
||||||
|
try_body!(data, NativeClientHelloBody);
|
||||||
|
try_body!(data, NativeServerHelloBody);
|
||||||
|
try_body!(data, NativeUserAuthBody);
|
||||||
|
try_body!(data, NativeAuthOkBody);
|
||||||
|
try_body!(data, NativeAuthCheckOkBody);
|
||||||
|
|
||||||
|
// bare native handshake structs
|
||||||
|
try_body!(data, NativeClientHello);
|
||||||
|
try_body!(data, NativeServerHello);
|
||||||
|
try_body!(data, NativeUserAuth);
|
||||||
|
try_body!(data, NativeAuthOk);
|
||||||
|
try_body!(data, HostPublicKey);
|
||||||
|
|
||||||
|
// auth.rs structs
|
||||||
|
try_body!(data, BootstrapResponse);
|
||||||
|
try_body!(data, SealedAttachTicket);
|
||||||
|
try_body!(data, AttachTicketPlain);
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#![no_main]
|
||||||
|
//! Fuzz the native handshake structs and their structural verifiers.
|
||||||
|
//!
|
||||||
|
//! Beyond plain deserialization (covered by the `from_body` target), this drives
|
||||||
|
//! the verifier state machine: if the input happens to decode into the handshake
|
||||||
|
//! structs, run `verify_server_hello`, `user_auth_transcript`, and
|
||||||
|
//! `verify_native_user_auth` on them. These run on attacker-controlled material
|
||||||
|
//! during the handshake and must reject (Err) without panicking.
|
||||||
|
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
use dosh::native::{
|
||||||
|
NativeClientHello, NativeServerHello, NativeUserAuth, user_auth_transcript,
|
||||||
|
verify_native_user_auth, verify_server_hello,
|
||||||
|
};
|
||||||
|
use dosh::protocol;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
// Split the input into three slices and try to decode each into a handshake
|
||||||
|
// struct. Use a length prefix scheme that is robust to short inputs.
|
||||||
|
if data.len() < 3 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let n = data.len();
|
||||||
|
let a = n / 3;
|
||||||
|
let b = 2 * n / 3;
|
||||||
|
let (chunk_client, chunk_server, chunk_auth) = (&data[..a], &data[a..b], &data[b..]);
|
||||||
|
|
||||||
|
let client: Option<NativeClientHello> = protocol::from_body(chunk_client).ok();
|
||||||
|
let server: Option<NativeServerHello> = protocol::from_body(chunk_server).ok();
|
||||||
|
let auth: Option<NativeUserAuth> = protocol::from_body(chunk_auth).ok();
|
||||||
|
|
||||||
|
if let (Some(client), Some(server)) = (&client, &server) {
|
||||||
|
// Host signature verification over the transcript must not panic.
|
||||||
|
let _ = verify_server_hello(client, server);
|
||||||
|
|
||||||
|
if let Some(auth) = &auth {
|
||||||
|
// Transcript construction and full user-auth verification (signature
|
||||||
|
// check + authorized-key matching) must not panic on garbage.
|
||||||
|
let _ = user_auth_transcript(client, server, auth);
|
||||||
|
let _ = verify_native_user_auth(client, server, auth, &[], None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#![no_main]
|
||||||
|
//! Fuzz the known_hosts parser and the host-public-key line parser. None may
|
||||||
|
//! panic on arbitrary input.
|
||||||
|
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
use dosh::native::{parse_host_public_key_line, parse_known_hosts};
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
if let Ok(text) = std::str::from_utf8(data) {
|
||||||
|
let _ = parse_known_hosts(text);
|
||||||
|
let _ = parse_host_public_key_line(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#![no_main]
|
||||||
|
//! Fuzz the wire-packet decoder + decrypt path.
|
||||||
|
//!
|
||||||
|
//! Mirrors tests/parser_robustness.rs but driven by libFuzzer so the coverage
|
||||||
|
//! engine can search for panics in `protocol::decode`, `Header::parse`, and the
|
||||||
|
//! decode -> decrypt_body pipeline. The objective is: NO PANICS on any input.
|
||||||
|
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
use dosh::protocol::{self, CLIENT_TO_SERVER, SERVER_TO_CLIENT};
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
// Header parsing must never panic.
|
||||||
|
let _ = protocol::Header::parse(data);
|
||||||
|
|
||||||
|
// Full decode, then attempt decryption with a fixed key in both directions.
|
||||||
|
// A real attacker controls these bytes; neither path may panic.
|
||||||
|
if let Ok(packet) = protocol::decode(data) {
|
||||||
|
let key = [0x42u8; 32];
|
||||||
|
let _ = protocol::decrypt_body(&packet, &key, CLIENT_TO_SERVER);
|
||||||
|
let _ = protocol::decrypt_body(&packet, &key, SERVER_TO_CLIENT);
|
||||||
|
}
|
||||||
|
});
|
||||||
+215
-17
@@ -1,11 +1,17 @@
|
|||||||
param(
|
param(
|
||||||
[ValidateSet("client")]
|
[ValidateSet("client")]
|
||||||
[string]$Role = $(if ($env:DOSH_ROLE) { $env:DOSH_ROLE } else { "client" }),
|
[string]$Role = $(if ($env:DOSH_ROLE) { $env:DOSH_ROLE } else { "client" }),
|
||||||
[string]$Repo = $env:DOSH_REPO,
|
[string]$Repo = $(if ($env:DOSH_REPO) { $env:DOSH_REPO } else { "https://git.palav.dev/Palav/dosh.git" }),
|
||||||
[string]$Server = $env:DOSH_SERVER,
|
[string]$Server = $env:DOSH_SERVER,
|
||||||
[string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }),
|
[string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }),
|
||||||
[int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }),
|
[int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }),
|
||||||
[string]$Prefix = $(if ($env:PREFIX) { $env:PREFIX } else { Join-Path $HOME ".local" }),
|
[string]$Prefix = $(if ($env:PREFIX) { $env:PREFIX } else { Join-Path $HOME ".local" }),
|
||||||
|
[switch]$UsePrebuilt = $(-not $env:DOSH_USE_PREBUILT -or $env:DOSH_USE_PREBUILT -ne "0"),
|
||||||
|
[string]$BinaryUrl = $env:DOSH_BINARY_URL,
|
||||||
|
[string]$BinaryBase = $env:DOSH_BINARY_BASE,
|
||||||
|
[string]$BinaryName = $env:DOSH_BINARY_NAME,
|
||||||
|
[string]$BinaryVersion = $(if ($env:DOSH_BINARY_VERSION) { $env:DOSH_BINARY_VERSION } else { "latest" }),
|
||||||
|
[switch]$BinaryRequired = $($env:DOSH_BINARY_REQUIRED -and $env:DOSH_BINARY_REQUIRED -ne "0"),
|
||||||
[switch]$ForceConfig
|
[switch]$ForceConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,8 +23,183 @@ function Require-Command($Name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Require-Command cargo
|
function Normalize-Arch {
|
||||||
|
switch ($env:PROCESSOR_ARCHITECTURE) {
|
||||||
|
"AMD64" { "x86_64"; break }
|
||||||
|
"ARM64" { "aarch64"; break }
|
||||||
|
default { $env:PROCESSOR_ARCHITECTURE.ToLowerInvariant() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Release-ArtifactName {
|
||||||
|
if ($BinaryName) {
|
||||||
|
return $BinaryName
|
||||||
|
}
|
||||||
|
"dosh-windows-$(Normalize-Arch).zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Repo-WebBase($Value) {
|
||||||
|
if (-not $Value) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
$base = $Value.TrimEnd("/")
|
||||||
|
if ($base.EndsWith(".git")) {
|
||||||
|
$base = $base.Substring(0, $base.Length - 4)
|
||||||
|
}
|
||||||
|
if ($base -notmatch "^https?://") {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
$base
|
||||||
|
}
|
||||||
|
|
||||||
|
function Release-DownloadUrl {
|
||||||
|
if ($BinaryUrl) {
|
||||||
|
return $BinaryUrl
|
||||||
|
}
|
||||||
|
$name = Release-ArtifactName
|
||||||
|
if ($BinaryBase) {
|
||||||
|
return "$($BinaryBase.TrimEnd('/'))/$name"
|
||||||
|
}
|
||||||
|
$web = Repo-WebBase $Repo
|
||||||
|
if (-not $web) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
if ($BinaryVersion -eq "latest") {
|
||||||
|
return "$web/releases/latest/download/$name"
|
||||||
|
}
|
||||||
|
"$web/releases/download/$BinaryVersion/$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Release-LatestTagDownloadUrl {
|
||||||
|
if ($BinaryUrl -or $BinaryBase -or $BinaryVersion -ne "latest") {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
$web = Repo-WebBase $Repo
|
||||||
|
if (-not $web) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$response = Invoke-WebRequest -UseBasicParsing -Uri "$web/releases/latest" -MaximumRedirection 5
|
||||||
|
$effective = $null
|
||||||
|
if ($response.BaseResponse.ResponseUri) {
|
||||||
|
$effective = $response.BaseResponse.ResponseUri.AbsoluteUri
|
||||||
|
} elseif ($response.BaseResponse.RequestMessage -and $response.BaseResponse.RequestMessage.RequestUri) {
|
||||||
|
$effective = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri
|
||||||
|
}
|
||||||
|
$prefix = "$web/releases/tag/"
|
||||||
|
if ($effective -and $effective.StartsWith($prefix)) {
|
||||||
|
$tag = $effective.Substring($prefix.Length)
|
||||||
|
if ($tag) {
|
||||||
|
return "$web/releases/download/$tag/$(Release-ArtifactName)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Verify-ArchiveChecksum($Url, $Archive) {
|
||||||
|
$checksumPath = "$Archive.sha256"
|
||||||
|
try {
|
||||||
|
Invoke-WebRequest -UseBasicParsing -Uri "$Url.sha256" -OutFile $checksumPath
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "prebuilt checksum unavailable; continuing without sidecar verification"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$expected = ((Get-Content $checksumPath -Raw).Trim() -split "\s+")[0].ToLowerInvariant()
|
||||||
|
$actual = (Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant()
|
||||||
|
if ($expected -ne $actual) {
|
||||||
|
throw "prebuilt checksum mismatch for $Url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Expected-ArchiveVersion($Url) {
|
||||||
|
if ($Url -match "/releases/download/v([^/]+)/") {
|
||||||
|
return $Matches[1]
|
||||||
|
}
|
||||||
|
if (-not $BinaryUrl -and -not $BinaryBase -and $BinaryVersion -ne "latest") {
|
||||||
|
return $BinaryVersion.TrimStart("v")
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Verify-ArchiveVersion($ExtractDir, $Url) {
|
||||||
|
$expected = Expected-ArchiveVersion $Url
|
||||||
|
if (-not $expected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$versionFile = Get-ChildItem -Path $ExtractDir -Recurse -File -Filter VERSION | Select-Object -First 1
|
||||||
|
if (-not $versionFile) {
|
||||||
|
throw "prebuilt archive missing VERSION for $Url"
|
||||||
|
}
|
||||||
|
$actual = (Get-Content $versionFile.FullName -Raw).Trim()
|
||||||
|
if ($actual -ne $expected) {
|
||||||
|
throw "prebuilt archive version mismatch for ${Url}: expected $expected, got $actual"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Install-Binary($Source, $Destination) {
|
||||||
|
$dir = Split-Path -Parent $Destination
|
||||||
|
$name = Split-Path -Leaf $Destination
|
||||||
|
$tmp = Join-Path $dir ".$name.tmp.$PID"
|
||||||
|
Copy-Item $Source $tmp -Force
|
||||||
|
try {
|
||||||
|
Move-Item $tmp $Destination -Force
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$bindir = Join-Path $Prefix "bin"
|
||||||
|
$configDir = Join-Path $HOME ".config\dosh"
|
||||||
|
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
|
||||||
|
|
||||||
|
function Install-Prebuilt {
|
||||||
|
$url = Release-LatestTagDownloadUrl
|
||||||
|
if (-not $url) {
|
||||||
|
$url = Release-DownloadUrl
|
||||||
|
}
|
||||||
|
if (-not $url) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-bin-" + [guid]::NewGuid())
|
||||||
|
$zip = Join-Path $tmp (Release-ArtifactName)
|
||||||
|
$extract = Join-Path $tmp "extract"
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Force -Path $tmp, $extract | Out-Null
|
||||||
|
Write-Host "Trying Dosh prebuilt $(Release-ArtifactName)"
|
||||||
|
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $zip
|
||||||
|
Verify-ArchiveChecksum $url $zip
|
||||||
|
Expand-Archive -Force -Path $zip -DestinationPath $extract
|
||||||
|
Verify-ArchiveVersion $extract $url
|
||||||
|
foreach ($bin in @("dosh-client.exe", "dosh-bench.exe")) {
|
||||||
|
$found = Get-ChildItem -Path $extract -Recurse -File -Filter $bin | Select-Object -First 1
|
||||||
|
if (-not $found) {
|
||||||
|
throw "prebuilt archive missing $bin"
|
||||||
|
}
|
||||||
|
Install-Binary $found.FullName (Join-Path $bindir $bin)
|
||||||
|
}
|
||||||
|
Install-Binary (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe")
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "prebuilt install failed: $_"
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (Test-Path $tmp) {
|
||||||
|
Remove-Item -Recurse -Force $tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Install-FromSource {
|
||||||
|
Require-Command cargo
|
||||||
$tmp = $null
|
$tmp = $null
|
||||||
if (Test-Path "Cargo.toml") {
|
if (Test-Path "Cargo.toml") {
|
||||||
$src = (Get-Location).Path
|
$src = (Get-Location).Path
|
||||||
@@ -35,14 +216,30 @@ if (Test-Path "Cargo.toml") {
|
|||||||
try {
|
try {
|
||||||
Push-Location $src
|
Push-Location $src
|
||||||
cargo build --release --bin dosh-client --bin dosh-bench
|
cargo build --release --bin dosh-client --bin dosh-bench
|
||||||
|
Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe")
|
||||||
|
Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe")
|
||||||
|
Install-Binary "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe")
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
if ($tmp) {
|
||||||
|
Remove-Item -Recurse -Force $tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$bindir = Join-Path $Prefix "bin"
|
if ($UsePrebuilt) {
|
||||||
$configDir = Join-Path $HOME ".config\dosh"
|
$ok = Install-Prebuilt
|
||||||
New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null
|
if (-not $ok) {
|
||||||
|
if ($BinaryRequired) {
|
||||||
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force
|
throw "prebuilt install failed and DOSH_BINARY_REQUIRED=1"
|
||||||
Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force
|
}
|
||||||
Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force
|
Write-Host "Falling back to source build"
|
||||||
|
Install-FromSource
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Install-FromSource
|
||||||
|
}
|
||||||
|
|
||||||
$clientConfig = Join-Path $configDir "client.toml"
|
$clientConfig = Join-Path $configDir "client.toml"
|
||||||
if ($ForceConfig -or -not (Test-Path $clientConfig)) {
|
if ($ForceConfig -or -not (Test-Path $clientConfig)) {
|
||||||
@@ -60,8 +257,11 @@ dosh_port = $Port
|
|||||||
default_session = "new"
|
default_session = "new"
|
||||||
reconnect_timeout_secs = 5
|
reconnect_timeout_secs = 5
|
||||||
view_only = false
|
view_only = false
|
||||||
|
predict = true
|
||||||
|
predict_mode = "experimental"
|
||||||
cache_attach_tickets = true
|
cache_attach_tickets = true
|
||||||
credential_cache = "~/.local/share/dosh/credentials"
|
credential_cache = "~/.local/share/dosh/credentials"
|
||||||
|
escape_key = "^]"
|
||||||
"@ | Set-Content -NoNewline -Encoding utf8 $clientConfig
|
"@ | Set-Content -NoNewline -Encoding utf8 $clientConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,14 +274,12 @@ credential_cache = "~/.local/share/dosh/credentials"
|
|||||||
Write-Host "Configured UDP port $Port"
|
Write-Host "Configured UDP port $Port"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
$displayServer = if ($Server) { $Server } else { "user@host" }
|
$displayServer = if ($Server) { $Server } else { "user@host" }
|
||||||
Write-Host "Client command:"
|
Write-Host "Client commands:"
|
||||||
Write-Host " $bindir\dosh.exe $displayServer"
|
Write-Host " $bindir\dosh.exe $displayServer"
|
||||||
|
Write-Host " $bindir\dosh.exe setup <ssh-alias>"
|
||||||
|
Write-Host " $bindir\dosh.exe update --check"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Client config:"
|
||||||
|
Write-Host " $configDir\client.toml"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Open a new terminal for PATH changes to apply."
|
Write-Host "Open a new terminal for PATH changes to apply."
|
||||||
}
|
|
||||||
finally {
|
|
||||||
Pop-Location
|
|
||||||
if ($tmp) {
|
|
||||||
Remove-Item -Recurse -Force $tmp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+294
-25
@@ -12,6 +12,12 @@ start_server=1
|
|||||||
force_config=0
|
force_config=0
|
||||||
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
|
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
|
||||||
quiet="${DOSH_UPDATE_QUIET:-0}"
|
quiet="${DOSH_UPDATE_QUIET:-0}"
|
||||||
|
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
|
||||||
|
binary_url="${DOSH_BINARY_URL:-}"
|
||||||
|
binary_base="${DOSH_BINARY_BASE:-}"
|
||||||
|
binary_name="${DOSH_BINARY_NAME:-}"
|
||||||
|
binary_version="${DOSH_BINARY_VERSION:-latest}"
|
||||||
|
binary_required="${DOSH_BINARY_REQUIRED:-0}"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
@@ -32,6 +38,18 @@ Options:
|
|||||||
Environment alternatives:
|
Environment alternatives:
|
||||||
DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX,
|
DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX,
|
||||||
DOSH_UPDATE_CACHE
|
DOSH_UPDATE_CACHE
|
||||||
|
DOSH_USE_PREBUILT=0
|
||||||
|
Build from source instead of trying a release tarball first
|
||||||
|
DOSH_BINARY_URL URL
|
||||||
|
Exact release tarball URL to install
|
||||||
|
DOSH_BINARY_BASE URL
|
||||||
|
Release download base; otherwise derived from the latest tag
|
||||||
|
DOSH_BINARY_NAME NAME
|
||||||
|
Release tarball name; defaults to dosh-OS-ARCH.tar.gz
|
||||||
|
DOSH_BINARY_VERSION TAG
|
||||||
|
Release tag when deriving DOSH_BINARY_BASE; default latest
|
||||||
|
DOSH_BINARY_REQUIRED=1
|
||||||
|
Fail instead of falling back to source when binary install fails
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +130,6 @@ ensure_cargo() {
|
|||||||
. "$HOME/.cargo/env"
|
. "$HOME/.cargo/env"
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_cargo
|
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
if [ -n "${tmpdir:-}" ]; then
|
if [ -n "${tmpdir:-}" ]; then
|
||||||
rm -rf "$tmpdir"
|
rm -rf "$tmpdir"
|
||||||
@@ -121,6 +137,218 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
bindir="$prefix/bin"
|
||||||
|
config_dir="$HOME/.config/dosh"
|
||||||
|
data_dir="$HOME/.local/share/dosh"
|
||||||
|
systemd_user_dir="$HOME/.config/systemd/user"
|
||||||
|
src_dir=""
|
||||||
|
|
||||||
|
mkdir -p "$bindir" "$config_dir" "$data_dir"
|
||||||
|
|
||||||
|
normalize_os() {
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) printf '%s\n' macos ;;
|
||||||
|
Linux) printf '%s\n' linux ;;
|
||||||
|
FreeBSD) printf '%s\n' freebsd ;;
|
||||||
|
*) uname -s | tr '[:upper:]' '[:lower:]' ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_arch() {
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64|amd64) printf '%s\n' x86_64 ;;
|
||||||
|
arm64|aarch64) printf '%s\n' aarch64 ;;
|
||||||
|
armv7l) printf '%s\n' armv7 ;;
|
||||||
|
*) uname -m | tr '[:upper:]' '[:lower:]' ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
repo_web_base() {
|
||||||
|
repo_base="$1"
|
||||||
|
case "$repo_base" in
|
||||||
|
http://*|https://*)
|
||||||
|
repo_base="${repo_base%.git}"
|
||||||
|
printf '%s\n' "${repo_base%/}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
release_artifact_name() {
|
||||||
|
if [ -n "$binary_name" ]; then
|
||||||
|
printf '%s\n' "$binary_name"
|
||||||
|
else
|
||||||
|
printf 'dosh-%s-%s.tar.gz\n' "$(normalize_os)" "$(normalize_arch)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
release_download_url() {
|
||||||
|
if [ -n "$binary_url" ]; then
|
||||||
|
printf '%s\n' "$binary_url"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ -n "$binary_base" ]; then
|
||||||
|
printf '%s/%s\n' "${binary_base%/}" "$(release_artifact_name)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ -z "$repo" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
web_base="$(repo_web_base "$repo")" || return 1
|
||||||
|
if [ "$binary_version" = "latest" ]; then
|
||||||
|
printf '%s/releases/latest/download/%s\n' "$web_base" "$(release_artifact_name)"
|
||||||
|
else
|
||||||
|
printf '%s/releases/download/%s/%s\n' "$web_base" "$binary_version" "$(release_artifact_name)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
release_latest_tag_download_url() {
|
||||||
|
if [ -n "$binary_url" ] || [ -n "$binary_base" ] || [ "$binary_version" != "latest" ] || [ -z "$repo" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
web_base="$(repo_web_base "$repo")" || return 1
|
||||||
|
latest_url="$(curl -fsSL -o /dev/null -w '%{url_effective}' "$web_base/releases/latest" 2>/dev/null || true)"
|
||||||
|
case "$latest_url" in
|
||||||
|
"$web_base"/releases/tag/*)
|
||||||
|
tag="${latest_url##"$web_base"/releases/tag/}"
|
||||||
|
[ -n "$tag" ] || return 1
|
||||||
|
printf '%s/releases/download/%s/%s\n' "$web_base" "$tag" "$(release_artifact_name)"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
find_extracted_binary() {
|
||||||
|
find "$1" -type f -name "$2" 2>/dev/null | sed -n '1p'
|
||||||
|
}
|
||||||
|
|
||||||
|
install_binary() {
|
||||||
|
src="$1"
|
||||||
|
dst="$2"
|
||||||
|
dst_dir="$(dirname "$dst")"
|
||||||
|
dst_base="$(basename "$dst")"
|
||||||
|
tmp="$dst_dir/.$dst_base.tmp.$$"
|
||||||
|
install -m 0755 "$src" "$tmp"
|
||||||
|
if ! mv -f "$tmp" "$dst"; then
|
||||||
|
rm -f "$tmp"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_extracted_binary() {
|
||||||
|
found="$(find_extracted_binary "$1" "$2")"
|
||||||
|
if [ -z "$found" ]; then
|
||||||
|
echo "prebuilt archive missing $2" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
install_binary "$found" "$3"
|
||||||
|
}
|
||||||
|
|
||||||
|
sha256_file() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum "$1" | awk '{print $1}'
|
||||||
|
elif command -v shasum >/dev/null 2>&1; then
|
||||||
|
shasum -a 256 "$1" | awk '{print $1}'
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_archive_checksum() {
|
||||||
|
url="$1"
|
||||||
|
archive="$2"
|
||||||
|
checksum_file="$3"
|
||||||
|
if ! curl -fsL "$url.sha256" -o "$checksum_file"; then
|
||||||
|
echo "prebuilt checksum unavailable; continuing without sidecar verification" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
expected="$(awk '{print $1}' "$checksum_file" | sed -n '1p')"
|
||||||
|
actual="$(sha256_file "$archive")" || {
|
||||||
|
echo "sha256sum/shasum not found for checksum verification" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if [ "$expected" != "$actual" ]; then
|
||||||
|
echo "prebuilt checksum mismatch for $url" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
expected_archive_version() {
|
||||||
|
url="$1"
|
||||||
|
case "$url" in
|
||||||
|
*/releases/download/v*/*)
|
||||||
|
tag="${url#*/releases/download/v}"
|
||||||
|
tag="${tag%%/*}"
|
||||||
|
printf '%s\n' "$tag"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [ -z "$binary_url" ] && [ -z "$binary_base" ] && [ "$binary_version" != "latest" ]; then
|
||||||
|
printf '%s\n' "${binary_version#v}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_archive_version() {
|
||||||
|
extract_dir="$1"
|
||||||
|
url="$2"
|
||||||
|
expected="$(expected_archive_version "$url" || true)"
|
||||||
|
[ -n "$expected" ] || return 0
|
||||||
|
version_file="$(find "$extract_dir" -type f -name VERSION 2>/dev/null | sed -n '1p')"
|
||||||
|
if [ -z "$version_file" ]; then
|
||||||
|
echo "prebuilt archive missing VERSION for $url" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
actual="$(tr -d '\r\n' <"$version_file")"
|
||||||
|
if [ "$actual" != "$expected" ]; then
|
||||||
|
echo "prebuilt archive version mismatch for $url: expected $expected, got $actual" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
try_install_prebuilt() {
|
||||||
|
download_url="$(release_latest_tag_download_url || release_download_url)" || return 1
|
||||||
|
tmpdir="$(mktemp -d)"
|
||||||
|
archive="$tmpdir/$(release_artifact_name)"
|
||||||
|
checksum_file="$archive.sha256"
|
||||||
|
[ "$quiet" = "1" ] && echo "Trying Dosh prebuilt $(release_artifact_name)"
|
||||||
|
need curl
|
||||||
|
need tar
|
||||||
|
if ! curl -fsL "$download_url" -o "$archive" 2>/dev/null; then
|
||||||
|
alt_download_url="$(release_download_url || true)"
|
||||||
|
if [ -z "$alt_download_url" ] || ! curl -fsL "$alt_download_url" -o "$archive"; then
|
||||||
|
echo "prebuilt unavailable: $download_url" >&2
|
||||||
|
[ -z "$alt_download_url" ] || echo "prebuilt unavailable: $alt_download_url" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
download_url="$alt_download_url"
|
||||||
|
fi
|
||||||
|
verify_archive_checksum "$download_url" "$archive" "$checksum_file" || return 1
|
||||||
|
mkdir -p "$tmpdir/extract"
|
||||||
|
if ! tar -xzf "$archive" -C "$tmpdir/extract"; then
|
||||||
|
echo "prebuilt archive could not be extracted: $download_url" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
verify_archive_version "$tmpdir/extract" "$download_url" || return 1
|
||||||
|
install_extracted_binary "$tmpdir/extract" dosh-client "$bindir/dosh-client" || return 1
|
||||||
|
ln -sf dosh-client "$bindir/dosh"
|
||||||
|
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
|
||||||
|
install_extracted_binary "$tmpdir/extract" dosh-server "$bindir/dosh-server" || return 1
|
||||||
|
install_extracted_binary "$tmpdir/extract" dosh-auth "$bindir/dosh-auth" || return 1
|
||||||
|
fi
|
||||||
|
if found_bench="$(find_extracted_binary "$tmpdir/extract" dosh-bench)" && [ -n "$found_bench" ]; then
|
||||||
|
install_binary "$found_bench" "$bindir/dosh-bench"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
install_from_source() {
|
||||||
|
ensure_cargo
|
||||||
if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then
|
if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then
|
||||||
src_dir="$(pwd)"
|
src_dir="$(pwd)"
|
||||||
else
|
else
|
||||||
@@ -173,21 +401,51 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
bindir="$prefix/bin"
|
install_binary target/release/dosh-client "$bindir/dosh-client"
|
||||||
config_dir="$HOME/.config/dosh"
|
|
||||||
data_dir="$HOME/.local/share/dosh"
|
|
||||||
systemd_user_dir="$HOME/.config/systemd/user"
|
|
||||||
|
|
||||||
mkdir -p "$bindir" "$config_dir" "$data_dir"
|
|
||||||
|
|
||||||
install -m 0755 target/release/dosh-client "$bindir/dosh-client"
|
|
||||||
ln -sf dosh-client "$bindir/dosh"
|
ln -sf dosh-client "$bindir/dosh"
|
||||||
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
|
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
|
||||||
install -m 0755 target/release/dosh-server "$bindir/dosh-server"
|
install_binary target/release/dosh-server "$bindir/dosh-server"
|
||||||
install -m 0755 target/release/dosh-auth "$bindir/dosh-auth"
|
install_binary target/release/dosh-auth "$bindir/dosh-auth"
|
||||||
fi
|
fi
|
||||||
if [ -f target/release/dosh-bench ]; then
|
if [ -f target/release/dosh-bench ]; then
|
||||||
install -m 0755 target/release/dosh-bench "$bindir/dosh-bench"
|
install_binary target/release/dosh-bench "$bindir/dosh-bench"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_systemd_service() {
|
||||||
|
if [ -n "$src_dir" ] && [ -f "$src_dir/packaging/systemd/dosh-server.service" ]; then
|
||||||
|
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \
|
||||||
|
"$src_dir/packaging/systemd/dosh-server.service" >"$systemd_user_dir/dosh-server.service"
|
||||||
|
else
|
||||||
|
cat >"$systemd_user_dir/dosh-server.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=dosh dormant shell server
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=$bindir/dosh-server serve
|
||||||
|
Restart=always
|
||||||
|
RestartSec=1
|
||||||
|
KillMode=process
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$use_prebuilt" != "0" ]; then
|
||||||
|
if ! try_install_prebuilt; then
|
||||||
|
if [ "$binary_required" = "1" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[ "$quiet" = "1" ] && echo "Falling back to source build"
|
||||||
|
install_from_source
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
install_from_source
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
|
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
|
||||||
@@ -206,8 +464,9 @@ scrollback = 5000
|
|||||||
auth_ttl_secs = 30
|
auth_ttl_secs = 30
|
||||||
attach_ticket_ttl_secs = 3600
|
attach_ticket_ttl_secs = 3600
|
||||||
allow_attach_tickets = true
|
allow_attach_tickets = true
|
||||||
client_timeout_secs = 86400
|
client_timeout_secs = 2592000
|
||||||
retransmit_window = 256
|
retransmit_window = 256
|
||||||
|
output_frame_interval_ms = 16
|
||||||
default_input_mode = "read-write"
|
default_input_mode = "read-write"
|
||||||
prewarm_sessions = ["default"]
|
prewarm_sessions = ["default"]
|
||||||
create_on_attach = true
|
create_on_attach = true
|
||||||
@@ -221,15 +480,16 @@ native_auth_rate_limit_per_minute = 30
|
|||||||
allow_tcp_forwarding = true
|
allow_tcp_forwarding = true
|
||||||
allow_remote_forwarding = false
|
allow_remote_forwarding = false
|
||||||
allow_agent_forwarding = false
|
allow_agent_forwarding = false
|
||||||
|
persist_sessions = true
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v systemctl >/dev/null 2>&1; then
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
mkdir -p "$systemd_user_dir"
|
mkdir -p "$systemd_user_dir"
|
||||||
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \
|
write_systemd_service
|
||||||
packaging/systemd/dosh-server.service >"$systemd_user_dir/dosh-server.service"
|
|
||||||
if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then
|
if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then
|
||||||
systemctl --user enable --now dosh-server.service
|
systemctl --user enable dosh-server.service
|
||||||
|
systemctl --user restart dosh-server.service
|
||||||
fi
|
fi
|
||||||
elif [ "$start_server" -eq 1 ]; then
|
elif [ "$start_server" -eq 1 ]; then
|
||||||
nohup "$bindir/dosh-server" serve >"$data_dir/dosh-server.log" 2>&1 &
|
nohup "$bindir/dosh-server" serve >"$data_dir/dosh-server.log" 2>&1 &
|
||||||
@@ -272,7 +532,8 @@ dosh_port = $port
|
|||||||
default_session = "new"
|
default_session = "new"
|
||||||
reconnect_timeout_secs = 5
|
reconnect_timeout_secs = 5
|
||||||
view_only = false
|
view_only = false
|
||||||
predict = false
|
predict = true
|
||||||
|
predict_mode = "experimental"
|
||||||
cache_attach_tickets = true
|
cache_attach_tickets = true
|
||||||
credential_cache = "~/.local/share/dosh/credentials"
|
credential_cache = "~/.local/share/dosh/credentials"
|
||||||
auth_preference = "native,ssh"
|
auth_preference = "native,ssh"
|
||||||
@@ -282,6 +543,7 @@ known_hosts = "~/.config/dosh/known_hosts"
|
|||||||
identity_files = ["~/.ssh/id_ed25519"]
|
identity_files = ["~/.ssh/id_ed25519"]
|
||||||
use_ssh_agent = true
|
use_ssh_agent = true
|
||||||
forward_agent = false
|
forward_agent = false
|
||||||
|
escape_key = "^]"
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -295,18 +557,18 @@ EOF
|
|||||||
fi
|
fi
|
||||||
cat >"$hosts_config" <<EOF
|
cat >"$hosts_config" <<EOF
|
||||||
# Example:
|
# Example:
|
||||||
# [palav]
|
# [server]
|
||||||
# ssh = "palav"
|
# ssh = "server"
|
||||||
# dosh_host = "palav.dev"
|
# dosh_host = "server.example.com"
|
||||||
# port = 50000
|
# port = 50000
|
||||||
# default_command = "tm"
|
# default_command = "tm"
|
||||||
# predict = false
|
# predict = true
|
||||||
|
|
||||||
[default]
|
[default]
|
||||||
ssh = "$default_server"
|
ssh = "$default_server"
|
||||||
dosh_host = "$host_udp"
|
dosh_host = "$host_udp"
|
||||||
port = $port
|
port = $port
|
||||||
predict = false
|
predict = true
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -317,10 +579,17 @@ Configured UDP port $port
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
if [ "$role" = "client" ] || [ "$role" = "both" ]; then
|
if [ "$role" = "client" ] || [ "$role" = "both" ]; then
|
||||||
|
next_server="${server:-user@host}"
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
||||||
Client command:
|
Client commands:
|
||||||
$bindir/dosh ${server:-user@host}
|
$bindir/dosh $next_server
|
||||||
|
$bindir/dosh setup <ssh-alias>
|
||||||
|
$bindir/dosh update --check
|
||||||
|
|
||||||
|
Client config:
|
||||||
|
$config_dir/client.toml
|
||||||
|
$config_dir/hosts.toml
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
|
||||||
cd "$repo_root"
|
|
||||||
exec sh ./install.sh --from-current "$@"
|
|
||||||
@@ -6,17 +6,9 @@ Wants=network-online.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=%h/.local/bin/dosh-server serve
|
ExecStart=%h/.local/bin/dosh-server serve
|
||||||
Restart=on-failure
|
Restart=always
|
||||||
RestartSec=1
|
RestartSec=1
|
||||||
NoNewPrivileges=true
|
KillMode=process
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=read-only
|
|
||||||
ReadWritePaths=%h/.config/dosh %h/.local/share/dosh
|
|
||||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
||||||
RestrictRealtime=true
|
|
||||||
LockPersonality=true
|
|
||||||
MemoryDenyWriteExecute=true
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
|
|||||||
Executable
+193
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# Safe, self-contained local Dosh benchmark harness.
|
||||||
|
#
|
||||||
|
# Spins up a THROWAWAY dosh-server bound to 127.0.0.1 on a random free port in a
|
||||||
|
# temp HOME, runs the full path matrix (native cold auth, cached attach-ticket,
|
||||||
|
# UDP resume, and local-auth), prints raw samples + summary stats, then tears
|
||||||
|
# everything down.
|
||||||
|
#
|
||||||
|
# It NEVER touches the production server: it never uses UDP port 50000, never
|
||||||
|
# restarts any systemd unit, and never reads or writes the real ~/.config/dosh
|
||||||
|
# or ~/.local. Everything lives under a mktemp HOME that is removed on exit.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/bench-local.sh [ITERATIONS]
|
||||||
|
# Environment:
|
||||||
|
# DOSH_BENCH_ITERS iteration count (default 20; overridden by $1)
|
||||||
|
# DOSH_BENCH_JSON=1 emit machine-readable JSON instead of the table
|
||||||
|
# DOSH_BENCH_PROFILE release|debug build profile (default release)
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
iters="${1:-${DOSH_BENCH_ITERS:-20}}"
|
||||||
|
profile="${DOSH_BENCH_PROFILE:-release}"
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
# Make sure cargo is reachable in non-login shells.
|
||||||
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$profile" = "release" ]; then
|
||||||
|
cargo build --release >&2
|
||||||
|
bindir="$repo_root/target/release"
|
||||||
|
else
|
||||||
|
cargo build >&2
|
||||||
|
bindir="$repo_root/target/debug"
|
||||||
|
fi
|
||||||
|
server_bin="$bindir/dosh-server"
|
||||||
|
client_bin="$bindir/dosh-client"
|
||||||
|
bench_bin="$bindir/dosh-bench"
|
||||||
|
|
||||||
|
# Pick a free UDP port that is NOT the production port (50000).
|
||||||
|
free_udp_port() {
|
||||||
|
python3 - <<'PY'
|
||||||
|
import socket
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
print(s.getsockname()[1])
|
||||||
|
s.close()
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
dosh_port="$(free_udp_port)"
|
||||||
|
if [ "$dosh_port" = "50000" ]; then
|
||||||
|
dosh_port="$(free_udp_port)"
|
||||||
|
fi
|
||||||
|
if [ "$dosh_port" = "50000" ]; then
|
||||||
|
echo "refusing to bind production port 50000" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
home="$(mktemp -d)"
|
||||||
|
server_pid=""
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "$server_pid" ]; then
|
||||||
|
kill "$server_pid" 2>/dev/null || true
|
||||||
|
wait "$server_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
pkill -f "$server_bin hold --runtime-dir $home/sessions/run" 2>/dev/null || true
|
||||||
|
rm -rf "$home"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
mkdir -p "$home/.config/dosh" "$home/.ssh" "$home/.local/share/dosh"
|
||||||
|
|
||||||
|
# Client identity used by the native cold-auth path.
|
||||||
|
ssh-keygen -t ed25519 -N "" -q -f "$home/.ssh/id_ed25519"
|
||||||
|
client_pub="$(cat "$home/.ssh/id_ed25519.pub")"
|
||||||
|
printf '%s\n' "$client_pub" > "$home/authorized_keys"
|
||||||
|
|
||||||
|
# Throwaway server config: localhost only, throwaway port, attach tickets on.
|
||||||
|
cat > "$home/.config/dosh/server.toml" <<EOF
|
||||||
|
port = $dosh_port
|
||||||
|
bind = "127.0.0.1"
|
||||||
|
scrollback = 5000
|
||||||
|
auth_ttl_secs = 30
|
||||||
|
attach_ticket_ttl_secs = 3600
|
||||||
|
allow_attach_tickets = true
|
||||||
|
persist_sessions = false
|
||||||
|
client_timeout_secs = 60
|
||||||
|
retransmit_window = 256
|
||||||
|
default_input_mode = "read-write"
|
||||||
|
prewarm_sessions = ["default"]
|
||||||
|
create_on_attach = true
|
||||||
|
shell = "/bin/sh"
|
||||||
|
sessions_dir = "$home/sessions"
|
||||||
|
secret_path = "$home/secret"
|
||||||
|
host_key = "$home/host_key"
|
||||||
|
authorized_keys = ["$home/authorized_keys"]
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Client config: native auth, trust-on-first-use (no SSH needed), localhost UDP.
|
||||||
|
write_client_config() {
|
||||||
|
# $1 = cache_attach_tickets (true|false)
|
||||||
|
cat > "$home/.config/dosh/client.toml" <<EOF
|
||||||
|
server = "local"
|
||||||
|
dosh_host = "127.0.0.1"
|
||||||
|
dosh_port = $dosh_port
|
||||||
|
default_session = "default"
|
||||||
|
reconnect_timeout_secs = 5
|
||||||
|
view_only = false
|
||||||
|
predict = false
|
||||||
|
cache_attach_tickets = $1
|
||||||
|
credential_cache = "$home/.local/share/dosh/credentials"
|
||||||
|
known_hosts = "$home/.config/dosh/known_hosts"
|
||||||
|
auth_preference = "native"
|
||||||
|
trust_on_first_use = true
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
start_server() {
|
||||||
|
HOME="$home" "$server_bin" serve --config "$home/.config/dosh/server.toml" \
|
||||||
|
>"$home/server.log" 2>&1 &
|
||||||
|
server_pid="$!"
|
||||||
|
# Wait for the UDP socket to come up.
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt 50 ]; do
|
||||||
|
if grep -q "listening on" "$home/server.log" 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if ! kill -0 "$server_pid" 2>/dev/null; then
|
||||||
|
echo "dosh-server exited early:" >&2
|
||||||
|
cat "$home/server.log" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
run_bench() {
|
||||||
|
# $@ extra args forwarded to dosh-bench
|
||||||
|
json_flag=""
|
||||||
|
[ "${DOSH_BENCH_JSON:-0}" = "1" ] && json_flag="--json"
|
||||||
|
label="$(uname -s) $(uname -m), profile=$profile"
|
||||||
|
HOME="$home" "$bench_bin" \
|
||||||
|
--client "$client_bin" \
|
||||||
|
--server local \
|
||||||
|
--dosh-host 127.0.0.1 \
|
||||||
|
--dosh-port "$dosh_port" \
|
||||||
|
--session default \
|
||||||
|
--skip-ssh-baseline \
|
||||||
|
--iterations "$iters" \
|
||||||
|
--label "$label" \
|
||||||
|
$json_flag \
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "dosh local benchmark: port=$dosh_port iters=$iters profile=$profile home=$home" >&2
|
||||||
|
|
||||||
|
# 1) Native cold auth + cached attach-ticket in one run (ticket cache on).
|
||||||
|
write_client_config true
|
||||||
|
start_server
|
||||||
|
echo "== native cold auth + cached attach-ticket ==" >&2
|
||||||
|
run_bench --cold-native --cached-ticket
|
||||||
|
# Sanity: native cold auth must have trusted the host (proves the generated
|
||||||
|
# client config loaded and the native handshake ran instead of silently
|
||||||
|
# falling back to a different path).
|
||||||
|
if [ ! -s "$home/.config/dosh/known_hosts" ]; then
|
||||||
|
echo "native cold auth did not record a trusted host; check server.log:" >&2
|
||||||
|
cat "$home/server.log" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
kill "$server_pid" 2>/dev/null || true
|
||||||
|
wait "$server_pid" 2>/dev/null || true
|
||||||
|
server_pid=""
|
||||||
|
|
||||||
|
# 2) Self-contained local-auth path (no SSH, no native handshake).
|
||||||
|
#
|
||||||
|
# Note on UDP resume: resume is the roaming path for a client that is STILL
|
||||||
|
# attached when its network endpoint changes. The `--attach-only` benchmark
|
||||||
|
# model detaches after each iteration, which tears the client down on the
|
||||||
|
# server, so a fresh-process resume has nothing live to resume and is not
|
||||||
|
# meaningful here. The cold fresh-process reconnect fast path is the attach
|
||||||
|
# ticket (measured above). Roaming resume is validated by the integration test
|
||||||
|
# `resume_updates_udp_endpoint_for_roaming`. `dosh-bench --resume` exists for
|
||||||
|
# scenarios that keep a live session out-of-band (e.g. remote soak tests).
|
||||||
|
rm -rf "$home/.local/share/dosh/credentials"
|
||||||
|
write_client_config true
|
||||||
|
start_server
|
||||||
|
echo "== local-auth (no SSH) ==" >&2
|
||||||
|
run_bench --local-auth --no-cache
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
out="${DOSH_BENCH_REPORT:-target/dosh-bench/report.md}"
|
||||||
|
iters="${DOSH_BENCH_ITERS:-10}"
|
||||||
|
server="${DOSH_BENCH_SERVER:-${1:-local}}"
|
||||||
|
label="${DOSH_BENCH_LABEL:-$(uname -s) $(uname -m)}"
|
||||||
|
|
||||||
|
cargo build --release >/dev/null
|
||||||
|
mkdir -p "$(dirname "$out")"
|
||||||
|
|
||||||
|
exec target/release/dosh-bench \
|
||||||
|
--server "$server" \
|
||||||
|
--iterations "$iters" \
|
||||||
|
--label "$label" \
|
||||||
|
--markdown \
|
||||||
|
--output "$out" \
|
||||||
|
${DOSH_BENCH_ARGS:-}
|
||||||
@@ -31,7 +31,7 @@ for _ in 1 2 3 4 5; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts"
|
ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts"
|
||||||
mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-cached" "$workdir/home-mosh"
|
mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-native" "$workdir/home-cached" "$workdir/home-mosh"
|
||||||
|
|
||||||
if ! HOME="$workdir/home" target/release/dosh-bench \
|
if ! HOME="$workdir/home" target/release/dosh-bench \
|
||||||
--server bench@127.0.0.1 \
|
--server bench@127.0.0.1 \
|
||||||
@@ -64,6 +64,32 @@ if ! HOME="$workdir/home-controlmaster" target/release/dosh-bench \
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! HOME="$workdir/home-native" target/release/dosh-client \
|
||||||
|
--ssh-port "$ssh_port" \
|
||||||
|
--ssh-key "$workdir/id_ed25519" \
|
||||||
|
--ssh-known-hosts "$workdir/known_hosts" \
|
||||||
|
--ssh-auth-command /usr/local/bin/dosh-auth \
|
||||||
|
trust bench@127.0.0.1 >/dev/null; then
|
||||||
|
docker logs "$container_id" || true
|
||||||
|
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! HOME="$workdir/home-native" target/release/dosh-bench \
|
||||||
|
--server bench@127.0.0.1 \
|
||||||
|
--ssh-port "$ssh_port" \
|
||||||
|
--dosh-port "$dosh_port" \
|
||||||
|
--dosh-host 127.0.0.1 \
|
||||||
|
--ssh-key "$workdir/id_ed25519" \
|
||||||
|
--ssh-known-hosts "$workdir/known_hosts" \
|
||||||
|
--iterations 5 \
|
||||||
|
--cold-native \
|
||||||
|
--assert-ssh-plus-ms "${DOSH_BENCH_NATIVE_SSH_PLUS_MS:-0}"; then
|
||||||
|
docker logs "$container_id" || true
|
||||||
|
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if ! HOME="$workdir/home-cached" target/release/dosh-bench \
|
if ! HOME="$workdir/home-cached" target/release/dosh-bench \
|
||||||
--server bench@127.0.0.1 \
|
--server bench@127.0.0.1 \
|
||||||
--ssh-port "$ssh_port" \
|
--ssh-port "$ssh_port" \
|
||||||
|
|||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
seconds="${DOSH_FUZZ_SECONDS:-${1:-20}}"
|
||||||
|
targets="
|
||||||
|
packet_decode
|
||||||
|
from_body
|
||||||
|
authorized_keys
|
||||||
|
known_hosts
|
||||||
|
handshake_structs
|
||||||
|
attach_ticket
|
||||||
|
"
|
||||||
|
|
||||||
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
|
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for target in $targets; do
|
||||||
|
echo "== fuzzing $target for ${seconds}s =="
|
||||||
|
cargo +nightly fuzz run --fuzz-dir fuzz "$target" -- \
|
||||||
|
-max_total_time="$seconds" -rss_limit_mb="${DOSH_FUZZ_RSS_MB:-4096}"
|
||||||
|
done
|
||||||
Executable
+150
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
out_dir="${DOSH_PACKAGE_DIR:-target/dosh-release}"
|
||||||
|
version="${DOSH_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | sed -n '1p')}"
|
||||||
|
|
||||||
|
normalize_os() {
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) printf '%s\n' macos ;;
|
||||||
|
Linux) printf '%s\n' linux ;;
|
||||||
|
FreeBSD) printf '%s\n' freebsd ;;
|
||||||
|
MINGW*|MSYS*|CYGWIN*) printf '%s\n' windows ;;
|
||||||
|
*) uname -s | tr '[:upper:]' '[:lower:]' ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_arch() {
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64|amd64) printf '%s\n' x86_64 ;;
|
||||||
|
arm64|aarch64) printf '%s\n' aarch64 ;;
|
||||||
|
armv7l) printf '%s\n' armv7 ;;
|
||||||
|
*) uname -m | tr '[:upper:]' '[:lower:]' ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
host_os="$(normalize_os)"
|
||||||
|
os="${DOSH_PACKAGE_OS:-$host_os}"
|
||||||
|
arch="${DOSH_PACKAGE_ARCH:-$(normalize_arch)}"
|
||||||
|
target="${DOSH_PACKAGE_TARGET:-}"
|
||||||
|
if [ -z "$target" ] && [ "$os" = "windows" ] && [ "$host_os" != "windows" ]; then
|
||||||
|
case "$arch" in
|
||||||
|
x86_64) target="x86_64-pc-windows-gnu" ;;
|
||||||
|
*) echo "set DOSH_PACKAGE_TARGET for windows/$arch" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
if [ "$os" = "windows" ]; then
|
||||||
|
artifact="dosh-$os-$arch.zip"
|
||||||
|
versioned_artifact="dosh-$version-$os-$arch.zip"
|
||||||
|
else
|
||||||
|
artifact="dosh-$os-$arch.tar.gz"
|
||||||
|
versioned_artifact="dosh-$version-$os-$arch.tar.gz"
|
||||||
|
fi
|
||||||
|
stage="$out_dir/stage/dosh"
|
||||||
|
write_versioned="${DOSH_PACKAGE_VERSIONED:-0}"
|
||||||
|
|
||||||
|
if [ -n "$target" ]; then
|
||||||
|
release_dir="target/$target/release"
|
||||||
|
else
|
||||||
|
release_dir="target/release"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$os" = "windows" ]; then
|
||||||
|
if [ -n "$target" ]; then
|
||||||
|
cargo build --release --target "$target" --bin dosh-client --bin dosh-bench
|
||||||
|
else
|
||||||
|
cargo build --release --bin dosh-client --bin dosh-bench
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ -n "$target" ]; then
|
||||||
|
cargo build --release --target "$target"
|
||||||
|
else
|
||||||
|
cargo build --release
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$stage"
|
||||||
|
mkdir -p "$stage/bin" "$out_dir"
|
||||||
|
for bin in dosh-client dosh-server dosh-auth dosh-bench; do
|
||||||
|
if [ -f "$release_dir/$bin" ]; then
|
||||||
|
install -m 0755 "$release_dir/$bin" "$stage/bin/$bin"
|
||||||
|
elif [ -f "$release_dir/$bin.exe" ]; then
|
||||||
|
install -m 0755 "$release_dir/$bin.exe" "$stage/bin/$bin.exe"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -f "$stage/bin/dosh-client.exe" ]; then
|
||||||
|
cp "$stage/bin/dosh-client.exe" "$stage/bin/dosh.exe"
|
||||||
|
elif [ -f "$stage/bin/dosh-client" ]; then
|
||||||
|
cp "$stage/bin/dosh-client" "$stage/bin/dosh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
strip_bin() {
|
||||||
|
file="$1"
|
||||||
|
[ -f "$file" ] || return 0
|
||||||
|
case "$file" in
|
||||||
|
*.exe)
|
||||||
|
if command -v x86_64-w64-mingw32-strip >/dev/null 2>&1; then
|
||||||
|
x86_64-w64-mingw32-strip "$file" 2>/dev/null || true
|
||||||
|
elif command -v strip >/dev/null 2>&1; then
|
||||||
|
strip "$file" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if command -v strip >/dev/null 2>&1; then
|
||||||
|
strip "$file" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
for bin in "$stage"/bin/*; do
|
||||||
|
strip_bin "$bin"
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '%s\n' "$version" >"$stage/VERSION"
|
||||||
|
|
||||||
|
if [ "$os" = "windows" ]; then
|
||||||
|
if command -v powershell.exe >/dev/null 2>&1; then
|
||||||
|
powershell.exe -NoProfile -Command "Compress-Archive -Force -Path '$stage' -DestinationPath '$out_dir/$artifact'"
|
||||||
|
elif command -v zip >/dev/null 2>&1; then
|
||||||
|
(cd "$out_dir/stage" && zip -qr "../$artifact" dosh)
|
||||||
|
else
|
||||||
|
echo "windows packaging requires powershell.exe or zip" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
tar -C "$out_dir/stage" -czf "$out_dir/$artifact" dosh
|
||||||
|
fi
|
||||||
|
write_sha256() {
|
||||||
|
file="$1"
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum "$file" >"$file.sha256"
|
||||||
|
elif command -v shasum >/dev/null 2>&1; then
|
||||||
|
shasum -a 256 "$file" >"$file.sha256"
|
||||||
|
else
|
||||||
|
echo "warning: sha256sum/shasum not found; skipping checksum for $file" >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_sha256 "$out_dir/$artifact"
|
||||||
|
|
||||||
|
if [ "$write_versioned" = "1" ]; then
|
||||||
|
cp "$out_dir/$artifact" "$out_dir/$versioned_artifact"
|
||||||
|
write_sha256 "$out_dir/$versioned_artifact"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Wrote:\n'
|
||||||
|
cat <<EOF
|
||||||
|
$out_dir/$artifact
|
||||||
|
$out_dir/$artifact.sha256
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ "$write_versioned" = "1" ]; then
|
||||||
|
cat <<EOF
|
||||||
|
$out_dir/$versioned_artifact
|
||||||
|
$out_dir/$versioned_artifact.sha256
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
Executable
+145
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
scripts/publish-gitea-release.sh [TAG]
|
||||||
|
|
||||||
|
Publishes the current Dosh release to Gitea.
|
||||||
|
|
||||||
|
Defaults:
|
||||||
|
TAG v<Cargo.toml version>
|
||||||
|
DOSH_PUBLISH_BUILD 1: run scripts/package-release.sh first
|
||||||
|
DOSH_PUBLISH_PUSH 1: push TAG to origin
|
||||||
|
DOSH_PUBLISH_PRUNE 1: remove stale dosh-<version>-* duplicate assets
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
GITEA_URL, GITEA_REPO, GITEA_TOKEN: same as upload-gitea-release.sh
|
||||||
|
DOSH_PUBLISH_ALLOW_DIRTY=1 allow uncommitted changes
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | sed -n '1p')"
|
||||||
|
tag="${1:-v$version}"
|
||||||
|
base="${GITEA_URL:-https://git.palav.dev}"
|
||||||
|
repo="${GITEA_REPO:-Palav/dosh}"
|
||||||
|
token="${GITEA_TOKEN:-}"
|
||||||
|
build="${DOSH_PUBLISH_BUILD:-1}"
|
||||||
|
push_tag="${DOSH_PUBLISH_PUSH:-1}"
|
||||||
|
prune="${DOSH_PUBLISH_PRUNE:-1}"
|
||||||
|
|
||||||
|
if [ -z "$token" ] && [ -f "$HOME/.config/dosh/gitea-token" ]; then
|
||||||
|
token="$(tr -d '\r\n' <"$HOME/.config/dosh/gitea-token")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
need() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
echo "missing required command: $1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
need git
|
||||||
|
need curl
|
||||||
|
|
||||||
|
if [ "${DOSH_PUBLISH_ALLOW_DIRTY:-0}" != "1" ] && [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo "refusing to publish with uncommitted changes" >&2
|
||||||
|
echo "commit first, or set DOSH_PUBLISH_ALLOW_DIRTY=1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
head="$(git rev-parse HEAD)"
|
||||||
|
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
|
||||||
|
tagged="$(git rev-list -n 1 "$tag")"
|
||||||
|
if [ "$tagged" != "$head" ]; then
|
||||||
|
echo "tag $tag points at $tagged, not HEAD $head" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
git tag "$tag" "$head"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$push_tag" = "1" ]; then
|
||||||
|
git push origin "$tag"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$build" = "1" ]; then
|
||||||
|
sh scripts/package-release.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
scripts/upload-gitea-release.sh "$tag"
|
||||||
|
|
||||||
|
api="$base/api/v1/repos/$repo"
|
||||||
|
auth_header="Authorization: token $token"
|
||||||
|
release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag")"
|
||||||
|
release_id=""
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$prune" = "1" ] && [ -n "$release_id" ] && command -v jq >/dev/null 2>&1; then
|
||||||
|
printf '%s\n' "$release_json" \
|
||||||
|
| jq -r '.assets[]? | select(.name | test("^dosh-[0-9]")) | [.id, .name] | @tsv' \
|
||||||
|
| while IFS="$(printf '\t')" read -r asset_id name; do
|
||||||
|
[ -n "$asset_id" ] || continue
|
||||||
|
echo "deleting duplicate $name"
|
||||||
|
curl -fsS -H "$auth_header" -X DELETE "$api/releases/$release_id/assets/$asset_id" >/dev/null
|
||||||
|
done
|
||||||
|
release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
web_repo="${base%/}/${repo}"
|
||||||
|
failed=0
|
||||||
|
artifact_version() {
|
||||||
|
artifact="$1"
|
||||||
|
case "$artifact" in
|
||||||
|
*.tar.gz)
|
||||||
|
tar -xOf "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
|
||||||
|
;;
|
||||||
|
*.zip)
|
||||||
|
unzip -p "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
for artifact in target/dosh-release/dosh-linux-*.tar.gz target/dosh-release/dosh-macos-*.tar.gz target/dosh-release/dosh-windows-*.zip; do
|
||||||
|
[ -f "$artifact" ] || continue
|
||||||
|
name="$(basename "$artifact")"
|
||||||
|
case "$name" in
|
||||||
|
dosh-[0-9]*)
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
actual="$(artifact_version "$artifact" || true)"
|
||||||
|
if [ "$actual" != "$version" ]; then
|
||||||
|
echo "skipping stale artifact $name: version ${actual:-unknown}, expected $version" >&2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
url="$web_repo/releases/download/$tag/$name"
|
||||||
|
if curl -fsSI "$url" >/dev/null; then
|
||||||
|
echo "verified $url"
|
||||||
|
else
|
||||||
|
echo "missing release asset: $url" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$failed" != "0" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
printf '%s\n' "$release_json" | jq -r '"assets=\(.assets|length)", (.assets[] | "\(.name) \(.size)")'
|
||||||
|
fi
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
for test_name in \
|
||||||
|
live_output_forwards_terminal_control_sequences \
|
||||||
|
tui_control_sequences_survive_transport_verbatim \
|
||||||
|
unicode_output_survives_transport \
|
||||||
|
tui_graph_glyphs_survive_fast_repaints_without_snapshot_substitution \
|
||||||
|
btop_style_graph_output_stays_raw_when_retransmit_history_overflows \
|
||||||
|
large_tui_paint_is_delivered_in_mtu_safe_frames \
|
||||||
|
resume_snapshot_preserves_alternate_screen_mode
|
||||||
|
do
|
||||||
|
cargo test --test integration_smoke "$test_name" -- --nocapture
|
||||||
|
done
|
||||||
Executable
+167
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
GITEA_TOKEN=... scripts/upload-gitea-release.sh TAG [artifact...]
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
GITEA_URL Base URL, default https://git.palav.dev
|
||||||
|
GITEA_REPO owner/repo, default Palav/dosh
|
||||||
|
GITEA_TOKEN Token with release write permission
|
||||||
|
GITEA_TITLE Release title, default TAG
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
tag="${1:-}"
|
||||||
|
if [ -z "$tag" ]; then
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
|
||||||
|
base="${GITEA_URL:-https://git.palav.dev}"
|
||||||
|
repo="${GITEA_REPO:-Palav/dosh}"
|
||||||
|
token="${GITEA_TOKEN:-}"
|
||||||
|
title="${GITEA_TITLE:-$tag}"
|
||||||
|
expected_version="${tag#v}"
|
||||||
|
|
||||||
|
artifact_version() {
|
||||||
|
artifact="$1"
|
||||||
|
case "$artifact" in
|
||||||
|
*.tar.gz)
|
||||||
|
tar -xOf "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
|
||||||
|
;;
|
||||||
|
*.zip)
|
||||||
|
unzip -p "$artifact" dosh/VERSION 2>/dev/null | tr -d '\r\n'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "$token" ] && [ -f "$HOME/.config/dosh/gitea-token" ]; then
|
||||||
|
token="$(tr -d '\r\n' <"$HOME/.config/dosh/gitea-token")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$token" ]; then
|
||||||
|
echo "GITEA_TOKEN is required (or put it in ~/.config/dosh/gitea-token)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
for artifact in \
|
||||||
|
target/dosh-release/dosh-linux-*.tar.gz \
|
||||||
|
target/dosh-release/dosh-macos-*.tar.gz \
|
||||||
|
target/dosh-release/dosh-windows-*.zip; do
|
||||||
|
[ -f "$artifact" ] || continue
|
||||||
|
case "$(basename "$artifact")" in
|
||||||
|
dosh-[0-9]*)
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
actual="$(artifact_version "$artifact" || true)"
|
||||||
|
if [ "$actual" != "$expected_version" ]; then
|
||||||
|
echo "skipping $artifact: version ${actual:-unknown}, expected $expected_version" >&2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ -f "$artifact.sha256" ]; then
|
||||||
|
set -- "$@" "$artifact" "$artifact.sha256"
|
||||||
|
else
|
||||||
|
set -- "$@" "$artifact"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
api="$base/api/v1/repos/$repo"
|
||||||
|
auth_header="Authorization: token $token"
|
||||||
|
|
||||||
|
release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag" 2>/dev/null || true)"
|
||||||
|
release_id=""
|
||||||
|
if [ -n "$release_json" ] && command -v jq >/dev/null 2>&1; then
|
||||||
|
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
|
||||||
|
elif [ -n "$release_json" ]; then
|
||||||
|
release_id="$(printf '%s\n' "$release_json" | sed -n 's/^[[:space:]]*{"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$release_id" ]; then
|
||||||
|
payload="$(printf '{"tag_name":"%s","target_commitish":"main","name":"%s","body":"Dosh release %s","draft":false,"prerelease":false}\n' "$tag" "$title" "$tag")"
|
||||||
|
release_json="$(curl -fsS -H "$auth_header" -H 'Content-Type: application/json' -X POST "$api/releases" -d "$payload")"
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
|
||||||
|
else
|
||||||
|
release_id="$(printf '%s\n' "$release_json" | sed -n 's/^[[:space:]]*{"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$release_id" ]; then
|
||||||
|
echo "could not determine Gitea release id for $tag" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
delete_existing_asset() {
|
||||||
|
name="$1"
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$release_json" \
|
||||||
|
| jq -r --arg name "$name" '.assets[]? | select(.name == $name) | .id' \
|
||||||
|
| while IFS= read -r asset_id; do
|
||||||
|
[ -n "$asset_id" ] || continue
|
||||||
|
echo "replacing $name"
|
||||||
|
curl -fsS \
|
||||||
|
-H "$auth_header" \
|
||||||
|
-X DELETE \
|
||||||
|
"$api/releases/$release_id/assets/$asset_id" >/dev/null
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_release_artifact_version() {
|
||||||
|
artifact="$1"
|
||||||
|
name="$(basename "$artifact")"
|
||||||
|
case "$name" in
|
||||||
|
*.sha256)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
dosh-*.tar.gz|dosh-*.zip)
|
||||||
|
actual="$(artifact_version "$artifact" || true)"
|
||||||
|
if [ -z "$actual" ]; then
|
||||||
|
echo "release artifact missing dosh/VERSION: $artifact" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$actual" != "$expected_version" ]; then
|
||||||
|
echo "release artifact version mismatch: $artifact has $actual, expected $expected_version" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
uploaded=0
|
||||||
|
for artifact in "$@"; do
|
||||||
|
if [ ! -f "$artifact" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
name="$(basename "$artifact")"
|
||||||
|
verify_release_artifact_version "$artifact"
|
||||||
|
delete_existing_asset "$name"
|
||||||
|
echo "uploading $name"
|
||||||
|
curl -fsS \
|
||||||
|
-H "$auth_header" \
|
||||||
|
-X POST \
|
||||||
|
-F "attachment=@$artifact" \
|
||||||
|
"$api/releases/$release_id/assets?name=$name" >/dev/null
|
||||||
|
uploaded=$((uploaded + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$uploaded" -eq 0 ]; then
|
||||||
|
echo "no release artifacts uploaded" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -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"
|
||||||
+15
-4
@@ -7,6 +7,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -68,6 +69,12 @@ pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> {
|
|||||||
.decode(String::from_utf8_lossy(&raw).trim())
|
.decode(String::from_utf8_lossy(&raw).trim())
|
||||||
.context("decode server secret")?
|
.context("decode server secret")?
|
||||||
};
|
};
|
||||||
|
anyhow::ensure!(
|
||||||
|
decoded.len() == 32,
|
||||||
|
"server secret at {} must be 32 bytes, got {}",
|
||||||
|
path.display(),
|
||||||
|
decoded.len()
|
||||||
|
);
|
||||||
let mut out = [0u8; 32];
|
let mut out = [0u8; 32];
|
||||||
out.copy_from_slice(&decoded[..32]);
|
out.copy_from_slice(&decoded[..32]);
|
||||||
return Ok(out);
|
return Ok(out);
|
||||||
@@ -76,10 +83,11 @@ pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> {
|
|||||||
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||||
}
|
}
|
||||||
let secret = crypto::random_32();
|
let secret = crypto::random_32();
|
||||||
let mut file = fs::OpenOptions::new()
|
let mut options = fs::OpenOptions::new();
|
||||||
.create_new(true)
|
options.create_new(true).write(true);
|
||||||
.write(true)
|
#[cfg(unix)]
|
||||||
.mode(0o600)
|
options.mode(0o600);
|
||||||
|
let mut file = options
|
||||||
.open(&path)
|
.open(&path)
|
||||||
.with_context(|| format!("create {}", path.display()))?;
|
.with_context(|| format!("create {}", path.display()))?;
|
||||||
file.write_all(URL_SAFE_NO_PAD.encode(secret).as_bytes())?;
|
file.write_all(URL_SAFE_NO_PAD.encode(secret).as_bytes())?;
|
||||||
@@ -87,6 +95,7 @@ pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> {
|
|||||||
Ok(secret)
|
Ok(secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_bootstrap(
|
pub fn build_bootstrap(
|
||||||
config: &ServerConfig,
|
config: &ServerConfig,
|
||||||
secret: &[u8; 32],
|
secret: &[u8; 32],
|
||||||
@@ -153,6 +162,7 @@ pub fn build_bootstrap(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn attach_token(
|
pub fn attach_token(
|
||||||
secret: &[u8; 32],
|
secret: &[u8; 32],
|
||||||
user: &str,
|
user: &str,
|
||||||
@@ -198,6 +208,7 @@ pub fn verify_bootstrap(resp: &BootstrapResponse, secret: &[u8; 32]) -> Result<b
|
|||||||
Ok(expected == resp.attach_token)
|
Ok(expected == resp.attach_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_attach_ticket(
|
pub fn build_attach_ticket(
|
||||||
secret: &[u8; 32],
|
secret: &[u8; 32],
|
||||||
server_id: [u8; 32],
|
server_id: [u8; 32],
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ use dosh::config::load_server_config;
|
|||||||
use dosh::native::{host_public_key, host_public_key_line, load_or_create_host_key};
|
use dosh::native::{host_public_key, host_public_key_line, load_or_create_host_key};
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "dosh-auth",
|
||||||
|
version = dosh::build_info::VERSION,
|
||||||
|
long_version = dosh::build_info::LONG_VERSION
|
||||||
|
)]
|
||||||
struct Args {
|
struct Args {
|
||||||
#[arg(long, default_value_t = 1)]
|
#[arg(long, default_value_t = 1)]
|
||||||
protocol: u8,
|
protocol: u8,
|
||||||
|
|||||||
+500
-83
@@ -2,6 +2,8 @@ use anyhow::{Context, Result, anyhow};
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
|
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::fs;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -9,7 +11,11 @@ use std::sync::mpsc;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(name = "dosh-bench")]
|
#[command(
|
||||||
|
name = "dosh-bench",
|
||||||
|
version = dosh::build_info::VERSION,
|
||||||
|
long_version = dosh::build_info::LONG_VERSION
|
||||||
|
)]
|
||||||
struct Args {
|
struct Args {
|
||||||
#[arg(long, default_value = "local")]
|
#[arg(long, default_value = "local")]
|
||||||
server: String,
|
server: String,
|
||||||
@@ -25,6 +31,15 @@ struct Args {
|
|||||||
iterations: usize,
|
iterations: usize,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
local_auth: bool,
|
local_auth: bool,
|
||||||
|
/// Benchmark native cold auth (no cache, native handshake) terminal-ready time.
|
||||||
|
#[arg(long)]
|
||||||
|
cold_native: bool,
|
||||||
|
/// Benchmark cached attach-ticket terminal-ready time (warms the cache first).
|
||||||
|
#[arg(long)]
|
||||||
|
cached_ticket: bool,
|
||||||
|
/// Benchmark UDP resume terminal-ready time (warms the cache first).
|
||||||
|
#[arg(long)]
|
||||||
|
resume: bool,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
client: Option<PathBuf>,
|
client: Option<PathBuf>,
|
||||||
#[arg(long, default_value = "~/.local/bin/dosh-auth")]
|
#[arg(long, default_value = "~/.local/bin/dosh-auth")]
|
||||||
@@ -51,6 +66,18 @@ struct Args {
|
|||||||
mosh_server_command: String,
|
mosh_server_command: String,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
mosh_port: Option<String>,
|
mosh_port: Option<String>,
|
||||||
|
/// Emit machine-readable JSON (one object per metric, with raw samples).
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
/// Emit a publishable Markdown benchmark report.
|
||||||
|
#[arg(long)]
|
||||||
|
markdown: bool,
|
||||||
|
/// Write the report to a file instead of stdout.
|
||||||
|
#[arg(long)]
|
||||||
|
output: Option<PathBuf>,
|
||||||
|
/// Optional label printed in summary/JSON output (e.g. machine/OS identifier).
|
||||||
|
#[arg(long)]
|
||||||
|
label: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
assert_ssh_plus_ms: Option<f64>,
|
assert_ssh_plus_ms: Option<f64>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -59,12 +86,45 @@ struct Args {
|
|||||||
assert_dosh_max_ms: Option<f64>,
|
assert_dosh_max_ms: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One Dosh attach path to benchmark.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum DoshPath {
|
||||||
|
/// `--auth native --no-cache`: full native handshake, no cache.
|
||||||
|
ColdNative,
|
||||||
|
/// Cached attach-ticket fast path (requires a warmed cache).
|
||||||
|
CachedTicket,
|
||||||
|
/// UDP resume fast path (requires a warmed cache + `cache_attach_tickets = false`).
|
||||||
|
Resume,
|
||||||
|
/// `--local-auth`: self-contained local bootstrap, no SSH.
|
||||||
|
LocalAuth,
|
||||||
|
/// SSH-bootstrap cold attach (cache controlled by `--no-cache` / `--warm-cache`).
|
||||||
|
SshBootstrap,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DoshPath {
|
||||||
|
fn metric(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
DoshPath::ColdNative => "dosh_cold_native_ms",
|
||||||
|
DoshPath::CachedTicket => "dosh_cached_attach_ms",
|
||||||
|
DoshPath::Resume => "dosh_resume_ms",
|
||||||
|
DoshPath::LocalAuth => "dosh_local_attach_ms",
|
||||||
|
DoshPath::SshBootstrap => "dosh_attach_ms",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this path consumes a warmed credential cache.
|
||||||
|
fn needs_warm(self) -> bool {
|
||||||
|
matches!(self, DoshPath::CachedTicket | DoshPath::Resume)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
let client = args.client.clone().unwrap_or_else(default_client_path);
|
let client = args.client.clone().unwrap_or_else(default_client_path);
|
||||||
let mut ssh_times = Vec::new();
|
if args.no_cache && args.warm_cache {
|
||||||
let mut dosh_times = Vec::new();
|
return Err(anyhow!("--warm-cache cannot be used with --no-cache"));
|
||||||
let mut mosh_times = Vec::new();
|
}
|
||||||
|
|
||||||
let generated_control_path = if args.controlmaster {
|
let generated_control_path = if args.controlmaster {
|
||||||
Some(std::env::temp_dir().join(format!("dosh-bench-control-{}", std::process::id())))
|
Some(std::env::temp_dir().join(format!("dosh-bench-control-{}", std::process::id())))
|
||||||
} else {
|
} else {
|
||||||
@@ -78,96 +138,115 @@ fn main() -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
if args.no_cache && args.warm_cache {
|
|
||||||
return Err(anyhow!("--warm-cache cannot be used with --no-cache"));
|
// Resolve which Dosh paths to benchmark. Explicit path flags select an
|
||||||
}
|
// explicit matrix; otherwise fall back to the legacy single-path behavior
|
||||||
let dosh_label = if args.warm_cache {
|
// so existing callers (CI docker scripts) keep working unchanged.
|
||||||
let _ = time_dosh_attach(&client, &args, control_path)?;
|
let explicit_paths = explicit_dosh_paths(&args);
|
||||||
"dosh_cached_attach_ms"
|
let dosh_paths = if explicit_paths.is_empty() {
|
||||||
|
vec![legacy_dosh_path(&args)]
|
||||||
} else {
|
} else {
|
||||||
"dosh_attach_ms"
|
explicit_paths
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut results: Vec<MetricSamples> = Vec::new();
|
||||||
|
|
||||||
|
// SSH baseline (shared across the matrix; the comparison is per-iteration
|
||||||
|
// ssh-true vs the dosh path startup that replaces it).
|
||||||
|
let run_ssh = !args.local_auth && !args.skip_ssh_baseline && !dosh_only(&dosh_paths);
|
||||||
|
if run_ssh {
|
||||||
|
let mut ssh_times = Vec::new();
|
||||||
for _ in 0..args.iterations.max(1) {
|
for _ in 0..args.iterations.max(1) {
|
||||||
if !args.local_auth && !args.skip_ssh_baseline {
|
|
||||||
let mut ssh = Command::new("ssh");
|
let mut ssh = Command::new("ssh");
|
||||||
add_ssh_options(&mut ssh, &args, control_path);
|
add_ssh_options(&mut ssh, &args, control_path);
|
||||||
ssh.arg(&args.server).arg("true");
|
ssh.arg(&args.server).arg("true");
|
||||||
ssh_times.push(time_command(&mut ssh)?);
|
ssh_times.push(time_command(&mut ssh)?);
|
||||||
}
|
}
|
||||||
|
results.push(MetricSamples::new("ssh_true_ms", ssh_times));
|
||||||
|
}
|
||||||
|
|
||||||
dosh_times.push(time_dosh_attach(&client, &args, control_path)?);
|
for path in &dosh_paths {
|
||||||
|
if path.needs_warm() {
|
||||||
|
// Prime the cache with one attach so the fast path has credentials.
|
||||||
|
let _ = time_dosh_attach(&client, &args, *path, control_path, true)?;
|
||||||
|
}
|
||||||
|
let mut samples = Vec::new();
|
||||||
|
for _ in 0..args.iterations.max(1) {
|
||||||
|
samples.push(time_dosh_attach(
|
||||||
|
&client,
|
||||||
|
&args,
|
||||||
|
*path,
|
||||||
|
control_path,
|
||||||
|
false,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
results.push(MetricSamples::new(path.metric(), samples));
|
||||||
|
}
|
||||||
|
|
||||||
if args.include_mosh {
|
if args.include_mosh {
|
||||||
|
let mut mosh_times = Vec::new();
|
||||||
|
for _ in 0..args.iterations.max(1) {
|
||||||
mosh_times.push(time_mosh_in_pty(&args)?);
|
mosh_times.push(time_mosh_in_pty(&args)?);
|
||||||
}
|
}
|
||||||
|
results.push(MetricSamples::new("mosh_start_true_ms", mosh_times));
|
||||||
|
}
|
||||||
|
|
||||||
|
let report = if args.json {
|
||||||
|
render_json(&args, &results)
|
||||||
|
} else if args.markdown {
|
||||||
|
render_markdown(&args, &results)
|
||||||
|
} else {
|
||||||
|
render_table(&args, &results)
|
||||||
|
};
|
||||||
|
write_report(&args, &report)?;
|
||||||
|
|
||||||
|
run_assertions(&args, &results)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paths explicitly requested via flags.
|
||||||
|
fn explicit_dosh_paths(args: &Args) -> Vec<DoshPath> {
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
if args.cold_native {
|
||||||
|
paths.push(DoshPath::ColdNative);
|
||||||
|
}
|
||||||
|
if args.cached_ticket {
|
||||||
|
paths.push(DoshPath::CachedTicket);
|
||||||
|
}
|
||||||
|
if args.resume {
|
||||||
|
paths.push(DoshPath::Resume);
|
||||||
|
}
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single path implied by legacy flags when no explicit path is requested.
|
||||||
|
fn legacy_dosh_path(args: &Args) -> DoshPath {
|
||||||
|
if args.local_auth {
|
||||||
|
DoshPath::LocalAuth
|
||||||
|
} else if args.warm_cache {
|
||||||
|
DoshPath::CachedTicket
|
||||||
|
} else {
|
||||||
|
DoshPath::SshBootstrap
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ssh_times.is_empty() {
|
/// True when none of the selected paths needs an SSH baseline comparison
|
||||||
println!(
|
/// (i.e. all are local-auth or cache fast paths driven without SSH).
|
||||||
"ssh_true_ms avg={:.2} samples={:?}",
|
fn dosh_only(paths: &[DoshPath]) -> bool {
|
||||||
avg_ms(&ssh_times),
|
paths.iter().all(|p| {
|
||||||
ssh_times
|
matches!(
|
||||||
);
|
p,
|
||||||
}
|
DoshPath::LocalAuth | DoshPath::CachedTicket | DoshPath::Resume
|
||||||
println!(
|
)
|
||||||
"{dosh_label} avg={:.2} samples={:?}",
|
})
|
||||||
avg_ms(&dosh_times),
|
|
||||||
dosh_times
|
|
||||||
);
|
|
||||||
if !mosh_times.is_empty() {
|
|
||||||
println!(
|
|
||||||
"mosh_start_true_ms avg={:.2} samples={:?}",
|
|
||||||
avg_ms(&mosh_times),
|
|
||||||
mosh_times
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(margin) = args.assert_ssh_plus_ms {
|
|
||||||
if ssh_times.is_empty() {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"--assert-ssh-plus-ms requires non-local SSH benchmark"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let ssh_avg = avg_ms(&ssh_times);
|
|
||||||
let dosh_avg = avg_ms(&dosh_times);
|
|
||||||
if dosh_avg > ssh_avg + margin {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"dosh attach avg {dosh_avg:.2}ms exceeded ssh avg {ssh_avg:.2}ms + {margin:.2}ms"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
println!("gate ok: dosh avg {dosh_avg:.2}ms <= ssh avg {ssh_avg:.2}ms + {margin:.2}ms");
|
|
||||||
}
|
|
||||||
if let Some(margin) = args.assert_mosh_minus_ms {
|
|
||||||
if mosh_times.is_empty() {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"--assert-mosh-minus-ms requires --include-mosh benchmark"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let dosh_avg = avg_ms(&dosh_times);
|
|
||||||
let mosh_avg = avg_ms(&mosh_times);
|
|
||||||
if dosh_avg + margin > mosh_avg {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"dosh attach avg {dosh_avg:.2}ms was not at least {margin:.2}ms faster than mosh avg {mosh_avg:.2}ms"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
println!("gate ok: dosh avg {dosh_avg:.2}ms + {margin:.2}ms <= mosh avg {mosh_avg:.2}ms");
|
|
||||||
}
|
|
||||||
if let Some(max_ms) = args.assert_dosh_max_ms {
|
|
||||||
let dosh_avg = avg_ms(&dosh_times);
|
|
||||||
if dosh_avg > max_ms {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{dosh_label} avg {dosh_avg:.2}ms exceeded max {max_ms:.2}ms"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
println!("gate ok: {dosh_label} avg {dosh_avg:.2}ms <= {max_ms:.2}ms");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn time_dosh_attach(
|
fn time_dosh_attach(
|
||||||
client: &PathBuf,
|
client: &PathBuf,
|
||||||
args: &Args,
|
args: &Args,
|
||||||
|
path: DoshPath,
|
||||||
control_path: Option<&PathBuf>,
|
control_path: Option<&PathBuf>,
|
||||||
|
warm: bool,
|
||||||
) -> Result<Duration> {
|
) -> Result<Duration> {
|
||||||
let mut cmd = Command::new(client);
|
let mut cmd = Command::new(client);
|
||||||
cmd.arg("--attach-only")
|
cmd.arg("--attach-only")
|
||||||
@@ -178,16 +257,55 @@ fn time_dosh_attach(
|
|||||||
if let Some(host) = &args.dosh_host {
|
if let Some(host) = &args.dosh_host {
|
||||||
cmd.arg("--dosh-host").arg(host);
|
cmd.arg("--dosh-host").arg(host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match path {
|
||||||
|
DoshPath::LocalAuth => {
|
||||||
|
cmd.arg("--local-auth");
|
||||||
|
// While warming, write the cache; measured runs honor --no-cache.
|
||||||
|
if args.no_cache && !warm {
|
||||||
|
cmd.arg("--no-cache");
|
||||||
|
}
|
||||||
|
cmd.arg(&args.server);
|
||||||
|
}
|
||||||
|
DoshPath::CachedTicket | DoshPath::Resume => {
|
||||||
|
// Fast paths must read the warmed cache, so never pass --no-cache here.
|
||||||
|
// Whether the cache uses tickets vs resume is decided by the client
|
||||||
|
// config (`cache_attach_tickets`) the harness wrote into HOME.
|
||||||
if args.local_auth {
|
if args.local_auth {
|
||||||
cmd.arg("--local-auth").arg(&args.server);
|
cmd.arg("--local-auth");
|
||||||
} else {
|
}
|
||||||
|
add_bootstrap_args(&mut cmd, args, control_path);
|
||||||
|
cmd.arg(&args.server);
|
||||||
|
}
|
||||||
|
DoshPath::ColdNative => {
|
||||||
|
cmd.arg("--auth").arg("native");
|
||||||
|
// Cold path: never use a warmed cache.
|
||||||
|
cmd.arg("--no-cache");
|
||||||
|
add_bootstrap_args(&mut cmd, args, control_path);
|
||||||
|
cmd.arg(&args.server);
|
||||||
|
}
|
||||||
|
DoshPath::SshBootstrap => {
|
||||||
|
add_bootstrap_args(&mut cmd, args, control_path);
|
||||||
|
// Cold SSH bootstrap honors --no-cache on measured runs.
|
||||||
|
if args.no_cache && !warm {
|
||||||
|
cmd.arg("--no-cache");
|
||||||
|
}
|
||||||
|
cmd.arg(&args.server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time_command(&mut cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append the SSH bootstrap arguments shared by the non-local paths. Native
|
||||||
|
/// auth reuses the same SSH key/known-hosts/port plumbing.
|
||||||
|
fn add_bootstrap_args(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) {
|
||||||
|
if args.local_auth {
|
||||||
|
return;
|
||||||
|
}
|
||||||
cmd.arg("--ssh-port")
|
cmd.arg("--ssh-port")
|
||||||
.arg(args.ssh_port.to_string())
|
.arg(args.ssh_port.to_string())
|
||||||
.arg("--ssh-auth-command")
|
.arg("--ssh-auth-command")
|
||||||
.arg(&args.ssh_auth_command);
|
.arg(&args.ssh_auth_command);
|
||||||
if args.no_cache {
|
|
||||||
cmd.arg("--no-cache");
|
|
||||||
}
|
|
||||||
if let Some(key) = &args.ssh_key {
|
if let Some(key) = &args.ssh_key {
|
||||||
cmd.arg("--ssh-key").arg(key);
|
cmd.arg("--ssh-key").arg(key);
|
||||||
}
|
}
|
||||||
@@ -197,9 +315,6 @@ fn time_dosh_attach(
|
|||||||
if let Some(control_path) = control_path {
|
if let Some(control_path) = control_path {
|
||||||
cmd.arg("--ssh-control-path").arg(control_path);
|
cmd.arg("--ssh-control-path").arg(control_path);
|
||||||
}
|
}
|
||||||
cmd.arg(&args.server);
|
|
||||||
}
|
|
||||||
time_command(&mut cmd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_ssh_options(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) {
|
fn add_ssh_options(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) {
|
||||||
@@ -381,9 +496,245 @@ fn time_command(cmd: &mut Command) -> Result<Duration> {
|
|||||||
Ok(start.elapsed())
|
Ok(start.elapsed())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn avg_ms(samples: &[Duration]) -> f64 {
|
/// Per-metric samples with summary statistics.
|
||||||
let total: f64 = samples.iter().map(|d| d.as_secs_f64() * 1000.0).sum();
|
struct MetricSamples {
|
||||||
total / samples.len() as f64
|
name: &'static str,
|
||||||
|
samples: Vec<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MetricSamples {
|
||||||
|
fn new(name: &'static str, samples: Vec<Duration>) -> Self {
|
||||||
|
Self { name, samples }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ms(&self) -> Vec<f64> {
|
||||||
|
self.samples
|
||||||
|
.iter()
|
||||||
|
.map(|d| d.as_secs_f64() * 1000.0)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stats(&self) -> Stats {
|
||||||
|
Stats::from_ms(&self.ms())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary statistics for a set of millisecond samples.
|
||||||
|
struct Stats {
|
||||||
|
count: usize,
|
||||||
|
min: f64,
|
||||||
|
median: f64,
|
||||||
|
p95: f64,
|
||||||
|
mean: f64,
|
||||||
|
max: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stats {
|
||||||
|
fn from_ms(values: &[f64]) -> Self {
|
||||||
|
if values.is_empty() {
|
||||||
|
return Self {
|
||||||
|
count: 0,
|
||||||
|
min: 0.0,
|
||||||
|
median: 0.0,
|
||||||
|
p95: 0.0,
|
||||||
|
mean: 0.0,
|
||||||
|
max: 0.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let mut sorted = values.to_vec();
|
||||||
|
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
let count = sorted.len();
|
||||||
|
let mean = sorted.iter().sum::<f64>() / count as f64;
|
||||||
|
Self {
|
||||||
|
count,
|
||||||
|
min: sorted[0],
|
||||||
|
median: percentile(&sorted, 50.0),
|
||||||
|
p95: percentile(&sorted, 95.0),
|
||||||
|
mean,
|
||||||
|
max: sorted[count - 1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linear-interpolated percentile over a pre-sorted slice.
|
||||||
|
fn percentile(sorted: &[f64], pct: f64) -> f64 {
|
||||||
|
if sorted.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
if sorted.len() == 1 {
|
||||||
|
return sorted[0];
|
||||||
|
}
|
||||||
|
let rank = (pct / 100.0) * (sorted.len() - 1) as f64;
|
||||||
|
let lo = rank.floor() as usize;
|
||||||
|
let hi = rank.ceil() as usize;
|
||||||
|
if lo == hi {
|
||||||
|
sorted[lo]
|
||||||
|
} else {
|
||||||
|
let frac = rank - lo as f64;
|
||||||
|
sorted[lo] + (sorted[hi] - sorted[lo]) * frac
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_table(args: &Args, results: &[MetricSamples]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
if let Some(label) = &args.label {
|
||||||
|
let _ = writeln!(out, "# label: {label}");
|
||||||
|
}
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"{:<24} {:>6} {:>9} {:>9} {:>9} {:>9} {:>9}",
|
||||||
|
"metric", "n", "min", "median", "p95", "mean", "max"
|
||||||
|
);
|
||||||
|
for metric in results {
|
||||||
|
let s = metric.stats();
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"{:<24} {:>6} {:>9.2} {:>9.2} {:>9.2} {:>9.2} {:>9.2}",
|
||||||
|
metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Raw per-iteration samples, so published numbers can include raw data.
|
||||||
|
for metric in results {
|
||||||
|
let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect();
|
||||||
|
let _ = writeln!(out, "{} samples_ms=[{}]", metric.name, ms.join(", "));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_json(args: &Args, results: &[MetricSamples]) -> String {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for metric in results {
|
||||||
|
let s = metric.stats();
|
||||||
|
let samples: Vec<String> = metric.ms().iter().map(|v| format!("{v:.4}")).collect();
|
||||||
|
entries.push(format!(
|
||||||
|
"{{\"metric\":\"{}\",\"count\":{},\"min\":{:.4},\"median\":{:.4},\"p95\":{:.4},\"mean\":{:.4},\"max\":{:.4},\"samples_ms\":[{}]}}",
|
||||||
|
metric.name,
|
||||||
|
s.count,
|
||||||
|
s.min,
|
||||||
|
s.median,
|
||||||
|
s.p95,
|
||||||
|
s.mean,
|
||||||
|
s.max,
|
||||||
|
samples.join(",")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let label = match &args.label {
|
||||||
|
Some(label) => format!("\"{}\"", label.replace('"', "\\\"")),
|
||||||
|
None => "null".to_string(),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{{\"label\":{label},\"iterations\":{},\"metrics\":[{}]}}",
|
||||||
|
args.iterations.max(1),
|
||||||
|
entries.join(",")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_markdown(args: &Args, results: &[MetricSamples]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let label = args.label.as_deref().unwrap_or("Dosh benchmark");
|
||||||
|
let _ = writeln!(out, "# {label}\n");
|
||||||
|
let _ = writeln!(out, "- server: `{}`", args.server);
|
||||||
|
let _ = writeln!(out, "- iterations: `{}`", args.iterations.max(1));
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"- generated_by: `dosh-bench {}`\n",
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"| metric | n | min ms | median ms | p95 ms | mean ms | max ms |"
|
||||||
|
);
|
||||||
|
let _ = writeln!(out, "|---|---:|---:|---:|---:|---:|---:|");
|
||||||
|
for metric in results {
|
||||||
|
let s = metric.stats();
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"| `{}` | {} | {:.2} | {:.2} | {:.2} | {:.2} | {:.2} |",
|
||||||
|
metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = writeln!(out, "\n## Raw Samples\n");
|
||||||
|
for metric in results {
|
||||||
|
let ms: Vec<String> = metric.ms().iter().map(|v| format!("{v:.2}")).collect();
|
||||||
|
let _ = writeln!(out, "- `{}`: [{}]", metric.name, ms.join(", "));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_report(args: &Args, report: &str) -> Result<()> {
|
||||||
|
if let Some(path) = &args.output {
|
||||||
|
if let Some(parent) = path.parent()
|
||||||
|
&& !parent.as_os_str().is_empty()
|
||||||
|
{
|
||||||
|
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
fs::write(path, report).with_context(|| format!("write {}", path.display()))?;
|
||||||
|
println!("wrote {}", path.display());
|
||||||
|
} else {
|
||||||
|
println!("{report}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metric_mean(results: &[MetricSamples], name: &str) -> Option<f64> {
|
||||||
|
results
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.name == name)
|
||||||
|
.map(|m| m.stats().mean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The headline dosh metric for assertions: prefer cached, then resume, then
|
||||||
|
/// cold native, then ssh-bootstrap, then local-auth.
|
||||||
|
fn primary_dosh(results: &[MetricSamples]) -> Option<(&'static str, f64)> {
|
||||||
|
const ORDER: [&str; 5] = [
|
||||||
|
"dosh_cached_attach_ms",
|
||||||
|
"dosh_resume_ms",
|
||||||
|
"dosh_cold_native_ms",
|
||||||
|
"dosh_attach_ms",
|
||||||
|
"dosh_local_attach_ms",
|
||||||
|
];
|
||||||
|
for name in ORDER {
|
||||||
|
if let Some(mean) = metric_mean(results, name) {
|
||||||
|
return Some((name, mean));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_assertions(args: &Args, results: &[MetricSamples]) -> Result<()> {
|
||||||
|
if let Some(margin) = args.assert_ssh_plus_ms {
|
||||||
|
let ssh = metric_mean(results, "ssh_true_ms")
|
||||||
|
.ok_or_else(|| anyhow!("--assert-ssh-plus-ms requires non-local SSH benchmark"))?;
|
||||||
|
let (name, dosh) =
|
||||||
|
primary_dosh(results).ok_or_else(|| anyhow!("no dosh metric to assert against"))?;
|
||||||
|
if dosh > ssh + margin {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"{name} avg {dosh:.2}ms exceeded ssh avg {ssh:.2}ms + {margin:.2}ms"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
println!("gate ok: {name} avg {dosh:.2}ms <= ssh avg {ssh:.2}ms + {margin:.2}ms");
|
||||||
|
}
|
||||||
|
if let Some(margin) = args.assert_mosh_minus_ms {
|
||||||
|
let mosh = metric_mean(results, "mosh_start_true_ms")
|
||||||
|
.ok_or_else(|| anyhow!("--assert-mosh-minus-ms requires --include-mosh benchmark"))?;
|
||||||
|
let (name, dosh) =
|
||||||
|
primary_dosh(results).ok_or_else(|| anyhow!("no dosh metric to assert against"))?;
|
||||||
|
if dosh + margin > mosh {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"{name} avg {dosh:.2}ms was not at least {margin:.2}ms faster than mosh avg {mosh:.2}ms"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
println!("gate ok: {name} avg {dosh:.2}ms + {margin:.2}ms <= mosh avg {mosh:.2}ms");
|
||||||
|
}
|
||||||
|
if let Some(max_ms) = args.assert_dosh_max_ms {
|
||||||
|
let (name, dosh) =
|
||||||
|
primary_dosh(results).ok_or_else(|| anyhow!("no dosh metric to assert against"))?;
|
||||||
|
if dosh > max_ms {
|
||||||
|
return Err(anyhow!("{name} avg {dosh:.2}ms exceeded max {max_ms:.2}ms"));
|
||||||
|
}
|
||||||
|
println!("gate ok: {name} avg {dosh:.2}ms <= {max_ms:.2}ms");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_client_path() -> PathBuf {
|
fn default_client_path() -> PathBuf {
|
||||||
@@ -392,3 +743,69 @@ fn default_client_path() -> PathBuf {
|
|||||||
.and_then(|path| path.parent().map(|parent| parent.join("dosh-client")))
|
.and_then(|path| path.parent().map(|parent| parent.join("dosh-client")))
|
||||||
.unwrap_or_else(|| PathBuf::from("dosh-client"))
|
.unwrap_or_else(|| PathBuf::from("dosh-client"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn args_for_render() -> Args {
|
||||||
|
Args {
|
||||||
|
server: "local".to_string(),
|
||||||
|
session: "default".to_string(),
|
||||||
|
ssh_port: 22,
|
||||||
|
dosh_port: 50000,
|
||||||
|
dosh_host: None,
|
||||||
|
iterations: 2,
|
||||||
|
local_auth: true,
|
||||||
|
cold_native: false,
|
||||||
|
cached_ticket: false,
|
||||||
|
resume: false,
|
||||||
|
client: None,
|
||||||
|
ssh_auth_command: "~/.local/bin/dosh-auth".to_string(),
|
||||||
|
ssh_key: None,
|
||||||
|
ssh_known_hosts: None,
|
||||||
|
ssh_control_path: None,
|
||||||
|
controlmaster: false,
|
||||||
|
no_cache: false,
|
||||||
|
warm_cache: false,
|
||||||
|
skip_ssh_baseline: true,
|
||||||
|
include_mosh: false,
|
||||||
|
mosh: PathBuf::from("mosh"),
|
||||||
|
mosh_server_command: "mosh-server".to_string(),
|
||||||
|
mosh_port: None,
|
||||||
|
json: false,
|
||||||
|
markdown: true,
|
||||||
|
output: None,
|
||||||
|
label: Some("test host".to_string()),
|
||||||
|
assert_ssh_plus_ms: None,
|
||||||
|
assert_mosh_minus_ms: None,
|
||||||
|
assert_dosh_max_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn markdown_report_contains_summary_and_samples() {
|
||||||
|
let args = args_for_render();
|
||||||
|
let metrics = vec![MetricSamples::new(
|
||||||
|
"dosh_cached_attach_ms",
|
||||||
|
vec![Duration::from_millis(3), Duration::from_millis(5)],
|
||||||
|
)];
|
||||||
|
let report = render_markdown(&args, &metrics);
|
||||||
|
assert!(report.contains("# test host"));
|
||||||
|
assert!(report.contains("| `dosh_cached_attach_ms` | 2 | 3.00"));
|
||||||
|
assert!(report.contains("- `dosh_cached_attach_ms`: [3.00, 5.00]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_report_contains_metrics() {
|
||||||
|
let args = args_for_render();
|
||||||
|
let metrics = vec![MetricSamples::new(
|
||||||
|
"dosh_local_attach_ms",
|
||||||
|
vec![Duration::from_millis(7)],
|
||||||
|
)];
|
||||||
|
let report = render_json(&args, &metrics);
|
||||||
|
assert!(report.contains("\"label\":\"test host\""));
|
||||||
|
assert!(report.contains("\"metric\":\"dosh_local_attach_ms\""));
|
||||||
|
assert!(report.contains("\"samples_ms\":[7.0000]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+5981
-228
File diff suppressed because it is too large
Load Diff
+3047
-290
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
pub const GIT_HASH: &str = env!("DOSH_GIT_HASH");
|
||||||
|
pub const COMMIT_DATE: &str = env!("DOSH_COMMIT_DATE");
|
||||||
|
pub const LONG_VERSION: &str = concat!(
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
" (",
|
||||||
|
env!("DOSH_GIT_HASH"),
|
||||||
|
", ",
|
||||||
|
env!("DOSH_COMMIT_DATE"),
|
||||||
|
")"
|
||||||
|
);
|
||||||
+442
@@ -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"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
-6
@@ -14,6 +14,8 @@ pub struct ServerConfig {
|
|||||||
pub allow_attach_tickets: bool,
|
pub allow_attach_tickets: bool,
|
||||||
pub client_timeout_secs: u64,
|
pub client_timeout_secs: u64,
|
||||||
pub retransmit_window: usize,
|
pub retransmit_window: usize,
|
||||||
|
#[serde(default = "default_output_frame_interval_ms")]
|
||||||
|
pub output_frame_interval_ms: u64,
|
||||||
pub default_input_mode: String,
|
pub default_input_mode: String,
|
||||||
pub prewarm_sessions: Vec<String>,
|
pub prewarm_sessions: Vec<String>,
|
||||||
pub create_on_attach: bool,
|
pub create_on_attach: bool,
|
||||||
@@ -28,6 +30,14 @@ pub struct ServerConfig {
|
|||||||
pub authorized_keys: Vec<String>,
|
pub authorized_keys: Vec<String>,
|
||||||
#[serde(default = "default_native_auth_rate_limit_per_minute")]
|
#[serde(default = "default_native_auth_rate_limit_per_minute")]
|
||||||
pub native_auth_rate_limit_per_minute: u32,
|
pub native_auth_rate_limit_per_minute: u32,
|
||||||
|
/// Rotate a client's transport traffic key after this many packets in the
|
||||||
|
/// current epoch (spec §11). `0` disables the packet-count trigger.
|
||||||
|
#[serde(default = "default_rekey_after_packets")]
|
||||||
|
pub rekey_after_packets: u64,
|
||||||
|
/// Rotate a client's transport traffic key after this many wall-clock seconds
|
||||||
|
/// in the current epoch (spec §11). `0` disables the time trigger.
|
||||||
|
#[serde(default = "default_rekey_after_secs")]
|
||||||
|
pub rekey_after_secs: u64,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub allow_tcp_forwarding: bool,
|
pub allow_tcp_forwarding: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -36,8 +46,16 @@ pub struct ServerConfig {
|
|||||||
pub allow_remote_non_loopback_bind: bool,
|
pub allow_remote_non_loopback_bind: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub allow_agent_forwarding: bool,
|
pub allow_agent_forwarding: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub allow_file_transfer: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub allow_exec_command: bool,
|
||||||
#[serde(default = "default_accept_env")]
|
#[serde(default = "default_accept_env")]
|
||||||
pub accept_env: Vec<String>,
|
pub accept_env: Vec<String>,
|
||||||
|
/// Run terminal sessions under per-session holder processes so shells
|
||||||
|
/// survive a quick `dosh-server` restart and clients can reconnect.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub persist_sessions: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ServerConfig {
|
impl Default for ServerConfig {
|
||||||
@@ -49,8 +67,9 @@ impl Default for ServerConfig {
|
|||||||
auth_ttl_secs: 30,
|
auth_ttl_secs: 30,
|
||||||
attach_ticket_ttl_secs: 3600,
|
attach_ticket_ttl_secs: 3600,
|
||||||
allow_attach_tickets: true,
|
allow_attach_tickets: true,
|
||||||
client_timeout_secs: 86400,
|
client_timeout_secs: 2_592_000,
|
||||||
retransmit_window: 256,
|
retransmit_window: 256,
|
||||||
|
output_frame_interval_ms: default_output_frame_interval_ms(),
|
||||||
default_input_mode: "read-write".to_string(),
|
default_input_mode: "read-write".to_string(),
|
||||||
prewarm_sessions: vec!["default".to_string()],
|
prewarm_sessions: vec!["default".to_string()],
|
||||||
create_on_attach: true,
|
create_on_attach: true,
|
||||||
@@ -61,11 +80,16 @@ impl Default for ServerConfig {
|
|||||||
host_key: default_host_key(),
|
host_key: default_host_key(),
|
||||||
authorized_keys: default_authorized_keys(),
|
authorized_keys: default_authorized_keys(),
|
||||||
native_auth_rate_limit_per_minute: default_native_auth_rate_limit_per_minute(),
|
native_auth_rate_limit_per_minute: default_native_auth_rate_limit_per_minute(),
|
||||||
|
rekey_after_packets: default_rekey_after_packets(),
|
||||||
|
rekey_after_secs: default_rekey_after_secs(),
|
||||||
allow_tcp_forwarding: true,
|
allow_tcp_forwarding: true,
|
||||||
allow_remote_forwarding: false,
|
allow_remote_forwarding: false,
|
||||||
allow_remote_non_loopback_bind: false,
|
allow_remote_non_loopback_bind: false,
|
||||||
allow_agent_forwarding: false,
|
allow_agent_forwarding: false,
|
||||||
|
allow_file_transfer: true,
|
||||||
|
allow_exec_command: true,
|
||||||
accept_env: default_accept_env(),
|
accept_env: default_accept_env(),
|
||||||
|
persist_sessions: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,8 +106,12 @@ pub struct ClientConfig {
|
|||||||
pub default_session: String,
|
pub default_session: String,
|
||||||
pub reconnect_timeout_secs: u64,
|
pub reconnect_timeout_secs: u64,
|
||||||
pub view_only: bool,
|
pub view_only: bool,
|
||||||
#[serde(default)]
|
#[serde(default = "default_true")]
|
||||||
pub predict: bool,
|
pub predict: bool,
|
||||||
|
/// Prediction display policy: "off", "experimental" (adaptive, the default),
|
||||||
|
/// or "always". Controls when speculative local echo is shown.
|
||||||
|
#[serde(default = "default_predict_mode")]
|
||||||
|
pub predict_mode: String,
|
||||||
pub cache_attach_tickets: bool,
|
pub cache_attach_tickets: bool,
|
||||||
pub credential_cache: String,
|
pub credential_cache: String,
|
||||||
#[serde(default = "default_auth_preference")]
|
#[serde(default = "default_auth_preference")]
|
||||||
@@ -100,10 +128,23 @@ pub struct ClientConfig {
|
|||||||
pub use_ssh_agent: bool,
|
pub use_ssh_agent: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub forward_agent: bool,
|
pub forward_agent: bool,
|
||||||
|
/// Show a non-destructive, mosh-style status line on the bottom screen row
|
||||||
|
/// when UDP packets stop arriving ("[dosh] last contact Ns ago …"). Drawn
|
||||||
|
/// with save/restore cursor so it never moves the app cursor or corrupts a
|
||||||
|
/// full-screen TUI, and cleared the instant packets resume. Default on.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub disconnect_status: bool,
|
||||||
|
#[serde(default = "default_escape_key")]
|
||||||
|
pub escape_key: String,
|
||||||
#[serde(default = "default_send_env")]
|
#[serde(default = "default_send_env")]
|
||||||
pub send_env: Vec<String>,
|
pub send_env: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub set_env: HashMap<String, String>,
|
pub set_env: HashMap<String, String>,
|
||||||
|
/// Optional client-side command extensions. These are just shell command
|
||||||
|
/// templates expanded into the remote session's startup input; Dosh does not
|
||||||
|
/// depend on the tools they name.
|
||||||
|
#[serde(default)]
|
||||||
|
pub extensions: HashMap<String, CommandExtension>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@@ -119,6 +160,22 @@ pub struct HostConfig {
|
|||||||
pub send_env: Option<Vec<String>>,
|
pub send_env: Option<Vec<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub set_env: HashMap<String, String>,
|
pub set_env: HashMap<String, String>,
|
||||||
|
/// Per-host extension overrides. A host can replace a global extension or set
|
||||||
|
/// `disabled = true` to opt out of it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub extensions: HashMap<String, CommandExtension>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct CommandExtension {
|
||||||
|
/// Remote shell command template. `{args}` expands to shell-quoted extra
|
||||||
|
/// words after the extension name. When absent, extra args are appended.
|
||||||
|
#[serde(default)]
|
||||||
|
pub command: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub disabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@@ -140,7 +197,8 @@ impl Default for ClientConfig {
|
|||||||
default_session: "new".to_string(),
|
default_session: "new".to_string(),
|
||||||
reconnect_timeout_secs: 5,
|
reconnect_timeout_secs: 5,
|
||||||
view_only: false,
|
view_only: false,
|
||||||
predict: false,
|
predict: true,
|
||||||
|
predict_mode: default_predict_mode(),
|
||||||
cache_attach_tickets: true,
|
cache_attach_tickets: true,
|
||||||
credential_cache: "~/.local/share/dosh/credentials".to_string(),
|
credential_cache: "~/.local/share/dosh/credentials".to_string(),
|
||||||
auth_preference: default_auth_preference(),
|
auth_preference: default_auth_preference(),
|
||||||
@@ -150,8 +208,11 @@ impl Default for ClientConfig {
|
|||||||
identity_files: default_identity_files(),
|
identity_files: default_identity_files(),
|
||||||
use_ssh_agent: true,
|
use_ssh_agent: true,
|
||||||
forward_agent: false,
|
forward_agent: false,
|
||||||
|
disconnect_status: true,
|
||||||
|
escape_key: default_escape_key(),
|
||||||
send_env: default_send_env(),
|
send_env: default_send_env(),
|
||||||
set_env: HashMap::new(),
|
set_env: HashMap::new(),
|
||||||
|
extensions: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,6 +236,22 @@ fn default_native_auth_rate_limit_per_minute() -> u32 {
|
|||||||
30
|
30
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_output_frame_interval_ms() -> u64 {
|
||||||
|
16
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_rekey_after_packets() -> u64 {
|
||||||
|
// Rotate well before any AEAD nonce-reuse concern; ChaCha20-Poly1305 with a
|
||||||
|
// per-direction monotonic seq is safe far beyond this, but a bounded epoch
|
||||||
|
// keeps forward-secrecy windows small.
|
||||||
|
100_000
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_rekey_after_secs() -> u64 {
|
||||||
|
// One hour per epoch by default.
|
||||||
|
3600
|
||||||
|
}
|
||||||
|
|
||||||
fn default_auth_preference() -> String {
|
fn default_auth_preference() -> String {
|
||||||
"native,ssh".to_string()
|
"native,ssh".to_string()
|
||||||
}
|
}
|
||||||
@@ -183,10 +260,18 @@ fn default_native_auth_timeout_ms() -> u64 {
|
|||||||
700
|
700
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_predict_mode() -> String {
|
||||||
|
"experimental".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_known_hosts() -> String {
|
fn default_known_hosts() -> String {
|
||||||
"~/.config/dosh/known_hosts".to_string()
|
"~/.config/dosh/known_hosts".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_escape_key() -> String {
|
||||||
|
"^]".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_identity_files() -> Vec<String> {
|
fn default_identity_files() -> Vec<String> {
|
||||||
vec!["~/.ssh/id_ed25519".to_string()]
|
vec!["~/.ssh/id_ed25519".to_string()]
|
||||||
}
|
}
|
||||||
@@ -232,10 +317,10 @@ pub fn load_hosts_config(path: Option<PathBuf>) -> Result<HostsConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn expand_tilde(path: &str) -> PathBuf {
|
pub fn expand_tilde(path: &str) -> PathBuf {
|
||||||
if let Some(rest) = path.strip_prefix("~/") {
|
if let Some(rest) = path.strip_prefix("~/")
|
||||||
if let Some(home) = dirs::home_dir() {
|
&& let Some(home) = dirs::home_dir()
|
||||||
|
{
|
||||||
return home.join(rest);
|
return home.join(rest);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
PathBuf::from(path)
|
PathBuf::from(path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -1,7 +1,26 @@
|
|||||||
|
//! Dosh is an encrypted UDP transport for remote terminals and embeddable Rust
|
||||||
|
//! application streams.
|
||||||
|
//!
|
||||||
|
//! Use [`client::DoshClient`] and [`server::DoshServer`] when you want Dosh to
|
||||||
|
//! handle native authentication, host key checks, roaming, keepalives, reliable
|
||||||
|
//! streams, and service routing. Use [`transport::DoshTransport`] only when an
|
||||||
|
//! application already owns session establishment and wants direct access to the
|
||||||
|
//! encrypted stream transport.
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod build_info;
|
||||||
|
pub mod client;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
|
pub mod exec_service;
|
||||||
|
pub mod file_transfer;
|
||||||
pub mod native;
|
pub mod native;
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub mod persist;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
|
#[cfg(unix)]
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
|
pub mod server;
|
||||||
pub mod ssh_agent;
|
pub mod ssh_agent;
|
||||||
|
pub mod transport;
|
||||||
|
pub mod udp;
|
||||||
|
|||||||
+442
-48
@@ -5,17 +5,20 @@ use anyhow::{Context, Result, bail};
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
||||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||||
|
use rsa::traits::PublicKeyParts;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use signature::{SignatureEncoding, Signer as SignatureSigner, Verifier as SignatureVerifier};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
|
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
|
||||||
|
|
||||||
pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519";
|
pub const HOST_KEY_ALGORITHM: &str = "dosh-ed25519";
|
||||||
pub const NATIVE_PROTOCOL_VERSION: u8 = 1;
|
pub const NATIVE_PROTOCOL_VERSION: u8 = 2;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct HostPublicKey {
|
pub struct HostPublicKey {
|
||||||
@@ -91,6 +94,12 @@ pub enum ForwardingKind {
|
|||||||
Local,
|
Local,
|
||||||
Remote,
|
Remote,
|
||||||
Dynamic,
|
Dynamic,
|
||||||
|
/// SSH-agent forwarding: the client opts in and, if the server policy allows
|
||||||
|
/// (`allow_agent_forwarding`), the server exports a proxy unix socket as
|
||||||
|
/// `SSH_AUTH_SOCK` in the remote session and tunnels each connection back to
|
||||||
|
/// the client's local agent over a Dosh stream. Added at the end of the enum
|
||||||
|
/// so bincode discriminants for the existing variants are unchanged.
|
||||||
|
Agent,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -145,10 +154,11 @@ pub fn load_or_create_host_key(config: &ServerConfig) -> Result<SigningKey> {
|
|||||||
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||||
}
|
}
|
||||||
let bytes = crypto::random_32();
|
let bytes = crypto::random_32();
|
||||||
let mut file = fs::OpenOptions::new()
|
let mut options = fs::OpenOptions::new();
|
||||||
.create_new(true)
|
options.create_new(true).write(true);
|
||||||
.write(true)
|
#[cfg(unix)]
|
||||||
.mode(0o600)
|
options.mode(0o600);
|
||||||
|
let mut file = options
|
||||||
.open(&path)
|
.open(&path)
|
||||||
.with_context(|| format!("create {}", path.display()))?;
|
.with_context(|| format!("create {}", path.display()))?;
|
||||||
file.write_all(URL_SAFE_NO_PAD.encode(bytes).as_bytes())?;
|
file.write_all(URL_SAFE_NO_PAD.encode(bytes).as_bytes())?;
|
||||||
@@ -248,17 +258,55 @@ pub fn sign_server_hello(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Error raised when a native handshake peer speaks a different protocol version.
|
||||||
|
///
|
||||||
|
/// Carries the peer's advertised version so callers can render an actionable,
|
||||||
|
/// named message ("upgrade dosh") rather than letting a mismatch surface as an
|
||||||
|
/// opaque decrypt failure or a silent timeout. The `Display` text deliberately
|
||||||
|
/// embeds [`crate::protocol::VERSION_MISMATCH_REASON`] so the server's reject and
|
||||||
|
/// the client's local error read the same way.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ProtocolVersionMismatch {
|
||||||
|
pub local: u8,
|
||||||
|
pub remote: u8,
|
||||||
|
pub peer: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ProtocolVersionMismatch {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{} ({} speaks native protocol v{}, this build speaks v{})",
|
||||||
|
crate::protocol::VERSION_MISMATCH_REASON,
|
||||||
|
self.peer,
|
||||||
|
self.remote,
|
||||||
|
self.local,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ProtocolVersionMismatch {}
|
||||||
|
|
||||||
|
/// Verify a peer-advertised native protocol version against this build.
|
||||||
|
///
|
||||||
|
/// `peer` names whose version was wrong ("client" or "server") for the error
|
||||||
|
/// message. Returns a typed [`ProtocolVersionMismatch`] so the caller can both
|
||||||
|
/// match on it and print an actionable, upgrade-oriented message.
|
||||||
|
pub fn check_native_protocol_version(version: u8, peer: &'static str) -> Result<()> {
|
||||||
|
if version != NATIVE_PROTOCOL_VERSION {
|
||||||
|
return Err(ProtocolVersionMismatch {
|
||||||
|
local: NATIVE_PROTOCOL_VERSION,
|
||||||
|
remote: version,
|
||||||
|
peer,
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn verify_server_hello(client: &NativeClientHello, server: &NativeServerHello) -> Result<()> {
|
pub fn verify_server_hello(client: &NativeClientHello, server: &NativeServerHello) -> Result<()> {
|
||||||
anyhow::ensure!(
|
check_native_protocol_version(client.protocol_version, "client")?;
|
||||||
client.protocol_version == NATIVE_PROTOCOL_VERSION,
|
check_native_protocol_version(server.protocol_version, "server")?;
|
||||||
"unsupported native client protocol {}",
|
|
||||||
client.protocol_version
|
|
||||||
);
|
|
||||||
anyhow::ensure!(
|
|
||||||
server.protocol_version == NATIVE_PROTOCOL_VERSION,
|
|
||||||
"unsupported native server protocol {}",
|
|
||||||
server.protocol_version
|
|
||||||
);
|
|
||||||
let transcript = server_hello_transcript(client, server)?;
|
let transcript = server_hello_transcript(client, server)?;
|
||||||
verify_host_signature(&server.host_key, &transcript, &server.host_signature)
|
verify_host_signature(&server.host_key, &transcript, &server.host_signature)
|
||||||
}
|
}
|
||||||
@@ -295,6 +343,92 @@ pub fn sign_user_auth(
|
|||||||
Ok(auth)
|
Ok(auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn sign_user_auth_with_private_key(
|
||||||
|
private_key: &ssh_key::PrivateKey,
|
||||||
|
client: &NativeClientHello,
|
||||||
|
server: &NativeServerHello,
|
||||||
|
requested_forwardings: Vec<ForwardingRequest>,
|
||||||
|
) -> Result<NativeUserAuth> {
|
||||||
|
anyhow::ensure!(!private_key.is_encrypted(), "OpenSSH identity is encrypted");
|
||||||
|
let public_key = private_key.public_key();
|
||||||
|
let key_algorithm = public_key.algorithm().as_str().to_string();
|
||||||
|
anyhow::ensure!(
|
||||||
|
is_supported_user_key_algorithm(&key_algorithm),
|
||||||
|
"unsupported native identity key algorithm {key_algorithm}"
|
||||||
|
);
|
||||||
|
let public_key_blob = ssh_public_blob_from_public_key(public_key)?;
|
||||||
|
let public_key_for_auth = if key_algorithm == "ssh-ed25519" {
|
||||||
|
parse_ssh_ed25519_public_blob(&public_key_blob)?.to_vec()
|
||||||
|
} else {
|
||||||
|
public_key_blob
|
||||||
|
};
|
||||||
|
let signature_algorithm = signature_algorithm_for_private_key(&key_algorithm)?;
|
||||||
|
let mut auth = NativeUserAuth {
|
||||||
|
public_key_algorithm: signature_algorithm.to_string(),
|
||||||
|
public_key: public_key_for_auth,
|
||||||
|
signature: Vec::new(),
|
||||||
|
requested_forwardings,
|
||||||
|
};
|
||||||
|
let transcript = user_auth_transcript(client, server, &auth)?;
|
||||||
|
auth.signature = if key_algorithm == "ssh-rsa" {
|
||||||
|
sign_rsa_sha512_private_key(private_key, &transcript)?
|
||||||
|
} else {
|
||||||
|
let signature = SignatureSigner::try_sign(private_key, &transcript)
|
||||||
|
.context("sign native user auth with OpenSSH private key")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
signature.algorithm().as_str() == auth.public_key_algorithm,
|
||||||
|
"private key produced signature algorithm {}, expected {}",
|
||||||
|
signature.algorithm().as_str(),
|
||||||
|
auth.public_key_algorithm
|
||||||
|
);
|
||||||
|
signature.as_bytes().to_vec()
|
||||||
|
};
|
||||||
|
Ok(auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_rsa_sha512_private_key(
|
||||||
|
private_key: &ssh_key::PrivateKey,
|
||||||
|
transcript: &[u8],
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
let rsa_keypair = private_key
|
||||||
|
.key_data()
|
||||||
|
.rsa()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity is not ssh-rsa"))?;
|
||||||
|
let rsa_private = rsa_private_key_from_ssh_keypair(rsa_keypair)?;
|
||||||
|
let signing_key = rsa::pkcs1v15::SigningKey::<sha2::Sha512>::new(rsa_private);
|
||||||
|
let signature: rsa::pkcs1v15::Signature = SignatureSigner::sign(&signing_key, transcript);
|
||||||
|
Ok(signature.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rsa_private_key_from_ssh_keypair(
|
||||||
|
keypair: &ssh_key::private::RsaKeypair,
|
||||||
|
) -> Result<rsa::RsaPrivateKey> {
|
||||||
|
let private = rsa::RsaPrivateKey::from_components(
|
||||||
|
rsa::BigUint::try_from(&keypair.public.n).context("convert RSA modulus")?,
|
||||||
|
rsa::BigUint::try_from(&keypair.public.e).context("convert RSA public exponent")?,
|
||||||
|
rsa::BigUint::try_from(&keypair.private.d).context("convert RSA private exponent")?,
|
||||||
|
vec![
|
||||||
|
rsa::BigUint::try_from(&keypair.private.p).context("convert RSA p prime")?,
|
||||||
|
rsa::BigUint::try_from(&keypair.private.q).context("convert RSA q prime")?,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.context("convert OpenSSH RSA private key")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
private.size().saturating_mul(8) >= 2048,
|
||||||
|
"OpenSSH RSA identity is smaller than 2048 bits"
|
||||||
|
);
|
||||||
|
Ok(private)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signature_algorithm_for_private_key(key_algorithm: &str) -> Result<&'static str> {
|
||||||
|
match key_algorithm {
|
||||||
|
"ssh-ed25519" => Ok("ssh-ed25519"),
|
||||||
|
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
|
||||||
|
"ssh-rsa" => Ok("rsa-sha2-512"),
|
||||||
|
_ => bail!("unsupported native identity key algorithm {key_algorithm}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn verify_native_user_auth(
|
pub fn verify_native_user_auth(
|
||||||
client: &NativeClientHello,
|
client: &NativeClientHello,
|
||||||
server: &NativeServerHello,
|
server: &NativeServerHello,
|
||||||
@@ -303,10 +437,54 @@ pub fn verify_native_user_auth(
|
|||||||
source_ip: Option<IpAddr>,
|
source_ip: Option<IpAddr>,
|
||||||
) -> Result<AuthorizedKey> {
|
) -> Result<AuthorizedKey> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
auth.public_key_algorithm == "ssh-ed25519",
|
is_supported_user_signature_algorithm(&auth.public_key_algorithm),
|
||||||
"unsupported native user key algorithm {}",
|
"unsupported native user key algorithm {}",
|
||||||
auth.public_key_algorithm
|
auth.public_key_algorithm
|
||||||
);
|
);
|
||||||
|
let authorized_algorithm = authorized_key_algorithm_for_auth(&auth.public_key_algorithm)?;
|
||||||
|
let authorized = authorized_keys
|
||||||
|
.iter()
|
||||||
|
.find(|key| key.algorithm == authorized_algorithm && key.key == auth.public_key)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
|
||||||
|
authorized.ensure_native_allowed(auth, source_ip)?;
|
||||||
|
|
||||||
|
let transcript = user_auth_transcript(client, server, auth)?;
|
||||||
|
verify_native_user_signature(auth, &transcript)?;
|
||||||
|
Ok(authorized.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn supported_user_key_algorithms() -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"ssh-ed25519".to_string(),
|
||||||
|
"ecdsa-sha2-nistp256".to_string(),
|
||||||
|
"rsa-sha2-512".to_string(),
|
||||||
|
"rsa-sha2-256".to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_supported_user_key_algorithm(algorithm: &str) -> bool {
|
||||||
|
matches!(algorithm, "ssh-ed25519" | "ecdsa-sha2-nistp256" | "ssh-rsa")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_supported_user_signature_algorithm(algorithm: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
algorithm,
|
||||||
|
"ssh-ed25519" | "ecdsa-sha2-nistp256" | "rsa-sha2-512" | "rsa-sha2-256"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authorized_key_algorithm_for_auth(signature_algorithm: &str) -> Result<&'static str> {
|
||||||
|
match signature_algorithm {
|
||||||
|
"ssh-ed25519" => Ok("ssh-ed25519"),
|
||||||
|
"ecdsa-sha2-nistp256" => Ok("ecdsa-sha2-nistp256"),
|
||||||
|
"rsa-sha2-512" | "rsa-sha2-256" => Ok("ssh-rsa"),
|
||||||
|
_ => bail!("unsupported native user key algorithm {signature_algorithm}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_native_user_signature(auth: &NativeUserAuth, transcript: &[u8]) -> Result<()> {
|
||||||
|
match auth.public_key_algorithm.as_str() {
|
||||||
|
"ssh-ed25519" => {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
auth.public_key.len() == 32,
|
auth.public_key.len() == 32,
|
||||||
"ssh-ed25519 public key must be 32 bytes"
|
"ssh-ed25519 public key must be 32 bytes"
|
||||||
@@ -315,23 +493,69 @@ pub fn verify_native_user_auth(
|
|||||||
auth.signature.len() == 64,
|
auth.signature.len() == 64,
|
||||||
"ssh-ed25519 signature must be 64 bytes"
|
"ssh-ed25519 signature must be 64 bytes"
|
||||||
);
|
);
|
||||||
let authorized = authorized_keys
|
|
||||||
.iter()
|
|
||||||
.find(|key| key.algorithm == "ssh-ed25519" && key.key == auth.public_key)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("native user key is not authorized"))?;
|
|
||||||
authorized.ensure_native_allowed(auth, source_ip)?;
|
|
||||||
|
|
||||||
let mut public_key = [0u8; 32];
|
let mut public_key = [0u8; 32];
|
||||||
public_key.copy_from_slice(&auth.public_key);
|
public_key.copy_from_slice(&auth.public_key);
|
||||||
let verifying_key = VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
|
let verifying_key =
|
||||||
|
VerifyingKey::from_bytes(&public_key).context("parse user public key")?;
|
||||||
let mut signature = [0u8; 64];
|
let mut signature = [0u8; 64];
|
||||||
signature.copy_from_slice(&auth.signature);
|
signature.copy_from_slice(&auth.signature);
|
||||||
let signature = Signature::from_bytes(&signature);
|
let signature = Signature::from_bytes(&signature);
|
||||||
let transcript = user_auth_transcript(client, server, auth)?;
|
|
||||||
verifying_key
|
verifying_key
|
||||||
.verify(&transcript, &signature)
|
.verify(transcript, &signature)
|
||||||
.context("verify native user signature")?;
|
.context("verify native user signature")?;
|
||||||
Ok(authorized.clone())
|
}
|
||||||
|
"ecdsa-sha2-nistp256" => {
|
||||||
|
let public_key = ssh_public_key_from_blob(&auth.public_key)
|
||||||
|
.context("parse native user SSH public key")?;
|
||||||
|
let algorithm =
|
||||||
|
ssh_key::Algorithm::from_str(&auth.public_key_algorithm).with_context(|| {
|
||||||
|
format!("parse signature algorithm {}", auth.public_key_algorithm)
|
||||||
|
})?;
|
||||||
|
let signature = ssh_key::Signature::new(algorithm, auth.signature.clone())
|
||||||
|
.context("parse native user SSH signature")?;
|
||||||
|
SignatureVerifier::verify(public_key.key_data(), transcript, &signature)
|
||||||
|
.context("verify native user SSH signature")?;
|
||||||
|
}
|
||||||
|
"rsa-sha2-512" | "rsa-sha2-256" => {
|
||||||
|
verify_rsa_sha2_signature(
|
||||||
|
&auth.public_key,
|
||||||
|
&auth.public_key_algorithm,
|
||||||
|
transcript,
|
||||||
|
&auth.signature,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
algorithm => bail!("unsupported native user key algorithm {algorithm}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_rsa_sha2_signature(
|
||||||
|
public_key_blob: &[u8],
|
||||||
|
signature_algorithm: &str,
|
||||||
|
transcript: &[u8],
|
||||||
|
signature: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
let (e, n) = parse_ssh_rsa_public_blob(public_key_blob)?;
|
||||||
|
let public = rsa::RsaPublicKey::new(
|
||||||
|
rsa::BigUint::from_bytes_be(n),
|
||||||
|
rsa::BigUint::from_bytes_be(e),
|
||||||
|
)
|
||||||
|
.context("parse RSA public key")?;
|
||||||
|
let signature =
|
||||||
|
rsa::pkcs1v15::Signature::try_from(signature).context("parse RSA PKCS#1v1.5 signature")?;
|
||||||
|
match signature_algorithm {
|
||||||
|
"rsa-sha2-256" => {
|
||||||
|
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha256>::new(public);
|
||||||
|
SignatureVerifier::verify(&key, transcript, &signature)
|
||||||
|
.context("verify RSA-SHA256 signature")
|
||||||
|
}
|
||||||
|
"rsa-sha2-512" => {
|
||||||
|
let key = rsa::pkcs1v15::VerifyingKey::<sha2::Sha512>::new(public);
|
||||||
|
SignatureVerifier::verify(&key, transcript, &signature)
|
||||||
|
.context("verify RSA-SHA512 signature")
|
||||||
|
}
|
||||||
|
_ => bail!("legacy ssh-rsa/SHA-1 signatures are not accepted"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn verify_native_user_auth_from_config(
|
pub fn verify_native_user_auth_from_config(
|
||||||
@@ -369,6 +593,36 @@ pub fn derive_native_session_key(
|
|||||||
crypto::hkdf32(shared.as_bytes(), &salt, b"dosh/native/chacha20poly1305/v1")
|
crypto::hkdf32(shared.as_bytes(), &salt, b"dosh/native/chacha20poly1305/v1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive a rotated transport key for transport rekey (spec §11 / §9).
|
||||||
|
///
|
||||||
|
/// The rotated key is derived **independently of the handshake traffic keys**:
|
||||||
|
/// the input keying material is the current epoch's key (which both peers already
|
||||||
|
/// hold) mixed with fresh, server-generated `rekey_material` (32 bytes of CSPRNG
|
||||||
|
/// output, delivered confidentially inside an AEAD `Rekey` packet sealed under
|
||||||
|
/// the current key). The new `epoch` and the previous epoch's `session_key_id`
|
||||||
|
/// salt the derivation so each epoch's key is unique. It never re-derives from
|
||||||
|
/// the handshake DH output, satisfying the spec requirement that "rotated session
|
||||||
|
/// keys must be derived independently ... from fresh randomness" and "must not
|
||||||
|
/// reuse handshake traffic keys."
|
||||||
|
///
|
||||||
|
/// Both peers run this identically — the client never needs the server's
|
||||||
|
/// long-term secret, only the fresh `rekey_material` it receives in the `Rekey`
|
||||||
|
/// packet plus the current key it already shares.
|
||||||
|
pub fn derive_rekey_session_key(
|
||||||
|
current_key: &[u8; 32],
|
||||||
|
rekey_material: &[u8; 32],
|
||||||
|
previous_session_key_id: &[u8; 16],
|
||||||
|
epoch: u64,
|
||||||
|
) -> Result<[u8; 32]> {
|
||||||
|
let mut ikm = Vec::with_capacity(64);
|
||||||
|
ikm.extend_from_slice(current_key);
|
||||||
|
ikm.extend_from_slice(rekey_material);
|
||||||
|
let mut salt = b"dosh/native/rekey/v1".to_vec();
|
||||||
|
salt.extend_from_slice(previous_session_key_id);
|
||||||
|
salt.extend_from_slice(&epoch.to_be_bytes());
|
||||||
|
crypto::hkdf32(&ikm, &salt, b"dosh/native/rekey/chacha20poly1305/v1")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load_ed25519_identity(path: &Path) -> Result<SigningKey> {
|
pub fn load_ed25519_identity(path: &Path) -> Result<SigningKey> {
|
||||||
load_ed25519_identity_with_passphrase(path, None)
|
load_ed25519_identity_with_passphrase(path, None)
|
||||||
}
|
}
|
||||||
@@ -396,6 +650,35 @@ pub fn load_ed25519_identity_with_passphrase(
|
|||||||
.with_context(|| format!("parse ssh-ed25519 identity {}", path.display()))
|
.with_context(|| format!("parse ssh-ed25519 identity {}", path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn load_native_identity(path: &Path) -> Result<ssh_key::PrivateKey> {
|
||||||
|
load_native_identity_with_passphrase(path, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_native_identity_with_passphrase(
|
||||||
|
path: &Path,
|
||||||
|
passphrase: Option<&str>,
|
||||||
|
) -> Result<ssh_key::PrivateKey> {
|
||||||
|
let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
||||||
|
let private_key = ssh_key::PrivateKey::from_openssh(&raw)
|
||||||
|
.with_context(|| format!("parse OpenSSH identity {}", path.display()))?;
|
||||||
|
let private_key = if private_key.is_encrypted() {
|
||||||
|
let passphrase = passphrase
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("OpenSSH identity {} is encrypted", path.display()))?;
|
||||||
|
private_key
|
||||||
|
.decrypt(passphrase)
|
||||||
|
.with_context(|| format!("decrypt OpenSSH identity {}", path.display()))?
|
||||||
|
} else {
|
||||||
|
private_key
|
||||||
|
};
|
||||||
|
let algorithm = private_key.public_key().algorithm().as_str().to_string();
|
||||||
|
anyhow::ensure!(
|
||||||
|
is_supported_user_key_algorithm(&algorithm),
|
||||||
|
"OpenSSH identity {} has unsupported native key algorithm {algorithm}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
Ok(private_key)
|
||||||
|
}
|
||||||
|
|
||||||
impl AuthorizedKey {
|
impl AuthorizedKey {
|
||||||
fn ensure_native_allowed(
|
fn ensure_native_allowed(
|
||||||
&self,
|
&self,
|
||||||
@@ -445,6 +728,10 @@ impl AuthorizedKey {
|
|||||||
"authorized key permitopen= cannot authorize remote or dynamic forwarding"
|
"authorized key permitopen= cannot authorize remote or dynamic forwarding"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Agent forwarding does not open a host:port, so permitopen
|
||||||
|
// (which restricts which targets may be opened) does not apply;
|
||||||
|
// it is gated separately by the server's allow_agent_forwarding.
|
||||||
|
ForwardingKind::Agent => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,7 +774,7 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
|
|||||||
fields.len() >= 2,
|
fields.len() >= 2,
|
||||||
"authorized_keys:{line_number}: expected key fields"
|
"authorized_keys:{line_number}: expected key fields"
|
||||||
);
|
);
|
||||||
let (options, key_type_index) = if fields[0].starts_with("ssh-") {
|
let (options, key_type_index) = if is_supported_user_key_algorithm(&fields[0]) {
|
||||||
(AuthorizedKeyOptions::default(), 0)
|
(AuthorizedKeyOptions::default(), 0)
|
||||||
} else {
|
} else {
|
||||||
(parse_authorized_key_options(&fields[0])?, 1)
|
(parse_authorized_key_options(&fields[0])?, 1)
|
||||||
@@ -498,15 +785,20 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
|
|||||||
let key_blob = fields
|
let key_blob = fields
|
||||||
.get(key_type_index + 1)
|
.get(key_type_index + 1)
|
||||||
.with_context(|| format!("authorized_keys:{line_number}: missing key blob"))?;
|
.with_context(|| format!("authorized_keys:{line_number}: missing key blob"))?;
|
||||||
anyhow::ensure!(
|
|
||||||
algorithm == "ssh-ed25519",
|
|
||||||
"authorized_keys:{line_number}: unsupported key type {algorithm}"
|
|
||||||
);
|
|
||||||
let decoded = STANDARD
|
let decoded = STANDARD
|
||||||
.decode(key_blob)
|
.decode(key_blob)
|
||||||
.with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?;
|
.with_context(|| format!("authorized_keys:{line_number}: decode key blob"))?;
|
||||||
let key = parse_ssh_ed25519_public_blob(&decoded)
|
anyhow::ensure!(
|
||||||
.with_context(|| format!("authorized_keys:{line_number}: parse ssh-ed25519 key"))?;
|
is_supported_user_key_algorithm(algorithm),
|
||||||
|
"authorized_keys:{line_number}: unsupported key type {algorithm}"
|
||||||
|
);
|
||||||
|
validate_supported_public_key_blob(algorithm, &decoded)
|
||||||
|
.with_context(|| format!("authorized_keys:{line_number}: parse {algorithm} key"))?;
|
||||||
|
let key = if algorithm == "ssh-ed25519" {
|
||||||
|
parse_ssh_ed25519_public_blob(&decoded)?.to_vec()
|
||||||
|
} else {
|
||||||
|
decoded
|
||||||
|
};
|
||||||
let comment = if fields.len() > key_type_index + 2 {
|
let comment = if fields.len() > key_type_index + 2 {
|
||||||
Some(fields[key_type_index + 2..].join(" "))
|
Some(fields[key_type_index + 2..].join(" "))
|
||||||
} else {
|
} else {
|
||||||
@@ -520,6 +812,51 @@ fn parse_authorized_key_line(line_number: usize, line: &str) -> Result<Authorize
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_supported_public_key_blob(algorithm: &str, blob: &[u8]) -> Result<()> {
|
||||||
|
let blob_algorithm = ssh_public_key_blob_algorithm(blob)?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
blob_algorithm == algorithm,
|
||||||
|
"key blob type {blob_algorithm} does not match authorized_keys type {algorithm}"
|
||||||
|
);
|
||||||
|
match algorithm {
|
||||||
|
"ssh-ed25519" => {
|
||||||
|
parse_ssh_ed25519_public_blob(blob)?;
|
||||||
|
}
|
||||||
|
"ecdsa-sha2-nistp256" | "ssh-rsa" => {
|
||||||
|
let _ = ssh_public_key_from_blob(blob)?;
|
||||||
|
}
|
||||||
|
_ => bail!("unsupported key type {algorithm}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ssh_public_key_blob_algorithm(blob: &[u8]) -> Result<String> {
|
||||||
|
let mut cursor = blob;
|
||||||
|
let algorithm = read_ssh_string(&mut cursor)?;
|
||||||
|
Ok(String::from_utf8_lossy(algorithm).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ssh_public_key_from_blob(blob: &[u8]) -> Result<ssh_key::PublicKey> {
|
||||||
|
let algorithm = ssh_public_key_blob_algorithm(blob)?;
|
||||||
|
let encoded = STANDARD.encode(blob);
|
||||||
|
ssh_key::PublicKey::from_openssh(&format!("{algorithm} {encoded}"))
|
||||||
|
.with_context(|| format!("parse OpenSSH public key blob {algorithm}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ssh_public_blob_from_public_key(public_key: &ssh_key::PublicKey) -> Result<Vec<u8>> {
|
||||||
|
let line = public_key
|
||||||
|
.to_openssh()
|
||||||
|
.context("encode OpenSSH public key")?;
|
||||||
|
let mut fields = line.split_whitespace();
|
||||||
|
let _algorithm = fields
|
||||||
|
.next()
|
||||||
|
.context("encoded public key missing algorithm")?;
|
||||||
|
let encoded = fields.next().context("encoded public key missing blob")?;
|
||||||
|
STANDARD
|
||||||
|
.decode(encoded)
|
||||||
|
.context("decode encoded OpenSSH public key blob")
|
||||||
|
}
|
||||||
|
|
||||||
fn split_authorized_key_fields(line: &str) -> Vec<String> {
|
fn split_authorized_key_fields(line: &str) -> Vec<String> {
|
||||||
line.split_whitespace().map(ToString::to_string).collect()
|
line.split_whitespace().map(ToString::to_string).collect()
|
||||||
}
|
}
|
||||||
@@ -687,6 +1024,18 @@ pub fn parse_ssh_ed25519_public_blob(blob: &[u8]) -> Result<[u8; 32]> {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_ssh_rsa_public_blob(blob: &[u8]) -> Result<(&[u8], &[u8])> {
|
||||||
|
let mut cursor = blob;
|
||||||
|
let key_type = read_ssh_string(&mut cursor)?;
|
||||||
|
anyhow::ensure!(key_type == b"ssh-rsa", "key blob type mismatch");
|
||||||
|
let e = read_ssh_mpint(&mut cursor)?;
|
||||||
|
let n = read_ssh_mpint(&mut cursor)?;
|
||||||
|
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-rsa key blob");
|
||||||
|
anyhow::ensure!(!e.is_empty(), "ssh-rsa exponent is empty");
|
||||||
|
anyhow::ensure!(!n.is_empty(), "ssh-rsa modulus is empty");
|
||||||
|
Ok((e, n))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
|
pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
write_ssh_string(&mut out, b"ssh-ed25519");
|
write_ssh_string(&mut out, b"ssh-ed25519");
|
||||||
@@ -694,6 +1043,12 @@ pub fn ssh_ed25519_public_blob(public_key: &[u8; 32]) -> Vec<u8> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_ssh_mpint<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
|
||||||
|
let raw = read_ssh_string(cursor)?;
|
||||||
|
let raw = raw.strip_prefix(&[0]).unwrap_or(raw);
|
||||||
|
Ok(raw)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
|
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
|
||||||
anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length");
|
anyhow::ensure!(cursor.len() >= 4, "truncated SSH string length");
|
||||||
let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize;
|
let len = u32::from_be_bytes(cursor[..4].try_into().unwrap()) as usize;
|
||||||
@@ -880,11 +1235,11 @@ fn write_known_host_entries(path: &Path, entries: &[KnownHost]) -> Result<()> {
|
|||||||
));
|
));
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
let mut file = fs::OpenOptions::new()
|
let mut options = fs::OpenOptions::new();
|
||||||
.create(true)
|
options.create(true).truncate(true).write(true);
|
||||||
.truncate(true)
|
#[cfg(unix)]
|
||||||
.write(true)
|
options.mode(0o600);
|
||||||
.mode(0o600)
|
let mut file = options
|
||||||
.open(path)
|
.open(path)
|
||||||
.with_context(|| format!("write {}", path.display()))?;
|
.with_context(|| format!("write {}", path.display()))?;
|
||||||
file.write_all(out.as_bytes())
|
file.write_all(out.as_bytes())
|
||||||
@@ -926,16 +1281,16 @@ mod tests {
|
|||||||
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
|
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
trust_host(&path, "palav", &first, "ssh", false).unwrap(),
|
trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
|
||||||
TrustResult::Trusted
|
TrustResult::Trusted
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
trust_host(&path, "palav", &first, "ssh", false).unwrap(),
|
trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
|
||||||
TrustResult::AlreadyTrusted
|
TrustResult::AlreadyTrusted
|
||||||
);
|
);
|
||||||
assert!(trust_host(&path, "palav", &second, "ssh", false).is_err());
|
assert!(trust_host(&path, "homelab", &second, "ssh", false).is_err());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
trust_host(&path, "palav", &second, "ssh", true).unwrap(),
|
trust_host(&path, "homelab", &second, "ssh", true).unwrap(),
|
||||||
TrustResult::Trusted
|
TrustResult::Trusted
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -948,16 +1303,16 @@ mod tests {
|
|||||||
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
|
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
verify_known_host(&path, "palav", &first).unwrap(),
|
verify_known_host(&path, "homelab", &first).unwrap(),
|
||||||
KnownHostStatus::Unknown
|
KnownHostStatus::Unknown
|
||||||
);
|
);
|
||||||
trust_host(&path, "palav", &first, "ssh", false).unwrap();
|
trust_host(&path, "homelab", &first, "ssh", false).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
verify_known_host(&path, "palav", &first).unwrap(),
|
verify_known_host(&path, "homelab", &first).unwrap(),
|
||||||
KnownHostStatus::Trusted
|
KnownHostStatus::Trusted
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
verify_known_host(&path, "palav", &second).unwrap(),
|
verify_known_host(&path, "homelab", &second).unwrap(),
|
||||||
KnownHostStatus::Mismatch { .. }
|
KnownHostStatus::Mismatch { .. }
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1006,6 +1361,45 @@ mod tests {
|
|||||||
assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err());
|
assert!(verify_native_user_auth(&client, &server, &auth, &removed, None).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_user_auth_accepts_ecdsa_p256_private_key() {
|
||||||
|
let mut rng = rand::rngs::OsRng;
|
||||||
|
let private = ssh_key::PrivateKey::random(
|
||||||
|
&mut rng,
|
||||||
|
ssh_key::Algorithm::Ecdsa {
|
||||||
|
curve: ssh_key::EcdsaCurve::NistP256,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let host_signing = SigningKey::from_bytes(&[10u8; 32]);
|
||||||
|
let client = test_client_hello();
|
||||||
|
let mut server = test_server_hello(&host_signing);
|
||||||
|
sign_server_hello(&host_signing, &client, &mut server).unwrap();
|
||||||
|
let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap();
|
||||||
|
let authorized =
|
||||||
|
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(auth.public_key_algorithm, "ecdsa-sha2-nistp256");
|
||||||
|
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_user_auth_accepts_rsa_sha2_private_key() {
|
||||||
|
let mut rng = rand::rngs::OsRng;
|
||||||
|
let private =
|
||||||
|
ssh_key::PrivateKey::random(&mut rng, ssh_key::Algorithm::Rsa { hash: None }).unwrap();
|
||||||
|
let host_signing = SigningKey::from_bytes(&[11u8; 32]);
|
||||||
|
let client = test_client_hello();
|
||||||
|
let mut server = test_server_hello(&host_signing);
|
||||||
|
sign_server_hello(&host_signing, &client, &mut server).unwrap();
|
||||||
|
let auth = sign_user_auth_with_private_key(&private, &client, &server, Vec::new()).unwrap();
|
||||||
|
let authorized =
|
||||||
|
parse_authorized_keys(&private.public_key().to_openssh().unwrap()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(auth.public_key_algorithm, "rsa-sha2-512");
|
||||||
|
verify_native_user_auth(&client, &server, &auth, &authorized, None).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn native_user_auth_rejects_tampered_transcript() {
|
fn native_user_auth_rejects_tampered_transcript() {
|
||||||
let host_signing = SigningKey::from_bytes(&[3u8; 32]);
|
let host_signing = SigningKey::from_bytes(&[3u8; 32]);
|
||||||
@@ -1233,8 +1627,8 @@ mod tests {
|
|||||||
protocol_version: NATIVE_PROTOCOL_VERSION,
|
protocol_version: NATIVE_PROTOCOL_VERSION,
|
||||||
client_random: [1u8; 32],
|
client_random: [1u8; 32],
|
||||||
client_ephemeral_public: [2u8; 32],
|
client_ephemeral_public: [2u8; 32],
|
||||||
requested_host: "palav".to_string(),
|
requested_host: "homelab".to_string(),
|
||||||
requested_user: "palav".to_string(),
|
requested_user: "alice".to_string(),
|
||||||
requested_session: "term".to_string(),
|
requested_session: "term".to_string(),
|
||||||
requested_mode: "read-write".to_string(),
|
requested_mode: "read-write".to_string(),
|
||||||
terminal_size: (80, 24),
|
terminal_size: (80, 24),
|
||||||
|
|||||||
+666
@@ -0,0 +1,666 @@
|
|||||||
|
//! Session persistence across server restarts.
|
||||||
|
//!
|
||||||
|
//! A persistent dosh session's shell does not run as a child of `dosh-server`.
|
||||||
|
//! Instead the server spawns a tiny per-session *holder* process (the same
|
||||||
|
//! `dosh-server` binary re-exec'd as `dosh-server hold ...`). The holder calls
|
||||||
|
//! `setsid()` to leave the server's process group/session, opens a PTY, spawns
|
||||||
|
//! the shell as ITS own child, and listens on a Unix socket in a per-session
|
||||||
|
//! runtime directory. The server connects to that socket and the holder hands it
|
||||||
|
//! the PTY master fd over `SCM_RIGHTS`.
|
||||||
|
//!
|
||||||
|
//! Because the shell belongs to the holder (which is in its own session and is
|
||||||
|
//! not waited on by the server), killing or restarting `dosh-server` leaves the
|
||||||
|
//! holder + shell alive. On startup the server scans the runtime directory,
|
||||||
|
//! reconnects to each live holder, receives the master fd again, and rebuilds the
|
||||||
|
//! in-memory session so clients reattach to the very same shell.
|
||||||
|
//!
|
||||||
|
//! Screen / scrollback state lives only in server memory, so it is also mirrored
|
||||||
|
//! to disk (atomically) and restored on re-adoption, letting a reattaching client
|
||||||
|
//! repaint the screen exactly as it was before the restart.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, RawFd};
|
||||||
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// One-byte commands a server sends to a holder over its control socket after
|
||||||
|
/// the holder has handed back the master fd.
|
||||||
|
const HOLDER_CMD_SHUTDOWN: u8 = b'X';
|
||||||
|
const HOLDER_STARTUP_TIMEOUT: Duration = Duration::from_millis(750);
|
||||||
|
|
||||||
|
/// Magic written into a holder's `meta` file, bumped if the on-disk layout
|
||||||
|
/// changes so a stale holder from an incompatible build is ignored.
|
||||||
|
const META_MAGIC: &str = "dosh-holder-1";
|
||||||
|
|
||||||
|
/// Per-session runtime metadata persisted next to the holder socket.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct HolderMeta {
|
||||||
|
pub session: String,
|
||||||
|
pub shell_pid: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Root runtime directory holding one subdirectory per persistent session.
|
||||||
|
/// Lives under `sessions_dir/run` so it shares the session storage location and
|
||||||
|
/// can be wiped with it.
|
||||||
|
pub fn runtime_root(sessions_dir: &Path) -> PathBuf {
|
||||||
|
sessions_dir.join("run")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a session name to a filesystem-safe directory name. Session names are
|
||||||
|
/// user-controlled, so hex-encode them rather than trusting them as path
|
||||||
|
/// components (avoids traversal, slashes, NULs, length issues).
|
||||||
|
fn session_dir_name(session: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(session.len() * 2);
|
||||||
|
for byte in session.as_bytes() {
|
||||||
|
out.push(char::from_digit((byte >> 4) as u32, 16).unwrap());
|
||||||
|
out.push(char::from_digit((byte & 0xf) as u32, 16).unwrap());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_session_dir_name(name: &str) -> Option<String> {
|
||||||
|
if !name.len().is_multiple_of(2) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut bytes = Vec::with_capacity(name.len() / 2);
|
||||||
|
let raw = name.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < raw.len() {
|
||||||
|
let hi = (raw[i] as char).to_digit(16)?;
|
||||||
|
let lo = (raw[i + 1] as char).to_digit(16)?;
|
||||||
|
bytes.push(((hi << 4) | lo) as u8);
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
String::from_utf8(bytes).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory for one session's holder runtime state.
|
||||||
|
pub fn session_runtime_dir(sessions_dir: &Path, session: &str) -> PathBuf {
|
||||||
|
runtime_root(sessions_dir).join(session_dir_name(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn holder_sock_path(dir: &Path) -> PathBuf {
|
||||||
|
dir.join("holder.sock")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meta_path(dir: &Path) -> PathBuf {
|
||||||
|
dir.join("meta")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn screen_path(dir: &Path) -> PathBuf {
|
||||||
|
dir.join("screen")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the runtime directory tree with private (0700) permissions.
|
||||||
|
pub fn ensure_runtime_dir(sessions_dir: &Path, session: &str) -> Result<PathBuf> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let root = runtime_root(sessions_dir);
|
||||||
|
std::fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?;
|
||||||
|
let _ = std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700));
|
||||||
|
let dir = session_runtime_dir(sessions_dir, session);
|
||||||
|
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||||
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
|
||||||
|
.with_context(|| format!("chmod {}", dir.display()))?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically write `data` to `path` (write temp + rename), so a concurrent
|
||||||
|
/// reader (e.g. a restarting server) never sees a half-written file.
|
||||||
|
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let tmp = path.with_extension("tmp");
|
||||||
|
{
|
||||||
|
let mut file =
|
||||||
|
std::fs::File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
|
||||||
|
let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
|
||||||
|
file.write_all(data)
|
||||||
|
.with_context(|| format!("write {}", tmp.display()))?;
|
||||||
|
file.sync_all().ok();
|
||||||
|
}
|
||||||
|
std::fs::rename(&tmp, path)
|
||||||
|
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the vt100 screen snapshot plus recent raw output for a session so a
|
||||||
|
/// post-restart reattach repaints correctly. `snapshot` is the bytes a fresh
|
||||||
|
/// attach would receive (alt-screen toggle + `state_formatted`).
|
||||||
|
pub fn save_screen(
|
||||||
|
sessions_dir: &Path,
|
||||||
|
session: &str,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
output_seq: u64,
|
||||||
|
snapshot: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
let dir = session_runtime_dir(sessions_dir, session);
|
||||||
|
if !dir.exists() {
|
||||||
|
// No holder runtime for this session (non-persistent): nothing to do.
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut buf = Vec::with_capacity(snapshot.len() + 32);
|
||||||
|
buf.extend_from_slice(&cols.to_be_bytes());
|
||||||
|
buf.extend_from_slice(&rows.to_be_bytes());
|
||||||
|
buf.extend_from_slice(&output_seq.to_be_bytes());
|
||||||
|
buf.extend_from_slice(&(snapshot.len() as u32).to_be_bytes());
|
||||||
|
buf.extend_from_slice(snapshot);
|
||||||
|
atomic_write(&screen_path(&dir), &buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A restored screen for a re-adopted session.
|
||||||
|
pub struct SavedScreen {
|
||||||
|
pub cols: u16,
|
||||||
|
pub rows: u16,
|
||||||
|
pub output_seq: u64,
|
||||||
|
pub snapshot: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a previously persisted screen, if any.
|
||||||
|
pub fn load_screen(sessions_dir: &Path, session: &str) -> Option<SavedScreen> {
|
||||||
|
let dir = session_runtime_dir(sessions_dir, session);
|
||||||
|
let data = std::fs::read(screen_path(&dir)).ok()?;
|
||||||
|
if data.len() < 16 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let cols = u16::from_be_bytes(data[0..2].try_into().ok()?);
|
||||||
|
let rows = u16::from_be_bytes(data[2..4].try_into().ok()?);
|
||||||
|
let output_seq = u64::from_be_bytes(data[4..12].try_into().ok()?);
|
||||||
|
let len = u32::from_be_bytes(data[12..16].try_into().ok()?) as usize;
|
||||||
|
if data.len() < 16 + len {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(SavedScreen {
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
output_seq,
|
||||||
|
snapshot: data[16..16 + len].to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_meta(dir: &Path, meta: &HolderMeta) -> Result<()> {
|
||||||
|
let body = format!("{META_MAGIC}\n{}\n{}\n", meta.shell_pid, meta.session);
|
||||||
|
atomic_write(&meta_path(dir), body.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_meta(dir: &Path) -> Option<HolderMeta> {
|
||||||
|
let body = std::fs::read_to_string(meta_path(dir)).ok()?;
|
||||||
|
let mut lines = body.lines();
|
||||||
|
if lines.next()? != META_MAGIC {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let shell_pid: i32 = lines.next()?.parse().ok()?;
|
||||||
|
let session = lines.next()?.to_string();
|
||||||
|
Some(HolderMeta { session, shell_pid })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a holder process for `session`: re-exec this binary as `dosh-server
|
||||||
|
/// hold` with the runtime dir, shell, terminal size, and accepted env. The
|
||||||
|
/// holder daemonizes (setsid, owns the PTY) and listens on its control socket.
|
||||||
|
/// Returns once the holder has signalled readiness by creating its socket.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn spawn_holder(
|
||||||
|
sessions_dir: &Path,
|
||||||
|
session: &str,
|
||||||
|
shell: &str,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
env: &[(String, String)],
|
||||||
|
) -> Result<()> {
|
||||||
|
let dir = ensure_runtime_dir(sessions_dir, session)?;
|
||||||
|
let sock = holder_sock_path(&dir);
|
||||||
|
// Clear any stale socket left by a crashed holder with the same name.
|
||||||
|
let _ = std::fs::remove_file(&sock);
|
||||||
|
|
||||||
|
let exe = std::env::current_exe().context("locate current executable")?;
|
||||||
|
let mut cmd = std::process::Command::new(exe);
|
||||||
|
cmd.arg("hold")
|
||||||
|
.arg("--runtime-dir")
|
||||||
|
.arg(&dir)
|
||||||
|
.arg("--session")
|
||||||
|
.arg(session)
|
||||||
|
.arg("--shell")
|
||||||
|
.arg(shell)
|
||||||
|
.arg("--cols")
|
||||||
|
.arg(cols.to_string())
|
||||||
|
.arg("--rows")
|
||||||
|
.arg(rows.to_string());
|
||||||
|
for (name, value) in env {
|
||||||
|
cmd.arg("--env").arg(format!("{name}={value}"));
|
||||||
|
}
|
||||||
|
cmd.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null());
|
||||||
|
let mut child = cmd.spawn().context("spawn holder process")?;
|
||||||
|
|
||||||
|
// Wait (briefly) for the holder to come up. The holder forks/setsids before
|
||||||
|
// creating the socket; once the socket exists we can connect. We also reap
|
||||||
|
// the short-lived launcher child so it never becomes a zombie.
|
||||||
|
let deadline = std::time::Instant::now() + HOLDER_STARTUP_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
if sock.exists() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(status) = child.try_wait().context("wait holder launcher")? {
|
||||||
|
bail!("holder launcher for session {session} exited before socket was ready: {status}");
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
bail!("holder for session {session} did not come up in time");
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
// The launcher exits immediately after the grandchild setsids; reap it.
|
||||||
|
let _ = child.wait();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to a session's holder and receive the PTY master fd over SCM_RIGHTS.
|
||||||
|
/// Returns the raw fd (caller owns it) and the holder control socket, kept open
|
||||||
|
/// so the server can later ask the holder to shut down. Returns an error if the
|
||||||
|
/// holder is gone (caller then degrades to a fresh, non-persistent session).
|
||||||
|
pub fn adopt_holder(sessions_dir: &Path, session: &str) -> Result<(RawFd, UnixStream)> {
|
||||||
|
let dir = session_runtime_dir(sessions_dir, session);
|
||||||
|
let sock = holder_sock_path(&dir);
|
||||||
|
let mut stream = UnixStream::connect(&sock)
|
||||||
|
.with_context(|| format!("connect holder socket {}", sock.display()))?;
|
||||||
|
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
|
||||||
|
let fd = recv_fd(&mut stream).context("receive master fd from holder")?;
|
||||||
|
Ok((fd, stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask a holder to terminate its shell and exit, then clean its runtime dir.
|
||||||
|
/// Used when reaping a truly-abandoned persistent session.
|
||||||
|
pub fn request_shutdown(sessions_dir: &Path, session: &str, control: Option<&mut UnixStream>) {
|
||||||
|
if let Some(stream) = control {
|
||||||
|
let _ = stream.write_all(&[HOLDER_CMD_SHUTDOWN]);
|
||||||
|
let _ = stream.flush();
|
||||||
|
} else {
|
||||||
|
let dir = session_runtime_dir(sessions_dir, session);
|
||||||
|
if let Ok(mut stream) = UnixStream::connect(holder_sock_path(&dir)) {
|
||||||
|
// Drain the fd the holder sends on connect, then send shutdown.
|
||||||
|
let _ = recv_fd(&mut stream);
|
||||||
|
let _ = stream.write_all(&[HOLDER_CMD_SHUTDOWN]);
|
||||||
|
let _ = stream.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Give the holder a moment to tear down, then remove its runtime dir.
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
let dir = session_runtime_dir(sessions_dir, session);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a session's runtime directory unconditionally (best effort).
|
||||||
|
pub fn remove_runtime_dir(sessions_dir: &Path, session: &str) {
|
||||||
|
let dir = session_runtime_dir(sessions_dir, session);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan the runtime root for holders left behind by a previous server. Returns
|
||||||
|
/// `(session_name, meta)` for each one whose holder process still appears alive.
|
||||||
|
/// Stale entries (no live process) are cleaned up.
|
||||||
|
pub fn scan_existing_holders(sessions_dir: &Path) -> Vec<HolderMeta> {
|
||||||
|
let root = runtime_root(sessions_dir);
|
||||||
|
let mut found = Vec::new();
|
||||||
|
let Ok(entries) = std::fs::read_dir(&root) else {
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let dir = entry.path();
|
||||||
|
if !dir.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(name) = dir.file_name().and_then(|n| n.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Decode the on-disk name back to a session name; skip junk dirs.
|
||||||
|
if decode_session_dir_name(name).is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match read_meta(&dir) {
|
||||||
|
Some(meta) if process_alive(meta.shell_pid) && holder_sock_path(&dir).exists() => {
|
||||||
|
found.push(meta);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Holder gone or meta unreadable: clean up so we don't try to
|
||||||
|
// adopt a dead session (degrade to fresh on next attach).
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a pid refers to a live process (signal 0 probe).
|
||||||
|
fn process_alive(pid: i32) -> bool {
|
||||||
|
if pid <= 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
unsafe {
|
||||||
|
libc::kill(pid, 0) == 0
|
||||||
|
|| std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Holder process entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Run as a holder process. Daemonizes (double-fork + setsid), opens a PTY,
|
||||||
|
/// spawns the shell as its own child, and serves the control socket: every
|
||||||
|
/// accepted connection is handed the master fd (SCM_RIGHTS); a subsequent
|
||||||
|
/// SHUTDOWN byte (or the shell exiting) tears everything down.
|
||||||
|
///
|
||||||
|
/// This function does not return on success — it `exit`s the process. Errors
|
||||||
|
/// before the daemonization point are returned to the launcher.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn run_holder(
|
||||||
|
runtime_dir: &Path,
|
||||||
|
session: &str,
|
||||||
|
shell: &str,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
env: &[(String, String)],
|
||||||
|
) -> Result<()> {
|
||||||
|
use portable_pty::{NativePtySystem, PtySize, PtySystem};
|
||||||
|
|
||||||
|
// Detach from the launching server: own session + process group so a server
|
||||||
|
// exit (even a process-group kill of the service) does not take us down.
|
||||||
|
daemonize().context("daemonize holder")?;
|
||||||
|
|
||||||
|
let pty_system = NativePtySystem::default();
|
||||||
|
let pair = pty_system
|
||||||
|
.openpty(PtySize {
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
})
|
||||||
|
.context("holder open pty")?;
|
||||||
|
let cmd = crate::pty::build_shell_command(shell, env);
|
||||||
|
let mut child = pair
|
||||||
|
.slave
|
||||||
|
.spawn_command(cmd)
|
||||||
|
.context("holder spawn shell")?;
|
||||||
|
drop(pair.slave);
|
||||||
|
let master_fd = pair
|
||||||
|
.master
|
||||||
|
.as_raw_fd()
|
||||||
|
.ok_or_else(|| anyhow!("holder master has no raw fd"))?;
|
||||||
|
let shell_pid = child.process_id().map(|p| p as i32).unwrap_or(-1);
|
||||||
|
|
||||||
|
write_meta(
|
||||||
|
runtime_dir,
|
||||||
|
&HolderMeta {
|
||||||
|
session: session.to_string(),
|
||||||
|
shell_pid,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let sock = holder_sock_path(runtime_dir);
|
||||||
|
let _ = std::fs::remove_file(&sock);
|
||||||
|
let listener =
|
||||||
|
UnixListener::bind(&sock).with_context(|| format!("holder bind {}", sock.display()))?;
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600));
|
||||||
|
}
|
||||||
|
listener
|
||||||
|
.set_nonblocking(false)
|
||||||
|
.context("holder listener blocking")?;
|
||||||
|
|
||||||
|
// A watcher thread reaps the shell: when it exits, the holder cleans up and
|
||||||
|
// exits too, so an abandoned shell does not linger forever.
|
||||||
|
let runtime_owned = runtime_dir.to_path_buf();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = child.wait();
|
||||||
|
// Shell exited: remove runtime dir and exit the holder process.
|
||||||
|
let _ = std::fs::remove_dir_all(&runtime_owned);
|
||||||
|
std::process::exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accept loop: each connecting server gets the master fd; a SHUTDOWN byte
|
||||||
|
// from any of them tears the holder down. Multiple servers never run at once
|
||||||
|
// in practice (one service), but serving repeated connections lets a server
|
||||||
|
// restart re-adopt cleanly.
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
let Ok(mut stream) = stream else { continue };
|
||||||
|
if send_fd(&mut stream, master_fd).is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Wait for an optional command byte. EOF (server dropped the control
|
||||||
|
// socket on its own exit) just means "keep running, await re-adoption".
|
||||||
|
let mut cmd = [0u8; 1];
|
||||||
|
match stream.read(&mut cmd) {
|
||||||
|
Ok(1) if cmd[0] == HOLDER_CMD_SHUTDOWN => {
|
||||||
|
if shell_pid > 0 {
|
||||||
|
let _ = unsafe { libc::kill(shell_pid, libc::SIGHUP) };
|
||||||
|
let _ = unsafe { libc::kill(shell_pid, libc::SIGTERM) };
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(runtime_dir);
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Server detached (exit/restart) or sent nothing actionable:
|
||||||
|
// loop back and wait for the next server to re-adopt us.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Double-fork + `setsid` so the holder runs in its own session, detached from
|
||||||
|
/// the server's controlling terminal and process group. Without this a
|
||||||
|
/// `systemctl restart` (which signals the whole service cgroup/process group)
|
||||||
|
/// could take the holder down with the server.
|
||||||
|
fn daemonize() -> Result<()> {
|
||||||
|
// First fork: parent (launcher) returns to reap; child continues.
|
||||||
|
match unsafe { libc::fork() } {
|
||||||
|
-1 => bail!("fork: {}", std::io::Error::last_os_error()),
|
||||||
|
0 => {}
|
||||||
|
_ => {
|
||||||
|
// Parent process: exit so the launcher's wait() returns promptly and
|
||||||
|
// the grandchild is reparented to init.
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// New session: detaches from controlling tty and the server's process group.
|
||||||
|
if unsafe { libc::setsid() } == -1 {
|
||||||
|
bail!("setsid: {}", std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
// Second fork: ensures we are not a session leader, so we can never
|
||||||
|
// re-acquire a controlling terminal.
|
||||||
|
match unsafe { libc::fork() } {
|
||||||
|
-1 => bail!("fork2: {}", std::io::Error::last_os_error()),
|
||||||
|
0 => Ok(()),
|
||||||
|
_ => std::process::exit(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SCM_RIGHTS file-descriptor passing over a Unix domain socket
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Send a single fd over `stream` using SCM_RIGHTS, with one byte of normal data
|
||||||
|
/// (sendmsg requires at least one iovec byte for the ancillary data to ride on).
|
||||||
|
fn send_fd(stream: &mut UnixStream, fd: RawFd) -> Result<()> {
|
||||||
|
let dummy: [u8; 1] = [0];
|
||||||
|
let mut iov = libc::iovec {
|
||||||
|
iov_base: dummy.as_ptr() as *mut libc::c_void,
|
||||||
|
iov_len: 1,
|
||||||
|
};
|
||||||
|
let mut cmsg_buf = [0u8; cmsg_space_one_fd()];
|
||||||
|
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
|
||||||
|
msg.msg_iov = &mut iov;
|
||||||
|
msg.msg_iovlen = 1;
|
||||||
|
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
|
||||||
|
msg.msg_controllen = cmsg_buf.len() as _;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let cmsg = libc::CMSG_FIRSTHDR(&msg);
|
||||||
|
if cmsg.is_null() {
|
||||||
|
bail!("CMSG_FIRSTHDR null");
|
||||||
|
}
|
||||||
|
(*cmsg).cmsg_level = libc::SOL_SOCKET;
|
||||||
|
(*cmsg).cmsg_type = libc::SCM_RIGHTS;
|
||||||
|
(*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::<RawFd>() as u32) as _;
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
&fd as *const RawFd as *const u8,
|
||||||
|
libc::CMSG_DATA(cmsg),
|
||||||
|
std::mem::size_of::<RawFd>(),
|
||||||
|
);
|
||||||
|
let n = libc::sendmsg(stream.as_raw_fd(), &msg, 0);
|
||||||
|
if n < 0 {
|
||||||
|
return Err(std::io::Error::last_os_error()).context("sendmsg SCM_RIGHTS");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive a single fd sent via SCM_RIGHTS. Returns a fresh fd owned by the
|
||||||
|
/// caller.
|
||||||
|
fn recv_fd(stream: &mut UnixStream) -> Result<RawFd> {
|
||||||
|
let mut dummy = [0u8; 1];
|
||||||
|
let mut iov = libc::iovec {
|
||||||
|
iov_base: dummy.as_mut_ptr() as *mut libc::c_void,
|
||||||
|
iov_len: 1,
|
||||||
|
};
|
||||||
|
let mut cmsg_buf = [0u8; cmsg_space_one_fd()];
|
||||||
|
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
|
||||||
|
msg.msg_iov = &mut iov;
|
||||||
|
msg.msg_iovlen = 1;
|
||||||
|
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
|
||||||
|
msg.msg_controllen = cmsg_buf.len() as _;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let n = libc::recvmsg(stream.as_raw_fd(), &mut msg, 0);
|
||||||
|
if n < 0 {
|
||||||
|
return Err(std::io::Error::last_os_error()).context("recvmsg SCM_RIGHTS");
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
bail!("holder closed connection before sending fd");
|
||||||
|
}
|
||||||
|
let cmsg = libc::CMSG_FIRSTHDR(&msg);
|
||||||
|
if cmsg.is_null() {
|
||||||
|
bail!("no ancillary data (fd) received from holder");
|
||||||
|
}
|
||||||
|
if (*cmsg).cmsg_level != libc::SOL_SOCKET || (*cmsg).cmsg_type != libc::SCM_RIGHTS {
|
||||||
|
bail!("unexpected ancillary message from holder");
|
||||||
|
}
|
||||||
|
let mut fd: RawFd = -1;
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
libc::CMSG_DATA(cmsg),
|
||||||
|
&mut fd as *mut RawFd as *mut u8,
|
||||||
|
std::mem::size_of::<RawFd>(),
|
||||||
|
);
|
||||||
|
if fd < 0 {
|
||||||
|
bail!("invalid fd received from holder");
|
||||||
|
}
|
||||||
|
Ok(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Space, in bytes, needed for a control-message buffer carrying exactly one fd.
|
||||||
|
const fn cmsg_space_one_fd() -> usize {
|
||||||
|
// CMSG_SPACE is not const in libc; this is the equivalent for one RawFd.
|
||||||
|
// cmsghdr is aligned to size_of::<usize>(); add data length rounded up.
|
||||||
|
let data = std::mem::size_of::<RawFd>();
|
||||||
|
let hdr = std::mem::size_of::<libc::cmsghdr>();
|
||||||
|
let align = std::mem::size_of::<usize>();
|
||||||
|
// round(hdr) + round(data)
|
||||||
|
((hdr + align - 1) & !(align - 1)) + ((data + align - 1) & !(align - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper used by `adopt_holder` callers to turn the received raw fd into an
|
||||||
|
/// owned `File`-like object if they need RAII (the server hands it to
|
||||||
|
/// `pty::adopt_pty_from_fd`, which takes ownership of the raw fd).
|
||||||
|
pub fn fd_into_file(fd: RawFd) -> std::fs::File {
|
||||||
|
unsafe { std::fs::File::from_raw_fd(fd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a `File` back into a raw fd it no longer owns (so it can be handed to
|
||||||
|
/// `adopt_pty_from_fd`). Currently unused outside tests but kept symmetric.
|
||||||
|
pub fn file_into_fd(file: std::fs::File) -> RawFd {
|
||||||
|
file.into_raw_fd()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_dir_name_round_trips() {
|
||||||
|
for name in ["default", "work", "a/b/../c", "weird name", "日本語"] {
|
||||||
|
let encoded = session_dir_name(name);
|
||||||
|
assert!(encoded.bytes().all(|b| b.is_ascii_hexdigit()));
|
||||||
|
assert_eq!(decode_session_dir_name(&encoded).as_deref(), Some(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rejects_non_hex() {
|
||||||
|
assert!(decode_session_dir_name("zz").is_none());
|
||||||
|
assert!(decode_session_dir_name("abc").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_and_load_screen_round_trips() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sessions_dir = tmp.path();
|
||||||
|
ensure_runtime_dir(sessions_dir, "work").unwrap();
|
||||||
|
let snap = b"\x1b[?1049lhello world".to_vec();
|
||||||
|
save_screen(sessions_dir, "work", 100, 40, 7, &snap).unwrap();
|
||||||
|
let loaded = load_screen(sessions_dir, "work").expect("screen restored");
|
||||||
|
assert_eq!(loaded.cols, 100);
|
||||||
|
assert_eq!(loaded.rows, 40);
|
||||||
|
assert_eq!(loaded.output_seq, 7);
|
||||||
|
assert_eq!(loaded.snapshot, snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_screen_absent_is_none() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
assert!(load_screen(tmp.path(), "missing").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_skips_dead_and_junk_entries() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sessions_dir = tmp.path();
|
||||||
|
// A meta pointing at a definitely-dead pid is cleaned up, not returned.
|
||||||
|
let dir = ensure_runtime_dir(sessions_dir, "ghost").unwrap();
|
||||||
|
write_meta(
|
||||||
|
&dir,
|
||||||
|
&HolderMeta {
|
||||||
|
session: "ghost".to_string(),
|
||||||
|
shell_pid: 2_000_000_000, // not a live pid
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// A junk (non-hex) directory is ignored.
|
||||||
|
std::fs::create_dir_all(runtime_root(sessions_dir).join("not-hex")).unwrap();
|
||||||
|
let found = scan_existing_holders(sessions_dir);
|
||||||
|
assert!(found.is_empty(), "dead/junk holders must be skipped");
|
||||||
|
assert!(!dir.exists(), "dead holder dir should be cleaned up");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn send_and_recv_fd_round_trips() {
|
||||||
|
use std::io::Seek;
|
||||||
|
// Pass a temp file's fd across a socketpair and confirm both ends point
|
||||||
|
// at the same open file (write via one, read via the other).
|
||||||
|
let (mut a, mut b) = UnixStream::pair().unwrap();
|
||||||
|
let mut file = tempfile::tempfile().unwrap();
|
||||||
|
writeln!(file, "shared-fd-marker").unwrap();
|
||||||
|
file.flush().unwrap();
|
||||||
|
send_fd(&mut a, file.as_raw_fd()).unwrap();
|
||||||
|
let received = recv_fd(&mut b).unwrap();
|
||||||
|
let mut got = fd_into_file(received);
|
||||||
|
got.rewind().unwrap();
|
||||||
|
let mut contents = String::new();
|
||||||
|
got.read_to_string(&mut contents).unwrap();
|
||||||
|
assert!(contents.contains("shared-fd-marker"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+129
-3
@@ -3,10 +3,56 @@ use crate::crypto;
|
|||||||
use crate::native::{EnvVar, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth};
|
use crate::native::{EnvVar, NativeAuthOk, NativeClientHello, NativeServerHello, NativeUserAuth};
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// Generate a name for an implicit, ephemeral terminal session — the kind
|
||||||
|
/// created by `dosh host` with no `--session`. Single source of truth shared by
|
||||||
|
/// the client (which generates it) and the server (which recognizes it via
|
||||||
|
/// [`is_implicit_session_name`] to decide a session is NOT worth persisting).
|
||||||
|
pub fn generate_implicit_session_name() -> String {
|
||||||
|
let millis = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis();
|
||||||
|
format!("term-{millis}-{}", std::process::id())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `name` is a client-generated implicit session name
|
||||||
|
/// (`term-<digits>-<digits>`). Such sessions are ephemeral: the user can never
|
||||||
|
/// reattach by name, so the server must not persist them across restarts.
|
||||||
|
/// Explicitly-named (`--session work`) and prewarmed sessions are not implicit.
|
||||||
|
pub fn is_implicit_session_name(name: &str) -> bool {
|
||||||
|
let Some(rest) = name.strip_prefix("term-") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mut parts = rest.split('-');
|
||||||
|
match (parts.next(), parts.next(), parts.next()) {
|
||||||
|
(Some(millis), Some(pid), None) => {
|
||||||
|
!millis.is_empty()
|
||||||
|
&& millis.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
&& !pid.is_empty()
|
||||||
|
&& pid.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub const MAGIC: &[u8; 4] = b"DOSH";
|
pub const MAGIC: &[u8; 4] = b"DOSH";
|
||||||
pub const VERSION: u8 = 1;
|
// v4: added reliable stream offsets/acks to `StreamData` and
|
||||||
|
// `StreamWindowAdjust`.
|
||||||
|
// v3: added `ForwardingKind::Agent` (SSH-agent forwarding). The new variant rides
|
||||||
|
// inside `NativeUserAuth.requested_forwardings`, so a pre-agent peer would fail to
|
||||||
|
// deserialize it; bumping the wire version makes such a peer answer with a clear
|
||||||
|
// version-mismatch reject instead. Existing variants' bincode discriminants are
|
||||||
|
// unchanged, so the bump is purely a compatibility gate.
|
||||||
|
pub const VERSION: u8 = 4;
|
||||||
pub const HEADER_LEN: usize = 58;
|
pub const HEADER_LEN: usize = 58;
|
||||||
|
|
||||||
|
/// Stable, user-facing reason string the server puts in an `AttachReject` when a
|
||||||
|
/// native handshake arrives carrying a `protocol_version` it cannot speak. The
|
||||||
|
/// client recognizes this prefix and surfaces a clear "upgrade dosh" message
|
||||||
|
/// instead of the generic transport rejection or, worse, a silent timeout.
|
||||||
|
pub const VERSION_MISMATCH_REASON: &str = "protocol version mismatch — upgrade dosh";
|
||||||
const HEADER_AAD_LEN: usize = HEADER_LEN - 2;
|
const HEADER_AAD_LEN: usize = HEADER_LEN - 2;
|
||||||
pub const CLIENT_TO_SERVER: u32 = 1;
|
pub const CLIENT_TO_SERVER: u32 = 1;
|
||||||
pub const SERVER_TO_CLIENT: u32 = 2;
|
pub const SERVER_TO_CLIENT: u32 = 2;
|
||||||
@@ -39,6 +85,8 @@ pub enum PacketKind {
|
|||||||
StreamEof = 23,
|
StreamEof = 23,
|
||||||
StreamClose = 24,
|
StreamClose = 24,
|
||||||
NativeAuthCheckOk = 25,
|
NativeAuthCheckOk = 25,
|
||||||
|
Rekey = 26,
|
||||||
|
RekeyAck = 27,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<u8> for PacketKind {
|
impl TryFrom<u8> for PacketKind {
|
||||||
@@ -71,6 +119,8 @@ impl TryFrom<u8> for PacketKind {
|
|||||||
23 => Self::StreamEof,
|
23 => Self::StreamEof,
|
||||||
24 => Self::StreamClose,
|
24 => Self::StreamClose,
|
||||||
25 => Self::NativeAuthCheckOk,
|
25 => Self::NativeAuthCheckOk,
|
||||||
|
26 => Self::Rekey,
|
||||||
|
27 => Self::RekeyAck,
|
||||||
_ => bail!("unknown packet kind {value}"),
|
_ => bail!("unknown packet kind {value}"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -110,7 +160,11 @@ impl Header {
|
|||||||
bail!("bad magic");
|
bail!("bad magic");
|
||||||
}
|
}
|
||||||
if input[4] != VERSION {
|
if input[4] != VERSION {
|
||||||
bail!("bad protocol version {}", input[4]);
|
bail!(
|
||||||
|
"{} (peer wire protocol v{}, this build speaks v{VERSION})",
|
||||||
|
VERSION_MISMATCH_REASON,
|
||||||
|
input[4]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let kind = PacketKind::try_from(input[5])?;
|
let kind = PacketKind::try_from(input[5])?;
|
||||||
let flags = u16::from_be_bytes(input[6..8].try_into().unwrap());
|
let flags = u16::from_be_bytes(input[6..8].try_into().unwrap());
|
||||||
@@ -133,6 +187,20 @@ impl Header {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cheaply inspect a datagram that carries our [`MAGIC`] but whose wire
|
||||||
|
/// [`VERSION`] byte differs from this build's. Returns the peer's advertised
|
||||||
|
/// wire version when (and only when) the packet is a Dosh packet we cannot
|
||||||
|
/// otherwise decode because of a version skew, so the receiver can answer with a
|
||||||
|
/// clear, named version-mismatch reject instead of dropping it (a silent
|
||||||
|
/// timeout for the peer). Returns `None` for our own version, foreign magic, or
|
||||||
|
/// runt packets.
|
||||||
|
pub fn peek_foreign_wire_version(input: &[u8]) -> Option<u8> {
|
||||||
|
if input.len() < 5 || &input[..4] != MAGIC || input[4] == VERSION {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(input[4])
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Packet {
|
pub struct Packet {
|
||||||
pub header: Header,
|
pub header: Header,
|
||||||
@@ -212,7 +280,7 @@ pub fn decode(input: &[u8]) -> Result<Packet> {
|
|||||||
|
|
||||||
pub fn decrypt_body(packet: &Packet, key: &[u8; 32], direction: u32) -> Result<Vec<u8>> {
|
pub fn decrypt_body(packet: &Packet, key: &[u8; 32], direction: u32) -> Result<Vec<u8>> {
|
||||||
if packet.header.flags & 1 == 0 {
|
if packet.header.flags & 1 == 0 {
|
||||||
return Ok(packet.body.clone());
|
bail!("packet body is not encrypted");
|
||||||
}
|
}
|
||||||
let nonce = crypto::nonce_from(direction, packet.header.seq);
|
let nonce = crypto::nonce_from(direction, packet.header.seq);
|
||||||
let aad = packet.header.aad();
|
let aad = packet.header.aad();
|
||||||
@@ -289,6 +357,10 @@ pub struct NativeAuthCheckOkBody {
|
|||||||
pub allow_tcp_forwarding: bool,
|
pub allow_tcp_forwarding: bool,
|
||||||
pub allow_remote_forwarding: bool,
|
pub allow_remote_forwarding: bool,
|
||||||
pub allow_agent_forwarding: bool,
|
pub allow_agent_forwarding: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_file_transfer: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_exec_command: bool,
|
||||||
pub policy_flags: Vec<String>,
|
pub policy_flags: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub server_version: String,
|
pub server_version: String,
|
||||||
@@ -350,12 +422,14 @@ pub struct StreamOpenReject {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StreamData {
|
pub struct StreamData {
|
||||||
pub stream_id: u64,
|
pub stream_id: u64,
|
||||||
|
pub offset: u64,
|
||||||
pub bytes: Vec<u8>,
|
pub bytes: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StreamWindowAdjust {
|
pub struct StreamWindowAdjust {
|
||||||
pub stream_id: u64,
|
pub stream_id: u64,
|
||||||
|
pub received_offset: u64,
|
||||||
pub bytes: u32,
|
pub bytes: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +443,20 @@ pub struct StreamClose {
|
|||||||
pub stream_id: u64,
|
pub stream_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Server→client transport rekey, sealed under the *current* session key.
|
||||||
|
///
|
||||||
|
/// Carries the fresh server-generated `rekey_material` and the new `epoch`; both
|
||||||
|
/// peers feed these into [`crate::native::derive_rekey_session_key`] to compute
|
||||||
|
/// the next traffic key. `new_session_key_id` is the id the next epoch's packets
|
||||||
|
/// will carry, so the client can recognize and switch atomically. The client
|
||||||
|
/// replies with a [`PacketKind::RekeyAck`] encrypted under the *new* key.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Rekey {
|
||||||
|
pub epoch: u64,
|
||||||
|
pub rekey_material: [u8; 32],
|
||||||
|
pub new_session_key_id: [u8; 16],
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Frame {
|
pub struct Frame {
|
||||||
pub session: String,
|
pub session: String,
|
||||||
@@ -449,3 +537,41 @@ impl ReplayWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod session_name_tests {
|
||||||
|
use super::{generate_implicit_session_name, is_implicit_session_name};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_names_are_recognized_as_implicit() {
|
||||||
|
let name = generate_implicit_session_name();
|
||||||
|
assert!(is_implicit_session_name(&name), "generated: {name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_and_prewarm_names_are_not_implicit() {
|
||||||
|
for name in [
|
||||||
|
"default",
|
||||||
|
"work",
|
||||||
|
"logs",
|
||||||
|
"term",
|
||||||
|
"term-",
|
||||||
|
"term-abc-1",
|
||||||
|
"term-1-x",
|
||||||
|
"term-1",
|
||||||
|
"term-1-2-3",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!is_implicit_session_name(name),
|
||||||
|
"should not be implicit: {name:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn implicit_shape_matches() {
|
||||||
|
assert!(is_implicit_session_name("term-1781470634216-76685"));
|
||||||
|
assert!(is_implicit_session_name("term-0-0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+209
-14
@@ -1,14 +1,42 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use portable_pty::{CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
|
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::path::Path;
|
use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
// Keep live terminal output comfortably below common path MTUs after Dosh's
|
||||||
|
// protocol header, AEAD tag, UDP/IP headers, and bincode framing. Full-screen
|
||||||
|
// TUIs often write several KiB on the first draw; sending that as one UDP
|
||||||
|
// datagram can fragment and vanish, leaving only a blank alternate screen.
|
||||||
|
const PTY_OUTPUT_CHUNK_BYTES: usize = 1024;
|
||||||
|
|
||||||
|
/// Backing for a PTY master held by the server.
|
||||||
|
///
|
||||||
|
/// `Owned` means this process spawned the shell as a child and is responsible
|
||||||
|
/// for it: dropping the handle kills the shell. This is the original,
|
||||||
|
/// non-persistent model and stays the default.
|
||||||
|
///
|
||||||
|
/// `Adopted` means the shell lives in a separate holder process and this handle
|
||||||
|
/// only borrows the master fd (received over a Unix socket via SCM_RIGHTS).
|
||||||
|
/// Dropping it must NOT kill the shell — it just closes our copy of the fd and
|
||||||
|
/// stops the reader thread, leaving the holder + shell alive so a server restart
|
||||||
|
/// can re-adopt them.
|
||||||
|
enum Backing {
|
||||||
|
Owned {
|
||||||
|
child: Mutex<Box<dyn Child + Send + Sync>>,
|
||||||
|
_master: Box<dyn MasterPty + Send>,
|
||||||
|
},
|
||||||
|
Adopted {
|
||||||
|
master: Mutex<std::fs::File>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PtyHandle {
|
pub struct PtyHandle {
|
||||||
writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
||||||
_master: Box<dyn MasterPty + Send>,
|
backing: Backing,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PtyHandle {
|
impl PtyHandle {
|
||||||
@@ -20,14 +48,69 @@ impl PtyHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
|
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
|
||||||
self._master.resize(PtySize {
|
match &self.backing {
|
||||||
|
Backing::Owned { _master, .. } => {
|
||||||
|
_master.resize(PtySize {
|
||||||
rows,
|
rows,
|
||||||
cols,
|
cols,
|
||||||
pixel_width: 0,
|
pixel_width: 0,
|
||||||
pixel_height: 0,
|
pixel_height: 0,
|
||||||
})?;
|
})?;
|
||||||
|
}
|
||||||
|
Backing::Adopted { master } => {
|
||||||
|
let file = master.lock().expect("pty master poisoned");
|
||||||
|
resize_fd(file.as_raw_fd(), cols, rows)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True for a handle backed by a separate holder process. Such a handle must
|
||||||
|
/// be detached (not killed) when the server lets go of a session, so the
|
||||||
|
/// shell survives a server restart.
|
||||||
|
pub fn is_persistent(&self) -> bool {
|
||||||
|
matches!(self.backing, Backing::Adopted { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminate the shell process backing this PTY and reap it.
|
||||||
|
///
|
||||||
|
/// Only meaningful for an `Owned` backing (the server spawned the shell as a
|
||||||
|
/// child). For an `Adopted` backing the shell belongs to the holder process,
|
||||||
|
/// so this is a no-op here; reaping a persistent session is done by asking
|
||||||
|
/// the holder to shut down (see `persist::request_shutdown`).
|
||||||
|
pub fn kill(&self) {
|
||||||
|
if let Backing::Owned { child, .. } = &self.backing
|
||||||
|
&& let Ok(mut child) = child.lock()
|
||||||
|
{
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PtyHandle {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Adopted handles must NOT kill the shell: it lives in the holder so it
|
||||||
|
// can outlive this server. Dropping just closes our fd / stops the
|
||||||
|
// reader. Owned handles keep the original kill-on-drop behavior.
|
||||||
|
if let Backing::Owned { .. } = &self.backing {
|
||||||
|
self.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_fd(fd: RawFd, cols: u16, rows: u16) -> Result<()> {
|
||||||
|
let winsize = libc::winsize {
|
||||||
|
ws_row: rows,
|
||||||
|
ws_col: cols,
|
||||||
|
ws_xpixel: 0,
|
||||||
|
ws_ypixel: 0,
|
||||||
|
};
|
||||||
|
let rc = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &winsize) };
|
||||||
|
if rc != 0 {
|
||||||
|
return Err(std::io::Error::last_os_error()).context("TIOCSWINSZ");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -54,12 +137,82 @@ pub fn spawn_pty_session(
|
|||||||
pixel_height: 0,
|
pixel_height: 0,
|
||||||
})
|
})
|
||||||
.context("open pty")?;
|
.context("open pty")?;
|
||||||
|
let cmd = build_shell_command(shell, env);
|
||||||
|
let child = pair.slave.spawn_command(cmd).context("spawn shell")?;
|
||||||
|
drop(pair.slave);
|
||||||
|
|
||||||
|
let writer = pair.master.take_writer().context("take pty writer")?;
|
||||||
|
let reader = pair.master.try_clone_reader().context("clone pty reader")?;
|
||||||
|
spawn_reader_thread(session, reader, tx)?;
|
||||||
|
|
||||||
|
Ok(PtyHandle {
|
||||||
|
writer: Arc::new(Mutex::new(writer)),
|
||||||
|
backing: Backing::Owned {
|
||||||
|
child: Mutex::new(child),
|
||||||
|
_master: pair.master,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this host has a terminfo entry for `term`, searching the standard
|
||||||
|
/// ncurses directories. Both the legacy single-letter (`x/xterm`) and the
|
||||||
|
/// hashed (`78/xterm`) subdirectory layouts are checked.
|
||||||
|
fn terminfo_available(term: &str) -> bool {
|
||||||
|
let Some(first) = term.chars().next() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let letter = first.to_string();
|
||||||
|
let hashed = format!("{:x}", first as u32);
|
||||||
|
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||||
|
if let Ok(t) = std::env::var("TERMINFO")
|
||||||
|
&& !t.is_empty()
|
||||||
|
{
|
||||||
|
dirs.push(PathBuf::from(t));
|
||||||
|
}
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
dirs.push(home.join(".terminfo"));
|
||||||
|
}
|
||||||
|
if let Ok(td) = std::env::var("TERMINFO_DIRS") {
|
||||||
|
for d in td.split(':').filter(|d| !d.is_empty()) {
|
||||||
|
dirs.push(PathBuf::from(d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for d in [
|
||||||
|
"/etc/terminfo",
|
||||||
|
"/lib/terminfo",
|
||||||
|
"/usr/share/terminfo",
|
||||||
|
"/usr/lib/terminfo",
|
||||||
|
"/usr/share/lib/terminfo",
|
||||||
|
] {
|
||||||
|
dirs.push(PathBuf::from(d));
|
||||||
|
}
|
||||||
|
dirs.iter()
|
||||||
|
.any(|dir| dir.join(&letter).join(term).exists() || dir.join(&hashed).join(term).exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the [`CommandBuilder`] for a dosh shell, identically for the in-process
|
||||||
|
/// `spawn_pty_session` and the out-of-process holder, so a persistent session's
|
||||||
|
/// environment matches a non-persistent one.
|
||||||
|
pub fn build_shell_command(shell: &str, env: &[(String, String)]) -> CommandBuilder {
|
||||||
let mut cmd = CommandBuilder::new(shell);
|
let mut cmd = CommandBuilder::new(shell);
|
||||||
cmd.env("TERM", "xterm-256color");
|
|
||||||
cmd.env("COLORTERM", "truecolor");
|
cmd.env("COLORTERM", "truecolor");
|
||||||
for (name, value) in env {
|
for (name, value) in env {
|
||||||
cmd.env(name, value);
|
cmd.env(name, value);
|
||||||
}
|
}
|
||||||
|
// The client's TERM is propagated, but a server that lacks that terminal's
|
||||||
|
// terminfo entry (e.g. xterm-ghostty, xterm-kitty) breaks ncurses apps like
|
||||||
|
// tmux/vim with "missing or unsuitable terminal". Keep the requested TERM
|
||||||
|
// only when this host actually has its terminfo; otherwise fall back to a
|
||||||
|
// universally available entry so remote apps always work.
|
||||||
|
let term = match env
|
||||||
|
.iter()
|
||||||
|
.find(|(n, _)| n == "TERM")
|
||||||
|
.map(|(_, v)| v.as_str())
|
||||||
|
{
|
||||||
|
Some(requested) if terminfo_available(requested) => requested.to_string(),
|
||||||
|
_ => "xterm-256color".to_string(),
|
||||||
|
};
|
||||||
|
cmd.env("TERM", term);
|
||||||
cmd.env("SHELL", shell);
|
cmd.env("SHELL", shell);
|
||||||
if let Some(home) = dirs::home_dir() {
|
if let Some(home) = dirs::home_dir() {
|
||||||
cmd.env("HOME", home.as_os_str());
|
cmd.env("HOME", home.as_os_str());
|
||||||
@@ -68,11 +221,39 @@ pub fn spawn_pty_session(
|
|||||||
} else if let Some(parent) = Path::new(shell).parent() {
|
} else if let Some(parent) = Path::new(shell).parent() {
|
||||||
cmd.env("PWD", parent.as_os_str());
|
cmd.env("PWD", parent.as_os_str());
|
||||||
}
|
}
|
||||||
let _child = pair.slave.spawn_command(cmd).context("spawn shell")?;
|
cmd
|
||||||
drop(pair.slave);
|
}
|
||||||
|
|
||||||
let writer = pair.master.take_writer().context("take pty writer")?;
|
/// Build a [`PtyHandle`] from a master fd received from a holder process.
|
||||||
let mut reader = pair.master.try_clone_reader().context("clone pty reader")?;
|
///
|
||||||
|
/// `master_fd` is an fd this handle takes ownership of (it is wrapped in a
|
||||||
|
/// `File` and closed on drop). The shell is NOT a child of this process; it
|
||||||
|
/// belongs to the holder, so dropping this handle leaves it running.
|
||||||
|
pub fn adopt_pty_from_fd(
|
||||||
|
session: String,
|
||||||
|
master_fd: RawFd,
|
||||||
|
tx: mpsc::UnboundedSender<PtyOutput>,
|
||||||
|
) -> Result<PtyHandle> {
|
||||||
|
// Take ownership of the fd. A clone gives us an independent reader so the
|
||||||
|
// reader thread and the writer/resize side hold separate `File`s and don't
|
||||||
|
// get closed out from under each other.
|
||||||
|
let master = unsafe { std::fs::File::from_raw_fd(master_fd) };
|
||||||
|
let reader_file = master.try_clone().context("clone master for reader")?;
|
||||||
|
let writer_file = master.try_clone().context("clone master for writer")?;
|
||||||
|
spawn_reader_thread(session, Box::new(reader_file), tx)?;
|
||||||
|
Ok(PtyHandle {
|
||||||
|
writer: Arc::new(Mutex::new(Box::new(writer_file))),
|
||||||
|
backing: Backing::Adopted {
|
||||||
|
master: Mutex::new(master),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_reader_thread(
|
||||||
|
session: String,
|
||||||
|
mut reader: Box<dyn Read + Send>,
|
||||||
|
tx: mpsc::UnboundedSender<PtyOutput>,
|
||||||
|
) -> Result<()> {
|
||||||
let reader_session = session.clone();
|
let reader_session = session.clone();
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name(format!("dosh-pty-{session}"))
|
.name(format!("dosh-pty-{session}"))
|
||||||
@@ -89,12 +270,14 @@ pub fn spawn_pty_session(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
|
for chunk in buf[..n].chunks(PTY_OUTPUT_CHUNK_BYTES) {
|
||||||
let _ = tx.send(PtyOutput {
|
let _ = tx.send(PtyOutput {
|
||||||
session: reader_session.clone(),
|
session: reader_session.clone(),
|
||||||
bytes: buf[..n].to_vec(),
|
bytes: chunk.to_vec(),
|
||||||
exited: false,
|
exited: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = tx.send(PtyOutput {
|
let _ = tx.send(PtyOutput {
|
||||||
session: reader_session.clone(),
|
session: reader_session.clone(),
|
||||||
@@ -107,9 +290,21 @@ pub fn spawn_pty_session(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.context("spawn pty reader")?;
|
.context("spawn pty reader")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
Ok(PtyHandle {
|
#[cfg(test)]
|
||||||
writer: Arc::new(Mutex::new(writer)),
|
mod tests {
|
||||||
_master: pair.master,
|
use super::*;
|
||||||
})
|
|
||||||
|
const _: () = assert!(PTY_OUTPUT_CHUNK_BYTES <= 1200);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminfo_available_detects_known_and_unknown() {
|
||||||
|
// A near-universal entry should be present on any host with ncurses.
|
||||||
|
assert!(terminfo_available("xterm") || terminfo_available("xterm-256color"));
|
||||||
|
// Bogus / empty names must report missing so we fall back.
|
||||||
|
assert!(!terminfo_available("definitely-not-a-real-terminal-xyz"));
|
||||||
|
assert!(!terminfo_available(""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+705
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
-26
@@ -1,26 +1,43 @@
|
|||||||
|
use crate::native::{ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth};
|
||||||
|
#[cfg(unix)]
|
||||||
use crate::native::{
|
use crate::native::{
|
||||||
ForwardingRequest, NativeClientHello, NativeServerHello, NativeUserAuth,
|
is_supported_user_key_algorithm, parse_ssh_ed25519_public_blob, user_auth_transcript,
|
||||||
parse_ssh_ed25519_public_blob, user_auth_transcript,
|
|
||||||
};
|
};
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
#[cfg(unix)]
|
||||||
|
use anyhow::{Context, bail};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
#[cfg(unix)]
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
|
#[cfg(unix)]
|
||||||
use std::os::unix::net::UnixStream;
|
use std::os::unix::net::UnixStream;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
const SSH_AGENT_FAILURE: u8 = 5;
|
const SSH_AGENT_FAILURE: u8 = 5;
|
||||||
|
#[cfg(unix)]
|
||||||
const SSH2_AGENTC_REQUEST_IDENTITIES: u8 = 11;
|
const SSH2_AGENTC_REQUEST_IDENTITIES: u8 = 11;
|
||||||
|
#[cfg(unix)]
|
||||||
const SSH2_AGENT_IDENTITIES_ANSWER: u8 = 12;
|
const SSH2_AGENT_IDENTITIES_ANSWER: u8 = 12;
|
||||||
|
#[cfg(unix)]
|
||||||
const SSH2_AGENTC_SIGN_REQUEST: u8 = 13;
|
const SSH2_AGENTC_SIGN_REQUEST: u8 = 13;
|
||||||
|
#[cfg(unix)]
|
||||||
const SSH2_AGENT_SIGN_RESPONSE: u8 = 14;
|
const SSH2_AGENT_SIGN_RESPONSE: u8 = 14;
|
||||||
|
#[cfg(unix)]
|
||||||
|
const SSH_AGENT_RSA_SHA2_512: u32 = 4;
|
||||||
|
#[cfg(unix)]
|
||||||
const MAX_AGENT_PACKET: usize = 256 * 1024;
|
const MAX_AGENT_PACKET: usize = 256 * 1024;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct AgentIdentity {
|
pub struct AgentIdentity {
|
||||||
pub key_blob: Vec<u8>,
|
pub key_blob: Vec<u8>,
|
||||||
pub public_key: [u8; 32],
|
pub public_key_algorithm: String,
|
||||||
|
pub public_key: Vec<u8>,
|
||||||
|
pub sign_algorithm: String,
|
||||||
|
pub sign_flags: u32,
|
||||||
pub comment: String,
|
pub comment: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
pub fn sign_user_auth_with_agent(
|
pub fn sign_user_auth_with_agent(
|
||||||
client: &NativeClientHello,
|
client: &NativeClientHello,
|
||||||
server: &NativeServerHello,
|
server: &NativeServerHello,
|
||||||
@@ -30,6 +47,18 @@ pub fn sign_user_auth_with_agent(
|
|||||||
sign_user_auth_with_agent_at(sock, client, server, requested_forwardings)
|
sign_user_auth_with_agent_at(sock, client, server, requested_forwardings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
pub fn sign_user_auth_with_agent(
|
||||||
|
_client: &NativeClientHello,
|
||||||
|
_server: &NativeServerHello,
|
||||||
|
_requested_forwardings: Vec<ForwardingRequest>,
|
||||||
|
) -> Result<NativeUserAuth> {
|
||||||
|
Err(anyhow!(
|
||||||
|
"ssh-agent native auth is not supported on this platform yet; use identity_files"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
pub fn sign_user_auth_with_agent_at(
|
pub fn sign_user_auth_with_agent_at(
|
||||||
socket_path: impl AsRef<Path>,
|
socket_path: impl AsRef<Path>,
|
||||||
client: &NativeClientHello,
|
client: &NativeClientHello,
|
||||||
@@ -38,13 +67,13 @@ pub fn sign_user_auth_with_agent_at(
|
|||||||
) -> Result<NativeUserAuth> {
|
) -> Result<NativeUserAuth> {
|
||||||
let mut agent = UnixStream::connect(socket_path.as_ref())
|
let mut agent = UnixStream::connect(socket_path.as_ref())
|
||||||
.with_context(|| format!("connect ssh-agent {}", socket_path.as_ref().display()))?;
|
.with_context(|| format!("connect ssh-agent {}", socket_path.as_ref().display()))?;
|
||||||
let identities = request_ed25519_identities(&mut agent)?;
|
let identities = request_supported_identities(&mut agent)?;
|
||||||
let identity = identities
|
let identity = identities
|
||||||
.first()
|
.first()
|
||||||
.ok_or_else(|| anyhow!("ssh-agent has no ssh-ed25519 identities"))?;
|
.ok_or_else(|| anyhow!("ssh-agent has no supported identities"))?;
|
||||||
let mut auth = NativeUserAuth {
|
let mut auth = NativeUserAuth {
|
||||||
public_key_algorithm: "ssh-ed25519".to_string(),
|
public_key_algorithm: identity.sign_algorithm.clone(),
|
||||||
public_key: identity.public_key.to_vec(),
|
public_key: identity.public_key.clone(),
|
||||||
signature: Vec::new(),
|
signature: Vec::new(),
|
||||||
requested_forwardings,
|
requested_forwardings,
|
||||||
};
|
};
|
||||||
@@ -54,7 +83,20 @@ pub fn sign_user_auth_with_agent_at(
|
|||||||
Ok(auth)
|
Ok(auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> {
|
#[cfg(not(unix))]
|
||||||
|
pub fn sign_user_auth_with_agent_at(
|
||||||
|
_socket_path: impl AsRef<Path>,
|
||||||
|
_client: &NativeClientHello,
|
||||||
|
_server: &NativeServerHello,
|
||||||
|
_requested_forwardings: Vec<ForwardingRequest>,
|
||||||
|
) -> Result<NativeUserAuth> {
|
||||||
|
Err(anyhow!(
|
||||||
|
"ssh-agent native auth is not supported on this platform yet; use identity_files"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn request_supported_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentity>> {
|
||||||
write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?;
|
write_agent_packet(agent, &[SSH2_AGENTC_REQUEST_IDENTITIES])?;
|
||||||
let payload = read_agent_packet(agent)?;
|
let payload = read_agent_packet(agent)?;
|
||||||
let mut cursor = payload.as_slice();
|
let mut cursor = payload.as_slice();
|
||||||
@@ -72,18 +114,51 @@ fn request_ed25519_identities(agent: &mut UnixStream) -> Result<Vec<AgentIdentit
|
|||||||
for _ in 0..count {
|
for _ in 0..count {
|
||||||
let key_blob = read_ssh_string(&mut cursor)?.to_vec();
|
let key_blob = read_ssh_string(&mut cursor)?.to_vec();
|
||||||
let comment = String::from_utf8_lossy(read_ssh_string(&mut cursor)?).to_string();
|
let comment = String::from_utf8_lossy(read_ssh_string(&mut cursor)?).to_string();
|
||||||
if let Ok(public_key) = parse_ssh_ed25519_public_blob(&key_blob) {
|
if let Some(identity) = supported_identity(key_blob, comment)? {
|
||||||
identities.push(AgentIdentity {
|
identities.push(identity);
|
||||||
key_blob,
|
|
||||||
public_key,
|
|
||||||
comment,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities");
|
anyhow::ensure!(cursor.is_empty(), "trailing data in ssh-agent identities");
|
||||||
Ok(identities)
|
Ok(identities)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn supported_identity(key_blob: Vec<u8>, comment: String) -> Result<Option<AgentIdentity>> {
|
||||||
|
let algorithm = key_blob_algorithm(&key_blob)?;
|
||||||
|
if !is_supported_user_key_algorithm(&algorithm) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let identity = match algorithm.as_str() {
|
||||||
|
"ssh-ed25519" => AgentIdentity {
|
||||||
|
public_key_algorithm: algorithm.clone(),
|
||||||
|
public_key: parse_ssh_ed25519_public_blob(&key_blob)?.to_vec(),
|
||||||
|
sign_algorithm: "ssh-ed25519".to_string(),
|
||||||
|
sign_flags: 0,
|
||||||
|
key_blob,
|
||||||
|
comment,
|
||||||
|
},
|
||||||
|
"ecdsa-sha2-nistp256" => AgentIdentity {
|
||||||
|
public_key_algorithm: algorithm.clone(),
|
||||||
|
public_key: key_blob.clone(),
|
||||||
|
sign_algorithm: algorithm,
|
||||||
|
sign_flags: 0,
|
||||||
|
key_blob,
|
||||||
|
comment,
|
||||||
|
},
|
||||||
|
"ssh-rsa" => AgentIdentity {
|
||||||
|
public_key_algorithm: algorithm,
|
||||||
|
public_key: key_blob.clone(),
|
||||||
|
sign_algorithm: "rsa-sha2-512".to_string(),
|
||||||
|
sign_flags: SSH_AGENT_RSA_SHA2_512,
|
||||||
|
key_blob,
|
||||||
|
comment,
|
||||||
|
},
|
||||||
|
_ => return Ok(None),
|
||||||
|
};
|
||||||
|
Ok(Some(identity))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn sign_with_agent(
|
fn sign_with_agent(
|
||||||
agent: &mut UnixStream,
|
agent: &mut UnixStream,
|
||||||
identity: &AgentIdentity,
|
identity: &AgentIdentity,
|
||||||
@@ -93,7 +168,7 @@ fn sign_with_agent(
|
|||||||
request.push(SSH2_AGENTC_SIGN_REQUEST);
|
request.push(SSH2_AGENTC_SIGN_REQUEST);
|
||||||
write_ssh_string(&mut request, &identity.key_blob);
|
write_ssh_string(&mut request, &identity.key_blob);
|
||||||
write_ssh_string(&mut request, transcript);
|
write_ssh_string(&mut request, transcript);
|
||||||
request.extend_from_slice(&0u32.to_be_bytes());
|
request.extend_from_slice(&identity.sign_flags.to_be_bytes());
|
||||||
write_agent_packet(agent, &request)?;
|
write_agent_packet(agent, &request)?;
|
||||||
|
|
||||||
let payload = read_agent_packet(agent)?;
|
let payload = read_agent_packet(agent)?;
|
||||||
@@ -114,23 +189,21 @@ fn sign_with_agent(
|
|||||||
|
|
||||||
let mut signature_cursor = signature_blob;
|
let mut signature_cursor = signature_blob;
|
||||||
let algorithm = read_ssh_string(&mut signature_cursor)?;
|
let algorithm = read_ssh_string(&mut signature_cursor)?;
|
||||||
|
let algorithm = String::from_utf8_lossy(algorithm);
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
algorithm == b"ssh-ed25519",
|
algorithm == identity.sign_algorithm,
|
||||||
"ssh-agent returned unsupported signature algorithm {}",
|
"ssh-agent returned signature algorithm {algorithm}, expected {}",
|
||||||
String::from_utf8_lossy(algorithm)
|
identity.sign_algorithm
|
||||||
);
|
);
|
||||||
let signature = read_ssh_string(&mut signature_cursor)?;
|
let signature = read_ssh_string(&mut signature_cursor)?;
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
signature_cursor.is_empty(),
|
signature_cursor.is_empty(),
|
||||||
"trailing data in ssh-agent signature blob"
|
"trailing data in ssh-agent signature blob"
|
||||||
);
|
);
|
||||||
anyhow::ensure!(
|
|
||||||
signature.len() == 64,
|
|
||||||
"ssh-agent Ed25519 signature was not 64 bytes"
|
|
||||||
);
|
|
||||||
Ok(signature.to_vec())
|
Ok(signature.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn read_agent_packet(stream: &mut UnixStream) -> Result<Vec<u8>> {
|
fn read_agent_packet(stream: &mut UnixStream) -> Result<Vec<u8>> {
|
||||||
let mut len = [0u8; 4];
|
let mut len = [0u8; 4];
|
||||||
stream
|
stream
|
||||||
@@ -145,6 +218,7 @@ fn read_agent_packet(stream: &mut UnixStream) -> Result<Vec<u8>> {
|
|||||||
Ok(payload)
|
Ok(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> {
|
fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
payload.len() <= MAX_AGENT_PACKET,
|
payload.len() <= MAX_AGENT_PACKET,
|
||||||
@@ -155,6 +229,7 @@ fn write_agent_packet(stream: &mut UnixStream, payload: &[u8]) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn read_u8(cursor: &mut &[u8]) -> Result<u8> {
|
fn read_u8(cursor: &mut &[u8]) -> Result<u8> {
|
||||||
anyhow::ensure!(!cursor.is_empty(), "truncated u8");
|
anyhow::ensure!(!cursor.is_empty(), "truncated u8");
|
||||||
let value = cursor[0];
|
let value = cursor[0];
|
||||||
@@ -162,6 +237,7 @@ fn read_u8(cursor: &mut &[u8]) -> Result<u8> {
|
|||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn read_u32(cursor: &mut &[u8]) -> Result<u32> {
|
fn read_u32(cursor: &mut &[u8]) -> Result<u32> {
|
||||||
anyhow::ensure!(cursor.len() >= 4, "truncated u32");
|
anyhow::ensure!(cursor.len() >= 4, "truncated u32");
|
||||||
let value = u32::from_be_bytes(cursor[..4].try_into().unwrap());
|
let value = u32::from_be_bytes(cursor[..4].try_into().unwrap());
|
||||||
@@ -169,6 +245,7 @@ fn read_u32(cursor: &mut &[u8]) -> Result<u32> {
|
|||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
|
fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
|
||||||
let len = read_u32(cursor)? as usize;
|
let len = read_u32(cursor)? as usize;
|
||||||
anyhow::ensure!(cursor.len() >= len, "truncated SSH string");
|
anyhow::ensure!(cursor.len() >= len, "truncated SSH string");
|
||||||
@@ -177,12 +254,20 @@ fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
|
|||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
|
fn write_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
|
||||||
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
|
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
|
||||||
out.extend_from_slice(value);
|
out.extend_from_slice(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(unix)]
|
||||||
|
fn key_blob_algorithm(blob: &[u8]) -> Result<String> {
|
||||||
|
let mut cursor = blob;
|
||||||
|
let algorithm = read_ssh_string(&mut cursor)?;
|
||||||
|
Ok(String::from_utf8_lossy(algorithm).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, unix))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::native::{
|
use crate::native::{
|
||||||
@@ -253,8 +338,8 @@ mod tests {
|
|||||||
protocol_version: crate::native::NATIVE_PROTOCOL_VERSION,
|
protocol_version: crate::native::NATIVE_PROTOCOL_VERSION,
|
||||||
client_random: [1u8; 32],
|
client_random: [1u8; 32],
|
||||||
client_ephemeral_public: [2u8; 32],
|
client_ephemeral_public: [2u8; 32],
|
||||||
requested_host: "palav".to_string(),
|
requested_host: "homelab".to_string(),
|
||||||
requested_user: "palav".to_string(),
|
requested_user: "alice".to_string(),
|
||||||
requested_session: "term".to_string(),
|
requested_session: "term".to_string(),
|
||||||
requested_mode: "read-write".to_string(),
|
requested_mode: "read-write".to_string(),
|
||||||
terminal_size: (80, 24),
|
terminal_size: (80, 24),
|
||||||
|
|||||||
+1680
File diff suppressed because it is too large
Load Diff
+98
@@ -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)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+1960
-16
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
|||||||
|
//! Parser robustness tests (Track B, spec milestone 5 / §16 "Fuzz packet parsing").
|
||||||
|
//!
|
||||||
|
//! These throw arbitrary/garbage bytes at every reachable public parser in the
|
||||||
|
//! `dosh` library and assert that NONE of them panic. A parser is allowed to
|
||||||
|
//! return `Ok` (if the bytes happened to be valid) or `Err`, but a panic on
|
||||||
|
//! untrusted input is a denial-of-service / robustness bug against a hostile
|
||||||
|
//! network attacker (threat model §5: "Active network attacker that can spoof
|
||||||
|
//! ... or modify packets").
|
||||||
|
//!
|
||||||
|
//! Determinism: a fixed-seed PRNG (`rand::rngs::StdRng`) is used so failures are
|
||||||
|
//! reproducible. No external dependencies beyond what is already in Cargo.toml.
|
||||||
|
|
||||||
|
use std::panic::{self, AssertUnwindSafe};
|
||||||
|
|
||||||
|
use dosh::auth::{
|
||||||
|
AttachTicketPlain, BootstrapResponse, SealedAttachTicket, decode_bootstrap, open_attach_ticket,
|
||||||
|
verify_attach_ticket,
|
||||||
|
};
|
||||||
|
use dosh::native::{
|
||||||
|
AuthorizedKey, HostPublicKey, KnownHost, NativeAuthOk, NativeClientHello, NativeServerHello,
|
||||||
|
NativeUserAuth, parse_authorized_keys, parse_host_public_key_line, parse_known_hosts,
|
||||||
|
parse_ssh_ed25519_public_blob, verify_known_host,
|
||||||
|
};
|
||||||
|
use dosh::protocol::{
|
||||||
|
self, AttachOk, AttachReject, BootstrapAttachRequest, Frame, Header, Input,
|
||||||
|
NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody,
|
||||||
|
NativeUserAuthBody, Packet, Resize, ResumeRequest, StreamClose, StreamData, StreamEof,
|
||||||
|
StreamOpen, StreamOpenOk, StreamOpenReject, StreamWindowAdjust, TicketAttachBody,
|
||||||
|
TicketAttachEnvelope, TicketAttachOkEnvelope,
|
||||||
|
};
|
||||||
|
use rand::rngs::StdRng;
|
||||||
|
use rand::{Rng, RngCore, SeedableRng};
|
||||||
|
|
||||||
|
const ITERATIONS: usize = 4000;
|
||||||
|
|
||||||
|
/// Run `f` and convert a panic into a test failure with a descriptive message.
|
||||||
|
fn no_panic<F: FnOnce()>(label: &str, input: &[u8], f: F) {
|
||||||
|
let result = panic::catch_unwind(AssertUnwindSafe(f));
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"parser `{label}` PANICKED on input ({} bytes): {:02x?}",
|
||||||
|
input.len(),
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a variety of "interesting" byte buffers for a given iteration.
|
||||||
|
fn fuzz_bytes(rng: &mut StdRng) -> Vec<u8> {
|
||||||
|
let strategy = rng.gen_range(0..7u8);
|
||||||
|
match strategy {
|
||||||
|
0 => {
|
||||||
|
let len = rng.gen_range(0..1200);
|
||||||
|
let mut buf = vec![0u8; len];
|
||||||
|
rng.fill_bytes(&mut buf);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
let len = rng.gen_range(0..16);
|
||||||
|
let mut buf = vec![0u8; len];
|
||||||
|
rng.fill_bytes(&mut buf);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
let len = rng.gen_range(0..(protocol::HEADER_LEN + 64));
|
||||||
|
let mut buf = vec![0u8; len];
|
||||||
|
rng.fill_bytes(&mut buf);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
3 => vec![0u8; rng.gen_range(0..256)],
|
||||||
|
4 => vec![0xffu8; rng.gen_range(0..256)],
|
||||||
|
5 => {
|
||||||
|
// A valid-magic prefix followed by garbage to drive deeper paths.
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
buf.extend_from_slice(protocol::MAGIC);
|
||||||
|
buf.push(protocol::VERSION);
|
||||||
|
let extra = rng.gen_range(0..256);
|
||||||
|
let mut tail = vec![0u8; extra];
|
||||||
|
rng.fill_bytes(&mut tail);
|
||||||
|
buf.extend_from_slice(&tail);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Large length prefixes to provoke huge allocations / overflow in
|
||||||
|
// length fields (a classic deserialization hazard).
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
buf.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||||
|
let extra = rng.gen_range(0..64);
|
||||||
|
let mut tail = vec![0u8; extra];
|
||||||
|
rng.fill_bytes(&mut tail);
|
||||||
|
buf.extend_from_slice(&tail);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a possibly-valid UTF-8 string from random bytes (for text parsers).
|
||||||
|
fn fuzz_text(rng: &mut StdRng) -> String {
|
||||||
|
let len = rng.gen_range(0..256);
|
||||||
|
let mut s = String::new();
|
||||||
|
for _ in 0..len {
|
||||||
|
let pick = rng.gen_range(0..10u8);
|
||||||
|
let ch = match pick {
|
||||||
|
0 => ' ',
|
||||||
|
1 => '\n',
|
||||||
|
2 => '\t',
|
||||||
|
3 => '=',
|
||||||
|
4 => ',',
|
||||||
|
5 => '"',
|
||||||
|
6 => '/',
|
||||||
|
7 => rng.gen_range(b'a'..=b'z') as char,
|
||||||
|
8 => rng.gen_range(b'0'..=b'9') as char,
|
||||||
|
_ => char::from_u32(rng.gen_range(0..0x110000)).unwrap_or('?'),
|
||||||
|
};
|
||||||
|
s.push(ch);
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_packet_decode_never_panics() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(0xD05Au64);
|
||||||
|
for _ in 0..ITERATIONS {
|
||||||
|
let input = fuzz_bytes(&mut rng);
|
||||||
|
no_panic("protocol::decode", &input, || {
|
||||||
|
let _ = protocol::decode(&input);
|
||||||
|
});
|
||||||
|
no_panic("Header::parse", &input, || {
|
||||||
|
let _ = Header::parse(&input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `from_body` deserializes a bincode body into each protocol/native struct.
|
||||||
|
/// On the wire this runs on attacker-controlled bytes, so it must never panic.
|
||||||
|
#[test]
|
||||||
|
fn protocol_from_body_never_panics() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(0xBEEFu64);
|
||||||
|
|
||||||
|
macro_rules! body_target {
|
||||||
|
($input:expr, $ty:ty) => {{
|
||||||
|
let input = $input;
|
||||||
|
no_panic(concat!("from_body::<", stringify!($ty), ">"), input, || {
|
||||||
|
let _ = protocol::from_body::<$ty>(input);
|
||||||
|
});
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..ITERATIONS {
|
||||||
|
let input = fuzz_bytes(&mut rng);
|
||||||
|
let input = input.as_slice();
|
||||||
|
|
||||||
|
// protocol.rs structs
|
||||||
|
body_target!(input, BootstrapAttachRequest);
|
||||||
|
body_target!(input, TicketAttachEnvelope);
|
||||||
|
body_target!(input, TicketAttachBody);
|
||||||
|
body_target!(input, TicketAttachOkEnvelope);
|
||||||
|
body_target!(input, AttachOk);
|
||||||
|
body_target!(input, AttachReject);
|
||||||
|
body_target!(input, ResumeRequest);
|
||||||
|
body_target!(input, Input);
|
||||||
|
body_target!(input, Resize);
|
||||||
|
body_target!(input, Frame);
|
||||||
|
body_target!(input, StreamOpen);
|
||||||
|
body_target!(input, StreamOpenOk);
|
||||||
|
body_target!(input, StreamOpenReject);
|
||||||
|
body_target!(input, StreamData);
|
||||||
|
body_target!(input, StreamWindowAdjust);
|
||||||
|
body_target!(input, StreamEof);
|
||||||
|
body_target!(input, StreamClose);
|
||||||
|
|
||||||
|
// native handshake wrapper bodies
|
||||||
|
body_target!(input, NativeClientHelloBody);
|
||||||
|
body_target!(input, NativeServerHelloBody);
|
||||||
|
body_target!(input, NativeUserAuthBody);
|
||||||
|
body_target!(input, NativeAuthOkBody);
|
||||||
|
body_target!(input, NativeAuthCheckOkBody);
|
||||||
|
|
||||||
|
// bare native handshake structs
|
||||||
|
body_target!(input, NativeClientHello);
|
||||||
|
body_target!(input, NativeServerHello);
|
||||||
|
body_target!(input, NativeUserAuth);
|
||||||
|
body_target!(input, NativeAuthOk);
|
||||||
|
body_target!(input, HostPublicKey);
|
||||||
|
|
||||||
|
// auth.rs structs (deserialized from untrusted material too)
|
||||||
|
body_target!(input, BootstrapResponse);
|
||||||
|
body_target!(input, SealedAttachTicket);
|
||||||
|
body_target!(input, AttachTicketPlain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full decode -> decrypt_body pipeline on garbage. decrypt should Err (not
|
||||||
|
/// panic) on bad ciphertext / wrong key id / truncated body.
|
||||||
|
#[test]
|
||||||
|
fn protocol_decode_then_decrypt_never_panics() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(0x1234_5678u64);
|
||||||
|
let key = [7u8; 32];
|
||||||
|
for _ in 0..ITERATIONS {
|
||||||
|
let input = fuzz_bytes(&mut rng);
|
||||||
|
no_panic("decode+decrypt_body", &input, || {
|
||||||
|
if let Ok(packet) = protocol::decode(&input) {
|
||||||
|
let _ = protocol::decrypt_body(&packet, &key, protocol::CLIENT_TO_SERVER);
|
||||||
|
let _ = protocol::decrypt_body(&packet, &key, protocol::SERVER_TO_CLIENT);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutate a single byte of a valid encrypted packet; decode and decrypt must
|
||||||
|
/// not panic, and decryption of the mutated packet must fail (no double-apply).
|
||||||
|
#[test]
|
||||||
|
fn protocol_bit_flips_on_valid_packet_never_panic() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(0x900Du64);
|
||||||
|
let key = [9u8; 32];
|
||||||
|
let conn_id = [3u8; 16];
|
||||||
|
for _ in 0..1000 {
|
||||||
|
let mut plaintext = vec![0u8; rng.gen_range(0..200)];
|
||||||
|
rng.fill_bytes(&mut plaintext);
|
||||||
|
let seq = rng.gen_range(1..u64::MAX);
|
||||||
|
let Ok(mut packet) = protocol::encode_encrypted(
|
||||||
|
protocol::PacketKind::Input,
|
||||||
|
conn_id,
|
||||||
|
seq,
|
||||||
|
0,
|
||||||
|
&key,
|
||||||
|
protocol::CLIENT_TO_SERVER,
|
||||||
|
&plaintext,
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if packet.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let idx = rng.gen_range(0..packet.len());
|
||||||
|
packet[idx] ^= 1 << rng.gen_range(0..8);
|
||||||
|
no_panic("flip+decode+decrypt", &packet, || {
|
||||||
|
if let Ok(decoded) = protocol::decode(&packet) {
|
||||||
|
let _ = protocol::decrypt_body(&decoded, &key, protocol::CLIENT_TO_SERVER);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssh_ed25519_blob_parser_never_panics() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(0x5511u64);
|
||||||
|
for _ in 0..ITERATIONS {
|
||||||
|
let input = fuzz_bytes(&mut rng);
|
||||||
|
no_panic("parse_ssh_ed25519_public_blob", &input, || {
|
||||||
|
let _ = parse_ssh_ed25519_public_blob(&input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Targeted: length prefixes that lie about the body length.
|
||||||
|
for bad_len in [0u32, 1, 31, 32, 33, u32::MAX, u32::MAX - 1] {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
buf.extend_from_slice(&bad_len.to_be_bytes());
|
||||||
|
buf.extend_from_slice(b"ssh-ed25519");
|
||||||
|
buf.extend_from_slice(&32u32.to_be_bytes());
|
||||||
|
buf.extend_from_slice(&[0u8; 16]);
|
||||||
|
no_panic("parse_ssh_ed25519_public_blob:lying-len", &buf, || {
|
||||||
|
let _ = parse_ssh_ed25519_public_blob(&buf);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authorized_keys_parser_never_panics() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(0xA011u64);
|
||||||
|
for _ in 0..ITERATIONS {
|
||||||
|
let text = fuzz_text(&mut rng);
|
||||||
|
no_panic("parse_authorized_keys", text.as_bytes(), || {
|
||||||
|
let _ = parse_authorized_keys(&text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let crafted = [
|
||||||
|
"ssh-ed25519",
|
||||||
|
"ssh-ed25519 ",
|
||||||
|
"ssh-ed25519 not-base64!!!",
|
||||||
|
"from= ssh-ed25519 AAAA",
|
||||||
|
"from=\"unterminated ssh-ed25519 AAAA",
|
||||||
|
"command=\"x\\\" ssh-ed25519 AAAA",
|
||||||
|
"permitopen=,,, ssh-ed25519 AAAA",
|
||||||
|
"restrict,no-port-forwarding,from=\"127.0.0.1\" ssh-ed25519 AAAA comment",
|
||||||
|
"ssh-rsa AAAA",
|
||||||
|
"\u{0}\u{0}\u{0} ssh-ed25519 AAAA",
|
||||||
|
];
|
||||||
|
for line in crafted {
|
||||||
|
no_panic("parse_authorized_keys:crafted", line.as_bytes(), || {
|
||||||
|
let _ = parse_authorized_keys(line);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_hosts_parser_never_panics() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(0xC051u64);
|
||||||
|
for _ in 0..ITERATIONS {
|
||||||
|
let text = fuzz_text(&mut rng);
|
||||||
|
no_panic("parse_known_hosts", text.as_bytes(), || {
|
||||||
|
let _ = parse_known_hosts(&text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let crafted = [
|
||||||
|
"host",
|
||||||
|
"host dosh-ed25519",
|
||||||
|
"host dosh-ed25519 not-base64!!!",
|
||||||
|
"host wrong-algo AAAA",
|
||||||
|
"host dosh-ed25519 AAAA first-seen=notnum source=tofu",
|
||||||
|
"host dosh-ed25519 AAAA first-seen= source=",
|
||||||
|
"* dosh-ed25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
];
|
||||||
|
for line in crafted {
|
||||||
|
no_panic("parse_known_hosts:crafted", line.as_bytes(), || {
|
||||||
|
let _ = parse_known_hosts(line);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_public_key_line_parser_never_panics() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(0x4002u64);
|
||||||
|
for _ in 0..ITERATIONS {
|
||||||
|
let text = fuzz_text(&mut rng);
|
||||||
|
no_panic("parse_host_public_key_line", text.as_bytes(), || {
|
||||||
|
let _ = parse_host_public_key_line(&text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_bootstrap_never_panics() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(0xB007u64);
|
||||||
|
for _ in 0..ITERATIONS {
|
||||||
|
let text = fuzz_text(&mut rng);
|
||||||
|
no_panic("decode_bootstrap", text.as_bytes(), || {
|
||||||
|
let _ = decode_bootstrap(&text);
|
||||||
|
});
|
||||||
|
// Also feed base64-shaped random for the decode path proper.
|
||||||
|
let raw = fuzz_bytes(&mut rng);
|
||||||
|
use base64::Engine;
|
||||||
|
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&raw);
|
||||||
|
no_panic("decode_bootstrap:b64", b64.as_bytes(), || {
|
||||||
|
let _ = decode_bootstrap(&b64);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attach_ticket_open_and_verify_never_panic() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(0x7CE7u64);
|
||||||
|
let secret = [42u8; 32];
|
||||||
|
let psk = [11u8; 32];
|
||||||
|
for _ in 0..ITERATIONS {
|
||||||
|
let input = fuzz_bytes(&mut rng);
|
||||||
|
no_panic("open_attach_ticket", &input, || {
|
||||||
|
let _ = open_attach_ticket(&secret, &input);
|
||||||
|
});
|
||||||
|
no_panic("verify_attach_ticket", &input, || {
|
||||||
|
let _ = verify_attach_ticket(&secret, &input, &psk, "default", "read-write");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Throw garbage at the known-host verifier (file parse + host key compare).
|
||||||
|
#[test]
|
||||||
|
fn verify_known_host_with_garbage_keys_never_panics() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(0x9090u64);
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
for _ in 0..500 {
|
||||||
|
let text = fuzz_text(&mut rng);
|
||||||
|
let path = dir.path().join("known_hosts");
|
||||||
|
std::fs::write(&path, &text).unwrap();
|
||||||
|
let mut key_bytes = [0u8; 32];
|
||||||
|
rng.fill_bytes(&mut key_bytes);
|
||||||
|
let host = HostPublicKey {
|
||||||
|
algorithm: "dosh-ed25519".to_string(),
|
||||||
|
key: key_bytes,
|
||||||
|
};
|
||||||
|
let host_name = fuzz_text(&mut rng);
|
||||||
|
no_panic("verify_known_host", text.as_bytes(), || {
|
||||||
|
let _ = verify_known_host(&path, &host_name, &host);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression guard: valid inputs still parse, so the fuzz harness isn't
|
||||||
|
/// accidentally exercising a build where every path simply Errs.
|
||||||
|
#[test]
|
||||||
|
fn valid_inputs_still_parse() {
|
||||||
|
let key = [5u8; 32];
|
||||||
|
let blob = dosh::native::ssh_ed25519_public_blob(&key);
|
||||||
|
assert_eq!(parse_ssh_ed25519_public_blob(&blob).unwrap(), key);
|
||||||
|
|
||||||
|
let session_key = [1u8; 32];
|
||||||
|
let packet = protocol::encode_encrypted(
|
||||||
|
protocol::PacketKind::Input,
|
||||||
|
[2u8; 16],
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
&session_key,
|
||||||
|
protocol::CLIENT_TO_SERVER,
|
||||||
|
b"hello",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let decoded: Packet = protocol::decode(&packet).unwrap();
|
||||||
|
let plain = protocol::decrypt_body(&decoded, &session_key, protocol::CLIENT_TO_SERVER).unwrap();
|
||||||
|
assert_eq!(plain, b"hello");
|
||||||
|
|
||||||
|
assert!(parse_authorized_keys("").unwrap().is_empty());
|
||||||
|
assert!(parse_known_hosts("# just a comment\n").unwrap().is_empty());
|
||||||
|
|
||||||
|
// Reference types only otherwise used in macro expansions / signatures.
|
||||||
|
let _ = std::mem::size_of::<AuthorizedKey>();
|
||||||
|
let _ = std::mem::size_of::<KnownHost>();
|
||||||
|
}
|
||||||
@@ -58,6 +58,19 @@ fn encrypted_packet_round_trips() {
|
|||||||
assert_eq!(plain, b"hello");
|
assert_eq!(plain, b"hello");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plaintext_packet_never_decrypts_as_authenticated_body() {
|
||||||
|
let key = crypto::random_32();
|
||||||
|
let mut decoded = protocol::decode(
|
||||||
|
&protocol::encode_plain(PacketKind::Input, crypto::random_16(), 1, 0, b"hello").unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
decoded.header.session_key_id = protocol::session_key_id(&key);
|
||||||
|
|
||||||
|
let err = protocol::decrypt_body(&decoded, &key, CLIENT_TO_SERVER).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("not encrypted"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn encrypted_packet_rejects_wrong_session_key_id_before_decrypt() {
|
fn encrypted_packet_rejects_wrong_session_key_id_before_decrypt() {
|
||||||
let key = crypto::random_32();
|
let key = crypto::random_32();
|
||||||
@@ -126,6 +139,139 @@ fn attach_ticket_is_sealed_and_verifies_scope() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peek_foreign_wire_version_flags_only_version_skew() {
|
||||||
|
let key = crypto::random_32();
|
||||||
|
let mut packet = protocol::encode_encrypted(
|
||||||
|
PacketKind::Input,
|
||||||
|
crypto::random_16(),
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
&key,
|
||||||
|
CLIENT_TO_SERVER,
|
||||||
|
b"hi",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// A correctly framed packet for this build is not "foreign".
|
||||||
|
assert_eq!(protocol::peek_foreign_wire_version(&packet), None);
|
||||||
|
// Bumping the wire version byte makes it undecodable but recognizable.
|
||||||
|
packet[4] = protocol::VERSION.wrapping_add(7);
|
||||||
|
assert_eq!(
|
||||||
|
protocol::peek_foreign_wire_version(&packet),
|
||||||
|
Some(protocol::VERSION.wrapping_add(7))
|
||||||
|
);
|
||||||
|
assert!(protocol::decode(&packet).is_err());
|
||||||
|
// Non-Dosh datagrams and runts are ignored.
|
||||||
|
assert_eq!(protocol::peek_foreign_wire_version(b"XXXX\x01"), None);
|
||||||
|
assert_eq!(protocol::peek_foreign_wire_version(b"DOS"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rekey_key_derivation_agrees_and_is_independent_per_epoch() {
|
||||||
|
use dosh::native::derive_rekey_session_key;
|
||||||
|
|
||||||
|
// The handshake/current key both peers already share.
|
||||||
|
let current_key = crypto::random_32();
|
||||||
|
let current_id = protocol::session_key_id(¤t_key);
|
||||||
|
// Fresh server-generated material, delivered confidentially in the Rekey.
|
||||||
|
let material = crypto::random_32();
|
||||||
|
|
||||||
|
// Both peers derive identically from shared current key + shipped material.
|
||||||
|
let server_view = derive_rekey_session_key(¤t_key, &material, ¤t_id, 1).unwrap();
|
||||||
|
let client_view = derive_rekey_session_key(¤t_key, &material, ¤t_id, 1).unwrap();
|
||||||
|
assert_eq!(server_view, client_view);
|
||||||
|
|
||||||
|
// The rotated key must not equal the handshake/current key.
|
||||||
|
assert_ne!(server_view, current_key);
|
||||||
|
|
||||||
|
// A different epoch (or different material) yields a different key.
|
||||||
|
let next_epoch = derive_rekey_session_key(¤t_key, &material, ¤t_id, 2).unwrap();
|
||||||
|
assert_ne!(server_view, next_epoch);
|
||||||
|
let other_material = crypto::random_32();
|
||||||
|
let other = derive_rekey_session_key(¤t_key, &other_material, ¤t_id, 1).unwrap();
|
||||||
|
assert_ne!(server_view, other);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rekey_round_trip_decrypts_old_and_new_epoch_packets() {
|
||||||
|
use dosh::native::derive_rekey_session_key;
|
||||||
|
|
||||||
|
let key_epoch0 = crypto::random_32();
|
||||||
|
let id0 = protocol::session_key_id(&key_epoch0);
|
||||||
|
let conn_id = crypto::random_16();
|
||||||
|
|
||||||
|
// Pre-rekey packet sealed under epoch-0 key.
|
||||||
|
let pre = protocol::encode_encrypted(
|
||||||
|
PacketKind::Frame,
|
||||||
|
conn_id,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
&key_epoch0,
|
||||||
|
protocol::SERVER_TO_CLIENT,
|
||||||
|
b"before",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Rotate to epoch 1.
|
||||||
|
let material = crypto::random_32();
|
||||||
|
let key_epoch1 = derive_rekey_session_key(&key_epoch0, &material, &id0, 1).unwrap();
|
||||||
|
let post = protocol::encode_encrypted(
|
||||||
|
PacketKind::Frame,
|
||||||
|
conn_id,
|
||||||
|
6,
|
||||||
|
0,
|
||||||
|
&key_epoch1,
|
||||||
|
protocol::SERVER_TO_CLIENT,
|
||||||
|
b"after",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pre = protocol::decode(&pre).unwrap();
|
||||||
|
let post = protocol::decode(&post).unwrap();
|
||||||
|
|
||||||
|
// Each epoch's key carries its own session_key_id; the receiver picks the
|
||||||
|
// right one and both decrypt correctly.
|
||||||
|
assert_eq!(pre.header.session_key_id, id0);
|
||||||
|
assert_eq!(
|
||||||
|
pre.header.session_key_id,
|
||||||
|
protocol::session_key_id(&key_epoch0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
post.header.session_key_id,
|
||||||
|
protocol::session_key_id(&key_epoch1)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
protocol::decrypt_body(&pre, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap(),
|
||||||
|
b"before"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
protocol::decrypt_body(&post, &key_epoch1, protocol::SERVER_TO_CLIENT).unwrap(),
|
||||||
|
b"after"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A stale-epoch packet under the wrong key is rejected via session_key_id
|
||||||
|
// BEFORE any AEAD work — ignorable, not a fatal decrypt error.
|
||||||
|
let err = protocol::decrypt_body(&post, &key_epoch0, protocol::SERVER_TO_CLIENT).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("session key id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_native_protocol_version_names_the_mismatch() {
|
||||||
|
use dosh::native::{NATIVE_PROTOCOL_VERSION, check_native_protocol_version};
|
||||||
|
check_native_protocol_version(NATIVE_PROTOCOL_VERSION, "server").unwrap();
|
||||||
|
let err = check_native_protocol_version(NATIVE_PROTOCOL_VERSION.wrapping_add(1), "server")
|
||||||
|
.unwrap_err();
|
||||||
|
let message = err.to_string();
|
||||||
|
assert!(
|
||||||
|
message.contains(protocol::VERSION_MISMATCH_REASON),
|
||||||
|
"expected actionable upgrade message, got {message:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
message.contains("server"),
|
||||||
|
"should name the wrong peer: {message:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_window_rejects_duplicates_but_allows_bounded_out_of_order() {
|
fn replay_window_rejects_duplicates_but_allows_bounded_out_of_order() {
|
||||||
let mut replay = ReplayWindow::new(8);
|
let mut replay = ReplayWindow::new(8);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user