diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 4c3ad59..005cf51 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,16 @@ # Felhom Hub — Changelog +## v0.6.0 (2026-02-25) + +### Added +- **Geo-restriction display** (`customer_unified.html`) — New "Geo-korlátozás" section on customer detail pages showing: enabled/disabled status, allowed countries, per-app overrides, last sync time, and sync errors. Only visible when the controller reports geo_restriction data. +- **"Összes geo-korlátozás eltávolítása" button** — One-click removal of all `[felhom-geo]` Cloudflare WAF rules. The Hub calls the Cloudflare API directly (bypasses potentially blocked tunnel), then retries notifying the controller in background (every 30s for up to 10 min) to disable geo in its settings. +- **Cloudflare unblock client** (`internal/cloudflare/unblock.go`) — Minimal Cloudflare API client for deleting geo-restriction WAF rules. Resolves zone ID, finds the `http_request_firewall_custom` ruleset, and deletes rules with `[felhom-geo]` description prefix. +- **`POST /customers/{id}/geo/disable`** route — CSRF-protected endpoint for the geo-disable action. + +### Removed +- **Legacy Monitoring UUIDs** — Removed the "Monitoring UUIDs" section from the config form (`config_form.html`), UUID form-field handling from `buildConfigJSON()`, UUID import from `handlePullConfig()`, volatile key entries for `monitoring.ping_uuids.*`, and the commented-out `ping_uuids` section from `controller.yaml.default`. Monitoring is fully handled by the Hub event system since v0.3.0. + ## v0.5.0 (2026-02-25) ### Added diff --git a/hub/internal/cloudflare/unblock.go b/hub/internal/cloudflare/unblock.go new file mode 100644 index 0000000..b9b6b43 --- /dev/null +++ b/hub/internal/cloudflare/unblock.go @@ -0,0 +1,215 @@ +// Package cloudflare provides a minimal Cloudflare API client for removing +// geo-restriction WAF rules created by felhom-controller. +package cloudflare + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" +) + +const apiBase = "https://api.cloudflare.com/client/v4" + +// geoRulePrefix is the description prefix used by felhom-controller for geo-blocking rules. +const geoRulePrefix = "[felhom-geo]" + +// RemoveGeoRules deletes all WAF custom rules with a [felhom-geo] description prefix +// from the Cloudflare zone associated with the given domain. +func RemoveGeoRules(apiToken, domain string, logger *log.Logger) error { + if apiToken == "" { + return fmt.Errorf("no Cloudflare API token provided") + } + if domain == "" { + return fmt.Errorf("no domain provided") + } + + // 1. Resolve zone ID + zoneID, err := resolveZone(apiToken, domain) + if err != nil { + return fmt.Errorf("resolve zone for %s: %w", domain, err) + } + if logger != nil { + logger.Printf("[INFO] cloudflare.RemoveGeoRules: zone=%s for domain=%s", zoneID, domain) + } + + // 2. Find the http_request_firewall_custom phase ruleset + rulesetID, err := findFirewallRuleset(apiToken, zoneID) + if err != nil { + return fmt.Errorf("find firewall ruleset: %w", err) + } + if rulesetID == "" { + if logger != nil { + logger.Printf("[INFO] cloudflare.RemoveGeoRules: no firewall ruleset found — nothing to remove") + } + return nil + } + + // 3. List rules and filter by [felhom-geo] prefix + rules, err := listRules(apiToken, zoneID, rulesetID) + if err != nil { + return fmt.Errorf("list rules: %w", err) + } + + var geoRuleIDs []string + for _, r := range rules { + if strings.HasPrefix(r.Description, geoRulePrefix) { + geoRuleIDs = append(geoRuleIDs, r.ID) + } + } + + if len(geoRuleIDs) == 0 { + if logger != nil { + logger.Printf("[INFO] cloudflare.RemoveGeoRules: no [felhom-geo] rules found") + } + return nil + } + + // 4. Delete each matching rule + var errors []string + for _, ruleID := range geoRuleIDs { + if err := deleteRule(apiToken, zoneID, rulesetID, ruleID); err != nil { + errors = append(errors, fmt.Sprintf("delete rule %s: %v", ruleID, err)) + } + } + + if len(errors) > 0 { + return fmt.Errorf("deleted %d/%d rules; errors: %s", + len(geoRuleIDs)-len(errors), len(geoRuleIDs), strings.Join(errors, "; ")) + } + + if logger != nil { + logger.Printf("[INFO] cloudflare.RemoveGeoRules: deleted %d [felhom-geo] rule(s)", len(geoRuleIDs)) + } + return nil +} + +// --- Cloudflare API helpers --- + +type cfResponse struct { + Success bool `json:"success"` + Result json.RawMessage `json:"result"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +type zone struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type ruleset struct { + ID string `json:"id"` + Phase string `json:"phase"` +} + +type rule struct { + ID string `json:"id"` + Description string `json:"description"` +} + +func resolveZone(apiToken, domain string) (string, error) { + // Try exact domain first, then parent domain + for _, name := range []string{domain, parentDomain(domain)} { + if name == "" { + continue + } + resp, err := cfDo(apiToken, "GET", fmt.Sprintf("/zones?name=%s&status=active", name), nil) + if err != nil { + return "", err + } + var zones []zone + if err := json.Unmarshal(resp.Result, &zones); err != nil { + return "", fmt.Errorf("parse zones: %w", err) + } + if len(zones) > 0 { + return zones[0].ID, nil + } + } + return "", fmt.Errorf("no active zone found for %s", domain) +} + +func parentDomain(domain string) string { + parts := strings.SplitN(domain, ".", 2) + if len(parts) < 2 { + return "" + } + return parts[1] +} + +func findFirewallRuleset(apiToken, zoneID string) (string, error) { + resp, err := cfDo(apiToken, "GET", fmt.Sprintf("/zones/%s/rulesets", zoneID), nil) + if err != nil { + return "", err + } + var rulesets []ruleset + if err := json.Unmarshal(resp.Result, &rulesets); err != nil { + return "", fmt.Errorf("parse rulesets: %w", err) + } + for _, rs := range rulesets { + if rs.Phase == "http_request_firewall_custom" { + return rs.ID, nil + } + } + return "", nil +} + +func listRules(apiToken, zoneID, rulesetID string) ([]rule, error) { + resp, err := cfDo(apiToken, "GET", fmt.Sprintf("/zones/%s/rulesets/%s", zoneID, rulesetID), nil) + if err != nil { + return nil, err + } + var rs struct { + Rules []rule `json:"rules"` + } + if err := json.Unmarshal(resp.Result, &rs); err != nil { + return nil, fmt.Errorf("parse ruleset detail: %w", err) + } + return rs.Rules, nil +} + +func deleteRule(apiToken, zoneID, rulesetID, ruleID string) error { + _, err := cfDo(apiToken, "DELETE", fmt.Sprintf("/zones/%s/rulesets/%s/rules/%s", zoneID, rulesetID, ruleID), nil) + return err +} + +func cfDo(apiToken, method, path string, body io.Reader) (*cfResponse, error) { + url := apiBase + path + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request %s %s: %w", method, path, err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + var cfResp cfResponse + if err := json.Unmarshal(data, &cfResp); err != nil { + return nil, fmt.Errorf("parse CF response (status %d): %s", resp.StatusCode, string(data)) + } + + if !cfResp.Success { + msgs := make([]string, len(cfResp.Errors)) + for i, e := range cfResp.Errors { + msgs[i] = e.Message + } + return nil, fmt.Errorf("CF API error: %s", strings.Join(msgs, "; ")) + } + + return &cfResp, nil +} diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index 2b59fee..b74c42e 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -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) +} diff --git a/hub/internal/web/controller.yaml.default b/hub/internal/web/controller.yaml.default index d295b18..8e7da88 100644 --- a/hub/internal/web/controller.yaml.default +++ b/hub/internal/web/controller.yaml.default @@ -80,12 +80,6 @@ backup: monitoring: enabled: true healthchecks_base: "https://status.felhom.eu" - # ping_uuids: (deprecated — monitoring is now handled by the Hub event system) - # heartbeat: "" - # system_health: "" - # db_dump: "" - # backup: "" - # backup_integrity: "" system_health_interval: "5m" health_check_schedule: "06:00" thresholds: diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 3c448c6..d141247 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -190,6 +190,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } + case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/geo/disable"): + customerID := strings.TrimPrefix(path, "/customers/") + customerID = strings.TrimSuffix(customerID, "/geo/disable") + if r.Method == http.MethodPost { + s.handleGeoDisable(w, r, customerID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/push-config"): customerID := strings.TrimPrefix(path, "/customers/") customerID = strings.TrimSuffix(customerID, "/push-config") diff --git a/hub/internal/web/templates/config_form.html b/hub/internal/web/templates/config_form.html index 80e48bb..1b723ce 100644 --- a/hub/internal/web/templates/config_form.html +++ b/hub/internal/web/templates/config_form.html @@ -100,45 +100,6 @@ -
-

