package web import ( "context" "encoding/json" "fmt" "html/template" "net/http" "regexp" "sort" "strconv" "strings" "time" cfClient "gitea.dooplex.hu/admin/felhom-hub/internal/cloudflare" "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" "gitea.dooplex.hu/admin/felhom-hub/internal/offsite" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`) // NOTE (R-94, 2026-08-02): there is deliberately NO host-install version constant here, and the // Setup tab renders no version number. The hub cannot know which version a box will run: the // Option-1 command downloads felhom-host-install.sh from the website at run time, and the website // git-syncs `main` every 30s (R-110). Any build-time literal here is a guess wearing a version // number's authority — the previous const said 1.19.0 while the served script was 1.22.0, and had // been wrong since 2026-07-14. The single version source is scripts/felhom-host-install.sh's // SCRIPT_VERSION; scripts/hostinstall_gates.py gate 1 now asserts this file's ABSENCE of any // host-install version literal. // validSemver matches a bare X.Y.Z controller version (the floor format). Empty is also accepted by // the floor handlers (clears the override). var validSemver = regexp.MustCompile(`^\d+\.\d+\.\d+$`) // normalizeFloorInput trims, strips a leading "v", and validates a floor version submitted from the // operator UI. Returns (value, true) on a valid bare semver or empty string; (_, false) otherwise. func normalizeFloorInput(raw string) (string, bool) { v := strings.TrimSpace(raw) v = strings.TrimPrefix(v, "v") if v == "" { return "", true } if !validSemver.MatchString(v) { return "", false } return v, true } // customerListEntry is a merged view of a customer from both configs and reports. type customerListEntry struct { CustomerID string CustomerName string Domain string HasConfig bool IsBlocked bool OverallStatus string // ok, warn, down, disabled, pending, "" if no reports HostCause string // v0.53.0 roll-up: "" or "host down|stale|pending: " ControllerVersion string TimeSinceReport time.Duration ConfigCreatedAt time.Time // Phase 2 managed-update floor FloorOverride string // per-customer override ("" = none) EffectiveFloor string // override else global ("" = no floor) BelowFloor bool // current < effective floor (would auto-update) } // handleConfigList shows all customers (merged from configs + reports). func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) { configs, err := s.store.ListCustomerConfigs() if err != nil { s.logger.Printf("[ERROR] Failed to list configs: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } customers, err := s.store.GetCustomers() if err != nil { s.logger.Printf("[ERROR] Failed to list customers: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } // Build merged map keyed by customer_id merged := make(map[string]*customerListEntry) for _, cfg := range configs { merged[cfg.CustomerID] = &customerListEntry{ CustomerID: cfg.CustomerID, CustomerName: cfg.CustomerName, Domain: cfg.Domain, HasConfig: true, IsBlocked: cfg.Status == "blocked", ConfigCreatedAt: cfg.CreatedAt, FloorOverride: cfg.MinControllerVersion, } } for _, c := range customers { // Controller-derived status + the v0.53.0 dead-host roll-up (rollup.go). status, hostCause := s.foldHostStatus(c.CustomerID, controllerStatus(&c), true) if entry, ok := merged[c.CustomerID]; ok { // Config exists — enrich with report data entry.OverallStatus = status entry.HostCause = hostCause entry.ControllerVersion = c.ControllerVersion entry.TimeSinceReport = c.TimeSinceReport if entry.CustomerName == "" { entry.CustomerName = c.CustomerName } } else { // Report-only customer (no config) merged[c.CustomerID] = &customerListEntry{ CustomerID: c.CustomerID, CustomerName: c.CustomerName, OverallStatus: status, HostCause: hostCause, ControllerVersion: c.ControllerVersion, TimeSinceReport: c.TimeSinceReport, } } } // Config-only customers (no reports yet): the roll-up still applies — a down/stale host // must not hide behind the muted no-reports dash; only never-reported hosts are excluded. for _, e := range merged { if e.OverallStatus == "" { e.OverallStatus, e.HostCause = s.foldHostStatus(e.CustomerID, "", false) } } // Phase 2 floor: resolve each customer's effective floor (override else global) + below-floor flag. globalFloor := s.store.GetGlobalMinControllerVersion() for _, e := range merged { e.EffectiveFloor = e.FloorOverride if e.EffectiveFloor == "" { e.EffectiveFloor = globalFloor } if e.EffectiveFloor != "" && e.ControllerVersion != "" { e.BelowFloor = compareVersions(e.EffectiveFloor, e.ControllerVersion) > 0 } } // Sort by customer_id entries := make([]customerListEntry, 0, len(merged)) for _, e := range merged { entries = append(entries, *e) } sort.Slice(entries, func(i, j int) bool { return entries[i].CustomerID < entries[j].CustomerID }) // GlobalFloor + the artifact manifest are global settings — they render + save on the // Configuration tab now (handleConfiguration), not here. globalFloor above is still used // for per-customer effective-floor resolution. data := struct { Customers []customerListEntry ActiveNav string Flash string }{ Customers: entries, ActiveNav: "configs", Flash: r.URL.Query().Get("flash"), } s.templates.ExecuteTemplate(w, "configs.html", data) } // handleCustomerUnified shows the unified customer detail page (config + reports). func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, customerID string) { cfg, _ := s.store.GetCustomerConfig(customerID) customer, _ := s.store.GetCustomer(customerID) // 404 if neither config nor reports exist if cfg == nil && customer == nil { http.NotFound(w, r) return } // Determine identity fields from best source name := "" domain := "" email := "" if cfg != nil { name = cfg.CustomerName domain = cfg.Domain email = cfg.Email } if name == "" && customer != nil { name = customer.CustomerName } // Parse report JSON var report map[string]interface{} if customer != nil { json.Unmarshal([]byte(customer.ReportJSON), &report) } // Parse config overrides var overrides map[string]interface{} if cfg != nil { json.Unmarshal([]byte(cfg.ConfigJSON), &overrides) } // Overall status: controller-derived + the v0.53.0 dead-host roll-up (rollup.go). The // blocked override stays LAST (administrative state wins the token); the host cause chip // renders regardless so the header says WHICH host is the problem. overallStatus := "pending" if customer != nil { overallStatus = controllerStatus(customer) } var hostCause string overallStatus, hostCause = s.foldHostStatus(customerID, overallStatus, customer != nil) if cfg != nil && cfg.Status == "blocked" { overallStatus = "blocked" } // Controller URL controllerURL := "" if customer != nil { controllerURL = customer.ControllerURL if controllerURL == "" { var rpt struct { ControllerURL string `json:"controller_url"` } json.Unmarshal([]byte(customer.ReportJSON), &rpt) controllerURL = rpt.ControllerURL } } // Version check var latestVersion string var updateAvailable bool if s.versionChecker != nil && customer != nil { latestVersion = s.versionChecker.LatestVersion() if latestVersion != "" && customer.ControllerVersion != "" { updateAvailable = latestVersion != customer.ControllerVersion && compareVersions(latestVersion, customer.ControllerVersion) > 0 } } // Phase 2 managed-update floor: per-customer override, the global default, the effective floor, and // whether the box is currently below it (i.e. would auto-update on its next report). floorOverride := "" if cfg != nil { floorOverride = cfg.MinControllerVersion } globalFloor := s.store.GetGlobalMinControllerVersion() effectiveFloor := s.store.EffectiveMinControllerVersion(customerID) belowFloor := false if effectiveFloor != "" && customer != nil && customer.ControllerVersion != "" { belowFloor = compareVersions(effectiveFloor, customer.ControllerVersion) > 0 } // History, notifications, events var history []store.CustomerSummary var notifPrefs *store.NotificationPrefs var recentNotifs []store.NotificationLogEntry var events []store.Event var eventCounts map[string]int var appTelemetry []store.CustomerAppSummary var logTails []store.AppLogTail var pendingTails []string if customer != nil { history, _ = s.store.GetCustomerHistory(customerID, 24*time.Hour) notifPrefs, _ = s.store.GetNotificationPrefs(customerID) recentNotifs, _ = s.store.GetRecentNotifications(customerID, 10) events, _ = s.store.GetRecentEvents(customerID, 50) eventCounts, _ = s.store.CountEventsBySeverity(customerID, time.Now().Add(-24*time.Hour)) appTelemetry, _ = s.store.GetCustomerAppSummary(customerID, time.Now().Add(-7*24*time.Hour)) logTails, _ = s.store.GetCustomerLogTails(customerID) pendingTails, _ = s.store.GetPendingLogTailRequests(customerID) } type pageData struct { CustomerID string CustomerName string Domain string Email string HasConfig bool Config *store.CustomerConfig Overrides map[string]interface{} IsBlocked bool // Deletable (v0.70.1) gates the Danger-zone card. It is the exact negation of the delete // preview's 404 predicate (customer_delete.go: cfg == nil && no hosts && residue empty) — // one truth, not a lookalike. Before v0.70.1 the card sat inside {{if .HasConfig}}, so the // entire v0.70.0 ghost-delete path was implemented but unreachable (dead UI). Deletable bool HasReports bool Customer *store.CustomerSummary Report map[string]interface{} OverallStatus string HostCause string // v0.53.0 roll-up: "" or "host down|stale|pending: " LatestVersion string UpdateAvailable bool ControllerURL string // Phase 2 managed-update floor controls FloorOverride string // per-customer override ("" = none) GlobalFloor string // global default (hub_settings → config/env) EffectiveFloor string // override else global ("" = no floor) BelowFloor bool // current < effective floor (would auto-update) NotifPrefs *store.NotificationPrefs RecentNotifications []store.NotificationLogEntry History []store.CustomerSummary Events []store.Event EventCounts map[string]int // severity → count (last 24h) AppTelemetry []store.CustomerAppSummary HasAppTelemetry bool // On-demand log tails (v0.43.0): received tails (last 2 per app) + apps with a // still-pending request (badge on the telemetry row). LogTails []store.AppLogTail HasLogTails bool PendingTails map[string]bool HasDRRecipe bool DRRecipeUpdatedAt string DRRecipeHasHost bool DRRecipeHasApps bool Flash string ActiveNav string CSRFField template.HTML CSRFToken string // Hosts (v0.47.0): the customer's enrolled hosts for the Host tab — a LIST by design // (1 today, N for a later HA cluster). Each entry is the hostDetailData view-model map // the shared host_detail_body sub-template renders. Hosts []map[string]interface{} // ConfigForm (v0.48.0 edit-a): the embedded config form's view model for the Edit tab — // the same configFormData the standalone chrome renders. Zero-valued (and never rendered) // when the customer has no config. ConfigForm configFormView // Claim (v0.50.0, customer-claim arc): the dashboard claim state for the Setup-tab card — // nil when no code has been issued yet (pre-arc / never-pulled customer). Claim *store.ClaimState // StaleSinceReset (v0.67.0, R-37): a RESET completed AFTER the newest report, so every // health number on this page describes a lifecycle that no longer exists. Without this the // page keeps showing pre-RESET warnings as if they were current. StaleSinceReset bool ResetAt string // OffsiteUnprovisioned (v0.67.0, R-36 interim): the customer's config says offsite is // ENABLED, but no descriptor was ever provisioned (type == ""). Provisioning is // Save-triggered (applyOffsite), and the re-enroll auto-re-issue deliberately skips an // unprovisioned target — so this state is stable and silent until someone presses Save. OffsiteUnprovisioned bool } pendingSet := make(map[string]bool, len(pendingTails)) for _, app := range pendingTails { pendingSet[app] = true } // Host tab (v0.47.0): per-host view models via the shared hostDetailData builder. var hostViews []map[string]interface{} if hosts, err := s.store.ListHostsByCustomer(customerID); err != nil { s.logger.Printf("[ERROR] ListHostsByCustomer %s: %v", customerID, err) } else { for i := range hosts { hostViews = append(hostViews, s.hostDetailData(&hosts[i], r)) } } // DR recipe presence — show the secret-free reconstruction recipe panel + download link when // either half has landed (host-report and/or controller report). var hasDR, drHost, drApps bool var drUpdated string if rec, err := s.store.GetDRRecipe(customerID); err == nil && rec != nil { hasDR = rec.HostHalfJSON != "" || rec.AppHalfJSON != "" drHost = rec.HostHalfJSON != "" drApps = rec.AppHalfJSON != "" drUpdated = rec.UpdatedAt } // R-37 (v0.67.0): is every health figure on this page pre-RESET? True only when a reset actually // COMPLETED and no report has arrived since. A reset still running, or one older than the newest // report, leaves the page exactly as it was. var staleSinceReset bool var resetAt string if cr, err := s.store.LatestCustomerReset(customerID); err != nil { s.logger.Printf("[WARN] LatestCustomerReset %s: %v", customerID, err) } else if cr != nil && cr.CompletedAt != nil { resetAt = cr.CompletedAt.Format("2006-01-02 15:04 MST") // Stale unless a report is STRICTLY newer than the reset. SQLite timestamps are // second-resolution, so a report and a reset can tie; a tie resolves to STALE because a // same-second report almost certainly arrived just before the reset destroyed the state it // describes. Erring the other way would hide the banner exactly when it matters most. staleSinceReset = customer == nil || !customer.ReceivedAt.After(*cr.CompletedAt) } // v0.70.1: the Danger-zone render gate. Hosts are already fetched above for the Host tab — // only the residue count is an extra read, and it runs ONLY on the ghost shape (config-less, // hostless), never on the hot normal path. A lookup error logs and leaves Deletable=false: // fail toward HIDING a destructive control, never toward showing one on unknown state. deletable := cfg != nil || len(hostViews) > 0 if !deletable { if residue, err := s.store.CustomerResidue(customerID); err != nil { s.logger.Printf("[ERROR] CustomerResidue %s: %v", customerID, err) } else { deletable = residue.Total() > 0 } } // R-36 interim (v0.67.0): enabled-but-unprovisioned is a real, stable state — the same predicate // the offsite re-issue handler already uses to refuse ("No provisioned offsite tier"). var offsiteView struct { Offsite struct { Enabled bool `json:"enabled"` Type string `json:"type"` } `json:"offsite"` } if cfg != nil { _ = json.Unmarshal([]byte(cfg.ConfigJSON), &offsiteView) } offsiteUnprovisioned := offsiteView.Offsite.Enabled && offsiteView.Offsite.Type == "" data := pageData{ CustomerID: customerID, CustomerName: name, Domain: domain, Email: email, HasConfig: cfg != nil, Config: cfg, Overrides: overrides, IsBlocked: cfg != nil && cfg.Status == "blocked", Deletable: deletable, HasReports: customer != nil, Customer: customer, Report: report, OverallStatus: overallStatus, HostCause: hostCause, StaleSinceReset: staleSinceReset, ResetAt: resetAt, OffsiteUnprovisioned: offsiteUnprovisioned, LatestVersion: latestVersion, UpdateAvailable: updateAvailable, ControllerURL: controllerURL, FloorOverride: floorOverride, GlobalFloor: globalFloor, EffectiveFloor: effectiveFloor, BelowFloor: belowFloor, NotifPrefs: notifPrefs, RecentNotifications: recentNotifs, History: history, Events: events, EventCounts: eventCounts, AppTelemetry: appTelemetry, HasAppTelemetry: len(appTelemetry) > 0, LogTails: logTails, HasLogTails: len(logTails) > 0, PendingTails: pendingSet, HasDRRecipe: hasDR, DRRecipeUpdatedAt: drUpdated, DRRecipeHasHost: drHost, DRRecipeHasApps: drApps, Flash: r.URL.Query().Get("flash"), ActiveNav: "configs", CSRFField: s.csrfField(r), CSRFToken: s.csrfToken(r), Hosts: hostViews, } // Edit tab (v0.48.0 edit-a): embed the config form. nil overrides → the builder parses the // STORED ConfigJSON (the read path; submitted-value preservation is the standalone error // re-render's job). if cfg != nil { data.ConfigForm = s.configFormData(r, false, cfg, nil, "") } // Claim state (v0.50.0) for the Setup-tab access card (nil-safe: no row → no card content). if cs, err := s.store.GetClaim(customerID); err == nil { data.Claim = cs } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.templates.ExecuteTemplate(w, "customer_unified.html", data); err != nil { s.logger.Printf("[ERROR] Template render: %v", err) } } // configFormView is the render model of the customer config form — consumed by the standalone // config_form.html chrome AND embedded in the customer page's Edit tab as .ConfigForm // (v0.48.0 edit-a; the hostDetailData/host_detail_body pattern). type configFormView struct { IsNew bool Config *store.CustomerConfig Overrides map[string]interface{} ActiveNav string Error string CSRFField template.HTML PBSDR pbsDRView Delivery *deliveryView } // deliveryView is the R-70 customer-card rendering of offsite.DeliveryStateFor — the hub's real // delivery knowledge replacing the static "delivered to the controller once" copy that let a // burned credential hide for 2 days (DIAG-f10-demo-hp-offsite-2026-07-23). All strings are // precomputed operator-tier English (this page's existing language). type deliveryView struct { State string // offsite.DeliveryState (template branch key + test anchor) Badge string // short badge text BadgeClass string // n-ok | n-warn | n-neutral Line string // the state sentence, with age ("mióta" — every state carries its timestamp) StaleLine string // non-empty ONLY for applied+stale-staged (the demo-felhom shape) } // agoHuman renders a duration as a coarse operator-friendly age. func agoHuman(d time.Duration) string { switch { case d < time.Minute: return "under a minute" case d < time.Hour: return fmt.Sprintf("%d min", int(d.Minutes())) case d < 48*time.Hour: return fmt.Sprintf("%.1f h", d.Hours()) default: return fmt.Sprintf("%d days", int(d.Hours()/24)) } } // deliveryViewFor derives the card view; nil for brand-new configs (nothing staged yet) or on a // detector error (the card then simply omits the state line — never a fabricated one). func (s *Server) deliveryViewFor(customerID string) *deliveryView { if customerID == "" { return nil } now := time.Now() status, err := offsite.DeliveryStateFor(s.store, customerID) if err != nil { s.logger.Printf("[WARN] delivery view %s: %v", customerID, err) return nil } v := &deliveryView{State: string(status.State)} age := agoHuman(now.Sub(status.Since)) switch status.State { case offsite.DeliveryApplied: v.Badge, v.BadgeClass = "applied", "n-ok" v.Line = "offsite active on the box (last report " + age + " ago)" if !status.StaleStagedSince.IsZero() { v.StaleLine = fmt.Sprintf("Note: an unconsumed one-time secret has been staged since %s (%s ago) — superseded by the working install (key-auth-first never consumes); harmless, replaced by the next re-issue.", status.StaleStagedSince.UTC().Format("2006-01-02 15:04 UTC"), agoHuman(now.Sub(status.StaleStagedSince))) } case offsite.DeliveryConsumedAwaitingApply: // Amber past 30 min: consume→apply is a seconds-scale hop; half an hour of it is the // burned-credential shape taking form (the monitor turns it into an event at 1 h). v.Badge = "consumed" if now.Sub(status.Since) > 30*time.Minute { v.BadgeClass = "n-warn" v.Line = fmt.Sprintf("password consumed %s ago and the box still reports no offsite target — likely burned mid-apply; Re-issue delivers a fresh one", age) } else { v.BadgeClass = "n-neutral" v.Line = "password consumed — apply in progress (" + age + ")" } case offsite.DeliveryStagedAwaitingConsume: v.Badge, v.BadgeClass = "staged", "n-neutral" v.Line = "one-time password staged " + age + " ago — the box consumes it on its next config refresh" if now.Sub(status.Since) > 30*time.Minute { v.BadgeClass = "n-warn" v.Line = "one-time password staged " + age + " ago and NOT yet consumed — the box has not re-pulled its config (is it reporting?)" } default: // DeliveryNoSecret v.Badge, v.BadgeClass = "missing", "n-warn" v.Line = "offsite is enabled but no credential is staged — needs attention (Re-issue stages a fresh one)" } return v } // configFormData assembles the config form's view model. overrides carries SUBMITTED form values // for the validation-error re-render (typed values must survive — B3); pass nil to fall back to // the STORED cfg.ConfigJSON (the normal read path). func (s *Server) configFormData(r *http.Request, isNew bool, cfg *store.CustomerConfig, overrides map[string]interface{}, errMsg string) configFormView { if overrides == nil { json.Unmarshal([]byte(cfg.ConfigJSON), &overrides) } if overrides == nil { overrides = make(map[string]interface{}) } return configFormView{ IsNew: isNew, Config: cfg, Overrides: overrides, ActiveNav: "configs", Error: errMsg, CSRFField: s.csrfField(r), PBSDR: s.pbsDRViewFor(cfg.CustomerID, cfg.DRTier), Delivery: s.deliveryViewFor(cfg.CustomerID), } } // handleConfigNewForm shows the form to create a new customer config. DRTier starts ON — // DR-tier-by-default is the new-customer default (operator decision 2026-07-12 #2); opting out // is the per-customer exception. func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) { s.renderConfigForm(w, r, true, &store.CustomerConfig{DRTier: true}, nil, "") } // handleConfigCreate processes the form submission to create a new config. func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } customerID := strings.TrimSpace(r.FormValue("customer_id")) if customerID == "" || !validCustomerID.MatchString(customerID) { s.renderConfigForm(w, r, true, &store.CustomerConfig{ CustomerName: r.FormValue("customer_name"), Domain: r.FormValue("domain"), Email: r.FormValue("email"), }, nil, "Invalid Customer ID. Use only letters, numbers, dots, and hyphens.") return } // Check for duplicates existing, _ := s.store.GetCustomerConfig(customerID) if existing != nil { s.renderConfigForm(w, r, true, &store.CustomerConfig{ CustomerID: customerID, CustomerName: r.FormValue("customer_name"), Domain: r.FormValue("domain"), Email: r.FormValue("email"), }, nil, fmt.Sprintf("Customer ID %q already exists.", customerID)) return } // Generate credentials retrievalPassword, err := configgen.RandomPassphrase(5) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } apiKey, err := configgen.RandomHex(32) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } // Build config_json from optional form fields configJSON := buildConfigJSON(r) cfg := &store.CustomerConfig{ CustomerID: customerID, CustomerName: strings.TrimSpace(r.FormValue("customer_name")), Domain: strings.TrimSpace(r.FormValue("domain")), Email: strings.TrimSpace(r.FormValue("email")), RetrievalPassword: retrievalPassword, APIKey: apiKey, ConfigJSON: configJSON, // v0.51.0: the DR-tier flag (the form checkbox defaults ON for new customers). Set // BEFORE applyOffsite — offsite provisioning is refused without the DR tier (F-6). DRTier: formBool(r, "dr_tier"), } // Offsite provisioning (fail-closed): a provisioning error must NOT save a half-enabled config. if err := s.applyOffsite(r.Context(), r, cfg); err != nil { s.logger.Printf("[ERROR] offsite provision for %s: %v", customerID, err) http.Error(w, "Offsite provisioning failed: "+err.Error(), http.StatusBadGateway) return } // PBS DR tier (fail-closed, same discipline; the descriptor lives in the HOST desired-state). if err := s.applyPBSDR(r.Context(), r, cfg); err != nil { s.logger.Printf("[ERROR] pbsdr provision for %s: %v", customerID, err) http.Error(w, "PBS DR provisioning failed: "+err.Error(), http.StatusBadGateway) return } if err := s.store.SaveCustomerConfig(cfg); err != nil { s.logger.Printf("[ERROR] Failed to save config for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Customer config created: %s", customerID) // v0.67.0 (R-36 sub-item): mint the self-bind link NOW, not when the operator remembers. The // box's console banner tells the customer to open „az e-mailben kapott link" — that email should // already exist by the time anyone reads the banner. Never fails the create (see autoMint…). s.autoMintSelfBindLink(customerID, cfg.Email, "customer creation") s.bumpIntent(customerID) // Direction-2: wake a long-polling box in seconds http.Redirect(w, r, "/customers/"+customerID+"?flash=created", http.StatusSeeOther) } // handleConfigEditForm — the standalone edit page merged into the customer page's Edit tab // (v0.48.0 edit-a); old links and bookmarks land on the tab. POST /configs/{id}/edit stays the // real mutation endpoint (the embedded form posts to it). func (s *Server) handleConfigEditForm(w http.ResponseWriter, r *http.Request, customerID string) { cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } http.Redirect(w, r, "/customers/"+customerID+"#tab=edit", http.StatusFound) } // handleConfigUpdate processes the edit form submission. func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, customerID string) { if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } cfg.CustomerName = strings.TrimSpace(r.FormValue("customer_name")) cfg.Domain = strings.TrimSpace(r.FormValue("domain")) cfg.Email = strings.TrimSpace(r.FormValue("email")) // v0.51.0: the DR-tier flag — set BEFORE applyOffsite (offsite requires the tier) and // applyPBSDR (which converges the descriptor toward it). cfg.DRTier = formBool(r, "dr_tier") // Server-side twin of the form's required attributes (v0.48.0 — B3). The error re-render is // the STANDALONE page and carries the SUBMITTED overrides, so nothing the operator typed is // lost; runs BEFORE provisioning so an invalid submit never touches Hetzner/ep0. if cfg.CustomerName == "" || cfg.Domain == "" { var submitted map[string]interface{} _ = json.Unmarshal([]byte(buildConfigJSON(r)), &submitted) s.renderConfigForm(w, r, false, cfg, submitted, "Display Name and Domain are required.") return } cfg.ConfigJSON = buildConfigJSON(r) if err := s.applyOffsite(r.Context(), r, cfg); err != nil { s.logger.Printf("[ERROR] offsite provision for %s: %v", customerID, err) http.Error(w, "Offsite provisioning failed: "+err.Error(), http.StatusBadGateway) return } // PBS DR tier (fail-closed; idempotent on an already-provisioned descriptor — no re-key, // no second secret, no spurious generation bump). if err := s.applyPBSDR(r.Context(), r, cfg); err != nil { s.logger.Printf("[ERROR] pbsdr provision for %s: %v", customerID, err) http.Error(w, "PBS DR provisioning failed: "+err.Error(), http.StatusBadGateway) return } if err := s.store.SaveCustomerConfig(cfg); err != nil { s.logger.Printf("[ERROR] Failed to update config for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Customer config updated: %s", customerID) s.bumpIntent(customerID) // Direction-2: wake a long-polling box in seconds http.Redirect(w, r, "/customers/"+customerID+"?flash=updated#tab=edit", http.StatusSeeOther) } // handleClaimResend (v0.50.0, customer-claim arc) rotates the claim/reset code and re-sends it to // the REGISTERED customer address — the operator "Kód újraküldése" / "Visszaállító kód küldése" // button. The old code stops verifying immediately (single active code); the fresh hash reaches // the box on its next report ACK (no config bump needed). No plaintext is ever rendered or logged. func (s *Server) handleClaimResend(w http.ResponseWriter, r *http.Request, customerID string) { if s.claimEngine == nil { http.Error(w, "Claim engine is not configured on this hub", http.StatusBadGateway) return } cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } if err := s.claimEngine.Resend(cfg); err != nil { s.logger.Printf("[ERROR] claim resend for %s: %v", customerID, err) http.Redirect(w, r, "/customers/"+customerID+"?flash=claim-resend-failed#tab=setup", http.StatusSeeOther) return } s.logger.Printf("[INFO] claim code re-sent for %s (operator resend; generation rotated)", customerID) s.bumpIntent(customerID) // Direction-2: the fresh claim hash rides the next report ACK http.Redirect(w, r, "/customers/"+customerID+"?flash=claim-resent#tab=setup", http.StatusSeeOther) } // handleOffsiteReissue (F4) resets the customer's offsite credential and stores a fresh one-time password — // the explicit operator recovery for a consumed-password dead-end (fresh-guest DR, consumed-but-failed // install). Scoped to the resource labelled for THIS customer (the provisioner refuses unless exactly one). // The config is re-saved unchanged so ConfigVersion bumps → the stuck guest's next refresh re-runs the // bridge, which consumes the fresh password. The password value is never logged or rendered. func (s *Server) handleOffsiteReissue(w http.ResponseWriter, r *http.Request, customerID string) { if s.offsite == nil { http.Error(w, "Offsite provisioning is not configured on this hub", http.StatusBadGateway) return } cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } var overrides struct { Offsite struct { Enabled bool `json:"enabled"` Type string `json:"type"` } `json:"offsite"` } _ = json.Unmarshal([]byte(cfg.ConfigJSON), &overrides) if !overrides.Offsite.Enabled || overrides.Offsite.Type == "" { http.Error(w, "No provisioned offsite tier for this customer", http.StatusBadRequest) return } // Same detached-ctx discipline as applyOffsite (F1): once the reset starts, reset→store must complete. ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 3*time.Minute) defer cancel() if err := s.offsite.ReissueCredentials(ctx, customerID, overrides.Offsite.Type); err != nil { s.logger.Printf("[ERROR] offsite reissue for %s: %v", customerID, err) http.Error(w, "Offsite credential re-issue failed: "+err.Error(), http.StatusBadGateway) return } // Re-save unchanged → ConfigVersion bump → the customer's controller re-pulls + re-runs the bridge. if err := s.store.SaveCustomerConfig(cfg); err != nil { s.logger.Printf("[ERROR] offsite reissue for %s: config bump failed: %v", customerID, err) http.Error(w, "Credential re-issued but the config bump failed — save the config once to trigger the pickup", http.StatusInternalServerError) return } s.logger.Printf("[INFO] offsite credentials re-issued for %s (fresh one-time password stored; ConfigVersion bumped)", customerID) s.bumpIntent(customerID) // Direction-2: wake the stuck box to re-pull + re-run the bridge http.Redirect(w, r, "/customers/"+customerID+"?flash=offsite_reissued#tab=edit", http.StatusSeeOther) } // ReissueOffsiteForCustomer is the programmatic form of handleOffsiteReissue — the seam the API // host-enroll path calls on a clean-slate re-enrollment (F3, v0.57.0): the offsite one-time password // only ever reached the OLD controller, so the fresh box has no target. It re-issues (and, via // ReissueCredentials, invalidates the now-stale escrow + emits events), then bumps ConfigVersion so // the controller re-pulls and the bridge consumes the fresh password. Silent NO-OP (nil) when the // customer has no provisioned/enabled offsite tier — that is the common non-DR case, not an error. func (s *Server) ReissueOffsiteForCustomer(ctx context.Context, customerID string) error { if s.offsite == nil { return nil // offsite not configured on this hub } cfg, err := s.store.GetCustomerConfig(customerID) if err != nil { return fmt.Errorf("offsite re-issue: customer lookup: %w", err) } if cfg == nil { return nil } var overrides struct { Offsite struct { Enabled bool `json:"enabled"` Type string `json:"type"` } `json:"offsite"` } _ = json.Unmarshal([]byte(cfg.ConfigJSON), &overrides) if !overrides.Offsite.Enabled || overrides.Offsite.Type == "" { return nil // no provisioned offsite tier — nothing to re-issue } rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Minute) defer cancel() if err := s.offsite.ReissueCredentials(rctx, customerID, overrides.Offsite.Type); err != nil { return fmt.Errorf("offsite re-issue: %w", err) } if err := s.store.SaveCustomerConfig(cfg); err != nil { return fmt.Errorf("offsite re-issue: config bump: %w", err) } s.logger.Printf("[INFO] offsite credentials re-issued for %s on re-enroll (fresh one-time password; ConfigVersion bumped)", customerID) s.bumpIntent(customerID) // Direction-2: the fresh box's first wait wakes on this return nil } // handleOffsiteFreeze (SLICE 4) freezes/unfreezes the customer's shared sub-account (readonly) — an // OPERATOR lever, never automatic (freezing also blocks prune, the customer's only way down from // over-quota). Shared model only; the exactly-1 label guard lives in the provisioner. Action logged, // no secrets involved. func (s *Server) handleOffsiteFreeze(w http.ResponseWriter, r *http.Request, customerID string, frozen bool) { if s.offsite == nil { http.Error(w, "Offsite provisioning is not configured on this hub", http.StatusBadGateway) return } cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } var overrides struct { Offsite struct { Enabled bool `json:"enabled"` Type string `json:"type"` } `json:"offsite"` } _ = json.Unmarshal([]byte(cfg.ConfigJSON), &overrides) if !overrides.Offsite.Enabled || overrides.Offsite.Type != "shared" { http.Error(w, "Freeze applies to a provisioned SHARED offsite tier only", http.StatusBadRequest) return } ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 2*time.Minute) defer cancel() if err := s.offsite.SetOffsiteFrozen(ctx, customerID, frozen); err != nil { s.logger.Printf("[ERROR] offsite freeze(%v) for %s: %v", frozen, customerID, err) http.Error(w, "Offsite freeze/unfreeze failed: "+err.Error(), http.StatusBadGateway) return } s.logger.Printf("[INFO] offsite frozen=%v (readonly) for %s (operator action)", frozen, customerID) s.bumpIntent(customerID) // Direction-2: reflect the freeze state change to the box promptly flash := "offsite_frozen" if !frozen { flash = "offsite_unfrozen" } http.Redirect(w, r, "/customers/"+customerID+"?flash="+flash+"#tab=edit", http.StatusSeeOther) } // The shallow customer DELETE that used to live here (a bare DeleteCustomerConfig behind a native // confirm) was REPLACED in v0.69.0 by the guided full-teardown cascade — see web/customer_delete.go // (R-25b). The route is unchanged (POST /configs/{id}/delete); what changed is that it now tears the // hosts, the offsite repo, the PBS namespace and the tunnel/zone down before purging the record, // behind three acknowledgements and a typed customer-id. Do NOT reintroduce a shallow delete path. // handleConfigPreview returns the generated YAML for a customer config. func (s *Server) handleConfigPreview(w http.ResponseWriter, r *http.Request, customerID string) { cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } templateYAML := defaultControllerTemplate if s.templateFetcher != nil { templateYAML = s.templateFetcher.Template() } // Claim arc (v0.50.0): the preview BAKES an existing claim hash (so it matches what a box // would pull) but never ISSUES one — issuing + emailing belongs to the real config retrieve. claimState, _ := s.store.GetClaim(customerID) yamlOutput, err := configgen.Generate(templateYAML, cfg, claimState) if err != nil { s.logger.Printf("[ERROR] Failed to generate preview for %s: %v", customerID, err) http.Error(w, "Generation error: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/yaml; charset=utf-8") w.Write([]byte(yamlOutput)) } // handleConfigRegenPassword regenerates the retrieval password. func (s *Server) handleConfigRegenPassword(w http.ResponseWriter, r *http.Request, customerID string) { newPassword, err := configgen.RandomPassphrase(5) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } if err := s.store.UpdateRetrievalPassword(customerID, newPassword); err != nil { s.logger.Printf("[ERROR] Failed to regen password for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Retrieval password regenerated for %s", customerID) s.bumpIntent(customerID) // Direction-2: nudge the box promptly after a credential change http.Redirect(w, r, "/customers/"+customerID+"?flash=password_regenerated#tab=setup", http.StatusSeeOther) } // handleBlockCustomer sets a customer's status to "blocked". func (s *Server) handleBlockCustomer(w http.ResponseWriter, r *http.Request, customerID string) { cfg, _ := s.store.GetCustomerConfig(customerID) if cfg == nil { http.NotFound(w, r) return } if err := s.store.SetCustomerConfigStatus(customerID, "blocked"); err != nil { s.logger.Printf("[ERROR] Failed to block %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Customer blocked: %s", customerID) s.bumpIntent(customerID) // Direction-2: deliver the blocked flag to the box in seconds http.Redirect(w, r, "/customers/"+customerID+"?flash=blocked#tab=edit", http.StatusSeeOther) } // handleUnblockCustomer sets a customer's status back to "active". func (s *Server) handleUnblockCustomer(w http.ResponseWriter, r *http.Request, customerID string) { cfg, _ := s.store.GetCustomerConfig(customerID) if cfg == nil { http.NotFound(w, r) return } if err := s.store.SetCustomerConfigStatus(customerID, "active"); err != nil { s.logger.Printf("[ERROR] Failed to unblock %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Customer unblocked: %s", customerID) s.bumpIntent(customerID) // Direction-2: clear the blocked flag on the box promptly http.Redirect(w, r, "/customers/"+customerID+"?flash=unblocked#tab=edit", http.StatusSeeOther) } // countBoxesBelowFloor counts reporting boxes whose EFFECTIVE floor (per-customer override else the // proposed global) would exceed their reported controller version — i.e. how many boxes a proposed // global-floor save would immediately push into an update. Boxes with a per-customer override are // governed by that override, not the proposed global, so they are excluded from the global-save // blast radius (the confirm dialog is about the GLOBAL knob). Reused by the confirm-count endpoint. func (s *Server) countBoxesBelowFloor(proposedGlobal string) int { customers, err := s.store.GetCustomers() if err != nil { return 0 } configs, _ := s.store.ListCustomerConfigs() override := make(map[string]string, len(configs)) for _, c := range configs { if c.MinControllerVersion != "" { override[c.CustomerID] = c.MinControllerVersion } } n := 0 for _, c := range customers { floor := proposedGlobal if ov, ok := override[c.CustomerID]; ok { floor = ov // an overridden box is not moved by the global knob } if floor != "" && c.ControllerVersion != "" && compareVersions(floor, c.ControllerVersion) > 0 { n++ } } return n } // handleGlobalFloorImpact answers the confirm dialog's "how many boxes are below ?" probe // (GET /configuration/global-floor/impact?v=X.Y.Z). Read-only JSON; blank v = 0. func (s *Server) handleGlobalFloorImpact(w http.ResponseWriter, r *http.Request) { v, ok := normalizeFloorInput(r.URL.Query().Get("v")) count := 0 if ok && v != "" { count = s.countBoxesBelowFloor(v) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"version": v, "valid": ok, "below": count}) } // handleSetGlobalFloor sets (or clears) the global controller-version floor (Phase 2 managed // updates). Empty clears the hub_settings override, falling back to the config/env default. func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } v, ok := normalizeFloorInput(r.FormValue("min_controller_version")) if !ok { http.Redirect(w, r, "/configuration?flash=floor_invalid", http.StatusSeeOther) return } if err := s.store.SetGlobalMinControllerVersion(v); err != nil { s.logger.Printf("[ERROR] Failed to set global floor: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Global controller-version floor set to %q", v) // Direction-2: the global floor affects every config-managed customer — wake each long-polling // box so the new floor lands in seconds (nil-safe; a customer with no held wait just advances). if configs, cerr := s.store.ListCustomerConfigs(); cerr == nil { for _, c := range configs { s.bumpIntent(c.CustomerID) } } http.Redirect(w, r, "/configuration?flash=floor_set", http.StatusSeeOther) } // validSHA256 matches a lowercase 64-hex sha256 digest. Empty is also accepted by the artifact // handler (clears that artifact's checksum). var validSHA256 = regexp.MustCompile(`^[0-9a-f]{64}$`) // normalizeSHA256 trims/lowercases and validates a sha256 submitted from the operator UI. // Returns (value, true) on a valid 64-hex digest or empty string; (_, false) otherwise. func normalizeSHA256(raw string) (string, bool) { v := strings.ToLower(strings.TrimSpace(raw)) if v == "" { return "", true } if !validSHA256.MatchString(v) { return "", false } return v, true } // handleSetArtifacts records the operator-vouched current artifact set (agent binary + golden // archive) into hub_settings — the checksum TRUST ROOT the host-bootstrap script verifies fetched // artifacts against. The operator picks a VERSION (from the Gitea-populated dropdown); the hub DERIVES // that version's sha256 from Gitea itself (never trusting a client-supplied checksum), so there is no // hand-copied sha to get wrong. When no Gitea client is configured (no registry creds) it falls back // to the submitted sha256 (legacy manual path). Empty version clears that artifact. // sha256HexRe validates an operator-typed sha256 (R-50b(a) wrapper hash): exactly 64 lowercase hex // characters. The agent/golden hashes are resolved from the package registry instead, so this is the // only manifest field a human types by hand — and a truncated paste must be refused, not stored as a // hash that can never match. var sha256HexRe = regexp.MustCompile(`^[0-9a-f]{64}$`) func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } agentVer, okAV := normalizeFloorInput(r.FormValue("agent_version")) goldenVer, okGV := normalizeFloorInput(r.FormValue("golden_version")) minAgent, okMA := normalizeFloorInput(r.FormValue("min_agent")) // Part D: empty = uncoupled release if !okAV || !okGV || !okMA { http.Redirect(w, r, "/configuration?flash=artifact_ver_invalid", http.StatusSeeOther) return } agentSHA, okAS := s.resolveArtifactSHA(r.Context(), pkgAgent, fileAgent, agentVer, r.FormValue("agent_sha256")) goldenSHA, okGS := s.resolveArtifactSHA(r.Context(), pkgGolden, fileGolden, goldenVer, r.FormValue("golden_sha256")) if !okAS || !okGS { http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther) return } // R-50b(a): the PBS-DR wrapper hash is operator-typed, not resolved from the package registry — // unlike the agent binary and the golden, this artifact is not published there at all. It is // installed from raw/branch/main, which is exactly the drift this field makes visible. wrapperSHA := strings.ToLower(strings.TrimSpace(r.FormValue("wrapper_sha256"))) if wrapperSHA != "" && !sha256HexRe.MatchString(wrapperSHA) { http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther) return } // ── R-120 GATE — refuses a golden older than the controller the fleet is already running ───────── // // WHY THIS IS A GATE AND NOT A SCRIPT. The golden's version IS the controller it bakes // (felhom-agent configs/build-golden.sh: GOLDEN_VERSION defaults to ${CONTROLLER_IMAGE##*:}), so a // golden behind the newest deployed controller means every FRESH install lands on stale // application code. That has happened three times — R-111 (the golden's agent 17 releases behind), // R-115 (an agent built and deployed but never published), R-120 (this: the golden a controller // release behind, shipping a customer-facing FALSEHOOD, since 0.186.0 is what made the // absent-backup-target message true). The first two were closed by re-baking and remembering, and // remembering then failed again — which is why this is enforcement, not a reminder. // // It lives HERE, immediately before the only write, because handleSetArtifacts is the sole UI path // to SetArtifactManifest: it therefore runs without anyone choosing to run it. R-29 is the standing // proof that the alternative does not work — `hostinstall_gates.py` sat RED and invoked by nothing // across three version bumps while every report said green, and `hub_confirm_gate.py` has never run // at all. A check in scripts/ asserting this same fact would have been a fourth orphan. // // It REFUSES rather than warns (operator ruling, 2026-07-30): a non-blocking check reads as coverage // it is not providing, which is R-29's whole finding. // // FAIL-OPEN, deliberately, in exactly two cases: an empty golden field (clearing the manifest is a // legitimate operator act) and an unknown fleet version (no guest has reported one — a brand-new hub // must be able to vouch its first golden). Neither is the drift this catches. if goldenVer != "" { if newest := s.store.NewestReportedControllerVersion(); newest != "" && compareVersions(goldenVer, newest) < 0 { s.logger.Printf("[WARN] artifact vouch REFUSED: golden %s is older than the newest controller the fleet reports (%s) — "+ "a fresh install would land on stale application code (R-120)", goldenVer, newest) http.Redirect(w, r, "/configuration?flash=golden_behind_fleet", http.StatusSeeOther) return } } if err := s.store.SetArtifactManifest(store.ArtifactManifest{ AgentVersion: agentVer, AgentSHA256: agentSHA, GoldenVersion: goldenVer, GoldenSHA256: goldenSHA, MinAgent: minAgent, WrapperSHA256: wrapperSHA, }); err != nil { s.logger.Printf("[ERROR] Failed to set artifact manifest: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s min_agent=%q wrapper_sha=%t", agentVer, goldenVer, minAgent, wrapperSHA != "") // Agent-plane immediate-sync (Direction-2a, v0.59.0): a MinAgent-floor / vouched-agent change is // a fleet-wide agent-plane intent shift. Fire-and-forget nudge every box so it re-reports at // once (the self-update train's signed op / floor re-evaluation lands in seconds, not ≤15 min). // The manifest write itself does not bump per-host desired generation; the poke only accelerates // the next report where the floor is applied. Never blocks the save. s.poke.PokeAllHosts() http.Redirect(w, r, "/configuration?flash=artifacts_set", http.StatusSeeOther) } // resolveArtifactSHA determines the sha256 to store for a chosen artifact version. An empty version // clears the artifact (returns "",true). With a Gitea client it fetches the sha AUTHORITATIVELY from // Gitea (the submitted value is ignored — nothing hand-typed to trust); a fetch failure returns // (_,false) so the caller refuses the save rather than storing a version with a wrong/blank checksum. // Without a Gitea client it validates + uses the submitted sha (legacy manual path). func (s *Server) resolveArtifactSHA(ctx context.Context, pkg, file, version, submittedSHA string) (string, bool) { if version == "" { return "", true } if s.gitea != nil { sha, err := s.gitea.FileSHA256(ctx, pkg, version, file) if err != nil { s.logger.Printf("[WARN] artifact sha resolve (%s/%s): %v", pkg, version, err) return "", false } return sha, true } return normalizeSHA256(submittedSHA) } // handleSetCustomerFloor sets (or clears) a customer's per-customer controller-version floor // override. Empty clears the override (the customer then uses the global floor). func (s *Server) handleSetCustomerFloor(w http.ResponseWriter, r *http.Request, customerID string) { cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } v, ok := normalizeFloorInput(r.FormValue("min_controller_version")) if !ok { http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_invalid", http.StatusSeeOther) return } if err := s.store.SetMinControllerVersion(customerID, v); err != nil { s.logger.Printf("[ERROR] Failed to set floor for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Customer %s controller-version floor override set to %q", customerID, v) s.bumpIntent(customerID) // Direction-2: deliver the new floor to the box in seconds http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_set", http.StatusSeeOther) } // handleCreateConfigFromReport auto-creates a config entry from report data. func (s *Server) handleCreateConfigFromReport(w http.ResponseWriter, r *http.Request, customerID string) { // Check if config already exists existing, _ := s.store.GetCustomerConfig(customerID) if existing != nil { http.Redirect(w, r, "/configs/"+customerID+"/edit", http.StatusSeeOther) return } // Get report data to pre-fill customer, _ := s.store.GetCustomer(customerID) name := customerID if customer != nil && customer.CustomerName != "" { name = customer.CustomerName } // Generate credentials retrievalPassword, _ := configgen.RandomPassphrase(5) apiKey, _ := configgen.RandomHex(32) cfg := &store.CustomerConfig{ CustomerID: customerID, CustomerName: name, RetrievalPassword: retrievalPassword, APIKey: apiKey, ConfigJSON: "{}", } if err := s.store.SaveCustomerConfig(cfg); err != nil { s.logger.Printf("[ERROR] Failed to create config from report for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Config auto-created from report for %s", customerID) http.Redirect(w, r, "/configs/"+customerID+"/edit", http.StatusSeeOther) } // renderConfigForm renders the STANDALONE config form page (chrome + config_form_body) — the // create flow and the validation-error re-render. The customer page's Edit tab embeds the same // body via configFormData directly. func (s *Server) renderConfigForm(w http.ResponseWriter, r *http.Request, isNew bool, cfg *store.CustomerConfig, overrides map[string]interface{}, errMsg string) { s.templates.ExecuteTemplate(w, "config_form.html", s.configFormData(r, isNew, cfg, overrides, errMsg)) } // buildConfigJSON builds the config_json from optional form fields. // applyOffsite provisions the offsite tier (if enabled in the form) and merges the NON-SECRET descriptor // into cfg.ConfigJSON. Fail-closed: on any provisioning error it returns the error and leaves cfg.ConfigJSON // unchanged — the caller must NOT save. When offsite is unchecked, the offsite key is naturally absent from // the freshly-built ConfigJSON (disabled by omission; the Hetzner resource is NOT deprovisioned this slice). func (s *Server) applyOffsite(ctx context.Context, r *http.Request, cfg *store.CustomerConfig) error { if v := r.FormValue("offsite_enabled"); v != "on" && v != "true" { return nil // not enabled → disabled by omission } // v0.51.0 (decision 3, drill F-6 closed by policy): offsite app backup REQUIRES the DR tier — // fork-4 needs the escrow ceremony, and the ceremony hard-requires the PBS key K. Without the // tier, provisioning would run straight into the F-6 dead end (EscrowState pending forever). // The guard reads cfg.DRTier, which the handlers set from the form BEFORE calling here. if !cfg.DRTier { return fmt.Errorf("Offsite backup requires the DR tier — enable it first (the escrow ceremony depends on the PBS key)") } if s.offsite == nil { return fmt.Errorf("offsite provisioning is not configured on this hub (no Hetzner token)") } // Detach from the client's request context: provisioning takes ~25s (create + wait + host-key scan) and // an impatient re-click cancels r.Context() MID-SEQUENCE — live finding: the cancel landed between // CreateSubaccount and SaveOneTimeSecret, stranding a sub-account whose one-time password was lost // forever. Once provisioning starts it must run to completion (create→wait→store is the atom); the // absolute timeout still bounds a hung Hetzner call. ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Minute) defer cancel() in := offsite.Input{ Enabled: true, Type: strings.TrimSpace(r.FormValue("offsite_type")), BoxType: strings.TrimSpace(r.FormValue("offsite_box_type")), } if q := strings.TrimSpace(r.FormValue("offsite_quota_gb")); q != "" { in.QuotaGB, _ = strconv.Atoi(q) } d, err := s.offsite.ProvisionOffsite(ctx, cfg.CustomerID, in) if err != nil { return err } merged, err := offsite.MergeDescriptor(cfg.ConfigJSON, d) if err != nil { return err } cfg.ConfigJSON = merged return nil } func buildConfigJSON(r *http.Request) string { overrides := make(map[string]interface{}) // Infrastructure infra := make(map[string]interface{}) if v := strings.TrimSpace(r.FormValue("cf_tunnel_token")); v != "" { infra["cf_tunnel_token"] = v } if v := strings.TrimSpace(r.FormValue("cf_api_token")); v != "" { infra["cf_api_token"] = v } if len(infra) > 0 { overrides["infrastructure"] = infra } // Git git := make(map[string]interface{}) if v := strings.TrimSpace(r.FormValue("git_username")); v != "" { git["username"] = v } if v := strings.TrimSpace(r.FormValue("git_token")); v != "" { git["token"] = v } if len(git) > 0 { overrides["git"] = git } // Logging (remote debug-mode toggle). The controller's /debug menu + verbose log key off // Logging.Level=="debug" (controller isDebug()). This lives in the FORM — not raw-JSON injection — // on purpose: handleConfigUpdate rebuilds ConfigJSON from the form on every save (buildConfigJSON), // so a foreign key would be dropped on the next save. Checked → logging.level=debug; unchecked → the // logging key is OMITTED entirely (the generated controller.yaml default stands — no needless "info"). if r.FormValue("debug_mode") != "" { overrides["logging"] = map[string]interface{}{"level": "debug"} } data, _ := json.Marshal(overrides) return string(data) } // formBool reads a checkbox-style form value ("on"/"true" → true; anything else, incl. absent, // → false). func formBool(r *http.Request, name string) bool { v := r.FormValue(name) return v == "on" || v == "true" } // handleGeoDisable removes all [felhom-geo] WAF rules from Cloudflare for a customer. The Cloudflare // WAF rules ARE the geo enforcement, so removing them disables geo-restriction. This is a hub→Cloudflare // call (NOT into the box) and stays. The old secondary inbound notify to the controller // (notifyControllerGeoDisable) was retired in v0.26.0 to honour the never-inbound posture — the // controller-side geo intent is left as a noted follow-up (there is no periodic re-apply, so the CF // removal sticks). func (s *Server) handleGeoDisable(w http.ResponseWriter, r *http.Request, customerID string) { cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Customer not found"}) return } // Extract CF API token from config_json → infrastructure.cf_api_token var overrides map[string]interface{} if err := json.Unmarshal([]byte(cfg.ConfigJSON), &overrides); err != nil { overrides = make(map[string]interface{}) } var cfToken string if infra, ok := overrides["infrastructure"].(map[string]interface{}); ok { cfToken, _ = infra["cf_api_token"].(string) } if cfToken == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No Cloudflare API token configured for this customer"}) return } if cfg.Domain == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No domain configured for this customer"}) return } // 1. Remove WAF rules directly via Cloudflare API if err := cfClient.RemoveGeoRules(cfToken, cfg.Domain, s.logger); err != nil { s.logger.Printf("[ERROR] Geo disable for %s: Cloudflare removal failed: %v", customerID, err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadGateway) json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Cloudflare API error: %v", err)}) return } s.logger.Printf("[INFO] Geo disable for %s: Cloudflare WAF rules removed", customerID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Geo-restriction removed from Cloudflare."}) }