From 0af04a0405fcb5de75798d79ca9f78564884138a Mon Sep 17 00:00:00 2001 From: Andrey Smirnov Date: Mon, 27 Nov 2023 21:13:53 +0400 Subject: [PATCH] feat: implement ingress firewall rules Fixes #4421 See documentation for details on how to use the feature. With `talosctl cluster create`, firewall can be easily test with `--with-firewall=accept|block` (default mode). Signed-off-by: Andrey Smirnov --- .drone.jsonnet | 9 +- Dockerfile | 2 +- cmd/talosctl/cmd/mgmt/cluster/create.go | 25 ++ .../internal/firewallpatch/firewallpatch.go | 191 ++++++++++++ hack/release.toml | 6 + hack/test/e2e-qemu.sh | 8 + .../network/nftables_chain_config.go | 224 ++++++++++++++ .../network/nftables_chain_config_test.go | 283 ++++++++++++++++++ .../runtime/v1alpha2/v1alpha2_controller.go | 1 + pkg/machinery/config/config/config.go | 1 + pkg/machinery/config/config/helpers.go | 39 +++ pkg/machinery/config/config/network.go | 66 ++++ pkg/machinery/config/config/runtime.go | 20 -- pkg/machinery/config/container/container.go | 5 + pkg/machinery/config/encoder/encoder.go | 5 + .../types/network/deep_copy.generated.go | 27 ++ .../types/network/default_action_config.go | 62 ++++ .../network/default_action_config_test.go | 54 ++++ pkg/machinery/config/types/network/network.go | 8 + .../config/types/network/port_range.go | 102 +++++++ .../config/types/network/port_range_test.go | 124 ++++++++ .../config/types/network/rule_config.go | 154 ++++++++++ .../config/types/network/rule_config_test.go | 199 ++++++++++++ .../network/testdata/defaultactionconfig.yaml | 3 + .../types/network/testdata/ruleconfig.yaml | 12 + .../config/types/runtime/event_sink_test.go | 8 - .../config/types/runtime/kmsg_log_test.go | 13 +- .../types/siderolink/siderolink_test.go | 19 +- pkg/machinery/config/types/types.go | 1 + website/content/v1.6/reference/cli.md | 1 + .../editing-machine-configuration.md | 3 +- .../talos-guides/network/ingress-firewall.md | 233 ++++++++++++++ 32 files changed, 1858 insertions(+), 50 deletions(-) create mode 100644 cmd/talosctl/cmd/mgmt/cluster/internal/firewallpatch/firewallpatch.go create mode 100644 internal/app/machined/pkg/controllers/network/nftables_chain_config.go create mode 100644 internal/app/machined/pkg/controllers/network/nftables_chain_config_test.go create mode 100644 pkg/machinery/config/config/helpers.go create mode 100644 pkg/machinery/config/config/network.go create mode 100644 pkg/machinery/config/types/network/deep_copy.generated.go create mode 100644 pkg/machinery/config/types/network/default_action_config.go create mode 100644 pkg/machinery/config/types/network/default_action_config_test.go create mode 100644 pkg/machinery/config/types/network/network.go create mode 100644 pkg/machinery/config/types/network/port_range.go create mode 100644 pkg/machinery/config/types/network/port_range_test.go create mode 100644 pkg/machinery/config/types/network/rule_config.go create mode 100644 pkg/machinery/config/types/network/rule_config_test.go create mode 100644 pkg/machinery/config/types/network/testdata/defaultactionconfig.yaml create mode 100644 pkg/machinery/config/types/network/testdata/ruleconfig.yaml create mode 100644 website/content/v1.6/talos-guides/network/ingress-firewall.md diff --git a/.drone.jsonnet b/.drone.jsonnet index b7517437d97..d09d5470f20 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -491,7 +491,12 @@ local integration_cilium_strict = Step('e2e-cilium-strict', target='e2e-qemu', p IMAGE_REGISTRY: local_registry, }); -local integration_network_chaos = Step('e2e-network-chaos', target='e2e-qemu', privileged=true, depends_on=[load_artifacts], environment={ +local integration_firewall = Step('e2e-network-chaos', target='e2e-qemu', privileged=true, depends_on=[load_artifacts], environment={ + SHORT_INTEGRATION_TEST: 'yes', + WITH_FIREWALL: 'block', + REGISTRY: local_registry, +}); +local integration_network_chaos = Step('e2e-network-chaos', target='e2e-qemu', privileged=true, depends_on=[integration_firewall], environment={ SHORT_INTEGRATION_TEST: 'yes', WITH_NETWORK_CHAOS: 'true', REGISTRY: local_registry, @@ -605,6 +610,7 @@ local integration_pipelines = [ Pipeline('integration-provision-1', default_pipeline_steps + [integration_provision_tests_prepare, integration_provision_tests_track_1]) + integration_trigger(['integration-provision', 'integration-provision-1']), Pipeline('integration-provision-2', default_pipeline_steps + [integration_provision_tests_prepare, integration_provision_tests_track_2]) + integration_trigger(['integration-provision', 'integration-provision-2']), Pipeline('integration-misc', default_pipeline_steps + [ + integration_firewall, integration_network_chaos, integration_canal_reset, integration_bios_cgroupsv1, @@ -629,6 +635,7 @@ local integration_pipelines = [ Pipeline('cron-integration-provision-1', default_pipeline_steps + [integration_provision_tests_prepare, integration_provision_tests_track_1], [default_cron_pipeline]) + cron_trigger(['thrice-daily', 'nightly']), Pipeline('cron-integration-provision-2', default_pipeline_steps + [integration_provision_tests_prepare, integration_provision_tests_track_2], [default_cron_pipeline]) + cron_trigger(['thrice-daily', 'nightly']), Pipeline('cron-integration-misc', default_pipeline_steps + [ + integration_firewall, integration_network_chaos, integration_canal_reset, integration_bios_cgroupsv1, diff --git a/Dockerfile b/Dockerfile index 10733a437eb..cb309d045fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -292,7 +292,7 @@ COPY --from=generate-build /api/inspect/*.pb.go /pkg/machinery/api/inspect/ COPY --from=go-generate /src/pkg/flannel/ /pkg/flannel/ COPY --from=go-generate /src/pkg/imager/profile/ /pkg/imager/profile/ COPY --from=go-generate /src/pkg/machinery/resources/ /pkg/machinery/resources/ -COPY --from=go-generate /src/pkg/machinery/config/types/v1alpha1/ /pkg/machinery/config/types/v1alpha1/ +COPY --from=go-generate /src/pkg/machinery/config/types/ /pkg/machinery/config/types/ COPY --from=go-generate /src/pkg/machinery/nethelpers/ /pkg/machinery/nethelpers/ COPY --from=go-generate /src/pkg/machinery/extensions/ /pkg/machinery/extensions/ COPY --from=embed-abbrev / / diff --git a/cmd/talosctl/cmd/mgmt/cluster/create.go b/cmd/talosctl/cmd/mgmt/cluster/create.go index d5285631fd8..2624fdd770b 100644 --- a/cmd/talosctl/cmd/mgmt/cluster/create.go +++ b/cmd/talosctl/cmd/mgmt/cluster/create.go @@ -26,6 +26,7 @@ import ( "github.com/spf13/pflag" "k8s.io/client-go/tools/clientcmd" + "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/cluster/internal/firewallpatch" "github.com/siderolabs/talos/cmd/talosctl/pkg/mgmt/helpers" "github.com/siderolabs/talos/pkg/cli" "github.com/siderolabs/talos/pkg/cluster/check" @@ -84,6 +85,7 @@ const ( kubePrismFlag = "kubeprism-port" tpm2EnabledFlag = "with-tpm2" diskEncryptionKeyTypesFlag = "disk-encryption-key-types" + firewallFlag = "with-firewall" ) var ( @@ -161,6 +163,7 @@ var ( packetCorrupt float64 bandwidth int diskEncryptionKeyTypes []string + withFirewall string ) // createCmd represents the cluster up command. @@ -595,6 +598,26 @@ func create(ctx context.Context, flags *pflag.FlagSet) (err error) { return err } + if withFirewall != "" { + var defaultAction nethelpers.DefaultAction + + defaultAction, err = nethelpers.DefaultActionString(withFirewall) + if err != nil { + return err + } + + var controlplaneIPs []netip.Addr + + for i := range ips { + controlplaneIPs = append(controlplaneIPs, ips[i][:controlplanes]...) + } + + configBundleOpts = append(configBundleOpts, + bundle.WithPatchControlPlane([]configpatcher.Patch{firewallpatch.ControlPlane(defaultAction, cidrs, gatewayIPs, controlplaneIPs)}), + bundle.WithPatchWorker([]configpatcher.Patch{firewallpatch.Worker(defaultAction, cidrs, gatewayIPs)}), + ) + } + configBundle, err := bundle.NewBundle(configBundleOpts...) if err != nil { return err @@ -1010,6 +1033,7 @@ func init() { createCmd.Flags().Float64Var(&packetCorrupt, "with-network-packet-corrupt", 0.0, "specify percent of corrupt packets on the bridge interface when creating a qemu cluster. e.g. 50% = 0.50 (default: 0.0)") createCmd.Flags().IntVar(&bandwidth, "with-network-bandwidth", 0, "specify bandwidth restriction (in kbps) on the bridge interface when creating a qemu cluster") + createCmd.Flags().StringVar(&withFirewall, firewallFlag, "", "inject firewall rules into the cluster, value is default policy - accept/block (QEMU only)") Cmd.AddCommand(createCmd) } @@ -1040,6 +1064,7 @@ func checkForDefinedGenFlag(flags *pflag.FlagSet) string { forceEndpointFlag, controlPlanePortFlag, kubePrismFlag, + firewallFlag, } for _, genFlag := range genOptionFlags { if flags.Lookup(genFlag).Changed { diff --git a/cmd/talosctl/cmd/mgmt/cluster/internal/firewallpatch/firewallpatch.go b/cmd/talosctl/cmd/mgmt/cluster/internal/firewallpatch/firewallpatch.go new file mode 100644 index 00000000000..af4f9d8fe54 --- /dev/null +++ b/cmd/talosctl/cmd/mgmt/cluster/internal/firewallpatch/firewallpatch.go @@ -0,0 +1,191 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Package firewallpatch provides a set of default config patches to enable firewall. +package firewallpatch + +import ( + "net/netip" + + "github.com/siderolabs/gen/xslices" + + "github.com/siderolabs/talos/pkg/machinery/config/configpatcher" + "github.com/siderolabs/talos/pkg/machinery/config/container" + "github.com/siderolabs/talos/pkg/machinery/config/types/network" + "github.com/siderolabs/talos/pkg/machinery/constants" + "github.com/siderolabs/talos/pkg/machinery/nethelpers" +) + +func ingressRuleWithinCluster(cidrs []netip.Prefix, gateways []netip.Addr) []network.IngressRule { + rules := make([]network.IngressRule, 0, len(cidrs)) + + for i := range cidrs { + rules = append(rules, + network.IngressRule{ + Subnet: cidrs[i], + Except: netip.PrefixFrom(gateways[i], gateways[i].BitLen()), + }, + ) + } + + return rules +} + +func ingressRuleWideOpen() []network.IngressRule { + return []network.IngressRule{ + { + Subnet: netip.MustParsePrefix("0.0.0.0/0"), + }, + { + Subnet: netip.MustParsePrefix("::/0"), + }, + } +} + +func ingressOnly(ips []netip.Addr) []network.IngressRule { + return xslices.Map(ips, func(ip netip.Addr) network.IngressRule { + return network.IngressRule{ + Subnet: netip.PrefixFrom(ip, ip.BitLen()), + } + }) +} + +// ControlPlane generates a default firewall for a controlplane node. +// +// Kubelet and Trustd are only available within the cluster. +// Apid & Kubernetes API is wide open. +// Etcd is only available within the controlplanes. +func ControlPlane(defaultAction nethelpers.DefaultAction, cidrs []netip.Prefix, gateways []netip.Addr, controlplanes []netip.Addr) configpatcher.Patch { + def := network.NewDefaultActionConfigV1Alpha1() + def.Ingress = defaultAction + + kubeletRule := network.NewRuleConfigV1Alpha1() + kubeletRule.MetaName = "kubelet-ingress" + kubeletRule.PortSelector.Ports = []network.PortRange{ + { + Lo: constants.KubeletPort, + Hi: constants.KubeletPort, + }, + } + kubeletRule.PortSelector.Protocol = nethelpers.ProtocolTCP + kubeletRule.Ingress = ingressRuleWithinCluster(cidrs, gateways) + + apidRule := network.NewRuleConfigV1Alpha1() + apidRule.MetaName = "apid-ingress" + apidRule.PortSelector.Ports = []network.PortRange{ + { + Lo: constants.ApidPort, + Hi: constants.ApidPort, + }, + } + apidRule.PortSelector.Protocol = nethelpers.ProtocolTCP + apidRule.Ingress = ingressRuleWideOpen() + + trustdRule := network.NewRuleConfigV1Alpha1() + trustdRule.MetaName = "trustd-ingress" + trustdRule.PortSelector.Ports = []network.PortRange{ + { + Lo: constants.TrustdPort, + Hi: constants.TrustdPort, + }, + } + trustdRule.PortSelector.Protocol = nethelpers.ProtocolTCP + trustdRule.Ingress = ingressRuleWithinCluster(cidrs, gateways) + + kubeAPIRule := network.NewRuleConfigV1Alpha1() + kubeAPIRule.MetaName = "kubernetes-api-ingress" + kubeAPIRule.PortSelector.Ports = []network.PortRange{ + { + Lo: constants.DefaultControlPlanePort, + Hi: constants.DefaultControlPlanePort, + }, + } + kubeAPIRule.PortSelector.Protocol = nethelpers.ProtocolTCP + kubeAPIRule.Ingress = ingressRuleWideOpen() + + etcdRule := network.NewRuleConfigV1Alpha1() + etcdRule.MetaName = "etcd-ingress" + etcdRule.PortSelector.Ports = []network.PortRange{ + { + Lo: constants.EtcdClientPort, + Hi: constants.EtcdPeerPort, + }, + } + etcdRule.PortSelector.Protocol = nethelpers.ProtocolTCP + etcdRule.Ingress = ingressOnly(controlplanes) + + vxlanRule := network.NewRuleConfigV1Alpha1() + vxlanRule.MetaName = "cni-vxlan" + vxlanRule.PortSelector.Ports = []network.PortRange{ + { + Lo: 4789, // Flannel, Calico VXLAN + Hi: 4789, + }, + { + Lo: 8472, // Cilium VXLAN + Hi: 8472, + }, + } + vxlanRule.PortSelector.Protocol = nethelpers.ProtocolUDP + vxlanRule.Ingress = ingressRuleWithinCluster(cidrs, gateways) + + provider, err := container.New(def, kubeletRule, apidRule, trustdRule, kubeAPIRule, etcdRule, vxlanRule) + if err != nil { // should not fail + panic(err) + } + + return configpatcher.StrategicMergePatch{Provider: provider} +} + +// Worker generates a default firewall for a worker node. +// +// Kubelet & apid are only available within the cluster. +func Worker(defaultAction nethelpers.DefaultAction, cidrs []netip.Prefix, gateways []netip.Addr) configpatcher.Patch { + def := network.NewDefaultActionConfigV1Alpha1() + def.Ingress = defaultAction + + kubeletRule := network.NewRuleConfigV1Alpha1() + kubeletRule.MetaName = "kubelet-ingress" + kubeletRule.PortSelector.Ports = []network.PortRange{ + { + Lo: constants.KubeletPort, + Hi: constants.KubeletPort, + }, + } + kubeletRule.PortSelector.Protocol = nethelpers.ProtocolTCP + kubeletRule.Ingress = ingressRuleWithinCluster(cidrs, gateways) + + apidRule := network.NewRuleConfigV1Alpha1() + apidRule.MetaName = "apid-ingress" + apidRule.PortSelector.Ports = []network.PortRange{ + { + Lo: constants.ApidPort, + Hi: constants.ApidPort, + }, + } + apidRule.PortSelector.Protocol = nethelpers.ProtocolTCP + apidRule.Ingress = ingressRuleWithinCluster(cidrs, gateways) + + vxlanRule := network.NewRuleConfigV1Alpha1() + vxlanRule.MetaName = "cni-vxlan" + vxlanRule.PortSelector.Ports = []network.PortRange{ + { + Lo: 4789, // Flannel, Calico VXLAN + Hi: 4789, + }, + { + Lo: 8472, // Cilium VXLAN + Hi: 8472, + }, + } + vxlanRule.PortSelector.Protocol = nethelpers.ProtocolUDP + vxlanRule.Ingress = ingressRuleWithinCluster(cidrs, gateways) + + provider, err := container.New(def, kubeletRule, apidRule, vxlanRule) + if err != nil { // should not fail + panic(err) + } + + return configpatcher.StrategicMergePatch{Provider: provider} +} diff --git a/hack/release.toml b/hack/release.toml index 53f96a83c4b..3e6660f01b9 100644 --- a/hack/release.toml +++ b/hack/release.toml @@ -107,6 +107,12 @@ machine: title = "OAuth2 Machine Config Flow" description = """\ Talos Linux when running on the `metal` platform can be configured to authenticate the machine configuration download using [OAuth2 device flow](https://www.talos.dev/v1.6/advanced/machine-config-oauth/). +""" + + [note.ingress] + title = "Ingress Firewall" + description = """\ +Talos Linux now supports configuring the [ingress firewall rules](https://talos.dev/v1.6/talos-guides/network/ingress-firewall/). """ [make_deps] diff --git a/hack/test/e2e-qemu.sh b/hack/test/e2e-qemu.sh index 61315725e4a..af7244396d2 100755 --- a/hack/test/e2e-qemu.sh +++ b/hack/test/e2e-qemu.sh @@ -87,6 +87,14 @@ case "${WITH_NETWORK_CHAOS:-false}" in ;; esac +case "${WITH_FIREWALL:-false}" in + false) + ;; + *) + QEMU_FLAGS+=("--with-firewall=${WITH_FIREWALL}") + ;; +esac + case "${USE_DISK_IMAGE:-false}" in false) ;; diff --git a/internal/app/machined/pkg/controllers/network/nftables_chain_config.go b/internal/app/machined/pkg/controllers/network/nftables_chain_config.go new file mode 100644 index 00000000000..4075db30f34 --- /dev/null +++ b/internal/app/machined/pkg/controllers/network/nftables_chain_config.go @@ -0,0 +1,224 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package network + +import ( + "cmp" + "context" + "fmt" + "net/netip" + "slices" + + "github.com/cosi-project/runtime/pkg/controller" + "github.com/cosi-project/runtime/pkg/safe" + "github.com/cosi-project/runtime/pkg/state" + "github.com/siderolabs/gen/optional" + "github.com/siderolabs/gen/xslices" + "github.com/siderolabs/go-pointer" + "go.uber.org/zap" + + "github.com/siderolabs/talos/pkg/machinery/constants" + "github.com/siderolabs/talos/pkg/machinery/nethelpers" + "github.com/siderolabs/talos/pkg/machinery/resources/config" + "github.com/siderolabs/talos/pkg/machinery/resources/network" +) + +// IngressChainName is the name of the ingress chain. +const IngressChainName = "ingress" + +// NfTablesChainConfigController generates nftables rules based on machine configuration. +type NfTablesChainConfigController struct{} + +// Name implements controller.Controller interface. +func (ctrl *NfTablesChainConfigController) Name() string { + return "network.NfTablesChainConfigController" +} + +// Inputs implements controller.Controller interface. +func (ctrl *NfTablesChainConfigController) Inputs() []controller.Input { + return []controller.Input{ + { + Namespace: config.NamespaceName, + Type: config.MachineConfigType, + ID: optional.Some(config.V1Alpha1ID), + Kind: controller.InputWeak, + }, + } +} + +// Outputs implements controller.Controller interface. +func (ctrl *NfTablesChainConfigController) Outputs() []controller.Output { + return []controller.Output{ + { + Type: network.NfTablesChainType, + Kind: controller.OutputShared, + }, + } +} + +// Run implements controller.Controller interface. +// +//nolint:gocyclo +func (ctrl *NfTablesChainConfigController) Run(ctx context.Context, r controller.Runtime, logger *zap.Logger) (err error) { + for { + select { + case <-ctx.Done(): + return nil + case <-r.EventCh(): + } + + cfg, err := safe.ReaderGetByID[*config.MachineConfig](ctx, r, config.V1Alpha1ID) + if err != nil && !state.IsNotFoundError(err) { + return fmt.Errorf("error getting machine config: %w", err) + } + + r.StartTrackingOutputs() + + if cfg != nil && !(cfg.Config().NetworkRules().DefaultAction() == nethelpers.DefaultActionAccept && cfg.Config().NetworkRules().Rules() == nil) { + if err = safe.WriterModify(ctx, r, network.NewNfTablesChain(network.NamespaceName, IngressChainName), + func(chain *network.NfTablesChain) error { + spec := chain.TypedSpec() + + spec.Type = nethelpers.ChainTypeFilter + spec.Hook = nethelpers.ChainHookInput + spec.Priority = nethelpers.ChainPriorityFilter + spec.Policy = nethelpers.VerdictAccept + + // preamble + spec.Rules = []network.NfTablesRule{ + // trusted interfaces: loopback, siderolink and kubespan + { + MatchIIfName: &network.NfTablesIfNameMatch{ + InterfaceNames: []string{ + "lo", + constants.SideroLinkName, + constants.KubeSpanLinkName, + }, + Operator: nethelpers.OperatorEqual, + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictAccept), + }, + } + + defaultAction := cfg.Config().NetworkRules().DefaultAction() + + if defaultAction == nethelpers.DefaultActionBlock { + spec.Policy = nethelpers.VerdictDrop + + spec.Rules = append(spec.Rules, + // conntrack + network.NfTablesRule{ + MatchConntrackState: &network.NfTablesConntrackStateMatch{ + States: []nethelpers.ConntrackState{ + nethelpers.ConntrackStateEstablished, + nethelpers.ConntrackStateRelated, + }, + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictAccept), + }, + network.NfTablesRule{ + MatchConntrackState: &network.NfTablesConntrackStateMatch{ + States: []nethelpers.ConntrackState{ + nethelpers.ConntrackStateInvalid, + }, + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictDrop), + }, + // allow ICMP and ICMPv6 explicitly + network.NfTablesRule{ + MatchLayer4: &network.NfTablesLayer4Match{ + Protocol: nethelpers.ProtocolICMP, + }, + MatchLimit: &network.NfTablesLimitMatch{ + PacketRatePerSecond: 5, + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictAccept), + }, + network.NfTablesRule{ + MatchLayer4: &network.NfTablesLayer4Match{ + Protocol: nethelpers.ProtocolICMPv6, + }, + MatchLimit: &network.NfTablesLimitMatch{ + PacketRatePerSecond: 5, + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictAccept), + }, + ) + + if cfg.Config().Cluster() != nil { + spec.Rules = append(spec.Rules, + // allow Kubernetes pod/service traffic + network.NfTablesRule{ + MatchSourceAddress: &network.NfTablesAddressMatch{ + IncludeSubnets: xslices.Map( + append(slices.Clone(cfg.Config().Cluster().Network().PodCIDRs()), cfg.Config().Cluster().Network().ServiceCIDRs()...), + netip.MustParsePrefix, + ), + }, + MatchDestinationAddress: &network.NfTablesAddressMatch{ + IncludeSubnets: xslices.Map( + append(slices.Clone(cfg.Config().Cluster().Network().PodCIDRs()), cfg.Config().Cluster().Network().ServiceCIDRs()...), + netip.MustParsePrefix, + ), + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictAccept), + }, + ) + } + } + + for _, rule := range cfg.Config().NetworkRules().Rules() { + portRanges := rule.PortRanges() + + // sort port ranges, machine config validation ensures that there are no overlaps + slices.SortFunc(portRanges, func(a, b [2]uint16) int { + return cmp.Compare(a[0], b[0]) + }) + + // if default accept, drop anything that doesn't match the rule + verdict := nethelpers.VerdictDrop + + if defaultAction == nethelpers.DefaultActionBlock { + verdict = nethelpers.VerdictAccept + } + + spec.Rules = append(spec.Rules, + network.NfTablesRule{ + MatchSourceAddress: &network.NfTablesAddressMatch{ + IncludeSubnets: rule.Subnets(), + ExcludeSubnets: rule.ExceptSubnets(), + Invert: defaultAction == nethelpers.DefaultActionAccept, + }, + MatchLayer4: &network.NfTablesLayer4Match{ + Protocol: rule.Protocol(), + MatchDestinationPort: &network.NfTablesPortMatch{ + Ranges: xslices.Map(portRanges, func(pr [2]uint16) network.PortRange { + return network.PortRange{Lo: pr[0], Hi: pr[1]} + }), + }, + }, + AnonCounter: true, + Verdict: pointer.To(verdict), + }, + ) + } + + return nil + }); err != nil { + return err + } + } + + if err = safe.CleanupOutputs[*network.NfTablesChain](ctx, r); err != nil { + return err + } + } +} diff --git a/internal/app/machined/pkg/controllers/network/nftables_chain_config_test.go b/internal/app/machined/pkg/controllers/network/nftables_chain_config_test.go new file mode 100644 index 00000000000..29572dac0e6 --- /dev/null +++ b/internal/app/machined/pkg/controllers/network/nftables_chain_config_test.go @@ -0,0 +1,283 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package network_test + +import ( + "net/netip" + "testing" + "time" + + "github.com/siderolabs/go-pointer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/siderolabs/talos/internal/app/machined/pkg/controllers/ctest" + netctrl "github.com/siderolabs/talos/internal/app/machined/pkg/controllers/network" + configtypes "github.com/siderolabs/talos/pkg/machinery/config/config" + "github.com/siderolabs/talos/pkg/machinery/config/container" + networkcfg "github.com/siderolabs/talos/pkg/machinery/config/types/network" + "github.com/siderolabs/talos/pkg/machinery/constants" + "github.com/siderolabs/talos/pkg/machinery/nethelpers" + "github.com/siderolabs/talos/pkg/machinery/resources/config" + "github.com/siderolabs/talos/pkg/machinery/resources/network" +) + +type NfTablesChainConfigTestSuite struct { + ctest.DefaultSuite +} + +func (suite *NfTablesChainConfigTestSuite) injectConfig(block bool) { + kubeletIngressCfg := networkcfg.NewRuleConfigV1Alpha1() + kubeletIngressCfg.MetaName = "kubelet-ingress" + kubeletIngressCfg.PortSelector.Ports = []networkcfg.PortRange{ + { + Lo: 10250, + Hi: 10250, + }, + } + kubeletIngressCfg.PortSelector.Protocol = nethelpers.ProtocolTCP + kubeletIngressCfg.Ingress = []networkcfg.IngressRule{ + { + Subnet: netip.MustParsePrefix("10.0.0.0/8"), + Except: netip.MustParsePrefix("10.3.0.0/16"), + }, + { + Subnet: netip.MustParsePrefix("192.168.0.0/16"), + }, + } + + apidIngressCfg := networkcfg.NewRuleConfigV1Alpha1() + apidIngressCfg.MetaName = "apid-ingress" + apidIngressCfg.PortSelector.Ports = []networkcfg.PortRange{ + { + Lo: 50000, + Hi: 50000, + }, + } + apidIngressCfg.PortSelector.Protocol = nethelpers.ProtocolTCP + apidIngressCfg.Ingress = []networkcfg.IngressRule{ + { + Subnet: netip.MustParsePrefix("0.0.0.0/0"), + }, + } + + configs := []configtypes.Document{kubeletIngressCfg, apidIngressCfg} + + if block { + defaultActionCfg := networkcfg.NewDefaultActionConfigV1Alpha1() + defaultActionCfg.Ingress = nethelpers.DefaultActionBlock + + configs = append(configs, defaultActionCfg) + } + + cfg, err := container.New(configs...) + suite.Require().NoError(err) + + suite.Require().NoError(suite.State().Create(suite.Ctx(), config.NewMachineConfig(cfg))) +} + +func (suite *NfTablesChainConfigTestSuite) TestDefaultAccept() { + ctest.AssertNoResource[*network.NfTablesChain](suite, netctrl.IngressChainName) + + suite.injectConfig(false) + + ctest.AssertResource(suite, netctrl.IngressChainName, func(chain *network.NfTablesChain, asrt *assert.Assertions) { + spec := chain.TypedSpec() + + asrt.Equal(nethelpers.ChainTypeFilter, spec.Type) + asrt.Equal(nethelpers.ChainPriorityFilter, spec.Priority) + asrt.Equal(nethelpers.ChainHookInput, spec.Hook) + asrt.Equal(nethelpers.VerdictAccept, spec.Policy) + + asrt.Equal( + []network.NfTablesRule{ + { + MatchIIfName: &network.NfTablesIfNameMatch{ + InterfaceNames: []string{ + "lo", + constants.SideroLinkName, + constants.KubeSpanLinkName, + }, + Operator: nethelpers.OperatorEqual, + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictAccept), + }, + { + MatchSourceAddress: &network.NfTablesAddressMatch{ + IncludeSubnets: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("192.168.0.0/16"), + }, + ExcludeSubnets: []netip.Prefix{ + netip.MustParsePrefix("10.3.0.0/16"), + }, + Invert: true, + }, + MatchLayer4: &network.NfTablesLayer4Match{ + Protocol: nethelpers.ProtocolTCP, + MatchDestinationPort: &network.NfTablesPortMatch{ + Ranges: []network.PortRange{ + { + Lo: 10250, + Hi: 10250, + }, + }, + }, + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictDrop), + }, + { + MatchSourceAddress: &network.NfTablesAddressMatch{ + IncludeSubnets: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), + }, + Invert: true, + }, + MatchLayer4: &network.NfTablesLayer4Match{ + Protocol: nethelpers.ProtocolTCP, + MatchDestinationPort: &network.NfTablesPortMatch{ + Ranges: []network.PortRange{ + { + Lo: 50000, + Hi: 50000, + }, + }, + }, + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictDrop), + }, + }, + spec.Rules) + }) +} + +func (suite *NfTablesChainConfigTestSuite) TestDefaultBlock() { + ctest.AssertNoResource[*network.NfTablesChain](suite, netctrl.IngressChainName) + + suite.injectConfig(true) + + ctest.AssertResource(suite, netctrl.IngressChainName, func(chain *network.NfTablesChain, asrt *assert.Assertions) { + spec := chain.TypedSpec() + + asrt.Equal(nethelpers.ChainTypeFilter, spec.Type) + asrt.Equal(nethelpers.ChainPriorityFilter, spec.Priority) + asrt.Equal(nethelpers.ChainHookInput, spec.Hook) + asrt.Equal(nethelpers.VerdictDrop, spec.Policy) + + asrt.Equal( + []network.NfTablesRule{ + { + MatchIIfName: &network.NfTablesIfNameMatch{ + InterfaceNames: []string{ + "lo", + constants.SideroLinkName, + constants.KubeSpanLinkName, + }, + Operator: nethelpers.OperatorEqual, + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictAccept), + }, + { + MatchConntrackState: &network.NfTablesConntrackStateMatch{ + States: []nethelpers.ConntrackState{ + nethelpers.ConntrackStateEstablished, + nethelpers.ConntrackStateRelated, + }, + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictAccept), + }, + { + MatchConntrackState: &network.NfTablesConntrackStateMatch{ + States: []nethelpers.ConntrackState{ + nethelpers.ConntrackStateInvalid, + }, + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictDrop), + }, + { + MatchLayer4: &network.NfTablesLayer4Match{ + Protocol: nethelpers.ProtocolICMP, + }, + MatchLimit: &network.NfTablesLimitMatch{ + PacketRatePerSecond: 5, + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictAccept), + }, + { + MatchLayer4: &network.NfTablesLayer4Match{ + Protocol: nethelpers.ProtocolICMPv6, + }, + MatchLimit: &network.NfTablesLimitMatch{ + PacketRatePerSecond: 5, + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictAccept), + }, + { + MatchSourceAddress: &network.NfTablesAddressMatch{ + IncludeSubnets: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("192.168.0.0/16"), + }, + ExcludeSubnets: []netip.Prefix{ + netip.MustParsePrefix("10.3.0.0/16"), + }, + }, + MatchLayer4: &network.NfTablesLayer4Match{ + Protocol: nethelpers.ProtocolTCP, + MatchDestinationPort: &network.NfTablesPortMatch{ + Ranges: []network.PortRange{ + { + Lo: 10250, + Hi: 10250, + }, + }, + }, + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictAccept), + }, + { + MatchSourceAddress: &network.NfTablesAddressMatch{ + IncludeSubnets: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), + }, + }, + MatchLayer4: &network.NfTablesLayer4Match{ + Protocol: nethelpers.ProtocolTCP, + MatchDestinationPort: &network.NfTablesPortMatch{ + Ranges: []network.PortRange{ + { + Lo: 50000, + Hi: 50000, + }, + }, + }, + }, + AnonCounter: true, + Verdict: pointer.To(nethelpers.VerdictAccept), + }, + }, + spec.Rules) + }) +} + +func TestNfTablesChainConfig(t *testing.T) { + suite.Run(t, &NfTablesChainConfigTestSuite{ + DefaultSuite: ctest.DefaultSuite{ + Timeout: 5 * time.Second, + AfterSetup: func(s *ctest.DefaultSuite) { + s.Require().NoError(s.Runtime().RegisterController(&netctrl.NfTablesChainConfigController{})) + }, + }, + }) +} diff --git a/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_controller.go b/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_controller.go index a6c831bd729..a05962102b0 100644 --- a/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_controller.go +++ b/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_controller.go @@ -201,6 +201,7 @@ func (ctrl *Controller) Run(ctx context.Context, drainer *runtime.Drainer) error &network.LinkMergeController{}, &network.LinkSpecController{}, &network.LinkStatusController{}, + &network.NfTablesChainConfigController{}, &network.NfTablesChainController{}, &network.NodeAddressController{}, &network.OperatorConfigController{ diff --git a/pkg/machinery/config/config/config.go b/pkg/machinery/config/config/config.go index 76b21580c1b..2b8829a4c4f 100644 --- a/pkg/machinery/config/config/config.go +++ b/pkg/machinery/config/config/config.go @@ -12,4 +12,5 @@ type Config interface { Cluster() ClusterConfig SideroLink() SideroLinkConfig Runtime() RuntimeConfig + NetworkRules() NetworkRuleConfig } diff --git a/pkg/machinery/config/config/helpers.go b/pkg/machinery/config/config/helpers.go new file mode 100644 index 00000000000..00d19ee38d1 --- /dev/null +++ b/pkg/machinery/config/config/helpers.go @@ -0,0 +1,39 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package config + +func findFirstValue[T any, R comparable](documents []T, getter func(T) R) R { + var zeroR R + + for _, document := range documents { + if value := getter(document); value != zeroR { + return value + } + } + + return zeroR +} + +func aggregateValues[T any, R any](documents []T, getter func(T) []R) []R { + var result []R + + for _, document := range documents { + result = append(result, getter(document)...) + } + + return result +} + +func filterDocuments[T any, R any](documents []R) []T { + var result []T + + for _, document := range documents { + if document, ok := any(document).(T); ok { + result = append(result, document) + } + } + + return result +} diff --git a/pkg/machinery/config/config/network.go b/pkg/machinery/config/config/network.go new file mode 100644 index 00000000000..a67aa753523 --- /dev/null +++ b/pkg/machinery/config/config/network.go @@ -0,0 +1,66 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package config + +import ( + "net/netip" + + "github.com/siderolabs/talos/pkg/machinery/nethelpers" +) + +// NetworkRuleConfig defines the interface to access network firewall configuration. +type NetworkRuleConfig interface { + NetworkRuleConfigRules + NetworkRuleConfigDefaultAction +} + +// NetworkRuleConfigRules defines the interface to access network firewall configuration. +type NetworkRuleConfigRules interface { + Rules() []NetworkRule +} + +// NetworkRuleConfigDefaultAction defines the interface to access network firewall configuration. +type NetworkRuleConfigDefaultAction interface { + DefaultAction() nethelpers.DefaultAction +} + +// NetworkRuleConfigSignal is used to signal documents which implement either of the NetworkRuleConfig interfaces. +type NetworkRuleConfigSignal interface { + NetworkRuleConfigSignal() +} + +// NetworkRule defines a network firewall rule. +type NetworkRule interface { + Protocol() nethelpers.Protocol + PortRanges() [][2]uint16 + Subnets() []netip.Prefix + ExceptSubnets() []netip.Prefix +} + +// WrapNetworkRuleConfigList wraps a list of NetworkConfig into a single NetworkConfig aggregating the results. +func WrapNetworkRuleConfigList(configs ...NetworkRuleConfigSignal) NetworkRuleConfig { + return networkRuleConfigWrapper(configs) +} + +type networkRuleConfigWrapper []NetworkRuleConfigSignal + +func (w networkRuleConfigWrapper) DefaultAction() nethelpers.DefaultAction { + // DefaultAction zero value is 'accept' which is the default config value as well. + return findFirstValue( + filterDocuments[NetworkRuleConfigDefaultAction](w), + func(c NetworkRuleConfigDefaultAction) nethelpers.DefaultAction { + return c.DefaultAction() + }, + ) +} + +func (w networkRuleConfigWrapper) Rules() []NetworkRule { + return aggregateValues( + filterDocuments[NetworkRuleConfigRules](w), + func(c NetworkRuleConfigRules) []NetworkRule { + return c.Rules() + }, + ) +} diff --git a/pkg/machinery/config/config/runtime.go b/pkg/machinery/config/config/runtime.go index 35ca359013a..3f75581087b 100644 --- a/pkg/machinery/config/config/runtime.go +++ b/pkg/machinery/config/config/runtime.go @@ -30,23 +30,3 @@ func (w runtimeConfigWrapper) KmsgLogURLs() []*url.URL { return c.KmsgLogURLs() }) } - -func findFirstValue[T any, R any](documents []T, getter func(T) *R) *R { - for _, document := range documents { - if value := getter(document); value != nil { - return value - } - } - - return nil -} - -func aggregateValues[T any, R any](documents []T, getter func(T) []R) []R { - var result []R - - for _, document := range documents { - result = append(result, getter(document)...) - } - - return result -} diff --git a/pkg/machinery/config/container/container.go b/pkg/machinery/config/container/container.go index 9a0ebd760fa..8226a48096c 100644 --- a/pkg/machinery/config/container/container.go +++ b/pkg/machinery/config/container/container.go @@ -155,6 +155,11 @@ func (container *Container) Runtime() config.RuntimeConfig { return config.WrapRuntimeConfigList(findMatchingDocs[config.RuntimeConfig](container.documents)...) } +// NetworkRules implements config.Config interface. +func (container *Container) NetworkRules() config.NetworkRuleConfig { + return config.WrapNetworkRuleConfigList(findMatchingDocs[config.NetworkRuleConfigSignal](container.documents)...) +} + // Bytes returns source YAML representation (if available) or does default encoding. func (container *Container) Bytes() ([]byte, error) { if !container.readonly { diff --git a/pkg/machinery/config/encoder/encoder.go b/pkg/machinery/config/encoder/encoder.go index 80c8f6f34a3..c18ea469853 100644 --- a/pkg/machinery/config/encoder/encoder.go +++ b/pkg/machinery/config/encoder/encoder.go @@ -5,6 +5,7 @@ package encoder import ( + "encoding" "reflect" "sort" "strings" @@ -134,6 +135,10 @@ func toYamlNode(in interface{}, options *Options) (*yaml.Node, error) { in = res } + if _, ok := in.(encoding.TextMarshaler); ok && !isNil(reflect.ValueOf(in)) { + return node, node.Encode(in) + } + v := reflect.ValueOf(in) if v.Kind() == reflect.Ptr { v = v.Elem() diff --git a/pkg/machinery/config/types/network/deep_copy.generated.go b/pkg/machinery/config/types/network/deep_copy.generated.go new file mode 100644 index 00000000000..6af50d5e546 --- /dev/null +++ b/pkg/machinery/config/types/network/deep_copy.generated.go @@ -0,0 +1,27 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Code generated by "deep-copy -type DefaultActionConfigV1Alpha1 -type RuleConfigV1Alpha1 -pointer-receiver -header-file ../../../../../hack/boilerplate.txt -o deep_copy.generated.go ."; DO NOT EDIT. + +package network + +// DeepCopy generates a deep copy of *DefaultActionConfigV1Alpha1. +func (o *DefaultActionConfigV1Alpha1) DeepCopy() *DefaultActionConfigV1Alpha1 { + var cp DefaultActionConfigV1Alpha1 = *o + return &cp +} + +// DeepCopy generates a deep copy of *RuleConfigV1Alpha1. +func (o *RuleConfigV1Alpha1) DeepCopy() *RuleConfigV1Alpha1 { + var cp RuleConfigV1Alpha1 = *o + if o.PortSelector.Ports != nil { + cp.PortSelector.Ports = make([]PortRange, len(o.PortSelector.Ports)) + copy(cp.PortSelector.Ports, o.PortSelector.Ports) + } + if o.Ingress != nil { + cp.Ingress = make([]IngressRule, len(o.Ingress)) + copy(cp.Ingress, o.Ingress) + } + return &cp +} diff --git a/pkg/machinery/config/types/network/default_action_config.go b/pkg/machinery/config/types/network/default_action_config.go new file mode 100644 index 00000000000..af0b916950f --- /dev/null +++ b/pkg/machinery/config/types/network/default_action_config.go @@ -0,0 +1,62 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package network + +import ( + "github.com/siderolabs/talos/pkg/machinery/config/config" + "github.com/siderolabs/talos/pkg/machinery/config/internal/registry" + "github.com/siderolabs/talos/pkg/machinery/config/types/meta" + "github.com/siderolabs/talos/pkg/machinery/nethelpers" +) + +// DefaultActionConfig is a default action config document kind. +const DefaultActionConfig = "NetworkDefaultActionConfig" + +func init() { + registry.Register(DefaultActionConfig, func(version string) config.Document { + switch version { + case "v1alpha1": + return &DefaultActionConfigV1Alpha1{} + default: + return nil + } + }) +} + +// Check interfaces. +var ( + _ config.NetworkRuleConfigDefaultAction = &DefaultActionConfigV1Alpha1{} + _ config.NetworkRuleConfigSignal = &DefaultActionConfigV1Alpha1{} +) + +// DefaultActionConfigV1Alpha1 is a event sink config document. +type DefaultActionConfigV1Alpha1 struct { + meta.Meta `yaml:",inline"` + + Ingress nethelpers.DefaultAction `yaml:"ingress"` +} + +// NewDefaultActionConfigV1Alpha1 creates a new DefaultActionConfig config document. +func NewDefaultActionConfigV1Alpha1() *DefaultActionConfigV1Alpha1 { + return &DefaultActionConfigV1Alpha1{ + Meta: meta.Meta{ + MetaKind: DefaultActionConfig, + MetaAPIVersion: "v1alpha1", + }, + } +} + +// Clone implements config.Document interface. +func (s *DefaultActionConfigV1Alpha1) Clone() config.Document { + return s.DeepCopy() +} + +// NetworkRuleConfigSignal implements config.NetworkRuleConfigSignal interface. +func (s *DefaultActionConfigV1Alpha1) NetworkRuleConfigSignal() {} + +// DefaultAction implements config.NetworkRuleConfigDefaultAction interface. +func (s *DefaultActionConfigV1Alpha1) DefaultAction() nethelpers.DefaultAction { + return s.Ingress +} diff --git a/pkg/machinery/config/types/network/default_action_config_test.go b/pkg/machinery/config/types/network/default_action_config_test.go new file mode 100644 index 00000000000..b89591cc3cd --- /dev/null +++ b/pkg/machinery/config/types/network/default_action_config_test.go @@ -0,0 +1,54 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package network_test + +import ( + _ "embed" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/siderolabs/talos/pkg/machinery/config/configloader" + "github.com/siderolabs/talos/pkg/machinery/config/encoder" + "github.com/siderolabs/talos/pkg/machinery/config/types/meta" + "github.com/siderolabs/talos/pkg/machinery/config/types/network" + "github.com/siderolabs/talos/pkg/machinery/nethelpers" +) + +//go:embed testdata/defaultactionconfig.yaml +var expectedDefaultActionConfigDocument []byte + +func TestDefaultActionConfigMarshalStability(t *testing.T) { + t.Parallel() + + cfg := network.NewDefaultActionConfigV1Alpha1() + cfg.Ingress = nethelpers.DefaultActionBlock + + marshaled, err := encoder.NewEncoder(cfg).Encode() + require.NoError(t, err) + + t.Log(string(marshaled)) + + assert.Equal(t, expectedDefaultActionConfigDocument, marshaled) +} + +func TestDefaultActionConfigUnmarshal(t *testing.T) { + t.Parallel() + + provider, err := configloader.NewFromBytes(expectedDefaultActionConfigDocument) + require.NoError(t, err) + + docs := provider.Documents() + require.Len(t, docs, 1) + + assert.Equal(t, &network.DefaultActionConfigV1Alpha1{ + Meta: meta.Meta{ + MetaAPIVersion: "v1alpha1", + MetaKind: network.DefaultActionConfig, + }, + Ingress: nethelpers.DefaultActionBlock, + }, docs[0]) +} diff --git a/pkg/machinery/config/types/network/network.go b/pkg/machinery/config/types/network/network.go new file mode 100644 index 00000000000..0210ac9a642 --- /dev/null +++ b/pkg/machinery/config/types/network/network.go @@ -0,0 +1,8 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Package network provides Talos network config documents. +package network + +//go:generate deep-copy -type DefaultActionConfigV1Alpha1 -type RuleConfigV1Alpha1 -pointer-receiver -header-file ../../../../../hack/boilerplate.txt -o deep_copy.generated.go . diff --git a/pkg/machinery/config/types/network/port_range.go b/pkg/machinery/config/types/network/port_range.go new file mode 100644 index 00000000000..7f85602d2e3 --- /dev/null +++ b/pkg/machinery/config/types/network/port_range.go @@ -0,0 +1,102 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Package network provides Talos network config documents. +package network + +import ( + "cmp" + "fmt" + "slices" + "strconv" + "strings" +) + +// PortRange is a port range. +type PortRange struct { + Lo uint16 + Hi uint16 +} + +// UnmarshalYAML is a custom unmarshaller for `PortRange`. +func (pr *PortRange) UnmarshalYAML(unmarshal func(any) error) error { + var port uint16 + + if err := unmarshal(&port); err == nil { + pr.Lo = port + pr.Hi = port + + return nil + } + + var rangeStr string + + if err := unmarshal(&rangeStr); err != nil { + return err + } + + lo, hi, ok := strings.Cut(rangeStr, "-") + if !ok { + return fmt.Errorf("invalid port range: %q", rangeStr) + } + + prLo, err := strconv.ParseUint(lo, 10, 16) + if err != nil { + return fmt.Errorf("invalid port range: %q", rangeStr) + } + + prHi, err := strconv.ParseUint(hi, 10, 16) + if err != nil { + return fmt.Errorf("invalid port range: %q", rangeStr) + } + + pr.Lo, pr.Hi = uint16(prLo), uint16(prHi) + + return nil +} + +// MarshalYAML is a custom marshaller for `PortRange`. +func (pr PortRange) MarshalYAML() (any, error) { + if pr.Lo == pr.Hi { + return pr.Lo, nil + } + + return fmt.Sprintf("%d-%d", pr.Lo, pr.Hi), nil +} + +// String implements fmt.Stringer interface. +func (pr PortRange) String() string { + return fmt.Sprintf("%d-%d", pr.Lo, pr.Hi) +} + +// PortRanges is a slice of port ranges. +type PortRanges []PortRange + +// Validate the port ranges. +func (prs PortRanges) Validate() error { + clone := slices.Clone(prs) + slices.SortFunc(clone, func(a, b PortRange) int { + return cmp.Compare(a.Lo, b.Lo) + }) + + for i, pr := range clone { + if pr.Lo > pr.Hi { + return fmt.Errorf("invalid port range: %s", pr) + } + + if i > 0 { + prev := clone[i-1] + + if pr.Lo == prev.Lo { + return fmt.Errorf("invalid port range: %s, overlaps with %s", pr, prev) + } + + if pr.Lo <= prev.Hi { + return fmt.Errorf("invalid port range: %s, overlaps with %s", pr, prev) + } + } + } + + return nil +} diff --git a/pkg/machinery/config/types/network/port_range_test.go b/pkg/machinery/config/types/network/port_range_test.go new file mode 100644 index 00000000000..ca5346ed2c2 --- /dev/null +++ b/pkg/machinery/config/types/network/port_range_test.go @@ -0,0 +1,124 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package network_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/siderolabs/talos/pkg/machinery/config/types/network" +) + +func TestPortRange(t *testing.T) { + t.Run("MarshalYAML", func(t *testing.T) { + for _, test := range []struct { + name string + pr network.PortRange + + expected string + }{ + { + name: "single port", + pr: network.PortRange{Lo: 80, Hi: 80}, + + expected: "80\n", + }, + { + name: "port range", + pr: network.PortRange{Lo: 80, Hi: 443}, + + expected: "80-443\n", + }, + } { + t.Run(test.name, func(t *testing.T) { + marshaled, err := yaml.Marshal(test.pr) + require.NoError(t, err) + + assert.Equal(t, test.expected, string(marshaled)) + }) + } + }) + + t.Run("UnmarshalYAML", func(t *testing.T) { + for _, test := range []struct { + name string + yaml string + + expected network.PortRange + }{ + { + name: "single port", + yaml: "80\n", + + expected: network.PortRange{Lo: 80, Hi: 80}, + }, + { + name: "port range", + yaml: "80-443\n", + + expected: network.PortRange{Lo: 80, Hi: 443}, + }, + } { + t.Run(test.name, func(t *testing.T) { + var pr network.PortRange + + err := yaml.Unmarshal([]byte(test.yaml), &pr) + require.NoError(t, err) + + assert.Equal(t, test.expected, pr) + }) + } + }) +} + +func TestPortRanges(t *testing.T) { + t.Run("Validate", func(t *testing.T) { + for _, test := range []struct { + name string + prs network.PortRanges + + expectedError string + }{ + { + name: "empty", + prs: network.PortRanges{}, + }, + { + name: "valid", + prs: network.PortRanges{{Lo: 80, Hi: 80}, {Lo: 443, Hi: 443}, {Lo: 8080, Hi: 8081}}, + }, + { + name: "inversion", + prs: network.PortRanges{{Lo: 8081, Hi: 8080}}, + + expectedError: "invalid port range: 8081-8080", + }, + { + name: "overlap", + prs: network.PortRanges{{Lo: 1000, Hi: 2000}, {Lo: 80, Hi: 80}, {Lo: 1500, Hi: 2500}}, + + expectedError: "invalid port range: 1500-2500, overlaps with 1000-2000", + }, + { + name: "duplicate", + prs: network.PortRanges{{Lo: 1000, Hi: 1000}, {Lo: 80, Hi: 80}, {Lo: 1000, Hi: 1000}}, + + expectedError: "invalid port range: 1000-1000, overlaps with 1000-1000", + }, + } { + t.Run(test.name, func(t *testing.T) { + err := test.prs.Validate() + if test.expectedError != "" { + require.EqualError(t, err, test.expectedError) + } else { + require.NoError(t, err) + } + }) + } + }) +} diff --git a/pkg/machinery/config/types/network/rule_config.go b/pkg/machinery/config/types/network/rule_config.go new file mode 100644 index 00000000000..d3995e62a13 --- /dev/null +++ b/pkg/machinery/config/types/network/rule_config.go @@ -0,0 +1,154 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package network + +import ( + "fmt" + "net/netip" + + "github.com/siderolabs/gen/value" + "github.com/siderolabs/gen/xslices" + + "github.com/siderolabs/talos/pkg/machinery/config/config" + "github.com/siderolabs/talos/pkg/machinery/config/internal/registry" + "github.com/siderolabs/talos/pkg/machinery/config/types/meta" + "github.com/siderolabs/talos/pkg/machinery/config/validation" + "github.com/siderolabs/talos/pkg/machinery/nethelpers" +) + +// RuleConfigKind is a rule config document kind. +const RuleConfigKind = "NetworkRuleConfig" + +func init() { + registry.Register(RuleConfigKind, func(version string) config.Document { + switch version { + case "v1alpha1": + return &RuleConfigV1Alpha1{} + default: + return nil + } + }) +} + +// Check interfaces. +var ( + _ config.NetworkRuleConfigRules = &RuleConfigV1Alpha1{} + _ config.NetworkRuleConfigSignal = &RuleConfigV1Alpha1{} + _ config.NamedDocument = &RuleConfigV1Alpha1{} + _ config.Validator = &RuleConfigV1Alpha1{} +) + +// RuleConfigV1Alpha1 is a network firewall rule config document. +type RuleConfigV1Alpha1 struct { + meta.Meta `yaml:",inline"` + MetaName string `yaml:"name"` + + PortSelector RulePortSelector `yaml:"portSelector"` + Ingress IngressConfig `yaml:"ingress"` +} + +// RulePortSelector is a port selector for the network rule. +type RulePortSelector struct { + Ports PortRanges `yaml:"ports"` + Protocol nethelpers.Protocol `yaml:"protocol"` +} + +// IngressConfig is a ingress config. +type IngressConfig []IngressRule + +// IngressRule is a ingress rule. +type IngressRule struct { + Subnet netip.Prefix `yaml:"subnet"` + Except netip.Prefix `yaml:"except,omitempty"` +} + +// NewRuleConfigV1Alpha1 creates a new RuleConfig config document. +func NewRuleConfigV1Alpha1() *RuleConfigV1Alpha1 { + return &RuleConfigV1Alpha1{ + Meta: meta.Meta{ + MetaKind: RuleConfigKind, + MetaAPIVersion: "v1alpha1", + }, + } +} + +// Name implements config.NamedDocument interface. +func (s *RuleConfigV1Alpha1) Name() string { + return s.MetaName +} + +// Clone implements config.Document interface. +func (s *RuleConfigV1Alpha1) Clone() config.Document { + return s.DeepCopy() +} + +// Validate implements config.Validator interface. +func (s *RuleConfigV1Alpha1) Validate(validation.RuntimeMode, ...validation.Option) ([]string, error) { + if s.MetaName == "" { + return nil, fmt.Errorf("name is required") + } + + if len(s.PortSelector.Ports) == 0 { + return nil, fmt.Errorf("portSelector.ports is required") + } + + if err := s.PortSelector.Ports.Validate(); err != nil { + return nil, err + } + + for _, rule := range s.Ingress { + if !rule.Subnet.IsValid() { + return nil, fmt.Errorf("invalid subnet: %s", rule.Subnet) + } + + if !value.IsZero(rule.Except) && !rule.Except.IsValid() { + return nil, fmt.Errorf("invalid except: %s", rule.Except) + } + } + + return nil, nil +} + +// NetworkRuleConfigSignal implements config.NetworkRuleConfigSignal interface. +func (s *RuleConfigV1Alpha1) NetworkRuleConfigSignal() {} + +// Rules implements config.NetworkRuleConfigRules interface. +func (s *RuleConfigV1Alpha1) Rules() []config.NetworkRule { + return []config.NetworkRule{s} +} + +// Protocol implements config.NetworkRule interface. +func (s *RuleConfigV1Alpha1) Protocol() nethelpers.Protocol { + return s.PortSelector.Protocol +} + +// PortRanges implements config.NetworkRule interface. +func (s *RuleConfigV1Alpha1) PortRanges() [][2]uint16 { + return xslices.Map(s.PortSelector.Ports, func(pr PortRange) [2]uint16 { + return [2]uint16{pr.Lo, pr.Hi} + }) +} + +// Subnets implements config.NetworkRule interface. +func (s *RuleConfigV1Alpha1) Subnets() []netip.Prefix { + return xslices.Map(s.Ingress, func(rule IngressRule) netip.Prefix { + return rule.Subnet + }) +} + +// ExceptSubnets implements config.NetworkRule interface. +func (s *RuleConfigV1Alpha1) ExceptSubnets() []netip.Prefix { + return xslices.Map( + xslices.Filter( + s.Ingress, + func(rule IngressRule) bool { + return rule.Except.IsValid() + }, + ), + func(rule IngressRule) netip.Prefix { + return rule.Except + }, + ) +} diff --git a/pkg/machinery/config/types/network/rule_config_test.go b/pkg/machinery/config/types/network/rule_config_test.go new file mode 100644 index 00000000000..1605155ffa8 --- /dev/null +++ b/pkg/machinery/config/types/network/rule_config_test.go @@ -0,0 +1,199 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package network_test + +import ( + _ "embed" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/siderolabs/talos/pkg/machinery/config/configloader" + "github.com/siderolabs/talos/pkg/machinery/config/encoder" + "github.com/siderolabs/talos/pkg/machinery/config/types/meta" + "github.com/siderolabs/talos/pkg/machinery/config/types/network" + "github.com/siderolabs/talos/pkg/machinery/nethelpers" +) + +//go:embed testdata/ruleconfig.yaml +var expectedRuleConfigDocument []byte + +func TestRuleConfigMarshalStability(t *testing.T) { + t.Parallel() + + cfg := network.NewRuleConfigV1Alpha1() + cfg.MetaName = "test" + + cfg.PortSelector = network.RulePortSelector{ + Protocol: nethelpers.ProtocolUDP, + Ports: network.PortRanges{ + {Lo: 53, Hi: 53}, + {Lo: 8000, Hi: 9000}, + }, + } + + cfg.Ingress = network.IngressConfig{ + { + Subnet: netip.MustParsePrefix("192.168.0.0/16"), + Except: netip.MustParsePrefix("192.168.0.3/32"), + }, + { + Subnet: netip.MustParsePrefix("2001::/16"), + }, + } + + marshaled, err := encoder.NewEncoder(cfg).Encode() + require.NoError(t, err) + + t.Log(string(marshaled)) + + assert.Equal(t, expectedRuleConfigDocument, marshaled) +} + +func TestRuleConfigUnmarshal(t *testing.T) { + t.Parallel() + + provider, err := configloader.NewFromBytes(expectedRuleConfigDocument) + require.NoError(t, err) + + docs := provider.Documents() + require.Len(t, docs, 1) + + assert.Equal(t, &network.RuleConfigV1Alpha1{ + Meta: meta.Meta{ + MetaAPIVersion: "v1alpha1", + MetaKind: network.RuleConfigKind, + }, + MetaName: "test", + PortSelector: network.RulePortSelector{ + Protocol: nethelpers.ProtocolUDP, + Ports: network.PortRanges{ + {Lo: 53, Hi: 53}, + {Lo: 8000, Hi: 9000}, + }, + }, + Ingress: network.IngressConfig{ + { + Subnet: netip.MustParsePrefix("192.168.0.0/16"), + Except: netip.MustParsePrefix("192.168.0.3/32"), + }, + { + Subnet: netip.MustParsePrefix("2001::/16"), + }, + }, + }, docs[0]) +} + +func TestRuleConfigValidate(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + cfg func() *network.RuleConfigV1Alpha1 + + expectedError string + expectedWarnings []string + }{ + { + name: "empty", + cfg: network.NewRuleConfigV1Alpha1, + + expectedError: "name is required", + }, + { + name: "no ports", + cfg: func() *network.RuleConfigV1Alpha1 { + cfg := network.NewRuleConfigV1Alpha1() + cfg.MetaName = "-" + + return cfg + }, + + expectedError: "portSelector.ports is required", + }, + { + name: "invalid port range", + cfg: func() *network.RuleConfigV1Alpha1 { + cfg := network.NewRuleConfigV1Alpha1() + cfg.MetaName = "-" + cfg.PortSelector.Ports = network.PortRanges{ + {Lo: 80, Hi: 80}, + {Lo: 80, Hi: 79}, + } + + return cfg + }, + + expectedError: "invalid port range: 80-79", + }, + { + name: "invalid subnet", + cfg: func() *network.RuleConfigV1Alpha1 { + cfg := network.NewRuleConfigV1Alpha1() + cfg.MetaName = "--" + cfg.PortSelector.Ports = network.PortRanges{ + {Lo: 80, Hi: 80}, + } + cfg.Ingress = network.IngressConfig{ + {}, + } + + return cfg + }, + + expectedError: "invalid subnet: invalid Prefix", + }, + { + name: "valid", + cfg: func() *network.RuleConfigV1Alpha1 { + cfg := network.NewRuleConfigV1Alpha1() + cfg.MetaName = "--" + cfg.PortSelector.Ports = network.PortRanges{ + {Lo: 80, Hi: 80}, + {Lo: 6443, Hi: 6444}, + } + cfg.Ingress = network.IngressConfig{ + { + Subnet: netip.MustParsePrefix("192.168.0.0/16"), + Except: netip.MustParsePrefix("192.168.3.0/24"), + }, + { + Subnet: netip.MustParsePrefix("2001::/16"), + }, + } + + return cfg + }, + }, + } { + test := test + + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + warnings, err := test.cfg().Validate(validationMode{}) + + assert.Equal(t, test.expectedWarnings, warnings) + + if test.expectedError != "" { + assert.EqualError(t, err, test.expectedError) + } else { + assert.NoError(t, err) + } + }) + } +} + +type validationMode struct{} + +func (validationMode) String() string { + return "" +} + +func (validationMode) RequiresInstall() bool { + return false +} diff --git a/pkg/machinery/config/types/network/testdata/defaultactionconfig.yaml b/pkg/machinery/config/types/network/testdata/defaultactionconfig.yaml new file mode 100644 index 00000000000..b306fde3746 --- /dev/null +++ b/pkg/machinery/config/types/network/testdata/defaultactionconfig.yaml @@ -0,0 +1,3 @@ +apiVersion: v1alpha1 +kind: NetworkDefaultActionConfig +ingress: block diff --git a/pkg/machinery/config/types/network/testdata/ruleconfig.yaml b/pkg/machinery/config/types/network/testdata/ruleconfig.yaml new file mode 100644 index 00000000000..dce08527f3d --- /dev/null +++ b/pkg/machinery/config/types/network/testdata/ruleconfig.yaml @@ -0,0 +1,12 @@ +apiVersion: v1alpha1 +kind: NetworkRuleConfig +name: test +portSelector: + ports: + - 53 + - 8000-9000 + protocol: udp +ingress: + - subnet: 192.168.0.0/16 + except: 192.168.0.3/32 + - subnet: 2001::/16 diff --git a/pkg/machinery/config/types/runtime/event_sink_test.go b/pkg/machinery/config/types/runtime/event_sink_test.go index 3cb34a4495a..50abb0f09e9 100644 --- a/pkg/machinery/config/types/runtime/event_sink_test.go +++ b/pkg/machinery/config/types/runtime/event_sink_test.go @@ -85,14 +85,6 @@ func TestEventSinkValidate(t *testing.T) { } } -func must[T any](t T, err error) T { - if err != nil { - panic(err) - } - - return t -} - type validationMode struct{} func (validationMode) String() string { diff --git a/pkg/machinery/config/types/runtime/kmsg_log_test.go b/pkg/machinery/config/types/runtime/kmsg_log_test.go index 3902f34012b..486283e47e4 100644 --- a/pkg/machinery/config/types/runtime/kmsg_log_test.go +++ b/pkg/machinery/config/types/runtime/kmsg_log_test.go @@ -9,6 +9,7 @@ import ( "net/url" "testing" + "github.com/siderolabs/gen/ensure" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,7 +23,7 @@ var expectedKmsgLogDocument []byte func TestKmsgLogMarshalStability(t *testing.T) { cfg := runtime.NewKmsgLogV1Alpha1() cfg.MetaName = "apiSink" - cfg.KmsgLogURL.URL = must(url.Parse("https://kmsglog.api/logs")) + cfg.KmsgLogURL.URL = ensure.Value(url.Parse("https://kmsglog.api/logs")) marshaled, err := encoder.NewEncoder(cfg).Encode() require.NoError(t, err) @@ -64,7 +65,7 @@ func TestKmsgLogValidate(t *testing.T) { cfg: func() *runtime.KmsgLogV1Alpha1 { cfg := runtime.NewKmsgLogV1Alpha1() cfg.MetaName = "name2" - cfg.KmsgLogURL.URL = must(url.Parse("https://some.destination/path")) + cfg.KmsgLogURL.URL = ensure.Value(url.Parse("https://some.destination/path")) return cfg }, @@ -76,7 +77,7 @@ func TestKmsgLogValidate(t *testing.T) { cfg: func() *runtime.KmsgLogV1Alpha1 { cfg := runtime.NewKmsgLogV1Alpha1() cfg.MetaName = "name5" - cfg.KmsgLogURL.URL = must(url.Parse("tcp://some.destination:34/path")) + cfg.KmsgLogURL.URL = ensure.Value(url.Parse("tcp://some.destination:34/path")) return cfg }, @@ -88,7 +89,7 @@ func TestKmsgLogValidate(t *testing.T) { cfg: func() *runtime.KmsgLogV1Alpha1 { cfg := runtime.NewKmsgLogV1Alpha1() cfg.MetaName = "name6" - cfg.KmsgLogURL.URL = must(url.Parse("tcp://some.destination/")) + cfg.KmsgLogURL.URL = ensure.Value(url.Parse("tcp://some.destination/")) return cfg }, @@ -100,7 +101,7 @@ func TestKmsgLogValidate(t *testing.T) { cfg: func() *runtime.KmsgLogV1Alpha1 { cfg := runtime.NewKmsgLogV1Alpha1() cfg.MetaName = "name3" - cfg.KmsgLogURL.URL = must(url.Parse("tcp://10.2.3.4:5000/")) + cfg.KmsgLogURL.URL = ensure.Value(url.Parse("tcp://10.2.3.4:5000/")) return cfg }, @@ -110,7 +111,7 @@ func TestKmsgLogValidate(t *testing.T) { cfg: func() *runtime.KmsgLogV1Alpha1 { cfg := runtime.NewKmsgLogV1Alpha1() cfg.MetaName = "name4" - cfg.KmsgLogURL.URL = must(url.Parse("udp://10.2.3.4:5000/")) + cfg.KmsgLogURL.URL = ensure.Value(url.Parse("udp://10.2.3.4:5000/")) return cfg }, diff --git a/pkg/machinery/config/types/siderolink/siderolink_test.go b/pkg/machinery/config/types/siderolink/siderolink_test.go index 830ccb21acf..e86daab2242 100644 --- a/pkg/machinery/config/types/siderolink/siderolink_test.go +++ b/pkg/machinery/config/types/siderolink/siderolink_test.go @@ -9,6 +9,7 @@ import ( "net/url" "testing" + "github.com/siderolabs/gen/ensure" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -20,7 +21,7 @@ func TestRedact(t *testing.T) { t.Parallel() cfg := siderolink.NewConfigV1Alpha1() - cfg.APIUrlConfig.URL = must(url.Parse("https://siderolink.api/join?jointoken=secret&user=alice")) + cfg.APIUrlConfig.URL = ensure.Value(url.Parse("https://siderolink.api/join?jointoken=secret&user=alice")) assert.Equal(t, "https://siderolink.api/join?jointoken=secret&user=alice", cfg.SideroLink().APIUrl().String()) @@ -36,7 +37,7 @@ func TestMarshalStability(t *testing.T) { t.Parallel() cfg := siderolink.NewConfigV1Alpha1() - cfg.APIUrlConfig.URL = must(url.Parse("https://siderolink.api/join?jointoken=secret&user=alice")) + cfg.APIUrlConfig.URL = ensure.Value(url.Parse("https://siderolink.api/join?jointoken=secret&user=alice")) marshaled, err := encoder.NewEncoder(cfg).Encode() require.NoError(t, err) @@ -64,7 +65,7 @@ func TestValidate(t *testing.T) { name: "wrong scheme", cfg: func() *siderolink.ConfigV1Alpha1 { cfg := siderolink.NewConfigV1Alpha1() - cfg.APIUrlConfig.URL = must(url.Parse("http://siderolink.api/")) + cfg.APIUrlConfig.URL = ensure.Value(url.Parse("http://siderolink.api/")) return cfg }, @@ -75,7 +76,7 @@ func TestValidate(t *testing.T) { name: "extra path", cfg: func() *siderolink.ConfigV1Alpha1 { cfg := siderolink.NewConfigV1Alpha1() - cfg.APIUrlConfig.URL = must(url.Parse("grpc://siderolink.api/path?jointoken=foo")) + cfg.APIUrlConfig.URL = ensure.Value(url.Parse("grpc://siderolink.api/path?jointoken=foo")) return cfg }, @@ -86,7 +87,7 @@ func TestValidate(t *testing.T) { name: "valid", cfg: func() *siderolink.ConfigV1Alpha1 { cfg := siderolink.NewConfigV1Alpha1() - cfg.APIUrlConfig.URL = must(url.Parse("https://siderolink.api:434/?jointoken=foo")) + cfg.APIUrlConfig.URL = ensure.Value(url.Parse("https://siderolink.api:434/?jointoken=foo")) return cfg }, @@ -110,14 +111,6 @@ func TestValidate(t *testing.T) { } } -func must[T any](t T, err error) T { - if err != nil { - panic(err) - } - - return t -} - type validationMode struct{} func (validationMode) String() string { diff --git a/pkg/machinery/config/types/types.go b/pkg/machinery/config/types/types.go index 79a37f51e28..fdcbfa75cf9 100644 --- a/pkg/machinery/config/types/types.go +++ b/pkg/machinery/config/types/types.go @@ -6,6 +6,7 @@ package types import ( + _ "github.com/siderolabs/talos/pkg/machinery/config/types/network" // import config types to register them _ "github.com/siderolabs/talos/pkg/machinery/config/types/runtime" // import config types to register them _ "github.com/siderolabs/talos/pkg/machinery/config/types/siderolink" // import config types to register them _ "github.com/siderolabs/talos/pkg/machinery/config/types/v1alpha1" // import config types to register them diff --git a/website/content/v1.6/reference/cli.md b/website/content/v1.6/reference/cli.md index 6e079584778..6e215db24a2 100644 --- a/website/content/v1.6/reference/cli.md +++ b/website/content/v1.6/reference/cli.md @@ -155,6 +155,7 @@ talosctl cluster create [flags] --with-bootloader enable bootloader to load kernel and initramfs from disk image after install (default true) --with-cluster-discovery enable cluster discovery (default true) --with-debug enable debug in Talos config to send service logs to the console + --with-firewall string inject firewall rules into the cluster, value is default policy - accept/block (QEMU only) --with-init-node create the cluster with an init node --with-kubespan enable KubeSpan system --with-network-bandwidth int specify bandwidth restriction (in kbps) on the bridge interface when creating a qemu cluster diff --git a/website/content/v1.6/talos-guides/configuration/editing-machine-configuration.md b/website/content/v1.6/talos-guides/configuration/editing-machine-configuration.md index f68b2f7a07d..81067bbc269 100644 --- a/website/content/v1.6/talos-guides/configuration/editing-machine-configuration.md +++ b/website/content/v1.6/talos-guides/configuration/editing-machine-configuration.md @@ -19,10 +19,11 @@ There are three `talosctl` commands which facilitate machine configuration updat Each of these commands can operate in one of four modes: -* apply change in automatic mode(default): reboot if the change can't be applied without a reboot, otherwise apply the change immediately +* apply change in automatic mode (default): reboot if the change can't be applied without a reboot, otherwise apply the change immediately * apply change with a reboot (`--mode=reboot`): update configuration, reboot Talos node to apply configuration change * apply change immediately (`--mode=no-reboot` flag): change is applied immediately without a reboot, fails if the change contains any fields that can not be updated without a reboot * apply change on next reboot (`--mode=staged`): change is staged to be applied after a reboot, but node is not rebooted +* apply change with automatic revert (`--mode=try`): change is applied immediately (if not possible, returns an error), and reverts it automatically in 1 minute if no configuration update is applied * apply change in the interactive mode (`--mode=interactive`; only for `talosctl apply-config`): launches TUI based interactive installer > Note: applying change on next reboot (`--mode=staged`) doesn't modify current node configuration, so next call to diff --git a/website/content/v1.6/talos-guides/network/ingress-firewall.md b/website/content/v1.6/talos-guides/network/ingress-firewall.md new file mode 100644 index 00000000000..6bbee6ac5fb --- /dev/null +++ b/website/content/v1.6/talos-guides/network/ingress-firewall.md @@ -0,0 +1,233 @@ +--- +title: "Ingress Firewall" +description: "Learn to use Talos Linux Ingress Firewall to limit access to the host services." +--- + +Talos Linux Ingress Firewall is a simple and effective way to limit access to the services running on the host, which includes both Talos standard +services (e.g. `apid` and `kubelet`), and any additional workloads that may be running on the host. +Talos Linux Ingress Firewall doesn't affect the traffic between the Kubernetes pods/services, please use CNI Network Policies for that. + +## Configuration + +Ingress rules are configured as extra documents in the Talos machine configuration: + +```yaml +apiVersion: v1alpha1 +kind: NetworkDefaultActionConfig +ingress: block +--- +apiVersion: v1alpha1 +kind: NetworkRuleConfig +name: kubelet-ingress +portSelector: + ports: + - 10250 + protocol: tcp +ingress: + - subnet: 172.20.0.0/24 + except: 172.20.0.1/32 +``` + +The first document configures the default action for the ingress traffic, which can be either `accept` or `block`, with the default being `accept`. +If the default action is set to `accept`, then all the ingress traffic will be allowed, unless there is a matching rule that blocks it. +If the default action is set to `block`, then all the ingress traffic will be blocked, unless there is a matching rule that allows it. + +With either `accept` or `block`, the traffic is always allowed on the following network interfaces: + +* `lo` +* `siderolink` +* `kubespan` + +In the `block` mode: + +* ICMP and ICMPv6 traffic is also allowed with a rate limit of 5 packets per second +* traffic between Kubernetes pod/service subnets is allowed (for native routing CNIs) + +The second document defines an ingress rule for a set of ports and protocols on the host. +The `NetworkRuleConfig` might be repeated many times to define multiple rules, but each document must have a unique name. + +The `ports` field accepts either a single port or a port range: + +```yaml +portSelector: + ports: + - 10250 + - 10260 + - 10300-10400 +``` + +The `protocol` might be either `tcp` or `udp`. + +The `ingress` specifies the list of subnets that are allowed to access the host services, with the optional `except` field to exclude a set of addresses from the subnet. + +> Note: incorrect configuration of the ingress firewall might result in the host becoming inaccessible over Talos API. +> The configuration might be [applied]({{< relref "../configuration/editing-machine-configuration" >}}) in `--mode=try` to make sure it gets reverted in case of a mistake. + +## Recommended Rules + +The following rules improve the security of the cluster and cover only standard Talos services. +If there are additional services running with host networking in the cluster, they should be covered by additional rules. + +In the `block` mode, the ingress firewall will also block encapsulated traffic (e.g. VXLAN) between the nodes, which needs to be explicitly allowed for the Kubernetes +networking to function properly. +Please refer to the CNI documentation for the specifics, some default configurations are listed below: + +* Flannel, Calico: `vxlan` UDP port 4789 +* Cilium: `vxlan` UDP port 8472 + +In the examples we assume following template variables to describe the cluster: + +* `$CLUSTER_SUBNET`, e.g. `172.20.0.0/24` - the subnet which covers all machines in the cluster +* `$CP1`, `$CP2`, `$CP3` - the IP addresses of the controlplane nodes +* `$VXLAN_PORT` - the UDP port used by the CNI for encapsulated traffic + +### Controlplane + +* `apid` and Kubernetes API are wide open +* `kubelet` and `trustd` API is only accessible within the cluster +* `etcd` API is limited to controlplane nodes + +```yaml +apiVersion: v1alpha1 +kind: NetworkDefaultActionConfig +ingress: block +--- +apiVersion: v1alpha1 +kind: NetworkRuleConfig +name: kubelet-ingress +portSelector: + ports: + - 10250 + protocol: tcp +ingress: + - subnet: $CLUSTER_SUBNET +--- +apiVersion: v1alpha1 +kind: NetworkRuleConfig +name: apid-ingress +portSelector: + ports: + - 50000 + protocol: tcp +ingress: + - subnet: 0.0.0.0/0 + - subnet: ::/0 +--- +apiVersion: v1alpha1 +kind: NetworkRuleConfig +name: trustd-ingress +portSelector: + ports: + - 50001 + protocol: tcp +ingress: + - subnet: $CLUSTER_SUBNET +--- +apiVersion: v1alpha1 +kind: NetworkRuleConfig +name: kubernetes-api-ingress +portSelector: + ports: + - 6443 + protocol: tcp +ingress: + - subnet: 0.0.0.0/0 + - subnet: ::/0 +--- +apiVersion: v1alpha1 +kind: NetworkRuleConfig +name: etcd-ingress +portSelector: + ports: + - 2379-2380 + protocol: tcp +ingress: + - subnet: $CP1/32 + - subnet: $CP2/32 + - subnet: $CP3/32 +--- +apiVersion: v1alpha1 +kind: NetworkRuleConfig +name: cni-vxlan +portSelector: + ports: + - $VXLAN_PORT + protocol: udp +ingress: + - subnet: $CLUSTER_SUBNET +``` + +### Worker + +* `kubelet` and `apid` API is only accessible within the cluster + +```yaml +apiVersion: v1alpha1 +kind: NetworkDefaultActionConfig +ingress: block +--- +apiVersion: v1alpha1 +kind: NetworkRuleConfig +name: kubelet-ingress +portSelector: + ports: + - 10250 + protocol: tcp +ingress: + - subnet: $CLUSTER_SUBNET +--- +apiVersion: v1alpha1 +kind: NetworkRuleConfig +name: apid-ingress +portSelector: + ports: + - 50000 + protocol: tcp +ingress: + - subnet: $CLUSTER_SUBNET +--- +apiVersion: v1alpha1 +kind: NetworkRuleConfig +name: cni-vxlan +portSelector: + ports: + - $VXLAN_PORT + protocol: udp +ingress: + - subnet: $CLUSTER_SUBNET +``` + +## Learn More + +Talos Linux Ingress Firewall is using the `nftables` to perform the filtering. + +With the default action set to `accept`, the following rules are applied (example): + +```text +table inet talos { + chain ingress { + type filter hook input priority filter; policy accept; + iifname { "lo", "siderolink", "kubespan" } accept + ip saddr != { 172.20.0.0/24 } tcp dport { 10250 } drop + meta nfproto ipv6 tcp dport { 10250 } drop + } +} +``` + +With the default action set to `block`, the following rules are applied (example): + +```text +table inet talos { + chain ingress { + type filter hook input priority filter; policy drop; + iifname { "lo", "siderolink", "kubespan" } accept + ct state { established, related } accept + ct state invalid drop + meta l4proto icmp limit rate 5/second accept + meta l4proto ipv6-icmp limit rate 5/second accept + ip saddr { 172.20.0.0/24 } tcp dport { 10250 } accept + meta nfproto ipv4 tcp dport { 50000 } accept + meta nfproto ipv6 tcp dport { 50000 } accept + } +} +```