Skip to content

Commit e796273

Browse files
committed
forwarder: propagate source IP
Signed-off-by: Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp>
1 parent af3ea88 commit e796273

11 files changed

Lines changed: 534 additions & 12 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ require (
1111
github.com/dustin/go-humanize v1.0.1
1212
github.com/foxcpp/go-mockdns v1.2.0
1313
github.com/google/gopacket v1.1.19
14-
github.com/inetaf/tcpproxy v0.0.0-20250222171855-c4b9df066048
14+
github.com/inetaf/tcpproxy v0.0.0-20260515195445-c159a6051109
1515
github.com/insomniacslk/dhcp v0.0.0-20240710054256-ddd8a41251c9
1616
github.com/linuxkit/virtsock v0.0.0-20220523201153-1a23e78aa7a2
1717
github.com/mdlayher/vsock v1.3.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF
3939
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
4040
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
4141
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
42-
github.com/inetaf/tcpproxy v0.0.0-20250222171855-c4b9df066048 h1:jaqViOFFlZtkAwqvwZN+id37fosQqR5l3Oki9Dk4hz8=
43-
github.com/inetaf/tcpproxy v0.0.0-20250222171855-c4b9df066048/go.mod h1:Di7LXRyUcnvAcLicFhtM9/MlZl/TNgRSDHORM2c6CMI=
42+
github.com/inetaf/tcpproxy v0.0.0-20260515195445-c159a6051109 h1:H3ztMPCBcyG60Ks0plTncU3Vs6jIrxhn3nEThpMEpo4=
43+
github.com/inetaf/tcpproxy v0.0.0-20260515195445-c159a6051109/go.mod h1:Di7LXRyUcnvAcLicFhtM9/MlZl/TNgRSDHORM2c6CMI=
4444
github.com/insomniacslk/dhcp v0.0.0-20240710054256-ddd8a41251c9 h1:LZJWucZz7ztCqY6Jsu7N9g124iJ2kt/O62j3+UchZFg=
4545
github.com/insomniacslk/dhcp v0.0.0-20240710054256-ddd8a41251c9/go.mod h1:KclMyHxX06VrVr0DJmeFSUb1ankt7xTfoOA35pCkoic=
4646
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=

pkg/services/forwarder/ports.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,12 @@ func (f *PortsForwarder) Expose(protocol types.TransportProtocol, local, remote
213213
if err != nil {
214214
return err
215215
}
216-
p, err := NewUDPProxy(listener, func() (net.Conn, error) {
217-
return gonet.DialUDP(f.stack, nil, &address, ipv4.ProtocolNumber)
216+
p, err := NewUDPProxy(listener, func(from net.Addr) (net.Conn, error) {
217+
var bindAddr *tcpip.FullAddress
218+
if a, ok := from.(*net.UDPAddr); ok {
219+
bindAddr = sourceBindAddr(a.IP, a.Port)
220+
}
221+
return gonet.DialUDP(f.stack, bindAddr, &address, ipv4.ProtocolNumber)
218222
})
219223
if err != nil {
220224
return err
@@ -236,7 +240,13 @@ func (f *PortsForwarder) Expose(protocol types.TransportProtocol, local, remote
236240
p.AddRoute(local, &tcpproxy.DialProxy{
237241
Addr: remote,
238242
DialContext: func(ctx context.Context, _, _ string) (conn net.Conn, e error) {
239-
return gonet.DialContextTCP(ctx, f.stack, address, ipv4.ProtocolNumber)
243+
var bindAddr tcpip.FullAddress
244+
if a, ok := ctx.Value(tcpproxy.SourceAddrContextKey).(*net.TCPAddr); ok {
245+
if b := sourceBindAddr(a.IP, a.Port); b != nil {
246+
bindAddr = *b
247+
}
248+
}
249+
return gonet.DialTCPWithBind(ctx, f.stack, bindAddr, address, ipv4.ProtocolNumber)
240250
},
241251
})
242252
if err := p.Start(); err != nil {
@@ -362,6 +372,24 @@ func remote(req types.ExposeRequest, ip string) (string, error) {
362372
return req.Remote, nil
363373
}
364374

375+
// sourceBindAddr returns the gvisor-stack bind address used to propagate a
376+
// client's source IP to the guest, or nil when the source IP must not be
377+
// propagated (so the caller falls back to the default gateway source).
378+
//
379+
// Loopback and unspecified addresses are skipped: the guest sends its replies
380+
// back to the source IP via the gateway, and gvisor drops those replies as
381+
// martian packets when their destination is a loopback address on the
382+
// (non-loopback) tap NIC. This notably covers host-local forwards such as
383+
// 127.0.0.1:2222 -> 192.168.127.2:22.
384+
func sourceBindAddr(ip net.IP, port int) *tcpip.FullAddress {
385+
v4 := ip.To4()
386+
if v4 == nil || ip.IsLoopback() || ip.IsUnspecified() {
387+
return nil
388+
}
389+
//#nosec G115 -- port originates from a net.Addr and always fits in a uint16
390+
return &tcpip.FullAddress{NIC: 1, Addr: tcpip.AddrFrom4Slice(v4), Port: uint16(port)}
391+
}
392+
365393
// helper function for parsed URL query strings
366394
func firstValueOrEmpty(x []string) string {
367395
if len(x) > 0 {
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package forwarder
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net"
7+
"testing"
8+
"time"
9+
10+
"github.com/containers/gvisor-tap-vsock/pkg/types"
11+
"github.com/onsi/ginkgo/v2"
12+
"github.com/onsi/gomega"
13+
"gvisor.dev/gvisor/pkg/tcpip"
14+
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
15+
"gvisor.dev/gvisor/pkg/tcpip/header"
16+
"gvisor.dev/gvisor/pkg/tcpip/link/loopback"
17+
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
18+
"gvisor.dev/gvisor/pkg/tcpip/stack"
19+
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
20+
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
21+
)
22+
23+
func TestSuite(t *testing.T) {
24+
gomega.RegisterFailHandler(ginkgo.Fail)
25+
ginkgo.RunSpecs(t, "forwarder suite")
26+
}
27+
28+
func hostIP() net.IP {
29+
addrs, err := net.InterfaceAddrs()
30+
if err != nil {
31+
return nil
32+
}
33+
for _, addr := range addrs {
34+
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
35+
return ipnet.IP
36+
}
37+
}
38+
return nil
39+
}
40+
41+
var (
42+
gatewayIP = tcpip.AddrFrom4([4]byte{10, 0, 2, 1})
43+
childIP = tcpip.AddrFrom4([4]byte{10, 0, 2, 100})
44+
)
45+
46+
// newTestStack creates a gvisor stack with spoofing and promiscuous mode
47+
// enabled, matching the configuration used by virtualnetwork.New.
48+
func newTestStack() *stack.Stack {
49+
s := stack.New(stack.Options{
50+
NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol},
51+
TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol},
52+
})
53+
gomega.Expect(s.CreateNIC(1, loopback.New())).To(gomega.BeNil())
54+
for _, addr := range []tcpip.Address{gatewayIP, childIP} {
55+
gomega.Expect(s.AddProtocolAddress(1, tcpip.ProtocolAddress{
56+
Protocol: ipv4.ProtocolNumber,
57+
AddressWithPrefix: addr.WithPrefix(),
58+
}, stack.AddressProperties{})).To(gomega.BeNil())
59+
}
60+
s.SetSpoofing(1, true)
61+
s.SetPromiscuousMode(1, true)
62+
s.SetRouteTable([]tcpip.Route{{Destination: header.IPv4EmptySubnet, NIC: 1}})
63+
return s
64+
}
65+
66+
// freeHostAddr returns a free "hostIP:port" address for the given network.
67+
func freeHostAddr(network string, ip net.IP) string {
68+
switch network {
69+
case "tcp":
70+
ln, err := net.Listen("tcp", ip.String()+":0")
71+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
72+
addr := ln.Addr().String()
73+
ln.Close()
74+
return addr
75+
case "udp":
76+
conn, err := net.ListenPacket("udp", ip.String()+":0")
77+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
78+
addr := conn.LocalAddr().String()
79+
conn.Close()
80+
return addr
81+
default:
82+
panic("unsupported network: " + network)
83+
}
84+
}
85+
86+
var _ = ginkgo.Describe("source IP propagation", func() {
87+
ginkgo.It("should propagate routable IPv4 source addresses", func() {
88+
bindAddr := sourceBindAddr(net.ParseIP("10.0.2.50"), 1234)
89+
gomega.Expect(bindAddr).ToNot(gomega.BeNil())
90+
gomega.Expect(bindAddr.NIC).To(gomega.Equal(tcpip.NICID(1)))
91+
gomega.Expect(bindAddr.Addr).To(gomega.Equal(tcpip.AddrFrom4([4]byte{10, 0, 2, 50})))
92+
gomega.Expect(bindAddr.Port).To(gomega.Equal(uint16(1234)))
93+
})
94+
95+
// Loopback and unspecified sources must fall back to the default gateway
96+
// source: the guest replies to the source IP via the gateway, and gvisor
97+
// drops the reply as a martian packet when its destination is a loopback
98+
// address on the (non-loopback) tap NIC. This covers host-local forwards
99+
// such as 127.0.0.1:2222 -> 192.168.127.2:22.
100+
ginkgo.It("should not propagate loopback or unspecified source addresses", func() {
101+
gomega.Expect(sourceBindAddr(net.ParseIP("127.0.0.1"), 2222)).To(gomega.BeNil())
102+
gomega.Expect(sourceBindAddr(net.IPv4zero, 0)).To(gomega.BeNil())
103+
gomega.Expect(sourceBindAddr(net.ParseIP("::1"), 2222)).To(gomega.BeNil())
104+
gomega.Expect(sourceBindAddr(net.ParseIP("2001:db8::1"), 2222)).To(gomega.BeNil())
105+
})
106+
})
107+
108+
var _ = ginkgo.Describe("port forwarding", func() {
109+
ginkgo.It("should preserve the client source IP for TCP", func() {
110+
ip := hostIP()
111+
if ip == nil {
112+
ginkgo.Skip("no non-loopback IPv4 address found")
113+
}
114+
115+
s := newTestStack()
116+
117+
childLn, err := gonet.ListenTCP(s, tcpip.FullAddress{NIC: 1, Addr: childIP, Port: 8080}, ipv4.ProtocolNumber)
118+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
119+
defer childLn.Close()
120+
121+
sourceAddrCh := make(chan string, 1)
122+
go func() {
123+
conn, err := childLn.Accept()
124+
if err != nil {
125+
return
126+
}
127+
defer conn.Close()
128+
sourceAddrCh <- conn.RemoteAddr().String()
129+
_, _ = io.Copy(io.Discard, conn)
130+
}()
131+
132+
listenAddr := freeHostAddr("tcp", ip)
133+
fw := NewPortsForwarder(s)
134+
gomega.Expect(fw.Expose(types.TCP, listenAddr, "10.0.2.100:8080")).Should(gomega.Succeed())
135+
defer func() { _ = fw.Unexpose(types.TCP, listenAddr) }()
136+
137+
conn, err := net.Dial("tcp", listenAddr)
138+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
139+
clientIP := conn.LocalAddr().(*net.TCPAddr).IP.String()
140+
conn.Close()
141+
142+
var addr string
143+
gomega.Eventually(sourceAddrCh).Should(gomega.Receive(&addr))
144+
host, _, err := net.SplitHostPort(addr)
145+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
146+
gomega.Expect(host).To(gomega.Equal(clientIP),
147+
fmt.Sprintf("child saw %s, expected client IP %s (gateway is 10.0.2.1)", host, clientIP))
148+
})
149+
150+
ginkgo.It("should preserve the client source IP for UDP", func() {
151+
ip := hostIP()
152+
if ip == nil {
153+
ginkgo.Skip("no non-loopback IPv4 address found")
154+
}
155+
156+
s := newTestStack()
157+
158+
childAddr := tcpip.FullAddress{NIC: 1, Addr: childIP, Port: 8081}
159+
childConn, err := gonet.DialUDP(s, &childAddr, nil, ipv4.ProtocolNumber)
160+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
161+
defer childConn.Close()
162+
163+
sourceAddrCh := make(chan string, 1)
164+
go func() {
165+
buf := make([]byte, 1024)
166+
n, from, err := childConn.ReadFrom(buf)
167+
if err != nil {
168+
return
169+
}
170+
sourceAddrCh <- from.String()
171+
// Echo back
172+
_, _ = childConn.WriteTo(buf[:n], from)
173+
}()
174+
175+
listenAddr := freeHostAddr("udp", ip)
176+
fw := NewPortsForwarder(s)
177+
gomega.Expect(fw.Expose(types.UDP, listenAddr, "10.0.2.100:8081")).Should(gomega.Succeed())
178+
defer func() { _ = fw.Unexpose(types.UDP, listenAddr) }()
179+
180+
clientConn, err := net.Dial("udp", listenAddr)
181+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
182+
defer clientConn.Close()
183+
clientIP := clientConn.LocalAddr().(*net.UDPAddr).IP.String()
184+
185+
_, err = clientConn.Write([]byte("hello"))
186+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
187+
188+
// Read echo to ensure round-trip completes.
189+
gomega.Expect(clientConn.SetReadDeadline(time.Now().Add(5 * time.Second))).ShouldNot(gomega.HaveOccurred())
190+
buf := make([]byte, 1024)
191+
_, err = clientConn.Read(buf)
192+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
193+
194+
var addr string
195+
gomega.Eventually(sourceAddrCh).Should(gomega.Receive(&addr))
196+
host, _, err := net.SplitHostPort(addr)
197+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
198+
gomega.Expect(host).To(gomega.Equal(clientIP),
199+
fmt.Sprintf("child saw %s, expected client IP %s (gateway is 10.0.2.1)", host, clientIP))
200+
})
201+
})

pkg/services/forwarder/udp.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func UDP(s *stack.Stack, nat map[tcpip.Address]tcpip.Address, natLock *sync.Mute
4040
return false
4141
}
4242

43-
p, _ := NewUDPProxy(&autoStoppingListener{underlying: gonet.NewUDPConn(&wq, ep)}, func() (net.Conn, error) {
43+
p, _ := NewUDPProxy(&autoStoppingListener{underlying: gonet.NewUDPConn(&wq, ep)}, func(_ net.Addr) (net.Conn, error) {
4444
return net.Dial("udp", net.JoinHostPort(localAddress.String(), strconv.Itoa(int(r.ID().LocalPort))))
4545
})
4646
go func() {

pkg/services/forwarder/udp_proxy.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ type connTrackMap map[connTrackKey]net.Conn
5252
// addresses.
5353
type UDPProxy struct {
5454
listener udpConn
55-
dialer func() (net.Conn, error)
55+
dialer func(from net.Addr) (net.Conn, error)
5656
connTrackTable connTrackMap
5757
connTrackLock sync.Mutex
5858
}
5959

6060
// NewUDPProxy creates a new UDPProxy.
61-
func NewUDPProxy(listener udpConn, dialer func() (net.Conn, error)) (*UDPProxy, error) {
61+
func NewUDPProxy(listener udpConn, dialer func(from net.Addr) (net.Conn, error)) (*UDPProxy, error) {
6262
return &UDPProxy{
6363
listener: listener,
6464
connTrackTable: make(connTrackMap),
@@ -123,7 +123,7 @@ func (proxy *UDPProxy) Run() {
123123
proxy.connTrackLock.Lock()
124124
proxyConn, hit := proxy.connTrackTable[*fromKey]
125125
if !hit {
126-
proxyConn, err = proxy.dialer()
126+
proxyConn, err = proxy.dialer(from)
127127
if err != nil {
128128
log.Errorf("Can't proxy a datagram to udp: %s\n", err)
129129
proxy.connTrackLock.Unlock()

vendor/github.com/inetaf/tcpproxy/tcpproxy.go

Lines changed: 7 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)