557629d2bf
gates / gates (push) Successful in 2m3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
229 lines
6.2 KiB
Go
229 lines
6.2 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 the exact domain first, then every parent down to two labels (R-134: the controller's
|
|
// GetZoneID strips progressively; stripping ONE label missed the zone for a deeper name).
|
|
for _, name := range zoneCandidates(domain) {
|
|
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)
|
|
}
|
|
|
|
// zoneCandidates lists the names a zone lookup tries, most specific first: the domain itself, then each
|
|
// parent that still has at least two labels ("a.b.felhom.eu" → a.b.felhom.eu, b.felhom.eu, felhom.eu).
|
|
// A bare TLD is never tried — no Cloudflare zone is named "eu". Pinned by TestZoneCandidates.
|
|
func zoneCandidates(domain string) []string {
|
|
domain = strings.Trim(strings.TrimSpace(domain), ".")
|
|
if domain == "" {
|
|
return nil
|
|
}
|
|
out := []string{domain}
|
|
for name := domain; ; {
|
|
i := strings.IndexByte(name, '.')
|
|
if i < 0 {
|
|
break
|
|
}
|
|
name = name[i+1:]
|
|
if !strings.Contains(name, ".") {
|
|
break // a single label (the TLD) is not a zone
|
|
}
|
|
out = append(out, name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
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
|
|
}
|