fix: deep bug hunt II — concurrency, security & optimization (25 files)

Critical: watchdog mutex panic safety, SetGeoAppOverride nil guard,
SSD-only app DB restore fallback.

High: double deploy race (atomic Deploying flag), delete/remove during
deploy guard, ScanStacks overwrite protection, FileBrowser mount mutex,
PushEvent history, PushOnce error handling, DB dump sync+close before
rename, restic retry fresh context, encrypt failure logging, cross-backup
path traversal validation, deepCopyStack completeness.

Security: constant-time API key comparison, login rate limiting (5/min),
git credential masking in logs, storage path prefix traversal fix.

Concurrency: MigrateEncryption lock ordering, SubdomainInUse I/O outside
lock, scheduler late-registered jobs, SQLite WAL verification, metrics
shutdown context, telemetry scan error logging, asset sync lock scope.

Optimization: streaming file copy for DB dumps, restic stats dedup,
atomic infra config copy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 14:21:09 +01:00
parent 72ab145b41
commit db83db383c
25 changed files with 930 additions and 626 deletions
+10
View File
@@ -86,6 +86,11 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
return nil, fmt.Errorf("stack %q is not orphaned — only orphaned stacks can be deleted", name)
}
// Must not be deploying (H2 fix)
if stack.Deploying {
return nil, fmt.Errorf("stack %q is currently being deployed — wait for deployment to finish", name)
}
// Must be stopped (not running)
if stack.State == StateRunning || stack.State == StateStarting || stack.State == StateRestarting {
return nil, fmt.Errorf("stack %q is still running — stop it first before deleting", name)
@@ -239,6 +244,11 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
return nil, fmt.Errorf("stack %q is not deployed", name)
}
// Must not be deploying (H2 fix)
if stack.Deploying {
return nil, fmt.Errorf("stack %q is currently being deployed — wait for deployment to finish", name)
}
// Must be stopped (not running)
if stack.State == StateRunning || stack.State == StateStarting || stack.State == StateRestarting {
return nil, fmt.Errorf("stack %q is still running — stop it first before removing", name)
+63 -18
View File
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/hex"
"fmt"
"log"
"math/big"
"os"
"path/filepath"
@@ -56,24 +57,35 @@ func validateSubdomain(s string) error {
// SubdomainInUse checks if a subdomain is already used by any deployed stack
// other than excludeStack.
func (m *Manager) SubdomainInUse(subdomain, excludeStack string) bool {
// Collect stack dirs and metadata under lock, then do I/O outside the lock.
type candidate struct {
dir string
metaSubdomain string
}
var candidates []candidate
m.mu.RLock()
defer m.mu.RUnlock()
for name, stack := range m.stacks {
if name == excludeStack || !stack.Deployed {
continue
}
stackDir := filepath.Dir(stack.ComposePath)
appCfg := LoadAppConfig(stackDir)
candidates = append(candidates, candidate{
dir: filepath.Dir(stack.ComposePath),
metaSubdomain: stack.Meta.Subdomain,
})
}
m.mu.RUnlock()
for _, c := range candidates {
appCfg := LoadAppConfig(c.dir)
if appCfg == nil {
continue
}
// Check stored SUBDOMAIN first
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd == subdomain {
return true
}
// Backward compat: check metadata subdomain for apps without SUBDOMAIN in env
if _, hasSub := appCfg.Env["SUBDOMAIN"]; !hasSub {
if stack.Meta.Subdomain == subdomain {
if c.metaSubdomain == subdomain {
return true
}
}
@@ -107,20 +119,43 @@ type DeployRequest struct {
// 7. Run docker compose up -d with env vars
// 8. Update in-memory stack state
func (m *Manager) DeployStack(req DeployRequest) (string, error) {
// Atomically check and set the Deploying flag to prevent concurrent deploys (H1 fix).
m.mu.Lock()
sPtr, sOk := m.stacks[req.StackName]
if !sOk {
m.mu.Unlock()
return "", fmt.Errorf("stack %q not found", req.StackName)
}
if sPtr.Deploying {
m.mu.Unlock()
return "", fmt.Errorf("stack %q is already being deployed — please wait", req.StackName)
}
if sPtr.Deployed {
m.mu.Unlock()
return "", fmt.Errorf("stack %q is already deployed; use update instead", req.StackName)
}
sPtr.Deploying = true
sPtr.DeployError = ""
m.mu.Unlock()
// If any validation below fails, clear the Deploying flag.
clearDeploying := func() {
m.mu.Lock()
if s, ok := m.stacks[req.StackName]; ok {
s.Deploying = false
}
m.mu.Unlock()
}
stack, ok := m.GetStack(req.StackName)
if !ok {
clearDeploying()
return "", fmt.Errorf("stack %q not found", req.StackName)
}
stackDir := filepath.Dir(stack.ComposePath)
meta := LoadMetadata(stackDir)
// Check if already deployed
existing := LoadAppConfig(stackDir)
if existing != nil && existing.Deployed {
return "", fmt.Errorf("stack %q is already deployed; use update instead", req.StackName)
}
// --- Memory validation ---
var deployWarning string
reservedMB := m.cfg.System.ReservedMemoryMB
@@ -136,6 +171,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
// Hard block: real used + new request exceeds usable memory
if newReqMB > 0 && usedMB+newReqMB > usableMB {
clearDeploying()
return "", fmt.Errorf(
"Nincs elég memória az alkalmazás telepítéséhez. "+
"Szükséges: %d MB, Elérhető: %d MB "+
@@ -187,12 +223,15 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
value = field.Default
}
if err := validateSubdomain(value); err != nil {
clearDeploying()
return "", err
}
if reservedSubdomains[value] {
clearDeploying()
return "", fmt.Errorf("a(z) %q aldomain foglalt rendszer számára", value)
}
if m.SubdomainInUse(value, req.StackName) {
clearDeploying()
return "", fmt.Errorf("a(z) %q aldomain már használatban van egy másik alkalmazásban", value)
}
@@ -204,6 +243,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
} else {
generated, err := generateValue(field.Generate)
if err != nil {
clearDeploying()
return "", fmt.Errorf("generating %s: %w", field.EnvVar, err)
}
value = generated
@@ -215,6 +255,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" {
value = userVal
} else {
clearDeploying()
return "", fmt.Errorf("a(z) %q mező kitöltése kötelező — használja a Generálás gombot vagy írjon be egy jelszót", field.Label)
}
@@ -229,12 +270,14 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
// Validate required fields
if field.Required && value == "" {
clearDeploying()
return "", fmt.Errorf("a(z) %q (%s) mező kitöltése kötelező", field.Label, field.EnvVar)
}
// Validate path fields exist on disk (inside the container's filesystem)
// Validate path fields exist on the host filesystem
if field.Type == "path" && value != "" {
if _, err := os.Stat(value); os.IsNotExist(err) {
clearDeploying()
return "", fmt.Errorf("path %q does not exist for field %q", value, field.Label)
}
}
@@ -257,6 +300,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
}
if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
clearDeploying()
return "", fmt.Errorf("saving app config: %w", err)
}
@@ -272,14 +316,12 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
m.checkLocalImages(req.StackName, stackDir)
}
// Update in-memory stack state and mark as deploying. The compose-up
// runs in a goroutine so the API can return immediately and the UI
// shows progress via polling (image pull can take 30-60s).
// Update in-memory stack state. Deploying was already set at the top (H1 fix).
// The compose-up runs in a goroutine so the API can return immediately
// and the UI shows progress via polling (image pull can take 30-60s).
m.mu.Lock()
if s, ok := m.stacks[req.StackName]; ok {
s.Deployed = true
s.Deploying = true
s.DeployError = ""
s.AppConfig = appCfg
}
m.mu.Unlock()
@@ -544,6 +586,9 @@ func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars
if enc, err := crypto.Encrypt(encKey, v); err == nil {
saveCfg.Env[k] = enc
continue
} else {
// H10 fix: log encryption failure — value will be saved in plaintext.
log.Printf("[WARN] Failed to encrypt env var %q: %v — saving as plaintext", k, err)
}
}
saveCfg.Env[k] = v
+43 -5
View File
@@ -120,11 +120,11 @@ func (m *Manager) SetEncryptionKey(key []byte) {
// MigrateEncryption re-saves app.yaml for deployed stacks that still have
// plaintext values in sensitive fields. Called once on startup.
func (m *Manager) MigrateEncryption() {
m.mu.Lock()
defer m.mu.Unlock()
if m.encKey == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
migrated := 0
for _, s := range m.stacks {
@@ -233,8 +233,12 @@ func (m *Manager) ScanStacks() error {
existing.ComposePath = composePath
existing.Meta = meta
existing.Protected = m.cfg.IsProtectedStack(name)
existing.Deployed = deployed
existing.AppConfig = appCfg
// Don't overwrite Deployed/AppConfig while an async deploy is in
// progress — the goroutine manages these fields (H3 fix).
if !existing.Deploying {
existing.Deployed = deployed
existing.AppConfig = appCfg
}
} else {
m.stacks[name] = &Stack{
Name: name,
@@ -507,10 +511,44 @@ func deepCopyStack(s *Stack) Stack {
cp.HealthProbe = &hpCopy
}
// Deep-copy Meta.DeployFields slice
// Deep-copy Meta.DeployFields slice (including nested Options)
if s.Meta.DeployFields != nil {
cp.Meta.DeployFields = make([]DeployField, len(s.Meta.DeployFields))
copy(cp.Meta.DeployFields, s.Meta.DeployFields)
for i, f := range s.Meta.DeployFields {
if f.Options != nil {
cp.Meta.DeployFields[i].Options = make([]SelectOption, len(f.Options))
copy(cp.Meta.DeployFields[i].Options, f.Options)
}
}
}
// Deep-copy Meta.OptionalConfig (slice of groups with nested Fields slices)
if s.Meta.OptionalConfig != nil {
cp.Meta.OptionalConfig = make([]OptionalConfigGroup, len(s.Meta.OptionalConfig))
copy(cp.Meta.OptionalConfig, s.Meta.OptionalConfig)
for i, g := range s.Meta.OptionalConfig {
if g.Fields != nil {
cp.Meta.OptionalConfig[i].Fields = make([]OptionalConfigField, len(g.Fields))
copy(cp.Meta.OptionalConfig[i].Fields, g.Fields)
}
}
}
// Deep-copy Meta.HealthCheck pointer
if s.Meta.HealthCheck != nil {
hcCopy := *s.Meta.HealthCheck
if s.Meta.HealthCheck.Checks != nil {
hcCopy.Checks = make([]HealthCheckItem, len(s.Meta.HealthCheck.Checks))
copy(hcCopy.Checks, s.Meta.HealthCheck.Checks)
for i, c := range s.Meta.HealthCheck.Checks {
if c.Expect != nil {
eCopy := *c.Expect
hcCopy.Checks[i].Expect = &eCopy
}
}
}
cp.Meta.HealthCheck = &hcCopy
}
return cp