Skip to content
This repository was archived by the owner on Dec 9, 2025. It is now read-only.

Commit bb7e8b7

Browse files
feat: Replicate host routing rules and tables in pod
This change enables DRANET to discover and replicate host routing rules and routes from non-default tables into the pod's network namespace. Previously, only routes from the main table were copied. This was insufficient for advanced networking configurations that rely on policy-based routing (ip rule) and multiple routing tables (like source based routing) This commit introduces the following changes: - The driver now inspects the host for ip rule entries associated with a network interface. - It also inspects for ip route entries in non-default tables. - These rules and routes are then replicated within the pod's network namespace when it's created. - An end-to-end test has been added to validate this functionality.
1 parent 0260dce commit bb7e8b7

File tree

8 files changed

+382
-28
lines changed

8 files changed

+382
-28
lines changed

pkg/apis/types.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ type NetworkConfig struct {
2626
// Routes defines static routes to be configured for this interface.
2727
Routes []RouteConfig `json:"routes,omitempty"`
2828

29+
// Rules defines routing rules to be configured for this interface.
30+
Rules []RuleConfig `json:"rules,omitempty"`
31+
2932
// Neighbors defines permanent neighbor (ARP/NDP) entries to be added for this interface.
3033
Neighbors []NeighborConfig `json:"neighbors,omitempty"`
3134

@@ -86,6 +89,20 @@ type RouteConfig struct {
8689
// Scope is the scope of the route (e.g., link, host, global).
8790
// Refers to Linux route scopes (e.g., 0 for RT_SCOPE_UNIVERSE, 253 for RT_SCOPE_LINK).
8891
Scope uint8 `json:"scope,omitempty"`
92+
// Table is the routing table to use for the route.
93+
Table int `json:"table,omitempty"`
94+
}
95+
96+
// RuleConfig represents a network rule configuration.
97+
type RuleConfig struct {
98+
// Priority is the priority of the rule.
99+
Priority int `json:"priority,omitempty"`
100+
// Source is the source IP address for the rule.
101+
Source string `json:"source,omitempty"`
102+
// Destination is the destination IP address for the rule.
103+
Destination string `json:"destination,omitempty"`
104+
// Table is the routing table to use for the rule.
105+
Table int `json:"table,omitempty"`
89106
}
90107

91108
// NeighborConfig represents a neighbor (ARP/NDP) entry.

pkg/apis/validation.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ func ValidateConfig(raw *runtime.RawExtension) (*NetworkConfig, []error) {
6767
allErrors = append(allErrors, validateRoutes(config.Routes, "routes")...)
6868
}
6969

70+
// Validate Rules
71+
if len(config.Rules) > 0 {
72+
allErrors = append(allErrors, validateRules(config.Rules, "rules")...)
73+
}
74+
7075
// Validate EthtoolConfig if present
7176
if config.Ethtool != nil {
7277
allErrors = append(allErrors, validateEthtoolConfig(config.Ethtool, "ethtool")...)
@@ -204,6 +209,38 @@ func validateRoutes(routes []RouteConfig, fieldPath string) (allErrors []error)
204209
allErrors = append(allErrors, fmt.Errorf("%s.source: invalid IP address format '%s'", currentFieldPath, route.Source))
205210
}
206211
}
212+
213+
if route.Table < 0 {
214+
allErrors = append(allErrors, fmt.Errorf("%s.table: must be a non-negative integer, got %d", currentFieldPath, route.Table))
215+
}
216+
}
217+
return allErrors
218+
}
219+
220+
// validateRules validates a slice of RuleConfig.
221+
func validateRules(rules []RuleConfig, fieldPath string) (allErrors []error) {
222+
for i, rule := range rules {
223+
currentFieldPath := fmt.Sprintf("%s[%d]", fieldPath, i)
224+
225+
if rule.Priority < 0 || rule.Priority > 32767 {
226+
allErrors = append(allErrors, fmt.Errorf("%s.priority: must be an integer between 0 and 32767, got %d", currentFieldPath, rule.Priority))
227+
}
228+
229+
if rule.Table < 0 {
230+
allErrors = append(allErrors, fmt.Errorf("%s.table: must be a non-negative integer, got %d", currentFieldPath, rule.Table))
231+
}
232+
233+
if rule.Source != "" {
234+
if _, _, err := net.ParseCIDR(rule.Source); err != nil {
235+
allErrors = append(allErrors, fmt.Errorf("%s.source: invalid CIDR format '%s'", currentFieldPath, rule.Source))
236+
}
237+
}
238+
239+
if rule.Destination != "" {
240+
if _, _, err := net.ParseCIDR(rule.Destination); err != nil {
241+
allErrors = append(allErrors, fmt.Errorf("%s.destination: invalid CIDR format '%s'", currentFieldPath, rule.Destination))
242+
}
243+
}
207244
}
208245
return allErrors
209246
}

