Merge pull request #54129 from corhere/fix-nftables-cleanup-legacy-chains

libnetwork/d/bridge: fix cleanup of 27.x iptables
This commit is contained in:
Sebastiaan van Stijn authored and GitHub committed 2026-11-04 23:14:29 +01:00
commit 43ffffd387
7 files changed
+222 -66

No files matched your search

@@ -41,10 +41,10 @@ func (d *driver) initStore() error {
return err
}
// If there's a firewall cleaner, it's done its job by cleaning up rules
// belonging to the restored networks. So, drop it.
// If there's a firewall cleaner, it's cleaned up rules belonging to the
// restored networks. Let it finish, then drop it.
if nft, ok := d.firewaller.(*nftabler.Nftabler); ok {
nft.SetFirewallCleaner(nil)
nft.FinishCleanup(context.TODO())
}
return nil
@@ -138,6 +138,11 @@ type FirewallCleaner interface {
// DelPorts removes firewall rules associated with the specified port bindings.
// It should be called by the new Firewaller when adding new port mappings.
DelPorts(ctx context.Context, nc NetworkConfig, pbs []types.PortBinding)
// Done deletes rules that could only be deleted after the calls to
// DelNetwork, DelEndpoint and DelPorts for restored networks and
// endpoints. It should be called by the new Firewaller once they have
// been restored.
Done(ctx context.Context)
// DelLink removes firewall rules associated with a legacy link.
// It should be called by the new Firewaller when adding a new legacy link.
// (Excluded from the interface at present, it's not required by any current
@@ -13,7 +13,9 @@ import (
)
type iptablesCleaner struct {
config firewaller.Config
config firewaller.Config
cleaned4 bool
cleaned6 bool
}
// NewCleaner checks for iptables rules left behind by an old daemon that was using
@@ -27,7 +29,8 @@ type iptablesCleaner struct {
// this stage, interface names, addresses, etc. are unknown. So, also return
// FirewallCleaner that the new Firewaller can use to delete those rules while
// it's setting up those networks/ports (probably on replay from persistent
// storage).
// storage). A chain that one of those rules jumps to can't be removed until
// the rule is deleted, so the FirewallCleaner's Done removes the chains again.
//
// If there are no old rules to clean up, return nil.
func NewCleaner(ctx context.Context, config firewaller.Config) firewaller.FirewallCleaner {
@@ -60,24 +63,25 @@ func NewCleaner(ctx context.Context, config firewaller.Config) firewaller.Firewa
if !cleaned4 && !cleaned6 {
return nil
}
return &iptablesCleaner{config: config}
return &iptablesCleaner{config: config, cleaned4: cleaned4, cleaned6: cleaned6}
}
func (ic iptablesCleaner) DelNetwork(ctx context.Context, nc firewaller.NetworkConfig) {
if nc.Internal {
return
}
n := network{
config: nc,
ipt: &Iptabler{config: ic.config},
}
if ic.config.IPv4 && nc.Config4.Prefix.IsValid() {
_ = deleteLegacyFilterRules(iptables.IPv4, nc.IfName)
_ = n.setupNonInternalNetworkRules(ctx, iptables.IPv4, nc.Config4, false)
if !nc.Internal {
_ = n.setupNonInternalNetworkRules(ctx, iptables.IPv4, nc.Config4, false)
}
}
if ic.config.IPv6 && nc.Config6.Prefix.IsValid() {
_ = deleteLegacyFilterRules(iptables.IPv6, nc.IfName)
_ = n.setupNonInternalNetworkRules(ctx, iptables.IPv6, nc.Config6, false)
if !nc.Internal {
_ = n.setupNonInternalNetworkRules(ctx, iptables.IPv6, nc.Config6, false)
}
}
}
@@ -101,3 +105,16 @@ func (ic iptablesCleaner) DelPorts(ctx context.Context, nc firewaller.NetworkCon
}
_ = n.DelPorts(ctx, pbs)
}
// Done removes the chains that NewCleaner couldn't remove. Daemons older than
// 28.0.0 jumped from filter-FORWARD to DOCKER for each bridge ("-o <bridge> -j
// DOCKER"), and iptables can't remove a chain that a rule jumps to. DelNetwork
// deletes those jumps for restored networks.
func (ic iptablesCleaner) Done(ctx context.Context) {
if ic.cleaned4 {
removeIPChains(ctx, iptables.IPv4)
}
if ic.cleaned6 {
removeIPChains(ctx, iptables.IPv6)
}
}
@@ -104,6 +104,61 @@ func TestCleanupOldIngressChains(t *testing.T) {
}
}
// TestCleanerPerBridgeRules checks that the cleaner's DelNetwork deletes the
// per-bridge rules that daemons older than 28.0.1 left in filter-FORWARD, for
// internal and non-internal networks. Then, that Done removes the filter
// table's DOCKER chain, which NewCleaner can't remove while filter-FORWARD
// has per-bridge jumps to it.
func TestCleanerPerBridgeRules(t *testing.T) {
skip.If(t, iptables.UsingFirewalld(), "firewalld is running in the host netns, it can't modify rules in the test's netns")
defer netnsutils.SetupTestOSContext(t)()
versions := []iptables.IPVersion{iptables.IPv4, iptables.IPv6}
for _, version := range versions {
iptable := iptables.GetIptable(version)
for _, args := range [][]string{
{"-N", dockerChain},
{"-N", isolationChain1},
{"-A", "FORWARD", "-j", isolationChain1},
{"-A", "FORWARD", "-o", "br-restored", "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"},
{"-A", "FORWARD", "-o", "br-restored", "-j", dockerChain},
{"-A", "FORWARD", "-i", "br-restored", "!", "-o", "br-restored", "-j", "ACCEPT"},
{"-A", "FORWARD", "-i", "br-restored", "-o", "br-restored", "-j", "ACCEPT"},
{"-A", "FORWARD", "-i", "br-internal", "-o", "br-internal", "-j", "ACCEPT"},
} {
out, err := iptable.Raw(args...)
assert.NilError(t, err, "version:%s args:%v out:%s", version, args, out)
}
}
fc := NewCleaner(t.Context(), firewaller.Config{IPv4: true, IPv6: true})
assert.Assert(t, fc != nil)
for _, version := range versions {
iptable := iptables.GetIptable(version)
assert.Check(t, !iptable.ExistChain(isolationChain1, iptables.Filter), "version:%s", version)
}
for _, nc := range []firewaller.NetworkConfig{
{IfName: "br-restored", ICC: true, Masquerade: true},
{IfName: "br-internal", ICC: true, Internal: true},
} {
nc.Config4.Prefix = netip.MustParsePrefix("192.0.2.0/24")
nc.Config6.Prefix = netip.MustParsePrefix("2001:db8::/64")
fc.DelNetwork(t.Context(), nc)
}
for _, version := range versions {
out, err := iptables.GetIptable(version).Raw("-S", "FORWARD")
assert.NilError(t, err)
assert.Check(t, is.Equal(string(out), "-P FORWARD ACCEPT\n"), "version:%s", version)
}
fc.Done(t.Context())
for _, version := range versions {
iptable := iptables.GetIptable(version)
assert.Check(t, !iptable.ExistChain(dockerChain, iptables.Filter), "version:%s", version)
}
}
// TestIptabler tests combinations of firewaller options against golden results.
func TestIptabler(t *testing.T) {
skip.If(t, iptables.UsingFirewalld(), "firewalld is running in the host netns, it can't modify rules in the test's netns")
@@ -89,6 +89,10 @@ func (n *network) setupIPTables(ctx context.Context, ipVersion iptables.IPVersio
n.registerCleanFunc(func() error {
return setupInternalNetworkRules(ctx, n.config.IfName, config.Prefix, n.config.ICC, false)
})
if err := deleteLegacyFilterRules(ipVersion, n.config.IfName); err != nil {
return fmt.Errorf("failed to delete legacy rules in filter-FORWARD: %w", err)
}
} else {
if err := n.setupNonInternalNetworkRules(ctx, ipVersion, config, true); err != nil {
return fmt.Errorf("Failed to Setup IP tables: %w", err)
@@ -219,43 +223,27 @@ func addNATJumpRules(ipVer iptables.IPVersion, hairpinMode, enable bool) error {
return nil
}
// deleteLegacyFilterRules removes the legacy per-bridge rules from the filter-FORWARD
// chain. This is required for users upgrading the Engine to v28.0.
// deleteLegacyFilterRules removes the per-bridge rules that daemons older than
// 28.0.1 added to the filter-FORWARD chain. The rules that replaced them are in
// Docker's own chains.
// TODO(aker): drop this function once Mirantis latest LTS is v28.0 (or higher).
func deleteLegacyFilterRules(ipVer iptables.IPVersion, bridgeName string) error {
iptable := iptables.GetIptable(ipVer)
// Delete legacy per-bridge jump to the DOCKER chain from the FORWARD chain, if it exists.
// These rules have been replaced by an ipset-matching rule.
link := []string{
"-o", bridgeName,
"-j", dockerChain,
}
if iptable.Exists(iptables.Filter, "FORWARD", link...) {
del := append([]string{string(iptables.Delete), "FORWARD"}, link...)
if output, err := iptable.Raw(del...); err != nil {
return err
} else if len(output) != 0 {
return fmt.Errorf("could not delete linking rule from %s-%s: %s", iptables.Filter, dockerChain, output)
for _, args := range [][]string{
// Replaced by an ipset-matching rule in 28.0.0.
{"-o", bridgeName, "-j", dockerChain},
{"-o", bridgeName, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"},
// Outgoing packets, without and with ICC.
{"-i", bridgeName, "!", "-o", bridgeName, "-j", "ACCEPT"},
{"-i", bridgeName, "-j", "ACCEPT"},
// ICC.
{"-i", bridgeName, "-o", bridgeName, "-j", "ACCEPT"},
{"-i", bridgeName, "-o", bridgeName, "-j", "DROP"},
} {
rule := iptables.Rule{IPVer: ipVer, Table: iptables.Filter, Chain: "FORWARD", Args: args}
if err := rule.Delete(); err != nil {
return fmt.Errorf("deleting legacy rule %q: %w", rule, err)
}
}
// Delete legacy per-bridge related/established rule if it exists. These rules
// have been replaced by an ipset-matching rule.
establish := []string{
"-o", bridgeName,
"-m", "conntrack",
"--ctstate", "RELATED,ESTABLISHED",
"-j", "ACCEPT",
}
if iptable.Exists(iptables.Filter, "FORWARD", establish...) {
del := append([]string{string(iptables.Delete), "FORWARD"}, establish...)
if output, err := iptable.Raw(del...); err != nil {
return err
} else if len(output) != 0 {
return fmt.Errorf("could not delete establish rule from %s-%s: %s", iptables.Filter, dockerChain, output)
}
}
return nil
}
@@ -371,12 +359,6 @@ func (n *network) setupNonInternalNetworkRules(ctx context.Context, ipVer iptabl
"!", "-o", n.config.IfName,
"-j", "ACCEPT",
}}
// If there's a version of outRuleNoICC in the FORWARD chain, created by moby 28.0.0 or older, delete it.
if enable {
if err := outRuleNoICC.WithChain("FORWARD").Delete(); err != nil {
return fmt.Errorf("deleting FORWARD chain outRuleNoICC: %w", err)
}
}
if n.config.ICC {
// Accept outgoing traffic to anywhere, including other containers on this bridge.
outRuleICC := iptables.Rule{IPVer: ipVer, Table: iptables.Filter, Chain: dockerForwardChain, Args: []string{
@@ -386,12 +368,6 @@ func (n *network) setupNonInternalNetworkRules(ctx context.Context, ipVer iptabl
if err := appendOrDelChainRule(outRuleICC, "ACCEPT OUTGOING", enable); err != nil {
return err
}
// If there's a version of outRuleICC in the FORWARD chain, created by moby 28.0.0 or older, delete it.
if enable {
if err := outRuleICC.WithChain("FORWARD").Delete(); err != nil {
return fmt.Errorf("deleting FORWARD chain outRuleICC: %w", err)
}
}
} else {
// Accept outgoing traffic to anywhere, apart from other containers on this bridge.
// setIcc added a DROP rule for ICC traffic.
@@ -432,16 +408,6 @@ func setIcc(ctx context.Context, version iptables.IPVersion, bridgeIface string,
log.G(ctx).WithError(err).Warn("Failed to delete ICC drop rule")
}
}
// Delete rules that may have been inserted into the FORWARD chain by moby 28.0.0 or older.
if insert {
if err := acceptRule.WithChain("FORWARD").Delete(); err != nil {
return fmt.Errorf("deleting FORWARD chain accept rule: %w", err)
}
if err := dropRule.WithChain("FORWARD").Delete(); err != nil {
return fmt.Errorf("deleting FORWARD chain drop rule: %w", err)
}
}
return nil
}
@@ -37,3 +37,12 @@ func tryCleanup(ctx context.Context, family nftables.Family, label string) {
func (nft *Nftabler) SetFirewallCleaner(fc firewaller.FirewallCleaner) {
nft.cleaner = fc
}
// FinishCleanup tells the FirewallCleaner, if there is one, that networks and
// endpoints have been restored. Then, it drops the FirewallCleaner.
func (nft *Nftabler) FinishCleanup(ctx context.Context) {
if nft.cleaner != nil {
nft.cleaner.Done(ctx)
nft.cleaner = nil
}
}
@@ -1125,6 +1125,110 @@ func TestFirewallBackendSwitch(t *testing.T) {
assert.Check(t, !nftablesTablesExist(), "nftables tables exist after running with iptables")
}
// TestFirewallBackendSwitchFrom27 checks that, when the daemon starts with the
// nftables backend after a 27.x release ran with iptables, it deletes the 27.x
// release's chains and rules. In that layout, the filter-FORWARD chain has
// per-bridge rules, including a jump to the DOCKER chain.
func TestFirewallBackendSwitchFrom27(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")
_ = setupTest(t)
// Run in a clean netns.
addr4 := netip.MustParseAddr("192.168.126.1")
addr6 := netip.MustParseAddr("fd76:c828:41fa::1")
l3 := networking.NewL3Segment(t, "test-fwbe27",
netip.PrefixFrom(addr4, 24),
netip.PrefixFrom(addr6, 64),
)
defer l3.Destroy(t)
addr4, addr6 = addr4.Next(), addr6.Next()
const hostname = "fwbe27"
l3.AddHost(t, hostname, hostname+"-netns", "eth0",
netip.PrefixFrom(addr4, 24),
netip.PrefixFrom(addr6, 64),
)
host := l3.Hosts[hostname]
// Run without OTel because there's no routing from this netns for it.
d := daemon.New(t, daemon.WithEnvVars("OTEL_EXPORTER_OTLP_ENDPOINT="))
// A start with the iptables backend leaves the default bridge network in
// the bridge driver's store, so the next start restores it, as it would
// after an upgrade from 27.x.
host.Do(t, func() {
d.Start(t, "--firewall-backend=iptables")
d.Stop(t)
})
// Replace the rules with those a 27.x release created for the default
// bridge network, from the first generated copy of
// iptablesdoc/generated/new-daemon.md. A rule with cmd set is only for
// that command. The default bridge network had no IPv6, so ip6tables has
// no per-bridge rules.
rules27 := []struct {
cmd string
table string
args []string
}{
{table: "filter", args: []string{"-N", "DOCKER"}},
{table: "filter", args: []string{"-N", "DOCKER-ISOLATION-STAGE-1"}},
{table: "filter", args: []string{"-N", "DOCKER-ISOLATION-STAGE-2"}},
{table: "filter", args: []string{"-N", "DOCKER-USER"}},
{table: "filter", args: []string{"-A", "FORWARD", "-j", "DOCKER-USER"}},
{table: "filter", args: []string{"-A", "FORWARD", "-j", "DOCKER-ISOLATION-STAGE-1"}},
{cmd: "iptables", table: "filter", args: []string{"-A", "FORWARD", "-o", "docker0", "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"}},
{cmd: "iptables", table: "filter", args: []string{"-A", "FORWARD", "-o", "docker0", "-j", "DOCKER"}},
{cmd: "iptables", table: "filter", args: []string{"-A", "FORWARD", "-i", "docker0", "!", "-o", "docker0", "-j", "ACCEPT"}},
{cmd: "iptables", table: "filter", args: []string{"-A", "FORWARD", "-i", "docker0", "-o", "docker0", "-j", "ACCEPT"}},
{cmd: "iptables", table: "filter", args: []string{"-A", "DOCKER-ISOLATION-STAGE-1", "-i", "docker0", "!", "-o", "docker0", "-j", "DOCKER-ISOLATION-STAGE-2"}},
{table: "filter", args: []string{"-A", "DOCKER-ISOLATION-STAGE-1", "-j", "RETURN"}},
{cmd: "iptables", table: "filter", args: []string{"-A", "DOCKER-ISOLATION-STAGE-2", "-o", "docker0", "-j", "DROP"}},
{table: "filter", args: []string{"-A", "DOCKER-ISOLATION-STAGE-2", "-j", "RETURN"}},
{table: "filter", args: []string{"-A", "DOCKER-USER", "-j", "RETURN"}},
{table: "nat", args: []string{"-N", "DOCKER"}},
{table: "nat", args: []string{"-A", "PREROUTING", "-m", "addrtype", "--dst-type", "LOCAL", "-j", "DOCKER"}},
{cmd: "iptables", table: "nat", args: []string{"-A", "OUTPUT", "!", "-d", "127.0.0.0/8", "-m", "addrtype", "--dst-type", "LOCAL", "-j", "DOCKER"}},
{cmd: "ip6tables", table: "nat", args: []string{"-A", "OUTPUT", "!", "-d", "::1/128", "-m", "addrtype", "--dst-type", "LOCAL", "-j", "DOCKER"}},
{cmd: "iptables", table: "nat", args: []string{"-A", "POSTROUTING", "-s", "172.17.0.0/16", "!", "-o", "docker0", "-j", "MASQUERADE"}},
{cmd: "iptables", table: "nat", args: []string{"-A", "DOCKER", "-i", "docker0", "-j", "RETURN"}},
}
for _, cmd := range []string{"iptables", "ip6tables"} {
for _, table := range []string{"filter", "nat"} {
host.MustRun(t, cmd, "-t", table, "-F")
host.MustRun(t, cmd, "-t", table, "-X")
}
for _, r := range rules27 {
if r.cmd != "" && r.cmd != cmd {
continue
}
host.MustRun(t, cmd, append([]string{"-t", r.table}, r.args...)...)
}
}
host.Do(t, func() {
d.Start(t, "--firewall-backend=nftables")
d.Stop(t)
})
// The daemon doesn't delete DOCKER-USER, or the jump to it. Everything
// else in the 27.x rules is Docker's, and must be gone.
for _, cmd := range []string{"iptables-save", "ip6tables-save"} {
dump := host.MustRun(t, cmd)
var remaining []string
for line := range strings.SplitSeq(dump, "\n") {
if strings.Contains(line, "DOCKER-USER") {
continue
}
if strings.HasPrefix(line, ":DOCKER") || strings.HasPrefix(line, "-A ") {
remaining = append(remaining, line)
}
}
assert.Check(t, is.Len(remaining, 0), "%s:\n%s", cmd, dump)
}
}
func TestEmptyPortBindingsBC(t *testing.T) {
ctx := setupTest(t)
d := daemon.New(t)