Monitoring UUIDs

Legacy
-

Healthchecks ping UUIDs are deprecated. Monitoring is now handled natively by the Hub event system. These fields are kept for backward compatibility with older controllers.

-
- {{$uuids := ""}} - {{with .Overrides}}{{with index . "monitoring"}}{{with index . "ping_uuids"}}{{$uuids = .}}{{end}}{{end}}{{end}} -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
Cancel diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index a2e77c5..f917d14 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -226,6 +226,76 @@ {{end}} + + {{if .HasReports}} + {{with .Report.geo_restriction}} +
+

Geo-korlátozás

+
+
+ Állapot + + {{if index . "enabled"}} + Aktív + {{else}} + Inaktív + {{end}} + +
+ {{if index . "enabled"}} +
+ Engedélyezett országok + + {{$countries := index . "allowed_countries"}} + {{if $countries}} + {{range $i, $c := $countries}}{{if $i}}, {{end}}{{$c}}{{end}} + {{else}} + — + {{end}} + +
+ {{end}} + {{if index . "last_sync"}} +
+ Utolsó szinkron + {{index . "last_sync"}} +
+ {{end}} + {{if index . "last_sync_error"}} +
+ Szinkron hiba + {{index . "last_sync_error"}} +
+ {{end}} +
+ {{$overrides := index . "app_overrides"}} + {{if $overrides}} +

