package web import ( "bytes" "context" "fmt" "log" "net/http" "net/url" "os" "os/exec" "path/filepath" "sort" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/crypto" "gitea.dooplex.hu/admin/felhom-controller/internal/infra" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" "golang.org/x/crypto/bcrypt" ) // protectedStackSubdomains maps programmatically managed protected stacks // to their well-known subdomains (these stacks have no .felhom.yml or app.yaml). var protectedStackSubdomains = map[string]string{ "filebrowser": "files", } // StorageBarInfo holds data for rendering a storage usage bar on dashboard/monitoring. type StorageBarInfo struct { Label string // e.g., "USB HDD 1TB", "SYS Storage 350G" Path string // e.g., "/mnt/hdd_1" Purpose string // Hungarian explanation of what this drive holds (monitoring page) TotalGB float64 UsedGB float64 Percent float64 Disconnected bool } // storageBarPurpose is the Hungarian description for the registered user-data drives shown in the // monitoring "Tárolók kapacitása" list. These are all external/user-data drives (the agent's // system/PBS storage is not in the controller's storage-path registry), matching the user-data // purpose text on the storage-management page (Phase 4C). const storageBarPurpose = "Külső adattároló — a telepített alkalmazások nagy méretű fájljai (média, dokumentumok) ide kerülnek; az adatbázisok a belső SSD-n vannak." // buildStorageBars returns usage bars for all registered storage paths, in a stable order // (by path) with a purpose description. func (s *Server) buildStorageBars() []StorageBarInfo { var bars []StorageBarInfo for _, sp := range s.settings.GetStoragePaths() { // Skip decommissioned drives — they are no longer in active use if sp.Decommissioned { continue } if sp.Disconnected { bars = append(bars, StorageBarInfo{ Label: sp.Label, Path: sp.Path, Purpose: storageBarPurpose, Disconnected: true, }) continue } di := system.GetDiskUsage(sp.Path) if di == nil { continue } bars = append(bars, StorageBarInfo{ Label: sp.Label, Path: sp.Path, Purpose: storageBarPurpose, TotalGB: di.TotalGB, UsedGB: di.UsedGB, Percent: di.UsedPercent, }) } // Deterministic order regardless of registry insertion order. sort.Slice(bars, func(i, j int) bool { return bars[i].Path < bars[j].Path }) return bars } // DeployStoragePath extends StoragePath with free space data for the deploy dropdown. type DeployStoragePath struct { settings.StoragePath FreeHuman string // "234.5 GB" FreePercent float64 // 67.5 } // StorageAppDetail holds info about an app using a specific storage path. type StorageAppDetail struct { Name string // Display name (e.g., "Immich") Stack string // Stack name (for link) SizeHuman string // Data size on this path } // StoragePathView extends StoragePath with display data for the settings page. type StoragePathView struct { settings.StoragePath DiskInfo *system.DiskUsageInfo AppCount int IsMounted bool AppDetails []StorageAppDetail FSInfo *system.FSInfo IsUSB bool // true if this is a USB-attached device (safe disconnect available) StoppedApps []string // stacks auto-stopped due to disconnect (for restart UI) MigratedToLabel string // label of the drive data was migrated to HasOtherPaths bool // true if other connected non-decommissioned paths exist IsEnrolled bool // enrolled via the wizard (stable /mnt/felhom-drives/ path) — lifecycle is disconnect/decommission, not list-removal } func (s *Server) baseData(page, title string) map[string]interface{} { data := map[string]interface{}{ "Page": page, "Title": title, "CustomerName": s.cfg.Customer.Name, "Domain": s.cfg.Customer.Domain, "Version": s.version, "AuthEnabled": s.authEnabled(), "DebugMode": s.isDebug(), // Customer-claim arc (v0.122.0, F-4): the transitional legacy-open banner — no password, // no code hash yet. Cleared the moment the hub delivers a code hash (gate flips on). "ClaimLegacyOpen": s.claimLegacyOpen(), } if s.alertManager != nil { data["Alerts"] = s.alertManager.GetAlerts() } return data } func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) { stackList := s.stackMgr.GetStacks() // Filter to deployed + protected stacks first var deployedStacks []stacks.Stack for _, st := range stackList { if st.Deployed || st.Protected { deployedStacks = append(deployedStacks, st) } } // Count from the DISPLAYED set only running, stopped := 0, 0 for _, st := range deployedStacks { switch st.State { case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting: running++ // R-51: degraded counts with stopped — the dashboard counter answers "how many of my apps // work", and a stack with a dead supervised member does not. case stacks.StateStopped, stacks.StateExited, stacks.StateDegraded: stopped++ } } sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector) data := s.baseData("dashboard", "Vezérlőpult") data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption data["Stacks"] = deployedStacks data["MissingStorage"] = s.missingStorageMap(deployedStacks) nw, ns := s.networkStorageWarnings(deployedStacks) // NAS unreachable (recoverable) / guest-side stub (defect) data["NetworkWarnings"] = nw data["NetworkStubs"] = ns data["RunningCount"] = running data["StoppedCount"] = stopped data["TotalCount"] = len(stackList) data["SystemInfo"] = sysInfo data["StorageBars"] = s.buildStorageBars() // Disk-health card (v0.169.0) — physical-disk SMART verdicts via the 60s-TTL-cached /disks call. // Never blocks the render: an unreachable agent yields nil rows and the card shows its empty state. data["DiskHealthRows"] = s.diskHealthRows(r.Context()) // Backup status data["BackupEnabled"] = s.cfg.Backup.Enabled if s.backupMgr != nil { nextDBDump := scheduler.NextDailyRun(s.effectiveBackupWindow()) fullStatus := s.backupMgr.GetFullStatus(nextDBDump) data["DBDumpStatus"] = fullStatus.LastDBDump // F3 (AUDIT-vacation-remote-ops-2026-07-20): the card's "Utolsó mentés" row branches on // .BackupStatus, which was never passed — so the {{if}} arm was unreachable and EVERY box // rendered "Még nem futott" regardless of history. *DBDumpStatus nil/non-nil maps exactly // onto the template's branch, so a fresh box still reads "Még nem futott" honestly. data["BackupStatus"] = fullStatus.LastDBDump data["BackupRunning"] = fullStatus.Running data["BackupMaxAgeHours"] = s.cfg.Monitoring.Thresholds.BackupMaxAgeHours } // Build subdomain map for "Megnyitás" buttons data["Subdomains"] = s.subdomainMap(deployedStacks) if s.alertManager != nil { data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard") } s.executeTemplate(w, r, "dashboard", data) } // subdomainMap builds the stack-name → subdomain lookup used by the "Megnyitás" open links (dashboard // + Alkalmazások) and the Indítópult launcher. Priority — unchanged from the two inline copies it // replaces: the deployed app.yaml SUBDOMAIN env wins, then the .felhom.yml Meta.Subdomain, then the // well-known protectedStackSubdomains fallback. A stack with no source of a subdomain gets no entry. func (s *Server) subdomainMap(stackList []stacks.Stack) map[string]string { subdomains := make(map[string]string) for _, stack := range stackList { if stack.Deployed { if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil { if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" { subdomains[stack.Name] = sd continue } } } if stack.Meta.Subdomain != "" { subdomains[stack.Name] = stack.Meta.Subdomain } else if sd, ok := protectedStackSubdomains[stack.Name]; ok { subdomains[stack.Name] = sd } } return subdomains } // controllerStackName is the controller's own stack — it IS this dashboard, so it never appears as a // launchable app even if a subdomain mapping somehow exists for it. const controllerStackName = "felhom-controller" // LauncherApp is one openable app tile for the Indítópult (launcher) page. type LauncherApp struct { Name string DisplayName string Slug string State stacks.ContainerState Subdomain string OpenPath string BrandColor string } // buildLauncherApps builds the sorted launcher tile list from the deployed/protected stacks and the // subdomain lookup. A tile exists exactly when a "Megnyitás" button would — i.e. the stack has a // subdomain entry — minus the controller itself. Sorted by DisplayName (tie-broken by Name) so the // grid order is stable regardless of Go's map iteration order. func buildLauncherApps(stackList []stacks.Stack, subdomains map[string]string) []LauncherApp { var apps []LauncherApp for _, st := range stackList { if st.Name == controllerStackName { continue } sd, ok := subdomains[st.Name] if !ok || sd == "" { continue } dn := st.Meta.DisplayName if dn == "" { dn = st.Name } apps = append(apps, LauncherApp{ Name: st.Name, DisplayName: dn, Slug: st.Meta.Slug, State: st.State, Subdomain: sd, OpenPath: st.Meta.OpenPath, BrandColor: st.Meta.BrandColor, }) } sort.Slice(apps, func(i, j int) bool { if apps[i].DisplayName == apps[j].DisplayName { return apps[i].Name < apps[j].Name } return apps[i].DisplayName < apps[j].DisplayName }) return apps } // launcherApps assembles the sorted launcher tile list (deployed/protected stacks with a subdomain, // controller excluded). Extracted from launcherHandler so the guest share page (v0.165.0) renders // the EXACT same app slice as the admin launcher. func (s *Server) launcherApps() []LauncherApp { var eligible []stacks.Stack for _, st := range s.stackMgr.GetStacks() { if st.Deployed || st.Protected { eligible = append(eligible, st) } } return buildLauncherApps(eligible, s.subdomainMap(eligible)) } // launcherHandler renders the Indítópult: a grid of large tappable tiles, one per openable deployed // app (subdomain presence is the single openability criterion — see buildLauncherApps). Behind // RequireAuth like every page; the "/" landing page stays the Vezérlőpult. It also carries the // "Indítópult megosztása" share state (v0.165.0) for the modal. func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("launcher", "Indítópult") data["Apps"] = s.launcherApps() // Share modal state. The share URL is built from the request Host at render time (the canonical // controller subdomain is not persisted anywhere reachable here); it carries the live token, which // is fine to show the authed admin inside the modal — the ONE admin surface allowed to reveal it. token := s.settings.GetLauncherShareToken() data["ShareEnabled"] = token != "" if token != "" { data["ShareURL"] = "https://" + r.Host + "/s/" + token } data["SharePasswordSet"] = s.settings.GetLauncherSharePasswordHash() != "" if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" { data["ShareFlash"] = f } s.executeTemplate(w, r, "launcher", data) } // visibleCatalogStacks drops templates that are no longer OFFERED for new installs (lifecycle // `hidden` or `abandoned`) AND are not deployed on this box. // // The `Deployed || Protected` half is the load-bearing part: a customer already running an app must // keep seeing and managing it, whatever the catalog now says about offering it to new customers. // Withdrawing an app must never take a working app away from someone — that is precisely the failure // the short-lived `retired/` directory move would have caused, and why lifecycle is a metadata field // rather than a deletion. // // Filtered here, in the handler, rather than in the template: the template already carries five // conditional badges per card, and a visibility rule buried among them is a rule nobody can test. func visibleCatalogStacks(in []stacks.Stack) []stacks.Stack { out := make([]stacks.Stack, 0, len(in)) for _, st := range in { if st.Deployed || st.Protected || st.Meta.CanInstall() { out = append(out, st) } } return out } func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("stacks", "Alkalmazások") allStacks := visibleCatalogStacks(s.stackMgr.GetStacks()) data["Stacks"] = allStacks data["MissingStorage"] = s.missingStorageMap(allStacks) nw, ns := s.networkStorageWarnings(allStacks) // NAS unreachable (recoverable) / guest-side stub (defect) data["NetworkWarnings"] = nw data["NetworkStubs"] = ns // Build storage label lookup for deployed apps storageLabels := make(map[string]string) // stack name → storage label storagePaths := s.settings.GetStoragePaths() for _, stack := range s.stackMgr.GetStacks() { if !stack.Deployed { continue } if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil { if hddPath := appCfg.Env["HDD_PATH"]; hddPath != "" { for _, sp := range storagePaths { if sp.Path == hddPath { storageLabels[stack.Name] = sp.Label break } } } } } data["StorageLabels"] = storageLabels // Build effective subdomain lookup (stored env > metadata > well-known fallback) data["Subdomains"] = s.subdomainMap(s.stackMgr.GetStacks()) s.executeTemplate(w, r, "stacks", data) } func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string) { stack, ok := s.stackMgr.GetStack(name) if !ok { http.NotFound(w, r) return } logs, err := s.stackMgr.GetLogs(name, 200) if err != nil { logs = fmt.Sprintf("Hiba a naplók lekérésekor: %v", err) } // Raw mode: return plain text for AJAX polling if r.URL.Query().Get("raw") == "1" { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprint(w, logs) return } data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók") data["Stack"] = stack data["Logs"] = logs s.executeTemplate(w, r, "logs", data) } func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) { if s.isDebug() { s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s method=%s", name, r.Method) } meta, appCfg, err := s.stackMgr.GetDeployFields(name) if err != nil { if s.isDebug() { s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s not found: %v", name, err) } http.NotFound(w, r) return } stack, _ := s.stackMgr.GetStack(name) alreadyDeployed := appCfg != nil && appCfg.Deployed pageTitle := meta.DisplayName + " — Telepítés" if alreadyDeployed { pageTitle = meta.DisplayName + " — Beállítások" } data := s.baseData("deploy", pageTitle) data["Stack"] = stack data["Meta"] = meta data["AppConfig"] = appCfg data["AlreadyDeployed"] = alreadyDeployed data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug) data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug) data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug) data["UserFields"] = meta.UserFacingFields() data["AutoFields"] = meta.AutoGeneratedFields() // Auto-generated field values: existing values for deployed apps, pre-generated for new deploys autoFieldValues := make(map[string]string) var decryptedEnv map[string]string if appCfg != nil { decryptedEnv = crypto.DecryptMap(s.encKey, appCfg.Env) } if alreadyDeployed && appCfg != nil { for _, f := range meta.AutoGeneratedFields() { if val, ok := decryptedEnv[f.EnvVar]; ok { autoFieldValues[f.EnvVar] = val } } } else if !alreadyDeployed { // Pre-generate values so the user sees (and can note down) domain/passwords before deploying. // These same values are submitted back in the form and saved to app.yaml. if preview, err := s.stackMgr.PreviewDeployValues(name); err == nil { autoFieldValues = preview } } data["AutoFieldValues"] = autoFieldValues // For deployed apps, pass stored field values (decrypted) so fields show current values if alreadyDeployed && decryptedEnv != nil { data["DeployedFieldValues"] = decryptedEnv } // Storage paths with free space info for deploy dropdown var deployPaths []DeployStoragePath for _, sp := range s.settings.GetSchedulableStoragePaths() { dp := DeployStoragePath{StoragePath: sp} if di := system.GetDiskUsage(sp.Path); di != nil { dp.FreeHuman = formatFreeSpace(di.AvailGB) if di.TotalGB > 0 { dp.FreePercent = di.AvailGB / di.TotalGB * 100 } } deployPaths = append(deployPaths, dp) } data["StoragePaths"] = deployPaths // RCA fix 4: a deployed app's read-only storage select must show the app's STORED HDD_PATH — // never the default drive (the pre-fix render selected by IsDefault only, so the settings view // lied about where the data lives). When the stored path is no longer schedulable, an extra // disabled option names it verbatim rather than silently showing a different storage. data["CurrentHDDPath"] = "" data["CurrentHDDPathMissing"] = false if alreadyDeployed && appCfg != nil { if hdd := appCfg.Env["HDD_PATH"]; hdd != "" { data["CurrentHDDPath"] = hdd inList := false for _, dp := range deployPaths { if dp.Path == hdd { inList = true break } } data["CurrentHDDPathMissing"] = !inList } } // Prevention layer (storage-split): surface the Docker-data volume's reserved-buffer state so the // customer sees BEFORE deploying when free space is too low (the API gate also hard-refuses). Only // meaningful for a NEW deploy (an existing app's config save doesn't consume fresh image space). if !alreadyDeployed { if hr := system.GetDockerVolumeHeadroom(); hr.OK { data["DockerBelowReserve"] = hr.BelowReserve data["DockerFreeHuman"] = formatFreeSpace(hr.AvailGB) data["DockerReserveHuman"] = formatFreeSpace(hr.ReserveGB) } } // Effective subdomain for "Megnyitás" button if alreadyDeployed && appCfg != nil { if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" { data["EffectiveSubdomain"] = sd } } // Disk-tier storage management (drive info, stale-data cleanup, cross-drive // backup) has moved to the host agent (slice 8C); the deploy page no longer // renders those sections. if alreadyDeployed { // App-to-app integrations if im := s.integrationMgr.Load(); meta.HasIntegrations() && im != nil { data["HasIntegrations"] = true data["Integrations"] = im.ListForProvider(meta.Slug) } // Geo-restriction per-app data geo := s.settings.GetGeoRestriction() if geo != nil && geo.Enabled && s.cfg.Infrastructure.CFAPIToken != "" { data["GeoGlobalEnabled"] = true data["GeoGlobalCountries"] = geo.AllowedCountries if ov, ok := geo.AppOverrides[name]; ok { data["GeoAppOverride"] = true data["GeoAppOverrideCountries"] = ov.AllowedCountries } else { data["GeoAppOverrideCountries"] = []string{} } } else { data["GeoGlobalCountries"] = []string{} data["GeoAppOverrideCountries"] = []string{} } // Optional config (metadata providers, etc.) if meta.HasOptionalConfig() { data["HasOptionalConfig"] = true data["OptionalConfig"] = meta.OptionalConfig optValues := make(map[string]string) if decryptedEnv != nil { for _, group := range meta.OptionalConfig { for _, field := range group.Fields { if val, ok := decryptedEnv[field.EnvVar]; ok { optValues[field.EnvVar] = val } } } } data["CurrentValues"] = optValues } // App-email per-app toggle — only for apps that declare an smtp_mapping. Shown with // honest context whether or not the global toggle is on. if supported, enabled := s.stackMgr.AppEmailStatus(name); supported { data["AppEmailSupported"] = true data["AppEmailAppOn"] = enabled data["AppEmailGlobalOn"] = s.settings.AppEmailEnabled() local := meta.SMTPMapping.FromLocal if local == "" { local = meta.Slug } domain := "felhom.eu" if len(s.cfg.MailRelay.FromDomains) > 0 && s.cfg.MailRelay.FromDomains[0] != "" { domain = s.cfg.MailRelay.FromDomains[0] } data["AppEmailFromAddress"] = local + "@" + domain } } // Memory info for deploy page (only for non-deployed apps) if !alreadyDeployed { memInfo := map[string]interface{}{"Available": false} totalMB, usedMB, memErr := system.GetMemoryMB() if memErr == nil { reservedMB := s.cfg.System.ReservedMemoryMB usableMB := totalMB - reservedMB newReqMB := stacks.ParseMemoryMB(meta.Resources.MemRequest) afterMB := usedMB + newReqMB percent := 0 if usableMB > 0 { percent = afterMB * 100 / usableMB } usedPercent := 0 if usableMB > 0 { usedPercent = usedMB * 100 / usableMB } // Overcommit warning still uses declared limits _, committedLimitMB := s.stackMgr.CommittedMemory() newLimitMB := stacks.ParseMemoryMB(meta.Resources.MemLimit) afterLimitMB := committedLimitMB + newLimitMB memInfo["Available"] = true memInfo["TotalMB"] = totalMB memInfo["ReservedMB"] = reservedMB memInfo["UsableMB"] = usableMB memInfo["UsedMB"] = usedMB memInfo["NewRequestMB"] = newReqMB memInfo["AfterMB"] = afterMB memInfo["Percent"] = percent memInfo["UsedPercent"] = usedPercent memInfo["Blocked"] = newReqMB > 0 && afterMB > usableMB memInfo["OvercommitWarn"] = newLimitMB > 0 && afterLimitMB > totalMB } data["MemoryInfo"] = memInfo } // Flash messages from cross-drive backup save redirect if flash := r.URL.Query().Get("flash"); flash != "" { data["FlashSuccess"] = flash } if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" { data["FlashError"] = flashErr } s.executeTemplate(w, r, "deploy", data) } func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) { var found *stacks.Stack for _, stack := range s.stackMgr.GetStacks() { if stack.Meta.Slug == slug { found = &stack break } } if found == nil { http.NotFound(w, r) return } // Determine effective subdomain (stored env > metadata fallback) effectiveSubdomain := found.Meta.Subdomain if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil { if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" { effectiveSubdomain = sd } } data := s.baseData("stacks", found.Meta.DisplayName) data["Stack"] = found data["Meta"] = found.Meta data["AppInfo"] = found.Meta.AppInfo data["HasAppInfo"] = found.Meta.HasAppInfo() data["EffectiveSubdomain"] = effectiveSubdomain // „Hova tegyem a fájlokat?" (R-75) — deployed apps that declare data_paths only. Set EXPLICITLY, // like every other key here: appDetailHandler does not funnel through baseData, and the v0.150.0 // app_export.html bug (a CSRF token rendered where the domain belonged) came from assuming it did. if cards := s.buildDataPathCards(found); len(cards) > 0 { data["DataPathCards"] = cards } // Initial auto-generated login (e.g. Crafty writes a random admin password to a file at first // boot). Read it live from the container so the customer doesn't have to dig through logs. Only // for deployed apps that declare an initial_credentials spec; hidden when unreadable. if found.Deployed && found.Meta.InitialCreds != nil { if creds, err := s.stackMgr.ReadInitialCredentials(found.Name); err != nil { s.logger.Printf("[WARN] [web] initial-creds for %s: %v", found.Name, err) } else if creds != nil && creds.Available { data["InitialCreds"] = creds } } // Per-app migration (B1): offer to move this app's data to another connected drive (≠ current). if found.Deployed { current := "" if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil { current = appCfg.Env["HDD_PATH"] } var targets []settings.StoragePath for _, sp := range s.settings.GetStoragePaths() { if sp.Path == current || sp.Decommissioned || sp.Disconnected || !sp.Schedulable { continue } targets = append(targets, sp) } data["MigrateTargets"] = targets data["MigrateCurrent"] = current if label, missing := s.missingStorageLabel(current); missing { data["MissingStorageLabel"] = label } } s.executeTemplate(w, r, "app_info", data) } func (s *Server) monitoringHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("monitoring", "Rendszermonitor") data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector) data["StorageBars"] = s.buildStorageBars() if s.alertManager != nil { data["Alerts"] = s.alertManager.GetAlerts() data["DiskWarnings"] = s.alertManager.GetInlineAlerts("monitoring") } // Hub connection status section data["HubEnabled"] = s.cfg.Hub.Enabled && s.cfg.Hub.URL != "" data["HubURL"] = s.cfg.Hub.URL data["CustomerID"] = s.cfg.Customer.ID if s.hubPushStatusFn != nil { ps := s.hubPushStatusFn() data["HubLastAttempt"] = ps.LastAttempt data["HubLastSuccess"] = ps.LastSuccess data["HubLastError"] = ps.LastError data["HubConsecutiveFailures"] = ps.Consecutive // Connected if last success was within 2x the push interval (or 30min default) connected := !ps.LastSuccess.IsZero() && time.Since(ps.LastSuccess) < 30*time.Minute data["HubConnected"] = connected } // Legacy ping status section (still shown for backward compat during transition) data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled if s.cfg.Monitoring.Enabled { pings := []map[string]interface{}{ {"Label": "Eletjel (Heartbeat)", "Icon": "heartbeat", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Heartbeat), "Schedule": "5 percenkent"}, {"Label": "Rendszer allapot", "Icon": "system", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.SystemHealth), "Schedule": "5 percenkent"}, {"Label": "Adatbazis mentes", "Icon": "db", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.DBDump), "Schedule": "Naponta " + s.cfg.Backup.DBDumpSchedule}, {"Label": "Biztonsagi mentes", "Icon": "backup", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Backup), "Schedule": "Naponta " + s.cfg.Backup.ResticSchedule}, {"Label": "Mentes integritas", "Icon": "integrity", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.BackupIntegrity), "Schedule": "Hetente (vasarnap)"}, } allConfigured := true for _, p := range pings { if !p["Configured"].(bool) { allConfigured = false break } } data["PingStatus"] = pings data["AllPingsConfigured"] = allConfigured } s.executeTemplate(w, r, "monitoring", data) } // isPingConfigured returns true if a healthcheck ping UUID is non-empty and not a placeholder. func isPingConfigured(uuid string) bool { return uuid != "" && !strings.HasPrefix(uuid, "CHANGEME") } // backupsCommonData builds what every backups sub-page shares (v0.124.0 IA split): the page // chrome + the backup full-status with the redirect flash. Backup stays nil (empty-state) when // the manager is absent. Each page handler adds ONLY the data its sections render — no // duplicated computation across the four pages. func (s *Server) backupsCommonData(page, title string, r *http.Request) map[string]interface{} { data := s.baseData(page, title) if s.backupMgr == nil { data["Backup"] = nil return data } nextDBDump := scheduler.NextDailyRun(s.effectiveBackupWindow()) fullStatus := s.backupMgr.GetFullStatus(nextDBDump) // Pass flash messages from query params (set by redirect handlers) if flash := r.URL.Query().Get("flash"); flash != "" { fullStatus.FlashSuccess = flash } if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" { fullStatus.FlashError = flashErr } data["Backup"] = fullStatus return data } // backupsOffboxData adds the offbox target + per-app toggle state (the remote, apps and restore // pages all render some of it: status card / toggle list / tier-3 rows / restore-to-verify). func (s *Server) backupsOffboxData(data map[string]interface{}) { offboxTgt := s.settings.GetOffboxTarget() data["Offbox"] = offboxTgt data["OffboxConfigured"] = s.backupMgr != nil && s.backupMgr.OffboxConfigured() // R-70 (v0.161.0): hub-managed offsite is enabled in controller.yaml — the descriptor exists // and the apply-bridge WILL configure the target automatically. The empty state must say so // instead of the bare "nincs beállítva" (a box stuck pre-apply looked identical to one that // was never provisioned — DIAG-f10: that ambiguity hid a burned credential for 2 days). data["OffsiteHubEnabled"] = s.cfg != nil && s.cfg.Offsite.Enabled offboxApps := s.buildOffboxApps() data["OffboxApps"] = offboxApps // Zero-toggle hint (take-two obs.): configured + escrowed but no app selected — nothing is // actually covered by the offsite leg until the customer toggles at least one. offboxToggled := 0 for _, a := range offboxApps { if a.Enabled { offboxToggled++ } } data["OffboxToggledCount"] = offboxToggled // Part E (v0.126.0): the LastWarning DISPLAY pick — never a state mutation. if offboxTgt != nil { data["OffboxWarningDisplay"] = offboxWarningDisplay(offboxTgt.LastWarning, offboxToggled) } else { data["OffboxWarningDisplay"] = "" } // SLICE 4 soft-quota usage bar (rendered only when a quota is set — shared model). data["OffboxQuotaPct"] = backup.OffboxQuotaPercent(offboxTgt) // 3a: per-app "config+DB only" note set — apps whose enlarged push the quota gate blocked last run. blocked := map[string]bool{} if offboxTgt != nil { for _, a := range offboxTgt.EnlargedBlocked { blocked[a] = true } } data["OffboxBlockedSet"] = blocked } // offboxStaleWarningMarker is the substring the zero-toggled offbox run writes into // LastWarning (backup/offbox.go); offboxSelectionChangedLine replaces it once the // selection has moved on. const ( offboxStaleWarningMarker = "nincs mentésre jelölt alkalmazás" offboxSelectionChangedLine = "A kijelölés módosult az utolsó futás óta — a következő távoli mentés már tartalmazza." ) // offboxWarningDisplay picks what the Távoli mentés page shows for the persisted // Offbox.LastWarning. A zero-toggled run records "Sikeres — nincs mentésre jelölt // alkalmazás…"; once the customer HAS toggled apps that line is stale and misleading — // replace it with the honest "selection changed, the next run covers it" note. // Pure display logic: the persisted LastWarning is never touched, and every other // warning (quota, partial failure) passes through verbatim. func offboxWarningDisplay(lastWarning string, toggledCount int) string { if toggledCount >= 1 && strings.Contains(lastWarning, offboxStaleWarningMarker) { return offboxSelectionChangedLine } return lastWarning } // backupsHandler renders the Áttekintés page: storage overview, whole-guest Rendszermentés and // the status stat cards. func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { data := s.backupsCommonData("backups", "Biztonsági mentés", r) // System info for storage overview bars data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector) data["StorageBars"] = s.buildStorageBars() // Whole-guest backup view (agent-sourced, read-only) for the "Rendszermentés" section. data["GuestBackup"] = s.loadGuestBackup(r.Context()) // Customer-configurable backup window (v0.168.0): effective start + derived leg/gate times. s.backupWindowData(data) if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil { // DB-section state — honest messaging for embedded-DB-only boxes (SQLite etc.): // "dumps" (real dumps) | "pending" (discovered, first run tonight) | "embedded". data["DBSectionState"] = dbSectionState(len(fullStatus.DiscoveredDBs), len(fullStatus.DumpFiles)) } s.executeTemplate(w, r, "backups", data) } // escrowCeremonyGraceWindow (v0.138.0) bounds how long the "awaiting hub confirmation" card is // shown after a completed escrow ceremony before it degrades to the "confirmation did not arrive" // warning. Two report cycles (2×15m) + slack — long enough for the normal report-ACK confirm // (Phase-0 verdict A: the demo confirmed on the next ACK ~14m out), short enough that a genuinely // stuck ceremony never renders as an indefinite wait. const escrowCeremonyGraceWindow = 35 * time.Minute // offboxCeremonyWaitState classifies the post-ceremony wait for the remote page's escrow card: // awaiting (stamped, within the grace window, still pending) vs timedOut (stamped, past the window, // still pending). Both false when escrowed, unstamped, or the timestamp is unparseable — fail to the // plain pending CTA rather than render a phantom wait. func offboxCeremonyWaitState(t *settings.OffboxTarget) (awaiting, timedOut bool) { if t == nil || t.EscrowState == "escrowed" || t.CeremonyCompletedAt == "" { return false, false } ts, err := time.Parse(time.RFC3339, t.CeremonyCompletedAt) if err != nil { return false, false } if time.Since(ts) >= escrowCeremonyGraceWindow { return false, true } return true, false } // backupsRemoteHandler renders the Távoli mentés page: the Felhom-offsite status card, the // participation toggles and the manual-target form. func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) { data := s.backupsCommonData("backups-remote", "Biztonsági mentés — Távoli mentés", r) s.backupsOffboxData(data) // Escrow ceremony card states (v0.127.0): the Scenario-F stale flag + the agent version gate. data["EscrowStale"] = s.escrowStale() agentVer := "" if agent, err := s.escrowAgentConn(); err == nil { agentVer = agent.AgentVersion() } data["EscrowAgentOK"] = escrowAgentSupported(agentVer) // v0.138.0: the post-ceremony "awaiting hub confirmation" card (and its timeout degrade) — // bridges the report-cycle gap between the ceremony and the auto-confirmer's pending→escrowed flip. awaiting, timedOut := offboxCeremonyWaitState(s.settings.GetOffboxTarget()) data["OffboxCeremonyAwaiting"] = awaiting data["OffboxCeremonyTimedOut"] = timedOut // v0.142.0 offsite-repo continuity: the orphan card + the auto-refresh (Part C) trigger. data["OffboxOrphaned"] = s.backupMgr != nil && s.backupMgr.OffboxOrphaned() s.executeTemplate(w, r, "backups_remote", data) } // backupsAppsHandler renders the Alkalmazások page: schedule, databases and the per-app // 1./2./3. tier rows. func (s *Server) backupsAppsHandler(w http.ResponseWriter, r *http.Request) { data := s.backupsCommonData("backups-apps", "Biztonsági mentés — Alkalmazások", r) s.backupsOffboxData(data) // the tier-3 rows render $.Offbox status if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil { // Enrich AppDataInfo with storage labels storagePaths := s.settings.GetStoragePaths() for i := range fullStatus.AppDataInfo { app := &fullStatus.AppDataInfo[i] if len(app.HDDPaths) > 0 { hddPath := app.HDDPaths[0].HostPath // Match HDD path prefix against registered storage paths for _, sp := range storagePaths { if strings.HasPrefix(hddPath, sp.Path) { app.StorageLabel = sp.Label break } } } } // Build unified per-app backup rows for the app-data backup UI. // Disk-tier (cross-drive / restic) backup has moved to the host agent. data["AppBackupRows"] = s.buildAppBackupRows(fullStatus) data["DBSectionState"] = dbSectionState(len(fullStatus.DiscoveredDBs), len(fullStatus.DumpFiles)) } s.executeTemplate(w, r, "backups_apps", data) } // backupsRestoreHandler renders the Visszaállítás page: the restore panel, the offbox // restore-to-verify list and the .fab export/import loop. func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) { data := s.backupsCommonData("backups-restore", "Biztonsági mentés — Visszaállítás", r) s.backupsOffboxData(data) // restore-to-verify lists the offbox-toggled apps // Full-restore two-step reveal (§7.2): after the size+headroom prepare step, offboxRestoreHandler // redirects here with the app + human size so the confirm section can show the size BEFORE starting. if fp := strings.TrimSpace(r.URL.Query().Get("full_prep")); fp != "" { data["FullPrepApp"] = fp data["FullPrepSize"] = r.URL.Query().Get("full_size") } // Per-app place-to-live availability (a completed full scratch exists → offer the merge action). ready := map[string]bool{} if s.backupMgr != nil { if apps, ok := data["OffboxApps"].([]OffboxAppRow); ok { for _, a := range apps { if a.Enabled && s.backupMgr.OffboxFullScratchReady(a.Name) { ready[a.Name] = true } } } } data["OffboxScratchReady"] = ready // R-43: the same prepared scratch also enables the TRUE restore (files + database). Its confirm // has to state what the pair actually IS — how old the DB half is, whether the two halves even // come from the same run, and whether the dump looks customer-empty — because a restore is the // one operation whose result the customer cannot inspect until after committing to it. pairs := map[string]backup.OffsitePairInfo{} if s.backupMgr != nil { for name := range ready { pairs[name] = s.backupMgr.OffsiteScratchPair(name) } } data["OffboxPairInfo"] = pairs // R-7b: the shares source is not an app — it has no per-app toggle and no recovery unit — so it // gets its own restore entry rather than a synthetic row in OffboxApps (which would also make it // appear in the per-app offsite TOGGLE list on /backups/remote, where it does not belong). if s.backupMgr != nil { data["SharesRestoreOffered"] = s.settings != nil && len(s.settings.GetSMBShares()) > 0 data["SharesScratchReady"] = s.backupMgr.SharesScratchReady() data["SharesDisplayName"] = backup.SharesDisplayName } // v0.147.0 (4a): what verification restores have actually left on disk. Previously nothing on any // page listed these, so they accumulated invisibly and the only way to find them was SSH. if s.backupMgr != nil { data["OffsiteRestoreCopies"] = s.backupMgr.ListOffsiteRestoreCopies() } s.executeTemplate(w, r, "backups_restore", data) } // OffboxAppRow is one deployed app's off-box toggle state for the backups page. type OffboxAppRow struct { Name string DisplayName string Slug string // catalog slug for the shared app-row icon (logoURL) Enabled bool } // buildOffboxApps lists deployed, non-protected apps with their off-box toggle state. func (s *Server) buildOffboxApps() []OffboxAppRow { var out []OffboxAppRow if s.stackMgr == nil { return out } for _, st := range s.stackMgr.GetStacks() { if !st.Deployed || st.Protected { continue } dn := st.Meta.DisplayName if dn == "" { dn = st.Name } out = append(out, OffboxAppRow{Name: st.Name, DisplayName: dn, Slug: st.Meta.Slug, Enabled: s.settings.IsAppOffbox(st.Name)}) } return out } // AppBackupRow holds per-tier backup information for one app on the backup page. type AppBackupRow struct { StackName string DisplayName string Slug string // catalog slug for the aligned header icon (logoURL) Status string // "green", "yellow", "red", "auto" StatusText string // short Hungarian tooltip // App characteristics HasHDDData bool HasDB bool HasVolumeData bool StorageLabel string HDDSizeHuman string // What this app's backup contains (for display) // e.g., "DB + Konfiguráció + Adatok", "DB + Konfiguráció", "Konfiguráció" BackupContents string // Tier 1: Nightly backup (always exists) Tier1LastRun string // RFC3339 time of the newest recovery-unit artifact ("" = no unit yet) Tier1LastStatus string // "ok", "error", "" Tier1DBStatus string // "ok", "error", "" — separate DB dump status for warning // Tier 2: Cross-drive backup (configurable for all apps) Tier2Configured bool Tier2Dest string // destination label Tier2Schedule string // "Naponta", "Hetente" Tier2LastRun string Tier2LastStatus string // "ok", "error", "running", "" Tier2LastError string Tier2LastWarning string // 3b: capture-gap / state-only notice on an otherwise-ok run Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—" Tier2SizeHuman string // Drive disconnected — app's home drive is currently disconnected DriveDisconnected bool // Tier2 destination drive is currently disconnected (backup paused, not failed) Tier2DestDisconnected bool // Tier2 destination drive is inactive (Schedulable=false, backup paused) Tier2DestInactive bool // Tier2UserDisabled — customer turned Tier 2 off for this app from the config panel. Tier2UserDisabled bool // Tier 3: Off-box (NAS) restic-SFTP backup — the off-site 3-2-1 leg. OffboxEnabled bool // this app toggled for off-box inclusion (IsAppOffbox) Tier3State string // "unconfigured" | "off" | "escrow_pending" | "active" (see tier3State) // Warnings accumulated for this app Warnings []string } // buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page. // Disk-tier (cross-drive / restic) backup has moved to the host agent; this now // reflects only the app-data backup (DB dumps + Docker-volume tars). func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackupRow { // Build DB stack lookup dbStacks := make(map[string]bool) for _, db := range status.DiscoveredDBs { dbStacks[db.StackName] = true } for _, f := range status.DumpFiles { dbStacks[f.StackName] = true } tier1DBStatus := "" if status.LastDBDump != nil { if status.LastDBDump.Success { tier1DBStatus = "ok" } else { tier1DBStatus = "error" } } // Build disconnected paths set for drive-disconnected detection disconnectedPaths := make(map[string]bool) for _, dp := range s.settings.GetDisconnectedPaths() { disconnectedPaths[dp.Path] = true } // Off-box (Tier 3) globals — resolved once for all rows. The per-app state is // (global configured) × (global escrow) × (per-app toggle); see tier3State. offboxConfigured := s.backupMgr != nil && s.backupMgr.OffboxConfigured() offboxEscrowState := "" if t := s.settings.GetOffboxTarget(); t != nil { offboxEscrowState = t.EscrowState } var rows []AppBackupRow for _, app := range status.AppDataInfo { hasDB := dbStacks[app.StackName] || app.HasDBDump // Check if this app's home drive is disconnected driveDisconnected := false if app.HasHDDData && len(app.HDDPaths) > 0 { for dp := range disconnectedPaths { for _, hp := range app.HDDPaths { if strings.HasPrefix(hp.HostPath, dp+"/") || hp.HostPath == dp { driveDisconnected = true break } } if driveDisconnected { break } } } // Build backup contents label var parts []string if hasDB { parts = append(parts, "DB") } parts = append(parts, "Konfig") if app.HasHDDData || app.HasVolumeData { parts = append(parts, "Adatok") } contents := strings.Join(parts, " + ") slug := "" if s.stackMgr != nil { if st, ok := s.stackMgr.GetStack(app.StackName); ok { slug = st.Meta.Slug } } row := AppBackupRow{ StackName: app.StackName, DisplayName: app.DisplayName, Slug: slug, HasHDDData: app.HasHDDData, HasDB: hasDB, HasVolumeData: app.HasVolumeData, DriveDisconnected: driveDisconnected, StorageLabel: app.StorageLabel, HDDSizeHuman: app.HDDSizeHuman, BackupContents: contents, Tier1DBStatus: tier1DBStatus, } // Tier 1: newest recovery-unit artifact time. ListRestorePoints does the correct // per-drive namespace resolution (do NOT re-derive paths — the offbox DIAG trap). // A known stack with no unit yet returns an empty list → no fabricated time. if s.backupMgr != nil { if pts, ok := s.backupMgr.ListRestorePoints(app.StackName); ok && len(pts) > 0 { row.Tier1LastRun = pts[0].Time // A unit exists: green unless the DB dump failed (keep tier1DBStatus as the source). if status.LastDBDump != nil && !status.LastDBDump.Success { row.Tier1LastStatus = "error" } else { row.Tier1LastStatus = "ok" } } } // Tier 3: off-box (NAS) inclusion state for this app. row.OffboxEnabled = s.settings.IsAppOffbox(app.StackName) row.Tier3State = tier3State(offboxConfigured, row.OffboxEnabled, offboxEscrowState) // Status dot — app-data backup status row.Status = "green" row.StatusText = "Alkalmazás-adat mentés rendben" if hasDB && tier1DBStatus == "error" { row.Status = "yellow" row.StatusText = "Adatbázis mentés sikertelen" } // Tier 2 (off-drive copy) status, from the config the Tier 2 runner persists. if cd := s.settings.GetCrossDriveConfig(app.StackName); cd != nil { row.Tier2UserDisabled = cd.UserDisabled if cd.UserDisabled { // Customer turned Tier 2 off — show nothing more; the panel button still appears. } else if cd.LastStatus == "no_target" { // Auto Tier 2 found no off-drive target — surface the honest reason (no silent gap). row.Tier2Configured = false row.Tier2StatusBadge = "Nincs 2. meghajtó" row.Tier2LastError = cd.LastError } else if cd.Enabled { row.Tier2Configured = true row.Tier2Dest = tier2DestLabel(cd.DestinationPath, s.cfg.Paths.SystemDataPath) row.Tier2Schedule = "Naponta" row.Tier2LastRun = cd.LastRun row.Tier2LastStatus = cd.LastStatus row.Tier2LastError = cd.LastError row.Tier2LastWarning = cd.LastWarning row.Tier2SizeHuman = cd.LastSizeHuman switch cd.LastStatus { case "ok": row.Tier2StatusBadge = "Sikeres" case "error": row.Tier2StatusBadge = "Hiba" case "running": row.Tier2StatusBadge = "Fut..." default: row.Tier2StatusBadge = "—" } } } rows = append(rows, row) } return rows } // tier2DestLabel renders a friendly destination label for the "2. mentés" card. A destination under // the system-data path is the internal SSD (DB/config only); otherwise it's an external drive. func tier2DestLabel(destPath, systemDataPath string) string { if systemDataPath != "" && strings.HasPrefix(destPath, systemDataPath) { return "belső SSD (csak DB/konfiguráció)" } return filepath.Base(strings.TrimSuffix(destPath, "/"+backup.FelhomDataDir)) } func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() stackName := r.FormValue("stack_name") snapshotID := r.FormValue("snapshot_id") if s.isDebug() { s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr) } if stackName == "" || snapshotID == "" { http.Redirect(w, r, "/backups/restore?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound) return } // F2 (defense-in-depth): a stack name is a single segment, never a path. Reject traversal before any // restore work — never let it reach RestoreFromRecoveryUnit. if !validStackName(stackName) { s.logger.Printf("[WARN] [web] restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr) http.Redirect(w, r, "/backups/restore?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound) return } if s.backupMgr == nil { http.Redirect(w, r, "/backups/restore?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound) return } // Part B: restore is a long SYNCHRONOUS op (F4 — through cloudflared's hard 100s cap the customer // got an error page while it silently succeeded). Fast-path refuse a concurrent op, then run it in // a BACKGROUND goroutine (survives the request; the poll banner shows progress → result). if s.backupMgr.IsRunning() { http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound) return } s.logger.Printf("[WARN] [web] Restore requested (async): stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr) s.backupMgr.BeginRestoreOp("restore", stackName) go func() { start := time.Now() // Phase 2b: restore from the app's recovery unit (recovers secrets from the guest, fail-closed // on an unrecoverable data-encrypting key; falls back to volume-only restore if no unit exists). if err := s.backupMgr.RestoreFromRecoveryUnit(stackName); err != nil { s.logger.Printf("[ERROR] [web] Restore failed (async): stack=%s: %v", stackName, err) s.backupMgr.EndRestoreOp(false, "Visszaállítás sikertelen: "+err.Error()) return } s.logger.Printf("[INFO] [web] Restore completed (async): stack=%s in %s", stackName, time.Since(start)) s.backupMgr.EndRestoreOp(true, stackName+" visszaállítva ("+snapshotID+").") }() http.Redirect(w, r, "/backups/restore?flash="+url.QueryEscape("Visszaállítás elindult — az állapot itt frissül."), http.StatusFound) } // backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its // recorded Tier-2 copy — additive-only: existing live files are never overwritten and nothing is // ever deleted (see backup.RestoreTier2Files). Same handler shape as backupRestoreHandler. func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() stackName := r.FormValue("stack_name") if stackName == "" { http.Redirect(w, r, "/backups/apps?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound) return } // Same F2-defense as the unit restore: a stack name is a single segment, never a path. if !validStackName(stackName) { s.logger.Printf("[WARN] [web] Tier-2 file restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr) http.Redirect(w, r, "/backups/apps?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound) return } if s.backupMgr == nil { http.Redirect(w, r, "/backups/apps?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound) return } // Part B (same async shape as backupRestoreHandler): fast-path refuse, then background goroutine. if s.backupMgr.IsRunning() { http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound) return } s.logger.Printf("[WARN] [web] Tier-2 file restore requested (async): stack=%s from %s", stackName, r.RemoteAddr) s.backupMgr.BeginRestoreOp("tier2-restore", stackName) go func() { n, err := s.backupMgr.RestoreTier2Files(stackName) if err != nil { s.logger.Printf("[ERROR] [web] Tier-2 file restore failed (async): stack=%s: %v", stackName, err) s.backupMgr.EndRestoreOp(false, "Fájl-visszaállítás sikertelen: "+err.Error()) return } msg := "Nincs hiányzó fájl — minden fájl megvan a helyén." if n > 0 { msg = fmt.Sprintf("%s: %d fájl visszaállítva a másodlagos másolatból.", stackName, n) } s.logger.Printf("[INFO] [web] Tier-2 file restore completed (async): stack=%s (%d files)", stackName, n) s.backupMgr.EndRestoreOp(true, msg) }() http.Redirect(w, r, "/backups/apps?flash="+url.QueryEscape("Fájl-visszaállítás elindult — az állapot itt frissül."), http.StatusFound) } // settingsBaseData is the shared identity block used by every settings-family subpage // (D1 split: /settings, /settings/notifications, /settings/security, /storage). func (s *Server) settingsBaseData(page, title string) map[string]interface{} { data := s.baseData(page, title) data["CustomerID"] = s.cfg.Customer.ID data["CustomerDomain"] = s.cfg.Customer.Domain return data } // systemPageData builds the Rendszer subpage: read-only configuration, version/update, // controller + server restart. func (s *Server) systemPageData() map[string]interface{} { data := s.settingsBaseData("settings", "Beállítások") data["GitRepoURL"] = s.cfg.Git.RepoURL data["GitSyncInterval"] = s.cfg.Git.SyncInterval data["BackupEnabled"] = s.cfg.Backup.Enabled data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule data["ResticSchedule"] = s.cfg.Backup.ResticSchedule data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase data["HubEnabled"] = s.cfg.Hub.Enabled // Self-update status data["SelfUpdateEnabled"] = s.cfg.SelfUpdate.Enabled if s.updater != nil { // Registry mode (v0.112.0): credential-less is a SUPPORTED mode (anonymous pull of // the public package), not an error state — the panel says which mode is active. data["RegistryAnonymous"] = s.updater.RegistryAnonymous() status := s.updater.GetStatus() data["UpdateRunning"] = status.Running if status.LastCheck != nil { data["UpdateAvailable"] = status.LastCheck.UpdateAvailable data["LatestVersion"] = status.LastCheck.LatestVersion data["LastCheckTime"] = status.LastCheck.CheckedAt data["LastCheckError"] = status.LastCheck.Error } if status.LastState != nil { data["LastUpdateState"] = status.LastState } data["AutoUpdateEnabled"] = s.cfg.SelfUpdate.AutoUpdate data["AutoUpdateTime"] = s.cfg.SelfUpdate.AutoUpdateTime // Phase 2 managed updates: the operator-enforced minimum version (FLOOR) the box auto-updates // to. Empty = none set by the operator. data["ControllerFloor"] = s.updater.GetFloor() } // Guest RAM resize card (v0.143.0, R-24): current allocation + bounds + capability/reachability. s.memoryCardData(data) // „Hálózat" card (R-66): where the box IS, live-computed per render and stored NOWHERE — the // guest holds its address by DHCP, so a stored copy eventually misdirects people (S-5); an // address-less row („—") beats a wrong address. Hálózati név renders ONLY while Megosztás is // enabled: the NetBIOS name exists only while samba runs — showing \\FELHOM otherwise would be // a wrong promise. data["NetLANAddress"] = s.sambaLANAddress() data["NetGateway"] = s.guestGateway() if smb := s.settings.GetSMBSettings(); smb.Enabled { data["NetSMBName"] = smb.EffectiveServerName() } return data } // storagePageData builds the Tárhely page: physical drive registry, NAS shares, and the // data the unified agent-enriched drive view needs. func (s *Server) storagePageData() map[string]interface{} { data := s.settingsBaseData("storage", "Tárhely") // Storage paths with display data storagePaths := s.settings.GetStoragePaths() connectedCount := 0 for _, sp := range storagePaths { if !sp.Disconnected && !sp.Decommissioned { connectedCount++ } } var storageViews []StoragePathView for _, sp := range storagePaths { // NAS network shares are rendered in their OWN section (NetworkStoragePaths) with the agent's // per-share health — never in the physical-drive list (which offers eject/decommission/wipe). if sp.IsNetwork() { continue } view := StoragePathView{ StoragePath: sp, StoppedApps: sp.StoppedStacks, HasOtherPaths: connectedCount > 1, IsEnrolled: strings.HasPrefix(sp.Path, "/mnt/felhom-drives/"), } if sp.Disconnected { // Skip I/O calls on disconnected drives — they'd hang or fail view.IsMounted = false } else if sp.Decommissioned { view.IsMounted = false view.MigratedToLabel = s.settings.GetStorageLabel(sp.MigratedTo) } else { view.IsMounted = system.IsMountPoint(sp.Path) view.AppDetails = s.appDetailsForPath(sp.Path) view.FSInfo = system.GetFSInfo(sp.Path) view.AppCount = len(view.AppDetails) if di := system.GetDiskUsage(sp.Path); di != nil { view.DiskInfo = di } // Detect USB for safe disconnect button if view.FSInfo != nil && view.FSInfo.Device != "" { view.IsUSB = system.IsUSBDevice(view.FSInfo.Device) } } storageViews = append(storageViews, view) } data["StoragePaths"] = storageViews return data } // networkStoragePageData builds the Tárhely → Hálózati tárhely (NAS) subpage: the NAS shares // with the agent's per-share health (ok/idle/unreachable/unknown). Split from the physical // drive page — the two storage classes were confusingly interleaved on one page. func (s *Server) networkStoragePageData() map[string]interface{} { data := s.settingsBaseData("storage-network", "Hálózati tárhely") data["NetworkStoragePaths"] = s.networkStorageItems(context.Background()) // Capability banner: "no" swaps the add form for the agent-outdated notice (yes/unknown render // the form — flaky states belong to the add-time gate). Short-budget probe, cache-backed. data["NetAddSupport"] = s.netAddSupport() return data } // notificationsPageData builds the Értesítések subpage: notification prefs + app-email. func (s *Server) notificationsPageData() map[string]interface{} { data := s.settingsBaseData("settings-notifications", "Értesítések") data["HubEnabled"] = s.cfg.Hub.Enabled data["NotificationPrefs"] = s.settings.GetNotificationPrefs() // App-email (SMTP relay) — global toggle. Only meaningful when a hub is configured (the relay // path runs through the hub); the template hides the control otherwise. appEmail := s.settings.GetAppEmail() data["AppEmailEnabled"] = appEmail.Enabled data["AppEmailFromName"] = appEmail.FromName data["AppEmailAvailable"] = s.cfg.Hub.URL != "" && s.cfg.MailRelay.HardEnabled() return data } // securityPageData builds the Biztonság és hozzáférés subpage: password, geo-restriction, // emergency/recovery info. func (s *Server) securityPageData() map[string]interface{} { data := s.settingsBaseData("settings-security", "Biztonság és hozzáférés") // Recovery info for emergency section data["RetrievalPassword"] = s.settings.GetRetrievalPassword() data["HubURL"] = s.cfg.Hub.URL data["SupportEmail"] = "support@felhom.eu" data["SupportURL"] = "https://felhom.eu/kapcsolat" // Geo-restriction data data["CFConfigured"] = s.cfg.Infrastructure.CFAPIToken != "" geo := s.settings.GetGeoRestriction() if geo != nil { data["GeoEnabled"] = geo.Enabled data["GeoAllowedCountries"] = geo.AllowedCountries data["GeoAppOverrides"] = geo.AppOverrides data["GeoLastSync"] = geo.LastSync data["GeoLastError"] = geo.LastSyncError } else { data["GeoEnabled"] = false data["GeoAllowedCountries"] = []string{"HU"} data["GeoAppOverrides"] = map[string]interface{}{} } // Deployed apps for per-app override selector var deployedApps []map[string]string for _, stack := range s.stackMgr.GetStacks() { if !stack.Deployed || s.cfg.IsProtectedStack(stack.Name) { continue } deployedApps = append(deployedApps, map[string]string{ "Name": stack.Name, "Display": stack.Meta.DisplayName, }) } data["DeployedApps"] = deployedApps return data } func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) { s.executeTemplate(w, r, "settings_system", s.systemPageData()) } // storagePageHandler serves the Tárhely main-nav page (D1). Storage action flashes land here. func (s *Server) storagePageHandler(w http.ResponseWriter, r *http.Request) { data := s.storagePageData() if msg := r.URL.Query().Get("storage_msg"); msg == "success" { data["StorageSuccess"] = r.URL.Query().Get("storage_detail") } s.executeTemplate(w, r, "storage", data) } // storageNetworkPageHandler serves the Tárhely → Hálózati tárhely (NAS) subpage. func (s *Server) storageNetworkPageHandler(w http.ResponseWriter, r *http.Request) { s.executeTemplate(w, r, "storage_network", s.networkStoragePageData()) } // settingsNotificationsPageHandler serves GET /settings/notifications (the POST on the same // path is the save handler — dispatch is split in the router). func (s *Server) settingsNotificationsPageHandler(w http.ResponseWriter, r *http.Request) { s.executeTemplate(w, r, "settings_notifications", s.notificationsPageData()) } // settingsSecurityPageHandler serves GET /settings/security. func (s *Server) settingsSecurityPageHandler(w http.ResponseWriter, r *http.Request) { s.executeTemplate(w, r, "settings_security", s.securityPageData()) } func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() currentPassword := r.FormValue("current_password") newPassword := r.FormValue("new_password") confirmPassword := r.FormValue("confirm_password") if s.isDebug() { s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: password change attempt from %s", r.RemoteAddr) } data := s.securityPageData() // Validate current password effectiveHash := s.effectivePasswordHash() if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil { if s.isDebug() { s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: current password mismatch from %s", r.RemoteAddr) } data["PasswordError"] = "Hibás jelenlegi jelszó" s.executeTemplate(w, r, "settings_security", data) return } // Validate new password length if len(newPassword) < 8 { data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie" s.executeTemplate(w, r, "settings_security", data) return } // Validate passwords match if newPassword != confirmPassword { data["PasswordError"] = "A két jelszó nem egyezik" s.executeTemplate(w, r, "settings_security", data) return } // Generate bcrypt hash hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10) if err != nil { s.logger.Printf("[ERROR] [web] Failed to hash new password: %v", err) data["PasswordError"] = "Belső hiba a jelszó mentésekor" s.executeTemplate(w, r, "settings_security", data) return } // Save to settings.json if err := s.settings.SetPasswordHash(string(hash)); err != nil { s.logger.Printf("[ERROR] [web] Failed to save password to settings.json: %v", err) data["PasswordError"] = "Belső hiba a jelszó mentésekor" s.executeTemplate(w, r, "settings_security", data) return } s.logger.Printf("[INFO] [web] Password changed via settings page from %s", r.RemoteAddr) // Invalidate all sessions (force re-login) s.invalidateAllSessions() // Redirect to login with flash message flash := url.QueryEscape("Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.") http.Redirect(w, r, "/login?flash="+flash, http.StatusFound) } func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() if s.isDebug() { s.logger.Printf("[DEBUG] [web] settingsNotificationsHandler: updating notification prefs from %s", r.RemoteAddr) } email := strings.TrimSpace(r.FormValue("notification_email")) cooldownStr := r.FormValue("cooldown_hours") cooldownHours := 6 if cooldownStr != "" { if n, err := fmt.Sscanf(cooldownStr, "%d", &cooldownHours); n != 1 || err != nil { cooldownHours = 6 } } if cooldownHours < 1 { cooldownHours = 1 } if cooldownHours > 168 { cooldownHours = 168 } // Collect enabled events from checkboxes var enabledEvents []string // Single-event checkboxes for _, evt := range []string{ "backup_failed", "db_dump_failed", "backup_integrity_failed", "crossdrive_failed", "offbox_enlarge_blocked", "storage_disconnected", "node_down", "health_critical", "storage_reconnected", "health_recovered", } { if r.FormValue("event_"+evt) == "on" { enabledEvents = append(enabledEvents, evt) } } // Compound toggles: one checkbox → two event types if r.FormValue("event_disk_alerts") == "on" { enabledEvents = append(enabledEvents, "disk_warning", "disk_critical") } if r.FormValue("event_expected_missed") == "on" { enabledEvents = append(enabledEvents, "expected_backup_missed", "expected_dbdump_missed") } // EMPTY-EMAIL WIPE GUARD (2026-07-15 demo incident): a blank email box saved while events are // still enabled would store an empty Email AND push it to the hub via SyncPreferences, wiping // the customer's provisioning-seeded alert address — enabled events with nowhere to send them. // The only way to reach this state is the bug, so refuse the save outright (BEFORE // SetNotificationPrefs and BEFORE any hub sync), leaving the stored email untouched, and ask for // an address. The intentional "turn everything off" case (empty email + ZERO events) falls // through below — clearing the email is legitimate there and the empty hub push is correct. if email == "" && len(enabledEvents) > 0 { s.logger.Printf("[WARN] [web] Refused notification save: empty email with %d enabled event(s) — would wipe hub-side alert delivery", len(enabledEvents)) data := s.notificationsPageData() // Repaint the customer's just-submitted intent (their ticked events + chosen cooldown, empty // email) so they only need to add an address, not re-tick everything. Overlay the stored // prefs — do NOT persist this; it is render-only. data["NotificationPrefs"] = &settings.NotificationPrefs{ Email: email, EnabledEvents: enabledEvents, CooldownHours: cooldownHours, } data["NotificationError"] = "Adj meg egy értesítési e-mail címet – bekapcsolt értesítésekhez szükséges egy cím, ahova küldhetjük őket." s.executeTemplate(w, r, "settings_notifications", data) return } prefs := &settings.NotificationPrefs{ Email: email, EnabledEvents: enabledEvents, CooldownHours: cooldownHours, } if err := s.settings.SetNotificationPrefs(prefs); err != nil { s.logger.Printf("[ERROR] [web] Failed to save notification prefs: %v", err) data := s.notificationsPageData() data["NotificationError"] = "Hiba a beállítások mentésekor" s.executeTemplate(w, r, "settings_notifications", data) return } s.logger.Printf("[INFO] [web] Notification preferences updated: email=%s, events=%v", email, enabledEvents) s.reportTriggerNow() // v0.139.0: hub reflects the saved prefs in seconds, not next cycle // Sync preferences to hub data := s.notificationsPageData() if s.notifier != nil && s.notifier.IsEnabled() { if err := s.notifier.SyncPreferences(email, enabledEvents, cooldownHours); err != nil { s.logger.Printf("[WARN] [web] Failed to sync preferences to hub: %v", err) data["NotificationSuccess"] = fmt.Sprintf("Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: %v", err) } else { data["NotificationSuccess"] = "Értesítési beállítások mentve." } } else { data["NotificationSuccess"] = "Értesítési beállítások mentve." } s.executeTemplate(w, r, "settings_notifications", data) } // settingsAppEmailHandler saves the global app-email toggle and starts/stops the on-box // SMTP shim to match (no controller restart needed). func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() enabled := r.FormValue("app_email_enabled") == "on" || r.FormValue("app_email_enabled") == "true" fromName := strings.TrimSpace(r.FormValue("app_email_from_name")) data := s.notificationsPageData() if err := s.settings.SetAppEmail(enabled, fromName); err != nil { s.logger.Printf("[ERROR] [web] Failed to save app-email toggle: %v", err) data["AppEmailError"] = "Hiba az alkalmazás-email beállítás mentésekor" s.executeTemplate(w, r, "settings_notifications", data) return } // v0.139.0: the toggle is committed (the shim reconcile below is runtime state, not the // setting) — report out-of-cycle so the hub sees it in seconds. s.reportTriggerNow() // Reconcile the shim's running state with the new toggle. if s.mailShim != nil { if err := s.mailShim.Apply(enabled); err != nil { s.logger.Printf("[ERROR] [web] app-email shim could not be %s: %v", map[bool]string{true: "started", false: "stopped"}[enabled], err) data = s.notificationsPageData() data["AppEmailError"] = "A beállítás elmentve, de az email-szolgáltatás indítása nem sikerült." s.executeTemplate(w, r, "settings_notifications", data) return } } s.logger.Printf("[INFO] [web] App-email globally %s (from_name=%q)", map[bool]string{true: "enabled", false: "disabled"}[enabled], fromName) data = s.notificationsPageData() if enabled { data["AppEmailSuccess"] = "Alkalmazás-email bekapcsolva. Kapcsold be az egyes alkalmazásoknál is, ahol email-küldést szeretnél." } else { data["AppEmailSuccess"] = "Alkalmazás-email kikapcsolva." } s.executeTemplate(w, r, "settings_notifications", data) } func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) { data := s.notificationsPageData() if s.notifier == nil { data["NotificationError"] = "Az értesítések nincsenek bekapcsolva" s.executeTemplate(w, r, "settings_notifications", data) return } err := s.notifier.SendTest() if err != nil { s.logger.Printf("[ERROR] [web] Test notification failed: %v", err) data["NotificationError"] = fmt.Sprintf("Teszt email küldése sikertelen: %v", err) s.executeTemplate(w, r, "settings_notifications", data) return } data["NotificationSuccess"] = "Teszt email elküldve." s.executeTemplate(w, r, "settings_notifications", data) } // --- Storage path management handlers --- func (s *Server) countAppsUsingPath(storagePath string) int { count := 0 for _, stack := range s.stackMgr.GetStacks() { if !stack.Deployed { continue } if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil { if appCfg.Env["HDD_PATH"] == storagePath { count++ } } } return count } func (s *Server) appsUsingPath(storagePath string) []string { return appsUsingPathIn(s.stackMgr.GetStacks(), s.stackMgr.LoadAppConfigByName, storagePath) } // missingStorageLabel reports whether a deployed app's HDD_PATH resolves to an UNAVAILABLE registry // path (decommissioned, disconnected, or no longer registered) and returns its human label. An app // with no HDD_PATH (SSD-resident) is never "missing". func (s *Server) missingStorageLabel(hddPath string) (string, bool) { if hddPath == "" { return "", false } for _, sp := range s.settings.GetStoragePaths() { if sp.Path == hddPath { // A NAS network path is NEVER "missing": its availability is the agent's per-share liveness // (surfaced as a recoverable warning by networkStorageWarnings), NOT the drive missing/stop // cascade. An `unreachable` NAS must not look like a removed drive. if sp.IsNetwork() { return "", false } if sp.Decommissioned || sp.Disconnected { return s.settings.GetStorageLabel(hddPath), true } return "", false // present + available } } return s.settings.GetStorageLabel(hddPath), true // not in registry → its drive is gone } // networkStorageWarnings returns two stack-name → share-label maps for deployed apps on NAS // network paths: // - warnings: the agent reports the share `unreachable` — RECOVERABLE ("hálózati tárhely nem // elérhető"), explicitly NOT the drive missing/stop-cascade; clears when the NAS returns. // - stubs: the path is a plain local STUB in the controller's namespace (RCA fix 2 — the // guest-reboot state where apps silently see an empty dir while the agent's host-side view is // healthy). Checked from THIS process (the consuming namespace), independent of the agent. // // Stub wins: a stack never appears in both. An idle autofs trigger is HEALTHY and is never // force-mounted from here (classification reads the fs magic only). Best-effort: an agent error // drops the unreachable leg but the stub leg still runs. func (s *Server) networkStorageWarnings(list []stacks.Stack) (warnings, stubs map[string]string) { warnings, stubs = map[string]string{}, map[string]string{} if s.settings == nil || s.stackMgr == nil { return warnings, stubs } netPaths := map[string]settings.StoragePath{} for _, sp := range s.settings.GetStoragePaths() { if sp.IsNetwork() { netPaths[sp.Path] = sp } } if len(netPaths) == 0 { return warnings, stubs } // Stub leg — the consuming namespace's own verdict (no agent, no force-mount). stubPaths := s.stubNetworkPaths(netPaths) // Unreachable leg — the agent's host-side liveness view (unchanged behavior). unreachable := map[string]string{} // path → label if agent, err := s.agentClient(); err == nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if mounts, err := agent.ListNetStorage(ctx); err == nil { for _, m := range mounts { if !m.Unreachable() { // only `unreachable` is degraded; `idle`/`ok` are benign continue } p := settings.NetworkMountRoot + "/" + m.Name if sp, ok := netPaths[p]; ok { lbl := sp.Label if lbl == "" { lbl = m.Name } unreachable[p] = lbl } } } else { s.logger.Printf("[WARN] [web] network storage health unavailable for warnings: %v", err) } } return networkStorageWarningsIn(list, s.stackMgr.LoadAppConfigByName, unreachable, stubPaths) } // stubNetworkPaths classifies each registered network path in the controller's namespace and // returns path→label for every STUB (RCA fix 2). Idle autofs is healthy and classification never // force-mounts (fs magic read only); unknown (timeout/statfs error) is NOT a stub — fail open. func (s *Server) stubNetworkPaths(netPaths map[string]settings.StoragePath) map[string]string { out := map[string]string{} for p, sp := range netPaths { if s.classifyFSPath(p) == system.FSClassStub { lbl := sp.Label if lbl == "" { lbl = strings.TrimPrefix(p, settings.NetworkMountRoot+"/") } out[p] = lbl } } return out } // networkStorageWarningsIn is the pure per-stack mapping core (the appsUsingPathIn pattern): given // the path→label verdict sets, assign each deployed stack its badge. Stub WINS over unreachable — // a stack never appears in both maps. func networkStorageWarningsIn(list []stacks.Stack, load func(string) *stacks.AppConfig, unreachable, stubPaths map[string]string) (warnings, stubs map[string]string) { warnings, stubs = map[string]string{}, map[string]string{} if len(unreachable) == 0 && len(stubPaths) == 0 { return warnings, stubs } for _, st := range list { if !st.Deployed { continue } if cfg := load(st.Name); cfg != nil { hdd := cfg.Env["HDD_PATH"] if lbl, bad := stubPaths[hdd]; bad { stubs[st.Name] = lbl // stub wins over unreachable continue } if lbl, bad := unreachable[hdd]; bad { warnings[st.Name] = lbl } } } return warnings, stubs } // missingStorageMap returns stack-name → storage label for every deployed app whose data drive is // currently unavailable (drives the "Hiányzó tárhely" dashboard/stacks/app-card indicator). func (s *Server) missingStorageMap(list []stacks.Stack) map[string]string { out := map[string]string{} for _, st := range list { if !st.Deployed { continue } if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil { if label, missing := s.missingStorageLabel(cfg.Env["HDD_PATH"]); missing { out[st.Name] = label } } } return out } // appsUsingPathIn is the pure core of appsUsingPath (testable without a live stacks.Manager): the // deployed apps whose data dir (app.yaml HDD_PATH) is exactly storagePath, by display name. This is // the "name the apps that break" list for the type-to-confirm wipe/eject UI. func appsUsingPathIn(allStacks []stacks.Stack, loadCfg func(string) *stacks.AppConfig, storagePath string) []string { var names []string for _, stack := range allStacks { if !stack.Deployed { continue } if appCfg := loadCfg(stack.Name); appCfg != nil { if appCfg.Env["HDD_PATH"] == storagePath { names = append(names, stack.Meta.DisplayName) } } } return names } func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail { var details []StorageAppDetail for _, stack := range s.stackMgr.GetStacks() { if !stack.Deployed { continue } appCfg := s.stackMgr.LoadAppConfigByName(stack.Name) if appCfg == nil { continue } hddPath := appCfg.Env["HDD_PATH"] if hddPath != storagePath { continue } detail := StorageAppDetail{ Name: stack.Meta.DisplayName, Stack: stack.Meta.Slug, } // Try to get data size from the storage subdirectory. F-S2: the app's real appdata dir name is // NOT always the stack name (paperless-ngx writes appdata/paperless) — sum the resolved dir(s). // Here hddPath == storagePath (the drive's in-guest mount is the namespace root, Model A). var total int64 var any bool for _, name := range s.stackMgr.ResolveAppDataDirNames(stack.Name) { d := backup.AppDataDir(storagePath, name) if fi, err := os.Stat(d); err == nil && fi.IsDir() { total += dirSizeBytesWalk(d) any = true } } if any { detail.SizeHuman = humanizeDirBytes(total) } details = append(details, detail) } return details } // dirSizeBytesWalk returns the total size in bytes of the regular files under path (0 if absent). func dirSizeBytesWalk(path string) int64 { var total int64 filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } total += info.Size() return nil }) return total } // humanizeDirBytes formats a byte count as B/KB/MB/GB (the storage-page display convention). func humanizeDirBytes(total int64) string { const ( KB = 1024 MB = KB * 1024 GB = MB * 1024 ) switch { case total >= GB: return fmt.Sprintf("%.1f GB", float64(total)/float64(GB)) case total >= MB: return fmt.Sprintf("%.1f MB", float64(total)/float64(MB)) case total >= KB: return fmt.Sprintf("%.1f KB", float64(total)/float64(KB)) default: return fmt.Sprintf("%d B", total) } } // dirSizeHuman returns a human-readable size for a single directory. func dirSizeHuman(path string) string { return humanizeDirBytes(dirSizeBytesWalk(path)) } func formatFreeSpace(gb float64) string { if gb >= 1000 { return fmt.Sprintf("%.1f TB", gb/1024) } return fmt.Sprintf("%.1f GB", gb) } func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() path := filepath.Clean(r.FormValue("storage_path")) label := strings.TrimSpace(r.FormValue("storage_label")) isDefault := r.FormValue("storage_default") == "true" if s.isDebug() { s.logger.Printf("[DEBUG] [web] settingsStorageAddHandler: path=%s label=%q default=%v from %s", path, label, isDefault, r.RemoteAddr) } if label == "" { label = settings.InferStorageLabel(path) } data := s.storagePageData() // 1. Exists and is directory fi, err := os.Stat(path) if err != nil || !fi.IsDir() { data["StorageError"] = "Az útvonal nem létezik vagy nem mappa." s.executeTemplate(w, r, "storage", data) return } // 2. Is mount point if !system.IsMountPoint(path) { data["StorageError"] = "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!" s.executeTemplate(w, r, "storage", data) return } // 3. Writable if !system.IsWritable(path) { data["StorageError"] = "Az útvonal nem írható." s.executeTemplate(w, r, "storage", data) return } // 4. No overlap with existing paths for _, existing := range s.settings.GetStoragePaths() { if system.PathsOverlap(path, existing.Path) { data["StorageError"] = fmt.Sprintf("Az útvonal átfedi a már regisztrált %s útvonalat.", existing.Path) s.executeTemplate(w, r, "storage", data) return } } // 5. Soft warning if not under /mnt/ if !strings.HasPrefix(path, "/mnt/") { s.logger.Printf("[WARN] [web] Storage path %s is not under /mnt/ — unusual but allowed", path) } sp := settings.StoragePath{ Path: path, Label: label, IsDefault: isDefault, Schedulable: true, AddedAt: time.Now().UTC().Format(time.RFC3339), } if err := s.settings.AddStoragePath(sp); err != nil { s.logger.Printf("[ERROR] [web] Failed to add storage path: %v", err) data["StorageError"] = "Hiba a mentés során." s.executeTemplate(w, r, "storage", data) return } s.logger.Printf("[INFO] [web] Storage path added: %s (%s)", path, label) go s.SyncFileBrowserMounts() http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló sikeresen hozzáadva: "+path), http.StatusFound) } func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() path := r.FormValue("storage_path") if s.isDebug() { s.logger.Printf("[DEBUG] [web] settingsStorageRemoveHandler: path=%s from %s", path, r.RemoteAddr) } data := s.storagePageData() // Check: apps using this path apps := s.appsUsingPath(path) if len(apps) > 0 { data["StorageError"] = fmt.Sprintf("Nem törölhető: az alábbi alkalmazások használják: %s", strings.Join(apps, ", ")) s.executeTemplate(w, r, "storage", data) return } // Check: cannot remove default for _, sp := range s.settings.GetStoragePaths() { if sp.Path == path && sp.IsDefault { data["StorageError"] = "Az alapértelmezett adattároló nem törölhető." s.executeTemplate(w, r, "storage", data) return } } // Check: last path if len(s.settings.GetStoragePaths()) <= 1 { data["StorageError"] = "Az utolsó adattároló nem törölhető." s.executeTemplate(w, r, "storage", data) return } if err := s.settings.RemoveStoragePath(path); err != nil { data["StorageError"] = "Hiba a törlés során." s.executeTemplate(w, r, "storage", data) return } s.logger.Printf("[INFO] [web] Storage path removed: %s", path) // Sync FileBrowser mounts after storage path removal go s.SyncFileBrowserMounts() http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló eltávolítva: "+path), http.StatusFound) } func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() path := r.FormValue("storage_path") if s.isDebug() { s.logger.Printf("[DEBUG] [web] settingsStorageDefaultHandler: path=%s from %s", path, r.RemoteAddr) } if err := s.settings.SetDefaultStoragePath(path); err != nil { s.logger.Printf("[ERROR] [web] Failed to set default storage path: %v", err) http.Redirect(w, r, "/storage", http.StatusFound) return } s.logger.Printf("[INFO] [web] Default storage path set to %s", path) http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Alapértelmezett adattároló beállítva: "+path), http.StatusFound) } func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() path := r.FormValue("storage_path") schedulable := r.FormValue("schedulable") == "true" if s.isDebug() { s.logger.Printf("[DEBUG] [web] settingsStorageSchedulableHandler: path=%s schedulable=%v from %s", path, schedulable, r.RemoteAddr) } if err := s.settings.SetSchedulable(path, schedulable); err != nil { s.logger.Printf("[ERROR] [web] Failed to update schedulable: %v", err) http.Redirect(w, r, "/storage", http.StatusFound) return } s.logger.Printf("[INFO] [web] Storage schedulable updated: %s → %v", path, schedulable) http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló állapot módosítva: "+path), http.StatusFound) } func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() path := r.FormValue("storage_path") label := strings.TrimSpace(r.FormValue("storage_label")) if s.isDebug() { s.logger.Printf("[DEBUG] [web] settingsStorageLabelHandler: path=%s label=%q from %s", path, label, r.RemoteAddr) } if label == "" || len(label) > 50 { data := s.storagePageData() data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter." s.executeTemplate(w, r, "storage", data) return } if err := s.settings.SetStorageLabel(path, label); err != nil { s.logger.Printf("[ERROR] [web] Failed to set storage label: %v", err) data := s.storagePageData() data["StorageError"] = "Hiba a megnevezés mentésekor." s.executeTemplate(w, r, "storage", data) return } s.logger.Printf("[INFO] [web] Storage label updated: %s → %q", path, label) http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Megnevezés módosítva: "+label), http.StatusFound) } // SyncFileBrowserMounts regenerates FileBrowser's docker-compose.yml and config.yaml // with volume mounts and sources for all registered storage paths, then recreates the container. func (s *Server) SyncFileBrowserMounts() { s.syncFileBrowserMounts(false) } // SyncFileBrowserMountsReset is like SyncFileBrowserMounts but resets the FileBrowser // database when sources change. Use only after restore — normal operations should use // SyncFileBrowserMounts to preserve user accounts, permissions, and share links. func (s *Server) SyncFileBrowserMountsReset() { s.syncFileBrowserMounts(true) } // skipFileBrowserPath reports whether a registered storage path should be skipped this FileBrowser // sync pass: an EXTERNAL drive path (under StableParentDir) that is not currently a live mountpoint is // detached, so its userdata skeleton must not be created (would land on the rootfs) and it must not be // mounted into FileBrowser until it returns. System/local paths (not under StableParentDir) are never // skipped. Pure + isMount-injected for testability. func skipFileBrowserPath(path string, isMount func(string) bool) bool { return strings.HasPrefix(path, StableParentDir+"/") && !isMount(path) } func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) { // Prevent concurrent syncs — multiple callers can race on the same files (H5 fix). s.fileBrowserMu.Lock() defer s.fileBrowserMu.Unlock() stackDir := "/opt/docker/stacks/filebrowser" composePath := stackDir + "/docker-compose.yml" // Check if FileBrowser stack exists if _, err := os.Stat(composePath); os.IsNotExist(err) { s.logger.Printf("[WARN] [web] FileBrowser stack not found at %s — skipping mount sync", composePath) return } // Get all active storage paths paths := s.settings.GetStoragePaths() // Use domain from controller config domain := s.cfg.Customer.Domain if domain == "" { s.logger.Printf("[WARN] [web] Cannot sync FileBrowser mounts — customer domain not configured") return } // Build volume mount lines + the config source set (R-67: the two must agree — a source with // no mount behind it renders a broken sidebar entry). storageMounts, configPaths := buildFileBrowserPaths(paths, fbPathDeps{ isMount: system.IsMountPoint, classify: s.classifyFSPath, ensureSkeleton: s.ensureUserdataSkeleton, logger: s.logger, }) // R-75: the canonical drop-zone is an EXTRA bind, outside the registered-storage-path loop above. // The system drive is deliberately not a registered StoragePath (it would become a customer-visible // drive, a deploy target and a wipe candidate), so it is mounted here explicitly. Ensure the root // first — a source whose path does not exist renders a broken sidebar entry. importSource := false if s.stackMgr != nil { if importRoot := s.stackMgr.GetImportRoot(); importRoot != "" { if err := s.stackMgr.EnsureImportRoot(); err != nil { s.logger.Printf("[WARN] [web] FileBrowser: could not ensure the import root %s: %v", importRoot, err) } storageMounts = append(storageMounts, fmt.Sprintf(" - %s:/srv/%s", importRoot, infra.FileBrowserImportMount)) importSource = true } } // Generate and write config.yaml (sources + sidebar entries per drive/share) configPath := stackDir + "/config.yaml" fbConfig := generateFileBrowserConfig(configPaths, importSource) // Capture the current on-disk content BEFORE any writes, so we can detect whether this sync // actually changes anything (F2). The integrations' ReapplyConfigForTarget edits config.yaml // after we write it, so the recreate decision is made AFTER the writes against the final files. oldConfig, _ := os.ReadFile(configPath) oldCompose, _ := os.ReadFile(composePath) // Detect if sources changed — if so, the database must be reset so // FileBrowser picks up the new source list (user prefs cache old sources). sourcesChanged := string(oldConfig) != fbConfig if err := os.WriteFile(configPath, []byte(fbConfig), 0644); err != nil { s.logger.Printf("[ERROR] [web] Failed to write FileBrowser config: %v", err) return } // Re-apply active integrations into config.yaml (before container restart) if im := s.integrationMgr.Load(); im != nil { im.ReapplyConfigForTarget("filebrowser") } // Generate and write compose (includes config.yaml mount) compose := generateFileBrowserCompose(domain, storageMounts) if err := os.WriteFile(composePath, []byte(compose), 0644); err != nil { s.logger.Printf("[ERROR] [web] Failed to write FileBrowser compose: %v", err) return } // Read back the FINAL content (post-integrations) to decide whether a recreate is warranted (F2): // a controller restart or a no-op storage sync must NOT bounce the customer's file UI when nothing // actually changed. The recreate only happens when config.yaml or the compose file truly differ. finalConfig, _ := os.ReadFile(configPath) finalCompose, _ := os.ReadFile(composePath) changed := fbNeedsRecreate(oldConfig, finalConfig, oldCompose, finalCompose) // If sources changed and caller requested a DB reset (restore flow), // nuke the data volume so FileBrowser re-reads config.yaml from scratch. // Normal operations skip this to preserve user accounts, permissions, and share links. if sourcesChanged && resetDBOnChange { s.logger.Printf("[INFO] [web] FileBrowser sources changed — resetting database (restore mode)") resetCtx, resetCancel := context.WithTimeout(context.Background(), 30*time.Second) defer resetCancel() stop := exec.CommandContext(resetCtx, "docker", "compose", "down", "-v") stop.Dir = stackDir if out, err := stop.CombinedOutput(); err != nil { s.logger.Printf("[WARN] [web] FileBrowser down -v: %s — %v", strings.TrimSpace(string(out)), err) } changed = true // a DB reset removed the container — it must be recreated } // Bring FileBrowser up. H16: 60s timeout to prevent hanging indefinitely. Only force-recreate when // something actually changed; otherwise a plain `up -d` just ensures it's running without a bounce. ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() args := []string{"compose", "up", "-d", "--remove-orphans"} if changed { args = []string{"compose", "up", "-d", "--force-recreate", "--remove-orphans"} } cmd := exec.CommandContext(ctx, "docker", args...) cmd.Dir = stackDir if out, err := cmd.CombinedOutput(); err != nil { s.logger.Printf("[ERROR] [web] Failed to bring up FileBrowser: %s — %v", string(out), err) } else if changed { s.logger.Printf("[INFO] [web] FileBrowser mounts synced (recreated) — %d storage path(s), config updated", len(paths)) } else { s.logger.Printf("[INFO] [web] FileBrowser sync — no config/compose change, ensured running without recreate (%d storage path(s))", len(paths)) } } // fbPathDeps are the injectable edges of buildFileBrowserPaths — everything that would otherwise // touch the real mount table, the real filesystem or a real statfs, so the A/B/C/D scenarios run // without a drive, a NAS or docker. type fbPathDeps struct { isMount func(string) bool // drive-absent gate probe (production: system.IsMountPoint) classify func(string) string // network stub gate (production: Server.classifyFSPath; nil → include, fail open) ensureSkeleton func(string) error // userdata skeleton (production: appbackup.EnsureUserdataSkeleton) — DRIVES ONLY logger *log.Logger } // buildFileBrowserPaths computes one FileBrowser sync pass's volume mount lines + the source-list // paths, with the per-kind gates applied. Two storage classes, two DIFFERENT gates: // // DRIVES (v0.66.0 semantics, unchanged byte-for-byte): scope to the `userdata/` subtree, pre-create // the skeleton, and apply the drive-absent gate — a detached external drive must not get a skeleton // written onto the rootfs. Drives always stay in the config source list (pre-R-67 behavior kept). // // NETWORK SHARES (R-67): the drive-absent gate does NOT apply — an idle automount trigger is // HEALTHY (first access mounts it; the old gate skipped an idle share forever). The gate here is // the STUB classifier instead, and it is a data-safety gate, not cosmetics: exposing a local stub // dir lets a customer upload into a directory the real mount will later SHADOW — their files // silently vanish from view. A stub share is excluded from BOTH the mounts and the source list // this pass (a source without a mount is a broken sidebar entry). autofs-trigger / real network fs // / unknown all include (unknown fails open — a wedged NAS must not hide the share forever). // The bind is the share ROOT with :rslave — load-bearing: host-side automount wake and // idle-unmount events must propagate into the RUNNING container (Phase-0 probe 2026-07-22 proved // an in-container access through an rslave bind wakes the idle trigger). Never a skeleton, never // any write toward the NAS — Felhom conventions must not be written onto a customer's own NAS. func buildFileBrowserPaths(paths []settings.StoragePath, d fbPathDeps) (storageMounts []string, configPaths []settings.StoragePath) { configPaths = make([]settings.StoragePath, 0, len(paths)) for _, sp := range paths { mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1"; ".../Felhom-Share" → "Felhom-Share" if sp.IsNetwork() { if d.classify != nil && d.classify(sp.Path) == system.FSClassStub { if d.logger != nil { d.logger.Printf("[WARN] [web] FileBrowser: %s namespace sees a local stub, not the NAS — excluded until propagation recovers", mountName) } continue } storageMounts = append(storageMounts, fmt.Sprintf(" - %s:/srv/%s:rslave", sp.Path, mountName)) configPaths = append(configPaths, sp) continue } // Drives are ALWAYS in the source list (pre-R-67 behavior: the config listed every // registered path; only the mount obeys the drive-absent gate). configPaths = append(configPaths, sp) // Drive-absent gate: an external drive path that isn't currently a live mountpoint is detached — // don't create its userdata skeleton (would write onto the rootfs) and don't mount it into // FileBrowser this pass. It returns on the next sync after reconnect. Matches planDriveGates' // external-only rule (system paths, not under StableParentDir, are never skipped). if skipFileBrowserPath(sp.Path, d.isMount) { if d.logger != nil { d.logger.Printf("[INFO] [web] FileBrowser: drive %s not mounted — skipping userdata skeleton", sp.Path) } continue } // SCOPE to the drive's `userdata/` subtree (v0.66.0): the customer browses ONLY userdata — // app internals (appdata/) and the recovery units + Tier 2 copies (backups/) are NOT mounted // into FileBrowser. userdata is owned group 1000 mode 2775 (setgid), and FileBrowser runs as // uid 1000 → it can create folders + upload files (the old appdata mount was guest-root 0755 // → permission-denied). Pre-create the full skeleton with the convention. if err := d.ensureSkeleton(sp.Path); err != nil { if d.logger != nil { d.logger.Printf("[WARN] [web] FileBrowser: could not ensure userdata skeleton on %s: %v", sp.Path, err) } } userdataSrc := appbackup.UserdataDir(sp.Path) storageMounts = append(storageMounts, fmt.Sprintf(" - %s:/srv/%s", userdataSrc, mountName)) } return storageMounts, configPaths } // fbNeedsRecreate reports whether the FileBrowser container must be force-recreated: true when either // the config.yaml or the compose file content changed between the pre-sync and post-sync state. On the // first-ever run the old files are empty → differs from the freshly generated content → true (creates // it). Pure, so syncFileBrowserMounts' recreate decision is unit-testable without shelling to docker. func fbNeedsRecreate(oldConfig, newConfig, oldCompose, newCompose []byte) bool { return !bytes.Equal(oldConfig, newConfig) || !bytes.Equal(oldCompose, newCompose) } // generateFileBrowserCompose returns a FileBrowser docker-compose.yml string with the given domain // and storage volume-mount lines. Delegates to internal/infra (the single source of truth — so the // pinned image and the base-infra bring-up path can never diverge). func generateFileBrowserCompose(domain string, storageMounts []string) string { return infra.RenderFileBrowserCompose(domain, storageMounts) } // generateFileBrowserConfig returns a FileBrowser Quantum config.yaml with a separate source per // registered storage path. Delegates to internal/infra (single source of truth). func generateFileBrowserConfig(paths []settings.StoragePath, importSource bool) string { return infra.RenderFileBrowserConfig(paths, importSource) }