Files
felhom.eu/hub/internal/web/configs.go
T

1400 lines
56 KiB
Go

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.\-]+$`)
// hostInstallVersion is the felhom-host-install.sh version the customer page's install-command
// generator targets. Kept in sync with scripts/felhom-host-install.sh SCRIPT_VERSION — the generator
// only ever emits flags this version parses. Display-only (the Option-1 command downloads the served
// script, which is always current); bump when the generator's flag surface follows a new script.
// Drift is now gated: scripts/hostinstall_gates.py asserts this const == SCRIPT_VERSION (drill F-1).
const hostInstallVersion = "1.16.0"
// 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: <id>"
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: <id>"
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
// ScriptVersion drives the install-command generator's header (GL-7). Display-only.
ScriptVersion 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),
ScriptVersion: hostInstallVersion,
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 <v>?" 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
}
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."})
}