diff --git a/README.md b/README.md index d0bed16..922111a 100644 --- a/README.md +++ b/README.md @@ -358,3 +358,9 @@ keep-alive); HTTP/2-only clients are not supported. WebSocket and other protocol upgrades return 501 in inspection mode; frame inspection is not implemented. Plain HTTP carried inside CONNECT (as used by Node fetch) is also captured. Only traffic configured to pass through Netkit can be inspected. + +Inspection has no upstream response-header deadline by default, so long-polling +requests can wait for a response. Client cancellation still cancels the upstream +request. To impose a deadline, use `--inspection-response-header-timeout=90s` +with a duration longer than the upstream polling interval. TLS handshake and +idle-connection timeouts remain in effect. diff --git a/cmd/netkit/main.go b/cmd/netkit/main.go index d679916..9df769a 100644 --- a/cmd/netkit/main.go +++ b/cmd/netkit/main.go @@ -48,7 +48,11 @@ func runServe() { inspectHTTPS := flag.Bool("inspect-https", false, "Decrypt and capture HTTPS for all destinations (clients must trust the CA)") caCert := flag.String("ca-cert", "", "Inspection CA certificate PEM file") caKey := flag.String("ca-key", "", "Inspection CA private key PEM file") + inspectionTimeout := flag.Duration("inspection-response-header-timeout", 0, "Upstream response-header timeout in inspection mode (0 disables; allow for long polling)") flag.Parse() + if *inspectionTimeout < 0 { + log.Fatal("--inspection-response-header-timeout must not be negative") + } var ca *proxy.CertificateAuthority if *inspectHTTPS { var err error @@ -62,15 +66,16 @@ func runServe() { // Create proxy configuration config := &proxy.Config{ - InspectionCA: ca, - Port: *port, - AdminPort: *adminPort, - HistorySize: *historySize, - Dashboard: *dashboard, - DashboardPort: *dashboardPort, - DashboardDir: *dashboardDir, - DashboardBasePath: *dashboardBasePath, - LogLevel: *logLevel, + InspectionResponseHeaderTimeout: *inspectionTimeout, + InspectionCA: ca, + Port: *port, + AdminPort: *adminPort, + HistorySize: *historySize, + Dashboard: *dashboard, + DashboardPort: *dashboardPort, + DashboardDir: *dashboardDir, + DashboardBasePath: *dashboardBasePath, + LogLevel: *logLevel, } // Create and start proxy server diff --git a/internal/proxy/inspection_test.go b/internal/proxy/inspection_test.go index 9099c4e..6998005 100644 --- a/internal/proxy/inspection_test.go +++ b/internal/proxy/inspection_test.go @@ -225,3 +225,56 @@ func TestInspectionStopClosesIdleTunnels(t *testing.T) { require.NoError(t, p.Stop()) require.Eventually(t, func() bool { p.inspectionMu.Lock(); defer p.inspectionMu.Unlock(); return len(p.inspectionConns) == 0 }, time.Second, time.Millisecond) } + +func TestInspectionLongPolling(t *testing.T) { + pollDelay := 100 * time.Millisecond + if os.Getenv("NETKIT_LONG_POLL_TEST") == "1" { + pollDelay = 31 * time.Second + } + for _, tc := range []struct { + name string + timeout time.Duration + status int + }{ + {"default permits delayed headers", 0, 200}, + {"explicit deadline is enforced", 20 * time.Millisecond, 502}, + } { + t.Run(tc.name, func(t *testing.T) { + upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-time.After(pollDelay): + _, err := io.WriteString(w, "poll-result") + require.NoError(t, err) + case <-r.Context().Done(): + } + })) + defer upstream.Close() + p, c, _ := inspectionClient(t, upstream) + c.Timeout = 40 * time.Second + // Check the default separately so a fixed deadline regression cannot pass + // merely because this test uses a short simulated polling interval. + require.Zero(t, p.inspectionTransport.ResponseHeaderTimeout) + p.inspectionTransport.ResponseHeaderTimeout = tc.timeout + resp, err := c.Get(upstream.URL + "/poll") + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, tc.status, resp.StatusCode) + record := waitRecords(t, p, 1)[0] + if tc.status == 200 { + require.Equal(t, "poll-result", string(body)) + require.Equal(t, "poll-result", record.ResponseBody) + } else { + require.False(t, record.Success) + require.Contains(t, record.Error, "timeout") + } + }) + } +} + +func TestInspectionTimeoutConfiguration(t *testing.T) { + p := New(&Config{InspectionResponseHeaderTimeout: 90 * time.Second}) + require.Equal(t, 90*time.Second, p.inspectionTransport.ResponseHeaderTimeout) + require.NoError(t, p.Stop()) +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 4a81eba..fca9326 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -21,15 +21,16 @@ import ( // Config holds the proxy configuration type Config struct { - InspectionCA *CertificateAuthority // Non-nil enables inspection for every CONNECT destination - Port int - AdminPort int - LogLevel string - HistorySize int // Maximum number of requests to keep in history - Dashboard bool // Enable dashboard serving - DashboardPort int // Port for dashboard (separate from admin port) - DashboardDir string // Directory containing dashboard build files - DashboardBasePath string // Optional public URL prefix for path-based reverse proxies + InspectionResponseHeaderTimeout time.Duration // Zero allows long polling without a proxy header deadline + InspectionCA *CertificateAuthority // Non-nil enables inspection for every CONNECT destination + Port int + AdminPort int + LogLevel string + HistorySize int // Maximum number of requests to keep in history + Dashboard bool // Enable dashboard serving + DashboardPort int // Port for dashboard (separate from admin port) + DashboardDir string // Directory containing dashboard build files + DashboardBasePath string // Optional public URL prefix for path-based reverse proxies } // Proxy represents the HTTP proxy server @@ -64,7 +65,7 @@ func New(config *Config) *Proxy { proxy.inspectionConns = make(map[net.Conn]struct{}) proxy.inspectionTransport = http.DefaultTransport.(*http.Transport).Clone() - proxy.inspectionTransport.ResponseHeaderTimeout = 30 * time.Second + proxy.inspectionTransport.ResponseHeaderTimeout = config.InspectionResponseHeaderTimeout proxy.inspectionTransport.Proxy = nil // Never route upstream connections back into this proxy. // Initialize the main HTTP proxy server