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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user