Alkalmazás felülírások

+ + + + {{range $app, $override := $overrides}} + + + + + {{end}} + +
AlkalmazásEngedélyezett országok
{{$app}} + {{$ac := index $override "allowed_countries"}} + {{if $ac}}{{range $i, $c := $ac}}{{if $i}}, {{end}}{{$c}}{{end}}{{else}}—{{end}} +
+ {{end}} + {{if index . "enabled"}} +
+ + +
+ {{end}} +
+ {{end}} + {{end}} +

Infra Backup

@@ -595,6 +665,38 @@ }); } + function disableGeo(customerID) { + if (!confirm('Összes geo-korlátozás eltávolítása?\n\nEz közvetlenül törli a Cloudflare WAF szabályokat és értesíti a controllert.')) return; + var btn = document.getElementById('btn-geo-disable'); + var msg = document.getElementById('geo-msg'); + btn.disabled = true; + btn.textContent = 'Eltávolítás...'; + msg.style.display = 'none'; + fetch('/customers/' + customerID + '/geo/disable', {method: 'POST', headers: csrfHeaders()}) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.ok) { + msg.textContent = data.message || 'Geo-korlátozás eltávolítva'; + msg.style.display = 'inline'; + msg.style.color = '#4ade80'; + setTimeout(function() { location.reload(); }, 2000); + } else { + msg.textContent = data.error || 'Hiba történt'; + msg.style.display = 'inline'; + msg.style.color = '#f87171'; + btn.disabled = false; + btn.textContent = 'Összes geo-korlátozás eltávolítása'; + } + }) + .catch(function() { + msg.textContent = 'Kapcsolódási hiba'; + msg.style.display = 'inline'; + msg.style.color = '#f87171'; + btn.disabled = false; + btn.textContent = 'Összes geo-korlátozás eltávolítása'; + }); + } + function triggerControllerUpdate(customerID) { if (!confirm('Trigger self-update on this controller?\n\nThe controller will be briefly unavailable during restart.')) return; var btn = document.getElementById('btn-trigger-update');