a3499d1807
gates / gates (push) Successful in 9s
R-216: the offsite key recovery is a coupled feature and now says so. featureProbes +
featureMinAgent 0.125.0 + a Supports gate at the unlock entry point, FAILING CLOSED — an
agent that cannot answer is named as such instead of the customer's code being blamed.
Measured live: a 404 from agent 0.120.0 came back as "we did not accept your recovery
code, check that all ten words", in 0.134 s, against a perfect code.
R-218: delete the repo-password short-circuit in needsOffsiteCredential. The declaration
stops when the TIER WORKS, not when a key exists — installing a key is the recovery
screen's whole job, so succeeding at recovery was switching off the mechanism that would
have delivered the coordinates to use it.
R-219: the unlock finishes the job — place the key, bring the tier up, then list. Without
it the promised listing could never render on the shape the screen exists for.
R-217: an unreadable store no longer claims to have opened with unattributable content
(the OffsiteInventory{} zero value). Opened / empty / unreadable are three states.
R-222: a code that is right about a RETAINED earlier package is named, not blamed. States
what the hub knows and promises nothing — no read path exists.
R-215: GET /recovery is gated on the same predicate as the interception.
Five red-proofs, each demonstrated failing and restored.
815 lines
35 KiB
Go
815 lines
35 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
|
"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/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/system"
|
|
)
|
|
|
|
type Server struct {
|
|
cfg *config.Config
|
|
stackMgr *stacks.Manager
|
|
cpuCollector *system.CPUCollector
|
|
backupMgr *backup.Manager
|
|
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
|
|
|
|
// Guest launcher share (v0.165.0): its OWN per-IP brute-force limiter for the optional share
|
|
// password gate — deliberately separate from loginAttempts (the admin login), so a guest and the
|
|
// owner never share a counter. Lazily initialized (struct-literal test servers skip NewServer).
|
|
shareAttempts map[string]*loginAttempt
|
|
shareAttemptMu sync.Mutex
|
|
|
|
// Customer-claim arc (v0.122.0, F-4): the claim/reset code brute-force limiter. Per-source
|
|
// (IP) + a global counter; both must be clear. claimClock is the test clock seam (nil → time.Now).
|
|
claimMu sync.Mutex
|
|
claimAttempts map[string]*claimAttempt
|
|
claimGlobal claimAttempt
|
|
claimClock func() time.Time
|
|
|
|
// Guard for FileBrowser sync — prevents concurrent file writes (H5 fix)
|
|
fileBrowserMu sync.Mutex
|
|
|
|
// Shared agent local-API client (built once, reused). cfg.LocalAPI is static per process (a
|
|
// config-apply triggers a graceful self-restart), so the client is memoized via agentCliOnce —
|
|
// this kills the per-call http.Transport leak that exhausted the controller's ephemeral ports to
|
|
// the agent's :8443 after ~5 days of uptime (see agentClient()).
|
|
agentCli *agentapi.Client
|
|
agentCliErr error
|
|
agentCliOnce sync.Once
|
|
|
|
// Hub push status callback — set via SetHubPushStatus for monitoring page
|
|
hubPushStatusFn func() HubPushStatusData
|
|
|
|
// Out-of-cycle hub report trigger (v0.139.0, Direction 1) — set via SetReportTrigger to
|
|
// report.Trigger.Fire. Fired via reportTriggerNow() AFTER a successful hub-relevant local
|
|
// commit (escrow claim, notification/app-email save, offsite config/toggle, customer
|
|
// claim) so the hub reflects the new state in seconds instead of the next ~15-min cycle.
|
|
// nil (hub reporting off / tests) = strict no-op.
|
|
reportTriggerFn func()
|
|
|
|
// Fork-4 hygiene seam: wipes the agent-staged offsite repo password when EscrowState flips to
|
|
// escrowed (DELETE /escrow/stage-secret). nil → the default agentClient()-backed impl; tests inject.
|
|
wipeStagedEscrowFn func(ctx context.Context) error
|
|
|
|
// Controller-driven escrow ceremony (v0.127.0) seams. escrowAgentFn nil → the shared
|
|
// agentClient(); escrowStageFn nil → PushOffboxPasswordForEscrow over the client;
|
|
// escrowStaleFn is the Scenario-F stale-blob flag (report.EscrowAutoConfirmer.StaleBlob,
|
|
// wired via SetEscrowStale; nil → never stale).
|
|
escrowAgentFn func() (escrowAgent, error)
|
|
escrowStageFn func(ctx context.Context) error
|
|
escrowStaleFn func() bool
|
|
|
|
// escrowSealedAtFn (v0.200.0, R-193) reports WHEN the hub's sealed recovery package was created —
|
|
// the one non-secret fact the recovery screen may state before a code is entered. Wired via
|
|
// SetEscrowSealedAt from the report ACK; nil → the page says nothing about the date rather than
|
|
// guessing one.
|
|
escrowSealedAtFn func() string
|
|
// recoveryRecovererFn is the recovery screen's agent seam (nil → the shared agentClient(), the
|
|
// same channel the CLI uses). Tests inject a fake so the HANDLER itself can be driven.
|
|
recoveryRecovererFn func() (backup.OffsiteKeyRecoverer, error)
|
|
// recoverySupportFn overrides the R-216 agent-capability verdict for the unlock path (tests).
|
|
// nil → the real gate over netFeatures. See recoverySupport: Unknown means CANNOT ASK here.
|
|
recoverySupportFn func(context.Context) agentapi.SupportState
|
|
// recoveryTierUp brings the off-site tier up between placing a recovered key and reading the
|
|
// repository (R-219). Wired from main.go to the apply-bridge's Reconcile; nil → skipped, and the
|
|
// listing branch then reports what is pending rather than claiming a failure.
|
|
recoveryTierUp func(context.Context) error
|
|
|
|
// NAS add orchestration (verify-before-commit): the single-flight job slot + the two seams.
|
|
// netAgentFn nil → the shared agentClient(); netProbeFn nil → runNetProbe (the uid-1000 re-exec).
|
|
netAdd netAddState
|
|
storageInit storageInitState
|
|
// sambaEnsure is the Megosztás bring-up progress slot (v0.147.0, 4b) — same single-flight shape.
|
|
sambaEnsure sambaEnsureState
|
|
// sambaAddrFn is the connect-address seam (v0.151.0, S-2/S-5): the guest's LAN IPv4 for the
|
|
// „Csatlakozás a megosztáshoz" card, or "" when it cannot be read. nil → stackMgr.SambaLANAddress.
|
|
// Called PER RENDER and stored nowhere — the address is a DHCP lease (see sambaLANAddress).
|
|
sambaAddrFn func() string
|
|
// guestGatewayFn / guestNetFn are the R-66 guest-network seams: the Hálózat card's gateway row
|
|
// and the Debug dump's network section. nil → stackMgr.GuestGateway / stackMgr.GuestNetSnapshot.
|
|
// Same S-5 law as sambaAddrFn: live-computed per render/dump, stored nowhere.
|
|
guestGatewayFn func() string
|
|
guestNetFn func() stacks.GuestNetSnapshot
|
|
netAgentFn func() (netAgent, error)
|
|
// fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0).
|
|
fabUpload uploadState
|
|
netProbeFn func(ctx context.Context, dir string) probeOutcome
|
|
netListFn func(ctx context.Context) ([]agentapi.NetworkMountStatus, error)
|
|
// agentLogsFn is the Debug-page agent-tab seam (v0.116.0). nil → the shared
|
|
// agentClient().DebugLogs; tests inject (incl. the pre-0.83 typed-404 path).
|
|
agentLogsFn func(ctx context.Context) (agentapi.AgentLogsResponse, error)
|
|
// classifyFSPath classifies a network path's filesystem in THIS process's namespace (the stub
|
|
// badge, RCA fix 2). Set to system.ClassifyPathFSTimeout by NewServer; tests inject fake classes.
|
|
classifyFSPath func(path string) string
|
|
// netFeatures caches the agent-capability probe (agentapi features.go) for the coupled NAS add
|
|
// semantics — the add gate + the settings-page banner read it. Zero value ready.
|
|
netFeatures agentapi.SupportCache
|
|
|
|
// memFeatures caches the guest-memory-resize capability probe (agent v0.90.0, R-24) — the resize
|
|
// handler gate + the settings-page render read it. Zero value ready. memAgentFn is the test seam.
|
|
memFeatures agentapi.SupportCache
|
|
memAgentFn func() (memAgent, error)
|
|
|
|
// Asset syncer for Hub-managed assets (optional)
|
|
assetsSyncer *assets.Syncer
|
|
|
|
// App-to-app integration manager (optional).
|
|
// M25: atomic because the constructor launches the SyncFileBrowserMounts
|
|
// goroutine (which reads this) BEFORE SetIntegrationManager runs in main.go —
|
|
// so a plain field would be a data race (the init-only happens-before that
|
|
// covers the other Set* fields does NOT hold for this one).
|
|
integrationMgr atomic.Pointer[integrations.Manager]
|
|
|
|
// App export/import engine (optional)
|
|
appExporter *appexport.Exporter
|
|
|
|
// Whole-guest backup trigger (the quiesce loop; optional — nil on an unprovisioned guest or when
|
|
// quiesce is disabled). Drives the "Mentés most" button: the CONTROLLER owns quiescing, so the
|
|
// manual trigger goes through the loop (stop stacks → backup → resume), never a bare agent call.
|
|
backupTrigger BackupTrigger
|
|
|
|
// Disk-health card + 6h degradation check (v0.169.0). diskHealth holds the 60s /disks TTL cache +
|
|
// the in-memory verdict baseline. disksFn / diskNotifyFn are test seams (nil → the real agent
|
|
// client Disks() / the real notifier).
|
|
diskHealth diskHealthState
|
|
disksFn func(context.Context) (agentapi.DisksResponse, error)
|
|
diskNotifyFn func(label string, attrs []string, critical bool)
|
|
|
|
// tiersFn is the sibling test seam for the agent's backup-tier view (nil → the real client's
|
|
// BackupTiers()). Added with R-114 so the backup-target state — which is the source of a
|
|
// customer-facing banner — is testable through its REAL resolver rather than only through the
|
|
// pure copy helper. Without it the resolver's own branching had no test at all, which is how the
|
|
// configured-but-absent case reached production saying the wrong thing.
|
|
tiersFn func(context.Context) (agentapi.TiersResponse, error)
|
|
|
|
// App-email SMTP shim lifecycle (optional — nil when no hub is configured or the kill-switch is
|
|
// off). The global app-email settings toggle calls Apply() so the shim starts/stops at runtime.
|
|
mailShim MailShimController
|
|
|
|
// Debug mode support
|
|
logBuffer *LogBuffer
|
|
debugCallbacks *DebugCallbacks
|
|
startTime time.Time
|
|
}
|
|
|
|
// MailShimController is the lifecycle handle the settings toggle uses to start/stop the
|
|
// app-email SMTP shim at runtime. Satisfied by *mailrelay.Lifecycle (kept as an interface
|
|
// to avoid a web→mailrelay import cycle risk and to allow a fake in tests).
|
|
type MailShimController interface {
|
|
Apply(enabled bool) error
|
|
Running() bool
|
|
}
|
|
|
|
// SetMailShim wires the app-email shim lifecycle (optional).
|
|
func (s *Server) SetMailShim(c MailShimController) { s.mailShim = c }
|
|
|
|
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, 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,
|
|
scheduler: sched,
|
|
settings: sett,
|
|
alertManager: alertMgr,
|
|
notifier: notif,
|
|
updater: updater,
|
|
logger: logger,
|
|
version: version,
|
|
sessions: make(map[string]*session),
|
|
loginAttempts: make(map[string]*loginAttempt),
|
|
shareAttempts: make(map[string]*loginAttempt),
|
|
done: make(chan struct{}),
|
|
}
|
|
s.classifyFSPath = system.ClassifyPathFSTimeout
|
|
|
|
if cfg.Logging.Level == "debug" {
|
|
logger.Printf("[DEBUG] [web] NewServer: initializing web server v%s", version)
|
|
logger.Printf("[DEBUG] [web] NewServer: backup=%v scheduler=%v alertMgr=%v notifier=%v updater=%v",
|
|
backupMgr != nil, sched != nil, alertMgr != nil, notif != nil, updater != nil)
|
|
}
|
|
|
|
s.loadTemplates()
|
|
go s.cleanupSessions()
|
|
// .fab download staging (v0.124.0): sweep aged bundles left by a crash/abandoned download.
|
|
if cfg.Paths.DataDir != "" {
|
|
if n := sweepFabDownloads(s.fabDownloadDir(), time.Now(), fabDownloadTTL, logger); n > 0 {
|
|
logger.Printf("[INFO] [web] download-export startup sweep removed %d staged bundle(s)", n)
|
|
}
|
|
}
|
|
// Drive-absent gate reconcile (intermediary-mount model): the periodic absent/return detector that
|
|
// replaced the retired slice-8C watchdog. Stops+blocks apps whose drive vanished, auto-restarts them
|
|
// when it returns. No-op when the agent is unreachable.
|
|
go s.driveGateLoop()
|
|
|
|
// Log auth source on startup
|
|
if sett != nil && sett.GetPasswordHash() != "" {
|
|
logger.Printf("[INFO] [web] Auth: using password from settings.json")
|
|
} else if cfg.Web.PasswordHash != "" {
|
|
logger.Printf("[INFO] [web] Auth: using password from controller.yaml")
|
|
} else {
|
|
logger.Printf("[INFO] [web] 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))
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetReportTrigger wires the out-of-cycle hub report trigger (report.Trigger.Fire). The
|
|
// provided func MUST be non-blocking — it is called from request handlers (the trigger's
|
|
// worker does the waiting/pushing). Init-time only, like every Set* here.
|
|
func (s *Server) SetReportTrigger(fn func()) {
|
|
s.reportTriggerFn = fn
|
|
}
|
|
|
|
// reportTriggerNow fires the out-of-cycle report trigger if wired (mirrors
|
|
// api.Router.reportPushNow). Call AFTER a successful hub-relevant local commit — never
|
|
// before it, never on an error path. Nil-safe no-op when hub reporting is off.
|
|
func (s *Server) reportTriggerNow() {
|
|
if s.reportTriggerFn != nil {
|
|
s.reportTriggerFn()
|
|
}
|
|
}
|
|
|
|
// SetIntegrationManager sets the app-to-app integration manager.
|
|
func (s *Server) SetIntegrationManager(mgr *integrations.Manager) {
|
|
s.integrationMgr.Store(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
|
|
}
|
|
|
|
// BackupTrigger forces an app-consistent whole-guest backup NOW (the quiesce loop satisfies it).
|
|
// Returns quiesce.ErrBackupInProgress when a cycle is already running (single-flight).
|
|
type BackupTrigger interface {
|
|
TriggerNow() error
|
|
}
|
|
|
|
// SetBackupTrigger wires the whole-guest backup trigger (the quiesce loop) for the "Mentés most"
|
|
// button. Optional — left nil on an unprovisioned guest or when quiesce is disabled (the button then
|
|
// renders disabled with an explanatory note).
|
|
func (s *Server) SetBackupTrigger(t BackupTrigger) {
|
|
s.backupTrigger = t
|
|
}
|
|
|
|
// 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).
|
|
// v0.116.1: NO logging-level gate — the capture ring always exists now, and gating
|
|
// the viewer on logging.level=debug was exactly the motivating incident's blind
|
|
// spot (an info box 404'd the whole debug surface). Auth: RequireAuth at the mux.
|
|
func (s *Server) ServeDebugAPI(w http.ResponseWriter, r *http.Request) {
|
|
s.handleDebugAPI(w, r)
|
|
}
|
|
|
|
// ServeHTTP handles all non-API web requests.
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
path := r.URL.Path
|
|
|
|
if s.isDebug() {
|
|
// The guest launcher share token (v0.165.0) is a secret — redact it from the request log so
|
|
// debug logging never leaks a live capability URL (Scenario G). Method + IP stay intact.
|
|
logPath := path
|
|
if strings.HasPrefix(path, "/s/") {
|
|
logPath = "/s/<redacted>"
|
|
}
|
|
s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, logPath, r.RemoteAddr)
|
|
}
|
|
|
|
// R-193: the recovery screen takes over the LANDING pages (and only those) while the situation
|
|
// holds and the customer has not postponed. Placed before the switch so it cannot be defeated by
|
|
// a route added later, and scoped to two paths so it never traps the customer inside it — every
|
|
// other page, including the backups area the entry point lives in, stays reachable.
|
|
if (path == "/launcher" || path == "/dashboard") && r.Method == http.MethodGet && s.recoveryInterrupts() {
|
|
http.Redirect(w, r, "/recovery", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
switch {
|
|
// Customer-claim arc (v0.122.0, F-4): the code-entry page + its handlers. Reachable pre-auth
|
|
// (code-gated internally); CSRF via the pre-auth HMAC token (validated inside the handlers).
|
|
case path == "/claim" && r.Method == http.MethodGet:
|
|
s.handleClaimPage(w, r, "", r.URL.Query().Get("flash"))
|
|
case path == "/claim" && r.Method == http.MethodPost:
|
|
s.handleClaimSubmit(w, r)
|
|
case path == "/claim/request-new-code" && r.Method == http.MethodPost:
|
|
s.handleClaimRequestNewCode(w, r)
|
|
case path == "/":
|
|
// v0.170.0 (operator ruling reversing the v0.163.0 landing choice): the Indítópult is the
|
|
// canonical landing page. "/" 302s to /launcher (ONE canonical URL per page — the launcher body
|
|
// is never served AT "/"). Post-login lands on "/", so it flows here → the launcher.
|
|
http.Redirect(w, r, "/launcher", http.StatusFound)
|
|
// R-193 — the recovery screen. A FULL PAGE, not a banner: someone who has just lost a machine
|
|
// deserves a screen about that and nothing else. It takes over the landing pages while the
|
|
// situation holds AND the customer has not chosen "most nem"; afterwards it stays reachable here
|
|
// (and from the backups area) for as long as the situation lasts.
|
|
case path == "/recovery" && r.Method == http.MethodGet:
|
|
s.recoveryPageHandler(w, r)
|
|
case path == "/recovery/unlock" && r.Method == http.MethodPost:
|
|
s.recoveryUnlockHandler(w, r)
|
|
case path == "/recovery/postpone" && r.Method == http.MethodPost:
|
|
s.recoveryPostponeHandler(w, r)
|
|
case path == "/dashboard":
|
|
s.dashboardHandler(w, r)
|
|
case path == "/launcher":
|
|
s.launcherHandler(w, r)
|
|
// Guest launcher share (v0.165.0). /s/<token> is the pre-auth capability URL (RequireAuth lets
|
|
// the /s/ prefix through after the claim gate). A GET renders the guest launcher (or the password
|
|
// gate); a POST submits the optional share password. An unknown/disabled token falls through to a
|
|
// byte-identical 404 (share404), so nothing distinguishes a wrong token from an unknown route.
|
|
case strings.HasPrefix(path, "/s/") && r.Method == http.MethodGet:
|
|
s.shareGuestHandler(w, r)
|
|
case strings.HasPrefix(path, "/s/") && r.Method == http.MethodPost:
|
|
s.shareGuestPasswordHandler(w, r)
|
|
// Admin share management (session-authed via RequireAuth + session CSRF via CsrfProtect).
|
|
case path == "/launcher/share/qr.png" && r.Method == http.MethodGet:
|
|
s.launcherShareQRHandler(w, r)
|
|
case path == "/launcher/share/enable" && r.Method == http.MethodPost:
|
|
s.launcherShareEnableHandler(w, r)
|
|
case path == "/launcher/share/rotate" && r.Method == http.MethodPost:
|
|
s.launcherShareRotateHandler(w, r)
|
|
case path == "/launcher/share/disable" && r.Method == http.MethodPost:
|
|
s.launcherShareDisableHandler(w, r)
|
|
case path == "/launcher/share/password" && r.Method == http.MethodPost:
|
|
s.launcherSharePasswordHandler(w, r)
|
|
case path == "/stacks":
|
|
s.stacksHandler(w, r)
|
|
case path == "/backups":
|
|
s.backupsHandler(w, r)
|
|
case path == "/backups/window" && r.Method == http.MethodPost:
|
|
s.backupWindowSaveHandler(w, r)
|
|
// v0.124.0 IA split: the backups page's four sub-pages (old /backups deep links keep working —
|
|
// /backups itself is the Áttekintés page).
|
|
case path == "/backups/remote":
|
|
s.backupsRemoteHandler(w, r)
|
|
case path == "/backups/apps":
|
|
s.backupsAppsHandler(w, r)
|
|
case path == "/backups/restore":
|
|
s.backupsRestoreHandler(w, r)
|
|
// R-48: the per-app offsite restore wizard. A GET-only page — every action inside it posts to the
|
|
// pre-existing /backup/offbox/* endpoints, so this adds no mutation surface.
|
|
case path == "/backups/restore/app" && r.Method == http.MethodGet:
|
|
s.backupsRestoreWizardHandler(w, r)
|
|
case path == "/monitoring":
|
|
s.monitoringHandler(w, r)
|
|
case path == "/settings":
|
|
s.settingsHandler(w, r)
|
|
case path == "/storage" && r.Method == http.MethodGet:
|
|
s.storagePageHandler(w, r)
|
|
case path == "/storage/network" && r.Method == http.MethodGet:
|
|
s.storageNetworkPageHandler(w, r)
|
|
// „Megosztás" — LAN network sharing (R-7 slice 1)
|
|
case path == "/sharing" && r.Method == http.MethodGet:
|
|
s.sharingPageHandler(w, r)
|
|
case path == "/sharing/enable" && r.Method == http.MethodPost:
|
|
s.sharingEnableHandler(w, r)
|
|
case path == "/sharing/status" && r.Method == http.MethodGet:
|
|
s.sharingStatusHandler(w, r)
|
|
case path == "/sharing/password" && r.Method == http.MethodPost:
|
|
s.sharingPasswordHandler(w, r)
|
|
case path == "/sharing/shares" && r.Method == http.MethodPost:
|
|
s.sharingShareCreateHandler(w, r)
|
|
case path == "/sharing/shares/delete" && r.Method == http.MethodPost:
|
|
s.sharingShareDeleteHandler(w, r)
|
|
case path == "/sharing/shares/offsite" && r.Method == http.MethodPost:
|
|
s.sharingShareOffsiteHandler(w, r)
|
|
case path == "/settings/notifications" && r.Method == http.MethodGet:
|
|
s.settingsNotificationsPageHandler(w, r)
|
|
case path == "/settings/security" && r.Method == http.MethodGet:
|
|
s.settingsSecurityPageHandler(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/app-email" && r.Method == http.MethodPost:
|
|
s.settingsAppEmailHandler(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 path == "/storage/init" && r.Method == http.MethodGet:
|
|
s.storageWizardPageHandler(w, r, "storage_init")
|
|
case path == "/storage/attach" && r.Method == http.MethodGet:
|
|
s.storageWizardPageHandler(w, r, "storage_attach")
|
|
// D1: the wizard pages moved under /storage — permanent redirects keep old links working.
|
|
case path == "/settings/storage/init" && r.Method == http.MethodGet:
|
|
http.Redirect(w, r, "/storage/init", http.StatusMovedPermanently)
|
|
case path == "/settings/storage/attach" && r.Method == http.MethodGet:
|
|
http.Redirect(w, r, "/storage/attach", http.StatusMovedPermanently)
|
|
case path == "/backup/restore" && r.Method == http.MethodPost:
|
|
s.backupRestoreHandler(w, r)
|
|
// C2: in-place, additive-only file restore from the Tier-2 copy (class-C user files)
|
|
case path == "/backup/tier2/restore" && r.Method == http.MethodPost:
|
|
s.backupTier2RestoreHandler(w, r)
|
|
// Off-box (NAS) restic-SFTP backup (Part B)
|
|
case path == "/backup/offbox/config" && r.Method == http.MethodPost:
|
|
s.offboxConfigHandler(w, r)
|
|
case path == "/backup/offbox/toggle" && r.Method == http.MethodPost:
|
|
s.offboxToggleHandler(w, r)
|
|
case path == "/backup/offbox/run" && r.Method == http.MethodPost:
|
|
s.offboxRunHandler(w, r)
|
|
case path == "/backup/offbox/reset" && r.Method == http.MethodPost:
|
|
s.offboxResetHandler(w, r)
|
|
case path == "/backup/offbox/status" && r.Method == http.MethodGet:
|
|
s.offboxStatusHandler(w, r)
|
|
case path == "/backup/offbox/restore" && r.Method == http.MethodPost:
|
|
s.offboxRestoreHandler(w, r)
|
|
case path == "/backup/offbox/place" && r.Method == http.MethodPost:
|
|
s.offboxPlaceHandler(w, r)
|
|
case path == "/backup/offbox/reconstitute" && r.Method == http.MethodPost:
|
|
s.offboxReconstituteHandler(w, r)
|
|
// v0.147.0 (4a): remove ONE verification copy. The only delete path this slice adds — see
|
|
// DeleteOffsiteRestoreCopy for the prefix guard.
|
|
case path == "/backup/offbox/verify-copy/delete" && r.Method == http.MethodPost:
|
|
s.offboxVerifyCopyDeleteHandler(w, r)
|
|
// R-7b: „Megosztások" restore — a sibling of the per-app pair above.
|
|
case path == "/backup/shares/restore" && r.Method == http.MethodPost:
|
|
s.sharesRestoreHandler(w, r)
|
|
case path == "/backup/shares/place" && r.Method == http.MethodPost:
|
|
s.sharesPlaceHandler(w, r)
|
|
// Controller-driven escrow ceremony wizard (v0.127.0): the customer-facing R flow.
|
|
case path == "/backup/escrow" && r.Method == http.MethodGet:
|
|
s.escrowWizardPageHandler(w, r)
|
|
// fork-4: escrow atomicity — confirm the R-escrow ceremony; DR pre-place the recovered password.
|
|
case path == "/backup/offbox/confirm-escrow" && r.Method == http.MethodPost:
|
|
s.offboxConfirmEscrowHandler(w, r)
|
|
case path == "/backup/offbox/inject-password" && r.Method == http.MethodPost:
|
|
s.offboxInjectPasswordHandler(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, "/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 strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodGet:
|
|
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup")
|
|
s.tier2ConfigPageHandler(w, r, name)
|
|
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodPost:
|
|
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup")
|
|
s.tier2ConfigSaveHandler(w, r, name)
|
|
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/app-email") && r.Method == http.MethodPost:
|
|
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/app-email")
|
|
s.appEmailToggleHandler(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 strings.HasPrefix(path, "/static/fonts/"):
|
|
s.serveFontHandler(w, r, strings.TrimPrefix(path, "/static/fonts/"))
|
|
case path == "/static/felhom-logo.svg":
|
|
s.serveLogoHandler(w, r)
|
|
case path == "/static/favicon.svg":
|
|
s.serveFaviconHandler(w, r)
|
|
case path == "/static/infra-logo.svg":
|
|
w.Header().Set("Content-Type", "image/svg+xml")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
fmt.Fprint(w, InfraLogoSVG)
|
|
case path == "/static/app-placeholder.svg":
|
|
w.Header().Set("Content-Type", "image/svg+xml")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
fmt.Fprint(w, AppPlaceholderSVG)
|
|
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":
|
|
// v0.116.1: available at ANY logging.level (the ring always captures; see ServeDebugAPI).
|
|
s.debugPageHandler(w, r)
|
|
default:
|
|
s.logger.Printf("[WARN] [web] 404 Not Found: %s %s", r.Method, path)
|
|
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] [web] 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
|
|
}
|
|
|
|
// 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] [web] 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] [web] 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)
|
|
}
|
|
|
|
// serveFontHandler serves the vendored woff2 files embedded in the binary
|
|
// (self-hosted fonts — no Google Fonts CDN dependency on offline nodes).
|
|
func (s *Server) serveFontHandler(w http.ResponseWriter, r *http.Request, filename string) {
|
|
filename = filepath.Base(filename)
|
|
data, err := fontFS.ReadFile("static/fonts/" + filename)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "font/woff2")
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
w.Write(data)
|
|
}
|
|
|
|
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)
|
|
}
|