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) + } + }) + } +}