// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package config import ( "fmt" "net" "os" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" hcpconfig "github.com/hashicorp/consul/agent/hcp/config" "github.com/hashicorp/consul/types" ) func TestLoad(t *testing.T) { // Basically just testing that injection of the extra // source works. devMode := true builderOpts := LoadOpts{ // putting this in dev mode so that the config validates // without having to specify a data directory DevMode: &devMode, DefaultConfig: FileSource{ Name: "test", Format: "hcl", Data: `node_name = "hobbiton"`, }, Overrides: []Source{ FileSource{ Name: "overrides", Format: "json", Data: `{"check_reap_interval": "1ms"}`, }, }, } result, err := Load(builderOpts) require.NoError(t, err) require.Empty(t, result.Warnings) cfg := result.RuntimeConfig require.NotNil(t, cfg) require.Equal(t, "hobbiton", cfg.NodeName) require.Equal(t, 1*time.Millisecond, cfg.CheckReapInterval) } func TestShouldParseFile(t *testing.T) { var testcases = []struct { filename string configFormat string expected bool }{ {filename: "config.json", expected: true}, {filename: "config.hcl", expected: true}, {filename: "config", configFormat: "hcl", expected: true}, {filename: "config.js", configFormat: "json", expected: true}, {filename: "config.yaml", expected: false}, } for _, tc := range testcases { name := fmt.Sprintf("filename=%s, format=%s", tc.filename, tc.configFormat) t.Run(name, func(t *testing.T) { require.Equal(t, tc.expected, shouldParseFile(tc.filename, tc.configFormat)) }) } } func TestNewBuilder_PopulatesSourcesFromConfigFiles(t *testing.T) { path, err := os.MkdirTemp("", t.Name()) require.NoError(t, err) t.Cleanup(func() { os.RemoveAll(path) }) subpath := filepath.Join(path, "sub") err = os.Mkdir(subpath, 0755) require.NoError(t, err) for _, dir := range []string{path, subpath} { err = os.WriteFile(filepath.Join(dir, "a.hcl"), []byte("content a"), 0644) require.NoError(t, err) err = os.WriteFile(filepath.Join(dir, "b.json"), []byte("content b"), 0644) require.NoError(t, err) err = os.WriteFile(filepath.Join(dir, "c.yaml"), []byte("content c"), 0644) require.NoError(t, err) } paths := []string{ filepath.Join(path, "a.hcl"), filepath.Join(path, "b.json"), filepath.Join(path, "c.yaml"), } t.Run("fail on unknown files", func(t *testing.T) { _, err := newBuilder(LoadOpts{ConfigFiles: append(paths, subpath)}) require.Error(t, err) }) t.Run("skip on unknown files in dir", func(t *testing.T) { b, err := newBuilder(LoadOpts{ConfigFiles: []string{subpath}}) require.NoError(t, err) expected := []Source{ FileSource{Name: filepath.Join(subpath, "a.hcl"), Format: "hcl", Data: "content a"}, FileSource{Name: filepath.Join(subpath, "b.json"), Format: "json", Data: "content b"}, } require.Equal(t, expected, b.Sources) require.Len(t, b.Warnings, 1) }) t.Run("force config format", func(t *testing.T) { b, err := newBuilder(LoadOpts{ConfigFiles: append(paths, subpath), ConfigFormat: "hcl"}) require.NoError(t, err) expected := []Source{ FileSource{Name: paths[0], Format: "hcl", Data: "content a"}, FileSource{Name: paths[1], Format: "hcl", Data: "content b"}, FileSource{Name: paths[2], Format: "hcl", Data: "content c"}, FileSource{Name: filepath.Join(subpath, "a.hcl"), Format: "hcl", Data: "content a"}, FileSource{Name: filepath.Join(subpath, "b.json"), Format: "hcl", Data: "content b"}, FileSource{Name: filepath.Join(subpath, "c.yaml"), Format: "hcl", Data: "content c"}, } require.Equal(t, expected, b.Sources) }) } func TestLoad_NodeName(t *testing.T) { type testCase struct { name string nodeName string expectedWarn string } fn := func(t *testing.T, tc testCase) { opts := LoadOpts{ FlagValues: FlagValuesTarget{ Config: Config{ NodeName: pString(tc.nodeName), DataDir: pString("dir"), }, }, } patchLoadOptsShims(&opts) result, err := Load(opts) require.NoError(t, err) require.Len(t, result.Warnings, 1) require.Contains(t, result.Warnings[0], tc.expectedWarn) } var testCases = []testCase{ { name: "invalid character - unicode", nodeName: "🐼", expectedWarn: `Node name "🐼" will not be discoverable via DNS due to invalid characters`, }, { name: "invalid character - slash", nodeName: "thing/other/ok", expectedWarn: `Node name "thing/other/ok" will not be discoverable via DNS due to invalid characters`, }, { name: "too long", nodeName: strings.Repeat("a", 66), expectedWarn: "due to it being too long.", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { fn(t, tc) }) } } func TestBuilder_unixPermissionsVal(t *testing.T) { b, _ := newBuilder(LoadOpts{ FlagValues: FlagValuesTarget{ Config: Config{ NodeName: pString("foo"), DataDir: pString("dir"), }, }, }) goodmode := "666" badmode := "9666" patchLoadOptsShims(&b.opts) require.NoError(t, b.err) _ = b.unixPermissionsVal("local_bind_socket_mode", &goodmode) require.NoError(t, b.err) require.Len(t, b.Warnings, 0) _ = b.unixPermissionsVal("local_bind_socket_mode", &badmode) require.NotNil(t, b.err) require.Contains(t, b.err.Error(), "local_bind_socket_mode: invalid mode") require.Len(t, b.Warnings, 0) } func patchLoadOptsShims(opts *LoadOpts) { if opts.hostname == nil { opts.hostname = func() (string, error) { return "thehostname", nil } } if opts.getPrivateIPv4 == nil { opts.getPrivateIPv4 = func() ([]*net.IPAddr, error) { return []*net.IPAddr{ipAddr("10.0.0.1")}, nil } } if opts.getPublicIPv6 == nil { opts.getPublicIPv6 = func() ([]*net.IPAddr, error) { return []*net.IPAddr{ipAddr("dead:beef::1")}, nil } } } func TestLoad_HTTPMaxConnsPerClientExceedsRLimit(t *testing.T) { hcl := ` limits{ # We put a very high value to be sure to fail # This value is more than max on Windows as well http_max_conns_per_client = 16777217 }` opts := LoadOpts{ DefaultConfig: FileSource{ Name: "test", Format: "hcl", Data: ` ae_interval = "1m" data_dir="/tmp/00000000001979" bind_addr = "127.0.0.1" advertise_addr = "127.0.0.1" datacenter = "dc1" bootstrap = true server = true node_id = "00000000001979" node_name = "Node-00000000001979" `, }, HCL: []string{hcl}, } _, err := Load(opts) require.Error(t, err) assert.Contains(t, err.Error(), "but limits.http_max_conns_per_client: 16777217 needs at least 16777237") } func TestLoad_EmptyClientAddr(t *testing.T) { type testCase struct { name string clientAddr *string expectedWarningMessage *string } fn := func(t *testing.T, tc testCase) { opts := LoadOpts{ FlagValues: FlagValuesTarget{ Config: Config{ ClientAddr: tc.clientAddr, DataDir: pString("dir"), }, }, } patchLoadOptsShims(&opts) result, err := Load(opts) require.NoError(t, err) if tc.expectedWarningMessage != nil { require.Len(t, result.Warnings, 1) require.Contains(t, result.Warnings[0], *tc.expectedWarningMessage) } } var testCases = []testCase{ { name: "empty string", clientAddr: pString(""), expectedWarningMessage: pString("client_addr is empty, client services (DNS, HTTP, HTTPS, GRPC) will not be listening for connections"), }, { name: "nil pointer", clientAddr: nil, // defaults to 127.0.0.1 expectedWarningMessage: nil, // expecting no warnings }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { fn(t, tc) }) } } func TestBuilder_DurationVal_InvalidDuration(t *testing.T) { b := builder{} badDuration1 := "not-a-duration" badDuration2 := "also-not" b.durationVal("field1", &badDuration1) b.durationVal("field1", &badDuration2) require.Error(t, b.err) require.Contains(t, b.err.Error(), "2 errors") require.Contains(t, b.err.Error(), badDuration1) require.Contains(t, b.err.Error(), badDuration2) } func TestBuilder_DurationValWithDefaultMin(t *testing.T) { b := builder{} // Attempt to validate that a duration of 10 hours will not error when the min val is 1 hour. dur := "10h0m0s" b.durationValWithDefaultMin("field2", &dur, 24*7*time.Hour, time.Hour) require.NoError(t, b.err) // Attempt to validate that a duration of 1 min will error when the min val is 1 hour. dur = "0h1m0s" b.durationValWithDefaultMin("field1", &dur, 24*7*time.Hour, time.Hour) require.Error(t, b.err) require.Contains(t, b.err.Error(), "1 error") } func TestBuilder_ServiceVal_MultiError(t *testing.T) { b := builder{} b.serviceVal(&ServiceDefinition{ Meta: map[string]string{"": "empty-key"}, Port: intPtr(12345), SocketPath: strPtr("/var/run/socket.sock"), Checks: []CheckDefinition{ {Interval: strPtr("bad-interval")}, }, Weights: &ServiceWeights{Passing: intPtr(-1)}, }) require.Error(t, b.err) require.Contains(t, b.err.Error(), "4 errors") require.Contains(t, b.err.Error(), "bad-interval") require.Contains(t, b.err.Error(), "Key cannot be blank") require.Contains(t, b.err.Error(), "Invalid weight") require.Contains(t, b.err.Error(), "cannot have both socket path") } func TestBuilder_ServiceVal_with_Check(t *testing.T) { b := builder{} svc := b.serviceVal(&ServiceDefinition{ Name: strPtr("unbound"), ID: strPtr("unbound"), Port: intPtr(12345), Checks: []CheckDefinition{ { Interval: strPtr("5s"), UDP: strPtr("localhost:53"), }, }, }) require.NoError(t, b.err) require.Equal(t, 1, len(svc.Checks)) require.Equal(t, "localhost:53", svc.Checks[0].UDP) } func intPtr(v int) *int { return &v } func TestBuilder_tlsVersion(t *testing.T) { b := builder{} validTLSVersion := "TLSv1_3" b.tlsVersion("tls.defaults.tls_min_version", &validTLSVersion) deprecatedTLSVersion := "tls11" b.tlsVersion("tls.defaults.tls_min_version", &deprecatedTLSVersion) invalidTLSVersion := "tls9" b.tlsVersion("tls.defaults.tls_min_version", &invalidTLSVersion) require.Error(t, b.err) require.Contains(t, b.err.Error(), "2 errors") require.Contains(t, b.err.Error(), deprecatedTLSVersion) require.Contains(t, b.err.Error(), invalidTLSVersion) } func TestBuilder_WarnGRPCTLS(t *testing.T) { tests := []struct { name string hcl string expectErr bool }{ { name: "success", hcl: ``, expectErr: false, }, { name: "grpc_tls is disabled but explicitly defined", hcl: ` ports { grpc_tls = -1 } tls { grpc { cert_file = "defined" }} `, // This behavior is a little strange, but it allows users // to setup TLS and disable the port if they wish. expectErr: false, }, { name: "grpc is disabled", hcl: ` ports { grpc = -1 } tls { grpc { cert_file = "defined" }} `, expectErr: false, }, { name: "grpc_tls is undefined with default manual cert", hcl: ` tls { defaults { cert_file = "defined" }} `, expectErr: true, }, { name: "grpc_tls is undefined with manual cert", hcl: ` tls { grpc { cert_file = "defined" }} `, expectErr: true, }, { name: "grpc_tls is undefined with auto encrypt", hcl: ` auto_encrypt { tls = true } tls { grpc { use_auto_cert = true }} `, expectErr: true, }, { name: "grpc_tls is undefined with auto config", hcl: ` auto_config { enabled = true } tls { grpc { use_auto_cert = true }} `, expectErr: true, }, } for _, tc := range tests { // using dev mode skips the need for a data dir // and enables both grpc ports by default. devMode := true builderOpts := LoadOpts{ DevMode: &devMode, Overrides: []Source{ FileSource{ Name: "overrides", Format: "hcl", Data: tc.hcl, }, }, } _, err := Load(builderOpts) if tc.expectErr { require.Error(t, err) require.Contains(t, err.Error(), "listener no longer supports TLS") } else { require.NoError(t, err) } } } func TestBuilder_tlsCipherSuites(t *testing.T) { b := builder{} validCipherSuites := strings.Join([]string{ "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", }, ",") b.tlsCipherSuites("tls.defaults.tls_cipher_suites", &validCipherSuites, types.TLSv1_2) require.NoError(t, b.err) unsupportedCipherSuites := strings.Join([]string{ "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", }, ",") b.tlsCipherSuites("tls.defaults.tls_cipher_suites", &unsupportedCipherSuites, types.TLSv1_2) invalidCipherSuites := strings.Join([]string{ "cipherX", }, ",") b.tlsCipherSuites("tls.defaults.tls_cipher_suites", &invalidCipherSuites, types.TLSv1_2) b.tlsCipherSuites("tls.defaults.tls_cipher_suites", &validCipherSuites, types.TLSv1_3) require.Error(t, b.err) require.Contains(t, b.err.Error(), "3 errors") require.Contains(t, b.err.Error(), unsupportedCipherSuites) require.Contains(t, b.err.Error(), invalidCipherSuites) require.Contains(t, b.err.Error(), "cipher suites are not configurable") } func TestBuilder_parsePrefixFilter(t *testing.T) { t.Run("Check that 1.12 rpc metrics are parsed correctly.", func(t *testing.T) { type testCase struct { name string metricsPrefix string prefixFilter []string expectedAllowedPrefix []string expectedBlockedPrefix []string } var testCases = []testCase{ { name: "no prefix filter", metricsPrefix: "somePrefix", prefixFilter: []string{}, expectedAllowedPrefix: nil, expectedBlockedPrefix: []string{"somePrefix.rpc.server.call"}, }, { name: "operator enables 1.12 rpc metrics", metricsPrefix: "somePrefix", prefixFilter: []string{"+somePrefix.rpc.server.call"}, expectedAllowedPrefix: []string{"somePrefix.rpc.server.call"}, expectedBlockedPrefix: nil, }, { name: "operator enables 1.12 rpc metrics", metricsPrefix: "somePrefix", prefixFilter: []string{"-somePrefix.rpc.server.call"}, expectedAllowedPrefix: nil, expectedBlockedPrefix: []string{"somePrefix.rpc.server.call"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { b := builder{} telemetry := &Telemetry{ MetricsPrefix: &tc.metricsPrefix, PrefixFilter: tc.prefixFilter, } allowedPrefix, blockedPrefix := b.parsePrefixFilter(telemetry) require.Equal(t, tc.expectedAllowedPrefix, allowedPrefix) require.Equal(t, tc.expectedBlockedPrefix, blockedPrefix) }) } }) } func TestBuidler_hostMetricsWithCloud(t *testing.T) { devMode := true builderOpts := LoadOpts{ DevMode: &devMode, DefaultConfig: FileSource{ Name: "test", Format: "hcl", Data: `cloud{ resource_id = "abc" client_id = "abc" client_secret = "abc"}`, }, } result, err := Load(builderOpts) require.NoError(t, err) require.Empty(t, result.Warnings) cfg := result.RuntimeConfig require.NotNil(t, cfg) require.True(t, cfg.Telemetry.EnableHostMetrics) } func TestBuilder_CheckExperimentsInSecondaryDatacenters(t *testing.T) { type testcase struct { hcl string expectErr bool } run := func(t *testing.T, tc testcase) { // using dev mode skips the need for a data dir devMode := true builderOpts := LoadOpts{ DevMode: &devMode, Overrides: []Source{ FileSource{ Name: "overrides", Format: "hcl", Data: tc.hcl, }, }, } _, err := Load(builderOpts) if tc.expectErr { require.Error(t, err) require.Contains(t, err.Error(), "`experiments` cannot include") } else { require.NoError(t, err) } } const ( primary = `server = true primary_datacenter = "dc1" datacenter = "dc1" ` secondary = `server = true primary_datacenter = "dc1" datacenter = "dc2" ` ) cases := map[string]testcase{ "primary server no experiments": { hcl: primary + `experiments = []`, }, "primary server v2catalog": { hcl: primary + `experiments = ["resource-apis"]`, }, "primary server v2dns": { hcl: primary + `experiments = ["v2dns"]`, }, "primary server v2tenancy": { hcl: primary + `experiments = ["v2tenancy"]`, }, "secondary server no experiments": { hcl: secondary + `experiments = []`, }, "secondary server v2catalog": { hcl: secondary + `experiments = ["resource-apis"]`, expectErr: true, }, "secondary server v2dns": { hcl: secondary + `experiments = ["v2dns"]`, expectErr: true, }, "secondary server v2tenancy": { hcl: secondary + `experiments = ["v2tenancy"]`, expectErr: true, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { run(t, tc) }) } } func TestBuilder_WarnCloudConfigWithResourceApis(t *testing.T) { tests := []struct { name string hcl string expectErr bool }{ { name: "base_case", hcl: ``, }, { name: "resource-apis_no_cloud", hcl: `experiments = ["resource-apis"]`, }, { name: "cloud-config_no_experiments", hcl: `cloud{ resource_id = "abc" client_id = "abc" client_secret = "abc"}`, }, { name: "cloud-config_resource-apis_experiment", hcl: ` experiments = ["resource-apis"] cloud{ resource_id = "abc" client_id = "abc" client_secret = "abc"}`, expectErr: true, }, { name: "cloud-config_other_experiment", hcl: ` experiments = ["test"] cloud{ resource_id = "abc" client_id = "abc" client_secret = "abc"}`, }, { name: "cloud-config_resource-apis_experiment_override", hcl: ` experiments = ["resource-apis", "hcp-v2-resource-apis"] cloud{ resource_id = "abc" client_id = "abc" client_secret = "abc"}`, }, } for _, tc := range tests { // using dev mode skips the need for a data dir devMode := true builderOpts := LoadOpts{ DevMode: &devMode, Overrides: []Source{ FileSource{ Name: "overrides", Format: "hcl", Data: tc.hcl, }, }, } _, err := Load(builderOpts) if tc.expectErr { require.Error(t, err) require.Contains(t, err.Error(), "cannot include 'resource-apis' when HCP") } else { require.NoError(t, err) } } } func TestBuilder_CloudConfigWithEnvironmentVars(t *testing.T) { tests := map[string]struct { hcl string env map[string]string expected hcpconfig.CloudConfig }{ "ConfigurationOnly": { hcl: `cloud{ resource_id = "config-resource-id" client_id = "config-client-id" client_secret = "config-client-secret" auth_url = "auth.config.com" hostname = "api.config.com" scada_address = "scada.config.com"}`, expected: hcpconfig.CloudConfig{ ResourceID: "config-resource-id", ClientID: "config-client-id", ClientSecret: "config-client-secret", AuthURL: "auth.config.com", Hostname: "api.config.com", ScadaAddress: "scada.config.com", }, }, "EnvVarsOnly": { env: map[string]string{ "HCP_RESOURCE_ID": "env-resource-id", "HCP_CLIENT_ID": "env-client-id", "HCP_CLIENT_SECRET": "env-client-secret", "HCP_AUTH_URL": "auth.env.com", "HCP_API_ADDRESS": "api.env.com", "HCP_SCADA_ADDRESS": "scada.env.com", }, expected: hcpconfig.CloudConfig{ ResourceID: "env-resource-id", ClientID: "env-client-id", ClientSecret: "env-client-secret", AuthURL: "auth.env.com", Hostname: "api.env.com", ScadaAddress: "scada.env.com", }, }, "EnvVarsOverrideConfig": { hcl: `cloud{ resource_id = "config-resource-id" client_id = "config-client-id" client_secret = "config-client-secret" auth_url = "auth.config.com" hostname = "api.config.com" scada_address = "scada.config.com"}`, env: map[string]string{ "HCP_RESOURCE_ID": "env-resource-id", "HCP_CLIENT_ID": "env-client-id", "HCP_CLIENT_SECRET": "env-client-secret", "HCP_AUTH_URL": "auth.env.com", "HCP_API_ADDRESS": "api.env.com", "HCP_SCADA_ADDRESS": "scada.env.com", }, expected: hcpconfig.CloudConfig{ ResourceID: "env-resource-id", ClientID: "env-client-id", ClientSecret: "env-client-secret", AuthURL: "auth.env.com", Hostname: "api.env.com", ScadaAddress: "scada.env.com", }, }, "Combination": { hcl: `cloud{ resource_id = "config-resource-id" client_id = "config-client-id" client_secret = "config-client-secret"}`, env: map[string]string{ "HCP_AUTH_URL": "auth.env.com", "HCP_API_ADDRESS": "api.env.com", "HCP_SCADA_ADDRESS": "scada.env.com", }, expected: hcpconfig.CloudConfig{ ResourceID: "config-resource-id", ClientID: "config-client-id", ClientSecret: "config-client-secret", AuthURL: "auth.env.com", Hostname: "api.env.com", ScadaAddress: "scada.env.com", }, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { for k, v := range tc.env { t.Setenv(k, v) } devMode := true builderOpts := LoadOpts{ DevMode: &devMode, Overrides: []Source{ FileSource{ Name: "overrides", Format: "hcl", Data: tc.hcl, }, }, } loaded, err := Load(builderOpts) require.NoError(t, err) nodeName, err := os.Hostname() require.NoError(t, err) tc.expected.NodeName = nodeName actual := loaded.RuntimeConfig.Cloud require.Equal(t, tc.expected, actual) }) } }