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
+215
View File
@@ -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
}
+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)
}
-6
View File
@@ -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:
+8
View File
@@ -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")
@@ -100,45 +100,6 @@
</div>
</details>
<details class="card">
<summary><h2 style="display:inline">Monitoring UUIDs</h2> <span class="severity-badge severity-warning" style="font-size: 0.7em; vertical-align: middle;">Legacy</span></summary>
<p class="text-muted" style="margin: 0.5rem 0;">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.</p>
<div class="form-grid" style="margin-top: 1rem;">
{{$uuids := ""}}
{{with .Overrides}}{{with index . "monitoring"}}{{with index . "ping_uuids"}}{{$uuids = .}}{{end}}{{end}}{{end}}
<div class="form-group">
<label for="uuid_heartbeat">Heartbeat</label>
<input type="text" id="uuid_heartbeat" name="uuid_heartbeat"
value="{{with $uuids}}{{with index . "heartbeat"}}{{.}}{{end}}{{end}}"
placeholder="UUID">
</div>
<div class="form-group">
<label for="uuid_system_health">System Health</label>
<input type="text" id="uuid_system_health" name="uuid_system_health"
value="{{with $uuids}}{{with index . "system_health"}}{{.}}{{end}}{{end}}"
placeholder="UUID">
</div>
<div class="form-group">
<label for="uuid_db_dump">DB Dump</label>
<input type="text" id="uuid_db_dump" name="uuid_db_dump"
value="{{with $uuids}}{{with index . "db_dump"}}{{.}}{{end}}{{end}}"
placeholder="UUID">
</div>
<div class="form-group">
<label for="uuid_backup">Backup</label>
<input type="text" id="uuid_backup" name="uuid_backup"
value="{{with $uuids}}{{with index . "backup"}}{{.}}{{end}}{{end}}"
placeholder="UUID">
</div>
<div class="form-group">
<label for="uuid_backup_integrity">Backup Integrity</label>
<input type="text" id="uuid_backup_integrity" name="uuid_backup_integrity"
value="{{with $uuids}}{{with index . "backup_integrity"}}{{.}}{{end}}{{end}}"
placeholder="UUID">
</div>
</div>
</details>
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
<button type="submit" class="btn">{{if .IsNew}}Create Configuration{{else}}Save Changes{{end}}</button>
<a href="{{if .IsNew}}/configs{{else}}/customers/{{.Config.CustomerID}}{{end}}" class="btn btn-outline">Cancel</a>
@@ -226,6 +226,76 @@
{{end}}
</section>
<!-- Geo-restriction -->
{{if .HasReports}}
{{with .Report.geo_restriction}}
<section class="card">
<h2>Geo-korlátozás</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">Állapot</span>
<span class="value">
{{if index . "enabled"}}
<span class="severity-badge severity-critical">Aktív</span>
{{else}}
<span class="severity-badge severity-ok">Inaktív</span>
{{end}}
</span>
</div>
{{if index . "enabled"}}
<div class="info-item">
<span class="label">Engedélyezett országok</span>
<span class="value">
{{$countries := index . "allowed_countries"}}
{{if $countries}}
{{range $i, $c := $countries}}{{if $i}}, {{end}}{{$c}}{{end}}
{{else}}
{{end}}
</span>
</div>
{{end}}
{{if index . "last_sync"}}
<div class="info-item">
<span class="label">Utolsó szinkron</span>
<span class="value">{{index . "last_sync"}}</span>
</div>
{{end}}
{{if index . "last_sync_error"}}
<div class="info-item">
<span class="label">Szinkron hiba</span>
<span class="value" style="color: #f87171">{{index . "last_sync_error"}}</span>
</div>
{{end}}
</div>
{{$overrides := index . "app_overrides"}}
{{if $overrides}}
<h3 style="margin-top: 1rem; font-size: 0.95rem;">Alkalmazás felülírások</h3>
<table class="data-table" style="margin-top: 0.5rem;">
<thead><tr><th>Alkalmazás</th><th>Engedélyezett országok</th></tr></thead>
<tbody>
{{range $app, $override := $overrides}}
<tr>
<td>{{$app}}</td>
<td>
{{$ac := index $override "allowed_countries"}}
{{if $ac}}{{range $i, $c := $ac}}{{if $i}}, {{end}}{{$c}}{{end}}{{else}}—{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if index . "enabled"}}
<div style="margin-top: 1rem;">
<button class="btn btn-danger" id="btn-geo-disable" onclick="disableGeo('{{$.Customer.CustomerID}}')">Összes geo-korlátozás eltávolítása</button>
<span id="geo-msg" style="display:none; margin-left: 0.75rem;"></span>
</div>
{{end}}
</section>
{{end}}
{{end}}
<!-- Infra Backup -->
<section class="card">
<h2>Infra Backup</h2>
@@ -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');