pkg/apis/validation_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,14 @@ func TestValidateConfig(t *testing.T) {
4848
Routes: []RouteConfig{
4949
{Destination: "0.0.0.0/0", Gateway: "192.168.1.254", Scope: unix.RT_SCOPE_UNIVERSE},
5050
},
51+
Rules: []RuleConfig{
52+
{Source: "10.0.0.0/8", Table: 100},
53+
},
5154
Ethtool: &EthtoolConfig{Features: map[string]bool{"tso": true}},
5255
}
5356
invalidInterfaceConf := NetworkConfig{Interface: InterfaceConfig{Name: "eth/0"}}
5457
invalidRouteConf := NetworkConfig{Interface: InterfaceConfig{Name: "eth0"}, Routes: []RouteConfig{{Destination: "invalid-cidr"}}}
58+
invalidRuleConf := NetworkConfig{Interface: InterfaceConfig{Name: "eth0"}, Rules: []RuleConfig{{Source: "invalid-cidr"}}}
5559

5660
tests := []struct {
5761
name string
@@ -106,6 +110,13 @@ func TestValidateConfig(t *testing.T) {
106110
expectedCfg: &invalidRouteConf,
107111
errContains: []string{"routes[0].destination: invalid IP or CIDR format 'invalid-cidr'"},
108112
},
113+
{
114+
name: "config with rule validation error",
115+
raw: newRawExtension(t, invalidRuleConf),
116+
expectErr: true,
117+
expectedCfg: &invalidRuleConf,
118+
errContains: []string{"rules[0].source: invalid CIDR format 'invalid-cidr'"},
119+
},
109120
}
110121

