package config import ( "bytes" "context" "crypto/tls" "fmt" "io" "net" "net/http" "os" "path/filepath" "reflect" "strings" "testing" "time" "github.com/fleetdm/fleet/v4/pkg/testutils" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/net/http2" yaml "gopkg.in/yaml.v2" ) func TestConfigRoundtrip(t *testing.T) { // This test verifies that a config can be roundtripped through yaml. // Doing so ensures that config_dump will provide the correct config. // Newly added config values will automatically be tested in this // function because of the reflection on the config struct. // viper tries to load config from the environment too, clear it in case // any config values are set in the environment. // save the current env before clearing it. testutils.SaveEnv(t) os.Clearenv() cmd := &cobra.Command{} // Leaving this flag unset means that no attempt will be made to load // the config file cmd.PersistentFlags().StringP("config", "c", "", "Path to a configuration file") man := NewManager(cmd) // Use reflection magic to walk the config struct, setting unique // values to be verified on the roundtrip. Note that bools are always // set to true, which could false positive if the default value is // true. original := &FleetConfig{} v := reflect.ValueOf(original) for conf_index := 0; conf_index < v.Elem().NumField(); conf_index++ { conf_v := v.Elem().Field(conf_index) conf_t := conf_v.Type() for key_index := 0; key_index < conf_v.NumField(); key_index++ { // ignore unexported fields if !conf_t.Field(key_index).IsExported() { continue } key_v := conf_v.Field(key_index) switch key_v.Interface().(type) { case string: switch conf_v.Type().Field(key_index).Name { case "TLSProfile": // we have to explicitly set value for this key as it will only // accept intermediate or modern key_v.SetString(TLSProfileModern) case "EnableAsyncHostProcessing": // supports a bool or per-task config key_v.SetString("true") case "AsyncHostCollectInterval", "AsyncHostCollectLockTimeout": // supports a duration or per-task config key_v.SetString("30s") // These are deprecated field names in the S3 config. Set them to zero value, which leads to the new fields being populated instead. case "Bucket", "Prefix", "Region", "EndpointURL", "AccessKeyID", "SecretAccessKey", "StsAssumeRoleArn", "StsExternalID": key_v.SetString("") // This is a deprecated config for "Fleet Sandbox" that doesn't exist anymore. case "GlobalEnrollSecret": key_v.SetString("") default: key_v.SetString(v.Elem().Type().Field(conf_index).Name + "_" + conf_v.Type().Field(key_index).Name) } case int: key_v.SetInt(int64(conf_index*100 + key_index)) case bool: switch conf_v.Type().Field(key_index).Name { // These are deprecated field names in the S3 config. Set them to zero value, which leads to the new fields being populated instead. case "DisableSSL", "ForceS3PathStyle": key_v.SetBool(false) default: key_v.SetBool(true) } case time.Duration: d := time.Duration(conf_index*100 + key_index) key_v.Set(reflect.ValueOf(d)) } } } // Marshal the generated config buf, err := yaml.Marshal(original) require.NoError(t, err) t.Log(string(buf)) // Manually load the serialized config man.viper.SetConfigType("yaml") err = man.viper.ReadConfig(bytes.NewReader(buf)) require.Nil(t, err) // Ensure the read config is the same as the original actual := man.LoadConfig() assert.Equal(t, *original, actual) } func TestConfigOsqueryAsync(t *testing.T) { cases := []struct { desc string yaml string envVars []string panics bool wantLabelCfg AsyncProcessingConfig }{ { desc: "default", wantLabelCfg: AsyncProcessingConfig{ Enabled: false, CollectInterval: 30 * time.Second, CollectMaxJitterPercent: 10, CollectLockTimeout: 1 * time.Minute, CollectLogStatsInterval: 1 * time.Minute, InsertBatch: 2000, DeleteBatch: 2000, UpdateBatch: 1000, RedisPopCount: 1000, RedisScanKeysCount: 1000, }, }, { desc: "yaml set enabled true", yaml: ` osquery: enable_async_host_processing: true`, wantLabelCfg: AsyncProcessingConfig{ Enabled: true, CollectInterval: 30 * time.Second, CollectMaxJitterPercent: 10, CollectLockTimeout: 1 * time.Minute, CollectLogStatsInterval: 1 * time.Minute, InsertBatch: 2000, DeleteBatch: 2000, UpdateBatch: 1000, RedisPopCount: 1000, RedisScanKeysCount: 1000, }, }, { desc: "yaml set enabled invalid", yaml: ` osquery: enable_async_host_processing: nope`, panics: true, }, { desc: "yaml set enabled per-task", yaml: ` osquery: enable_async_host_processing: label_membership=true&policy_membership=false`, wantLabelCfg: AsyncProcessingConfig{ Enabled: true, CollectInterval: 30 * time.Second, CollectMaxJitterPercent: 10, CollectLockTimeout: 1 * time.Minute, CollectLogStatsInterval: 1 * time.Minute, InsertBatch: 2000, DeleteBatch: 2000, UpdateBatch: 1000, RedisPopCount: 1000, RedisScanKeysCount: 1000, }, }, { desc: "yaml set invalid per-task", yaml: ` osquery: enable_async_host_processing: label_membership=nope&policy_membership=false`, panics: true, }, { desc: "envvar set enabled", envVars: []string{"FLEET_OSQUERY_ENABLE_ASYNC_HOST_PROCESSING=true"}, wantLabelCfg: AsyncProcessingConfig{ Enabled: true, CollectInterval: 30 * time.Second, CollectMaxJitterPercent: 10, CollectLockTimeout: 1 * time.Minute, CollectLogStatsInterval: 1 * time.Minute, InsertBatch: 2000, DeleteBatch: 2000, UpdateBatch: 1000, RedisPopCount: 1000, RedisScanKeysCount: 1000, }, }, { desc: "envvar set enabled on", envVars: []string{"FLEET_OSQUERY_ENABLE_ASYNC_HOST_PROCESSING=on"}, // on/off, yes/no is only valid in yaml panics: true, }, { desc: "envvar set enabled per task", envVars: []string{"FLEET_OSQUERY_ENABLE_ASYNC_HOST_PROCESSING=policy_membership=false&label_membership=true"}, wantLabelCfg: AsyncProcessingConfig{ Enabled: true, CollectInterval: 30 * time.Second, CollectMaxJitterPercent: 10, CollectLockTimeout: 1 * time.Minute, CollectLogStatsInterval: 1 * time.Minute, InsertBatch: 2000, DeleteBatch: 2000, UpdateBatch: 1000, RedisPopCount: 1000, RedisScanKeysCount: 1000, }, }, { desc: "yaml collect interval lock timeout", yaml: ` osquery: enable_async_host_processing: true async_host_collect_interval: 10s async_host_collect_lock_timeout: 20s`, wantLabelCfg: AsyncProcessingConfig{ Enabled: true, CollectInterval: 10 * time.Second, CollectMaxJitterPercent: 10, CollectLockTimeout: 20 * time.Second, CollectLogStatsInterval: 1 * time.Minute, InsertBatch: 2000, DeleteBatch: 2000, UpdateBatch: 1000, RedisPopCount: 1000, RedisScanKeysCount: 1000, }, }, { desc: "yaml collect interval lock timeout per task", yaml: ` osquery: enable_async_host_processing: true async_host_collect_interval: label_membership=10s async_host_collect_lock_timeout: policy_membership=20s`, wantLabelCfg: AsyncProcessingConfig{ Enabled: true, CollectInterval: 10 * time.Second, CollectMaxJitterPercent: 10, CollectLockTimeout: 1 * time.Minute, CollectLogStatsInterval: 1 * time.Minute, InsertBatch: 2000, DeleteBatch: 2000, UpdateBatch: 1000, RedisPopCount: 1000, RedisScanKeysCount: 1000, }, }, { desc: "yaml env var override", yaml: ` osquery: enable_async_host_processing: false async_host_collect_interval: label_membership=10s async_host_collect_lock_timeout: policy_membership=20s async_host_insert_batch: 10`, envVars: []string{ "FLEET_OSQUERY_ENABLE_ASYNC_HOST_PROCESSING=policy_membership=false&label_membership=true", "FLEET_OSQUERY_ASYNC_HOST_COLLECT_INTERVAL=policy_membership=30s&label_membership=50s", "FLEET_OSQUERY_ASYNC_HOST_COLLECT_LOCK_TIMEOUT=40s", }, wantLabelCfg: AsyncProcessingConfig{ Enabled: true, CollectInterval: 50 * time.Second, CollectMaxJitterPercent: 10, CollectLockTimeout: 40 * time.Second, CollectLogStatsInterval: 1 * time.Minute, InsertBatch: 10, DeleteBatch: 2000, UpdateBatch: 1000, RedisPopCount: 1000, RedisScanKeysCount: 1000, }, }, } for _, c := range cases { t.Run(c.desc, func(t *testing.T) { var cmd cobra.Command // Leaving this flag unset means that no attempt will be made to load // the config file cmd.PersistentFlags().StringP("config", "c", "", "Path to a configuration file") man := NewManager(&cmd) // load the yaml config man.viper.SetConfigType("yaml") require.NoError(t, man.viper.ReadConfig(strings.NewReader(c.yaml))) // TODO: tried to test command-line flags too by using cmd.SetArgs to // test-case values, but that didn't seem to work, not sure how it can // be done in our particular setup. // save the current env before clearing it. testutils.SaveEnv(t) os.Clearenv() for _, env := range c.envVars { kv := strings.SplitN(env, "=", 2) t.Setenv(kv[0], kv[1]) } var loadedCfg FleetConfig if c.panics { require.Panics(t, func() { loadedCfg = man.LoadConfig() }) } else { require.NotPanics(t, func() { loadedCfg = man.LoadConfig() }) got := loadedCfg.Osquery.AsyncConfigForTask(AsyncTaskLabelMembership) require.Equal(t, c.wantLabelCfg, got) } }) } } func TestToTLSConfig(t *testing.T) { dir := t.TempDir() caFile, certFile, keyFile, garbageFile := filepath.Join(dir, "ca"), filepath.Join(dir, "cert"), filepath.Join(dir, "key"), filepath.Join(dir, "garbage") require.NoError(t, os.WriteFile(caFile, testCA, 0o600)) require.NoError(t, os.WriteFile(certFile, testCert, 0o600)) require.NoError(t, os.WriteFile(keyFile, testKey, 0o600)) require.NoError(t, os.WriteFile(garbageFile, []byte("zzzz"), 0o600)) cases := []struct { name string in TLS errContains string }{ {"zero", TLS{}, ""}, {"invalid file", TLS{TLSCA: "/no/such/file"}, "no such file"}, {"CA", TLS{TLSCA: caFile}, ""}, {"invalid CA content", TLS{TLSCA: garbageFile}, "failed to append PEM"}, {"CA invalid cert", TLS{TLSCA: caFile, TLSCert: "/no/such/file"}, "no such file"}, {"CA invalid key", TLS{TLSCA: caFile, TLSCert: certFile, TLSKey: "/no/such/file"}, "no such file"}, {"CA cert key", TLS{TLSCA: caFile, TLSCert: certFile, TLSKey: keyFile}, ""}, {"CA invalid cert content", TLS{TLSCA: caFile, TLSCert: garbageFile, TLSKey: keyFile}, "failed to find any PEM data"}, {"CA invalid key content", TLS{TLSCA: caFile, TLSCert: certFile, TLSKey: garbageFile}, "failed to find any PEM data"}, {"CA cert key server", TLS{TLSCA: caFile, TLSCert: certFile, TLSKey: keyFile, TLSServerName: "abc"}, ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, err := c.in.ToTLSConfig() if c.errContains != "" { require.Error(t, err) require.Nil(t, got) require.Contains(t, err.Error(), c.errContains) return } require.NoError(t, err) require.NotNil(t, got) // root ca is required if TLSCA is set if c.in.TLSCA != "" { require.NotNil(t, got.RootCAs) } else { require.Nil(t, got.RootCAs) } require.Equal(t, got.ServerName, c.in.TLSServerName) if c.in.TLSCert != "" { require.Len(t, got.Certificates, 1) } else { require.Nil(t, got.Certificates) } }) } } func TestAppleAPNSSCEPConfig(t *testing.T) { dir := t.TempDir() certFile, keyFile, garbageFile, invalidKeyFile := filepath.Join(dir, "cert"), filepath.Join(dir, "key"), filepath.Join(dir, "garbage"), filepath.Join(dir, "invalid_key") require.NoError(t, os.WriteFile(certFile, testCert, 0o600)) require.NoError(t, os.WriteFile(keyFile, testKey, 0o600)) require.NoError(t, os.WriteFile(garbageFile, []byte("zzzz"), 0o600)) require.NoError(t, os.WriteFile(invalidKeyFile, unrelatedTestKey, 0o600)) cases := []struct { name string in MDMConfig errMatches string }{ {"missing cert", MDMConfig{AppleAPNsKey: keyFile, AppleSCEPKey: keyFile}, `Apple MDM (APNs|SCEP) configuration: no certificate provided`}, {"missing key", MDMConfig{AppleAPNsCert: certFile, AppleSCEPCert: certFile}, "Apple MDM (APNs|SCEP) configuration: no key provided"}, {"missing cert with raw key", MDMConfig{AppleAPNsKeyBytes: string(testKey), AppleSCEPKeyBytes: string(testKey)}, `Apple MDM (APNs|SCEP) configuration: no certificate provided`}, {"missing key with raw cert", MDMConfig{AppleAPNsCertBytes: string(testCert), AppleSCEPCertBytes: string(testCert)}, "Apple MDM (APNs|SCEP) configuration: no key provided"}, {"cert file does not exist", MDMConfig{AppleAPNsCert: "no-such-file", AppleAPNsKey: keyFile, AppleSCEPCert: "no-such-file", AppleSCEPKey: keyFile}, `open no-such-file: no such file or directory`}, {"key file does not exist", MDMConfig{AppleAPNsKey: "no-such-file", AppleAPNsCert: certFile, AppleSCEPKey: "no-such-file", AppleSCEPCert: certFile}, `open no-such-file: no such file or directory`}, {"valid file pairs", MDMConfig{AppleAPNsCert: certFile, AppleAPNsKey: keyFile, AppleSCEPCert: certFile, AppleSCEPKey: keyFile}, ""}, {"valid file/raw pairs", MDMConfig{AppleAPNsCert: certFile, AppleAPNsKeyBytes: string(testKey), AppleSCEPCert: certFile, AppleSCEPKeyBytes: string(testKey)}, ""}, {"valid raw/file pairs", MDMConfig{AppleAPNsCertBytes: string(testCert), AppleAPNsKey: keyFile, AppleSCEPCertBytes: string(testCert), AppleSCEPKey: keyFile}, ""}, {"invalid file pairs", MDMConfig{AppleAPNsCert: certFile, AppleAPNsKey: invalidKeyFile, AppleSCEPCert: certFile, AppleSCEPKey: invalidKeyFile}, "tls: private key does not match public key"}, {"invalid file/raw pairs", MDMConfig{AppleAPNsCert: certFile, AppleAPNsKeyBytes: string(unrelatedTestKey), AppleSCEPCert: certFile, AppleSCEPKeyBytes: string(unrelatedTestKey)}, "tls: private key does not match public key"}, {"invalid raw/file pairs", MDMConfig{AppleAPNsCertBytes: string(testCert), AppleAPNsKey: invalidKeyFile, AppleSCEPCertBytes: string(testCert), AppleSCEPKey: invalidKeyFile}, "tls: private key does not match public key"}, {"invalid file key", MDMConfig{AppleAPNsCert: certFile, AppleAPNsKey: garbageFile, AppleSCEPCert: certFile, AppleSCEPKey: garbageFile}, "tls: failed to find any PEM data"}, {"invalid raw key", MDMConfig{AppleAPNsCert: certFile, AppleAPNsKeyBytes: "zzzz", AppleSCEPCert: certFile, AppleSCEPKeyBytes: "zzzz"}, "tls: failed to find any PEM data"}, {"invalid raw cert", MDMConfig{AppleAPNsCertBytes: "zzzz", AppleAPNsKey: keyFile, AppleSCEPCertBytes: "zzzz", AppleSCEPKey: keyFile}, "tls: failed to find any PEM data in certificate input"}, {"duplicate cert", MDMConfig{AppleAPNsCert: certFile, AppleAPNsCertBytes: string(testCert), AppleAPNsKey: keyFile, AppleSCEPCert: certFile, AppleSCEPCertBytes: string(testCert), AppleSCEPKey: keyFile}, `Apple MDM (APNs|SCEP) configuration: only one of the certificate path or bytes must be provided`}, {"duplicate key", MDMConfig{AppleAPNsCert: certFile, AppleAPNsKey: keyFile, AppleAPNsKeyBytes: string(testKey), AppleSCEPCert: certFile, AppleSCEPKey: keyFile, AppleSCEPKeyBytes: string(testKey)}, `Apple MDM (APNs|SCEP) configuration: only one of the key path or bytes must be provided`}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if c.in.AppleAPNsCert != "" || c.in.AppleAPNsCertBytes != "" || c.in.AppleAPNsKey != "" || c.in.AppleAPNsKeyBytes != "" { got, pemCert, pemKey, err := c.in.AppleAPNs() if c.errMatches != "" { require.Error(t, err) require.Nil(t, got) require.Regexp(t, c.errMatches, err.Error()) } else { require.NoError(t, err) require.NotNil(t, got) require.NotNil(t, got.Leaf) // APNs cert is parsed and stored require.NotEmpty(t, pemCert) require.NotEmpty(t, pemKey) } } if c.in.AppleSCEPCert != "" || c.in.AppleSCEPCertBytes != "" || c.in.AppleSCEPKey != "" || c.in.AppleSCEPKeyBytes != "" { got, pemCert, pemKey, err := c.in.AppleSCEP() if c.errMatches != "" { require.Error(t, err) require.Nil(t, got) require.Regexp(t, c.errMatches, err.Error()) } else { require.NoError(t, err) require.NotNil(t, got) require.NotNil(t, got.Leaf) // SCEP cert is not kept, not needed require.NotEmpty(t, pemCert) require.NotEmpty(t, pemKey) } } }) } } func TestAppleBMConfig(t *testing.T) { dir := t.TempDir() certFile, keyFile, garbageFile, invalidKeyFile := filepath.Join(dir, "cert"), filepath.Join(dir, "key"), filepath.Join(dir, "garbage"), filepath.Join(dir, "invalid_key") require.NoError(t, os.WriteFile(certFile, testCert, 0o600)) require.NoError(t, os.WriteFile(keyFile, testKey, 0o600)) require.NoError(t, os.WriteFile(garbageFile, []byte("zzzz"), 0o600)) require.NoError(t, os.WriteFile(invalidKeyFile, unrelatedTestKey, 0o600)) cases := []struct { name string in MDMConfig errMatches string }{ {"missing cert", MDMConfig{AppleBMKey: keyFile, AppleBMServerToken: garbageFile}, `Apple BM configuration: no certificate provided`}, {"missing key", MDMConfig{AppleBMCert: certFile, AppleBMServerToken: garbageFile}, "Apple BM configuration: no key provided"}, {"missing cert with raw key", MDMConfig{AppleBMKeyBytes: string(testKey), AppleBMServerToken: garbageFile}, `Apple BM configuration: no certificate provided`}, {"missing key with raw cert", MDMConfig{AppleBMCertBytes: string(testCert), AppleBMServerToken: garbageFile}, "Apple BM configuration: no key provided"}, {"cert file does not exist", MDMConfig{AppleBMCert: "no-such-file", AppleBMKey: keyFile, AppleBMServerToken: garbageFile}, `open no-such-file: no such file or directory`}, {"key file does not exist", MDMConfig{AppleBMKey: "no-such-file", AppleBMCert: certFile, AppleBMServerToken: garbageFile}, `open no-such-file: no such file or directory`}, {"invalid file pairs", MDMConfig{AppleBMCert: certFile, AppleBMKey: invalidKeyFile, AppleBMServerToken: garbageFile}, "tls: private key does not match public key"}, {"invalid file/raw pairs", MDMConfig{AppleBMCert: certFile, AppleBMKeyBytes: string(unrelatedTestKey), AppleBMServerToken: garbageFile}, "tls: private key does not match public key"}, {"invalid raw/file pairs", MDMConfig{AppleBMCertBytes: string(testCert), AppleBMKey: invalidKeyFile, AppleBMServerToken: garbageFile}, "tls: private key does not match public key"}, {"invalid file key", MDMConfig{AppleBMCert: certFile, AppleBMKey: garbageFile, AppleBMServerToken: garbageFile}, "tls: failed to find any PEM data"}, {"invalid raw key", MDMConfig{AppleBMCert: certFile, AppleBMKeyBytes: "zzzz", AppleBMServerToken: garbageFile}, "tls: failed to find any PEM data"}, {"invalid raw cert", MDMConfig{AppleBMCertBytes: "zzzz", AppleBMKey: keyFile, AppleBMServerToken: garbageFile}, "tls: failed to find any PEM data in certificate input"}, {"duplicate cert", MDMConfig{AppleBMCert: certFile, AppleBMCertBytes: string(testCert), AppleBMKey: keyFile, AppleBMServerToken: garbageFile}, `Apple BM configuration: only one of the certificate path or bytes must be provided`}, {"duplicate key", MDMConfig{AppleBMCert: certFile, AppleBMKey: keyFile, AppleBMKeyBytes: string(testKey), AppleBMServerToken: garbageFile}, `Apple BM configuration: only one of the key path or bytes must be provided`}, {"token file does not exist", MDMConfig{AppleBMCert: certFile, AppleBMKey: keyFile, AppleBMServerToken: "no-such-file"}, `Apple BM configuration: reading token file: open no-such-file: no such file or directory`}, {"invalid token file", MDMConfig{AppleBMCert: certFile, AppleBMKey: keyFile, AppleBMServerToken: garbageFile}, `Apple BM configuration: decrypt token: malformed MIME header: missing colon: "zzzz"`}, {"invalid raw token file", MDMConfig{AppleBMCert: certFile, AppleBMKey: keyFile, AppleBMServerTokenBytes: "zzzz"}, `Apple BM configuration: decrypt token: malformed MIME header: missing colon: "zzzz"`}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { _, err := c.in.AppleBM() require.Error(t, err) require.Regexp(t, c.errMatches, err.Error()) }) } } func TestMicrosoftWSTEPConfig(t *testing.T) { dir := t.TempDir() certFile, keyFile, garbageFile, invalidKeyFile := filepath.Join(dir, "cert"), filepath.Join(dir, "key"), filepath.Join(dir, "garbage"), filepath.Join(dir, "invalid_key") require.NoError(t, os.WriteFile(certFile, testCert, 0o600)) require.NoError(t, os.WriteFile(keyFile, testKey, 0o600)) require.NoError(t, os.WriteFile(garbageFile, []byte("zzzz"), 0o600)) require.NoError(t, os.WriteFile(invalidKeyFile, unrelatedTestKey, 0o600)) cases := []struct { name string in MDMConfig errMatches string }{ {"missing cert", MDMConfig{WindowsWSTEPIdentityKey: keyFile}, `Microsoft MDM WSTEP configuration: no certificate provided`}, {"missing key", MDMConfig{WindowsWSTEPIdentityCert: certFile}, "Microsoft MDM WSTEP configuration: no key provided"}, {"cert file does not exist", MDMConfig{WindowsWSTEPIdentityCert: "no-such-file", WindowsWSTEPIdentityKey: keyFile}, `open no-such-file: no such file or directory`}, {"key file does not exist", MDMConfig{WindowsWSTEPIdentityKey: "no-such-file", WindowsWSTEPIdentityCert: certFile}, `open no-such-file: no such file or directory`}, {"valid file pairs", MDMConfig{WindowsWSTEPIdentityCert: certFile, WindowsWSTEPIdentityKey: keyFile}, ""}, {"valid file/raw pairs", MDMConfig{WindowsWSTEPIdentityCert: certFile, WindowsWSTEPIdentityKeyBytes: string(testKey)}, ""}, {"valid raw/file pairs", MDMConfig{WindowsWSTEPIdentityCertBytes: string(testCert), WindowsWSTEPIdentityKey: keyFile}, ""}, {"invalid file pairs", MDMConfig{WindowsWSTEPIdentityCert: certFile, WindowsWSTEPIdentityKey: invalidKeyFile}, "tls: private key does not match public key"}, {"invalid file key", MDMConfig{WindowsWSTEPIdentityCert: certFile, WindowsWSTEPIdentityKey: garbageFile}, "tls: failed to find any PEM data"}, {"invalid file cert", MDMConfig{WindowsWSTEPIdentityCert: garbageFile, WindowsWSTEPIdentityKey: keyFile}, "tls: failed to find any PEM data"}, {"invalid file/raw pairs", MDMConfig{WindowsWSTEPIdentityCert: certFile, WindowsWSTEPIdentityKeyBytes: string(unrelatedTestKey)}, "tls: private key does not match public key"}, {"invalid raw/file pairs", MDMConfig{WindowsWSTEPIdentityCertBytes: string(testCert), WindowsWSTEPIdentityKey: invalidKeyFile}, "tls: private key does not match public key"}, {"invalid file key", MDMConfig{WindowsWSTEPIdentityCert: certFile, WindowsWSTEPIdentityKey: garbageFile}, "tls: failed to find any PEM data"}, {"invalid raw key", MDMConfig{WindowsWSTEPIdentityCert: certFile, WindowsWSTEPIdentityKeyBytes: "zzzz"}, "tls: failed to find any PEM data"}, {"invalid raw cert", MDMConfig{WindowsWSTEPIdentityCertBytes: "zzzz", WindowsWSTEPIdentityKey: keyFile}, "tls: failed to find any PEM data in certificate input"}, {"duplicate cert", MDMConfig{WindowsWSTEPIdentityCert: certFile, WindowsWSTEPIdentityCertBytes: string(testCert), WindowsWSTEPIdentityKey: keyFile}, `Microsoft MDM WSTEP configuration: only one of the certificate path or bytes must be provided`}, {"duplicate key", MDMConfig{WindowsWSTEPIdentityCert: certFile, WindowsWSTEPIdentityKey: keyFile, WindowsWSTEPIdentityKeyBytes: string(testKey)}, `Microsoft MDM WSTEP configuration: only one of the key path or bytes must be provided`}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if c.in.WindowsWSTEPIdentityCert != "" || c.in.WindowsWSTEPIdentityKey != "" { got, pemCert, pemKey, err := c.in.MicrosoftWSTEP() if c.errMatches != "" { require.Error(t, err) require.Nil(t, got) require.Regexp(t, c.errMatches, err.Error()) } else { require.NoError(t, err) require.NotNil(t, got) require.NotNil(t, got.Leaf) // TODO: confirm cert is not kept, not needed? require.NotEmpty(t, pemCert) require.NotEmpty(t, pemKey) } } }) } } var ( testCA = []byte(`-----BEGIN CERTIFICATE----- MIIFSzCCAzOgAwIBAgIUf4lOcb9bkN2+u6FjWL0fSFCjGGgwDQYJKoZIhvcNAQEL BQAwNTETMBEGA1UECgwKUmVkaXMgVGVzdDEeMBwGA1UEAwwVQ2VydGlmaWNhdGUg QXV0aG9yaXR5MB4XDTIxMTAxOTEyNTEwNloXDTMxMTAxNzEyNTEwNlowNTETMBEG A1UECgwKUmVkaXMgVGVzdDEeMBwGA1UEAwwVQ2VydGlmaWNhdGUgQXV0aG9yaXR5 MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA02LNfNKjI/PwV4F2CVix vVfFN41yxMKYkapTrvC1nc7lVmG5oxxgOIUpFT+7xj0+h2bBqR+t3eiFiaudz3Yc 9eG2J7BTtMST9QmQtNEyeC17TZxf4XB2EA68dYC24XaHBnSFsPg8/axlIVi1Hz7b QmDRNY/X3cc3nzGxuuk3NnSN7s1UlKnZ1v0YZGwWhYD3iAv7kQcI3WYF0TF0nc2a OXb68/AOghq9Z9zLk1ULIfTmT0fcJRsFssWClF7E378PSk0qjB6NEKADVyWq3d2g 8ValKmbKvAacsGxb2EXAPCJsBil0Sv7jAsl1hVfMCBwj6LfPKvn7/K8vbKz7Gtrw COWVJtzaBrKzpjOTXQp9RnuqlDUZackTmn9hlCMLgapEC+j7PNvS8cyAbOz9bpEk wdF/wrvUVsJc74+MXzEK7DWBKD2lP9nvY+0DrYJ/55KH1wbIH1RncLm6s6M4Zc9L YfaeTuklimAOlx8WvuYQUJpxTh6gT4xWqZG2p8IcjxVp2Sl7eYtlaE/u7Ixc+Bfd QpTaBXrtcQzttPNiSZM8b+nNL05p+LxtSVAYUu1Yc0hWBHJBb/dkDibOU3Mi8Aio bvpsBp1RLfXSrRMOpXS3w4G1THrhC4IC1KkUbZ8EQaBlwa7mlwV8hxZOjJQ7Mf4D Z8WEh1j/XH/zlKVJon2aUWUCAwEAAaNTMFEwHQYDVR0OBBYEFIDJJVTvQCl1vMIi 246T25FZVBsWMB8GA1UdIwQYMBaAFIDJJVTvQCl1vMIi246T25FZVBsWMA8GA1Ud EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBAGZqxsaleZqmljrqrpL5JxoQ G9/9tvfw5WYqeJ6r8s86HfxaqsEUemzSBb7HFJS42Ik6ghd32d62wp7xLxtQY8As jvU9YZ2s42tSWgxch8kY/kgCjwsqTFViWmyxmc05TxulRr8BonIo8YAU6/5kBam+ sV5nfbBse5i9+nQqmjzVI7lVp7lIk+T9T4UsdH/mtbWv8cJjCBzbyObU+V9kjTSQ O+cshOn59IMRvAkySKIHvm7keO4skazo2RMjdME9KW/ydc7iQ9YC0+MiDQF+eIAP a/SGdTD8W/WNXT1rtD4DyTEZK1modAI7KukkrTwlaTW0GwssLq5TpwzQKK5W/ANZ SU44yILArQrWZgXXxBBfGAH/asd4JgIxal/iM0hlYh6WYdSUa/QzJFFRngtE52jL M1sTsUgXjItspH79oUD+my4ioDv6r2CAnlxl2MvqGzfBgItb5yq3bBwxNe/qOzWR PbKbp3UvlzMbbpbeJHO2NHnu7Hha9mV3yr9+lsTv2SFeKGqFRbC7v+9kSDu6eOyC lnARbzReZyZiYr9vCTxH76wCyUBBg7p59ZriBw0yaXvXcr4cO8IUPx4aPe9nHkbC 8G/rnKycuGGIDjslRTOJodxf2ud2UPYUTZDBi1QoV4+jzWKUjUxuHuN2WIwxnXKB cJap0OI7VFpOjIJLzXRQ -----END CERTIFICATE-----`) testCert = []byte(`-----BEGIN CERTIFICATE----- MIID6DCCAdACFGX99Sw4aF2qKGLucoIWQRAXHrs1MA0GCSqGSIb3DQEBCwUAMDUx EzARBgNVBAoMClJlZGlzIFRlc3QxHjAcBgNVBAMMFUNlcnRpZmljYXRlIEF1dGhv cml0eTAeFw0yMTEwMTkxNzM0MzlaFw0yMjEwMTkxNzM0MzlaMCwxEzARBgNVBAoM ClJlZGlzIFRlc3QxFTATBgNVBAMMDEdlbmVyaWMtY2VydDCCASIwDQYJKoZIhvcN AQEBBQADggEPADCCAQoCggEBAKSHcH8EjSvp3Nm4IHAFxG9DZm8+0h1BwU0OX0VH cJ+Cf+f6h0XYMcMo9LFEpnUJRRMjKrM4mkI75NIIufNBN+GrtqqTPTid8wfOGu/U fa5EEU1hb2j7AiMlpM6i0+ZysXSNo+Vc/cNZT0PXfyOtJnYm6p9WZM84ID1t2ea0 bLwC12cTKv5oybVGtJHh76TRxAR3FeQ9+SY30vUAxYm6oWyYho8rRdKtUSe11pXj 6OhxxfTZnsSWn4lo0uBpXai63XtieTVpz74htSNC1bunIGv7//m5F60sH5MrF5JS kPxfCfgqski84ICDSRNlvpT+eMPiygAAJ8zY8wYUXRYFYTUCAwEAATANBgkqhkiG 9w0BAQsFAAOCAgEAAAw+6Uz2bAcXgQ7fQfdOm+T6FLRBcr8PD4ajOvSu/T+HhVVj E26Qt2IBwFEYve2FvDxrBCF8aQYZcyQqnP8bdKebnWAaqL8BbTwLWW+fDuZLO2b4 QHjAEdEKKdZC5/FRpQrkerf5CCPTHE+5M17OZg41wdVYnCEwJOkP5pUAVsmwtrSw VeIquy20TZO0qbscDQETf7NIJgW0IXg82wBe53Rv4/wL3Ybq13XVRGYiJrwpaNTf UNgsDWqgwlQ5L2GOLDgg8S2NoF9mWVgCGSp3a2eHW+EmBRQ1OP6EYQtIhKdGLrSn dAOMJ2ER1pgHWUFKkWQaZ9i37Dx2j7P5c4/XNeVozcRQcLwKwN+n8k+bwIYcTX0H MOVFYm+WiFi/gjI860Tx853Sc0nkpOXmBCeHSXigGUscgjBYbmJz4iExXuwgawLX KLDKs0yyhLDnKEjmx/Vhz03JpsVFJ84kSWkTZkYsXiG306TxuJCX9zAt1z+6Clie TTGiFY+D8DfkC4H82rlPEtImpZ6rInsMUlAykImpd58e4PMSa+w/wSHXDvwFP7py 1Gvz3XvcbGLmpBXblxTUpToqC7zSQJhHOMBBt6XnhcRwd6G9Vj/mQM3FvJIrxtKk 8O7FwMJloGivS85OEzCIur5A+bObXbM2pcI8y4ueHE4NtElRBwn859AdB2k= -----END CERTIFICATE-----`) testKey = []byte(testingKey(`-----BEGIN RSA TESTING KEY----- MIIEogIBAAKCAQEApIdwfwSNK+nc2bggcAXEb0Nmbz7SHUHBTQ5fRUdwn4J/5/qH Rdgxwyj0sUSmdQlFEyMqsziaQjvk0gi580E34au2qpM9OJ3zB84a79R9rkQRTWFv aPsCIyWkzqLT5nKxdI2j5Vz9w1lPQ9d/I60mdibqn1ZkzzggPW3Z5rRsvALXZxMq /mjJtUa0keHvpNHEBHcV5D35JjfS9QDFibqhbJiGjytF0q1RJ7XWlePo6HHF9Nme xJafiWjS4GldqLrde2J5NWnPviG1I0LVu6cga/v/+bkXrSwfkysXklKQ/F8J+Cqy SLzggINJE2W+lP54w+LKAAAnzNjzBhRdFgVhNQIDAQABAoIBAAtUbFHC3XnVq+iu PkWYkBNdX9NvTwbGvWnyAGuD5OSHFwnBfck4fwzCaD9Ay/mpPsF3nXwj/LNs7m/s O+ndZty6d2S9qOyaK98wuTgkuNbkRxC+Ee73wgjrkbLNEax/32p4Sn4D7lGid8vj LhUl2k0ult+MEnsWkVnJk8TITeiQaT2AHhMr3HKdaI86hJJfam3wEBiLBglnnKqA TInMqHoudnFOn/C8iVCFuHCE0oo1dMalbc4rlZuRBqezVhbSMWPLypMVXQb7eixM ScJ3m8+DooGDSIe+EW/afhN2VnFbrhQC9/DlxGfwTwsUseWv7pgp53ufyyAzzydn 2plW/4ECgYEA1Va5RzSUDxr75JX003YZiBcYrG268vosiNYWRhE7frvn5EorZBRW t4R70Y2gcXA10aPHzpbq40t6voWtpkfynU3fyRzbBmwfiWLEgckrYMwtcNz8nhG2 ETAg4LXO9CufbwuDa66h76TpkBzQVNc5TSbBUr/apLDWjKPMz6qW7VUCgYEAxW4K Yqp3NgJkC5DhuD098jir9AH96hGhUryOi2CasCvmbjWCgWdolD7SRZJfxOXFOtHv 7Dkp9glA1Cg/nSmEHKslaTJfBIWK+5rqVD6k6kZE/+4QQWQtUxXXVgGINnGrnPvo 6MlRJxqGUtYJ0GRTFJP4Py0gwuzf5BMIwe+fpGECgYAOhLRfMCjTTlbOG5ZpvaPH Kys2sNEEMBpPxaIGaq3N1iPV2WZSjT/JhW6XuDevAJ/pAGhcmtCpXz2fMaG7qzHL mr0cBqaxLTKIOvx8iKA3Gi4NfDyE1Ve6m7fhEv5eh4l2GSZ8cYn7sRFkCVH0NCFm KrkFVKEgjBhNwefySf2zcQKBgHDVPgw7nlv4q9LMX6RbI98eMnAG/2XZ45gUeWcA tAeBX3WXEVoBjoxDBwuJ5z/xjXHbb8JSvT+G9E0MH6cjhgSYb44aoqFD7TV0yP2S u8/Ej0SxewrURO8aKXJW99Edz9WtRuRbwgyWJTSMbRlzbOPy2UrJ8NJWbHK9yiCE YXmhAoGAA3QUiCCl11c1C4VsF68Fa2i7qwnty3fvFidZpW3ds0tzZdIvkpRLp5+u XAJ5+zStdEGdnu0iXALQlY7ektawXguT/zYKg3nfS9RMGW6CxZotn4bqfQwDuttf b1xn1jGQd/o0xFf9ojpDNy6vNojidQGHh6E3h0GYvxbnQmVNq5U= -----END RSA TESTING KEY-----`)) unrelatedTestKey = []byte(testingKey(`-----BEGIN TESTING KEY----- MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDbIFkAXN3M1A2/ xRWwWJcuT+cXeD3+1W0EEHEYT+Ad8zNGB3yTsn7iw8MELalrXbUnKJkkiah8XbSh bw9ngHLHXTIbhvl2ceg7dwWNrK+286k5e/vVH/wwWfImBl8gK6ksoDic5U1fTzGQ 1wkhfqIScD9j0hR7wwUxwJejKxzBq83gl3x5p9JgiVNcIo5h4EowshkVNo+xPuL9 ZBZGUp6w2XBAdOfOzQbqIMbDy/puKz3n5kckAOtcgH+T/AFn/asMCAde+Ym1mPl/ /wR7rPJHJvjrq6TSzga85pZJqszlleFQ74MLm7tXtVMlXHLR86Po9NXG1Z3G7Cna Io4o2F+bAgMBAAECggEABh5jHdV6BAwvzhkMv/3ZStvEUi1zXbhL8P8ciVdBpNRz rBLtcZpcXKymt2km/+5/7nX9wL1vTPm434EgZv15NwPtMEOWl64ak/6A0zHtPiiT ox1JLOxVuGvqjRFEert9X9ehfRASFwU5FxhKEvtcPzOPMZReKg6KCJeeJFpB1U6P l40F37bneQqHfOj+8h1m+7zL07w0Vfl2XMdvM1TKf1KACxBfgIhqKL1TO0LrI/fC iJsL6948sBe//e/Ee11CA8+VcqbrJIlE+wouasFQWhKjJQoGcrdh/3PtgfRfTRWP 3HpOlPSLeizXdwOJOKmZv/XeOGlOpl2xBAC6rO9hAQKBgQDzz5Wxe4e0dBYzra7G V046O9Q34k2Gfi4BlV5NqCnFjcBwvxjOGE/rfjsgQO5bdZWGUqzNIubYvOisNiv9 k1RqQXFibpHmKcWZz6zNHfOpZC6/ACKA72cR2TyOaj8YLk69do5zc4jz7vH6pU5C q6jBIX5nrm62wycdBImpx2JhwQKBgQDmFNaB3dd8xvVtigNUVhAXEkZMj4N3IOiL esvfLEoqiaNMgZTRH2sas1BATxXNAs+PzYTxn2+bq4/63AcSx9Pd4QbwuhN0D0Pp 24ZU5FYWVPtpztWXnTNgfP6rj7MngJmLFjqVJynwt1ZIA2mkymjDyOo7+bEtAXvV evvW/4egWwKBgQDvcnPltxh0FX6oim8XxC7D6nZl3A+fgtTUIUpYoktEBg91q3hF EIONGJAhASQXFsge/5tObHSjcARi/WD+zW8eW99reIQ5s9SpVtizKjNfrVBrrUo1 rulfEibzB02oBfK3CHSm1lUunQFx1F+kAsrdwnNOiHWbcNY9HXPGFld9AQKBgQC0 qFoCILGpzQM63mpc1zLNGtFOHkXIzXMqyeG4u6sEmYw6b2jthzDvBysVQ8PHdNSL goFHw7u7zMtB23BGY9dM2fs8G69Yqv/VaUSh9aRO5q1+WCTIZmvH8H17MlsmwkhN uMeJA/Zfh2VdKCjUdwYp7OFW9GkVAJw+dNG38G6LDwKBgQCC1zsyTKl0Y5DElJc5 e+Z1cALnWREYhEPv4JrR5U0VvqeIdExDD6Ida61yvd7oc59pn0kpfKjozPJr6FsU 2AUs1ibpKVgbzDfDZiX1xWgt1OJ9x9yMi9ZvQAU5oYckiV88VjS5c2PFc0m7g6ik 2GYiMrU/kZ2OfcPxYtZJpZG+kw== -----END TESTING KEY-----`)) ) // prevent static analysis tools from raising issues due to detection of private key // in code. func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") } func TestValidateCloudfrontURL(t *testing.T) { t.Parallel() cases := []struct { name string url string publicKey string privateKey string errMatches string }{ {"happy path", "https://example.com", "public", "private", ""}, {"bad URL", "bozo!://example.com", "public", "private", "parse"}, {"non-HTTPS URL", "http://example.com", "public", "private", "cloudfront url scheme must be https"}, {"missing URL", "", "public", "private", "`s3_software_installers_cloudfront_url` must be set"}, { "missing public key", "https://example.com", "", "private", "Both `s3_software_installers_cloudfront_url_signing_public_key_id` and `s3_software_installers_cloudfront_url_signing_private_key` must be set", }, { "missing private key", "https://example.com", "public", "", "Both `s3_software_installers_cloudfront_url_signing_public_key_id` and `s3_software_installers_cloudfront_url_signing_private_key` must be set", }, { "missing keys", "https://example.com", "", "", "Both `s3_software_installers_cloudfront_url_signing_public_key_id` and `s3_software_installers_cloudfront_url_signing_private_key` must be set", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { s3 := S3Config{ SoftwareInstallersCloudFrontURL: c.url, SoftwareInstallersCloudFrontURLSigningPublicKeyID: c.publicKey, SoftwareInstallersCloudFrontURLSigningPrivateKey: c.privateKey, } initFatal := func(err error, msg string) { if c.errMatches != "" { require.Error(t, err) require.Regexp(t, c.errMatches, err.Error()) } else { t.Errorf("unexpected error: %v", err) } } s3.ValidateCloudFrontURL(initFatal) }) } } func TestAndroidAgentConfigValidate(t *testing.T) { t.Parallel() t.Run("valid when both set", func(t *testing.T) { cfg := AndroidAgentConfig{Package: "com.fleetdm.agent", SigningSHA256: "abc123"} cfg.Validate(func(err error, msg string) { t.Fatalf("unexpected error: %v", err) }) }) t.Run("valid when both empty", func(t *testing.T) { cfg := AndroidAgentConfig{} cfg.Validate(func(err error, msg string) { t.Fatalf("unexpected error: %v", err) }) }) t.Run("invalid when only package set", func(t *testing.T) { cfg := AndroidAgentConfig{Package: "com.fleetdm.agent"} called := false cfg.Validate(func(err error, msg string) { called = true }) require.True(t, called) }) t.Run("invalid when only signing_sha256 set", func(t *testing.T) { cfg := AndroidAgentConfig{SigningSHA256: "abc123"} called := false cfg.Validate(func(err error, msg string) { called = true }) require.True(t, called) }) } func TestServerConfigWithH2C(t *testing.T) { ctx := context.Background() // Create a simple mux mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { protocol := "HTTP/1.1" if r.ProtoMajor == 2 { protocol = "HTTP/2.0" } fmt.Fprintf(w, "ServerConfig test using %s", protocol) }) // Create server config with a random available port config := &ServerConfig{Address: ":0", ForceH2C: true} // Create server using our ServerConfig server := config.DefaultHTTPServer(ctx, mux) // Start the server listener, err := net.Listen("tcp", server.Addr) if err != nil { t.Fatalf("Failed to listen: %v", err) } // Get the actual port port := listener.Addr().(*net.TCPAddr).Port serverURL := fmt.Sprintf("http://localhost:%d", port) // Start server in a goroutine go func() { if err := server.Serve(listener); err != http.ErrServerClosed { t.Logf("Server error: %v", err) } }() // Ensure server is closed at the end of the test defer func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = server.Shutdown(ctx) }() // Test with HTTP/2 client client := &http.Client{ // nolint:gocritic Transport: &http2.Transport{ AllowHTTP: true, DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) { return net.Dial(network, addr) }, }, } // Make request req, err := http.NewRequest("GET", serverURL, nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Failed to make request: %v", err) } defer resp.Body.Close() // Check response body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("Failed to read body: %v", err) } if !strings.Contains(string(body), "HTTP/2.0") { t.Errorf("Expected HTTP/2.0 in response, got: %s", string(body)) } t.Logf("Response from ServerConfig: %s", string(body)) } func TestConditionalAccessConfigValidate(t *testing.T) { t.Parallel() tests := []struct { name string format string expectErr bool }{ { name: "valid hex format", format: CertSerialFormatHex, expectErr: false, }, { name: "valid decimal format", format: CertSerialFormatDecimal, expectErr: false, }, { name: "invalid format", format: "invalid", expectErr: true, }, { name: "empty format", format: "", expectErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := ConditionalAccessConfig{CertSerialFormat: tt.format} called := false cfg.Validate(func(err error, msg string) { called = true }) require.Equal(t, tt.expectErr, called) }) } }