From c6d1e8827836f0eb24eee9cfc124c48d6c1728a8 Mon Sep 17 00:00:00 2001 From: Melora Hugues Date: Wed, 25 Oct 2023 20:30:23 +0200 Subject: [PATCH] feat #37: Use environment variables for most of the configuration --- README.md | 43 +++-- polyculeconnect/config/config.go | 151 +++++++++-------- polyculeconnect/config/config_test.go | 235 +++++++++++++------------- polyculeconnect/config/envvar.go | 26 +++ 4 files changed, 251 insertions(+), 204 deletions(-) create mode 100644 polyculeconnect/config/envvar.go diff --git a/README.md b/README.md index 68f0ad8..6650cf3 100644 --- a/README.md +++ b/README.md @@ -10,21 +10,13 @@ TODO ## Configuration -Here is an example config file +As a temporary solution, the list of backends and applications, as well as the openconnect configuration +can only be handled through the JSON config file. ```json { - "log": { - "level": "debug" // debug,info,warn,error - }, - "server": { - "port": 5000, // only used in net mode - "host": "0.0.0.0", // only used in net mode - // "sock": "/your/sock.sock" // path to your unix sock if "mode" is set to "unix" - "mode": "net" // net,unix - }, "openconnect": { - "issuer": "https://polyculeconnect.domain", // hostname of your polyculeconnect server + "issuer": "https://polyculeconnect.domain", "clients": [ { "name": "", @@ -40,19 +32,42 @@ Here is an example config file "id": "", "name": "", "local": true, - "type": "oidc", // must be "oidc" for now + "type": "oidc", "config": { - "issuer": "https://polyculeconnect.domain", // must be the same as current issuer + "issuer": "https://polyculeconnect.domain", "clientID": "", "clientSecret": "", "redirectURI": "" } - }, + } ] } } ``` +The rest of the configuration is handled through environment variables + +```ini +# Can be debug,info,warning,error +LOG_LEVEL = "info" + +# Can be net,unix +SERVER_MODE = "net" +SERVER_HOST = "0.0.0.0" +SERVER_PORT = "5000" +# SERVER_SOCK_PATH = "" + +STORAGE_TYPE = "sqlite" +STORAGE_FILEPATH = "./build/polyculeconnect.db" +# STORAGE_HOST = "127.0.0.1" +# STORAGE_PORT = "5432" +# STORAGE_DB = "polyculeconnect" +# STORAGE_USER = "polyculeconnect" +# STORAGE_PASSWORD = "polyculeconnect" +# STORAGE_SSL_MODE = "" +# STORAGE_SSL_CA_FILE = "" +``` + You can register multiple backend and multiple clients (applications) ## Running the server diff --git a/polyculeconnect/config/config.go b/polyculeconnect/config/config.go index 94cd077..9eb2bcc 100644 --- a/polyculeconnect/config/config.go +++ b/polyculeconnect/config/config.go @@ -12,32 +12,32 @@ import ( "github.com/sirupsen/logrus" ) -type ListeningMode int64 - -func (lm ListeningMode) String() string { - mapping := map[ListeningMode]string{ - ModeNet: "net", - ModeUnix: "unix", - } - val := mapping[lm] - return val -} - -func listeningModeFromString(rawVal string) (ListeningMode, error) { - mapping := map[string]ListeningMode{ - "unix": ModeUnix, - "net": ModeNet, - } - if typedVal, ok := mapping[rawVal]; !ok { - return ModeNet, fmt.Errorf("invalid listening mode %s", rawVal) - } else { - return typedVal, nil - } -} +type envVar string const ( - ModeUnix ListeningMode = iota - ModeNet + varLogLevel envVar = "LOG_LEVEL" + + varServerMode envVar = "SERVER_MODE" + varServerHost envVar = "SERVER_HOST" + varServerPort envVar = "SERVER_PORT" + varServerSocket envVar = "SERVER_SOCK_PATH" + + varStorageType envVar = "STORAGE_TYPE" + varStorageFile envVar = "STORAGE_FILEPATH" + varStorageHost envVar = "STORAGE_HOST" + varStoragePort envVar = "STORAGE_PORT" + varStorageDB envVar = "STORAGE_DB" + varStorageUser envVar = "STORAGE_USER" + varStoragePassword envVar = "STORAGE_PASSWORD" + varStorageSSLMode envVar = "STORAGE_SSL_MODE" + varStorageSSLCaFile envVar = "STORAGE_SSL_CA_FILE" +) + +type ListeningMode string + +const ( + ModeUnix ListeningMode = "unix" + ModeNet ListeningMode = "net" ) type BackendConfigType string @@ -47,6 +47,25 @@ const ( SQLite BackendConfigType = "sqlite" ) +const ( + defaultLogLevel = logrus.InfoLevel + + defaultServerMode = ModeNet + defaultServerHost = "0.0.0.0" + defaultServerPort = 5000 + defaultServerSocket = "" + + defaultStorageType = Memory + defaultStorageFile = "./polyculeconnect.db" + defaultStorageHost = "127.0.0.1" + defaultStoragePort = 5432 + defaultStorageDB = "polyculeconnect" + defaultStorageUser = "polyculeconnect" + defaultStoragePassword = "polyculeconnect" + defaultStorageSSLMode = "" + defaultStorageSSLCaFile = "" +) + type BackendConfig struct { Config *oidc.Config `json:"config"` Name string `json:"name"` @@ -62,32 +81,19 @@ type OpenConnectConfig struct { } type StorageConfig struct { - File string `json:"file"` - Host string `json:"host"` - Port int `json:"port"` - Database string `json:"database"` - User string `json:"user"` - Password string `json:"password"` + File string + Host string + Port int + Database string + User string + Password string Ssl struct { - Mode string `json:"mode"` - CaFile string `json:"caFile"` - } `json:"ssl"` + Mode string + CaFile string + } } type jsonConf struct { - Log struct { - Level string `json:"level"` - } `json:"log"` - Server struct { - Host string `json:"host"` - Port int `json:"port"` - Mode string `json:"mode"` - SockPath string `json:"sock"` - } `json:"server"` - Storage struct { - StorageType string `json:"type"` - Config *StorageConfig `json:"config"` - } `json:"storage"` OpenConnectConfig *OpenConnectConfig `json:"openconnect"` } @@ -116,43 +122,42 @@ func (ac *AppConfig) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &jsonConf); err != nil { return fmt.Errorf("failed to read JSON: %w", err) } - ac.LogLevel = parseLevel(jsonConf.Log.Level) - - lm, err := listeningModeFromString(jsonConf.Server.Mode) - if err != nil { - return fmt.Errorf("failed to parse server listening mode: %w", err) - } - - ac.ServerMode = lm - ac.SockPath = jsonConf.Server.SockPath - ac.Host = jsonConf.Server.Host - ac.Port = jsonConf.Server.Port ac.OpenConnectConfig = jsonConf.OpenConnectConfig - ac.StorageType = jsonConf.Storage.StorageType - ac.StorageConfig = jsonConf.Storage.Config return nil } -var defaultConfig AppConfig = AppConfig{ - LogLevel: logrus.InfoLevel, - ServerMode: ModeNet, - Host: "0.0.0.0", - Port: 5000, - StorageType: "memory", +func (ac *AppConfig) getConfFromEnv() { + ac.LogLevel = parseLevel(getStringFromEnv(varLogLevel, defaultLogLevel.String())) + + ac.ServerMode = ListeningMode(getStringFromEnv(varServerMode, string(defaultServerMode))) + ac.Host = getStringFromEnv(varServerHost, defaultServerHost) + ac.Port = getIntFromEnv(varServerPort, defaultServerPort) + ac.SockPath = getStringFromEnv(varServerSocket, defaultServerSocket) + + ac.StorageType = getStringFromEnv(varStorageType, string(defaultStorageType)) + ac.StorageConfig.Database = getStringFromEnv(varStorageDB, defaultStorageDB) + ac.StorageConfig.File = getStringFromEnv(varStorageFile, defaultStorageFile) + ac.StorageConfig.Host = getStringFromEnv(varStorageHost, defaultStorageHost) + ac.StorageConfig.Port = getIntFromEnv(varStoragePort, defaultStoragePort) + ac.StorageConfig.User = getStringFromEnv(varStorageUser, defaultStorageUser) + ac.StorageConfig.Password = getStringFromEnv(varStoragePassword, defaultStoragePassword) + ac.StorageConfig.Ssl.CaFile = getStringFromEnv(varStorageSSLCaFile, defaultStorageSSLCaFile) + ac.StorageConfig.Ssl.Mode = getStringFromEnv(varStorageSSLMode, defaultStorageSSLMode) } func New(filepath string) (*AppConfig, error) { + var conf AppConfig + conf.StorageConfig = &StorageConfig{} content, err := os.ReadFile(filepath) if err != nil { - if errors.Is(err, fs.ErrNotExist) { - conf := defaultConfig - return &conf, nil + if !errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("failed to read config file %q: %w", filepath, err) + } + } else { + if err := json.Unmarshal(content, &conf); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) } - return nil, fmt.Errorf("failed to read config file %q: %w", filepath, err) - } - var conf AppConfig - if err := json.Unmarshal(content, &conf); err != nil { - return nil, fmt.Errorf("failed to parse config file: %w", err) } + conf.getConfFromEnv() return &conf, nil } diff --git a/polyculeconnect/config/config_test.go b/polyculeconnect/config/config_test.go index 7088e2d..2e93854 100644 --- a/polyculeconnect/config/config_test.go +++ b/polyculeconnect/config/config_test.go @@ -10,128 +10,129 @@ import ( "github.com/stretchr/testify/require" ) -func TestListeningModeString(t *testing.T) { - assert.Equal(t, "net", ModeNet.String(), "Unexpected string value") - assert.Equal(t, "unix", ModeUnix.String(), "Unexpected string value") +var defaultConfig = AppConfig{ + LogLevel: defaultLogLevel, + ServerMode: defaultServerMode, + Host: defaultServerHost, + Port: defaultServerPort, + SockPath: defaultServerSocket, + StorageType: string(defaultStorageType), + StorageConfig: &StorageConfig{ + File: defaultStorageFile, + Host: defaultStorageHost, + Port: defaultStoragePort, + Database: defaultStorageDB, + User: defaultStorageUser, + Password: defaultStoragePassword, + Ssl: struct { + Mode string + CaFile string + }{Mode: defaultStorageSSLMode, CaFile: defaultStorageSSLCaFile}, + }, +} + +func initJson(t *testing.T, content string) string { + tmpPath := t.TempDir() + confPath := path.Join(tmpPath, "config.json") + err := os.WriteFile(confPath, []byte(content), 0o644) + require.NoError(t, err) + return confPath +} + +func setEnv(t *testing.T, envVars map[string]string) { + for key, val := range envVars { + t.Setenv(key, val) + } } -// Test returning a default config when providing a path that does not exist func TestDefault(t *testing.T) { - conf, err := New("/this/path/does/not/exist") - if assert.Nil(t, err, "Unexpected error") { - assert.Equal(t, defaultConfig, *conf, "Unexpected config") - } -} - -// Test creating a valid config (net mode) -func TestOKNet(t *testing.T) { - tmpPath := t.TempDir() - content := `{ - "log": { - "level": "error" - }, - "server": { - "mode": "net", - "host": "127.0.0.1", - "port": 8888 - } -}` - confPath := path.Join(tmpPath, "config.json") - require.Nil(t, os.WriteFile(confPath, []byte(content), 0o644), "Failed to write config") - - expectedConf := AppConfig{ - LogLevel: logrus.ErrorLevel, - ServerMode: ModeNet, - Host: "127.0.0.1", - Port: 8888, - } - conf, err := New(confPath) - if assert.Nil(t, err, "Unexpected error") { - assert.Equal(t, expectedConf, *conf, "Unexpected config") - } -} - -// Test creating a valid config (unix mode) -func TestOKUnix(t *testing.T) { - tmpPath := t.TempDir() - content := `{ - "log": { - "level": "error" - }, - "server": { - "mode": "unix", - "sock": "/run/toto.sock" - } -}` - confPath := path.Join(tmpPath, "config.json") - require.Nil(t, os.WriteFile(confPath, []byte(content), 0o644), "Failed to write config") - - expectedConf := AppConfig{ - LogLevel: logrus.ErrorLevel, - ServerMode: ModeUnix, - SockPath: "/run/toto.sock", - } - conf, err := New(confPath) - if assert.Nil(t, err, "Unexpected error") { - assert.Equal(t, expectedConf, *conf, "Unexpected config") - } -} - -// Test creating a valid config, no log level provided, should be info -func TestOKNoLogLevel(t *testing.T) { - tmpPath := t.TempDir() - content := `{ - "server": { - "mode": "net", - "host": "127.0.0.1", - "port": 8888 - } -}` - confPath := path.Join(tmpPath, "config.json") - require.Nil(t, os.WriteFile(confPath, []byte(content), 0o644), "Failed to write config") - - expectedConf := AppConfig{ - LogLevel: logrus.InfoLevel, - ServerMode: ModeNet, - Host: "127.0.0.1", - Port: 8888, - } - conf, err := New(confPath) - if assert.Nil(t, err, "Unexpected error") { - assert.Equal(t, expectedConf, *conf, "Unexpected config") - } -} - -// Test giving an invalid server mode -func TestErrMode(t *testing.T) { - tmpPath := t.TempDir() - content := `{ - "log": { - "level": "error" - }, - "server": { - "mode": "toto", - "sock": "/run/toto.sock" - } -}` - confPath := path.Join(tmpPath, "config.json") - require.Nil(t, os.WriteFile(confPath, []byte(content), 0o644), "Failed to write config") - - _, err := New(confPath) - if assert.Error(t, err, "Unexpected nil error") { - errMsg := "failed to parse config file: failed to parse server listening mode: invalid listening mode toto" - assert.Equal(t, errMsg, err.Error(), "Unexpected error message") - } + t.Run("no file", func(t *testing.T) { + conf, err := New("/this/path/does/not/exist") + require.NoError(t, err) + assert.Equal(t, defaultConfig, *conf) + }) + + t.Run("empty config", func(t *testing.T) { + confPath := initJson(t, `{}`) + conf, err := New(confPath) + require.NoError(t, err) + assert.Equal(t, defaultConfig, *conf) + }) } +// Since we still use a JSON conf for the OIDC config, we still need to check this for now +// But as soon as the config file is not necessary, this will probably disappear func TestInvalidJSON(t *testing.T) { - tmpPath := t.TempDir() - content := "toto" - confPath := path.Join(tmpPath, "config.json") - require.Nil(t, os.WriteFile(confPath, []byte(content), 0o644), "Failed to write config") + confPath := initJson(t, "toto") + errMsg := "failed to parse config file: invalid character 'o' in literal true (expecting 'r')" _, err := New(confPath) - if assert.Error(t, err, "Unexpected nil error") { - errMsg := "failed to parse config file: invalid character 'o' in literal true (expecting 'r')" - assert.Equal(t, errMsg, err.Error(), "Unexpected error message") - } + assert.ErrorContains(t, err, errMsg) +} + +func TestHostNetMode(t *testing.T) { + envVars := map[string]string{ + string(varServerMode): string(ModeNet), + string(varServerHost): "127.0.0.1", + string(varServerPort): "8888", + } + setEnv(t, envVars) + + conf, err := New("") + require.NoError(t, err) + + assert.Equal(t, ModeNet, conf.ServerMode) + assert.Equal(t, "127.0.0.1", conf.Host) + assert.Equal(t, 8888, conf.Port) +} + +func TestHostSocketMode(t *testing.T) { + envVars := map[string]string{ + string(varServerMode): string(ModeUnix), + string(varServerSocket): "/run/polyculeconnect.sock", + } + setEnv(t, envVars) + + conf, err := New("") + require.NoError(t, err) + + assert.Equal(t, ModeUnix, conf.ServerMode) + assert.Equal(t, "/run/polyculeconnect.sock", conf.SockPath) +} + +func TestLogLevel(t *testing.T) { + envVars := map[string]string{ + string(varLogLevel): "error", + } + setEnv(t, envVars) + + conf, err := New("") + require.NoError(t, err) + + assert.Equal(t, logrus.ErrorLevel, conf.LogLevel) +} + +func TestLogLevelInvalidValue(t *testing.T) { + envVars := map[string]string{ + string(varLogLevel): "toto", + } + setEnv(t, envVars) + + conf, err := New("") + require.NoError(t, err) + + assert.Equal(t, logrus.InfoLevel, conf.LogLevel) // if invalid, no error should occur, but info level should be used +} + +func TestSqliteConfig(t *testing.T) { + envVars := map[string]string{ + string(varStorageType): "sqlite", + string(varStorageFile): "/data/polyculeconnect.db", + } + setEnv(t, envVars) + + conf, err := New("") + require.NoError(t, err) + + assert.Equal(t, string(SQLite), conf.StorageType) + assert.Equal(t, "/data/polyculeconnect.db", conf.StorageConfig.File) } diff --git a/polyculeconnect/config/envvar.go b/polyculeconnect/config/envvar.go new file mode 100644 index 0000000..a851edf --- /dev/null +++ b/polyculeconnect/config/envvar.go @@ -0,0 +1,26 @@ +package config + +import ( + "os" + "strconv" +) + +func getStringFromEnv(key envVar, defaultValue string) string { + val, ok := os.LookupEnv(string(key)) + if !ok { + return defaultValue + } + return val +} + +func getIntFromEnv(key envVar, defaultValue int) int { + rawVal, ok := os.LookupEnv(string(key)) + if !ok { + return defaultValue + } + val, err := strconv.Atoi(rawVal) + if err != nil { + return defaultValue + } + return val +}