diff --git a/README.md b/README.md index 08da0869..00ca6e4b 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,13 @@ You can use it with any OpenAPI compliant tool, for example the online [Swagger This API is consumed by the `elemental-agent` and is meant for **Internal** use only. +### Authentication & Authorization + +The Elemental API uses two different authorization header. +The `Registration-Authorization` should contain a valid JWT formatted `Bearer` token when fetching registration information or registering a new host. +When registering a new host and modifying a host resource, the `Authorization` header should also contain a valid JWT formatted `Bearer` token that identifies the host. +For more information, you can find more details in the [documentation](doc/AUTH.md). + ## Rancher Integration [Rancher Turtles](https://docs.rancher-turtles.com/) is an extension to Rancher that brings increased integration with Cluster API. diff --git a/api/v1beta1/elementalhost_types.go b/api/v1beta1/elementalhost_types.go index 3a8625c4..edd9c60e 100644 --- a/api/v1beta1/elementalhost_types.go +++ b/api/v1beta1/elementalhost_types.go @@ -31,6 +31,9 @@ type ElementalHostSpec struct { // using this host. // +optional MachineRef *corev1.ObjectReference `json:"machineRef,omitempty"` + // PubKey is the host public key to verify when authenticating + // Elemental API requests for this host. + PubKey string `json:"pubKey,omitempty"` } // ElementalHostStatus defines the observed state of ElementalHost. diff --git a/api/v1beta1/elementalregistration_types.go b/api/v1beta1/elementalregistration_types.go index b63cad8e..c1fe44c1 100644 --- a/api/v1beta1/elementalregistration_types.go +++ b/api/v1beta1/elementalregistration_types.go @@ -19,6 +19,7 @@ package v1beta1 import ( "time" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -34,6 +35,8 @@ type ElementalRegistrationSpec struct { // Config points to Elemental machine configuration. // +optional Config Config `json:"config,omitempty"` + // PrivateKeyRef is a reference to a secret containing the private key used to generate registration tokens + PrivateKeyRef *corev1.ObjectReference `json:"privateKeyRef,omitempty"` } // ElementalRegistrationStatus defines the observed state of ElementalRegistration. @@ -78,6 +81,10 @@ type Registration struct { URI string `json:"uri,omitempty" yaml:"uri,omitempty" mapstructure:"uri"` // +optional CACert string `json:"caCert,omitempty" yaml:"caCert,omitempty" mapstructure:"caCert"` + // +optional + TokenDuration time.Duration `json:"tokenDuration,omitempty" yaml:"tokenDuration,omitempty" mapstructure:"tokenDuration"` + // +optional + Token string `json:"token,omitempty" yaml:"token,omitempty" mapstructure:"token"` } type Agent struct { diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 2b246e10..40222581 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -663,6 +663,11 @@ func (in *ElementalRegistrationSpec) DeepCopyInto(out *ElementalRegistrationSpec } } in.Config.DeepCopyInto(&out.Config) + if in.PrivateKeyRef != nil { + in, out := &in.PrivateKeyRef, &out.PrivateKeyRef + *out = new(v1.ObjectReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ElementalRegistrationSpec. diff --git a/cmd/agent/README.md b/cmd/agent/README.md index 8f895018..2aaa8184 100644 --- a/cmd/agent/README.md +++ b/cmd/agent/README.md @@ -57,6 +57,8 @@ registration: WhfJrSPzvfWPO73w0MFMBRXZ74Tc24SN6QPBin5LaAIhAM9hidFQ71SZQnPY3Y1I JZPkAoVeIOoFDgXvl9MkHBuk -----END CERTIFICATE----- + # A valid JWT token to use during registration + token: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJFbGVtZW50YWxSZWdpc3RyYXRpb25SZWNvbmNpbGVyIiwic3ViIjoiaHR0cDovLzE5Mi4xNjguMTIyLjEwOjMwMDA5L2VsZW1lbnRhbC92MS9uYW1lc3BhY2VzL2RlZmF1bHQvcmVnaXN0cmF0aW9ucy9teS1yZWdpc3RyYXRpb24iLCJhdWQiOlsiaHR0cDovLzE5Mi4xNjguMTIyLjEwOjMwMDA5L2VsZW1lbnRhbC92MS9uYW1lc3BhY2VzL2RlZmF1bHQvcmVnaXN0cmF0aW9ucy9teS1yZWdpc3RyYXRpb24iXSwibmJmIjoxNjk5ODY0NzIwLCJpYXQiOjE2OTk4NjQ3MjB9.YQsYZoaZ3tGV6z5aXo1e9LmGdA-wQOtmmpi4yAAfXcqh6_S6iIjgblXqw6koQJCzhBMy2-APPQL0ANEBcAljBQ agent: # Work directory workDir: /var/lib/elemental/agent @@ -77,7 +79,7 @@ agent: # Enable agent debug logs debug: false # Which OS plugin to use - osPlugin: "/usr/lib/elemental/plugins/elemental.so" + osPlugin: /usr/lib/elemental/plugins/elemental.so # The period used by the agent to sync with the Elemental API reconciliation: 1m # Allow 'http' scheme diff --git a/cmd/agent/main.go b/cmd/agent/main.go index c6b3a1d4..93e556da 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -10,10 +10,10 @@ import ( "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/client" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/config" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/hostname" - "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/identity" log "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/log" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/utils" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/api" + "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/identity" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/version" "github.com/rancher-sandbox/cluster-api-provider-elemental/pkg/agent/osplugin" "github.com/spf13/cobra" @@ -50,7 +50,7 @@ var ( func main() { fs := vfs.OSFS osPluginLoader := osplugin.NewLoader() - client := client.NewClient() + client := client.NewClient(version.Version) commandRunner := utils.NewCommandRunner() cmd := newCommand(fs, osPluginLoader, commandRunner, client) if err := cmd.Execute(); err != nil { @@ -103,13 +103,13 @@ func newCommand(fs vfs.FS, pluginLoader osplugin.Loader, commandRunner utils.Com return fmt.Errorf("Initializing plugin: %w", err) } // Initialize Identity - identityManager := identity.NewDummyManager(fs, conf.Agent.WorkDir) - signingKey, err := identityManager.LoadSigningKeyOrCreateNew() + identityManager := identity.NewManager(fs, conf.Agent.WorkDir) + identity, err := identityManager.LoadSigningKeyOrCreateNew() if err != nil { return fmt.Errorf("initializing identity: %w", err) } // Initialize Elemental API Client - if err := client.Init(fs, signingKey, conf); err != nil { + if err := client.Init(fs, identity, conf); err != nil { return fmt.Errorf("initializing Elemental API client: %w", err) } // Get current hostname @@ -120,10 +120,14 @@ func newCommand(fs vfs.FS, pluginLoader osplugin.Loader, commandRunner utils.Com // Register if registerFlag { log.Info("Registering Elemental Host") + pubKey, err := identity.MarshalPublic() + if err != nil { + return fmt.Errorf("marshalling host public key: %w", err) + } var registration *api.RegistrationResponse - hostname, registration = handleRegistration(client, osPlugin, conf.Agent.Reconciliation) + hostname, registration = handleRegistration(client, osPlugin, pubKey, conf.Registration.Token, conf.Agent.Reconciliation) log.Infof("Successfully registered as '%s'", hostname) - if err := handlePostRegistration(osPlugin, hostname, signingKey, registration); err != nil { + if err := handlePostRegistration(osPlugin, hostname, identity, registration); err != nil { return fmt.Errorf("handling post registration: %w", err) } // Exit program if --install was not called @@ -134,7 +138,7 @@ func newCommand(fs vfs.FS, pluginLoader osplugin.Loader, commandRunner utils.Com // Install if installFlag { log.Info("Installing Elemental") - handleInstall(client, osPlugin, hostname, conf.Agent.Reconciliation) + handleInstall(client, osPlugin, hostname, conf.Registration.Token, conf.Agent.Reconciliation) log.Info("Installation successful") handlePost(osPlugin, conf.Agent.PostInstall.PowerOff, conf.Agent.PostInstall.Reboot) return nil @@ -143,7 +147,7 @@ func newCommand(fs vfs.FS, pluginLoader osplugin.Loader, commandRunner utils.Com // Reset if resetFlag { log.Info("Resetting Elemental") - handleReset(client, osPlugin, conf.Agent.Reconciliation, hostname) + handleReset(client, osPlugin, hostname, conf.Registration.Token, conf.Agent.Reconciliation) log.Info("Reset successful") handlePost(osPlugin, conf.Agent.PostReset.PowerOff, conf.Agent.PostReset.Reboot) return nil @@ -221,7 +225,7 @@ func getConfig(fs vfs.FS) (config.Config, error) { return conf, nil } -func handleRegistration(client client.Client, osPlugin osplugin.Plugin, registrationRecoveryPeriod time.Duration) (string, *api.RegistrationResponse) { +func handleRegistration(client client.Client, osPlugin osplugin.Plugin, pubKey []byte, registrationToken string, registrationRecoveryPeriod time.Duration) (string, *api.RegistrationResponse) { hostnameFormatter := hostname.NewFormatter(osPlugin) var newHostname string var registration *api.RegistrationResponse @@ -235,7 +239,7 @@ func handleRegistration(client client.Client, osPlugin osplugin.Plugin, registra } // Fetch remote Registration log.Debug("Fetching remote registration") - registration, err = client.GetRegistration() + registration, err = client.GetRegistration(registrationToken) if err != nil { log.Error(err, "getting remote Registration") registrationError = true @@ -257,7 +261,8 @@ func handleRegistration(client client.Client, osPlugin osplugin.Plugin, registra Name: newHostname, Annotations: registration.HostAnnotations, Labels: registration.HostLabels, - }); err != nil { + PubKey: string(pubKey), + }, registrationToken); err != nil { log.Error(err, "registering new ElementalHost") registrationError = true continue @@ -267,7 +272,7 @@ func handleRegistration(client client.Client, osPlugin osplugin.Plugin, registra return newHostname, registration } -func handlePostRegistration(osPlugin osplugin.Plugin, hostnameToSet string, signingKey []byte, registration *api.RegistrationResponse) error { +func handlePostRegistration(osPlugin osplugin.Plugin, hostnameToSet string, id identity.Identity, registration *api.RegistrationResponse) error { // Persist registered hostname if err := osPlugin.PersistHostname(hostnameToSet); err != nil { return fmt.Errorf("persisting hostname '%s': %w", hostnameToSet, err) @@ -282,14 +287,18 @@ func handlePostRegistration(osPlugin osplugin.Plugin, hostnameToSet string, sign return fmt.Errorf("persisting agent config file '%s': %w", configPath, err) } // Persist identity file + identityBytes, err := id.Marshal() + if err != nil { + return fmt.Errorf("marshalling identity: %w", err) + } privateKeyPath := fmt.Sprintf("%s/%s", agentConfig.Agent.WorkDir, identity.PrivateKeyFile) - if err := osPlugin.PersistFile(signingKey, privateKeyPath, 0640, 0, 0); err != nil { + if err := osPlugin.PersistFile(identityBytes, privateKeyPath, 0640, 0, 0); err != nil { return fmt.Errorf("persisting private key file '%s': %w", privateKeyPath, err) } return nil } -func handleInstall(client client.Client, osPlugin osplugin.Plugin, hostname string, installationRecoveryPeriod time.Duration) { +func handleInstall(client client.Client, osPlugin osplugin.Plugin, hostname string, registrationToken string, installationRecoveryPeriod time.Duration) { cloudConfigAlreadyApplied := false alreadyInstalled := false installationError := false @@ -304,7 +313,7 @@ func handleInstall(client client.Client, osPlugin osplugin.Plugin, hostname stri var err error if !cloudConfigAlreadyApplied || !alreadyInstalled { log.Debug("Fetching remote registration") - registration, err = client.GetRegistration() + registration, err = client.GetRegistration(registrationToken) if err != nil { log.Error(err, "getting remote Registration") installationError = true @@ -354,7 +363,7 @@ func handleInstall(client client.Client, osPlugin osplugin.Plugin, hostname stri } } -func handleReset(client client.Client, osPlugin osplugin.Plugin, resetRecoveryPeriod time.Duration, hostname string) { +func handleReset(client client.Client, osPlugin osplugin.Plugin, hostname string, registrationToken string, resetRecoveryPeriod time.Duration) { resetError := false alreadyReset := false for { @@ -375,7 +384,7 @@ func handleReset(client client.Client, osPlugin osplugin.Plugin, resetRecoveryPe if !alreadyReset { // Fetch remote Registration log.Debug("Fetching remote registration") - registration, err := client.GetRegistration() + registration, err := client.GetRegistration(registrationToken) if err != nil { log.Error(err, "getting remote Registration") resetError = true diff --git a/cmd/agent/main_test.go b/cmd/agent/main_test.go index e671fddc..feea70ab 100644 --- a/cmd/agent/main_test.go +++ b/cmd/agent/main_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path" + "path/filepath" "testing" "time" @@ -14,9 +15,9 @@ import ( "github.com/rancher-sandbox/cluster-api-provider-elemental/api/v1beta1" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/client" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/config" - "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/identity" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/utils" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/api" + "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/identity" "github.com/rancher-sandbox/cluster-api-provider-elemental/pkg/agent/osplugin" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -37,6 +38,7 @@ var ( Registration: v1beta1.Registration{ URI: "https://test.test/elemental/v1/namespaces/test/registrations/test", CACert: "just a CA cert", + Token: "just a test token", }, Agent: v1beta1.Agent{ WorkDir: "/test/var/lib/elemental/agent", @@ -294,21 +296,24 @@ var _ = Describe("elemental-agent", Label("agent", "cli"), func() { } wantAgentConfig := config.FromAPI(registrationFixture) wantAgentConfigBytes, err := yaml.Marshal(wantAgentConfig) + Expect(err).ToNot(HaveOccurred()) wantIdentityFilePath := fmt.Sprintf("%s/%s", registrationFixture.Config.Elemental.Agent.WorkDir, identity.PrivateKeyFile) It("should register and exit", func() { - Expect(err).ToNot(HaveOccurred()) + _, pubKeyPem := initializeIdentity(fs) + wantRequest := wantCreateHostRequest + wantRequest.PubKey = pubKeyPem cmd.SetArgs([]string{"--register"}) gomock.InOrder( // First get registration call fails. Should repeat to recover. - mClient.EXPECT().GetRegistration().Return(nil, errors.New("test get registration fail")), - mClient.EXPECT().GetRegistration().Return(registrationFixture, nil), + mClient.EXPECT().GetRegistration(configFixture.Registration.Token).Return(nil, errors.New("test get registration fail")), + mClient.EXPECT().GetRegistration(configFixture.Registration.Token).Return(registrationFixture, nil), plugin.EXPECT().GetHostname().Return("host", nil), // Let's make the first create host call fail. Expect to recover. - mClient.EXPECT().CreateHost(wantCreateHostRequest).Return(errors.New("test creat host fail")), - mClient.EXPECT().GetRegistration().Return(registrationFixture, nil), + mClient.EXPECT().CreateHost(wantRequest, configFixture.Registration.Token).Return(errors.New("test creat host fail")), + mClient.EXPECT().GetRegistration(configFixture.Registration.Token).Return(registrationFixture, nil), // Expect a new hostname to be formatted due to creation failure. plugin.EXPECT().GetHostname().Return("host", nil), - mClient.EXPECT().CreateHost(wantCreateHostRequest).Return(nil), + mClient.EXPECT().CreateHost(wantRequest, configFixture.Registration.Token).Return(nil), // Post --register plugin.EXPECT().PersistHostname(hostResponseFixture.Name).Return(nil), plugin.EXPECT().PersistFile(wantAgentConfigBytes, configPathDefault, uint32(0640), 0, 0).Return(nil), @@ -317,18 +322,21 @@ var _ = Describe("elemental-agent", Label("agent", "cli"), func() { Expect(cmd.Execute()).ToNot(HaveOccurred()) }) It("should register and try to install if --install also passed", func() { + _, pubKeyPem := initializeIdentity(fs) + wantRequest := wantCreateHostRequest + wantRequest.PubKey = pubKeyPem cmd.SetArgs([]string{"--register", "--install"}) gomock.InOrder( // --register - mClient.EXPECT().GetRegistration().Return(registrationFixture, nil), + mClient.EXPECT().GetRegistration(configFixture.Registration.Token).Return(registrationFixture, nil), plugin.EXPECT().GetHostname().Return("host", nil), - mClient.EXPECT().CreateHost(wantCreateHostRequest).Return(nil), + mClient.EXPECT().CreateHost(wantRequest, configFixture.Registration.Token).Return(nil), // Post --register plugin.EXPECT().PersistHostname(hostResponseFixture.Name).Return(nil), plugin.EXPECT().PersistFile(wantAgentConfigBytes, configPathDefault, uint32(0640), 0, 0).Return(nil), plugin.EXPECT().PersistFile(gomock.Any(), wantIdentityFilePath, uint32(0640), 0, 0).Return(nil), // --install - mClient.EXPECT().GetRegistration().Return(registrationFixture, nil), + mClient.EXPECT().GetRegistration(configFixture.Registration.Token).Return(registrationFixture, nil), plugin.EXPECT().ApplyCloudInit(gomock.Any()).Return(nil), plugin.EXPECT().Install(gomock.Any()).Return(nil), mClient.EXPECT().PatchHost(gomock.Any(), gomock.Any()).Return(&api.HostResponse{}, nil), @@ -348,15 +356,15 @@ var _ = Describe("elemental-agent", Label("agent", "cli"), func() { cmd.SetArgs([]string{"--install"}) gomock.InOrder( // Make the first get registration call fail. Expect to recover by calling again - mClient.EXPECT().GetRegistration().Return(nil, errors.New("get registration test error")), - mClient.EXPECT().GetRegistration().Return(registrationFixture, nil), + mClient.EXPECT().GetRegistration(configFixture.Registration.Token).Return(nil, errors.New("get registration test error")), + mClient.EXPECT().GetRegistration(configFixture.Registration.Token).Return(registrationFixture, nil), // Make the cloud init apply fail. Expect to recover by getting registration and applying cloud init again plugin.EXPECT().ApplyCloudInit(wantCloudInit).Return(errors.New("cloud init test failed")), - mClient.EXPECT().GetRegistration().Return(registrationFixture, nil), + mClient.EXPECT().GetRegistration(configFixture.Registration.Token).Return(registrationFixture, nil), plugin.EXPECT().ApplyCloudInit(wantCloudInit).Return(nil), // Make the install fail. Expect to recover by getting registration and installing again plugin.EXPECT().Install(wantInstall).Return(errors.New("install test fail")), - mClient.EXPECT().GetRegistration().Return(registrationFixture, nil), + mClient.EXPECT().GetRegistration(configFixture.Registration.Token).Return(registrationFixture, nil), plugin.EXPECT().Install(wantInstall).Return(nil), // Make the patch host fail. Expect to recover by patching it again mClient.EXPECT().PatchHost(gomock.Any(), hostResponseFixture.Name).Return(nil, errors.New("patch host test fail")), @@ -384,13 +392,13 @@ var _ = Describe("elemental-agent", Label("agent", "cli"), func() { mClient.EXPECT().DeleteHost(hostResponseFixture.Name).Return(errors.New("delete host test error")), mClient.EXPECT().DeleteHost(hostResponseFixture.Name).Return(nil), // Make the first registration call fail. Expect to recover by calling again - mClient.EXPECT().GetRegistration().Return(nil, errors.New("get registration test error")), + mClient.EXPECT().GetRegistration(configFixture.Registration.Token).Return(nil, errors.New("get registration test error")), mClient.EXPECT().DeleteHost(hostResponseFixture.Name).Return(nil), // Always called - mClient.EXPECT().GetRegistration().Return(registrationFixture, nil), + mClient.EXPECT().GetRegistration(configFixture.Registration.Token).Return(registrationFixture, nil), // Make the reset call fail. Expect to recover by getting registration and resetting again plugin.EXPECT().Reset(wantReset).Return(errors.New("reset test error")), mClient.EXPECT().DeleteHost(hostResponseFixture.Name).Return(nil), - mClient.EXPECT().GetRegistration().Return(registrationFixture, nil), + mClient.EXPECT().GetRegistration(configFixture.Registration.Token).Return(registrationFixture, nil), plugin.EXPECT().Reset(wantReset).Return(nil), // Make the patch host fail. Expect to recover by patching it again mClient.EXPECT().PatchHost(gomock.Any(), hostResponseFixture.Name).Return(nil, errors.New("patch host test fail")), @@ -412,6 +420,21 @@ var _ = Describe("elemental-agent", Label("agent", "cli"), func() { }) }) +func initializeIdentity(fs vfs.FS) (identity.Identity, string) { + id, err := identity.NewED25519Identity() + Expect(err).ToNot(HaveOccurred()) + // Initialize private key on filesystem + keyPath := fmt.Sprintf("%s/%s", registrationFixture.Config.Elemental.Agent.WorkDir, identity.PrivateKeyFile) + idPem, err := id.Marshal() + Expect(err).ToNot(HaveOccurred()) + Expect(vfs.MkdirAll(fs, filepath.Dir(keyPath), os.ModePerm)).Should(Succeed()) + Expect(fs.WriteFile(keyPath, idPem, os.ModePerm)).Should(Succeed()) + Expect(err).ToNot(HaveOccurred()) + pubKeyPem, err := id.MarshalPublic() + Expect(err).ToNot(HaveOccurred()) + return id, string(pubKeyPem) +} + func marshalIntoFile(fs vfs.FS, input any, filePath string) { bytes := marshalToBytes(input) Expect(vfs.MkdirAll(fs, path.Dir(filePath), os.ModePerm)).ToNot(HaveOccurred()) diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_elementalhosts.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_elementalhosts.yaml index 009c84ba..4177fb00 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_elementalhosts.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_elementalhosts.yaml @@ -110,6 +110,10 @@ spec: type: string type: object x-kubernetes-map-type: atomic + pubKey: + description: PubKey is the host public key to verify when authenticating + Elemental API requests for this host. + type: string type: object status: description: ElementalHostStatus defines the observed state of ElementalHost. diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_elementalregistrations.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_elementalregistrations.yaml index f61d4c84..0e2dbdb8 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_elementalregistrations.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_elementalregistrations.yaml @@ -105,6 +105,15 @@ spec: properties: caCert: type: string + token: + type: string + tokenDuration: + description: A Duration represents the elapsed time between + two instants as an int64 nanosecond count. The representation + limits the largest representable duration to approximately + 290 years. + format: int64 + type: integer uri: type: string type: object @@ -124,6 +133,44 @@ spec: description: HostLabels are labels propagated to each ElementalHost object linked to this registration. type: object + privateKeyRef: + description: PrivateKeyRef is a reference to a secret containing the + private key used to generate registration tokens + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an object instead of + an entire object, this string should contain a valid JSON/Go + field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within + a pod, this would take on a value like: "spec.containers{name}" + (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" + (container with index 2 in this pod). This syntax is chosen + only to have some well-defined way of referencing a part of + an object. TODO: this design is not final and this field is + subject to change in the future.' + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which this reference + is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + x-kubernetes-map-type: atomic type: object status: description: ElementalRegistrationStatus defines the observed state of diff --git a/doc/AUTH.md b/doc/AUTH.md new file mode 100644 index 00000000..8b7beb4f --- /dev/null +++ b/doc/AUTH.md @@ -0,0 +1,186 @@ +# Authentication & Authorization + +This document describes the usage of the `Registration-Authorization` and `Authorization` headers when communicating with the [Elemental API](../README.md#elemental-api). + +## Registration Tokens + +Any ElementalHost that wants to register to an active registration, will need a registration token. +This should be passed to the Elemental API using the `Registration-Authorization` header, as described in the [OpenAPI specs](../elemental-openapi.yaml). + +Registration tokens are automatically generated by the controller whenever a registration is created: + +```bash +cat << EOF | kubectl apply -f - +apiVersion: infrastructure.cluster.x-k8s.io/v1beta1 +kind: ElementalRegistration +metadata: + name: my-registration + namespace: default +spec: + config: + elemental: + registration: + tokenDuration: 0 +EOF +``` + +After applying, the newly created registration will contain a new `token`: + +```bash +kubectl get elementalregistration my-registration -o=jsonpath='{.spec.config.elemental.registration.token}' + +eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJFbGVtZW50YWxSZWdpc3RyYXRpb25SZWNvbmNpbGVyIiwic3ViIjoiaHR0cDovLzE5Mi4xNjguMTIyLjEwOjMwMDA5L2VsZW1lbnRhbC92MS9uYW1lc3BhY2VzL2RlZmF1bHQvcmVnaXN0cmF0aW9ucy9teS1yZWdpc3RyYXRpb24iLCJhdWQiOlsiaHR0cDovLzE5Mi4xNjguMTIyLjEwOjMwMDA5L2VsZW1lbnRhbC92MS9uYW1lc3BhY2VzL2RlZmF1bHQvcmVnaXN0cmF0aW9ucy9teS1yZWdpc3RyYXRpb24iXSwibmJmIjoxNjk5ODc1NDYxLCJpYXQiOjE2OTk4NzU0NjF9.VK6jQ7MutSrb4RVT-w0PGd14MRZbJVoJXXyWd44l_tijC_IlSernkaZpa-KFTLn0XVyE6IcpPm4zYXlyAGYoAw +``` + +The token is in JWT format. You can inspect it using the web app at [jwt.io](https://jwt.io). +By default registration tokens do not and should not expire. +The registration tokens are normally shared with hosts during the provisioning phase and are used by the `elemental-agent` to register a new ElementalHost. +Note that during the registration phase (`elemental-agent --register`), the `elemental-agent` will exchange its registration token for a fresh one, when fetching the remote ElementalRegistration to update (and override) the agent config file. +For this reason issuing tokens with an expiration date will eventually impact the ability of the hosts to reset and re-register. + +You can generate a valid `elemental-agent` config file from any registration, using the conveniency `print_agent_config.sh` script from this repo (depends on `kubectl` and `yq`): + +```bash +test/scripts/print_agent_config.sh -n default -r my-registration + +agent: + debug: false + hostname: + useExisting: false + osPlugin: /usr/lib/elemental/plugins/elemental.so + reconciliation: 10000000000 + workDir: /var/lib/elemental/agent +registration: + token: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJFbGVtZW50YWxSZWdpc3RyYXRpb25SZWNvbmNpbGVyIiwic3ViIjoiaHR0cDovLzE5Mi4xNjguMTIyLjEwOjMwMDA5L2VsZW1lbnRhbC92MS9uYW1lc3BhY2VzL2RlZmF1bHQvcmVnaXN0cmF0aW9ucy9teS1yZWdpc3RyYXRpb24iLCJhdWQiOlsiaHR0cDovLzE5Mi4xNjguMTIyLjEwOjMwMDA5L2VsZW1lbnRhbC92MS9uYW1lc3BhY2VzL2RlZmF1bHQvcmVnaXN0cmF0aW9ucy9teS1yZWdpc3RyYXRpb24iXSwibmJmIjoxNjk5ODc1NDYxLCJpYXQiOjE2OTk4NzU0NjF9.VK6jQ7MutSrb4RVT-w0PGd14MRZbJVoJXXyWd44l_tijC_IlSernkaZpa-KFTLn0XVyE6IcpPm4zYXlyAGYoAw + tokenDuration: 0 + uri: http://192.168.122.10:30009/elemental/v1/namespaces/default/registrations/my-registration +``` + +### Registration signing key + +Whenever a new registration is created, a signing private key is also auto-generated by the controller. +The key is stored in a secret with the same name of the registration, within the same namespace as well. + +```bash +kubectl get secret my-registration -o yaml +apiVersion: v1 +data: + privKey: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1DNENBUUF3QlFZREsyVndCQ0lFSU1KZi95YWhicFlnU3VFaDBHaFpxR1hCWjNKcVZGQ29WQlFhY2NkZ1RCeGkKLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLQo= +kind: Secret +metadata: + creationTimestamp: "2023-11-13T11:37:41Z" + name: my-registration + namespace: default + ownerReferences: + - apiVersion: infrastructure.cluster.x-k8s.io/v1beta1 + controller: true + kind: ElementalRegistration + name: my-registration + uid: 3d3e58bf-495f-4d63-a825-e4a02c224156 + resourceVersion: "37704" + uid: c8e09eeb-cbb4-4482-9b78-9fbf90926594 +type: Opaque +``` + +The key is an EdDSA private key, in PEM format. +You can choose to let the controller generate its own registration signing key, or you can create one yourself. + +The ElementalRegistration also carries a reference to the signing key secret: + +```bash +kubectl get elementalregistration my-registration -o=jsonpath='{.spec.privateKeyRef}' +{"name":"my-registration","namespace":"default","uid":"c8e09eeb-cbb4-4482-9b78-9fbf90926594"} +``` + +This reference can be optionally updated to a different secret, containing a PEM formatted EdDSA key in the `privKey` data value. + +### Registration token leaks + +Registration tokens are sensitive secrets. Any valid and not-expired token will allow any actor to register a new ElementalHost, if they have access to the Elemental API. +These tokens are also normally included in any installation media that was created to provision new machine, for example a bootable .iso or a raw image. +If these tokens are leaked for any reason, it is possible to generate a new registration signing key, invalidating any previously issued token. +**Note** that this will require to manually provide a new valid token to each already running and registered machine, by updating the agent config (`/etc/elemental/agent/config.yaml` by default). + +What to do in case of leaks: + +1. Delete the secret containing the signing key: + + ```bash + kubectl delete secret my-registration + ``` + + After the deletion of the signing key, the registration API controller will error out when trying to register any new host. + **This already prevents new registrations, nullifying any leaked token.** + +1. Delete the Registration's `spec.privateKeyRef`: + + ```bash + kubectl patch elementalregistration my-registration -p '{"spec":{"privateKeyRef":null}}' --type=merge + ``` + + At this point the registration controller should have generated a new secret containing a new signing key. + The Registration's `spec.privateKeyRef` should also be referencing the new secret by default. + +1. You can now generate a new valid token by deleting the previous registration token. A fresh one will be generated: + + ```bash + kubectl patch elementalregistration my-registration -p '{"spec":{"config":{"elemental":{"registration":{"token":""}}}}}' --type=merge + ``` + +1. Last but not least, you should audit the existing `ElementalHosts` and verify that none of them registered after the leak occurred. + This can be achieved by sorting the `ElementalHosts` by creation date: + + ```bash + kubectl get elementalhost --sort-by=.medatada.creationTimestamp -o yaml + ``` + + You should also update the agent config on each host by providing a new valid token, so that the agent will be able to reset and re-register correctly. + +### Forbidding new registrations + +While normally discouraged, it is possible to "freeze" a registration by forbidding new hosts from registering. +This can be a valid scenario for example when no new provisioning or reset of existing hosts is expected to take place. + +A quick way to do this is to manually replace the registration token: + +```bash +kubectl patch elementalregistration my-registration -p '{"spec":{"config":{"elemental":{"registration":{"token":"disabled"}}}}}' --type=merge +``` + +The controller will not try to replace any existing string provided, even if not in the expected JWT format. + +Another way to achieve the same goal is to generate an already expired token. +This can be controlled setting the `tokenDuration` value to `-1` for example. + +```bash +kubectl patch elementalregistration my-registration -p '{"spec":{"config":{"elemental":{"registration":{"token":"", "tokenDuration":-1}}}}}' --type=merge +``` + +## Host Authentication + +When modifying hosts resources, the Elemental API expects a valid host token provided in the `Authorization` header. +This is also documented in in the [OpenAPI specs](../elemental-openapi.yaml). + +Similarly to the registration tokens, host tokens are also in JWT format. +The `elemental-agent` will always generate a new private signing key in a `private.key` file within its work directory. +If no file exists, a new one is created, otherwise it's simply loaded. +This gives the possibility of pre-creating a signing key before running `elemental-agent --register`. +The key must be an EdDSA key in PEM format. + +When registering a new `ElementalHost`, the agent will pass the public key that should be used to validate its signature and also a signed JWT so that the controller can validate the request. +After registration, you can inspect the `ElementalHost` to check the registered public key: + +```bash +kubectl get elementalhost m-ede4a577-0c4b-4325-a344-2fb7cb6a85c7 -o yaml + [..cut..] + spec: + pubKey: | + -----BEGIN PUBLIC KEY----- + MCowBQYDK2VwAyEA3jGZyutpdZVLa0z1wWrLB+6i1uWCDoDftohgl2k7HqE= + -----END PUBLIC KEY----- +``` + +### Host identity leaks + +If a running host is compromised and the `private.key` file is leaked, it is possible to invalidate any further request signed by the key by deleting or replacing the ElementalHost's `spec.pubKey` field. +If the host is recoverable, it is possible to manually generate a new `private.key` file in the agent's work directory, and update the related `ElementalHost` object with a valid `spec.pubKey` (in PEM format) that can be used to validate the new key signature. diff --git a/doc/QUICKSTART.md b/doc/QUICKSTART.md index a25c1315..f6096d61 100644 --- a/doc/QUICKSTART.md +++ b/doc/QUICKSTART.md @@ -83,7 +83,7 @@ cat << EOF > $HOME/.cluster-api/clusterctl.yaml providers: - name: "elemental" - url: "https://github.com/rancher-sandbox/cluster-api-provider-elemental/releases/v0.1.0/infrastructure-components.yaml" + url: "https://github.com/rancher-sandbox/cluster-api-provider-elemental/releases/v0.2.0/infrastructure-components.yaml" type: "InfrastructureProvider" - name: "k3s" url: "https://github.com/cluster-api-provider-k3s/cluster-api-k3s/releases/v0.1.8/bootstrap-components.yaml" @@ -97,7 +97,7 @@ 1. Install CAPI Core provider, the k3s Control Plane and Bootstrap providers, and the Elemental Infrastructure provider: ```bash - clusterctl init --bootstrap k3s:v0.1.8 --control-plane k3s:v0.1.8 --infrastructure elemental:v0.1.0 + clusterctl init --bootstrap k3s:v0.1.8 --control-plane k3s:v0.1.8 --infrastructure elemental:v0.2.0 ``` 1. Expose the Elemental API server: @@ -132,7 +132,7 @@ ```bash CONTROL_PLANE_ENDPOINT_IP=192.168.122.100 clusterctl generate cluster \ - --infrastructure elemental:v0.1.0 \ + --infrastructure elemental:v0.2.0 \ --flavor k3s-single-node \ --kubernetes-version v1.28.2 \ elemental-cluster-k3s > $HOME/elemental-cluster-k3s.yaml @@ -193,22 +193,19 @@ You can configure a different device, editing the `ElementalRegistration` create cd cluster-api-provider-elemental ``` -- (Optionally) Generate a valid agent config (depends on `yq`): +- Generate a valid agent config (depends on `kubectl` and `yq`): ```bash ./test/scripts/print_agent_config.sh -n default -r my-registration > iso/config/my-config.yaml ``` + Note that the agent config should contain a valid registration `token`. + By default this is a JWT formatted token with no expiration. + - Build the ISO image: This depends on `make` and `docker`: - ```bash - make build-iso - ``` - - Optionally, a custom agent config can be injected in the image: - ```bash AGENT_CONFIG_FILE=iso/config/my-config.yaml make build-iso ``` diff --git a/elemental-openapi.yaml b/elemental-openapi.yaml index 15e67e60..6bb41bd8 100644 --- a/elemental-openapi.yaml +++ b/elemental-openapi.yaml @@ -22,6 +22,10 @@ paths: required: true schema: type: string + - in: header + name: Registration-Authorization + schema: + type: string responses: "200": content: @@ -29,6 +33,19 @@ paths: schema: $ref: '#/components/schemas/ApiRegistrationResponse' description: Returns the ElementalRegistration + "401": + content: + text/html: + schema: + type: string + description: If the 'Registration-Authorization' header does not contain + a Bearer token + "403": + content: + text/html: + schema: + type: string + description: If the 'Registration-Authorization' token is not valid "404": content: text/html: @@ -56,6 +73,14 @@ paths: required: true schema: type: string + - in: header + name: Authorization + schema: + type: string + - in: header + name: Registration-Authorization + schema: + type: string requestBody: content: application/json: @@ -71,6 +96,20 @@ paths: schema: type: string description: ElementalHost request is badly formatted + "401": + content: + text/html: + schema: + type: string + description: If the 'Authorization' or 'Registration-Authorization' headers + do not contain Bearer tokens + "403": + content: + text/html: + schema: + type: string + description: If the 'Authorization' or 'Registration-Authorization' tokens + are not valid "404": content: text/html: @@ -110,9 +149,25 @@ paths: required: true schema: type: string + - in: header + name: Authorization + schema: + type: string responses: "202": description: ElementalHost correctly deleted. + "401": + content: + text/html: + schema: + type: string + description: If the 'Authorization' header does not contain a Bearer token + "403": + content: + text/html: + schema: + type: string + description: If the 'Authorization' token is not valid "404": content: text/html: @@ -144,6 +199,10 @@ paths: required: true schema: type: string + - in: header + name: Authorization + schema: + type: string requestBody: content: application/json: @@ -162,6 +221,18 @@ paths: schema: type: string description: If the ElementalHostPatch request is badly formatted + "401": + content: + text/html: + schema: + type: string + description: If the 'Authorization' header does not contain a Bearer token + "403": + content: + text/html: + schema: + type: string + description: If the 'Authorization' token is not valid "404": content: text/html: @@ -194,6 +265,10 @@ paths: required: true schema: type: string + - in: header + name: Authorization + schema: + type: string responses: "200": content: @@ -201,6 +276,18 @@ paths: schema: $ref: '#/components/schemas/ApiBootstrapResponse' description: Returns the ElementalHost bootstrap instructions + "401": + content: + text/html: + schema: + type: string + description: If the 'Authorization' header does not contain a Bearer token + "403": + content: + text/html: + schema: + type: string + description: If the 'Authorization' token is not valid "404": content: text/html: @@ -242,6 +329,8 @@ components: type: object name: type: string + pubKey: + type: string type: object ApiHostPatchRequest: properties: @@ -310,6 +399,7 @@ components: type: object RuntimeRawExtension: type: object + TimeDuration: {} V1Beta1Agent: properties: debug: @@ -329,7 +419,7 @@ components: postReset: $ref: '#/components/schemas/V1Beta1PostReset' reconciliation: - type: integer + $ref: '#/components/schemas/TimeDuration' useSystemCertPool: type: boolean workDir: @@ -384,6 +474,10 @@ components: properties: caCert: type: string + token: + type: string + tokenDuration: + type: integer uri: type: string type: object diff --git a/go.mod b/go.mod index b4ff6548..8edf897c 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21 require ( github.com/go-logr/logr v1.2.4 github.com/go-logr/zapr v1.2.4 + github.com/golang-jwt/jwt/v5 v5.1.0 github.com/google/uuid v1.3.1 github.com/gorilla/mux v1.8.0 github.com/onsi/ginkgo/v2 v2.13.0 @@ -17,6 +18,8 @@ require ( github.com/twpayne/go-vfsafero v1.0.0 go.uber.org/mock v0.3.0 go.uber.org/zap v1.26.0 + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 + gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.28.2 k8s.io/apimachinery v0.28.2 @@ -50,9 +53,7 @@ require ( github.com/swaggest/jsonschema-go v0.3.62 // indirect github.com/swaggest/refl v1.3.0 // indirect github.com/swaggest/usecase v1.2.1 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 // indirect ) require ( diff --git a/go.sum b/go.sum index 8a0bbbf8..408377b2 100644 --- a/go.sum +++ b/go.sum @@ -39,13 +39,18 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= +github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 h1:yL7+Jz0jTC6yykIK/Wh74gnTJnrGr5AyrNMXuA0gves= github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df h1:7RFfzj4SSt6nnvCPbCqijJi1nWCd+TqAT3bYCStRC18= +github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df/go.mod h1:pSwJ0fSY5KhvocuWSx4fz3BA8OrA1bQn+K1Eli3BRwM= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -54,7 +59,9 @@ github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnweb github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bool64/dev v0.2.31 h1:OS57EqYaYe2M/2bw9uhDCIFiZZwywKFS/4qMLN6JUmQ= +github.com/bool64/dev v0.2.31/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= +github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -68,6 +75,7 @@ github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnht github.com/coredns/caddy v1.1.1 h1:2eYKZT7i6yxIfGP3qLJoJ7HAsDJqYB+X68g4NYjSrE0= github.com/coredns/caddy v1.1.1/go.mod h1:A6ntJQlAWuQfFlsd9hvigKbo2WS0VUs2l1e2F+BawD4= github.com/coredns/corefile-migration v1.0.21 h1:W/DCETrHDiFo0Wj03EyMkaQ9fwsmSgqTCQDHpceaSsE= +github.com/coredns/corefile-migration v1.0.21/go.mod h1:XnhgULOEouimnzgn0t4WPuFDN2/PJQcTxdWKC5eXNGE= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -75,6 +83,7 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= +github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -84,13 +93,16 @@ github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5y github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= +github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -113,6 +125,8 @@ github.com/gobuffalo/flect v1.0.2 h1:eqjPGSo2WmjgY2XlpGwo2NXgL3RucAKo4k4qQMNA5sA github.com/gobuffalo/flect v1.0.2/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.1.0 h1:UGKbA/IPjtS6zLcdB7i5TyACMgSbOTiR8qzXgw8HWQU= +github.com/golang-jwt/jwt/v5 v5.1.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -146,6 +160,7 @@ github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/cel-go v0.16.0 h1:DG9YQ8nFCFXAs/FDDwBxmL1tpKNrdlGUM9U3537bX/Y= +github.com/google/cel-go v0.16.0/go.mod h1:HXZKzB0LXqer5lHHgfWAnlYwJaQBDKMjxjulNQzhwhY= github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -202,6 +217,7 @@ github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= +github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= @@ -225,6 +241,7 @@ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -236,9 +253,11 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -253,6 +272,7 @@ github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xl github.com/onsi/gomega v1.28.0 h1:i2rg/p9n/UqIDAMFUJ6qIUUMcsqOuUHgbpbu235Vr1c= github.com/onsi/gomega v1.28.0/go.mod h1:A1H2JE76sI14WIP57LMKj7FVfCHx3g3BcZVjJG8bjX8= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -261,6 +281,7 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -272,14 +293,18 @@ github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwa github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ= github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 h1:levPcBfnazlA1CyCMC3asL/QLZkq9pa8tQZOH513zQw= +github.com/santhosh-tekuri/jsonschema/v3 v3.1.0/go.mod h1:8kzK2TC0k0YjOForaAHdNEa7ik0fokNa2k30BKJ/W7Y= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= @@ -294,6 +319,7 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -310,7 +336,9 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= +github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= github.com/swaggest/form/v5 v5.1.1 h1:ct6/rOQBGrqWUQ0FUv3vW5sHvTUb31AwTUWj947N6cY= +github.com/swaggest/form/v5 v5.1.1/go.mod h1:X1hraaoONee20PMnGNLQpO32f9zbQ0Czfm7iZThuEKg= github.com/swaggest/jsonschema-go v0.3.62 h1:eIE0aRklWa2eLJg2L/zqyWpKvgUPbq2oKOtrJGJkPH0= github.com/swaggest/jsonschema-go v0.3.62/go.mod h1:DYuKqdpms/edvywsX6p1zHXCZkdwB28wRaBdFCe3Duw= github.com/swaggest/openapi-go v0.2.41 h1:aO8Q5ZugBmbd16YcppG18e3T+tgU8NJrXA3HLPgnANI= @@ -327,7 +355,9 @@ github.com/twpayne/go-vfs v1.7.2/go.mod h1:1eni2ntkiiAHZG27xfLOO4CYvMR4Kw8V7rYiL github.com/twpayne/go-vfsafero v1.0.0 h1:ZlH32HF4OoVX/aRqc5bZa+2+M+/ezmJ4XYpT0ShtZNc= github.com/twpayne/go-vfsafero v1.0.0/go.mod h1:rs2H15b2z0euJzwyoBS63eUHZgBhNXVQfIFfRp8DKEk= github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -342,6 +372,7 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo= go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= @@ -358,6 +389,7 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -395,6 +427,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -453,6 +486,7 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -635,7 +669,9 @@ google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb h1:XFBgcDwm7irdHTbz4Zk2h7Mh+eis4nfJEFQFYzJzuIA= google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb h1:lK0oleSc7IQsUxO3U5TjL9DWlsxpEBemh+zpB7IqhWI= +google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb/go.mod h1:KjSP20unUpOx5kyQUFa7k4OJg0qeJ7DEZflGDu2p6Bk= google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 h1:N3bU/SQDCDyD6R528GJ/PwW9KjYcJA3dgyH+MovAkIM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:KSqppvjFjtoCI+KGd4PELB0qLNxdJHRGqRI09mB6pQA= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -698,6 +734,7 @@ k8s.io/apiextensions-apiserver v0.28.0/go.mod h1:uRdYiwIuu0SyqJKriKmqEN2jThIJPhV k8s.io/apimachinery v0.28.2 h1:KCOJLrc6gu+wV1BYgwik4AF4vXOlVJPdiqn0yAWWwXQ= k8s.io/apimachinery v0.28.2/go.mod h1:RdzF87y/ngqk9H4z3EL2Rppv5jj95vGS/HaFXrLDApU= k8s.io/apiserver v0.28.1 h1:dw2/NKauDZCnOUAzIo2hFhtBRUo6gQK832NV8kuDbGM= +k8s.io/apiserver v0.28.1/go.mod h1:d8aizlSRB6yRgJ6PKfDkdwCy2DXt/d1FDR6iJN9kY1w= k8s.io/client-go v0.28.2 h1:DNoYI1vGq0slMBN/SWKMZMw0Rq+0EQW6/AK4v9+3VeY= k8s.io/client-go v0.28.2/go.mod h1:sMkApowspLuc7omj1FOSUxSoqjr+d5Q0Yc0LOFnYFJY= k8s.io/cluster-bootstrap v0.28.0 h1:qbweWYQcaajD4FH16GNJQk+u5qUHc+1/kZ5O4oUFWwo= diff --git a/infrastructure-elemental/v0.0.0/infrastructure-components.yaml b/infrastructure-elemental/v0.0.0/infrastructure-components.yaml index 98613b83..a5e9328a 100644 --- a/infrastructure-elemental/v0.0.0/infrastructure-components.yaml +++ b/infrastructure-elemental/v0.0.0/infrastructure-components.yaml @@ -333,6 +333,10 @@ spec: type: string type: object x-kubernetes-map-type: atomic + pubKey: + description: PubKey is the host public key to verify when authenticating + Elemental API requests for this host. + type: string type: object status: description: ElementalHostStatus defines the observed state of ElementalHost. @@ -807,6 +811,15 @@ spec: properties: caCert: type: string + token: + type: string + tokenDuration: + description: A Duration represents the elapsed time between + two instants as an int64 nanosecond count. The representation + limits the largest representable duration to approximately + 290 years. + format: int64 + type: integer uri: type: string type: object @@ -826,6 +839,44 @@ spec: description: HostLabels are labels propagated to each ElementalHost object linked to this registration. type: object + privateKeyRef: + description: PrivateKeyRef is a reference to a secret containing the + private key used to generate registration tokens + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an object instead of + an entire object, this string should contain a valid JSON/Go + field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within + a pod, this would take on a value like: "spec.containers{name}" + (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" + (container with index 2 in this pod). This syntax is chosen + only to have some well-defined way of referencing a part of + an object. TODO: this design is not final and this field is + subject to change in the future.' + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which this reference + is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + x-kubernetes-map-type: atomic type: object status: description: ElementalRegistrationStatus defines the observed state of diff --git a/infrastructure-elemental/v0.0.0/metadata.yaml b/infrastructure-elemental/v0.0.0/metadata.yaml index ce35fcbc..5a3c076f 100644 --- a/infrastructure-elemental/v0.0.0/metadata.yaml +++ b/infrastructure-elemental/v0.0.0/metadata.yaml @@ -7,3 +7,6 @@ releaseSeries: - major: 0 minor: 1 contract: v1beta1 +- major: 0 + minor: 2 + contract: v1beta1 diff --git a/internal/agent/client/client.go b/internal/agent/client/client.go index 5cb2bdb7..f01f44f1 100644 --- a/internal/agent/client/client.go +++ b/internal/agent/client/client.go @@ -9,11 +9,14 @@ import ( "net/http" "net/url" "strings" + "time" + "github.com/golang-jwt/jwt/v5" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/config" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/log" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/tls" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/api" + "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/identity" "github.com/twpayne/go-vfs" ) @@ -23,9 +26,9 @@ var ( ) type Client interface { - Init(fs vfs.FS, signingKey []byte, conf config.Config) error - GetRegistration() (*api.RegistrationResponse, error) - CreateHost(host api.HostCreateRequest) error + Init(vfs.FS, identity.Identity, config.Config) error + GetRegistration(token string) (*api.RegistrationResponse, error) + CreateHost(newHost api.HostCreateRequest, registrationToken string) error DeleteHost(hostname string) error PatchHost(patch api.HostPatchRequest, hostname string) (*api.HostResponse, error) GetBootstrap(hostname string) (*api.BootstrapResponse, error) @@ -34,18 +37,22 @@ type Client interface { var _ Client = (*client)(nil) type client struct { + userAgent string registrationURI string httpClient http.Client - signingKey []byte + identity identity.Identity } -func NewClient() Client { - return &client{} +func NewClient(version string) Client { + userAgent := fmt.Sprintf("elemental-agent/%s", version) + return &client{ + userAgent: userAgent, + } } -func (c *client) Init(fs vfs.FS, signingKey []byte, conf config.Config) error { +func (c *client) Init(fs vfs.FS, identity identity.Identity, conf config.Config) error { log.Debug("Initializing Client") - c.signingKey = signingKey + c.identity = identity url, err := url.Parse(conf.Registration.URI) if err != nil { @@ -81,9 +88,14 @@ func (c *client) Init(fs vfs.FS, signingKey []byte, conf config.Config) error { return nil } -func (c *client) GetRegistration() (*api.RegistrationResponse, error) { +func (c *client) GetRegistration(registrationToken string) (*api.RegistrationResponse, error) { log.Debugf("Getting registration: %s", c.registrationURI) - response, err := c.httpClient.Get(c.registrationURI) + request, err := c.newRequest(http.MethodGet, c.registrationURI, nil) + if err != nil { + return nil, fmt.Errorf("preparing GET registration request: %w", err) + } + c.addRegistrationHeader(&request.Header, registrationToken) + response, err := c.httpClient.Do(request) if err != nil { return nil, fmt.Errorf("getting registration: %w", err) } @@ -105,14 +117,21 @@ func (c *client) GetRegistration() (*api.RegistrationResponse, error) { return ®istration, nil } -func (c *client) CreateHost(newHost api.HostCreateRequest) error { +func (c *client) CreateHost(newHost api.HostCreateRequest, registrationToken string) error { log.Debugf("Creating new host: %s", newHost.Name) requestBody, err := json.Marshal(newHost) if err != nil { return fmt.Errorf("marshalling new host request body: %w", err) } - response, err := c.httpClient.Post(fmt.Sprintf("%s/hosts", c.registrationURI), "application/json", bytes.NewBuffer(requestBody)) + url := fmt.Sprintf("%s/hosts", c.registrationURI) + request, err := c.newAuthenticatedRequest(newHost.Name, http.MethodPost, url, bytes.NewBuffer(requestBody)) + if err != nil { + return fmt.Errorf("preparing POST host request: %w", err) + } + request.Header.Add("Content-Type", "application/json") + c.addRegistrationHeader(&request.Header, registrationToken) + response, err := c.httpClient.Do(request) if err != nil { return fmt.Errorf("creating new host: %w", err) } @@ -126,10 +145,12 @@ func (c *client) CreateHost(newHost api.HostCreateRequest) error { func (c *client) DeleteHost(hostname string) error { log.Debugf("Marking host for deletion: %s", hostname) - request, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/hosts/%s", c.registrationURI, hostname), nil) + url := fmt.Sprintf("%s/hosts/%s", c.registrationURI, hostname) + request, err := c.newAuthenticatedRequest(hostname, http.MethodDelete, url, nil) if err != nil { - return fmt.Errorf("preparing host delete request: %w", err) + return fmt.Errorf("preparing DELETE host request: %w", err) } + response, err := c.httpClient.Do(request) if err != nil { return fmt.Errorf("deleting host: %w", err) @@ -149,11 +170,12 @@ func (c *client) PatchHost(patch api.HostPatchRequest, hostname string) (*api.Ho return nil, fmt.Errorf("marshalling patch host request body: %w", err) } - request, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/hosts/%s", c.registrationURI, hostname), bytes.NewBuffer(requestBody)) + url := fmt.Sprintf("%s/hosts/%s", c.registrationURI, hostname) + request, err := c.newAuthenticatedRequest(hostname, http.MethodPatch, url, bytes.NewBuffer(requestBody)) if err != nil { - return nil, fmt.Errorf("preparing host patch request: %w", err) + return nil, fmt.Errorf("preparing PATCH host request: %w", err) } - request.Header.Set("Content-Type", "application/json") + request.Header.Add("Content-Type", "application/json") response, err := c.httpClient.Do(request) if err != nil { @@ -179,7 +201,12 @@ func (c *client) PatchHost(patch api.HostPatchRequest, hostname string) (*api.Ho func (c *client) GetBootstrap(hostname string) (*api.BootstrapResponse, error) { log.Debugf("Getting bootstrap for host: %s", hostname) - response, err := c.httpClient.Get(fmt.Sprintf("%s/hosts/%s/bootstrap", c.registrationURI, hostname)) + url := fmt.Sprintf("%s/hosts/%s/bootstrap", c.registrationURI, hostname) + request, err := c.newAuthenticatedRequest(hostname, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("preparing get bootstrap request: %w", err) + } + response, err := c.httpClient.Do(request) if err != nil { return nil, fmt.Errorf("getting bootstrap: %w", err) } @@ -200,3 +227,57 @@ func (c *client) GetBootstrap(hostname string) (*api.BootstrapResponse, error) { return &bootstrap, nil } + +func (c *client) newRequest(method string, url string, body io.Reader) (*http.Request, error) { + request, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("preparing request: %w", err) + } + c.addUserAgentHeader(&request.Header) + return request, nil +} + +func (c *client) newAuthenticatedRequest(forHostname string, method string, url string, body io.Reader) (*http.Request, error) { + request, err := c.newRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("creating new request: %w", err) + } + if err := c.addAuthHeader(&request.Header, forHostname); err != nil { + return nil, fmt.Errorf("setting Authorization header: %w", err) + } + return request, nil +} + +func (c *client) addRegistrationHeader(header *http.Header, registrationToken string) { + header.Add("Registration-Authorization", fmt.Sprintf("Bearer %s", registrationToken)) +} + +func (c *client) addUserAgentHeader(header *http.Header) { + header.Add("User-Agent", c.userAgent) +} + +func (c *client) addAuthHeader(header *http.Header, hostname string) error { + token, err := c.newToken(hostname) + if err != nil { + return fmt.Errorf("generating new token: %w", err) + } + header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + return nil +} + +func (c *client) newToken(hostname string) (string, error) { + now := time.Now() + claims := jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Minute)), + Issuer: c.userAgent, + Subject: hostname, + Audience: []string{c.registrationURI}, + } + token, err := c.identity.Sign(claims) + if err != nil { + return "", fmt.Errorf("signing JWT claims: %w", err) + } + return token, nil +} diff --git a/internal/agent/client/client_mocks.go b/internal/agent/client/client_mocks.go index 629c0403..7d407fab 100644 --- a/internal/agent/client/client_mocks.go +++ b/internal/agent/client/client_mocks.go @@ -30,6 +30,7 @@ import ( config "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/config" api "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/api" + identity "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/identity" vfs "github.com/twpayne/go-vfs" gomock "go.uber.org/mock/gomock" ) @@ -58,17 +59,17 @@ func (m *MockClient) EXPECT() *MockClientMockRecorder { } // CreateHost mocks base method. -func (m *MockClient) CreateHost(arg0 api.HostCreateRequest) error { +func (m *MockClient) CreateHost(arg0 api.HostCreateRequest, arg1 string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateHost", arg0) + ret := m.ctrl.Call(m, "CreateHost", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // CreateHost indicates an expected call of CreateHost. -func (mr *MockClientMockRecorder) CreateHost(arg0 any) *gomock.Call { +func (mr *MockClientMockRecorder) CreateHost(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateHost", reflect.TypeOf((*MockClient)(nil).CreateHost), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateHost", reflect.TypeOf((*MockClient)(nil).CreateHost), arg0, arg1) } // DeleteHost mocks base method. @@ -101,22 +102,22 @@ func (mr *MockClientMockRecorder) GetBootstrap(arg0 any) *gomock.Call { } // GetRegistration mocks base method. -func (m *MockClient) GetRegistration() (*api.RegistrationResponse, error) { +func (m *MockClient) GetRegistration(arg0 string) (*api.RegistrationResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetRegistration") + ret := m.ctrl.Call(m, "GetRegistration", arg0) ret0, _ := ret[0].(*api.RegistrationResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // GetRegistration indicates an expected call of GetRegistration. -func (mr *MockClientMockRecorder) GetRegistration() *gomock.Call { +func (mr *MockClientMockRecorder) GetRegistration(arg0 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRegistration", reflect.TypeOf((*MockClient)(nil).GetRegistration)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRegistration", reflect.TypeOf((*MockClient)(nil).GetRegistration), arg0) } // Init mocks base method. -func (m *MockClient) Init(arg0 vfs.FS, arg1 []byte, arg2 config.Config) error { +func (m *MockClient) Init(arg0 vfs.FS, arg1 identity.Identity, arg2 config.Config) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Init", arg0, arg1, arg2) ret0, _ := ret[0].(error) diff --git a/internal/agent/client/client_test.go b/internal/agent/client/client_test.go index 08abf7d3..d1cedcdb 100644 --- a/internal/agent/client/client_test.go +++ b/internal/agent/client/client_test.go @@ -7,6 +7,7 @@ import ( . "github.com/onsi/gomega" "github.com/rancher-sandbox/cluster-api-provider-elemental/api/v1beta1" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/config" + "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/identity" "github.com/twpayne/go-vfs" "github.com/twpayne/go-vfs/vfst" ) @@ -39,41 +40,41 @@ wcHkvD3kEU33TR9VnkHUwgC9jDyDa62sef84S5MUAiAJfWf5G5PqtN+AE4XJgg2K -----END CERTIFICATE-----`, }, } - signingKey := []byte{} + var identity identity.Identity BeforeEach(func() { - client = NewClient() + client = NewClient("v0.0.0-test") fs, fsCleanup, err = vfst.NewTestFS(map[string]interface{}{}) Expect(err).ToNot(HaveOccurred()) DeferCleanup(fsCleanup) }) It("should succeed on valid config", func() { - Expect(client.Init(fs, signingKey, conf)).Should(Succeed()) + Expect(client.Init(fs, identity, conf)).Should(Succeed()) }) It("should fail on http insecure protocol", func() { httpURIConf := conf httpURIConf.Registration.URI = "http://localhost:9090/just/for/testing" - Expect(client.Init(fs, signingKey, httpURIConf)).Should(MatchError(ErrInvalidScheme)) + Expect(client.Init(fs, identity, httpURIConf)).Should(MatchError(ErrInvalidScheme)) // Allow insecure http httpURIConf.Agent.InsecureAllowHTTP = true - Expect(client.Init(fs, signingKey, httpURIConf)).Should(Succeed()) + Expect(client.Init(fs, identity, httpURIConf)).Should(Succeed()) }) It("should fail on badly formatted CACert", func() { badCACertConf := conf badCACertConf.Registration.CACert = "not a parsable cert" - Expect(client.Init(fs, signingKey, badCACertConf)).ShouldNot(Succeed()) + Expect(client.Init(fs, identity, badCACertConf)).ShouldNot(Succeed()) }) It("should fail on badly formatted URI", func() { badURIConf := conf badURIConf.Registration.URI = "not a parsable URL" - Expect(client.Init(fs, signingKey, badURIConf)).ShouldNot(Succeed()) + Expect(client.Init(fs, identity, badURIConf)).ShouldNot(Succeed()) }) It("should fail on unknown protocol", func() { unknownProtocolConf := conf unknownProtocolConf.Registration.URI = "unknown://localhost:9090/just/for/testing" - Expect(client.Init(fs, signingKey, unknownProtocolConf)).Should(MatchError(ErrInvalidScheme)) + Expect(client.Init(fs, identity, unknownProtocolConf)).Should(MatchError(ErrInvalidScheme)) // Verify behavior when http allowed unknownProtocolConf.Agent.InsecureAllowHTTP = true - Expect(client.Init(fs, signingKey, unknownProtocolConf)).Should(MatchError(ErrInvalidScheme)) + Expect(client.Init(fs, identity, unknownProtocolConf)).Should(MatchError(ErrInvalidScheme)) }) }) diff --git a/internal/agent/identity/manager.go b/internal/agent/identity/manager.go deleted file mode 100644 index 041f1723..00000000 --- a/internal/agent/identity/manager.go +++ /dev/null @@ -1,60 +0,0 @@ -package identity - -import ( - "fmt" - "os" - - "github.com/google/uuid" - "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/log" - "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/utils" - "github.com/twpayne/go-vfs" -) - -const ( - PrivateKeyFile = "private.key" -) - -type Manager interface { - LoadSigningKeyOrCreateNew() ([]byte, error) -} - -var _ Manager = (*DummyManager)(nil) - -type DummyManager struct { - workDir string - fs vfs.FS -} - -func NewDummyManager(fs vfs.FS, workDir string) Manager { - return &DummyManager{ - workDir: workDir, - fs: fs, - } -} - -func (m *DummyManager) LoadSigningKeyOrCreateNew() ([]byte, error) { - path := fmt.Sprintf("%s/%s", m.workDir, PrivateKeyFile) - log.Debugf("Loading dummy key: %s", path) - _, err := m.fs.Stat(path) - if os.IsNotExist(err) { - log.Debug("Dummy key does not exist, creating a new one") - key, err := m.generateNewKey() - if err != nil { - return nil, fmt.Errorf("generating new key: %w", err) - } - return key, nil - } - key, err := utils.ReadFile(m.fs, path) - if err != nil { - return nil, fmt.Errorf("loading '%s': %w", path, err) - } - return key, nil -} - -func (m *DummyManager) generateNewKey() ([]byte, error) { - uuid, err := uuid.NewRandom() - if err != nil { - return nil, fmt.Errorf("generating new random UUID: %w", err) - } - return []byte(uuid.String()), nil -} diff --git a/internal/agent/identity/manager_mocks.go b/internal/agent/identity/manager_mocks.go deleted file mode 100644 index 0269cffa..00000000 --- a/internal/agent/identity/manager_mocks.go +++ /dev/null @@ -1,70 +0,0 @@ -// /* -// Copyright © 2022 - 2023 SUSE LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// */ -// - -// Code generated by MockGen. DO NOT EDIT. -// Source: github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/identity (interfaces: Manager) -// -// Generated by this command: -// -// mockgen -copyright_file=hack/boilerplate.go.txt -destination=internal/agent/identity/manager_mocks.go -package=identity github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/identity Manager -// -// Package identity is a generated GoMock package. -package identity - -import ( - reflect "reflect" - - gomock "go.uber.org/mock/gomock" -) - -// MockManager is a mock of Manager interface. -type MockManager struct { - ctrl *gomock.Controller - recorder *MockManagerMockRecorder -} - -// MockManagerMockRecorder is the mock recorder for MockManager. -type MockManagerMockRecorder struct { - mock *MockManager -} - -// NewMockManager creates a new mock instance. -func NewMockManager(ctrl *gomock.Controller) *MockManager { - mock := &MockManager{ctrl: ctrl} - mock.recorder = &MockManagerMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockManager) EXPECT() *MockManagerMockRecorder { - return m.recorder -} - -// LoadSigningKeyOrCreateNew mocks base method. -func (m *MockManager) LoadSigningKeyOrCreateNew() ([]byte, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "LoadSigningKeyOrCreateNew") - ret0, _ := ret[0].([]byte) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// LoadSigningKeyOrCreateNew indicates an expected call of LoadSigningKeyOrCreateNew. -func (mr *MockManagerMockRecorder) LoadSigningKeyOrCreateNew() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadSigningKeyOrCreateNew", reflect.TypeOf((*MockManager)(nil).LoadSigningKeyOrCreateNew)) -} diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 00000000..10077ecd --- /dev/null +++ b/internal/api/auth.go @@ -0,0 +1,162 @@ +package api + +import ( + "crypto/ed25519" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/go-logr/logr" + "github.com/golang-jwt/jwt/v5" + "github.com/rancher-sandbox/cluster-api-provider-elemental/api/v1beta1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + ErrUnauthorized = errors.New("unauthorized") + ErrForbidden = errors.New("forbidden") + ErrMissingRegistrationSecret = errors.New("registration secret is missing") + ErrNoSigningKey = errors.New("registration signing key is missing") + ErrUnparsableSigningKey = errors.New("registration signing key is not in the expected format") +) + +type Authenticator interface { + ValidateHostRequest(*http.Request, http.ResponseWriter, *v1beta1.ElementalHost, *v1beta1.ElementalRegistration) error + ValidateRegistrationRequest(*http.Request, http.ResponseWriter, *v1beta1.ElementalRegistration) error +} + +func NewAuthenticator(k8sClient client.Client, logger logr.Logger) Authenticator { + return &authenticator{ + k8sClient: k8sClient, + logger: logger, + } +} + +var _ Authenticator = (*authenticator)(nil) + +type authenticator struct { + logger logr.Logger + k8sClient client.Client +} + +func (a *authenticator) ValidateHostRequest(request *http.Request, response http.ResponseWriter, host *v1beta1.ElementalHost, registration *v1beta1.ElementalRegistration) error { + // Verify token was passed correctly + authValue := request.Header.Get("Authorization") + if len(authValue) == 0 { + err := fmt.Errorf("missing 'Authorization' header: %w", ErrUnauthorized) + a.writeResponse(response, err) + return err + } + token, found := strings.CutPrefix(authValue, "Bearer ") + if !found { + err := fmt.Errorf("not a 'Bearer' token: %w", ErrUnauthorized) + a.writeResponse(response, err) + return err + } + // Validate and Verify JWT + expectedClaims := &jwt.RegisteredClaims{ + Subject: host.Name, + Audience: []string{registration.Spec.Config.Elemental.Registration.URI}, + } + _, err := jwt.ParseWithClaims(token, expectedClaims, func(parsedToken *jwt.Token) (any, error) { + signingAlg := parsedToken.Method.Alg() + switch signingAlg { + case "EdDSA": + pubKey, err := jwt.ParseEdPublicKeyFromPEM([]byte(host.Spec.PubKey)) + if err != nil { + return nil, fmt.Errorf("parsing host Public Key: %w", err) + } + return pubKey, nil + default: + return nil, fmt.Errorf("JWT is using unsupported '%s' signing algorithm: %w", signingAlg, ErrUnauthorized) + } + }) + if err != nil { + err := fmt.Errorf("validating JWT token: %w: %w", err, ErrForbidden) + a.writeResponse(response, err) + return err + } + return nil +} + +func (a *authenticator) ValidateRegistrationRequest(request *http.Request, response http.ResponseWriter, registration *v1beta1.ElementalRegistration) error { + // Verify token was passed correctly + authValue := request.Header.Get("Registration-Authorization") + if len(authValue) == 0 { + err := fmt.Errorf("missing 'Registration-Authorization' header: %w", ErrUnauthorized) + a.writeResponse(response, err) + return err + } + token, found := strings.CutPrefix(authValue, "Bearer ") + if !found { + err := fmt.Errorf("not a 'Bearer' token: %w", ErrUnauthorized) + a.writeResponse(response, err) + return err + } + + // Fetch registration secret and read the private key + registrationSecret := &corev1.Secret{} + if err := a.k8sClient.Get(request.Context(), types.NamespacedName{ + Name: registration.Name, + Namespace: registration.Namespace, + }, registrationSecret); err != nil { + err := fmt.Errorf("getting registration secret: %w", ErrMissingRegistrationSecret) + a.writeResponse(response, err) + return err + } + privKeyPem, found := registrationSecret.Data["privKey"] + if !found { + a.writeResponse(response, ErrNoSigningKey) + return ErrNoSigningKey + } + parsedKey, err := jwt.ParseEdPrivateKeyFromPEM(privKeyPem) + if err != nil { + err := fmt.Errorf("parsing ed25519 key: %w", err) + a.writeResponse(response, err) + return err + } + var privKey ed25519.PrivateKey + var ok bool + if privKey, ok = parsedKey.(ed25519.PrivateKey); !ok { + a.writeResponse(response, jwt.ErrNotEdPrivateKey) + return jwt.ErrNotEdPrivateKey + } + // Validate and Verify JWT + expectedClaims := &jwt.RegisteredClaims{ + Subject: registration.Spec.Config.Elemental.Registration.URI, + Audience: []string{registration.Spec.Config.Elemental.Registration.URI}, + } + _, err = jwt.ParseWithClaims(token, expectedClaims, func(parsedToken *jwt.Token) (any, error) { + signingAlg := parsedToken.Method.Alg() + switch signingAlg { + case "EdDSA": + return privKey.Public(), nil + default: + return nil, fmt.Errorf("JWT is using unsupported '%s' signing algorithm: %w", signingAlg, ErrUnauthorized) + } + }) + if err != nil { + err := fmt.Errorf("validating JWT token: %w: %w", err, ErrForbidden) + a.writeResponse(response, err) + return err + } + return nil +} + +func (a *authenticator) writeResponse(response http.ResponseWriter, err error) { + if errors.Is(err, ErrUnauthorized) { + response.WriteHeader(http.StatusUnauthorized) + WriteResponse(a.logger, response, fmt.Sprintf("Unauthorized: %s", err.Error())) + return + } + if errors.Is(err, ErrForbidden) { + response.WriteHeader(http.StatusForbidden) + WriteResponse(a.logger, response, fmt.Sprintf("Forbidden: %s", err.Error())) + return + } + response.WriteHeader(http.StatusInternalServerError) + WriteResponse(a.logger, response, fmt.Sprintf("Could not authenticate request: %s", err.Error())) +} diff --git a/internal/api/elementalhost_controller.go b/internal/api/elementalhost_controller.go index 4f363d52..ea9bb1ad 100644 --- a/internal/api/elementalhost_controller.go +++ b/internal/api/elementalhost_controller.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "errors" "fmt" "html" "net/http" @@ -26,12 +27,14 @@ var _ http.Handler = (*PatchElementalHostHandler)(nil) type PatchElementalHostHandler struct { logger logr.Logger k8sClient client.Client + auth Authenticator } func NewPatchElementalHostHandler(logger logr.Logger, k8sClient client.Client) *PatchElementalHostHandler { return &PatchElementalHostHandler{ logger: logger, k8sClient: k8sClient, + auth: NewAuthenticator(k8sClient, logger), } } @@ -44,6 +47,8 @@ func (h *PatchElementalHostHandler) SetupOpenAPIOperation(oc openapi.OperationCo oc.AddRespStructure(HostResponse{}, WithDecoration("Returns the patched ElementalHost", "application/json", http.StatusOK)) oc.AddRespStructure(nil, WithDecoration("If the ElementalRegistration or the ElementalHost are not found", "text/html", http.StatusNotFound)) oc.AddRespStructure(nil, WithDecoration("If the ElementalHostPatch request is badly formatted", "text/html", http.StatusBadRequest)) + oc.AddRespStructure(nil, WithDecoration("If the 'Authorization' header does not contain a Bearer token", "text/html", http.StatusUnauthorized)) + oc.AddRespStructure(nil, WithDecoration("If the 'Authorization' token is not valid", "text/html", http.StatusForbidden)) oc.AddRespStructure(nil, WithDecoration("", "text/html", http.StatusInternalServerError)) return nil @@ -88,6 +93,16 @@ func (h *PatchElementalHostHandler) ServeHTTP(response http.ResponseWriter, requ return } + // Authenticate Request + if err := h.auth.ValidateHostRequest(request, response, host, registration); err != nil { + if errors.Is(err, ErrUnauthorized) || errors.Is(err, ErrForbidden) { + logger.Info("Host request denied", "reason", err.Error()) + return + } + logger.Error(err, "Could not authenticate request") + return + } + // Unmarshal PATCH request body hostPatchRequest := &HostPatchRequest{} if err := json.NewDecoder(request.Body).Decode(hostPatchRequest); err != nil { @@ -159,12 +174,14 @@ var _ http.Handler = (*PostElementalHostHandler)(nil) type PostElementalHostHandler struct { logger logr.Logger k8sClient client.Client + auth Authenticator } func NewPostElementalHostHandler(logger logr.Logger, k8sClient client.Client) *PostElementalHostHandler { return &PostElementalHostHandler{ logger: logger, k8sClient: k8sClient, + auth: NewAuthenticator(k8sClient, logger), } } @@ -178,6 +195,8 @@ func (h *PostElementalHostHandler) SetupOpenAPIOperation(oc openapi.OperationCon oc.AddRespStructure(nil, WithDecoration("ElementalHost with same name within this ElementalRegistration already exists", "text/html", http.StatusConflict)) oc.AddRespStructure(nil, WithDecoration("ElementalRegistration not found", "text/html", http.StatusNotFound)) oc.AddRespStructure(nil, WithDecoration("ElementalHost request is badly formatted", "text/html", http.StatusBadRequest)) + oc.AddRespStructure(nil, WithDecoration("If the 'Authorization' or 'Registration-Authorization' headers do not contain Bearer tokens", "text/html", http.StatusUnauthorized)) + oc.AddRespStructure(nil, WithDecoration("If the 'Authorization' or 'Registration-Authorization' tokens are not valid", "text/html", http.StatusForbidden)) oc.AddRespStructure(nil, WithDecoration("", "text/html", http.StatusInternalServerError)) return nil @@ -243,6 +262,25 @@ func (h *PostElementalHostHandler) ServeHTTP(response http.ResponseWriter, reque logger = logger.WithValues(log.KeyElementalHost, newHost.Name) + // Authenticate Request + if err := h.auth.ValidateHostRequest(request, response, &newHost, registration); err != nil { + if errors.Is(err, ErrUnauthorized) || errors.Is(err, ErrForbidden) { + logger.Info("Host request denied", "reason", err.Error()) + return + } + logger.Error(err, "Could not authenticate host request") + return + } + // Authenticate Registration token + if err := h.auth.ValidateRegistrationRequest(request, response, registration); err != nil { + if errors.Is(err, ErrUnauthorized) || errors.Is(err, ErrForbidden) { + logger.Info("Registration request denied", "reason", err.Error()) + return + } + logger.Error(err, "Could not authenticate registration request") + return + } + // Create new Host if err := h.k8sClient.Create(request.Context(), &newHost); err != nil { if k8sapierrors.IsAlreadyExists(err) { @@ -269,12 +307,14 @@ var _ http.Handler = (*DeleteElementalHostHandler)(nil) type DeleteElementalHostHandler struct { logger logr.Logger k8sClient client.Client + auth Authenticator } func NewDeleteElementalHostHandler(logger logr.Logger, k8sClient client.Client) *DeleteElementalHostHandler { return &DeleteElementalHostHandler{ logger: logger, k8sClient: k8sClient, + auth: NewAuthenticator(k8sClient, logger), } } @@ -286,6 +326,8 @@ func (h *DeleteElementalHostHandler) SetupOpenAPIOperation(oc openapi.OperationC oc.AddRespStructure(nil, WithDecoration("ElementalHost correctly deleted.", "", http.StatusAccepted)) oc.AddRespStructure(nil, WithDecoration("ElementalHost not found", "text/html", http.StatusNotFound)) + oc.AddRespStructure(nil, WithDecoration("If the 'Authorization' header does not contain a Bearer token", "text/html", http.StatusUnauthorized)) + oc.AddRespStructure(nil, WithDecoration("If the 'Authorization' token is not valid", "text/html", http.StatusForbidden)) oc.AddRespStructure(nil, WithDecoration("", "text/html", http.StatusInternalServerError)) return nil @@ -302,7 +344,7 @@ func (h *DeleteElementalHostHandler) ServeHTTP(response http.ResponseWriter, req WithValues(log.KeyElementalHost, hostName) logger.Info("Deleting ElementalHost") - // Fetch ElementalRegistration (sanity check) + // Fetch ElementalRegistration registration := &infrastructurev1beta1.ElementalRegistration{} if err := h.k8sClient.Get(request.Context(), k8sclient.ObjectKey{Namespace: namespace, Name: registrationName}, registration); err != nil { if k8sapierrors.IsNotFound(err) { @@ -332,6 +374,16 @@ func (h *DeleteElementalHostHandler) ServeHTTP(response http.ResponseWriter, req return } + // Authenticate Request + if err := h.auth.ValidateHostRequest(request, response, host, registration); err != nil { + if errors.Is(err, ErrUnauthorized) || errors.Is(err, ErrForbidden) { + logger.Info("Host request denied", "reason", err.Error()) + return + } + logger.Error(err, "Could not authenticate host request") + return + } + if !host.GetDeletionTimestamp().IsZero() { logger.Info("ElementalHost is already scheduled for deletion") response.WriteHeader(http.StatusAccepted) @@ -355,12 +407,14 @@ var _ http.Handler = (*GetElementalHostBootstrapHandler)(nil) type GetElementalHostBootstrapHandler struct { logger logr.Logger k8sClient client.Client + auth Authenticator } func NewGetElementalHostBootstrapHandler(logger logr.Logger, k8sClient client.Client) *GetElementalHostBootstrapHandler { return &GetElementalHostBootstrapHandler{ logger: logger, k8sClient: k8sClient, + auth: NewAuthenticator(k8sClient, logger), } } @@ -372,6 +426,8 @@ func (h *GetElementalHostBootstrapHandler) SetupOpenAPIOperation(oc openapi.Oper oc.AddRespStructure(BootstrapResponse{}, WithDecoration("Returns the ElementalHost bootstrap instructions", "application/json", http.StatusOK)) oc.AddRespStructure(nil, WithDecoration("If the ElementalRegistration or ElementalHost are not found, or if there are no bootstrap instructions yet", "text/html", http.StatusNotFound)) + oc.AddRespStructure(nil, WithDecoration("If the 'Authorization' header does not contain a Bearer token", "text/html", http.StatusUnauthorized)) + oc.AddRespStructure(nil, WithDecoration("If the 'Authorization' token is not valid", "text/html", http.StatusForbidden)) oc.AddRespStructure(nil, WithDecoration("", "text/html", http.StatusInternalServerError)) return nil @@ -416,6 +472,16 @@ func (h *GetElementalHostBootstrapHandler) ServeHTTP(response http.ResponseWrite return } + // Authenticate Request + if err := h.auth.ValidateHostRequest(request, response, host, registration); err != nil { + if errors.Is(err, ErrUnauthorized) || errors.Is(err, ErrForbidden) { + logger.Info("Host request denied", "reason", err.Error()) + return + } + logger.Error(err, "Could not authenticate host request") + return + } + // Check if there is any Bootstrap secret associated to this host if host.Spec.BootstrapSecret == nil { response.WriteHeader(http.StatusNotFound) diff --git a/internal/api/elementalregistration_controller.go b/internal/api/elementalregistration_controller.go index 933fabf9..d74d9b54 100644 --- a/internal/api/elementalregistration_controller.go +++ b/internal/api/elementalregistration_controller.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "errors" "fmt" "html" "net/http" @@ -22,12 +23,14 @@ var _ http.Handler = (*GetElementalRegistrationHandler)(nil) type GetElementalRegistrationHandler struct { logger logr.Logger k8sClient client.Client + auth Authenticator } func NewGetElementalRegistrationHandler(logger logr.Logger, k8sClient client.Client) *GetElementalRegistrationHandler { return &GetElementalRegistrationHandler{ logger: logger, k8sClient: k8sClient, + auth: NewAuthenticator(k8sClient, logger), } } @@ -39,6 +42,8 @@ func (h *GetElementalRegistrationHandler) SetupOpenAPIOperation(oc openapi.Opera oc.AddRespStructure(RegistrationResponse{}, WithDecoration("Returns the ElementalRegistration", "application/json", http.StatusOK)) oc.AddRespStructure(nil, WithDecoration("If the ElementalRegistration is not found", "text/html", http.StatusNotFound)) + oc.AddRespStructure(nil, WithDecoration("If the 'Registration-Authorization' header does not contain a Bearer token", "text/html", http.StatusUnauthorized)) + oc.AddRespStructure(nil, WithDecoration("If the 'Registration-Authorization' token is not valid", "text/html", http.StatusForbidden)) oc.AddRespStructure(nil, WithDecoration("", "text/html", http.StatusInternalServerError)) return nil @@ -67,6 +72,16 @@ func (h *GetElementalRegistrationHandler) ServeHTTP(response http.ResponseWriter return } + // Authenticate Registration token + if err := h.auth.ValidateRegistrationRequest(request, response, registration); err != nil { + if errors.Is(err, ErrUnauthorized) || errors.Is(err, ErrForbidden) { + logger.Info("Registration request denied", "reason", err.Error()) + return + } + logger.Error(err, "Could not authenticate registration request") + return + } + registrationResponse := RegistrationResponse{} registrationResponse.fromElementalRegistration(*registration) diff --git a/internal/api/types.go b/internal/api/types.go index 43f3dc5b..3075848b 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -11,12 +11,16 @@ import ( ) type HostCreateRequest struct { + Auth string `header:"Authorization"` + RegAuth string `header:"Registration-Authorization"` + Namespace string `path:"namespace"` RegistrationName string `path:"registrationName"` Name string `json:"name,omitempty"` Annotations map[string]string `json:"annotations,omitempty"` Labels map[string]string `json:"labels,omitempty"` + PubKey string `json:"pubKey,omitempty"` } func (h *HostCreateRequest) toElementalHost(namespace string) infrastructurev1beta1.ElementalHost { @@ -27,16 +31,23 @@ func (h *HostCreateRequest) toElementalHost(namespace string) infrastructurev1be Labels: h.Labels, Annotations: h.Annotations, }, + Spec: infrastructurev1beta1.ElementalHostSpec{ + PubKey: h.PubKey, + }, } } type HostDeleteRequest struct { + Auth string `header:"Authorization"` + Namespace string `path:"namespace"` RegistrationName string `path:"registrationName"` HostName string `path:"hostName"` } type HostPatchRequest struct { + Auth string `header:"Authorization"` + Namespace string `path:"namespace"` RegistrationName string `path:"registrationName"` HostName string `path:"hostName"` @@ -100,6 +111,8 @@ func (h *HostResponse) fromElementalHost(elementalHost infrastructurev1beta1.Ele } type RegistrationGetRequest struct { + RegAuth string `header:"Registration-Authorization"` + Namespace string `path:"namespace"` RegistrationName string `path:"registrationName"` } @@ -123,6 +136,8 @@ func (r *RegistrationResponse) fromElementalRegistration(elementalRegistration i } type BootstrapGetRequest struct { + Auth string `header:"Authorization"` + Namespace string `path:"namespace"` RegistrationName string `path:"registrationName"` HostName string `path:"hostName"` diff --git a/internal/controller/elementalhost_controller_test.go b/internal/controller/elementalhost_controller_test.go index 4ef7707e..2fe41a9f 100644 --- a/internal/controller/elementalhost_controller_test.go +++ b/internal/controller/elementalhost_controller_test.go @@ -11,6 +11,7 @@ import ( "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/client" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/config" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/api" + "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/identity" "github.com/twpayne/go-vfs" "github.com/twpayne/go-vfs/vfst" corev1 "k8s.io/api/core/v1" @@ -142,26 +143,42 @@ runcmd: var err error var fsCleanup func() var eClient client.Client + var registrationToken string BeforeAll(func() { fs, fsCleanup, err = vfst.NewTestFS(map[string]interface{}{}) Expect(err).ToNot(HaveOccurred()) DeferCleanup(fsCleanup) Expect(k8sClient.Create(ctx, &namespace)).Should(Succeed()) Expect(k8sClient.Create(ctx, ®istration)).Should(Succeed()) + updatedRegistration := &v1beta1.ElementalRegistration{} + Eventually(func() bool { + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: registration.Name, + Namespace: registration.Namespace}, + updatedRegistration)).Should(Succeed()) + return len(updatedRegistration.Spec.Config.Elemental.Registration.Token) != 0 + }).WithTimeout(time.Minute).Should(BeTrue(), "missing registration token") + registrationToken = updatedRegistration.Spec.Config.Elemental.Registration.Token Expect(k8sClient.Create(ctx, &bootstrapSecret)).Should(Succeed()) - eClient = client.NewClient() + eClient = client.NewClient("v0.0.0-test") conf := config.Config{ Registration: registration.Spec.Config.Elemental.Registration, Agent: registration.Spec.Config.Elemental.Agent, } - Expect(eClient.Init(fs, []byte{}, conf)).Should(Succeed()) + idManager := identity.NewManager(fs, registration.Spec.Config.Elemental.Agent.WorkDir) + id, err := idManager.LoadSigningKeyOrCreateNew() + Expect(err).ToNot(HaveOccurred()) + pubKey, err := id.MarshalPublic() + Expect(err).ToNot(HaveOccurred()) + request.PubKey = string(pubKey) + Expect(eClient.Init(fs, id, conf)).Should(Succeed()) }) AfterAll(func() { Expect(k8sClient.Delete(ctx, &namespace)).Should(Succeed()) }) It("should create new host", func() { // Create the new host - Expect(eClient.CreateHost(request)).Should(Succeed()) + Expect(eClient.CreateHost(request, registrationToken)).Should(Succeed()) // Issue an empty patch to get a host response response, err := eClient.PatchHost(api.HostPatchRequest{}, request.Name) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/controller/elementalregistration_controller.go b/internal/controller/elementalregistration_controller.go index d62def88..23582ad2 100644 --- a/internal/controller/elementalregistration_controller.go +++ b/internal/controller/elementalregistration_controller.go @@ -21,20 +21,30 @@ import ( "errors" "fmt" "net/url" + "time" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" "sigs.k8s.io/cluster-api/util/patch" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" + "github.com/golang-jwt/jwt/v5" infrastructurev1beta1 "github.com/rancher-sandbox/cluster-api-provider-elemental/api/v1beta1" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/api" + "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/identity" ilog "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/log" ) -var ErrAPIEndpointNil = errors.New("API endpoint is nil, the controller was not initialized correctly") +var ( + ErrAPIEndpointNil = errors.New("API endpoint is nil, the controller was not initialized correctly") + ErrNoPrivateKey = errors.New("could not find 'privKey' value in registration secret") +) // ElementalRegistrationReconciler reconciles a ElementalRegistration object. type ElementalRegistrationReconciler struct { @@ -91,9 +101,29 @@ func (r *ElementalRegistrationReconciler) Reconcile(ctx context.Context, req ctr // Only set the URI if not set before or manually by the end user. if len(registration.Spec.Config.Elemental.Registration.URI) == 0 { + logger.Info("Setting Registration URI") if err := r.setURI(registration); err != nil { return ctrl.Result{}, fmt.Errorf("updating registration URI: %w", err) } + return ctrl.Result{RequeueAfter: time.Second}, nil + } + + // Generate new token signing key if secret does not exists yet. + if registration.Spec.PrivateKeyRef == nil { + logger.Info("Generating new signing key") + if err := r.generateNewIdentity(ctx, registration); err != nil { + return ctrl.Result{}, fmt.Errorf("generating new identity: %w", err) + } + return ctrl.Result{RequeueAfter: time.Second}, nil + } + + // Generate new token if does not exist yet. + if len(registration.Spec.Config.Elemental.Registration.Token) == 0 { + logger.Info("Generating new registration token") + if err := r.setNewToken(ctx, registration); err != nil { + return ctrl.Result{}, fmt.Errorf("refreshing registration token: %w", err) + } + return ctrl.Result{RequeueAfter: time.Second}, nil } return ctrl.Result{}, nil @@ -111,3 +141,82 @@ func (r *ElementalRegistrationReconciler) setURI(registration *infrastructurev1b registration.Name) return nil } + +func (r *ElementalRegistrationReconciler) generateNewIdentity(ctx context.Context, registration *infrastructurev1beta1.ElementalRegistration) error { + id, err := identity.NewED25519Identity() + if err != nil { + return fmt.Errorf("generating new ed25519 identity: %w", err) + } + privKeyPem, err := id.Marshal() + if err != nil { + return fmt.Errorf("marshaling PEM key: %w", err) + } + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: registration.Name, + Namespace: registration.Namespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: registration.APIVersion, + Kind: registration.Kind, + Name: registration.Name, + UID: registration.UID, + Controller: ptr.To(true), + }, + }, + }, + StringData: map[string]string{ + "privKey": string(privKeyPem), + }, + } + // If the secret already exists, assume it was created by this controller already or directly by the user. + if err := r.Client.Create(ctx, secret); err != nil && !apierrors.IsAlreadyExists(err) { + return fmt.Errorf("creating new secret: %w", err) + } + registration.Spec.PrivateKeyRef = &corev1.ObjectReference{ + Kind: secret.Kind, + Name: secret.Name, + Namespace: secret.Namespace, + UID: secret.UID, + } + return nil +} + +func (r *ElementalRegistrationReconciler) setNewToken(ctx context.Context, registration *infrastructurev1beta1.ElementalRegistration) error { + secret := &corev1.Secret{} + if err := r.Client.Get(ctx, types.NamespacedName{ + Name: registration.Name, + Namespace: registration.Namespace, + }, secret); err != nil { + return fmt.Errorf("fetching signing key secret: %w", err) + } + + privKeyPem, found := secret.Data["privKey"] + if !found { + return ErrNoPrivateKey + } + + id := identity.Ed25519Identity{} + if err := id.Unmarshal([]byte(privKeyPem)); err != nil { + return fmt.Errorf("parsing private key PEM: %w", err) + } + + now := time.Now() + claims := jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + Issuer: "ElementalRegistrationReconciler", + Subject: registration.Spec.Config.Elemental.Registration.URI, + Audience: []string{registration.Spec.Config.Elemental.Registration.URI}, + } + if registration.Spec.Config.Elemental.Registration.TokenDuration != 0 { + claims.ExpiresAt = jwt.NewNumericDate(now.Add(registration.Spec.Config.Elemental.Registration.TokenDuration)) + } + token, err := id.Sign(claims) + if err != nil { + return fmt.Errorf("signing JWT claims: %w", err) + } + + registration.Spec.Config.Elemental.Registration.Token = token + return nil +} diff --git a/internal/controller/elementalregistration_controller_test.go b/internal/controller/elementalregistration_controller_test.go index f6bd1fc6..09e3d54c 100644 --- a/internal/controller/elementalregistration_controller_test.go +++ b/internal/controller/elementalregistration_controller_test.go @@ -5,12 +5,14 @@ import ( "fmt" "time" + "github.com/golang-jwt/jwt/v5" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/rancher-sandbox/cluster-api-provider-elemental/api/v1beta1" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/client" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/config" "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/api" + "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/identity" "github.com/twpayne/go-vfs" "github.com/twpayne/go-vfs/vfst" corev1 "k8s.io/api/core/v1" @@ -87,6 +89,107 @@ var _ = Describe("ElementalRegistration controller", Label("controller", "elemen Expect(updatedRegistration.Spec.Config.Elemental.Registration.URI). To(Equal(registrationWithURI.Spec.Config.Elemental.Registration.URI)) }) + It("should create non-expirable registration token by default", func() { + // Initial Registration has empty token. + // This is the normal state. + updatedRegistration := &v1beta1.ElementalRegistration{} + // Wait for the controller to create a new token. + Eventually(func() bool { + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: registration.Name, + Namespace: registration.Namespace}, + updatedRegistration)).Should(Succeed()) + return len(updatedRegistration.Spec.Config.Elemental.Registration.Token) != 0 + }).WithTimeout(time.Minute).Should(BeTrue(), "registration token should be created") + + token := updatedRegistration.Spec.Config.Elemental.Registration.Token + expectedClaims := &jwt.RegisteredClaims{ + Subject: registration.Spec.Config.Elemental.Registration.URI, + Audience: []string{registration.Spec.Config.Elemental.Registration.URI}, + } + parser := jwt.Parser{} + parsedToken, _, err := parser.ParseUnverified(token, expectedClaims) + Expect(err).ToNot(HaveOccurred()) + expirationTime, err := parsedToken.Claims.GetExpirationTime() + Expect(err).ToNot(HaveOccurred()) + Expect(expirationTime).Should(BeNil(), "registration token should not expire") + }) + It("should not override already created token", func() { + registrationWithToken := registration + registrationWithToken.Name = registration.Name + "-with-token" + registrationWithToken.Spec.Config.Elemental.Registration.Token = "just a test token" + Expect(k8sClient.Create(ctx, ®istrationWithToken)).Should(Succeed()) + // Let's trigger a patch just to ensure the controller will be triggered. + patchHelper, err := patch.NewHelper(®istrationWithToken, k8sClient) + Expect(err).ToNot(HaveOccurred()) + registrationWithToken.Spec.Config.Elemental.Registration.CACert = "just to trigger the controller" + Expect(patchHelper.Patch(ctx, ®istrationWithToken)).Should(Succeed()) + updatedRegistration := &v1beta1.ElementalRegistration{} + Eventually(func() string { + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: registrationWithToken.Name, + Namespace: registrationWithToken.Namespace}, + updatedRegistration)).Should(Succeed()) + return updatedRegistration.Spec.Config.Elemental.Registration.CACert + }).WithTimeout(time.Minute).Should(Equal(registrationWithToken.Spec.Config.Elemental.Registration.CACert)) + // Verify the initial token did not change + Expect(updatedRegistration.Spec.Config.Elemental.Registration.Token). + To(Equal(registrationWithToken.Spec.Config.Elemental.Registration.Token)) + }) + It("should generate new token after token is deleted", func() { + // Initial Registration has empty token. + // This is the normal state. + updatedRegistration := &v1beta1.ElementalRegistration{} + // Wait for the controller to create a new token. + Eventually(func() bool { + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: registration.Name, + Namespace: registration.Namespace}, + updatedRegistration)).Should(Succeed()) + return len(updatedRegistration.Spec.Config.Elemental.Registration.Token) != 0 + }).WithTimeout(time.Minute).Should(BeTrue(), "registration token should be created") + // Delete the token + patchHelper, err := patch.NewHelper(updatedRegistration, k8sClient) + Expect(err).ToNot(HaveOccurred()) + updatedRegistration.Spec.Config.Elemental.Registration.Token = "" + Expect(patchHelper.Patch(ctx, updatedRegistration)).Should(Succeed()) + // Ensure token is re-created + Eventually(func() bool { + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: registration.Name, + Namespace: registration.Namespace}, + updatedRegistration)).Should(Succeed()) + return len(updatedRegistration.Spec.Config.Elemental.Registration.Token) != 0 + }).WithTimeout(time.Minute).Should(BeTrue(), "registration token should be created") + }) + It("should generate an already expired token if duration is negative", func() { + registrationWithExpiredToken := registration + registrationWithExpiredToken.Name = registration.Name + "-with-expired-token" + registrationWithExpiredToken.Spec.Config.Elemental.Registration.TokenDuration = -1 + Expect(k8sClient.Create(ctx, ®istrationWithExpiredToken)).Should(Succeed()) + updatedRegistration := &v1beta1.ElementalRegistration{} + // Wait for the controller to create a new token. + Eventually(func() bool { + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: registrationWithExpiredToken.Name, + Namespace: registrationWithExpiredToken.Namespace}, + updatedRegistration)).Should(Succeed()) + return len(updatedRegistration.Spec.Config.Elemental.Registration.Token) != 0 + }).WithTimeout(time.Minute).Should(BeTrue(), "registration token should be created") + + token := updatedRegistration.Spec.Config.Elemental.Registration.Token + expectedClaims := &jwt.RegisteredClaims{ + Subject: registration.Spec.Config.Elemental.Registration.URI, + Audience: []string{registration.Spec.Config.Elemental.Registration.URI}, + } + parser := jwt.Parser{} + parsedToken, _, err := parser.ParseUnverified(token, expectedClaims) + Expect(err).ToNot(HaveOccurred()) + expirationTime, err := parsedToken.Claims.GetExpirationTime() + Expect(err).ToNot(HaveOccurred()) + Expect(expirationTime).ShouldNot(BeNil(), "epiration time should be set") + Expect(expirationTime.Before(time.Now())).Should(BeTrue(), "registration token should be expired") + }) }) var _ = Describe("Elemental API Registration controller", Label("api", "elemental-registration"), Ordered, func() { @@ -131,15 +234,32 @@ wcHkvD3kEU33TR9VnkHUwgC9jDyDa62sef84S5MUAiAJfWf5G5PqtN+AE4XJgg2K var fs vfs.FS var err error var fsCleanup func() + var eClient client.Client + var id identity.Identity + var registrationToken string BeforeAll(func() { Expect(k8sClient.Create(ctx, &namespace)).Should(Succeed()) }) BeforeEach(func() { registrationToCreate := registration Expect(k8sClient.Create(ctx, ®istrationToCreate)).Should(Succeed()) + updatedRegistration := &v1beta1.ElementalRegistration{} + Eventually(func() bool { + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: registration.Name, + Namespace: registration.Namespace}, + updatedRegistration)).Should(Succeed()) + return len(updatedRegistration.Spec.Config.Elemental.Registration.Token) != 0 + }).WithTimeout(time.Minute).Should(BeTrue(), "missing registration token") + registrationToken = updatedRegistration.Spec.Config.Elemental.Registration.Token fs, fsCleanup, err = vfst.NewTestFS(map[string]interface{}{}) Expect(err).ToNot(HaveOccurred()) DeferCleanup(fsCleanup) + idManager := identity.NewManager(fs, registration.Spec.Config.Elemental.Agent.WorkDir) + id, err = idManager.LoadSigningKeyOrCreateNew() + Expect(err).ToNot(HaveOccurred()) + eClient = client.NewClient("v0.0.0-test") + Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { Expect(k8sClient.Delete(ctx, ®istration)).Should(Succeed()) @@ -148,7 +268,6 @@ wcHkvD3kEU33TR9VnkHUwgC9jDyDa62sef84S5MUAiAJfWf5G5PqtN+AE4XJgg2K Expect(k8sClient.Delete(ctx, &namespace)).Should(Succeed()) }) It("should return expected Registration Response", func() { - client := client.NewClient() conf := config.Config{ Registration: registration.Spec.Config.Elemental.Registration, Agent: registration.Spec.Config.Elemental.Agent, @@ -159,6 +278,7 @@ wcHkvD3kEU33TR9VnkHUwgC9jDyDa62sef84S5MUAiAJfWf5G5PqtN+AE4XJgg2K Registration: v1beta1.Registration{ URI: "http://localhost:9191/elemental/v1/namespaces/registration-test-client/registrations/test-client", CACert: "-----BEGIN CERTIFICATE-----\nMIIBvDCCAWOgAwIBAgIBADAKBggqhkjOPQQDAjBGMRwwGgYDVQQKExNkeW5hbWlj\nbGlzdGVuZXItb3JnMSYwJAYDVQQDDB1keW5hbWljbGlzdGVuZXItY2FAMTY5NzEy\nNjgwNTAeFw0yMzEwMTIxNjA2NDVaFw0zMzEwMDkxNjA2NDVaMEYxHDAaBgNVBAoT\nE2R5bmFtaWNsaXN0ZW5lci1vcmcxJjAkBgNVBAMMHWR5bmFtaWNsaXN0ZW5lci1j\nYUAxNjk3MTI2ODA1MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE9KvZXqQ7+hN/\n4T0LVsFogfENa7UeSI3egvhg54qA6kI4ROQj0sObkbuBbepgGEcaOw8eJW0+M4o3\n+SnprKYPkqNCMEAwDgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB/wQFMAMBAf8wHQYD\nVR0OBBYEFD8W3gE6pK1EjnBM/kPaQF3Uqkc1MAoGCCqGSM49BAMCA0cAMEQCIDxz\nwcHkvD3kEU33TR9VnkHUwgC9jDyDa62sef84S5MUAiAJfWf5G5PqtN+AE4XJgg2K\n+ETPIs22tcmXyYOG0WY7KQ==\n-----END CERTIFICATE-----", + Token: registrationToken, }, Agent: v1beta1.Agent{ WorkDir: "/var/lib/elemental/agent", @@ -170,29 +290,28 @@ wcHkvD3kEU33TR9VnkHUwgC9jDyDa62sef84S5MUAiAJfWf5G5PqtN+AE4XJgg2K }, }, } - Expect(client.Init(fs, []byte{}, conf)).Should(Succeed()) + Expect(eClient.Init(fs, id, conf)).Should(Succeed()) // Test API client by fetching the Registration - registrationResponse, err := client.GetRegistration() + registrationResponse, err := eClient.GetRegistration(registrationToken) Expect(err).ToNot(HaveOccurred()) Expect(*registrationResponse).To(Equal(expected)) }) It("should return error if namespace or registration not found", func() { - client := client.NewClient() wrongNamespaceURI := fmt.Sprintf("%s%s%s/namespaces/%s/registrations/%s", serverURL, api.Prefix, api.PrefixV1, "does-not-exist", registration.Name) conf := config.Config{ Registration: v1beta1.Registration{URI: wrongNamespaceURI}, Agent: registration.Spec.Config.Elemental.Agent, } - Expect(client.Init(fs, []byte{}, conf)).Should(Succeed()) + Expect(eClient.Init(fs, id, conf)).Should(Succeed()) // Expect err on wrong namespace - _, err := client.GetRegistration() + _, err := eClient.GetRegistration(registrationToken) Expect(err).To(HaveOccurred()) wrongRegistrationURI := fmt.Sprintf("%s%s%s/namespaces/%s/registrations/%s", serverURL, api.Prefix, api.PrefixV1, namespace.Name, "does-not-exist") conf.Registration.URI = wrongRegistrationURI - Expect(client.Init(fs, []byte{}, conf)).Should(Succeed()) + Expect(eClient.Init(fs, id, conf)).Should(Succeed()) // Expect err on wrong registration name - _, err = client.GetRegistration() + _, err = eClient.GetRegistration(registrationToken) Expect(err).To(HaveOccurred()) }) }) diff --git a/internal/identity/ed25519.go b/internal/identity/ed25519.go new file mode 100644 index 00000000..5b59673d --- /dev/null +++ b/internal/identity/ed25519.go @@ -0,0 +1,77 @@ +package identity + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + + "github.com/golang-jwt/jwt/v5" +) + +var ( + ErrPEMDecoding = errors.New("no PEM data found") +) + +var _ Identity = (*Ed25519Identity)(nil) + +type Ed25519Identity struct { + privateKey ed25519.PrivateKey +} + +func NewED25519Identity() (Identity, error) { + _, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating new key: %w", err) + } + return &Ed25519Identity{privateKey: privKey}, nil +} + +func (i *Ed25519Identity) MarshalPublic() ([]byte, error) { + x509key, err := x509.MarshalPKIXPublicKey(i.privateKey.Public()) + if err != nil { + return nil, fmt.Errorf("marshalling public key: %w", err) + } + keyPem := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: x509key, + }) + return keyPem, nil +} + +func (i *Ed25519Identity) Sign(claims jwt.Claims) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + signed, err := token.SignedString(i.privateKey) + if err != nil { + return "", fmt.Errorf("signing token: %w", err) + } + return signed, nil +} + +func (i *Ed25519Identity) Marshal() ([]byte, error) { + x509Key, err := x509.MarshalPKCS8PrivateKey(i.privateKey) + if err != nil { + return nil, fmt.Errorf("marshalling key: %w", err) + } + keyPem := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: x509Key}, + ) + return keyPem, nil +} + +func (i *Ed25519Identity) Unmarshal(key []byte) error { + parsedKey, err := jwt.ParseEdPrivateKeyFromPEM(key) + if err != nil { + return fmt.Errorf("parsing Ed25519 private key: %w", err) + } + var privKey ed25519.PrivateKey + var ok bool + if privKey, ok = parsedKey.(ed25519.PrivateKey); !ok { + return jwt.ErrNotEdPrivateKey + } + i.privateKey = privKey + return nil +} diff --git a/internal/identity/identity.go b/internal/identity/identity.go new file mode 100644 index 00000000..e8a1bcdb --- /dev/null +++ b/internal/identity/identity.go @@ -0,0 +1,12 @@ +package identity + +import ( + "github.com/golang-jwt/jwt/v5" +) + +type Identity interface { + MarshalPublic() ([]byte, error) + Sign(claims jwt.Claims) (string, error) + Marshal() ([]byte, error) + Unmarshal([]byte) error +} diff --git a/internal/identity/identity_mocks.go b/internal/identity/identity_mocks.go new file mode 100644 index 00000000..b8bd2c84 --- /dev/null +++ b/internal/identity/identity_mocks.go @@ -0,0 +1,153 @@ +// /* +// Copyright © 2022 - 2023 SUSE LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// */ +// + +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/rancher-sandbox/cluster-api-provider-elemental/internal/identity (interfaces: Manager,Identity) +// +// Generated by this command: +// +// mockgen -copyright_file=hack/boilerplate.go.txt -destination=internal/identity/identity_mocks.go -package=identity github.com/rancher-sandbox/cluster-api-provider-elemental/internal/identity Manager,Identity +// +// Package identity is a generated GoMock package. +package identity + +import ( + reflect "reflect" + + jwt "github.com/golang-jwt/jwt/v5" + gomock "go.uber.org/mock/gomock" +) + +// MockManager is a mock of Manager interface. +type MockManager struct { + ctrl *gomock.Controller + recorder *MockManagerMockRecorder +} + +// MockManagerMockRecorder is the mock recorder for MockManager. +type MockManagerMockRecorder struct { + mock *MockManager +} + +// NewMockManager creates a new mock instance. +func NewMockManager(ctrl *gomock.Controller) *MockManager { + mock := &MockManager{ctrl: ctrl} + mock.recorder = &MockManagerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManager) EXPECT() *MockManagerMockRecorder { + return m.recorder +} + +// LoadSigningKeyOrCreateNew mocks base method. +func (m *MockManager) LoadSigningKeyOrCreateNew() (Identity, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadSigningKeyOrCreateNew") + ret0, _ := ret[0].(Identity) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadSigningKeyOrCreateNew indicates an expected call of LoadSigningKeyOrCreateNew. +func (mr *MockManagerMockRecorder) LoadSigningKeyOrCreateNew() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadSigningKeyOrCreateNew", reflect.TypeOf((*MockManager)(nil).LoadSigningKeyOrCreateNew)) +} + +// MockIdentity is a mock of Identity interface. +type MockIdentity struct { + ctrl *gomock.Controller + recorder *MockIdentityMockRecorder +} + +// MockIdentityMockRecorder is the mock recorder for MockIdentity. +type MockIdentityMockRecorder struct { + mock *MockIdentity +} + +// NewMockIdentity creates a new mock instance. +func NewMockIdentity(ctrl *gomock.Controller) *MockIdentity { + mock := &MockIdentity{ctrl: ctrl} + mock.recorder = &MockIdentityMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIdentity) EXPECT() *MockIdentityMockRecorder { + return m.recorder +} + +// Marshal mocks base method. +func (m *MockIdentity) Marshal() ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Marshal") + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Marshal indicates an expected call of Marshal. +func (mr *MockIdentityMockRecorder) Marshal() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Marshal", reflect.TypeOf((*MockIdentity)(nil).Marshal)) +} + +// MarshalPublic mocks base method. +func (m *MockIdentity) MarshalPublic() ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarshalPublic") + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MarshalPublic indicates an expected call of MarshalPublic. +func (mr *MockIdentityMockRecorder) MarshalPublic() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarshalPublic", reflect.TypeOf((*MockIdentity)(nil).MarshalPublic)) +} + +// Sign mocks base method. +func (m *MockIdentity) Sign(arg0 jwt.Claims) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Sign", arg0) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Sign indicates an expected call of Sign. +func (mr *MockIdentityMockRecorder) Sign(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Sign", reflect.TypeOf((*MockIdentity)(nil).Sign), arg0) +} + +// Unmarshal mocks base method. +func (m *MockIdentity) Unmarshal(arg0 []byte) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Unmarshal", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Unmarshal indicates an expected call of Unmarshal. +func (mr *MockIdentityMockRecorder) Unmarshal(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unmarshal", reflect.TypeOf((*MockIdentity)(nil).Unmarshal), arg0) +} diff --git a/internal/identity/manager.go b/internal/identity/manager.go new file mode 100644 index 00000000..a3c9a801 --- /dev/null +++ b/internal/identity/manager.go @@ -0,0 +1,59 @@ +package identity + +import ( + "fmt" + "os" + + "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/log" + "github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/utils" + "github.com/twpayne/go-vfs" +) + +const ( + PrivateKeyFile = "private.key" +) + +type Manager interface { + LoadSigningKeyOrCreateNew() (Identity, error) +} + +var _ Manager = (*manager)(nil) + +type manager struct { + workDir string + fs vfs.FS +} + +func NewManager(fs vfs.FS, workDir string) Manager { + return &manager{ + workDir: workDir, + fs: fs, + } +} + +func (m *manager) LoadSigningKeyOrCreateNew() (Identity, error) { + identity := &Ed25519Identity{} + + path := fmt.Sprintf("%s/%s", m.workDir, PrivateKeyFile) + log.Debugf("Loading identity from file: %s", path) + _, err := m.fs.Stat(path) + if os.IsNotExist(err) { + log.Debug("Identity file does not exist, creating a new one") + identity, err := NewED25519Identity() + if err != nil { + return nil, fmt.Errorf("creating new Ed25519 identity: %w", err) + } + return identity, nil + } + if err != nil { + return nil, fmt.Errorf("getting '%s' file info: %w", path, err) + } + key, err := utils.ReadFile(m.fs, path) + if err != nil { + return nil, fmt.Errorf("reading '%s': %w", path, err) + } + if err := identity.Unmarshal(key); err != nil { + return nil, fmt.Errorf("unmarshalling private key: %w", err) + } + return identity, nil +} diff --git a/test/scripts/generate_mocks.sh b/test/scripts/generate_mocks.sh index 29f2d104..213e3c96 100755 --- a/test/scripts/generate_mocks.sh +++ b/test/scripts/generate_mocks.sh @@ -11,4 +11,4 @@ mockgen -copyright_file=hack/boilerplate.go.txt -destination=internal/agent/host mockgen -copyright_file=hack/boilerplate.go.txt -destination=internal/agent/host/host_mocks.go -package=host github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/host Manager mockgen -copyright_file=hack/boilerplate.go.txt -destination=internal/agent/elementalcli/runner_mocks.go -package=elementalcli github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/elementalcli Runner mockgen -copyright_file=hack/boilerplate.go.txt -destination=internal/agent/utils/runner_mocks.go -package=utils github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/utils CommandRunner -mockgen -copyright_file=hack/boilerplate.go.txt -destination=internal/agent/identity/manager_mocks.go -package=identity github.com/rancher-sandbox/cluster-api-provider-elemental/internal/agent/identity Manager +mockgen -copyright_file=hack/boilerplate.go.txt -destination=internal/identity/identity_mocks.go -package=identity github.com/rancher-sandbox/cluster-api-provider-elemental/internal/identity Manager,Identity diff --git a/test/scripts/setup_kind_cluster.sh b/test/scripts/setup_kind_cluster.sh index 812ca6ee..ec810670 100755 --- a/test/scripts/setup_kind_cluster.sh +++ b/test/scripts/setup_kind_cluster.sh @@ -22,10 +22,9 @@ nodes: - containerPort: 30009 hostPort: 30009 protocol: TCP - EOF EOF -clusterctl init --infrastructure "-" +clusterctl init --bootstrap k3s:v0.1.8 --control-plane k3s:v0.1.8 --infrastructure "-" make generate-infra-yaml kubectl apply -f infrastructure-elemental/v0.0.0/infrastructure-components.yaml