Files
felhom.eu/hub/internal/cloudflare/unblock.go
T
admin 5e2012728f 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>
2026-02-25 12:43:00 +01:00

216 lines
5.6 KiB
Go

// 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
}