commit 555d738a85d34ead0c9245926637eac91e93a173 Author: Codex Date: Thu Jun 11 08:42:28 2026 -0400 Initial Dosh implementation diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..72b9266 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: ci + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Format check + run: cargo fmt -- --check + - name: Test + run: cargo test + - name: Build release + run: cargo build --release + - name: Docker SSH benchmark gate + run: sh scripts/ci-docker-ssh-bench.sh + + remote-bench: + runs-on: ubuntu-latest + env: + DOSH_BENCH_HOST: ${{ secrets.DOSH_BENCH_HOST }} + DOSH_BENCH_USER: ${{ secrets.DOSH_BENCH_USER }} + DOSH_BENCH_SSH_KEY: ${{ secrets.DOSH_BENCH_SSH_KEY }} + DOSH_BENCH_SSH_PORT: ${{ secrets.DOSH_BENCH_SSH_PORT }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Check remote benchmark configuration + id: config + run: | + if [ -n "$DOSH_BENCH_HOST" ] && [ -n "$DOSH_BENCH_USER" ] && [ -n "$DOSH_BENCH_SSH_KEY" ]; then + echo "configured=true" >> "$GITHUB_OUTPUT" + else + echo "configured=false" >> "$GITHUB_OUTPUT" + echo "Skipping remote benchmark; configure DOSH_BENCH_HOST, DOSH_BENCH_USER, and DOSH_BENCH_SSH_KEY secrets to enable it." + fi + - name: Build release + if: steps.config.outputs.configured == 'true' + run: cargo build --release + - name: Configure SSH key + if: steps.config.outputs.configured == 'true' + run: | + port="${DOSH_BENCH_SSH_PORT:-22}" + mkdir -p ~/.ssh + printf '%s\n' "$DOSH_BENCH_SSH_KEY" > ~/.ssh/dosh_bench + chmod 600 ~/.ssh/dosh_bench + ssh-keyscan -p "$port" "$DOSH_BENCH_HOST" >> ~/.ssh/known_hosts + - name: Copy binaries + if: steps.config.outputs.configured == 'true' + run: | + port="${DOSH_BENCH_SSH_PORT:-22}" + scp -P "$port" -i ~/.ssh/dosh_bench \ + target/release/dosh-server target/release/dosh-auth \ + "$DOSH_BENCH_USER@$DOSH_BENCH_HOST:/tmp/" + - name: Start remote server + if: steps.config.outputs.configured == 'true' + run: | + port="${DOSH_BENCH_SSH_PORT:-22}" + ssh -p "$port" -i ~/.ssh/dosh_bench "$DOSH_BENCH_USER@$DOSH_BENCH_HOST" \ + 'mkdir -p ~/.local/bin && install -m 0755 /tmp/dosh-server /tmp/dosh-auth ~/.local/bin/ && pkill -f "dosh-server serve" || true; nohup ~/.local/bin/dosh-server serve >/tmp/dosh-server-ci.log 2>&1 &' + sleep 1 + - name: Run remote benchmark + if: steps.config.outputs.configured == 'true' + run: | + port="${DOSH_BENCH_SSH_PORT:-22}" + target/release/dosh-bench \ + --server "$DOSH_BENCH_USER@$DOSH_BENCH_HOST" \ + --ssh-port "$port" \ + --ssh-key ~/.ssh/dosh_bench \ + --ssh-auth-command "~/.local/bin/dosh-auth" \ + --iterations 3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9227398 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target/ +**/*.rs.bk +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..409e9a7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1553 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dosh" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "bincode", + "bytes", + "chacha20poly1305", + "clap", + "crossterm", + "dirs", + "hkdf", + "hmac", + "portable-pty", + "rand", + "serde", + "sha2", + "tempfile", + "tokio", + "toml", + "vt100", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror", + "winapi", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1d68593 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "dosh" +version = "0.1.0" +edition = "2024" +license = "MIT" + +[dependencies] +anyhow = "1.0" +base64 = "0.22" +bincode = "1.3" +bytes = "1.7" +chacha20poly1305 = "0.10" +clap = { version = "4.5", features = ["derive"] } +crossterm = "0.28" +dirs = "5.0" +hkdf = "0.12" +hmac = "0.12" +portable-pty = "0.8" +rand = "0.8" +serde = { version = "1.0", features = ["derive"] } +sha2 = "0.10" +tokio = { version = "1.41", features = ["full"] } +toml = "0.8" +vt100 = "0.15" + +[dev-dependencies] +tempfile = "3.14" + +[profile.release] +codegen-units = 1 +lto = "thin" +strip = true diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..869f432 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: build test fmt install bench-local bench-docker-ssh + +build: + cargo build --release + +test: + cargo test + +fmt: + cargo fmt + +install: + sh packaging/install.sh + +bench-local: + cargo build + tmp="$$(mktemp -d)"; \ + HOME="$$tmp" target/debug/dosh-server serve >/tmp/dosh-bench-server.log 2>&1 & \ + pid="$$!"; \ + trap 'kill "$$pid" 2>/dev/null || true; rm -rf "$$tmp"' EXIT INT TERM; \ + sleep 0.5; \ + HOME="$$tmp" target/debug/dosh-bench --local-auth --server local --iterations 5 + +bench-docker-ssh: + sh scripts/ci-docker-ssh-bench.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae0e033 --- /dev/null +++ b/README.md @@ -0,0 +1,231 @@ +# dosh - Dormant Shell + +dosh is a low-latency remote terminal designed around fast attach and fast reconnect. +It is mosh-shaped, but not a mosh clone: the server is a resident daemon, terminal +sessions stay hot, and repeat connects try encrypted UDP before starting SSH. + +The core target is simple: + +- First secure trust establishment uses SSH. +- Existing sessions attach in one encrypted UDP exchange whenever cached credentials allow it. +- Reconnect after sleep, roaming, or network change resumes in one encrypted UDP exchange. +- Cold SSH fallback stays competitive with plain `ssh` by doing less after auth. + +## Why not just mosh? + +mosh is excellent at roaming and high-latency interactivity. Its startup path still +has work dosh can avoid: + +1. SSH connects to the host. +2. SSH starts `mosh-server`. +3. The client receives connection material over SSH. +4. SSH exits and the mosh UDP session begins. + +dosh keeps `dosh-server` running before the client arrives. Named PTY sessions can +also be prewarmed, so attaching to `default` does not need to spawn a daemon, create +a PTY, or start a shell on the user's critical path. + +This is not an encryption argument against mosh. dosh also encrypts its UDP data +channel; the speed difference comes from keeping the server and session hot. + +## Fast Path Order + +The client always tries the cheapest valid path first: + +1. **UDP resume:** existing `ClientId` and session key. No SSH. One encrypted UDP + request, one encrypted UDP reply. +2. **UDP attach ticket:** cached server-issued attach ticket for the same + host/user/session/mode. No SSH. One encrypted UDP request, one encrypted UDP + reply. +3. **SSH bootstrap:** `ssh -T user@host 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. + +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 + +Default UDP port: `50000`. This is intentionally inside the common forwarded range +`50000-52000/udp`. + +Put this repo on your Gitea server, then install on each Linux server you want to +attach to: + +```bash +curl -fsSL https://gitea.example.com/you/dosh/raw/branch/main/install.sh \ + | DOSH_REPO=https://gitea.example.com/you/dosh.git DOSH_PORT=50000 sh -s -- server +``` + +Install the client on macOS: + +```bash +curl -fsSL https://gitea.example.com/you/dosh/raw/branch/main/install.sh \ + | DOSH_REPO=https://gitea.example.com/you/dosh.git DOSH_SERVER=user@host DOSH_PORT=50000 sh -s -- client +``` + +Install the client on Windows PowerShell: + +```powershell +$env:DOSH_REPO="https://gitea.example.com/you/dosh.git"; $env:DOSH_SERVER="user@host"; $env:DOSH_PORT="50000"; irm https://gitea.example.com/you/dosh/raw/branch/main/install.ps1 | iex +``` + +Attach: + +```bash +dosh-client user@host +``` + +Use named sessions: + +```bash +dosh-client --session work user@host +``` + +Press `Ctrl-]` to detach the current client while leaving the server session alive. + +If SSH and UDP use different public names, specify the UDP address: + +```bash +dosh-client --dosh-host public.example.com --dosh-port 50000 user@host +``` + +## 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 +``` + +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. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..e9e3f03 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,593 @@ +# 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 `dosh-auth`, invoked by SSH with `-T` + +--- + +## 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. + +--- + +## 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 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 + 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 + +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 dosh-auth \ + --protocol 1 \ + --nonce \ + --session \ + --mode \ + --size x \ + --client-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 # attach default +dosh attach # attach default +dosh attach work +dosh attach work --view-only +dosh new work +dosh list +dosh list-clients [session] +dosh kill work +``` + +Session behavior: + +- One PTY per session. +- Sessions persist until killed or server exits. +- If a session has zero clients, the PTY keeps running. +- 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. +- 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 = 30 +retransmit_window = 256 +default_input_mode = "read-write" +prewarm_sessions = ["default"] +create_on_attach = true +shell = "/bin/sh" +sessions_dir = "~/.local/share/dosh/sessions" +secret_path = "~/.config/dosh/secret" +``` + +Client config: `~/.config/dosh/client.toml` + +```toml +server = "user@example.com" +ssh_port = 22 +dosh_port = 50000 +default_session = "default" +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. diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..78c202d --- /dev/null +++ b/install.ps1 @@ -0,0 +1,83 @@ +param( + [ValidateSet("client")] + [string]$Role = $(if ($env:DOSH_ROLE) { $env:DOSH_ROLE } else { "client" }), + [string]$Repo = $env:DOSH_REPO, + [string]$Server = $env:DOSH_SERVER, + [string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }), + [int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }), + [string]$Prefix = $(if ($env:PREFIX) { $env:PREFIX } else { Join-Path $HOME ".local" }), + [switch]$ForceConfig +) + +$ErrorActionPreference = "Stop" + +function Require-Command($Name) { + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "missing required command: $Name" + } +} + +Require-Command cargo + +$tmp = $null +if (Test-Path "Cargo.toml") { + $src = (Get-Location).Path +} else { + if (-not $Repo) { + throw "DOSH_REPO is required when running the installer from irm/iex" + } + Require-Command git + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-" + [guid]::NewGuid()) + git clone --depth 1 $Repo $tmp | Out-Null + $src = $tmp +} + +try { + Push-Location $src + cargo build --release --bin dosh-client --bin dosh-bench + + $bindir = Join-Path $Prefix "bin" + $configDir = Join-Path $HOME ".config\dosh" + New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null + + Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force + Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force + Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force + + $clientConfig = Join-Path $configDir "client.toml" + if ($ForceConfig -or -not (Test-Path $clientConfig)) { + $defaultServer = if ($Server) { $Server } else { "user@example.com" } + $doshHostLine = if ($DoshHost) { "dosh_host = `"$DoshHost`"" } else { "# dosh_host = `"public.example.com`"" } + @" +server = "$defaultServer" +$doshHostLine +ssh_port = 22 +dosh_port = $Port +default_session = "default" +reconnect_timeout_secs = 5 +view_only = false +cache_attach_tickets = true +credential_cache = "~/.local/share/dosh/credentials" +"@ | Set-Content -NoNewline -Encoding utf8 $clientConfig + } + + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + if (-not (($userPath -split ';') -contains $bindir)) { + [Environment]::SetEnvironmentVariable("Path", "$userPath;$bindir", "User") + } + + Write-Host "Installed Dosh client to $bindir" + Write-Host "Configured UDP port $Port" + Write-Host "" + $displayServer = if ($Server) { $Server } else { "user@host" } + Write-Host "Client command:" + Write-Host " $bindir\dosh.exe $displayServer" + Write-Host "" + Write-Host "Open a new terminal for PATH changes to apply." +} +finally { + Pop-Location + if ($tmp) { + Remove-Item -Recurse -Force $tmp + } +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b2f854e --- /dev/null +++ b/install.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env sh +set -eu + +role="${DOSH_ROLE:-both}" +repo="${DOSH_REPO:-}" +server="${DOSH_SERVER:-}" +dosh_host="${DOSH_HOST:-${DOSH_DOSH_HOST:-}}" +port="${DOSH_PORT:-50000}" +prefix="${PREFIX:-$HOME/.local}" +from_current=0 +start_server=1 +force_config=0 + +usage() { + cat <<'EOF' +Usage: + install.sh [server|client|both] [options] + +Options: + --repo URL Git repository to clone when not run from a checkout + --server HOST Default SSH target for client config, for example user@host + --dosh-host HOST UDP host for Dosh packets when different from SSH target + --port PORT Dosh UDP port; default 50000 + --prefix DIR Install prefix; default ~/.local + --no-start Install server but do not start it + --force-config Rewrite existing ~/.config/dosh/*.toml files + +Environment alternatives: + DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + server|client|both) + role="$1" + ;; + --repo) + repo="$2" + shift + ;; + --server) + server="$2" + shift + ;; + --dosh-host) + dosh_host="$2" + shift + ;; + --port) + port="$2" + shift + ;; + --prefix) + prefix="$2" + shift + ;; + --from-current) + from_current=1 + ;; + --no-start) + start_server=0 + ;; + --force-config) + force_config=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +case "$role" in + server|client|both) ;; + *) + echo "role must be server, client, or both" >&2 + exit 2 + ;; +esac + +need() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required command: $1" >&2 + exit 1 + fi +} + +need cargo + +cleanup() { + if [ -n "${tmpdir:-}" ]; then + rm -rf "$tmpdir" + fi +} +trap cleanup EXIT INT TERM + +if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then + src_dir="$(pwd)" +else + if [ -z "$repo" ]; then + echo "DOSH_REPO or --repo is required when running the installer from curl" >&2 + exit 2 + fi + need git + tmpdir="$(mktemp -d)" + git clone --depth 1 "$repo" "$tmpdir/dosh" >/dev/null + src_dir="$tmpdir/dosh" +fi + +cd "$src_dir" +if [ "$role" = "client" ]; then + cargo build --release --bin dosh-client --bin dosh-bench +else + cargo build --release +fi + +bindir="$prefix/bin" +config_dir="$HOME/.config/dosh" +data_dir="$HOME/.local/share/dosh" +systemd_user_dir="$HOME/.config/systemd/user" + +mkdir -p "$bindir" "$config_dir" "$data_dir" + +install -m 0755 target/release/dosh-client "$bindir/dosh-client" +ln -sf dosh-client "$bindir/dosh" +if [ "$role" = "server" ] || [ "$role" = "both" ]; then + install -m 0755 target/release/dosh-server "$bindir/dosh-server" + install -m 0755 target/release/dosh-auth "$bindir/dosh-auth" +fi +if [ -f target/release/dosh-bench ]; then + install -m 0755 target/release/dosh-bench "$bindir/dosh-bench" +fi + +if [ "$role" = "server" ] || [ "$role" = "both" ]; then + server_config="$config_dir/server.toml" + if [ "$force_config" -eq 1 ] || [ ! -f "$server_config" ]; then + cat >"$server_config" </dev/null 2>&1; then + mkdir -p "$systemd_user_dir" + sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \ + packaging/systemd/dosh-server.service >"$systemd_user_dir/dosh-server.service" + if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then + systemctl --user enable --now dosh-server.service + fi + elif [ "$start_server" -eq 1 ]; then + nohup "$bindir/dosh-server" serve >"$data_dir/dosh-server.log" 2>&1 & + fi +fi + +if [ "$role" = "client" ] || [ "$role" = "both" ]; then + client_config="$config_dir/client.toml" + if [ "$force_config" -eq 1 ] || [ ! -f "$client_config" ]; then + default_server="${server:-user@example.com}" + if [ -n "$dosh_host" ]; then + dosh_host_line="dosh_host = \"$dosh_host\"" + else + dosh_host_line="# dosh_host = \"public.example.com\"" + fi + cat >"$client_config" </tmp/dosh-server.log 2>&1 &' && exec /usr/sbin/sshd -D -e"] diff --git a/packaging/install.sh b/packaging/install.sh new file mode 100755 index 0000000..49723e1 --- /dev/null +++ b/packaging/install.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +set -eu + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" +exec sh ./install.sh --from-current "$@" diff --git a/packaging/systemd/dosh-server.service b/packaging/systemd/dosh-server.service new file mode 100644 index 0000000..f4156f2 --- /dev/null +++ b/packaging/systemd/dosh-server.service @@ -0,0 +1,23 @@ +[Unit] +Description=dosh dormant shell server +Documentation=file:README.md file:SPEC.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/dosh-server serve +Restart=on-failure +RestartSec=1 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=%h/.config/dosh %h/.local/share/dosh +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictRealtime=true +LockPersonality=true +MemoryDenyWriteExecute=true + +[Install] +WantedBy=default.target diff --git a/scripts/ci-docker-ssh-bench.sh b/scripts/ci-docker-ssh-bench.sh new file mode 100755 index 0000000..e0e782f --- /dev/null +++ b/scripts/ci-docker-ssh-bench.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env sh +set -eu + +cargo build --release + +workdir="$(mktemp -d)" +cleanup() { + if [ -n "${container_id:-}" ]; then + docker rm -f "$container_id" >/dev/null 2>&1 || true + fi + rm -rf "$workdir" +} +trap cleanup EXIT INT TERM + +ssh-keygen -t ed25519 -N "" -f "$workdir/id_ed25519" >/dev/null +cp target/release/dosh-server "$workdir/dosh-server" +cp target/release/dosh-auth "$workdir/dosh-auth" +cp "$workdir/id_ed25519.pub" "$workdir/bench_authorized_keys" + +docker build -q -t dosh-ssh-bench -f packaging/docker/ssh-bench.Dockerfile "$workdir" >/dev/null +container_id="$(docker run -d -p 127.0.0.1:0:22/tcp -p 127.0.0.1:0:50000/udp dosh-ssh-bench)" +ssh_port="$(docker port "$container_id" 22/tcp | sed 's/.*://')" +dosh_port="$(docker port "$container_id" 50000/udp | sed 's/.*://')" + +for _ in 1 2 3 4 5; do + if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i "$workdir/id_ed25519" -p "$ssh_port" bench@127.0.0.1 true >/dev/null 2>&1; then + break + fi + sleep 1 +done + +ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts" + +if ! HOME="$workdir/home" target/release/dosh-bench \ + --server bench@127.0.0.1 \ + --ssh-port "$ssh_port" \ + --dosh-port "$dosh_port" \ + --ssh-key "$workdir/id_ed25519" \ + --ssh-known-hosts "$workdir/known_hosts" \ + --ssh-auth-command /usr/local/bin/dosh-auth \ + --iterations 3 \ + --no-cache \ + --assert-ssh-plus-ms 500; 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-controlmaster" target/release/dosh-bench \ + --server bench@127.0.0.1 \ + --ssh-port "$ssh_port" \ + --dosh-port "$dosh_port" \ + --ssh-key "$workdir/id_ed25519" \ + --ssh-known-hosts "$workdir/known_hosts" \ + --ssh-auth-command /usr/local/bin/dosh-auth \ + --iterations 3 \ + --no-cache \ + --controlmaster \ + --assert-ssh-plus-ms 500; 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 diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..7dd06c4 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,287 @@ +use crate::config::{ServerConfig, expand_tilde}; +use crate::crypto; +use anyhow::{Context, Result}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BootstrapResponse { + pub protocol_version: u8, + pub server_id: [u8; 32], + pub server_key_epoch: u64, + pub issued_at: u64, + pub expires_at: u64, + pub user: String, + pub session: String, + pub mode: String, + pub terminal_size: (u16, u16), + pub client_nonce: [u8; 12], + pub attach_token: [u8; 32], + pub attach_ticket: Vec, + pub attach_ticket_psk: [u8; 32], + pub session_key: [u8; 32], + pub session_key_id: [u8; 16], + pub udp_host: String, + pub udp_port: u16, + pub aead_algorithm: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SealedAttachTicket { + pub nonce: [u8; 12], + pub ciphertext: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttachTicketPlain { + pub server_id: [u8; 32], + pub server_key_epoch: u64, + pub user: String, + pub session: String, + pub mode: String, + pub issued_at: u64, + pub expires_at: u64, + pub psk: [u8; 32], +} + +pub fn now_secs() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock before UNIX epoch")? + .as_secs()) +} + +pub fn load_or_create_server_secret(config: &ServerConfig) -> Result<[u8; 32]> { + let path = expand_tilde(&config.secret_path); + if path.exists() { + let raw = fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let decoded = if raw.len() == 32 { + raw + } else { + URL_SAFE_NO_PAD + .decode(String::from_utf8_lossy(&raw).trim()) + .context("decode server secret")? + }; + let mut out = [0u8; 32]; + out.copy_from_slice(&decoded[..32]); + return Ok(out); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let secret = crypto::random_32(); + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&path) + .with_context(|| format!("create {}", path.display()))?; + file.write_all(URL_SAFE_NO_PAD.encode(secret).as_bytes())?; + file.write_all(b"\n")?; + Ok(secret) +} + +pub fn build_bootstrap( + config: &ServerConfig, + secret: &[u8; 32], + user: String, + session: String, + mode: String, + terminal_size: (u16, u16), + client_nonce: [u8; 12], + udp_host: String, +) -> Result { + let issued_at = now_secs()?; + let expires_at = issued_at + config.auth_ttl_secs; + let ticket_expires = issued_at + config.attach_ticket_ttl_secs; + let server_id = crypto::sha256(secret); + let session_key = crypto::hkdf32( + secret, + &client_nonce, + format!("dosh/session/{user}/{session}/{issued_at}").as_bytes(), + )?; + let session_key_id = { + let digest = crypto::sha256(&session_key); + let mut out = [0u8; 16]; + out.copy_from_slice(&digest[..16]); + out + }; + let attach_token = attach_token( + secret, + &user, + &session, + &mode, + terminal_size, + &client_nonce, + issued_at, + expires_at, + &session_key_id, + ); + let attach_ticket_psk = crypto::random_32(); + let attach_ticket = build_attach_ticket( + secret, + server_id, + 1, + user.clone(), + session.clone(), + mode.clone(), + issued_at, + ticket_expires, + &attach_ticket_psk, + )?; + + Ok(BootstrapResponse { + protocol_version: 1, + server_id, + server_key_epoch: 1, + issued_at, + expires_at, + user, + session, + mode, + terminal_size, + client_nonce, + attach_token, + attach_ticket, + attach_ticket_psk, + session_key, + session_key_id, + udp_host, + udp_port: config.port, + aead_algorithm: "chacha20poly1305".to_string(), + }) +} + +pub fn attach_token( + secret: &[u8; 32], + user: &str, + session: &str, + mode: &str, + terminal_size: (u16, u16), + client_nonce: &[u8; 12], + issued_at: u64, + expires_at: u64, + session_key_id: &[u8; 16], +) -> [u8; 32] { + crypto::hmac_sha256( + secret, + &[ + user.as_bytes(), + session.as_bytes(), + mode.as_bytes(), + &terminal_size.0.to_be_bytes(), + &terminal_size.1.to_be_bytes(), + client_nonce, + &issued_at.to_be_bytes(), + &expires_at.to_be_bytes(), + session_key_id, + ], + ) +} + +pub fn verify_bootstrap(resp: &BootstrapResponse, secret: &[u8; 32]) -> Result { + if now_secs()? > resp.expires_at { + return Ok(false); + } + let expected = attach_token( + secret, + &resp.user, + &resp.session, + &resp.mode, + resp.terminal_size, + &resp.client_nonce, + resp.issued_at, + resp.expires_at, + &resp.session_key_id, + ); + Ok(expected == resp.attach_token) +} + +fn build_attach_ticket( + secret: &[u8; 32], + server_id: [u8; 32], + server_key_epoch: u64, + user: String, + session: String, + mode: String, + issued_at: u64, + expires_at: u64, + psk: &[u8; 32], +) -> Result> { + let payload = AttachTicketPlain { + server_id, + server_key_epoch, + user, + session, + mode, + issued_at, + expires_at, + psk: *psk, + }; + let key = attach_ticket_key(secret)?; + let nonce = crypto::random_12(); + let encoded = bincode::serialize(&payload)?; + let ciphertext = crypto::seal(&key, &nonce, b"dosh-sealed-attach-ticket-v1", &encoded)?; + Ok(bincode::serialize(&SealedAttachTicket { + nonce, + ciphertext, + })?) +} + +pub fn verify_attach_ticket( + secret: &[u8; 32], + ticket_bytes: &[u8], + psk: &[u8; 32], + session: &str, + mode: &str, +) -> Result> { + let sealed: SealedAttachTicket = bincode::deserialize(ticket_bytes)?; + let key = attach_ticket_key(secret)?; + let plain = crypto::open( + &key, + &sealed.nonce, + b"dosh-sealed-attach-ticket-v1", + &sealed.ciphertext, + )?; + let ticket: AttachTicketPlain = bincode::deserialize(&plain)?; + if now_secs()? > ticket.expires_at { + return Ok(None); + } + if ticket.session != session || ticket.mode != mode { + return Ok(None); + } + if ticket.psk != *psk { + return Ok(None); + } + Ok(Some(ticket)) +} + +pub fn open_attach_ticket(secret: &[u8; 32], ticket_bytes: &[u8]) -> Result { + let sealed: SealedAttachTicket = bincode::deserialize(ticket_bytes)?; + let key = attach_ticket_key(secret)?; + let plain = crypto::open( + &key, + &sealed.nonce, + b"dosh-sealed-attach-ticket-v1", + &sealed.ciphertext, + )?; + Ok(bincode::deserialize(&plain)?) +} + +fn attach_ticket_key(secret: &[u8; 32]) -> Result<[u8; 32]> { + crypto::hkdf32(secret, b"dosh-ticket-key-salt-v1", b"dosh/attach-ticket/v1") +} + +pub fn encode_bootstrap(resp: &BootstrapResponse) -> Result { + Ok(URL_SAFE_NO_PAD.encode(bincode::serialize(resp)?)) +} + +pub fn decode_bootstrap(raw: &str) -> Result { + let bytes = URL_SAFE_NO_PAD.decode(raw.trim())?; + Ok(bincode::deserialize(&bytes)?) +} diff --git a/src/bin/dosh-auth.rs b/src/bin/dosh-auth.rs new file mode 100644 index 0000000..3ae66b7 --- /dev/null +++ b/src/bin/dosh-auth.rs @@ -0,0 +1,59 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use dosh::auth::{build_bootstrap, encode_bootstrap, load_or_create_server_secret}; +use dosh::config::load_server_config; + +#[derive(Debug, Parser)] +struct Args { + #[arg(long, default_value_t = 1)] + protocol: u8, + #[arg(long)] + nonce: String, + #[arg(long, default_value = "default")] + session: String, + #[arg(long, default_value = "read-write")] + mode: String, + #[arg(long, default_value = "80x24")] + size: String, + #[arg(long, default_value = "dev")] + client_version: String, + #[arg(long)] + udp_host: Option, +} + +fn main() -> Result<()> { + let args = Args::parse(); + anyhow::ensure!(args.protocol == 1, "unsupported protocol {}", args.protocol); + let config = load_server_config(None)?; + let secret = load_or_create_server_secret(&config)?; + let nonce = parse_nonce(&args.nonce)?; + let size = parse_size(&args.size)?; + let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()); + let udp_host = args.udp_host.unwrap_or_else(|| "127.0.0.1".to_string()); + let resp = build_bootstrap( + &config, + &secret, + user, + args.session, + args.mode, + size, + nonce, + udp_host, + )?; + println!("{}", encode_bootstrap(&resp)?); + Ok(()) +} + +fn parse_nonce(raw: &str) -> Result<[u8; 12]> { + let bytes = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, raw) + .context("decode nonce")?; + anyhow::ensure!(bytes.len() == 12, "nonce must decode to 12 bytes"); + let mut out = [0u8; 12]; + out.copy_from_slice(&bytes); + Ok(out) +} + +fn parse_size(raw: &str) -> Result<(u16, u16)> { + let (cols, rows) = raw.split_once('x').context("size must be COLSxROWS")?; + Ok((cols.parse()?, rows.parse()?)) +} diff --git a/src/bin/dosh-bench.rs b/src/bin/dosh-bench.rs new file mode 100644 index 0000000..16eaf7a --- /dev/null +++ b/src/bin/dosh-bench.rs @@ -0,0 +1,233 @@ +use anyhow::{Context, Result, anyhow}; +use clap::Parser; +use std::path::PathBuf; +use std::process::Command; +use std::time::{Duration, Instant}; + +#[derive(Debug, Parser)] +#[command(name = "dosh-bench")] +struct Args { + #[arg(long, default_value = "local")] + server: String, + #[arg(long, default_value = "default")] + session: String, + #[arg(long, default_value_t = 22)] + ssh_port: u16, + #[arg(long, default_value_t = 50000)] + dosh_port: u16, + #[arg(long)] + dosh_host: Option, + #[arg(long, default_value_t = 3)] + iterations: usize, + #[arg(long)] + local_auth: bool, + #[arg(long)] + client: Option, + #[arg(long, default_value = "dosh-auth")] + ssh_auth_command: String, + #[arg(long)] + ssh_key: Option, + #[arg(long)] + ssh_known_hosts: Option, + #[arg(long)] + ssh_control_path: Option, + #[arg(long)] + controlmaster: bool, + #[arg(long)] + no_cache: bool, + #[arg(long)] + assert_ssh_plus_ms: Option, +} + +fn main() -> Result<()> { + let args = Args::parse(); + let client = args.client.clone().unwrap_or_else(default_client_path); + let mut ssh_times = Vec::new(); + let mut dosh_times = Vec::new(); + let generated_control_path = if args.controlmaster { + Some(std::env::temp_dir().join(format!("dosh-bench-control-{}", std::process::id()))) + } else { + None + }; + let control_path = generated_control_path + .as_ref() + .or(args.ssh_control_path.as_ref()); + let _controlmaster = if let Some(path) = generated_control_path.as_ref() { + Some(ControlMaster::start(&args, path.clone())?) + } else { + None + }; + + for _ in 0..args.iterations.max(1) { + if !args.local_auth { + let mut ssh = Command::new("ssh"); + add_ssh_options(&mut ssh, &args, control_path); + ssh.arg(&args.server).arg("true"); + ssh_times.push(time_command(&mut ssh)?); + } + + let mut cmd = Command::new(&client); + cmd.arg("--attach-only") + .arg("--session") + .arg(&args.session) + .arg("--dosh-port") + .arg(args.dosh_port.to_string()); + if let Some(host) = &args.dosh_host { + cmd.arg("--dosh-host").arg(host); + } + if args.local_auth { + cmd.arg("--local-auth").arg(&args.server); + } else { + cmd.arg("--ssh-port") + .arg(args.ssh_port.to_string()) + .arg("--ssh-auth-command") + .arg(&args.ssh_auth_command); + if args.no_cache { + cmd.arg("--no-cache"); + } + if let Some(key) = &args.ssh_key { + cmd.arg("--ssh-key").arg(key); + } + if let Some(known_hosts) = &args.ssh_known_hosts { + cmd.arg("--ssh-known-hosts").arg(known_hosts); + } + if let Some(control_path) = control_path { + cmd.arg("--ssh-control-path").arg(control_path); + } + cmd.arg(&args.server); + } + dosh_times.push(time_command(&mut cmd)?); + } + + if !ssh_times.is_empty() { + println!( + "ssh_true_ms avg={:.2} samples={:?}", + avg_ms(&ssh_times), + ssh_times + ); + } + println!( + "dosh_attach_ms avg={:.2} samples={:?}", + avg_ms(&dosh_times), + dosh_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"); + } + Ok(()) +} + +fn add_ssh_options(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf>) { + cmd.arg("-p").arg(args.ssh_port.to_string()).arg("-T"); + if let Some(key) = &args.ssh_key { + cmd.arg("-i").arg(key); + } + if let Some(known_hosts) = &args.ssh_known_hosts { + cmd.arg("-o") + .arg(format!("UserKnownHostsFile={}", known_hosts.display())); + } + if let Some(control_path) = control_path { + cmd.arg("-S").arg(control_path); + } +} + +struct ControlMaster { + server: String, + ssh_port: u16, + ssh_key: Option, + ssh_known_hosts: Option, + control_path: PathBuf, +} + +impl ControlMaster { + fn start(args: &Args, control_path: PathBuf) -> Result { + let mut cmd = Command::new("ssh"); + cmd.arg("-p") + .arg(args.ssh_port.to_string()) + .arg("-T") + .arg("-M") + .arg("-S") + .arg(&control_path) + .arg("-f") + .arg("-N") + .arg("-o") + .arg("ControlPersist=60") + .arg("-o") + .arg("ExitOnForwardFailure=yes"); + if let Some(key) = &args.ssh_key { + cmd.arg("-i").arg(key); + } + if let Some(known_hosts) = &args.ssh_known_hosts { + cmd.arg("-o") + .arg(format!("UserKnownHostsFile={}", known_hosts.display())); + } + cmd.arg(&args.server); + time_command(&mut cmd).context("start SSH ControlMaster")?; + Ok(Self { + server: args.server.clone(), + ssh_port: args.ssh_port, + ssh_key: args.ssh_key.clone(), + ssh_known_hosts: args.ssh_known_hosts.clone(), + control_path, + }) + } +} + +impl Drop for ControlMaster { + fn drop(&mut self) { + let mut cmd = Command::new("ssh"); + cmd.arg("-p") + .arg(self.ssh_port.to_string()) + .arg("-T") + .arg("-S") + .arg(&self.control_path) + .arg("-O") + .arg("exit"); + if let Some(key) = &self.ssh_key { + cmd.arg("-i").arg(key); + } + if let Some(known_hosts) = &self.ssh_known_hosts { + cmd.arg("-o") + .arg(format!("UserKnownHostsFile={}", known_hosts.display())); + } + let _ = cmd.arg(&self.server).output(); + } +} + +fn time_command(cmd: &mut Command) -> Result { + let start = Instant::now(); + let output = cmd.output().with_context(|| format!("run {:?}", cmd))?; + if !output.status.success() { + return Err(anyhow!( + "command failed {:?}\nstdout:\n{}\nstderr:\n{}", + cmd, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + } + Ok(start.elapsed()) +} + +fn avg_ms(samples: &[Duration]) -> f64 { + let total: f64 = samples.iter().map(|d| d.as_secs_f64() * 1000.0).sum(); + total / samples.len() as f64 +} + +fn default_client_path() -> PathBuf { + std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(|parent| parent.join("dosh-client"))) + .unwrap_or_else(|| PathBuf::from("dosh-client")) +} diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs new file mode 100644 index 0000000..cffa1d4 --- /dev/null +++ b/src/bin/dosh-client.rs @@ -0,0 +1,617 @@ +use anyhow::{Context, Result, anyhow}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use clap::Parser; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, size}; +use dosh::auth::{ + BootstrapResponse, build_bootstrap, decode_bootstrap, load_or_create_server_secret, +}; +use dosh::config::{expand_tilde, load_client_config, load_server_config}; +use dosh::crypto; +use dosh::protocol::{ + self, AttachOk, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, PacketKind, + ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, TicketAttachEnvelope, + TicketAttachOkEnvelope, +}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::{Read, Write}; +use std::net::{SocketAddr, ToSocketAddrs}; +use std::process::Command; +use std::time::{Duration, Instant}; +use tokio::net::UdpSocket; +use tokio::sync::mpsc; + +#[derive(Debug, Parser)] +#[command(name = "dosh-client")] +struct Args { + #[arg()] + server: Option, + #[arg(long, default_value = "default")] + session: String, + #[arg(long)] + view_only: bool, + #[arg(long)] + local_auth: bool, + #[arg(long)] + no_cache: bool, + #[arg(long)] + attach_only: bool, + #[arg(long)] + ssh_port: Option, + #[arg(long, default_value = "dosh-auth")] + ssh_auth_command: String, + #[arg(long)] + ssh_key: Option, + #[arg(long)] + ssh_known_hosts: Option, + #[arg(long)] + ssh_control_path: Option, + #[arg(long)] + dosh_port: Option, + #[arg(long)] + dosh_host: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CachedCredential { + server: String, + session: String, + mode: String, + udp_host: String, + udp_port: u16, + client_id: [u8; 16], + session_key: [u8; 32], + session_key_id: [u8; 16], + attach_ticket: Vec, + attach_ticket_psk: [u8; 32], + last_rendered_seq: u64, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { + let args = Args::parse(); + let config = load_client_config(None).unwrap_or_default(); + let server = args.server.unwrap_or(config.server); + let session = args.session; + let mode = if args.view_only || config.view_only { + "view-only" + } else { + "read-write" + } + .to_string(); + let ssh_port = args.ssh_port.unwrap_or(config.ssh_port); + let dosh_port = args.dosh_port.unwrap_or(config.dosh_port); + let cache_path = cache_path(&config.credential_cache, &server, &session, &mode); + let (cols, rows) = size().unwrap_or((80, 24)); + + let started = Instant::now(); + let target_udp_host = args + .dosh_host + .clone() + .or_else(|| config.dosh_host.clone()) + .unwrap_or_else(|| { + if args.local_auth { + "127.0.0.1".to_string() + } else { + ssh_destination_host(&server) + } + }); + + let credential = if !args.no_cache { + load_cache(&cache_path).ok() + } else { + None + }; + + let socket = UdpSocket::bind("0.0.0.0:0").await?; + + if let Some(mut cached) = credential.clone() { + cached.udp_host = target_udp_host.clone(); + cached.udp_port = dosh_port; + eprintln!( + "dosh timing credential_lookup_end={}ms", + started.elapsed().as_millis() + ); + match try_resume(&socket, &cached, cols, rows).await { + Ok((frame, cred)) => { + eprintln!( + "dosh timing udp_resume_ready={}ms", + started.elapsed().as_millis() + ); + if !args.no_cache { + save_cache(&cache_path, &cred)?; + } + if args.attach_only { + render_frame(&frame)?; + detach_once(&socket, &cred, 2).await?; + return Ok(()); + } + return run_terminal(socket, cred, Some(frame)).await; + } + Err(err) => { + eprintln!("dosh resume failed, trying ticket attach before SSH: {err:#}"); + if config.cache_attach_tickets && !args.no_cache { + match try_ticket_attach(&socket, &cached, cols, rows).await { + Ok((frame, cred)) => { + eprintln!( + "dosh timing udp_ticket_attach_ready={}ms", + started.elapsed().as_millis() + ); + save_cache(&cache_path, &cred)?; + if args.attach_only { + render_frame(&frame)?; + detach_once(&socket, &cred, 2).await?; + return Ok(()); + } + return run_terminal(socket, cred, Some(frame)).await; + } + Err(err) => { + eprintln!( + "dosh ticket attach failed, falling back to SSH bootstrap: {err:#}" + ); + } + } + } + } + } + } + + let bootstrap_start = Instant::now(); + let bootstrap = if args.local_auth { + local_bootstrap(&session, &mode, cols, rows, dosh_port, target_udp_host)? + } else { + let mut bootstrap = ssh_bootstrap( + &server, + ssh_port, + &args.ssh_auth_command, + args.ssh_key.as_deref(), + args.ssh_known_hosts.as_deref(), + args.ssh_control_path.as_deref(), + &session, + &mode, + cols, + rows, + )?; + bootstrap.udp_host = target_udp_host; + bootstrap.udp_port = dosh_port; + bootstrap + }; + eprintln!( + "dosh timing ssh_bootstrap={}ms", + bootstrap_start.elapsed().as_millis() + ); + + let (ok, mut cred) = bootstrap_attach(&socket, &server, &bootstrap, cols, rows).await?; + cred.last_rendered_seq = ok.initial_seq; + if !args.no_cache { + save_cache(&cache_path, &cred)?; + } + eprintln!( + "dosh timing terminal_ready={}ms", + started.elapsed().as_millis() + ); + let first = Frame { + session: ok.session, + output_seq: ok.initial_seq, + bytes: ok.snapshot, + snapshot: true, + }; + if args.attach_only { + render_frame(&first)?; + detach_once(&socket, &cred, 2).await?; + return Ok(()); + } + run_terminal(socket, cred, Some(first)).await +} + +fn local_bootstrap( + session: &str, + mode: &str, + cols: u16, + rows: u16, + port: u16, + host: String, +) -> Result { + let mut server_config = load_server_config(None)?; + server_config.port = port; + let secret = load_or_create_server_secret(&server_config)?; + let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()); + let nonce = crypto::random_12(); + build_bootstrap( + &server_config, + &secret, + user, + session.to_string(), + mode.to_string(), + (cols, rows), + nonce, + host, + ) +} + +fn ssh_bootstrap( + server: &str, + ssh_port: u16, + ssh_auth_command: &str, + ssh_key: Option<&std::path::Path>, + ssh_known_hosts: Option<&std::path::Path>, + ssh_control_path: Option<&std::path::Path>, + session: &str, + mode: &str, + cols: u16, + rows: u16, +) -> Result { + let nonce = crypto::random_12(); + let nonce_b64 = URL_SAFE_NO_PAD.encode(nonce); + let size = format!("{cols}x{rows}"); + let mut command = Command::new("ssh"); + command.arg("-p").arg(ssh_port.to_string()).arg("-T"); + if let Some(key) = ssh_key { + command.arg("-i").arg(key); + } + if let Some(known_hosts) = ssh_known_hosts { + command + .arg("-o") + .arg(format!("UserKnownHostsFile={}", known_hosts.display())); + } + if let Some(control_path) = ssh_control_path { + command.arg("-S").arg(control_path); + } + let output = command + .arg(server) + .arg(ssh_auth_command) + .arg("--protocol") + .arg("1") + .arg("--nonce") + .arg(nonce_b64) + .arg("--session") + .arg(session) + .arg("--mode") + .arg(mode) + .arg("--size") + .arg(size) + .output() + .context("run ssh dosh-auth")?; + if !output.status.success() { + return Err(anyhow!( + "ssh bootstrap failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + let raw = String::from_utf8(output.stdout)?; + decode_bootstrap(&raw) +} + +fn ssh_destination_host(server: &str) -> String { + let without_user = server.rsplit_once('@').map_or(server, |(_, host)| host); + let without_path = without_user + .strip_prefix("ssh://") + .unwrap_or(without_user) + .split('/') + .next() + .unwrap_or(without_user); + if let Some(stripped) = without_path.strip_prefix('[') { + if let Some((host, _)) = stripped.split_once(']') { + return host.to_string(); + } + } + without_path + .split_once(':') + .map_or(without_path, |(host, _)| host) + .to_string() +} + +fn resolve_addr(host: &str, port: u16) -> Result { + (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}")) +} + +async fn bootstrap_attach( + socket: &UdpSocket, + server_name: &str, + bootstrap: &BootstrapResponse, + cols: u16, + rows: u16, +) -> Result<(AttachOk, CachedCredential)> { + let addr = resolve_addr(&bootstrap.udp_host, bootstrap.udp_port)?; + let req = BootstrapAttachRequest { + bootstrap: bootstrap.clone(), + cols, + rows, + }; + let body = protocol::to_body(&req)?; + let packet = + protocol::encode_plain(PacketKind::BootstrapAttachRequest, [0u8; 16], 1, 0, &body)?; + socket.send_to(&packet, addr).await?; + let mut buf = vec![0u8; 65535]; + let (n, _) = tokio::time::timeout(Duration::from_secs(5), socket.recv_from(&mut buf)).await??; + let packet = protocol::decode(&buf[..n])?; + if packet.header.kind != PacketKind::AttachOk { + return Err(anyhow!("attach rejected or unexpected response")); + } + let plain = protocol::decrypt_body(&packet, &bootstrap.session_key, SERVER_TO_CLIENT)?; + let ok: AttachOk = protocol::from_body(&plain)?; + let cred = CachedCredential { + server: server_name.to_string(), + session: ok.session.clone(), + mode: ok.mode.clone(), + udp_host: bootstrap.udp_host.clone(), + udp_port: bootstrap.udp_port, + client_id: ok.client_id, + session_key: ok.session_key, + session_key_id: ok.session_key_id, + attach_ticket: bootstrap.attach_ticket.clone(), + attach_ticket_psk: bootstrap.attach_ticket_psk, + last_rendered_seq: ok.initial_seq, + }; + Ok((ok, cred)) +} + +async fn try_ticket_attach( + socket: &UdpSocket, + cached: &CachedCredential, + cols: u16, + rows: u16, +) -> Result<(Frame, CachedCredential)> { + let addr = resolve_addr(&cached.udp_host, cached.udp_port)?; + let client_nonce = crypto::random_12(); + let request_key = crypto::hkdf32( + &cached.attach_ticket_psk, + &client_nonce, + b"dosh/ticket-attach-request/v1", + )?; + let body = protocol::to_body(&TicketAttachBody { + session: cached.session.clone(), + mode: cached.mode.clone(), + cols, + rows, + })?; + let ciphertext = crypto::seal( + &request_key, + &client_nonce, + b"dosh-ticket-attach-request-v1", + &body, + )?; + let envelope = TicketAttachEnvelope { + ticket: cached.attach_ticket.clone(), + client_nonce, + ciphertext, + }; + let packet = protocol::encode_plain( + PacketKind::TicketAttachRequest, + [0u8; 16], + 1, + 0, + &protocol::to_body(&envelope)?, + )?; + socket.send_to(&packet, addr).await?; + + let mut buf = vec![0u8; 65535]; + let (n, _) = + tokio::time::timeout(Duration::from_millis(700), socket.recv_from(&mut buf)).await??; + let packet = protocol::decode(&buf[..n])?; + if packet.header.kind != PacketKind::AttachOk { + return Err(anyhow!("ticket attach rejected")); + } + let envelope: TicketAttachOkEnvelope = protocol::from_body(&packet.body)?; + let mut salt = Vec::with_capacity(24); + salt.extend_from_slice(&client_nonce); + salt.extend_from_slice(&envelope.server_nonce); + let response_key = crypto::hkdf32( + &cached.attach_ticket_psk, + &salt, + b"dosh/ticket-attach-ok/v1", + )?; + let plain = crypto::open( + &response_key, + &envelope.server_nonce, + b"dosh-ticket-attach-ok-v1", + &envelope.ciphertext, + )?; + let ok: AttachOk = protocol::from_body(&plain)?; + let frame = Frame { + session: ok.session.clone(), + output_seq: ok.initial_seq, + bytes: ok.snapshot.clone(), + snapshot: true, + }; + let mut next = cached.clone(); + next.client_id = ok.client_id; + next.session_key = ok.session_key; + next.session_key_id = ok.session_key_id; + next.last_rendered_seq = ok.initial_seq; + Ok((frame, next)) +} + +async fn try_resume( + socket: &UdpSocket, + cached: &CachedCredential, + cols: u16, + rows: u16, +) -> Result<(Frame, CachedCredential)> { + let addr = resolve_addr(&cached.udp_host, cached.udp_port)?; + let req = ResumeRequest { + session: cached.session.clone(), + last_rendered_seq: cached.last_rendered_seq, + cols, + rows, + }; + let body = protocol::to_body(&req)?; + let packet = protocol::encode_encrypted( + PacketKind::ResumeRequest, + cached.client_id, + 1, + 0, + &cached.session_key, + CLIENT_TO_SERVER, + &body, + )?; + socket.send_to(&packet, addr).await?; + let mut buf = vec![0u8; 65535]; + let (n, _) = + tokio::time::timeout(Duration::from_millis(700), socket.recv_from(&mut buf)).await??; + let packet = protocol::decode(&buf[..n])?; + if packet.header.kind != PacketKind::ResumeOk { + return Err(anyhow!("resume rejected")); + } + let plain = protocol::decrypt_body(&packet, &cached.session_key, SERVER_TO_CLIENT)?; + let frame: Frame = protocol::from_body(&plain)?; + let mut next = cached.clone(); + next.last_rendered_seq = frame.output_seq; + Ok((frame, next)) +} + +async fn run_terminal( + socket: UdpSocket, + mut cred: CachedCredential, + first_frame: Option, +) -> Result<()> { + let _raw = RawMode::enter()?; + let addr = resolve_addr(&cred.udp_host, cred.udp_port)?; + let mut send_seq = 2u64; + if let Some(frame) = first_frame { + render_frame(&frame)?; + cred.last_rendered_seq = frame.output_seq; + send_ack(&socket, addr, &cred, &mut send_seq).await?; + } + + let (stdin_tx, mut stdin_rx) = mpsc::unbounded_channel::>(); + std::thread::Builder::new() + .name("dosh-stdin".to_string()) + .spawn(move || { + let mut stdin = std::io::stdin(); + let mut buf = [0u8; 4096]; + loop { + match stdin.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let _ = stdin_tx.send(buf[..n].to_vec()); + } + Err(_) => break, + } + } + })?; + + let mut recv_buf = vec![0u8; 65535]; + loop { + tokio::select! { + Some(bytes) = stdin_rx.recv() => { + if bytes == [0x1d] { + break; + } + if cred.mode != "view-only" { + let body = protocol::to_body(&Input { bytes })?; + let packet = protocol::encode_encrypted( + PacketKind::Input, + cred.client_id, + send_seq, + cred.last_rendered_seq, + &cred.session_key, + CLIENT_TO_SERVER, + &body, + )?; + send_seq += 1; + socket.send_to(&packet, addr).await?; + } + } + recv = socket.recv_from(&mut recv_buf) => { + let (n, _) = recv?; + let packet = protocol::decode(&recv_buf[..n])?; + match packet.header.kind { + PacketKind::Frame | PacketKind::ResumeOk => { + let plain = protocol::decrypt_body(&packet, &cred.session_key, SERVER_TO_CLIENT)?; + let frame: Frame = protocol::from_body(&plain)?; + render_frame(&frame)?; + cred.last_rendered_seq = frame.output_seq; + send_ack(&socket, addr, &cred, &mut send_seq).await?; + } + PacketKind::Pong => {} + _ => {} + } + } + } + } + Ok(()) +} + +async fn send_ack( + socket: &UdpSocket, + addr: SocketAddr, + cred: &CachedCredential, + send_seq: &mut u64, +) -> Result<()> { + let packet = protocol::encode_encrypted( + PacketKind::Ack, + cred.client_id, + *send_seq, + cred.last_rendered_seq, + &cred.session_key, + CLIENT_TO_SERVER, + b"", + )?; + *send_seq += 1; + socket.send_to(&packet, addr).await?; + Ok(()) +} + +async fn detach_once(socket: &UdpSocket, cred: &CachedCredential, seq: u64) -> Result<()> { + let addr = resolve_addr(&cred.udp_host, cred.udp_port)?; + let packet = protocol::encode_encrypted( + PacketKind::Detach, + cred.client_id, + seq, + cred.last_rendered_seq, + &cred.session_key, + CLIENT_TO_SERVER, + b"", + )?; + socket.send_to(&packet, addr).await?; + Ok(()) +} + +fn render_frame(frame: &Frame) -> Result<()> { + let mut stdout = std::io::stdout(); + stdout.write_all(&frame.bytes)?; + stdout.flush()?; + Ok(()) +} + +fn cache_path(root: &str, server: &str, session: &str, mode: &str) -> std::path::PathBuf { + let safe = format!("{server}_{session}_{mode}") + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::(); + expand_tilde(root).join(format!("{safe}.bin")) +} + +fn load_cache(path: &std::path::Path) -> Result { + let raw = fs::read(path)?; + Ok(bincode::deserialize(&raw)?) +} + +fn save_cache(path: &std::path::Path, cred: &CachedCredential) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, bincode::serialize(cred)?)?; + Ok(()) +} + +struct RawMode; + +impl RawMode { + fn enter() -> Result { + enable_raw_mode()?; + Ok(Self) + } +} + +impl Drop for RawMode { + fn drop(&mut self) { + let _ = disable_raw_mode(); + } +} diff --git a/src/bin/dosh-server.rs b/src/bin/dosh-server.rs new file mode 100644 index 0000000..9848725 --- /dev/null +++ b/src/bin/dosh-server.rs @@ -0,0 +1,743 @@ +use anyhow::{Context, Result, anyhow}; +use clap::{Parser, Subcommand}; +use dosh::auth::{ + build_bootstrap, encode_bootstrap, load_or_create_server_secret, open_attach_ticket, + verify_bootstrap, +}; +use dosh::config::{ServerConfig, load_server_config}; +use dosh::crypto; +use dosh::protocol::{ + self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, + PacketKind, ReplayWindow, Resize, ResumeRequest, SERVER_TO_CLIENT, TicketAttachBody, + TicketAttachEnvelope, TicketAttachOkEnvelope, +}; +use dosh::pty::{PtyHandle, PtyOutput, spawn_pty_session}; +use std::collections::{HashMap, VecDeque}; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::net::UdpSocket; +use tokio::sync::mpsc; + +#[derive(Debug, Parser)] +#[command(name = "dosh-server")] +struct Args { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + Serve { + #[arg(long)] + config: Option, + }, + Auth { + #[arg(long, default_value_t = 1)] + protocol: u8, + #[arg(long)] + nonce: String, + #[arg(long, default_value = "default")] + session: String, + #[arg(long, default_value = "read-write")] + mode: String, + #[arg(long, default_value = "80x24")] + size: String, + #[arg(long, default_value = "dev")] + client_version: String, + #[arg(long)] + udp_host: Option, + }, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { + let args = Args::parse(); + match args.command { + Command::Serve { config } => serve(config).await, + Command::Auth { + protocol, + nonce, + session, + mode, + size, + client_version: _, + udp_host, + } => { + anyhow::ensure!(protocol == 1, "unsupported protocol {protocol}"); + let config = load_server_config(None)?; + let secret = load_or_create_server_secret(&config)?; + let nonce = parse_nonce(&nonce)?; + let size = parse_size(&size)?; + let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()); + let udp_host = udp_host.unwrap_or_else(|| "127.0.0.1".to_string()); + let resp = + build_bootstrap(&config, &secret, user, session, mode, size, nonce, udp_host)?; + println!("{}", encode_bootstrap(&resp)?); + Ok(()) + } + } +} + +async fn serve(config_path: Option) -> Result<()> { + let config = load_server_config(config_path)?; + let secret = load_or_create_server_secret(&config)?; + let bind = format!("{}:{}", config.bind, config.port); + let socket = Arc::new( + UdpSocket::bind(&bind) + .await + .with_context(|| format!("bind {bind}"))?, + ); + eprintln!("dosh-server listening on {bind}"); + + let (pty_tx, mut pty_rx) = mpsc::unbounded_channel(); + let state = Arc::new(Mutex::new(ServerState::new( + config.clone(), + secret, + pty_tx.clone(), + ))); + { + let mut locked = state.lock().expect("server state poisoned"); + for session in config.prewarm_sessions.clone() { + locked.ensure_session(&session, 80, 24)?; + } + } + + let output_state = Arc::clone(&state); + let output_socket = Arc::clone(&socket); + tokio::spawn(async move { + while let Some(output) = pty_rx.recv().await { + if let Err(err) = broadcast_output(&output_state, &output_socket, output).await { + eprintln!("broadcast error: {err:#}"); + } + } + }); + + let retransmit_state = Arc::clone(&state); + let retransmit_socket = Arc::clone(&socket); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_millis(100)); + loop { + interval.tick().await; + if let Err(err) = retransmit_pending(&retransmit_state, &retransmit_socket).await { + eprintln!("retransmit error: {err:#}"); + } + } + }); + + let mut buf = vec![0u8; 65535]; + loop { + let (n, peer) = socket.recv_from(&mut buf).await?; + if let Err(err) = handle_packet(&state, &socket, peer, &buf[..n]).await { + eprintln!("packet from {peer}: {err:#}"); + } + } +} + +struct ServerState { + config: ServerConfig, + secret: [u8; 32], + pty_tx: mpsc::UnboundedSender, + sessions: HashMap, +} + +struct Session { + pty: PtyHandle, + parser: vt100::Parser, + clients: HashMap<[u8; 16], ClientState>, + output_seq: u64, + recent: VecDeque>, +} + +#[derive(Clone)] +struct ClientState { + endpoint: SocketAddr, + mode: String, + session_key: [u8; 32], + last_acked: u64, + replay: ReplayWindow, + send_seq: u64, + cols: u16, + rows: u16, + last_seen: Instant, + pending: VecDeque, + last_screen: Option, +} + +#[derive(Clone)] +struct PendingFrame { + output_seq: u64, + packet: Vec, + last_sent: Instant, + attempts: u8, +} + +impl ServerState { + fn new( + config: ServerConfig, + secret: [u8; 32], + pty_tx: mpsc::UnboundedSender, + ) -> Self { + Self { + config, + secret, + pty_tx, + sessions: HashMap::new(), + } + } + + fn ensure_session(&mut self, name: &str, cols: u16, rows: u16) -> Result<()> { + if self.sessions.contains_key(name) { + return Ok(()); + } + let pty = spawn_pty_session( + name.to_string(), + &self.config.shell, + cols.max(1), + rows.max(1), + self.pty_tx.clone(), + )?; + self.sessions.insert( + name.to_string(), + Session { + pty, + parser: vt100::Parser::new(rows.max(1), cols.max(1), self.config.scrollback), + clients: HashMap::new(), + output_seq: 0, + recent: VecDeque::with_capacity(self.config.scrollback), + }, + ); + Ok(()) + } +} + +async fn handle_packet( + state: &Arc>, + socket: &Arc, + peer: SocketAddr, + raw: &[u8], +) -> Result<()> { + let packet = protocol::decode(raw)?; + match packet.header.kind { + PacketKind::BootstrapAttachRequest => { + handle_bootstrap_attach(state, socket, peer, packet.body).await + } + PacketKind::TicketAttachRequest => { + handle_ticket_attach(state, socket, peer, packet.body).await + } + PacketKind::ResumeRequest => handle_resume(state, socket, peer, &packet).await, + PacketKind::Input => handle_input(state, peer, &packet).await, + PacketKind::Resize => handle_resize(state, peer, &packet).await, + PacketKind::Ping => handle_ping(state, socket, peer, &packet).await, + PacketKind::Ack => handle_ack(state, &packet).await, + PacketKind::Detach => handle_detach(state, &packet).await, + _ => Ok(()), + } +} + +async fn handle_bootstrap_attach( + state: &Arc>, + socket: &Arc, + peer: SocketAddr, + body: Vec, +) -> Result<()> { + let req: BootstrapAttachRequest = protocol::from_body(&body)?; + let (client_id, key, key_id, session_name, mode, output_seq, snapshot) = { + let mut locked = state.lock().expect("server state poisoned"); + if !verify_bootstrap(&req.bootstrap, &locked.secret)? { + return send_reject(socket, peer, "invalid or expired bootstrap").await; + } + if !locked.sessions.contains_key(&req.bootstrap.session) { + if locked.config.create_on_attach { + locked.ensure_session(&req.bootstrap.session, req.cols, req.rows)?; + } else { + return send_reject(socket, peer, "session does not exist").await; + } + } + let session = locked + .sessions + .get_mut(&req.bootstrap.session) + .expect("session exists"); + let client_id = crypto::random_16(); + let snapshot = session.parser.screen().state_formatted(); + let screen = session.parser.screen().clone(); + let output_seq = session.output_seq; + session.clients.insert( + client_id, + ClientState { + endpoint: peer, + mode: req.bootstrap.mode.clone(), + session_key: req.bootstrap.session_key, + last_acked: output_seq, + replay: ReplayWindow::default(), + send_seq: 1, + cols: req.cols, + rows: req.rows, + last_seen: Instant::now(), + pending: VecDeque::new(), + last_screen: Some(screen), + }, + ); + ( + client_id, + req.bootstrap.session_key, + req.bootstrap.session_key_id, + req.bootstrap.session.clone(), + req.bootstrap.mode.clone(), + output_seq, + snapshot, + ) + }; + let ok = AttachOk { + client_id, + session: session_name, + mode, + session_key: key, + session_key_id: key_id, + initial_seq: output_seq, + snapshot, + }; + let body = protocol::to_body(&ok)?; + let out = protocol::encode_encrypted( + PacketKind::AttachOk, + client_id, + 1, + 0, + &key, + SERVER_TO_CLIENT, + &body, + )?; + socket.send_to(&out, peer).await?; + Ok(()) +} + +async fn handle_ticket_attach( + state: &Arc>, + socket: &Arc, + peer: SocketAddr, + body: Vec, +) -> Result<()> { + let env: TicketAttachEnvelope = protocol::from_body(&body)?; + let (ticket, request_plain) = { + let locked = state.lock().expect("server state poisoned"); + if !locked.config.allow_attach_tickets { + return send_reject(socket, peer, "attach tickets disabled").await; + } + let ticket = open_attach_ticket(&locked.secret, &env.ticket)?; + let request_key = crypto::hkdf32( + &ticket.psk, + &env.client_nonce, + b"dosh/ticket-attach-request/v1", + )?; + let request_plain = crypto::open( + &request_key, + &env.client_nonce, + b"dosh-ticket-attach-request-v1", + &env.ciphertext, + )?; + (ticket, request_plain) + }; + let req: TicketAttachBody = protocol::from_body(&request_plain)?; + if req.session != ticket.session || req.mode != ticket.mode { + return send_reject(socket, peer, "ticket scope mismatch").await; + } + + let session_key = crypto::random_32(); + let session_key_id = { + let digest = crypto::sha256(&session_key); + let mut out = [0u8; 16]; + out.copy_from_slice(&digest[..16]); + out + }; + let (client_id, output_seq, snapshot) = { + let mut locked = state.lock().expect("server state poisoned"); + if !locked.sessions.contains_key(&req.session) { + if locked.config.create_on_attach { + locked.ensure_session(&req.session, req.cols, req.rows)?; + } else { + return send_reject(socket, peer, "session does not exist").await; + } + } + let session = locked + .sessions + .get_mut(&req.session) + .expect("session exists"); + let client_id = crypto::random_16(); + let snapshot = session.parser.screen().state_formatted(); + let screen = session.parser.screen().clone(); + let output_seq = session.output_seq; + session.clients.insert( + client_id, + ClientState { + endpoint: peer, + mode: req.mode.clone(), + session_key, + last_acked: output_seq, + replay: ReplayWindow::default(), + send_seq: 1, + cols: req.cols, + rows: req.rows, + last_seen: Instant::now(), + pending: VecDeque::new(), + last_screen: Some(screen), + }, + ); + (client_id, output_seq, snapshot) + }; + + let ok = AttachOk { + client_id, + session: req.session, + mode: req.mode, + session_key, + session_key_id, + initial_seq: output_seq, + snapshot, + }; + let ok_plain = protocol::to_body(&ok)?; + let server_nonce = crypto::random_12(); + let mut salt = Vec::with_capacity(24); + salt.extend_from_slice(&env.client_nonce); + salt.extend_from_slice(&server_nonce); + let response_key = crypto::hkdf32(&ticket.psk, &salt, b"dosh/ticket-attach-ok/v1")?; + let ciphertext = crypto::seal( + &response_key, + &server_nonce, + b"dosh-ticket-attach-ok-v1", + &ok_plain, + )?; + let envelope = TicketAttachOkEnvelope { + server_nonce, + ciphertext, + }; + let body = protocol::to_body(&envelope)?; + let out = protocol::encode_plain(PacketKind::AttachOk, client_id, 1, 0, &body)?; + socket.send_to(&out, peer).await?; + Ok(()) +} + +async fn send_reject(socket: &UdpSocket, peer: SocketAddr, reason: &str) -> Result<()> { + let body = protocol::to_body(&AttachReject { + reason: reason.to_string(), + })?; + let out = protocol::encode_plain(PacketKind::AttachReject, [0u8; 16], 0, 0, &body)?; + socket.send_to(&out, peer).await?; + Ok(()) +} + +async fn handle_resume( + state: &Arc>, + socket: &Arc, + peer: SocketAddr, + packet: &protocol::Packet, +) -> Result<()> { + let (key, session_name) = match find_client_key(state, &packet.header.conn_id) { + Ok(found) => found, + Err(_) => return send_reject(socket, peer, "unknown client").await, + }; + let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?; + let req: ResumeRequest = protocol::from_body(&body)?; + let (send_seq, output_seq, snapshot) = { + let mut locked = state.lock().expect("server state poisoned"); + let session = locked + .sessions + .get_mut(&req.session) + .ok_or_else(|| anyhow!("unknown session"))?; + let client = session + .clients + .get_mut(&packet.header.conn_id) + .ok_or_else(|| anyhow!("unknown client"))?; + if !client.replay.accept(packet.header.seq) { + return Ok(()); + } + client.endpoint = peer; + client.last_acked = req.last_rendered_seq; + client.cols = req.cols; + client.rows = req.rows; + client.last_seen = Instant::now(); + client.send_seq += 1; + let snapshot = session.parser.screen().state_formatted(); + client.last_screen = Some(session.parser.screen().clone()); + (client.send_seq, session.output_seq, snapshot) + }; + let frame = Frame { + session: session_name, + output_seq, + bytes: snapshot, + snapshot: true, + }; + let body = protocol::to_body(&frame)?; + let out = protocol::encode_encrypted( + PacketKind::ResumeOk, + packet.header.conn_id, + send_seq, + packet.header.seq, + &key, + SERVER_TO_CLIENT, + &body, + )?; + socket.send_to(&out, peer).await?; + Ok(()) +} + +async fn handle_input( + state: &Arc>, + peer: SocketAddr, + packet: &protocol::Packet, +) -> Result<()> { + let (key, session_name) = find_client_key(state, &packet.header.conn_id)?; + let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?; + let input: Input = protocol::from_body(&body)?; + let mut locked = state.lock().expect("server state poisoned"); + let session = locked + .sessions + .get_mut(&session_name) + .ok_or_else(|| anyhow!("unknown session"))?; + let client = session + .clients + .get_mut(&packet.header.conn_id) + .ok_or_else(|| anyhow!("unknown client"))?; + if !client.replay.accept(packet.header.seq) { + return Ok(()); + } + if client.endpoint != peer { + client.endpoint = peer; + } + client.last_seen = Instant::now(); + if client.mode == "view-only" { + return Ok(()); + } + session.pty.write_all(&input.bytes)?; + Ok(()) +} + +async fn handle_resize( + state: &Arc>, + peer: SocketAddr, + packet: &protocol::Packet, +) -> Result<()> { + let (key, session_name) = find_client_key(state, &packet.header.conn_id)?; + let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?; + let resize: Resize = protocol::from_body(&body)?; + let mut locked = state.lock().expect("server state poisoned"); + let session = locked + .sessions + .get_mut(&session_name) + .ok_or_else(|| anyhow!("unknown session"))?; + let client = session + .clients + .get_mut(&packet.header.conn_id) + .ok_or_else(|| anyhow!("unknown client"))?; + if !client.replay.accept(packet.header.seq) { + return Ok(()); + } + if client.mode != "view-only" { + client.endpoint = peer; + client.cols = resize.cols; + client.rows = resize.rows; + session.pty.resize(resize.cols, resize.rows)?; + session.parser.set_size(resize.rows, resize.cols); + } + Ok(()) +} + +async fn handle_ping( + state: &Arc>, + socket: &Arc, + peer: SocketAddr, + packet: &protocol::Packet, +) -> Result<()> { + let (key, _) = find_client_key(state, &packet.header.conn_id)?; + let seq = { + let mut locked = state.lock().expect("server state poisoned"); + let mut found = None; + for session in locked.sessions.values_mut() { + if let Some(client) = session.clients.get_mut(&packet.header.conn_id) { + if !client.replay.accept(packet.header.seq) { + return Ok(()); + } + client.last_seen = Instant::now(); + client.send_seq += 1; + found = Some(client.send_seq); + break; + } + } + found.ok_or_else(|| anyhow!("unknown client"))? + }; + let out = protocol::encode_encrypted( + PacketKind::Pong, + packet.header.conn_id, + seq, + packet.header.seq, + &key, + SERVER_TO_CLIENT, + b"", + )?; + socket.send_to(&out, peer).await?; + Ok(()) +} + +async fn handle_detach(state: &Arc>, packet: &protocol::Packet) -> Result<()> { + let mut locked = state.lock().expect("server state poisoned"); + for session in locked.sessions.values_mut() { + session.clients.remove(&packet.header.conn_id); + } + Ok(()) +} + +async fn handle_ack(state: &Arc>, packet: &protocol::Packet) -> Result<()> { + let (key, _) = find_client_key(state, &packet.header.conn_id)?; + let _ = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?; + let mut locked = state.lock().expect("server state poisoned"); + for session in locked.sessions.values_mut() { + if let Some(client) = session.clients.get_mut(&packet.header.conn_id) { + if !client.replay.accept(packet.header.seq) { + return Ok(()); + } + client.last_seen = Instant::now(); + client.last_acked = packet.header.ack; + while client + .pending + .front() + .is_some_and(|pending| pending.output_seq <= packet.header.ack) + { + client.pending.pop_front(); + } + return Ok(()); + } + } + Ok(()) +} + +async fn broadcast_output( + state: &Arc>, + socket: &Arc, + output: PtyOutput, +) -> Result<()> { + let sends = { + let mut locked = state.lock().expect("server state poisoned"); + let scrollback = locked.config.scrollback; + let retransmit_window = locked.config.retransmit_window; + let session = locked + .sessions + .get_mut(&output.session) + .ok_or_else(|| anyhow!("unknown session"))?; + session.parser.process(&output.bytes); + session.output_seq += 1; + let output_seq = session.output_seq; + session.recent.push_back(output.bytes.clone()); + while session.recent.len() > scrollback { + session.recent.pop_front(); + } + let mut sends = Vec::new(); + for (client_id, client) in session.clients.iter_mut() { + client.send_seq += 1; + let current_screen = session.parser.screen().clone(); + let mut snapshot = false; + let mut bytes = if client.pending.len() >= retransmit_window { + client.pending.clear(); + snapshot = true; + current_screen.state_formatted() + } else if let Some(prev) = &client.last_screen { + current_screen.state_diff(prev) + } else { + snapshot = true; + current_screen.state_formatted() + }; + if bytes.is_empty() { + bytes = output.bytes.clone(); + } + let frame = Frame { + session: output.session.clone(), + output_seq, + bytes, + snapshot, + }; + let body = protocol::to_body(&frame)?; + let packet = protocol::encode_encrypted( + PacketKind::Frame, + *client_id, + client.send_seq, + client.last_acked, + &client.session_key, + SERVER_TO_CLIENT, + &body, + )?; + while client.pending.len() >= retransmit_window { + client.pending.pop_front(); + } + client.last_screen = Some(current_screen); + client.pending.push_back(PendingFrame { + output_seq, + packet: packet.clone(), + last_sent: Instant::now(), + attempts: 0, + }); + sends.push((client.endpoint, packet)); + } + sends + }; + for (endpoint, packet) in sends { + socket.send_to(&packet, endpoint).await?; + } + Ok(()) +} + +async fn retransmit_pending( + state: &Arc>, + socket: &Arc, +) -> Result<()> { + let sends = { + let mut locked = state.lock().expect("server state poisoned"); + let now = Instant::now(); + let mut sends = Vec::new(); + for session in locked.sessions.values_mut() { + for client in session.clients.values_mut() { + for pending in client.pending.iter_mut() { + if pending.output_seq <= client.last_acked { + continue; + } + if now.duration_since(pending.last_sent) >= Duration::from_millis(200) + && pending.attempts < 8 + { + pending.last_sent = now; + pending.attempts += 1; + sends.push((client.endpoint, pending.packet.clone())); + } + } + } + } + sends + }; + for (endpoint, packet) in sends { + socket.send_to(&packet, endpoint).await?; + } + Ok(()) +} + +fn find_client_key( + state: &Arc>, + client_id: &[u8; 16], +) -> Result<([u8; 32], String)> { + let locked = state.lock().expect("server state poisoned"); + for (name, session) in &locked.sessions { + if let Some(client) = session.clients.get(client_id) { + return Ok((client.session_key, name.clone())); + } + } + Err(anyhow!("unknown client")) +} + +fn parse_nonce(raw: &str) -> Result<[u8; 12]> { + let bytes = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, raw) + .context("decode nonce")?; + anyhow::ensure!(bytes.len() == 12, "nonce must decode to 12 bytes"); + let mut out = [0u8; 12]; + out.copy_from_slice(&bytes); + Ok(out) +} + +fn parse_size(raw: &str) -> Result<(u16, u16)> { + let (cols, rows) = raw.split_once('x').context("size must be COLSxROWS")?; + Ok((cols.parse()?, rows.parse()?)) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..3f853d9 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,99 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + pub port: u16, + pub bind: String, + pub scrollback: usize, + pub auth_ttl_secs: u64, + pub attach_ticket_ttl_secs: u64, + pub allow_attach_tickets: bool, + pub client_timeout_secs: u64, + pub retransmit_window: usize, + pub default_input_mode: String, + pub prewarm_sessions: Vec, + pub create_on_attach: bool, + pub shell: String, + pub sessions_dir: String, + pub secret_path: String, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + port: 50000, + bind: "0.0.0.0".to_string(), + scrollback: 5000, + auth_ttl_secs: 30, + attach_ticket_ttl_secs: 3600, + allow_attach_tickets: true, + client_timeout_secs: 30, + retransmit_window: 256, + default_input_mode: "read-write".to_string(), + prewarm_sessions: vec!["default".to_string()], + create_on_attach: true, + shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()), + sessions_dir: "~/.local/share/dosh/sessions".to_string(), + secret_path: "~/.config/dosh/secret".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientConfig { + pub server: String, + pub dosh_host: Option, + pub ssh_port: u16, + pub dosh_port: u16, + pub default_session: String, + pub reconnect_timeout_secs: u64, + pub view_only: bool, + pub cache_attach_tickets: bool, + pub credential_cache: String, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + server: "user@example.com".to_string(), + dosh_host: None, + ssh_port: 22, + dosh_port: 50000, + default_session: "default".to_string(), + reconnect_timeout_secs: 5, + view_only: false, + cache_attach_tickets: true, + credential_cache: "~/.local/share/dosh/credentials".to_string(), + } + } +} + +pub fn load_server_config(path: Option) -> Result { + let path = path.unwrap_or_else(|| expand_tilde("~/.config/dosh/server.toml")); + if !path.exists() { + return Ok(ServerConfig::default()); + } + let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + toml::from_str(&raw).with_context(|| format!("parse {}", path.display())) +} + +pub fn load_client_config(path: Option) -> Result { + let path = path.unwrap_or_else(|| expand_tilde("~/.config/dosh/client.toml")); + if !path.exists() { + return Ok(ClientConfig::default()); + } + let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + toml::from_str(&raw).with_context(|| format!("parse {}", path.display())) +} + +pub fn expand_tilde(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + PathBuf::from(path) +} diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..7ce9696 --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,96 @@ +use anyhow::{Result, anyhow}; +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use sha2::{Digest, Sha256}; + +pub type HmacSha256 = Hmac; + +pub fn random_32() -> [u8; 32] { + let mut out = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut out); + out +} + +pub fn random_16() -> [u8; 16] { + let mut out = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut out); + out +} + +pub fn random_12() -> [u8; 12] { + let mut out = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut out); + out +} + +pub fn hmac_sha256(key: &[u8], parts: &[&[u8]]) -> [u8; 32] { + let mut mac = ::new_from_slice(key).expect("HMAC accepts any key size"); + for part in parts { + mac.update(part); + } + mac.finalize().into_bytes().into() +} + +pub fn verify_hmac(key: &[u8], parts: &[&[u8]], expected: &[u8; 32]) -> bool { + let actual = hmac_sha256(key, parts); + constant_time_eq(&actual, expected) +} + +pub fn sha256(data: &[u8]) -> [u8; 32] { + Sha256::digest(data).into() +} + +pub fn hkdf32(secret: &[u8], salt: &[u8], info: &[u8]) -> Result<[u8; 32]> { + let hk = Hkdf::::new(Some(salt), secret); + let mut out = [0u8; 32]; + hk.expand(info, &mut out) + .map_err(|_| anyhow!("HKDF expand failed"))?; + Ok(out) +} + +pub fn nonce_from(direction: u32, seq: u64) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[..4].copy_from_slice(&direction.to_be_bytes()); + nonce[4..].copy_from_slice(&seq.to_be_bytes()); + nonce +} + +pub fn seal(key: &[u8; 32], nonce: &[u8; 12], aad: &[u8], plaintext: &[u8]) -> Result> { + let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); + cipher + .encrypt( + Nonce::from_slice(nonce), + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| anyhow!("encrypt failed")) +} + +pub fn open(key: &[u8; 32], nonce: &[u8; 12], aad: &[u8], ciphertext: &[u8]) -> Result> { + let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); + cipher + .decrypt( + Nonce::from_slice(nonce), + Payload { + msg: ciphertext, + aad, + }, + ) + .map_err(|_| anyhow!("decrypt failed")) +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..2da2555 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +pub mod auth; +pub mod config; +pub mod crypto; +pub mod protocol; +pub mod pty; diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..6e13625 --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,330 @@ +use crate::auth::BootstrapResponse; +use crate::crypto; +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +pub const MAGIC: &[u8; 4] = b"DOSH"; +pub const VERSION: u8 = 1; +pub const HEADER_LEN: usize = 42; +pub const CLIENT_TO_SERVER: u32 = 1; +pub const SERVER_TO_CLIENT: u32 = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum PacketKind { + BootstrapAttachRequest = 1, + TicketAttachRequest = 2, + AttachOk = 3, + AttachReject = 4, + ResumeRequest = 5, + ResumeOk = 6, + Input = 7, + Resize = 8, + Frame = 9, + Ack = 10, + Ping = 11, + Pong = 12, + Detach = 13, +} + +impl TryFrom for PacketKind { + type Error = anyhow::Error; + + fn try_from(value: u8) -> Result { + Ok(match value { + 1 => Self::BootstrapAttachRequest, + 2 => Self::TicketAttachRequest, + 3 => Self::AttachOk, + 4 => Self::AttachReject, + 5 => Self::ResumeRequest, + 6 => Self::ResumeOk, + 7 => Self::Input, + 8 => Self::Resize, + 9 => Self::Frame, + 10 => Self::Ack, + 11 => Self::Ping, + 12 => Self::Pong, + 13 => Self::Detach, + _ => bail!("unknown packet kind {value}"), + }) + } +} + +#[derive(Debug, Clone)] +pub struct Header { + pub kind: PacketKind, + pub flags: u16, + pub conn_id: [u8; 16], + pub seq: u64, + pub ack: u64, + pub body_len: u16, +} + +impl Header { + pub fn aad(&self) -> [u8; HEADER_LEN] { + let mut out = [0u8; HEADER_LEN]; + out[..4].copy_from_slice(MAGIC); + out[4] = VERSION; + out[5] = self.kind as u8; + out[6..8].copy_from_slice(&self.flags.to_be_bytes()); + out[8..24].copy_from_slice(&self.conn_id); + out[24..32].copy_from_slice(&self.seq.to_be_bytes()); + out[32..40].copy_from_slice(&self.ack.to_be_bytes()); + out[40..42].copy_from_slice(&self.body_len.to_be_bytes()); + out + } + + pub fn parse(input: &[u8]) -> Result { + if input.len() < HEADER_LEN { + bail!("packet too short"); + } + if &input[..4] != MAGIC { + bail!("bad magic"); + } + if input[4] != VERSION { + bail!("bad protocol version {}", input[4]); + } + let kind = PacketKind::try_from(input[5])?; + let flags = u16::from_be_bytes(input[6..8].try_into().unwrap()); + let mut conn_id = [0u8; 16]; + conn_id.copy_from_slice(&input[8..24]); + let seq = u64::from_be_bytes(input[24..32].try_into().unwrap()); + let ack = u64::from_be_bytes(input[32..40].try_into().unwrap()); + let body_len = u16::from_be_bytes(input[40..42].try_into().unwrap()); + Ok(Self { + kind, + flags, + conn_id, + seq, + ack, + body_len, + }) + } +} + +#[derive(Debug, Clone)] +pub struct Packet { + pub header: Header, + pub body: Vec, +} + +pub fn encode_plain( + kind: PacketKind, + conn_id: [u8; 16], + seq: u64, + ack: u64, + body: &[u8], +) -> Result> { + if body.len() > u16::MAX as usize { + bail!("packet body too large"); + } + let header = Header { + kind, + flags: 0, + conn_id, + seq, + ack, + body_len: body.len() as u16, + }; + let mut out = Vec::with_capacity(HEADER_LEN + body.len()); + out.extend_from_slice(&header.aad()); + out.extend_from_slice(body); + Ok(out) +} + +pub fn encode_encrypted( + kind: PacketKind, + conn_id: [u8; 16], + seq: u64, + ack: u64, + key: &[u8; 32], + direction: u32, + plaintext: &[u8], +) -> Result> { + let nonce = crypto::nonce_from(direction, seq); + let header = Header { + kind, + flags: 1, + conn_id, + seq, + ack, + body_len: 0, + }; + let aad_without_len = header.aad(); + let ciphertext = crypto::seal(key, &nonce, &aad_without_len[..40], plaintext)?; + if ciphertext.len() > u16::MAX as usize { + bail!("packet body too large"); + } + let header = Header { + body_len: ciphertext.len() as u16, + ..header + }; + let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len()); + out.extend_from_slice(&header.aad()); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +pub fn decode(input: &[u8]) -> Result { + let header = Header::parse(input)?; + let end = HEADER_LEN + header.body_len as usize; + if input.len() < end { + bail!("truncated packet body"); + } + Ok(Packet { + header, + body: input[HEADER_LEN..end].to_vec(), + }) +} + +pub fn decrypt_body(packet: &Packet, key: &[u8; 32], direction: u32) -> Result> { + if packet.header.flags & 1 == 0 { + return Ok(packet.body.clone()); + } + let nonce = crypto::nonce_from(direction, packet.header.seq); + let aad = packet.header.aad(); + crypto::open(key, &nonce, &aad[..40], &packet.body) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BootstrapAttachRequest { + pub bootstrap: BootstrapResponse, + pub cols: u16, + pub rows: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TicketAttachEnvelope { + pub ticket: Vec, + pub client_nonce: [u8; 12], + pub ciphertext: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TicketAttachBody { + pub session: String, + pub mode: String, + pub cols: u16, + pub rows: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TicketAttachOkEnvelope { + pub server_nonce: [u8; 12], + pub ciphertext: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttachOk { + pub client_id: [u8; 16], + pub session: String, + pub mode: String, + pub session_key: [u8; 32], + pub session_key_id: [u8; 16], + pub initial_seq: u64, + pub snapshot: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttachReject { + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResumeRequest { + pub session: String, + pub last_rendered_seq: u64, + pub cols: u16, + pub rows: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Input { + pub bytes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Resize { + pub cols: u16, + pub rows: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Frame { + pub session: String, + pub output_seq: u64, + pub bytes: Vec, + pub snapshot: bool, +} + +pub fn to_body(value: &T) -> Result> { + bincode::serialize(value).context("serialize protocol body") +} + +pub fn from_body Deserialize<'de>>(body: &[u8]) -> Result { + bincode::deserialize(body).context("deserialize protocol body") +} + +#[derive(Debug, Clone)] +pub struct ReplayWindow { + highest: u64, + seen: u128, + width: u32, +} + +impl Default for ReplayWindow { + fn default() -> Self { + Self::new(128) + } +} + +impl ReplayWindow { + pub fn new(width: u32) -> Self { + assert!((1..=128).contains(&width)); + Self { + highest: 0, + seen: 0, + width, + } + } + + pub fn accept(&mut self, seq: u64) -> bool { + if seq == 0 { + return false; + } + if self.highest == 0 { + self.highest = seq; + self.seen = 1; + return true; + } + if seq > self.highest { + let shift = (seq - self.highest).min(128) as u32; + self.seen = if shift >= self.width { + 1 + } else { + ((self.seen << shift) | 1) & self.mask() + }; + self.highest = seq; + return true; + } + let offset = self.highest - seq; + if offset >= self.width as u64 { + return false; + } + let bit = 1u128 << offset; + if self.seen & bit != 0 { + false + } else { + self.seen |= bit; + true + } + } + + fn mask(&self) -> u128 { + if self.width == 128 { + u128::MAX + } else { + (1u128 << self.width) - 1 + } + } +} diff --git a/src/pty.rs b/src/pty.rs new file mode 100644 index 0000000..975d1b3 --- /dev/null +++ b/src/pty.rs @@ -0,0 +1,84 @@ +use anyhow::{Context, Result}; +use portable_pty::{CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem}; +use std::io::{Read, Write}; +use std::sync::{Arc, Mutex}; +use std::thread; +use tokio::sync::mpsc; + +pub struct PtyHandle { + writer: Arc>>, + _master: Box, +} + +impl PtyHandle { + pub fn write_all(&self, bytes: &[u8]) -> Result<()> { + let mut writer = self.writer.lock().expect("pty writer poisoned"); + writer.write_all(bytes)?; + writer.flush()?; + Ok(()) + } + + pub fn resize(&self, cols: u16, rows: u16) -> Result<()> { + self._master.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + })?; + Ok(()) + } +} + +#[derive(Debug)] +pub struct PtyOutput { + pub session: String, + pub bytes: Vec, +} + +pub fn spawn_pty_session( + session: String, + shell: &str, + cols: u16, + rows: u16, + tx: mpsc::UnboundedSender, +) -> Result { + let pty_system = NativePtySystem::default(); + let pair = pty_system + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .context("open pty")?; + let cmd = CommandBuilder::new(shell); + let _child = pair.slave.spawn_command(cmd).context("spawn shell")?; + drop(pair.slave); + + let writer = pair.master.take_writer().context("take pty writer")?; + let mut reader = pair.master.try_clone_reader().context("clone pty reader")?; + let reader_session = session.clone(); + thread::Builder::new() + .name(format!("dosh-pty-{session}")) + .spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let _ = tx.send(PtyOutput { + session: reader_session.clone(), + bytes: buf[..n].to_vec(), + }); + } + Err(_) => break, + } + } + }) + .context("spawn pty reader")?; + + Ok(PtyHandle { + writer: Arc::new(Mutex::new(writer)), + _master: pair.master, + }) +} diff --git a/tests/integration_smoke.rs b/tests/integration_smoke.rs new file mode 100644 index 0000000..ecddae7 --- /dev/null +++ b/tests/integration_smoke.rs @@ -0,0 +1,445 @@ +use std::fs; +use std::net::UdpSocket; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::Duration; + +use dosh::auth::{build_bootstrap, load_or_create_server_secret}; +use dosh::config::load_server_config; +use dosh::crypto; +use dosh::protocol::{ + self, AttachOk, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, PacketKind, Resize, + ResumeRequest, SERVER_TO_CLIENT, +}; + +fn free_udp_port() -> u16 { + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + socket.local_addr().unwrap().port() +} + +fn write_server_config(dir: &tempfile::TempDir, port: u16) -> std::path::PathBuf { + let config_dir = dir.path().join(".config/dosh"); + fs::create_dir_all(&config_dir).unwrap(); + let config = config_dir.join("server.toml"); + fs::write( + &config, + format!( + r#" +port = {port} +bind = "127.0.0.1" +scrollback = 5000 +auth_ttl_secs = 30 +attach_ticket_ttl_secs = 3600 +allow_attach_tickets = true +client_timeout_secs = 30 +retransmit_window = 256 +default_input_mode = "read-write" +prewarm_sessions = ["default"] +create_on_attach = true +shell = "/bin/sh" +sessions_dir = "{sessions}" +secret_path = "{secret}" +"#, + sessions = dir.path().join("sessions").display(), + secret = dir.path().join("secret").display(), + ), + ) + .unwrap(); + config +} + +fn start_server(dir: &tempfile::TempDir, config: &std::path::Path) -> Child { + let server = env!("CARGO_BIN_EXE_dosh-server"); + let child = Command::new(server) + .arg("serve") + .arg("--config") + .arg(config) + .env("HOME", dir.path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + thread::sleep(Duration::from_millis(500)); + child +} + +fn attach_once(dir: &tempfile::TempDir, port: u16, cache: bool) -> std::process::Output { + let client = env!("CARGO_BIN_EXE_dosh-client"); + let mut cmd = Command::new(client); + cmd.arg("--local-auth") + .arg("--attach-only") + .arg("--dosh-port") + .arg(port.to_string()) + .arg("local") + .env("HOME", dir.path()); + if !cache { + cmd.arg("--no-cache"); + } + cmd.output().unwrap() +} + +fn direct_attach( + config: &std::path::Path, + port: u16, + mode: &str, +) -> (std::net::UdpSocket, dosh::auth::BootstrapResponse, AttachOk) { + let config = load_server_config(Some(config.to_path_buf())).unwrap(); + let secret = load_or_create_server_secret(&config).unwrap(); + let bootstrap = build_bootstrap( + &config, + &secret, + "tester".to_string(), + "default".to_string(), + mode.to_string(), + (80, 24), + crypto::random_12(), + "127.0.0.1".to_string(), + ) + .unwrap(); + let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let req = BootstrapAttachRequest { + bootstrap: bootstrap.clone(), + cols: 80, + rows: 24, + }; + let packet = protocol::encode_plain( + PacketKind::BootstrapAttachRequest, + [0u8; 16], + 1, + 0, + &protocol::to_body(&req).unwrap(), + ) + .unwrap(); + socket + .send_to(&packet, format!("127.0.0.1:{port}")) + .unwrap(); + let mut buf = [0u8; 65535]; + let (n, _) = socket.recv_from(&mut buf).unwrap(); + let packet = protocol::decode(&buf[..n]).unwrap(); + assert_eq!(packet.header.kind, PacketKind::AttachOk); + let plain = protocol::decrypt_body(&packet, &bootstrap.session_key, SERVER_TO_CLIENT).unwrap(); + let ok: AttachOk = protocol::from_body(&plain).unwrap(); + (socket, bootstrap, ok) +} + +fn send_encrypted( + socket: &UdpSocket, + port: u16, + kind: PacketKind, + client_id: [u8; 16], + seq: u64, + ack: u64, + key: &[u8; 32], + body: &[u8], +) { + let packet = + protocol::encode_encrypted(kind, client_id, seq, ack, key, CLIENT_TO_SERVER, body).unwrap(); + socket + .send_to(&packet, format!("127.0.0.1:{port}")) + .unwrap(); +} + +fn recv_frame(socket: &UdpSocket, key: &[u8; 32]) -> Option<(protocol::Header, Frame)> { + let mut buf = [0u8; 65535]; + let (n, _) = socket.recv_from(&mut buf).ok()?; + let packet = protocol::decode(&buf[..n]).ok()?; + match packet.header.kind { + PacketKind::Frame | PacketKind::ResumeOk => { + let plain = protocol::decrypt_body(&packet, key, SERVER_TO_CLIENT).ok()?; + let frame: Frame = protocol::from_body(&plain).ok()?; + Some((packet.header, frame)) + } + _ => None, + } +} + +fn collect_frame_text(socket: &UdpSocket, key: &[u8; 32], millis: u64) -> String { + socket + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + let deadline = std::time::Instant::now() + Duration::from_millis(millis); + let mut text = String::new(); + while std::time::Instant::now() < deadline { + if let Some((_header, frame)) = recv_frame(socket, key) { + text.push_str(&String::from_utf8_lossy(&frame.bytes)); + } + } + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + text +} + +#[test] +fn local_attach_only_smoke() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + let mut server = start_server(&dir, &config); + let output = attach_once(&dir, port, false); + let _ = server.kill(); + let _ = server.wait(); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("terminal_ready"), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn ticket_attach_after_server_restart_smoke() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + let mut server = start_server(&dir, &config); + let first = attach_once(&dir, port, true); + let _ = server.kill(); + let _ = server.wait(); + assert!( + first.status.success(), + "stderr={}", + String::from_utf8_lossy(&first.stderr) + ); + + let mut server = start_server(&dir, &config); + let second = attach_once(&dir, port, true); + let _ = server.kill(); + let _ = server.wait(); + let stderr = String::from_utf8_lossy(&second.stderr); + assert!(second.status.success(), "stderr={stderr}"); + assert!( + stderr.contains("udp_ticket_attach_ready"), + "stderr={stderr}" + ); +} + +#[test] +fn view_only_input_is_rejected_by_server() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + let mut server = start_server(&dir, &config); + + let (view_socket, view_bootstrap, view_ok) = direct_attach(&config, port, "view-only"); + let input = Input { + bytes: b"echo DOSH_VIEW_ONLY_BUG\n".to_vec(), + }; + let packet = protocol::encode_encrypted( + PacketKind::Input, + view_ok.client_id, + 2, + 0, + &view_bootstrap.session_key, + CLIENT_TO_SERVER, + &protocol::to_body(&input).unwrap(), + ) + .unwrap(); + view_socket + .send_to(&packet, format!("127.0.0.1:{port}")) + .unwrap(); + thread::sleep(Duration::from_millis(250)); + + let (_rw_socket, _rw_bootstrap, rw_ok) = direct_attach(&config, port, "read-write"); + let snapshot = String::from_utf8_lossy(&rw_ok.snapshot); + + let _ = server.kill(); + let _ = server.wait(); + + assert!( + !snapshot.contains("DOSH_VIEW_ONLY_BUG"), + "view-only input reached PTY; snapshot={snapshot:?}" + ); +} + +#[test] +fn multiple_clients_share_one_session_screen() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + let mut server = start_server(&dir, &config); + let (writer_socket, writer_bootstrap, writer_ok) = direct_attach(&config, port, "read-write"); + let (reader_socket, reader_bootstrap, _reader_ok) = direct_attach(&config, port, "read-write"); + + let input = Input { + bytes: b"printf DOSH_MULTI_CLIENT\\n\n".to_vec(), + }; + send_encrypted( + &writer_socket, + port, + PacketKind::Input, + writer_ok.client_id, + 2, + 0, + &writer_bootstrap.session_key, + &protocol::to_body(&input).unwrap(), + ); + let text = collect_frame_text(&reader_socket, &reader_bootstrap.session_key, 2000); + + let _ = server.kill(); + let _ = server.wait(); + + assert!( + text.contains("DOSH_MULTI_CLIENT"), + "expected second client to see first client's output, got {text:?}" + ); +} + +#[test] +fn server_retransmits_unacked_output_frames() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + let mut server = start_server(&dir, &config); + let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); + + let input = Input { + bytes: b"printf DOSH_RETRANSMIT\\n\n".to_vec(), + }; + send_encrypted( + &socket, + port, + PacketKind::Input, + ok.client_id, + 2, + 0, + &bootstrap.session_key, + &protocol::to_body(&input).unwrap(), + ); + + let mut seen = Vec::new(); + let mut duplicate = None; + let deadline = std::time::Instant::now() + Duration::from_secs(3); + while std::time::Instant::now() < deadline { + if let Some((header, frame)) = recv_frame(&socket, &bootstrap.session_key) { + if String::from_utf8_lossy(&frame.bytes).contains("DOSH_RETRANSMIT") { + if seen + .iter() + .any(|(_, output_seq)| *output_seq == frame.output_seq) + { + duplicate = Some((header.seq, frame.output_seq)); + break; + } + seen.push((header.seq, frame.output_seq)); + } + } + } + + let _ = server.kill(); + let _ = server.wait(); + + assert!( + duplicate.is_some(), + "expected retransmitted same output seq, seen={seen:?}" + ); +} + +#[test] +fn resize_updates_pty_size() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + let mut server = start_server(&dir, &config); + let (socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); + + let resize = Resize { + cols: 100, + rows: 10, + }; + send_encrypted( + &socket, + port, + PacketKind::Resize, + ok.client_id, + 2, + 0, + &bootstrap.session_key, + &protocol::to_body(&resize).unwrap(), + ); + thread::sleep(Duration::from_millis(100)); + let input = Input { + bytes: b"stty size\n".to_vec(), + }; + send_encrypted( + &socket, + port, + PacketKind::Input, + ok.client_id, + 3, + 0, + &bootstrap.session_key, + &protocol::to_body(&input).unwrap(), + ); + let text = collect_frame_text(&socket, &bootstrap.session_key, 2000); + + let _ = server.kill(); + let _ = server.wait(); + + assert!( + text.contains("10 100"), + "expected resized pty to report 10 100, got {text:?}" + ); +} + +#[test] +fn resume_updates_udp_endpoint_for_roaming() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + let mut server = start_server(&dir, &config); + let (_old_socket, bootstrap, ok) = direct_attach(&config, port, "read-write"); + let new_socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + new_socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + + let resume = ResumeRequest { + session: "default".to_string(), + last_rendered_seq: ok.initial_seq, + cols: 80, + rows: 24, + }; + send_encrypted( + &new_socket, + port, + PacketKind::ResumeRequest, + ok.client_id, + 1, + 0, + &bootstrap.session_key, + &protocol::to_body(&resume).unwrap(), + ); + let (_header, resume_frame) = + recv_frame(&new_socket, &bootstrap.session_key).expect("resume response on new socket"); + assert!(resume_frame.snapshot); + + let input = Input { + bytes: b"printf DOSH_ROAM\\n\n".to_vec(), + }; + send_encrypted( + &new_socket, + port, + PacketKind::Input, + ok.client_id, + 2, + resume_frame.output_seq, + &bootstrap.session_key, + &protocol::to_body(&input).unwrap(), + ); + let text = collect_frame_text(&new_socket, &bootstrap.session_key, 2000); + + let _ = server.kill(); + let _ = server.wait(); + + assert!( + text.contains("DOSH_ROAM"), + "expected output on resumed socket, got {text:?}" + ); +} diff --git a/tests/protocol_auth.rs b/tests/protocol_auth.rs new file mode 100644 index 0000000..a9c7e67 --- /dev/null +++ b/tests/protocol_auth.rs @@ -0,0 +1,117 @@ +use dosh::auth::{ + build_bootstrap, decode_bootstrap, encode_bootstrap, load_or_create_server_secret, + open_attach_ticket, verify_attach_ticket, verify_bootstrap, +}; +use dosh::config::ServerConfig; +use dosh::crypto; +use dosh::protocol::{self, CLIENT_TO_SERVER, PacketKind, ReplayWindow}; + +#[test] +fn bootstrap_round_trips_and_verifies() { + let dir = tempfile::tempdir().unwrap(); + let config = ServerConfig { + secret_path: dir.path().join("secret").display().to_string(), + ..ServerConfig::default() + }; + let secret = load_or_create_server_secret(&config).unwrap(); + let nonce = crypto::random_12(); + let resp = build_bootstrap( + &config, + &secret, + "user".to_string(), + "default".to_string(), + "read-write".to_string(), + (80, 24), + nonce, + "127.0.0.1".to_string(), + ) + .unwrap(); + assert!(verify_bootstrap(&resp, &secret).unwrap()); + let encoded = encode_bootstrap(&resp).unwrap(); + let decoded = decode_bootstrap(&encoded).unwrap(); + assert_eq!(decoded.session, "default"); + assert_eq!(decoded.session_key, resp.session_key); +} + +#[test] +fn encrypted_packet_round_trips() { + let key = crypto::random_32(); + let conn_id = crypto::random_16(); + let packet = protocol::encode_encrypted( + PacketKind::Input, + conn_id, + 7, + 0, + &key, + CLIENT_TO_SERVER, + b"hello", + ) + .unwrap(); + let decoded = protocol::decode(&packet).unwrap(); + assert_eq!(decoded.header.kind, PacketKind::Input); + assert_eq!(decoded.header.conn_id, conn_id); + let plain = protocol::decrypt_body(&decoded, &key, CLIENT_TO_SERVER).unwrap(); + assert_eq!(plain, b"hello"); +} + +#[test] +fn attach_ticket_is_sealed_and_verifies_scope() { + let dir = tempfile::tempdir().unwrap(); + let config = ServerConfig { + secret_path: dir.path().join("secret").display().to_string(), + ..ServerConfig::default() + }; + let secret = load_or_create_server_secret(&config).unwrap(); + let resp = build_bootstrap( + &config, + &secret, + "user".to_string(), + "work".to_string(), + "view-only".to_string(), + (100, 30), + crypto::random_12(), + "127.0.0.1".to_string(), + ) + .unwrap(); + + let opened = open_attach_ticket(&secret, &resp.attach_ticket).unwrap(); + assert_eq!(opened.session, "work"); + assert_eq!(opened.mode, "view-only"); + assert_eq!(opened.psk, resp.attach_ticket_psk); + + assert!( + verify_attach_ticket( + &secret, + &resp.attach_ticket, + &resp.attach_ticket_psk, + "work", + "view-only", + ) + .unwrap() + .is_some() + ); + assert!( + verify_attach_ticket( + &secret, + &resp.attach_ticket, + &resp.attach_ticket_psk, + "default", + "view-only", + ) + .unwrap() + .is_none() + ); +} + +#[test] +fn replay_window_rejects_duplicates_but_allows_bounded_out_of_order() { + let mut replay = ReplayWindow::new(8); + assert!(replay.accept(10)); + assert!(!replay.accept(10)); + assert!(replay.accept(12)); + assert!(replay.accept(11)); + assert!(!replay.accept(11)); + assert!(replay.accept(18)); + assert!(!replay.accept(9)); + assert!(!replay.accept(0)); +}