Merge pull request #52818 from mat007/portallocator-reserved-ports

daemon/libnetwork/portallocator: skip kernel-reserved ports
This commit is contained in:
Sebastiaan van Stijn
2026-06-12 17:02:42 +02:00
committed by GitHub
6 changed files with 224 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"math"
"net"
"net/netip"
"sync"
@@ -41,6 +42,7 @@ type (
ipMap ipMapping
begin int
end int
reserved map[uint16]struct{}
}
portRange struct {
begin int
@@ -80,6 +82,7 @@ func newInstance() *PortAllocator {
defaultIP: net.IPv4zero,
begin: begin,
end: end,
reserved: reservedPorts(begin, end),
}
}
@@ -92,6 +95,15 @@ func dynamicPortRange() (start, end int) {
return begin, end
}
func reservedPorts(begin, end int) map[uint16]struct{} {
reserved, err := getReservedPorts(begin, end)
if err != nil {
log.G(context.TODO()).WithError(err).Info("ignoring system reserved ports")
return nil
}
return reserved
}
func makeIpMapping(begin, end int) ipMapping {
return ipMapping{
netip.IPv4Unspecified(): makeProtoMap(begin, end),
@@ -224,6 +236,12 @@ func (p *PortAllocator) RequestPortsInRange(ips []net.IP, proto string, portStar
pRanges[addr] = pMap.portMap.getPortRange(portStart, portEnd)
}
// Requests for the default ephemeral range are automatic assignments, so
// the ports the kernel excludes from its own automatic assignments must
// be skipped. Requests for a specific range are honored as-is, like
// requests for a specific port.
skipReserved := portStart == 0 && portEnd == 0
// Arbitrarily starting after the last port allocated for the first address, search
// for a port that's available in all ranges.
firstAddr, _ := netip.AddrFromSlice(ips[0])
@@ -235,6 +253,12 @@ func (p *PortAllocator) RequestPortsInRange(ips []net.IP, proto string, portStar
port = firstRange.begin
}
if skipReserved && port >= 0 && port <= math.MaxUint16 {
if _, ok := p.reserved[uint16(port)]; ok {
continue
}
}
portAlreadyAllocated := func() bool {
for _, pMap := range ipToPortMapRef {
if _, ok := pMap.portMap.p[port]; ok {

View File

@@ -39,3 +39,7 @@ func getDynamicPortRange() (start int, end int, _ error) {
}
return start, end, nil
}
func getReservedPorts(_, _ int) (map[uint16]struct{}, error) {
return nil, nil
}

View File

@@ -4,6 +4,8 @@ import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func getDynamicPortRange() (start int, end int, _ error) {
@@ -23,3 +25,53 @@ func getDynamicPortRange() (start int, end int, _ error) {
}
return start, end, nil
}
// getReservedPorts returns the ports the kernel excludes from automatic
// assignment, read from /proc/sys/net/ipv4/ip_local_reserved_ports
// (e.g. "8080,9148" or "30000-32767"). Reserved ports outside the
// begin-end allocation range are dropped, the allocator never picks
// them anyway.
func getReservedPorts(begin, end int) (map[uint16]struct{}, error) {
const reservedPortsKernelParam = "/proc/sys/net/ipv4/ip_local_reserved_ports"
data, err := os.ReadFile(reservedPortsKernelParam)
if err != nil {
return nil, err
}
reserved, err := parseReservedPorts(string(data), begin, end)
if err != nil {
return nil, fmt.Errorf("port allocator - failed to parse system reserved ports from %s: %v", reservedPortsKernelParam, err)
}
return reserved, nil
}
// parseReservedPorts parses a port list in the kernel format, a
// comma-separated list of single ports ("1080") and port ranges
// ("30000-32767"), keeping only ports within begin-end.
func parseReservedPorts(list string, begin, end int) (map[uint16]struct{}, error) {
list = strings.TrimSpace(list)
if list == "" {
return nil, nil
}
reserved := map[uint16]struct{}{}
for entry := range strings.SplitSeq(list, ",") {
first, last, isRange := strings.Cut(entry, "-")
entryBegin, err := strconv.ParseUint(first, 10, 16)
if err != nil {
return nil, fmt.Errorf("invalid port %q: %v", entry, err)
}
entryEnd := entryBegin
if isRange {
entryEnd, err = strconv.ParseUint(last, 10, 16)
if err != nil {
return nil, fmt.Errorf("invalid port range %q: %v", entry, err)
}
if entryEnd < entryBegin {
return nil, fmt.Errorf("invalid port range %q", entry)
}
}
for port := max(int(entryBegin), begin); port <= min(int(entryEnd), end); port++ {
reserved[uint16(port)] = struct{}{}
}
}
return reserved, nil
}

View File

@@ -0,0 +1,100 @@
package portallocator
import (
"testing"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
func TestParseReservedPorts(t *testing.T) {
tests := []struct {
name string
list string
begin, end int
expected map[uint16]struct{}
expectedErr string
}{
{
name: "empty",
list: "\n",
},
{
name: "single port",
list: "8080\n",
expected: map[uint16]struct{}{8080: {}},
},
{
name: "multiple ports",
list: "8080,9148",
expected: map[uint16]struct{}{8080: {}, 9148: {}},
},
{
name: "port range",
list: "30000-30002",
expected: map[uint16]struct{}{30000: {}, 30001: {}, 30002: {}},
},
{
name: "mixed",
list: "8080,30000-30001,9148",
expected: map[uint16]struct{}{8080: {}, 30000: {}, 30001: {}, 9148: {}},
},
{
name: "port outside allocation range",
list: "8080,30000",
begin: 20000,
end: 40000,
// 8080 is below the allocation range, the allocator never picks it.
expected: map[uint16]struct{}{30000: {}},
},
{
name: "range clamped to allocation range",
list: "29998-30001",
begin: 30000,
end: 40000,
expected: map[uint16]struct{}{30000: {}, 30001: {}},
},
{
name: "range fully outside allocation range",
list: "8080-8082",
begin: 30000,
end: 40000,
expected: map[uint16]struct{}{},
},
{
name: "invalid port",
list: "8080,abc",
expectedErr: `invalid port "abc"`,
},
{
name: "invalid range",
list: "30002-30000",
expectedErr: `invalid port range "30002-30000"`,
},
{
name: "incomplete range",
list: "30000-",
expectedErr: `invalid port range "30000-"`,
},
{
name: "out of range port",
list: "65536",
expectedErr: `invalid port "65536"`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
begin, end := tc.begin, tc.end
if begin == 0 && end == 0 {
begin, end = 1, 65535
}
reserved, err := parseReservedPorts(tc.list, begin, end)
if tc.expectedErr != "" {
assert.Check(t, is.ErrorContains(err, tc.expectedErr))
return
}
assert.Check(t, err)
assert.Check(t, is.DeepEqual(reserved, tc.expected))
})
}
}

View File

@@ -356,3 +356,43 @@ func TestMixUnspecAndSpecificAddrs(t *testing.T) {
assert.Check(t, err)
assert.Check(t, is.Equal(port, p.begin+1))
}
func TestRequestNewPortSkipsReservedPorts(t *testing.T) {
p := newInstance()
p.reserved = map[uint16]struct{}{uint16(p.begin): {}, uint16(p.begin + 1): {}}
port, err := p.RequestPort(net.IPv4zero, "tcp", 0)
assert.Check(t, err)
assert.Check(t, is.Equal(port, p.begin+2))
}
func TestRequestSpecificReservedPort(t *testing.T) {
p := newInstance()
p.reserved = map[uint16]struct{}{10000: {}}
// Explicit port allocation is unchanged by reserved ports.
port, err := p.RequestPort(net.IPv4zero, "tcp", 10000)
assert.Check(t, err)
assert.Check(t, is.Equal(port, 10000))
}
func TestRequestPortInRangeWithReservedPort(t *testing.T) {
p := newInstance()
p.reserved = map[uint16]struct{}{20000: {}}
// Explicit range allocation is unchanged by reserved ports.
port, err := p.RequestPortInRange(net.IPv4zero, "tcp", 20000, 20004)
assert.Check(t, err)
assert.Check(t, is.Equal(port, 20000))
}
func TestRequestNewPortAllReserved(t *testing.T) {
p := newInstance()
p.reserved = map[uint16]struct{}{}
for i := p.begin; i <= p.end; i++ {
p.reserved[uint16(i)] = struct{}{}
}
_, err := p.RequestPort(net.IPv4zero, "tcp", 0)
assert.Check(t, is.ErrorIs(err, errAllPortsAllocated))
}

View File

@@ -10,3 +10,7 @@ const (
func getDynamicPortRange() (start int, end int, _ error) {
return defaultPortRangeStart, defaultPortRangeEnd, nil
}
func getReservedPorts(_, _ int) (map[uint16]struct{}, error) {
return nil, nil
}