de39e47f53
FULL PAGE ONCE PER ENTRY, NOT ONCE EVER. "Most nem" used to set a flag that
nothing ever cleared, so a box that abandoned its history and was rebuilt
months later - a genuinely NEW situation - would never see the page again. The
offer now carries an EPOCH, advanced on the edge into the offered state, and a
dismissal is recorded against the epoch it was made in. A fresh entry passes
the dismissal by arithmetic, with nothing to clear and nothing that can be
forgotten to clear.
That is NOT the flag the operator's ruling forbids. The forbidden thing
remembers that the customer decided so the screen can be suppressed while the
state stays wrong. This records WHICH SITUATION a dismissal was about.
A REAL BUG, caught by the test and not by review: the first draft returned
early from recoveryInterrupts when the offer was false, so the FALLING edge
was never recorded, RecoveryOfferActive stayed true through a settled period,
and the next entry counted as a continuation. The page never came back - the
exact defect the epoch exists to fix, reintroduced inside the fix. The sync is
now unconditional and the ordering is commented as load-bearing.
THREE LEVERS, THREE SCOPES, and none of them removes the route:
- clicking the bar away -> a browser SESSION cookie, cleared on login, so
the reminder is genuinely back at the next login. Nothing persisted.
- "ne emlekeztessen ujra" -> durable, epoch-scoped, silences the BANNER ONLY.
It starts no countdown, abandons nothing, and a fresh entry reminds again.
- "most nem" -> suppresses the full page only, as before.
The entry point on /backups/remote is bound to the OFFER and to nothing else,
pinned by a test that fires all three dismissals and asserts it survives.
SEC 7.3 / Q7 - THE TRAP DOES NOT SURVIVE THIS SESSION. While a recovery is
outstanding the "Helyrealitasi kod letrehozasa" button is UNAVAILABLE, not
merely captioned: creating a new code seals the current key, demotes the
package that opens the earlier history to retained custody that no shipped
path can read (R-199), and re-enables the recovery screen through the orphan
route while invalidating the code that screen accepts. A warning beside a
button is a warning people click past. The card now explains and points at
/recovery instead.
SEC 2.4 - the abandon confirmation changes with the behaviour. It used to
promise "felretesszuk - nem toroljuk". It now states the grace in days (from
the constant the countdown actually uses, never a literal in prose), that the
sealed package goes with it, that the customer can change their mind, where
the date is visible, and that the question does not come back afterwards.
The countdown is shown on /backups/remote for the WHOLE window - the bar
elsewhere is a nudge, this is the record, and a deletion date must be findable
on a quiet day too.
Tests: once-per-entry across a full settle-and-re-enter cycle; the banner
dismissal proven to be a session cookie (MaxAge 0, no Expires) and to persist
nothing; the opt-out proven to silence the banner while leaving the offer, the
route and the countdown untouched, and to remind again on a fresh entry; the
entry point surviving all three dismissals; a settled box showing nothing; and
the back-redirect refusing "//evil.example".
An existing test (TestRecovery_E) was updated: it asserted the legacy boolean,
which the epoch replaces. It now asserts the dismissal landed on the current
epoch, which is the stronger property.
Green: go build, go vet, go test ./... all pass; controller gates OK.
828 lines
36 KiB
Go
828 lines
36 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
|
|
// recoveryRefusalTrustedFn overrides the R-224 gate deciding whether a 400 may be read as a
|
|
// genuine refusal (tests). nil → the agent-version path.
|
|
recoveryRefusalTrustedFn func(context.Context) bool
|
|
// recoveryNowFn is the unlock path's clock (tests inject; nil → time.Now). Observability and
|
|
// tests only — never a classifier.
|
|
recoveryNowFn func() time.Time
|
|
// 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)
|
|
// R-241 (v0.206.0): the per-visit banner dismissal and the durable reminder opt-out. They are
|
|
// SEPARATE ROUTES because they are separate decisions — one is "not now", the other is "stop
|
|
// asking about this situation", and neither removes the entry point on the backups page.
|
|
case path == "/recovery/banner/dismiss" && r.Method == http.MethodPost:
|
|
s.recoveryBannerDismissHandler(w, r)
|
|
case path == "/recovery/remind-optout" && r.Method == http.MethodPost:
|
|
s.recoveryRemindOptOutHandler(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)
|
|
}
|