2e03de1e0c
- 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
1128 lines
42 KiB
Go
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."})
|
|
}
|