nftables: don't enable IP forwarding

For nftables only, never enable IP forwarding on the host. Instead,
return an error on network creation if forwarding is not enabled,
required by a bridge network, and --ip-forward=true.

If IPv4 forwarding is not enabled when the daemon is started with
nftables enabled and other config at defaults, the daemon will
exit when it tries to create the default bridge.

Otherwise, network creation will fail with an error if IPv4/IPv6
forwarding is not enabled when a network is created with IPv4/IPv6.

It's the user's responsibility to configure and secure their host
when they run Docker with nftables.

Signed-off-by: Rob Murray <rob.murray@docker.com>
This commit is contained in:
Rob Murray
2025-08-05 10:51:35 +01:00
parent 7dfeee8460
commit 67ffa47090
13 changed files with 164 additions and 162 deletions

View File

@@ -880,14 +880,28 @@ func (d *driver) createNetwork(ctx context.Context, config *networkConfiguration
config.EnableIPv4 && d.config.EnableIPForwarding,
"setupIPv4Forwarding",
func(*networkConfiguration, *bridgeInterface) error {
return setupIPv4Forwarding(d.firewaller, d.config.EnableIPTables && !d.config.DisableFilterForwardDrop)
ffd, ok := d.firewaller.(filterForwardDropper)
if !ok {
// The firewaller can't drop non-Docker forwarding. It's up to the user to enable
// forwarding on their host, and configure their firewall appropriately.
return checkIPv4Forwarding()
}
// Enable forwarding and set a default-drop forwarding policy if necessary.
return setupIPv4Forwarding(ffd, d.config.EnableIPTables && !d.config.DisableFilterForwardDrop)
},
},
{
config.EnableIPv6 && d.config.EnableIPForwarding,
"setupIPv6Forwarding",
func(*networkConfiguration, *bridgeInterface) error {
return setupIPv6Forwarding(d.firewaller, d.config.EnableIP6Tables && !d.config.DisableFilterForwardDrop)
ffd, ok := d.firewaller.(filterForwardDropper)
if !ok {
// The firewaller can't drop non-Docker forwarding. It's up to the user to enable
// forwarding on their host, and configure their firewall appropriately.
return checkIPv6Forwarding()
}
// Enable forwarding and set a default-drop forwarding policy if necessary.
return setupIPv6Forwarding(ffd, d.config.EnableIP6Tables && !d.config.DisableFilterForwardDrop)
},
},

View File

@@ -76,9 +76,6 @@ type Firewaller interface {
// NewNetwork returns an object that can be used to add published ports and legacy
// links for a bridge network.
NewNetwork(ctx context.Context, nc NetworkConfig) (Network, error)
// FilterForwardDrop sets the default policy of the FORWARD chain in the filter
// table to DROP.
FilterForwardDrop(ctx context.Context, ipv IPVersion) error
}
// Network can be used to manipulate firewall rules for a bridge network.

View File