111122
for _, tt := range tests {
@@ -323,6 +334,19 @@ func TestValidateRoutes(t *testing.T) {
323334
fieldPath: "routes",
324335
expectErr: false,
325336
},
337+
{
338+
name: "valid route with table",
339+
routes: []RouteConfig{{Destination: "10.10.10.0/24", Gateway: "192.168.1.1", Table: 100}},
340+
fieldPath: "routes",
341+
expectErr: false,
342+
},
343+
{
344+
name: "invalid route with negative table",
345+
routes: []RouteConfig{{Destination: "10.10.10.0/24", Gateway: "192.168.1.1", Table: -1}},
346+
fieldPath: "routes",
347+
expectErr: true,
348+
errCount: 1,
349+
},
326350
{
327351
name: "empty destination",
328352
routes: []RouteConfig{{Gateway: "192.168.1.1"}},
@@ -387,6 +411,89 @@ func TestValidateRoutes(t *testing.T) {
387411
}
388412
}
389413

414+
func TestValidateRules(t *testing.T) {
415+
tests := []struct {
416+
name string
417+
rules []RuleConfig
418+
fieldPath string
419+
expectErr bool
420+
errCount int
421+
}{
422+
{
423+
name: "valid rule",
424+
rules: []RuleConfig{{Source: "10.0.0.0/8", Table: 100, Priority: 10}},
425+
fieldPath: "rules",
426+
expectErr: false,
427+
},
428+
{
429+
name: "valid rule - priority at min",
430+
rules: []RuleConfig{{Priority: 0, Table: 100}},
431+
fieldPath: "rules",
432+
expectErr: false,
433+
},
434+
{
435+
name: "valid rule - priority at max",
436+
rules: []RuleConfig{{Priority: 32767, Table: 100}},
437+
fieldPath: "rules",
438+
expectErr: false,
439+
},
440+
{
441+
name: "invalid priority - too high",
442+
rules: []RuleConfig{{Priority: 32768}},
443+
fieldPath: "rules",
444+
expectErr: true,
445+
errCount: 1,
446+
},
447+
{
448+
name: "invalid priority - negative",
449+
rules: []RuleConfig{{Priority: -1}},
450+
fieldPath: "rules",
451+
expectErr: true,
452+
errCount: 1,
453+
},
454+
{
455+
name: "invalid table",
456+
rules: []RuleConfig{{Table: -1}},
457+
fieldPath: "rules",
458+
expectErr: true,
459+
errCount: 1,
460+
},
461+
{
462+
name: "invalid source CIDR",
463+
rules: []RuleConfig{{Source: "invalid-cidr"}},
464+
fieldPath: "rules",
465+
expectErr: true,
466+
errCount: 1,
467+
},
468+
{
469+
name: "invalid destination CIDR",
470+
rules: []RuleConfig{{Destination: "invalid-cidr"}},
471+
fieldPath: "rules",
472+
expectErr: true,
473+
errCount: 1,
474+
},
475+
{
476+
name: "multiple errors",
477+
rules: []RuleConfig{{Priority: -1, Table: -1, Source: "invalid", Destination: "invalid"}},
478+
fieldPath: "rules",
479+
expectErr: true,
480+
errCount: 4,
481+
},
482+
}
483+
484+
for _, tt := range tests {
485+
t.Run(tt.name, func(t *testing.T) {
486+
errs := validateRules(tt.rules, tt.fieldPath)
487+
if (len(errs) > 0) != tt.expectErr {
488+
t.Errorf("validateRules() expectErr %v, got errors: %v", tt.expectErr, errs)
489+
}
490+
if tt.expectErr && len(errs) != tt.errCount {
491+
t.Errorf("validateRules() expected %d errors, got %d: %v", tt.errCount, len(errs), errs)
492+
}
493+
})
494+
}
495+
}
496+
390497
func TestValidateNeighborConfig(t *testing.T) {
391498
tests := []struct {
392499
name string

pkg/driver/dra_hooks.go

Lines changed: 103 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ func (np *NetworkDriver) prepareResourceClaims(ctx context.Context, claims []*re
134134
// prepareResourceClaim gets all the configuration required to be applied at runtime and passes it downs to the handlers.
135135
// This happens in the kubelet so it can be a "slow" operation, so we can execute fast in RunPodsandbox, that happens in the
136136
// container runtime and has strong expectactions to be executed fast (default hook timeout is 2 seconds).
137+
//
138+
// TODO(#290): This function has grown too large and needs to be split apart.
137139
func (np *NetworkDriver) prepareResourceClaim(ctx context.Context, claim *resourceapi.ResourceClaim) kubeletplugin.PrepareResult {
138140
klog.V(2).Infof("PrepareResourceClaim Claim %s/%s", claim.Namespace, claim.Name)
139141
start := time.Now()
@@ -161,6 +163,13 @@ func (np *NetworkDriver) prepareResourceClaim(ctx context.Context, claim *resour
161163
}
162164
}
163165

166+
rulesByTable, err := getRuleInfo(nlHandle)
167+
if err != nil {
168+
return kubeletplugin.PrepareResult{
169+
Err: fmt.Errorf("error getting rule info: %v", err),
170+
}
171+
}
172+
164173
var errorList []error
165174
charDevices := sets.New[string]()
166175
for _, result := range claim.Status.Allocation.Devices.Results {
@@ -284,35 +293,21 @@ func (np *NetworkDriver) prepareResourceClaim(ctx context.Context, claim *resour
284293
podCfg.NetworkInterfaceConfigInPod.Ethtool.Features = ethtoolFeatures
285294
}
286295

287-
// Obtain the routes associated to the interface
288-
// TODO: only considers outgoing traffic
289-
filter := &netlink.Route{
290-
LinkIndex: link.Attrs().Index,
291-
}
292-
routes, err := nlHandle.RouteListFiltered(netlink.FAMILY_ALL, filter, netlink.RT_FILTER_OIF)
296+
// Obtain the routes and rules associated with the interface.
297+
routes, tables, err := getRouteInfo(nlHandle, ifName, link)
293298
if err != nil {
294-
klog.Infof("fail to get ip routes for interface %s : %v", ifName, err)
299+
errorList = append(errorList, err)
300+
continue
295301
}
296-
for _, route := range routes {
297-
routeCfg := apis.RouteConfig{}
298-
// routes need a destination
299-
if route.Dst == nil {
300-
continue
301-
}
302-
// Discard IPv6 link-local routes, but allow IPv4 link-local.
303-
if route.Dst.IP.To4() == nil && route.Dst.IP.IsLinkLocalUnicast() {
304-
continue
302+
podCfg.NetworkInterfaceConfigInPod.Routes = append(podCfg.NetworkInterfaceConfigInPod.Routes, routes...)
303+
304+
for _, table := range tables.UnsortedList() {
305+
if rules, ok := rulesByTable[table]; ok {
306+
klog.V(5).Infof("Adding %d rules for table %d associated with interface %s", len(rules), table, ifName)
307+
podCfg.NetworkInterfaceConfigInPod.Rules = append(podCfg.NetworkInterfaceConfigInPod.Rules, rules...)
308+
// Avoid adding the same rule twice
309+
delete(rulesByTable, table)
305310
}
306-
routeCfg.Destination = route.Dst.String()
307-
308-
if route.Gw != nil {
309-
routeCfg.Gateway = route.Gw.String()
310-
}
311-
if route.Src != nil {
312-
routeCfg.Source = route.Src.String()
313-
}
314-
routeCfg.Scope = uint8(route.Scope)
315-
podCfg.NetworkInterfaceConfigInPod.Routes = append(podCfg.NetworkInterfaceConfigInPod.Routes, routeCfg)
316311
}
317312

318313
// Obtain the neighbors associated to the interface
@@ -446,3 +441,85 @@ func formatDeviceNames(devices []resourceapi.Device, max int) string {
446441

447442
return fmt.Sprintf("%s, and %d more", strings.Join(deviceNames[:max], ", "), len(deviceNames)-max)
448443
}
444+
445+
// getRuleInfo lists all IP rules in the host network namespace and groups them
446+
// by the route table they are associated with. It returns a map where keys are
447+
// table IDs and values are slices of RuleConfig. Rules associated with the
448+
// main or local tables are ignored.
449+
func getRuleInfo(nlHandle nlwrap.Handle) (map[int][]apis.RuleConfig, error) {
450+
rulesByTable := make(map[int][]apis.RuleConfig)
451+
rules, err := nlHandle.RuleList(netlink.FAMILY_ALL)
452+
if err != nil {
453+
return nil, fmt.Errorf("failed to get ip rules: %w", err)
454+
}
455+
for _, rule := range rules {
456+
ruleCfg := apis.RuleConfig{
457+
Priority: rule.Priority,
458+
Table: rule.Table,
459+
}
460+
if rule.Src != nil {
461+
ruleCfg.Source = rule.Src.String()
462+
}
463+
if rule.Dst != nil {
464+
ruleCfg.Destination = rule.Dst.String()
465+
}
466+
// Only care about rules with route tables associated, and exclude main and local tables.
467+
if rule.Table > 0 && rule.Table != unix.RT_TABLE_MAIN && rule.Table != unix.RT_TABLE_LOCAL {
468+
klog.V(5).Infof("Found rule %s for table %d", rule.String(), rule.Table)
469+
rulesByTable[rule.Table] = append(rulesByTable[rule.Table], ruleCfg)
470+
}
471+
}
472+
return rulesByTable, nil
473+
}
474+
475+
// getRouteInfo retrieves all routes associated with a given network interface.
476+
// It filters out routes that are not suitable for pod namespaces, such as
477+
// routes in the local table. It returns the list of suitable routes and a set
478+
// of the route table IDs to which they belong.
479+
func getRouteInfo(nlHandle nlwrap.Handle, ifName string, link netlink.Link) ([]apis.RouteConfig, sets.Set[int], error) {
480+
routes := []apis.RouteConfig{}
481+
tables := sets.Set[int]{}
482+
filter := &netlink.Route{
483+
LinkIndex: link.Attrs().Index,
484+
}
485+
rl, err := nlHandle.RouteListFiltered(netlink.FAMILY_ALL, filter, netlink.RT_FILTER_OIF|netlink.RT_FILTER_TABLE)
486+
if err != nil {
487+
return nil, nil, fmt.Errorf("fail to get ip routes for interface %s : %w", ifName, err)
488+
}
489+
for _, route := range rl {
490+
routeCfg := apis.RouteConfig{}
491+
// routes need a destination
492+
if route.Dst == nil {
493+
klog.V(5).Infof("Skipping route %s for interface %s because it has no destination", route.String(), ifName)
494+
continue
495+
}
496+
// Do not copy routes from the local table because they are specific
497+
// to the host and the kernel will manage the local routing
498+
// table within the pod's network namespace.
499+
if route.Table == unix.RT_TABLE_LOCAL {
500+
klog.V(5).Infof("Skipping route %s for interface %s because it is in the local table", route.String(), ifName)
501+
continue
502+
}
503+
// Discard IPv6 link-local routes, but allow IPv4 link-local.
504+
if route.Dst.IP.To4() == nil && route.Dst.IP.IsLinkLocalUnicast() {
505+
klog.V(5).Infof("Skipping IPv6 link-local route %s for interface %s", route.String(), ifName)
506+
continue
507+
}
508+
routeCfg.Destination = route.Dst.String()
509+
if route.Gw != nil {
510+
routeCfg.Gateway = route.Gw.String()
511+
}
512+
if route.Src != nil {
513+
routeCfg.Source = route.Src.String()
514+
}
515+
routeCfg.Scope = uint8(route.Scope)
516+
routeCfg.Table = route.Table
517+
routes = append(routes, routeCfg)
518+
// Collect table IDs for rules lookup later.
519+
if route.Table > 0 {
520+
klog.V(5).Infof("Found route table %d for interface %s", route.Table, ifName)
521+
tables.Insert(route.Table)
522+
}
523+
}
524+
return routes, tables, nil
525+
}

0 commit comments

Comments
 (0)