diff --git a/CHANGELOG.md b/CHANGELOG.md index c394670..15c1eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,12 @@ # Changelog -## Unreleased +## v0.1.0-preview.8 — 2026-08-28 + +- Preserve `http.Hijacker` through the request-evidence middleware so audited, + authenticated WebSocket and other HTTP upgrade handlers can operate without + bypassing request logging. Successful upgrades are recorded as HTTP 101; + upgraded-protocol bytes remain outside HTTP body-byte accounting. - Add revisioned active/archived lifecycles for organizations and teams, invitation listing and revocation, membership suspension/removal, team-member diff --git a/README.md b/README.md index 932684d..af425be 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Gamertan Web Foundations -> Status: `v0.1.0-preview.7` public preview. APIs may change before a stable +> Status: `v0.1.0-preview.8` public preview. APIs may change before a stable > release; Linux is the maintained release platform. Small, composable Go packages for the unglamorous boundaries of a careful web @@ -24,14 +24,14 @@ Pin the preview in an application module, then import only the packages that application needs: ```bash -go get gamertan.com/web@v0.1.0-preview.7 +go get gamertan.com/web@v0.1.0-preview.8 go mod verify ``` An application may also name the first package it intends to adopt: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.7 +go get gamertan.com/web/requestmeta@v0.1.0-preview.8 ``` The version belongs to the `gamertan.com/web` module. Go compiles and links diff --git a/requestlog/requestlog.go b/requestlog/requestlog.go index 9401d1f..c58a797 100644 --- a/requestlog/requestlog.go +++ b/requestlog/requestlog.go @@ -4,8 +4,10 @@ package requestlog import ( + "bufio" "context" "errors" + "net" "net/http" "strings" "time" @@ -208,3 +210,17 @@ func (capture *responseCapture) Write(body []byte) (int, error) { } func (capture *responseCapture) Unwrap() http.ResponseWriter { return capture.ResponseWriter } + +// Hijack preserves connection-upgrade support through the request evidence +// wrapper. A successful upgrade is recorded as HTTP 101; bytes exchanged after +// hijacking belong to the upgraded protocol and are intentionally not counted +// as HTTP response-body bytes. +func (capture *responseCapture) Hijack() (net.Conn, *bufio.ReadWriter, error) { + connection, buffer, err := http.NewResponseController(capture.ResponseWriter).Hijack() + if err != nil { + return nil, nil, err + } + capture.wroteHeader = true + capture.status = http.StatusSwitchingProtocols + return connection, buffer, nil +} diff --git a/requestlog/requestlog_test.go b/requestlog/requestlog_test.go index e308d7c..186fa86 100644 --- a/requestlog/requestlog_test.go +++ b/requestlog/requestlog_test.go @@ -3,8 +3,10 @@ package requestlog import ( + "bufio" "context" "encoding/json" + "net" "net/http" "net/http/httptest" "net/netip" @@ -24,6 +26,16 @@ type memorySink struct { ctxErr error } +type hijackableRecorder struct { + *httptest.ResponseRecorder + connection net.Conn + buffer *bufio.ReadWriter +} + +func (recorder *hijackableRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + return recorder.connection, recorder.buffer, nil +} + func (sink *memorySink) WriteRecord(ctx context.Context, record Record) error { sink.records = append(sink.records, record) sink.ctxErr = ctx.Err() @@ -208,3 +220,29 @@ func TestResponseStatusUsesFirstHeader(t *testing.T) { t.Fatalf("status=%d", sink.records[0].Status) } } + +func TestResponseCapturePreservesConnectionHijacking(t *testing.T) { + serverConnection, clientConnection := net.Pipe() + defer serverConnection.Close() + defer clientConnection.Close() + underlying := &hijackableRecorder{ + ResponseRecorder: httptest.NewRecorder(), + connection: serverConnection, + buffer: bufio.NewReadWriter(bufio.NewReader(serverConnection), bufio.NewWriter(serverConnection)), + } + capture := &responseCapture{ResponseWriter: underlying, status: http.StatusOK} + hijacker, ok := any(capture).(http.Hijacker) + if !ok { + t.Fatal("request evidence wrapper does not expose http.Hijacker") + } + connection, buffer, err := hijacker.Hijack() + if err != nil { + t.Fatal(err) + } + if connection != serverConnection || buffer != underlying.buffer { + t.Fatal("hijacked connection was not passed through") + } + if capture.status != http.StatusSwitchingProtocols || !capture.wroteHeader || capture.bytes != 0 { + t.Fatalf("capture after hijack=%+v", capture) + } +}