Files
deploy-felhom-compose/controller/internal/web/server.go
T
admin 95c821deb2 feat: comprehensive debug logging across all controller modules
Add detailed [DEBUG] logging to every controller module when
logging.level is set to "debug". Each module with stateful debug
uses SetDebug(bool) wired from main.go. Covers stacks, backup,
cloudflare, integrations, system, monitor, settings, scheduler,
web handlers, storage, metrics, API, selfupdate, and assets.

Also includes the app export/import (.fab bundles) feature from
v0.32.0 and its debug page integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:14:43 +01:00

559 lines
18 KiB
Go

package web
import (
"bytes"
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/storage"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
type Server struct {
cfg *config.Config
stackMgr *stacks.Manager
cpuCollector *system.CPUCollector
backupMgr *backup.Manager
crossDriveRunner *backup.CrossDriveRunner
scheduler *scheduler.Scheduler
settings *settings.Settings
alertManager *AlertManager
notifier *notify.Notifier
updater *selfupdate.Updater
logger *log.Logger
version string
encKey []byte // AES-256 key for decrypting app.yaml values
tmpl *template.Template
sessions map[string]*session
sessionsMu sync.RWMutex
loginAttempts map[string]*loginAttempt
loginAttemptMu sync.Mutex
done chan struct{}
closeOnce sync.Once
// Disk operation state (format/migrate jobs)
diskJobMu sync.Mutex
diskJob *activeDiskJob
// Active raw mount for the attach wizard (empty when not in use)
activeRawMount string
// Guard for FileBrowser sync — prevents concurrent file writes (H5 fix)
fileBrowserMu sync.Mutex
// Drive migration
driveMigrator *storage.DriveMigrator
// DR restore mode state
restoreMu sync.RWMutex
restorePlan *backup.RestorePlan
// Storage watchdog (set after construction to break init ordering)
storageWatchdog *monitor.StorageWatchdog
// Hub push status callback — set via SetHubPushStatus for monitoring page
hubPushStatusFn func() HubPushStatusData
// Asset syncer for Hub-managed assets (optional)
assetsSyncer *assets.Syncer
// App-to-app integration manager (optional)
integrationMgr *integrations.Manager
// App export/import engine (optional)
appExporter *appexport.Exporter
// Debug mode support
logBuffer *LogBuffer
debugCallbacks *DebugCallbacks
startTime time.Time
}
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, updater *selfupdate.Updater, logger *log.Logger, version string) *Server {
s := &Server{
cfg: cfg,
stackMgr: stackMgr,
cpuCollector: cpuCollector,
backupMgr: backupMgr,
crossDriveRunner: crossDrive,
scheduler: sched,
settings: sett,
alertManager: alertMgr,
notifier: notif,
updater: updater,
logger: logger,
version: version,
sessions: make(map[string]*session),
loginAttempts: make(map[string]*loginAttempt),
done: make(chan struct{}),
}
if cfg.Logging.Level == "debug" {
logger.Printf("[DEBUG] [web] NewServer: initializing web server v%s", version)
logger.Printf("[DEBUG] [web] NewServer: backup=%v crossDrive=%v scheduler=%v alertMgr=%v notifier=%v updater=%v",
backupMgr != nil, crossDrive != nil, sched != nil, alertMgr != nil, notif != nil, updater != nil)
}
s.loadTemplates()
go s.cleanupSessions()
// Log auth source on startup
if sett != nil && sett.GetPasswordHash() != "" {
logger.Printf("[INFO] Auth: using password from settings.json")
} else if cfg.Web.PasswordHash != "" {
logger.Printf("[INFO] Auth: using password from controller.yaml")
} else {
logger.Printf("[INFO] Auth: no password configured — dashboard is open")
}
// Sync FileBrowser config on startup to ensure mounts and sources are current.
// After a restore, a flag file signals that the database should be reset
// (stale source prefs from initial install). Consume the flag and reset.
fbResetFlag := filepath.Join(cfg.Paths.DataDir, ".fb-reset")
if _, err := os.Stat(fbResetFlag); err == nil {
os.Remove(fbResetFlag)
go s.SyncFileBrowserMountsReset()
} else {
go s.SyncFileBrowserMounts()
}
return s
}
// SetEncryptionKey sets the AES-256 key used to decrypt app.yaml values for display.
// Must be called before ListenAndServe (all Set* methods are init-time only).
func (s *Server) SetEncryptionKey(key []byte) {
s.encKey = key
}
func (s *Server) loadTemplates() {
s.tmpl = template.Must(
template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"),
)
if s.isDebug() {
names := s.tmpl.Templates()
s.logger.Printf("[DEBUG] [web] loadTemplates: loaded %d templates", len(names))
}
}
// SetRestoreState puts the server into DR restore mode with the given plan.
func (s *Server) SetRestoreState(plan *backup.RestorePlan) {
s.restoreMu.Lock()
defer s.restoreMu.Unlock()
s.restorePlan = plan
}
// SetStorageWatchdog sets the storage watchdog for disconnect/reconnect operations.
func (s *Server) SetStorageWatchdog(w *monitor.StorageWatchdog) {
s.storageWatchdog = w
}
// SetDriveMigrator sets the drive migration engine for full drive migration.
func (s *Server) SetDriveMigrator(dm *storage.DriveMigrator) {
s.driveMigrator = dm
}
// HubPushStatusData holds hub push status for the monitoring page.
type HubPushStatusData struct {
LastAttempt time.Time
LastSuccess time.Time
LastError string
Consecutive int
}
// SetHubPushStatus sets the hub push status callback for the monitoring page.
func (s *Server) SetHubPushStatus(fn func() HubPushStatusData) {
s.hubPushStatusFn = fn
}
// SetAssetsSyncer sets the Hub asset syncer for resolving app assets.
func (s *Server) SetAssetsSyncer(as *assets.Syncer) {
s.assetsSyncer = as
}
// SetIntegrationManager sets the app-to-app integration manager.
func (s *Server) SetIntegrationManager(mgr *integrations.Manager) {
s.integrationMgr = mgr
}
// SetLogBuffer sets the in-memory log ring buffer for the debug log viewer.
func (s *Server) SetLogBuffer(lb *LogBuffer) {
s.logBuffer = lb
}
// SetDebugCallbacks sets the callbacks for debug endpoints that need main.go wiring.
func (s *Server) SetDebugCallbacks(dc *DebugCallbacks) {
s.debugCallbacks = dc
}
// SetAppExporter sets the app export/import engine.
func (s *Server) SetAppExporter(e *appexport.Exporter) {
s.appExporter = e
}
// SetStartTime records the controller start time for uptime calculation.
func (s *Server) SetStartTime(t time.Time) {
s.startTime = t
}
// isDebug returns true if the controller is running in debug mode.
func (s *Server) isDebug() bool {
return s.cfg.Logging.Level == "debug"
}
// ServeDebugAPI handles /api/debug/* routes (JSON API for debug operations).
// Called from the mux carve-out; debug mode check is done here.
func (s *Server) ServeDebugAPI(w http.ResponseWriter, r *http.Request) {
if !s.isDebug() {
http.NotFound(w, r)
return
}
s.handleDebugAPI(w, r)
}
// InRestoreMode returns true if the server is in DR restore mode.
func (s *Server) InRestoreMode() bool {
s.restoreMu.RLock()
defer s.restoreMu.RUnlock()
return s.restorePlan != nil
}
// ServeHTTP handles all non-API web requests.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, path, r.RemoteAddr)
}
// DR restore mode: intercept all routes except restore page, static, and restore API
if s.InRestoreMode() {
switch {
case path == "/restore":
s.restorePageHandler(w, r)
return
case path == "/api/restore/status":
s.apiRestoreStatus(w, r)
return
case path == "/api/restore/all" && r.Method == http.MethodPost:
s.apiRestoreAll(w, r)
return
case path == "/api/restore/skip" && r.Method == http.MethodPost:
s.apiRestoreSkip(w, r)
return
case strings.HasPrefix(path, "/static/"):
// Allow static assets through
default:
// Redirect everything else to the restore page
http.Redirect(w, r, "/restore", http.StatusFound)
return
}
}
switch {
case path == "/" || path == "/dashboard":
s.dashboardHandler(w, r)
case path == "/stacks":
s.stacksHandler(w, r)
case path == "/backups":
s.backupsHandler(w, r)
case path == "/monitoring":
s.monitoringHandler(w, r)
case path == "/settings":
s.settingsHandler(w, r)
case path == "/settings/password" && r.Method == http.MethodPost:
s.settingsPasswordHandler(w, r)
case path == "/settings/notifications" && r.Method == http.MethodPost:
s.settingsNotificationsHandler(w, r)
case path == "/settings/notifications/test" && r.Method == http.MethodPost:
s.settingsNotificationsTestHandler(w, r)
case path == "/settings/storage/add" && r.Method == http.MethodPost:
s.settingsStorageAddHandler(w, r)
case path == "/settings/storage/remove" && r.Method == http.MethodPost:
s.settingsStorageRemoveHandler(w, r)
case path == "/settings/storage/default" && r.Method == http.MethodPost:
s.settingsStorageDefaultHandler(w, r)
case path == "/settings/storage/schedulable" && r.Method == http.MethodPost:
s.settingsStorageSchedulableHandler(w, r)
case path == "/settings/storage/label" && r.Method == http.MethodPost:
s.settingsStorageLabelHandler(w, r)
case strings.HasPrefix(path, "/settings/cross-backup/") && r.Method == http.MethodPost:
name := strings.TrimPrefix(path, "/settings/cross-backup/")
s.settingsCrossBackupHandler(w, r, name)
case path == "/backup/restore" && r.Method == http.MethodPost:
s.backupRestoreHandler(w, r)
case path == "/settings/storage/init":
s.storageInitHandler(w, r)
case path == "/settings/storage/attach":
s.storageAttachHandler(w, r)
case path == "/settings/storage/migrate-drive":
s.migrateDrivePageHandler(w, r)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/export")
s.exportPageHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/migrate"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/migrate")
s.migratePageHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/logs")
s.logsHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/deploy"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/deploy")
s.deployHandler(w, r, name)
case path == "/import":
s.importPageHandler(w, r)
case path == "/static/style.css":
s.serveCSSHandler(w, r)
case path == "/static/chart.min.js":
s.serveChartJSHandler(w, r)
case path == "/static/felhom-logo.svg":
s.serveLogoHandler(w, r)
case path == "/static/favicon.svg":
s.serveFaviconHandler(w, r)
case strings.HasPrefix(path, "/static/assets/"):
s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
case strings.HasPrefix(path, "/apps/"):
slug := strings.TrimPrefix(path, "/apps/")
s.appDetailHandler(w, r, slug)
case path == "/debug":
if !s.isDebug() {
http.NotFound(w, r)
return
}
s.debugPageHandler(w, r)
default:
http.NotFound(w, r)
}
}
// CatchAllMiddleware intercepts requests to non-controller hosts and serves
// a branded error page (for stopped/undeployed app subdomains). Requests to
// the controller host (felhom.DOMAIN) pass through normally.
func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
controllerHost := "felhom." + s.cfg.Customer.Domain
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] CatchAllMiddleware: controller host=%s", controllerHost)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if idx := strings.LastIndex(host, ":"); idx != -1 {
host = host[:idx]
}
// Pass through: controller host, localhost (healthcheck/internal), or empty
if strings.EqualFold(host, controllerHost) || host == "" ||
host == "localhost" || host == "127.0.0.1" {
next.ServeHTTP(w, r)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] CatchAllMiddleware: non-controller host=%s, serving catch-all page", host)
}
s.serveCatchAll(w, r, host)
})
}
// serveCatchAll renders a branded page for requests reaching a stopped/undeployed
// app subdomain. Served without auth since the user has no session on this host.
func (s *Server) serveCatchAll(w http.ResponseWriter, r *http.Request, host string) {
domain := s.cfg.Customer.Domain
subdomain := ""
suffix := "." + domain
if strings.HasSuffix(host, suffix) {
subdomain = strings.TrimSuffix(host, suffix)
}
data := map[string]interface{}{
"Domain": domain,
"ControllerURL": "https://felhom." + domain,
"Host": host,
}
if subdomain != "" {
if stack, ok := s.findStackBySubdomain(subdomain); ok {
data["AppName"] = stack.Meta.DisplayName
data["AppSlug"] = stack.Meta.Slug
data["AppLogoURL"] = s.cfg.AppLogoURL(stack.Meta.Slug)
if stack.Deployed {
data["Status"] = "stopped"
data["StatusText"] = "Az alkalmazás jelenleg le van állítva"
} else {
data["Status"] = "not_deployed"
data["StatusText"] = "Az alkalmazás nincs telepítve"
}
} else {
data["Status"] = "unknown"
data["StatusText"] = "Ez az oldal nem található"
}
} else {
data["Status"] = "unknown"
data["StatusText"] = "Ez az oldal nem található"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
if err := s.tmpl.ExecuteTemplate(w, "catchall", data); err != nil {
s.logger.Printf("[ERROR] Catch-all template error: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
// findStackBySubdomain looks up the stack that owns the given subdomain.
func (s *Server) findStackBySubdomain(subdomain string) (*stacks.Stack, bool) {
for _, stack := range s.stackMgr.GetStacks() {
// Check deployed app.yaml SUBDOMAIN env first
if stack.Deployed {
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd == subdomain {
return &stack, true
}
}
}
// Fallback to metadata subdomain
if stack.Meta.Subdomain == subdomain {
return &stack, true
}
}
return nil, false
}
// ServeStorageAPI handles /api/storage/* routes (JSON API for disk operations).
func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] ServeStorageAPI: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
}
s.storageAPIHandler(w, r)
}
// primaryHDDPath returns the default storage path, or the legacy config value.
func (s *Server) primaryHDDPath() string {
if p := s.settings.GetDefaultStoragePath(); p != "" {
return p
}
return s.cfg.Paths.HDDPath
}
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
s.logger.Printf("[ERROR] Template error (%s): %v", name, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
}
// executeTemplate renders a template with CSRF data auto-injected into the data map.
// Use this instead of render() for all authenticated page handlers.
func (s *Server) executeTemplate(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) {
if data == nil {
data = make(map[string]interface{})
}
data["CSRFField"] = s.csrfField(r)
data["CSRFToken"] = s.csrfToken(r)
var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
s.logger.Printf("[ERROR] Template error (%s): %v", name, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
}
// --- Static file / asset serving ---
func (s *Server) serveCSSHandler(w http.ResponseWriter, r *http.Request) {
data, err := templateFS.ReadFile("templates/style.css")
if err != nil {
http.Error(w, "CSS not found", 500)
return
}
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(data)
}
func (s *Server) serveChartJSHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(chartJS)
}
func (s *Server) serveLogoHandler(w http.ResponseWriter, r *http.Request) {
// Try synced asset first (allows logo updates via Hub without rebuild)
if s.assetsSyncer != nil {
path := s.assetsSyncer.Resolve("felhom-logo.svg")
if _, err := os.Stat(path); err == nil {
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, path)
return
}
}
// Fallback to embedded logo
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=86400")
fmt.Fprint(w, FelhomLogoSVG)
}
func (s *Server) serveFaviconHandler(w http.ResponseWriter, r *http.Request) {
// Try synced asset first
if s.assetsSyncer != nil {
path := s.assetsSyncer.Resolve("felhom-favicon.svg")
if _, err := os.Stat(path); err == nil {
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, path)
return
}
}
// Fallback to embedded favicon
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=86400")
fmt.Fprint(w, FelhomFaviconSVG)
}
// serveAsset serves baked-in app assets (logos, screenshots) from /usr/share/felhom/assets/
const assetsDir = "/usr/share/felhom/assets"
func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request, filename string) {
filename = filepath.Base(filename)
var path string
if s.assetsSyncer != nil {
path = s.assetsSyncer.Resolve(filename)
} else {
path = filepath.Join(assetsDir, filename)
}
if _, err := os.Stat(path); os.IsNotExist(err) {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, path)
}