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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user