Skip to content

Commit 48a0eaf

Browse files
committed
Allocate a free host port from the range for a single container port
When the container side is a single port and the host side is a range (e.g. `-p 3000-3001:8080`), nerdctl now treats the range as a pool and binds the container port to the first free host port in it, using getUsedPorts to skip ports already in use, matching Docker's behavior. Previously the extra host ports were silently dropped. Genuine range/range mismatches of unequal length are still rejected. The host IP is validated and normalized once, and the pool test occupies the first port of a range and asserts the free successor is chosen, so it fails without this change (Linux-only; skipped in rootless). Signed-off-by: s3onghyun <[email protected]>
1 parent b4c5feb commit 48a0eaf

2 files changed

Lines changed: 92 additions & 16 deletions

File tree

pkg/portutil/portutil.go

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,17 @@ func ParseFlagP(s string) ([]cni.PortMapping, error) {
7676

7777
ip, hostPort, containerPort := splitParts(splitBySlash[0])
7878

79+
// Validate and normalize the host IP once. An empty IP is passed through to
80+
// getUsedPorts below as "all interfaces"; for error messages and the resulting
81+
// PortMapping it is normalized to 0.0.0.0.
82+
if ip != "" && net.ParseIP(ip) == nil {
83+
return nil, fmt.Errorf("invalid ip address: %s", ip)
84+
}
85+
hostIP := ip
86+
if hostIP == "" {
87+
hostIP = "0.0.0.0"
88+
}
89+
7990
if containerPort == "" {
8091
return nil, fmt.Errorf("no port specified: %s", splitBySlash[0])
8192
}
@@ -107,32 +118,43 @@ func ParseFlagP(s string) ([]cni.PortMapping, error) {
107118
if err != nil {
108119
return nil, err
109120
}
110-
for i := startHostPort; i <= endHostPort; i++ {
111-
if usedPorts[i] {
112-
return nil, fmt.Errorf("bind for %s:%d failed: port is already allocated", ip, i)
121+
if startPort == endPort && startHostPort != endHostPort {
122+
// Docker-compatible behavior: a single container port with a host port
123+
// range (e.g. "3000-3001:8080") treats the range as a pool and binds the
124+
// container port to the first free host port in it, rather than silently
125+
// collapsing to the first port and dropping the rest of the range.
126+
// https://github.com/moby/moby/blob/master/daemon/libnetwork/portallocator/portallocator.go
127+
found := false
128+
for p := startHostPort; p <= endHostPort; p++ {
129+
if !usedPorts[p] {
130+
startHostPort, endHostPort = p, p
131+
found = true
132+
break
133+
}
134+
}
135+
if !found {
136+
return nil, fmt.Errorf("bind for %s failed: all ports in range %s are already allocated", hostIP, hostPort)
137+
}
138+
} else {
139+
for i := startHostPort; i <= endHostPort; i++ {
140+
if usedPorts[i] {
141+
return nil, fmt.Errorf("bind for %s:%d failed: port is already allocated", hostIP, i)
142+
}
113143
}
114144
}
115145
}
116146
if hostPort != "" && (endPort-startPort) != (endHostPort-startHostPort) {
117-
if endPort != startPort {
118-
return nil, fmt.Errorf("invalid ranges specified for container and host Ports: %s and %s", containerPort, hostPort)
119-
}
147+
// Both container and host sides are ranges but of unequal length — a genuine
148+
// mismatch (the single-container-port pool case above has already collapsed
149+
// the host range to one port, so it does not reach here).
150+
return nil, fmt.Errorf("invalid ranges specified for container and host Ports: %s and %s", containerPort, hostPort)
120151
}
121152

122153
for i := int32(0); i <= (int32(endPort) - int32(startPort)); i++ {
123154

124155
res.ContainerPort = int32(startPort) + i
125156
res.HostPort = int32(startHostPort) + i
126-
if ip == "" {
127-
//TODO handle ipv6
128-
res.HostIP = "0.0.0.0"
129-
} else {
130-
// TODO handle ipv6
131-
if net.ParseIP(ip) == nil {
132-
return nil, fmt.Errorf("invalid ip address: %s", ip)
133-
}
134-
res.HostIP = ip
135-
}
157+
res.HostIP = hostIP
136158

137159
mr = append(mr, res)
138160
}

pkg/portutil/portutil_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
package portutil
1818

1919
import (
20+
"fmt"
21+
"net"
2022
"reflect"
2123
"runtime"
2224
"sort"
@@ -359,3 +361,55 @@ func TestParseFlagP(t *testing.T) {
359361
})
360362
}
361363
}
364+
365+
// TestParseFlagPHostRangePool verifies the Docker-compatible behavior for a single
366+
// container port with a host port range (e.g. "3000-3001:8080"): the container port
367+
// is bound to one free host port from the range, not collapsed-and-dropped. The exact
368+
// port chosen depends on what is free in the environment, so this asserts membership
369+
// in the range rather than a specific port.
370+
func TestParseFlagPHostRangePool(t *testing.T) {
371+
if runtime.GOOS != "linux" {
372+
t.Skip("getUsedPorts is only implemented on Linux")
373+
}
374+
if rootlessutil.IsRootless() {
375+
t.Skip("getUsedPorts relies on the host network namespace and is not reliable in rootless mode")
376+
}
377+
378+
// Occupy the first port of a two-port range and confirm that the single
379+
// container port is bound to the next free host port, not collapsed onto the
380+
// occupied first port. Without the pool fix this asserts the wrong port.
381+
var occupied net.Listener
382+
var first int
383+
for attempt := 0; attempt < 50; attempt++ {
384+
l, err := net.Listen("tcp", "127.0.0.1:0")
385+
if err != nil {
386+
continue
387+
}
388+
p := l.Addr().(*net.TCPAddr).Port
389+
if p+1 > 65535 {
390+
l.Close()
391+
continue
392+
}
393+
// Ensure the successor port is currently free.
394+
probe, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p+1))
395+
if err != nil {
396+
l.Close()
397+
continue
398+
}
399+
probe.Close()
400+
occupied, first = l, p
401+
break
402+
}
403+
if occupied == nil {
404+
t.Skip("could not find an occupied port with a free successor")
405+
}
406+
defer occupied.Close()
407+
408+
got, err := ParseFlagP(fmt.Sprintf("127.0.0.1:%d-%d:8080/tcp", first, first+1))
409+
assert.NilError(t, err)
410+
assert.Equal(t, len(got), 1)
411+
assert.Equal(t, got[0].ContainerPort, int32(8080))
412+
assert.Equal(t, got[0].Protocol, "tcp")
413+
assert.Equal(t, got[0].HostIP, "127.0.0.1")
414+
assert.Equal(t, got[0].HostPort, int32(first+1))
415+
}

0 commit comments

Comments
 (0)