From 9f20bede3e0b3a0d9dce4ceace4e529d0b4672f4 Mon Sep 17 00:00:00 2001 From: Dean Chen <862469039@qq.com> Date: Mon, 20 Jul 2026 23:52:15 +0500 Subject: [PATCH 1/2] dhcp: allow suppressing default gateway from lease result Some DHCP servers send a router option even when the client did not request it (skipDefault only affects the parameter request list). With Multus secondary attachments that installs a second default route and breaks pod networking. Add an ipam.suppress list; when it includes "gateway", clear IPConfig.Gateway and drop default routes (0.0.0.0/0, ::/0) from the result while keeping non-default routes such as option 121 classless static routes. Example: "ipam": { "type": "dhcp", "suppress": ["gateway"] } Fixes #1208 Signed-off-by: Dean Chen <862469039@qq.com> --- plugins/ipam/dhcp/daemon.go | 19 ++++++- plugins/ipam/dhcp/main.go | 15 ++++++ plugins/ipam/dhcp/options.go | 34 +++++++++++++ plugins/ipam/dhcp/options_test.go | 82 +++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 2 deletions(-) diff --git a/plugins/ipam/dhcp/daemon.go b/plugins/ipam/dhcp/daemon.go index 276148abd..384127e56 100644 --- a/plugins/ipam/dhcp/daemon.go +++ b/plugins/ipam/dhcp/daemon.go @@ -106,11 +106,26 @@ func (d *DHCP) Allocate(args *skel.CmdArgs, result *current.Result) error { d.setLease(clientID, l) + suppressGW, err := parseSuppress(conf.IPAM.Suppress) + if err != nil { + return err + } + + gw := l.Gateway() + routes := l.Routes() + if suppressGW { + // Clear gateway and drop default routes so main plugins do not install + // a default route from this attachment. Keep any non-default routes + // (e.g. classless static routes / option 121). + gw = nil + routes = filterDefaultRoutes(routes) + } + result.IPs = []*current.IPConfig{{ Address: *ipn, - Gateway: l.Gateway(), + Gateway: gw, }} - result.Routes = l.Routes() + result.Routes = routes if conf.IPAM.Priority != 0 { for _, r := range result.Routes { r.Priority = conf.IPAM.Priority diff --git a/plugins/ipam/dhcp/main.go b/plugins/ipam/dhcp/main.go index 2173e2c11..08c1bbefe 100644 --- a/plugins/ipam/dhcp/main.go +++ b/plugins/ipam/dhcp/main.go @@ -40,6 +40,15 @@ type NetConf struct { IPAM *IPAMConfig `json:"ipam"` } +// Supported values for IPAMConfig.Suppress. +const ( + // suppressGateway omits the DHCP-provided default gateway from the CNI + // result (IPConfig.Gateway and any 0.0.0.0/0 / ::/0 routes). Non-default + // routes from the lease are still returned. Useful with Multus so a + // secondary interface does not overwrite the pod default route. + suppressGateway = "gateway" +) + type IPAMConfig struct { types.IPAM DaemonSocketPath string `json:"daemonSocketPath"` @@ -53,6 +62,12 @@ type IPAMConfig struct { RequestOptions []RequestOption `json:"request"` // The metric of routes Priority int `json:"priority,omitempty"` + // Suppress is a list of result fields to omit from the CNI result even if + // the DHCP server provided them. Currently supported: "gateway". + // Note that skipDefault only controls which options are requested; some + // servers still send a router option unsolicited. Use suppress: ["gateway"] + // to ignore it in the result. + Suppress []string `json:"suppress,omitempty"` } // DHCPOption represents a DHCP option. It can be a number, or a string defined in manual dhcp-options(5). diff --git a/plugins/ipam/dhcp/options.go b/plugins/ipam/dhcp/options.go index deb152a2d..c5432bafc 100644 --- a/plugins/ipam/dhcp/options.go +++ b/plugins/ipam/dhcp/options.go @@ -24,6 +24,40 @@ import ( "github.com/containernetworking/cni/pkg/types" ) +// parseSuppress validates the suppress list and reports which known items are set. +func parseSuppress(items []string) (gateway bool, err error) { + for _, item := range items { + switch item { + case suppressGateway: + gateway = true + default: + return false, fmt.Errorf("unknown suppress value %q (supported: %q)", item, suppressGateway) + } + } + return gateway, nil +} + +// isDefaultRoute reports whether dst is a default route (prefix length 0). +func isDefaultRoute(dst net.IPNet) bool { + ones, bits := dst.Mask.Size() + return bits != 0 && ones == 0 +} + +// filterDefaultRoutes returns a copy of routes without default routes. +func filterDefaultRoutes(routes []*types.Route) []*types.Route { + if len(routes) == 0 { + return routes + } + out := make([]*types.Route, 0, len(routes)) + for _, r := range routes { + if r == nil || isDefaultRoute(r.Dst) { + continue + } + out = append(out, r) + } + return out +} + var optionNameToID = map[string]dhcp4.OptionCode{ "dhcp-client-identifier": dhcp4.OptionClientIdentifier, "subnet-mask": dhcp4.OptionSubnetMask, diff --git a/plugins/ipam/dhcp/options_test.go b/plugins/ipam/dhcp/options_test.go index 338174a5e..50ab36fb2 100644 --- a/plugins/ipam/dhcp/options_test.go +++ b/plugins/ipam/dhcp/options_test.go @@ -97,3 +97,85 @@ func TestParseOptionName(t *testing.T) { }) } } + +func TestParseSuppress(t *testing.T) { + tests := []struct { + name string + items []string + wantGateway bool + wantErr bool + }{ + {name: "nil", items: nil, wantGateway: false}, + {name: "empty", items: []string{}, wantGateway: false}, + {name: "gateway", items: []string{"gateway"}, wantGateway: true}, + {name: "unknown", items: []string{"routes"}, wantErr: true}, + {name: "gateway and unknown", items: []string{"gateway", "nope"}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseSuppress(tt.items) + if (err != nil) != tt.wantErr { + t.Fatalf("parseSuppress() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.wantGateway { + t.Errorf("parseSuppress() = %v, want %v", got, tt.wantGateway) + } + }) + } +} + +func TestIsDefaultRoute(t *testing.T) { + _, def4, err := net.ParseCIDR("0.0.0.0/0") + if err != nil { + t.Fatal(err) + } + _, def6, err := net.ParseCIDR("::/0") + if err != nil { + t.Fatal(err) + } + _, nonDef, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + t.Fatal(err) + } + + if !isDefaultRoute(*def4) { + t.Errorf("expected 0.0.0.0/0 to be default") + } + if !isDefaultRoute(*def6) { + t.Errorf("expected ::/0 to be default") + } + if isDefaultRoute(*nonDef) { + t.Errorf("expected 10.0.0.0/8 not to be default") + } +} + +func TestFilterDefaultRoutes(t *testing.T) { + _, def4, err := net.ParseCIDR("0.0.0.0/0") + if err != nil { + t.Fatal(err) + } + _, lan, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + t.Fatal(err) + } + + routes := []*types.Route{ + {Dst: *def4, GW: net.IPv4(192, 168, 1, 1)}, + {Dst: *lan, GW: net.IPv4(192, 168, 1, 1)}, + nil, + } + got := filterDefaultRoutes(routes) + if len(got) != 1 { + t.Fatalf("expected 1 route, got %d", len(got)) + } + if got[0].Dst.String() != "10.0.0.0/8" { + t.Errorf("unexpected route: %v", got[0].Dst) + } + + if filterDefaultRoutes(nil) != nil { + t.Errorf("nil input should return nil") + } + if len(filterDefaultRoutes([]*types.Route{})) != 0 { + t.Errorf("empty input should return empty") + } +} From 288ae54bc0c1fe19125bc68ffb9207225baf3044 Mon Sep 17 00:00:00 2001 From: Dean Chen <862469039@qq.com> Date: Tue, 21 Jul 2026 00:38:05 +0500 Subject: [PATCH 2/2] dhcp: drop named returns in parseSuppress for lint nonamedreturns fails CI on the suppress helper. Signed-off-by: Dean Chen <862469039@qq.com> --- plugins/ipam/dhcp/options.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/ipam/dhcp/options.go b/plugins/ipam/dhcp/options.go index c5432bafc..ab61bc79a 100644 --- a/plugins/ipam/dhcp/options.go +++ b/plugins/ipam/dhcp/options.go @@ -25,7 +25,8 @@ import ( ) // parseSuppress validates the suppress list and reports which known items are set. -func parseSuppress(items []string) (gateway bool, err error) { +func parseSuppress(items []string) (bool, error) { + gateway := false for _, item := range items { switch item { case suppressGateway: