Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 83 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -72,6 +99,7 @@ httpmon --replay <file> [--replay-target <url>]
| `--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. |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions har.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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}

Expand Down
7 changes: 5 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 6 additions & 3 deletions record.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "/"
}
Expand Down
2 changes: 1 addition & 1 deletion tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
Expand Down
1 change: 1 addition & 0 deletions websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading