From caee24bf61136daecb095991a6e439f7fbf40da2 Mon Sep 17 00:00:00 2001 From: Andrey Smirnov Date: Fri, 27 Aug 2021 18:43:18 +0300 Subject: [PATCH] feat: implement KubeSpan identity controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #4138 When KubeSpan is enabled, Talos automatically generates or loads KubeSpan identity which consists of Wireguard key pair. ULA address is calculated based on ClusterID and first NIC MAC address. Some code was borrowed from #3577. Example: ``` $ talosctl -n 172.20.0.2 get ksi NODE NAMESPACE TYPE ID VERSION ADDRESS PUBLICKEY 172.20.0.2 kubespan KubeSpanIdentity local 1 fd71:6e1d:86be:6302:e871:1bff:feb2:ccee/128 Oak2fBEWngBhwslBxDVgnRNHXs88OAp4kjroSX0uqUE= ``` Additional changes: * `--with-kubespan` flag for `talosctl cluster create` for quick testing * validate that cluster discovery (and KubeSpan) requires ClusterID and ClusterSecret. Signed-off-by: Andrey Smirnov Signed-off-by: Seán C McCord Co-authored-by: Seán C McCord --- cmd/talosctl/cmd/mgmt/cluster/create.go | 11 ++ go.mod | 1 + go.sum | 2 + .../pkg/controllers/cluster/node_identity.go | 53 +----- .../pkg/controllers/kubespan/config.go | 103 ++++++++++++ .../pkg/controllers/kubespan/config_test.go | 93 +++++++++++ .../pkg/controllers/kubespan/identity.go | 152 ++++++++++++++++++ .../pkg/controllers/kubespan/identity_test.go | 141 ++++++++++++++++ .../pkg/controllers/kubespan/kubespan.go | 6 + .../pkg/controllers/kubespan/kubespan_test.go | 93 +++++++++++ .../app/machined/pkg/controllers/utils.go | 60 +++++++ .../runtime/v1alpha2/v1alpha2_controller.go | 7 +- .../pkg/runtime/v1alpha2/v1alpha2_state.go | 4 + .../types/v1alpha1/generate/generate.go | 2 + .../config/types/v1alpha1/generate/init.go | 3 + .../config/types/v1alpha1/generate/options.go | 10 ++ .../config/types/v1alpha1/generate/worker.go | 3 + .../v1alpha1/v1alpha1_network_options.go | 9 ++ .../types/v1alpha1/v1alpha1_validation.go | 30 +++- .../v1alpha1/v1alpha1_validation_test.go | 26 ++- pkg/machinery/constants/constants.go | 3 + pkg/resources/kubespan/config.go | 84 ++++++++++ pkg/resources/kubespan/identity.go | 125 ++++++++++++++ pkg/resources/kubespan/identity_test.go | 33 ++++ pkg/resources/kubespan/kubespan.go | 10 ++ pkg/resources/kubespan/kubespan_test.go | 33 ++++ pkg/resources/kubespan/utils.go | 31 ++++ pkg/resources/network/ula.go | 45 ++++++ pkg/resources/network/ula_test.go | 17 ++ website/content/docs/v0.13/Reference/cli.md | 1 + 30 files changed, 1134 insertions(+), 57 deletions(-) create mode 100644 internal/app/machined/pkg/controllers/kubespan/config.go create mode 100644 internal/app/machined/pkg/controllers/kubespan/config_test.go create mode 100644 internal/app/machined/pkg/controllers/kubespan/identity.go create mode 100644 internal/app/machined/pkg/controllers/kubespan/identity_test.go create mode 100644 internal/app/machined/pkg/controllers/kubespan/kubespan.go create mode 100644 internal/app/machined/pkg/controllers/kubespan/kubespan_test.go create mode 100644 internal/app/machined/pkg/controllers/utils.go create mode 100644 pkg/resources/kubespan/config.go create mode 100644 pkg/resources/kubespan/identity.go create mode 100644 pkg/resources/kubespan/identity_test.go create mode 100644 pkg/resources/kubespan/kubespan.go create mode 100644 pkg/resources/kubespan/kubespan_test.go create mode 100644 pkg/resources/kubespan/utils.go create mode 100644 pkg/resources/network/ula.go create mode 100644 pkg/resources/network/ula_test.go diff --git a/cmd/talosctl/cmd/mgmt/cluster/create.go b/cmd/talosctl/cmd/mgmt/cluster/create.go index 170af22db..c8650adff 100644 --- a/cmd/talosctl/cmd/mgmt/cluster/create.go +++ b/cmd/talosctl/cmd/mgmt/cluster/create.go @@ -104,6 +104,7 @@ var ( encryptStatePartition bool encryptEphemeralPartition bool useVIP bool + enableKubeSpan bool configPatch string configPatchControlPlane string configPatchWorker string @@ -382,6 +383,15 @@ func create(ctx context.Context) (err error) { ) } + if enableKubeSpan { + genOptions = append(genOptions, + generate.WithNetworkOptions( + v1alpha1.WithKubeSpan(), + ), + generate.WithClusterDiscovery(), + ) + } + defaultInternalLB, defaultEndpoint := provisioner.GetLoadBalancers(request.Network) if defaultInternalLB == "" { @@ -818,6 +828,7 @@ func init() { createCmd.Flags().BoolVar(&encryptEphemeralPartition, "encrypt-ephemeral", false, "enable ephemeral partition encryption") createCmd.Flags().StringVar(&talosVersion, "talos-version", "", "the desired Talos version to generate config for (if not set, defaults to image version)") createCmd.Flags().BoolVar(&useVIP, "use-vip", false, "use a virtual IP for the controlplane endpoint instead of the loadbalancer") + createCmd.Flags().BoolVar(&enableKubeSpan, "with-kubespan", false, "enable KubeSpan system") createCmd.Flags().StringVar(&configPatch, "config-patch", "", "patch generated machineconfigs (applied to all node types)") createCmd.Flags().StringVar(&configPatchControlPlane, "config-patch-control-plane", "", "patch generated machineconfigs (applied to 'init' and 'controlplane' types)") createCmd.Flags().StringVar(&configPatchWorker, "config-patch-worker", "", "patch generated machineconfigs (applied to 'worker' type)") diff --git a/go.mod b/go.mod index 4de38df0d..8e48c7799 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,7 @@ require ( github.com/mdlayher/ethtool v0.0.0-20210210192532-2b88debcdd43 github.com/mdlayher/genetlink v1.0.0 github.com/mdlayher/netlink v1.4.1 + github.com/mdlayher/netx v0.0.0-20200512211805-669a06fde734 github.com/morikuni/aec v1.0.0 // indirect github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417 github.com/packethost/packngo v0.19.0 diff --git a/go.sum b/go.sum index 3d2a1098d..7eed22f22 100644 --- a/go.sum +++ b/go.sum @@ -851,6 +851,8 @@ github.com/mdlayher/netlink v1.3.0/go.mod h1:xK/BssKuwcRXHrtN04UBkwQ6dY9VviGGuri github.com/mdlayher/netlink v1.4.0/go.mod h1:dRJi5IABcZpBD2A3D0Mv/AiX8I9uDEu5oGkAVrekmf8= github.com/mdlayher/netlink v1.4.1 h1:I154BCU+mKlIf7BgcAJB2r7QjveNPty6uNY1g9ChVfI= github.com/mdlayher/netlink v1.4.1/go.mod h1:e4/KuJ+s8UhfUpO9z00/fDZZmhSrs+oxyqAS9cNgn6Q= +github.com/mdlayher/netx v0.0.0-20200512211805-669a06fde734 h1:DzkgdcT/W7794xU5P7GdZvok/lJECZ8g4xS+vMNLREI= +github.com/mdlayher/netx v0.0.0-20200512211805-669a06fde734/go.mod h1:iN5Y6R8oOaC0KMzLtw/dqCJ2ZOipmk+bncXKStCHr7Q= github.com/mdlayher/raw v0.0.0-20190313224157-43dbcdd7739d/go.mod h1:r1fbeITl2xL/zLbVnNHFyOzQJTgr/3fpf1lJX/cjzR8= github.com/mdlayher/raw v0.0.0-20190606142536-fef19f00fc18/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 h1:aFkJ6lx4FPip+S+Uw4aTegFMct9shDvP+79PsSxpm3w= diff --git a/internal/app/machined/pkg/controllers/cluster/node_identity.go b/internal/app/machined/pkg/controllers/cluster/node_identity.go index ed03ff41e..a4ce7d980 100644 --- a/internal/app/machined/pkg/controllers/cluster/node_identity.go +++ b/internal/app/machined/pkg/controllers/cluster/node_identity.go @@ -7,17 +7,15 @@ package cluster import ( "context" "fmt" - "os" "path/filepath" - "reflect" "github.com/AlekSi/pointer" "github.com/cosi-project/runtime/pkg/controller" "github.com/cosi-project/runtime/pkg/resource" "github.com/cosi-project/runtime/pkg/state" "go.uber.org/zap" - "gopkg.in/yaml.v3" + "github.com/talos-systems/talos/internal/app/machined/pkg/controllers" "github.com/talos-systems/talos/internal/app/machined/pkg/runtime" "github.com/talos-systems/talos/pkg/machinery/constants" "github.com/talos-systems/talos/pkg/resources/cluster" @@ -60,53 +58,6 @@ func (ctrl *NodeIdentityController) Outputs() []controller.Output { } } -func loadOrNewFromState(statePath, path string, empty interface{}, generate func(interface{}) error) error { - fullPath := filepath.Join(statePath, path) - - f, err := os.OpenFile(fullPath, os.O_RDONLY, 0) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("error reading state file: %w", err) - } - - // file doesn't exist yet, generate new value and save it - if f == nil { - if err = generate(empty); err != nil { - return err - } - - f, err = os.OpenFile(fullPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) - if err != nil { - return fmt.Errorf("error creating state file: %w", err) - } - - defer f.Close() //nolint:errcheck - - encoder := yaml.NewEncoder(f) - if err = encoder.Encode(empty); err != nil { - return fmt.Errorf("error marshaling: %w", err) - } - - if err = encoder.Close(); err != nil { - return err - } - - return f.Close() - } - - // read existing cached value - defer f.Close() //nolint:errcheck - - if err = yaml.NewDecoder(f).Decode(empty); err != nil { - return fmt.Errorf("error unmarshaling: %w", err) - } - - if reflect.ValueOf(empty).Elem().IsZero() { - return fmt.Errorf("value is still zero after unmarshaling") - } - - return f.Close() -} - // Run implements controller.Controller interface. // //nolint:gocyclo @@ -136,7 +87,7 @@ func (ctrl *NodeIdentityController) Run(ctx context.Context, r controller.Runtim var localIdentity cluster.IdentitySpec - if err := loadOrNewFromState(ctrl.StatePath, constants.NodeIdentityFilename, &localIdentity, func(v interface{}) error { + if err := controllers.LoadOrNewFromFile(filepath.Join(ctrl.StatePath, constants.NodeIdentityFilename), &localIdentity, func(v interface{}) error { return v.(*cluster.IdentitySpec).Generate() }); err != nil { return fmt.Errorf("error caching node identity: %w", err) diff --git a/internal/app/machined/pkg/controllers/kubespan/config.go b/internal/app/machined/pkg/controllers/kubespan/config.go new file mode 100644 index 000000000..b9e654106 --- /dev/null +++ b/internal/app/machined/pkg/controllers/kubespan/config.go @@ -0,0 +1,103 @@ +// 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 kubespan + +import ( + "context" + "fmt" + + "github.com/AlekSi/pointer" + "github.com/cosi-project/runtime/pkg/controller" + "github.com/cosi-project/runtime/pkg/resource" + "github.com/cosi-project/runtime/pkg/state" + "go.uber.org/zap" + + "github.com/talos-systems/talos/pkg/resources/config" + "github.com/talos-systems/talos/pkg/resources/kubespan" +) + +// ConfigController watches v1alpha1.Config, updates KubeSpan config. +type ConfigController struct{} + +// Name implements controller.Controller interface. +func (ctrl *ConfigController) Name() string { + return "kubespan.ConfigController" +} + +// Inputs implements controller.Controller interface. +func (ctrl *ConfigController) Inputs() []controller.Input { + return []controller.Input{ + { + Namespace: config.NamespaceName, + Type: config.MachineConfigType, + ID: pointer.ToString(config.V1Alpha1ID), + Kind: controller.InputWeak, + }, + } +} + +// Outputs implements controller.Controller interface. +func (ctrl *ConfigController) Outputs() []controller.Output { + return []controller.Output{ + { + Type: kubespan.ConfigType, + Kind: controller.OutputExclusive, + }, + } +} + +// Run implements controller.Controller interface. +// +//nolint:gocyclo +func (ctrl *ConfigController) Run(ctx context.Context, r controller.Runtime, logger *zap.Logger) error { + for { + select { + case <-ctx.Done(): + return nil + case <-r.EventCh(): + cfg, err := r.Get(ctx, resource.NewMetadata(config.NamespaceName, config.MachineConfigType, config.V1Alpha1ID, resource.VersionUndefined)) + if err != nil { + if !state.IsNotFoundError(err) { + return fmt.Errorf("error getting config: %w", err) + } + } + + touchedIDs := make(map[resource.ID]struct{}) + + if cfg != nil { + c := cfg.(*config.MachineConfig).Config() + + if err = r.Modify(ctx, kubespan.NewConfig(config.NamespaceName, kubespan.ConfigID), func(res resource.Resource) error { + res.(*kubespan.Config).TypedSpec().Enabled = c.Machine().Network().KubeSpan().Enabled() + res.(*kubespan.Config).TypedSpec().ClusterID = c.Cluster().ID() + + return nil + }); err != nil { + return err + } + + touchedIDs[kubespan.ConfigID] = struct{}{} + } + + // list keys for cleanup + list, err := r.List(ctx, resource.NewMetadata(config.NamespaceName, kubespan.ConfigType, "", resource.VersionUndefined)) + if err != nil { + return fmt.Errorf("error listing resources: %w", err) + } + + for _, res := range list.Items { + if res.Metadata().Owner() != ctrl.Name() { + continue + } + + if _, ok := touchedIDs[res.Metadata().ID()]; !ok { + if err = r.Destroy(ctx, res.Metadata()); err != nil { + return fmt.Errorf("error cleaning up specs: %w", err) + } + } + } + } + } +} diff --git a/internal/app/machined/pkg/controllers/kubespan/config_test.go b/internal/app/machined/pkg/controllers/kubespan/config_test.go new file mode 100644 index 000000000..c04c6c058 --- /dev/null +++ b/internal/app/machined/pkg/controllers/kubespan/config_test.go @@ -0,0 +1,93 @@ +// 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 kubespan_test + +import ( + "testing" + "time" + + "github.com/cosi-project/runtime/pkg/resource" + "github.com/stretchr/testify/suite" + "github.com/talos-systems/go-retry/retry" + + kubespanctrl "github.com/talos-systems/talos/internal/app/machined/pkg/controllers/kubespan" + "github.com/talos-systems/talos/pkg/machinery/config/types/v1alpha1" + "github.com/talos-systems/talos/pkg/resources/config" + "github.com/talos-systems/talos/pkg/resources/kubespan" +) + +type ConfigSuite struct { + KubeSpanSuite +} + +func (suite *ConfigSuite) TestReconcileConfig() { + suite.Require().NoError(suite.runtime.RegisterController(&kubespanctrl.ConfigController{})) + + suite.startRuntime() + + cfg := config.NewMachineConfig(&v1alpha1.Config{ + ConfigVersion: "v1alpha1", + MachineConfig: &v1alpha1.MachineConfig{ + MachineNetwork: &v1alpha1.NetworkConfig{ + NetworkKubeSpan: v1alpha1.NetworkKubeSpan{ + KubeSpanEnabled: true, + }, + }, + }, + ClusterConfig: &v1alpha1.ClusterConfig{ + ClusterID: "8XuV9TZHW08DOk3bVxQjH9ih_TBKjnh-j44tsCLSBzo=", + }, + }) + + suite.Require().NoError(suite.state.Create(suite.ctx, cfg)) + + specMD := resource.NewMetadata(config.NamespaceName, kubespan.ConfigType, kubespan.ConfigID, resource.VersionUndefined) + + suite.Assert().NoError(retry.Constant(3*time.Second, retry.WithUnits(100*time.Millisecond)).Retry( + suite.assertResource( + specMD, + func(res resource.Resource) error { + spec := res.(*kubespan.Config).TypedSpec() + + suite.Assert().True(spec.Enabled) + suite.Assert().Equal("8XuV9TZHW08DOk3bVxQjH9ih_TBKjnh-j44tsCLSBzo=", spec.ClusterID) + + return nil + }, + ), + )) +} + +func (suite *ConfigSuite) TestReconcileDisabled() { + suite.Require().NoError(suite.runtime.RegisterController(&kubespanctrl.ConfigController{})) + + suite.startRuntime() + + cfg := config.NewMachineConfig(&v1alpha1.Config{ + ConfigVersion: "v1alpha1", + MachineConfig: &v1alpha1.MachineConfig{}, + ClusterConfig: &v1alpha1.ClusterConfig{}, + }) + + suite.Require().NoError(suite.state.Create(suite.ctx, cfg)) + + specMD := resource.NewMetadata(config.NamespaceName, kubespan.ConfigType, kubespan.ConfigID, resource.VersionUndefined) + + suite.Assert().NoError(retry.Constant(3*time.Second, retry.WithUnits(100*time.Millisecond)).Retry( + suite.assertResource( + specMD, + func(res resource.Resource) error { + spec := res.(*kubespan.Config).TypedSpec() + + suite.Assert().False(spec.Enabled) + + return nil + }, + ), + )) +} + +func TestConfigSuite(t *testing.T) { + suite.Run(t, new(ConfigSuite)) +} diff --git a/internal/app/machined/pkg/controllers/kubespan/identity.go b/internal/app/machined/pkg/controllers/kubespan/identity.go new file mode 100644 index 000000000..2fcd3af0e --- /dev/null +++ b/internal/app/machined/pkg/controllers/kubespan/identity.go @@ -0,0 +1,152 @@ +// 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 kubespan + +import ( + "context" + "fmt" + "net" + "path/filepath" + + "github.com/AlekSi/pointer" + "github.com/cosi-project/runtime/pkg/controller" + "github.com/cosi-project/runtime/pkg/resource" + "github.com/cosi-project/runtime/pkg/state" + "go.uber.org/zap" + + "github.com/talos-systems/talos/internal/app/machined/pkg/controllers" + "github.com/talos-systems/talos/pkg/machinery/constants" + "github.com/talos-systems/talos/pkg/resources/config" + "github.com/talos-systems/talos/pkg/resources/kubespan" + "github.com/talos-systems/talos/pkg/resources/network" + runtimeres "github.com/talos-systems/talos/pkg/resources/runtime" + "github.com/talos-systems/talos/pkg/resources/v1alpha1" +) + +// IdentityController watches KubeSpan configuration, updates KubeSpan Identity. +type IdentityController struct { + StatePath string +} + +// Name implements controller.Controller interface. +func (ctrl *IdentityController) Name() string { + return "kubespan.IdentityController" +} + +// Inputs implements controller.Controller interface. +func (ctrl *IdentityController) Inputs() []controller.Input { + return []controller.Input{ + { + Namespace: config.NamespaceName, + Type: kubespan.ConfigType, + ID: pointer.ToString(kubespan.ConfigID), + Kind: controller.InputWeak, + }, + { + Namespace: network.NamespaceName, + Type: network.HardwareAddrType, + ID: pointer.ToString(network.FirstHardwareAddr), + Kind: controller.InputWeak, + }, + { + Namespace: v1alpha1.NamespaceName, + Type: runtimeres.MountStatusType, + ID: pointer.ToString(constants.StatePartitionLabel), + Kind: controller.InputWeak, + }, + } +} + +// Outputs implements controller.Controller interface. +func (ctrl *IdentityController) Outputs() []controller.Output { + return []controller.Output{ + { + Type: kubespan.IdentityType, + Kind: controller.OutputExclusive, + }, + } +} + +// Run implements controller.Controller interface. +// +//nolint:gocyclo,cyclop +func (ctrl *IdentityController) Run(ctx context.Context, r controller.Runtime, logger *zap.Logger) error { + if ctrl.StatePath == "" { + ctrl.StatePath = constants.StateMountPoint + } + + for { + select { + case <-ctx.Done(): + return nil + case <-r.EventCh(): + if _, err := r.Get(ctx, resource.NewMetadata(v1alpha1.NamespaceName, runtimeres.MountStatusType, constants.StatePartitionLabel, resource.VersionUndefined)); err != nil { + if state.IsNotFoundError(err) { + // wait for STATE to be mounted + continue + } + + return fmt.Errorf("error reading mount status: %w", err) + } + + cfg, err := r.Get(ctx, resource.NewMetadata(config.NamespaceName, kubespan.ConfigType, kubespan.ConfigID, resource.VersionUndefined)) + if err != nil && !state.IsNotFoundError(err) { + return fmt.Errorf("error getting kubespan configuration: %w", err) + } + + firstMAC, err := r.Get(ctx, resource.NewMetadata(network.NamespaceName, network.HardwareAddrType, network.FirstHardwareAddr, resource.VersionUndefined)) + if err != nil && !state.IsNotFoundError(err) { + return fmt.Errorf("error getting first MAC address: %w", err) + } + + touchedIDs := make(map[resource.ID]struct{}) + + if cfg != nil && firstMAC != nil && cfg.(*kubespan.Config).TypedSpec().Enabled { + var localIdentity kubespan.IdentitySpec + + if err = controllers.LoadOrNewFromFile(filepath.Join(ctrl.StatePath, constants.KubeSpanIdentityFilename), &localIdentity, func(v interface{}) error { + return v.(*kubespan.IdentitySpec).GenerateKey() + }); err != nil { + return fmt.Errorf("error caching kubespan identity: %w", err) + } + + kubespanCfg := cfg.(*kubespan.Config).TypedSpec() + mac := firstMAC.(*network.HardwareAddr).TypedSpec() + + if err = localIdentity.UpdateAddress(kubespanCfg.ClusterID, net.HardwareAddr(mac.HardwareAddr)); err != nil { + return fmt.Errorf("error updating KubeSpan address: %w", err) + } + + if err = r.Modify(ctx, kubespan.NewIdentity(kubespan.NamespaceName, kubespan.LocalIdentity), func(res resource.Resource) error { + *res.(*kubespan.Identity).TypedSpec() = localIdentity + + return nil + }); err != nil { + return err + } + + touchedIDs[kubespan.LocalIdentity] = struct{}{} + } + + // list keys for cleanup + list, err := r.List(ctx, resource.NewMetadata(kubespan.NamespaceName, kubespan.IdentityType, "", resource.VersionUndefined)) + if err != nil { + return fmt.Errorf("error listing resources: %w", err) + } + + for _, res := range list.Items { + if res.Metadata().Owner() != ctrl.Name() { + continue + } + + if _, ok := touchedIDs[res.Metadata().ID()]; !ok { + if err = r.Destroy(ctx, res.Metadata()); err != nil { + return fmt.Errorf("error cleaning up specs: %w", err) + } + } + } + } + } +} diff --git a/internal/app/machined/pkg/controllers/kubespan/identity_test.go b/internal/app/machined/pkg/controllers/kubespan/identity_test.go new file mode 100644 index 000000000..f66d51fb2 --- /dev/null +++ b/internal/app/machined/pkg/controllers/kubespan/identity_test.go @@ -0,0 +1,141 @@ +// 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 kubespan_test + +import ( + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/cosi-project/runtime/pkg/resource" + "github.com/stretchr/testify/suite" + "github.com/talos-systems/go-retry/retry" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + kubespanctrl "github.com/talos-systems/talos/internal/app/machined/pkg/controllers/kubespan" + "github.com/talos-systems/talos/pkg/machinery/constants" + "github.com/talos-systems/talos/pkg/machinery/nethelpers" + "github.com/talos-systems/talos/pkg/resources/config" + "github.com/talos-systems/talos/pkg/resources/kubespan" + "github.com/talos-systems/talos/pkg/resources/network" + runtimeres "github.com/talos-systems/talos/pkg/resources/runtime" + "github.com/talos-systems/talos/pkg/resources/v1alpha1" +) + +type IdentitySuite struct { + KubeSpanSuite + + statePath string +} + +func (suite *IdentitySuite) TestGenerate() { + suite.statePath = suite.T().TempDir() + + suite.Require().NoError(suite.runtime.RegisterController(&kubespanctrl.IdentityController{ + StatePath: suite.statePath, + })) + + suite.startRuntime() + + stateMount := runtimeres.NewMountStatus(v1alpha1.NamespaceName, constants.StatePartitionLabel) + + suite.Assert().NoError(suite.state.Create(suite.ctx, stateMount)) + + cfg := kubespan.NewConfig(config.NamespaceName, kubespan.ConfigID) + cfg.TypedSpec().Enabled = true + cfg.TypedSpec().ClusterID = "8XuV9TZHW08DOk3bVxQjH9ih_TBKjnh-j44tsCLSBzo=" + + suite.Require().NoError(suite.state.Create(suite.ctx, cfg)) + + firstMac := network.NewHardwareAddr(network.NamespaceName, network.FirstHardwareAddr) + mac, err := net.ParseMAC("ea:71:1b:b2:cc:ee") + suite.Require().NoError(err) + + firstMac.TypedSpec().HardwareAddr = nethelpers.HardwareAddr(mac) + + suite.Require().NoError(suite.state.Create(suite.ctx, firstMac)) + + specMD := resource.NewMetadata(kubespan.NamespaceName, kubespan.IdentityType, kubespan.LocalIdentity, resource.VersionUndefined) + + suite.Assert().NoError(retry.Constant(3*time.Second, retry.WithUnits(100*time.Millisecond)).Retry( + suite.assertResource( + specMD, + func(res resource.Resource) error { + spec := res.(*kubespan.Identity).TypedSpec() + + _, err := wgtypes.ParseKey(spec.PrivateKey) + suite.Assert().NoError(err) + + _, err = wgtypes.ParseKey(spec.PublicKey) + suite.Assert().NoError(err) + + suite.Assert().Equal("fd7f:175a:b97c:5602:e871:1bff:feb2:ccee/128", spec.Address.String()) + suite.Assert().Equal("fd7f:175a:b97c:5602::/64", spec.Subnet.String()) + + return nil + }, + ), + )) +} + +func (suite *IdentitySuite) TestLoad() { + // using verbatim data here to make sure nodeId representation is supported in future version fo Talos + const identityYaml = `address: "" +subnet: "" +privateKey: sF45u5ePau58WeeCUY3T8D9foEKaQ8Opx4cGC8g4XE4= +publicKey: Oak2fBEWngBhwslBxDVgnRNHXs88OAp4kjroSX0uqUE= +` + + suite.statePath = suite.T().TempDir() + + suite.Require().NoError(suite.runtime.RegisterController(&kubespanctrl.IdentityController{ + StatePath: suite.statePath, + })) + + suite.startRuntime() + + suite.Require().NoError(os.WriteFile(filepath.Join(suite.statePath, constants.KubeSpanIdentityFilename), []byte(identityYaml), 0o600)) + + stateMount := runtimeres.NewMountStatus(v1alpha1.NamespaceName, constants.StatePartitionLabel) + + suite.Assert().NoError(suite.state.Create(suite.ctx, stateMount)) + + cfg := kubespan.NewConfig(config.NamespaceName, kubespan.ConfigID) + cfg.TypedSpec().Enabled = true + cfg.TypedSpec().ClusterID = "8XuV9TZHW08DOk3bVxQjH9ih_TBKjnh-j44tsCLSBzo=" + + suite.Require().NoError(suite.state.Create(suite.ctx, cfg)) + + firstMac := network.NewHardwareAddr(network.NamespaceName, network.FirstHardwareAddr) + mac, err := net.ParseMAC("ea:71:1b:b2:cc:ee") + suite.Require().NoError(err) + + firstMac.TypedSpec().HardwareAddr = nethelpers.HardwareAddr(mac) + + suite.Require().NoError(suite.state.Create(suite.ctx, firstMac)) + + specMD := resource.NewMetadata(kubespan.NamespaceName, kubespan.IdentityType, kubespan.LocalIdentity, resource.VersionUndefined) + + suite.Assert().NoError(retry.Constant(3*time.Second, retry.WithUnits(100*time.Millisecond)).Retry( + suite.assertResource( + specMD, + func(res resource.Resource) error { + spec := res.(*kubespan.Identity).TypedSpec() + + suite.Assert().Equal("sF45u5ePau58WeeCUY3T8D9foEKaQ8Opx4cGC8g4XE4=", spec.PrivateKey) + suite.Assert().Equal("Oak2fBEWngBhwslBxDVgnRNHXs88OAp4kjroSX0uqUE=", spec.PublicKey) + suite.Assert().Equal("fd7f:175a:b97c:5602:e871:1bff:feb2:ccee/128", spec.Address.String()) + suite.Assert().Equal("fd7f:175a:b97c:5602::/64", spec.Subnet.String()) + + return nil + }, + ), + )) +} + +func TestIdentitySuite(t *testing.T) { + suite.Run(t, new(IdentitySuite)) +} diff --git a/internal/app/machined/pkg/controllers/kubespan/kubespan.go b/internal/app/machined/pkg/controllers/kubespan/kubespan.go new file mode 100644 index 000000000..cb59c1ee7 --- /dev/null +++ b/internal/app/machined/pkg/controllers/kubespan/kubespan.go @@ -0,0 +1,6 @@ +// 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 kubespan provides controllers which manage Talos KubeSpan feature. +package kubespan diff --git a/internal/app/machined/pkg/controllers/kubespan/kubespan_test.go b/internal/app/machined/pkg/controllers/kubespan/kubespan_test.go new file mode 100644 index 000000000..2a7bc2aed --- /dev/null +++ b/internal/app/machined/pkg/controllers/kubespan/kubespan_test.go @@ -0,0 +1,93 @@ +// 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 kubespan_test + +import ( + "context" + "log" + "sync" + "time" + + "github.com/cosi-project/runtime/pkg/controller/runtime" + "github.com/cosi-project/runtime/pkg/resource" + "github.com/cosi-project/runtime/pkg/state" + "github.com/cosi-project/runtime/pkg/state/impl/inmem" + "github.com/cosi-project/runtime/pkg/state/impl/namespaced" + "github.com/stretchr/testify/suite" + "github.com/talos-systems/go-retry/retry" + + "github.com/talos-systems/talos/pkg/logging" + "github.com/talos-systems/talos/pkg/machinery/config/types/v1alpha1" + "github.com/talos-systems/talos/pkg/resources/config" +) + +type KubeSpanSuite struct { + suite.Suite + + state state.State + + runtime *runtime.Runtime + wg sync.WaitGroup + + ctx context.Context + ctxCancel context.CancelFunc +} + +func (suite *KubeSpanSuite) SetupTest() { + suite.ctx, suite.ctxCancel = context.WithTimeout(context.Background(), 3*time.Minute) + + suite.state = state.WrapCore(namespaced.NewState(inmem.Build)) + + var err error + + logger := logging.Wrap(log.Writer()) + + suite.runtime, err = runtime.NewRuntime(suite.state, logger) + suite.Require().NoError(err) +} + +func (suite *KubeSpanSuite) startRuntime() { + suite.wg.Add(1) + + go func() { + defer suite.wg.Done() + + suite.Assert().NoError(suite.runtime.Run(suite.ctx)) + }() +} + +func (suite *KubeSpanSuite) assertResource(md resource.Metadata, check func(res resource.Resource) error) func() error { + return func() error { + r, err := suite.state.Get(suite.ctx, md) + if err != nil { + if state.IsNotFoundError(err) { + return retry.ExpectedError(err) + } + + return err + } + + return check(r) + } +} + +func (suite *KubeSpanSuite) TearDownTest() { + suite.T().Log("tear down") + + suite.ctxCancel() + + suite.wg.Wait() + + // trigger updates in resources to stop watch loops + err := suite.state.Create(context.Background(), config.NewMachineConfig(&v1alpha1.Config{ + ConfigVersion: "v1alpha1", + MachineConfig: &v1alpha1.MachineConfig{}, + })) + if state.IsConflictError(err) { + err = suite.state.Destroy(context.Background(), config.NewMachineConfig(nil).Metadata()) + } + + suite.Assert().NoError(err) +} diff --git a/internal/app/machined/pkg/controllers/utils.go b/internal/app/machined/pkg/controllers/utils.go new file mode 100644 index 000000000..a913a07fc --- /dev/null +++ b/internal/app/machined/pkg/controllers/utils.go @@ -0,0 +1,60 @@ +// 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 controllers provides common methods for controller operations. +package controllers + +import ( + "fmt" + "os" + "reflect" + + yaml "gopkg.in/yaml.v3" +) + +// LoadOrNewFromFile either loads value from file.yaml or generates new values and saves as file.yaml. +func LoadOrNewFromFile(path string, empty interface{}, generate func(interface{}) error) error { + f, err := os.OpenFile(path, os.O_RDONLY, 0) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("error reading state file: %w", err) + } + + // file doesn't exist yet, generate new value and save it + if f == nil { + if err = generate(empty); err != nil { + return err + } + + f, err = os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) + if err != nil { + return fmt.Errorf("error creating state file: %w", err) + } + + defer f.Close() //nolint:errcheck + + encoder := yaml.NewEncoder(f) + if err = encoder.Encode(empty); err != nil { + return fmt.Errorf("error marshaling: %w", err) + } + + if err = encoder.Close(); err != nil { + return err + } + + return f.Close() + } + + // read existing cached value + defer f.Close() //nolint:errcheck + + if err = yaml.NewDecoder(f).Decode(empty); err != nil { + return fmt.Errorf("error unmarshaling: %w", err) + } + + if reflect.ValueOf(empty).Elem().IsZero() { + return fmt.Errorf("value is still zero after unmarshaling") + } + + return f.Close() +} diff --git a/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_controller.go b/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_controller.go index 001a1e6d4..7f63b7ae8 100644 --- a/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_controller.go +++ b/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_controller.go @@ -19,6 +19,7 @@ import ( "github.com/talos-systems/talos/internal/app/machined/pkg/controllers/config" "github.com/talos-systems/talos/internal/app/machined/pkg/controllers/files" "github.com/talos-systems/talos/internal/app/machined/pkg/controllers/k8s" + "github.com/talos-systems/talos/internal/app/machined/pkg/controllers/kubespan" "github.com/talos-systems/talos/internal/app/machined/pkg/controllers/network" "github.com/talos-systems/talos/internal/app/machined/pkg/controllers/perf" runtimecontrollers "github.com/talos-systems/talos/internal/app/machined/pkg/controllers/runtime" @@ -75,7 +76,9 @@ func (ctrl *Controller) Run(ctx context.Context) error { &time.SyncController{ V1Alpha1Mode: ctrl.v1alpha1Runtime.State().Platform().Mode(), }, - &cluster.NodeIdentityController{}, + &cluster.NodeIdentityController{ + V1Alpha1Mode: ctrl.v1alpha1Runtime.State().Platform().Mode(), + }, &config.MachineTypeController{}, &config.K8sControlPlaneController{}, &files.EtcFileController{ @@ -90,6 +93,8 @@ func (ctrl *Controller) Run(ctx context.Context) error { &k8s.ManifestApplyController{}, &k8s.NodenameController{}, &k8s.RenderSecretsStaticPodController{}, + &kubespan.ConfigController{}, + &kubespan.IdentityController{}, &network.AddressConfigController{ Cmdline: procfs.ProcCmdline(), V1Alpha1Mode: ctrl.v1alpha1Runtime.State().Platform().Mode(), diff --git a/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_state.go b/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_state.go index 9dc9b2cd8..2da7946fb 100644 --- a/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_state.go +++ b/internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_state.go @@ -18,6 +18,7 @@ import ( "github.com/talos-systems/talos/pkg/resources/config" "github.com/talos-systems/talos/pkg/resources/files" "github.com/talos-systems/talos/pkg/resources/k8s" + "github.com/talos-systems/talos/pkg/resources/kubespan" "github.com/talos-systems/talos/pkg/resources/network" "github.com/talos-systems/talos/pkg/resources/perf" "github.com/talos-systems/talos/pkg/resources/runtime" @@ -62,6 +63,7 @@ func NewState() (*State, error) { {config.NamespaceName, "Talos node configuration."}, {files.NamespaceName, "Files and file-like resources."}, {k8s.ControlPlaneNamespaceName, "Kubernetes control plane resources."}, + {kubespan.NamespaceName, "KubeSpan resources."}, {network.NamespaceName, "Networking resources."}, {network.ConfigNamespaceName, "Networking configuration resources."}, {secrets.NamespaceName, "Resources with secret material."}, @@ -88,6 +90,8 @@ func NewState() (*State, error) { &k8s.StaticPod{}, &k8s.StaticPodStatus{}, &k8s.SecretsStatus{}, + &kubespan.Config{}, + &kubespan.Identity{}, &network.AddressStatus{}, &network.AddressSpec{}, &network.HardwareAddr{}, diff --git a/pkg/machinery/config/types/v1alpha1/generate/generate.go b/pkg/machinery/config/types/v1alpha1/generate/generate.go index 2d045cc21..9973765c6 100644 --- a/pkg/machinery/config/types/v1alpha1/generate/generate.go +++ b/pkg/machinery/config/types/v1alpha1/generate/generate.go @@ -85,6 +85,7 @@ type Input struct { Debug bool Persist bool AllowSchedulingOnMasters bool + DiscoveryEnabled bool } // GetAPIServerEndpoint returns the formatted host:port of the API server endpoint. @@ -497,6 +498,7 @@ func NewInput(clustername, endpoint, kubernetesVersion string, secrets *SecretsB AllowSchedulingOnMasters: options.AllowSchedulingOnMasters, MachineDisks: options.MachineDisks, SystemDiskEncryptionConfig: options.SystemDiskEncryptionConfig, + DiscoveryEnabled: options.DiscoveryEnabled, } return input, nil diff --git a/pkg/machinery/config/types/v1alpha1/generate/init.go b/pkg/machinery/config/types/v1alpha1/generate/init.go index e60796baa..9f6db61aa 100644 --- a/pkg/machinery/config/types/v1alpha1/generate/init.go +++ b/pkg/machinery/config/types/v1alpha1/generate/init.go @@ -101,6 +101,9 @@ func initUd(in *Input) (*v1alpha1.Config, error) { ClusterAESCBCEncryptionSecret: in.Secrets.AESCBCEncryptionSecret, ExtraManifests: []string{}, ClusterInlineManifests: v1alpha1.ClusterInlineManifests{}, + ClusterDiscoveryConfig: v1alpha1.ClusterDiscoveryConfig{ + DiscoveryEnabled: in.DiscoveryEnabled, + }, } config.MachineConfig = machine diff --git a/pkg/machinery/config/types/v1alpha1/generate/options.go b/pkg/machinery/config/types/v1alpha1/generate/options.go index 278b476b1..af45a8e83 100644 --- a/pkg/machinery/config/types/v1alpha1/generate/options.go +++ b/pkg/machinery/config/types/v1alpha1/generate/options.go @@ -203,6 +203,15 @@ func WithRoles(roles role.Set) GenOption { } } +// WithClusterDiscovery enables cluster discovery feature. +func WithClusterDiscovery() GenOption { + return func(o *GenOptions) error { + o.DiscoveryEnabled = true + + return nil + } +} + // GenOptions describes generate parameters. type GenOptions struct { EndpointList []string @@ -222,6 +231,7 @@ type GenOptions struct { VersionContract *config.VersionContract SystemDiskEncryptionConfig *v1alpha1.SystemDiskEncryptionConfig Roles role.Set + DiscoveryEnabled bool } // DefaultGenOptions returns default options. diff --git a/pkg/machinery/config/types/v1alpha1/generate/worker.go b/pkg/machinery/config/types/v1alpha1/generate/worker.go index f5045a388..0645bad45 100644 --- a/pkg/machinery/config/types/v1alpha1/generate/worker.go +++ b/pkg/machinery/config/types/v1alpha1/generate/worker.go @@ -77,6 +77,9 @@ func workerUd(in *Input) (*v1alpha1.Config, error) { ServiceSubnet: in.ServiceNet, CNI: in.CNIConfig, }, + ClusterDiscoveryConfig: v1alpha1.ClusterDiscoveryConfig{ + DiscoveryEnabled: in.DiscoveryEnabled, + }, } config.MachineConfig = machine diff --git a/pkg/machinery/config/types/v1alpha1/v1alpha1_network_options.go b/pkg/machinery/config/types/v1alpha1/v1alpha1_network_options.go index c95db714a..42f6a1cdf 100644 --- a/pkg/machinery/config/types/v1alpha1/v1alpha1_network_options.go +++ b/pkg/machinery/config/types/v1alpha1/v1alpha1_network_options.go @@ -120,3 +120,12 @@ func WithNetworkInterfaceVirtualIP(iface, cidr string) NetworkConfigOption { return nil } } + +// WithKubeSpan configures a KubeSpan interface. +func WithKubeSpan() NetworkConfigOption { + return func(_ machine.Type, cfg *NetworkConfig) error { + cfg.NetworkKubeSpan.KubeSpanEnabled = true + + return nil + } +} diff --git a/pkg/machinery/config/types/v1alpha1/v1alpha1_validation.go b/pkg/machinery/config/types/v1alpha1/v1alpha1_validation.go index a0aa720b4..9f6cbeb64 100644 --- a/pkg/machinery/config/types/v1alpha1/v1alpha1_validation.go +++ b/pkg/machinery/config/types/v1alpha1/v1alpha1_validation.go @@ -176,8 +176,18 @@ func (c *Config) Validate(mode config.RuntimeMode, options ...config.ValidationO } } - if c.Machine().Network().KubeSpan().Enabled() && !c.Cluster().Discovery().Enabled() { - result = multierror.Append(result, fmt.Errorf(".cluster.discovery should be enabled when .machine.network.kubespan is enabled")) + if c.Machine().Network().KubeSpan().Enabled() { + if !c.Cluster().Discovery().Enabled() { + result = multierror.Append(result, fmt.Errorf(".cluster.discovery should be enabled when .machine.network.kubespan is enabled")) + } + + if c.Cluster().ID() == "" { + result = multierror.Append(result, fmt.Errorf(".cluster.id should be set when .machine.network.kubespan is enabled")) + } + + if c.Cluster().Secret() == "" { + result = multierror.Append(result, fmt.Errorf(".cluster.secret should be set when .machine.network.kubespan is enabled")) + } } if opts.Strict { @@ -215,7 +225,7 @@ func (c *ClusterConfig) Validate() error { result = multierror.Append(result, ecp.Validate()) } - result = multierror.Append(result, c.ClusterInlineManifests.Validate(), c.ClusterDiscoveryConfig.Validate()) + result = multierror.Append(result, c.ClusterInlineManifests.Validate(), c.ClusterDiscoveryConfig.Validate(c)) return result.ErrorOrNil() } @@ -296,14 +306,26 @@ func (manifests ClusterInlineManifests) Validate() error { } // Validate the discovery config. -func (c ClusterDiscoveryConfig) Validate() error { +func (c ClusterDiscoveryConfig) Validate(clusterCfg *ClusterConfig) error { var result *multierror.Error + if !c.Enabled() { + return nil + } + if c.Registries().Service().Enabled() { _, err := url.ParseRequestURI(c.Registries().Service().Endpoint()) if err != nil { result = multierror.Append(result, fmt.Errorf("cluster discovery service registry endpoint is invalid: %w", err)) } + + if clusterCfg.ID() == "" { + result = multierror.Append(result, fmt.Errorf("cluster discovery service requires .cluster.id")) + } + + if clusterCfg.Secret() == "" { + result = multierror.Append(result, fmt.Errorf("cluster discovery service requires .cluster.secret")) + } } return result.ErrorOrNil() diff --git a/pkg/machinery/config/types/v1alpha1/v1alpha1_validation_test.go b/pkg/machinery/config/types/v1alpha1/v1alpha1_validation_test.go index f3325360a..35f8fb6ed 100644 --- a/pkg/machinery/config/types/v1alpha1/v1alpha1_validation_test.go +++ b/pkg/machinery/config/types/v1alpha1/v1alpha1_validation_test.go @@ -744,7 +744,9 @@ func TestValidate(t *testing.T) { }, }, }, - expectedError: "1 error occurred:\n\t* .cluster.discovery should be enabled when .machine.network.kubespan is enabled\n\n", + expectedError: "3 errors occurred:\n\t* .cluster.discovery should be enabled when .machine.network.kubespan is enabled\n" + + "\t* .cluster.id should be set when .machine.network.kubespan is enabled\n" + + "\t* .cluster.secret should be set when .machine.network.kubespan is enabled\n\n", }, { name: "DiscoveryServiceEndpoint", @@ -754,6 +756,8 @@ func TestValidate(t *testing.T) { MachineType: "controlplane", }, ClusterConfig: &v1alpha1.ClusterConfig{ + ClusterID: "foo", + ClusterSecret: "bar", ControlPlane: &v1alpha1.ControlPlaneConfig{ Endpoint: &v1alpha1.Endpoint{ endpointURL, @@ -771,6 +775,26 @@ func TestValidate(t *testing.T) { }, expectedError: "1 error occurred:\n\t* cluster discovery service registry endpoint is invalid: parse \"foo\": invalid URI for request\n\n", }, + { + name: "DiscoveryServiceClusterIDSecret", + config: &v1alpha1.Config{ + ConfigVersion: "v1alpha1", + MachineConfig: &v1alpha1.MachineConfig{ + MachineType: "controlplane", + }, + ClusterConfig: &v1alpha1.ClusterConfig{ + ControlPlane: &v1alpha1.ControlPlaneConfig{ + Endpoint: &v1alpha1.Endpoint{ + endpointURL, + }, + }, + ClusterDiscoveryConfig: v1alpha1.ClusterDiscoveryConfig{ + DiscoveryEnabled: true, + }, + }, + }, + expectedError: "2 errors occurred:\n\t* cluster discovery service requires .cluster.id\n\t* cluster discovery service requires .cluster.secret\n\n", + }, } { test := test diff --git a/pkg/machinery/constants/constants.go b/pkg/machinery/constants/constants.go index 32c9e27f4..d18c5282b 100644 --- a/pkg/machinery/constants/constants.go +++ b/pkg/machinery/constants/constants.go @@ -467,6 +467,9 @@ const ( // DefaultDiscoveryServiceEndpoint is the default endpoint for Talos discovery service. DefaultDiscoveryServiceEndpoint = "https://discovery.talos.dev/" + + // KubeSpanIdentityFilename is the filename to cache KubeSpan identity across reboots. + KubeSpanIdentityFilename = "kubespan-identity.yaml" ) // See https://linux.die.net/man/3/klogctl diff --git a/pkg/resources/kubespan/config.go b/pkg/resources/kubespan/config.go new file mode 100644 index 000000000..d65972d8c --- /dev/null +++ b/pkg/resources/kubespan/config.go @@ -0,0 +1,84 @@ +// 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 kubespan + +import ( + "fmt" + + "github.com/cosi-project/runtime/pkg/resource" + "github.com/cosi-project/runtime/pkg/resource/meta" + + "github.com/talos-systems/talos/pkg/resources/config" +) + +// ConfigType is type of Config resource. +const ConfigType = resource.Type("KubeSpanConfigs.kubespan.talos.dev") + +// ConfigID the singleton config resource ID. +const ConfigID = resource.ID("kubespan") + +// LocalConfig is the resource ID for the local node Config. +const LocalConfig = resource.ID("local") + +// Config resource holds KubeSpan configuration. +type Config struct { + md resource.Metadata + spec ConfigSpec +} + +// ConfigSpec describes KubeSpan configuration.. +type ConfigSpec struct { + Enabled bool `yaml:"enabled"` + ClusterID string `yaml:"clusterId"` +} + +// NewConfig initializes a Config resource. +func NewConfig(namespace resource.Namespace, id resource.ID) *Config { + r := &Config{ + md: resource.NewMetadata(namespace, ConfigType, id, resource.VersionUndefined), + spec: ConfigSpec{}, + } + + r.md.BumpVersion() + + return r +} + +// Metadata implements resource.Resource. +func (r *Config) Metadata() *resource.Metadata { + return &r.md +} + +// Spec implements resource.Resource. +func (r *Config) Spec() interface{} { + return r.spec +} + +func (r *Config) String() string { + return fmt.Sprintf("kubespan.Config(%q)", r.md.ID()) +} + +// DeepCopy implements resource.Resource. +func (r *Config) DeepCopy() resource.Resource { + return &Config{ + md: r.md, + spec: r.spec, + } +} + +// ResourceDefinition implements meta.ResourceDefinitionProvider interface. +func (r *Config) ResourceDefinition() meta.ResourceDefinitionSpec { + return meta.ResourceDefinitionSpec{ + Type: ConfigType, + Aliases: []resource.Type{}, + DefaultNamespace: config.NamespaceName, + PrintColumns: []meta.PrintColumn{}, + } +} + +// TypedSpec allows to access the Spec with the proper type. +func (r *Config) TypedSpec() *ConfigSpec { + return &r.spec +} diff --git a/pkg/resources/kubespan/identity.go b/pkg/resources/kubespan/identity.go new file mode 100644 index 000000000..a84bc91ed --- /dev/null +++ b/pkg/resources/kubespan/identity.go @@ -0,0 +1,125 @@ +// 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 kubespan + +import ( + "fmt" + "net" + + "github.com/cosi-project/runtime/pkg/resource" + "github.com/cosi-project/runtime/pkg/resource/meta" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "inet.af/netaddr" + + "github.com/talos-systems/talos/pkg/resources/network" +) + +// IdentityType is type of Identity resource. +const IdentityType = resource.Type("KubeSpanIdentities.kubespan.talos.dev") + +// LocalIdentity is the resource ID for the local node KubeSpan identity. +const LocalIdentity = resource.ID("local") + +// Identity resource holds node identity (as a member of the cluster). +type Identity struct { + md resource.Metadata + spec IdentitySpec +} + +// IdentitySpec describes KubeSpan keys and address. +// +// Note: IdentitySpec is persisted on disk in the STATE partition, +// so YAML serialization should be kept backwards compatible. +type IdentitySpec struct { + // Address of the node on the Wireguard network. + Address netaddr.IPPrefix `yaml:"address"` + Subnet netaddr.IPPrefix `yaml:"subnet"` + // Public and private Wireguard keys. + PrivateKey string `yaml:"privateKey"` + PublicKey string `yaml:"publicKey"` +} + +// NewIdentity initializes a Identity resource. +func NewIdentity(namespace resource.Namespace, id resource.ID) *Identity { + r := &Identity{ + md: resource.NewMetadata(namespace, IdentityType, id, resource.VersionUndefined), + spec: IdentitySpec{}, + } + + r.md.BumpVersion() + + return r +} + +// Metadata implements resource.Resource. +func (r *Identity) Metadata() *resource.Metadata { + return &r.md +} + +// Spec implements resource.Resource. +func (r *Identity) Spec() interface{} { + return r.spec +} + +func (r *Identity) String() string { + return fmt.Sprintf("kubespan.Identity(%q)", r.md.ID()) +} + +// DeepCopy implements resource.Resource. +func (r *Identity) DeepCopy() resource.Resource { + return &Identity{ + md: r.md, + spec: r.spec, + } +} + +// ResourceDefinition implements meta.ResourceDefinitionProvider interface. +func (r *Identity) ResourceDefinition() meta.ResourceDefinitionSpec { + return meta.ResourceDefinitionSpec{ + Type: IdentityType, + Aliases: []resource.Type{}, + DefaultNamespace: NamespaceName, + PrintColumns: []meta.PrintColumn{ + { + Name: "Address", + JSONPath: `{.address}`, + }, + { + Name: "PublicKey", + JSONPath: `{.publicKey}`, + }, + }, + Sensitivity: meta.Sensitive, + } +} + +// TypedSpec allows to access the Spec with the proper type. +func (r *Identity) TypedSpec() *IdentitySpec { + return &r.spec +} + +// GenerateKey generates new Wireguard key. +func (spec *IdentitySpec) GenerateKey() error { + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + return err + } + + spec.PrivateKey = key.String() + spec.PublicKey = key.PublicKey().String() + + return nil +} + +// UpdateAddress re-calculates node address based on input data. +func (spec *IdentitySpec) UpdateAddress(clusterID string, mac net.HardwareAddr) error { + spec.Subnet = network.ULAPrefix(clusterID, network.ULAKubeSpan) + + var err error + + spec.Address, err = wgEUI64(spec.Subnet, mac) + + return err +} diff --git a/pkg/resources/kubespan/identity_test.go b/pkg/resources/kubespan/identity_test.go new file mode 100644 index 000000000..8fedc116d --- /dev/null +++ b/pkg/resources/kubespan/identity_test.go @@ -0,0 +1,33 @@ +// 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 kubespan_test + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/talos-systems/talos/pkg/resources/kubespan" +) + +func TestIdentityGenerateKey(t *testing.T) { + var spec kubespan.IdentitySpec + + assert.NoError(t, spec.GenerateKey()) +} + +func TestIdentityUpdateAddress(t *testing.T) { + var spec kubespan.IdentitySpec + + mac, err := net.ParseMAC("2e:1a:b6:53:81:69") + require.NoError(t, err) + + assert.NoError(t, spec.UpdateAddress("8XuV9TZHW08DOk3bVxQjH9ih_TBKjnh-j44tsCLSBzo=", mac)) + + assert.Equal(t, "fd7f:175a:b97c:5602:2c1a:b6ff:fe53:8169/128", spec.Address.String()) + assert.Equal(t, "fd7f:175a:b97c:5602::/64", spec.Subnet.String()) +} diff --git a/pkg/resources/kubespan/kubespan.go b/pkg/resources/kubespan/kubespan.go new file mode 100644 index 000000000..865fdaa41 --- /dev/null +++ b/pkg/resources/kubespan/kubespan.go @@ -0,0 +1,10 @@ +// 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 kubespan + +import "github.com/cosi-project/runtime/pkg/resource" + +// NamespaceName contains resources related to KubeSpan. +const NamespaceName resource.Namespace = "kubespan" diff --git a/pkg/resources/kubespan/kubespan_test.go b/pkg/resources/kubespan/kubespan_test.go new file mode 100644 index 000000000..335a19404 --- /dev/null +++ b/pkg/resources/kubespan/kubespan_test.go @@ -0,0 +1,33 @@ +// 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 kubespan_test + +import ( + "context" + "testing" + + "github.com/cosi-project/runtime/pkg/resource" + "github.com/cosi-project/runtime/pkg/state" + "github.com/cosi-project/runtime/pkg/state/impl/inmem" + "github.com/cosi-project/runtime/pkg/state/impl/namespaced" + "github.com/cosi-project/runtime/pkg/state/registry" + "github.com/stretchr/testify/assert" + + "github.com/talos-systems/talos/pkg/resources/kubespan" +) + +func TestRegisterResource(t *testing.T) { + ctx := context.TODO() + + resources := state.WrapCore(namespaced.NewState(inmem.Build)) + resourceRegistry := registry.NewResourceRegistry(resources) + + for _, resource := range []resource.Resource{ + &kubespan.Config{}, + &kubespan.Identity{}, + } { + assert.NoError(t, resourceRegistry.Register(ctx, resource)) + } +} diff --git a/pkg/resources/kubespan/utils.go b/pkg/resources/kubespan/utils.go new file mode 100644 index 000000000..c8619177b --- /dev/null +++ b/pkg/resources/kubespan/utils.go @@ -0,0 +1,31 @@ +// 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 kubespan + +import ( + "fmt" + "net" + + "github.com/mdlayher/netx/eui64" + "inet.af/netaddr" +) + +func wgEUI64(prefix netaddr.IPPrefix, mac net.HardwareAddr) (out netaddr.IPPrefix, err error) { + if prefix.IsZero() { + return out, fmt.Errorf("cannot calculate IP from zero prefix") + } + + stdIP, err := eui64.ParseMAC(prefix.IPNet().IP, mac) + if err != nil { + return out, fmt.Errorf("failed to parse MAC into EUI-64 address: %w", err) + } + + ip, ok := netaddr.FromStdIP(stdIP) + if !ok { + return out, fmt.Errorf("failed to parse intermediate standard IP %q: %w", stdIP.String(), err) + } + + return netaddr.IPPrefixFrom(ip, ip.BitLen()), nil +} diff --git a/pkg/resources/network/ula.go b/pkg/resources/network/ula.go new file mode 100644 index 000000000..c03d67b91 --- /dev/null +++ b/pkg/resources/network/ula.go @@ -0,0 +1,45 @@ +// 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 ( + "crypto/sha256" + + "inet.af/netaddr" +) + +// ULAPurpose is the Unique Local Addressing key for the Talos-specific purpose of the prefix. +type ULAPurpose byte + +const ( + // ULAUnknown indicates an unknown ULA Purpose. + ULAUnknown = 0x00 + + // ULABootstrap is the Unique Local Addressing space key for the Talos Self-Bootstrapping protocol. + ULABootstrap = 0x01 + + // ULAKubeSpan is the Unique Local Addressing space key for the Talos KubeSpan feature. + ULAKubeSpan = 0x02 +) + +// ULAPrefix calculates and returns a Talos-specific Unique Local Address prefix for the given purpose. +// This implements a Talos-specific implementation of RFC4193. +// The Talos implementation uses a combination of a 48-bit cluster-unique portion with an 8-bit purpose portion. +func ULAPrefix(clusterID string, purpose ULAPurpose) netaddr.IPPrefix { + var prefixData [16]byte + + hash := sha256.Sum256([]byte(clusterID)) + + // Take the last 16 bytes of the clusterID's hash. + copy(prefixData[:], hash[sha256.Size-16:]) + + // Apply the ULA prefix as per RFC4193 + prefixData[0] = 0xfd + + // Apply the Talos-specific ULA Purpose suffix + prefixData[7] = byte(purpose) + + return netaddr.IPPrefixFrom(netaddr.IPFrom16(prefixData), 64).Masked() +} diff --git a/pkg/resources/network/ula_test.go b/pkg/resources/network/ula_test.go new file mode 100644 index 000000000..594926068 --- /dev/null +++ b/pkg/resources/network/ula_test.go @@ -0,0 +1,17 @@ +// 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/talos-systems/talos/pkg/resources/network" +) + +func TestULAPrefix(t *testing.T) { + assert.Equal(t, "fd7f:175a:b97c:5602::/64", network.ULAPrefix("8XuV9TZHW08DOk3bVxQjH9ih_TBKjnh-j44tsCLSBzo=", network.ULAKubeSpan).String()) +} diff --git a/website/content/docs/v0.13/Reference/cli.md b/website/content/docs/v0.13/Reference/cli.md index 444b94e19..4565d68fe 100644 --- a/website/content/docs/v0.13/Reference/cli.md +++ b/website/content/docs/v0.13/Reference/cli.md @@ -139,6 +139,7 @@ talosctl cluster create [flags] --with-bootloader enable bootloader to load kernel and initramfs from disk image after install (default true) --with-debug enable debug in Talos config to send service logs to the console --with-init-node create the cluster with an init node + --with-kubespan enable KubeSpan system --with-uefi enable UEFI on x86_64 architecture (always enabled for arm64) --workers int the number of workers to create (default 1) ```