Skip to content

Mosh-like per-session model, PQC on by default, real README - #1

Merged
l1a merged 15 commits into
mainfrom
feature/udp-transport-redesign
Jun 16, 2026
Merged

Mosh-like per-session model, PQC on by default, real README#1
l1a merged 15 commits into
mainfrom
feature/udp-transport-redesign

Conversation

@l1a

@l1a l1a commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Summary

  • Per-session architecture: etr now SSHes to the target and starts etrs on the fly — no pre-running daemon required. etrs binds a random UDP port, prints it to stdout, forks into the background, and exits on clean disconnect. Mirrors the mosh model.
  • Post-quantum crypto on by default: ML-KEM-1024 + AES-256-GCM is the negotiated suite when both sides are built with defaults. Opt out with --no-default-features.
  • Bootstrap protocol: etr writes SESSION_ID_HEX/PASSKEY/TERM to etrs stdin via SSH; etrs responds with PORT <n> on stdout, then forks. No TCP registration port, no daemon.
  • ml-kem 0.3.2: rewrite kyber.rs for the new API (DecapsulationKey<P>, EncapsulationKey<P>, Kem trait, as_slice() instead of AsRef).
  • Handshake fix: client now generates the KEM keypair for the most-preferred suite so the server can encapsulate with the right key size.
  • README: replace the crates.io placeholder with real documentation.
  • CI: trigger on feature/**, fix/**, chore/** branches.
  • Release: Linux-only build (macOS/Windows untested), fix --completions flag syntax.

Test plan

  • just check — fmt + clippy clean
  • cargo test — 94 tests pass
  • Manual: etr localhost connects and shows a shell (requires passwordless SSH to localhost)
  • Manual: reconnect — drop network for >15 s, verify session resumes
  • CI passes on this branch

🤖 Generated with Claude Code

l1a and others added 15 commits June 16, 2026 09:13
Replace the TCP/bincode stack with a UDP-based protocol:

- 26-byte unencrypted PacketHeader (version, flags, session_id,
  packet_seq) for server-side routing before decryption
- Protobuf envelope (prost derive macros, no protoc required) with
  stable field tags for forward/backward compatibility
- Stream multiplexing: stream 0 = terminal PTY, streams 1+ = port
  forwards, each with independent sequence/replay state
- 1-RTT handshake: ClientHello (plaintext) → ServerHello (hello-key
  encrypted); both sides derive the session key in one round trip
- Cipher suite negotiation with preference list; suites in priority
  order: ML-KEM-1024+AES-GCM+SHA3, ML-KEM-768+AES-GCM+SHA256,
  X25519+AES-GCM+SHA256, X25519+ChaCha20-Poly1305+SHA256
- PQC suites (ML-KEM) gated behind the `pqc` cargo feature
- Ephemeral KEM adds perfect forward secrecy on top of the SSH-
  bootstrapped passkey; passkey also encrypts ServerHello for
  implicit server authentication
- SessionState now owns per-stream StreamState ring buffers;
  reconnect replay is per-stream

Assisted-By: Claude Sonnet 4.6
The previous design had handle_client_hello spawning its own
recv_packet loop, racing with the main loop on the same socket.
Data packets received by the main loop were silently dropped.

Replace with a proper demux architecture:
- run_daemon owns the socket exclusively as the sole reader
- Handshake packets spawn a handle_client_hello task as before
- Data packets are routed by session_id to a per-session
  mpsc::Sender<ReceivedPacket> (inbound_tx), replacing the
  competing recv_packet call in handle_client_hello
- handle_client_hello creates the (inbound_tx, inbound_rx) pair,
  registers inbound_tx in ActiveSession, and reads from inbound_rx
- Replacing inbound_tx on reconnect closes the old Receiver,
  cleanly terminating the prior connection's reader task
- outbound_tx replaces udp_tx: PTY reader and disconnect signals
  write Envelopes here; the writer task encrypts and sends them
- server_last_received is now populated from actual session state
  rather than an empty HashMap

Assisted-By: Claude Sonnet 4.6
stderr is unbuffered, so operational log messages (startup, session
registration, PTY exit, timeouts) appear immediately when the daemon's
stdout is redirected to a file.  Error messages were already using
eprintln!; this makes all messages consistent.

Assisted-By: Claude Sonnet 4.6
Recipes: check-tools, build, install, test-local, log, clean.
test-local covers happy path and reconnect via SIGSTOP/SIGCONT
without requiring sudo or iptables.

Assisted-By: Claude Sonnet 4.6
Add fmt, fmt-check, clippy, check, test, audit,
build-release, and install-release recipes.
Decouple build from check-tools; scope check-tools
to test-local prerequisites only.

Assisted-By: Claude Sonnet 4.6
Assisted-By: Claude Sonnet 4.6
Socket: $XDG_RUNTIME_DIR/etr/etrs.sock
Log:    $XDG_STATE_HOME/etr/etrs.log

Use dirs crate for XDG lookup in etrs; create parent
directory before binding the socket. Justfile derives
both paths via shell expansion with XDG fallbacks.

Assisted-By: Claude Sonnet 4.6
-v  connection lifecycle (connect, reconnect, timeout)
-vv cipher suite negotiation, session IDs, peer address
-vvv per-packet trace (type, seq, size, direction)

CipherSuiteId gains Display and a name() method.
process_server_hello now returns the negotiated suite.
etrs uses a OnceLock<u8> global so spawned tasks log
without extra parameter threading.

Assisted-By: Claude Sonnet 4.6
Docs: struct/method docs for Aes256GcmCipher, ChaCha20Cipher,
AeadCipher, X25519KeyPair, MlKem768/1024KeyPair, StreamLifecycle
variants, SessionState methods, HandshakeError variants.

Tests added (+50 new tests):
- crypto/aead: AEAD auth-tag verification, wrong-key, mutated
  ciphertext, wrong-seq, empty plaintext, seq_to_nonce uniqueness
- crypto/kdf: determinism, salt/ikm/info binding, output length,
  SHA-256 vs SHA3-256 divergence
- crypto/x25519: invalid-length error paths, key uniqueness
- crypto/kyber: ML-KEM-768/1024 round-trips, wrong-ciphertext
  implicit rejection
- transport: decode_data_packet wrong key/mutation/seq errors,
  decode_plaintext_packet invalid protobuf, send/recv loopback
  (plaintext and encrypted), truncated-header returns None
- session/stream: acknowledge_up_to edge cases (empty, past end,
  drain all), replay_from on empty history, initial seq values
- session/mod: close/apply_acks on unknown stream, last_received
  semantics (nothing received → 0), collect_replays with empty
  peer map, open_stream idempotence
- handshake: MalformedPacket, UnexpectedPacket, UnsupportedSuite,
  last_received_seq roundtrip in both directions

Assisted-By: Claude Sonnet 4.6
- Box AeadCipher::Aes256Gcm to balance variant sizes
- Suppress vec_init_then_push: cfg-gated pushes can't use vec![]
- Fix doc_overindented_list_items in lib.rs and protocol/mod.rs
- Collapse nested if-let chains in parse_target (etr.rs)
- Use is_multiple_of(2) in hex_decode (etrs.rs)
- assert! instead of assert_eq!(x, true) in session tests

Assisted-By: Claude Sonnet 4.6
- Replace Unix domain socket with TCP loopback (127.0.0.1:udp+1) for
  session registration; eliminates XDG_RUNTIME_DIR path mismatch between
  interactive daemon and non-interactive SSH sessions
- Pass reg_port through stdin bootstrap channel instead of CLI flags,
  avoiding clap global-arg-after-subcommand parsing issues
- Resolve hostnames via tokio::net::lookup_host instead of SocketAddr::parse
  to support non-IP targets like 'localhost'
- Bind client UDP socket to [::]:0 for IPv6 targets, 0.0.0.0:0 for IPv4
- Default daemon bind address to [::] for dual-stack IPv4+IPv6 support
- Route etr verbose logs to ~/.local/state/etr/etr.log in interactive mode
  to avoid corrupting raw-mode terminal display
- Fix stale test asserting removed --socket CLI field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix vlog: show on stderr before raw mode, log file only during session
- Set IN_RAW_MODE flag around enable/disable_raw_mode calls
- AGENTS.md: require NOTES.md read on startup, update on commit/push
- NOTES.md: add product vision (Mode 1 mosh-like, Mode 2 port forwarding),
  update known gaps, fix verbose logging description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
etr now SSHes to the target and starts etrs on the fly — no
pre-running daemon required. etrs binds a random UDP port, prints
it to stdout, forks, and the child runs the session. On clean
disconnect the child exits immediately.

- etrs: complete rewrite as per-session binary (fork + detach)
- etr: bootstrap_ssh reads PORT from SSH stdout; no reg port
- ml-kem: update kyber.rs for ml-kem 0.3.2 API
- handshake: generate keypair for most-preferred suite (fixes PQC)
- PQC on by default; opt out with --no-default-features
- justfile: test-local updated for new architecture
- README: replace placeholder with real documentation
- NOTES.md: reflect new architecture throughout

Assisted-By: Claude Sonnet 4.6
ci.yml: trigger on feature/**, fix/**, chore/** push branches
release.yml: Linux-only build (macOS/Windows untested), fix
completions flag (--completions SHELL, not subcommand),
simplify to single job matrix

Assisted-By: Claude Sonnet 4.6
@l1a
l1a merged commit 3c05eb8 into main Jun 16, 2026
6 checks passed
@l1a
l1a deleted the feature/udp-transport-redesign branch June 16, 2026 21:31
@l1a l1a mentioned this pull request Jun 19, 2026
4 tasks
l1a added a commit that referenced this pull request Jul 21, 2026
* Fix Windows input path and terminal restore

Two independent native-Windows parity fixes in the etr client.

1. Special characters no longer "eaten" (issue #54): the stdin reader used
   std::io::stdin().read(), which on Windows goes through Rust std's
   ReadConsoleW shim (UTF-16->UTF-8 + line cooking). Even in raw mode it
   batches input and drops non-UTF-8 bytes, which made special characters
   vanish (zellij keybindings needing ^g) and caused the first-line-not-
   echoed bug. It now reads the console input handle directly with ReadFile
   (read_stdin); with ENABLE_VIRTUAL_TERMINAL_INPUT on this returns the same
   unbatched, per-keystroke VT byte stream a Unix terminal emits.
   enable_vt_console also sets the console input codepage to UTF-8 (65001),
   saved/restored on exit, so typed multi-byte input reaches the remote as
   UTF-8. Adds windows-sys feature Win32_Storage_FileSystem for ReadFile.

2. Local terminal restored on exit: a remote full-screen app leaves the
   local terminal in alternate-screen/mouse/paste/hidden-cursor modes that
   disable_raw_mode does not undo, so after a hard drop or ~. the mouse wheel
   spewed escapes and the terminal was unusable. restore_terminal() now emits
   VT resets on every final-exit path: a cursor-safe part (TERM_RESET_MODES)
   on every exit and a screen-restoring part (TERM_RESET_SCREEN, which homes
   the cursor) only on unclean exits. Avoids a full RIS so scrollback is kept.

Version 0.6.4 -> 0.6.5. Test count 110 -> 112 (reset-sequence regressions).

Assisted-By: Claude Opus 4.8

* docs: record live Windows->WSL verification of v0.6.5

Verified end-to-end against a real Unix etrs (WSL Fedora 44): remote prompt
renders, PTY command round-trips, and the client emits the cursor-safe
terminal-restore sequence on clean exit (fix #2 confirmed in the live byte
stream). The console input-VT path (fix #1) needs interactive keystrokes and
is flagged for manual confirmation. Also notes an adjacent pre-existing gap:
redirected stdin ends a remote-command session on EOF before output arrives.

Assisted-By: Claude Opus 4.8

* docs: verify v0.6.5 fixes via synthesized console keystrokes

Drove the live etr client with real console key events (WriteConsoleInputW)
against a WSL Fedora 44 etrs. Confirms fix #1 end-to-end: Ctrl+G->0x07, arrow
keys, rapid bursts and Unicode all survive the raw+VT-input ReadFile path
un-eaten, and injected keystrokes reach the remote per-keystroke (remote
zsh-syntax-highlighting recolours char-by-char). Confirms fix #2: cursor-safe
terminal-restore sequence emitted on clean exit. Updates NOTES accordingly.

Assisted-By: Claude Opus 4.8

* docs: add stdin-EOF remote-command gap to Known gaps

Promote the redirected-stdin truncation note from the v0.6.5 verification
footnote into the Known gaps / next steps list so it is discoverable as
tracked open work, with a sketch of the ssh-parity fix (half-close stdin,
keep draining PTY output).

Assisted-By: Claude Opus 4.8

* docs: note just recipes fail on native Windows shells

`just install` (and other bash-shebang recipes) fail from PowerShell/nushell
with "could not find cygpath": just tries to translate the shebang interpreter
path via cygpath, absent without Git Bash on PATH. Recorded in Known gaps with
the cargo-install workaround and a sketch of a cross-shell fix.

Assisted-By: Claude Opus 4.8

* Fix Windows first-line echo (#54) via reader gate

The single stdin reader thread is spawned before the QUIC connect, but raw +
VT-input mode is only enabled after connect. On Windows a ReadFile issued while
the console is still in cooked/line mode stays line-buffered for that whole
read, so the first line was held client-side until Enter ("no echo until first
Enter"). The v0.6.5 ReadFile change did not fix this — it is a timing problem,
not a read-mechanism one.

Gate the Windows reader on a one-shot signal fired right after the first
enable_raw_mode + enable_vt_console, so its first read happens in raw + VT mode
and is per-keystroke. Unix is unaffected (ungated, never had the bug).

Verified with an A/B console-keystroke harness that snapshots the client's
stdout before Enter: pre-fix the typed first line is absent (line-buffered);
with the gate it appears, echoed back per-keystroke. NOTES corrected (the
earlier claim that ReadFile alone fixed #54 was wrong).

Assisted-By: Claude Opus 4.8

* Fix local shell Enter broken after etr exits (Windows)

enable_vt_console sets ENABLE_VIRTUAL_TERMINAL_INPUT, but crossterm's
disable_raw_mode only ORs the line/echo/processed-input bits back — it never
clears the VT-input flag. So after etr exited, the console was left with
VT-input enabled and the local shell echoed typed characters but would not
accept Enter (the VT-translated Enter wasn't seen as line submission).

Capture the console's exact original input/output modes + input codepage once
(capture_console_originals, before raw mode is first enabled) and restore them
verbatim on every exit path (restore_console_state), which clears the leftover
VT-input flag. Verified with a harness: input mode restored byte-identical
(0x01f7 -> 0x01f7), VT_INPUT not left set. Pre-existing since v0.6.4; no-op on
Unix. NOTES also records a related server-side gap (clean shell `exit`
sometimes reconnects instead of quitting because the Disconnect races the
connection close).

Assisted-By: Claude Opus 4.8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant