feat: encrypt sensitive values in app.yaml with AES-256-GCM
Passwords and secrets from deploy fields (type: password/secret) are now encrypted at rest in app.yaml using a per-node 32-byte key. Values stored as ENC:base64(nonce+ciphertext), decrypted transparently for docker-compose and web UI. Key included in infra backup bundle for disaster recovery. Existing plaintext values migrated automatically on startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:<base64(nonce+ciphertext)>".
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user