Skip to content

Commit 1a61a32

Browse files
AkihiroSudaclaude
andcommitted
port/builtin: fix UDP forwarding for non-loopback clients (#592)
UDP port forwarding via the builtin driver was broken for non-loopback clients whenever --source-ip-transparent was enabled (the default). The server received the requests but responses never reached the client. The UDP IP_TRANSPARENT support added in v3.0 cannot work reliably: the in-netns server replies to the real (non-local) client address, and unlike TCP there is no per-flow accepted socket to carry the fwmark (no udp_fwmark_accept). The reply's route and source address are therefore selected at send time via the main table, so the reply egresses the default route (e.g. the slirp4netns TAP) and never reaches the transparent socket. Fall back to the non-transparent path for UDP, which forwards correctly for all clients (it does not preserve the client source IP). TCP source IP preservation is unchanged. - child: only take the IP_TRANSPARENT path for TCP; transparentDial is TCP-only again - msg/udp/udpproxy: drop the now-unused UDP source-address plumbing - testsuite: TestUDPTransparent now asserts the echo reply reaches a non-loopback client (and that the source IP is not preserved for UDP) - docs, --help: note that source IP propagation is TCP only Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp>
1 parent f742451 commit 1a61a32

8 files changed

Lines changed: 64 additions & 32 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ OPTIONS:
216216
Port:
217217
--port-driver value port driver for non-host network. [none, implicit (for pasta), builtin, slirp4netns, gvisor-tap-vsock(experimental)] (default: "none")
218218
--publish value, -p value [ --publish value, -p value ] publish ports. e.g. "127.0.0.1:8080:80/tcp"
219-
--source-ip-transparent preserve real client source IP using IP_TRANSPARENT (builtin port driver) (default: true)
219+
--source-ip-transparent preserve real client source IP using IP_TRANSPARENT (builtin port driver, TCP only) (default: true)
220220
221221
Process:
222222
--pidns create a PID namespace (default: false)

cmd/rootlesskit/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ See https://rootlesscontaine.rs/getting-started/common/ .
206206
}, CategoryPort),
207207
Categorize(&cli.BoolFlag{
208208
Name: "source-ip-transparent",
209-
Usage: "preserve real client source IP using IP_TRANSPARENT (builtin port driver)",
209+
Usage: "preserve real client source IP using IP_TRANSPARENT (builtin port driver, TCP only)",
210210
Value: true,
211211
}, CategoryPort),
212212
Categorize(&cli.BoolFlag{

docs/port.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ The default value is `none` (do not expose ports).
77
| `--port-driver` | Throughput | Source IP | Notes
88
|----------------------|-------------|----------|-------
99
| `slirp4netns` | 8.03 Gbps | Propagated |
10-
| `builtin` | 29.9 Gbps | Propagated (since v3.0) | In the case of Rootless Docker, userland-proxy has to be disabled for propagating the source IP.
10+
| `builtin` | 29.9 Gbps | Propagated for TCP (since v3.0) | Source IP propagation (`--source-ip-transparent`) applies to TCP only; UDP is not propagated. In the case of Rootless Docker, userland-proxy has to be disabled for propagating the source IP.
1111
| `implicit` | 37.6 Gbps | Propagated | Requires `pasta` network
1212
| `gvisor-tap-vsock` (Experimental) | 3.83 Gbps | Not propagated | Throughput is currently limited; see issue link below for improvement ideas.
1313

pkg/port/builtin/child/child.go

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,18 @@ func (d *childDriver) handleConnectRequest(c *net.UnixConn, req *msg.Request) er
147147

148148
var targetConn net.Conn
149149
var err error
150-
if d.sourceIPTransparent && req.SourceIP != "" && req.SourcePort != 0 && (dialProto == "tcp" || dialProto == "udp") && !net.ParseIP(req.SourceIP).IsLoopback() {
150+
// IP_TRANSPARENT source IP preservation is only supported for TCP.
151+
//
152+
// For UDP it cannot be made to work reliably: the in-netns server replies to
153+
// the real (non-local) client address, and unlike TCP there is no per-flow
154+
// accepted socket to carry the fwmark (no udp_fwmark_accept), so the reply's
155+
// route and source address are selected at send time via the main table. The
156+
// reply is therefore sent out the default route (e.g. the slirp4netns TAP)
157+
// and never reaches the transparent socket, breaking UDP forwarding entirely
158+
// for non-loopback clients (rootless-containers/rootlesskit#592). UDP falls
159+
// back to the non-transparent path below, which works for all clients but
160+
// does not preserve the client source IP.
161+
if d.sourceIPTransparent && req.SourceIP != "" && req.SourcePort != 0 && dialProto == "tcp" && !net.ParseIP(req.SourceIP).IsLoopback() {
151162
d.routingSetup.Do(func() { d.routingReady = d.setupTransparentRouting() })
152163
if !d.routingReady {
153164
d.routingWarn.Do(func() {
@@ -250,17 +261,11 @@ func (d *childDriver) setupTransparentRouting() bool {
250261

251262
// transparentDial dials targetAddr using IP_TRANSPARENT, binding to the given
252263
// source IP and port so the backend service sees the real client address.
264+
// Only TCP is supported; see the comment in handleConnectRequest.
253265
func transparentDial(dialProto, targetAddr, sourceIP string, sourcePort int) (net.Conn, error) {
254-
var localAddr net.Addr
255-
switch dialProto {
256-
case "tcp":
257-
localAddr = &net.TCPAddr{IP: net.ParseIP(sourceIP), Port: sourcePort}
258-
case "udp":
259-
localAddr = &net.UDPAddr{IP: net.ParseIP(sourceIP), Port: sourcePort}
260-
}
261266
dialer := net.Dialer{
262267
Timeout: time.Second,
263-
LocalAddr: localAddr,
268+
LocalAddr: &net.TCPAddr{IP: net.ParseIP(sourceIP), Port: sourcePort},
264269
Control: func(network, address string, c syscall.RawConn) error {
265270
var sockErr error
266271
if err := c.Control(func(fd uintptr) {

pkg/port/builtin/msg/msg.go

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,17 +82,10 @@ func ConnectToChild(c *net.UnixConn, spec port.Spec, sourceAddr net.Addr) (int,
8282
ParentIP: spec.ParentIP,
8383
HostGatewayIP: hostGatewayIP(),
8484
}
85-
switch a := sourceAddr.(type) {
86-
case *net.TCPAddr:
87-
if a != nil {
88-
req.SourceIP = a.IP.String()
89-
req.SourcePort = a.Port
90-
}
91-
case *net.UDPAddr:
92-
if a != nil {
93-
req.SourceIP = a.IP.String()
94-
req.SourcePort = a.Port
95-
}
85+
// Source IP preservation (IP_TRANSPARENT) is only supported for TCP.
86+
if tcpAddr, ok := sourceAddr.(*net.TCPAddr); ok && tcpAddr != nil {
87+
req.SourceIP = tcpAddr.IP.String()
88+
req.SourcePort = tcpAddr.Port
9689
}
9790
if _, err := lowlevelmsgutil.MarshalToWriter(c, &req); err != nil {
9891
return 0, err

pkg/port/builtin/parent/udp/udp.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ func Run(socketPath string, spec port.Spec, stopCh <-chan struct{}, stoppedCh ch
2424
udpp := &udpproxy.UDPProxy{
2525
LogWriter: logWriter,
2626
Listener: c,
27-
BackendDial: func(from *net.UDPAddr) (*net.UDPConn, error) {
27+
BackendDial: func() (*net.UDPConn, error) {
2828
// get fd from the child as an SCM_RIGHTS cmsg
29-
fd, err := msg.ConnectToChildWithRetry(socketPath, spec, 10, from)
29+
fd, err := msg.ConnectToChildWithRetry(socketPath, spec, 10, nil)
3030
if err != nil {
3131
return nil, err
3232
}

pkg/port/builtin/parent/udp/udpproxy/udp_proxy.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ type connTrackMap map[connTrackKey]*net.UDPConn
4949
type UDPProxy struct {
5050
LogWriter io.Writer
5151
Listener *net.UDPConn
52-
BackendDial func(from *net.UDPAddr) (*net.UDPConn, error)
52+
BackendDial func() (*net.UDPConn, error)
5353
connTrackTable connTrackMap
5454
connTrackLock sync.Mutex
5555
}
@@ -108,7 +108,7 @@ func (proxy *UDPProxy) Run() {
108108
proxy.connTrackLock.Lock()
109109
proxyConn, hit := proxy.connTrackTable[*fromKey]
110110
if !hit {
111-
proxyConn, err = proxy.BackendDial(from)
111+
proxyConn, err = proxy.BackendDial()
112112
if err != nil {
113113
fmt.Fprintf(proxy.LogWriter, "Can't proxy a datagram to udp: %v\n", err)
114114
proxy.connTrackLock.Unlock()

pkg/port/testsuite/testsuite.go

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -512,11 +512,25 @@ func transparentUDPDialAndSend(t *testing.T, parentAddr string) string {
512512
if err != nil {
513513
t.Fatal(err)
514514
}
515+
defer conn.Close()
515516
clientAddr := conn.LocalAddr().String()
516517
if _, err := conn.Write([]byte("hello")); err != nil {
517518
t.Fatal(err)
518519
}
519-
conn.Close()
520+
// Verify the return path: the echoed reply must reach the client. This is
521+
// the regression assertion for rootless-containers/rootlesskit#592, where
522+
// UDP responses were lost for non-loopback clients.
523+
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
524+
t.Fatal(err)
525+
}
526+
buf := make([]byte, 64)
527+
n, err := conn.Read(buf)
528+
if err != nil {
529+
t.Fatalf("did not receive UDP echo reply (issue #592 return-path regression): %v", err)
530+
}
531+
if got := string(buf[:n]); got != "hello" {
532+
t.Fatalf("unexpected UDP echo reply: %q", got)
533+
}
520534
return clientAddr
521535
}
522536

@@ -652,8 +666,6 @@ func testTransparentWithPID(t *testing.T, proto string, d port.ParentDriver, chi
652666

653667
echoCmd.Wait()
654668

655-
// Parse and verify: the echo server should see the client's non-loopback IP,
656-
// not 127.0.0.1 or a hard-coded router address.
657669
clientHost, _, err := net.SplitHostPort(clientAddr)
658670
if err != nil {
659671
t.Fatalf("failed to parse client address %q: %v", clientAddr, err)
@@ -663,8 +675,23 @@ func testTransparentWithPID(t *testing.T, proto string, d port.ParentDriver, chi
663675
t.Fatalf("failed to parse server-seen address %q: %v", serverSawAddr, err)
664676
}
665677

666-
if clientHost != serverHost {
667-
t.Errorf("IP mismatch: client=%s, server saw=%s", clientHost, serverHost)
678+
switch proto {
679+
case "tcp":
680+
// TCP preserves the real client source IP via IP_TRANSPARENT: the echo
681+
// server must see the client's non-loopback IP, not 127.0.0.1 or a
682+
// hard-coded router address.
683+
if clientHost != serverHost {
684+
t.Errorf("IP mismatch: client=%s, server saw=%s", clientHost, serverHost)
685+
}
686+
case "udp":
687+
// UDP does not preserve the source IP: it falls back to the
688+
// non-transparent path (see rootless-containers/rootlesskit#592 and the
689+
// comment in pkg/port/builtin/child). The server therefore sees a
690+
// loopback source, and the reply still reaches the client (verified by
691+
// transparentUDPDialAndSend reading the echo above).
692+
if clientHost == serverHost {
693+
t.Errorf("expected UDP source IP not to be preserved, but server saw client IP %s", serverHost)
694+
}
668695
}
669696

670697
// Cleanup
@@ -707,6 +734,13 @@ func runUDPEchoServer() {
707734
conn.WriteToUDP(buf[:n], from)
708735
}
709736

737+
// RunUDPTransparent exercises the source-ip-transparent code path for UDP. UDP
738+
// does not actually support IP_TRANSPARENT (it falls back to the non-transparent
739+
// path), so this is also the regression test for
740+
// rootless-containers/rootlesskit#592: the client connects from a non-loopback
741+
// address (which previously triggered the broken path) and the test asserts that
742+
// the echo reply is delivered back to the client. Source IP preservation is
743+
// intentionally not expected for UDP.
710744
func RunUDPTransparent(t *testing.T, pf func() port.ParentDriver) {
711745
t.Run("TestUDPTransparent", func(t *testing.T) { TestUDPTransparent(t, pf()) })
712746
}

0 commit comments

Comments
 (0)