diff --git a/CHANGELOG.md b/CHANGELOG.md index 21dc1f2..afaeb1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ Gatekeeper is a standalone credential-injecting TLS-intercepting proxy. It trans Gatekeeper is pre-1.0. The configuration schema and credential source interface may change between minor versions. +## v0.9.0 — 2026-04-22 + +### Added + +- **WebSocket support through TLS interception** — WebSocket upgrades (101 Switching Protocols) now work through CONNECT+TLS intercepted connections; credentials are injected on the upgrade request, then the proxy switches to bidirectional byte tunneling for WebSocket frames ([#22](https://github.com/majorcontext/gatekeeper/pull/22)) + +### Changed + +- **Refactored `handleConnectWithInterception`** — replaced the manual `http.ReadRequest` → `transport.RoundTrip` → `resp.Write` loop with `httputil.ReverseProxy` served via a single-connection `http.Server`; all existing behaviors (credential injection, network/Keep policy, LLM gateway policy, response transformers, canonical logging) are preserved through `Rewrite`, `ModifyResponse`, and `ErrorHandler` hooks ([#22](https://github.com/majorcontext/gatekeeper/pull/22)) +- **Extracted `evaluateAndReplaceLLMResponse`** — LLM gateway policy evaluation logic moved from inline in the request loop to a standalone method for readability ([#22](https://github.com/majorcontext/gatekeeper/pull/22)) + ## v0.8.0 — 2026-04-22 ### Added diff --git a/proxy/intercept_test.go b/proxy/intercept_test.go new file mode 100644 index 0000000..5697eae --- /dev/null +++ b/proxy/intercept_test.go @@ -0,0 +1,613 @@ +package proxy + +import ( + "bufio" + "bytes" + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// interceptTestSetup creates a proxy with TLS interception enabled and an HTTPS +// backend server. The proxy is configured to trust the backend's TLS cert and +// the returned client trusts the proxy's interception CA. +type interceptTestSetup struct { + Proxy *Proxy + ProxyServer *httptest.Server + Backend *httptest.Server + Client *http.Client + CA *CA + BackendHost string // hostname only (e.g., 127.0.0.1) — for credential and header matching +} + +func newInterceptTestSetup(t *testing.T, backendHandler http.Handler) *interceptTestSetup { + t.Helper() + + ca, err := generateCA() + if err != nil { + t.Fatal(err) + } + + backend := httptest.NewTLSServer(backendHandler) + + // Build a CA pool that trusts the backend's TLS cert. + upstreamCAs := x509.NewCertPool() + upstreamCAs.AddCert(backend.Certificate()) + + p := NewProxy() + p.SetCA(ca) + p.SetUpstreamCAs(upstreamCAs) + + proxyServer := httptest.NewServer(p) + + // Client trusts the interception CA and routes through the proxy. + clientCAs := x509.NewCertPool() + clientCAs.AppendCertsFromPEM(ca.certPEM) + + client := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(mustParseURL(proxyServer.URL)), + TLSClientConfig: &tls.Config{RootCAs: clientCAs}, + }, + } + + backendHostname := mustParseURL(backend.URL).Hostname() + + t.Cleanup(func() { + proxyServer.Close() + backend.Close() + }) + + return &interceptTestSetup{ + Proxy: p, + ProxyServer: proxyServer, + Backend: backend, + Client: client, + CA: ca, + BackendHost: backendHostname, + } +} + +func TestIntercept_CredentialInjection(t *testing.T) { + var receivedAuth string + setup := newInterceptTestSetup(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + w.Write([]byte("ok")) + })) + + setup.Proxy.SetCredentialWithGrant(setup.BackendHost, "Authorization", "Bearer test-token-123", "test-grant") + + resp, err := setup.Client.Get(setup.Backend.URL + "/api/data") + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() + io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if receivedAuth != "Bearer test-token-123" { + t.Errorf("Authorization = %q, want %q", receivedAuth, "Bearer test-token-123") + } +} + +func TestIntercept_CredentialInjectionCanonicalLog(t *testing.T) { + setup := newInterceptTestSetup(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + + setup.Proxy.SetCredentialWithGrant(setup.BackendHost, "Authorization", "Bearer granted-token", "my-grant") + + var logged RequestLogData + setup.Proxy.SetLogger(func(data RequestLogData) { + logged = data + }) + + resp, err := setup.Client.Get(setup.Backend.URL + "/resource") + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + + if !logged.AuthInjected { + t.Error("expected AuthInjected=true") + } + if len(logged.Grants) == 0 || logged.Grants[0] != "my-grant" { + t.Errorf("Grants = %v, want [my-grant]", logged.Grants) + } + if logged.RequestType != "connect" { + t.Errorf("RequestType = %q, want connect", logged.RequestType) + } + if logged.RequestID == "" { + t.Error("expected non-empty RequestID") + } + // Verify credential value is NOT present in logged request headers. + if v := logged.RequestHeaders.Get("Authorization"); v != "" { + t.Errorf("logged RequestHeaders contains injected Authorization %q; credential values must not appear in logs", v) + } +} + +func TestIntercept_MultiRequestKeepalive(t *testing.T) { + var requestCount int + var mu sync.Mutex + setup := newInterceptTestSetup(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requestCount++ + mu.Unlock() + w.Write([]byte("ok")) + })) + + for i := 0; i < 5; i++ { + resp, err := setup.Client.Get(setup.Backend.URL + "/ping") + if err != nil { + t.Fatalf("request %d: %v", i, err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("request %d: status = %d, want 200", i, resp.StatusCode) + } + } + + mu.Lock() + defer mu.Unlock() + if requestCount != 5 { + t.Errorf("requestCount = %d, want 5", requestCount) + } +} + +func TestIntercept_NetworkPolicyDenial(t *testing.T) { + setup := newInterceptTestSetup(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("backend should not be reached on denied request") + })) + + // Strict policy with no allows — denies everything at the inner request level. + setup.Proxy.SetNetworkPolicy("strict", nil, nil) + + var logged RequestLogData + setup.Proxy.SetLogger(func(data RequestLogData) { + logged = data + }) + + // Strict policy denies the CONNECT request itself with 407. + // Go's transport returns this as an error (non-200 CONNECT response). + resp, err := setup.Client.Get(setup.Backend.URL + "/blocked") + if err == nil { + resp.Body.Close() + t.Fatal("expected transport error from denied CONNECT, got nil") + } + // Verify the error message references the 407 status text. + if !strings.Contains(err.Error(), "407") && !strings.Contains(err.Error(), "Proxy Authentication Required") { + t.Errorf("expected 407/Proxy Authentication Required in error, got: %v", err) + } + if !logged.Denied { + t.Error("expected Denied=true in log") + } +} + +func TestIntercept_TransportError502(t *testing.T) { + ca, err := generateCA() + if err != nil { + t.Fatal(err) + } + + p := NewProxy() + p.SetCA(ca) + + var logged RequestLogData + p.SetLogger(func(data RequestLogData) { + logged = data + }) + + proxyServer := httptest.NewServer(p) + defer proxyServer.Close() + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(ca.certPEM) + + client := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(mustParseURL(proxyServer.URL)), + TLSClientConfig: &tls.Config{RootCAs: caCertPool}, + }, + } + + // Connect to a port nothing listens on. + resp, err := client.Get("https://127.0.0.1:1/nope") + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusBadGateway { + t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusBadGateway) + } + if logged.Err == nil { + t.Error("expected error in canonical log") + } + if logged.RequestType != "connect" { + t.Errorf("RequestType = %q, want connect", logged.RequestType) + } +} + +func TestIntercept_CanonicalLogFields(t *testing.T) { + setup := newInterceptTestSetup(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte("hello")) + })) + + setup.Proxy.SetCredentialWithGrant(setup.BackendHost, "Authorization", "Bearer tok", "test-grant") + + var logged RequestLogData + setup.Proxy.SetLogger(func(data RequestLogData) { + logged = data + }) + + resp, err := setup.Client.Get(setup.Backend.URL + "/some/path") + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + + if logged.Method != "GET" { + t.Errorf("Method = %q, want GET", logged.Method) + } + backendHostname := mustParseURL(setup.Backend.URL).Hostname() + if logged.Host != backendHostname { + t.Errorf("Host = %q, want %q", logged.Host, backendHostname) + } + if logged.Path != "/some/path" { + t.Errorf("Path = %q, want /some/path", logged.Path) + } + if logged.StatusCode != 200 { + t.Errorf("StatusCode = %d, want 200", logged.StatusCode) + } + if logged.RequestType != "connect" { + t.Errorf("RequestType = %q, want connect", logged.RequestType) + } + if !logged.AuthInjected { + t.Error("expected AuthInjected=true") + } +} + +func TestIntercept_ExtraHeaders(t *testing.T) { + var receivedHeaders http.Header + setup := newInterceptTestSetup(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header.Clone() + w.Write([]byte("ok")) + })) + + setup.Proxy.AddExtraHeader(setup.BackendHost, "X-Custom-Header", "custom-value") + + resp, err := setup.Client.Get(setup.Backend.URL + "/test") + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if receivedHeaders.Get("X-Custom-Header") != "custom-value" { + t.Errorf("X-Custom-Header = %q, want custom-value", receivedHeaders.Get("X-Custom-Header")) + } +} + +func TestIntercept_RemoveHeaders(t *testing.T) { + var receivedAPIKey string + setup := newInterceptTestSetup(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAPIKey = r.Header.Get("X-Api-Key") + w.Write([]byte("ok")) + })) + + setup.Proxy.RemoveRequestHeader(setup.BackendHost, "X-Api-Key") + + req, err := http.NewRequest("GET", setup.Backend.URL+"/test", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("X-Api-Key", "stale-key") + resp, err := setup.Client.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + + if receivedAPIKey != "" { + t.Errorf("X-Api-Key should be removed, got %q", receivedAPIKey) + } +} + +func TestIntercept_RequestBodyForwarded(t *testing.T) { + var receivedBody string + setup := newInterceptTestSetup(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + receivedBody = string(body) + w.Write([]byte("ok")) + })) + + reqBody := `{"key": "value"}` + resp, err := setup.Client.Post(setup.Backend.URL+"/submit", "application/json", strings.NewReader(reqBody)) + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if receivedBody != reqBody { + t.Errorf("body = %q, want %q", receivedBody, reqBody) + } +} + +func TestIntercept_LargeResponseBody(t *testing.T) { + // 1MB response body to verify streaming works. + largeBody := bytes.Repeat([]byte("x"), 1<<20) + setup := newInterceptTestSetup(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(largeBody) + })) + + resp, err := setup.Client.Get(setup.Backend.URL + "/large") + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if len(body) != len(largeBody) { + t.Errorf("body length = %d, want %d", len(body), len(largeBody)) + } +} + +func TestIntercept_ResponseStatusCodes(t *testing.T) { + codes := []int{200, 201, 204, 301, 400, 404, 500} + + for _, code := range codes { + t.Run(http.StatusText(code), func(t *testing.T) { + setup := newInterceptTestSetup(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(code) + })) + + resp, err := setup.Client.Get(setup.Backend.URL + "/status") + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != code { + t.Errorf("status = %d, want %d", resp.StatusCode, code) + } + }) + } +} + +func TestIntercept_ResponseHeaders(t *testing.T) { + setup := newInterceptTestSetup(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Backend-Header", "backend-value") + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{}`)) + })) + + resp, err := setup.Client.Get(setup.Backend.URL + "/headers") + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + + if resp.Header.Get("X-Backend-Header") != "backend-value" { + t.Errorf("X-Backend-Header = %q, want backend-value", resp.Header.Get("X-Backend-Header")) + } + if resp.Header.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %q, want application/json", resp.Header.Get("Content-Type")) + } +} + +func TestIntercept_XRequestIdInjected(t *testing.T) { + var receivedRequestID string + setup := newInterceptTestSetup(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedRequestID = r.Header.Get("X-Request-Id") + w.Write([]byte("ok")) + })) + + resp, err := setup.Client.Get(setup.Backend.URL + "/rid") + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + + if receivedRequestID == "" { + t.Error("expected X-Request-Id to be injected") + } + if !strings.HasPrefix(receivedRequestID, "req_") { + t.Errorf("X-Request-Id = %q, expected req_ prefix", receivedRequestID) + } +} + +func TestIntercept_ProxyAuthorizationStripped(t *testing.T) { + var receivedProxyAuth string + setup := newInterceptTestSetup(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedProxyAuth = r.Header.Get("Proxy-Authorization") + w.Write([]byte("ok")) + })) + + resp, err := setup.Client.Get(setup.Backend.URL + "/strip") + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + + // Proxy-Authorization should be stripped before forwarding upstream. + if receivedProxyAuth != "" { + t.Errorf("Proxy-Authorization should be stripped, got %q", receivedProxyAuth) + } +} + +func TestIntercept_HTTPMethods(t *testing.T) { + methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH"} + + for _, method := range methods { + t.Run(method, func(t *testing.T) { + var receivedMethod string + setup := newInterceptTestSetup(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedMethod = r.Method + w.Write([]byte("ok")) + })) + + req, err := http.NewRequest(method, setup.Backend.URL+"/method", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + resp, err := setup.Client.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + resp.Body.Close() + + if receivedMethod != method { + t.Errorf("method = %q, want %q", receivedMethod, method) + } + }) + } +} + +func TestIntercept_WebSocketUpgrade(t *testing.T) { + // Backend that accepts WebSocket upgrades and echoes raw bytes. + backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Upgrade") != "websocket" { + http.Error(w, "expected websocket upgrade", 400) + return + } + + w.Header().Set("Upgrade", "websocket") + w.Header().Set("Connection", "Upgrade") + w.WriteHeader(http.StatusSwitchingProtocols) + + hijacker, ok := w.(http.Hijacker) + if !ok { + return + } + conn, brw, err := hijacker.Hijack() + if err != nil { + return + } + defer conn.Close() + brw.Flush() + + // Echo: read up to 1024 bytes, write them back. + buf := make([]byte, 1024) + n, err := conn.Read(buf) + if err != nil { + return + } + conn.Write(buf[:n]) + })) + defer backend.Close() + + ca, err := generateCA() + if err != nil { + t.Fatal(err) + } + + upstreamCAs := x509.NewCertPool() + upstreamCAs.AddCert(backend.Certificate()) + + p := NewProxy() + p.SetCA(ca) + p.SetUpstreamCAs(upstreamCAs) + + backendHost := mustParseURL(backend.URL).Hostname() + p.SetCredential(backendHost, "Bearer ws-token") + + var logged RequestLogData + p.SetLogger(func(data RequestLogData) { + logged = data + }) + + proxyServer := httptest.NewServer(p) + defer proxyServer.Close() + + // Dial through the proxy using raw CONNECT. + proxyURL := mustParseURL(proxyServer.URL) + proxyConn, err := net.Dial("tcp", proxyURL.Host) + if err != nil { + t.Fatalf("dial proxy: %v", err) + } + defer proxyConn.Close() + + // Send CONNECT. + backendAddr := mustParseURL(backend.URL).Host + fmt.Fprintf(proxyConn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", backendAddr, backendAddr) + br := bufio.NewReader(proxyConn) + connectResp, err := http.ReadResponse(br, nil) + if err != nil { + t.Fatalf("read CONNECT response: %v", err) + } + if connectResp.StatusCode != 200 { + t.Fatalf("CONNECT status = %d, want 200", connectResp.StatusCode) + } + + // TLS handshake with the proxy's interception cert. + clientCAs := x509.NewCertPool() + clientCAs.AppendCertsFromPEM(ca.certPEM) + tlsConn := tls.Client(proxyConn, &tls.Config{ + RootCAs: clientCAs, + ServerName: backendHost, + }) + if err := tlsConn.Handshake(); err != nil { + t.Fatalf("TLS handshake: %v", err) + } + defer tlsConn.Close() + + // Send WebSocket upgrade request. + upgradeReq := "GET /ws HTTP/1.1\r\n" + + "Host: " + backendAddr + "\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + "\r\n" + if _, err := tlsConn.Write([]byte(upgradeReq)); err != nil { + t.Fatalf("write upgrade request: %v", err) + } + + // Read the 101 response. + tlsBr := bufio.NewReader(tlsConn) + upgradeResp, err := http.ReadResponse(tlsBr, nil) + if err != nil { + t.Fatalf("read upgrade response: %v", err) + } + if upgradeResp.StatusCode != http.StatusSwitchingProtocols { + t.Fatalf("upgrade status = %d, want 101", upgradeResp.StatusCode) + } + + // Send a raw message through the upgraded connection. + testMsg := []byte("hello websocket") + if _, err := tlsConn.Write(testMsg); err != nil { + t.Fatalf("write message: %v", err) + } + + // Read echoed message back. + echoBuf := make([]byte, len(testMsg)) + if _, err := io.ReadFull(tlsBr, echoBuf); err != nil { + t.Fatalf("read echo: %v", err) + } + if string(echoBuf) != string(testMsg) { + t.Errorf("echo = %q, want %q", echoBuf, testMsg) + } + + // Verify credential was injected on the upgrade request. + if !logged.AuthInjected { + t.Error("expected credential injection on upgrade request") + } +} diff --git a/proxy/proxy.go b/proxy/proxy.go index cac0512..17c768e 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -29,7 +29,6 @@ package proxy import ( - "bufio" "bytes" "context" "crypto/subtle" @@ -38,13 +37,16 @@ import ( "encoding/base64" "fmt" "io" + "log" "log/slog" "net" "net/http" + "net/http/httputil" "net/url" "strconv" "strings" "sync" + "sync/atomic" "time" keeplib "github.com/majorcontext/keep" @@ -1746,6 +1748,121 @@ func (p *Proxy) handleConnectTunnel(w http.ResponseWriter, r *http.Request) { }() } +// Context keys for passing data between ReverseProxy hooks in the interception path. +type interceptCredResultKey struct{} +type interceptCredsKey struct{} +type interceptReqStartKey struct{} +type interceptLogURLKey struct{} +type interceptPreInjHeadersKey struct{} +type interceptReqBodyKey struct{} + +func reqStartFromContext(ctx context.Context) time.Time { + if t, ok := ctx.Value(interceptReqStartKey{}).(time.Time); ok { + return t + } + return time.Now() +} + +// singleConnListener wraps a single net.Conn as a net.Listener. +// Accept returns the connection once, then blocks until Close is called. +// This keeps http.Server.Serve alive for the lifetime of the connection. +type singleConnListener struct { + conn net.Conn + connCh chan net.Conn + closeCh chan struct{} +} + +func newSingleConnListener(conn net.Conn) *singleConnListener { + ch := make(chan net.Conn, 1) + ch <- conn + return &singleConnListener{conn: conn, connCh: ch, closeCh: make(chan struct{})} +} + +func (l *singleConnListener) Accept() (net.Conn, error) { + select { + case conn := <-l.connCh: + return conn, nil + case <-l.closeCh: + return nil, net.ErrClosed + } +} + +func (l *singleConnListener) Close() error { + select { + case <-l.closeCh: + default: + close(l.closeCh) + } + return nil +} + +func (l *singleConnListener) Addr() net.Addr { + return l.conn.LocalAddr() +} + +// evaluateAndReplaceLLMResponse evaluates LLM gateway policy and replaces +// the response in-place if denied. Returns whether a denial occurred and the reason. +func (p *Proxy) evaluateAndReplaceLLMResponse(ctxReq *http.Request, req *http.Request, resp *http.Response, eng *keeplib.Engine) (denied bool, reason string) { + respBodyBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, maxLLMResponseSize+1)) + resp.Body.Close() + if readErr != nil { + p.logPolicy(ctxReq, "llm-gateway", "llm.read_error", "read-error", "Failed to read response body for policy evaluation") + errorBody := buildPolicyDeniedResponse("read-error", "Failed to read response body for policy evaluation.") + resp.StatusCode = http.StatusBadRequest + resp.Header = make(http.Header) + resp.Header.Set("Content-Type", "application/json") + resp.Header.Set("X-Moat-Blocked", "llm-policy") + resp.ContentLength = int64(len(errorBody)) + resp.Body = io.NopCloser(bytes.NewReader(errorBody)) + return true, "LLM policy read error" + } + if int64(len(respBodyBytes)) > maxLLMResponseSize { + p.logPolicy(ctxReq, "llm-gateway", "llm.response_too_large", "size-limit", "Response too large for policy evaluation") + errorBody := buildPolicyDeniedResponse("size-limit", "Response too large for policy evaluation.") + resp.StatusCode = http.StatusBadRequest + resp.Header = make(http.Header) + resp.Header.Set("Content-Type", "application/json") + resp.Header.Set("X-Moat-Blocked", "llm-policy") + resp.ContentLength = int64(len(errorBody)) + resp.Body = io.NopCloser(bytes.NewReader(errorBody)) + return true, "LLM policy response too large" + } + result := evaluateLLMResponse(eng, respBodyBytes, resp) + if result.Denied { + p.logPolicy(ctxReq, "llm-gateway", "llm.tool_use", result.Rule, result.Message) + errorBody := buildPolicyDeniedResponse(result.Rule, result.Message) + resp.StatusCode = http.StatusBadRequest + resp.Header = make(http.Header) + resp.Header.Set("Content-Type", "application/json") + resp.Header.Set("X-Moat-Blocked", "llm-policy") + resp.ContentLength = int64(len(errorBody)) + resp.Body = io.NopCloser(bytes.NewReader(errorBody)) + return true, "LLM policy denied: " + result.Rule + " " + result.Message + } else if result.Events != nil { + var buf bytes.Buffer + for _, ev := range result.Events { + if ev.ID != "" { + fmt.Fprintf(&buf, "id: %s\n", ev.ID) + } + if ev.Type != "" { + fmt.Fprintf(&buf, "event: %s\n", ev.Type) + } + lines := strings.Split(ev.Data, "\n") + for _, line := range lines { + fmt.Fprintf(&buf, "data: %s\n", line) + } + buf.WriteByte('\n') + } + resp.Header.Del("Content-Encoding") + resp.Body = io.NopCloser(&buf) + resp.ContentLength = int64(buf.Len()) + } else { + resp.Body = io.NopCloser(bytes.NewReader(respBodyBytes)) + resp.ContentLength = int64(len(respBodyBytes)) + } + return false, "" +} + func (p *Proxy) handleConnectWithInterception(w http.ResponseWriter, r *http.Request, host string) { hijacker, ok := w.(http.Hijacker) if !ok { @@ -1758,7 +1875,16 @@ func (p *Proxy) handleConnectWithInterception(w http.ResponseWriter, r *http.Req http.Error(w, err.Error(), http.StatusInternalServerError) return } - defer clientConn.Close() + + // Track whether the inner http.Server's connection was hijacked + // (e.g., for WebSocket upgrade). If hijacked, ReverseProxy owns the + // TLS conn and will close it; we must not close clientConn ourselves. + var hijacked atomic.Bool + defer func() { + if !hijacked.Load() { + clientConn.Close() + } + }() _, _ = clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) @@ -1779,7 +1905,11 @@ func (p *Proxy) handleConnectWithInterception(w http.ResponseWriter, r *http.Req "subsystem", "proxy", "host", host, "error", err) return } - defer tlsClientConn.Close() + defer func() { + if !hijacked.Load() { + tlsClientConn.Close() + } + }() transport := &http.Transport{ Proxy: nil, @@ -1809,38 +1939,175 @@ func (p *Proxy) handleConnectWithInterception(w http.ResponseWriter, r *http.Req } } - clientReader := bufio.NewReader(tlsClientConn) - for { - req, err := http.ReadRequest(clientReader) - if err != nil { - if err != io.EOF { - slog.Debug("failed to read request from intercepted connection", - "subsystem", "proxy", "host", host, "error", err) + // Create a reverse proxy that handles request forwarding, including + // WebSocket upgrades via the stdlib's built-in protocol switch support. + reverseProxy := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.Out.URL.Scheme = "https" + connectHost := r.Host + if rc := getRunContext(r); rc != nil && rc.HostGatewayIP != "" && isHostGateway(rc, host) { + connectHost = rewriteHostPort(r.Host, rc.HostGatewayIP) } - return - } + pr.Out.URL.Host = connectHost + pr.Out.Host = pr.In.Host + pr.Out.RequestURI = "" + + // Credentials were resolved in the wrapping handler and passed via context. + creds, _ := pr.Out.Context().Value(interceptCredsKey{}).([]credentialHeader) + + // Snapshot headers before credential injection so logs don't + // contain raw credential values (CLAUDE.md: never log credential values). + preInjectionHeaders := pr.Out.Header.Clone() + + credResult := injectCredentials(pr.Out, creds, host, pr.Out.Method, pr.Out.URL.Path) + + // Store credential result and pre-injection headers in context. + ctx := pr.Out.Context() + ctx = context.WithValue(ctx, interceptCredResultKey{}, credResult) + ctx = context.WithValue(ctx, interceptPreInjHeadersKey{}, preInjectionHeaders) + *pr.Out = *pr.Out.WithContext(ctx) + + // Extra headers. + mergeExtraHeaders(pr.Out, r.Host, p.getExtraHeadersForRequest(r, r.Host)) + + // Strip proxy headers. + pr.Out.Header.Del("Proxy-Connection") + pr.Out.Header.Del("Proxy-Authorization") + + // Remove configured headers (but not injected credential headers). + for _, headerName := range p.getRemoveHeadersForRequest(r, host) { + if credResult.InjectedHeaders[strings.ToLower(headerName)] { + continue + } + pr.Out.Header.Del(headerName) + } + + // Capture URL before token substitution so logs don't contain real tokens. + logURL := pr.Out.URL.String() + ctx = context.WithValue(pr.Out.Context(), interceptLogURLKey{}, logURL) + *pr.Out = *pr.Out.WithContext(ctx) + + // Token substitution. + if sub := p.getTokenSubstitutionForRequest(r, host); sub != nil { + p.applyTokenSubstitution(pr.Out, sub) + } + + // Request ID. + if pr.Out.Header.Get("X-Request-Id") == "" { + pr.Out.Header.Set("X-Request-Id", newRequestID()) + } + }, + Transport: transport, + ModifyResponse: func(resp *http.Response) error { + req := resp.Request + + // Track LLM policy denials for the canonical log line. + var llmDenied bool + var llmDenyReason string + + // LLM gateway policy evaluation (Anthropic API only). + if resp.StatusCode == http.StatusOK && host == "api.anthropic.com" { + if rc := getRunContext(r); rc != nil && rc.KeepEngines != nil { + if eng, ok := rc.KeepEngines["llm-gateway"]; ok { + llmDenied, llmDenyReason = p.evaluateAndReplaceLLMResponse(r, req, resp, eng) + } + } + } + + // Response transformers. + if transformers := p.getResponseTransformersForRequest(r, host); len(transformers) > 0 { + for _, transformer := range transformers { + if newRespInterface, transformed := transformer(req, resp); transformed { + if newResp, ok := newRespInterface.(*http.Response); ok { + *resp = *newResp + } + break + } + } + } + + // Canonical log line. + credResult, _ := req.Context().Value(interceptCredResultKey{}).(credentialInjectionResult) + // Use pre-substitution URL so logs don't contain real tokens. + logURL, _ := req.Context().Value(interceptLogURLKey{}).(string) + if logURL == "" { + logURL = req.URL.String() + } + var respBody []byte + respBody, resp.Body = captureBody(resp.Body, resp.Header.Get("Content-Type")) + + // Use pre-injection headers so credential values don't appear in logs. + preHeaders, _ := req.Context().Value(interceptPreInjHeadersKey{}).(http.Header) + if preHeaders == nil { + preHeaders = req.Header.Clone() + } + reqBody, _ := req.Context().Value(interceptReqBodyKey{}).([]byte) + + p.logRequest(r, RequestLogData{ + RequestID: req.Header.Get("X-Request-Id"), + Method: req.Method, + URL: logURL, + Host: host, + Path: req.URL.Path, + RequestType: "connect", + StatusCode: resp.StatusCode, + Duration: time.Since(reqStartFromContext(req.Context())), + RequestHeaders: preHeaders, + ResponseHeaders: resp.Header.Clone(), + RequestBody: reqBody, + ResponseBody: respBody, + RequestSize: req.ContentLength, + ResponseSize: resp.ContentLength, + AuthInjected: len(credResult.InjectedHeaders) > 0, + InjectedHeaders: credResult.InjectedHeaders, + Grants: credResult.Grants, + Denied: llmDenied, + DenyReason: llmDenyReason, + }) + + return nil + }, + ErrorHandler: func(rw http.ResponseWriter, req *http.Request, err error) { + rw.WriteHeader(http.StatusBadGateway) + credResult, _ := req.Context().Value(interceptCredResultKey{}).(credentialInjectionResult) + logURL, _ := req.Context().Value(interceptLogURLKey{}).(string) + if logURL == "" { + logURL = req.URL.String() + } + p.logRequest(r, RequestLogData{ + RequestID: req.Header.Get("X-Request-Id"), + Method: req.Method, + URL: logURL, + Host: host, + Path: req.URL.Path, + RequestType: "connect", + StatusCode: http.StatusBadGateway, + Duration: time.Since(reqStartFromContext(req.Context())), + RequestSize: req.ContentLength, + ResponseSize: -1, + Err: err, + AuthInjected: len(credResult.InjectedHeaders) > 0, + InjectedHeaders: credResult.InjectedHeaders, + Grants: credResult.Grants, + }) + }, + } + + // Wrapping handler: policy checks and credential resolution before ReverseProxy. + handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + reqStart := time.Now() innerReqID := req.Header.Get("X-Request-Id") if innerReqID == "" { innerReqID = newRequestID() } - reqStart := time.Now() - req.URL.Scheme = "https" - // Rewrite synthetic host-gateway hostname to actual IP for forwarding. - connectHost := r.Host - if rc := getRunContext(r); rc != nil && rc.HostGatewayIP != "" && isHostGateway(rc, host) { - connectHost = rewriteHostPort(r.Host, rc.HostGatewayIP) - } - req.URL.Host = connectHost - req.RequestURI = "" - // Check request-level rules (method + path) for the inner HTTP request. - // The CONNECT request r carries the per-run context for rule lookup. + // Network policy check. if !p.checkNetworkPolicyForRequest(r, host, connectPort, req.Method, req.URL.Path) { p.logRequest(r, RequestLogData{ RequestID: innerReqID, Method: req.Method, - URL: req.URL.String(), + URL: "https://" + r.Host + req.URL.RequestURI(), Host: host, Path: req.URL.Path, RequestType: "connect", @@ -1852,34 +2119,24 @@ func (p *Proxy) handleConnectWithInterception(w http.ResponseWriter, r *http.Req DenyReason: "Request blocked by network policy: " + req.Method + " " + host + req.URL.Path, }) p.logPolicy(r, "network", "http.request", "", req.Method+" "+host+req.URL.Path) - body := "Moat: request blocked by network policy.\nHost: " + host + "\nTo allow this request, update network.rules in moat.yaml.\n" - blockedResp := &http.Response{ - StatusCode: http.StatusProxyAuthRequired, - ProtoMajor: 1, - ProtoMinor: 1, - Header: make(http.Header), - ContentLength: int64(len(body)), - Body: io.NopCloser(strings.NewReader(body)), - } - blockedResp.Header.Set("X-Moat-Blocked", "request-rule") - blockedResp.Header.Set("Content-Type", "text/plain") - _ = blockedResp.Write(tlsClientConn) - continue + w.Header().Set("X-Moat-Blocked", "request-rule") + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusProxyAuthRequired) + fmt.Fprintf(w, "Moat: request blocked by network policy.\nHost: %s\nTo allow this request, update network.rules in moat.yaml.\n", host) + return } - // Evaluate Keep policy for the inner HTTP request. - // Uses the global "http" engine from network.keep_policy. + // Keep HTTP policy check. if rc := getRunContext(r); rc != nil && rc.KeepEngines != nil { - scope := "http" - if eng, ok := rc.KeepEngines[scope]; ok { + if eng, ok := rc.KeepEngines["http"]; ok { call := keeplib.NewHTTPCall(req.Method, host, req.URL.Path) call.Context.Scope = "http-" + host - result, evalErr := keeplib.SafeEvaluate(eng, call, scope) + result, evalErr := keeplib.SafeEvaluate(eng, call, "http") if evalErr != nil { p.logRequest(r, RequestLogData{ RequestID: innerReqID, Method: req.Method, - URL: req.URL.String(), + URL: "https://" + r.Host + req.URL.RequestURI(), Host: host, Path: req.URL.Path, RequestType: "connect", @@ -1891,25 +2148,18 @@ func (p *Proxy) handleConnectWithInterception(w http.ResponseWriter, r *http.Req DenyReason: "Keep policy evaluation error", Err: evalErr, }) - p.logPolicy(r, scope, "http.request", "evaluation-error", "Policy evaluation failed") - msg := "Moat: request blocked — policy evaluation error.\nHost: " + host + "\n" - blockedResp := &http.Response{ - StatusCode: http.StatusForbidden, - ProtoMajor: 1, - ProtoMinor: 1, - Header: make(http.Header), - ContentLength: int64(len(msg)), - Body: io.NopCloser(strings.NewReader(msg)), - } - blockedResp.Header.Set("X-Moat-Blocked", "keep-policy") - blockedResp.Header.Set("Content-Type", "text/plain") - _ = blockedResp.Write(tlsClientConn) - continue - } else if result.Decision == keeplib.Deny { + p.logPolicy(r, "http", "http.request", "evaluation-error", "Policy evaluation failed") + w.Header().Set("X-Moat-Blocked", "keep-policy") + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusForbidden) + fmt.Fprintf(w, "Moat: request blocked — policy evaluation error.\nHost: %s\n", host) + return + } + if result.Decision == keeplib.Deny { p.logRequest(r, RequestLogData{ RequestID: innerReqID, Method: req.Method, - URL: req.URL.String(), + URL: "https://" + r.Host + req.URL.RequestURI(), Host: host, Path: req.URL.Path, RequestType: "connect", @@ -1920,49 +2170,33 @@ func (p *Proxy) handleConnectWithInterception(w http.ResponseWriter, r *http.Req Denied: true, DenyReason: "Keep policy denied: " + result.Rule + " " + result.Message, }) - p.logPolicy(r, scope, "http.request", result.Rule, result.Message) - msg := "Moat: request blocked by Keep policy.\nHost: " + host + "\n" + p.logPolicy(r, "http", "http.request", result.Rule, result.Message) + w.Header().Set("X-Moat-Blocked", "keep-policy") + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusForbidden) + msg := fmt.Sprintf("Moat: request blocked by Keep policy.\nHost: %s\n", host) if result.Message != "" { msg += result.Message + "\n" } - blockedResp := &http.Response{ - StatusCode: http.StatusForbidden, - ProtoMajor: 1, - ProtoMinor: 1, - Header: make(http.Header), - ContentLength: int64(len(msg)), - Body: io.NopCloser(strings.NewReader(msg)), - } - blockedResp.Header.Set("X-Moat-Blocked", "keep-policy") - blockedResp.Header.Set("Content-Type", "text/plain") - _ = blockedResp.Write(tlsClientConn) - continue + fmt.Fprint(w, msg) + return } } } - // Inject MCP credentials if this is an MCP request. - // Use the CONNECT request r for context lookups since inner - // requests from the TLS stream don't carry the request context. + // MCP credential injection. p.injectMCPCredentialsWithContext(r, req) + // Resolve credentials before forwarding so errors are caught early. creds, credErr := p.getCredentialsForRequest(r, req, host) if credErr != nil { - body := "credential resolution failed\n" - errResp := &http.Response{ - StatusCode: http.StatusBadGateway, - ProtoMajor: 1, - ProtoMinor: 1, - Header: make(http.Header), - ContentLength: int64(len(body)), - Body: io.NopCloser(strings.NewReader(body)), - } - errResp.Header.Set("Content-Type", "text/plain") - _ = errResp.Write(tlsClientConn) + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusBadGateway) + fmt.Fprint(w, "credential resolution failed\n") p.logRequest(r, RequestLogData{ RequestID: innerReqID, Method: req.Method, - URL: req.URL.String(), + URL: "https://" + r.Host + req.URL.RequestURI(), Host: host, Path: req.URL.Path, RequestType: "connect", @@ -1972,207 +2206,38 @@ func (p *Proxy) handleConnectWithInterception(w http.ResponseWriter, r *http.Req ResponseSize: -1, Err: credErr, }) - continue + return } - // Capture request body and headers after credential resolution - // so that resolver side effects (e.g., subject header stripping) - // are reflected and sensitive headers are not logged. + // Capture request body for logging before ReverseProxy consumes it. var reqBody []byte reqBody, req.Body = captureBody(req.Body, req.Header.Get("Content-Type")) - originalReqHeaders := req.Header.Clone() - - credResult := injectCredentials(req, creds, host, req.Method, req.URL.Path) - - // Inject any additional headers configured for this host. - // Merges with existing values (comma-separated) to preserve client - // headers like anthropic-beta that support multiple flags. - mergeExtraHeaders(req, r.Host, p.getExtraHeadersForRequest(r, r.Host)) - req.Header.Del("Proxy-Connection") - req.Header.Del("Proxy-Authorization") - - // Remove headers that should be stripped for this host, but never - // remove a credential header the proxy just injected (see comment - // in handleHTTP for the multi-grant conflict scenario). - for _, headerName := range p.getRemoveHeadersForRequest(r, host) { - if credResult.InjectedHeaders[strings.ToLower(headerName)] { - continue - } - req.Header.Del(headerName) - } - // Apply token substitution if configured for this host. - // Capture the URL before substitution so logs don't contain real tokens. - logURL := req.URL.String() - if sub := p.getTokenSubstitutionForRequest(r, host); sub != nil { - p.applyTokenSubstitution(req, sub) - } - if req.Header.Get("X-Request-Id") == "" { - req.Header.Set("X-Request-Id", innerReqID) - } + // Propagate request ID so Rewrite preserves it (instead of generating a new one). + req.Header.Set("X-Request-Id", innerReqID) - resp, err := transport.RoundTrip(req) - - // Track LLM policy denials for the canonical log line. - var llmDenied bool - var llmDenyReason string - - // Evaluate LLM gateway policy on Anthropic API responses. - // NOTE: Only applies to the default Anthropic endpoint. Custom - // ANTHROPIC_BASE_URL endpoints bypass policy evaluation — this is - // mutually exclusive with llm-gateway (see config validation). - if resp != nil && resp.StatusCode == http.StatusOK && host == "api.anthropic.com" { - if rc := getRunContext(r); rc != nil && rc.KeepEngines != nil { - if eng, ok := rc.KeepEngines["llm-gateway"]; ok { - respBodyBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, maxLLMResponseSize+1)) - resp.Body.Close() - if readErr != nil { - p.logPolicy(r, "llm-gateway", "llm.read_error", "read-error", "Failed to read response body for policy evaluation") - llmDenied = true - llmDenyReason = "LLM policy read error" - errorBody := buildPolicyDeniedResponse("read-error", "Failed to read response body for policy evaluation.") - resp = &http.Response{ - StatusCode: http.StatusBadRequest, - ProtoMajor: 1, - ProtoMinor: 1, - Header: make(http.Header), - ContentLength: int64(len(errorBody)), - Body: io.NopCloser(bytes.NewReader(errorBody)), - } - resp.Header.Set("Content-Type", "application/json") - resp.Header.Set("X-Moat-Blocked", "llm-policy") - } else if int64(len(respBodyBytes)) > maxLLMResponseSize { - p.logPolicy(r, "llm-gateway", "llm.response_too_large", "size-limit", "Response too large for policy evaluation") - llmDenied = true - llmDenyReason = "LLM policy response too large" - errorBody := buildPolicyDeniedResponse("size-limit", "Response too large for policy evaluation.") - resp = &http.Response{ - StatusCode: http.StatusBadRequest, - ProtoMajor: 1, - ProtoMinor: 1, - Header: make(http.Header), - ContentLength: int64(len(errorBody)), - Body: io.NopCloser(bytes.NewReader(errorBody)), - } - resp.Header.Set("Content-Type", "application/json") - resp.Header.Set("X-Moat-Blocked", "llm-policy") - } else { - result := evaluateLLMResponse(eng, respBodyBytes, resp) - if result.Denied { - p.logPolicy(r, "llm-gateway", "llm.tool_use", result.Rule, result.Message) - llmDenied = true - llmDenyReason = "LLM policy denied: " + result.Rule + " " + result.Message - errorBody := buildPolicyDeniedResponse(result.Rule, result.Message) - resp = &http.Response{ - StatusCode: http.StatusBadRequest, - ProtoMajor: 1, - ProtoMinor: 1, - Header: make(http.Header), - ContentLength: int64(len(errorBody)), - Body: io.NopCloser(bytes.NewReader(errorBody)), - } - resp.Header.Set("Content-Type", "application/json") - resp.Header.Set("X-Moat-Blocked", "llm-policy") - } else if result.Events != nil { - // SSE response allowed — re-serialize evaluated events. - // Events were decompressed for evaluation, so the - // re-serialized body is plaintext — remove Content-Encoding. - var buf bytes.Buffer - for _, ev := range result.Events { - if ev.ID != "" { - fmt.Fprintf(&buf, "id: %s\n", ev.ID) - } - if ev.Type != "" { - fmt.Fprintf(&buf, "event: %s\n", ev.Type) - } - // Per SSE spec, multi-line data needs a `data:` prefix per line. - lines := strings.Split(ev.Data, "\n") - for _, line := range lines { - fmt.Fprintf(&buf, "data: %s\n", line) - } - buf.WriteByte('\n') // Event terminator. - } - resp.Header.Del("Content-Encoding") - resp.Body = io.NopCloser(&buf) - resp.ContentLength = int64(buf.Len()) - } else { - // JSON response allowed — restore original body. - resp.Body = io.NopCloser(bytes.NewReader(respBodyBytes)) - resp.ContentLength = int64(len(respBodyBytes)) - } - } - } - } - } + // Pass resolved credentials, start time, and captured body to Rewrite via context. + ctx := req.Context() + ctx = context.WithValue(ctx, interceptReqStartKey{}, reqStart) + ctx = context.WithValue(ctx, interceptCredsKey{}, creds) + ctx = context.WithValue(ctx, interceptReqBodyKey{}, reqBody) + reverseProxy.ServeHTTP(w, req.WithContext(ctx)) + }) - // Capture response - var respBody []byte - var respHeaders http.Header - statusCode := http.StatusBadGateway - var responseSize int64 = -1 - if resp != nil { - respHeaders = resp.Header.Clone() - statusCode = resp.StatusCode - responseSize = resp.ContentLength - - // Apply response transformers BEFORE capturing body - // so transformer can read the original response body. - // Only the first transformer that returns true is applied (transformers are not chained). - if transformers := p.getResponseTransformersForRequest(r, host); len(transformers) > 0 { - for _, transformer := range transformers { - if newRespInterface, transformed := transformer(req, resp); transformed { - if newResp, ok := newRespInterface.(*http.Response); ok { - resp = newResp - statusCode = resp.StatusCode - respHeaders = resp.Header.Clone() - } - break // Only apply first matching transformer - } - } + // Serve on a single-connection listener wrapping the TLS connection. + ln := newSingleConnListener(tlsClientConn) + srv := &http.Server{ + Handler: handler, + IdleTimeout: 120 * time.Second, + ErrorLog: log.New(io.Discard, "", 0), // Suppress server-level errors; handled in ErrorHandler. + ConnState: func(conn net.Conn, state http.ConnState) { + if state == http.StateHijacked { + hijacked.Store(true) } - - // Capture body AFTER transformation - respBody, resp.Body = captureBody(resp.Body, resp.Header.Get("Content-Type")) - } - - p.logRequest(r, RequestLogData{ - RequestID: innerReqID, - Method: req.Method, - URL: logURL, - Host: host, - Path: req.URL.Path, - RequestType: "connect", - StatusCode: statusCode, - Duration: time.Since(reqStart), - Err: err, - RequestHeaders: originalReqHeaders, - ResponseHeaders: respHeaders, - RequestBody: reqBody, - ResponseBody: respBody, - RequestSize: req.ContentLength, - ResponseSize: responseSize, - InjectedHeaders: credResult.InjectedHeaders, - Grants: credResult.Grants, - Denied: llmDenied, - DenyReason: llmDenyReason, - }) - - if err != nil { - errResp := &http.Response{ - StatusCode: http.StatusBadGateway, - ProtoMajor: 1, - ProtoMinor: 1, - Header: make(http.Header), + if state == http.StateClosed || state == http.StateHijacked { + ln.Close() } - _ = errResp.Write(tlsClientConn) - continue - } - - _ = resp.Write(tlsClientConn) - resp.Body.Close() - - if resp.Close || req.Close { - return - } + }, } + _ = srv.Serve(ln) }