package config import ( "context" "fmt" "log" "net/http" "os" "reflect" "strings" "github.com/coreos/go-oidc/v3/oidc" "github.com/dsa0x/sicher" "github.com/go-playground/validator/v10" "github.com/go-viper/mapstructure/v2" "github.com/gorilla/sessions" "github.com/joho/godotenv" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/confmap" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/posflag" "github.com/knadh/koanf/providers/structs" "github.com/knadh/koanf/v2" "github.com/samber/lo" "github.com/spf13/pflag" "go.uber.org/zap" "golang.org/x/oauth2" ) const defaultEnvironment = "development" type SicherObject interface { SetEnvStyle(string) LoadEnv(string, interface{}) error } type SicherConfig struct { AppSecret string `env:"APP_SECRET" koanf:"app_secret"` AppSecretBlock string `env:"APP_SECRET_BLOCK" koanf:"app_secret_block"` NocoDBToken string `env:"NOCODB_TOKEN" koanf:"nocodb.api_token"` OidcClientId string `env:"OIDC_CLIENT_ID" koanf:"oidc.client_id"` OidcClientSecret string `env:"OIDC_CLIENT_SECRET" koanf:"oidc.client_secret"` ServiceAccountId string `env:"IDMS_SERVICE_ACCOUNT_ID" koanf:"idms.service_account_id"` ServiceAccountPassword string `env:"IDMS_SERVICE_ACCOUNT_PASSWORD" koanf:"idms.service_account_password"` } var NewSicherObject = func(environment string, path string) SicherObject { return sicher.New(environment, path) } type OidcConfig struct { ClientId string `koanf:"oidc.client_id" validate:"required"` ClientSecret Secret[string] `koanf:"oidc.client_secret" validate:"required"` Issuer string `koanf:"oidc.issuer" validate:"required"` Scopes []string `koanf:"oidc.scopes" validate:"required"` Provider *oidc.Provider Verifier *oidc.IDTokenVerifier OAuth2 *oauth2.Config } type IdmsConfig struct { BaseUrl string `koanf:"idms.base_url" validate:"required"` CarsGroupId string `koanf:"idms.cars_group_id" validate:"required"` Id Secret[string] `koanf:"idms.service_account_id" validate:"required"` Password Secret[string] `koanf:"idms.service_account_password" validate:"required"` } type NocoDBConfig struct { ApiToken Secret[string] `koanf:"nocodb.api_token" validate:"required"` BaseUrl string `koanf:"nocodb.base_url" validate:"required"` EmployeesTableId string `koanf:"nocodb.employees_table_id" validate:"required"` InfractionsTableId string `koanf:"nocodb.infractions_table_id" validate:"required"` NegativeInfractionId int `koanf:"nocodb.negative_infraction_id" validate:"required"` NoPointsViewId string `koanf:"nocodb.no_points_view_id" validate:"required"` } type ApplicationConfig struct { AppSecret Secret[string] `koanf:"app_secret"` AppSecretBlock Secret[string] `koanf:"app_secret_block"` CookieStore *sessions.CookieStore Environment string `koanf:"environment" validate:"required"` Listen string `koanf:"listen" validate:"required,hostname_port"` MatchThreshold int `koanf:"match_threshold" validate:"required"` NocoDBConfig NocoDBConfig `koanf:"nocodb" validate:"required"` OidcConfig OidcConfig `koanf:"oidc" validate:"required"` PublicPath string `koanf:"paths.public"` Idms IdmsConfig `koanf:"idms" validate:"required"` SessionName string ViewPath string `koanf:"paths.views"` } type ParsedCliArgs struct { ConfigFiles *[]string DatabasePasswordPath *string Environment *string Listen *string Version *bool } func ParseArgs(version Version, exit func(int), args []string) (*ParsedCliArgs, *pflag.FlagSet) { f := pflag.NewFlagSet("config", pflag.ContinueOnError) f.Usage = func() { fmt.Println(version.AppNameAndVersion(false)) fmt.Println(f.FlagUsages()) exit(0) } parsedCliArgs := &ParsedCliArgs{ ConfigFiles: f.StringSliceP("conf", "c", []string{}, "path to one or more .yaml config files"), DatabasePasswordPath: f.StringP("db-secret-path", "d", "", "path to database secret"), Environment: f.StringP("environment", "e", defaultEnvironment, "Environment for the running binary"), Listen: f.StringP("listen", "l", "0.0.0.0:3000", "address and port to listen on"), Version: f.Bool("version", false, "Show version"), } err := f.Parse(args[1:]) if err != nil { _, _ = fmt.Fprintf( os.Stderr, "%s\n\nERROR: %s\n\n%s", version.AppNameAndVersion(false), err, f.FlagUsages(), ) exit(1) } return parsedCliArgs, f } // Load application configuration from config files, command line flags, and environment variables. // An error will be returned only if _unexpected_ error happened, such as a file not being found. // If the user specified a --help or a --version flag, the application will exit with a 0 status code. // If the user specified an unsupported flag, the application will exit with a 1 status code and print the help message. func Load(version Version, exit func(int), args []string, logger *zap.Logger) (*ApplicationConfig, error) { k := koanf.New(".") config := defaultConfig() err := k.Load(confmap.Provider(config, "."), nil) if err != nil { log.Fatal("error initializing config", zap.Error(err)) } parsedCliArgs, f := ParseArgs(version, exit, args) // Load the config files provided in the command line. for _, c := range *parsedCliArgs.ConfigFiles { err := k.Load(file.Provider(c), yaml.Parser()) if err != nil { return nil, fmt.Errorf("error loading configuration file: %v", err) } } err = godotenv.Load() if err != nil { logger.Info("No .env file found") } configPath := func(p string) string { combinedPath := fmt.Sprintf("%s/%s", k.String("path_prefix"), p) return combinedPath } // Override with environment variables lo.ForEach([]string{envPrefix, pgPrefix, databasePrefix, idmsPrefix, oidcPrefix}, func(prefix string, _ int) { if err := k.Load(env.Provider(prefix, "_", mapEnvVarNames(prefix)), nil); err != nil { logger.Fatal("error parsing environment variables", zap.Error(err)) } }) // Override with command-line values if err := k.Load(posflag.Provider(f, ".", k), nil); err != nil { return nil, fmt.Errorf("error parsing command-line parameters: %v", err) } if err := k.Unmarshal("", &config); err != nil { return nil, err } if *parsedCliArgs.Version { fmt.Println(version.AppNameAndVersion(true)) exit(0) } var sicherConfig SicherConfig s := NewSicherObject(k.String("environment"), configPath(k.String("paths.credentials"))) s.SetEnvStyle("yaml") // default is dotenv err = s.LoadEnv("", &sicherConfig) cookieStore := sessions.NewCookieStore([]byte(k.String("app_secret"))) cookieStore.Options.HttpOnly = true cookieStore.Options.Secure = *parsedCliArgs.Environment != "dev" && *parsedCliArgs.Environment != "testEnvironment" // Use HTTPS cookieStore.Options.SameSite = http.SameSiteLaxMode if err != nil { log.Println(err) } if err = k.Load(structs.Provider(sicherConfig, "koanf"), nil); err != nil { return nil, fmt.Errorf("error parsing sicher parameters: %v", err) } var appConfig ApplicationConfig unmarshalConf := koanf.UnmarshalConf{ DecoderConfig: &mapstructure.DecoderConfig{ DecodeHook: mapstructure.ComposeDecodeHookFunc( SecretFilePathUnmarshalHookFunc(), mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToSliceHookFunc(","), mapstructure.TextUnmarshallerHookFunc(), ), Metadata: nil, Result: &appConfig, WeaklyTypedInput: true, }, } if err := k.UnmarshalWithConf("", &config, unmarshalConf); err != nil { return nil, err } appConfig.AppSecret = SecretFromValue(k.String("app_secret")) appConfig.AppSecretBlock = SecretFromValue(k.String("app_secret_block")) nocodbConfig := NocoDBConfig{ ApiToken: SecretFromValue(k.String("nocodb.api_token")), BaseUrl: k.String("nocodb.base_url"), EmployeesTableId: k.String("nocodb.employees_table_id"), InfractionsTableId: k.String("nocodb.infractions_table_id"), NegativeInfractionId: k.Int("nocodb.negative_infraction_id"), NoPointsViewId: k.String("nocodb.no_points_view_id"), } oidcConfig := OidcConfig{ ClientId: k.String("oidc.client_id"), ClientSecret: SecretFromValue(k.String("oidc.client_secret")), Issuer: k.String("oidc.issuer"), Scopes: k.Strings("oidc.scopes"), } if *parsedCliArgs.Environment != "testEnvironment" { authProvider, err := oidc.NewProvider(context.Background(), k.String("oidc.issuer")) if err != nil { return nil, err } oidcConfig.Provider = authProvider oidcConfig.Verifier = authProvider.Verifier(&oidc.Config{ClientID: k.String("oidc.client_id")}) redirectUrl := fmt.Sprintf("%s/auth/callback", k.String("oidc.redirect_url")) oidcConfig.OAuth2 = &oauth2.Config{ ClientID: k.String("oidc.client_id"), ClientSecret: k.String("oidc.client_secret"), Endpoint: authProvider.Endpoint(), RedirectURL: redirectUrl, Scopes: k.Strings("oidc.scopes"), } } appConfig.CookieStore = cookieStore appConfig.Environment = *parsedCliArgs.Environment appConfig.Idms = IdmsConfig{ BaseUrl: k.String("idms.base_url"), CarsGroupId: k.String("idms.cars_group_id"), Id: SecretFromValue(k.String("idms.service_account_id")), Password: SecretFromValue(k.String("idms.service_account_password")), } appConfig.Listen = *parsedCliArgs.Listen appConfig.MatchThreshold = k.Int("match_threshold") appConfig.NocoDBConfig = nocodbConfig appConfig.OidcConfig = oidcConfig appConfig.PublicPath = configPath(k.String("paths.public")) appConfig.SessionName = "calculate-negative-points" appConfig.ViewPath = configPath(k.String("paths.views")) validate := validator.New() validate.RegisterCustomTypeFunc(secretTypeTranslator[string], Secret[string]{}) err = validate.Struct(appConfig) if err != nil { log.Println(err) fmt.Print("Could not create config") exit(1) return nil, err } return &appConfig, nil } const envPrefix = "APP_" const pgPrefix = "PG_" const databasePrefix = "DATABASE_" const idmsPrefix = "IDMS_" const oidcPrefix = "OIDC_" /* * Rio passes in PG_HOST and PG_PORT so we need to replace the PG string with DATABASE and then not trim the prefix */ func mapEnvVarNames(prefix string) func(s string) string { return func(s string) string { if prefix == databasePrefix { return strings.ToLower(s) } replacedPg := strings.Replace(s, "PG", "DATABASE", -1) lower := strings.ToLower(strings.TrimPrefix(replacedPg, prefix)) return lower } } func defaultConfig() map[string]interface{} { dbConfig := map[string]interface{}{ "host": "localhost", "port": 5432, "user": "postgres", "name": "calculate-negative-points", } return map[string]interface{}{ "database": dbConfig, "environment": defaultEnvironment, "listen": "127.0.0.1:3000", } } func secretTypeTranslator[T SecretValue](field reflect.Value) interface{} { if secret, ok := field.Interface().(Secret[T]); ok { return secret.Value() } return nil }