package cmd // Credential resolution. // // Credentials are read from a .env file in the working directory, falling back // to the process environment. Real environment variables always win over .env, // so a one-off override does not require editing the file: // // SCHLUNDTECH_CONTEXT=1 ./schlundtech-dns zones // // Nothing here prints, logs or writes a credential. import ( "fmt" "os" "path/filepath" "strings" "github.com/joho/godotenv" "schlundtech-dns/internal/api" ) // envFile is the dotenv file read from the working directory. const envFile = ".env" // Credential environment variable names. const ( envUser = "SCHLUNDTECH_USER" envPassword = "SCHLUNDTECH_PASSWORD" envContext = "SCHLUNDTECH_CONTEXT" envToken = "SCHLUNDTECH_TOKEN" envEndpoint = "SCHLUNDTECH_ENDPOINT" ) // loadCredentials fills in any credential not already present in the // environment from the .env file, then returns the credentials. // // A missing .env is not an error: the environment alone is enough. A warning is // returned when the file exists but is readable beyond its owner, since it // holds a password. func loadCredentials() (api.Credentials, string, error) { warning, err := loadEnvFile(envFile) if err != nil { return api.Credentials{}, "", err } creds, err := credentialsFromEnv() return creds, warning, err } // loadEnvFile exports every variable from path that the environment does not // already define. It reports a warning if the file is too permissive. func loadEnvFile(path string) (string, error) { info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return "", nil } return "", fmt.Errorf("read %s: %w", path, err) } if info.IsDir() { return "", fmt.Errorf("%s is a directory, expected a dotenv file", path) } raw, err := os.ReadFile(filepath.Clean(path)) if err != nil { return "", fmt.Errorf("read %s: %w", path, err) } values, err := godotenv.Unmarshal(string(raw)) if err != nil { return "", fmt.Errorf("parse %s: %w", path, err) } for k, v := range values { if _, set := os.LookupEnv(k); set { continue // the real environment wins } if err := os.Setenv(k, v); err != nil { return "", fmt.Errorf("set %s: %w", k, err) } } var warning string if perm := info.Mode().Perm(); perm&0o077 != 0 { warning = fmt.Sprintf( "%s holds your password and is readable by others (mode %#o); run: chmod 600 %s", path, perm, path) } return warning, nil } // credentialsFromEnv reads the credentials. It reports every missing variable // at once so a first run does not fail one variable at a time. func credentialsFromEnv() (api.Credentials, error) { c := api.Credentials{ User: strings.TrimSpace(os.Getenv(envUser)), Password: os.Getenv(envPassword), Context: strings.TrimSpace(os.Getenv(envContext)), Token: strings.TrimSpace(os.Getenv(envToken)), Endpoint: strings.TrimSpace(os.Getenv(envEndpoint)), } var missing []string if c.User == "" { missing = append(missing, envUser) } if c.Password == "" { missing = append(missing, envPassword) } if c.Context == "" { missing = append(missing, envContext) } if len(missing) > 0 { return c, fmt.Errorf("missing %s: set them in %s or in the environment", strings.Join(missing, ", "), envFile) } return c, nil }