diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..a95ff1e --- /dev/null +++ b/config/config.go @@ -0,0 +1,174 @@ +package config + +import ( + "crypto/rand" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/joho/godotenv" + "github.com/kelseyhightower/envconfig" + "github.com/rs/zerolog" + "github.com/screego/server/config/mode" +) + +var ( + prefix = "screego" + files = []string{"screego.config.development.local", "screego.config.development", "screego.config.local", "screego.config"} + absoluteFiles = []string{"/etc/screego/server.config"} + osExecutable = os.Executable + osStat = os.Stat +) + +const ( + AuthModeTurn = "turn" + AuthModeAll = "all" + AuthModeNone = "none" +) + +// Config represents the application configuration. +type Config struct { + LogLevel LogLevel `default:"info" split_words:"true"` + + ExternalIP string `split_words:"true"` + + TLSCertFile string `split_words:"true"` + TLSKeyFile string `split_words:"true"` + + ServerTLS bool `split_words:"true"` + ServerAddress string `default:"0.0.0.0:5050" split_words:"true"` + Secret []byte `split_words:"true"` + + TurnAddress string `default:"0.0.0.0:3478" required:"true" split_words:"true"` + TurnStrictAuth bool `default:"true" split_words:"true"` + + TrustProxyHeaders bool `split_words:"true"` + AuthMode string `default:"turn" split_words:"true"` + CorsAllowedOrigins []string `split_words:"true"` + UsersFile string `split_words:"true"` + + CheckOrigin func(string) bool `ignored:"true" json:"-"` +} + +// Get loads the application config. +func Get() (Config, []FutureLog) { + var logs []FutureLog + dir, log := getExecutableOrWorkDir() + if log != nil { + logs = append(logs, *log) + } + + for _, file := range getFiles(dir) { + _, fileErr := osStat(file) + if fileErr == nil { + if err := godotenv.Load(file); err != nil { + logs = append(logs, futureFatal(fmt.Sprintf("cannot load file %s: %s", file, err))) + } else { + logs = append(logs, FutureLog{ + Level: zerolog.DebugLevel, + Msg: fmt.Sprintf("Loading file %s", file)}) + } + } else if os.IsNotExist(fileErr) { + continue + } else { + logs = append(logs, FutureLog{ + Level: zerolog.WarnLevel, + Msg: fmt.Sprintf("cannot read file %s because %s", file, fileErr)}) + } + } + + config := Config{} + err := envconfig.Process(prefix, &config) + if err != nil { + logs = append(logs, + futureFatal(fmt.Sprintf("cannot parse env params: %s", err))) + } + + if config.AuthMode != AuthModeTurn && config.AuthMode != AuthModeAll && config.AuthMode != AuthModeNone { + logs = append(logs, + futureFatal(fmt.Sprintf("invalid SCREEGO_AUTH_MODE: %s", config.AuthMode))) + } + + if config.ExternalIP == "" { + logs = append(logs, futureFatal("SCREEGO_EXTERNAL_IP must be set")) + } + + if config.ServerTLS { + if config.TLSCertFile == "" { + logs = append(logs, futureFatal("SCREEGO_TLS_CERT_FILE must be set if TLS is enabled")) + } + + if config.TLSKeyFile == "" { + logs = append(logs, futureFatal("SCREEGO_TLS_KEY_FILE must be set if TLS is enabled")) + } + } + + var compiledAllowedOrigins []*regexp.Regexp + for _, origin := range config.CorsAllowedOrigins { + compiled, err := regexp.Compile(origin) + if err != nil { + logs = append(logs, futureFatal(fmt.Sprintf("invalid regex: %s", err))) + } + compiledAllowedOrigins = append(compiledAllowedOrigins, compiled) + } + + config.CheckOrigin = func(origin string) bool { + if origin == "" { + return true + } + for _, compiledOrigin := range compiledAllowedOrigins { + if compiledOrigin.Match([]byte(strings.ToLower(origin))) { + return true + } + } + return false + } + + if len(config.Secret) == 0 { + config.Secret = make([]byte, 32) + if _, err := rand.Read(config.Secret); err == nil { + logs = append(logs, FutureLog{ + Level: zerolog.InfoLevel, + Msg: "SCREEGO_SECRET unset, user logins will be invalidated on restart"}) + } else { + logs = append(logs, futureFatal(fmt.Sprintf("cannot create secret %s", err))) + } + } + + return config, logs +} + +func getExecutableOrWorkDir() (string, *FutureLog) { + dir, err := getExecutableDir() + // when using `go run main.go` the executable lives in th temp directory therefore the env.development + // will not be read, this enforces that the current work directory is used in dev mode. + if err != nil || mode.Get() == mode.Dev { + return filepath.Dir("."), err + } + return dir, nil +} + +func getExecutableDir() (string, *FutureLog) { + ex, err := osExecutable() + if err != nil { + return "", &FutureLog{ + Level: zerolog.ErrorLevel, + Msg: "Could not get path of executable using working directory instead. " + err.Error()} + } + return filepath.Dir(ex), nil +} + +func getFiles(relativeTo string) []string { + var result []string + for _, file := range files { + result = append(result, filepath.Join(relativeTo, file)) + } + homeDir, err := os.UserHomeDir() + if err == nil { + result = append(result, filepath.Join(homeDir, ".config/screego/server.config")) + } + result = append(result, absoluteFiles...) + return result +} diff --git a/config/error.go b/config/error.go new file mode 100644 index 0000000..7cdd65f --- /dev/null +++ b/config/error.go @@ -0,0 +1,17 @@ +package config + +import "github.com/rs/zerolog" + +// FutureLog is an intermediate type for log messages. It is used before the config was loaded because without loaded +// config we do not know the log level, so we log these messages once the config was initialized. +type FutureLog struct { + Level zerolog.Level + Msg string +} + +func futureFatal(msg string) FutureLog { + return FutureLog{ + Level: zerolog.FatalLevel, + Msg: msg, + } +} diff --git a/config/loglevel.go b/config/loglevel.go new file mode 100644 index 0000000..786946d --- /dev/null +++ b/config/loglevel.go @@ -0,0 +1,25 @@ +package config + +import ( + "errors" + + "github.com/rs/zerolog" +) + +// LogLevel type that provides helper methods for decoding. +type LogLevel zerolog.Level + +// Decode decodes a string to a log level. +func (ll *LogLevel) Decode(value string) error { + if level, err := zerolog.ParseLevel(value); err == nil { + *ll = LogLevel(level) + return nil + } + *ll = LogLevel(zerolog.InfoLevel) + return errors.New("unknown log level") +} + +// AsZeroLogLevel converts the LogLevel to a zerolog.Level. +func (ll LogLevel) AsZeroLogLevel() zerolog.Level { + return zerolog.Level(ll) +} diff --git a/config/loglevel_test.go b/config/loglevel_test.go new file mode 100644 index 0000000..6bee82a --- /dev/null +++ b/config/loglevel_test.go @@ -0,0 +1,22 @@ +package config + +import ( + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" +) + +func TestLogLevel_Decode_success(t *testing.T) { + ll := new(LogLevel) + err := ll.Decode("fatal") + assert.Nil(t, err) + assert.Equal(t, ll.AsZeroLogLevel(), zerolog.FatalLevel) +} + +func TestLogLevel_Decode_fail(t *testing.T) { + ll := new(LogLevel) + err := ll.Decode("asdasdasdasdasdasd") + assert.EqualError(t, err, "unknown log level") + assert.Equal(t, ll.AsZeroLogLevel(), zerolog.InfoLevel) +} diff --git a/config/mode/mode.go b/config/mode/mode.go new file mode 100644 index 0000000..6a9204d --- /dev/null +++ b/config/mode/mode.go @@ -0,0 +1,20 @@ +package mode + +const ( + // Dev for development mode + Dev = "dev" + // Prod for production mode + Prod = "prod" +) + +var mode = Dev + +// Set sets the new mode. +func Set(newMode string) { + mode = newMode +} + +// Get returns the current mode. +func Get() string { + return mode +} diff --git a/config/mode/mode_test.go b/config/mode/mode_test.go new file mode 100644 index 0000000..f2ce63a --- /dev/null +++ b/config/mode/mode_test.go @@ -0,0 +1,17 @@ +package mode + +import ( + "testing" + + "github.com/magiconair/properties/assert" +) + +func TestGet(t *testing.T) { + mode = Prod + assert.Equal(t, Prod, Get()) +} + +func TestSet(t *testing.T) { + Set(Prod) + assert.Equal(t, Prod, mode) +} diff --git a/screego.config.development b/screego.config.development new file mode 100644 index 0000000..f645884 --- /dev/null +++ b/screego.config.development @@ -0,0 +1,4 @@ +SCREEGO_EXTERNAL_IP=127.0.0.1 +SCREEGO_LOG_LEVEL=debug +SCREEGO_CORS_ALLOWED_ORIGINS=http://localhost:3000 +SCREEGO_USERS_FILE=./users diff --git a/screego.config.example b/screego.config.example new file mode 100644 index 0000000..0cbeff3 --- /dev/null +++ b/screego.config.example @@ -0,0 +1,52 @@ +# The external ip of the server +SCREEGO_EXTERNAL_IP= + +# A secret which should be unique. Is used for cookie authentication. +SCREEGO_SECRET= + +# If TLS should be enabled for HTTP requests. Screego requires TLS, +# you either have to enable this setting or serve TLS via a reverse proxy. +SCREEGO_SERVER_TLS=false +# The TLS cert file (only needed if TLS is enabled) +SCREEGO_TLS_CERT_FILE= +# The TLS key file (only needed if TLS is enabled) +SCREEGO_TLS_KEY_FILE= + +# The address the http server will listen on. +SCREEGO_SERVER_ADDRESS=0.0.0.0:5050 + +# The address the TURN server will listen on. +SCREEGO_TURN_ADDRESS=0.0.0.0:3478 + +# If reverse proxy headers should be trusted. +# Screego uses ip whitelisting for authentication +# of TURN connections. When behind a proxy the ip is always the proxy server. +# To still allow whitelisting this setting must be enabled and +# the `X-Real-Ip` header must be set by the reverse proxy. +SCREEGO_TRUST_PROXY_HEADERS=false + +# Defines when a user login is required +# Possible values: +# all: User login is always required +# turn: User login is required for TURN connections +# none: User login is never required +SCREEGO_AUTH_MODE=turn + +# Defines origins that will be allowed to access Screego (HTTP + WebSocket) +# Example Value: https://screego.net,https://sub.gotify.net +SCREEGO_CORS_ALLOWED_ORIGINS= + +# Defines the location of the users file. +# File Format: +# user1:bcrypt_password_hash +# user2:bcrypt_password_hash +# +# Example: +# user1:$2a$12$WEfYCnWGk0PDzbATLTNiTuoZ7e/43v6DM/h7arOnPU6qEtFG.kZQy +# +# The user password pair can be created via +# screego hash --name "user1" --pass "your password" +SCREEGO_USERS_FILE= + +# The loglevel (one of: debug, info, warn, error) +SCREEGO_LOG_LEVEL=info diff --git a/users b/users new file mode 100644 index 0000000..e6479b0 --- /dev/null +++ b/users @@ -0,0 +1,2 @@ +# Password: admin +admin:$2a$12$kNgc2ZYAXzIL6SHY.8PHAOQ8Casi0s1bKatYoG/jupt2yV1M5K5nO