122 lines
3.2 KiB
Go
122 lines
3.2 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_]*$`)
|
|
var readSecretsDirectoryEntries = os.ReadDir
|
|
|
|
// 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) {
|
|
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)
|
|
|
|
if err := fileops.ValidateConfinedDirectory(resolvedDir, validateSecretDirectory); err != nil {
|
|
return nil, fmt.Errorf("API-key loader: validate secrets env_dir: %w", err)
|
|
}
|
|
|
|
entries, err := readSecretsDirectoryEntries(resolvedDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("API-key loader: read secrets env_dir %q: %w", resolvedDir, 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) {
|
|
content, err := fileops.ReadRegularFileUnderRoot(
|
|
directory,
|
|
name,
|
|
APIKeyFileByteLimit,
|
|
validateSecretDirectory,
|
|
validateSecretFile,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("API-key loader: read secret file %q with %d-byte limit: %w", name, APIKeyFileByteLimit, err)
|
|
}
|
|
return strings.TrimRight(string(content), "\r\n"), nil
|
|
}
|