feat: support whole config as env (#6336)

* fix existing env vars

* feat: support all config by env

* cleanup

* remove system users hook

* decode system users in setup
This commit is contained in:
Elio Bischof 2024-02-16 17:04:42 +01:00 committed by GitHub
parent 32c7efea73
commit 19af2f7372
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 278 additions and 85 deletions

View File

@ -1,7 +1,7 @@
Log:
Level: info # ZITADEL_LOG_LEVEL
Formatter:
Format: text # ZITADEL_LOG_LEVEL
Format: text # ZITADEL_LOG_FORMATTER_FORMAT
# Exposes metrics on /debug/metrics
Metrics:
@ -29,7 +29,7 @@ Telemetry:
# As long as Enabled is true, ZITADEL tries to send usage data to the configured Telemetry.Endpoints.
# Data is projected by ZITADEL even if Enabled is false.
# This means that switching this to true makes ZITADEL try to send past data.
Enabled: false
Enabled: false # ZITADEL_TELEMETRY_ENABLED
# Push telemetry data to all these endpoints at least once using an HTTP POST request.
# If one endpoint returns an unsuccessful response code or times out,
# ZITADEL retries to push the data point to all configured endpoints until it succeeds.
@ -40,7 +40,9 @@ Telemetry:
Endpoints:
- https://httpbin.org/post
# These headers are sent with every request to the configured endpoints.
Headers:
# Configure headers by environment variable using a JSON string with header values as arrays, like this:
# ZITADEL_TELEMETRY_HEADERS='{"header1": ["value1"], "header2": ["value2", "value3"]}'
Headers: # ZITADEL_TELEMETRY_HEADERS
# single-value: "single-value"
# multi-value:
# - "multi-value-1"
@ -85,7 +87,7 @@ HTTP2HostHeader: ":authority" # ZITADEL_HTTP2HOSTHEADER
# Header name of HTTP1 calls from which the instance will be matched
HTTP1HostHeader: "host" # ZITADEL_HTTP1HOSTHEADER
WebAuthNName: ZITADEL # ZITADEL_WEBAUTHN_NAME
WebAuthNName: ZITADEL # ZITADEL_WEBAUTHNNAME
Database:
# ZITADEL manages three database connection pools.
@ -170,7 +172,7 @@ Machine:
Enabled: true # ZITADEL_MACHINE_IDENTIFICATION_WEBHOOK_ENABLED
Url: "http://metadata.google.internal/computeMetadata/v1/instance/id" # ZITADEL_MACHINE_IDENTIFICATION_WEBHOOK_URL
Headers:
"Metadata-Flavor": "Google" # ZITADEL_MACHINE_IDENTIFICATION_WEBHOOK_HEADERS
"Metadata-Flavor": "Google"
#
# AWS EC2 IMDSv1 Configuration: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html
# Webhook:
@ -205,7 +207,7 @@ Projections:
# Time interval between scheduled projections
RequeueEvery: 60s # ZITADEL_PROJECTIONS_REQUEUEEVERY
# Time between retried database statements resulting from projected events
RetryFailedAfter: 1s # ZITADEL_PROJECTIONS_RETRYFAILED
RetryFailedAfter: 1s # ZITADEL_PROJECTIONS_RETRYFAILEDAFTER
# Retried execution number of database statements resulting from projected events
MaxFailureCount: 5 # ZITADEL_PROJECTIONS_MAXFAILURECOUNT
# Limit of returned events per query
@ -378,28 +380,28 @@ Console:
EncryptionKeys:
DomainVerification:
EncryptionKeyID: "domainVerificationKey" # ZITADEL_ENCRYPTIONKEYS_DOMAINVERIFICATION_ENCRYPTIONKEYID
DecryptionKeyIDs:
DecryptionKeyIDs: # ZITADEL_ENCRYPTIONKEYS_DOMAINVERIFICATION_DECRYPTIONKEYIDS (comma separated list)
IDPConfig:
EncryptionKeyID: "idpConfigKey" # ZITADEL_ENCRYPTIONKEYS_IDPCONFIG_ENCRYPTIONKEYID
DecryptionKeyIDs:
DecryptionKeyIDs: # ZITADEL_ENCRYPTIONKEYS_IDPCONFIG_DECRYPTIONKEYIDS (comma separated list)
OIDC:
EncryptionKeyID: "oidcKey" # ZITADEL_ENCRYPTIONKEYS_OIDC_ENCRYPTIONKEYID
DecryptionKeyIDs:
DecryptionKeyIDs: # ZITADEL_ENCRYPTIONKEYS_OIDC_DECRYPTIONKEYIDS (comma separated list)
SAML:
EncryptionKeyID: "samlKey" # ZITADEL_ENCRYPTIONKEYS_SAML_ENCRYPTIONKEYID
DecryptionKeyIDs:
DecryptionKeyIDs: # ZITADEL_ENCRYPTIONKEYS_SAML_DECRYPTIONKEYIDS (comma separated list)
OTP:
EncryptionKeyID: "otpKey" # ZITADEL_ENCRYPTIONKEYS_OTP_ENCRYPTIONKEYID
DecryptionKeyIDs:
DecryptionKeyIDs: # ZITADEL_ENCRYPTIONKEYS_OTP_DECRYPTIONKEYIDS (comma separated list)
SMS:
EncryptionKeyID: "smsKey" # ZITADEL_ENCRYPTIONKEYS_SMS_ENCRYPTIONKEYID
DecryptionKeyIDs:
DecryptionKeyIDs: # ZITADEL_ENCRYPTIONKEYS_SMS_DECRYPTIONKEYIDS (comma separated list)
SMTP:
EncryptionKeyID: "smtpKey" # ZITADEL_ENCRYPTIONKEYS_SMTP_ENCRYPTIONKEYID
DecryptionKeyIDs:
DecryptionKeyIDs: # ZITADEL_ENCRYPTIONKEYS_SMTP_DECRYPTIONKEYIDS (comma separated list)
User:
EncryptionKeyID: "userKey" # ZITADEL_ENCRYPTIONKEYS_USER_ENCRYPTIONKEYID
DecryptionKeyIDs:
DecryptionKeyIDs: # ZITADEL_ENCRYPTIONKEYS_USER_DECRYPTIONKEYIDS (comma separated list)
CSRFCookieKeyID: "csrfCookieKey" # ZITADEL_ENCRYPTIONKEYS_CSRFCOOKIEKEYID
UserAgentCookieKeyID: "userAgentCookieKey" # ZITADEL_ENCRYPTIONKEYS_USERAGENTCOOKIEKEYID
@ -426,6 +428,8 @@ SystemAPIUsers:
# - superuser2:
# # If no memberships are specified, the user has a membership of type System with the role "SYSTEM_OWNER"
# KeyData: <base64 encoded key> # or you can directly embed it as base64 encoded value
# Configure the SystemAPIUsers by environment variable using JSON notation:
# ZITADEL_SYSTEMAPIUSERS='{"systemuser":{"Path":"/path/to/superuser/key.pem"},"systemuser2":{"KeyData":"<base64 encoded key>"}}'
#TODO: remove as soon as possible
SystemDefaults:
@ -457,13 +461,13 @@ SystemDefaults:
# Threads: 4 # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_THREADS
# Hasher:
# Algorithm: "scrypt"
# Cost: 15
# Algorithm: "scrypt" # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_ALGORITHM
# Cost: 15 # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_COST
# Hasher:
# Algorithm: "pbkdf2"
# Rounds: 290000
# Hash: "sha256" # Can be "sha1", "sha224", "sha256", "sha384" or "sha512"
# Algorithm: "pbkdf2" # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_ALGORITHM
# Rounds: 290000 # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_ROUNDS
# Hash: "sha256" # Can be "sha1", "sha224", "sha256", "sha384" or "sha512" # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_HASH
# Verifiers enable the possibility of verifying
# passwords that are previously hashed using another
@ -509,7 +513,7 @@ SystemDefaults:
Actions:
HTTP:
# Wildcard sub domains are currently unsupported
DenyList:
DenyList: # ZITADEL_ACTIONS_HTTP_DENYLIST (comma separated list)
- localhost
- "127.0.0.1"
@ -727,6 +731,9 @@ DefaultInstance:
From: # ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_FROM
FromName: # ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_FROMNAME
ReplyToAddress: # ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_REPLYTOADDRESS
# Configure the MessageTexts by environment variable using JSON notation:
# ZITADEL_DEFAULTINSTANCE_MESSAGETEXTS='[{"messageTextType": "InitCode", "title": "My custom title"},{"messageTextType": "PasswordReset", "greeting": "Hi there!"}]'
# Beware that if you configure the MessageTexts by environment variable, all the default MessageTexts are lost.
MessageTexts:
- MessageTextType: InitCode
Language: de
@ -857,7 +864,9 @@ DefaultInstance:
# "actions.all.runs.seconds"
# The sum of all actions run durations in seconds
Items:
# Configure the Items by environment variable using JSON notation:
# ZITADEL_DEFAULTINSTANCE_QUOTAS_ITEMS='[{"unit": "requests.all.authenticated", "notifications": [{"percent": 100}]}]'
Items: # ZITADEL_DEFAULTINSTANCE_QUOTAS_ITEMS
# - Unit: "requests.all.authenticated"
# # From defines the starting time from which the current quota period is calculated.
# # This is relevant for querying the current usage.
@ -884,6 +893,9 @@ DefaultInstance:
AuditLogRetention: 0s # ZITADEL_AUDITLOGRETENTION
InternalAuthZ:
# Configure the RolePermissionMappings by environment variable using JSON notation:
# ZITADEL_INTERNALAUTHZ_ROLEPERMISSIONMAPPINGS='[{"role": "IAM_OWNER", "permissions": ["iam.read", "iam.write"]}]'
# Beware that if you configure the RolePermissionMappings by environment variable, all the default RolePermissionMappings are lost.
RolePermissionMappings:
- Role: "SYSTEM_OWNER"
Permissions:

35
cmd/hooks/complex.go Normal file
View File

@ -0,0 +1,35 @@
package hooks
import (
"encoding/json"
"net/http"
"reflect"
)
func SliceTypeStringDecode[T any](from, to reflect.Value) (any, error) {
into := make([]T, 0)
return complexTypeStringDecodeHook(from, to, into)
}
func MapTypeStringDecode[K ~string | ~int, V any](from, to reflect.Value) (any, error) {
into := make(map[K]V, 0)
return complexTypeStringDecodeHook(from, to, into)
}
func MapHTTPHeaderStringDecode(from, to reflect.Value) (any, error) {
into := http.Header{}
return complexTypeStringDecodeHook(from, to, into)
}
func complexTypeStringDecodeHook(from, to reflect.Value, out any) (any, error) {
fromInterface := from.Interface()
if to.Type() != reflect.TypeOf(out) {
return fromInterface, nil
}
data, ok := fromInterface.(string)
if !ok {
return fromInterface, nil
}
err := json.Unmarshal([]byte(data), &out)
return out, err
}

View File

@ -10,7 +10,7 @@ import (
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/cmd/encryption"
"github.com/zitadel/zitadel/cmd/systemapi"
"github.com/zitadel/zitadel/cmd/hooks"
"github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/oidc"
@ -47,7 +47,7 @@ type Config struct {
Login login.Config
WebAuthNName string
Telemetry *handlers.TelemetryPusherConfig
SystemAPIUsers systemapi.Users
SystemAPIUsers map[string]*authz.SystemAPIUser
}
type InitProjections struct {
@ -70,6 +70,7 @@ func MustNewConfig(v *viper.Viper) *Config {
hook.EnumHookFunc(domain.FeatureString),
hook.EnumHookFunc(authz.MemberTypeString),
actions.HTTPConfigDecodeHook,
hooks.MapTypeStringDecode[string, *authz.SystemAPIUser],
)),
)
logging.OnError(err).Fatal("unable to read default config")
@ -127,7 +128,6 @@ func MustNewSteps(v *viper.Viper) *Steps {
mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToSliceHookFunc(","),
hook.EnumHookFunc(domain.FeatureString),
systemapi.UsersDecodeHook,
)),
)
logging.OnError(err).Fatal("unable to read steps")

