Initial Dosh implementation
ci / test (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
Codex
2026-06-11 08:42:28 -04:00
commit 555d738a85
25 changed files with 6039 additions and 0 deletions
+75
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
/target/
**/*.rs.bk
.DS_Store
Generated
+1553
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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
+25
View File
@@ -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
+231
View File
@@ -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.
+593
View File
@@ -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
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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"]
+6
View File
@@ -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 "$@"
+23
View File
@@ -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
+63
View File
@@ -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
View File
@@ -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)?)
}
+59
View File
@@ -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()?))
}
+233
View File
@@ -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"))
}
+617
View File
@@ -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();
}
}
+743
View File
@@ -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()?))
}
+99
View File
@@ -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)
}
+96
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
pub mod auth;
pub mod config;
pub mod crypto;
pub mod protocol;
pub mod pty;
+330
View File
@@ -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
View File
@@ -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,
})
}
+445
View File
@@ -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:?}"
);
}
+117
View File
@@ -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));
}