Files
moby/daemon/libnetwork/drivers/bridge/port_mapping_linux.go
Albin Kerouanton fc045ad139 libnet/pmapi: remove firewaller arg from Map/UnmapPorts
Signed-off-by: Albin Kerouanton <albinker@gmail.com>
2025-08-28 11:47:29 +02:00

502 lines
17 KiB
Go

package bridge
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
"os"
"slices"
"github.com/containerd/log"
"github.com/moby/moby/v2/daemon/libnetwork/drvregistry"
"github.com/moby/moby/v2/daemon/libnetwork/netutils"
"github.com/moby/moby/v2/daemon/libnetwork/portallocator"
"github.com/moby/moby/v2/daemon/libnetwork/portmapper"
"github.com/moby/moby/v2/daemon/libnetwork/portmapperapi"
"github.com/moby/moby/v2/daemon/libnetwork/types"
)
// Allow unit tests to supply a dummy StartProxy.
var startProxy = portmapper.StartProxy
// addPortMappings takes cfg, the configuration for port mappings, selects host
// ports when ranges are given, binds host ports to check they're available and
// reserve them, starts docker-proxy if required, and sets up iptables
// NAT/forwarding rules as necessary. If anything goes wrong, it will undo any
// work it's done and return an error. Otherwise, the returned slice of
// PortBinding has an entry per address family (if cfg describes a mapping for
// 'any' host address, it's expanded into mappings for IPv4 and IPv6, because
// that's how the mapping is presented in 'inspect'). HostPort and HostPortEnd in
// each returned PortBinding are set to the selected and reserved port.
func (n *bridgeNetwork) addPortMappings(
ctx context.Context,
ep *bridgeEndpoint,
cfg []portmapperapi.PortBindingReq,
defHostIP net.IP,
pbmReq portBindingMode,
) (_ []portmapperapi.PortBinding, retErr error) {
if len(defHostIP) == 0 {
defHostIP = net.IPv4zero
} else if addr4 := defHostIP.To4(); addr4 != nil {
// Unmap the address if it's IPv4-mapped IPv6.
defHostIP = addr4
}
pms := n.portMappers()
bindings := make([]portmapperapi.PortBinding, 0, len(cfg)*2)
defer func() {
if retErr != nil {
if err := n.unmapPBs(ctx, bindings); err != nil {
log.G(ctx).WithFields(log.Fields{
"bindings": bindings,
"error": err,
"origErr": retErr,
}).Warn("Failed to unmap port bindings after error")
}
}
}()
bindingReqs := n.sortAndNormPBs(ctx, ep, cfg, defHostIP, pbmReq)
// toBind accumulates port bindings that should be allocated the same host port
// (if required by NAT config). If the host address is unspecified, and defHostIP
// is 0.0.0.0, one iteration of the loop may generate bindings for v4 and v6. If
// a host address is specified, it'll either be IPv4 or IPv6, and only one
// binding will be added per iteration. Config for bindings that only differ in
// host IP are sorted next to each other, the loop continues until toBind has
// collected them all, for both v4 and v6. The addresses may be 0.0.0.0 and [::],
// or multiple addresses of both address families. Once there are no more
// bindings to collect, they're applied and toBind is reset.
var toBind []portmapperapi.PortBindingReq
for i, c := range bindingReqs {
toBind = append(toBind, c)
if i < len(bindingReqs)-1 && c.Mapper == bindingReqs[i+1].Mapper && needSamePort(c, bindingReqs[i+1]) {
// This port binding matches the next, apart from host IP. So, continue
// collecting bindings, then allocate the same host port for all addresses.
continue
}
newB, err := n.mapPorts(ctx, pms, toBind)
if err != nil {
return nil, err
}
bindings = append(bindings, newB...)
// Reset toBind now the ports are bound.
toBind = toBind[:0]
}
return bindings, nil
}
// mapPorts calls the port mapper used to map the ports in reqs, applies the firewall rules requested by that portmapper,
// and starts userland proxies if needed. It returns an error if it fails on any of these steps, and rolls back any
// changes it made. Caller must ensure that reqs is non-empty and all requests have the same Mapper set.
func (n *bridgeNetwork) mapPorts(ctx context.Context, pms *drvregistry.PortMappers, reqs []portmapperapi.PortBindingReq) (_ []portmapperapi.PortBinding, retErr error) {
mapper := reqs[0].Mapper
pm, err := pms.Get(mapper)
if err != nil {
return nil, err
}
bindings, err := pm.MapPorts(ctx, reqs)
if err != nil {
return nil, err
}
defer func() {
if retErr != nil {
if err := pm.UnmapPorts(ctx, bindings); err != nil {
log.G(ctx).WithFields(log.Fields{
"bindings": bindings,
"error": err,
"origErr": retErr,
}).Warn("Failed to unmap port bindings after error")
}
return
}
}()
for i := range bindings {
// Make sure that Mapper is correctly set such that UnmapPorts call the right portmapper.
bindings[i].Mapper = mapper
}
fwPorts := collectFirewallPorts(bindings)
if err := n.firewallerNetwork.AddPorts(ctx, fwPorts); err != nil {
return nil, err
}
defer func() {
if retErr != nil {
if err := n.firewallerNetwork.DelPorts(ctx, fwPorts); err != nil {
log.G(ctx).WithFields(log.Fields{
"bindings": bindings,
"error": err,
"origErr": retErr,
}).Warn("Failed to remove firewall rules after error")
}
}
}()
// Start userland proxy processes.
defer func() {
if retErr != nil {
for _, pb := range bindings {
if pb.StopProxy == nil {
continue
}
if err := pb.StopProxy(); err != nil {
log.G(ctx).WithFields(log.Fields{
"binding": pb.PortBinding,
"error": err,
}).Warnf("failed to stop userland proxy for port mapping")
}
}
}
}()
if n.driver.config.EnableProxy {
for i, pb := range bindings {
if pb.BoundSocket == nil || pb.RootlesskitUnsupported || pb.StopProxy != nil {
continue
}
if err := portallocator.DetachSocketFilter(bindings[i].BoundSocket); err != nil {
return nil, fmt.Errorf("failed to detach socket filter for port mapping %s: %w", bindings[i].PortBinding, err)
}
var err error
bindings[i].StopProxy, err = startProxy(pb.ChildPortBinding(), n.driver.config.ProxyPath, pb.BoundSocket)
if err != nil {
return nil, fmt.Errorf("failed to start userland proxy for port mapping %s: %w", pb.PortBinding, err)
}
if err := bindings[i].BoundSocket.Close(); err != nil {
log.G(ctx).WithFields(log.Fields{
"error": err,
"mapping": pb.PortBinding,
}).Warnf("failed to close proxy socket")
}
bindings[i].BoundSocket = nil
}
}
return bindings, nil
}
// sortAndNormPBs transforms cfg into a list of portBindingReq, with all fields
// normalized:
//
// - HostPortEnd=HostPort (rather than 0) if the host port isn't a range
// - HostIP is set to the default host IP if not specified, and the binding is
// NATed
// - DisableNAT is set if the binding is routed, and HostIP is cleared
//
// When no HostIP is specified, and the default HostIP is 0.0.0.0, a duplicate
// IPv6 port binding is created with the same port and protocol, but with
// HostIP set to [::].
//
// Finally, port bindings are sorted into the ordering defined by
// [PortBindingReqs.Compare] in order to form groups of port bindings that
// should be processed in one go.
func (n *bridgeNetwork) sortAndNormPBs(
ctx context.Context,
ep *bridgeEndpoint,
cfg []portmapperapi.PortBindingReq,
defHostIP net.IP,
pbmReq portBindingMode,
) []portmapperapi.PortBindingReq {
var containerIPv4, containerIPv6 net.IP
if ep.addr != nil {
containerIPv4 = ep.addr.IP
}
if ep.addrv6 != nil {
containerIPv6 = ep.addrv6.IP
}
disableNAT4, disableNAT6 := n.getNATDisabled()
add4 := !ep.portBindingState.ipv4 && pbmReq.ipv4 || (disableNAT4 && !ep.portBindingState.routed && pbmReq.routed)
add6 := !ep.portBindingState.ipv6 && pbmReq.ipv6 || (disableNAT6 && !ep.portBindingState.routed && pbmReq.routed)
reqs := make([]portmapperapi.PortBindingReq, 0, len(cfg))
for _, c := range cfg {
if c.HostPortEnd == 0 {
c.HostPortEnd = c.HostPort
}
if add4 {
if bindingIPv4, ok := configurePortBindingIPv4(ctx, disableNAT4, c, containerIPv4, defHostIP); ok {
reqs = append(reqs, bindingIPv4)
}
}
// If the container has no IPv6 address, allow proxying host IPv6 traffic to it
// by setting up the binding with the IPv4 interface if the userland proxy is enabled
// This change was added to keep backward compatibility
containerIP := containerIPv6
if containerIPv6 == nil && pbmReq.ipv4 && add6 {
if !n.driver.config.EnableProxy {
// There's no way to map from host-IPv6 to container-IPv4 with the userland proxy
// disabled.
// If that is required, don't treat it as an error because, as networks are
// connected/disconnected, the container's gateway endpoint might change to a
// network where this config makes more sense.
if len(c.HostIP) > 0 && c.HostIP.To4() == nil {
log.G(ctx).WithFields(log.Fields{"mapping": c}).Info(
"Cannot map from IPv6 to an IPv4-only container because the userland proxy is disabled")
}
if len(c.HostIP) == 0 && defHostIP.To4() == nil {
log.G(ctx).WithFields(log.Fields{
"mapping": c,
"default": defHostIP,
}).Info("Cannot map from default host binding address to an IPv4-only container because the userland proxy is disabled")
}
} else {
containerIP = containerIPv4
}
}
if add6 {
if bindingIPv6, ok := configurePortBindingIPv6(ctx, disableNAT6, c, containerIP, defHostIP); ok {
reqs = append(reqs, bindingIPv6)
}
}
}
slices.SortFunc(reqs, func(a, b portmapperapi.PortBindingReq) int {
return a.Compare(b)
})
return reqs
}
// needSamePort returns true iff a and b only differ in the host IP address,
// meaning they should be allocated the same host port (so that, if v4/v6
// addresses are returned in a DNS response or similar, clients can bind without
// needing to adjust the port number depending on which address is used).
func needSamePort(a, b portmapperapi.PortBindingReq) bool {
return a.Port == b.Port &&
a.Proto == b.Proto &&
a.HostPort == b.HostPort &&
a.HostPortEnd == b.HostPortEnd
}
// configurePortBindingIPv4 returns a new port binding with the HostIP field
// populated and true, if a binding is required. Else, false and an empty
// binding.
func configurePortBindingIPv4(
ctx context.Context,
disableNAT bool,
bnd portmapperapi.PortBindingReq,
containerIPv4,
defHostIP net.IP,
) (portmapperapi.PortBindingReq, bool) {
if len(containerIPv4) == 0 {
return portmapperapi.PortBindingReq{}, false
}
if len(bnd.HostIP) > 0 && bnd.HostIP.To4() == nil {
// The mapping is explicitly IPv6.
return portmapperapi.PortBindingReq{}, false
}
// If there's no host address, use the default.
if len(bnd.HostIP) == 0 {
if defHostIP.To4() == nil {
// The default binding address is IPv6.
return portmapperapi.PortBindingReq{}, false
}
// The default binding IP is an IPv4 address, use it - unless NAT is disabled,
// in which case it's not possible to bind to a specific host address (the port
// mapping only opens the container's port for direct routing).
if disableNAT {
bnd.HostIP = net.IPv4zero
} else {
bnd.HostIP = defHostIP
}
}
if disableNAT && len(bnd.HostIP) != 0 && !bnd.HostIP.Equal(net.IPv4zero) {
// Ignore the default binding when nat is disabled - it may have been set
// up for IPv6 if nat is enabled there.
// Don't treat this as an error because, as networks are connected/disconnected,
// the container's gateway endpoint might change to a network where this config
// makes more sense.
log.G(ctx).WithFields(log.Fields{"mapping": bnd}).Info(
"Using address 0.0.0.0 because NAT is disabled")
bnd.HostIP = net.IPv4zero
}
// Unmap the addresses if they're IPv4-mapped IPv6.
bnd.HostIP = bnd.HostIP.To4()
bnd.IP = containerIPv4.To4()
bnd.Mapper = "nat"
if disableNAT {
bnd.Mapper = "routed"
}
return bnd, true
}
// configurePortBindingIPv6 returns a new port binding with the HostIP field
// populated and true, if a binding is required. Else, false and an empty
// binding.
func configurePortBindingIPv6(
ctx context.Context,
disableNAT bool,
bnd portmapperapi.PortBindingReq,
containerIP, defHostIP net.IP,
) (portmapperapi.PortBindingReq, bool) {
if containerIP == nil {
return portmapperapi.PortBindingReq{}, false
}
if len(bnd.HostIP) > 0 && bnd.HostIP.To4() != nil {
// The mapping is explicitly IPv4.
return portmapperapi.PortBindingReq{}, false
}
// If there's no host address, use the default.
if len(bnd.HostIP) == 0 {
if defHostIP.Equal(net.IPv4zero) {
if !netutils.IsV6Listenable() {
// No implicit binding if the host has no IPv6 support.
return portmapperapi.PortBindingReq{}, false
}
// Implicit binding to "::", no explicit HostIP and the default is 0.0.0.0
bnd.HostIP = net.IPv6zero
} else if defHostIP.To4() == nil {
// The default binding IP is an IPv6 address, use it - unless NAT is disabled, in
// which case it's not possible to bind to a specific host address (the port
// mapping only opens the container's port for direct routing).
if disableNAT {
bnd.HostIP = net.IPv6zero
} else {
bnd.HostIP = defHostIP
}
} else {
// The default binding IP is an IPv4 address, nothing to do here.
return portmapperapi.PortBindingReq{}, false
}
}
if disableNAT && len(bnd.HostIP) != 0 && !bnd.HostIP.Equal(net.IPv6zero) {
// Ignore the default binding when nat is disabled - it may have been set
// up for IPv4 if nat is enabled there.
// Don't treat this as an error because, as networks are connected/disconnected,
// the container's gateway endpoint might change to a network where this config
// makes more sense.
log.G(ctx).WithFields(log.Fields{"mapping": bnd}).Info(
"Using address [::] because NAT is disabled")
bnd.HostIP = net.IPv6zero
}
bnd.IP = containerIP
bnd.Mapper = "nat"
if disableNAT {
bnd.Mapper = "routed"
}
return bnd, true
}
// releasePorts attempts to release all port bindings, does not stop on failure
func (n *bridgeNetwork) releasePorts(ep *bridgeEndpoint) error {
n.Lock()
pbs := ep.portMapping
ep.portMapping = nil
ep.portBindingState = portBindingMode{}
n.Unlock()
return n.unmapPBs(context.TODO(), pbs)
}
func (n *bridgeNetwork) unmapPBs(ctx context.Context, bindings []portmapperapi.PortBinding) error {
pms := n.portMappers()
var errs []error
for _, b := range bindings {
pm, err := pms.Get(b.Mapper)
if err != nil {
errs = append(errs, fmt.Errorf("unmapping port binding %s: %w", b.PortBinding, err))
continue
}
if err := pm.UnmapPorts(ctx, []portmapperapi.PortBinding{b}); err != nil {
errs = append(errs, fmt.Errorf("unmapping port binding %s: %w", b.PortBinding, err))
}
if b.StopProxy != nil {
if err := b.StopProxy(); err != nil && !errors.Is(err, os.ErrProcessDone) {
errs = append(errs, fmt.Errorf("unmapping port binding %s: failed to stop userland proxy: %w", b.PortBinding, err))
}
}
}
if err := n.firewallerNetwork.DelPorts(ctx, collectFirewallPorts(bindings)); err != nil {
return err
}
return errors.Join(errs...)
}
func (n *bridgeNetwork) reapplyPerPortIptables() {
n.Lock()
var allPBs []portmapperapi.PortBinding
var allEPs []*bridgeEndpoint
for _, ep := range n.endpoints {
allPBs = append(allPBs, ep.portMapping...)
allEPs = append(allEPs, ep)
}
n.Unlock()
for _, ep := range allEPs {
netip4, netip6 := ep.netipAddrs()
if err := n.firewallerNetwork.AddEndpoint(context.TODO(), netip4, netip6); err != nil {
log.G(context.TODO()).Warnf("Failed to reconfigure Endpoint: %s", err)
}
}
if err := n.firewallerNetwork.AddPorts(context.Background(), collectFirewallPorts(allPBs)); err != nil {
log.G(context.TODO()).Warnf("Failed to reconfigure NAT: %s", err)
}
}
// collectFirewallPorts collects all the types.PortBinding needed to
// reconfigure the host firewall for a given list of port bindings. If one of
// the pbs is NATed, but has an invalid NAT field (i.e. multicast address, or a
// port 0), an error is returned.
func collectFirewallPorts(pbs []portmapperapi.PortBinding) []types.PortBinding {
var fwPBs []types.PortBinding
for _, pb := range pbs {
if pb.NAT.IsValid() {
if pb.NAT.Addr().IsMulticast() || pb.NAT.Port() == 0 {
log.G(context.Background()).WithFields(log.Fields{"pb": pb}).Error("invalid NAT address")
continue
}
fwPBs = append(fwPBs, toNATBinding(pb))
} else if pb.Forwarding {
fwPBs = append(fwPBs, toFwdBinding(pb))
}
}
return fwPBs
}
// toNATBinding converts a portmapperapi.PortBinding to a types.PortBinding
// that can be passed to firewaller.Network for setting up a NAT rule.
func toNATBinding(pb portmapperapi.PortBinding) types.PortBinding {
return types.PortBinding{
IP: pb.IP,
Port: pb.Port,
Proto: pb.Proto,
HostIP: pb.NAT.Addr().AsSlice(),
HostPort: pb.NAT.Port(),
HostPortEnd: pb.NAT.Port(),
}
}
// toFwdBinding converts a portmapperapi.PortBinding to a types.PortBinding
// that can be passed to firewaller.Network for setting up forwarding.
func toFwdBinding(pb portmapperapi.PortBinding) types.PortBinding {
unspecAddr := netip.IPv4Unspecified()
if pb.IP.To4() == nil {
unspecAddr = netip.IPv6Unspecified()
}
return types.PortBinding{
IP: pb.IP,
Port: pb.Port,
Proto: pb.Proto,
HostIP: unspecAddr.AsSlice(),
}
}