Skip to content

Commit 043db3b

Browse files
committed
Bind the same port for multiple addresses
Without this change, if a port mapping did not specify a host address and the network was IPv6-enabled, the same port would be allocated for mappings from '0.0.0.0' and '::'. But, if the port mapping was specified with explicit addresses even, for example: -p 0.0.0.0:8080-8083:80 -p '[::]:8083-8080:80' This change looks for port mappings that only differ in the host IP address, and makes sure it allocates the same port for all of them. If it can't, it fails with an error. Signed-off-by: Rob Murray <[email protected]>
1 parent 20c99e4 commit 043db3b

2 files changed

Lines changed: 191 additions & 27 deletions

File tree

libnetwork/drivers/bridge/port_mapping_linux.go

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"errors"
66
"fmt"
77
"net"
8+
"net/netip"
9+
"slices"
810
"strconv"
911

1012
"github.com/containerd/log"
@@ -64,10 +66,23 @@ func (n *bridgeNetwork) addPortMappings(
6466
}
6567
}()
6668

69+
sortedCfg := slices.Clone(cfg)
70+
sortAndNormPBs(sortedCfg)
71+
6772
proxyPath := n.userlandProxyPath()
6873
disableNAT4, disableNAT6 := n.getNATDisabled()
69-
for _, c := range cfg {
70-
toBind := make([]portBindingReq, 0, 2)
74+
75+
// toBind accumulates port bindings that should be allocated the same host port
76+
// (if required by NAT config). If the host address is unspecified, and defHostIP
77+
// is 0.0.0.0, one iteration of the loop may generate bindings for v4 and v6. If
78+
// a host address is specified, it'll either be IPv4 or IPv6, and only one
79+
// binding will be added per iteration. Config for bindings that only differ in
80+
// host IP are sorted next to each other, the loop continues until toBind has
81+
// collected them all, for both v4 and v6. The addresses may be 0.0.0.0 and [::],
82+
// or multiple addresses of both address families. Once there are no more
83+
// bindings to collect, they're applied and toBind is reset.
84+
var toBind []portBindingReq
85+
for i, c := range sortedCfg {
7186
if bindingIPv4, ok := configurePortBindingIPv4(disableNAT4, c, containerIPv4, defHostIP); ok {
7287
toBind = append(toBind, bindingIPv4)
7388
}
@@ -88,11 +103,22 @@ func (n *bridgeNetwork) addPortMappings(
88103
toBind = append(toBind, bindingIPv6)
89104
}
90105

106+
if i < len(sortedCfg)-1 && needSamePort(c, sortedCfg[i+1]) {
107+
// This port binding matches the next, apart from host IP. So, continue
108+
// collecting bindings, then allocate the same host port for all addresses.
109+
continue
110+
}
111+
112+
// Allocate a host port, and reserve it by starting docker-proxy for each host
113+
// address in toBind.
91114
newB, err := bindHostPorts(toBind, proxyPath)
92115
if err != nil {
93116
return nil, err
94117
}
95118
bindings = append(bindings, newB...)
119+
120+
// Reset the collection of bindings now they're bound.
121+
toBind = toBind[:0]
96122
}
97123

98124
for _, b := range bindings {
@@ -104,6 +130,70 @@ func (n *bridgeNetwork) addPortMappings(
104130
return bindings, nil
105131
}
106132

133+
// sortAndNormPBs normalises cfg by making HostPortEnd=HostPort (rather than 0) if the
134+
// host port isn't a range - and sorts it into the ordering defined by cmpPortBinding.
135+
func sortAndNormPBs(cfg []types.PortBinding) {
136+
for i := range cfg {
137+
if cfg[i].HostPortEnd == 0 {
138+
cfg[i].HostPortEnd = cfg[i].HostPort
139+
}
140+
}
141+
slices.SortFunc(cfg, cmpPortBinding)
142+
}
143+
144+
// cmpPortBinding defines an ordering over PortBinding such that bindings that differ
145+
// only in host IP are adjacent (those bindings should be allocated the same port).
146+
//
147+
// Exact host ports are placed before ranges (in case exact ports fall within ranges,
148+
// giving a better chance of allocating the exact ports), then PortBindings with the:
149+
// - same container port are adjacent (lowest ports first), then
150+
// - same protocols are adjacent (tcp < udp < sctp), then
151+
// - same host ports or ranges are adjacent, then
152+
// - ordered by container IP (then host IP, if set).
153+
func cmpPortBinding(a, b types.PortBinding) int {
154+
// Exact host port < host port range.
155+
aIsRange := a.HostPort == 0 || a.HostPort != a.HostPortEnd
156+
bIsRange := b.HostPort == 0 || b.HostPort != b.HostPortEnd
157+
if aIsRange != bIsRange {
158+
if aIsRange {
159+
return 1
160+
}
161+
return -1
162+
}
163+
if a.Port != b.Port {
164+
return int(a.Port) - int(b.Port)
165+
}
166+
if a.Proto != b.Proto {
167+
return int(a.Proto) - int(b.Proto)
168+
}
169+
if a.HostPort != b.HostPort {
170+
return int(a.HostPort) - int(b.HostPort)
171+
}
172+
if a.HostPortEnd != b.HostPortEnd {
173+
return int(a.HostPortEnd) - int(b.HostPortEnd)
174+
}
175+
aHostIP, _ := netip.AddrFromSlice(a.HostIP)
176+
bHostIP, _ := netip.AddrFromSlice(b.HostIP)
177+
if c := aHostIP.Unmap().Compare(bHostIP.Unmap()); c != 0 {
178+
return c
179+
}
180+
aIP, _ := netip.AddrFromSlice(a.IP)
181+
bIP, _ := netip.AddrFromSlice(b.IP)
182+
return aIP.Unmap().Compare(bIP.Unmap())
183+
}
184+
185+
// needSamePort returns true iff a and b only differ in the host IP address,
186+
// meaning they should be allocated the same host port (so that, if v4/v6
187+
// addresses are returned in a DNS response or similar, clients can bind without
188+
// needing to adjust the port number depending on which address is used).
189+
func needSamePort(a, b types.PortBinding) bool {
190+
return a.Port == b.Port &&
191+
a.Proto == b.Proto &&
192+
a.HostPort == b.HostPort &&
193+
a.HostPortEnd == b.HostPortEnd &&
194+
a.IP.Equal(b.IP)
195+
}
196+
107197
// configurePortBindingIPv4 returns a new port binding with the HostIP field populated
108198
// if a binding is required, else nil.
109199
func configurePortBindingIPv4(disableNAT bool, bnd types.PortBinding, containerIPv4, defHostIP net.IP) (portBindingReq, bool) {
@@ -125,10 +215,6 @@ func configurePortBindingIPv4(disableNAT bool, bnd types.PortBinding, containerI
125215
// Unmap the addresses if they're IPv4-mapped IPv6.
126216
bnd.HostIP = bnd.HostIP.To4()
127217
bnd.IP = containerIPv4.To4()
128-
// Adjust HostPortEnd if this is not a range.
129-
if bnd.HostPortEnd == 0 {
130-
bnd.HostPortEnd = bnd.HostPort
131-
}
132218
return portBindingReq{
133219
PortBinding: bnd,
134220
disableNAT: disableNAT,
@@ -164,10 +250,6 @@ func configurePortBindingIPv6(disableNAT bool, bnd types.PortBinding, containerI
164250
}
165251
}
166252
bnd.IP = containerIP
167-
// Adjust HostPortEnd if this is not a range.
168-
if bnd.HostPortEnd == 0 {
169-
bnd.HostPortEnd = bnd.HostPort
170-
}
171253
return portBindingReq{
172254
PortBinding: bnd,
173255
disableNAT: disableNAT,

libnetwork/drivers/bridge/port_mapping_linux_test.go

Lines changed: 99 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ func TestPortMappingConfig(t *testing.T) {
3131
t.Fatalf("Failed to setup driver config: %v", err)
3232
}
3333

34-
binding1 := types.PortBinding{Proto: types.UDP, Port: uint16(400), HostPort: uint16(54000)}
35-
binding2 := types.PortBinding{Proto: types.TCP, Port: uint16(500), HostPort: uint16(65000)}
36-
binding3 := types.PortBinding{Proto: types.SCTP, Port: uint16(300), HostPort: uint16(65000)}
34+
binding1 := types.PortBinding{Proto: types.SCTP, Port: uint16(300), HostPort: uint16(65000)}
35+
binding2 := types.PortBinding{Proto: types.UDP, Port: uint16(400), HostPort: uint16(54000)}
36+
binding3 := types.PortBinding{Proto: types.TCP, Port: uint16(500), HostPort: uint16(65000)}
3737
portBindings := []types.PortBinding{binding1, binding2, binding3}
3838

3939
sbOptions := make(map[string]interface{})
@@ -180,6 +180,57 @@ func loopbackUp() error {
180180
return nlHandle.LinkSetUp(iface)
181181
}
182182

183+
func TestCmpPortBindings(t *testing.T) {
184+
pb := types.PortBinding{
185+
Proto: types.TCP,
186+
IP: net.ParseIP("172.17.0.2"),
187+
Port: 80,
188+
HostIP: net.ParseIP("192.168.1.2"),
189+
HostPort: 8080,
190+
HostPortEnd: 8080,
191+
}
192+
var pbA, pbB types.PortBinding
193+
194+
assert.Check(t, cmpPortBinding(pb, pb) == 0)
195+
196+
pbA, pbB = pb, pb
197+
pbA.Port = 22
198+
assert.Check(t, cmpPortBinding(pbA, pbB) < 0)
199+
assert.Check(t, cmpPortBinding(pbB, pbA) > 0)
200+
201+
pbA, pbB = pb, pb
202+
pbB.Proto = types.UDP
203+
assert.Check(t, cmpPortBinding(pbA, pbB) < 0)
204+
assert.Check(t, cmpPortBinding(pbB, pbA) > 0)
205+
206+
pbA, pbB = pb, pb
207+
pbA.Port = 22
208+
pbA.Proto = types.UDP
209+
assert.Check(t, cmpPortBinding(pbA, pbB) < 0)
210+
assert.Check(t, cmpPortBinding(pbB, pbA) > 0)
211+
212+
pbA, pbB = pb, pb
213+
pbB.HostPort = 8081
214+
assert.Check(t, cmpPortBinding(pbA, pbB) < 0)
215+
assert.Check(t, cmpPortBinding(pbB, pbA) > 0)
216+
217+
pbA, pbB = pb, pb
218+
pbB.HostPort, pbB.HostPortEnd = 0, 0
219+
assert.Check(t, cmpPortBinding(pbA, pbB) < 0)
220+
assert.Check(t, cmpPortBinding(pbB, pbA) > 0)
221+
222+
pbA, pbB = pb, pb
223+
pbB.HostPortEnd = 8081
224+
assert.Check(t, cmpPortBinding(pbA, pbB) < 0)
225+
assert.Check(t, cmpPortBinding(pbB, pbA) > 0)
226+
227+
pbA, pbB = pb, pb
228+
pbA.HostPortEnd = 8080
229+
pbB.HostPortEnd = 8081
230+
assert.Check(t, cmpPortBinding(pbA, pbB) < 0)
231+
assert.Check(t, cmpPortBinding(pbB, pbA) > 0)
232+
}
233+
183234
func TestBindHostPortsError(t *testing.T) {
184235
cfg := []portBindingReq{
185236
{
@@ -323,9 +374,8 @@ func TestAddPortMappings(t *testing.T) {
323374
},
324375
busyPortIPv4: 8080,
325376
expPBs: []types.PortBinding{
326-
{Proto: types.TCP, IP: ctrIP4.IP, Port: 80, HostIP: net.IPv4zero, HostPort: 8081, HostPortEnd: 8081},
327-
// Note that, unlike the previous test, IPv4/IPv6 get different host ports.
328-
{Proto: types.TCP, IP: ctrIP6.IP, Port: 80, HostIP: net.IPv6zero, HostPort: 8080, HostPortEnd: 8080},
377+
{Proto: types.TCP, IP: ctrIP4.IP, Port: 80, HostIP: net.IPv4zero, HostPort: 8081},
378+
{Proto: types.TCP, IP: ctrIP6.IP, Port: 80, HostIP: net.IPv6zero, HostPort: 8081},
329379
},
330380
},
331381
{
@@ -344,14 +394,14 @@ func TestAddPortMappings(t *testing.T) {
344394
expPBs: []types.PortBinding{
345395
{Proto: types.TCP, IP: ctrIP4.IP, Port: 80, HostIP: net.IPv4zero, HostPort: 8080, HostPortEnd: 8080},
346396
{Proto: types.TCP, IP: ctrIP6.IP, Port: 80, HostIP: net.IPv6zero, HostPort: 8080, HostPortEnd: 8080},
347-
{Proto: types.TCP, IP: ctrIP4.IP, Port: 81, HostIP: net.IPv4zero, HostPort: 8081, HostPortEnd: 8081},
348-
{Proto: types.TCP, IP: ctrIP6.IP, Port: 81, HostIP: net.IPv6zero, HostPort: 8081, HostPortEnd: 8081},
349-
{Proto: types.TCP, IP: ctrIP4.IP, Port: 82, HostIP: net.IPv4zero, HostPort: 8083, HostPortEnd: 8083},
350-
{Proto: types.TCP, IP: ctrIP6.IP, Port: 82, HostIP: net.IPv6zero, HostPort: 8083, HostPortEnd: 8083},
351397
{Proto: types.UDP, IP: ctrIP4.IP, Port: 80, HostIP: net.IPv4zero, HostPort: 8080, HostPortEnd: 8080},
352398
{Proto: types.UDP, IP: ctrIP6.IP, Port: 80, HostIP: net.IPv6zero, HostPort: 8080, HostPortEnd: 8080},
399+
{Proto: types.TCP, IP: ctrIP4.IP, Port: 81, HostIP: net.IPv4zero, HostPort: 8081, HostPortEnd: 8081},
400+
{Proto: types.TCP, IP: ctrIP6.IP, Port: 81, HostIP: net.IPv6zero, HostPort: 8081, HostPortEnd: 8081},
353401
{Proto: types.UDP, IP: ctrIP4.IP, Port: 81, HostIP: net.IPv4zero, HostPort: 8081, HostPortEnd: 8081},
354402
{Proto: types.UDP, IP: ctrIP6.IP, Port: 81, HostIP: net.IPv6zero, HostPort: 8081, HostPortEnd: 8081},
403+
{Proto: types.TCP, IP: ctrIP4.IP, Port: 82, HostIP: net.IPv4zero, HostPort: 8083, HostPortEnd: 8083},
404+
{Proto: types.TCP, IP: ctrIP6.IP, Port: 82, HostIP: net.IPv6zero, HostPort: 8083, HostPortEnd: 8083},
355405
{Proto: types.UDP, IP: ctrIP4.IP, Port: 82, HostIP: net.IPv4zero, HostPort: 8083, HostPortEnd: 8083},
356406
{Proto: types.UDP, IP: ctrIP6.IP, Port: 82, HostIP: net.IPv6zero, HostPort: 8083, HostPortEnd: 8083},
357407
},
@@ -436,17 +486,20 @@ func TestAddPortMappings(t *testing.T) {
436486
name: "error releasing bindings",
437487
epAddrV4: ctrIP4,
438488
epAddrV6: ctrIP6,
439-
cfg: []types.PortBinding{{Proto: types.TCP, Port: 80, HostPort: 8080}, {Proto: types.TCP, Port: 22, HostPort: 2222}},
489+
cfg: []types.PortBinding{
490+
{Proto: types.TCP, Port: 80, HostPort: 8080},
491+
{Proto: types.TCP, Port: 22, HostPort: 2222},
492+
},
440493
expPBs: []types.PortBinding{
441-
{Proto: types.TCP, IP: ctrIP4.IP, Port: 80, HostIP: net.IPv4zero, HostPort: 8080},
442-
{Proto: types.TCP, IP: ctrIP6.IP, Port: 80, HostIP: net.IPv6zero, HostPort: 8080},
443494
{Proto: types.TCP, IP: ctrIP4.IP, Port: 22, HostIP: net.IPv4zero, HostPort: 2222},
444495
{Proto: types.TCP, IP: ctrIP6.IP, Port: 22, HostIP: net.IPv6zero, HostPort: 2222},
496+
{Proto: types.TCP, IP: ctrIP4.IP, Port: 80, HostIP: net.IPv4zero, HostPort: 8080},
497+
{Proto: types.TCP, IP: ctrIP6.IP, Port: 80, HostIP: net.IPv6zero, HostPort: 8080},
445498
},
446-
expReleaseErr: "failed to stop docker-proxy for port mapping tcp/172.19.0.2:80/0.0.0.0:8080: can't stop now\n" +
447-
"failed to stop docker-proxy for port mapping tcp/fdf8:b88e:bb5c:3483::2:80/:::8080: can't stop now\n" +
448-
"failed to stop docker-proxy for port mapping tcp/172.19.0.2:22/0.0.0.0:2222: can't stop now\n" +
449-
"failed to stop docker-proxy for port mapping tcp/fdf8:b88e:bb5c:3483::2:22/:::2222: can't stop now",
499+
expReleaseErr: "failed to stop docker-proxy for port mapping tcp/172.19.0.2:22/0.0.0.0:2222: can't stop now\n" +
500+
"failed to stop docker-proxy for port mapping tcp/fdf8:b88e:bb5c:3483::2:22/:::2222: can't stop now\n" +
501+
"failed to stop docker-proxy for port mapping tcp/172.19.0.2:80/0.0.0.0:8080: can't stop now\n" +
502+
"failed to stop docker-proxy for port mapping tcp/fdf8:b88e:bb5c:3483::2:80/:::8080: can't stop now",
450503
},
451504
{
452505
name: "disable nat6",
@@ -497,6 +550,35 @@ func TestAddPortMappings(t *testing.T) {
497550
{Proto: types.TCP, IP: ctrIP6.IP, Port: 80, HostIP: net.IPv6zero},
498551
},
499552
},
553+
{
554+
name: "same ports for matching mappings with different host addresses",
555+
epAddrV4: ctrIP4,
556+
epAddrV6: ctrIP6,
557+
cfg: []types.PortBinding{
558+
// These two should both get the same host port.
559+
{Proto: types.TCP, Port: 80, HostIP: newIPNet(t, "fd0c:9167:5b11::2/64").IP},
560+
{Proto: types.TCP, Port: 80, HostIP: newIPNet(t, "192.168.1.2/24").IP},
561+
// These three should all get the same host port.
562+
{Proto: types.TCP, Port: 22, HostIP: newIPNet(t, "fd0c:9167:5b11::2/64").IP},
563+
{Proto: types.TCP, Port: 22, HostIP: newIPNet(t, "fd0c:9167:5b11::3/64").IP},
564+
{Proto: types.TCP, Port: 22, HostIP: newIPNet(t, "192.168.1.2/24").IP},
565+
// These two should get different host ports, and the exact-port should be allocated
566+
// before the range.
567+
{Proto: types.TCP, Port: 12345, HostPort: 12345, HostPortEnd: 12346},
568+
{Proto: types.TCP, Port: 12345, HostPort: 12345},
569+
},
570+
expPBs: []types.PortBinding{
571+
{Proto: types.TCP, IP: ctrIP4.IP, Port: 12345, HostIP: net.IPv4zero, HostPort: 12345},
572+
{Proto: types.TCP, IP: ctrIP6.IP, Port: 12345, HostIP: net.IPv6zero, HostPort: 12345},
573+
{Proto: types.TCP, IP: ctrIP4.IP, Port: 22, HostIP: newIPNet(t, "192.168.1.2/24").IP, HostPort: firstEphemPort},
574+
{Proto: types.TCP, IP: ctrIP6.IP, Port: 22, HostIP: newIPNet(t, "fd0c:9167:5b11::2/64").IP, HostPort: firstEphemPort},
575+
{Proto: types.TCP, IP: ctrIP6.IP, Port: 22, HostIP: newIPNet(t, "fd0c:9167:5b11::3/64").IP, HostPort: firstEphemPort},
576+
{Proto: types.TCP, IP: ctrIP4.IP, Port: 80, HostIP: newIPNet(t, "192.168.1.2/24").IP, HostPort: firstEphemPort + 1},
577+
{Proto: types.TCP, IP: ctrIP6.IP, Port: 80, HostIP: newIPNet(t, "fd0c:9167:5b11::2/64").IP, HostPort: firstEphemPort + 1},
578+
{Proto: types.TCP, IP: ctrIP4.IP, Port: 12345, HostIP: net.IPv4zero, HostPort: 12346},
579+
{Proto: types.TCP, IP: ctrIP6.IP, Port: 12345, HostIP: net.IPv6zero, HostPort: 12346},
580+
},
581+
},
500582
}
501583

502584
for _, tc := range testcases {

0 commit comments

Comments
 (0)