View File

@ -8,7 +8,7 @@ import (
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/cmd/encryption"
"github.com/zitadel/zitadel/cmd/systemapi"
"github.com/zitadel/zitadel/cmd/hooks"
"github.com/zitadel/zitadel/internal/actions"
admin_es "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing"
internal_authz "github.com/zitadel/zitadel/internal/api/authz"
@ -61,7 +61,7 @@ type Config struct {
EncryptionKeys *encryption.EncryptionKeyConfig
DefaultInstance command.InstanceSetup
AuditLogRetention time.Duration
SystemAPIUsers systemapi.Users
SystemAPIUsers map[string]*internal_authz.SystemAPIUser
CustomerPortal string
Machine *id.Config
Actions *actions.Config
@ -84,6 +84,12 @@ func MustNewConfig(v *viper.Viper) *Config {
err := v.Unmarshal(config,
viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
hooks.SliceTypeStringDecode[*domain.CustomMessageText],
hooks.SliceTypeStringDecode[*command.SetQuota],
hooks.SliceTypeStringDecode[internal_authz.RoleMapping],
hooks.MapTypeStringDecode[string, *internal_authz.SystemAPIUser],
hooks.MapTypeStringDecode[domain.Feature, any],
hooks.MapHTTPHeaderStringDecode,
hook.Base64ToBytesHookFunc(),
hook.TagToLanguageHookFunc(),
mapstructure.StringToTimeDurationHookFunc(),
@ -91,7 +97,6 @@ func MustNewConfig(v *viper.Viper) *Config {
mapstructure.StringToSliceHookFunc(","),
database.DecodeHook,
actions.HTTPConfigDecodeHook,
systemapi.UsersDecodeHook,
hook.EnumHookFunc(domain.FeatureString),
hook.EnumHookFunc(internal_authz.MemberTypeString),
)),

View File

@ -1,44 +1,106 @@
package start
import (
"reflect"
"encoding/base64"
"fmt"
"net"
"net/http"
"strings"
"testing"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
)
func TestMustNewConfig(t *testing.T) {
encodedKey := "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF6aStGRlNKTDdmNXl3NEtUd3pnTQpQMzRlUEd5Y20vTStrVDBNN1Y0Q2d4NVYzRWFESXZUUUtUTGZCYUVCNDV6YjlMdGpJWHpEdzByWFJvUzJoTzZ0CmgrQ1lRQ3ozS0N2aDA5QzBJenhaaUIySVMzSC9hVCs1Qng5RUZZK3ZuQWtaamNjYnlHNVlOUnZtdE9sbnZJZUkKSDdxWjB0RXdrUGZGNUdFWk5QSlB0bXkzVUdWN2lvZmRWUVMxeFJqNzMrYU13NXJ2SDREOElkeWlBQzNWZWtJYgpwdDBWajBTVVgzRHdLdG9nMzM3QnpUaVBrM2FYUkYwc2JGaFFvcWRKUkk4TnFnWmpDd2pxOXlmSTV0eXhZc3duCitKR3pIR2RIdlczaWRPRGxtd0V0NUsycGFzaVJJV0syT0dmcSt3MEVjbHRRSGFidXFFUGdabG1oQ2tSZE5maXgKQndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="
decodedKey, err := base64.StdEncoding.DecodeString(encodedKey)
if err != nil {
t.Fatal(err)
}
type args struct {
yaml string
}
tests := []struct {
name string
args args
want *Config
want func(*testing.T, *Config)
}{{
name: "actions deny list ok",
args: args{
yaml: `
Actions:
HTTP:
DenyList:
- localhost
- 127.0.0.1
- foobar
Log:
Level: info
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.Actions.HTTP.DenyList, []actions.AddressChecker{
&actions.DomainChecker{Domain: "localhost"},
&actions.IPChecker{IP: net.ParseIP("127.0.0.1")},
&actions.DomainChecker{Domain: "foobar"}})
},
}, {
name: "actions deny list string ok",
args: args{
yaml: `
Actions:
HTTP:
DenyList: localhost,127.0.0.1,foobar
Log:
Level: info
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.Actions.HTTP.DenyList, []actions.AddressChecker{
&actions.DomainChecker{Domain: "localhost"},
&actions.IPChecker{IP: net.ParseIP("127.0.0.1")},
&actions.DomainChecker{Domain: "foobar"}})
},
}, {
name: "features ok",
args: args{yaml: `
DefaultInstance:
Features:
- FeatureLoginDefaultOrg: true
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: &Config{
DefaultInstance: command.InstanceSetup{
Features: map[domain.Feature]any{
domain.FeatureLoginDefaultOrg: true,
},
},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.DefaultInstance.Features, map[domain.Feature]any{
domain.FeatureLoginDefaultOrg: true,
})
},
}, {
name: "membership types ok",
name: "features string ok",
args: args{yaml: `
DefaultInstance:
Features: >
[{"featureLoginDefaultOrg": true}]
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.DefaultInstance.Features, map[domain.Feature]any{
domain.FeatureLoginDefaultOrg: true,
})
},
}, {
name: "system api users ok",
args: args{yaml: `
SystemAPIUsers:
- superuser:
@ -46,9 +108,14 @@ SystemAPIUsers:
- MemberType: System
- MemberType: Organization
- MemberType: IAM
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: &Config{
SystemAPIUsers: map[string]*authz.SystemAPIUser{
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.SystemAPIUsers, map[string]*authz.SystemAPIUser{
"superuser": {
Memberships: authz.Memberships{{
MemberType: authz.MemberTypeSystem,
@ -58,27 +125,121 @@ SystemAPIUsers:
MemberType: authz.MemberTypeIAM,
}},
},
},
})
},
}, {
name: "system api users string ok",
args: args{yaml: fmt.Sprintf(`
SystemAPIUsers: >
{"systemuser": {"path": "/path/to/superuser/key.pem"}, "systemuser2": {"keyData": "%s"}}
Log:
Level: info
Actions:
HTTP:
DenyList: []
`, encodedKey)},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.SystemAPIUsers, map[string]*authz.SystemAPIUser{
"systemuser": {
Path: "/path/to/superuser/key.pem",
},
"systemuser2": {
KeyData: decodedKey,
},
})
},
}, {
name: "headers ok",
args: args{yaml: `
Telemetry:
Headers:
single-value: single-value
multi-value:
- multi-value1
- multi-value2
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.Telemetry.Headers, http.Header{
"single-value": []string{"single-value"},
"multi-value": []string{"multi-value1", "multi-value2"},
})
},
}, {
name: "headers string ok",
args: args{yaml: `
Telemetry:
Headers: >
{"single-value": "single-value", "multi-value": ["multi-value1", "multi-value2"]}
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.Telemetry.Headers, http.Header{
"single-value": []string{"single-value"},
"multi-value": []string{"multi-value1", "multi-value2"},
})
},
}, {
name: "message texts ok",
args: args{yaml: `
DefaultInstance:
MessageTexts:
- MessageTextType: InitCode
Title: foo
- MessageTextType: PasswordReset
Greeting: bar
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.DefaultInstance.MessageTexts, []*domain.CustomMessageText{{
MessageTextType: "InitCode",
Title: "foo",
}, {
MessageTextType: "PasswordReset",
Greeting: "bar",
}})
},
}, {
name: "message texts string ok",
args: args{yaml: `
DefaultInstance:
MessageTexts: >
[{"messageTextType": "InitCode", "title": "foo"}, {"messageTextType": "PasswordReset", "greeting": "bar"}]
Log:
Level: info
Actions:
HTTP:
DenyList: []
`},
want: func(t *testing.T, config *Config) {
assert.Equal(t, config.DefaultInstance.MessageTexts, []*domain.CustomMessageText{{
MessageTextType: "InitCode",
Title: "foo",
}, {
MessageTextType: "PasswordReset",
Greeting: "bar",
}})
},
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := viper.New()
v.SetConfigType("yaml")
err := v.ReadConfig(strings.NewReader(`Log:
Level: info
Actions:
HTTP:
DenyList: []
` + tt.args.yaml))
require.NoError(t, err)
tt.want.Log = &logging.Config{Level: "info"}
tt.want.Actions = &actions.Config{HTTP: actions.HTTPConfig{DenyList: []actions.AddressChecker{}}}
require.NoError(t, tt.want.Log.SetLogger())
require.NoError(t, v.ReadConfig(strings.NewReader(tt.args.yaml)))
got := MustNewConfig(v)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("MustNewConfig() = %v, want %v", got, tt.want)
}
tt.want(t, got)
})
}
}

