From 203fc580f42ae19ad977005649d38739899828f2 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 25 Oct 2020 13:58:57 -0400 Subject: [PATCH] fix #1049 --- default.yaml | 4 ++ irc/config.go | 117 +++++++++++++++++++++++++++++++++++++++++++++ irc/config_test.go | 101 ++++++++++++++++++++++++++++++++++++++ traditional.yaml | 4 ++ 4 files changed, 226 insertions(+) create mode 100644 irc/config_test.go diff --git a/default.yaml b/default.yaml index c404a71a..c1c811aa 100644 --- a/default.yaml +++ b/default.yaml @@ -922,3 +922,7 @@ history: #blacklist: # - "+draft/typing" # - "typing" + +# whether to allow customization of the config at runtime using environment variables, +# e.g., ORAGONO__SERVER__MAX_SENDQ=128k. see the manual for more details. +allow-environment-overrides: true diff --git a/irc/config.go b/irc/config.go index 502e664f..4b014935 100644 --- a/irc/config.go +++ b/irc/config.go @@ -15,6 +15,7 @@ import ( "net" "os" "path/filepath" + "reflect" "regexp" "strconv" "strings" @@ -478,6 +479,8 @@ type TorListenersConfig struct { // Config defines the overall configuration. type Config struct { + AllowEnvironmentOverrides bool `yaml:"allow-environment-overrides"` + Network struct { Name string } @@ -870,6 +873,105 @@ func LoadRawConfig(filename string) (config *Config, err error) { return } +// convert, e.g., "ALLOWED_ORIGINS" to "allowed-origins" +func screamingSnakeToKebab(in string) (out string) { + var buf strings.Builder + for i := 0; i < len(in); i++ { + c := in[i] + switch { + case c == '_': + buf.WriteByte('-') + case 'A' <= c && c <= 'Z': + buf.WriteByte(c + ('a' - 'A')) + default: + buf.WriteByte(c) + } + } + return buf.String() +} + +func isExported(field reflect.StructField) bool { + return field.PkgPath == "" // https://golang.org/pkg/reflect/#StructField +} + +// errors caused by config overrides +type configPathError struct { + name string + desc string + fatalErr error +} + +func (ce *configPathError) Error() string { + if ce.fatalErr != nil { + return fmt.Sprintf("Couldn't apply config override `%s`: %s: %v", ce.name, ce.desc, ce.fatalErr) + } + return fmt.Sprintf("Couldn't apply config override `%s`: %s", ce.name, ce.desc) +} + +func mungeFromEnvironment(config *Config, envPair string) (applied bool, err *configPathError) { + equalIdx := strings.IndexByte(envPair, '=') + name, value := envPair[:equalIdx], envPair[equalIdx+1:] + if !strings.HasPrefix(name, "ORAGONO__") { + return false, nil + } + name = strings.TrimPrefix(name, "ORAGONO__") + pathComponents := strings.Split(name, "__") + for i, pathComponent := range pathComponents { + pathComponents[i] = screamingSnakeToKebab(pathComponent) + } + + v := reflect.Indirect(reflect.ValueOf(config)) + t := v.Type() + for _, component := range pathComponents { + if component == "" { + return false, &configPathError{name, "invalid", nil} + } + if v.Kind() != reflect.Struct { + return false, &configPathError{name, "index into non-struct", nil} + } + var nextField reflect.StructField + success := false + n := t.NumField() + // preferentially get a field with an exact yaml tag match, + // then fall back to case-insensitive comparison of field names + for i := 0; i < n; i++ { + field := t.Field(i) + if isExported(field) && field.Tag.Get("yaml") == component { + nextField = field + success = true + break + } + } + if !success { + for i := 0; i < n; i++ { + field := t.Field(i) + if isExported(field) && strings.ToLower(field.Name) == component { + nextField = field + success = true + break + } + } + } + if !success { + return false, &configPathError{name, fmt.Sprintf("couldn't resolve path component: `%s`", component), nil} + } + v = v.FieldByName(nextField.Name) + // dereference pointer field if necessary, initialize new value if necessary + if v.Kind() == reflect.Ptr { + if v.IsNil() { + v.Set(reflect.New(v.Type().Elem())) + } + v = reflect.Indirect(v) + } + t = v.Type() + } + yamlErr := yaml.Unmarshal([]byte(value), v.Addr().Interface()) + if yamlErr != nil { + return false, &configPathError{name, "couldn't deserialize YAML", yamlErr} + } + return true, nil +} + // LoadConfig loads the given YAML configuration file. func LoadConfig(filename string) (config *Config, err error) { config, err = LoadRawConfig(filename) @@ -877,6 +979,21 @@ func LoadConfig(filename string) (config *Config, err error) { return nil, err } + if config.AllowEnvironmentOverrides { + for _, envPair := range os.Environ() { + applied, envErr := mungeFromEnvironment(config, envPair) + if envErr != nil { + if envErr.fatalErr != nil { + return nil, envErr + } else { + log.Println(envErr.Error()) + } + } else if applied { + log.Printf("applied environment override: %s\n", envPair) + } + } + } + config.Filename = filename if config.Network.Name == "" { diff --git a/irc/config_test.go b/irc/config_test.go new file mode 100644 index 00000000..cdc9a24d --- /dev/null +++ b/irc/config_test.go @@ -0,0 +1,101 @@ +// Copyright (c) 2020 Shivaram Lingamneni +// released under the MIT license + +package irc + +import ( + "reflect" + "testing" +) + +func TestEnvironmentOverrides(t *testing.T) { + var config Config + config.Server.Compatibility.SendUnprefixedSasl = true + config.History.Enabled = true + defaultUserModes := "+i" + config.Accounts.DefaultUserModes = &defaultUserModes + config.Server.WebSockets.AllowedOrigins = []string{"https://www.ircv3.net"} + config.Server.MOTD = "long.motd.txt" // overwrite this + env := []string{ + `USER=shivaram`, // unrelated var + `ORAGONO_USER=oragono`, // this should be ignored as well + `ORAGONO__NETWORK__NAME=example.com`, + `ORAGONO__SERVER__COMPATIBILITY__FORCE_TRAILING=false`, + `ORAGONO__SERVER__COERCE_IDENT="~user"`, + `ORAGONO__SERVER__MOTD=short.motd.txt`, + `ORAGONO__ACCOUNTS__NICK_RESERVATION__ENABLED=true`, + `ORAGONO__ACCOUNTS__DEFAULT_USER_MODES="+iR"`, + `ORAGONO__SERVER__IP_CLOAKING={"enabled": true, "enabled-for-always-on": true, "netname": "irc", "cidr-len-ipv4": 32, "cidr-len-ipv6": 64, "num-bits": 64}`, + } + for _, envPair := range env { + _, err := mungeFromEnvironment(&config, envPair) + if err != nil { + t.Errorf("couldn't apply override `%s`: %v", envPair, err) + } + } + + if config.Network.Name != "example.com" { + t.Errorf("unexpected value of network.name: %s", config.Network.Name) + } + if config.Server.CoerceIdent != "~user" { + t.Errorf("unexpected value of coerce-ident: %s", config.Server.CoerceIdent) + } + if config.Server.MOTD != "short.motd.txt" { + t.Errorf("unexpected value of motd: %s", config.Server.MOTD) + } + if !config.Accounts.NickReservation.Enabled { + t.Errorf("did not set bool as expected") + } + if !config.Server.Compatibility.SendUnprefixedSasl { + t.Errorf("overwrote unrelated field") + } + if !config.History.Enabled { + t.Errorf("overwrote unrelated field") + } + if !reflect.DeepEqual(config.Server.WebSockets.AllowedOrigins, []string{"https://www.ircv3.net"}) { + t.Errorf("overwrote unrelated field: %#v", config.Server.WebSockets.AllowedOrigins) + } + + cloakConf := config.Server.Cloaks + if !(cloakConf.Enabled == true && cloakConf.EnabledForAlwaysOn == true && cloakConf.Netname == "irc" && cloakConf.CidrLenIPv6 == 64) { + t.Errorf("bad value of Cloaks: %#v", config.Server.Cloaks) + } + + if *config.Server.Compatibility.ForceTrailing != false { + t.Errorf("couldn't set unset ptr field to false") + } + + if *config.Accounts.DefaultUserModes != "+iR" { + t.Errorf("couldn't override pre-set ptr field") + } +} + +func TestEnvironmentOverrideErrors(t *testing.T) { + var config Config + config.Server.Compatibility.SendUnprefixedSasl = true + config.History.Enabled = true + + invalidEnvs := []string{ + `ORAGONO__=asdf`, + `ORAGONO__SERVER__=asdf`, + `ORAGONO__SERVER____=asdf`, + `ORAGONO__NONEXISTENT_KEY=1`, + `ORAGONO__SERVER__NONEXISTENT_KEY=1`, + // invalid yaml: + `ORAGONO__SERVER__IP_CLOAKING__NETNAME="`, + // invalid type: + `ORAGONO__SERVER__IP_CLOAKING__NUM_BITS=asdf`, + `ORAGONO__SERVER__STS=[]`, + // index into non-struct: + `ORAGONO__NETWORK__NAME__QUX=1`, + // private field: + `ORAGONO__SERVER__PASSWORDBYTES="asdf"`, + } + + for _, env := range invalidEnvs { + success, err := mungeFromEnvironment(&config, env) + if err == nil || success { + t.Errorf("accepted invalid env override `%s`", env) + } + } +} diff --git a/traditional.yaml b/traditional.yaml index a0134ab4..94ad7e76 100644 --- a/traditional.yaml +++ b/traditional.yaml @@ -895,3 +895,7 @@ history: #blacklist: # - "+draft/typing" # - "typing" + +# whether to allow customization of the config at runtime using environment variables, +# e.g., ORAGONO__SERVER__MAX_SENDQ=128k. see the manual for more details. +allow-environment-overrides: true