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