Hub v0.6.0: Geo-restriction display + disable button + UUID cleanup

- Add geo-restriction section to customer detail page (status, countries,
  per-app overrides, sync state, errors)
- Add "Összes geo-korlátozás eltávolítása" button that directly calls
  Cloudflare API to delete [felhom-geo] WAF rules (bypasses blocked tunnel)
- Background retry to notify controller to disable geo in settings
- New internal/cloudflare/unblock.go — minimal CF client for rule deletion
- Remove legacy Monitoring UUIDs from config form, buildConfigJSON,
  handlePullConfig, volatileKeys, and controller.yaml.default

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 12:43:00 +01:00
parent f50278e2b0
commit 5e2012728f
7 changed files with 442 additions and 80 deletions
+106 -35
View File
@@ -12,6 +12,7 @@ import (
"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/store"
"gopkg.in/yaml.v3"
@@ -725,20 +726,6 @@ func buildConfigJSON(r *http.Request) string {
overrides["git"] = git
}
// Monitoring UUIDs (legacy — only written if user explicitly provides values)
uuids := make(map[string]interface{})
for _, key := range []string{"heartbeat", "system_health", "db_dump", "backup", "backup_integrity"} {
if v := strings.TrimSpace(r.FormValue("uuid_" + key)); v != "" {
uuids[key] = v
}
}
if len(uuids) > 0 {
if _, ok := overrides["monitoring"]; !ok {
overrides["monitoring"] = make(map[string]interface{})
}
overrides["monitoring"].(map[string]interface{})["ping_uuids"] = uuids
}
data, _ := json.Marshal(overrides)
return string(data)
}
@@ -762,12 +749,7 @@ func extractControllerYAML(infraData []byte) string {
// volatileKeys are YAML keys ignored during config comparison (always differ or deprecated).
var volatileKeys = map[string]bool{
"web.session_secret": true,
"monitoring.ping_uuids.heartbeat": true,
"monitoring.ping_uuids.system_health": true,
"monitoring.ping_uuids.db_dump": true,
"monitoring.ping_uuids.backup": true,
"monitoring.ping_uuids.backup_integrity": true,
"web.session_secret": true,
}
// sensitiveKeyParts are substrings that indicate a value should be masked in diff output.
@@ -1074,21 +1056,6 @@ func (s *Server) handlePullConfig(w http.ResponseWriter, r *http.Request, custom
}
}
// Monitoring UUIDs (legacy but still imported)
if monitoring, ok := parsed["monitoring"].(map[string]interface{}); ok {
if uuids, ok := monitoring["ping_uuids"].(map[string]interface{}); ok {
uuidOverrides := make(map[string]interface{})
for _, key := range []string{"heartbeat", "system_health", "db_dump", "backup", "backup_integrity"} {
if v, ok := uuids[key].(string); ok && v != "" {
uuidOverrides[key] = v
}
}
if len(uuidOverrides) > 0 {
overrides["monitoring"] = map[string]interface{}{"ping_uuids": uuidOverrides}
}
}
}
configJSON, _ := json.Marshal(overrides)
cfg.ConfigJSON = string(configJSON)
@@ -1103,3 +1070,107 @@ func (s *Server) handlePullConfig(w http.ResponseWriter, r *http.Request, custom
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Config imported from controller"})
}
// handleGeoDisable removes all [felhom-geo] WAF rules from Cloudflare for a customer,
// and notifies the controller to disable geo-restriction in its settings.
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)
// 2. Background: notify controller to disable geo in settings (retry for up to 10 min)
customer, _ := s.store.GetCustomer(customerID)
controllerURL := ""
if customer != nil {
controllerURL = customer.ControllerURL
}
if controllerURL == "" {
var rpt struct {
ControllerURL string `json:"controller_url"`
}
if customer != nil {
json.Unmarshal([]byte(customer.ReportJSON), &rpt)
controllerURL = rpt.ControllerURL
}
}
if controllerURL != "" && s.apiKey != "" {
go s.notifyControllerGeoDisable(customerID, controllerURL)
} else {
s.logger.Printf("[WARN] Geo disable for %s: cannot notify controller (no URL or API key)", customerID)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Geo-restriction removed from Cloudflare. Controller will be notified."})
}
// notifyControllerGeoDisable retries sending geo-disable to the controller every 30s for up to 10 min.
func (s *Server) notifyControllerGeoDisable(customerID, controllerURL string) {
geoURL := controllerURL + "/api/geo/settings"
for attempt := 1; attempt <= 20; attempt++ {
req, err := http.NewRequest("POST", geoURL, strings.NewReader(`{"enabled":false,"allowed_countries":["HU"]}`))
if err != nil {
s.logger.Printf("[ERROR] Geo disable notify %s attempt %d: create request: %v", customerID, attempt, err)
return
}
req.Header.Set("Authorization", "Bearer "+s.apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
s.logger.Printf("[WARN] Geo disable notify %s attempt %d: %v", customerID, attempt, err)
time.Sleep(30 * time.Second)
continue
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
s.logger.Printf("[INFO] Geo disable notify %s: controller confirmed (attempt %d): %s", customerID, attempt, string(body))
return
}
s.logger.Printf("[WARN] Geo disable notify %s attempt %d: status %d: %s", customerID, attempt, resp.StatusCode, string(body))
time.Sleep(30 * time.Second)
}
s.logger.Printf("[ERROR] Geo disable notify %s: gave up after 20 attempts", customerID)
}