wgengine/netstack: correctly proxy half-closed TCP connections

TCP connections are two unidirectional data streams, and if one of these
streams closes, we should not assume the other half is closed as well.
For example, if an HTTP client closes its write half of the connection
early, it may still be expecting to receive data on its read half, so we
should keep the server -> client half of the connection open, while
terminating the client -> server half.

Fixes tailscale/corp#29837.

Signed-off-by: Naman Sood <mail@nsood.in>
This commit is contained in:
Naman Sood 2025-07-07 15:36:16 -04:00 committed by GitHub
parent a84d58015c
commit 04d24cdbd4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -1435,6 +1435,13 @@ func (ns *Impl) acceptTCP(r *tcp.ForwarderRequest) {
}
}
// tcpCloser is an interface to abstract around various TCPConn types that
// allow closing of the read and write streams independently of each other.
type tcpCloser interface {
CloseRead() error
CloseWrite() error
}
func (ns *Impl) forwardTCP(getClient func(...tcpip.SettableSocketOption) *gonet.TCPConn, clientRemoteIP netip.Addr, wq *waiter.Queue, dialAddr netip.AddrPort) (handled bool) {
dialAddrStr := dialAddr.String()
if debugNetstack() {
@ -1501,18 +1508,48 @@ func (ns *Impl) forwardTCP(getClient func(...tcpip.SettableSocketOption) *gonet.
}
defer client.Close()
// As of 2025-07-03, backend is always either a net.TCPConn
// from stdDialer.DialContext (which has the requisite functions),
// or nil from hangDialer in tests (in which case we would have
// errored out by now), so this conversion should always succeed.
backendTCPCloser, backendIsTCPCloser := backend.(tcpCloser)
connClosed := make(chan error, 2)
go func() {
_, err := io.Copy(backend, client)
if err != nil {
err = fmt.Errorf("client -> backend: %w", err)
}
connClosed <- err
err = nil
if backendIsTCPCloser {
err = backendTCPCloser.CloseWrite()
}
err = errors.Join(err, client.CloseRead())
if err != nil {
ns.logf("client -> backend close connection: %v", err)
}
}()
go func() {
_, err := io.Copy(client, backend)
if err != nil {
err = fmt.Errorf("backend -> client: %w", err)
}
connClosed <- err
err = nil
if backendIsTCPCloser {
err = backendTCPCloser.CloseRead()
}
err = errors.Join(err, client.CloseWrite())
if err != nil {
ns.logf("backend -> client close connection: %v", err)
}
}()
err = <-connClosed
if err != nil {
ns.logf("proxy connection closed with error: %v", err)
// Wait for both ends of the connection to close.
for range 2 {
err = <-connClosed
if err != nil {
ns.logf("proxy connection closed with error: %v", err)
}
}
ns.logf("[v2] netstack: forwarder connection to %s closed", dialAddrStr)
return