diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a3e62f8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6a9d547 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/Formula/httpmon.rb b/Formula/httpmon.rb new file mode 100644 index 0000000..9616cca --- /dev/null +++ b/Formula/httpmon.rb @@ -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 diff --git a/main.go b/main.go index 1d5118f..4d1a8de 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/main_test.go b/main_test.go index 0223b32..f51561d 100644 --- a/main_test.go +++ b/main_test.go @@ -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) { diff --git a/websocket.go b/websocket.go new file mode 100644 index 0000000..254864b --- /dev/null +++ b/websocket.go @@ -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 +}