From 9ac84160543781eefa6b252c48a66caac25b1e49 Mon Sep 17 00:00:00 2001 From: bsaurusrex <82356519+bsaurusrex@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:56:51 +0800 Subject: [PATCH] feat: name misconfigured environment variables on startup Most startup failures reported after the v5 configuration change were renamed or misspelled environment variables, and the errors did not name them: - an unset app url failed with "failed to parse app url: invalid url". It now says the app url is not set and how to set it, and points at APP_URL / TINYAUTH_APP_URL when one of those (v4 style) is set. - TINYAUTH_ variables that match no configuration section were silently ignored. They are now logged as a warning once the logger is set up. Kubernetes service link variables (TINYAUTH_SERVICE_HOST, TINYAUTH_PORT, ...) are skipped. - a variable inside a known section that fails to decode only reported the internal node name (e.g. "node: databasepath"). The error now lists the variables that fail to decode on their own, and no longer propagates the decoder error, which could echo the offending value (a secret pasted into the wrong variable) into the logs. Also fixes the "https(s)" typo in the app url format error. Refs #685 Co-Authored-By: Claude Opus 5.5 --- cmd/tinyauth/tinyauth.go | 10 +-- internal/bootstrap/app_bootstrap.go | 22 +++++++ internal/utils/app_utils.go | 4 +- internal/utils/loaders/loader_env.go | 68 ++++++++++++++++++++- internal/utils/loaders/loader_env_test.go | 74 +++++++++++++++++++++++ 5 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 internal/utils/loaders/loader_env_test.go 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)) +}