credentials.go 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. package cmd
  2. // Credential resolution.
  3. //
  4. // Credentials are read from a .env file in the working directory, falling back
  5. // to the process environment. Real environment variables always win over .env,
  6. // so a one-off override does not require editing the file:
  7. //
  8. // SCHLUNDTECH_CONTEXT=1 ./schlundtech-dns zones
  9. //
  10. // Nothing here prints, logs or writes a credential.
  11. import (
  12. "fmt"
  13. "os"
  14. "path/filepath"
  15. "strings"
  16. "github.com/joho/godotenv"
  17. "schlundtech-dns/internal/api"
  18. )
  19. // envFile is the dotenv file read from the working directory.
  20. const envFile = ".env"
  21. // Credential environment variable names.
  22. const (
  23. envUser = "SCHLUNDTECH_USER"
  24. envPassword = "SCHLUNDTECH_PASSWORD"
  25. envContext = "SCHLUNDTECH_CONTEXT"
  26. envToken = "SCHLUNDTECH_TOKEN"
  27. envEndpoint = "SCHLUNDTECH_ENDPOINT"
  28. )
  29. // loadCredentials fills in any credential not already present in the
  30. // environment from the .env file, then returns the credentials.
  31. //
  32. // A missing .env is not an error: the environment alone is enough. A warning is
  33. // returned when the file exists but is readable beyond its owner, since it
  34. // holds a password.
  35. func loadCredentials() (api.Credentials, string, error) {
  36. warning, err := loadEnvFile(envFile)
  37. if err != nil {
  38. return api.Credentials{}, "", err
  39. }
  40. creds, err := credentialsFromEnv()
  41. return creds, warning, err
  42. }
  43. // loadEnvFile exports every variable from path that the environment does not
  44. // already define. It reports a warning if the file is too permissive.
  45. func loadEnvFile(path string) (string, error) {
  46. info, err := os.Stat(path)
  47. if err != nil {
  48. if os.IsNotExist(err) {
  49. return "", nil
  50. }
  51. return "", fmt.Errorf("read %s: %w", path, err)
  52. }
  53. if info.IsDir() {
  54. return "", fmt.Errorf("%s is a directory, expected a dotenv file", path)
  55. }
  56. raw, err := os.ReadFile(filepath.Clean(path))
  57. if err != nil {
  58. return "", fmt.Errorf("read %s: %w", path, err)
  59. }
  60. values, err := godotenv.Unmarshal(string(raw))
  61. if err != nil {
  62. return "", fmt.Errorf("parse %s: %w", path, err)
  63. }
  64. for k, v := range values {
  65. if _, set := os.LookupEnv(k); set {
  66. continue // the real environment wins
  67. }
  68. if err := os.Setenv(k, v); err != nil {
  69. return "", fmt.Errorf("set %s: %w", k, err)
  70. }
  71. }
  72. var warning string
  73. if perm := info.Mode().Perm(); perm&0o077 != 0 {
  74. warning = fmt.Sprintf(
  75. "%s holds your password and is readable by others (mode %#o); run: chmod 600 %s",
  76. path, perm, path)
  77. }
  78. return warning, nil
  79. }
  80. // credentialsFromEnv reads the credentials. It reports every missing variable
  81. // at once so a first run does not fail one variable at a time.
  82. func credentialsFromEnv() (api.Credentials, error) {
  83. c := api.Credentials{
  84. User: strings.TrimSpace(os.Getenv(envUser)),
  85. Password: os.Getenv(envPassword),
  86. Context: strings.TrimSpace(os.Getenv(envContext)),
  87. Token: strings.TrimSpace(os.Getenv(envToken)),
  88. Endpoint: strings.TrimSpace(os.Getenv(envEndpoint)),
  89. }
  90. var missing []string
  91. if c.User == "" {
  92. missing = append(missing, envUser)
  93. }
  94. if c.Password == "" {
  95. missing = append(missing, envPassword)
  96. }
  97. if c.Context == "" {
  98. missing = append(missing, envContext)
  99. }
  100. if len(missing) > 0 {
  101. return c, fmt.Errorf("missing %s: set them in %s or in the environment",
  102. strings.Join(missing, ", "), envFile)
  103. }
  104. return c, nil
  105. }