feat #37: Use environment variables for most of the configuration
This commit is contained in:
parent
3bc17d6aba
commit
2192775063
4 changed files with 251 additions and 204 deletions
43
README.md
43
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": "<name>",
|
||||
|
@ -40,19 +32,42 @@ Here is an example config file
|
|||
"id": "<unique_id>",
|
||||
"name": "<human_readable_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": "<client_id>",
|
||||
"clientSecret": "<client_secret>",
|
||||
"redirectURI": "<redirect_uri>"
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
|
|
@ -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 = "disable"
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
26
polyculeconnect/config/envvar.go
Normal file
26
polyculeconnect/config/envvar.go
Normal file
|
@ -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
|
||||
}
|
Loading…
Reference in a new issue