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) ```