From 7466dd71b2ef51ad239a8998b4a89e7be245676b Mon Sep 17 00:00:00 2001 From: James Rasell Date: Tue, 22 Jul 2025 09:50:19 +0200 Subject: [PATCH] server: Add new `server.client_introduction` config block. (#26315) The new configuration block exposes some key options which allow cluster administrators to control certain client introduction behaviours. This change introduces the new block and plumbing, so that it is exposed in the Nomad server for consumption via internal processes. --- command/agent/agent.go | 18 +++++ command/agent/agent_test.go | 57 ++++++++++++++ command/agent/config.go | 115 ++++++++++++++++++++++++++++ command/agent/config_parse.go | 13 ++++ command/agent/config_parse_test.go | 12 +++ command/agent/config_test.go | 117 +++++++++++++++++++++++++++++ command/agent/testdata/basic.hcl | 6 ++ command/agent/testdata/basic.json | 5 ++ nomad/config.go | 7 ++ nomad/structs/node.go | 104 ++++++++++++++++++++++++- nomad/structs/node_test.go | 109 ++++++++++++++++++++++++++- 11 files changed, 555 insertions(+), 8 deletions(-) diff --git a/command/agent/agent.go b/command/agent/agent.go index 5760b3747..f2694375b 100644 --- a/command/agent/agent.go +++ b/command/agent/agent.go @@ -656,6 +656,24 @@ func convertServerConfig(agentConfig *Config) (*nomad.Config, error) { runtime.NumCPU()) } + // If the operator has specified a client introduction server config block, + // translate this into the internal server configuration object. + if agentConfig.Server.ClientIntroduction != nil { + if agentConfig.Server.ClientIntroduction.Enforcement != "" { + conf.NodeIntroductionConfig.Enforcement = agentConfig.Server.ClientIntroduction.Enforcement + } + if agentConfig.Server.ClientIntroduction.DefaultIdentityTTL > 0 { + conf.NodeIntroductionConfig.DefaultIdentityTTL = agentConfig.Server.ClientIntroduction.DefaultIdentityTTL + } + if agentConfig.Server.ClientIntroduction.MaxIdentityTTL > 0 { + conf.NodeIntroductionConfig.MaxIdentityTTL = agentConfig.Server.ClientIntroduction.MaxIdentityTTL + } + } + + if err := conf.NodeIntroductionConfig.Validate(); err != nil { + return nil, fmt.Errorf("invalid server.client_introduction configuration: %w", err) + } + return conf, nil } diff --git a/command/agent/agent_test.go b/command/agent/agent_test.go index cbd7a076d..bc62c1019 100644 --- a/command/agent/agent_test.go +++ b/command/agent/agent_test.go @@ -1898,3 +1898,60 @@ func TestAgent_ServerConfig_JobDefaultPriority_Bad(t *testing.T) { }) } } + +func Test_convertServerConfig_clientIntroduction(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + inputClientIntroduction *ClientIntroduction + expectedNodeIntroductionConfig *structs.NodeIntroductionConfig + }{ + { + name: "nil client introduction", + inputClientIntroduction: nil, + expectedNodeIntroductionConfig: &structs.NodeIntroductionConfig{ + Enforcement: "warn", + DefaultIdentityTTL: 5 * time.Minute, + MaxIdentityTTL: 30 * time.Minute, + }, + }, + { + name: "partial override", + inputClientIntroduction: &ClientIntroduction{ + Enforcement: "strict", + }, + expectedNodeIntroductionConfig: &structs.NodeIntroductionConfig{ + Enforcement: "strict", + DefaultIdentityTTL: 5 * time.Minute, + MaxIdentityTTL: 30 * time.Minute, + }, + }, + { + name: "partial override", + inputClientIntroduction: &ClientIntroduction{ + Enforcement: "strict", + DefaultIdentityTTL: 50 * time.Minute, + MaxIdentityTTL: 300 * time.Minute, + }, + expectedNodeIntroductionConfig: &structs.NodeIntroductionConfig{ + Enforcement: "strict", + DefaultIdentityTTL: 50 * time.Minute, + MaxIdentityTTL: 300 * time.Minute, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + + baseConfig := DevConfig(nil) + must.NoError(t, baseConfig.normalizeAddrs()) + baseConfig.Server.ClientIntroduction = tc.inputClientIntroduction + + serverConf, err := convertServerConfig(baseConfig) + must.NoError(t, err) + must.Eq(t, tc.expectedNodeIntroductionConfig, serverConf.NodeIntroductionConfig) + }) + } +} diff --git a/command/agent/config.go b/command/agent/config.go index dfe49aabe..e35d6005a 100644 --- a/command/agent/config.go +++ b/command/agent/config.go @@ -519,6 +519,11 @@ type ServerConfig struct { // by withholding peers until enough servers join. BootstrapExpect int `hcl:"bootstrap_expect"` + // ClientIntroduction is the configuration block that configures the client + // introduction feature. This feature allows servers to validate requests + // and perform enforcement actions on client registrations. + ClientIntroduction *ClientIntroduction `hcl:"client_introduction"` + // DataDir is the directory to store our state in DataDir string `hcl:"data_dir"` @@ -785,6 +790,7 @@ func (s *ServerConfig) Copy() *ServerConfig { ns.JobDefaultPriority = pointer.Copy(s.JobDefaultPriority) ns.JobMaxPriority = pointer.Copy(s.JobMaxPriority) ns.JobTrackedVersions = pointer.Copy(s.JobTrackedVersions) + ns.ClientIntroduction = s.ClientIntroduction.Copy() return &ns } @@ -1017,6 +1023,110 @@ func (s *Search) Copy() *Search { return &ns } +// ClientIntroduction is the server configuration block that configures the +// client introduction feature. This feature allows servers to validate requests +// and perform enforcement actions on client registrations. +type ClientIntroduction struct { + + // Enforcement is the level of enforcement that the server will apply to + // client registrations. This can be one of "none", "warn", or "strict" + // which is also declared within ClientIntroductionEnforcementValues. + Enforcement string `hcl:"enforcement"` + + // DefaultIdentityTTL is the TTL assigned to client introduction identities + // that are generated by a caller who did not provide a TTL. + DefaultIdentityTTL time.Duration + DefaultIdentityTTLHCL string `hcl:"default_identity_ttl" json:"-"` + + // MaxIdentityTTL is the maximum TTL that can be assigned to a client + // introduction identity. This is used to validate the TTL provided by + // caller and allows operators a method to limit TTL requests. + MaxIdentityTTL time.Duration + MaxIdentityTTLHCL string `hcl:"max_identity_ttl" json:"-"` + + // ExtraKeysHCL is used by hcl to surface unexpected keys within the + // configuration block. Without this, unexpected keys will be silently + // ignored. + ExtraKeysHCL []string `hcl:",unusedKeys" json:"-"` +} + +// ClientIntroductionEnforcementValues are the valid values for the client +// introduction enforcement setting. +var ClientIntroductionEnforcementValues = []string{"none", "warn", "strict"} + +// Copy creates a copy of the ClientIntroduction configuration block. All fields +// are copied, including the ExtraKeysHCL field which is used by HCL to surface +// unexpected keys within the configuration block. +func (c *ClientIntroduction) Copy() *ClientIntroduction { + if c == nil { + return nil + } + + newCI := *c + newCI.ExtraKeysHCL = slices.Clone(c.ExtraKeysHCL) + + return &newCI +} + +// Merge performs a merge of two ClientIntroduction configuration blocks +// overwriting the values in the first block with the values in the block passed +// into the function. +func (c *ClientIntroduction) Merge(z *ClientIntroduction) *ClientIntroduction { + if c == nil { + return z + } + + result := *c + + if z == nil { + return &result + } + + if z.Enforcement != "" { + result.Enforcement = z.Enforcement + } + if z.DefaultIdentityTTL > 0 { + result.DefaultIdentityTTL = z.DefaultIdentityTTL + } + if z.MaxIdentityTTL > 0 { + result.MaxIdentityTTL = z.MaxIdentityTTL + } + if len(z.ExtraKeysHCL) > 0 { + result.ExtraKeysHCL = append(result.ExtraKeysHCL, z.ExtraKeysHCL...) + } + + return &result +} + +// Validate performs validation on the ClientIntroduction configuration block to +// ensure the values are set correctly for use by the server. +func (c *ClientIntroduction) Validate() error { + + if c == nil { + return nil + } + + if c.Enforcement == "" { + return errors.New("client_introduction.enforcement must be set") + } + if !slices.Contains(ClientIntroductionEnforcementValues, c.Enforcement) { + return fmt.Errorf("client_introduction.enforcement must be one of %v", + ClientIntroductionEnforcementValues) + } + + if c.DefaultIdentityTTL < 1 { + return errors.New("client_introduction.default_identity_ttl must be greater one") + } + if c.MaxIdentityTTL < 1 { + return errors.New("client_introduction.max_identity_ttl must be greater one") + } + if c.MaxIdentityTTL < c.DefaultIdentityTTL { + return errors.New("client_introduction.max_identity_ttl must be greater than default_identity_ttl") + } + + return nil +} + // ServerJoin is used in both clients and servers to bootstrap connections to // servers type ServerJoin struct { @@ -2456,6 +2566,11 @@ func (s *ServerConfig) Merge(b *ServerConfig) *ServerConfig { result.StartTimeout = b.StartTimeout } + // Merge the client introduction config. + if b.ClientIntroduction != nil { + result.ClientIntroduction = result.ClientIntroduction.Merge(b.ClientIntroduction) + } + // Add the schedulers result.EnabledSchedulers = append(result.EnabledSchedulers, b.EnabledSchedulers...) diff --git a/command/agent/config_parse.go b/command/agent/config_parse.go index 7b71b620a..5fd8eb333 100644 --- a/command/agent/config_parse.go +++ b/command/agent/config_parse.go @@ -54,6 +54,7 @@ func ParseConfigFile(path string) (*Config, error) { }, }, Server: &ServerConfig{ + ClientIntroduction: &ClientIntroduction{}, PlanRejectionTracker: &PlanRejectionTracker{}, ServerJoin: &ServerJoin{}, }, @@ -189,6 +190,18 @@ func ParseConfigFile(path string) (*Config, error) { {"rpc.connection_write_timeout", &c.RPC.ConnectionWriteTimeout, &c.RPC.ConnectionWriteTimeoutHCL, nil}, {"rpc.stream_open_timeout", &c.RPC.StreamOpenTimeout, &c.RPC.StreamOpenTimeoutHCL, nil}, {"rpc.stream_close_timeout", &c.RPC.StreamCloseTimeout, &c.RPC.StreamCloseTimeoutHCL, nil}, + { + "server.client_introduction.default_identity_ttl", + &c.Server.ClientIntroduction.DefaultIdentityTTL, + &c.Server.ClientIntroduction.DefaultIdentityTTLHCL, + nil, + }, + { + "server.client_introduction.max_identity_ttl", + &c.Server.ClientIntroduction.MaxIdentityTTL, + &c.Server.ClientIntroduction.MaxIdentityTTLHCL, + nil, + }, } // Parse durations for Consul and Vault config blocks if provided. diff --git a/command/agent/config_parse_test.go b/command/agent/config_parse_test.go index 9212f0c4a..526c9c088 100644 --- a/command/agent/config_parse_test.go +++ b/command/agent/config_parse_test.go @@ -160,6 +160,13 @@ var basicConfig = &Config{ JobDefaultPriority: pointer.Of(100), JobMaxPriority: pointer.Of(200), StartTimeout: "1m", + ClientIntroduction: &ClientIntroduction{ + Enforcement: "warn", + DefaultIdentityTTLHCL: "5m", + DefaultIdentityTTL: 5 * time.Minute, + MaxIdentityTTLHCL: "30m", + MaxIdentityTTL: 30 * time.Minute, + }, }, ACL: &ACLConfig{ Enabled: true, @@ -620,6 +627,9 @@ func (c *Config) addDefaults() { if c.Server.PlanRejectionTracker == nil { c.Server.PlanRejectionTracker = &PlanRejectionTracker{} } + if c.Server.ClientIntroduction == nil { + c.Server.ClientIntroduction = &ClientIntroduction{} + } if c.Reporting == nil { c.Reporting = &config.ReportingConfig{ License: &config.LicenseReportingConfig{ @@ -712,6 +722,7 @@ var sample0 = &Config{ NodeWindow: 31 * time.Minute, NodeWindowHCL: "31m", }, + ClientIntroduction: &ClientIntroduction{}, }, ACL: &ACLConfig{ Enabled: true, @@ -821,6 +832,7 @@ var sample1 = &Config{ NodeWindow: 31 * time.Minute, NodeWindowHCL: "31m", }, + ClientIntroduction: &ClientIntroduction{}, }, ACL: &ACLConfig{ Enabled: true, diff --git a/command/agent/config_test.go b/command/agent/config_test.go index ab77b4388..47cddaaa4 100644 --- a/command/agent/config_test.go +++ b/command/agent/config_test.go @@ -1260,6 +1260,123 @@ func TestIsMissingPort(t *testing.T) { } } +func TestClientIntroduction_Copy(t *testing.T) { + ci.Parallel(t) + + clientIntro := &ClientIntroduction{ + Enforcement: "warn", + DefaultIdentityTTL: 5 * time.Minute, + MaxIdentityTTL: 30 * time.Minute, + } + + copiedClientIntro := clientIntro.Copy() + + // Ensure the copied object contains the same values, but the underlying + // pointer address is different. + must.Eq(t, clientIntro, copiedClientIntro) + must.NotEq(t, fmt.Sprintf("%p", clientIntro), fmt.Sprintf("%p", copiedClientIntro)) +} + +func TestClientIntroduction_Merge(t *testing.T) { + ci.Parallel(t) + + clientIntro1 := &ClientIntroduction{ + Enforcement: "warn", + DefaultIdentityTTL: 5 * time.Minute, + MaxIdentityTTL: 30 * time.Minute, + ExtraKeysHCL: []string{"key1", "key2"}, + } + clientIntro2 := &ClientIntroduction{ + Enforcement: "strict", + DefaultIdentityTTL: 30 * time.Minute, + MaxIdentityTTL: 60 * time.Minute, + ExtraKeysHCL: []string{"key3", "key4"}, + } + expectedClientIntro := &ClientIntroduction{ + Enforcement: "strict", + DefaultIdentityTTL: 30 * time.Minute, + MaxIdentityTTL: 60 * time.Minute, + ExtraKeysHCL: []string{"key1", "key2", "key3", "key4"}, + } + must.Eq(t, expectedClientIntro, clientIntro1.Merge(clientIntro2)) +} + +func TestClientIntroduction_Validate(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + inputClientIntroduction *ClientIntroduction + expectedError bool + }{ + { + name: "nil block", + inputClientIntroduction: nil, + expectedError: false, + }, + { + name: "empty enforcement", + inputClientIntroduction: &ClientIntroduction{ + Enforcement: "", + }, + expectedError: true, + }, + { + name: "invalid enforcement", + inputClientIntroduction: &ClientIntroduction{ + Enforcement: "nuclear", + }, + expectedError: true, + }, + { + name: "invalid default_identity_ttl", + inputClientIntroduction: &ClientIntroduction{ + Enforcement: "warn", + DefaultIdentityTTL: 0, + }, + expectedError: true, + }, + { + name: "invalid max_identity_ttl", + inputClientIntroduction: &ClientIntroduction{ + Enforcement: "warn", + DefaultIdentityTTL: 5 * time.Minute, + MaxIdentityTTL: 0, + }, + expectedError: true, + }, + { + name: "invalid ttl combination", + inputClientIntroduction: &ClientIntroduction{ + Enforcement: "warn", + DefaultIdentityTTL: 5 * time.Minute, + MaxIdentityTTL: 4 * time.Minute, + }, + expectedError: true, + }, + { + name: "valid", + inputClientIntroduction: &ClientIntroduction{ + Enforcement: "warn", + DefaultIdentityTTL: 5 * time.Minute, + MaxIdentityTTL: 30 * time.Minute, + }, + expectedError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actualOutput := tc.inputClientIntroduction.Validate() + if tc.expectedError { + must.Error(t, actualOutput) + } else { + must.NoError(t, actualOutput) + } + }) + } +} + func TestMergeServerJoin(t *testing.T) { ci.Parallel(t) diff --git a/command/agent/testdata/basic.hcl b/command/agent/testdata/basic.hcl index db0b1b6dd..d41f7290e 100644 --- a/command/agent/testdata/basic.hcl +++ b/command/agent/testdata/basic.hcl @@ -168,6 +168,12 @@ server { } } + client_introduction { + enforcement = "warn" + default_identity_ttl = "5m" + max_identity_ttl = "30m" + } + license_path = "/tmp/nomad.hclic" } diff --git a/command/agent/testdata/basic.json b/command/agent/testdata/basic.json index 15c09e925..d557b3122 100644 --- a/command/agent/testdata/basic.json +++ b/command/agent/testdata/basic.json @@ -361,6 +361,11 @@ ] } ], + "client_introduction": { + "enforcement": "warn", + "default_identity_ttl": "5m", + "max_identity_ttl": "30m" + }, "upgrade_version": "0.8.0", "license_path": "/tmp/nomad.hclic", "job_default_priority": 100, diff --git a/nomad/config.go b/nomad/config.go index c1e1c969e..1341f8b22 100644 --- a/nomad/config.go +++ b/nomad/config.go @@ -448,6 +448,11 @@ type Config struct { // considered healthy. Without this, the server can hang indefinitely // waiting for these. StartTimeout time.Duration + + // NodeIntroductionConfig is the configuration for the node introduction + // feature. This feature allows servers to validate node registration + // requests and perform the appropriate enforcement actions. + NodeIntroductionConfig *structs.NodeIntroductionConfig } func (c *Config) Copy() *Config { @@ -477,6 +482,7 @@ func (c *Config) Copy() *Config { nc.LicenseConfig = c.LicenseConfig.Copy() nc.SearchConfig = c.SearchConfig.Copy() nc.KEKProviderConfigs = helper.CopySlice(c.KEKProviderConfigs) + nc.NodeIntroductionConfig = c.NodeIntroductionConfig.Copy() return &nc } @@ -657,6 +663,7 @@ func DefaultConfig() *Config { JobMaxPriority: structs.JobDefaultMaxPriority, JobTrackedVersions: structs.JobDefaultTrackedVersions, StartTimeout: 30 * time.Second, + NodeIntroductionConfig: structs.DefaultNodeIntroductionConfig(), } // Enable all known schedulers by default diff --git a/nomad/structs/node.go b/nomad/structs/node.go index 8e62ddbf0..c46126650 100644 --- a/nomad/structs/node.go +++ b/nomad/structs/node.go @@ -12,6 +12,7 @@ import ( "time" "github.com/go-jose/go-jose/v3/jwt" + "github.com/hashicorp/go-multierror" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/nomad/helper/uuid" ) @@ -497,10 +498,10 @@ type NodeMetaResponse struct { // NodeIdentityClaims represents the claims for a Nomad node identity JWT. type NodeIdentityClaims struct { - NodeID string `json:"nomad_node_id"` - NodePool string `json:"nomad_node_pool"` - NodeClass string `json:"nomad_node_class"` - NodeDatacenter string `json:"nomad_node_datacenter"` + NodeID string `json:"nomad_node_id,omitempty"` + NodePool string `json:"nomad_node_pool,omitempty"` + NodeClass string `json:"nomad_node_class,omitempty"` + NodeDatacenter string `json:"nomad_node_datacenter,omitempty"` } // GenerateNodeIdentityClaims creates a new NodeIdentityClaims for the given @@ -736,3 +737,98 @@ type NodeIdentityRenewReq struct { } type NodeIdentityRenewResp struct{} + +// DefaultNodeIntroductionConfig returns a default and fully hydrated +// configuration object for the node introduction feature. +func DefaultNodeIntroductionConfig() *NodeIntroductionConfig { + return &NodeIntroductionConfig{ + Enforcement: NodeIntroductionEnforcementWarn, + DefaultIdentityTTL: 5 * time.Minute, + MaxIdentityTTL: 30 * time.Minute, + } +} + +const ( + // NodeIntroductionEnforcementNone means secure intro token, and the + // pre-1.11 workflow that uses an empty authentication token can be used for + // initial client registration. No enforcement is applied and no emits are + // emitted based on the registration introduction. + NodeIntroductionEnforcementNone = "none" + + // NodeIntroductionEnforcementWarn means secure intro token, and the + // pre-1.11 workflow that uses an empty authentication token can be used for + // initial client registration. The server will emit a log and a metric for + // each registration that does not use an introduction token. + NodeIntroductionEnforcementWarn = "warn" + + // NodeIntroductionEnforcementStrict means initial client registration must + // use a secure introduction token. The server will reject any registration + // that does not use an introduction token. + NodeIntroductionEnforcementStrict = "strict" +) + +// NodeIntroductionConfig is the server configuration block that configures +// the client introduction feature. This feature allows servers to validate +// requests and perform enforcement actions on client registrations. +type NodeIntroductionConfig struct { + + // Enforcement is the level of enforcement that the server will apply to + // client registrations. This can be one of "none", "warn", or "strict" + // which are defined as NodeIntroductionEnforcementNone, + // NodeIntroductionEnforcementWarn, and NodeIntroductionEnforcementStrict + // respectively. + Enforcement string + + // DefaultIdentityTTL is the TTL assigned to client introduction identities + // that are generated by a caller who did not provide a TTL. + DefaultIdentityTTL time.Duration + + // MaxIdentityTTL is the maximum TTL that can be assigned to a client + // introduction identity. This is used to validate the TTL provided by + // caller and allows operators a method to limit TTL requests. + MaxIdentityTTL time.Duration +} + +// Copy creates a copy of the node introduction configuration block. +func (n *NodeIntroductionConfig) Copy() *NodeIntroductionConfig { + if n == nil { + return nil + } + + newCI := *n + return &newCI +} + +// Validate checks that the node introduction configuration is valid. +func (n *NodeIntroductionConfig) Validate() error { + + if n == nil { + return fmt.Errorf("cannot be empty") + } + + var mErr *multierror.Error + + switch n.Enforcement { + case NodeIntroductionEnforcementNone, + NodeIntroductionEnforcementWarn, + NodeIntroductionEnforcementStrict: + default: + mErr = multierror.Append(mErr, fmt.Errorf("invalid enforcement %q", n.Enforcement)) + } + + if n.DefaultIdentityTTL < 1 { + mErr = multierror.Append(mErr, errors.New("default_identity_ttl must be greater than 0")) + } + + if n.MaxIdentityTTL < 1 { + mErr = multierror.Append(mErr, errors.New("max_identity_ttl must be greater than 0")) + } + + if n.MaxIdentityTTL < n.DefaultIdentityTTL { + mErr = multierror.Append(mErr, errors.New( + "max_identity_ttl must be greater than or equal to default_identity_ttl", + )) + } + + return mErr.ErrorOrNil() +} diff --git a/nomad/structs/node_test.go b/nomad/structs/node_test.go index 57e3912ca..936c5dce9 100644 --- a/nomad/structs/node_test.go +++ b/nomad/structs/node_test.go @@ -4,6 +4,7 @@ package structs import ( + "fmt" "testing" "time" @@ -269,10 +270,10 @@ func TestGenerateNodeIdentityClaims(t *testing.T) { claims := GenerateNodeIdentityClaims(node, "euw", 10*time.Minute) - must.Eq(t, "node-id-1", claims.NodeID) - must.Eq(t, "custom-pool", claims.NodePool) - must.Eq(t, "custom-class", claims.NodeClass) - must.Eq(t, "euw2", claims.NodeDatacenter) + must.Eq(t, "node-id-1", claims.NodeIdentityClaims.NodeID) + must.Eq(t, "custom-pool", claims.NodeIdentityClaims.NodePool) + must.Eq(t, "custom-class", claims.NodeIdentityClaims.NodeClass) + must.Eq(t, "euw2", claims.NodeIdentityClaims.NodeDatacenter) must.StrEqFold(t, "node:euw:custom-pool:node-id-1:default", claims.Subject) must.Eq(t, []string{IdentityDefaultAud}, claims.Audience) must.NotNil(t, claims.ID) @@ -649,3 +650,103 @@ func TestNodeUpdateStatusRequest_IdentitySigningErrorIsTerminal(t *testing.T) { }) } } + +func Test_DefaultNodeIntroductionConfig(t *testing.T) { + ci.Parallel(t) + + expected := &NodeIntroductionConfig{ + Enforcement: "warn", + DefaultIdentityTTL: 5 * time.Minute, + MaxIdentityTTL: 30 * time.Minute, + } + must.Eq(t, expected, DefaultNodeIntroductionConfig()) +} + +func TestNodeIntroductionConfig_Copy(t *testing.T) { + ci.Parallel(t) + + nodeIntro := &NodeIntroductionConfig{ + Enforcement: "warn", + DefaultIdentityTTL: 5 * time.Minute, + MaxIdentityTTL: 30 * time.Minute, + } + + copiedNodeIntro := nodeIntro.Copy() + + // Ensure the copied object contains the same values, but the underlying + // pointer address is different. + must.Eq(t, nodeIntro, copiedNodeIntro) + must.NotEq(t, fmt.Sprintf("%p", nodeIntro), fmt.Sprintf("%p", copiedNodeIntro)) +} + +func TestNodeIntroductionConfig_Validate(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + inputNodeIntroductionConfig *NodeIntroductionConfig + expectedErrorContains string + }{ + { + name: "nil config", + inputNodeIntroductionConfig: nil, + expectedErrorContains: "cannot be empty", + }, + { + name: "incorrect enforcement", + inputNodeIntroductionConfig: &NodeIntroductionConfig{ + Enforcement: "invalid", + DefaultIdentityTTL: 5 * time.Minute, + MaxIdentityTTL: 30 * time.Minute, + }, + expectedErrorContains: "invalid enforcement", + }, + { + name: "incorrect default identity TTL", + inputNodeIntroductionConfig: &NodeIntroductionConfig{ + Enforcement: NodeIntroductionEnforcementStrict, + DefaultIdentityTTL: 0, + MaxIdentityTTL: 30 * time.Minute, + }, + expectedErrorContains: "default_identity_ttl must be greater than 0", + }, + { + name: "incorrect max identity TTL", + inputNodeIntroductionConfig: &NodeIntroductionConfig{ + Enforcement: NodeIntroductionEnforcementStrict, + DefaultIdentityTTL: 5 * time.Minute, + MaxIdentityTTL: 0, + }, + expectedErrorContains: "max_identity_ttl must be greater than 0", + }, + { + name: "incorrect max identity TTL greater than default identity TTL", + inputNodeIntroductionConfig: &NodeIntroductionConfig{ + Enforcement: NodeIntroductionEnforcementStrict, + DefaultIdentityTTL: 5 * time.Minute, + MaxIdentityTTL: 0, + }, + expectedErrorContains: "max_identity_ttl must be greater than or equal to default_identity_ttl", + }, + { + name: "valid", + inputNodeIntroductionConfig: &NodeIntroductionConfig{ + Enforcement: NodeIntroductionEnforcementStrict, + DefaultIdentityTTL: 15 * time.Minute, + MaxIdentityTTL: 45 * time.Minute, + }, + expectedErrorContains: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actualError := tc.inputNodeIntroductionConfig.Validate() + if tc.expectedErrorContains == "" { + must.NoError(t, actualError) + } else { + must.ErrorContains(t, actualError, tc.expectedErrorContains) + } + }) + } +}