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