Initial Dosh implementation
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/target/
|
||||||
|
**/*.rs.bk
|
||||||
|
.DS_Store
|
||||||
Generated
+1553
File diff suppressed because it is too large
Load Diff
+32
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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<SessionName, Session>
|
||||||
|
optional prewarm of configured sessions
|
||||||
|
auth helper mode for SSH bootstrap
|
||||||
|
metrics/timing logger
|
||||||
|
|
||||||
|
Session
|
||||||
|
PTY master
|
||||||
|
child process/shell
|
||||||
|
terminal parser
|
||||||
|
authoritative screen model
|
||||||
|
scrollback ring
|
||||||
|
monotonic output sequence
|
||||||
|
client table: HashMap<ClientId, ClientState>
|
||||||
|
|
||||||
|
ClientState
|
||||||
|
ClientId
|
||||||
|
UDP endpoint
|
||||||
|
mode: read-write | view-only
|
||||||
|
session key id
|
||||||
|
last acked sequence
|
||||||
|
terminal size
|
||||||
|
last seen timestamp
|
||||||
|
|
||||||
|
dosh-client
|
||||||
|
config loader
|
||||||
|
local credential cache
|
||||||
|
terminal raw mode
|
||||||
|
UDP protocol engine
|
||||||
|
SSH bootstrap runner
|
||||||
|
reconnect state machine
|
||||||
|
renderer
|
||||||
|
```
|
||||||
|
|
||||||
|
Server hot-path ownership should avoid locks on every broadcast. A single event-loop
|
||||||
|
owner per session is preferred. Cross-thread designs are allowed only if benchmarked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Security Model
|
||||||
|
|
||||||
|
SSH is the first trust root. dosh does not implement a competing public-key login
|
||||||
|
system in v0.
|
||||||
|
|
||||||
|
The UDP channel uses AEAD. Recommended default: `ChaCha20-Poly1305` for portable
|
||||||
|
speed, with `AES-GCM` allowed when hardware acceleration is known to be available.
|
||||||
|
The negotiated algorithm is recorded in the bootstrap response.
|
||||||
|
|
||||||
|
All encrypted packets use:
|
||||||
|
|
||||||
|
- Unique nonce per `(session_key_id, direction)`.
|
||||||
|
- Monotonic packet counter.
|
||||||
|
- Associated data containing protocol version, packet type, session name hash,
|
||||||
|
client id when known, and sequence numbers.
|
||||||
|
- Replay rejection using the packet counter window.
|
||||||
|
|
||||||
|
Secrets:
|
||||||
|
|
||||||
|
- `server_secret`: generated on first server start; stored mode `0600`.
|
||||||
|
- `session_key`: random 256-bit key per client attachment, rotated on SSH bootstrap
|
||||||
|
or ticket attach.
|
||||||
|
- `attach_ticket_key`: derived from `server_secret` and rotated by server key epoch.
|
||||||
|
|
||||||
|
No terminal bytes are sent outside AEAD after the attach handshake begins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. SSH Bootstrap Auth
|
||||||
|
|
||||||
|
Client command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -T user@host dosh-auth \
|
||||||
|
--protocol 1 \
|
||||||
|
--nonce <client_nonce> \
|
||||||
|
--session <name> \
|
||||||
|
--mode <read-write|view-only> \
|
||||||
|
--size <cols>x<rows> \
|
||||||
|
--client-version <version>
|
||||||
|
```
|
||||||
|
|
||||||
|
`dosh-auth` must:
|
||||||
|
|
||||||
|
- Not allocate a PTY.
|
||||||
|
- Not start a shell.
|
||||||
|
- Not run user shell rc files.
|
||||||
|
- Read server config and secret directly.
|
||||||
|
- Return one compact binary or base64url response on stdout.
|
||||||
|
- Exit immediately.
|
||||||
|
|
||||||
|
Bootstrap response fields:
|
||||||
|
|
||||||
|
- `protocol_version`
|
||||||
|
- `server_id`
|
||||||
|
- `server_key_epoch`
|
||||||
|
- `issued_at`
|
||||||
|
- `expires_at`
|
||||||
|
- `user`
|
||||||
|
- `session`
|
||||||
|
- `mode`
|
||||||
|
- `terminal_size`
|
||||||
|
- `attach_token`
|
||||||
|
- `attach_ticket`
|
||||||
|
- `attach_ticket_psk`
|
||||||
|
- `session_key`
|
||||||
|
- `session_key_id`
|
||||||
|
- `udp_host`
|
||||||
|
- `udp_port`
|
||||||
|
- `aead_algorithm`
|
||||||
|
|
||||||
|
`attach_token = HMAC-SHA256(server_secret, user || session || mode || terminal_size ||
|
||||||
|
client_nonce || issued_at || expires_at || session_key_id)`.
|
||||||
|
|
||||||
|
The token TTL defaults to 30 seconds. Attach tickets default to 1 hour and are
|
||||||
|
server-configurable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Attach Tickets
|
||||||
|
|
||||||
|
Attach tickets let a new client process attach without spawning SSH again.
|
||||||
|
|
||||||
|
Ticket properties:
|
||||||
|
|
||||||
|
- Server-sealed and authenticated by `attach_ticket_key`.
|
||||||
|
- Paired with a client-held random `attach_ticket_psk` returned during SSH bootstrap.
|
||||||
|
- Scoped to server identity, SSH username, session, mode, and key epoch.
|
||||||
|
- Short-lived by default.
|
||||||
|
- Revoked implicitly when server secret/key epoch changes.
|
||||||
|
- Stored client-side with mode `0600`, along with `attach_ticket_psk`.
|
||||||
|
|
||||||
|
Ticket attach does not prove fresh possession of the SSH private key. It proves recent
|
||||||
|
possession of a server-issued credential. This is acceptable for speed, configurable,
|
||||||
|
and can be disabled with `allow_attach_tickets = false`.
|
||||||
|
|
||||||
|
Ticket attach flow:
|
||||||
|
|
||||||
|
1. Client sends `TicketAttachRequest` containing the sealed ticket, client nonce, and
|
||||||
|
requested terminal size.
|
||||||
|
2. The request body is AEAD-encrypted with a key derived from
|
||||||
|
`HKDF(attach_ticket_psk, client_nonce || "ticket-attach-request")`.
|
||||||
|
3. Server opens the sealed ticket, validates scope/expiry/key epoch, derives the same
|
||||||
|
request key, and decrypts the request.
|
||||||
|
4. Server creates a fresh session key and `ClientId`.
|
||||||
|
5. `AttachOk` is AEAD-encrypted with
|
||||||
|
`HKDF(attach_ticket_psk, client_nonce || server_nonce || "ticket-attach-ok")` and
|
||||||
|
carries the fresh session key metadata plus first snapshot.
|
||||||
|
6. Subsequent terminal packets use the fresh session key, not the ticket PSK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. UDP Protocol
|
||||||
|
|
||||||
|
UDP port defaults to `50000`. One socket handles all sessions and clients.
|
||||||
|
|
||||||
|
Hot-path terminal packets use a fixed binary header:
|
||||||
|
|
||||||
|
```text
|
||||||
|
magic 4 bytes "DOSH"
|
||||||
|
version 1 byte 1
|
||||||
|
type 1 byte
|
||||||
|
flags 2 bytes
|
||||||
|
conn_id 16 bytes zero before client id is assigned
|
||||||
|
seq 8 bytes sender packet sequence
|
||||||
|
ack 8 bytes latest received peer sequence
|
||||||
|
body_len 2 bytes
|
||||||
|
body body_len bytes
|
||||||
|
tag AEAD tag, length depends on algorithm
|
||||||
|
```
|
||||||
|
|
||||||
|
Packet types:
|
||||||
|
|
||||||
|
| Type | Direction | Encrypted | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `BootstrapAttachRequest` | client -> server | token-authenticated | Attach after SSH bootstrap |
|
||||||
|
| `TicketAttachRequest` | client -> server | ticket PSK | Attach with cached ticket |
|
||||||
|
| `AttachOk` | server -> client | yes | Assign client id and send first snapshot |
|
||||||
|
| `AttachReject` | server -> client | no terminal bytes | Reject and require SSH |
|
||||||
|
| `ResumeRequest` | client -> server | yes | Resume known client |
|
||||||
|
| `ResumeOk` | server -> client | yes | Endpoint updated; diff/snapshot follows |
|
||||||
|
| `Input` | client -> server | yes | PTY input bytes |
|
||||||
|
| `Resize` | client -> server | yes | Terminal size update |
|
||||||
|
| `Frame` | server -> client | yes | Screen diff or PTY byte frame |
|
||||||
|
| `Ack` | both | yes | Ack without payload |
|
||||||
|
| `Ping` / `Pong` | both | yes | Keepalive and RTT |
|
||||||
|
| `Detach` | client -> server | yes | Remove client, keep session |
|
||||||
|
|
||||||
|
MTU target:
|
||||||
|
|
||||||
|
- Default payload target: 1200 bytes.
|
||||||
|
- Larger datagrams may be enabled only after path MTU discovery.
|
||||||
|
- Snapshots larger than the target are chunked.
|
||||||
|
|
||||||
|
Reliability:
|
||||||
|
|
||||||
|
- Input packets are reliable and ordered per client.
|
||||||
|
- Output frames are sequenced; clients ack rendered sequence.
|
||||||
|
- Server retransmits unacked frames within a bounded window.
|
||||||
|
- If a client falls too far behind, server sends a fresh snapshot instead of replaying
|
||||||
|
unlimited diffs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Sessions and PTYs
|
||||||
|
|
||||||
|
Named sessions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dosh # 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.
|
||||||
+83
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+216
@@ -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" <<EOF
|
||||||
|
port = $port
|
||||||
|
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"
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
mkdir -p "$systemd_user_dir"
|
||||||
|
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \
|
||||||
|
packaging/systemd/dosh-server.service >"$systemd_user_dir/dosh-server.service"
|
||||||
|
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" <<EOF
|
||||||
|
server = "$default_server"
|
||||||
|
$dosh_host_line
|
||||||
|
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"
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
Installed Dosh to $bindir
|
||||||
|
Configured UDP port $port
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ "$role" = "client" ] || [ "$role" = "both" ]; then
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Client command:
|
||||||
|
$bindir/dosh ${server:-user@host}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Server command:
|
||||||
|
$bindir/dosh-server serve
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends openssh-server ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN useradd -m -s /bin/sh bench \
|
||||||
|
&& mkdir -p /var/run/sshd /home/bench/.ssh /home/bench/.local/bin \
|
||||||
|
&& chown -R bench:bench /home/bench/.ssh /home/bench/.local
|
||||||
|
|
||||||
|
COPY dosh-server dosh-auth /usr/local/bin/
|
||||||
|
COPY bench_authorized_keys /home/bench/.ssh/authorized_keys
|
||||||
|
|
||||||
|
RUN chmod 0755 /usr/local/bin/dosh-server /usr/local/bin/dosh-auth \
|
||||||
|
&& chmod 0700 /home/bench/.ssh \
|
||||||
|
&& chmod 0600 /home/bench/.ssh/authorized_keys \
|
||||||
|
&& chown bench:bench /home/bench/.ssh/authorized_keys
|
||||||
|
|
||||||
|
EXPOSE 22/tcp 50000/udp
|
||||||
|
|
||||||
|
CMD ["/bin/sh", "-lc", "su - bench -c 'nohup /usr/local/bin/dosh-server serve >/tmp/dosh-server.log 2>&1 &' && exec /usr/sbin/sshd -D -e"]
|
||||||
Executable
+6
@@ -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 "$@"
|
||||||
@@ -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
|
||||||
Executable
+63
@@ -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
|
||||||
+287
@@ -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<u8>,
|
||||||
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<u64> {
|
||||||
|
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<BootstrapResponse> {
|
||||||
|
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<bool> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Option<AttachTicketPlain>> {
|
||||||
|
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<AttachTicketPlain> {
|
||||||
|
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<String> {
|
||||||
|
Ok(URL_SAFE_NO_PAD.encode(bincode::serialize(resp)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_bootstrap(raw: &str) -> Result<BootstrapResponse> {
|
||||||
|
let bytes = URL_SAFE_NO_PAD.decode(raw.trim())?;
|
||||||
|
Ok(bincode::deserialize(&bytes)?)
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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()?))
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
#[arg(long, default_value_t = 3)]
|
||||||
|
iterations: usize,
|
||||||
|
#[arg(long)]
|
||||||
|
local_auth: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
client: Option<PathBuf>,
|
||||||
|
#[arg(long, default_value = "dosh-auth")]
|
||||||
|
ssh_auth_command: String,
|
||||||
|
#[arg(long)]
|
||||||
|
ssh_key: Option<PathBuf>,
|
||||||
|
#[arg(long)]
|
||||||
|
ssh_known_hosts: Option<PathBuf>,
|
||||||
|
#[arg(long)]
|
||||||
|
ssh_control_path: Option<PathBuf>,
|
||||||
|
#[arg(long)]
|
||||||
|
controlmaster: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
no_cache: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
assert_ssh_plus_ms: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PathBuf>,
|
||||||
|
ssh_known_hosts: Option<PathBuf>,
|
||||||
|
control_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlMaster {
|
||||||
|
fn start(args: &Args, control_path: PathBuf) -> Result<Self> {
|
||||||
|
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<Duration> {
|
||||||
|
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"))
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
#[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<u16>,
|
||||||
|
#[arg(long, default_value = "dosh-auth")]
|
||||||
|
ssh_auth_command: String,
|
||||||
|
#[arg(long)]
|
||||||
|
ssh_key: Option<std::path::PathBuf>,
|
||||||
|
#[arg(long)]
|
||||||
|
ssh_known_hosts: Option<std::path::PathBuf>,
|
||||||
|
#[arg(long)]
|
||||||
|
ssh_control_path: Option<std::path::PathBuf>,
|
||||||
|
#[arg(long)]
|
||||||
|
dosh_port: Option<u16>,
|
||||||
|
#[arg(long)]
|
||||||
|
dosh_host: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<u8>,
|
||||||
|
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<BootstrapResponse> {
|
||||||
|
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<BootstrapResponse> {
|
||||||
|
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<SocketAddr> {
|
||||||
|
(host, port)
|
||||||
|
.to_socket_addrs()
|
||||||
|
.with_context(|| format!("resolve UDP target {host}:{port}"))?
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow!("no UDP address resolved for {host}:{port}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Frame>,
|
||||||
|
) -> 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::<Vec<u8>>();
|
||||||
|
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::<String>();
|
||||||
|
expand_tilde(root).join(format!("{safe}.bin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_cache(path: &std::path::Path) -> Result<CachedCredential> {
|
||||||
|
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<Self> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
Ok(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RawMode {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = disable_raw_mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<std::path::PathBuf>,
|
||||||
|
},
|
||||||
|
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<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<std::path::PathBuf>) -> 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<PtyOutput>,
|
||||||
|
sessions: HashMap<String, Session>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Session {
|
||||||
|
pty: PtyHandle,
|
||||||
|
parser: vt100::Parser,
|
||||||
|
clients: HashMap<[u8; 16], ClientState>,
|
||||||
|
output_seq: u64,
|
||||||
|
recent: VecDeque<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<PendingFrame>,
|
||||||
|
last_screen: Option<vt100::Screen>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PendingFrame {
|
||||||
|
output_seq: u64,
|
||||||
|
packet: Vec<u8>,
|
||||||
|
last_sent: Instant,
|
||||||
|
attempts: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerState {
|
||||||
|
fn new(
|
||||||
|
config: ServerConfig,
|
||||||
|
secret: [u8; 32],
|
||||||
|
pty_tx: mpsc::UnboundedSender<PtyOutput>,
|
||||||
|
) -> 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<Mutex<ServerState>>,
|
||||||
|
socket: &Arc<UdpSocket>,
|
||||||
|
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<Mutex<ServerState>>,
|
||||||
|
socket: &Arc<UdpSocket>,
|
||||||
|
peer: SocketAddr,
|
||||||
|
body: Vec<u8>,
|
||||||
|
) -> 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<Mutex<ServerState>>,
|
||||||
|
socket: &Arc<UdpSocket>,
|
||||||
|
peer: SocketAddr,
|
||||||
|
body: Vec<u8>,
|
||||||
|
) -> 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<Mutex<ServerState>>,
|
||||||
|
socket: &Arc<UdpSocket>,
|
||||||
|
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<Mutex<ServerState>>,
|
||||||
|
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<Mutex<ServerState>>,
|
||||||
|
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<Mutex<ServerState>>,
|
||||||
|
socket: &Arc<UdpSocket>,
|
||||||
|
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<Mutex<ServerState>>, 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<Mutex<ServerState>>, 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<Mutex<ServerState>>,
|
||||||
|
socket: &Arc<UdpSocket>,
|
||||||
|
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<Mutex<ServerState>>,
|
||||||
|
socket: &Arc<UdpSocket>,
|
||||||
|
) -> 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<Mutex<ServerState>>,
|
||||||
|
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()?))
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
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<String>,
|
||||||
|
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<PathBuf>) -> Result<ServerConfig> {
|
||||||
|
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<PathBuf>) -> Result<ClientConfig> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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<Sha256>;
|
||||||
|
|
||||||
|
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 = <HmacSha256 as 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::<Sha256>::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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod config;
|
||||||
|
pub mod crypto;
|
||||||
|
pub mod protocol;
|
||||||
|
pub mod pty;
|
||||||
+330
@@ -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<u8> for PacketKind {
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
|
fn try_from(value: u8) -> Result<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_plain(
|
||||||
|
kind: PacketKind,
|
||||||
|
conn_id: [u8; 16],
|
||||||
|
seq: u64,
|
||||||
|
ack: u64,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Packet> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<u8>,
|
||||||
|
pub client_nonce: [u8; 12],
|
||||||
|
pub ciphertext: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<u8>,
|
||||||
|
pub snapshot: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_body<T: Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||||
|
bincode::serialize(value).context("serialize protocol body")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_body<T: for<'de> Deserialize<'de>>(body: &[u8]) -> Result<T> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -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<Mutex<Box<dyn Write + Send>>>,
|
||||||
|
_master: Box<dyn MasterPty + Send>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_pty_session(
|
||||||
|
session: String,
|
||||||
|
shell: &str,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
tx: mpsc::UnboundedSender<PtyOutput>,
|
||||||
|
) -> Result<PtyHandle> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user