diff --git a/CHANGELOG.md b/CHANGELOG.md index f671909..036e8a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ ## Changelog +### v0.29.0 — Encrypt Sensitive Values in app.yaml (2026-02-23) + +#### Added +- **AES-256-GCM encryption for app.yaml secrets** — Sensitive deploy field values (`type: password` and `type: secret`) are now encrypted at rest in each stack's `app.yaml` using a per-node 32-byte key. Encrypted values are stored as `ENC:base64(nonce+ciphertext)`. New `internal/crypto` package provides `Encrypt`, `Decrypt`, `LoadOrCreateKey`, `DecryptMap`, and `IsEncrypted` helpers. +- **Encryption key in infra backup** — The encryption key (`encryption.key`) is included in the Hub infra backup bundle (`encryption_key_b64` field) and local drive infra backups for disaster recovery. +- **Encryption key restore** — The setup wizard's infra restore flow restores `encryption.key` from the backup bundle so encrypted app.yaml values remain readable after disaster recovery. +- **Startup migration** — On first start after upgrade, existing plaintext sensitive values in deployed stacks' `app.yaml` files are automatically encrypted in-place. + +#### Changed +- **`SaveAppConfig` signature** — Now accepts `encKey []byte` and `sensitiveVars []string` parameters for encryption. All callers (deploy, update, optional config, inject missing fields, HDD path update, storage handlers) updated. +- **`LoadAppConfigDecrypted`** — New helper that loads app.yaml and transparently decrypts all `ENC:` values for docker-compose env injection and web UI display. +- **`SensitiveEnvVars`** — New exported helper that identifies sensitive env vars from `.felhom.yml` metadata (`type: password` or `type: secret` deploy fields). +- **Manager struct** — Added `encKey` field and `SetEncryptionKey()` / `MigrateEncryption()` methods. +- **Web Server struct** — Added `encKey` field and `SetEncryptionKey()` method; deploy handler decrypts values before template rendering. + ### v0.28.8 — Password UX Polish (2026-02-23) #### Fixed diff --git a/controller/README.md b/controller/README.md index 6fcd7a6..a9fa644 100644 --- a/controller/README.md +++ b/controller/README.md @@ -90,6 +90,7 @@ A single, lightweight Go container that replaces Portainer + scattered systemd s | **Config** | `internal/config/` | YAML loader, validation, `FELHOM_*` env overrides | | **Settings** | `internal/settings/` | Runtime-mutable `settings.json` (passwords, backup prefs, storage paths, notifications) | | **Stacks** | `internal/stacks/` | Compose operations, scanning, `.felhom.yml` metadata, deploy/delete flow | +| **Crypto** | `internal/crypto/` | AES-256-GCM encryption for sensitive app.yaml values (passwords, secrets), key management | | **Sync** | `internal/sync/` | Git-based app catalog sync (clone/pull, content-hash copy) | | **Backup** | `internal/backup/` | Per-drive 3-layer backup: DB dumps → restic snapshots → cross-drive copies, restore | | **Storage** | `internal/storage/` | Disk scanning (`lsblk`), partitioning (`sfdisk`), formatting (`mkfs.ext4`), mounting, data migration (`rsync`) | @@ -1088,6 +1089,7 @@ controller/ ├── cmd/controller/main.go # Entry point, wires all 15 modules (setup mode branch + normal startup) ├── internal/ │ ├── config/config.go # YAML loader, validation, env overrides +│ ├── crypto/crypto.go # AES-256-GCM encryption for app.yaml secrets, key management │ ├── settings/settings.go # Runtime settings (JSON, atomic writes, RWMutex) │ ├── stacks/ │ │ ├── manager.go # Stack scanning, compose ops, container status @@ -1237,6 +1239,8 @@ Auto-managed by the controller. Contains password hash overrides, notification p Auto-generated during deployment. Contains env vars, locked fields list, deploy timestamp. Secret fields are locked (read-only after first deploy). Missing fields from updated templates are auto-injected on startup and after sync (see Missing Field Injection). +**Encryption at rest**: Sensitive env values (`type: password` and `type: secret` from `.felhom.yml` metadata) are stored encrypted as `ENC:base64(nonce+ciphertext)` using AES-256-GCM. The 32-byte encryption key is stored at `{dataDir}/encryption.key` (generated on first run, 0600 permissions). Values are decrypted transparently when passed to docker-compose or displayed in the UI. The key is included in infra backups (Hub + local drives) and restored during disaster recovery. On upgrade, existing plaintext values are migrated automatically on startup. + --- ## Scheduler Jobs diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 50ab82d..6aa0f88 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -19,6 +19,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/api" "gitea.dooplex.hu/admin/felhom-controller/internal/assets" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/crypto" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/metrics" "gitea.dooplex.hu/admin/felhom-controller/internal/monitor" @@ -88,11 +89,20 @@ func main() { discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger) sett.AutoDiscoverStoragePaths(discoveredPaths, cfg.Paths.HDDPath, logger) + // --- Load or create encryption key --- + encKeyPath := filepath.Join(cfg.Paths.DataDir, "encryption.key") + encKey, err := crypto.LoadOrCreateKey(encKeyPath) + if err != nil { + logger.Fatalf("[FATAL] Failed to load encryption key: %v", err) + } + logger.Printf("[INFO] Encryption key loaded from %s", encKeyPath) + // --- Initialize stack manager --- stackMgr, err := stacks.NewManager(cfg, logger) if err != nil { logger.Fatalf("[FATAL] Failed to initialize stack manager: %v", err) } + stackMgr.SetEncryptionKey(encKey) // Initial stack scan if err := stackMgr.ScanStacks(); err != nil { @@ -104,6 +114,9 @@ func main() { stackMgr.InjectMissingFields(names) } + // Migrate existing plaintext passwords to encrypted + stackMgr.MigrateEncryption() + // --- Initialize catalog syncer --- syncer := catalogsync.New(cfg, logger, stackMgr.ScanStacks, func(updated []string) { stackMgr.InjectMissingFields(updated) @@ -590,6 +603,7 @@ func main() { } // --- Initialize web server --- webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version) + webServer.SetEncryptionKey(encKey) webServer.SetStorageWatchdog(storageWatchdog) if assetsSyncer != nil { webServer.SetAssetsSyncer(assetsSyncer) @@ -942,7 +956,7 @@ func (a *driveMigrateStackAdapter) UpdateStackHDDPath(name, newPath string) erro return fmt.Errorf("app.yaml not found for stack: %s", name) } appCfg.Env["HDD_PATH"] = newPath - return stacks.SaveAppConfig(stackDir, appCfg) + return stacks.SaveAppConfig(stackDir, appCfg, nil, nil) } func (a *driveMigrateStackAdapter) StackExists(name string) bool { @@ -954,11 +968,13 @@ func (a *driveMigrateStackAdapter) StackExists(name string) bool { func pushInfraBackup(cfg *config.Config, sett *settings.Settings, stackProv *stackAdapter, pusher *report.Pusher, logger *log.Logger) { + encKeyPath := filepath.Join(cfg.Paths.DataDir, "encryption.key") ib, err := report.BuildInfraBackup( cfg.Customer.ID, cfg.Customer.Domain, Version, "/opt/docker/felhom-controller/controller.yaml", filepath.Join(cfg.Paths.DataDir, "settings.json"), cfg.Backup.ResticPasswordFile, + encKeyPath, cfg.Paths.SystemDataPath, sett, stackProv, logger, ) @@ -1053,11 +1069,13 @@ func runSetupMode(cfg *config.Config, logger *log.Logger) { func writeLocalInfraBackup(cfg *config.Config, sett *settings.Settings, stackProv *stackAdapter, logger *log.Logger) { + encKeyPath := filepath.Join(cfg.Paths.DataDir, "encryption.key") ib, err := report.BuildInfraBackup( cfg.Customer.ID, cfg.Customer.Domain, Version, "/opt/docker/felhom-controller/controller.yaml", filepath.Join(cfg.Paths.DataDir, "settings.json"), cfg.Backup.ResticPasswordFile, + encKeyPath, cfg.Paths.SystemDataPath, sett, stackProv, logger, ) diff --git a/controller/internal/crypto/crypto.go b/controller/internal/crypto/crypto.go new file mode 100644 index 0000000..22c9055 --- /dev/null +++ b/controller/internal/crypto/crypto.go @@ -0,0 +1,116 @@ +// Package crypto provides AES-256-GCM encryption for sensitive app config values. +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "strings" +) + +const ( + keySize = 32 // AES-256 + prefix = "ENC:" +) + +// LoadOrCreateKey reads a 32-byte encryption key from path, or generates one if it doesn't exist. +func LoadOrCreateKey(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err == nil { + if len(data) != keySize { + return nil, fmt.Errorf("encryption key file %s has invalid size %d (expected %d)", path, len(data), keySize) + } + return data, nil + } + if !os.IsNotExist(err) { + return nil, fmt.Errorf("reading encryption key %s: %w", path, err) + } + + // Generate new key + key := make([]byte, keySize) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generating encryption key: %w", err) + } + if err := os.WriteFile(path, key, 0600); err != nil { + return nil, fmt.Errorf("writing encryption key %s: %w", path, err) + } + return key, nil +} + +// Encrypt encrypts plaintext with AES-256-GCM and returns "ENC:". +func Encrypt(key []byte, plaintext string) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) // nonce prepended + return prefix + base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt decrypts a value produced by Encrypt. Returns error if value is not encrypted or decryption fails. +func Decrypt(key []byte, value string) (string, error) { + if !IsEncrypted(value) { + return "", fmt.Errorf("value is not encrypted") + } + + data, err := base64.StdEncoding.DecodeString(value[len(prefix):]) + if err != nil { + return "", fmt.Errorf("base64 decode: %w", err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + + plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil) + if err != nil { + return "", fmt.Errorf("decrypt: %w", err) + } + return string(plaintext), nil +} + +// IsEncrypted returns true if the value has the encryption prefix. +func IsEncrypted(value string) bool { + return strings.HasPrefix(value, prefix) +} + +// DecryptMap decrypts all encrypted values in a map, returning a new map with plaintext values. +func DecryptMap(key []byte, env map[string]string) map[string]string { + if key == nil || env == nil { + return env + } + result := make(map[string]string, len(env)) + for k, v := range env { + if IsEncrypted(v) { + if dec, err := Decrypt(key, v); err == nil { + result[k] = dec + continue + } + } + result[k] = v + } + return result +} diff --git a/controller/internal/report/infra_backup.go b/controller/internal/report/infra_backup.go index 67f7be5..d50522c 100644 --- a/controller/internal/report/infra_backup.go +++ b/controller/internal/report/infra_backup.go @@ -26,6 +26,7 @@ type InfraBackup struct { ResticPassword string `json:"restic_password,omitempty"` CrossDrivePassword string `json:"cross_drive_password,omitempty"` + EncryptionKeyB64 string `json:"encryption_key_b64,omitempty"` } // InfraStack identifies a deployed app for disaster recovery. @@ -42,6 +43,7 @@ func BuildInfraBackup( controllerYAMLPath string, settingsPath string, resticPasswordFile string, + encryptionKeyFile string, systemDataPath string, sett *settings.Settings, stackProvider backup.StackDataProvider, @@ -75,6 +77,15 @@ func BuildInfraBackup( logger.Printf("[WARN] Infra backup: could not read restic password file: %v", err) } + // Read encryption key for app.yaml secrets (important but non-fatal) + if encryptionKeyFile != "" { + if data, err := os.ReadFile(encryptionKeyFile); err == nil { + ib.EncryptionKeyB64 = base64.StdEncoding.EncodeToString(data) + } else if !os.IsNotExist(err) { + logger.Printf("[WARN] Infra backup: could not read encryption key file: %v", err) + } + } + // Collect disk layout from fstab + blkid ib.DiskLayout = collectDiskLayout(systemDataPath) diff --git a/controller/internal/setup/handlers.go b/controller/internal/setup/handlers.go index 6946987..dd5a8e5 100644 --- a/controller/internal/setup/handlers.go +++ b/controller/internal/setup/handlers.go @@ -653,6 +653,16 @@ func (s *Server) restoreFromInfraBackup(ib *report.InfraBackup) { } } } + + // Restore encryption key for app.yaml secrets + if ib.EncryptionKeyB64 != "" { + if data, err := base64.StdEncoding.DecodeString(ib.EncryptionKeyB64); err == nil { + keyFile := filepath.Join(s.dataDir, "encryption.key") + if err := atomicWriteFile(keyFile, data, 0600); err != nil { + s.logger.Printf("[WARN] Setup: failed to restore encryption key: %v", err) + } + } + } } func (s *Server) writeFreshConfig(configYAML, retrievalPassword string) error { diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index 92019dd..d96510b 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "gitea.dooplex.hu/admin/felhom-controller/internal/crypto" "gitea.dooplex.hu/admin/felhom-controller/internal/system" "gopkg.in/yaml.v3" ) @@ -255,7 +256,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { LockedFields: lockedFields, } - if err := SaveAppConfig(stackDir, appCfg); err != nil { + if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { return "", fmt.Errorf("saving app config: %w", err) } @@ -308,7 +309,7 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string, m.mu.Unlock() // Revert disk state — keep app.yaml for debugging but mark as not deployed appCfg.Deployed = false - _ = SaveAppConfig(stackDir, appCfg) + _ = SaveAppConfig(stackDir, appCfg, nil, nil) return } @@ -350,6 +351,7 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error lockedSet[f] = true } + meta := LoadMetadata(stackDir) for key, val := range values { if lockedSet[key] { return fmt.Errorf("field %q is locked and cannot be changed after deployment", key) @@ -357,7 +359,7 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error appCfg.Env[key] = val } - if err := SaveAppConfig(stackDir, appCfg); err != nil { + if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { return fmt.Errorf("saving updated config: %w", err) } @@ -444,7 +446,8 @@ func (m *Manager) UpdateOptionalConfig(stackName string, values map[string]strin } // Save app.yaml - if err := SaveAppConfig(stackDir, appCfg); err != nil { + meta := LoadMetadata(stackDir) + if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { return fmt.Errorf("saving app config: %w", err) } m.logger.Printf("[INFO] Saved updated app.yaml for %s", stackName) @@ -520,8 +523,29 @@ func LoadAppConfig(stackDir string) *AppConfig { return cfg } -func SaveAppConfig(stackDir string, cfg *AppConfig) error { - data, err := yaml.Marshal(cfg) +func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars []string) error { + // Clone env and encrypt sensitive values + saveCfg := &AppConfig{ + Deployed: cfg.Deployed, + DeployedAt: cfg.DeployedAt, + Env: make(map[string]string, len(cfg.Env)), + LockedFields: cfg.LockedFields, + } + sensitiveSet := make(map[string]bool, len(sensitiveVars)) + for _, v := range sensitiveVars { + sensitiveSet[v] = true + } + for k, v := range cfg.Env { + if encKey != nil && sensitiveSet[k] && !crypto.IsEncrypted(v) && v != "" { + if enc, err := crypto.Encrypt(encKey, v); err == nil { + saveCfg.Env[k] = enc + continue + } + } + saveCfg.Env[k] = v + } + + data, err := yaml.Marshal(saveCfg) if err != nil { return fmt.Errorf("marshaling app config: %w", err) } @@ -534,6 +558,27 @@ func SaveAppConfig(stackDir string, cfg *AppConfig) error { return nil } +// LoadAppConfigDecrypted loads app.yaml and decrypts any encrypted values. +func LoadAppConfigDecrypted(stackDir string, encKey []byte) *AppConfig { + cfg := LoadAppConfig(stackDir) + if cfg == nil || encKey == nil { + return cfg + } + cfg.Env = crypto.DecryptMap(encKey, cfg.Env) + return cfg +} + +// SensitiveEnvVars returns the env var names for secret/password fields from metadata. +func SensitiveEnvVars(meta *Metadata) []string { + var vars []string + for _, f := range meta.DeployFields { + if f.Type == "secret" || f.Type == "password" { + vars = append(vars, f.EnvVar) + } + } + return vars +} + // --- Secret generation --- const alphanumChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" @@ -650,7 +695,7 @@ func (m *Manager) InjectMissingFields(stackNames []string) { } if len(injected) > 0 { - if err := SaveAppConfig(stackDir, appCfg); err != nil { + if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { m.logger.Printf("[ERROR] Stack %s: failed to save app.yaml after injection: %v", name, err) continue } diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 8471fc5..ffa8b0b 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -14,6 +14,7 @@ import ( "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/crypto" ) // ContainerState represents the current state of a container. @@ -64,6 +65,7 @@ type Manager struct { composeCmd string stacks map[string]*Stack mu sync.RWMutex + encKey []byte // AES-256 key for encrypting sensitive values in app.yaml } // NewManager creates a new stack manager. @@ -90,6 +92,55 @@ func NewManager(cfg *config.Config, logger *log.Logger) (*Manager, error) { }, nil } +// SetEncryptionKey sets the AES-256 key used to encrypt/decrypt sensitive values in app.yaml. +func (m *Manager) SetEncryptionKey(key []byte) { + m.encKey = key +} + +// MigrateEncryption re-saves app.yaml for deployed stacks that still have +// plaintext values in sensitive fields. Called once on startup. +func (m *Manager) MigrateEncryption() { + if m.encKey == nil { + return + } + m.mu.RLock() + defer m.mu.RUnlock() + + migrated := 0 + for _, s := range m.stacks { + if !s.Deployed { + continue + } + stackDir := filepath.Dir(s.ComposePath) + appCfg := LoadAppConfig(stackDir) + if appCfg == nil { + continue + } + meta := LoadMetadata(stackDir) + sensitive := SensitiveEnvVars(&meta) + if len(sensitive) == 0 { + continue + } + needsMigration := false + for _, envVar := range sensitive { + if v, ok := appCfg.Env[envVar]; ok && v != "" && !crypto.IsEncrypted(v) { + needsMigration = true + break + } + } + if needsMigration { + if err := SaveAppConfig(stackDir, appCfg, m.encKey, sensitive); err != nil { + m.logger.Printf("[WARN] Encryption migration failed for %s: %v", s.Name, err) + } else { + migrated++ + } + } + } + if migrated > 0 { + m.logger.Printf("[INFO] Encrypted sensitive values in %d app.yaml file(s)", migrated) + } +} + // toTitleCase capitalizes the first letter of each word. func toTitleCase(s string) string { words := strings.Fields(s) @@ -535,8 +586,8 @@ func (m *Manager) stackEnv(stackDir string) []string { // Always inject DOMAIN env = append(env, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain)) - // Load app.yaml if it exists — merge its env vars - appCfg := LoadAppConfig(stackDir) + // Load app.yaml if it exists — merge its env vars (decrypted for docker-compose) + appCfg := LoadAppConfigDecrypted(stackDir, m.encKey) if appCfg != nil { for k, v := range appCfg.Env { env = append(env, fmt.Sprintf("%s=%s", k, v)) diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index bb90769..7e925e5 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -12,6 +12,7 @@ import ( "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/crypto" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" @@ -300,9 +301,13 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri data["AutoFields"] = meta.AutoGeneratedFields() // Auto-generated field values: existing values for deployed apps, pre-generated for new deploys autoFieldValues := make(map[string]string) + var decryptedEnv map[string]string + if appCfg != nil { + decryptedEnv = crypto.DecryptMap(s.encKey, appCfg.Env) + } if alreadyDeployed && appCfg != nil { for _, f := range meta.AutoGeneratedFields() { - if val, ok := appCfg.Env[f.EnvVar]; ok { + if val, ok := decryptedEnv[f.EnvVar]; ok { autoFieldValues[f.EnvVar] = val } } @@ -314,9 +319,9 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri } } data["AutoFieldValues"] = autoFieldValues - // For deployed apps, pass stored field values so subdomain and other user fields show current values - if alreadyDeployed && appCfg != nil { - data["DeployedFieldValues"] = appCfg.Env + // For deployed apps, pass stored field values (decrypted) so fields show current values + if alreadyDeployed && decryptedEnv != nil { + data["DeployedFieldValues"] = decryptedEnv } // Storage paths with free space info for deploy dropdown var deployPaths []DeployStoragePath diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 688161f..2f1056c 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -37,6 +37,7 @@ type Server struct { updater *selfupdate.Updater logger *log.Logger version string + encKey []byte // AES-256 key for decrypting app.yaml values tmpl *template.Template sessions map[string]*session @@ -107,6 +108,11 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste return s } +// SetEncryptionKey sets the AES-256 key used to decrypt app.yaml values for display. +func (s *Server) SetEncryptionKey(key []byte) { + s.encKey = key +} + func (s *Server) loadTemplates() { s.tmpl = template.Must( template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"), diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index 3fee8ae..bb9ab49 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -596,7 +596,8 @@ func (s *Server) updateStackHDDPath(stackName, newPath string) error { return fmt.Errorf("app.yaml not found for stack: %s", stackName) } appCfg.Env["HDD_PATH"] = newPath - return stacks.SaveAppConfig(stackDir, appCfg) + meta := stacks.LoadMetadata(stackDir) + return stacks.SaveAppConfig(stackDir, appCfg, s.encKey, stacks.SensitiveEnvVars(&meta)) } // storageInfoForStack returns deploy storage info for a deployed stack.