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
+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