Compare commits
3 Commits
2ad743b66f
...
72ab145b41
| Author | SHA1 | Date | |
|---|---|---|---|
| 72ab145b41 | |||
| 45f75a916c | |||
| 8b8c04a487 |
+623
-765
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,69 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v0.30.3 — Comprehensive Bug Hunt Fixes (2026-02-25)
|
||||||
|
|
||||||
|
#### Fixed (Critical — P0)
|
||||||
|
- **Encrypted env vars** — `UpdateStackConfig` now uses decrypted values when building compose env, preventing `ENC:...` literals in containers (C01)
|
||||||
|
- **Silent decrypt failures** — `DecryptMap` now logs warnings on decrypt failure instead of silently returning empty values (C02)
|
||||||
|
- **Deploy race condition** — `Deployed = false` flag now set inside the mutex lock in `runComposeDeploy` (C03)
|
||||||
|
- **Shared state mutation** — `GetStack`/`GetStacks` now return deep copies preventing callers from mutating cached state (C04)
|
||||||
|
- **Watchdog races** — Added per-state mutex to `pathProbeState` for thread-safe probe state access (C05)
|
||||||
|
- **Metrics double-start** — `MetricsCollector.Start()` guarded with `sync.Once` (C06)
|
||||||
|
- **Raw mount race** — `diskJobMu` now held across entire cleanup+mount+set operation (C07)
|
||||||
|
- **Encryption key race** — Added mutex to `SetEncryptionKey` (C08)
|
||||||
|
|
||||||
|
#### Fixed (High — P1)
|
||||||
|
- **Restic lock detection** — `Snapshot()` now extracts stderr from `*exec.ExitError` and checks `unlockCmd.Run()` error (H01)
|
||||||
|
- **Disconnected drives in backup** — `activeDrives()` now skips disconnected/decommissioned drives (H02)
|
||||||
|
- **Template rendering** — Buffered via `bytes.Buffer` to prevent partial HTML on error (H07)
|
||||||
|
- **Sync stop panic** — `Stop()` uses `sync.Once` for safe channel close (H08)
|
||||||
|
- **Sync race** — `syncing = true` set before releasing lock in `TriggerSync` (H09)
|
||||||
|
- **Cloudflare context** — Threaded `context.Context` through all Cloudflare API calls for cancellation support (H10)
|
||||||
|
- **Cross-drive collision** — Replaced flawed leaf-name dedup with proper `seen` map (H15)
|
||||||
|
- **CSRF bypass** — Bearer token now validated against Hub API key before skipping CSRF (H16)
|
||||||
|
- **Nil pointer** — Added nil check for `crossDriveRunner` in handlers (H17)
|
||||||
|
- **Selftest panic** — Replaced `out[:len(out)-1]` with `strings.TrimSpace` (H18)
|
||||||
|
- **Stderr goroutine** — Added `sync.WaitGroup` in `MigrateDrive` (H19)
|
||||||
|
- **UUID slice** — Guarded `uuid[:8]` with length check (H20)
|
||||||
|
- **Fstab matching** — Parse fields exactly instead of loose `strings.Contains` (H21)
|
||||||
|
- **Atomic save** — `SaveAppConfig` writes to `.tmp` then renames (H04)
|
||||||
|
- **Deploy failure** — `SaveAppConfig` on failure now includes `encKey` (H05)
|
||||||
|
- **Encryption migration** — Uses write lock instead of read lock (H03)
|
||||||
|
- **Deep copy** — `GetFullStatus` deep-copies `lastDBDump`/`lastBackup` (H11)
|
||||||
|
- **IPv6** — TCP health probe uses `net.JoinHostPort` for IPv6 compatibility
|
||||||
|
- **Backup path validation** — `RemoveStack` validates paths under expected directory (M12)
|
||||||
|
- **Updater race** — `SetBackupRunningCheck` protected by mutex (M18)
|
||||||
|
|
||||||
|
#### Fixed (Medium — P2)
|
||||||
|
- **Config env overrides** — `LoadFromBytes` now calls `applyEnvOverrides` (M05)
|
||||||
|
- **Selfupdate state** — Compose-up failure now sets `state.Status = "failed"` (M16)
|
||||||
|
- **Memory check** — `usableMB` clamped to min 0 (M22)
|
||||||
|
- **Cross-backup trigger** — Removed invalid "manual" schedule from `triggerAllCrossBackups` (M23)
|
||||||
|
- **mmcblk support** — Partition path and `stripPartition` now handle mmcblk devices (M21, L25)
|
||||||
|
- **Scheduler** — `Start()` guarded against double-start, `Stop()` acquires mutex (M14, L24)
|
||||||
|
- **Pending events** — Events restored on save failure in `DrainPendingEvents` (M03)
|
||||||
|
- **Duplicate storage** — `AddStoragePath` rejects already-registered paths (M04)
|
||||||
|
- **Setup scan** — `CleanupTempMounts` called after drive scan (H13)
|
||||||
|
- **Setup state** — `SetStep` now logs save errors (M25)
|
||||||
|
|
||||||
|
#### Fixed (Low — P3)
|
||||||
|
- **UTF-8 truncation** — `TruncateStr` now operates on runes and handles negative maxLen (L05/L06)
|
||||||
|
- **AllDone** — Returns false for empty restore plans (L14)
|
||||||
|
- **PushOnce** — Returns actual errors instead of swallowing them (L39)
|
||||||
|
- **CSRF token** — Panics on `crypto/rand.Read` failure instead of using static fallback (L40)
|
||||||
|
- **Logout** — Requires POST method (L32)
|
||||||
|
- **Server.Close** — Uses `sync.Once` to prevent double-close panic (L49)
|
||||||
|
- **Log cap** — `lines` query parameter capped at 10000 (L31)
|
||||||
|
- **Hash function** — Replaced custom `simpleHash` with `crc32.ChecksumIEEE` (L48)
|
||||||
|
- **hasPrefix** — Replaced custom implementation with `strings.HasPrefix` (L13)
|
||||||
|
- **DefaultEnabledEvents** — Copied in `GetNotificationPrefs` early return (L09)
|
||||||
|
- **Variable shadowing** — Renamed `copy` to `cp` in `SetNotificationPrefs` (L07)
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
- Dead `imageName` function in selfupdate (L02)
|
||||||
|
- Dead `detectHostIPViaRoute` function in setup (L03)
|
||||||
|
- Custom `hasPrefix` function in restore_scan (L13)
|
||||||
|
|
||||||
### v0.30.2 — Report geo-restriction + logo/favicon update (2026-02-25)
|
### v0.30.2 — Report geo-restriction + logo/favicon update (2026-02-25)
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
|
|||||||
@@ -379,6 +379,9 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
|||||||
if totalMB, usedMB, memErr := system.GetMemoryMB(); memErr == nil {
|
if totalMB, usedMB, memErr := system.GetMemoryMB(); memErr == nil {
|
||||||
reservedMB := r.cfg.System.ReservedMemoryMB
|
reservedMB := r.cfg.System.ReservedMemoryMB
|
||||||
usableMB := totalMB - reservedMB
|
usableMB := totalMB - reservedMB
|
||||||
|
if usableMB < 0 {
|
||||||
|
usableMB = 0
|
||||||
|
}
|
||||||
afterMB := usedMB + stackMemMB
|
afterMB := usedMB + stackMemMB
|
||||||
if afterMB > usableMB {
|
if afterMB > usableMB {
|
||||||
writeJSON(w, http.StatusConflict, apiResponse{
|
writeJSON(w, http.StatusConflict, apiResponse{
|
||||||
@@ -444,6 +447,9 @@ func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name str
|
|||||||
if v := req.URL.Query().Get("lines"); v != "" {
|
if v := req.URL.Query().Get("lines"); v != "" {
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
lines = n
|
lines = n
|
||||||
|
if lines > 10000 {
|
||||||
|
lines = 10000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,9 +924,6 @@ func (r *Router) triggerAllCrossBackups(w http.ResponseWriter, _ *http.Request)
|
|||||||
if err := r.crossDriveRunner.RunAllScheduled(ctx, "weekly"); err != nil {
|
if err := r.crossDriveRunner.RunAllScheduled(ctx, "weekly"); err != nil {
|
||||||
r.logger.Printf("[API] Cross-drive run-all weekly error: %v", err)
|
r.logger.Printf("[API] Cross-drive run-all weekly error: %v", err)
|
||||||
}
|
}
|
||||||
if err := r.crossDriveRunner.RunAllScheduled(ctx, "manual"); err != nil {
|
|
||||||
r.logger.Printf("[API] Cross-drive run-all manual error: %v", err)
|
|
||||||
}
|
|
||||||
if r.OnCrossDriveComplete != nil {
|
if r.OnCrossDriveComplete != nil {
|
||||||
r.OnCrossDriveComplete()
|
r.OnCrossDriveComplete()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = ©Dump
|
||||||
|
}
|
||||||
|
if m.lastBackup != nil {
|
||||||
|
copyBackup := *m.lastBackup
|
||||||
|
status.LastBackup = ©Backup
|
||||||
|
}
|
||||||
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
// isDebug returns true if logging level is "debug".
|
// isDebug returns true if logging level is "debug".
|
||||||
|
|||||||
@@ -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,14 +381,19 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -131,9 +132,13 @@ func (rp *RestorePlan) UpdateApp(name, status, errMsg string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AllDone returns true if all apps are done/failed/skipped.
|
// AllDone returns true if all apps are done/failed/skipped.
|
||||||
|
// Returns false for empty plans (no apps to restore).
|
||||||
func (rp *RestorePlan) AllDone() bool {
|
func (rp *RestorePlan) AllDone() bool {
|
||||||
rp.mu.RLock()
|
rp.mu.RLock()
|
||||||
defer rp.mu.RUnlock()
|
defer rp.mu.RUnlock()
|
||||||
|
if len(rp.Apps) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
for _, app := range rp.Apps {
|
for _, app := range rp.Apps {
|
||||||
if app.Status != "done" && app.Status != "failed" && app.Status != "skipped" {
|
if app.Status != "done" && app.Status != "failed" && app.Status != "skipped" {
|
||||||
return false
|
return false
|
||||||
@@ -280,13 +285,10 @@ func hasUserData(rsyncBase string) bool {
|
|||||||
}
|
}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
name := e.Name()
|
name := e.Name()
|
||||||
if name != "_config" && name != "_db" && !hasPrefix(name, ".") {
|
if name != "_config" && name != "_db" && !strings.HasPrefix(name, ".") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasPrefix(s, prefix string) bool {
|
|
||||||
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ func LoadFromBytes(data []byte) (*Config, error) {
|
|||||||
return nil, fmt.Errorf("parsing config: %w", err)
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
}
|
}
|
||||||
applyDefaults(cfg)
|
applyDefaults(cfg)
|
||||||
|
applyEnvOverrides(cfg)
|
||||||
if err := validate(cfg); err != nil {
|
if err := validate(cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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) {
|
||||||
|
c.startOnce.Do(func() {
|
||||||
ctx, c.cancel = context.WithCancel(ctx)
|
ctx, c.cancel = context.WithCancel(ctx)
|
||||||
go c.loop(ctx)
|
go c.loop(ctx)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop cancels the collection loop.
|
// Stop cancels the collection loop.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,29 +198,27 @@ func (p *Pusher) PushOnce(report *Report) error {
|
|||||||
|
|
||||||
data, err := json.Marshal(report)
|
data, err := json.Marshal(report)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.Printf("[WARN] Hub report marshal failed: %v", err)
|
return fmt.Errorf("marshal report: %w", err)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
url := p.hubURL + "/api/v1/report"
|
url := p.hubURL + "/api/v1/report"
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return fmt.Errorf("create request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
|
|
||||||
resp, err := p.httpClient.Do(req)
|
resp, err := p.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.Printf("[WARN] Hub disabled-notification failed: %v", err)
|
return fmt.Errorf("hub push-once: %w", err)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
io.Copy(io.Discard, resp.Body)
|
io.Copy(io.Discard, resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
p.logger.Printf("[INFO] Hub disabled-notification sent (%d bytes)", len(data))
|
p.logger.Printf("[INFO] Hub push-once sent (%d bytes)", len(data))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,12 +95,15 @@ func (s *Scheduler) Daily(name string, timeStr string, fn JobFunc) {
|
|||||||
s.logger.Printf("[SCHED] Daily job %s scheduled for %s", name, nextRun.Format("2006-01-02 15:04 MST"))
|
s.logger.Printf("[SCHED] Daily job %s scheduled for %s", name, nextRun.Format("2006-01-02 15:04 MST"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start begins running all registered jobs.
|
// Start begins running all registered jobs. Safe to call only once.
|
||||||
func (s *Scheduler) Start(ctx context.Context) {
|
func (s *Scheduler) Start(ctx context.Context) {
|
||||||
s.ctx, s.cancel = context.WithCancel(ctx)
|
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
if s.cancel != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.logger.Println("[WARN] Scheduler already started — ignoring duplicate Start()")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.ctx, s.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
for _, job := range s.jobs {
|
for _, job := range s.jobs {
|
||||||
if job.Interval > 0 {
|
if job.Interval > 0 {
|
||||||
@@ -113,12 +116,16 @@ func (s *Scheduler) Start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Printf("[SCHED] Scheduler started with %d jobs", len(s.jobs))
|
s.logger.Printf("[SCHED] Scheduler started with %d jobs", len(s.jobs))
|
||||||
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop cancels all jobs and waits for them to finish (30s timeout).
|
// Stop cancels all jobs and waits for them to finish (30s timeout).
|
||||||
func (s *Scheduler) Stop() {
|
func (s *Scheduler) Stop() {
|
||||||
if s.cancel != nil {
|
s.mu.Lock()
|
||||||
s.cancel()
|
cancel := s.cancel
|
||||||
|
s.mu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,13 +229,6 @@ func registryImagePath(image string) string {
|
|||||||
return image
|
return image
|
||||||
}
|
}
|
||||||
|
|
||||||
// imageName extracts the repo name from a full image reference.
|
|
||||||
// e.g., "gitea.dooplex.hu/admin/felhom-controller" → "felhom-controller"
|
|
||||||
func imageName(image string) string {
|
|
||||||
parts := strings.Split(image, "/")
|
|
||||||
return parts[len(parts)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// DryRunResult holds the result of a self-update dry run.
|
// DryRunResult holds the result of a self-update dry run.
|
||||||
type DryRunResult struct {
|
type DryRunResult struct {
|
||||||
CurrentVersion string `json:"current_version"`
|
CurrentVersion string `json:"current_version"`
|
||||||
@@ -397,8 +392,10 @@ func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initi
|
|||||||
composeDir := strings.TrimSuffix(u.composePath, "/docker-compose.yml")
|
composeDir := strings.TrimSuffix(u.composePath, "/docker-compose.yml")
|
||||||
upOut, upErr := runCommand("docker", "compose", "-f", u.composePath, "-p", "felhom-controller", "up", "-d")
|
upOut, upErr := runCommand("docker", "compose", "-f", u.composePath, "-p", "felhom-controller", "up", "-d")
|
||||||
if upErr != nil {
|
if upErr != nil {
|
||||||
// If we get here, compose up failed but we already changed the image tag.
|
state.Status = "failed"
|
||||||
// Log the error — the state file remains "pending" for manual investigation.
|
state.Error = fmt.Sprintf("docker compose up -d failed: %v — %s", upErr, upOut)
|
||||||
|
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
SaveState(u.dataDir, state)
|
||||||
u.logger.Printf("[ERROR] docker compose up -d failed: %v — %s (dir: %s)", upErr, upOut, composeDir)
|
u.logger.Printf("[ERROR] docker compose up -d failed: %v — %s (dir: %s)", upErr, upOut, composeDir)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,8 +264,10 @@ func (s *Settings) GetNotificationPrefs() *NotificationPrefs {
|
|||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
if s.Notifications == nil {
|
if s.Notifications == nil {
|
||||||
|
events := make([]string, len(DefaultEnabledEvents))
|
||||||
|
copy(events, DefaultEnabledEvents)
|
||||||
return &NotificationPrefs{
|
return &NotificationPrefs{
|
||||||
EnabledEvents: DefaultEnabledEvents,
|
EnabledEvents: events,
|
||||||
CooldownHours: 6,
|
CooldownHours: 6,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,14 +293,14 @@ func (s *Settings) SetNotificationPrefs(prefs *NotificationPrefs) error {
|
|||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
copy := *prefs
|
cp := *prefs
|
||||||
if len(prefs.EnabledEvents) > 0 {
|
if len(prefs.EnabledEvents) > 0 {
|
||||||
copy.EnabledEvents = make([]string, len(prefs.EnabledEvents))
|
cp.EnabledEvents = make([]string, len(prefs.EnabledEvents))
|
||||||
for i, e := range prefs.EnabledEvents {
|
for i, e := range prefs.EnabledEvents {
|
||||||
copy.EnabledEvents[i] = e
|
cp.EnabledEvents[i] = e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.Notifications = ©
|
s.Notifications = &cp
|
||||||
return s.save()
|
return s.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +424,11 @@ func (s *Settings) GetSchedulableStoragePaths() []StoragePath {
|
|||||||
func (s *Settings) AddStoragePath(sp StoragePath) error {
|
func (s *Settings) AddStoragePath(sp StoragePath) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
for _, existing := range s.StoragePaths {
|
||||||
|
if existing.Path == sp.Path {
|
||||||
|
return fmt.Errorf("storage path %q already registered", sp.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
if sp.IsDefault {
|
if sp.IsDefault {
|
||||||
for i := range s.StoragePaths {
|
for i := range s.StoragePaths {
|
||||||
s.StoragePaths[i].IsDefault = false
|
s.StoragePaths[i].IsDefault = false
|
||||||
@@ -808,7 +815,9 @@ func (s *Settings) DrainPendingEvents() []PendingEvent {
|
|||||||
copy(events, s.PendingEvents)
|
copy(events, s.PendingEvents)
|
||||||
s.PendingEvents = nil
|
s.PendingEvents = nil
|
||||||
if err := s.save(); err != nil {
|
if err := s.save(); err != nil {
|
||||||
s.log.Printf("[ERROR] Failed to save after draining pending events: %v", err)
|
s.log.Printf("[ERROR] Failed to save after draining pending events: %v — restoring events", err)
|
||||||
|
s.PendingEvents = events
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return events
|
return events
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ const csrfFormField = "_csrf"
|
|||||||
func generateCSRFToken() string {
|
func generateCSRFToken() string {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
// Fallback to time-based (extremely unlikely)
|
panic("crypto/rand.Read failed: " + err.Error())
|
||||||
return "fallback-csrf-token"
|
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package setup
|
|||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,46 +10,17 @@ import (
|
|||||||
// Inside a Docker container, the network interfaces only show the bridge IP
|
// Inside a Docker container, the network interfaces only show the bridge IP
|
||||||
// (e.g. 172.18.0.4), which is useless for users. Instead, we:
|
// (e.g. 172.18.0.4), which is useless for users. Instead, we:
|
||||||
// 1. Check HOST_IP env var (set by docker-compose.yml)
|
// 1. Check HOST_IP env var (set by docker-compose.yml)
|
||||||
// 2. Try to detect the Docker host gateway via `ip route`
|
// 2. Fall back to interface enumeration as last resort
|
||||||
// 3. Fall back to interface enumeration as last resort
|
|
||||||
func DetectLocalIPs() []string {
|
func DetectLocalIPs() []string {
|
||||||
// Option 1: explicit HOST_IP from environment
|
// Option 1: explicit HOST_IP from environment
|
||||||
if hostIP := os.Getenv("HOST_IP"); hostIP != "" {
|
if hostIP := os.Getenv("HOST_IP"); hostIP != "" {
|
||||||
return []string{hostIP}
|
return []string{hostIP}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option 2: detect Docker host gateway IP via default route
|
// Option 2: fallback to interface enumeration (works on bare metal)
|
||||||
// Inside a container, `ip route | grep default` gives the host gateway.
|
|
||||||
// Then we check the host's IP by looking at what IP routes to that gateway.
|
|
||||||
if ip := detectHostIPViaRoute(); ip != "" {
|
|
||||||
return []string{ip}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Option 3: fallback to interface enumeration (works on bare metal)
|
|
||||||
return detectInterfaceIPs()
|
return detectInterfaceIPs()
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectHostIPViaRoute tries to find the Docker host's LAN IP.
|
|
||||||
// Inside a container, the default gateway is the Docker host.
|
|
||||||
// We read /host-etc/hostname or use the gateway as a hint.
|
|
||||||
func detectHostIPViaRoute() string {
|
|
||||||
// Try: ip route get 1.0.0.0 — shows the source IP used for routing
|
|
||||||
out, err := exec.Command("ip", "route", "get", "1.0.0.0").Output()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
// Output: "1.0.0.0 via 172.18.0.1 dev eth0 src 172.18.0.4"
|
|
||||||
// The gateway (172.18.0.1) is the Docker host — but that's the bridge IP.
|
|
||||||
// We need the host's actual LAN IP.
|
|
||||||
|
|
||||||
// Better approach: read /proc/net/route or parse `ip route` for the gateway,
|
|
||||||
// then the gateway itself is the Docker host — but we need its external IP.
|
|
||||||
// Since we can't easily get the host's LAN IP from inside the container,
|
|
||||||
// return empty and let the fallback handle it or rely on HOST_IP env.
|
|
||||||
_ = out
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func detectInterfaceIPs() []string {
|
func detectInterfaceIPs() []string {
|
||||||
ifaces, err := net.Interfaces()
|
ifaces, err := net.Interfaces()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -266,6 +266,11 @@ func countValid(results []DriveBackup) int {
|
|||||||
func (s *Server) runDriveScan() {
|
func (s *Server) runDriveScan() {
|
||||||
results, err := ScanDrivesForInfraBackups(s.logger, s.isDebug())
|
results, err := ScanDrivesForInfraBackups(s.logger, s.isDebug())
|
||||||
|
|
||||||
|
// Clean up any temporary mounts created during scan
|
||||||
|
if results != nil {
|
||||||
|
CleanupTempMounts(results, s.logger)
|
||||||
|
}
|
||||||
|
|
||||||
s.scanMu.Lock()
|
s.scanMu.Lock()
|
||||||
defer s.scanMu.Unlock()
|
defer s.scanMu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func (s *SetupState) SetStep(step string) {
|
|||||||
s.Step = step
|
s.Step = step
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if err := s.Save(); err != nil {
|
if err := s.Save(); err != nil {
|
||||||
// Best effort — don't crash
|
log.Printf("[WARN] Failed to save setup step %q: %v", step, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 ©, 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 ---
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func FormatAndMount(req FormatRequest, progress chan<- FormatProgress) (string,
|
|||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
partDev = req.DevicePath + "1"
|
partDev = req.DevicePath + "1"
|
||||||
if strings.Contains(req.DevicePath, "nvme") {
|
if strings.Contains(req.DevicePath, "nvme") || strings.Contains(req.DevicePath, "mmcblk") {
|
||||||
partDev = req.DevicePath + "p1"
|
partDev = req.DevicePath + "p1"
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(HostDevicePath(partDev)); err != nil {
|
if _, err := os.Stat(HostDevicePath(partDev)); err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
s.stopOnce.Do(func() {
|
||||||
close(s.stopCh)
|
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()
|
||||||
|
|||||||
@@ -215,9 +215,9 @@ func isSameBlockDevice(pathA, pathB string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// stripPartition strips the partition suffix from a device name.
|
// stripPartition strips the partition suffix from a device name.
|
||||||
// e.g., "sda1" → "sda", "nvme0n1p1" → "nvme0n1".
|
// e.g., "sda1" → "sda", "nvme0n1p1" → "nvme0n1", "mmcblk0p1" → "mmcblk0".
|
||||||
func stripPartition(base string) string {
|
func stripPartition(base string) string {
|
||||||
if strings.HasPrefix(base, "nvme") {
|
if strings.HasPrefix(base, "nvme") || strings.HasPrefix(base, "mmcblk") {
|
||||||
if idx := strings.LastIndex(base, "p"); idx > 4 {
|
if idx := strings.LastIndex(base, "p"); idx > 4 {
|
||||||
return base[:idx]
|
return base[:idx]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ package util
|
|||||||
|
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
// TruncateStr truncates a string to maxLen characters, appending "..." if truncated.
|
// TruncateStr truncates a string to maxLen runes, appending "..." if truncated.
|
||||||
func TruncateStr(s string, maxLen int) string {
|
func TruncateStr(s string, maxLen int) string {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if len(s) <= maxLen {
|
if maxLen <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= maxLen {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return s[:maxLen] + "..."
|
return string(runes[:maxLen]) + "..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash/crc32"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -219,11 +220,7 @@ func (am *AlertManager) GetInlineAlerts(page string) []Alert {
|
|||||||
|
|
||||||
// simpleHash returns a short deterministic hash for deduplication.
|
// simpleHash returns a short deterministic hash for deduplication.
|
||||||
func simpleHash(s string) string {
|
func simpleHash(s string) string {
|
||||||
h := uint32(0)
|
return fmt.Sprintf("%08x", crc32.ChecksumIEEE([]byte(s)))
|
||||||
for _, c := range s {
|
|
||||||
h = h*31 + uint32(c)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%08x", h)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sortAlerts sorts alerts by severity: error > warning > info.
|
// sortAlerts sorts alerts by severity: error > warning > info.
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||||
s.sessionsMu.Lock()
|
s.sessionsMu.Lock()
|
||||||
delete(s.sessions, cookie.Value)
|
delete(s.sessions, cookie.Value)
|
||||||
@@ -203,9 +207,11 @@ func (s *Server) cleanupSessions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close signals the server to stop background goroutines.
|
// Close signals the server to stop background goroutines. Safe to call multiple times.
|
||||||
func (s *Server) Close() {
|
func (s *Server) Close() {
|
||||||
|
s.closeOnce.Do(func() {
|
||||||
close(s.done)
|
close(s.done)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) renderLogin(w http.ResponseWriter, errorMsg, flashMsg string) {
|
func (s *Server) renderLogin(w http.ResponseWriter, errorMsg, flashMsg string) {
|
||||||
|
|||||||
@@ -33,12 +33,16 @@ 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 ") {
|
||||||
|
token := strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
apiKey := s.cfg.Hub.APIKey
|
||||||
|
if apiKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(apiKey)) == 1 {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Invalid Bearer token — fall through to CSRF validation
|
||||||
|
}
|
||||||
|
|
||||||
// Get the session's CSRF token
|
// Get the session's CSRF token
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
@@ -43,6 +44,7 @@ type Server struct {
|
|||||||
sessions map[string]*session
|
sessions map[string]*session
|
||||||
sessionsMu sync.RWMutex
|
sessionsMu sync.RWMutex
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
|
||||||
// Disk operation state (format/migrate jobs)
|
// Disk operation state (format/migrate jobs)
|
||||||
diskJobMu sync.Mutex
|
diskJobMu sync.Mutex
|
||||||
@@ -391,11 +393,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 +411,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 ---
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user