Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions internal/proxy/inject.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
40 changes: 34 additions & 6 deletions internal/proxy/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand Down
Loading