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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: CI

on:
push:
branches: ["main", "feat/**", "fix/**", "claude/**"]
pull_request:
branches: ["main"]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: go vet ./...
- run: go test -race -count=1 -timeout 120s ./...
65 changes: 65 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Release

on:
push:
tags:
- "v*.*.*"

jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: linux
goarch: amd64
suffix: ""
- goos: linux
goarch: arm64
suffix: ""
- goos: darwin
goarch: amd64
suffix: ""
- goos: darwin
goarch: arm64
suffix: ""
- goos: windows
goarch: amd64
suffix: ".exe"

steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
go build -trimpath -ldflags="-s -w" \
-o httpmon-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.suffix }} .

- uses: actions/upload-artifact@v4
with:
name: httpmon-${{ matrix.goos }}-${{ matrix.goarch }}
path: httpmon-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.suffix }}

release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true

- uses: softprops/action-gh-release@v2
with:
files: dist/*
generate_release_notes: true
35 changes: 35 additions & 0 deletions Formula/httpmon.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
class Httpmon < Formula
desc "Zero-config HTTPS MITM proxy CLI for inspecting traffic"
homepage "https://github.com/hxddh/https-traffic-inspector"
version "0.1.0"
license "MIT"

on_macos do
if Hardware::CPU.arm?
url "https://github.com/hxddh/https-traffic-inspector/releases/download/v#{version}/httpmon-darwin-arm64"
sha256 "TODO_fill_in_after_release"
else
url "https://github.com/hxddh/https-traffic-inspector/releases/download/v#{version}/httpmon-darwin-amd64"
sha256 "TODO_fill_in_after_release"
end
end

on_linux do
if Hardware::CPU.arm?
url "https://github.com/hxddh/https-traffic-inspector/releases/download/v#{version}/httpmon-linux-arm64"
sha256 "TODO_fill_in_after_release"
else
url "https://github.com/hxddh/https-traffic-inspector/releases/download/v#{version}/httpmon-linux-amd64"
sha256 "TODO_fill_in_after_release"
end
end

def install
bin.install Dir["httpmon-*"][0] => "httpmon"
end

test do
assert_predicate bin/"httpmon", :exist?
assert_predicate bin/"httpmon", :executable?
end
end
20 changes: 20 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,26 @@ func handleConnect(w http.ResponseWriter, r *http.Request) {
reqID = logRequest(req) // logs original headers
}

// WebSocket upgrades require a raw bidirectional tunnel; bypass the
// normal hop-by-hop stripping and http.Client round-trip.
if strings.EqualFold(req.Header.Get("Upgrade"), "websocket") {
upConn, dialErr := tls.Dial("tcp", r.Host, &tls.Config{InsecureSkipVerify: true}) //nolint:gosec
if dialErr != nil {
if shouldLog {
discardReqID(reqID)
}
writeConnError(bw, http.StatusBadGateway, dialErr.Error())
bw.Flush() //nolint:errcheck
break
}
spliceWebSocket(upConn, req, tlsConn, bw, reader)
upConn.Close()
if shouldLog {
discardReqID(reqID)
}
break
}

removeHopByHopHeaders(req.Header) // strip before forwarding upstream

resp, err := upstreamClient.Do(req)
Expand Down
97 changes: 97 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,103 @@ func TestRecord_LargeBodyFullyStored(t *testing.T) {
}
}

// ---- WebSocket splice ----

func TestSpliceWebSocket(t *testing.T) {
// proxyUpConn ↔ upstreamConn: proxy → upstream raw connection.
// proxyClientConn ↔ clientConn: client → proxy (post-TLS) connection.
proxyUpConn, upstreamConn := net.Pipe()
proxyClientConn, clientConn := net.Pipe()
defer upstreamConn.Close()
defer clientConn.Close()

bw := bufio.NewWriterSize(proxyClientConn, 4096)
br := bufio.NewReader(proxyClientConn)

req, _ := http.NewRequest("GET", "/ws", nil)
req.Host = "example.com"
req.RequestURI = "/ws"
req.Header = http.Header{
"Upgrade": {"websocket"},
"Connection": {"Upgrade"},
"Sec-Websocket-Key": {"dGhlIHNhbXBsZSBub25jZQ=="},
"Sec-Websocket-Version": {"13"},
}

// Goroutine: act as the upstream WebSocket server.
upDone := make(chan error, 1)
go func() {
defer upstreamConn.Close()
ubuf := bufio.NewReader(upstreamConn)
if _, err := http.ReadRequest(ubuf); err != nil {
upDone <- fmt.Errorf("upstream read upgrade: %w", err)
return
}
fmt.Fprint(upstreamConn,
"HTTP/1.1 101 Switching Protocols\r\n"+
"Upgrade: websocket\r\nConnection: Upgrade\r\n\r\n")
upstreamConn.Write([]byte("ping")) //nolint:errcheck
pong := make([]byte, 4)
if _, err := io.ReadFull(upstreamConn, pong); err != nil {
upDone <- fmt.Errorf("upstream read pong: %w", err)
return
}
if string(pong) != "pong" {
upDone <- fmt.Errorf("got %q, want pong", pong)
return
}
upDone <- nil
}()

// Run splice on the proxy side (proxyClientConn = underlying conn bypassing bw).
spliceDone := make(chan struct{})
go func() {
defer close(spliceDone)
spliceWebSocket(proxyUpConn, req, proxyClientConn, bw, br)
}()

// Client side: read the forwarded 101.
cbuf := bufio.NewReader(clientConn)
resp, err := http.ReadResponse(cbuf, nil)
if err != nil {
t.Fatalf("client read 101: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("status = %d, want 101", resp.StatusCode)
}

// Client reads "ping" forwarded from upstream.
ping := make([]byte, 4)
if _, err := io.ReadFull(cbuf, ping); err != nil {
t.Fatalf("client read ping: %v", err)
}
if string(ping) != "ping" {
t.Errorf("ping = %q, want ping", ping)
}

// Client sends "pong" back.
clientConn.Write([]byte("pong")) //nolint:errcheck

select {
case err := <-upDone:
if err != nil {
t.Errorf("upstream: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("upstream goroutine timed out")
}

// Close client connection to unblock the client→upstream copy goroutine.
clientConn.Close()

select {
case <-spliceDone:
case <-time.After(3 * time.Second):
t.Fatal("splice goroutine timed out")
}
}

// ---- HAR capture ----

func TestHAR_BasicCapture(t *testing.T) {
Expand Down
58 changes: 58 additions & 0 deletions websocket.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package main

import (
"bufio"
"io"
"net"
"net/http"
)

// spliceWebSocket performs a WebSocket upgrade handshake with upConn, writes
// the server response to clientW, then splices raw bytes bidirectionally
// between upConn and clientConn until either side closes.
//
// clientConn is the underlying connection (e.g. *tls.Conn) used for the raw
// splice phase. clientW (its bufio wrapper) is only used for the HTTP
// handshake response; raw WebSocket frames bypass it to avoid stale buffering.
func spliceWebSocket(upConn net.Conn, req *http.Request, clientConn net.Conn, clientW *bufio.Writer, clientR io.Reader) {
if err := req.Write(upConn); err != nil {
writeConnError(clientW, http.StatusBadGateway, err.Error())
clientW.Flush() //nolint:errcheck
return
}

upReader := bufio.NewReader(upConn)
resp, err := http.ReadResponse(upReader, req)
if err != nil {
writeConnError(clientW, http.StatusBadGateway, err.Error())
clientW.Flush() //nolint:errcheck
return
}
resp.Body.Close()

if err := resp.Write(clientW); err != nil {
return
}
if err := clientW.Flush(); err != nil {
return
}

if resp.StatusCode != http.StatusSwitchingProtocols {
return
}

// Splice bidirectionally using the raw connection so WebSocket frames are
// not buffered inside bufio.Writer (which would stall delivery until flush).
errc := make(chan struct{}, 2)
go func() {
io.Copy(upConn, clientR) //nolint:errcheck
upConn.Close() // signal upstream EOF; unblocks the other goroutine
errc <- struct{}{}
}()
go func() {
io.Copy(clientConn, upReader) //nolint:errcheck
errc <- struct{}{}
}()
<-errc
<-errc
}
Loading