Files
narratio/internal/app/secrets_env.go

148 lines
4.3 KiB
Go

package app
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// APIKeyFileByteLimit bounds one credential value read from the configured
// secrets directory. API keys are expected to be short single-line values.
const APIKeyFileByteLimit int64 = 8 * 1024
const secretDirectoryPrivateMode os.FileMode = 0o700
const secretFilePrivateMode os.FileMode = 0o600
type secretsLoadStats struct {
Dir string
Loaded int
PreservedExisting int
Skipped int
}
func loadSecretsFromConfig(cfg *config.Config, logger *slog.Logger) (*secretsLoadStats, error) {
return loadSecretsFromConfigWithDependencies(cfg, logger, commandDependencies{})
}
func loadSecretsFromConfigWithDependencies(cfg *config.Config, logger *slog.Logger, deps commandDependencies) (*secretsLoadStats, error) {
deps = normalizeCommandDependencies(deps)
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Secrets == nil {
return nil, nil
}
rawDir := strings.TrimSpace(cfg.Pipeline.Secrets.EnvDir)
if rawDir == "" {
return nil, nil
}
resolvedDir := rawDir
if !filepath.IsAbs(resolvedDir) {
cwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("resolve secrets env_dir %q from current working directory: %w", rawDir, err)
}
resolvedDir = filepath.Join(cwd, resolvedDir)
}
resolvedDir = filepath.Clean(resolvedDir)
directoryInfo, err := os.Lstat(resolvedDir)
if err != nil {
return nil, fmt.Errorf("API-key loader: validate secrets env_dir: %w", err)
}
if directoryInfo.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("API-key loader: secrets env_dir must not be a symbolic link")
}
if err := validateSecretDirectory(directoryInfo); err != nil {
return nil, fmt.Errorf("API-key loader: validate secrets env_dir: %w", err)
}
entries, err := deps.readSecretsDirectory(resolvedDir)
if err != nil {
return nil, fmt.Errorf("API-key loader: read secrets env_dir %q: %w", resolvedDir, err)
}
directoryInfo, err = os.Lstat(resolvedDir)
if err != nil || directoryInfo.Mode()&os.ModeSymlink != 0 {
if err == nil {
err = fmt.Errorf("symbolic links are not allowed")
}
return nil, fmt.Errorf("API-key loader: validate secrets env_dir: %w", err)
}
stats := &secretsLoadStats{Dir: resolvedDir}
for _, entry := range entries {
name := entry.Name()
if !envVarNamePattern.MatchString(name) {
stats.Skipped++
continue
}
if _, exists := os.LookupEnv(name); exists {
stats.PreservedExisting++
continue
}
value, err := readAPIKeyFile(resolvedDir, name)
if err != nil {
return nil, err
}
if err := os.Setenv(name, value); err != nil {
return nil, fmt.Errorf("API-key loader: set environment variable %q: %w", name, err)
}
stats.Loaded++
}
if logger != nil {
logger.Info(
"loaded secret environment variables from filesystem",
"secrets_env_dir", stats.Dir,
"loaded", stats.Loaded,
"preserved_existing", stats.PreservedExisting,
"skipped", stats.Skipped,
)
}
return stats, nil
}
func validateSecretDirectory(info os.FileInfo) error {
if !info.IsDir() {
return fmt.Errorf("secrets env_dir is not a directory")
}
return validateSecretDirectoryPrivacy(info)
}
func validateSecretFile(info os.FileInfo) error {
if !info.Mode().IsRegular() {
return fmt.Errorf("secret entry is not a regular file")
}
return validateSecretFilePrivacy(info)
}
func readAPIKeyFile(directory, name string) (string, error) {
path := filepath.Join(directory, name)
info, err := os.Lstat(path)
if err == nil && info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("API-key loader: read secret file %q with %d-byte limit: symbolic links are not allowed", name, APIKeyFileByteLimit)
}
if err == nil {
err = validateSecretFile(info)
}
if err == nil {
content, readErr := fileops.ReadFileBounded(path, APIKeyFileByteLimit)
if readErr == nil {
return strings.TrimRight(string(content), "\r\n"), nil
}
err = readErr
}
if err != nil {
return "", fmt.Errorf("API-key loader: read secret file %q with %d-byte limit: %w", name, APIKeyFileByteLimit, err)
}
return "", fmt.Errorf("API-key loader: read secret file %q with %d-byte limit", name, APIKeyFileByteLimit)
}