Files
felhom.eu/hub/internal/web/configs.go
T
admin 2e03de1e0c hub: merge the customer edit page into the Edit tab (v0.48.0 edit-a, part 2)
- Settings tab renamed Edit; embeds config_form_body (.ConfigForm via the
  builder) + Controller Update + Geo + a new Danger zone card holding the
  relocated Block/Unblock/Delete forms (endpoints + confirm() unchanged).
  All cards are SIBLINGS after </form> — never nested in the config form.
- Customer Info header loses the Edit link and Block/Delete forms; only the
  config-less Create Config action stays.
- GET /configs/{id}/edit is a 302 to /customers/{id}#tab=edit; tabs JS gains
  the settings→edit legacy-hash alias.
- Post-action redirects land back on their tab: update/block/unblock/
  offsite-reissue/offsite-freeze/pbsdr-reissue → #tab=edit, regen-password
  → #tab=setup; delete unchanged (/configs).
- handleConfigUpdate gains the server-side twin of the form's required
  fields; the error path re-renders the STANDALONE page with the SUBMITTED
  overrides (B3 red-proof: nil overrides → typed values reset → test FAILS;
  header red-proof: restored header buttons → count=2 → test FAILS; both run).
- Tests: Group A (panel surface, sibling forms, header cleaned by COUNT),
  Group B (B1 302, B2 create unchanged, B3 typed-values, B4/B5 anchor table).
  Amended pins: customer_tabs_test settings→edit; pbsdr_test postUpdate now
  supplies the required fields + FormRendersState asserts the embedded render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZc5w5jDhFLv6qDC32KN5v
2026-07-12 17:33:07 +02:00

1128 lines
42 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.
const hostInstallVersion = "1.12.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
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 {
status := "ok"
if c.HealthStatus == "disabled" {
status = "disabled"
} else if c.TimeSinceReport > time.Hour {
status = "down"
} else if c.TimeSinceReport > 30*time.Minute || c.HealthStatus == "warn" {
status = "warn"
} else if c.HealthStatus == "fail" {
status = "down"
}
if entry, ok := merged[c.CustomerID]; ok {
// Config exists — enrich with report data
entry.OverallStatus = status
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,
ControllerVersion: c.ControllerVersion,
TimeSinceReport: c.TimeSinceReport,
}
}
}
// 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
overallStatus := "pending"
if customer != nil {
if customer.HealthStatus == "disabled" {
overallStatus = "disabled"
} else if customer.TimeSinceReport > time.Hour {
overallStatus = "down"
} else if customer.TimeSinceReport > 30*time.Minute || customer.HealthStatus == "warn" {
overallStatus = "warn"
} else if customer.HealthStatus == "fail" {
overallStatus = "down"
} else {
overallStatus = "ok"
}
}
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
HasReports bool
Customer *store.CustomerSummary
Report map[string]interface{}
OverallStatus string
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
}
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
}
data := pageData{
CustomerID: customerID,
CustomerName: name,
Domain: domain,
Email: email,
HasConfig: cfg != nil,
Config: cfg,
Overrides: overrides,
IsBlocked: cfg != nil && cfg.Status == "blocked",
HasReports: customer != nil,
Customer: customer,
Report: report,
OverallStatus: overallStatus,
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, "")
}
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
}
// 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),
}
}
// handleConfigNewForm shows the form to create a new customer config.
func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) {
s.renderConfigForm(w, r, true, &store.CustomerConfig{}, 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,
}
// 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)
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"))
// 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)
http.Redirect(w, r, "/customers/"+customerID+"?flash=updated#tab=edit", 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)
http.Redirect(w, r, "/customers/"+customerID+"?flash=offsite_reissued#tab=edit", http.StatusSeeOther)
}
// 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)
flash := "offsite_frozen"
if !frozen {
flash = "offsite_unfrozen"
}
http.Redirect(w, r, "/customers/"+customerID+"?flash="+flash+"#tab=edit", http.StatusSeeOther)
}
// handleConfigDelete deletes a customer config.
func (s *Server) handleConfigDelete(w http.ResponseWriter, r *http.Request, customerID string) {
if err := s.store.DeleteCustomerConfig(customerID); err != nil {
s.logger.Printf("[ERROR] Failed to delete config %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] Customer config deleted: %s", customerID)
http.Redirect(w, r, "/configs?flash=deleted", http.StatusSeeOther)
}
// 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()
}
yamlOutput, err := configgen.Generate(templateYAML, cfg)
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)
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)
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)
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)
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.
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
}
if err := s.store.SetArtifactManifest(store.ArtifactManifest{
AgentVersion: agentVer,
AgentSHA256: agentSHA,
GoldenVersion: goldenVer,
GoldenSHA256: goldenSHA,
MinAgent: minAgent,
}); 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", agentVer, goldenVer, minAgent)
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)
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
}
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)
}
// 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."})
}