Files
felhom.eu/hub/internal/web/configs.go
T
admin 7d64038f02
gates / gates (push) Successful in 6m45s
Hub v0.158.0 code (built, NOT deployed): agent-only vouch, R-895 snapshot alarms, the hub's own 90-day access log (R-1140 A); installer --rotate-recovery-only (R-1141); 02/03/05/09 + break-glass
Hub: the R-120 golden guard judges only a CHANGED golden selection; offsite_snapshots_vanished /
_unexplained (operator-only, hub-origin) on the 07:10 listing; internal/accesslog writes path-only
lines to /data/access-log, 90 days. Installer: --rotate-recovery-only runs step 4b alone (rides
the next installer tag). 03: the orphan check runs after a failed backup (agent 6545ccc). 02: the
allow-list is empty, appdata_folders (controller v0.329.0). All red-proofed.
2026-10-17 23:31:34 +02:00

1936 lines
86 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"context"
"encoding/json"
"errors"
"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/gitea"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"gitea.dooplex.hu/admin/felhom-hub/internal/i18n"
)
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: <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)
// FloorDeclaredMinAgent (R-472) — the MinAgent declared with the per-customer override, "" = none.
FloorDeclaredMinAgent string
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, s.staleThreshold), 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.FloorDeclaredMinAgent = s.store.CustomerFloorDeclaredMinAgent(e.CustomerID)
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, s.staleThreshold)
}
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{}
// BackupCard (R-331) is resolved in Go, not in the template: the card must distinguish
// "no data reported" from "measured empty" from "never measured", and a {{if}} chain over
// Report's float64s cannot. See backup_card.go.
BackupCard backupCardView
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)
// FloorDeclaredMinAgent (R-472) — the MinAgent declared with the override, "" = none.
FloorDeclaredMinAgent string
GlobalFloor string // global default (hub_settings → config/env)
EffectiveFloor string // override else global ("" = no floor)
BelowFloor bool // current < effective floor (would auto-update)
// EffectiveFloorWithdrawn (R-1085) — why the effective floor is a withdrawn build, "" when it is not. The
// box is served no floor then (store.ResolveManagedFloor); the page says so instead of „will auto-update".
EffectiveFloorWithdrawn string
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
// BoxClaimKnown/BoxClaimedTrue (R-283) are the box's own claim state from its latest report (unknown:
// no report or an old controller); BoxClaimedAt is that report's time. ClaimMismatch is set when the
// hub says claimed and the box says not — the shape a guest rebuild leaves behind.
BoxClaimKnown bool
BoxClaimedTrue bool
BoxClaimedAt time.Time
ClaimMismatch bool
// SelfBindSentAt / SelfBindSentOccasion (R-509, v0.114.0): when and why the connect link last
// went out (auto on creation / RESET / e-mail set / host delete, or the button). Empty = never.
SelfBindSentAt string
SelfBindSentOccasion string
// 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
// WaitingNoEmail (R-508, v0.135.0): a configured customer with NO box and NO registered e-mail — the
// connect link and the setup code can reach nobody. The hub logged it; the page now says it.
WaitingNoEmail 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 == ""
// R-331: the Backup card reads the report's `offsite` object, not the long-dead `backup`
// snapshot/size/integrity fields. Built here so the page holds one resolved view rather than
// re-deriving it in template conditionals. A nil customer (never reported) yields the zero view,
// which renders as "nothing reported".
var backupCard backupCardView
if customer != nil {
backupCard = reportBackupCard(customer.ReportJSON)
}
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,
BackupCard: backupCard,
OverallStatus: overallStatus,
HostCause: hostCause,
StaleSinceReset: staleSinceReset,
ResetAt: resetAt,
OffsiteUnprovisioned: offsiteUnprovisioned,
WaitingNoEmail: cfg != nil && len(hostViews) == 0 && strings.TrimSpace(email) == "",
LatestVersion: latestVersion,
UpdateAvailable: updateAvailable,
ControllerURL: controllerURL,
FloorOverride: floorOverride,
FloorDeclaredMinAgent: s.store.CustomerFloorDeclaredMinAgent(customerID),
GlobalFloor: globalFloor,
EffectiveFloor: effectiveFloor,
BelowFloor: belowFloor,
EffectiveFloorWithdrawn: func() string {
r, _ := store.SpoiledControllerTag(effectiveFloor)
return r
}(),
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
}
if bc, at, err := s.store.LatestReportClaimed(customerID); err == nil {
data.BoxClaimKnown, data.BoxClaimedAt = bc != nil, at
data.BoxClaimedTrue = bc != nil && *bc
data.ClaimMismatch = bc != nil && !*bc && data.Claim != nil && data.Claim.ClaimedAt != nil
}
if ev, err := s.store.GetLatestEventByType(customerID, selfBindSentEvent); err == nil && ev != nil {
data.SelfBindSentAt = ev.CreatedAt.UTC().Format("2006-01-02 15:04 UTC")
var d struct {
Occasion string `json:"occasion"`
}
_ = json.Unmarshal([]byte(ev.DetailsJSON), &d)
data.SelfBindSentOccasion = d.Occasion
}
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) {
// Operator ruling 2026-09-16: EVERY new customer starts with the off-site copy switched on —
// shared (a sub-account on the pool box), 100 GB soft quota. Opting a customer out is the
// per-customer exception, exactly as DRTier has been since 2026-07-12.
//
// Why it is a default rather than a nicety: the whole-guest tiers do not carry the customer's
// data drive (07-backup-architecture §6, "[FACT] What the whole-guest tiers do NOT carry") and a
// Tier-1 unit holds no file leg, so on a one-drive box with this unticked the household's own
// files are in NO backup. That was measured on a fresh box on 2026-09-16: five photos deleted,
// restored from the box's own backup, and none of them opened.
//
// The quota is PREFILLED rather than left empty on purpose: `monitor/offsite.go` only warns about
// a filling store when quota_gb > 0, so an empty field means no fill warning ever fires.
//
// Every key the template touches is present, including box_type: the form compares it with `eq`,
// and a missing key renders as an untyped nil that makes `eq` fail the whole page.
overrides := map[string]interface{}{
"offsite": map[string]interface{}{
"enabled": true,
"type": "shared",
"quota_gb": 100,
"box_type": "",
},
}
s.renderConfigForm(w, r, true, &store.CustomerConfig{DRTier: true}, overrides, "")
}
// 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
}
// R-728: one create per customer ID at a time. Held from before the duplicate check until the
// handler returns, so a double submit cannot pass the check twice and mint twice.
if _, busy := s.creating.LoadOrStore(customerID, struct{}{}); busy {
s.logger.Printf("[WARN] customer create %s refused: a create for this ID is already in progress (double submit)", customerID)
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 is already being created — this second submit was ignored. Open the customer page in a moment.", customerID))
return
}
defer s.creating.Delete(customerID)
// 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
}
// R-415 / R-138 option B: a domain equal to, inside or containing another customer's, or under felhom.eu, is refused
// BEFORE anything is generated or provisioned.
if msg := s.domainConflictMessage(customerID, r.FormValue("domain")); msg != "" {
s.renderConfigForm(w, r, true, &store.CustomerConfig{
CustomerID: customerID,
CustomerName: r.FormValue("customer_name"),
Domain: r.FormValue("domain"),
Email: r.FormValue("email"),
}, nil, msg)
return
}
// R-138 option C (decision 190): a pasted Cloudflare API token must reach only this customer's own zone.
if msg := s.cfTokenReachMessage(r.Context(), customerID, r.FormValue("domain"), r.FormValue("cf_api_token")); msg != "" {
var submitted map[string]interface{}
_ = json.Unmarshal([]byte(buildConfigJSON(r)), &submitted)
s.renderConfigForm(w, r, true, &store.CustomerConfig{
CustomerID: customerID,
CustomerName: r.FormValue("customer_name"),
Domain: r.FormValue("domain"),
Email: r.FormValue("email"),
}, submitted, msg)
return
}
// Generate credentials.
//
// R-597: the Owner passphrase follows the language the operator is choosing ON THIS FORM, not
// the one in the store — there is no stored customer yet, and CustomerLanguage would answer
// Hungarian for every English household created here. The store's own createdLanguage applies
// the same default to an absent or unknown value, so the two agree.
createLang := i18n.Normalize(strings.TrimSpace(r.FormValue("language")))
retrievalPassword, err := configgen.RandomPassphraseFor(createLang, configgen.UseOwnerPassphrase)
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"),
// v0.118.0 (R-558): the customer-mail language. An absent or unknown value is Hungarian
// (createdLanguage), so an operator page that never posts the field keeps today's behaviour.
Language: strings.TrimSpace(r.FormValue("language")),
}
// Offsite provisioning (fail-closed): a provisioning error must NOT save a half-enabled config.
if err := s.applyOffsite(r.Context(), r, cfg); errors.Is(err, offsite.ErrProvisionInProgress) {
// R-31: a second Save while the first still provisions — nothing saved, nothing created.
s.logger.Printf("[INFO] offsite provision for %s refused: one is already running (R-31)", customerID)
http.Error(w, err.Error(), http.StatusConflict)
return
} else if 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 s.beforeCreateSave != nil {
s.beforeCreateSave(customerID)
}
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
}
prevEmail := cfg.Email
prevDomain := cfg.Domain
prevCFToken := storedCFToken(cfg.ConfigJSON)
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")
// v0.118.0 (R-558): the customer-mail language default. Editing it changes the mails sent
// before a box reports (claim, bind); it does NOT overrule a household that has chosen on
// their own dashboard — CustomerLanguage prefers the reported one.
cfg.Language = strings.TrimSpace(r.FormValue("language"))
// 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
}
// R-415 / R-138 option B — the same guard on edit (a customer's own current domain never conflicts with itself).
if msg := s.domainConflictMessage(customerID, cfg.Domain); msg != "" {
var submitted map[string]interface{}
_ = json.Unmarshal([]byte(buildConfigJSON(r)), &submitted)
s.renderConfigForm(w, r, false, cfg, submitted, msg)
return
}
// R-138 option C (decision 190): a NEW token, or the same token moved to a new domain, is checked against
// Cloudflare; an unchanged token on an unchanged domain is not (no call — editing another field never
// depends on Cloudflare). A refusal saves nothing, so the previous token stays.
if newTok := strings.TrimSpace(r.FormValue("cf_api_token")); newTok != prevCFToken || !strings.EqualFold(normDomainForm(cfg.Domain), normDomainForm(prevDomain)) {
if msg := s.cfTokenReachMessage(r.Context(), customerID, cfg.Domain, newTok); msg != "" {
var submitted map[string]interface{}
_ = json.Unmarshal([]byte(buildConfigJSON(r)), &submitted)
s.renderConfigForm(w, r, false, cfg, submitted, msg)
return
}
}
cfg.ConfigJSON = buildConfigJSON(r)
if err := s.applyOffsite(r.Context(), r, cfg); errors.Is(err, offsite.ErrProvisionInProgress) {
// R-31: a second Save while the first still provisions — nothing saved, nothing created.
s.logger.Printf("[INFO] offsite provision for %s refused: one is already running (R-31)", customerID)
http.Error(w, err.Error(), http.StatusConflict)
return
} else if 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
// R-509: an address set or changed on a customer with no box → the connect link goes out now.
if cfg.Email != "" && !strings.EqualFold(cfg.Email, prevEmail) {
s.autoMintSelfBindIfWaiting(customerID, "e-mail set on a customer with no box")
}
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" / "Beállító kód küldése"
// button (R-295: the button used to read „Visszaállító kód küldése"; that name is retired).
// 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) {
// R-597: a regenerated Owner passphrase follows the household's language exactly as their mails
// do — CustomerLanguage's order is last-reported → created-with → Hungarian, so a household that
// switched their own dashboard to English gets English words on the next regeneration.
newPassword, err := configgen.RandomPassphraseFor(s.store.CustomerLanguage(customerID), configgen.UseOwnerPassphrase)
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"))
_, spoiled := store.SpoiledControllerTag(v)
if spoiled {
ok = false // R-1085: a withdrawn build is not a floor the dialog may offer to confirm
}
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, "spoiled": spoiled, "below": count})
}
// refuseSpoiledFloor is the input half of R-1085, shared by the two floor forms (NOT normalizeFloorInput, which
// also validates agent and golden versions, whose numbering is their own): a withdrawn controller build is never
// saved as a floor. It logs the refusal and reports whether the caller must stop. The serve-time half is
// store.ResolveManagedFloor.
func (s *Server) refuseSpoiledFloor(v, where string) bool {
reason, spoiled := store.SpoiledControllerTag(v)
if spoiled {
s.logger.Printf("[WARN] %s floor %q REFUSED: a withdrawn controller build (%s) — nothing saved (R-1085)", where, v, reason)
}
return spoiled
}
// 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 s.refuseSpoiledFloor(v, "global") {
http.Redirect(w, r, "/configuration?flash=floor_spoiled", http.StatusSeeOther)
return
}
ma, flash := s.floorDeclaredMinAgent(r, v)
if flash != "" {
http.Redirect(w, r, "/configuration?flash="+flash, 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
}
if err := s.store.SetGlobalFloorDeclaredMinAgent(v, ma); err != nil {
s.logger.Printf("[ERROR] Failed to record the global floor's declared MinAgent: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] Global controller-version floor set to %q (declared MinAgent %q)", v, ma)
s.reportFloorHeldBack(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)
}
// heldBackByOwnFloor lists the customers a global floor `global` does NOT move because their own per-customer
// floor override is LOWER (R-604). An override at or above the global is not held back — it already asks for
// at least as much. Pure on the store; ordered by customer id.
func (s *Server) heldBackByOwnFloor(global string) []store.CustomerFloorOverride {
if global == "" || !semver.Valid(global) {
return nil
}
ovs, err := s.store.CustomerFloorOverrides()
if err != nil {
s.logger.Printf("[ERROR] floor overrides: %v", err)
return nil
}
var out []store.CustomerFloorOverride
for _, o := range ovs {
if semver.Valid(o.Version) && semver.Compare(global, o.Version) > 0 {
out = append(out, o)
}
}
return out
}
// reportFloorHeldBack says, at the moment of the raise, which boxes it did NOT move (R-604, hub v0.135.0): one log
// line per customer and ONE operator mail naming them all. Before v0.135.0 the raise logged nothing for such a box,
// and demo-hp silently missed four raises. Pinned by r604_floor_held_back_test.go.
func (s *Server) reportFloorHeldBack(global string) {
held := s.heldBackByOwnFloor(global)
if len(held) == 0 {
return
}
var names []string
for _, o := range held {
age := "set before hub v0.135.0 — age unknown"
if !o.SetAt.IsZero() {
age = "set " + o.SetAt.UTC().Format("2006-01-02 15:04") + " UTC"
}
s.logger.Printf("[WARN] global floor %s does NOT move customer %s: its own floor %s wins (%s) — clear it on the customer page to let the global floor apply",
global, o.CustomerID, o.Version, age)
names = append(names, fmt.Sprintf("%s (own floor %s, %s)", o.CustomerID, o.Version, age))
}
if s.emit == nil {
return
}
details, _ := json.Marshal(map[string]any{"global_floor": global, "held_back": names})
s.emit("", "floor_raise_skipped", "warning",
fmt.Sprintf("Floor: the global controller floor is now %s, but %d box(es) keep their own LOWER floor and were not moved: %s. "+
"Clear each per-customer floor (or raise it) on the customer page.", global, len(held), strings.Join(names, "; ")),
string(details), "hub")
}
// floorDeclaredMinAgent reads and validates the `min_agent` a floor form declares (R-472). It returns
// the normalised value, or a flash key when the form must be REFUSED with nothing stored:
//
// - floor_min_agent_invalid — something was typed and it is not X.Y.Z;
// - floor_needs_min_agent — the floor is ABOVE the vouched golden and no MinAgent was declared.
// Such a floor would only be HELD on every box (publish-train rule 1); refusing it at the form
// turns a silent fleet-wide hold into a sentence the operator reads at the moment of saving.
//
// At or below the golden a declaration is optional and, if given, recorded but not used (the
// manifest governs there — see store.ResolveManagedFloor).
func (s *Server) floorDeclaredMinAgent(r *http.Request, floor string) (string, string) {
ma, ok := normalizeFloorInput(r.FormValue("min_agent"))
if !ok {
return "", "floor_min_agent_invalid"
}
golden := s.store.GetArtifactManifest().GoldenVersion
if floor != "" && ma == "" && semver.Valid(floor) && semver.Valid(golden) && semver.Compare(floor, golden) > 0 {
s.logger.Printf("[WARN] floor %s refused: it is above the vouched golden %s and no MinAgent was declared (R-472)", floor, golden)
return "", "floor_needs_min_agent"
}
return ma, ""
}
// 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}$`)
// installerProbeConfig is the FIRST config felhom-host-install.sh fetches from a vouched agent's
// tag (step 5/8, `fetch_raw "configs/felhom-mkfs-guarded.sh"`). It is the exact file whose 404 broke
// every install on 2026-08-09, and it is what the installability gate asserts — the path the
// installer uses, never a path that merely exists.
const installerProbeConfig = "configs/felhom-mkfs-guarded.sh"
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
}
// --- INSTALLABILITY GATE (R-273) -----------------------------------------------------------
//
// THE INCIDENT. On 2026-08-08 agent v0.128.0 was published as a package and never git-tagged.
// It was vouched here. felhom-host-install.sh fetches an agent's config files from
// `raw/tag/v<version>/configs/`, so EVERY fresh install and every reinstall died at step 5 of 8
// — as root, on a virgin machine — for most of a day. Nothing checked at the moment of risk, and
// the moment of risk is this handler: it is the sole UI path to SetArtifactManifest.
//
// TWO INDEPENDENT LEGS, because both have failed within two days of each other:
// the TAG — missing on 2026-08-08 (R-273)
// the PACKAGE — pruned out from under a still-tagged version on 2026-08-08/09 (R-287)
// Checking one would have caught one of them.
//
// IT ASSERTS THE PATH THE INSTALLER ACTUALLY FETCHES. `configs/felhom-mkfs-guarded.sh` is the
// FIRST of the sixteen `fetch_raw` calls (felhom-host-install.sh, step 5) and is literally the
// file whose 404 broke Friday. Probing some other path that merely exists is how that failure
// stayed invisible.
//
// IT RUNS BEFORE resolveArtifactSHA. Until R-292 the sha lookup failed with ONE flash,
// `artifact_sha_invalid`, covering "version missing / Gitea unreachable / bad sha", so the order
// was load-bearing (found by scenario E failing against the first draft). Since R-292 the sha
// lookup names its own cause too (TestArtifactSave_FlashNamesTheCause); the order is kept so the
// tag and package legs are still reported as such.
//
// UNDETERMINED IS ALSO A REFUSAL, and it says something different. A warning beside a success is
// read as a success, and this project has the scars; so an unreachable registry refuses too,
// with its own message. There is NO OVERRIDE: the registry is the operator's own server, and if
// it cannot be reached then vouching is moot rather than urgent.
if s.gitea != nil {
ctx := r.Context()
type probeTarget struct{ label, pkg, file, version, repo string }
targets := []probeTarget{}
if agentVer != "" {
targets = append(targets, probeTarget{"agent", pkgAgent, fileAgent, agentVer, "felhom-agent"})
}
if goldenVer != "" {
targets = append(targets, probeTarget{"golden", pkgGolden, fileGolden, goldenVer, ""})
}
for _, t := range targets {
// Leg 1 — the tag, but only where a tag is what the installer uses. The golden is
// fetched as a package by version and has no config tree, so a tag probe on it would be
// asserting something the installer never does.
if t.repo != "" {
res := s.gitea.TagServesFile(ctx, t.repo, "v"+t.version, installerProbeConfig)
if res.Err != nil {
s.logger.Printf("[WARN] artifact vouch REFUSED: could not verify the %s tag v%s: %v", t.label, t.version, res.Err)
http.Redirect(w, r, "/configuration?flash=artifact_unverifiable", http.StatusSeeOther)
return
}
if !res.OK {
s.logger.Printf("[WARN] artifact vouch REFUSED: %s v%s has NO usable git tag — "+
"raw/tag/v%s/%s does not resolve, so every install would 404 at step 5/8 (R-273)",
t.label, t.version, t.version, installerProbeConfig)
http.Redirect(w, r, "/configuration?flash=artifact_tag_missing", http.StatusSeeOther)
return
}
}
// Leg 2 — the artifact itself is fetchable.
res := s.gitea.PackageDownloadable(ctx, t.pkg, t.version, t.file)
if res.Err != nil {
s.logger.Printf("[WARN] artifact vouch REFUSED: could not verify the %s package %s: %v", t.label, t.version, res.Err)
http.Redirect(w, r, "/configuration?flash=artifact_unverifiable", http.StatusSeeOther)
return
}
if !res.OK {
s.logger.Printf("[WARN] artifact vouch REFUSED: %s package %s is NOT downloadable (R-287)", t.label, t.version)
http.Redirect(w, r, "/configuration?flash=artifact_pkg_missing", http.StatusSeeOther)
return
}
}
}
// R-292: each failure names its own cause — version missing, registry unreachable, no sha listed, or a
// bad typed sha — instead of one flash that conflated all of them.
agentSHA, flashAS := s.resolveArtifactSHA(r.Context(), pkgAgent, fileAgent, agentVer, r.FormValue("agent_sha256"))
goldenSHA, flashGS := s.resolveArtifactSHA(r.Context(), pkgGolden, fileGolden, goldenVer, r.FormValue("golden_sha256"))
if flash := firstNonEmpty(flashAS, flashGS); flash != "" {
http.Redirect(w, r, "/configuration?flash="+flash, http.StatusSeeOther)
return
}
// R-840: the vouched agent's CONFIG BUNDLE is resolved from the registry by exact name, like the binary. A version
// without one (older than v0.143.0) vouches none — the installer then falls back to its per-file fetches.
bundleSHA := ""
if agentVer != "" && s.gitea != nil {
b, berr := s.gitea.ExactFileSHA256(r.Context(), pkgAgent, agentVer, fileBundle)
if berr != nil {
s.logger.Printf("[WARN] artifact vouch REFUSED: could not read agent %s's config bundle sha: %v", agentVer, berr)
http.Redirect(w, r, "/configuration?flash=artifact_unverifiable", http.StatusSeeOther)
return
}
bundleSHA = b
}
// 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.
//
// AGENT-ONLY VOUCH (`09` §3 decision 250 items 3–4, hub v0.158.0). The golden is baked WEEKLY while
// controller releases ride the floor, so for most of a week the vouched golden is behind the fleet — and
// this gate then refused EVERY save, including one that changes only the agent. An agent vouch mid-week
// was impossible without a re-bake. The gate guards a golden SELECTION: when the submitted golden is the
// one already vouched (same version AND same sha256), nothing about the golden changes and the gate does
// not apply. A changed golden — another version, or the same version with another sha (a re-push) — is
// judged exactly as before. The MinAgent rule for a floor (a floor whose MinAgent is above the vouched
// agent is never set) lives on the floor path and is untouched. Pinned by
// TestVouchAgentOnlyWithStaleGoldenIsSaved / TestVouchGoldenChangedToOlderIsStillRefused.
cur := s.store.GetArtifactManifest()
goldenUnchanged := goldenVer != "" && goldenVer == cur.GoldenVersion && goldenSHA == cur.GoldenSHA256
if goldenUnchanged {
if newest := s.store.NewestReportedControllerVersion(); newest != "" && compareVersions(goldenVer, newest) < 0 {
s.logger.Printf("[INFO] artifact vouch: golden %s unchanged (behind the fleet's %s — the weekly bake) — the R-120 gate does not apply to an unchanged golden; saving agent=%s min_agent=%q",
goldenVer, newest, agentVer, minAgent)
}
}
if goldenVer != "" && !goldenUnchanged {
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,
BundleSHA256: bundleSHA,
}); 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 bundle_sha=%q", agentVer, goldenVer, minAgent, wrapperSHA != "", bundleSHA)
// 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 "",""). With a Gitea client it fetches the sha AUTHORITATIVELY from
// Gitea (the submitted value is ignored — nothing hand-typed to trust); without one it validates + uses
// the submitted sha (legacy manual path). The second value is "" on success, otherwise the flash that
// names the cause (R-292), so the caller refuses the save with the right message:
//
// artifact_version_missing — the registry answered 404 for that version
// artifact_sha_missing — the version exists but the registry listed no sha256
// artifact_unverifiable — the registry did not answer usefully (network error, 5xx)
// artifact_sha_invalid — a hand-typed sha is not 64 hex characters (manual path only)
func (s *Server) resolveArtifactSHA(ctx context.Context, pkg, file, version, submittedSHA string) (string, string) {
if version == "" {
return "", ""
}
if s.gitea != nil {
sha, err := s.gitea.FileSHA256(ctx, pkg, version, file)
if err != nil {
flash := "artifact_unverifiable"
switch {
case gitea.IsNotFound(err):
flash = "artifact_version_missing"
case errors.Is(err, gitea.ErrNoSHA):
flash = "artifact_sha_missing"
}
s.logger.Printf("[WARN] artifact sha resolve (%s/%s) refused as %s: %v", pkg, version, flash, err)
return "", flash
}
return sha, ""
}
if sha, ok := normalizeSHA256(submittedSHA); ok {
return sha, ""
}
return "", "artifact_sha_invalid"
}
// firstNonEmpty returns the first non-empty string, or "".
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// 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 s.refuseSpoiledFloor(v, "customer "+customerID) {
http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_spoiled", http.StatusSeeOther)
return
}
ma, flash := s.floorDeclaredMinAgent(r, v)
if flash != "" {
http.Redirect(w, r, "/customers/"+customerID+"?flash="+flash, 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
}
if err := s.store.SetCustomerFloorDeclaredMinAgent(customerID, v, ma); err != nil {
s.logger.Printf("[ERROR] Failed to record the declared MinAgent 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 (declared MinAgent %q)", customerID, v, ma)
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. The config below states Language: i18n.Default for this path (no
// operator is present), so the passphrase is drawn for the SAME language — reading it from the
// one line that decides it, rather than restating the choice.
retrievalPassword, _ := configgen.RandomPassphraseFor(i18n.Default, configgen.UseOwnerPassphrase)
apiKey, _ := configgen.RandomHex(32)
cfg := &store.CustomerConfig{
CustomerID: customerID,
CustomerName: name,
RetrievalPassword: retrievalPassword,
APIKey: apiKey,
ConfigJSON: "{}",
// No operator is present on this path (a config invented from a box's own report), so the
// language is the default. Stated rather than left blank: createdLanguage would supply the
// same value, and a reader should not have to know that to see what a box gets here.
Language: i18n.Default,
}
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."})
}
// domainConflictMessage is the operator's sentence for a refused domain ("" = allowed). A store error refuses too
// (fail closed: the save can be retried; a duplicate domain cannot be undone once boxes use it).
func (s *Server) domainConflictMessage(customerID, domain string) string {
other, err := s.store.DomainConflict(customerID, domain)
if err != nil {
s.logger.Printf("[ERROR] domain check for %s failed: %v — save refused", customerID, err)
return "The domain could not be checked against the other customers — nothing was saved. Try again."
}
switch other {
case "":
return ""
case "felhom.eu":
return fmt.Sprintf("Domain %q is under felhom.eu — every customer has their own domain (01 §7). Nothing was saved.", strings.TrimSpace(domain))
default:
s.logger.Printf("[WARN] domain %q for %s refused: it overlaps customer %s's domain (R-415)", strings.TrimSpace(domain), customerID, other)
return fmt.Sprintf("Domain %q equals, contains or lies under the domain of customer %q — every customer has their own domain (01 §7). Nothing was saved.", strings.TrimSpace(domain), other)
}
}
// storedCFToken reads infrastructure.cf_api_token from a stored config_json ("" when absent or unparsable).
func storedCFToken(configJSON string) string {
var overrides map[string]interface{}
if err := json.Unmarshal([]byte(configJSON), &overrides); err != nil {
return ""
}
if infra, ok := overrides["infrastructure"].(map[string]interface{}); ok {
v, _ := infra["cf_api_token"].(string)
return strings.TrimSpace(v)
}
return ""
}
func normDomainForm(d string) string { return strings.TrimSuffix(strings.TrimSpace(d), ".") }
// cfTokenReachMessage is the operator's sentence for a refused Cloudflare API token ("" = allowed). R-138 option C
// (operator ruling 2026-10-08, `09` §3 decision 190): the hub asks Cloudflare which zones the token can see and allows
// it only when it sees EXACTLY ONE zone and that zone IS the customer's domain (`01` §7: every customer has their own
// domain). A zone ABOVE the domain is refused even when no other customer sits under it today: a sibling customer
// added later (a.parent.hu, then b.parent.hu — R-415 compares the two domains only) would be reachable by the key
// already stored, so the check could not hold over time (security review 2026-10-08). An empty token (HTTP-01) is never checked. Fail closed: when Cloudflare
// cannot be asked, the save is refused and nothing changes. The token is never logged and never in a sentence.
// Pinned by TestR138_* (r138_cf_token_reach_test.go).
func (s *Server) cfTokenReachMessage(ctx context.Context, customerID, domain, token string) string {
token = strings.TrimSpace(token)
if token == "" {
return ""
}
domain = strings.TrimSpace(domain)
names, total, err := cfClient.TokenZones(ctx, s.cfAPIBase, token)
if err != nil {
s.logger.Printf("[WARN] cloudflare token check for %s: Cloudflare could not be asked (%v) — save refused", customerID, err)
return "Cloudflare could not be asked what this API token can reach, so it was not checked — nothing was saved and the previous token stays. Try again in a few minutes."
}
switch {
case total == 0:
s.logger.Printf("[WARN] cloudflare token check for %s: the token sees 0 zones — save refused", customerID)
return fmt.Sprintf("The Cloudflare API token sees no zone — it must be able to read exactly this customer's own zone (%q). Nothing was saved.", domain)
case total > 1:
s.logger.Printf("[WARN] cloudflare token check for %s: the token sees %d zones — save refused (R-138)", customerID, total)
return fmt.Sprintf("The Cloudflare API token reaches %d zones — it must reach only this customer's own zone. Make a token for this one zone (Zone Resources: Include, Specific zone). Nothing was saved.", total)
case len(names) != 1:
s.logger.Printf("[WARN] cloudflare token check for %s: Cloudflare counted 1 zone but listed %d — save refused", customerID, len(names))
return "Cloudflare's answer about this API token was incomplete, so it was not checked — nothing was saved and the previous token stays. Try again in a few minutes."
case !cfClient.ZoneEquals(names[0], domain):
s.logger.Printf("[WARN] cloudflare token check for %s: the token's one zone %q is not domain %q — save refused", customerID, names[0], domain)
return fmt.Sprintf("The Cloudflare API token reaches zone %q, which is not this customer's domain %q. Nothing was saved.", names[0], domain)
}
s.logger.Printf("[INFO] cloudflare token check for %s: the token sees 1 zone (%s), which is the customer's domain — allowed", customerID, names[0])
return ""
}