diff --git a/internal/proxy/inject.go b/internal/proxy/inject.go index 8a995aa..2fbe129 100644 --- a/internal/proxy/inject.go +++ b/internal/proxy/inject.go @@ -78,6 +78,25 @@ func sniAwareTLSConfig(ca *tls.Certificate) func(host string, ctx *goproxy.Proxy } } +// filteredWriter wraps an io.Writer and silently drops lines containing any +// of the configured substrings. Used to suppress expected goproxy warnings +// (broken pipe, handshake EOF) that are harmless noise in a proxy with +// short-lived polling connections. +type filteredWriter struct { + inner io.Writer + drop []string +} + +func (fw *filteredWriter) Write(p []byte) (int, error) { + s := string(p) + for _, d := range fw.drop { + if strings.Contains(s, d) { + return len(p), nil + } + } + return fw.inner.Write(p) +} + // Injector is an HTTP/HTTPS MITM proxy that intercepts requests and injects // credentials from the vault. It resolves bindings by destination, decrypts // credentials, and performs byte-level replacement of phantom tokens in @@ -177,6 +196,13 @@ func NewInjector(provider vault.Provider, resolver *atomic.Pointer[vault.Binding proxy := goproxy.NewProxyHttpServer() proxy.Verbose = false + // Suppress noisy goproxy warnings for expected conditions: + // - "broken pipe" from client closing before response delivery (Telegram polling) + // - "Cannot handshake" from client aborting during TLS setup (timeouts, retries) + proxy.Logger = log.New(&filteredWriter{ + inner: log.Writer(), + drop: []string{"broken pipe", "Cannot handshake"}, + }, "", 0) // Build a root CA pool for the outbound transport. Start with system // roots and add the sluice MITM CA cert. Adding the MITM CA is diff --git a/internal/proxy/server.go b/internal/proxy/server.go index 8d3545c..50659b2 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -1111,7 +1111,6 @@ func (s *Server) handleConnect(ctx context.Context, writer io.Writer, request *s // client is slow to send the TLS ClientHello. if conn, ok := writer.(net.Conn); ok { conn.SetReadDeadline(time.Now().Add(10 * time.Second)) //nolint:errcheck - defer conn.SetReadDeadline(time.Time{}) //nolint:errcheck } var allow bool @@ -1120,6 +1119,14 @@ func (s *Server) handleConnect(ctx context.Context, writer io.Writer, request *s return nil } + // Clear the read deadline before the relay phase. The deadline was + // only needed for the SNI peek. Leaving it active would kill the + // relay after 10 seconds, terminating long-running connections + // (streaming API responses, tool calls, etc.). + if conn, ok := writer.(net.Conn); ok { + conn.SetReadDeadline(time.Time{}) //nolint:errcheck + } + // Dial with the updated context (FQDN now contains the SNI hostname). target, err := s.dial(ctx, "tcp", request.DestAddr.String()) if err != nil { @@ -1155,6 +1162,14 @@ func (s *Server) handleConnect(ctx context.Context, writer io.Writer, request *s } // relayData bidirectionally copies data between the client and target. +// +// When the first direction finishes (either client or target closes), the +// writer (SOCKS5 connection) is closed to unblock the second goroutine. +// target is NOT closed here to avoid triggering broken pipe warnings in +// goproxy. The caller's deferred target.Close() handles final cleanup. +// If the second goroutine is blocked reading from target (e.g. pending +// long-poll), a short deadline forces it to return instead of blocking +// indefinitely and leaking the SOCKS5 connection in CLOSE_WAIT state. func (s *Server) relayData(clientReader io.Reader, writer io.Writer, target net.Conn) error { errCh := make(chan error, 2) go func() { @@ -1172,12 +1187,25 @@ func (s *Server) relayData(clientReader io.Reader, writer io.Writer, target net. errCh <- cpErr }() - for i := 0; i < 2; i++ { - if e := <-errCh; e != nil { - return e - } + // Wait for the first direction to complete. + e1 := <-errCh + + // Close writer to unblock goroutine 2 if it's stuck writing. Set a + // read deadline on target to unblock goroutine 2 if it's stuck reading + // (e.g. goproxy waiting for a long-poll response). This avoids closing + // target directly, which would trigger broken pipe warnings in goproxy. + if cl, ok := writer.(io.Closer); ok { + cl.Close() //nolint:errcheck } - return nil + target.SetReadDeadline(time.Now().Add(3 * time.Second)) //nolint:errcheck + + // Drain the second result so the goroutine is not leaked. + e2 := <-errCh + + if e1 != nil { + return e1 + } + return e2 } // sniPolicyCheckBeforeDial peeks the first bytes from the client to extract