Skip to content

Commit 4d2e3f4

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

11 files changed

Lines changed: 494 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.2.1

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-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
4141
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/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: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,13 @@ 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 local *tcpip.FullAddress
218+
if a, ok := from.(*net.UDPAddr); ok && a.IP.To4() != nil {
219+
//#nosec: G115
220+
local = &tcpip.FullAddress{NIC: 1, Addr: tcpip.AddrFrom4Slice(a.IP.To4()), Port: uint16(a.Port)}
221+
}
222+
return gonet.DialUDP(f.stack, local, &address, ipv4.ProtocolNumber)
218223
})
219224
if err != nil {
220225
return err
@@ -236,7 +241,12 @@ func (f *PortsForwarder) Expose(protocol types.TransportProtocol, local, remote
236241
p.AddRoute(local, &tcpproxy.DialProxy{
237242
Addr: remote,
238243
DialContext: func(ctx context.Context, _, _ string) (conn net.Conn, e error) {
239-
return gonet.DialContextTCP(ctx, f.stack, address, ipv4.ProtocolNumber)
244+
var local tcpip.FullAddress
245+
if a, ok := ctx.Value(tcpproxy.SourceAddrContextKey).(*net.TCPAddr); ok && a.IP.To4() != nil {
246+
//#nosec: G115
247+
local = tcpip.FullAddress{NIC: 1, Addr: tcpip.AddrFrom4Slice(a.IP.To4()), Port: uint16(a.Port)}
248+
}
249+
return gonet.DialTCPWithBind(ctx, f.stack, local, address, ipv4.ProtocolNumber)
240250
},
241251
})
242252
if err := p.Start(); err != nil {
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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("port forwarding", func() {
87+
ginkgo.It("should preserve the client source IP for TCP", func() {
88+
ip := hostIP()
89+
if ip == nil {
90+
ginkgo.Skip("no non-loopback IPv4 address found")
91+
}
92+
93+
s := newTestStack()
94+
95+
childLn, err := gonet.ListenTCP(s, tcpip.FullAddress{NIC: 1, Addr: childIP, Port: 8080}, ipv4.ProtocolNumber)
96+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
97+
defer childLn.Close()
98+
99+
sourceAddrCh := make(chan string, 1)
100+
go func() {
101+
conn, err := childLn.Accept()
102+
if err != nil {
103+
return
104+
}
105+
defer conn.Close()
106+
sourceAddrCh <- conn.RemoteAddr().String()
107+
_, _ = io.Copy(io.Discard, conn)
108+
}()
109+
110+
listenAddr := freeHostAddr("tcp", ip)
111+
fw := NewPortsForwarder(s)
112+
gomega.Expect(fw.Expose(types.TCP, listenAddr, "10.0.2.100:8080")).Should(gomega.Succeed())
113+
defer func() { _ = fw.Unexpose(types.TCP, listenAddr) }()
114+
115+
conn, err := net.Dial("tcp", listenAddr)
116+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
117+
clientIP := conn.LocalAddr().(*net.TCPAddr).IP.String()
118+
conn.Close()
119+
120+
var addr string
121+
gomega.Eventually(sourceAddrCh).Should(gomega.Receive(&addr))
122+
host, _, err := net.SplitHostPort(addr)
123+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
124+
gomega.Expect(host).To(gomega.Equal(clientIP),
125+
fmt.Sprintf("child saw %s, expected client IP %s (gateway is 10.0.2.1)", host, clientIP))
126+
})
127+
128+
ginkgo.It("should preserve the client source IP for UDP", func() {
129+
ip := hostIP()
130+
if ip == nil {
131+
ginkgo.Skip("no non-loopback IPv4 address found")
132+
}
133+
134+
s := newTestStack()
135+
136+
childAddr := tcpip.FullAddress{NIC: 1, Addr: childIP, Port: 8081}
137+
childConn, err := gonet.DialUDP(s, &childAddr, nil, ipv4.ProtocolNumber)
138+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
139+
defer childConn.Close()
140+
141+
sourceAddrCh := make(chan string, 1)
142+
go func() {
143+
buf := make([]byte, 1024)
144+
n, from, err := childConn.ReadFrom(buf)
145+
if err != nil {
146+
return
147+
}
148+
sourceAddrCh <- from.String()
149+
// Echo back
150+
_, _ = childConn.WriteTo(buf[:n], from)
151+
}()
152+
153+
listenAddr := freeHostAddr("udp", ip)
154+
fw := NewPortsForwarder(s)
155+
gomega.Expect(fw.Expose(types.UDP, listenAddr, "10.0.2.100:8081")).Should(gomega.Succeed())
156+
defer func() { _ = fw.Unexpose(types.UDP, listenAddr) }()
157+
158+
clientConn, err := net.Dial("udp", listenAddr)
159+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
160+
defer clientConn.Close()
161+
clientIP := clientConn.LocalAddr().(*net.UDPAddr).IP.String()
162+
163+
_, err = clientConn.Write([]byte("hello"))
164+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
165+
166+
// Read echo to ensure round-trip completes.
167+
gomega.Expect(clientConn.SetReadDeadline(time.Now().Add(5 * time.Second))).ShouldNot(gomega.HaveOccurred())
168+
buf := make([]byte, 1024)
169+
_, err = clientConn.Read(buf)
170+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
171+
172+
var addr string
173+
gomega.Eventually(sourceAddrCh).Should(gomega.Receive(&addr))
174+
host, _, err := net.SplitHostPort(addr)
175+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
176+
gomega.Expect(host).To(gomega.Equal(clientIP),
177+
fmt.Sprintf("child saw %s, expected client IP %s (gateway is 10.0.2.1)", host, clientIP))
178+
})
179+
})

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.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package loopback
2+
3+
import (
4+
"reflect"
5+
6+
"gvisor.dev/gvisor/pkg/sync"
7+
"gvisor.dev/gvisor/pkg/sync/locking"
8+
)
9+
10+
// RWMutex is sync.RWMutex with the correctness validator.
11+
type endpointRWMutex struct {
12+
mu sync.RWMutex
13+
}
14+
15+
// lockNames is a list of user-friendly lock names.
16+
// Populated in init.
17+
var endpointlockNames []string
18+
19+
// lockNameIndex is used as an index passed to NestedLock and NestedUnlock,
20+
// referring to an index within lockNames.
21+
// Values are specified using the "consts" field of go_template_instance.
22+
type endpointlockNameIndex int
23+
24+
// DO NOT REMOVE: The following function automatically replaced with lock index constants.
25+
// LOCK_NAME_INDEX_CONSTANTS
26+
const ()
27+
28+
// Lock locks m.
29+
// +checklocksignore
30+
func (m *endpointRWMutex) Lock() {
31+
locking.AddGLock(endpointprefixIndex, -1)
32+
m.mu.Lock()
33+
}
34+
35+
// NestedLock locks m knowing that another lock of the same type is held.
36+
// +checklocksignore
37+
func (m *endpointRWMutex) NestedLock(i endpointlockNameIndex) {
38+
locking.AddGLock(endpointprefixIndex, int(i))
39+
m.mu.Lock()
40+
}
41+
42+
// Unlock unlocks m.
43+
// +checklocksignore
44+
func (m *endpointRWMutex) Unlock() {
45+
m.mu.Unlock()
46+
locking.DelGLock(endpointprefixIndex, -1)
47+
}
48+
49+
// NestedUnlock unlocks m knowing that another lock of the same type is held.
50+
// +checklocksignore
51+
func (m *endpointRWMutex) NestedUnlock(i endpointlockNameIndex) {
52+
m.mu.Unlock()
53+
locking.DelGLock(endpointprefixIndex, int(i))
54+
}
55+
56+
// RLock locks m for reading.
57+
// +checklocksignore
58+
func (m *endpointRWMutex) RLock() {
59+
locking.AddGLock(endpointprefixIndex, -1)
60+
m.mu.RLock()
61+
}
62+
63+
// RUnlock undoes a single RLock call.
64+
// +checklocksignore
65+
func (m *endpointRWMutex) RUnlock() {
66+
m.mu.RUnlock()
67+
locking.DelGLock(endpointprefixIndex, -1)
68+
}
69+
70+
// RLockBypass locks m for reading without executing the validator.
71+
// +checklocksignore
72+
func (m *endpointRWMutex) RLockBypass() {
73+
m.mu.RLock()
74+
}
75+
76+
// RUnlockBypass undoes a single RLockBypass call.
77+
// +checklocksignore
78+
func (m *endpointRWMutex) RUnlockBypass() {
79+
m.mu.RUnlock()
80+
}
81+
82+
// DowngradeLock atomically unlocks rw for writing and locks it for reading.
83+
// +checklocksignore
84+
func (m *endpointRWMutex) DowngradeLock() {
85+
m.mu.DowngradeLock()
86+
}
87+
88+
var endpointprefixIndex *locking.MutexClass
89+
90+
// DO NOT REMOVE: The following function is automatically replaced.
91+
func endpointinitLockNames() {}
92+
93+
func init() {
94+
endpointinitLockNames()
95+
endpointprefixIndex = locking.NewMutexClass(reflect.TypeFor[endpointRWMutex](), endpointlockNames)
96+
}

0 commit comments

Comments
 (0)