diff --git a/proxy/https.go b/proxy/https.go
@@ -6,8 +6,13 @@ import (
"log"
"net"
"net/http"
+ "time"
)
+// Bounds the CONNECT header read only, never the proxied stream.
+// Tunable: tests lower it to keep the suite fast.
+var headerReadTimeout = 10 * time.Second
+
// parseAuthority extracts the server name and a dial-ready "host:port" from
// a CONNECT request's authority. If the input lacks an explicit port (rare,
// since CONNECT requires one), 443 is assumed. IPv6 literals in brackets are
@@ -27,7 +32,9 @@ func HttpsHandler(clientConn net.Conn, p *Proxy) {
defer clientConn.Close()
reader := bufio.NewReader(clientConn)
+ clientConn.SetReadDeadline(time.Now().Add(headerReadTimeout))
req, err := http.ReadRequest(reader)
+ clientConn.SetReadDeadline(time.Time{})
if err != nil {
log.Printf("Failed to read client request: %v", err)
return
diff --git a/proxy/https_test.go b/proxy/https_test.go
@@ -1,6 +1,10 @@
package proxy
-import "testing"
+import (
+ "net"
+ "testing"
+ "time"
+)
func TestParseAuthority(t *testing.T) {
cases := []struct {
@@ -44,3 +48,37 @@ func TestParseAuthority(t *testing.T) {
})
}
}
+
+func TestHttpsHandler_SilentPeerTimesOut(t *testing.T) {
+ orig := headerReadTimeout
+ headerReadTimeout = 200 * time.Millisecond
+ defer func() { headerReadTimeout = orig }()
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("net.Listen: %v", err)
+ }
+ defer ln.Close()
+
+ done := make(chan struct{})
+ go func() {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ HttpsHandler(conn, &Proxy{})
+ close(done)
+ }()
+
+ client, err := net.Dial("tcp", ln.Addr().String())
+ if err != nil {
+ t.Fatalf("net.Dial: %v", err)
+ }
+ defer client.Close()
+
+ select {
+ case <-done:
+ case <-time.After(3 * time.Second):
+ t.Fatal("HttpsHandler did not return on a peer that sent nothing")
+ }
+}
diff --git a/proxy/proxy.go b/proxy/proxy.go
@@ -3,16 +3,24 @@ package proxy
import (
"bufio"
"crypto/x509"
+ "errors"
"fmt"
"io"
"log"
"net"
"sync"
+ "time"
utls "github.com/bogdanfinn/utls"
"github.com/djnnvx/mic/fingerprint"
)
+const (
+ dialTimeout = 10 * time.Second
+ acceptRetryDelay = 5 * time.Millisecond
+ acceptMaxRetryDelay = 1 * time.Second
+)
+
type Proxy struct {
ListenAddr string
BackendAddr string // server-front: backend host:port
@@ -27,7 +35,7 @@ type Handler func(conn net.Conn, p *Proxy)
// dialTarget dials host (host:port) and returns a uTLS connection after a
// successful handshake. Falls back to HelloRandomized when no fingerprint is set.
func (p *Proxy) dialTarget(host string) (*utls.UConn, error) {
- tcpConn, err := net.Dial("tcp", host)
+ tcpConn, err := net.DialTimeout("tcp", host, dialTimeout)
if err != nil {
return nil, err
}
@@ -66,8 +74,33 @@ type bufferedConn struct {
func (b *bufferedConn) Read(p []byte) (int, error) { return b.r.Read(p) }
-// pipe copies bidirectionally between a and b. When either direction finishes
-// it closes that side so the other goroutine unblocks.
+func (b *bufferedConn) CloseWrite() error {
+ if hc, ok := b.Conn.(halfCloser); ok {
+ return hc.CloseWrite()
+ }
+ return errNoHalfClose
+}
+
+var errNoHalfClose = errors.New("proxy: connection does not support half-close")
+
+// halfCloser is implemented by *net.TCPConn, *tls.Conn and *utls.UConn.
+type halfCloser interface {
+ CloseWrite() error
+}
+
+// closeWrite shuts down only the write side so the peer still sees the data
+// already sent. Falls back to a full Close when half-close is unavailable.
+func closeWrite(c io.Closer) {
+ if hc, ok := c.(halfCloser); ok && hc.CloseWrite() == nil {
+ return
+ }
+ c.Close()
+}
+
+// pipe copies bidirectionally between a and b. A finished direction only
+// half-closes its write side: a client that shuts down its write end after
+// sending a request must still receive the response. Both ends are fully
+// closed once both directions are done.
func pipe(a, b io.ReadWriteCloser) {
var wg sync.WaitGroup
wg.Add(2)
@@ -75,16 +108,18 @@ func pipe(a, b io.ReadWriteCloser) {
go func() {
defer wg.Done()
io.Copy(b, a)
- b.Close()
+ closeWrite(b)
}()
go func() {
defer wg.Done()
io.Copy(a, b)
- a.Close()
+ closeWrite(a)
}()
wg.Wait()
+ a.Close()
+ b.Close()
}
func (p *Proxy) Run() error {
@@ -98,12 +133,36 @@ func (p *Proxy) Run() error {
defer ln.Close()
log.Printf("[+] Starting proxy on %s", p.ListenAddr)
+ return p.Serve(ln)
+}
+
+// Serve accepts connections until the listener is closed. Transient accept
+// errors are retried with exponential backoff, mirroring net/http.Server.Serve.
+func (p *Proxy) Serve(ln net.Listener) error {
+ if p.Handler == nil {
+ return fmt.Errorf("proxy: Handler is required")
+ }
+
+ var delay time.Duration
for {
conn, err := ln.Accept()
if err != nil {
- log.Printf("Failed to accept connection: %v", err)
+ if errors.Is(err, net.ErrClosed) {
+ return err
+ }
+ if delay == 0 {
+ delay = acceptRetryDelay
+ } else {
+ delay *= 2
+ }
+ if delay > acceptMaxRetryDelay {
+ delay = acceptMaxRetryDelay
+ }
+ log.Printf("Failed to accept connection: %v; retrying in %v", err, delay)
+ time.Sleep(delay)
continue
}
+ delay = 0
go p.Handler(conn, p)
}
}
diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go
@@ -7,12 +7,15 @@ import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
+ "errors"
"io"
"math/big"
"net"
+ "strings"
"testing"
"time"
+ utls "github.com/bogdanfinn/utls"
"github.com/djnnvx/mic/fingerprint"
)
@@ -123,8 +126,100 @@ func TestDialTarget_FallbackToRandomized(t *testing.T) {
conn, err := p.dialTarget(ln.Addr().String())
if err != nil {
- // HelloRandomized may select post-quantum curves unsupported by stdlib TLS.
- t.Skipf("dialTarget with randomized preset: %v", err)
+ // HelloRandomized sometimes picks a curve the local TLS stack rejects.
+ // That is the only tolerated failure, anything else is a real bug.
+ if !strings.Contains(err.Error(), "CurvePreferences includes unsupported curve") {
+ t.Fatalf("dialTarget with randomized preset: %v", err)
+ }
+ return
+ }
+ defer conn.Close()
+
+ if got := conn.ClientHelloID.Str(); got != utls.HelloRandomized.Str() {
+ t.Fatalf("ClientHelloID: got %q, want %q", got, utls.HelloRandomized.Str())
+ }
+}
+
+func TestServe_ReturnsWhenListenerClosed(t *testing.T) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("net.Listen: %v", err)
+ }
+
+ p := &Proxy{Handler: func(conn net.Conn, _ *Proxy) { conn.Close() }}
+
+ done := make(chan error, 1)
+ go func() { done <- p.Serve(ln) }()
+
+ time.Sleep(50 * time.Millisecond)
+ ln.Close()
+
+ select {
+ case err := <-done:
+ if !errors.Is(err, net.ErrClosed) {
+ t.Fatalf("Serve returned %v, want net.ErrClosed", err)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("Serve did not return after the listener was closed")
+ }
+}
+
+func TestPipe_HalfCloseDeliversResponse(t *testing.T) {
+ backend, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("net.Listen backend: %v", err)
+ }
+ defer backend.Close()
+
+ go func() {
+ conn, err := backend.Accept()
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ io.Copy(io.Discard, conn)
+ time.Sleep(100 * time.Millisecond)
+ conn.Write([]byte("PONG"))
+ }()
+
+ front, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("net.Listen front: %v", err)
+ }
+ defer front.Close()
+
+ go func() {
+ conn, err := front.Accept()
+ if err != nil {
+ return
+ }
+ upstream, err := net.Dial("tcp", backend.Addr().String())
+ if err != nil {
+ conn.Close()
+ return
+ }
+ pipe(conn, upstream)
+ }()
+
+ client, err := net.Dial("tcp", front.Addr().String())
+ if err != nil {
+ t.Fatalf("net.Dial front: %v", err)
+ }
+ defer client.Close()
+
+ if _, err := client.Write([]byte("PING")); err != nil {
+ t.Fatalf("writing request: %v", err)
+ }
+ if err := client.(*net.TCPConn).CloseWrite(); err != nil {
+ t.Fatalf("CloseWrite: %v", err)
+ }
+
+ client.SetReadDeadline(time.Now().Add(5 * time.Second))
+ got, err := io.ReadAll(client)
+ if err != nil {
+ t.Fatalf("reading response: %v", err)
+ }
+ if string(got) != "PONG" {
+ t.Fatalf("response: got %q, want %q", got, "PONG")
}
- conn.Close()
}
| | | |