Skip to content

Commit d3a2bb7

Browse files
Implement streaming HTTP proxy for CONNECT handling (#48)
Replaces http.ReadRequest with incremental header parsing to fix hanging issues with streaming requests and CONNECT tunnels. Key changes: - parseHTTPRequestHeaders: Parse headers line-by-line without reading body - streamRequestToTarget: Bidirectional streaming between client and target - handleConnectStreaming: Proper CONNECT tunnel handling (basic version) - Rule evaluation on headers only, before body streaming begins This should fix the hanging issues with claude and other clients that use streaming requests or CONNECT tunnels. Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
1 parent 742e09a commit d3a2bb7

File tree

2 files changed

+180
-30
lines changed

2 files changed

+180
-30
lines changed

boundary

14.1 MB
Binary file not shown.

proxy/proxy.go

Lines changed: 180 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"log/slog"
1010
"net"
1111
"net/http"
12-
"net/http/httptest"
1312
"net/url"
1413
"strings"
1514
"sync"
@@ -325,55 +324,55 @@ func (p *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
325324
p.logger.Debug("HTTPS request handling completed", "hostname", hostname)
326325
}
327326

328-
// handleTLSConnection processes decrypted HTTPS requests over the TLS connection
327+
// handleTLSConnection processes decrypted HTTPS requests over the TLS connection with streaming support
329328
func (p *Server) handleTLSConnection(tlsConn *tls.Conn, hostname string) {
330-
p.logger.Debug("Creating HTTP server for TLS connection", "hostname", hostname)
329+
p.logger.Debug("Creating streaming HTTP handler for TLS connection", "hostname", hostname)
331330

332-
// Set read timeout to detect hanging connections
333-
tlsConn.SetReadDeadline(time.Now().Add(5 * time.Second))
334-
335-
// Use ReadRequest to manually read HTTP requests from the TLS connection
331+
// Use streaming HTTP parsing instead of ReadRequest
336332
bufReader := bufio.NewReader(tlsConn)
337333
for {
338-
// Read HTTP request from TLS connection
339-
req, err := http.ReadRequest(bufReader)
334+
// Parse HTTP request headers incrementally
335+
req, err := p.parseHTTPRequestHeaders(bufReader, hostname)
340336
if err != nil {
341337
if err == io.EOF {
342338
p.logger.Debug("TLS connection closed by client", "hostname", hostname)
343-
} else if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
344-
p.logger.Debug("TLS connection read timeout - client not sending HTTP requests", "hostname", hostname)
345339
} else {
346-
p.logger.Debug("Failed to read HTTP request", "hostname", hostname, "error", err)
340+
p.logger.Debug("Failed to parse HTTP request headers", "hostname", hostname, "error", err)
347341
}
348342
break
349343
}
350344

351-
p.logger.Debug("Processing decrypted HTTPS request", "hostname", hostname, "method", req.Method, "path", req.URL.Path)
345+
p.logger.Debug("Processing streaming HTTPS request", "hostname", hostname, "method", req.Method, "path", req.URL.Path)
352346

353-
// Set the hostname and scheme if not already set
354-
if req.URL.Host == "" {
355-
req.URL.Host = hostname
356-
}
357-
if req.URL.Scheme == "" {
358-
req.URL.Scheme = "https"
347+
// Handle CONNECT method for HTTPS tunneling
348+
if req.Method == "CONNECT" {
349+
p.handleConnectStreaming(tlsConn, req, hostname)
350+
return // CONNECT takes over the entire connection
359351
}
360352

361-
// Create a response recorder to capture the response
362-
recorder := httptest.NewRecorder()
353+
// Check if request should be allowed (based on headers only)
354+
fullURL := p.constructFullURL(req, hostname)
355+
result := p.ruleEngine.Evaluate(req.Method, fullURL)
363356

364-
// Process the HTTPS request
365-
p.handleDecryptedHTTPS(recorder, req)
357+
// Audit the request
358+
p.auditor.AuditRequest(audit.Request{
359+
Method: req.Method,
360+
URL: fullURL,
361+
Allowed: result.Allowed,
362+
Rule: result.Rule,
363+
})
366364

367-
// Write the response back to the TLS connection
368-
resp := recorder.Result()
369-
err = resp.Write(tlsConn)
365+
if !result.Allowed {
366+
p.writeBlockedResponseStreaming(tlsConn, req)
367+
continue
368+
}
369+
370+
// Stream the request to target server
371+
err = p.streamRequestToTarget(tlsConn, bufReader, req, hostname)
370372
if err != nil {
371-
p.logger.Debug("Failed to write response", "hostname", hostname, "error", err)
373+
p.logger.Debug("Error streaming request", "hostname", hostname, "error", err)
372374
break
373375
}
374-
375-
// Reset read deadline for next request
376-
tlsConn.SetReadDeadline(time.Now().Add(5 * time.Second))
377376
}
378377

379378
p.logger.Debug("TLS connection handling completed", "hostname", hostname)
@@ -532,3 +531,154 @@ func (sl *singleConnectionListener) Addr() net.Addr {
532531
}
533532
return sl.conn.LocalAddr()
534533
}
534+
535+
// parseHTTPRequestHeaders parses HTTP request headers incrementally without reading the body
536+
func (p *Server) parseHTTPRequestHeaders(bufReader *bufio.Reader, hostname string) (*http.Request, error) {
537+
// Read the request line (e.g., "GET /path HTTP/1.1")
538+
requestLine, _, err := bufReader.ReadLine()
539+
if err != nil {
540+
return nil, err
541+
}
542+
543+
// Parse request line
544+
parts := strings.Fields(string(requestLine))
545+
if len(parts) != 3 {
546+
return nil, fmt.Errorf("invalid request line: %s", requestLine)
547+
}
548+
549+
method := parts[0]
550+
requestURI := parts[1]
551+
proto := parts[2]
552+
553+
// Parse URL
554+
var url *url.URL
555+
if strings.HasPrefix(requestURI, "http://") || strings.HasPrefix(requestURI, "https://") {
556+
url, err = url.Parse(requestURI)
557+
} else {
558+
// Relative URL, construct with hostname
559+
url, err = url.Parse("https://" + hostname + requestURI)
560+
}
561+
if err != nil {
562+
return nil, fmt.Errorf("invalid request URI: %s", requestURI)
563+
}
564+
565+
// Read headers
566+
headers := make(http.Header)
567+
for {
568+
headerLine, _, err := bufReader.ReadLine()
569+
if err != nil {
570+
return nil, err
571+
}
572+
573+
// Empty line indicates end of headers
574+
if len(headerLine) == 0 {
575+
break
576+
}
577+
578+
// Parse header
579+
headerStr := string(headerLine)
580+
colonIdx := strings.Index(headerStr, ":")
581+
if colonIdx == -1 {
582+
continue // Skip malformed headers
583+
}
584+
585+
headerName := strings.TrimSpace(headerStr[:colonIdx])
586+
headerValue := strings.TrimSpace(headerStr[colonIdx+1:])
587+
headers.Add(headerName, headerValue)
588+
}
589+
590+
// Create request object (without body)
591+
req := &http.Request{
592+
Method: method,
593+
URL: url,
594+
Proto: proto,
595+
Header: headers,
596+
Host: url.Host,
597+
// Note: Body is intentionally nil - we'll stream it separately
598+
}
599+
600+
return req, nil
601+
}
602+
603+
// constructFullURL builds the full URL from request and hostname
604+
func (p *Server) constructFullURL(req *http.Request, hostname string) string {
605+
if req.URL.Host == "" {
606+
req.URL.Host = hostname
607+
}
608+
if req.URL.Scheme == "" {
609+
req.URL.Scheme = "https"
610+
}
611+
return req.URL.String()
612+
}
613+
614+
// writeBlockedResponseStreaming writes a blocked response directly to the TLS connection
615+
func (p *Server) writeBlockedResponseStreaming(tlsConn *tls.Conn, req *http.Request) {
616+
response := fmt.Sprintf("HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n🚫 Request Blocked by Boundary\n\nRequest: %s %s\nHost: %s\n\nTo allow this request, restart boundary with:\n --allow \"%s\"\n",
617+
req.Method, req.URL.Path, req.Host, req.Host)
618+
tlsConn.Write([]byte(response))
619+
}
620+
621+
// streamRequestToTarget streams the HTTP request (including body) to the target server
622+
func (p *Server) streamRequestToTarget(clientConn *tls.Conn, bufReader *bufio.Reader, req *http.Request, hostname string) error {
623+
// Connect to target server
624+
targetConn, err := tls.Dial("tcp", hostname+":443", &tls.Config{ServerName: hostname})
625+
if err != nil {
626+
return fmt.Errorf("failed to connect to target %s: %v", hostname, err)
627+
}
628+
defer targetConn.Close()
629+
630+
// Send HTTP request headers to target
631+
reqLine := fmt.Sprintf("%s %s %s\r\n", req.Method, req.URL.RequestURI(), req.Proto)
632+
targetConn.Write([]byte(reqLine))
633+
634+
// Send headers
635+
for name, values := range req.Header {
636+
for _, value := range values {
637+
headerLine := fmt.Sprintf("%s: %s\r\n", name, value)
638+
targetConn.Write([]byte(headerLine))
639+
}
640+
}
641+
targetConn.Write([]byte("\r\n")) // End of headers
642+
643+
// Stream request body and response bidirectionally
644+
go func() {
645+
// Stream request body: client -> target
646+
io.Copy(targetConn, bufReader)
647+
}()
648+
649+
// Stream response: target -> client
650+
io.Copy(clientConn, targetConn)
651+
return nil
652+
}
653+
654+
// handleConnectStreaming handles CONNECT requests with streaming TLS termination
655+
func (p *Server) handleConnectStreaming(tlsConn *tls.Conn, req *http.Request, hostname string) {
656+
p.logger.Debug("Handling CONNECT request with streaming", "hostname", hostname)
657+
658+
// For CONNECT, we need to establish a tunnel but still maintain TLS termination
659+
// This is the tricky part - we're already inside a TLS connection from the client
660+
// The client is asking us to CONNECT to another server, but we want to intercept that too
661+
662+
// Send CONNECT response
663+
response := "HTTP/1.1 200 Connection established\r\n\r\n"
664+
tlsConn.Write([]byte(response))
665+
666+
// Now the client will try to do TLS handshake for the target server
667+
// But we want to intercept and terminate it
668+
// This means we need to do another level of TLS termination
669+
670+
// For now, let's create a simple tunnel and log that we're not inspecting
671+
p.logger.Warn("CONNECT tunnel established - content not inspected", "hostname", hostname)
672+
673+
// Create connection to real target
674+
targetConn, err := net.Dial("tcp", req.Host)
675+
if err != nil {
676+
p.logger.Error("Failed to connect to CONNECT target", "target", req.Host, "error", err)
677+
return
678+
}
679+
defer targetConn.Close()
680+
681+
// Bidirectional copy
682+
go io.Copy(targetConn, tlsConn)
683+
io.Copy(tlsConn, targetConn)
684+
}

0 commit comments

Comments
 (0)