From f522531fd4df20a3ecde618a12878779e3411b79 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 16:54:11 +0000 Subject: [PATCH 1/2] docs: update README for Sprints 2-4 Add --har flag, WebSocket support, body decompression, pre-built binaries, Homebrew install instructions, and updated Go version requirement (1.24+). https://claude.ai/code/session_01PPLEf2tHHFhw9rpW8dpdA3 --- README.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1601a51..b0c8007 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Headers: Accept: */* === RESPONSE === -HTTP/2.0 200 OK +HTTP/1.1 200 OK Headers: Content-Type: application/json; charset=utf-8 @@ -38,13 +38,40 @@ No system configuration is changed. Everything is cleaned up when the command ex - **HTTP** — forwarded and logged transparently - **HTTPS** — intercepted via TLS termination with a per-host certificate signed by the ephemeral CA -- **Streaming** — only the first 1 KB of each body is logged; the full stream is forwarded unmodified +- **WebSocket (`wss://`)** — upgrade handshake is proxied and frames are spliced bidirectionally +- **Body display** — compressed responses (gzip, deflate) are decompressed automatically; only the first 1 KB is shown, the full stream is forwarded unmodified +- **HAR export** — all captured traffic can be written to an HTTP Archive (`.har`) file on exit --- ## Installation -**From source** (requires Go 1.21+): +### Homebrew (macOS / Linux) + +```bash +# After the first release is published: +brew tap hxddh/tap +brew install httpmon +``` + +### Pre-built binaries + +Download the latest binary for your platform from the [Releases](https://github.com/hxddh/https-traffic-inspector/releases) page: + +| Platform | File | +|----------|------| +| macOS arm64 | `httpmon-darwin-arm64` | +| macOS amd64 | `httpmon-darwin-amd64` | +| Linux amd64 | `httpmon-linux-amd64` | +| Linux arm64 | `httpmon-linux-arm64` | +| Windows amd64 | `httpmon-windows-amd64.exe` | + +```bash +chmod +x httpmon-darwin-arm64 +sudo mv httpmon-darwin-arm64 /usr/local/bin/httpmon +``` + +### From source (requires Go 1.24+) ```bash git clone https://github.com/hxddh/https-traffic-inspector @@ -72,6 +99,7 @@ httpmon --replay [--replay-target ] | `--cert-ttl` | `1h` | How long per-host TLS certificates are cached. `0` disables caching. | | `--ui` | `false` | Launch the interactive terminal UI. | | `--record` | _(none)_ | Append recorded traffic to an NDJSON file. | +| `--har` | _(none)_ | Write captured traffic as HAR 1.2 JSON on exit. | | `--replay` | _(none)_ | Replay a recorded file (no proxy or command needed). | | `--replay-target` | _(none)_ | Override the base URL when replaying (e.g. `https://staging.example.com`). | | `--replay-delay` | `0` | Pause between replayed requests. | @@ -167,6 +195,37 @@ Subprocess stdout/stderr is captured during the TUI session and printed to stder --- +### Body decompression + +httpmon automatically decompresses `gzip` and `deflate` encoded response bodies before display. Most HTTPS APIs compress their responses; you see the decoded JSON or HTML rather than `[binary data, N bytes]`. + +``` +=== RESPONSE === +HTTP/1.1 200 OK + +Headers: + Content-Encoding: gzip + Content-Type: application/json + +Body: +{"login":"octocat","id":583231,...} ← decoded automatically +``` + +The full compressed stream is still forwarded to the subprocess unmodified. + +--- + +### WebSocket support + +`wss://` connections established via CONNECT tunnels are handled transparently. The upgrade handshake is proxied and logged; after the 101 response, frames are spliced bidirectionally between client and upstream without buffering. + +```bash +httpmon node ws-client.js # wss:// connections work automatically +httpmon --ui node ws-client.js # upgrade handshake visible in TUI +``` + +--- + ### Recording — `--record` Save all intercepted traffic to an NDJSON file for later replay or analysis. @@ -236,6 +295,22 @@ Replaying: POST https://staging.example.com/repos --- +### HAR export — `--har` + +Write all captured traffic to an [HTTP Archive](https://en.wikipedia.org/wiki/HAR_(file_format)) file (HAR 1.2) on exit. HAR files can be imported into Chrome DevTools, Charles Proxy, Postman, and other analysis tools. + +```bash +# Capture to HAR +httpmon --har session.har curl https://api.github.com + +# Combine with filter and TUI +httpmon --ui --har api.har --filter /api python3 app.py +``` + +Open `session.har` in Chrome DevTools → Network tab → Import, or drag it into the [HAR Analyzer](https://toolbox.googleapps.com/apps/har_analyzer/). + +--- + ## Examples ```bash @@ -255,8 +330,11 @@ httpmon --format json node app.js >> traffic.log httpmon --record prod.ndjson curl https://api.prod.example.com/health httpmon --replay prod.ndjson --replay-target https://api.staging.example.com -# Full session: TUI + recording + filter -httpmon --ui --record session.ndjson --filter /api python3 app.py +# Save a HAR file for sharing or import into DevTools +httpmon --har trace.har curl https://api.github.com + +# Full session: TUI + HAR + filter +httpmon --ui --har session.har --filter /api python3 app.py # Use a random port to avoid conflicts (useful in CI) httpmon --port 0 curl https://api.example.com From f85dee4a4a3fb50cb8d769dae5d721c2d0342037 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 17:06:57 +0000 Subject: [PATCH 2/2] fix: address 5 bugs found in code/docs review - tui: add m.cursor bounds check in refreshDetail to prevent index panic - websocket: close clientConn in downstream splice goroutine so upstream EOF unblocks the client-read goroutine (eliminates goroutine leak) - logResponse: guard time.Since(start) against zero start time so HAR/ record duration is 0 rather than ~2026 years when reqID is missing - har: use len(body) for Content.Size and BodySize instead of resp.ContentLength, which is -1 for chunked transfer encoding - record/replay: use IndexAny("/?#") to split host from rest of URL so query strings on path-less URLs are preserved when --replay-target is set - cert: add net.IPv6loopback to per-host certificate SAN list so tools connecting via ::1 pass TLS verification https://claude.ai/code/session_01PPLEf2tHHFhw9rpW8dpdA3 --- har.go | 5 +++-- main.go | 7 +++++-- record.go | 9 ++++++--- tui.go | 2 +- websocket.go | 1 + 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/har.go b/har.go index a5b4ce7..e0cd521 100644 --- a/har.go +++ b/har.go @@ -168,6 +168,7 @@ func addHARResponse(reqID int, resp *http.Response, body string, dur time.Durati statusText = statusText[4:] // strip "NNN " } + bodySize := int64(len(body)) ms := float64(dur) / float64(time.Millisecond) e.Time = ms e.Response = harResponse{ @@ -177,13 +178,13 @@ func addHARResponse(reqID int, resp *http.Response, body string, dur time.Durati Headers: harHeaders(resp.Header), Cookies: []harNameValue{}, Content: harContent{ - Size: resp.ContentLength, + Size: bodySize, MimeType: mt, Text: body, }, RedirectURL: resp.Header.Get("Location"), HeadersSize: -1, - BodySize: resp.ContentLength, + BodySize: bodySize, } e.Timings = harTimings{Wait: ms} diff --git a/main.go b/main.go index 1396ee0..d11ae63 100644 --- a/main.go +++ b/main.go @@ -150,7 +150,7 @@ func generateCert(host string) (*tls.Certificate, error) { KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, DNSNames: []string{host, "*." + host}, - IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, } certDER, err := x509.CreateCertificate(rand.Reader, &template, caCert, &key.PublicKey, caKey) @@ -421,7 +421,10 @@ func logResponse(resp *http.Response, reqID int) { start := reqStartTimes[reqID] delete(reqStartTimes, reqID) reqStartMu.Unlock() - dur := time.Since(start) + var dur time.Duration + if !start.IsZero() { + dur = time.Since(start) + } var bodyStr string if resp.Body != nil { diff --git a/record.go b/record.go index f9ae21d..3c53706 100644 --- a/record.go +++ b/record.go @@ -146,12 +146,15 @@ func replayFile(path, targetBase string, delayBetween time.Duration) int { func replayOne(client *http.Client, ex *recordedExchange, targetBase string) { replayURL := ex.URL if targetBase != "" { - // Replace scheme+host in the original URL with targetBase. + // Replace scheme+host in the original URL with targetBase, + // preserving path, query string, and fragment. rest := ex.URL if i := strings.Index(rest, "://"); i >= 0 { rest = rest[i+3:] - if j := strings.Index(rest, "/"); j >= 0 { - rest = rest[j:] + // Find the first '/' or '?' to split off the host. + cut := strings.IndexAny(rest, "/?#") + if cut >= 0 { + rest = rest[cut:] } else { rest = "/" } diff --git a/tui.go b/tui.go index 90df558..f0aa3e4 100644 --- a/tui.go +++ b/tui.go @@ -189,7 +189,7 @@ func (m *tuiModel) applySize() { } func (m *tuiModel) refreshDetail() { - if !m.ready || len(m.entries) == 0 { + if !m.ready || len(m.entries) == 0 || m.cursor >= len(m.entries) { return } m.vp.SetContent(renderEntryDetail(m.entries[m.cursor])) diff --git a/websocket.go b/websocket.go index 254864b..1a80826 100644 --- a/websocket.go +++ b/websocket.go @@ -51,6 +51,7 @@ func spliceWebSocket(upConn net.Conn, req *http.Request, clientConn net.Conn, cl }() go func() { io.Copy(clientConn, upReader) //nolint:errcheck + clientConn.Close() // signal client EOF; unblocks goroutine 1 errc <- struct{}{} }() <-errc