View File

@ -1,27 +0,0 @@
package systemapi
import (
"encoding/json"
"reflect"
"github.com/zitadel/zitadel/internal/api/authz"
)
type Users map[string]*authz.SystemAPIUser
func UsersDecodeHook(from, to reflect.Value) (any, error) {
if to.Type() != reflect.TypeOf(Users{}) {
return from.Interface(), nil
}
data, ok := from.Interface().(string)
if !ok {
return from.Interface(), nil
}
users := make(Users)
err := json.Unmarshal([]byte(data), &users)
if err != nil {
return nil, err
}
return users, nil
}

View File

@ -3,6 +3,7 @@ package actions
import (
"net"
"reflect"
"strings"
"github.com/mitchellh/mapstructure"
"github.com/zitadel/zitadel/internal/zerrors"
@ -41,12 +42,18 @@ func HTTPConfigDecodeHook(from, to reflect.Value) (interface{}, error) {
}
c := HTTPConfig{
DenyList: make([]AddressChecker, len(config.DenyList)),
DenyList: make([]AddressChecker, 0),
}
for i, entry := range config.DenyList {
if c.DenyList[i], err = parseDenyListEntry(entry); err != nil {
return nil, err
for _, unsplit := range config.DenyList {
for _, split := range strings.Split(unsplit, ",") {
parsed, parseErr := parseDenyListEntry(split)
if parseErr != nil {
return nil, parseErr
}
if parsed != nil {
c.DenyList = append(c.DenyList, parsed)
}
}
}