diff --git a/net/proxymux/mux.go b/net/proxymux/mux.go index d9c57cd76..48dae25e0 100644 --- a/net/proxymux/mux.go +++ b/net/proxymux/mux.go @@ -13,6 +13,8 @@ "net" "sync" "time" + + "tailscale.com/types/nettype" ) // SplitSOCKSAndHTTP accepts connections on ln and passes connections @@ -127,11 +129,15 @@ func (ln *listener) Addr() net.Addr { type connWithOneByte struct { net.Conn - b byte - bRead bool + b byte + bRead bool + readClosed bool } func (c *connWithOneByte) Read(bs []byte) (int, error) { + if c.readClosed { + return 0, net.ErrClosed + } if c.bRead { return c.Conn.Read(bs) } @@ -142,3 +148,22 @@ func (c *connWithOneByte) Read(bs []byte) (int, error) { bs[0] = c.b return 1, nil } + +// CloseRead implements [nettype.HalfCloser], allowing the underlying Conn +// to be half-closed if possible. Otherwise, this is a no-op. +func (c *connWithOneByte) CloseRead() error { + if hc, ok := c.Conn.(nettype.HalfCloser); ok { + c.readClosed = true + return hc.CloseRead() + } + return nil +} + +// CloseWrite implements [nettype.HalfCloser], allowing the underlying Conn +// to be half-closed if possible. Otherwise, this is a no-op. +func (c *connWithOneByte) CloseWrite() error { + if hc, ok := c.Conn.(nettype.HalfCloser); ok { + return hc.CloseWrite() + } + return nil +} diff --git a/net/socks5/socks5.go b/net/socks5/socks5.go index f8d3a91b3..4fed50e9c 100644 --- a/net/socks5/socks5.go +++ b/net/socks5/socks5.go @@ -28,6 +28,7 @@ "tailscale.com/syncs" "tailscale.com/types/logger" + "tailscale.com/types/nettype" ) // Authentication METHODs described in RFC 1928, section 3. @@ -239,6 +240,16 @@ func (c *Conn) handleTCP() error { } defer srv.Close() + // As of 2026-09-16, `srv.dial` always returns either a TCPConn-type + // connection, or such a connection wrapped by a [tsdial.sysConn], + // which passes down calls to half-close the connection to its + // underlying Conn. + srvHalfCloser, srvIsHalfCloser := srv.(nettype.HalfCloser) + // As of 2026-09-16, `c.clientConn` always originates from a TCP listener, + // sometimes split up by [proxymux.SplitSOCKSAndHTTP], which passes down + // calls to half-close the connection to its underlying Conn. + clientHalfCloser, clientIsHalfCloser := c.clientConn.(nettype.HalfCloser) + localAddr := srv.LocalAddr().String() serverAddr, serverPort, err := splitHostPort(localAddr) if err != nil { @@ -266,6 +277,12 @@ func (c *Conn) handleTCP() error { if err != nil { err = fmt.Errorf("from backend to client: %w", err) } + if clientIsHalfCloser { + err = errors.Join(err, clientHalfCloser.CloseWrite()) + } + if srvIsHalfCloser { + err = errors.Join(srvHalfCloser.CloseRead()) + } errc <- err }() go func() { @@ -273,9 +290,20 @@ func (c *Conn) handleTCP() error { if err != nil { err = fmt.Errorf("from client to backend: %w", err) } + if clientIsHalfCloser { + err = errors.Join(err, clientHalfCloser.CloseRead()) + } + if srvIsHalfCloser { + err = errors.Join(srvHalfCloser.CloseWrite()) + } errc <- err }() - return <-errc + // Wait for both sides of the connection to close. + var errs []error + for range 2 { + errs = append(errs, <-errc) + } + return errors.Join(errs...) } func (c *Conn) handleUDP() error { diff --git a/net/socks5/socks5_test.go b/net/socks5/socks5_test.go index fb20d36a6..8672006f6 100644 --- a/net/socks5/socks5_test.go +++ b/net/socks5/socks5_test.go @@ -484,3 +484,79 @@ func TestUDPLogNilLogf(t *testing.T) { time.Sleep(10 * time.Millisecond) } } + +func TestTCPHalfClose(t *testing.T) { + const msg = "we are so winning" + + // backend server which we'll use SOCKS5 to connect to + listener, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatal(err) + } + backendServerPort := listener.Addr().(*net.TCPAddr).Port + go func() { + c, err := listener.Accept() + if err != nil { + t.Errorf("backend accept conn: %v", err) + } + defer c.Close() + defer listener.Close() + tcpConn := c.(*net.TCPConn) + var buf [1500]byte + n, err := tcpConn.Read(buf[:]) + if err != nil { + t.Errorf("backend read: %v", err) + } + res := string(buf[:n]) + if res != msg { + t.Errorf("backend read: want %q, got %q", msg, res) + } + if err := tcpConn.CloseRead(); err != nil { + t.Errorf("backend closeread: %v", err) + } + _, err = tcpConn.Write([]byte(msg)) + if err != nil { + t.Errorf("backend write: %v", err) + } + }() + + // SOCKS5 server + socks5, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatal(err) + } + socks5Port := socks5.Addr().(*net.TCPAddr).Port + go socks5Server(socks5) + + // Client + addr := fmt.Sprintf("localhost:%d", socks5Port) + socksDialer, err := proxy.SOCKS5("tcp", addr, nil, proxy.Direct) + if err != nil { + t.Fatal(err) + } + + addr = fmt.Sprintf("localhost:%d", backendServerPort) + conn, err := socksDialer.Dial("tcp", addr) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + tcpConn := conn.(*net.TCPConn) + _, err = tcpConn.Write([]byte(msg)) + if err != nil { + t.Errorf("client write: %v", err) + } + if err := tcpConn.CloseWrite(); err != nil { + t.Errorf("client closewrite: %v", err) + } + var buf [1500]byte + n, err := tcpConn.Read(buf[:]) + if err != nil { + t.Errorf("client read: %v", err) + } + res := string(buf[:n]) + if res != msg { + t.Errorf("client read: want %q, got %q", msg, res) + } +} diff --git a/net/tsdial/tsdial.go b/net/tsdial/tsdial.go index 5c5fa2bd0..8c07d11a2 100644 --- a/net/tsdial/tsdial.go +++ b/net/tsdial/tsdial.go @@ -32,6 +32,7 @@ "tailscale.com/net/tsaddr" "tailscale.com/syncs" "tailscale.com/types/logger" + "tailscale.com/types/nettype" "tailscale.com/util/clientmetric" "tailscale.com/util/eventbus" "tailscale.com/util/mak" @@ -125,6 +126,24 @@ func (c sysConn) Close() error { return nil } +// CloseRead implements [nettype.HalfCloser], allowing the underlying Conn +// to be half-closed if possible. Otherwise, this is a no-op. +func (c sysConn) CloseRead() error { + if hc, ok := c.Conn.(nettype.HalfCloser); ok { + return hc.CloseRead() + } + return nil +} + +// CloseWrite implements [nettype.HalfCloser], allowing the underlying Conn +// to be half-closed if possible. Otherwise, this is a no-op. +func (c sysConn) CloseWrite() error { + if hc, ok := c.Conn.(nettype.HalfCloser); ok { + return hc.CloseWrite() + } + return nil +} + // SetTUNName sets the name of the tun device in use ("tailscale0", "utun6", // etc). This is needed on some platforms to set sockopts to bind // to the same interface index.