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