diff --git a/components/linux/action.pb.go b/components/linux/action.pb.go index d9aea217..38ceb3da 100644 --- a/components/linux/action.pb.go +++ b/components/linux/action.pb.go @@ -7,12 +7,11 @@ package linux import ( - reflect "reflect" - unsafe "unsafe" - api "github.com/Azure/AKSFlexNode/components/api" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" ) const ( @@ -22,6 +21,50 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type CheckRouteOverlapSpec_Mode int32 + +const ( + CheckRouteOverlapSpec_MODE_UNSPECIFIED CheckRouteOverlapSpec_Mode = 0 + CheckRouteOverlapSpec_WARN CheckRouteOverlapSpec_Mode = 1 + CheckRouteOverlapSpec_STRICT CheckRouteOverlapSpec_Mode = 2 +) + +// Enum value maps for CheckRouteOverlapSpec_Mode. +var ( + CheckRouteOverlapSpec_Mode_name = map[int32]string{ + 0: "MODE_UNSPECIFIED", + 1: "WARN", + 2: "STRICT", + } + CheckRouteOverlapSpec_Mode_value = map[string]int32{ + "MODE_UNSPECIFIED": 0, + "WARN": 1, + "STRICT": 2, + } +) + +func (x CheckRouteOverlapSpec_Mode) Enum() *CheckRouteOverlapSpec_Mode { + p := new(CheckRouteOverlapSpec_Mode) + *p = x + return p +} + +func (x CheckRouteOverlapSpec_Mode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (CheckRouteOverlapSpec_Mode) Descriptor() protoreflect.EnumDescriptor { + return file_components_linux_action_proto_enumTypes[0].Descriptor() +} + +func (CheckRouteOverlapSpec_Mode) Type() protoreflect.EnumType { + return &file_components_linux_action_proto_enumTypes[0] +} + +func (x CheckRouteOverlapSpec_Mode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + type ConfigureBaseOS struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_Metadata *api.Metadata `protobuf:"bytes,1,opt,name=metadata"` @@ -634,6 +677,729 @@ func (b0 ConfigureIPTablesStatus_builder) Build() *ConfigureIPTablesStatus { return m0 } +// ConfigureStaticRoutes installs one or more static IPv4 routes on the node +// via a systemd oneshot unit that runs `ip route replace` before kubelet +// starts. This is intended for cases where the VM provider's default routing +// is wrong for the cluster — for example, Azure ND-isr SKUs install connected +// /16 routes for the InfiniBand fabric that can shadow legitimate cluster +// CIDRs. More-specific /24 routes added via this action win over the IB /16 +// without disturbing peer-to-peer IB traffic. +type ConfigureStaticRoutes struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Metadata *api.Metadata `protobuf:"bytes,1,opt,name=metadata"` + xxx_hidden_Spec *ConfigureStaticRoutesSpec `protobuf:"bytes,2,opt,name=spec"` + xxx_hidden_Status *ConfigureStaticRoutesStatus `protobuf:"bytes,3,opt,name=status"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureStaticRoutes) Reset() { + *x = ConfigureStaticRoutes{} + mi := &file_components_linux_action_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureStaticRoutes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureStaticRoutes) ProtoMessage() {} + +func (x *ConfigureStaticRoutes) ProtoReflect() protoreflect.Message { + mi := &file_components_linux_action_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *ConfigureStaticRoutes) GetMetadata() *api.Metadata { + if x != nil { + return x.xxx_hidden_Metadata + } + return nil +} + +func (x *ConfigureStaticRoutes) GetSpec() *ConfigureStaticRoutesSpec { + if x != nil { + return x.xxx_hidden_Spec + } + return nil +} + +func (x *ConfigureStaticRoutes) GetStatus() *ConfigureStaticRoutesStatus { + if x != nil { + return x.xxx_hidden_Status + } + return nil +} + +func (x *ConfigureStaticRoutes) SetMetadata(v *api.Metadata) { + x.xxx_hidden_Metadata = v +} + +func (x *ConfigureStaticRoutes) SetSpec(v *ConfigureStaticRoutesSpec) { + x.xxx_hidden_Spec = v +} + +func (x *ConfigureStaticRoutes) SetStatus(v *ConfigureStaticRoutesStatus) { + x.xxx_hidden_Status = v +} + +func (x *ConfigureStaticRoutes) HasMetadata() bool { + if x == nil { + return false + } + return x.xxx_hidden_Metadata != nil +} + +func (x *ConfigureStaticRoutes) HasSpec() bool { + if x == nil { + return false + } + return x.xxx_hidden_Spec != nil +} + +func (x *ConfigureStaticRoutes) HasStatus() bool { + if x == nil { + return false + } + return x.xxx_hidden_Status != nil +} + +func (x *ConfigureStaticRoutes) ClearMetadata() { + x.xxx_hidden_Metadata = nil +} + +func (x *ConfigureStaticRoutes) ClearSpec() { + x.xxx_hidden_Spec = nil +} + +func (x *ConfigureStaticRoutes) ClearStatus() { + x.xxx_hidden_Status = nil +} + +type ConfigureStaticRoutes_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Metadata *api.Metadata + Spec *ConfigureStaticRoutesSpec + Status *ConfigureStaticRoutesStatus +} + +func (b0 ConfigureStaticRoutes_builder) Build() *ConfigureStaticRoutes { + m0 := &ConfigureStaticRoutes{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Metadata = b.Metadata + x.xxx_hidden_Spec = b.Spec + x.xxx_hidden_Status = b.Status + return m0 +} + +type ConfigureStaticRoutesSpec struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Enabled bool `protobuf:"varint,2,opt,name=enabled"` + xxx_hidden_Routes *[]*StaticRoute `protobuf:"bytes,1,rep,name=routes"` + XXX_raceDetectHookData protoimpl.RaceDetectHookData + XXX_presence [1]uint32 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureStaticRoutesSpec) Reset() { + *x = ConfigureStaticRoutesSpec{} + mi := &file_components_linux_action_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureStaticRoutesSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureStaticRoutesSpec) ProtoMessage() {} + +func (x *ConfigureStaticRoutesSpec) ProtoReflect() protoreflect.Message { + mi := &file_components_linux_action_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *ConfigureStaticRoutesSpec) GetEnabled() bool { + if x != nil { + return x.xxx_hidden_Enabled + } + return false +} + +func (x *ConfigureStaticRoutesSpec) GetRoutes() []*StaticRoute { + if x != nil { + if x.xxx_hidden_Routes != nil { + return *x.xxx_hidden_Routes + } + } + return nil +} + +func (x *ConfigureStaticRoutesSpec) SetEnabled(v bool) { + x.xxx_hidden_Enabled = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 0, 2) +} + +func (x *ConfigureStaticRoutesSpec) SetRoutes(v []*StaticRoute) { + x.xxx_hidden_Routes = &v +} + +func (x *ConfigureStaticRoutesSpec) HasEnabled() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 0) +} + +func (x *ConfigureStaticRoutesSpec) ClearEnabled() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 0) + x.xxx_hidden_Enabled = false +} + +type ConfigureStaticRoutesSpec_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + // enabled must be explicitly set to true before any routes are applied. + // This makes static-route injection an intentional opt-in: operators should + // only enable it after confirming a real routing overlap (for example, an + // IB /16 colliding with cluster/VNet CIDRs). + Enabled *bool + // routes is the list of static routes to install. Order is preserved but + // does not affect kernel selection (longest-prefix-match wins). + Routes []*StaticRoute +} + +func (b0 ConfigureStaticRoutesSpec_builder) Build() *ConfigureStaticRoutesSpec { + m0 := &ConfigureStaticRoutesSpec{} + b, x := &b0, m0 + _, _ = b, x + if b.Enabled != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 0, 2) + x.xxx_hidden_Enabled = *b.Enabled + } + x.xxx_hidden_Routes = &b.Routes + return m0 +} + +type ConfigureStaticRoutesStatus struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureStaticRoutesStatus) Reset() { + *x = ConfigureStaticRoutesStatus{} + mi := &file_components_linux_action_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureStaticRoutesStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureStaticRoutesStatus) ProtoMessage() {} + +func (x *ConfigureStaticRoutesStatus) ProtoReflect() protoreflect.Message { + mi := &file_components_linux_action_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +type ConfigureStaticRoutesStatus_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + +} + +func (b0 ConfigureStaticRoutesStatus_builder) Build() *ConfigureStaticRoutesStatus { + m0 := &ConfigureStaticRoutesStatus{} + b, x := &b0, m0 + _, _ = b, x + return m0 +} + +// StaticRoute describes a single IPv4 route. IPv6 is intentionally not +// supported; validation rejects non-IPv4 destinations and gateways. +type StaticRoute struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Destination *string `protobuf:"bytes,1,opt,name=destination"` + xxx_hidden_Gateway *string `protobuf:"bytes,2,opt,name=gateway"` + xxx_hidden_Dev *string `protobuf:"bytes,3,opt,name=dev"` + xxx_hidden_Metric uint32 `protobuf:"varint,4,opt,name=metric"` + XXX_raceDetectHookData protoimpl.RaceDetectHookData + XXX_presence [1]uint32 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StaticRoute) Reset() { + *x = StaticRoute{} + mi := &file_components_linux_action_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StaticRoute) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StaticRoute) ProtoMessage() {} + +func (x *StaticRoute) ProtoReflect() protoreflect.Message { + mi := &file_components_linux_action_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *StaticRoute) GetDestination() string { + if x != nil { + if x.xxx_hidden_Destination != nil { + return *x.xxx_hidden_Destination + } + return "" + } + return "" +} + +func (x *StaticRoute) GetGateway() string { + if x != nil { + if x.xxx_hidden_Gateway != nil { + return *x.xxx_hidden_Gateway + } + return "" + } + return "" +} + +func (x *StaticRoute) GetDev() string { + if x != nil { + if x.xxx_hidden_Dev != nil { + return *x.xxx_hidden_Dev + } + return "" + } + return "" +} + +func (x *StaticRoute) GetMetric() uint32 { + if x != nil { + return x.xxx_hidden_Metric + } + return 0 +} + +func (x *StaticRoute) SetDestination(v string) { + x.xxx_hidden_Destination = &v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 0, 4) +} + +func (x *StaticRoute) SetGateway(v string) { + x.xxx_hidden_Gateway = &v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 1, 4) +} + +func (x *StaticRoute) SetDev(v string) { + x.xxx_hidden_Dev = &v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 2, 4) +} + +func (x *StaticRoute) SetMetric(v uint32) { + x.xxx_hidden_Metric = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 3, 4) +} + +func (x *StaticRoute) HasDestination() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 0) +} + +func (x *StaticRoute) HasGateway() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 1) +} + +func (x *StaticRoute) HasDev() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 2) +} + +func (x *StaticRoute) HasMetric() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 3) +} + +func (x *StaticRoute) ClearDestination() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 0) + x.xxx_hidden_Destination = nil +} + +func (x *StaticRoute) ClearGateway() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 1) + x.xxx_hidden_Gateway = nil +} + +func (x *StaticRoute) ClearDev() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 2) + x.xxx_hidden_Dev = nil +} + +func (x *StaticRoute) ClearMetric() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 3) + x.xxx_hidden_Metric = 0 +} + +type StaticRoute_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + // destination is an IPv4 CIDR (e.g. "172.16.1.0/24"). Required. + Destination *string + // gateway is the next-hop IPv4 address. When empty the oneshot resolves + // the default gateway on `dev` at boot time (with a bounded retry, since + // DHCP may not have installed the default route yet). If the default + // gateway does not appear within the retry window, the oneshot fails + // and kubelet will not start. + Gateway *string + // dev is the outbound interface (e.g. "eth0"). When empty the oneshot + // resolves the outbound interface of the IPv4 default route at boot + // time — works with both classic (eth0) and predictable (ens*, enp*) + // interface names. Must match [A-Za-z0-9_.-]{1,15} when set. + Dev *string + // metric sets the route metric for tie-breaking. 0 means default. + Metric *uint32 +} + +func (b0 StaticRoute_builder) Build() *StaticRoute { + m0 := &StaticRoute{} + b, x := &b0, m0 + _, _ = b, x + if b.Destination != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 0, 4) + x.xxx_hidden_Destination = b.Destination + } + if b.Gateway != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 1, 4) + x.xxx_hidden_Gateway = b.Gateway + } + if b.Dev != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 2, 4) + x.xxx_hidden_Dev = b.Dev + } + if b.Metric != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 3, 4) + x.xxx_hidden_Metric = *b.Metric + } + return m0 +} + +// CheckRouteOverlap verifies at boot, before kubelet starts, that a list +// of expected IPv4 CIDRs (typically the cluster pod CIDR, service CIDR, +// and API server) actually route via the same interface as the IPv4 +// default route. When a CIDR resolves out a different interface — the +// classic symptom is the H200 IB driver shadowing a customer VNet CIDR +// with a connected /16 on ib0 — the check either logs a warning or +// fails the boot, depending on `mode`. +type CheckRouteOverlap struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Metadata *api.Metadata `protobuf:"bytes,1,opt,name=metadata"` + xxx_hidden_Spec *CheckRouteOverlapSpec `protobuf:"bytes,2,opt,name=spec"` + xxx_hidden_Status *CheckRouteOverlapStatus `protobuf:"bytes,3,opt,name=status"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckRouteOverlap) Reset() { + *x = CheckRouteOverlap{} + mi := &file_components_linux_action_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckRouteOverlap) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckRouteOverlap) ProtoMessage() {} + +func (x *CheckRouteOverlap) ProtoReflect() protoreflect.Message { + mi := &file_components_linux_action_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *CheckRouteOverlap) GetMetadata() *api.Metadata { + if x != nil { + return x.xxx_hidden_Metadata + } + return nil +} + +func (x *CheckRouteOverlap) GetSpec() *CheckRouteOverlapSpec { + if x != nil { + return x.xxx_hidden_Spec + } + return nil +} + +func (x *CheckRouteOverlap) GetStatus() *CheckRouteOverlapStatus { + if x != nil { + return x.xxx_hidden_Status + } + return nil +} + +func (x *CheckRouteOverlap) SetMetadata(v *api.Metadata) { + x.xxx_hidden_Metadata = v +} + +func (x *CheckRouteOverlap) SetSpec(v *CheckRouteOverlapSpec) { + x.xxx_hidden_Spec = v +} + +func (x *CheckRouteOverlap) SetStatus(v *CheckRouteOverlapStatus) { + x.xxx_hidden_Status = v +} + +func (x *CheckRouteOverlap) HasMetadata() bool { + if x == nil { + return false + } + return x.xxx_hidden_Metadata != nil +} + +func (x *CheckRouteOverlap) HasSpec() bool { + if x == nil { + return false + } + return x.xxx_hidden_Spec != nil +} + +func (x *CheckRouteOverlap) HasStatus() bool { + if x == nil { + return false + } + return x.xxx_hidden_Status != nil +} + +func (x *CheckRouteOverlap) ClearMetadata() { + x.xxx_hidden_Metadata = nil +} + +func (x *CheckRouteOverlap) ClearSpec() { + x.xxx_hidden_Spec = nil +} + +func (x *CheckRouteOverlap) ClearStatus() { + x.xxx_hidden_Status = nil +} + +type CheckRouteOverlap_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Metadata *api.Metadata + Spec *CheckRouteOverlapSpec + Status *CheckRouteOverlapStatus +} + +func (b0 CheckRouteOverlap_builder) Build() *CheckRouteOverlap { + m0 := &CheckRouteOverlap{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Metadata = b.Metadata + x.xxx_hidden_Spec = b.Spec + x.xxx_hidden_Status = b.Status + return m0 +} + +type CheckRouteOverlapSpec struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_ExpectedCidrs []string `protobuf:"bytes,1,rep,name=expected_cidrs,json=expectedCidrs"` + xxx_hidden_Mode CheckRouteOverlapSpec_Mode `protobuf:"varint,2,opt,name=mode,enum=aks.flex.components.linux.CheckRouteOverlapSpec_Mode"` + XXX_raceDetectHookData protoimpl.RaceDetectHookData + XXX_presence [1]uint32 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckRouteOverlapSpec) Reset() { + *x = CheckRouteOverlapSpec{} + mi := &file_components_linux_action_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckRouteOverlapSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckRouteOverlapSpec) ProtoMessage() {} + +func (x *CheckRouteOverlapSpec) ProtoReflect() protoreflect.Message { + mi := &file_components_linux_action_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *CheckRouteOverlapSpec) GetExpectedCidrs() []string { + if x != nil { + return x.xxx_hidden_ExpectedCidrs + } + return nil +} + +func (x *CheckRouteOverlapSpec) GetMode() CheckRouteOverlapSpec_Mode { + if x != nil { + if protoimpl.X.Present(&(x.XXX_presence[0]), 1) { + return x.xxx_hidden_Mode + } + } + return CheckRouteOverlapSpec_MODE_UNSPECIFIED +} + +func (x *CheckRouteOverlapSpec) SetExpectedCidrs(v []string) { + x.xxx_hidden_ExpectedCidrs = v +} + +func (x *CheckRouteOverlapSpec) SetMode(v CheckRouteOverlapSpec_Mode) { + x.xxx_hidden_Mode = v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 1, 2) +} + +func (x *CheckRouteOverlapSpec) HasMode() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 1) +} + +func (x *CheckRouteOverlapSpec) ClearMode() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 1) + x.xxx_hidden_Mode = CheckRouteOverlapSpec_MODE_UNSPECIFIED +} + +type CheckRouteOverlapSpec_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + // expected_cidrs are IPv4 CIDRs that kubelet, kube-proxy, and pods + // must be able to reach via the IPv4 default route's outbound + // interface. Typically populated by the controller from the cluster's + // pod CIDR, service CIDR, and API server endpoint. + ExpectedCidrs []string + // mode controls what happens when an overlap is detected. + // + // WARN : log + write /run/aks-flex-node/route-overlap.detected; + // kubelet starts anyway. + // STRICT : same logging, then exit 1 — kubelet does not start + // (the unit is RequiredBy=kubelet.service). Use STRICT in + // production where a misrouted node is worse than a node + // that won't join. + Mode *CheckRouteOverlapSpec_Mode +} + +func (b0 CheckRouteOverlapSpec_builder) Build() *CheckRouteOverlapSpec { + m0 := &CheckRouteOverlapSpec{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_ExpectedCidrs = b.ExpectedCidrs + if b.Mode != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 1, 2) + x.xxx_hidden_Mode = *b.Mode + } + return m0 +} + +type CheckRouteOverlapStatus struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckRouteOverlapStatus) Reset() { + *x = CheckRouteOverlapStatus{} + mi := &file_components_linux_action_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckRouteOverlapStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckRouteOverlapStatus) ProtoMessage() {} + +func (x *CheckRouteOverlapStatus) ProtoReflect() protoreflect.Message { + mi := &file_components_linux_action_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +type CheckRouteOverlapStatus_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + +} + +func (b0 CheckRouteOverlapStatus_builder) Build() *CheckRouteOverlapStatus { + m0 := &CheckRouteOverlapStatus{} + b, x := &b0, m0 + _, _ = b, x + return m0 +} + var File_components_linux_action_proto protoreflect.FileDescriptor const file_components_linux_action_proto_rawDesc = "" + @@ -656,36 +1422,79 @@ const file_components_linux_action_proto_rawDesc = "" + "\x04spec\x18\x02 \x01(\v20.aks.flex.components.linux.ConfigureIPTablesSpecR\x04spec\x12J\n" + "\x06status\x18\x03 \x01(\v22.aks.flex.components.linux.ConfigureIPTablesStatusR\x06status\"\x17\n" + "\x15ConfigureIPTablesSpec\"\x19\n" + - "\x17ConfigureIPTablesStatusB/Z-github.com/Azure/AKSFlexNode/components/linuxb\beditionsp\xe9\a" + "\x17ConfigureIPTablesStatus\"\xf0\x01\n" + + "\x15ConfigureStaticRoutes\x12=\n" + + "\bmetadata\x18\x01 \x01(\v2!.aks.flex.components.api.MetadataR\bmetadata\x12H\n" + + "\x04spec\x18\x02 \x01(\v24.aks.flex.components.linux.ConfigureStaticRoutesSpecR\x04spec\x12N\n" + + "\x06status\x18\x03 \x01(\v26.aks.flex.components.linux.ConfigureStaticRoutesStatusR\x06status\"u\n" + + "\x19ConfigureStaticRoutesSpec\x12\x18\n" + + "\aenabled\x18\x02 \x01(\bR\aenabled\x12>\n" + + "\x06routes\x18\x01 \x03(\v2&.aks.flex.components.linux.StaticRouteR\x06routes\"\x1d\n" + + "\x1bConfigureStaticRoutesStatus\"s\n" + + "\vStaticRoute\x12 \n" + + "\vdestination\x18\x01 \x01(\tR\vdestination\x12\x18\n" + + "\agateway\x18\x02 \x01(\tR\agateway\x12\x10\n" + + "\x03dev\x18\x03 \x01(\tR\x03dev\x12\x16\n" + + "\x06metric\x18\x04 \x01(\rR\x06metric\"\xe4\x01\n" + + "\x11CheckRouteOverlap\x12=\n" + + "\bmetadata\x18\x01 \x01(\v2!.aks.flex.components.api.MetadataR\bmetadata\x12D\n" + + "\x04spec\x18\x02 \x01(\v20.aks.flex.components.linux.CheckRouteOverlapSpecR\x04spec\x12J\n" + + "\x06status\x18\x03 \x01(\v22.aks.flex.components.linux.CheckRouteOverlapStatusR\x06status\"\xbd\x01\n" + + "\x15CheckRouteOverlapSpec\x12%\n" + + "\x0eexpected_cidrs\x18\x01 \x03(\tR\rexpectedCidrs\x12I\n" + + "\x04mode\x18\x02 \x01(\x0e25.aks.flex.components.linux.CheckRouteOverlapSpec.ModeR\x04mode\"2\n" + + "\x04Mode\x12\x14\n" + + "\x10MODE_UNSPECIFIED\x10\x00\x12\b\n" + + "\x04WARN\x10\x01\x12\n" + + "\n" + + "\x06STRICT\x10\x02\"\x19\n" + + "\x17CheckRouteOverlapStatusB/Z-github.com/Azure/AKSFlexNode/components/linuxb\beditionsp\xe9\a" -var file_components_linux_action_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_components_linux_action_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_components_linux_action_proto_msgTypes = make([]protoimpl.MessageInfo, 16) var file_components_linux_action_proto_goTypes = []any{ - (*ConfigureBaseOS)(nil), // 0: aks.flex.components.linux.ConfigureBaseOS - (*ConfigureBaseOSSpec)(nil), // 1: aks.flex.components.linux.ConfigureBaseOSSpec - (*ConfigureBaseOSStatus)(nil), // 2: aks.flex.components.linux.ConfigureBaseOSStatus - (*DisableDocker)(nil), // 3: aks.flex.components.linux.DisableDocker - (*DisableDockerSpec)(nil), // 4: aks.flex.components.linux.DisableDockerSpec - (*DisableDockerStatus)(nil), // 5: aks.flex.components.linux.DisableDockerStatus - (*ConfigureIPTables)(nil), // 6: aks.flex.components.linux.ConfigureIPTables - (*ConfigureIPTablesSpec)(nil), // 7: aks.flex.components.linux.ConfigureIPTablesSpec - (*ConfigureIPTablesStatus)(nil), // 8: aks.flex.components.linux.ConfigureIPTablesStatus - (*api.Metadata)(nil), // 9: aks.flex.components.api.Metadata + (CheckRouteOverlapSpec_Mode)(0), // 0: aks.flex.components.linux.CheckRouteOverlapSpec.Mode + (*ConfigureBaseOS)(nil), // 1: aks.flex.components.linux.ConfigureBaseOS + (*ConfigureBaseOSSpec)(nil), // 2: aks.flex.components.linux.ConfigureBaseOSSpec + (*ConfigureBaseOSStatus)(nil), // 3: aks.flex.components.linux.ConfigureBaseOSStatus + (*DisableDocker)(nil), // 4: aks.flex.components.linux.DisableDocker + (*DisableDockerSpec)(nil), // 5: aks.flex.components.linux.DisableDockerSpec + (*DisableDockerStatus)(nil), // 6: aks.flex.components.linux.DisableDockerStatus + (*ConfigureIPTables)(nil), // 7: aks.flex.components.linux.ConfigureIPTables + (*ConfigureIPTablesSpec)(nil), // 8: aks.flex.components.linux.ConfigureIPTablesSpec + (*ConfigureIPTablesStatus)(nil), // 9: aks.flex.components.linux.ConfigureIPTablesStatus + (*ConfigureStaticRoutes)(nil), // 10: aks.flex.components.linux.ConfigureStaticRoutes + (*ConfigureStaticRoutesSpec)(nil), // 11: aks.flex.components.linux.ConfigureStaticRoutesSpec + (*ConfigureStaticRoutesStatus)(nil), // 12: aks.flex.components.linux.ConfigureStaticRoutesStatus + (*StaticRoute)(nil), // 13: aks.flex.components.linux.StaticRoute + (*CheckRouteOverlap)(nil), // 14: aks.flex.components.linux.CheckRouteOverlap + (*CheckRouteOverlapSpec)(nil), // 15: aks.flex.components.linux.CheckRouteOverlapSpec + (*CheckRouteOverlapStatus)(nil), // 16: aks.flex.components.linux.CheckRouteOverlapStatus + (*api.Metadata)(nil), // 17: aks.flex.components.api.Metadata } var file_components_linux_action_proto_depIdxs = []int32{ - 9, // 0: aks.flex.components.linux.ConfigureBaseOS.metadata:type_name -> aks.flex.components.api.Metadata - 1, // 1: aks.flex.components.linux.ConfigureBaseOS.spec:type_name -> aks.flex.components.linux.ConfigureBaseOSSpec - 2, // 2: aks.flex.components.linux.ConfigureBaseOS.status:type_name -> aks.flex.components.linux.ConfigureBaseOSStatus - 9, // 3: aks.flex.components.linux.DisableDocker.metadata:type_name -> aks.flex.components.api.Metadata - 4, // 4: aks.flex.components.linux.DisableDocker.spec:type_name -> aks.flex.components.linux.DisableDockerSpec - 5, // 5: aks.flex.components.linux.DisableDocker.status:type_name -> aks.flex.components.linux.DisableDockerStatus - 9, // 6: aks.flex.components.linux.ConfigureIPTables.metadata:type_name -> aks.flex.components.api.Metadata - 7, // 7: aks.flex.components.linux.ConfigureIPTables.spec:type_name -> aks.flex.components.linux.ConfigureIPTablesSpec - 8, // 8: aks.flex.components.linux.ConfigureIPTables.status:type_name -> aks.flex.components.linux.ConfigureIPTablesStatus - 9, // [9:9] is the sub-list for method output_type - 9, // [9:9] is the sub-list for method input_type - 9, // [9:9] is the sub-list for extension type_name - 9, // [9:9] is the sub-list for extension extendee - 0, // [0:9] is the sub-list for field type_name + 17, // 0: aks.flex.components.linux.ConfigureBaseOS.metadata:type_name -> aks.flex.components.api.Metadata + 2, // 1: aks.flex.components.linux.ConfigureBaseOS.spec:type_name -> aks.flex.components.linux.ConfigureBaseOSSpec + 3, // 2: aks.flex.components.linux.ConfigureBaseOS.status:type_name -> aks.flex.components.linux.ConfigureBaseOSStatus + 17, // 3: aks.flex.components.linux.DisableDocker.metadata:type_name -> aks.flex.components.api.Metadata + 5, // 4: aks.flex.components.linux.DisableDocker.spec:type_name -> aks.flex.components.linux.DisableDockerSpec + 6, // 5: aks.flex.components.linux.DisableDocker.status:type_name -> aks.flex.components.linux.DisableDockerStatus + 17, // 6: aks.flex.components.linux.ConfigureIPTables.metadata:type_name -> aks.flex.components.api.Metadata + 8, // 7: aks.flex.components.linux.ConfigureIPTables.spec:type_name -> aks.flex.components.linux.ConfigureIPTablesSpec + 9, // 8: aks.flex.components.linux.ConfigureIPTables.status:type_name -> aks.flex.components.linux.ConfigureIPTablesStatus + 17, // 9: aks.flex.components.linux.ConfigureStaticRoutes.metadata:type_name -> aks.flex.components.api.Metadata + 11, // 10: aks.flex.components.linux.ConfigureStaticRoutes.spec:type_name -> aks.flex.components.linux.ConfigureStaticRoutesSpec + 12, // 11: aks.flex.components.linux.ConfigureStaticRoutes.status:type_name -> aks.flex.components.linux.ConfigureStaticRoutesStatus + 13, // 12: aks.flex.components.linux.ConfigureStaticRoutesSpec.routes:type_name -> aks.flex.components.linux.StaticRoute + 17, // 13: aks.flex.components.linux.CheckRouteOverlap.metadata:type_name -> aks.flex.components.api.Metadata + 15, // 14: aks.flex.components.linux.CheckRouteOverlap.spec:type_name -> aks.flex.components.linux.CheckRouteOverlapSpec + 16, // 15: aks.flex.components.linux.CheckRouteOverlap.status:type_name -> aks.flex.components.linux.CheckRouteOverlapStatus + 0, // 16: aks.flex.components.linux.CheckRouteOverlapSpec.mode:type_name -> aks.flex.components.linux.CheckRouteOverlapSpec.Mode + 17, // [17:17] is the sub-list for method output_type + 17, // [17:17] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name } func init() { file_components_linux_action_proto_init() } @@ -698,13 +1507,14 @@ func file_components_linux_action_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_components_linux_action_proto_rawDesc), len(file_components_linux_action_proto_rawDesc)), - NumEnums: 0, - NumMessages: 9, + NumEnums: 1, + NumMessages: 16, NumExtensions: 0, NumServices: 0, }, GoTypes: file_components_linux_action_proto_goTypes, DependencyIndexes: file_components_linux_action_proto_depIdxs, + EnumInfos: file_components_linux_action_proto_enumTypes, MessageInfos: file_components_linux_action_proto_msgTypes, }.Build() File_components_linux_action_proto = out.File diff --git a/components/linux/action.proto b/components/linux/action.proto index 09e5e5b5..44bb293a 100644 --- a/components/linux/action.proto +++ b/components/linux/action.proto @@ -46,4 +46,98 @@ message ConfigureIPTablesSpec { } message ConfigureIPTablesStatus { -} \ No newline at end of file +} + +// ConfigureStaticRoutes installs one or more static IPv4 routes on the node +// via a systemd oneshot unit that runs `ip route replace` before kubelet +// starts. This is intended for cases where the VM provider's default routing +// is wrong for the cluster — for example, Azure ND-isr SKUs install connected +// /16 routes for the InfiniBand fabric that can shadow legitimate cluster +// CIDRs. More-specific /24 routes added via this action win over the IB /16 +// without disturbing peer-to-peer IB traffic. +message ConfigureStaticRoutes { + api.Metadata metadata = 1; + + ConfigureStaticRoutesSpec spec = 2; + + ConfigureStaticRoutesStatus status = 3; +} + +message ConfigureStaticRoutesSpec { + // enabled must be explicitly set to true before any routes are applied. + // This makes static-route injection an intentional opt-in: operators should + // only enable it after confirming a real routing overlap (for example, an + // IB /16 colliding with cluster/VNet CIDRs). + bool enabled = 2; + + // routes is the list of static routes to install. Order is preserved but + // does not affect kernel selection (longest-prefix-match wins). + repeated StaticRoute routes = 1; +} + +message ConfigureStaticRoutesStatus { +} + +// StaticRoute describes a single IPv4 route. IPv6 is intentionally not +// supported; validation rejects non-IPv4 destinations and gateways. +message StaticRoute { + // destination is an IPv4 CIDR (e.g. "172.16.1.0/24"). Required. + string destination = 1; + + // gateway is the next-hop IPv4 address. When empty the oneshot resolves + // the default gateway on `dev` at boot time (with a bounded retry, since + // DHCP may not have installed the default route yet). If the default + // gateway does not appear within the retry window, the oneshot fails + // and kubelet will not start. + string gateway = 2; + + // dev is the outbound interface (e.g. "eth0"). When empty the oneshot + // resolves the outbound interface of the IPv4 default route at boot + // time — works with both classic (eth0) and predictable (ens*, enp*) + // interface names. Must match [A-Za-z0-9_.-]{1,15} when set. + string dev = 3; + + // metric sets the route metric for tie-breaking. 0 means default. + uint32 metric = 4; +} + +// CheckRouteOverlap verifies at boot, before kubelet starts, that a list +// of expected IPv4 CIDRs (typically the cluster pod CIDR, service CIDR, +// and API server) actually route via the same interface as the IPv4 +// default route. When a CIDR resolves out a different interface — the +// classic symptom is the H200 IB driver shadowing a customer VNet CIDR +// with a connected /16 on ib0 — the check either logs a warning or +// fails the boot, depending on `mode`. +message CheckRouteOverlap { + api.Metadata metadata = 1; + + CheckRouteOverlapSpec spec = 2; + + CheckRouteOverlapStatus status = 3; +} + +message CheckRouteOverlapSpec { + // expected_cidrs are IPv4 CIDRs that kubelet, kube-proxy, and pods + // must be able to reach via the IPv4 default route's outbound + // interface. Typically populated by the controller from the cluster's + // pod CIDR, service CIDR, and API server endpoint. + repeated string expected_cidrs = 1; + + // mode controls what happens when an overlap is detected. + // WARN : log + write /run/aks-flex-node/route-overlap.detected; + // kubelet starts anyway. + // STRICT : same logging, then exit 1 — kubelet does not start + // (the unit is RequiredBy=kubelet.service). Use STRICT in + // production where a misrouted node is worse than a node + // that won't join. + Mode mode = 2; + + enum Mode { + MODE_UNSPECIFIED = 0; + WARN = 1; + STRICT = 2; + } +} + +message CheckRouteOverlapStatus { +} diff --git a/components/linux/v20260301/assets/check-route-overlap.service b/components/linux/v20260301/assets/check-route-overlap.service new file mode 100644 index 00000000..b1f345e6 --- /dev/null +++ b/components/linux/v20260301/assets/check-route-overlap.service @@ -0,0 +1,15 @@ +[Unit] +Description=AKSFlexNode IPv4 route overlap pre-flight check +After=network-online.target static-routes.service +Wants=network-online.target +Before=kubelet.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/bin/bash /etc/aks-flex-node/check-route-overlap.sh +StandardOutput=journal +StandardError=journal + +[Install] +RequiredBy=kubelet.service diff --git a/components/linux/v20260301/assets/check-route-overlap.sh.tpl b/components/linux/v20260301/assets/check-route-overlap.sh.tpl new file mode 100644 index 00000000..07ed5487 --- /dev/null +++ b/components/linux/v20260301/assets/check-route-overlap.sh.tpl @@ -0,0 +1,54 @@ +#!/bin/bash +# Generated by AKSFlexNode CheckRouteOverlap. Do not edit. mode={{ .ModeLabel }} +set -eu +PATH=/usr/sbin:/sbin:/usr/bin:/bin:${PATH:-} + +mkdir -p /run/aks-flex-node +rm -f /run/aks-flex-node/route-overlap.detected +rm -f /run/aks-flex-node/route-overlap.ok + +DEFAULT_DEV=$(ip -4 route show default 2>/dev/null | awk '/^default / {for (i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}') +if [ -z "$DEFAULT_DEV" ]; then + echo "check-route-overlap: no IPv4 default route; cannot determine outbound interface" >&2 + echo "no-default-route" > /run/aks-flex-node/route-overlap.detected + exit {{ .FailExit }} +fi + +{{- if .HasEntries }} +bad=0 +while IFS='|' read -r CIDR PROBE; do + [ -z "$CIDR" ] && continue + ACTUAL=$(ip -4 route get "$PROBE" 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}') + if [ -z "$ACTUAL" ]; then ACTUAL=""; fi + if [ "$ACTUAL" != "$DEFAULT_DEV" ]; then + if [ "$ACTUAL" = "" ]; then + msg="NO-ROUTE: expected CIDR $CIDR (probe $PROBE) has no IPv4 route; expected via $DEFAULT_DEV" + else + msg="OVERLAP: expected CIDR $CIDR (probe $PROBE) routes via $ACTUAL, expected $DEFAULT_DEV" + fi + echo "$msg" >&2 + echo "$msg" >> /run/aks-flex-node/route-overlap.detected + bad=1 + fi +done <<'EOF' +{{ .Entries }} +EOF + +if [ "$bad" -eq 1 ]; then + cat >&2 <<'EOF' +Action: configure spec.staticRoutes on the NodeClass with more-specific +routes for the affected CIDRs, or rebuild the cluster on a non-overlapping +VNet CIDR. For each affected CIDR, add a spec.staticRoutes entry with the +destination CIDR and next-hop/default gateway for the node's normal outbound interface. +EOF + exit {{ .FailExit }} +fi + +echo "check-route-overlap: all expected CIDRs route via $DEFAULT_DEV" +touch /run/aks-flex-node/route-overlap.ok +exit 0 +{{- else }} +echo "check-route-overlap: no expected CIDRs configured; nothing to check (default dev: $DEFAULT_DEV)" +touch /run/aks-flex-node/route-overlap.ok +exit 0 +{{- end }} diff --git a/components/linux/v20260301/assets/static-routes.service b/components/linux/v20260301/assets/static-routes.service new file mode 100644 index 00000000..ccbc53ea --- /dev/null +++ b/components/linux/v20260301/assets/static-routes.service @@ -0,0 +1,16 @@ +[Unit] +Description=Install AKSFlexNode static routes +# Route install must happen before kubelet tries to reach cluster services +# whose CIDR may otherwise be shadowed by provider-installed connected +# routes (e.g. the Azure InfiniBand fabric /16 on ND-isr SKUs). +Before=kubelet.service +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/etc/aks-flex-node/static-routes.sh +RemainAfterExit=yes + +[Install] +RequiredBy=kubelet.service diff --git a/components/linux/v20260301/assets/static-routes.sh.tpl b/components/linux/v20260301/assets/static-routes.sh.tpl new file mode 100644 index 00000000..6ffd0d1d --- /dev/null +++ b/components/linux/v20260301/assets/static-routes.sh.tpl @@ -0,0 +1,59 @@ +#!/bin/bash +# Generated by AKSFlexNode ConfigureStaticRoutes. Do not edit. +set -eu +PATH=/usr/sbin:/sbin:/usr/bin:/bin:${PATH:-} + +# resolve_default_gw : prints the default gateway for after retrying +# for up to ~30s, in case cloud-init / DHCP has not installed it yet. +resolve_default_gw() { + local dev="$1" + local i gw + for i in $(seq 1 30); do + gw=$(ip -4 route show default dev "$dev" 2>/dev/null | awk '/^default via/ {print $3; exit}') + if [ -n "$gw" ]; then echo "$gw"; return 0; fi + sleep 1 + done + return 1 +} + +# resolve_default_dev: prints the outbound interface of the IPv4 default +# route (e.g. eth0, ens3, enp0s6). Retries up to ~30s for DHCP. +resolve_default_dev() { + local i dev + for i in $(seq 1 30); do + dev=$(ip -4 route show default 2>/dev/null | awk '/^default / {for (i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}') + if [ -n "$dev" ]; then echo "$dev"; return 0; fi + sleep 1 + done + return 1 +} + +{{- if .HasEntries }} +DEFAULT_DEV="" +resolve_default_dev_cached() { + if [ -n "$DEFAULT_DEV" ]; then echo "$DEFAULT_DEV"; return 0; fi + DEFAULT_DEV=$(resolve_default_dev) || return 1 + echo "$DEFAULT_DEV" +} + +while IFS='|' read -r DEST DEV GW METRIC; do + [ -z "$DEST" ] && continue + if [ "$DEV" = "{{ .AutoDevToken }}" ]; then + DEV=$(resolve_default_dev_cached) || { echo "no default IPv4 route; cannot install route $DEST" >&2; exit 1; } + fi + if [ "$GW" = "{{ .AutoGWToken }}" ]; then + GW=$(resolve_default_gw "$DEV") || { echo "no default gateway on $DEV after 30s; cannot install route $DEST" >&2; exit 1; } + fi + if [ "$METRIC" -gt 0 ]; then + ip -4 route replace "$DEST" via "$GW" dev "$DEV" metric "$METRIC" + else + ip -4 route replace "$DEST" via "$GW" dev "$DEV" + fi +done <<'EOF' +{{ .Entries }} +EOF +exit 0 +{{- else }} +# No routes configured; nothing to do. +exit 0 +{{- end }} diff --git a/components/linux/v20260301/check_route_overlap.go b/components/linux/v20260301/check_route_overlap.go new file mode 100644 index 00000000..de0642ee --- /dev/null +++ b/components/linux/v20260301/check_route_overlap.go @@ -0,0 +1,187 @@ +package v20260301 + +import ( + "bytes" + "context" + _ "embed" + "fmt" + "net/netip" + "strings" + "text/template" + + "google.golang.org/protobuf/types/known/anypb" + + "github.com/Azure/AKSFlexNode/components/linux" + "github.com/Azure/AKSFlexNode/components/services/actions" + "github.com/Azure/AKSFlexNode/pkg/config" + "github.com/Azure/AKSFlexNode/pkg/systemd" + "github.com/Azure/AKSFlexNode/pkg/utils/utilpb" +) + +const ( + checkRouteOverlapUnit = "check-route-overlap.service" + checkRouteOverlapScriptPath = config.ConfigDir + "/check-route-overlap.sh" +) + +//go:embed assets/check-route-overlap.service +var checkRouteOverlapServiceUnit []byte + +//go:embed assets/check-route-overlap.sh.tpl +var checkRouteOverlapScriptTemplate string + +// checkRouteOverlapAction installs a oneshot systemd unit that, before +// kubelet starts, verifies that a list of expected IPv4 CIDRs all route +// via the IPv4 default route's outbound interface. The classic failure +// it catches is the Azure ND-isr H200 IB driver shadowing a customer +// VNet CIDR with a connected /16 on ib0 — in that case `ip -4 route get +// ` returns ib0 instead of eth0, traffic blackholes, +// and kubelet looks healthy while pods can't reach the API server. +// +// Pair with ConfigureStaticRoutes (which fixes the overlap) for full +// coverage: the static-routes oneshot is ordered Before this one, so by +// the time the check runs the kernel route table reflects any +// mitigations. +type checkRouteOverlapAction struct { + systemd systemd.Manager +} + +func newCheckRouteOverlapAction() (actions.Server, error) { + return &checkRouteOverlapAction{ + systemd: systemd.New(), + }, nil +} + +var _ actions.Server = (*checkRouteOverlapAction)(nil) + +func (a *checkRouteOverlapAction) ApplyAction( + ctx context.Context, + req *actions.ApplyActionRequest, +) (*actions.ApplyActionResponse, error) { + settings, err := utilpb.AnyTo[*linux.CheckRouteOverlap](req.GetItem()) + if err != nil { + return nil, err + } + + spec := settings.GetSpec() + if err := validateCheckRouteOverlapSpec(spec); err != nil { + return nil, fmt.Errorf("validating CheckRouteOverlap spec: %w", err) + } + mode := spec.GetMode() + if mode == linux.CheckRouteOverlapSpec_MODE_UNSPECIFIED { + mode = linux.CheckRouteOverlapSpec_WARN + } + + script, err := renderCheckRouteOverlapScript(spec.GetExpectedCidrs(), mode) + if err != nil { + return nil, fmt.Errorf("rendering check-route-overlap script: %w", err) + } + + scriptUpdated, err := writeScriptIfChanged(checkRouteOverlapScriptPath, []byte(script)) + if err != nil { + return nil, fmt.Errorf("writing check-route-overlap script: %w", err) + } + + if err := a.ensureCheckRouteOverlapUnit(ctx, scriptUpdated); err != nil { + return nil, fmt.Errorf("configuring check-route-overlap service: %w", err) + } + + item, err := anypb.New(settings) + if err != nil { + return nil, err + } + return actions.ApplyActionResponse_builder{Item: item}.Build(), nil +} + +func validateCheckRouteOverlapSpec(spec *linux.CheckRouteOverlapSpec) error { + if spec == nil { + return fmt.Errorf("spec is required") + } + switch spec.GetMode() { + case linux.CheckRouteOverlapSpec_MODE_UNSPECIFIED, + linux.CheckRouteOverlapSpec_WARN, + linux.CheckRouteOverlapSpec_STRICT: + return nil + default: + return fmt.Errorf( + "invalid mode %d: must be one of MODE_UNSPECIFIED(0), WARN(1), or STRICT(2)", + spec.GetMode(), + ) + } +} + +func (a *checkRouteOverlapAction) ensureCheckRouteOverlapUnit(ctx context.Context, scriptUpdated bool) error { + unitUpdated, err := a.systemd.EnsureUnitFile(ctx, checkRouteOverlapUnit, checkRouteOverlapServiceUnit) + if err != nil { + return err + } + if err := a.systemd.EnableUnit(ctx, checkRouteOverlapUnit); err != nil { + return err + } + changed := unitUpdated || scriptUpdated + return systemd.EnsureUnitRunning(ctx, a.systemd, checkRouteOverlapUnit, unitUpdated, changed) +} + +// renderCheckRouteOverlapScript produces a bash script that, for each +// expected CIDR, picks a probe address inside the prefix and runs +// `ip -4 route get ` to ask the kernel which interface that +// address would actually go out. Any mismatch with the IPv4 default +// route's interface is logged; in STRICT mode the script then exits 1 +// and (because the unit is RequiredBy=kubelet.service) kubelet does +// not start. +func renderCheckRouteOverlapScript(cidrs []string, mode linux.CheckRouteOverlapSpec_Mode) (string, error) { + type entry struct { + cidr string + probe string + } + entries := make([]entry, 0, len(cidrs)) + for i, c := range cidrs { + prefix, err := netip.ParsePrefix(c) + if err != nil { + return "", fmt.Errorf("expected_cidrs[%d]: invalid CIDR %q: %w", i, c, err) + } + if !prefix.Addr().Is4() { + return "", fmt.Errorf("expected_cidrs[%d]: %q is not IPv4", i, c) + } + maskedPrefix := prefix.Masked() + // Probe with first usable address (network address + 1). Works for + // any prefix shorter than /32; for /32 we just probe the address. + // Use the masked/network form so the probe stays inside the CIDR even + // when the provided prefix is not network-aligned. + probe := maskedPrefix.Addr() + if maskedPrefix.Bits() < 32 { + next := probe.Next() + if maskedPrefix.Contains(next) { + probe = next + } + } + entries = append(entries, entry{cidr: c, probe: probe.String()}) + } + + failExit := "0" + modeLabel := "WARN" + if mode == linux.CheckRouteOverlapSpec_STRICT { + failExit = "1" + modeLabel = "STRICT" + } + + lines := make([]string, 0, len(entries)) + for _, e := range entries { + lines = append(lines, fmt.Sprintf("%s|%s", e.cidr, e.probe)) + } + + tmpl, err := template.New("check-route-overlap.sh.tpl").Parse(checkRouteOverlapScriptTemplate) + if err != nil { + return "", err + } + var out bytes.Buffer + if err := tmpl.Execute(&out, map[string]any{ + "ModeLabel": modeLabel, + "FailExit": failExit, + "HasEntries": len(lines) > 0, + "Entries": strings.Join(lines, "\n"), + }); err != nil { + return "", err + } + + return out.String(), nil +} diff --git a/components/linux/v20260301/check_route_overlap_test.go b/components/linux/v20260301/check_route_overlap_test.go new file mode 100644 index 00000000..530398d5 --- /dev/null +++ b/components/linux/v20260301/check_route_overlap_test.go @@ -0,0 +1,199 @@ +package v20260301 + +import ( + "strings" + "testing" + + "github.com/Azure/AKSFlexNode/components/linux" +) + +func TestRenderCheckRouteOverlapScript(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cidrs []string + mode linux.CheckRouteOverlapSpec_Mode + wantContains []string + wantErr bool + }{ + { + name: "empty CIDRs is a no-op", + cidrs: nil, + mode: linux.CheckRouteOverlapSpec_WARN, + wantContains: []string{ + "#!/bin/bash", + "mode=WARN", + "no expected CIDRs configured", + "touch /run/aks-flex-node/route-overlap.ok", + "exit 0", + }, + }, + { + name: "WARN mode exits 0 on overlap", + cidrs: []string{"172.16.0.0/16"}, + mode: linux.CheckRouteOverlapSpec_WARN, + wantContains: []string{ + "mode=WARN", + `ACTUAL=$(ip -4 route get "$PROBE"`, + `msg="NO-ROUTE: expected CIDR $CIDR (probe $PROBE) has no IPv4 route; expected via $DEFAULT_DEV"`, + `msg="OVERLAP: expected CIDR $CIDR (probe $PROBE) routes via $ACTUAL, expected $DEFAULT_DEV"`, + "172.16.0.0/16|172.16.0.1", + "if [ \"$bad\" -eq 1 ]; then", + "For each affected CIDR, add a spec.staticRoutes entry with the", + " exit 0", + }, + }, + { + name: "STRICT mode exits 1 on overlap", + cidrs: []string{"172.16.0.0/16", "10.0.0.0/8"}, + mode: linux.CheckRouteOverlapSpec_STRICT, + wantContains: []string{ + "mode=STRICT", + "172.16.0.0/16|172.16.0.1", + "10.0.0.0/8|10.0.0.1", + "if [ \"$bad\" -eq 1 ]; then", + " exit 1", + }, + }, + { + name: "MODE_UNSPECIFIED defaults are caller's job; renderer treats it as WARN by exit code", + cidrs: []string{"172.16.0.0/24"}, + mode: linux.CheckRouteOverlapSpec_WARN, + wantContains: []string{ + "mode=WARN", + " exit 0", + }, + }, + { + name: "rejects invalid CIDR", + cidrs: []string{"not-a-cidr"}, + mode: linux.CheckRouteOverlapSpec_WARN, + wantErr: true, + }, + { + name: "rejects IPv6 CIDR", + cidrs: []string{"2001:db8::/32"}, + mode: linux.CheckRouteOverlapSpec_WARN, + wantErr: true, + }, + { + name: "single host /32 is probed at the address itself", + cidrs: []string{"169.254.169.254/32"}, + mode: linux.CheckRouteOverlapSpec_STRICT, + wantContains: []string{ + "169.254.169.254/32|169.254.169.254", + }, + }, + { + name: "always emits no-default-route guard", + cidrs: []string{"172.16.0.0/24"}, + mode: linux.CheckRouteOverlapSpec_STRICT, + wantContains: []string{ + `awk '/^default / {for (i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}'`, + "no IPv4 default route; cannot determine outbound interface", + `echo "no-default-route" > /run/aks-flex-node/route-overlap.detected`, + }, + }, + { + name: "non-network-aligned prefix probes inside the CIDR", + cidrs: []string{"10.0.0.255/24"}, + mode: linux.CheckRouteOverlapSpec_WARN, + wantContains: []string{ + "10.0.0.255/24|10.0.0.1", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := renderCheckRouteOverlapScript(tc.cidrs, tc.mode) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got script:\n%s", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, want := range tc.wantContains { + if !strings.Contains(got, want) { + t.Errorf("script missing %q\nscript:\n%s", want, got) + } + } + }) + } +} + +func TestRenderCheckRouteOverlapScriptDefaultExitInGuard(t *testing.T) { + // In STRICT mode the no-default-route guard must also exit 1. + t.Parallel() + got, err := renderCheckRouteOverlapScript([]string{"172.16.0.0/24"}, linux.CheckRouteOverlapSpec_STRICT) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // The guard for "no default route" should use exit 1 in STRICT. + guard := "echo \"no-default-route\" > /run/aks-flex-node/route-overlap.detected\n exit 1\n" + if !strings.Contains(got, guard) { + t.Errorf("STRICT mode missing exit-1 in no-default-route guard\nscript:\n%s", got) + } +} + +func TestRenderCheckRouteOverlapScriptAvoidsRepoSourcePathInGuidance(t *testing.T) { + t.Parallel() + got, err := renderCheckRouteOverlapScript([]string{"172.16.0.0/24"}, linux.CheckRouteOverlapSpec_WARN) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(got, "AKSFlexNode/components/linux/v20260301/configure_static_routes.go") { + t.Fatalf("runtime guidance must not reference repository source paths:\n%s", got) + } +} + +func TestValidateCheckRouteOverlapSpec(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec *linux.CheckRouteOverlapSpec + wantErr bool + }{ + { + name: "nil spec is rejected", + spec: nil, + wantErr: true, + }, + { + name: "non-nil spec is accepted", + spec: linux.CheckRouteOverlapSpec_builder{ + ExpectedCidrs: []string{"172.16.0.0/16"}, + }.Build(), + wantErr: false, + }, + { + name: "unknown mode is rejected", + spec: func() *linux.CheckRouteOverlapSpec { + mode := linux.CheckRouteOverlapSpec_Mode(99) + return linux.CheckRouteOverlapSpec_builder{ + Mode: &mode, + }.Build() + }(), + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := validateCheckRouteOverlapSpec(tc.spec) + if tc.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("expected no error, got: %v", err) + } + }) + } +} diff --git a/components/linux/v20260301/configure_static_routes.go b/components/linux/v20260301/configure_static_routes.go new file mode 100644 index 00000000..948fb9cc --- /dev/null +++ b/components/linux/v20260301/configure_static_routes.go @@ -0,0 +1,245 @@ +package v20260301 + +import ( + "bytes" + "context" + _ "embed" + "errors" + "fmt" + "net/netip" + "os" + "strings" + "text/template" + + "google.golang.org/protobuf/types/known/anypb" + + "github.com/Azure/AKSFlexNode/components/linux" + "github.com/Azure/AKSFlexNode/components/services/actions" + "github.com/Azure/AKSFlexNode/pkg/config" + "github.com/Azure/AKSFlexNode/pkg/systemd" + "github.com/Azure/AKSFlexNode/pkg/utils/utilio" + "github.com/Azure/AKSFlexNode/pkg/utils/utilpb" +) + +const ( + staticRoutesUnit = "static-routes.service" + staticRoutesScriptPath = config.ConfigDir + "/static-routes.sh" +) + +//go:embed assets/static-routes.service +var staticRoutesServiceUnit []byte + +//go:embed assets/static-routes.sh.tpl +var staticRoutesScriptTemplate string + +// configureStaticRoutesAction installs a oneshot systemd unit that applies +// one or more static IPv4 routes via `ip route replace` before kubelet +// starts. Intended for cases where the VM provider's default routing is +// wrong for the cluster — for example, Azure ND-isr SKUs install connected +// /16 routes for the InfiniBand fabric that can shadow legitimate cluster +// CIDRs. More-specific routes added via this action win over the IB /16 +// without disturbing peer-to-peer IB traffic. +type configureStaticRoutesAction struct { + systemd systemd.Manager +} + +func newConfigureStaticRoutesAction() (actions.Server, error) { + return &configureStaticRoutesAction{ + systemd: systemd.New(), + }, nil +} + +var _ actions.Server = (*configureStaticRoutesAction)(nil) + +func (a *configureStaticRoutesAction) ApplyAction( + ctx context.Context, + req *actions.ApplyActionRequest, +) (*actions.ApplyActionResponse, error) { + settings, err := utilpb.AnyTo[*linux.ConfigureStaticRoutes](req.GetItem()) + if err != nil { + return nil, err + } + + spec := settings.GetSpec() + if err := validateConfigureStaticRoutesSpec(spec); err != nil { + return nil, fmt.Errorf("validating ConfigureStaticRoutes spec: %w", err) + } + + routes := spec.GetRoutes() + if len(routes) == 0 { + if err := systemd.EnsureUnitStoppedAndDisabled(ctx, a.systemd, staticRoutesUnit); err != nil { + return nil, fmt.Errorf("disabling static-routes service: %w", err) + } + item, err := anypb.New(settings) + if err != nil { + return nil, err + } + return actions.ApplyActionResponse_builder{Item: item}.Build(), nil + } + + script, err := renderStaticRoutesScript(routes) + if err != nil { + return nil, fmt.Errorf("rendering static-routes script: %w", err) + } + + scriptUpdated, err := writeScriptIfChanged(staticRoutesScriptPath, []byte(script)) + if err != nil { + return nil, fmt.Errorf("writing static-routes script: %w", err) + } + + if err := a.ensureStaticRoutesUnit(ctx, scriptUpdated); err != nil { + return nil, fmt.Errorf("configuring static-routes service: %w", err) + } + + item, err := anypb.New(settings) + if err != nil { + return nil, err + } + return actions.ApplyActionResponse_builder{Item: item}.Build(), nil +} + +func validateConfigureStaticRoutesSpec(spec *linux.ConfigureStaticRoutesSpec) error { + if spec == nil { + return fmt.Errorf("spec is required") + } + if len(spec.GetRoutes()) > 0 && !spec.GetEnabled() { + return fmt.Errorf("routes were provided but spec.enabled=false; set spec.enabled=true to apply static routes") + } + return nil +} + +// ensureStaticRoutesUnit installs, enables, and (re)starts the +// static-routes.service oneshot unit. The unit runs the script at +// /etc/aks-flex-node/static-routes.sh before kubelet.service starts. +// The service is restarted whenever the unit file or the script content +// has changed — the latter matters because RemainAfterExit=yes means an +// active oneshot will not rerun by itself. +func (a *configureStaticRoutesAction) ensureStaticRoutesUnit(ctx context.Context, scriptUpdated bool) error { + unitUpdated, err := a.systemd.EnsureUnitFile(ctx, staticRoutesUnit, staticRoutesServiceUnit) + if err != nil { + return err + } + if err := a.systemd.EnableUnit(ctx, staticRoutesUnit); err != nil { + return err + } + changed := unitUpdated || scriptUpdated + return systemd.EnsureUnitRunning(ctx, a.systemd, staticRoutesUnit, unitUpdated, changed) +} + +// writeScriptIfChanged writes content to path only when the existing file +// differs. Returns true when the file was created or updated. Compares +// byte-for-byte (no whitespace trimming) because the script is entirely +// machine-generated. +func writeScriptIfChanged(path string, content []byte) (bool, error) { + existing, err := os.ReadFile(path) //#nosec G304 -- trusted path constructed from constant + switch { + case errors.Is(err, os.ErrNotExist): + // fall through to write + case err != nil: + return false, err + default: + if bytes.Equal(existing, content) { + return false, nil + } + } + if err := utilio.WriteFile(path, content, 0o755); err != nil { + return false, err + } + return true, nil +} + +// renderStaticRoutesScript produces an idempotent bash script that applies +// each route via `ip -4 route replace`. When `dev` is empty the script +// resolves the outbound interface from the default IPv4 route at boot time. +// When `gateway` is empty the script resolves the default gateway on that +// dev. Both lookups retry briefly to survive DHCP races. +func renderStaticRoutesScript(routes []*linux.StaticRoute) (string, error) { + type entry struct { + dest string + dev string + gw string + metric uint32 + } + const ( + autoDevToken = "@@AUTO_DEV@@" + autoGWToken = "@@AUTO_GW@@" + ) + entries := make([]entry, 0, len(routes)) + for i, r := range routes { + dest := r.GetDestination() + prefix, err := netip.ParsePrefix(dest) + if err != nil { + return "", fmt.Errorf("route %d: invalid destination %q: %w", i, dest, err) + } + if !prefix.Addr().Is4() { + return "", fmt.Errorf("route %d: destination %q is not IPv4", i, dest) + } + if gw := r.GetGateway(); gw != "" { + gwAddr, err := netip.ParseAddr(gw) + if err != nil { + return "", fmt.Errorf("route %d: invalid gateway %q: %w", i, gw, err) + } + if !gwAddr.Is4() { + return "", fmt.Errorf("route %d: gateway %q is not IPv4", i, gw) + } + } + if dev := r.GetDev(); dev != "" && !isSafeIfaceName(dev) { + return "", fmt.Errorf("route %d: invalid dev name %q", i, dev) + } + dev := r.GetDev() + if dev == "" { + dev = autoDevToken + } + gw := r.GetGateway() + if gw == "" { + gw = autoGWToken + } + entries = append(entries, entry{ + dest: dest, + dev: dev, + gw: gw, + metric: r.GetMetric(), + }) + } + + lines := make([]string, 0, len(entries)) + for _, e := range entries { + lines = append(lines, fmt.Sprintf("%s|%s|%s|%d", e.dest, e.dev, e.gw, e.metric)) + } + + tmpl, err := template.New("static-routes.sh.tpl").Parse(staticRoutesScriptTemplate) + if err != nil { + return "", err + } + var out bytes.Buffer + if err := tmpl.Execute(&out, map[string]any{ + "AutoDevToken": autoDevToken, + "AutoGWToken": autoGWToken, + "HasEntries": len(lines) > 0, + "Entries": strings.Join(lines, "\n"), + }); err != nil { + return "", err + } + + return out.String(), nil +} + +// isSafeIfaceName rejects shell metacharacters to keep the generated script +// safe against malformed spec inputs. Linux interface names are <=15 chars +// of [A-Za-z0-9_.-]. +func isSafeIfaceName(s string) bool { + if len(s) == 0 || len(s) > 15 { + return false + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '_' || r == '.' || r == '-': + default: + return false + } + } + return true +} diff --git a/components/linux/v20260301/configure_static_routes_test.go b/components/linux/v20260301/configure_static_routes_test.go new file mode 100644 index 00000000..366e97ef --- /dev/null +++ b/components/linux/v20260301/configure_static_routes_test.go @@ -0,0 +1,388 @@ +package v20260301 + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/coreos/go-systemd/v22/dbus" + "google.golang.org/protobuf/types/known/anypb" + + "github.com/Azure/AKSFlexNode/components/linux" + "github.com/Azure/AKSFlexNode/components/services/actions" + "github.com/Azure/AKSFlexNode/pkg/systemd" +) + +func TestRenderStaticRoutesScript(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + routes []*linux.StaticRoute + wantContains []string + wantErr bool + }{ + { + name: "empty is no-op", + routes: nil, + wantContains: []string{ + "#!/bin/bash", + "No routes configured", + "exit 0", + }, + }, + { + name: "single route with explicit gateway", + routes: []*linux.StaticRoute{ + linux.StaticRoute_builder{ + Destination: to.Ptr("172.16.1.0/24"), + Gateway: to.Ptr("172.18.1.1"), + Dev: to.Ptr("eth0"), + }.Build(), + }, + wantContains: []string{ + `172.16.1.0/24|eth0|172.18.1.1|0`, + `ip -4 route replace "$DEST" via "$GW" dev "$DEV"`, + }, + }, + { + name: "auto-resolve gateway when empty", + routes: []*linux.StaticRoute{ + linux.StaticRoute_builder{ + Destination: to.Ptr("172.16.2.0/24"), + }.Build(), + }, + wantContains: []string{ + `resolve_default_dev_cached`, + `awk '/^default / {for (i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}'`, + `172.16.2.0/24|@@AUTO_DEV@@|@@AUTO_GW@@|0`, + `cannot install route $DEST`, + `ip -4 route replace "$DEST" via "$GW" dev "$DEV"`, + }, + }, + { + name: "metric included when nonzero", + routes: []*linux.StaticRoute{ + linux.StaticRoute_builder{ + Destination: to.Ptr("10.0.0.0/8"), + Gateway: to.Ptr("10.1.0.1"), + Metric: to.Ptr[uint32](100), + }.Build(), + }, + wantContains: []string{ + `10.0.0.0/8|@@AUTO_DEV@@|10.1.0.1|100`, + `metric "$METRIC"`, + }, + }, + { + name: "rejects invalid destination", + routes: []*linux.StaticRoute{ + linux.StaticRoute_builder{Destination: to.Ptr("not-a-cidr")}.Build(), + }, + wantErr: true, + }, + { + name: "rejects invalid gateway", + routes: []*linux.StaticRoute{ + linux.StaticRoute_builder{ + Destination: to.Ptr("172.16.1.0/24"), + Gateway: to.Ptr("not-an-ip"), + }.Build(), + }, + wantErr: true, + }, + { + name: "rejects IPv6 destination", + routes: []*linux.StaticRoute{ + linux.StaticRoute_builder{Destination: to.Ptr("2001:db8::/32")}.Build(), + }, + wantErr: true, + }, + { + name: "rejects IPv6 gateway", + routes: []*linux.StaticRoute{ + linux.StaticRoute_builder{ + Destination: to.Ptr("172.16.1.0/24"), + Gateway: to.Ptr("2001:db8::1"), + }.Build(), + }, + wantErr: true, + }, + { + name: "auto-resolve fails hard when gateway never appears", + routes: []*linux.StaticRoute{ + linux.StaticRoute_builder{Destination: to.Ptr("172.16.2.0/24")}.Build(), + }, + wantContains: []string{ + "resolve_default_gw", + "|| { echo", + "exit 1", + }, + }, + { + name: "rejects shell-meta in dev name", + routes: []*linux.StaticRoute{ + linux.StaticRoute_builder{ + Destination: to.Ptr("172.16.1.0/24"), + Dev: to.Ptr("eth0; rm -rf /"), + }.Build(), + }, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := renderStaticRoutesScript(tc.routes) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got script:\n%s", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, want := range tc.wantContains { + if !strings.Contains(got, want) { + t.Errorf("script missing %q\nscript:\n%s", want, got) + } + } + }) + } +} + +func TestValidateConfigureStaticRoutesSpec(t *testing.T) { + t.Parallel() + + routes := []*linux.StaticRoute{ + linux.StaticRoute_builder{Destination: to.Ptr("172.16.1.0/24"), Gateway: to.Ptr("172.18.1.1")}.Build(), + } + + tests := []struct { + name string + spec *linux.ConfigureStaticRoutesSpec + wantErr bool + }{ + { + name: "nil spec is rejected", + spec: nil, + wantErr: true, + }, + { + name: "routes without explicit opt-in are rejected", + spec: linux.ConfigureStaticRoutesSpec_builder{ + Routes: routes, + }.Build(), + wantErr: true, + }, + { + name: "routes with explicit opt-in are accepted", + spec: linux.ConfigureStaticRoutesSpec_builder{ + Enabled: to.Ptr(true), + Routes: routes, + }.Build(), + }, + { + name: "no routes with opt-in disabled is allowed", + spec: linux.ConfigureStaticRoutesSpec_builder{ + Enabled: to.Ptr(false), + }.Build(), + }, + { + name: "no routes with opt-in enabled is allowed", + spec: linux.ConfigureStaticRoutesSpec_builder{ + Enabled: to.Ptr(true), + }.Build(), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := validateConfigureStaticRoutesSpec(tc.spec) + if tc.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("expected no error, got: %v", err) + } + }) + } +} + +func TestApplyActionWithNoRoutesDisablesUnit(t *testing.T) { + t.Parallel() + + manager := &fakeSystemdManager{ + unitStatus: dbus.UnitStatus{ + Name: staticRoutesUnit, + ActiveState: systemd.UnitActiveStateActive, + }, + } + action := &configureStaticRoutesAction{systemd: manager} + + item, err := anypb.New( + linux.ConfigureStaticRoutes_builder{ + Spec: linux.ConfigureStaticRoutesSpec_builder{ + Enabled: to.Ptr(false), + }.Build(), + }.Build(), + ) + if err != nil { + t.Fatalf("marshal action: %v", err) + } + + _, err = action.ApplyAction(context.Background(), actions.ApplyActionRequest_builder{Item: item}.Build()) + if err != nil { + t.Fatalf("ApplyAction returned error: %v", err) + } + + if !manager.stopCalled { + t.Fatalf("expected StopUnit to be called for empty routes") + } + if !manager.disableCalled { + t.Fatalf("expected DisableUnit to be called for empty routes") + } + if manager.ensureUnitFileCalled { + t.Fatalf("did not expect EnsureUnitFile to be called for empty routes") + } + if manager.enableCalled { + t.Fatalf("did not expect EnableUnit to be called for empty routes") + } +} + +func TestRenderStaticRoutesScriptIsDeterministic(t *testing.T) { + t.Parallel() + routes := []*linux.StaticRoute{ + linux.StaticRoute_builder{Destination: to.Ptr("172.16.1.0/24"), Gateway: to.Ptr("172.18.1.1")}.Build(), + linux.StaticRoute_builder{Destination: to.Ptr("172.16.2.0/24")}.Build(), + } + first, err := renderStaticRoutesScript(routes) + if err != nil { + t.Fatalf("first render: %v", err) + } + second, err := renderStaticRoutesScript(routes) + if err != nil { + t.Fatalf("second render: %v", err) + } + if first != second { + t.Errorf("non-deterministic output.\nfirst:\n%s\nsecond:\n%s", first, second) + } +} + +func TestIsSafeIfaceName(t *testing.T) { + t.Parallel() + tests := map[string]bool{ + "eth0": true, + "ib6": true, + "bond0.100": true, + "veth-foo_bar": true, + "": false, + "toolong123456789a": false, + "eth0; rm -rf /": false, + "$(whoami)": false, + "eth0 eth1": false, + } + for in, want := range tests { + if got := isSafeIfaceName(in); got != want { + t.Errorf("isSafeIfaceName(%q) = %v, want %v", in, got, want) + } + } +} + +func TestWriteScriptIfChanged(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "script.sh") + + // First write. + changed, err := writeScriptIfChanged(path, []byte("hello")) + if err != nil { + t.Fatalf("first write: %v", err) + } + if !changed { + t.Errorf("first write: expected changed=true") + } + + // Identical content. + info1, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + changed, err = writeScriptIfChanged(path, []byte("hello")) + if err != nil { + t.Fatalf("noop write: %v", err) + } + if changed { + t.Errorf("noop write: expected changed=false") + } + info2, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if !info1.ModTime().Equal(info2.ModTime()) { + t.Errorf("noop write touched the file (mtime changed)") + } + + // Different content. + changed, err = writeScriptIfChanged(path, []byte("goodbye")) + if err != nil { + t.Fatalf("update write: %v", err) + } + if !changed { + t.Errorf("update write: expected changed=true") + } + got, err := os.ReadFile(path) // #nosec G304 -- path is t.TempDir()-scoped + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != "goodbye" { + t.Errorf("file content = %q, want %q", got, "goodbye") + } +} + +type fakeSystemdManager struct { + unitStatusErr error + unitStatus dbus.UnitStatus + + ensureUnitFileCalled bool + enableCalled bool + stopCalled bool + disableCalled bool +} + +func (f *fakeSystemdManager) DaemonReload(_ context.Context) error { return nil } +func (f *fakeSystemdManager) EnableUnit(_ context.Context, _ string) error { + f.enableCalled = true + return nil +} +func (f *fakeSystemdManager) DisableUnit(_ context.Context, _ string) error { + f.disableCalled = true + return nil +} +func (f *fakeSystemdManager) MaskUnit(_ context.Context, _ string) error { return nil } +func (f *fakeSystemdManager) StartUnit(_ context.Context, _ string) error { return nil } +func (f *fakeSystemdManager) StopUnit(_ context.Context, _ string) error { + f.stopCalled = true + return nil +} +func (f *fakeSystemdManager) ReloadOrRestartUnit(_ context.Context, _ string) error { return nil } +func (f *fakeSystemdManager) GetUnitStatus(_ context.Context, _ string) (dbus.UnitStatus, error) { + if f.unitStatusErr != nil { + return dbus.UnitStatus{}, f.unitStatusErr + } + return f.unitStatus, nil +} +func (f *fakeSystemdManager) EnsureUnitFile(_ context.Context, _ string, _ []byte) (bool, error) { + f.ensureUnitFileCalled = true + return true, nil +} +func (f *fakeSystemdManager) EnsureDropInFile(_ context.Context, _, _ string, _ []byte) (bool, error) { + return false, nil +} diff --git a/components/linux/v20260301/exports.go b/components/linux/v20260301/exports.go index 899fb10d..2219265f 100644 --- a/components/linux/v20260301/exports.go +++ b/components/linux/v20260301/exports.go @@ -18,4 +18,12 @@ func init() { newConfigureIPTablesAction, &linux.ConfigureIPTables{}, ) + actions.MustRegister( + newConfigureStaticRoutesAction, + &linux.ConfigureStaticRoutes{}, + ) + actions.MustRegister( + newCheckRouteOverlapAction, + &linux.CheckRouteOverlap{}, + ) }