feat: geo-restriction via Cloudflare WAF custom rules

Add country-based access control managed through the Settings page.
Global allow-list with per-app overrides, searchable country selector,
automatic sync to Cloudflare WAF on settings change / deploy / remove,
plus periodic 6-hour verification.

New package: internal/cloudflare/ (client, zone, waf, countries, geosync)
New API: /api/geo/* (6 endpoints) + /api/stacks/{name}/geo/override

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 11:58:22 +01:00
parent 4c5d430b1a
commit e1fb85240b
15 changed files with 2091 additions and 3 deletions
+113
View File
@@ -0,0 +1,113 @@
package cloudflare
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
)
const apiBase = "https://api.cloudflare.com/client/v4"
// Client handles Cloudflare API calls.
type Client struct {
apiToken string
httpClient *http.Client
logger *log.Logger
debug bool
}
// New creates a Cloudflare API client.
func New(apiToken string, logger *log.Logger, debug bool) *Client {
return &Client{
apiToken: apiToken,
httpClient: &http.Client{Timeout: 15 * time.Second},
logger: logger,
debug: debug,
}
}
// IsConfigured returns true if a CF API token is set.
func (c *Client) IsConfigured() bool {
return c.apiToken != ""
}
// apiResponse is the generic Cloudflare API response wrapper.
type apiResponse struct {
Success bool `json:"success"`
Errors []apiError `json:"errors"`
Messages []apiMessage `json:"messages"`
Result json.RawMessage `json:"result"`
}
type apiError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type apiMessage struct {
Code int `json:"code"`
Message string `json:"message"`
}
// do performs an HTTP request to the Cloudflare API and decodes the response.
func (c *Client) do(method, path string, body interface{}) (*apiResponse, error) {
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
bodyReader = bytes.NewReader(data)
if c.debug {
c.logger.Printf("[CF-DEBUG] %s %s body=%s", method, path, string(data))
}
} else if c.debug {
c.logger.Printf("[CF-DEBUG] %s %s", method, path)
}
url := apiBase + path
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if c.debug {
c.logger.Printf("[CF-DEBUG] Response %d: %s", resp.StatusCode, string(respBody))
}
var apiResp apiResponse
if err := json.Unmarshal(respBody, &apiResp); err != nil {
return nil, fmt.Errorf("decode response (status %d): %w", resp.StatusCode, err)
}
if !apiResp.Success {
msg := "unknown error"
if len(apiResp.Errors) > 0 {
msg = apiResp.Errors[0].Message
for _, e := range apiResp.Errors[1:] {
msg += "; " + e.Message
}
}
return &apiResp, fmt.Errorf("cloudflare API error (status %d): %s", resp.StatusCode, msg)
}
return &apiResp, nil
}
+276
View File
@@ -0,0 +1,276 @@
package cloudflare
import "sort"
// Country represents a country for the geo selector UI.
type Country struct {
Code string `json:"code"` // ISO 3166-1 alpha-2
Name string `json:"name"` // Hungarian name
}
// countries maps ISO 3166-1 alpha-2 codes to Hungarian country names.
var countries = map[string]string{
"AF": "Afganisztán",
"AL": "Albánia",
"DZ": "Algéria",
"AS": "Amerikai Szamoa",
"AD": "Andorra",
"AO": "Angola",
"AI": "Anguilla",
"AQ": "Antarktisz",
"AG": "Antigua és Barbuda",
"AR": "Argentína",
"AM": "Örményország",
"AW": "Aruba",
"AU": "Ausztrália",
"AT": "Ausztria",
"AZ": "Azerbajdzsán",
"BS": "Bahama-szigetek",
"BH": "Bahrein",
"BD": "Banglades",
"BB": "Barbados",
"BY": "Fehéroroszország",
"BE": "Belgium",
"BZ": "Belize",
"BJ": "Benin",
"BM": "Bermuda",
"BT": "Bhután",
"BO": "Bolívia",
"BA": "Bosznia-Hercegovina",
"BW": "Botswana",
"BR": "Brazília",
"BN": "Brunei",
"BG": "Bulgária",
"BF": "Burkina Faso",
"BI": "Burundi",
"CV": "Cabo Verde",
"KH": "Kambodzsa",
"CM": "Kamerun",
"CA": "Kanada",
"KY": "Kajmán-szigetek",
"CF": "Közép-afrikai Köztársaság",
"TD": "Csád",
"CL": "Chile",
"CN": "Kína",
"CO": "Kolumbia",
"KM": "Comore-szigetek",
"CG": "Kongó",
"CD": "Kongói Demokratikus Köztársaság",
"CK": "Cook-szigetek",
"CR": "Costa Rica",
"CI": "Elefántcsontpart",
"HR": "Horvátország",
"CU": "Kuba",
"CW": "Curaçao",
"CY": "Ciprus",
"CZ": "Csehország",
"DK": "Dánia",
"DJ": "Dzsibuti",
"DM": "Dominika",
"DO": "Dominikai Köztársaság",
"EC": "Ecuador",
"EG": "Egyiptom",
"SV": "Salvador",
"GQ": "Egyenlítői-Guinea",
"ER": "Eritrea",
"EE": "Észtország",
"SZ": "Eswatini",
"ET": "Etiópia",
"FK": "Falkland-szigetek",
"FO": "Feröer-szigetek",
"FJ": "Fidzsi-szigetek",
"FI": "Finnország",
"FR": "Franciaország",
"GF": "Francia Guyana",
"PF": "Francia Polinézia",
"GA": "Gabon",
"GM": "Gambia",
"GE": "Grúzia",
"DE": "Németország",
"GH": "Ghána",
"GI": "Gibraltár",
"GR": "Görögország",
"GL": "Grönland",
"GD": "Grenada",
"GP": "Guadeloupe",
"GU": "Guam",
"GT": "Guatemala",
"GG": "Guernsey",
"GN": "Guinea",
"GW": "Bissau-Guinea",
"GY": "Guyana",
"HT": "Haiti",
"HN": "Honduras",
"HK": "Hongkong",
"HU": "Magyarország",
"IS": "Izland",
"IN": "India",
"ID": "Indonézia",
"IR": "Irán",
"IQ": "Irak",
"IE": "Írország",
"IM": "Man-sziget",
"IL": "Izrael",
"IT": "Olaszország",
"JM": "Jamaica",
"JP": "Japán",
"JE": "Jersey",
"JO": "Jordánia",
"KZ": "Kazahsztán",
"KE": "Kenya",
"KI": "Kiribati",
"KP": "Észak-Korea",
"KR": "Dél-Korea",
"KW": "Kuvait",
"KG": "Kirgizisztán",
"LA": "Laosz",
"LV": "Lettország",
"LB": "Libanon",
"LS": "Lesotho",
"LR": "Libéria",
"LY": "Líbia",
"LI": "Liechtenstein",
"LT": "Litvánia",
"LU": "Luxemburg",
"MO": "Makaó",
"MG": "Madagaszkár",
"MW": "Malawi",
"MY": "Malajzia",
"MV": "Maldív-szigetek",
"ML": "Mali",
"MT": "Málta",
"MH": "Marshall-szigetek",
"MQ": "Martinique",
"MR": "Mauritánia",
"MU": "Mauritius",
"YT": "Mayotte",
"MX": "Mexikó",
"FM": "Mikronézia",
"MD": "Moldova",
"MC": "Monaco",
"MN": "Mongólia",
"ME": "Montenegró",
"MS": "Montserrat",
"MA": "Marokkó",
"MZ": "Mozambik",
"MM": "Mianmar",
"NA": "Namíbia",
"NR": "Nauru",
"NP": "Nepál",
"NL": "Hollandia",
"NC": "Új-Kaledónia",
"NZ": "Új-Zéland",
"NI": "Nicaragua",
"NE": "Niger",
"NG": "Nigéria",
"NU": "Niue",
"NF": "Norfolk-sziget",
"MK": "Észak-Macedónia",
"MP": "Északi-Mariana-szigetek",
"NO": "Norvégia",
"OM": "Omán",
"PK": "Pakisztán",
"PW": "Palau",
"PS": "Palesztina",
"PA": "Panama",
"PG": "Pápua Új-Guinea",
"PY": "Paraguay",
"PE": "Peru",
"PH": "Fülöp-szigetek",
"PL": "Lengyelország",
"PT": "Portugália",
"PR": "Puerto Rico",
"QA": "Katar",
"RE": "Réunion",
"RO": "Románia",
"RU": "Oroszország",
"RW": "Ruanda",
"BL": "Saint-Barthélemy",
"SH": "Szent Ilona",
"KN": "Saint Kitts és Nevis",
"LC": "Saint Lucia",
"MF": "Saint-Martin",
"PM": "Saint-Pierre és Miquelon",
"VC": "Saint Vincent és a Grenadine-szigetek",
"WS": "Szamoa",
"SM": "San Marino",
"ST": "São Tomé és Príncipe",
"SA": "Szaúd-Arábia",
"SN": "Szenegál",
"RS": "Szerbia",
"SC": "Seychelle-szigetek",
"SL": "Sierra Leone",
"SG": "Szingapúr",
"SX": "Sint Maarten",
"SK": "Szlovákia",
"SI": "Szlovénia",
"SB": "Salamon-szigetek",
"SO": "Szomália",
"ZA": "Dél-afrikai Köztársaság",
"SS": "Dél-Szudán",
"ES": "Spanyolország",
"LK": "Srí Lanka",
"SD": "Szudán",
"SR": "Suriname",
"SE": "Svédország",
"CH": "Svájc",
"SY": "Szíria",
"TW": "Tajvan",
"TJ": "Tádzsikisztán",
"TZ": "Tanzánia",
"TH": "Thaiföld",
"TL": "Kelet-Timor",
"TG": "Togo",
"TK": "Tokelau",
"TO": "Tonga",
"TT": "Trinidad és Tobago",
"TN": "Tunézia",
"TR": "Törökország",
"TM": "Türkmenisztán",
"TC": "Turks- és Caicos-szigetek",
"TV": "Tuvalu",
"UG": "Uganda",
"UA": "Ukrajna",
"AE": "Egyesült Arab Emírségek",
"GB": "Egyesült Királyság",
"US": "Egyesült Államok",
"UY": "Uruguay",
"UZ": "Üzbegisztán",
"VU": "Vanuatu",
"VA": "Vatikán",
"VE": "Venezuela",
"VN": "Vietnám",
"VG": "Brit Virgin-szigetek",
"VI": "Amerikai Virgin-szigetek",
"WF": "Wallis és Futuna",
"EH": "Nyugat-Szahara",
"YE": "Jemen",
"ZM": "Zambia",
"ZW": "Zimbabwe",
}
// AllCountries returns all countries sorted by Hungarian name.
func AllCountries() []Country {
result := make([]Country, 0, len(countries))
for code, name := range countries {
result = append(result, Country{Code: code, Name: name})
}
sort.Slice(result, func(i, j int) bool {
return result[i].Name < result[j].Name
})
return result
}
// CountryName returns the Hungarian name for a country code, or the code itself if unknown.
func CountryName(code string) string {
if name, ok := countries[code]; ok {
return name
}
return code
}
// ValidCountryCode returns true if code is a valid ISO 3166-1 alpha-2 code.
func ValidCountryCode(code string) bool {
_, ok := countries[code]
return ok
}
+254
View File
@@ -0,0 +1,254 @@
package cloudflare
import (
"context"
"fmt"
"log"
"sort"
"sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// StackLister provides deployed app hostnames (interface to break circular import).
type StackLister interface {
// GetDeployedHostnames returns appName → full hostname (e.g., "nextcloud.demo-felhom.eu").
GetDeployedHostnames() map[string]string
}
// GeoSyncManager synchronizes geo-restriction settings to Cloudflare WAF rules.
type GeoSyncManager struct {
client *Client
settings *settings.Settings
domain string
stacks StackLister
logger *log.Logger
mu sync.Mutex
running bool
}
// NewGeoSyncManager creates a new geo sync manager.
func NewGeoSyncManager(client *Client, sett *settings.Settings, domain string, stacks StackLister, logger *log.Logger) *GeoSyncManager {
return &GeoSyncManager{
client: client,
settings: sett,
domain: domain,
stacks: stacks,
logger: logger,
}
}
// IsRunning returns true if a sync operation is in progress.
func (g *GeoSyncManager) IsRunning() bool {
g.mu.Lock()
defer g.mu.Unlock()
return g.running
}
// Sync reads current geo settings and pushes/updates/deletes CF WAF rules.
func (g *GeoSyncManager) Sync(ctx context.Context) error {
g.mu.Lock()
if g.running {
g.mu.Unlock()
return fmt.Errorf("sync already in progress")
}
g.running = true
g.mu.Unlock()
defer func() {
g.mu.Lock()
g.running = false
g.mu.Unlock()
}()
geo := g.settings.GetGeoRestriction()
// If geo is nil or disabled, delete all felhom rules and return.
if geo == nil || !geo.Enabled {
return g.deleteAllRules(ctx, geo)
}
g.logger.Printf("[GEO] Starting sync for domain %s (%d allowed countries, %d app overrides)",
g.domain, len(geo.AllowedCountries), len(geo.AppOverrides))
// 1. Resolve zone ID (use cached value if available)
zoneID := geo.ZoneID
if zoneID == "" {
var err error
zoneID, err = g.client.GetZoneID(g.domain)
if err != nil {
g.saveError(zoneID, "", err.Error())
return fmt.Errorf("resolve zone: %w", err)
}
}
// 2. Get or create the custom WAF ruleset
rulesetID := geo.RulesetID
if rulesetID == "" {
var err error
rulesetID, err = g.client.GetCustomRulesetID(zoneID)
if err != nil {
g.saveError(zoneID, "", err.Error())
return fmt.Errorf("get ruleset: %w", err)
}
if rulesetID == "" {
rulesetID, err = g.client.CreateCustomRuleset(zoneID)
if err != nil {
g.saveError(zoneID, "", err.Error())
return fmt.Errorf("create ruleset: %w", err)
}
}
}
// 3. List existing felhom-managed rules
existing, err := g.client.GetFelhomRules(zoneID, rulesetID)
if err != nil {
g.saveError(zoneID, rulesetID, err.Error())
return fmt.Errorf("list existing rules: %w", err)
}
// 4. Build desired rules
desired := g.buildDesiredRules(geo)
// 5. Diff and apply
if err := g.applyDiff(zoneID, rulesetID, existing, desired); err != nil {
g.saveError(zoneID, rulesetID, err.Error())
return fmt.Errorf("apply diff: %w", err)
}
// 6. Save success state
g.saveError(zoneID, rulesetID, "")
g.logger.Printf("[GEO] Sync completed successfully")
return nil
}
// deleteAllRules removes all felhom-geo rules when the feature is disabled.
func (g *GeoSyncManager) deleteAllRules(ctx context.Context, geo *settings.GeoRestriction) error {
// Need zone and ruleset IDs to delete rules
zoneID := ""
rulesetID := ""
if geo != nil {
zoneID = geo.ZoneID
rulesetID = geo.RulesetID
}
if zoneID == "" || rulesetID == "" {
// No cached IDs — nothing to clean up
return nil
}
existing, err := g.client.GetFelhomRules(zoneID, rulesetID)
if err != nil {
g.logger.Printf("[GEO] Warning: could not list rules for cleanup: %v", err)
return nil
}
for _, r := range existing {
if err := g.client.DeleteRule(zoneID, rulesetID, r.ID); err != nil {
g.logger.Printf("[GEO] Warning: could not delete rule %s: %v", r.ID, err)
}
}
if len(existing) > 0 {
g.logger.Printf("[GEO] Deleted %d felhom-geo rules (feature disabled)", len(existing))
}
g.saveError(zoneID, rulesetID, "")
return nil
}
// desiredRule describes a rule that should exist.
type desiredRule struct {
description string
expression string
}
// buildDesiredRules builds the set of rules that should exist in Cloudflare.
func (g *GeoSyncManager) buildDesiredRules(geo *settings.GeoRestriction) []desiredRule {
var rules []desiredRule
hostnames := g.stacks.GetDeployedHostnames()
// Collect app hostnames that have overrides (to exclude from global rule)
var excludeHostnames []string
overrideApps := make(map[string]bool)
for appName, override := range geo.AppOverrides {
hostname, ok := hostnames[appName]
if !ok {
continue // app not deployed, skip
}
overrideApps[appName] = true
excludeHostnames = append(excludeHostnames, hostname)
// Per-app rule
rules = append(rules, desiredRule{
description: AppRuleDescription(appName),
expression: BuildAppExpression(hostname, override.AllowedCountries),
})
}
// Sort exclude hostnames for deterministic expression
sort.Strings(excludeHostnames)
// Global rule (excludes apps with their own rules)
rules = append(rules, desiredRule{
description: globalRuleDesc,
expression: BuildGlobalExpression(geo.AllowedCountries, excludeHostnames),
})
return rules
}
// applyDiff applies the difference between existing and desired rules.
func (g *GeoSyncManager) applyDiff(zoneID, rulesetID string, existing []GeoRule, desired []desiredRule) error {
// Index existing by description
existingByDesc := make(map[string]GeoRule)
for _, r := range existing {
existingByDesc[r.Description] = r
}
// Index desired by description
desiredByDesc := make(map[string]desiredRule)
for _, r := range desired {
desiredByDesc[r.description] = r
}
// Create or update
for _, d := range desired {
if ex, ok := existingByDesc[d.description]; ok {
// Rule exists — check if expression changed
if ex.Expression != d.expression {
r := newBlockRule(d.description, d.expression)
if err := g.client.UpdateRule(zoneID, rulesetID, ex.ID, r); err != nil {
return fmt.Errorf("update rule %q: %w", d.description, err)
}
}
} else {
// New rule — create
r := newBlockRule(d.description, d.expression)
if _, err := g.client.CreateRule(zoneID, rulesetID, r); err != nil {
return fmt.Errorf("create rule %q: %w", d.description, err)
}
}
}
// Delete rules that are no longer desired
for _, ex := range existing {
if _, ok := desiredByDesc[ex.Description]; !ok {
if err := g.client.DeleteRule(zoneID, rulesetID, ex.ID); err != nil {
return fmt.Errorf("delete rule %q: %w", ex.Description, err)
}
}
}
return nil
}
// saveError updates the sync state in settings.
func (g *GeoSyncManager) saveError(zoneID, rulesetID, errMsg string) {
if err := g.settings.SetGeoSyncState(zoneID, rulesetID, errMsg); err != nil {
g.logger.Printf("[GEO] Warning: failed to save sync state: %v", err)
}
}
+272
View File
@@ -0,0 +1,272 @@
package cloudflare
import (
"encoding/json"
"fmt"
"strings"
)
const (
// rulePrefix identifies felhom-managed WAF rules.
rulePrefix = "[felhom-geo]"
// wafPhase is the Cloudflare ruleset phase for custom WAF rules.
wafPhase = "http_request_firewall_custom"
// globalRuleDesc is the description for the global geo-restriction rule.
globalRuleDesc = "[felhom-geo] Global"
// appRuleDescPrefix is the prefix for per-app geo-restriction rules.
appRuleDescPrefix = "[felhom-geo] app:"
)
// ruleset represents a Cloudflare ruleset (minimal fields).
type ruleset struct {
ID string `json:"id"`
Name string `json:"name"`
Phase string `json:"phase"`
Kind string `json:"kind"`
}
// rule represents a Cloudflare custom rule.
type rule struct {
ID string `json:"id,omitempty"`
Description string `json:"description"`
Expression string `json:"expression"`
Action string `json:"action"`
ActionParameters *actionParameters `json:"action_parameters,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
type actionParameters struct {
Response *blockResponse `json:"response,omitempty"`
}
type blockResponse struct {
StatusCode int `json:"status_code"`
Content string `json:"content"`
ContentType string `json:"content_type"`
}
// GeoRule represents a felhom-managed WAF custom rule (for external consumption).
type GeoRule struct {
ID string
Description string
Expression string
Action string
}
// GetCustomRulesetID returns the zone's http_request_firewall_custom ruleset ID.
// Returns empty string if no such ruleset exists yet.
func (c *Client) GetCustomRulesetID(zoneID string) (string, error) {
path := fmt.Sprintf("/zones/%s/rulesets", zoneID)
resp, err := c.do("GET", path, nil)
if err != nil {
return "", fmt.Errorf("list rulesets: %w", err)
}
var rulesets []ruleset
if err := json.Unmarshal(resp.Result, &rulesets); err != nil {
return "", fmt.Errorf("decode rulesets: %w", err)
}
for _, rs := range rulesets {
if rs.Phase == wafPhase {
return rs.ID, nil
}
}
return "", nil
}
// CreateCustomRuleset creates the http_request_firewall_custom phase entry point ruleset.
func (c *Client) CreateCustomRuleset(zoneID string) (string, error) {
path := fmt.Sprintf("/zones/%s/rulesets", zoneID)
body := map[string]interface{}{
"name": "felhom custom rules",
"kind": "zone",
"phase": wafPhase,
"rules": []interface{}{},
}
resp, err := c.do("POST", path, body)
if err != nil {
return "", fmt.Errorf("create ruleset: %w", err)
}
var rs ruleset
if err := json.Unmarshal(resp.Result, &rs); err != nil {
return "", fmt.Errorf("decode created ruleset: %w", err)
}
c.logger.Printf("[CF] Created custom ruleset %s for zone %s", rs.ID, zoneID)
return rs.ID, nil
}
// GetRules returns all rules in a ruleset.
func (c *Client) GetRules(zoneID, rulesetID string) ([]rule, error) {
path := fmt.Sprintf("/zones/%s/rulesets/%s", zoneID, rulesetID)
resp, err := c.do("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("get ruleset: %w", err)
}
var rs struct {
Rules []rule `json:"rules"`
}
if err := json.Unmarshal(resp.Result, &rs); err != nil {
return nil, fmt.Errorf("decode rules: %w", err)
}
return rs.Rules, nil
}
// GetFelhomRules returns only rules with the [felhom-geo] prefix.
func (c *Client) GetFelhomRules(zoneID, rulesetID string) ([]GeoRule, error) {
rules, err := c.GetRules(zoneID, rulesetID)
if err != nil {
return nil, err
}
var result []GeoRule
for _, r := range rules {
if strings.HasPrefix(r.Description, rulePrefix) {
result = append(result, GeoRule{
ID: r.ID,
Description: r.Description,
Expression: r.Expression,
Action: r.Action,
})
}
}
return result, nil
}
// CreateRule adds a new rule to the ruleset.
func (c *Client) CreateRule(zoneID, rulesetID string, r rule) (string, error) {
path := fmt.Sprintf("/zones/%s/rulesets/%s/rules", zoneID, rulesetID)
resp, err := c.do("POST", path, r)
if err != nil {
return "", fmt.Errorf("create rule: %w", err)
}
// The response is the full ruleset; find the new rule by description.
var rs struct {
Rules []rule `json:"rules"`
}
if err := json.Unmarshal(resp.Result, &rs); err != nil {
return "", fmt.Errorf("decode created rule response: %w", err)
}
for _, created := range rs.Rules {
if created.Description == r.Description {
c.logger.Printf("[CF] Created rule %q → %s", r.Description, created.ID)
return created.ID, nil
}
}
return "", fmt.Errorf("created rule not found in response")
}
// UpdateRule updates an existing rule in the ruleset.
func (c *Client) UpdateRule(zoneID, rulesetID, ruleID string, r rule) error {
path := fmt.Sprintf("/zones/%s/rulesets/%s/rules/%s", zoneID, rulesetID, ruleID)
_, err := c.do("PATCH", path, r)
if err != nil {
return fmt.Errorf("update rule %s: %w", ruleID, err)
}
c.logger.Printf("[CF] Updated rule %q (%s)", r.Description, ruleID)
return nil
}
// DeleteRule removes a rule from the ruleset.
func (c *Client) DeleteRule(zoneID, rulesetID, ruleID string) error {
path := fmt.Sprintf("/zones/%s/rulesets/%s/rules/%s", zoneID, rulesetID, ruleID)
_, err := c.do("DELETE", path, nil)
if err != nil {
return fmt.Errorf("delete rule %s: %w", ruleID, err)
}
c.logger.Printf("[CF] Deleted rule %s", ruleID)
return nil
}
// BuildGlobalExpression builds the Cloudflare filter expression for the global geo rule.
// countries: allowed ISO country codes.
// excludeHostnames: app hostnames that have their own rules (excluded from global).
//
// Example output: (not ip.src.country in {"HU" "DE"}) and (http.host ne "app1.example.com")
func BuildGlobalExpression(countries []string, excludeHostnames []string) string {
if len(countries) == 0 {
return "true" // block everything (no countries allowed)
}
// Build country part: (not ip.src.country in {"HU" "DE"})
quoted := make([]string, len(countries))
for i, c := range countries {
quoted[i] = `"` + c + `"`
}
expr := "(not ip.src.country in {" + strings.Join(quoted, " ") + "})"
// Add hostname exclusions for apps with their own rules
for _, host := range excludeHostnames {
expr += ` and (http.host ne "` + host + `")`
}
return expr
}
// BuildAppExpression builds the Cloudflare filter expression for a per-app geo rule.
// hostname: full hostname of the app (e.g., "nextcloud.demo-felhom.eu").
// countries: allowed ISO country codes for this app.
//
// Example output: (http.host eq "nextcloud.demo-felhom.eu" and not ip.src.country in {"HU" "US"})
func BuildAppExpression(hostname string, countries []string) string {
if len(countries) == 0 {
return fmt.Sprintf(`(http.host eq "%s")`, hostname) // block all traffic to this host
}
quoted := make([]string, len(countries))
for i, c := range countries {
quoted[i] = `"` + c + `"`
}
return fmt.Sprintf(`(http.host eq "%s" and not ip.src.country in {%s})`,
hostname, strings.Join(quoted, " "))
}
// AppRuleDescription returns the rule description for a per-app rule.
func AppRuleDescription(appName string) string {
return appRuleDescPrefix + appName
}
// IsGlobalRule checks if a rule description matches the global rule.
func IsGlobalRule(desc string) bool {
return desc == globalRuleDesc
}
// IsAppRule checks if a rule description is a per-app rule and returns the app name.
func IsAppRule(desc string) (string, bool) {
if strings.HasPrefix(desc, appRuleDescPrefix) {
return strings.TrimPrefix(desc, appRuleDescPrefix), true
}
return "", false
}
// newBlockRule creates a rule struct with the standard felhom block response.
func newBlockRule(description, expression string) rule {
enabled := true
return rule{
Description: description,
Expression: expression,
Action: "block",
ActionParameters: &actionParameters{
Response: &blockResponse{
StatusCode: 403,
Content: "Hozzáférés megtagadva — az Ön országa nem engedélyezett.",
ContentType: "text/plain",
},
},
Enabled: &enabled,
}
}
+66
View File
@@ -0,0 +1,66 @@
package cloudflare
import (
"encoding/json"
"fmt"
"net/url"
)
// zone represents a Cloudflare zone (minimal fields).
type zone struct {
ID string `json:"id"`
Name string `json:"name"`
}
// GetZoneID resolves the Cloudflare zone ID for a domain.
// It tries the exact domain first, then strips subdomains progressively.
func (c *Client) GetZoneID(domain string) (string, error) {
// Try exact domain first (e.g., "demo-felhom.eu")
id, err := c.lookupZone(domain)
if err != nil {
return "", err
}
if id != "" {
return id, nil
}
// Try parent domains (e.g., "felhom.eu" from "demo.felhom.eu")
for i := 0; i < len(domain); i++ {
if domain[i] == '.' {
parent := domain[i+1:]
if parent == "" {
break
}
id, err = c.lookupZone(parent)
if err != nil {
return "", err
}
if id != "" {
return id, nil
}
}
}
return "", fmt.Errorf("no Cloudflare zone found for domain %q", domain)
}
// lookupZone queries the CF API for a zone by name.
func (c *Client) lookupZone(name string) (string, error) {
path := "/zones?name=" + url.QueryEscape(name) + "&status=active"
resp, err := c.do("GET", path, nil)
if err != nil {
return "", fmt.Errorf("lookup zone %q: %w", name, err)
}
var zones []zone
if err := json.Unmarshal(resp.Result, &zones); err != nil {
return "", fmt.Errorf("decode zones: %w", err)
}
if len(zones) == 0 {
return "", nil
}
c.logger.Printf("[CF] Resolved zone %q → %s", name, zones[0].ID)
return zones[0].ID, nil
}