Skip to content

Replace UDP transport with QUIC; add port forwarding (v0.3.0) - #2

Merged
l1a merged 13 commits into
mainfrom
feature/quic-transport
Jun 17, 2026
Merged

Replace UDP transport with QUIC; add port forwarding (v0.3.0)#2
l1a merged 13 commits into
mainfrom
feature/quic-transport

Conversation

@l1a

@l1a l1a commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Summary

  • Replace custom UDP transport with QUIC (quinn 0.11): TLS 1.3 with ephemeral self-signed cert pinned via SSH bootstrap, replacing bespoke KEM/AEAD/HKDF crypto. Reliable ordered streams eliminate the packet-loss/reordering problems of the prior UDP design.
  • Add -L local port forwarding (TCP + UDP): each TCP connection gets its own QUIC stream; UDP uses a dedicated stream per spec with last-sender reply routing. Runs without a PTY session if no terminal is attached.
  • Retire ML-KEM / custom cipher suites: transport security delegated entirely to rustls/TLS 1.3. Post-quantum can be re-added later via rustls-post-quantum hybrid.
  • Heartbeat ack piggybacking: Heartbeat carries last_received_seq so the send-history replay buffer is trimmed continuously, not just on reconnect.
  • Byte-based send-history cap: 4 MB per stream (oldest-first eviction), replacing the previous count-based cap.
  • etrs fork ordering fix: fork before starting the Tokio runtime to avoid undefined behaviour with threads + fork.
  • e2e-local test: reliable end-to-end test (happy path + reconnect) that runs etr directly as the tmux session command, uses the client log file for session-ready detection, and reads #{pane_pid} directly for SIGSTOP/SIGCONT.
  • stress-local test: 1 PTY + 2 -L forward streams pumped hard for 30 s; asserts etrs RSS growth < 20 MB.
  • v0.3.0: updated README, PROTOCOL.md (QUIC wire format), NOTES.md, and GitHub wiki.

Deleted

  • src/crypto/ — entire module (aead, kdf, kyber, x25519, mod)
  • src/handshake.rs
  • src/transport.rs
  • --cipher CLI flag and ciphers config field

Test plan

  • just check — fmt + clippy clean
  • just test — 57 unit/integration tests pass
  • just e2e-local — happy path + reconnect pass on localhost
  • just stress-local — etrs RSS growth < 20 MB over 30 s with 3 concurrent streams
  • etr -L 8080:example.com:80 localhost — TCP forward smoke test
  • etr -vvv localhost — verify QUIC log lines appear

🤖 Generated with Claude Code

l1a and others added 13 commits June 16, 2026 21:40
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Assisted-By: Claude Sonnet 4.6
Retire custom UDP/KEM/AEAD stack in favour of quinn 0.11 QUIC with
TLS 1.3 and rcgen self-signed cert pinning.  Stream tags route control
(0x01), PTY (0x02), and forward (0x03) streams over multiplexed QUIC
bidi streams.  Session persistence (send_history, replay_from) is
unchanged — QUIC does not replay application data across connections.
Remove --cipher flag, ML-KEM, X25519, AES-GCM, ChaCha20, and HKDF.
57 tests pass; cargo clippy -D warnings clean.

Assisted-By: Claude Sonnet 4.6
quinn::Endpoint::server() requires an active tokio runtime, so it
cannot be called before fork().  Use a pipe to let the child create
the quinn endpoint (inside tokio), write the assigned port back to the
parent, and have the parent print PORT/CERT to SSH stdout before
exiting.

Assisted-By: Claude Sonnet 4.6
Heartbeat messages now carry last_received_seq so each side can
continuously trim its send_history as the peer acks data, rather than
waiting until a reconnect.  Previously the server's PTY history grew
to max_history (10k entries × 4KB = ~40MB) and stayed there for the
duration of the session.

Also add level-3 (-vvv) traces for PTY chunk sends/receives and
heartbeat ack maps on both client and server, replacing the packet
traces that existed in the old UDP transport.

Assisted-By: Claude Sonnet 4.6
Add quic::tls_info() and log it at verbosity level 2 on both client
(after QUIC handshake) and server (after session verification),
replacing the cipher-selection log that existed in the old UDP
transport.  Quinn does not expose the negotiated cipher suite, so we
describe the full configured set (TLS 1.3 only; three suites).

Assisted-By: Claude Sonnet 4.6
- Replace count-based send_history cap (10,000 entries ≈ 40 MB max)
  with a 4 MB byte-based cap in StreamState::record_send; oldest entries
  are evicted immediately regardless of heartbeat-ack interval.
- Fix test-local reconnect PID lookup to use tmux pane_pid + pgrep -f
  fallback instead of pgrep -x (which fails in non-interactive shells).
- Add stress-local justfile recipe: 1 PTY + 2 -L forward streams (TCP
  echo + UDP echo), data pumped hard in both directions for 30 s while
  sampling etrs RSS every 2 s; fails if peak growth > 20 MB.

Assisted-By: Claude Sonnet 4.6
justfile's parser tokenizes all recipe body lines (including
heredoc content) for {{ }} interpolation, so attribute-access dots
in Python code trigger parse errors.  Move the four helper scripts
to scripts/stress/{tcp,udp}_{echo,pump}.py and call them by path
from the recipe.

Assisted-By: Claude Sonnet 4.6
Three fixes accumulated during testing:
- Run etr as the direct tmux session command (not via send-keys to a
  shell) so .zshrc/neofetch can't race with our command injection.
- Detect session readiness by watching the log file for the
  'Forwarding:' line rather than pane prompt matching (which fired
  on the local shell prompt before the session was up).
- Get etrs PID via 'awk /PPid/ /proc/$$/status' inside the remote
  shell — /proc/self/status gives the PPid of the subshell running
  awk, not of the login shell; $$ gives the login shell's own PID
  so PPid is correctly etrs, not the remote shell itself.

Stress test now passes: etrs peak RSS growth 4.6 MB over 30 s of
full-throttle PTY + TCP echo + UDP echo traffic, within the 20 MB
limit set by the 4 MB byte-based send-history cap.

Assisted-By: Claude Sonnet 4.6
Run etr as the tmux session command instead of via
send-keys so #{pane_pid} == etr's PID directly and
the .zshrc startup race is eliminated.  Wait for
"[etr] Connected." in the client log file before
sending the PTY sentinel, then for the sentinel in
the pane before running happy-path and reconnect
sub-tests.  Both sub-tests now pass reliably.

Assisted-By: Claude Sonnet 4.6
The recipe tests both the happy-path PTY session and
reconnect, not just one thing; e2e-local better reflects
its scope.

Assisted-By: Claude Sonnet 4.6
- Cargo.toml: 0.2.0 → 0.3.0
- README.md: full rewrite for QUIC transport — removes
  UDP/ML-KEM/cipher-table content, adds -L forwarding,
  QUIC security description, config section
- PROTOCOL.md: replace old UDP/KEM spec entirely with
  QUIC wire format (stream tags, framing, bootstrap,
  reconnect flow, protobuf definitions)
- NOTES.md: minor cleanup (test count phrasing)

Assisted-By: Claude Sonnet 4.6
Assisted-By: Claude Sonnet 4.6
Assisted-By: Claude Sonnet 4.6
@l1a
l1a merged commit eaa5d0c into main Jun 17, 2026
6 checks passed
@l1a
l1a deleted the feature/quic-transport branch June 17, 2026 18:03
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