@@ -15,7 +15,6 @@ import (
type StubFirewaller struct {
Config
Networks map[string]*StubFirewallerNetwork
FFD map[IPVersion]bool // filter forward drop
}
func NewStubFirewaller(config Config) *StubFirewaller {
@@ -24,7 +23,6 @@ func NewStubFirewaller(config Config) *StubFirewaller {
// A real Firewaller shouldn't hold on to its own networks, the bridge driver is doing that.
// But, for unit tests cross-checking the driver, this is useful.
Networks: make(map[string]*StubFirewallerNetwork),
FFD: make(map[IPVersion]bool),
}
}
@@ -41,11 +39,6 @@ func (fw *StubFirewaller) NewNetwork(_ context.Context, nc NetworkConfig) (Netwo
return nw, nil
}
func (fw *StubFirewaller) FilterForwardDrop(_ context.Context, ipv IPVersion) error {
fw.FFD[ipv] = true
return nil
}
type stubFirewallerLink struct {
parentIP netip.Addr
childIP netip.Addr

View File

@@ -91,6 +91,7 @@ func NewIptabler(ctx context.Context, config firewaller.Config) (*Iptabler, erro
return ipt, nil
}
// FilterForwardDrop sets the default policy of the FORWARD chain in the filter table to DROP.
func (ipt *Iptabler) FilterForwardDrop(ctx context.Context, ipv firewaller.IPVersion) error {
var iptv iptables.IPVersion
switch ipv {

View File

@@ -85,21 +85,6 @@ func NewNftabler(ctx context.Context, config firewaller.Config) (*Nftabler, erro
return nft, nil
}
func (nft *Nftabler) getTable(ipv firewaller.IPVersion) nftables.TableRef {
if ipv == firewaller.IPv4 {
return nft.table4
}
return nft.table6
}
func (nft *Nftabler) FilterForwardDrop(ctx context.Context, ipv firewaller.IPVersion) error {
table := nft.getTable(ipv)
if err := table.Chain(ctx, forwardChain).SetPolicy("drop"); err != nil {
return err
}
return nftApply(ctx, table)
}
// init creates the bridge driver's nftables table for IPv4 or IPv6.
func (nft *Nftabler) init(ctx context.Context, family nftables.Family) (nftables.TableRef, error) {
// Instantiate the table.

View File

@@ -4,6 +4,7 @@ package bridge
import (
"context"
"errors"
"fmt"
"os"
@@ -17,7 +18,24 @@ const (
ipv6ForwardConfAll = "/proc/sys/net/ipv6/conf/all/forwarding"
)
func setupIPv4Forwarding(fw firewaller.Firewaller, wantFilterForwardDrop bool) (retErr error) {
type filterForwardDropper interface {
FilterForwardDrop(context.Context, firewaller.IPVersion) error
}
func checkIPv4Forwarding() error {
enabled, err := getKernelBoolParam(ipv4ForwardConf)
if err != nil {
return fmt.Errorf("checking IPv4 forwarding: %w", err)
}
if enabled {
return nil
}
// It's the user's responsibility to enable forwarding and secure their host. Or,
// start docker with --ip-forward=false to disable this check.
return errors.New("IPv4 forwarding is disabled: check your host's firewalling and set sysctl net.ipv4.ip_forward=1, or disable this check using daemon option --ip-forward=false")
}
func setupIPv4Forwarding(ffd filterForwardDropper, wantFilterForwardDrop bool) (retErr error) {
changed, err := configureIPForwarding(ipv4ForwardConf, '1')
if err != nil {
return err
@@ -34,16 +52,35 @@ func setupIPv4Forwarding(fw firewaller.Firewaller, wantFilterForwardDrop bool) (
// When enabling ip_forward set the default policy on forward chain to drop.
if changed && wantFilterForwardDrop {
if err := fw.FilterForwardDrop(context.TODO(), firewaller.IPv4); err != nil {
if err := ffd.FilterForwardDrop(context.TODO(), firewaller.IPv4); err != nil {
return err
}
}
return nil
}
func setupIPv6Forwarding(fw firewaller.Firewaller, wantFilterForwardDrop bool) (retErr error) {
func checkIPv6Forwarding() error {
enabledDef, err := getKernelBoolParam(ipv6ForwardConfDefault)
if err != nil {
return fmt.Errorf("checking IPv6 default forwarding: %w", err)
}
enabledAll, err := getKernelBoolParam(ipv6ForwardConfAll)
if err != nil {
return fmt.Errorf("checking IPv6 global forwarding: %w", err)
}
if enabledDef && enabledAll {
return nil
}
// It's the user's responsibility to enable forwarding and secure their host. Or,
// start docker with --ip-forward=false to disable this check.
return errors.New("IPv6 global forwarding is disabled: check your host's firewalling and set sysctls net.ipv6.conf.all.forwarding=1 and net.ipv6.conf.default.forwarding=1, or disable this check using daemon option --ip-forward=false")
}
func setupIPv6Forwarding(ffd filterForwardDropper, wantFilterForwardDrop bool) (retErr error) {
// Set IPv6 default.forwarding, if needed.
// FIXME(robmry) - is it necessary to set this, setting "all" (below) does the job?
// Setting "all" (below) sets "default" as well, but need to check that "default" is
// set even if "all" is already set.
changedDef, err := configureIPForwarding(ipv6ForwardConfDefault, '1')
if err != nil {
return err
@@ -74,7 +111,7 @@ func setupIPv6Forwarding(fw firewaller.Firewaller, wantFilterForwardDrop bool) (
}
if (changedAll || changedDef) && wantFilterForwardDrop {
if err := fw.FilterForwardDrop(context.TODO(), firewaller.IPv6); err != nil {
if err := ffd.FilterForwardDrop(context.TODO(), firewaller.IPv6); err != nil {
return err
}
}

View File

@@ -14,19 +14,20 @@ import (
is "gotest.tools/v3/assert/cmp"
)
type ffdTestFirewaller struct {
ffd firewaller.IPVersion
}
// NewNetwork is part of interface [firewaller.Firewaller].
func (f *ffdTestFirewaller) NewNetwork(_ context.Context, _ firewaller.NetworkConfig) (firewaller.Network, error) {
return nil, nil
type ffDropper struct {
ffDrop4 bool
ffDrop6 bool
}
// FilterForwardDrop is part of interface [firewaller.Firewaller]. Just enough to check
// it was called with the expected IPVersion.
func (f *ffdTestFirewaller) FilterForwardDrop(_ context.Context, ipv firewaller.IPVersion) error {
f.ffd = ipv
func (f *ffDropper) FilterForwardDrop(_ context.Context, ipv firewaller.IPVersion) error {
switch ipv {
case firewaller.IPv4:
f.ffDrop4 = true
case firewaller.IPv6:
f.ffDrop6 = true
}
return nil
}
@@ -36,21 +37,16 @@ func TestSetupIPForwarding(t *testing.T) {
for _, wantFFD := range []bool{true, false} {
t.Run(fmt.Sprintf("wantFFD=%v", wantFFD), func(t *testing.T) {
// Disable IP Forwarding if enabled
_, err := configureIPForwarding(ipv4ForwardConf, '0')
assert.NilError(t, err)
setForwarding(t, '0')
// Set IP Forwarding
fw := &ffdTestFirewaller{}
err = setupIPv4Forwarding(fw, wantFFD)
ffd := &ffDropper{}
err := setupIPv4Forwarding(ffd, wantFFD)
assert.NilError(t, err)
// Check what the firewaller was told.
if wantFFD {
assert.Check(t, is.Equal(fw.ffd, firewaller.IPv4))
} else {
var noVer firewaller.IPVersion
assert.Check(t, is.Equal(fw.ffd, noVer))
}
assert.Check(t, is.Equal(ffd.ffDrop4, wantFFD))
assert.Check(t, !ffd.ffDrop6)
// Read new setting
procSetting, err := os.ReadFile(ipv4ForwardConf)
@@ -65,23 +61,15 @@ func TestSetupIP6Forwarding(t *testing.T) {
for _, wantFFD := range []bool{true, false} {
t.Run(fmt.Sprintf("wantFFD=%v", wantFFD), func(t *testing.T) {
_, err := configureIPForwarding(ipv6ForwardConfDefault, '0')
assert.NilError(t, err)
_, err = configureIPForwarding(ipv6ForwardConfAll, '0')
assert.NilError(t, err)
// Disable IP Forwarding if enabled
setForwarding(t, '0')
// Set IP Forwarding
fw := &ffdTestFirewaller{}
err = setupIPv6Forwarding(fw, wantFFD)
ffd := &ffDropper{}
err := setupIPv6Forwarding(ffd, wantFFD)
assert.NilError(t, err)
// Check what the firewaller was told.
if wantFFD {
assert.Check(t, is.Equal(fw.ffd, firewaller.IPv6))
} else {
var noVer firewaller.IPVersion
assert.Check(t, is.Equal(fw.ffd, noVer))
}
assert.Check(t, !ffd.ffDrop4)
assert.Check(t, is.Equal(ffd.ffDrop6, wantFFD))
// Read new setting
procSetting, err := os.ReadFile(ipv6ForwardConfDefault)
@@ -93,3 +81,30 @@ func TestSetupIP6Forwarding(t *testing.T) {
})
}
}
func TestCheckForwarding(t *testing.T) {
defer netnsutils.SetupTestOSContext(t)()
setForwarding(t, '0')
err := checkIPv4Forwarding()
assert.Check(t, is.ErrorContains(err, "IPv4 forwarding is disabled"))
err = checkIPv6Forwarding()
assert.Check(t, is.ErrorContains(err, "IPv6 global forwarding is disabled"))
setForwarding(t, '1')
err = checkIPv4Forwarding()
assert.Check(t, err)
err = checkIPv6Forwarding()
assert.Check(t, err)
}
func setForwarding(t *testing.T, val byte) {
for _, sysctl := range []string{
ipv4ForwardConf,
ipv6ForwardConfDefault,
ipv6ForwardConfAll,
} {
err := os.WriteFile(sysctl, []byte{val, '\n'}, 0o644)
assert.NilError(t, err)
}
}

View File

@@ -67,6 +67,13 @@ fi
dockerd="dockerd"
# When running with the nftables backend, dockerd will not enable IP forwarding (by default, it
# will error on network creation if forwarding is not enabled).
if [ "$DOCKER_FIREWALL_BACKEND" = "nftables" ]; then
sysctl -w net.ipv4.ip_forward=1 > /dev/null
sysctl -w net.ipv6.conf.all.forwarding=1 > /dev/null
fi
if [ -n "$DOCKER_ROOTLESS" ]; then
if [ -z "$TEST_SKIP_INTEGRATION_CLI" ]; then
echo >&2 '# DOCKER_ROOTLESS requires TEST_SKIP_INTEGRATION_CLI to be set'

View File

@@ -1,7 +1,6 @@
package networking
import (
"fmt"
"os/exec"
"regexp"
"strings"
@@ -9,45 +8,29 @@ import (
"github.com/moby/moby/v2/testutil/daemon"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
"gotest.tools/v3/icmd"
"gotest.tools/v3/poll"
)
const (
// The name of the bridge driver's nftables tables.
nftTable = "docker-bridges"
// The name of the filter-FORWARD chain in nftTable.
nftFFChain = "filter-FORWARD"
)
// Find the policy in, for example "Chain FORWARD (policy ACCEPT)".
var rePolicy = regexp.MustCompile("policy ([A-Za-z]+)")
// SetFilterForwardPolicies sets the default policy for the FORWARD chain in
// the filter tables for both IPv4 and IPv6. The original policy is restored
// using t.Cleanup().
// the iptables filter tables for both IPv4 and IPv6. The original policy is
// restored using t.Cleanup().
//
// There's only one filter-FORWARD policy, so this won't behave well if used by
// tests running in parallel in a single network namespace that expect different
// behaviour.
func SetFilterForwardPolicies(t *testing.T, firewallBackend string, policy string) {
t.Helper()
if strings.HasPrefix(firewallBackend, "iptables") {
setIptablesFFP(t, policy)
return
}
if strings.HasPrefix(firewallBackend, "nftables") {
setNftablesFFP(t, policy)
return
}
t.Fatalf("unknown firewall backend %s", firewallBackend)
}
func setIptablesFFP(t *testing.T, policy string) {
func SetFilterForwardPolicies(t *testing.T, policy string) {
t.Helper()
for _, iptablesCmd := range []string{"iptables", "ip6tables"} {
origPolicy, err := getChainPolicy(t, exec.Command(iptablesCmd, "-L", "FORWARD"))
assert.NilError(t, err, "failed to get iptables policy")
out, err := exec.Command(iptablesCmd, "-L", "FORWARD").Output()
assert.NilError(t, err, "failed to get %s policy", iptablesCmd)
opMatch := rePolicy.FindSubmatch(out)
assert.Assert(t, is.Len(opMatch, 2), "searching for policy: %w", err)
origPolicy := string(opMatch[1])
if origPolicy == policy {
continue
}
@@ -62,42 +45,6 @@ func setIptablesFFP(t *testing.T, policy string) {
}
}
func setNftablesFFP(t *testing.T, policy string) {
t.Helper()
policy = strings.ToLower(policy)
for _, family := range []string{"ip", "ip6"} {
origPolicy, err := getChainPolicy(t, exec.Command("nft", "list", "chain", family, nftTable, nftFFChain))
assert.NilError(t, err, "failed to get nftables policy")
if origPolicy == policy {
continue
}
cmd := func(p string) *exec.Cmd {
return exec.Command("nft", "add", "chain", family, nftTable, nftFFChain, "{", "policy", p, ";", "}")
}
if err := cmd(policy).Run(); err != nil {
t.Fatalf("Failed to set %s filter-FORWARD policy: %v", family, err)
}
t.Cleanup(func() {
if err := cmd(origPolicy).Run(); err != nil {
t.Logf("Failed to restore %s filter-FORWARD policy: %v", family, err)
}
})
}
}
func getChainPolicy(t *testing.T, cmd *exec.Cmd) (string, error) {
t.Helper()
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("getting policy: %w", err)
}
opMatch := rePolicy.FindSubmatch(out)
if len(opMatch) != 2 {
return "", fmt.Errorf("searching for policy: %w", err)
}
return string(opMatch[1]), nil
}
// FirewalldRunning returns true if "firewall-cmd --state" reports "running".
func FirewalldRunning() bool {
state, err := exec.Command("firewall-cmd", "--state").CombinedOutput()

View File

@@ -77,6 +77,8 @@ func (l3 *L3Segment) AddHost(t *testing.T, hostname, nsName, ifname string, addr
l3.bridge.MustRun(t, "ip", "link", "set", hostname, "up", "master", l3.bridge.Iface)
host.MustRun(t, "ip", "link", "set", host.Iface, "up")
host.MustRun(t, "ip", "link", "set", "lo", "up")
host.MustRun(t, "sysctl", "-w", "net.ipv4.ip_forward=1")
host.MustRun(t, "sysctl", "-w", "net.ipv6.conf.all.forwarding=1")
for _, addr := range addrs {
host.MustRun(t, "ip", "addr", "add", addr.String(), "dev", host.Iface, "nodad")

View File

@@ -246,6 +246,8 @@ func TestIPRangeAt64BitLimit(t *testing.T) {
func TestFilterForwardPolicy(t *testing.T) {
skip.If(t, testEnv.IsRootless, "rootless has its own netns")
skip.If(t, networking.FirewalldRunning(), "can't use firewalld in host netns to add rules in L3Segment")
skip.If(t, strings.HasPrefix(testEnv.FirewallBackendDriver(), "nftables"), "no policy is set for nftables")
ctx := setupTest(t)
// Set up a netns for each test to avoid sysctl and iptables pollution.
@@ -305,30 +307,17 @@ func TestFilterForwardPolicy(t *testing.T) {
)
host := l3.Hosts[hostname]
getFwdPolicy := func(usingNftables bool, fam string) string {
getFwdPolicy := func(cmd string) string {
t.Helper()
if usingNftables {
out := host.MustRun(t, "nft", "list chain "+fam+" docker-bridges filter-FORWARD")
if strings.Contains(out, "policy accept") {
return "ACCEPT"
}
if strings.Contains(out, "policy drop") {
return "DROP"
}
t.Fatalf("Failed to determine nftables filter-FORWARD policy: %s", out)
return ""
} else {
cmd := fam + "tables"
out := host.MustRun(t, cmd, "-S", "FORWARD")
if strings.HasPrefix(out, "-P FORWARD ACCEPT") {
return "ACCEPT"
}
if strings.HasPrefix(out, "-P FORWARD DROP") {
return "DROP"
}
t.Fatalf("Failed to determine %s FORWARD policy: %s", cmd, out)
return ""
out := host.MustRun(t, cmd, "-S", "FORWARD")
if strings.HasPrefix(out, "-P FORWARD ACCEPT") {
return "ACCEPT"
}
if strings.HasPrefix(out, "-P FORWARD DROP") {
return "DROP"
}
t.Fatalf("Failed to determine %s FORWARD policy: %s", cmd, out)
return ""
}
type sysctls struct{ v4, v6def, v6all string }
@@ -354,22 +343,21 @@ func TestFilterForwardPolicy(t *testing.T) {
d.StartWithBusybox(ctx, t, tc.daemonArgs...)
t.Cleanup(func() { d.Stop(t) })
})
usingNftables := d.FirewallBackendDriver(t) == "nftables"
c := d.NewClientT(t)
t.Cleanup(func() { c.Close() })
// If necessary, the IPv4 policy should have been updated when the default bridge network was created.
assert.Check(t, is.Equal(getFwdPolicy(usingNftables, "ip"), tc.expPolicy))
assert.Check(t, is.Equal(getFwdPolicy("iptables"), tc.expPolicy))
// IPv6 policy should not have been updated yet.
assert.Check(t, is.Equal(getFwdPolicy(usingNftables, "ip6"), "ACCEPT"))
assert.Check(t, is.Equal(getFwdPolicy("ip6tables"), "ACCEPT"))
assert.Check(t, is.Equal(getSysctls(), sysctls{tc.expForwarding, tc.initForwarding, tc.initForwarding}))
// If necessary, creating an IPv6 network should update the sysctls and policy.
const netName = "testnetffp"
network.CreateNoError(ctx, t, c, netName, network.WithIPv6())
t.Cleanup(func() { network.RemoveNoError(ctx, t, c, netName) })
assert.Check(t, is.Equal(getFwdPolicy(usingNftables, "ip"), tc.expPolicy))
assert.Check(t, is.Equal(getFwdPolicy(usingNftables, "ip6"), tc.expPolicy))
assert.Check(t, is.Equal(getFwdPolicy("iptables"), tc.expPolicy))
assert.Check(t, is.Equal(getFwdPolicy("ip6tables"), tc.expPolicy))
assert.Check(t, is.Equal(getSysctls(), sysctls{tc.expForwarding, tc.expForwarding, tc.expForwarding}))
})
}

View File

@@ -358,7 +358,6 @@ func TestBridgeINCRouted(t *testing.T) {
d := daemon.New(t)
d.StartWithBusybox(ctx, t)
t.Cleanup(func() { d.Stop(t) })
firewallBackend := d.FirewallBackendDriver(t)
c := d.NewClientT(t)
t.Cleanup(func() { c.Close() })
@@ -457,10 +456,12 @@ func TestBridgeINCRouted(t *testing.T) {
},
}
for _, fwdPolicy := range []string{"ACCEPT", "DROP"} {
networking.SetFilterForwardPolicies(t, firewallBackend, fwdPolicy)
runTests := func(testName, policy string) {
networking.FirewalldReload(t, d)
t.Run(fwdPolicy, func(t *testing.T) {
t.Run(testName, func(t *testing.T) {
if policy != "" {
networking.SetFilterForwardPolicies(t, policy)
}
for _, tc := range testcases {
t.Run(tc.name+"/v4/ping", func(t *testing.T) {
t.Parallel()
@@ -497,6 +498,13 @@ func TestBridgeINCRouted(t *testing.T) {
}
})
}
if strings.HasPrefix(d.FirewallBackendDriver(t), "iptables") {
runTests("iptables-ACCEPT", "ACCEPT")
runTests("iptables-DROP", "DROP")
} else {
runTests("nftables", "")
}
}
// TestAccessToPublishedPort checks that a container in one network can

View File

@@ -648,7 +648,6 @@ func TestDirectRoutingOpenPorts(t *testing.T) {
d := daemon.New(t)
d.StartWithBusybox(ctx, t)
t.Cleanup(func() { d.Stop(t) })
firewallBackend := d.FirewallBackendDriver(t)
c := d.NewClientT(t)
t.Cleanup(func() { c.Close() })
@@ -770,9 +769,11 @@ func TestDirectRoutingOpenPorts(t *testing.T) {
// Run the ping and http tests in two parallel groups, rather than waiting for
// ping/http timeouts separately. (The iptables filter-FORWARD policy affects the
// whole host, so ACCEPT/DROP tests can't be parallelized).
for _, fwdPolicy := range []string{"ACCEPT", "DROP"} {
networking.SetFilterForwardPolicies(t, firewallBackend, fwdPolicy)
t.Run(fwdPolicy, func(t *testing.T) {
runTests := func(testName, policy string) {
t.Run(testName, func(t *testing.T) {
if policy != "" {
networking.SetFilterForwardPolicies(t, policy)
}
for gwMode := range networks {
t.Run(gwMode+"/v4/ping", func(t *testing.T) {
testPing(t, "ping", networks[gwMode].ipv4, expPingExit[gwMode])
@@ -795,6 +796,13 @@ func TestDirectRoutingOpenPorts(t *testing.T) {
}
})
}
if strings.HasPrefix(d.FirewallBackendDriver(t), "iptables") {
runTests("iptables-ACCEPT", "ACCEPT")
runTests("iptables-DROP", "DROP")
} else {
runTests("nftables", "")
}
}
func TestAcceptFwMark(t *testing.T) {