diff --git a/cmd/tinyauth/tinyauth.go b/cmd/tinyauth/tinyauth.go index 506cb0ca4..b8ff9ccc2 100644 --- a/cmd/tinyauth/tinyauth.go +++ b/cmd/tinyauth/tinyauth.go @@ -20,10 +20,12 @@ func main() { env := model.DetectRuntimeEnv() tConfig := model.NewDefaultConfiguration(env) + envLoader := &loaders.EnvLoader{} + loaders := []cli.ResourceLoader{ &loaders.FileLoader{}, &loaders.FlagLoader{}, - &loaders.EnvLoader{}, + envLoader, } cmdTinyauth := &cli.Command{ @@ -37,7 +39,7 @@ func main() { colors := getColors() fmt.Println(colors.yellow.Render("⚠") + " Experimental features are enabled, use with caution. Experimental features may change with each release.") } - return runCmd(*tConfig) + return runCmd(*tConfig, envLoader.Ignored) }, } @@ -144,8 +146,8 @@ func main() { } } -func runCmd(cfg model.Config) error { - app := bootstrap.NewBootstrapApp(cfg) +func runCmd(cfg model.Config, ignoredEnvVars []string) error { + app := bootstrap.NewBootstrapApp(cfg).WithIgnoredEnvVars(ignoredEnvVars) err := app.Setup() diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index 6fc954080..6a6f73e11 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -58,6 +58,8 @@ type BootstrapApp struct { db *sql.DB ding *ding.Ding dig *dig.Container + + ignoredEnvVars []string } func NewBootstrapApp(config model.Config) *BootstrapApp { @@ -66,6 +68,12 @@ func NewBootstrapApp(config model.Config) *BootstrapApp { } } +// WithIgnoredEnvVars sets the unknown environment variables found while loading the configuration, they are logged on startup +func (app *BootstrapApp) WithIgnoredEnvVars(names []string) *BootstrapApp { + app.ignoredEnvVars = names + return app +} + func (app *BootstrapApp) Setup() error { // create context ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -87,9 +95,23 @@ func (app *BootstrapApp) Setup() error { app.log.App.Info().Msgf("Starting Tinyauth version: %s", model.Version) + if len(app.ignoredEnvVars) > 0 { + app.log.App.Warn().Strs("variables", app.ignoredEnvVars).Msg("Ignoring unknown environment variables, see https://tinyauth.app/docs/reference/configuration") + } + // get app url appURL, err := utils.SafeParseAppURL(app.config.AppURL) + if errors.Is(err, utils.ErrEmptyURL) { + // v4 used APP_URL, a common leftover when migrating to v5 + for _, legacy := range []string{"APP_URL", "TINYAUTH_APP_URL"} { + if os.Getenv(legacy) != "" { + return fmt.Errorf("%w, found %s which is not used since v5, rename it to TINYAUTH_APPURL", err, legacy) + } + } + return fmt.Errorf("%w, set it with TINYAUTH_APPURL, --appurl or appUrl in the config file (environment variables are ignored when a config file or CLI flags are used)", err) + } + if err != nil { return fmt.Errorf("failed to parse app url: %w", err) } diff --git a/internal/utils/app_utils.go b/internal/utils/app_utils.go index 7c168423c..b0a422bbd 100644 --- a/internal/utils/app_utils.go +++ b/internal/utils/app_utils.go @@ -11,7 +11,7 @@ import ( ) var ( - ErrEmptyURL = fmt.Errorf("invalid url") + ErrEmptyURL = fmt.Errorf("app url is not set") ) func SafeParseAppURL(str string) (string, error) { @@ -28,7 +28,7 @@ func SafeParseAppURL(str string) (string, error) { if u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { - return "", fmt.Errorf("invalid url, must be in format https(s)://host") + return "", fmt.Errorf("invalid url, must be in format http(s)://host") } hostname := strings.ToLower(u.Hostname()) diff --git a/internal/utils/loaders/loader_env.go b/internal/utils/loaders/loader_env.go index c09ad828c..82fab02e4 100644 --- a/internal/utils/loaders/loader_env.go +++ b/internal/utils/loaders/loader_env.go @@ -1,25 +1,87 @@ package loaders import ( + "errors" "fmt" "os" + "reflect" + "regexp" + "slices" + "strings" "github.com/tinyauthapp/paerser/cli" "github.com/tinyauthapp/paerser/env" "github.com/tinyauthapp/tinyauth/internal/model" ) -type EnvLoader struct{} +// kubernetesServiceEnvVar matches the variables Kubernetes injects for a service named tinyauth (service links) +var kubernetesServiceEnvVar = regexp.MustCompile(`^TINYAUTH_(SERVICE_HOST|SERVICE_PORT(_[A-Z0-9_]+)?|PORT(_[0-9]+_(TCP|UDP|SCTP)(_(ADDR|PORT|PROTO))?)?)$`) + +type EnvLoader struct { + // Ignored holds the prefixed environment variables that matched no configuration option, they are logged on startup + Ignored []string +} func (e *EnvLoader) Load(_ []string, cmd *cli.Command) (bool, error) { - vars := env.FindPrefixedEnvVars(os.Environ(), model.DefaultNamePrefix, cmd.Configuration) + environ := os.Environ() + vars := env.FindPrefixedEnvVars(environ, model.DefaultNamePrefix, cmd.Configuration) + e.Ignored = UnknownEnvVars(environ, vars) + if len(vars) == 0 { return false, nil } if err := env.Decode(vars, model.DefaultNamePrefix, cmd.Configuration); err != nil { - return false, fmt.Errorf("failed to decode configuration from environment variables: %w", err) + // The decoder error can echo the offending value (e.g. a strconv parse error), which may be a + // secret pasted into the wrong variable, so it is not propagated. The rejected variable names + // are reported instead, which is what the operator needs to fix it. + if invalid := InvalidEnvVars(vars, cmd.Configuration); len(invalid) > 0 { + return false, fmt.Errorf("failed to decode configuration from environment variables, check %s", strings.Join(invalid, ", ")) + } + return false, errors.New("failed to decode configuration from environment variables") } return true, nil } + +// UnknownEnvVars returns the names of prefixed environment variables that do not map to any configuration +// section and are therefore silently ignored by the decoder (e.g. TINYAUTH_APP_URL instead of TINYAUTH_APPURL). +func UnknownEnvVars(environ []string, matched []string) []string { + var unknown []string + + for _, value := range environ { + if !strings.HasPrefix(value, model.DefaultNamePrefix) || slices.Contains(matched, value) { + continue + } + name, _, _ := strings.Cut(value, "=") + if kubernetesServiceEnvVar.MatchString(name) { + continue + } + unknown = append(unknown, name) + } + + return unknown +} + +// InvalidEnvVars decodes each variable on its own into a fresh configuration to find the names the decoder rejects, +// since the decoder error only contains the failing node (e.g. "databasepath") and not the variable name. +func InvalidEnvVars(vars []string, configuration any) []string { + var invalid []string + + cfgType := reflect.TypeOf(configuration) + if cfgType == nil || cfgType.Kind() != reflect.Pointer { + return nil + } + + for _, value := range vars { + fresh := reflect.New(cfgType.Elem()).Interface() + if err := env.Decode([]string{value}, model.DefaultNamePrefix, fresh); err != nil { + name, _, _ := strings.Cut(value, "=") + if !slices.Contains(invalid, name) { + invalid = append(invalid, name) + } + } + } + + return invalid +} diff --git a/internal/utils/loaders/loader_env_test.go b/internal/utils/loaders/loader_env_test.go new file mode 100644 index 000000000..919a3959d --- /dev/null +++ b/internal/utils/loaders/loader_env_test.go @@ -0,0 +1,74 @@ +package loaders_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tinyauthapp/paerser/cli" + "github.com/tinyauthapp/paerser/env" + "github.com/tinyauthapp/tinyauth/internal/model" + "github.com/tinyauthapp/tinyauth/internal/utils/loaders" +) + +func TestLoadRedactsDecodeErrorValue(t *testing.T) { + secret := "super-secret-token-pasted-into-the-wrong-var" + t.Setenv("TINYAUTH_SERVER_PORT", secret) // port is an int, so this value fails to decode + + loader := &loaders.EnvLoader{} + cfg := model.NewDefaultConfiguration(model.RuntimeEnvUnknown) + + _, err := loader.Load(nil, &cli.Command{Configuration: cfg}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "TINYAUTH_SERVER_PORT") + assert.NotContains(t, err.Error(), secret) +} + +func TestUnknownEnvVars(t *testing.T) { + cfg := model.NewDefaultConfiguration(model.RuntimeEnvUnknown) + + environ := []string{ + "PATH=/usr/bin", + "APP_URL=https://tinyauth.example.com", // not prefixed, not ours + "TINYAUTH_APPURL=https://tinyauth.example.com", + "TINYAUTH_APP_URL=https://tinyauth.example.com", + "TINYAUTH_SERVER_PORT=3000", + "TINYAUTH_RESOURCESDIR=/data/resources", // matches the resources section, caught by InvalidEnvVars instead + // injected by Kubernetes for a service named tinyauth + "TINYAUTH_PORT=tcp://10.0.0.1:3000", + "TINYAUTH_PORT_3000_TCP=tcp://10.0.0.1:3000", + "TINYAUTH_PORT_3000_TCP_ADDR=10.0.0.1", + "TINYAUTH_PORT_3000_TCP_PORT=3000", + "TINYAUTH_PORT_3000_TCP_PROTO=tcp", + "TINYAUTH_SERVICE_HOST=10.0.0.1", + "TINYAUTH_SERVICE_PORT=3000", + "TINYAUTH_SERVICE_PORT_HTTP=3000", + } + + vars := env.FindPrefixedEnvVars(environ, model.DefaultNamePrefix, cfg) + + assert.Equal(t, []string{"TINYAUTH_APP_URL"}, loaders.UnknownEnvVars(environ, vars)) + assert.Empty(t, loaders.UnknownEnvVars([]string{"TINYAUTH_APPURL=https://tinyauth.example.com"}, []string{"TINYAUTH_APPURL=https://tinyauth.example.com"})) +} + +func TestInvalidEnvVars(t *testing.T) { + cfg := model.NewDefaultConfiguration(model.RuntimeEnvUnknown) + + vars := []string{ + "TINYAUTH_APPURL=https://tinyauth.example.com", + "TINYAUTH_DATABASEPATH=/data/tinyauth.db", + "TINYAUTH_DATABASEPATH=/data/tinyauth.db", // duplicates are reported once + "TINYAUTH_SERVER_PORT=3000", + "TINYAUTH_RESOURCESDIR=/data/resources", + "TINYAUTH_OAUTH_PROVIDERS_GOOGLE_CLIENT_ID=abc", + "TINYAUTH_OAUTH_PROVIDERS_GOOGLE_CLIENTID=abc", + } + + assert.Equal(t, []string{"TINYAUTH_DATABASEPATH", "TINYAUTH_RESOURCESDIR", "TINYAUTH_OAUTH_PROVIDERS_GOOGLE_CLIENT_ID"}, loaders.InvalidEnvVars(vars, cfg)) + + // The configuration passed in must not be modified + assert.Equal(t, model.NewDefaultConfiguration(model.RuntimeEnvUnknown), cfg) + + assert.Nil(t, loaders.InvalidEnvVars(vars, nil)) +}