feat(client): full-screen terminal UI and local request inspector (TUNNEL-33) - #96
Conversation
The CLI printed a static box and then went silent: no request log, no counters, and no way to see what actually flowed through a tunnel. This adds the two interfaces ngrok has and rustunnel lacked. Part 0 — HTTP visibility (crates/rustunnel-client/src/inspect/tap.rs) The client forwarded bytes without understanding them, which is why the styled `print_request` helper had sat dead since it was written. The proxy now feeds every copied byte through a passive HTTP/1.x tap that parses framing (Content-Length, chunked, EOF-delimited) without modifying, reordering, or delaying the stream. Anything that stops looking like HTTP — upgrades, HTTP/2, malformed traffic — drops to passthrough and the bytes keep flowing untouched. Captured exchanges mirror the server's CaptureEvent shape, plus headers and 64 KB-capped bodies so payloads can be shown and replayed. Part 1 — Terminal UI (src/tui/) Ratatui + crossterm alternate screen showing session status, region and live keepalive latency, per-tunnel health, connection/byte/percentile counters with a req/s sparkline, and a scrolling request log. Terminal is restored on quit, Ctrl-C, and panic. tracing output is buffered into a log pane instead of punching through the frame. Part 2 — Web inspector (src/inspect/server.rs) Loopback-only axum server on :4040 with request list, detail (headers, bodies, raw), SSE live tail, and replay against the local service. Bind-time probe skips ports that are already serving: on macOS binding 127.0.0.1:P succeeds even when another process holds 0.0.0.0:P, which otherwise hijacked the local tunnel server's own control port. Degradation is explicit: --json and non-TTY stdout behave exactly as before, --no-tui falls back to line output (now with live request lines), --no-inspect disables the web UI, and TCP/UDP tunnels report connection and byte counters only. Ratatui 0.29 rather than 0.30: last single-crate release, avoids the core/widgets/backend split for a first cut. Refs TUNNEL-33 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marvin-agent-rockflow
left a comment
There was a problem hiding this comment.
Hermes Agent Review
Head: 12a5420 · Files: 16 · +3988 / -75
Verdict: Request changes
Solid TUNNEL-33 delivery: passive HTTP tap (no stream mutation), loopback-only inspector with the macOS/BSD wildcard-shadow bind fix, clean TUI degradation paths (--json / non-TTY / --no-tui), and strong unit coverage on framing edge cases. Docs call out the credential exposure model correctly.
A few correctness/security items should be fixed before merge; CI is also red on an unrelated-looking dashboard scope test.
Critical
- None in the tunnel data path itself (bytes still pass through untouched).
Warnings
- Replay follows redirects by default (
inspect/server.rs) — reqwest default policy can chaseLocationoff the local service (link-local/metadata endpoints or external hosts) while still carrying capturedAuthorization/Cookieheaders on same-origin hops. Disable redirects (or restrict to loopback targets) for replay. local_addr_forfalls back to the first tunnel (inspect/mod.rs) — multi-tunnel sessions can replay against the wrong local service when the label does not match. Prefer hard 409 over silent fallback.- CI red on
get_tunnel_returns_404_for_other_tenant(expected 200, got 404). Client crate tests/clippy passed; this looks like a flaky/unrelated dashboard integration assertion, but the check must be green before merge.
Suggestions
pumpflushes after every 16 KiB chunk (proxy.rs) — extra syscalls vs untappedcopy_bidirectional. Consider flush-on-idle or only on partial buffers once capture is on a hot path.- Request-target absolute-form — if a client sends an absolute-form request-target, replay concatenates the local base with that absolute target and can produce a bad URL. Rare on this path; normalizing to origin-form would harden replay.
- Optional: default
--no-inspectunder--jsonfor pure automation (stdout is already clean; binding the inspector port is still a side effect).
Looks good
- Port probe before bind + reserved-port skip — excellent regression for the control-plane hijack you found.
- Tap design: keep-alive pairing, chunked/TE, HEAD/1xx/101 upgrade to passthrough, body/pending caps.
- UI XSS hygiene (
esc()on untrusted path/header/body text). - Degradation matrix matches the PR description; health + latency mirrored into UIs cleanly.
- Docs security note for local-only inspector is clear.
Automated hourly review by marvin-agent-rockflow (Hermes). Will re-review only if new commits land.
| let url = format!("http://{}{}", local_addr, original.path); | ||
| let client = reqwest::Client::builder() | ||
| .timeout(REPLAY_TIMEOUT) | ||
| .build() |
There was a problem hiding this comment.
Warning — redirect policy on replay: reqwest::Client follows redirects by default (up to 10). A local app that returns a 3xx with a non-loopback Location will be followed from this process. Captured Authorization / Cookie headers are also attached to the initial request.
For an inspector that intentionally re-issues real credentials, prefer:
reqwest::Client::builder()
.timeout(REPLAY_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()?Or a custom policy that only allows redirects whose host is loopback / the original local address. Surface the 3xx + Location in the recorded exchange so the UI still shows what happened.
| .tunnels | ||
| .iter() | ||
| .find(|t| t.name == tunnel) | ||
| .or_else(|| session.tunnels.first()) |
There was a problem hiding this comment.
Warning — silent multi-tunnel fallback: If tunnel does not match any registered name, this returns the first tunnel local address. On rustunnel start with multiple HTTP services, a label mismatch (or a stale exchange after reconnect) will replay against the wrong backend — potentially mutating the wrong app.
Safer: find by name only (no fallback to first), and let replay_request keep returning 409 when unknown. A unit test with two tunnels where only the second name matches would lock this in.
| return Ok(total); | ||
| } | ||
| writer.write_all(&buf[..n]).await?; | ||
| writer.flush().await?; |
There was a problem hiding this comment.
Suggestion — flush cadence: Flushing after every successful write_all of up to 16 KiB is correct for low-latency interactive use, but it is a measurable step up from copy_bidirectional on bulk transfers. If profiling shows it on the hot path, consider flushing only when n < COPY_BUF (likely end of a write burst) or coalescing with a short timed flush — without reintroducing Nagle issues on the local socket (you already set TCP_NODELAY).
Three fixes from the PR review, all in the replay path. Never follow redirects. reqwest follows up to 10 by default, so a local service returning 3xx made the inspector record the *destination's* response rather than what the service actually returned — and re-issued the captured request, credentials included, wherever Location pointed. The added test caught exactly that: unfixed, the replay chased a 302 out to the real example.com and recorded its 404. Resolve the replay target by tunnel name only. The previous fallback to the first registered tunnel meant a label mismatch — several HTTP tunnels under `rustunnel start`, or a stale exchange after a reconnect — could re-issue a mutating request against a different local service. Unknown tunnels now stay a 409. Normalise absolute-form request-targets. `GET http://host/path` built `http://localhost:3000http://host/path` and failed to parse; it is now reduced to origin-form, with asterisk-form falling back to `/`. Refs TUNNEL-33 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks — three of these are fixed in FixedReplay follows redirects — now Worth noting for the record: reqwest already strips
Absolute-form request-target — added Not changed
The cost is also narrower than it looks: CI red on Still open for a product decisionInspector under Client tests: 68 passing (was 62). |
In --json mode the inspector bound a port and never said so, making the listening socket an invisible side effect for automation — the one open item from the PR review. Human modes already showed the URL (startup box line, TUI header); JSON consumers now get it as an event, emitted once before any tunnel_ready and omitted entirely under --no-inspect. Refs TUNNEL-33 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Resolved the last open item in {"event":"inspector_ready","url":"http://127.0.0.1:4040"}
{"event":"tunnel_ready","protocol":"http","public_url":"https://myapp.edge.rustunnel.com","local_port":3000,"local_host":"localhost"}It fires once at startup before any Chose this over defaulting Client tests: 69. |
marvin-agent-rockflow
left a comment
There was a problem hiding this comment.
Hermes Agent Review
Head: 5855561 · Files: 17 · +4223 / -76
Verdict: Approve
Note: Re-review after ddda910 (replay hardening) + 5855561 (inspector_ready).
Prior findings — resolved
- Redirect follow on replay — now
redirect::Policy::none()withreplay_records_redirects_instead_of_following_them. - Multi-tunnel silent fallback —
local_addr_foris name-only; unknown tunnel →409 CONFLICT, covered by unit + handler tests. - Absolute-form request-target —
origin_form()normalizes proxy-style targets before building the local URL.
Critical
- None.
Warnings
- None blocking.
Suggestions
inspect/ui.html(replay handler): API returnsbody_truncated, but the UI never surfaces it. A truncated POST/PUT replay silently sends a short body with a recomputedContent-Length— easy to mutate the wrong state. Show a banner whenresult.body_truncatedis true (and ideally on detail whenrequest_body.truncated).proxy.rsflush-after-every-write: Still a latency/throughput trade-off on bulk transfers; optional follow-up to flush only on short reads / idle.- Replay hop-by-hop set: Consider also stripping
Expect(e.g.100-continue) so replay does not wait on a continue dance the capture path already finished.
Looks good
- Passive tap design (no stream mutation; passthrough on upgrade/non-HTTP) is the right call for tunnel fidelity.
- Port bind probe for macOS/BSD
127.0.0.1vs0.0.0.0shadowing — real footgun, solid regression test. - Loopback-only inspector, reserved port skip, body/head caps, pending-queue bound.
- UI escapes untrusted path/header/body text before
innerHTML. - Degradation matrix (
--json/ non-TTY /--no-tui/--no-inspect) andinspector_readyNDJSON event for automation. - Docs call out credential sensitivity of captures.
- Strong unit coverage across tap framing, bind edge cases, TUI
TestBackend, and the new replay hardening.
Automated hourly review by marvin-agent-rockflow (Hermes). Will re-review only if new commits land.
| $("tab-body").insertAdjacentHTML("afterbegin", `<p class="err">${esc(text)}</p>`); | ||
| return; | ||
| } | ||
| const result = await res.json(); |
There was a problem hiding this comment.
💡 Suggestion: The replay API returns body_truncated when the captured request body was capped at 64 KiB, but this UI path ignores it and jumps straight to the new row. For mutating methods, a partial replay with a recomputed Content-Length is easy to miss — consider a short error/warning banner when result.body_truncated is true (and/or a note on the detail pane when request_body.truncated).
| if is_hop_by_hop(name) { | ||
| continue; | ||
| } | ||
| request = request.header(name, value); |
There was a problem hiding this comment.
💡 Suggestion (nit): Hop-by-hop stripping correctly drops Transfer-Encoding / Content-Length. Worth also skipping Expect so a captured Expect: 100-continue does not make replay wait on a continue exchange the original connection already completed.
| return Ok(total); | ||
| } | ||
| writer.write_all(&buf[..n]).await?; | ||
| writer.flush().await?; |
There was a problem hiding this comment.
💡 Suggestion (perf, non-blocking): Flushing after every successful write_all keeps interactive latency low but is heavier than copy_bidirectional on bulk transfers. Optional follow-up: flush when n < COPY_BUF or on a short idle timer if profiling shows it on the hot path.
Release with the CLI terminal UI + local request inspector (TUNNEL-33, PR joaoh82#96). Client-only feature work; no server-side changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the two gaps in TUNNEL-33: the CLI printed a static box and went silent, with no way to see what flowed through a tunnel.
What changed
Part 0 — HTTP visibility. The client forwarded bytes without understanding them, which is why the styled
print_requesthelper had been dead code since it was written.proxy.rsnow feeds every copied byte through a passive HTTP/1.x tap (inspect/tap.rs) that parses framing —Content-Length, chunked (with extensions and trailers), EOF-delimited — without modifying, reordering, or delaying the stream. Anything that stops looking like HTTP/1.x (WebSocket upgrades, HTTP/2 prior knowledge, malformed traffic) drops to passthrough and bytes keep flowing untouched. I chose byte-level sniffing over a hyper-based intercepting proxy (Option A in the ticket) precisely to avoid changing tunnel semantics.Captured exchanges mirror the server's
CaptureEvent/CapturedRequestshape, plus headers and 64 KB-capped bodies — which is what makes payload display and replay possible.Part 1 — Terminal UI (
src/tui/). Ratatui alternate screen: session status, region + live keepalive latency, per-tunnel health (the existinghealth.rsprobes were never displayed before), connection/byte/p50/p90 counters with a req/s sparkline, and a scrolling request log. Keys:q/Esc/Ctrl-Cquit,↑↓/jkscroll,ffollow,cclear,llog pane.Part 2 — Web inspector (
src/inspect/server.rs). Loopback-only axum server on:4040— request list, detail (Summary/Headers/Raw), SSE live tail, and replay against the local service. UI is a single self-contained HTML file, no build step or CDN.Degradation (unchanged behaviour where it matters)
--json--no-tui--no-inspectBug found and fixed during testing
The inspector bound
127.0.0.1:4040while the local tunnel server held0.0.0.0:4040— macOS/BSD allow the more specific bind, so loopback traffic reached the inspector instead of the server and the client broke its own control connection.bind()now probes for a live listener before claiming a port. Regression test included; this would have hit anyone running the documented local dev setup.Testing
62 client unit tests (up from 28), workspace green,
make checkclean.100 Continue,101upgrade, non-HTTP passthrough, EOF-delimited, oversized head, body truncation, pending-queue cap.TestBackendand asserted on content; small/awkward terminal sizes don't panic.End-to-end against a real local server + demo app: all request types captured with correct method/path/status/bodies, chunked decoded, replay re-issued a POST and recorded it, SSE pushed live rows,
--jsonstayed clean, TCP tunnel showed byte counters with zero HTTP entries.Notes
ratatui-core/-widgets/-backendwith breaking API changes. Worth revisiting as a follow-up, not as part of a first cut.docs/client-guide.mdgets the new flags, a Terminal UI section, and a Request Inspector section (including the local-only security note — captured payloads may contain credentials).Follow-ups (not in scope here)
dashboard/capture.rs:124) — the client-side model here is a reference for filling that in.🤖 Generated with Claude Code