fix: P0+P1 critical bug fixes across controller (24 files)

Concurrency fixes:
- Deep-copy stacks in GetStack/GetStacks to prevent shared state mutation (C04)
- Add per-state mutex to watchdog pathProbeState (C05)
- Guard MetricsCollector.Start() with sync.Once against double-start (C06)
- Hold diskJobMu across entire raw mount operation (C07)
- Add mutex to SetEncryptionKey (C08), MigrateEncryption write lock (H03)
- Use sync.Once for sync.Stop() channel close (H08)
- Set syncing=true before releasing lock in TriggerSync (H09)
- Deep-copy lastDBDump/lastBackup in GetFullStatus (H11)
- Add WaitGroup for stderr goroutine in MigrateDrive (H19)
- Add mutex to SetBackupRunningCheck (M18)

Security fixes:
- Validate Bearer token against Hub API key in CSRF middleware (H16)
- Validate backup paths start with expected prefix in RemoveStack (M12)
- Guard uuid[:8] slice with length check (H20)
- Parse fstab fields exactly for mount target matching (H21)

Bug fixes:
- Use decrypted env vars for compose deploy (C01)
- Log decrypt failures in DecryptMap instead of swallowing (C02)
- Move Deployed=false inside lock in runComposeDeploy (C03)
- Fix activeDrives() to skip disconnected drives (H02)
- Fix Snapshot() stderr extraction from exec.ExitError (H01)
- Check unlockCmd.Run() error in restic (H01)
- Buffer template rendering via bytes.Buffer (H07)
- Thread context.Context through cloudflare client (H10)
- Fix leaf-name collision detection in cross-drive backup (H15)
- Add nil check for crossDriveRunner (H17)
- Use strings.TrimSpace instead of slice on command output (H18)
- Make SaveAppConfig atomic with write-to-tmp+rename (H04)
- Pass encKey on deploy failure SaveAppConfig (H05)
- Fix IPv6 address format in TCP health probe

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 13:39:45 +01:00
parent 2ad743b66f
commit 8b8c04a487
23 changed files with 248 additions and 83 deletions
+21 -7
View File
@@ -203,20 +203,22 @@ func (m *Manager) groupStacksByDrive() map[string][]StackSummary {
} }
// activeDrives returns sorted list of drives that have deployed apps. // activeDrives returns sorted list of drives that have deployed apps.
// Disconnected and decommissioned drives are excluded.
func (m *Manager) activeDrives() []string { func (m *Manager) activeDrives() []string {
groups := m.groupStacksByDrive() groups := m.groupStacksByDrive()
var drives []string var drives []string
var disconnected []string var skipped []string
for d := range groups { for d := range groups {
if m.settings != nil && (m.settings.IsDisconnected(d) || m.settings.IsDecommissioned(d)) { if m.settings != nil && (m.settings.IsDisconnected(d) || m.settings.IsDecommissioned(d)) {
disconnected = append(disconnected, d) skipped = append(skipped, d)
continue
} }
drives = append(drives, d) drives = append(drives, d)
} }
sort.Strings(drives) sort.Strings(drives)
if m.isDebug() { if m.isDebug() {
m.logger.Printf("[DEBUG] activeDrives: %d total (%s), %d disconnected/decommissioned", m.logger.Printf("[DEBUG] activeDrives: %d active (%s), %d skipped (disconnected/decommissioned)",
len(drives), strings.Join(drives, ", "), len(disconnected)) len(drives), strings.Join(drives, ", "), len(skipped))
} }
return drives return drives
} }
@@ -1211,11 +1213,10 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta
} }
// No cache yet — return a minimal status (first page load before cache is populated) // No cache yet — return a minimal status (first page load before cache is populated)
return &FullBackupStatus{ // Deep-copy lastDBDump and lastBackup to prevent callers from mutating shared state.
status := &FullBackupStatus{
Enabled: m.cfg.Backup.Enabled, Enabled: m.cfg.Backup.Enabled,
Running: m.running, Running: m.running,
LastDBDump: m.lastDBDump,
LastBackup: m.lastBackup,
DBDumpSchedule: m.cfg.Backup.DBDumpSchedule, DBDumpSchedule: m.cfg.Backup.DBDumpSchedule,
ResticSchedule: m.cfg.Backup.ResticSchedule, ResticSchedule: m.cfg.Backup.ResticSchedule,
PruneSchedule: m.cfg.Backup.PruneSchedule, PruneSchedule: m.cfg.Backup.PruneSchedule,
@@ -1225,6 +1226,19 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta
LastCheckTime: m.lastCheckTime, LastCheckTime: m.lastCheckTime,
LastCheckOK: m.lastCheckOK, LastCheckOK: m.lastCheckOK,
} }
if m.lastDBDump != nil {
copyDump := *m.lastDBDump
if len(m.lastDBDump.Results) > 0 {
copyDump.Results = make([]DumpResult, len(m.lastDBDump.Results))
copy(copyDump.Results, m.lastDBDump.Results)
}
status.LastDBDump = &copyDump
}
if m.lastBackup != nil {
copyBackup := *m.lastBackup
status.LastBackup = &copyBackup
}
return status
} }
// isDebug returns true if logging level is "debug". // isDebug returns true if logging level is "debug".
+12 -6
View File
@@ -372,7 +372,8 @@ func (r *CrossDriveRunner) runRsyncBackup(ctx context.Context, stackName, destBa
return fmt.Errorf("creating rsync dest dir: %w", err) return fmt.Errorf("creating rsync dest dir: %w", err)
} }
for i, srcMount := range mounts { seen := make(map[string]bool)
for _, srcMount := range mounts {
var dstPath string var dstPath string
if len(mounts) == 1 { if len(mounts) == 1 {
// Single mount: rsync directly into the stack folder (no extra nesting) // Single mount: rsync directly into the stack folder (no extra nesting)
@@ -380,13 +381,18 @@ func (r *CrossDriveRunner) runRsyncBackup(ctx context.Context, stackName, destBa
} else { } else {
// Multiple mounts: use the leaf directory name as subfolder // Multiple mounts: use the leaf directory name as subfolder
leaf := filepath.Base(srcMount) leaf := filepath.Base(srcMount)
dstPath = filepath.Join(destDir, leaf) if seen[leaf] {
// Disambiguate duplicate leaf names (e.g. two mounts both named "data") // Disambiguate duplicate leaf names (e.g. two mounts both named "data")
if i > 0 { for j := 2; ; j++ {
if _, err := os.Stat(dstPath); err == nil { candidate := fmt.Sprintf("%s_%d", leaf, j)
dstPath = filepath.Join(destDir, fmt.Sprintf("%s_%d", leaf, i)) if !seen[candidate] {
leaf = candidate
break
}
} }
} }
seen[leaf] = true
dstPath = filepath.Join(destDir, leaf)
} }
if err := os.MkdirAll(dstPath, 0755); err != nil { if err := os.MkdirAll(dstPath, 0755); err != nil {
return fmt.Errorf("creating rsync destination: %w", err) return fmt.Errorf("creating rsync destination: %w", err)
+7 -2
View File
@@ -134,12 +134,17 @@ func (r *ResticManager) Snapshot(repoPath string, paths []string, tags []string)
cmd := r.command(ctx, repoPath, args...) cmd := r.command(ctx, repoPath, args...)
out, err := cmd.Output() out, err := cmd.Output()
if err != nil { if err != nil {
// Check for stale lock // Check for stale lock — restic writes lock errors to stderr, not stdout
errStr := string(out) errStr := string(out)
if exitErr, ok := err.(*exec.ExitError); ok {
errStr += string(exitErr.Stderr)
}
if strings.Contains(errStr, "lock") || strings.Contains(errStr, "locked") { if strings.Contains(errStr, "lock") || strings.Contains(errStr, "locked") {
r.logger.Printf("[WARN] Restic repo locked — attempting unlock") r.logger.Printf("[WARN] Restic repo locked — attempting unlock")
unlockCmd := r.command(ctx, repoPath, "unlock") unlockCmd := r.command(ctx, repoPath, "unlock")
unlockCmd.Run() if unlockErr := unlockCmd.Run(); unlockErr != nil {
r.logger.Printf("[WARN] Restic unlock failed: %v", unlockErr)
}
// Retry once // Retry once
cmd = r.command(ctx, repoPath, args...) cmd = r.command(ctx, repoPath, args...)
out, err = cmd.Output() out, err = cmd.Output()
+3 -2
View File
@@ -2,6 +2,7 @@ package cloudflare
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -54,7 +55,7 @@ type apiMessage struct {
} }
// do performs an HTTP request to the Cloudflare API and decodes the response. // do performs an HTTP request to the Cloudflare API and decodes the response.
func (c *Client) do(method, path string, body interface{}) (*apiResponse, error) { func (c *Client) do(ctx context.Context, method, path string, body interface{}) (*apiResponse, error) {
var bodyReader io.Reader var bodyReader io.Reader
if body != nil { if body != nil {
data, err := json.Marshal(body) data, err := json.Marshal(body)
@@ -70,7 +71,7 @@ func (c *Client) do(method, path string, body interface{}) (*apiResponse, error)
} }
url := apiBase + path url := apiBase + path
req, err := http.NewRequest(method, url, bodyReader) req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil { if err != nil {
return nil, fmt.Errorf("create request: %w", err) return nil, fmt.Errorf("create request: %w", err)
} }
+11 -11
View File
@@ -76,7 +76,7 @@ func (g *GeoSyncManager) Sync(ctx context.Context) error {
zoneID := geo.ZoneID zoneID := geo.ZoneID
if zoneID == "" { if zoneID == "" {
var err error var err error
zoneID, err = g.client.GetZoneID(g.domain) zoneID, err = g.client.GetZoneID(ctx, g.domain)
if err != nil { if err != nil {
g.saveError(zoneID, "", err.Error()) g.saveError(zoneID, "", err.Error())
return fmt.Errorf("resolve zone: %w", err) return fmt.Errorf("resolve zone: %w", err)
@@ -87,13 +87,13 @@ func (g *GeoSyncManager) Sync(ctx context.Context) error {
rulesetID := geo.RulesetID rulesetID := geo.RulesetID
if rulesetID == "" { if rulesetID == "" {
var err error var err error
rulesetID, err = g.client.GetCustomRulesetID(zoneID) rulesetID, err = g.client.GetCustomRulesetID(ctx, zoneID)
if err != nil { if err != nil {
g.saveError(zoneID, "", err.Error()) g.saveError(zoneID, "", err.Error())
return fmt.Errorf("get ruleset: %w", err) return fmt.Errorf("get ruleset: %w", err)
} }
if rulesetID == "" { if rulesetID == "" {
rulesetID, err = g.client.CreateCustomRuleset(zoneID) rulesetID, err = g.client.CreateCustomRuleset(ctx, zoneID)
if err != nil { if err != nil {
g.saveError(zoneID, "", err.Error()) g.saveError(zoneID, "", err.Error())
return fmt.Errorf("create ruleset: %w", err) return fmt.Errorf("create ruleset: %w", err)
@@ -102,7 +102,7 @@ func (g *GeoSyncManager) Sync(ctx context.Context) error {
} }
// 3. List existing felhom-managed rules // 3. List existing felhom-managed rules
existing, err := g.client.GetFelhomRules(zoneID, rulesetID) existing, err := g.client.GetFelhomRules(ctx, zoneID, rulesetID)
if err != nil { if err != nil {
g.saveError(zoneID, rulesetID, err.Error()) g.saveError(zoneID, rulesetID, err.Error())
return fmt.Errorf("list existing rules: %w", err) return fmt.Errorf("list existing rules: %w", err)
@@ -112,7 +112,7 @@ func (g *GeoSyncManager) Sync(ctx context.Context) error {
desired := g.buildDesiredRules(geo) desired := g.buildDesiredRules(geo)
// 5. Diff and apply // 5. Diff and apply
if err := g.applyDiff(zoneID, rulesetID, existing, desired); err != nil { if err := g.applyDiff(ctx, zoneID, rulesetID, existing, desired); err != nil {
g.saveError(zoneID, rulesetID, err.Error()) g.saveError(zoneID, rulesetID, err.Error())
return fmt.Errorf("apply diff: %w", err) return fmt.Errorf("apply diff: %w", err)
} }
@@ -138,14 +138,14 @@ func (g *GeoSyncManager) deleteAllRules(ctx context.Context, geo *settings.GeoRe
return nil return nil
} }
existing, err := g.client.GetFelhomRules(zoneID, rulesetID) existing, err := g.client.GetFelhomRules(ctx, zoneID, rulesetID)
if err != nil { if err != nil {
g.logger.Printf("[GEO] Warning: could not list rules for cleanup: %v", err) g.logger.Printf("[GEO] Warning: could not list rules for cleanup: %v", err)
return nil return nil
} }
for _, r := range existing { for _, r := range existing {
if err := g.client.DeleteRule(zoneID, rulesetID, r.ID); err != nil { if err := g.client.DeleteRule(ctx, zoneID, rulesetID, r.ID); err != nil {
g.logger.Printf("[GEO] Warning: could not delete rule %s: %v", r.ID, err) g.logger.Printf("[GEO] Warning: could not delete rule %s: %v", r.ID, err)
} }
} }
@@ -202,7 +202,7 @@ func (g *GeoSyncManager) buildDesiredRules(geo *settings.GeoRestriction) []desir
} }
// applyDiff applies the difference between existing and desired rules. // applyDiff applies the difference between existing and desired rules.
func (g *GeoSyncManager) applyDiff(zoneID, rulesetID string, existing []GeoRule, desired []desiredRule) error { func (g *GeoSyncManager) applyDiff(ctx context.Context, zoneID, rulesetID string, existing []GeoRule, desired []desiredRule) error {
// Index existing by description // Index existing by description
existingByDesc := make(map[string]GeoRule) existingByDesc := make(map[string]GeoRule)
for _, r := range existing { for _, r := range existing {
@@ -221,14 +221,14 @@ func (g *GeoSyncManager) applyDiff(zoneID, rulesetID string, existing []GeoRule,
// Rule exists — check if expression changed // Rule exists — check if expression changed
if ex.Expression != d.expression { if ex.Expression != d.expression {
r := newBlockRule(d.description, d.expression) r := newBlockRule(d.description, d.expression)
if err := g.client.UpdateRule(zoneID, rulesetID, ex.ID, r); err != nil { if err := g.client.UpdateRule(ctx, zoneID, rulesetID, ex.ID, r); err != nil {
return fmt.Errorf("update rule %q: %w", d.description, err) return fmt.Errorf("update rule %q: %w", d.description, err)
} }
} }
} else { } else {
// New rule — create // New rule — create
r := newBlockRule(d.description, d.expression) r := newBlockRule(d.description, d.expression)
if _, err := g.client.CreateRule(zoneID, rulesetID, r); err != nil { if _, err := g.client.CreateRule(ctx, zoneID, rulesetID, r); err != nil {
return fmt.Errorf("create rule %q: %w", d.description, err) return fmt.Errorf("create rule %q: %w", d.description, err)
} }
} }
@@ -237,7 +237,7 @@ func (g *GeoSyncManager) applyDiff(zoneID, rulesetID string, existing []GeoRule,
// Delete rules that are no longer desired // Delete rules that are no longer desired
for _, ex := range existing { for _, ex := range existing {
if _, ok := desiredByDesc[ex.Description]; !ok { if _, ok := desiredByDesc[ex.Description]; !ok {
if err := g.client.DeleteRule(zoneID, rulesetID, ex.ID); err != nil { if err := g.client.DeleteRule(ctx, zoneID, rulesetID, ex.ID); err != nil {
return fmt.Errorf("delete rule %q: %w", ex.Description, err) return fmt.Errorf("delete rule %q: %w", ex.Description, err)
} }
} }
+15 -14
View File
@@ -1,6 +1,7 @@
package cloudflare package cloudflare
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
@@ -58,9 +59,9 @@ type GeoRule struct {
// GetCustomRulesetID returns the zone's http_request_firewall_custom ruleset ID. // GetCustomRulesetID returns the zone's http_request_firewall_custom ruleset ID.
// Returns empty string if no such ruleset exists yet. // Returns empty string if no such ruleset exists yet.
func (c *Client) GetCustomRulesetID(zoneID string) (string, error) { func (c *Client) GetCustomRulesetID(ctx context.Context, zoneID string) (string, error) {
path := fmt.Sprintf("/zones/%s/rulesets", zoneID) path := fmt.Sprintf("/zones/%s/rulesets", zoneID)
resp, err := c.do("GET", path, nil) resp, err := c.do(ctx, "GET", path, nil)
if err != nil { if err != nil {
return "", fmt.Errorf("list rulesets: %w", err) return "", fmt.Errorf("list rulesets: %w", err)
} }
@@ -80,7 +81,7 @@ func (c *Client) GetCustomRulesetID(zoneID string) (string, error) {
} }
// CreateCustomRuleset creates the http_request_firewall_custom phase entry point ruleset. // CreateCustomRuleset creates the http_request_firewall_custom phase entry point ruleset.
func (c *Client) CreateCustomRuleset(zoneID string) (string, error) { func (c *Client) CreateCustomRuleset(ctx context.Context, zoneID string) (string, error) {
path := fmt.Sprintf("/zones/%s/rulesets", zoneID) path := fmt.Sprintf("/zones/%s/rulesets", zoneID)
body := map[string]interface{}{ body := map[string]interface{}{
"name": "felhom custom rules", "name": "felhom custom rules",
@@ -89,7 +90,7 @@ func (c *Client) CreateCustomRuleset(zoneID string) (string, error) {
"rules": []interface{}{}, "rules": []interface{}{},
} }
resp, err := c.do("POST", path, body) resp, err := c.do(ctx, "POST", path, body)
if err != nil { if err != nil {
return "", fmt.Errorf("create ruleset: %w", err) return "", fmt.Errorf("create ruleset: %w", err)
} }
@@ -104,9 +105,9 @@ func (c *Client) CreateCustomRuleset(zoneID string) (string, error) {
} }
// GetRules returns all rules in a ruleset. // GetRules returns all rules in a ruleset.
func (c *Client) GetRules(zoneID, rulesetID string) ([]rule, error) { func (c *Client) GetRules(ctx context.Context, zoneID, rulesetID string) ([]rule, error) {
path := fmt.Sprintf("/zones/%s/rulesets/%s", zoneID, rulesetID) path := fmt.Sprintf("/zones/%s/rulesets/%s", zoneID, rulesetID)
resp, err := c.do("GET", path, nil) resp, err := c.do(ctx, "GET", path, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("get ruleset: %w", err) return nil, fmt.Errorf("get ruleset: %w", err)
} }
@@ -122,8 +123,8 @@ func (c *Client) GetRules(zoneID, rulesetID string) ([]rule, error) {
} }
// GetFelhomRules returns only rules with the [felhom-geo] prefix. // GetFelhomRules returns only rules with the [felhom-geo] prefix.
func (c *Client) GetFelhomRules(zoneID, rulesetID string) ([]GeoRule, error) { func (c *Client) GetFelhomRules(ctx context.Context, zoneID, rulesetID string) ([]GeoRule, error) {
rules, err := c.GetRules(zoneID, rulesetID) rules, err := c.GetRules(ctx, zoneID, rulesetID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -144,9 +145,9 @@ func (c *Client) GetFelhomRules(zoneID, rulesetID string) ([]GeoRule, error) {
} }
// CreateRule adds a new rule to the ruleset. // CreateRule adds a new rule to the ruleset.
func (c *Client) CreateRule(zoneID, rulesetID string, r rule) (string, error) { func (c *Client) CreateRule(ctx context.Context, zoneID, rulesetID string, r rule) (string, error) {
path := fmt.Sprintf("/zones/%s/rulesets/%s/rules", zoneID, rulesetID) path := fmt.Sprintf("/zones/%s/rulesets/%s/rules", zoneID, rulesetID)
resp, err := c.do("POST", path, r) resp, err := c.do(ctx, "POST", path, r)
if err != nil { if err != nil {
return "", fmt.Errorf("create rule: %w", err) return "", fmt.Errorf("create rule: %w", err)
} }
@@ -170,9 +171,9 @@ func (c *Client) CreateRule(zoneID, rulesetID string, r rule) (string, error) {
} }
// UpdateRule updates an existing rule in the ruleset. // UpdateRule updates an existing rule in the ruleset.
func (c *Client) UpdateRule(zoneID, rulesetID, ruleID string, r rule) error { func (c *Client) UpdateRule(ctx context.Context, zoneID, rulesetID, ruleID string, r rule) error {
path := fmt.Sprintf("/zones/%s/rulesets/%s/rules/%s", zoneID, rulesetID, ruleID) path := fmt.Sprintf("/zones/%s/rulesets/%s/rules/%s", zoneID, rulesetID, ruleID)
_, err := c.do("PATCH", path, r) _, err := c.do(ctx, "PATCH", path, r)
if err != nil { if err != nil {
return fmt.Errorf("update rule %s: %w", ruleID, err) return fmt.Errorf("update rule %s: %w", ruleID, err)
} }
@@ -181,9 +182,9 @@ func (c *Client) UpdateRule(zoneID, rulesetID, ruleID string, r rule) error {
} }
// DeleteRule removes a rule from the ruleset. // DeleteRule removes a rule from the ruleset.
func (c *Client) DeleteRule(zoneID, rulesetID, ruleID string) error { func (c *Client) DeleteRule(ctx context.Context, zoneID, rulesetID, ruleID string) error {
path := fmt.Sprintf("/zones/%s/rulesets/%s/rules/%s", zoneID, rulesetID, ruleID) path := fmt.Sprintf("/zones/%s/rulesets/%s/rules/%s", zoneID, rulesetID, ruleID)
_, err := c.do("DELETE", path, nil) _, err := c.do(ctx, "DELETE", path, nil)
if err != nil { if err != nil {
return fmt.Errorf("delete rule %s: %w", ruleID, err) return fmt.Errorf("delete rule %s: %w", ruleID, err)
} }
+6 -5
View File
@@ -1,6 +1,7 @@
package cloudflare package cloudflare
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url" "net/url"
@@ -14,9 +15,9 @@ type zone struct {
// GetZoneID resolves the Cloudflare zone ID for a domain. // GetZoneID resolves the Cloudflare zone ID for a domain.
// It tries the exact domain first, then strips subdomains progressively. // It tries the exact domain first, then strips subdomains progressively.
func (c *Client) GetZoneID(domain string) (string, error) { func (c *Client) GetZoneID(ctx context.Context, domain string) (string, error) {
// Try exact domain first (e.g., "demo-felhom.eu") // Try exact domain first (e.g., "demo-felhom.eu")
id, err := c.lookupZone(domain) id, err := c.lookupZone(ctx, domain)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -31,7 +32,7 @@ func (c *Client) GetZoneID(domain string) (string, error) {
if parent == "" { if parent == "" {
break break
} }
id, err = c.lookupZone(parent) id, err = c.lookupZone(ctx, parent)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -45,9 +46,9 @@ func (c *Client) GetZoneID(domain string) (string, error) {
} }
// lookupZone queries the CF API for a zone by name. // lookupZone queries the CF API for a zone by name.
func (c *Client) lookupZone(name string) (string, error) { func (c *Client) lookupZone(ctx context.Context, name string) (string, error) {
path := "/zones?name=" + url.QueryEscape(name) + "&status=active" path := "/zones?name=" + url.QueryEscape(name) + "&status=active"
resp, err := c.do("GET", path, nil) resp, err := c.do(ctx, "GET", path, nil)
if err != nil { if err != nil {
return "", fmt.Errorf("lookup zone %q: %w", name, err) return "", fmt.Errorf("lookup zone %q: %w", name, err)
} }
+8 -2
View File
@@ -7,6 +7,7 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"log"
"os" "os"
"strings" "strings"
) )
@@ -98,6 +99,7 @@ func IsEncrypted(value string) bool {
} }
// DecryptMap decrypts all encrypted values in a map, returning a new map with plaintext values. // DecryptMap decrypts all encrypted values in a map, returning a new map with plaintext values.
// Logs a warning for any value that fails to decrypt (key rotation, data corruption).
func DecryptMap(key []byte, env map[string]string) map[string]string { func DecryptMap(key []byte, env map[string]string) map[string]string {
if key == nil || env == nil { if key == nil || env == nil {
return env return env
@@ -105,10 +107,14 @@ func DecryptMap(key []byte, env map[string]string) map[string]string {
result := make(map[string]string, len(env)) result := make(map[string]string, len(env))
for k, v := range env { for k, v := range env {
if IsEncrypted(v) { if IsEncrypted(v) {
if dec, err := Decrypt(key, v); err == nil { dec, err := Decrypt(key, v)
result[k] = dec if err != nil {
log.Printf("[WARN] Failed to decrypt env var %q: %v — passing through encrypted value", k, err)
result[k] = v
continue continue
} }
result[k] = dec
continue
} }
result[k] = v result[k] = v
} }
+7 -2
View File
@@ -7,6 +7,7 @@ import (
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"gitea.dooplex.hu/admin/felhom-controller/internal/system" "gitea.dooplex.hu/admin/felhom-controller/internal/system"
@@ -19,6 +20,7 @@ type MetricsCollector struct {
hddPath string hddPath string
logger *log.Logger logger *log.Logger
cancel context.CancelFunc cancel context.CancelFunc
startOnce sync.Once
} }
// NewMetricsCollector creates a new collector. // NewMetricsCollector creates a new collector.
@@ -32,9 +34,12 @@ func NewMetricsCollector(store *MetricsStore, cpuCollector *system.CPUCollector,
} }
// Start begins the background collection loop (every 60 seconds). // Start begins the background collection loop (every 60 seconds).
// Safe to call multiple times — only the first call starts the loop.
func (c *MetricsCollector) Start(ctx context.Context) { func (c *MetricsCollector) Start(ctx context.Context) {
ctx, c.cancel = context.WithCancel(ctx) c.startOnce.Do(func() {
go c.loop(ctx) ctx, c.cancel = context.WithCancel(ctx)
go c.loop(ctx)
})
} }
// Stop cancels the collection loop. // Stop cancels the collection loop.
+23
View File
@@ -54,6 +54,7 @@ type WatchdogStackProvider interface {
// pathProbeState tracks in-memory probe state for a single storage path. // pathProbeState tracks in-memory probe state for a single storage path.
type pathProbeState struct { type pathProbeState struct {
mu sync.Mutex
consecutiveFailures int consecutiveFailures int
lastStatus string // "connected", "disconnected" lastStatus string // "connected", "disconnected"
lastProbeTime time.Time lastProbeTime time.Time
@@ -141,10 +142,13 @@ func (w *StorageWatchdog) Check(ctx context.Context) error {
state := w.getOrCreateState(sp.Path) state := w.getOrCreateState(sp.Path)
// Rate-limit per-path probes // Rate-limit per-path probes
state.mu.Lock()
if time.Since(state.lastProbeTime) < state.probeInterval { if time.Since(state.lastProbeTime) < state.probeInterval {
state.mu.Unlock()
continue continue
} }
state.lastProbeTime = time.Now() state.lastProbeTime = time.Now()
state.mu.Unlock()
// Skip decommissioned drives entirely — no apps reference them // Skip decommissioned drives entirely — no apps reference them
if sp.Decommissioned { if sp.Decommissioned {
@@ -186,6 +190,9 @@ func (w *StorageWatchdog) handleConnectedProbe(sp settings.StoragePath, state *p
result := system.ProbeStoragePath(sp.Path) result := system.ProbeStoragePath(sp.Path)
probeLatency := time.Since(probeStart) probeLatency := time.Since(probeStart)
state.mu.Lock()
defer state.mu.Unlock()
if w.isDebug() { if w.isDebug() {
state.probeCount++ state.probeCount++
state.totalLatency += probeLatency state.totalLatency += probeLatency
@@ -225,7 +232,9 @@ func (w *StorageWatchdog) handleConnectedProbe(sp settings.StoragePath, state *p
sp.Path, state.consecutiveFailures, probeThreshold, result.Err) sp.Path, state.consecutiveFailures, probeThreshold, result.Err)
if state.consecutiveFailures >= probeThreshold { if state.consecutiveFailures >= probeThreshold {
state.mu.Unlock()
w.handleDisconnect(sp, state, result) w.handleDisconnect(sp, state, result)
state.mu.Lock() // re-acquire for deferred Unlock
} }
} }
@@ -251,9 +260,11 @@ func (w *StorageWatchdog) handleDisconnect(sp settings.StoragePath, state *pathP
} }
// 4. Update in-memory state // 4. Update in-memory state
state.mu.Lock()
state.lastStatus = "disconnected" state.lastStatus = "disconnected"
state.probeInterval = disconnectedProbeInterval state.probeInterval = disconnectedProbeInterval
state.consecutiveFailures = 0 state.consecutiveFailures = 0
state.mu.Unlock()
// 5. Trigger alert refresh // 5. Trigger alert refresh
if w.alertRefresh != nil { if w.alertRefresh != nil {
@@ -343,9 +354,11 @@ func (w *StorageWatchdog) handleReconnectCheck(ctx context.Context, sp settings.
// Update in-memory state // Update in-memory state
state := w.getOrCreateState(sp.Path) state := w.getOrCreateState(sp.Path)
state.mu.Lock()
state.lastStatus = "connected" state.lastStatus = "connected"
state.probeInterval = defaultProbeInterval state.probeInterval = defaultProbeInterval
state.consecutiveFailures = 0 state.consecutiveFailures = 0
state.mu.Unlock()
// Trigger alert refresh // Trigger alert refresh
if w.alertRefresh != nil { if w.alertRefresh != nil {
@@ -551,9 +564,11 @@ func (w *StorageWatchdog) SafeDisconnect(ctx context.Context, path string) (stop
// 5. Update in-memory state // 5. Update in-memory state
state := w.getOrCreateState(path) state := w.getOrCreateState(path)
state.mu.Lock()
state.lastStatus = "disconnected" state.lastStatus = "disconnected"
state.probeInterval = disconnectedProbeInterval state.probeInterval = disconnectedProbeInterval
state.consecutiveFailures = 0 state.consecutiveFailures = 0
state.mu.Unlock()
// 6. Trigger alert refresh // 6. Trigger alert refresh
if w.alertRefresh != nil { if w.alertRefresh != nil {
@@ -624,9 +639,11 @@ func (w *StorageWatchdog) Reconnect(ctx context.Context, path string) (stoppedSt
// Update in-memory state // Update in-memory state
state := w.getOrCreateState(path) state := w.getOrCreateState(path)
state.mu.Lock()
state.lastStatus = "connected" state.lastStatus = "connected"
state.probeInterval = defaultProbeInterval state.probeInterval = defaultProbeInterval
state.consecutiveFailures = 0 state.consecutiveFailures = 0
state.mu.Unlock()
// Trigger alert refresh // Trigger alert refresh
if w.alertRefresh != nil { if w.alertRefresh != nil {
@@ -720,9 +737,11 @@ func (w *StorageWatchdog) SimulateDisconnect(ctx context.Context, path string) (
// Step 4: Update in-memory state // Step 4: Update in-memory state
state := w.getOrCreateState(path) state := w.getOrCreateState(path)
state.mu.Lock()
state.lastStatus = "disconnected" state.lastStatus = "disconnected"
state.probeInterval = disconnectedProbeInterval state.probeInterval = disconnectedProbeInterval
state.consecutiveFailures = 0 state.consecutiveFailures = 0
state.mu.Unlock()
// Step 5: Trigger alert refresh // Step 5: Trigger alert refresh
if w.alertRefresh != nil { if w.alertRefresh != nil {
@@ -782,9 +801,11 @@ func (w *StorageWatchdog) SimulateReconnect(ctx context.Context, path string) er
// Update in-memory state // Update in-memory state
state := w.getOrCreateState(path) state := w.getOrCreateState(path)
state.mu.Lock()
state.lastStatus = "connected" state.lastStatus = "connected"
state.probeInterval = defaultProbeInterval state.probeInterval = defaultProbeInterval
state.consecutiveFailures = 0 state.consecutiveFailures = 0
state.mu.Unlock()
// Trigger alert refresh // Trigger alert refresh
if w.alertRefresh != nil { if w.alertRefresh != nil {
@@ -841,6 +862,7 @@ func (w *StorageWatchdog) GetDebugStatus() []PathDebugStatus {
ds.Simulated = w.isSimulatedLocked(sp.Path) ds.Simulated = w.isSimulatedLocked(sp.Path)
if state, ok := w.pathState[sp.Path]; ok { if state, ok := w.pathState[sp.Path]; ok {
state.mu.Lock()
ds.DebounceCount = state.consecutiveFailures ds.DebounceCount = state.consecutiveFailures
ds.LastProbe = state.lastProbeTime ds.LastProbe = state.lastProbeTime
ds.ProbeOK = state.lastStatus == "connected" ds.ProbeOK = state.lastStatus == "connected"
@@ -849,6 +871,7 @@ func (w *StorageWatchdog) GetDebugStatus() []PathDebugStatus {
if state.probeCount > 0 { if state.probeCount > 0 {
ds.AvgLatencyMs = float64(state.totalLatency.Milliseconds()) / float64(state.probeCount) ds.AvgLatencyMs = float64(state.totalLatency.Milliseconds()) / float64(state.probeCount)
} }
state.mu.Unlock()
} }
result = append(result, ds) result = append(result, ds)
} }
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/backup"
@@ -96,7 +97,7 @@ func checkDockerSocket() CheckResult {
if err != nil { if err != nil {
return CheckResult{Name: "Docker socket", Status: "fail", Message: fmt.Sprintf("docker info failed: %v", err)} return CheckResult{Name: "Docker socket", Status: "fail", Message: fmt.Sprintf("docker info failed: %v", err)}
} }
return CheckResult{Name: "Docker socket", Status: "pass", Message: fmt.Sprintf("reachable (v%s)", string(out[:len(out)-1]))} return CheckResult{Name: "Docker socket", Status: "pass", Message: fmt.Sprintf("reachable (v%s)", strings.TrimSpace(string(out)))}
} }
func checkStacksDir(stacksDir string) CheckResult { func checkStacksDir(stacksDir string) CheckResult {
@@ -64,6 +64,8 @@ func NewUpdater(cfg *config.SelfUpdateConfig, gitCfg *config.GitConfig, currentV
// SetBackupRunningCheck sets the callback to check if a backup is in progress. // SetBackupRunningCheck sets the callback to check if a backup is in progress.
func (u *Updater) SetBackupRunningCheck(fn func() bool) { func (u *Updater) SetBackupRunningCheck(fn func() bool) {
u.mu.Lock()
defer u.mu.Unlock()
u.backupRunning = fn u.backupRunning = fn
} }
+6
View File
@@ -301,8 +301,14 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
} }
// Step 5: Handle backup data cleanup // Step 5: Handle backup data cleanup
backupsBase := filepath.Join(hddPath, felhomDataDir, "backups")
for _, bkPath := range backupPathsToRemove { for _, bkPath := range backupPathsToRemove {
cleanPath := filepath.Clean(bkPath) cleanPath := filepath.Clean(bkPath)
// Validate path is under the expected backups directory
if hddPath == "" || !strings.HasPrefix(cleanPath, backupsBase+string(filepath.Separator)) {
m.logger.Printf("[WARN] Refusing to remove backup path outside expected directory: %s", cleanPath)
continue
}
if _, err := os.Stat(cleanPath); os.IsNotExist(err) { if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
continue continue
} }
+19 -8
View File
@@ -298,7 +298,7 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string,
if composeErr != nil { if composeErr != nil {
m.logger.Printf("[ERROR] Stack %s deploy failed after %.1fs: %v", name, time.Since(start).Seconds(), composeErr) m.logger.Printf("[ERROR] Stack %s deploy failed after %.1fs: %v", name, time.Since(start).Seconds(), composeErr)
// Revert in-memory state // Revert in-memory and disk state
m.mu.Lock() m.mu.Lock()
if s, ok := m.stacks[name]; ok { if s, ok := m.stacks[name]; ok {
s.Deployed = false s.Deployed = false
@@ -306,10 +306,12 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string,
s.DeployError = composeErr.Error() s.DeployError = composeErr.Error()
s.AppConfig = nil s.AppConfig = nil
} }
m.mu.Unlock() // Also revert the shared appCfg under lock (C03 fix)
// Revert disk state — keep app.yaml for debugging but mark as not deployed
appCfg.Deployed = false appCfg.Deployed = false
_ = SaveAppConfig(stackDir, appCfg, nil, nil) m.mu.Unlock()
// Save reverted state to disk with encryption (H05 fix)
meta := LoadMetadata(stackDir)
_ = SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta))
return return
} }
@@ -363,8 +365,10 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error
return fmt.Errorf("saving updated config: %w", err) return fmt.Errorf("saving updated config: %w", err)
} }
_, err := m.composeExecWithEnv(stackDir, appCfg.Env, "up", "-d") // Use stackEnv which loads decrypted values for docker compose (C01 fix).
if err != nil { // appCfg.Env may contain encrypted values from LoadAppConfig.
env := m.stackEnv(stackDir)
if _, err := m.composeExecCustomEnv(stackDir, env, "up", "-d"); err != nil {
return fmt.Errorf("restarting with new config: %w", err) return fmt.Errorf("restarting with new config: %w", err)
} }
@@ -552,8 +556,15 @@ func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars
path := filepath.Join(stackDir, "app.yaml") path := filepath.Join(stackDir, "app.yaml")
header := "# Auto-generated by felhom-controller — do not edit locked fields manually\n" header := "# Auto-generated by felhom-controller — do not edit locked fields manually\n"
content := header + string(data) content := header + string(data)
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return fmt.Errorf("writing %s: %w", path, err) // Atomic write: write to .tmp then rename (H04 fix)
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, []byte(content), 0600); err != nil {
return fmt.Errorf("writing %s: %w", tmpPath, err)
}
if err := os.Rename(tmpPath, path); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("renaming %s to %s: %w", tmpPath, path, err)
} }
return nil return nil
} }
+1 -1
View File
@@ -165,7 +165,7 @@ func (m *Manager) runSingleCheck(containerName string, check HealthCheckItem) He
// probeTCP tests if a TCP port is reachable on the container. // probeTCP tests if a TCP port is reachable on the container.
func (m *Manager) probeTCP(containerName string, port int, target string) HealthCheckDetail { func (m *Manager) probeTCP(containerName string, port int, target string) HealthCheckDetail {
start := time.Now() start := time.Now()
addr := fmt.Sprintf("%s:%d", containerName, port) addr := net.JoinHostPort(containerName, fmt.Sprintf("%d", port))
conn, err := net.DialTimeout("tcp", addr, 5*time.Second) conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
latency := time.Since(start) latency := time.Since(start)
+52 -5
View File
@@ -112,6 +112,8 @@ func NewManager(cfg *config.Config, logger *log.Logger) (*Manager, error) {
// SetEncryptionKey sets the AES-256 key used to encrypt/decrypt sensitive values in app.yaml. // SetEncryptionKey sets the AES-256 key used to encrypt/decrypt sensitive values in app.yaml.
func (m *Manager) SetEncryptionKey(key []byte) { func (m *Manager) SetEncryptionKey(key []byte) {
m.mu.Lock()
defer m.mu.Unlock()
m.encKey = key m.encKey = key
} }
@@ -121,8 +123,8 @@ func (m *Manager) MigrateEncryption() {
if m.encKey == nil { if m.encKey == nil {
return return
} }
m.mu.RLock() m.mu.Lock()
defer m.mu.RUnlock() defer m.mu.Unlock()
migrated := 0 migrated := 0
for _, s := range m.stacks { for _, s := range m.stacks {
@@ -446,7 +448,7 @@ func (m *Manager) GetStacks() []Stack {
result := make([]Stack, 0, len(m.stacks)) result := make([]Stack, 0, len(m.stacks))
for _, s := range m.stacks { for _, s := range m.stacks {
result = append(result, *s) result = append(result, deepCopyStack(s))
} }
// Sort alphabetically by display name for consistent UI ordering // Sort alphabetically by display name for consistent UI ordering
@@ -465,8 +467,53 @@ func (m *Manager) GetStack(name string) (*Stack, bool) {
if !ok { if !ok {
return nil, false return nil, false
} }
copy := *s cp := deepCopyStack(s)
return &copy, true return &cp, true
}
// deepCopyStack creates a deep copy of a Stack, including pointer fields.
func deepCopyStack(s *Stack) Stack {
cp := *s
// Deep-copy Containers slice
if s.Containers != nil {
cp.Containers = make([]ContainerInfo, len(s.Containers))
copy(cp.Containers, s.Containers)
}
// Deep-copy AppConfig pointer
if s.AppConfig != nil {
acCopy := *s.AppConfig
if s.AppConfig.Env != nil {
acCopy.Env = make(map[string]string, len(s.AppConfig.Env))
for k, v := range s.AppConfig.Env {
acCopy.Env[k] = v
}
}
if s.AppConfig.LockedFields != nil {
acCopy.LockedFields = make([]string, len(s.AppConfig.LockedFields))
copy(acCopy.LockedFields, s.AppConfig.LockedFields)
}
cp.AppConfig = &acCopy
}
// Deep-copy HealthProbe pointer
if s.HealthProbe != nil {
hpCopy := *s.HealthProbe
if s.HealthProbe.Details != nil {
hpCopy.Details = make([]HealthCheckDetail, len(s.HealthProbe.Details))
copy(hpCopy.Details, s.HealthProbe.Details)
}
cp.HealthProbe = &hpCopy
}
// Deep-copy Meta.DeployFields slice
if s.Meta.DeployFields != nil {
cp.Meta.DeployFields = make([]DeployField, len(s.Meta.DeployFields))
copy(cp.Meta.DeployFields, s.Meta.DeployFields)
}
return cp
} }
// --- Stack operations --- // --- Stack operations ---
+20 -3
View File
@@ -55,7 +55,11 @@ func MountRaw(devicePath string) (string, error) {
// Choose a directory name: prefer label, fall back to UUID prefix // Choose a directory name: prefer label, fall back to UUID prefix
dirName := label dirName := label
if dirName == "" && uuid != "" { if dirName == "" && uuid != "" {
dirName = uuid[:8] // use first 8 chars of UUID if len(uuid) > 8 {
dirName = uuid[:8]
} else {
dirName = uuid
}
} }
if dirName == "" { if dirName == "" {
dirName = filepath.Base(devicePath) // "sdb1" dirName = filepath.Base(devicePath) // "sdb1"
@@ -441,12 +445,12 @@ func removeBindFstabEntry(fstabPath, targetMountPath string) error {
// Remove both the comment line and the bind mount line // Remove both the comment line and the bind mount line
if strings.Contains(line, "Bind mount (auto-generated by felhom-controller)") { if strings.Contains(line, "Bind mount (auto-generated by felhom-controller)") {
// Check if the next line is the actual bind entry for this target // Check if the next line is the actual bind entry for this target
if i+1 < len(lines) && strings.Contains(lines[i+1], targetMountPath) { if i+1 < len(lines) && fstabMatchesTarget(lines[i+1], targetMountPath) {
i++ // skip the bind line too i++ // skip the bind line too
continue continue
} }
} }
if strings.Contains(line, targetMountPath) && strings.Contains(line, "bind") { if fstabMatchesTarget(line, targetMountPath) && strings.Contains(line, "bind") {
continue continue
} }
kept = append(kept, line) kept = append(kept, line)
@@ -454,3 +458,16 @@ func removeBindFstabEntry(fstabPath, targetMountPath string) error {
return safeWriteFile(fstabPath, []byte(strings.Join(kept, "\n")), 0644) return safeWriteFile(fstabPath, []byte(strings.Join(kept, "\n")), 0644)
} }
// fstabMatchesTarget parses an fstab line and checks if the mount target (field 2) matches exactly.
func fstabMatchesTarget(line, target string) bool {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
return false
}
fields := strings.Fields(line)
if len(fields) < 2 {
return false
}
return fields[1] == target
}
@@ -312,7 +312,10 @@ func (dm *DriveMigrator) MigrateDrive(ctx context.Context, req DriveMigrateReque
}() }()
var stderrBuf strings.Builder var stderrBuf strings.Builder
var stderrWg sync.WaitGroup
stderrWg.Add(1)
go func() { go func() {
defer stderrWg.Done()
buf := make([]byte, 4096) buf := make([]byte, 4096)
for { for {
n, err := stderr.Read(buf) n, err := stderr.Read(buf)
@@ -326,10 +329,12 @@ func (dm *DriveMigrator) MigrateDrive(ctx context.Context, req DriveMigrateReque
}() }()
if err := rsyncCmd.Wait(); err != nil { if err := rsyncCmd.Wait(); err != nil {
stderrWg.Wait()
send("rolling_back", "rsync sikertelen, visszagörgetés...", 0) send("rolling_back", "rsync sikertelen, visszagörgetés...", 0)
tx.rollback() tx.rollback()
return fail("Adatmásolás sikertelen", fmt.Errorf("rsync failed: %w — %s", err, stderrBuf.String())) return fail("Adatmásolás sikertelen", fmt.Errorf("rsync failed: %w — %s", err, stderrBuf.String()))
} }
stderrWg.Wait()
// --- Step 3: Verify copy --- // --- Step 3: Verify copy ---
send("verifying", "Másolat ellenőrzése...", 62) send("verifying", "Másolat ellenőrzése...", 62)
+6 -2
View File
@@ -30,6 +30,7 @@ type Syncer struct {
lastErr error lastErr error
syncing bool syncing bool
stopCh chan struct{} stopCh chan struct{}
stopOnce sync.Once
} }
// SyncStatus holds information about the last sync operation. // SyncStatus holds information about the last sync operation.
@@ -110,9 +111,11 @@ func (s *Syncer) Start() {
}() }()
} }
// Stop terminates the periodic sync loop. // Stop terminates the periodic sync loop. Safe to call multiple times.
func (s *Syncer) Stop() { func (s *Syncer) Stop() {
close(s.stopCh) s.stopOnce.Do(func() {
close(s.stopCh)
})
} }
// TriggerSync performs an immediate sync. Returns the result. // TriggerSync performs an immediate sync. Returns the result.
@@ -131,6 +134,7 @@ func (s *Syncer) TriggerSync() SyncResult {
s.mu.Unlock() s.mu.Unlock()
return SyncResult{OK: false, Message: "Túl gyakori szinkronizálás — várj 30 másodpercet"} return SyncResult{OK: false, Message: "Túl gyakori szinkronizálás — várj 30 másodpercet"}
} }
s.syncing = true
s.mu.Unlock() s.mu.Unlock()
return s.doSync() return s.doSync()
+8 -4
View File
@@ -33,11 +33,15 @@ func (s *Server) CsrfProtect(next http.Handler) http.Handler {
} }
// Skip CSRF for Bearer-token authenticated requests. // Skip CSRF for Bearer-token authenticated requests.
// These endpoints also accept session auth, but when a Bearer token // Validate the token against the configured API key before skipping.
// is present, the request is from a script/hub, not a browser.
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") { if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
next.ServeHTTP(w, r) token := strings.TrimPrefix(auth, "Bearer ")
return apiKey := s.cfg.Hub.APIKey
if apiKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(apiKey)) == 1 {
next.ServeHTTP(w, r)
return
}
// Invalid Bearer token — fall through to CSRF validation
} }
// Get the session's CSRF token // Get the session's CSRF token
+1 -1
View File
@@ -915,7 +915,7 @@ func (s *Server) buildAppBackupRows(
} }
// Destination health check — can downgrade green to yellow/red // Destination health check — can downgrade green to yellow/red
if cfg.DestinationPath != "" { if cfg.DestinationPath != "" && s.crossDriveRunner != nil {
if err := s.crossDriveRunner.ValidateDestination(cfg.DestinationPath); err != nil { if err := s.crossDriveRunner.ValidateDestination(cfg.DestinationPath); err != nil {
if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "not writable") { if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "not writable") {
row.Status = "red" row.Status = "red"
+11 -4
View File
@@ -1,6 +1,7 @@
package web package web
import ( import (
"bytes"
"fmt" "fmt"
"html/template" "html/template"
"log" "log"
@@ -391,11 +392,14 @@ func (s *Server) primaryHDDPath() string {
} }
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) { func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
s.logger.Printf("[ERROR] Template error (%s): %v", name, err) s.logger.Printf("[ERROR] Template error (%s): %v", name, err)
http.Error(w, "Internal error", http.StatusInternalServerError) http.Error(w, "Internal error", http.StatusInternalServerError)
return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
} }
// executeTemplate renders a template with CSRF data auto-injected into the data map. // executeTemplate renders a template with CSRF data auto-injected into the data map.
@@ -406,11 +410,14 @@ func (s *Server) executeTemplate(w http.ResponseWriter, r *http.Request, name st
} }
data["CSRFField"] = s.csrfField(r) data["CSRFField"] = s.csrfField(r)
data["CSRFToken"] = s.csrfToken(r) data["CSRFToken"] = s.csrfToken(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8") var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
s.logger.Printf("[ERROR] Template error (%s): %v", name, err) s.logger.Printf("[ERROR] Template error (%s): %v", name, err)
http.Error(w, "Internal error", http.StatusInternalServerError) http.Error(w, "Internal error", http.StatusInternalServerError)
return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
} }
// --- Static file / asset serving --- // --- Static file / asset serving ---
+2 -3
View File
@@ -915,22 +915,21 @@ func (s *Server) storageAttachMountRawHandler(w http.ResponseWriter, r *http.Req
return return
} }
// Clean up any previous raw mount first // Hold lock across entire cleanup+mount+set to prevent races
s.diskJobMu.Lock() s.diskJobMu.Lock()
if s.activeRawMount != "" { if s.activeRawMount != "" {
_ = storage.CleanupRawMount(s.activeRawMount) _ = storage.CleanupRawMount(s.activeRawMount)
s.activeRawMount = "" s.activeRawMount = ""
} }
s.diskJobMu.Unlock()
rawPath, err := storage.MountRaw(req.DevicePath) rawPath, err := storage.MountRaw(req.DevicePath)
if err != nil { if err != nil {
s.diskJobMu.Unlock()
s.logger.Printf("[ERROR] storageAttachMountRaw: %v", err) s.logger.Printf("[ERROR] storageAttachMountRaw: %v", err)
jsonError(w, err.Error(), http.StatusInternalServerError) jsonError(w, err.Error(), http.StatusInternalServerError)
return return
} }
s.diskJobMu.Lock()
s.activeRawMount = rawPath s.activeRawMount = rawPath
s.diskJobMu.Unlock() s.diskJobMu.Unlock()