8e61cd7ec4
Add structured operational logging at INFO, WARN, and ERROR levels to every controller module. Standardize custom prefixes ([GEO], [SCHED], [SYNC]) to use [INFO/WARN/ERROR] [module] format. Fix misleveled logs (WARN->ERROR for data loss scenarios, WARN->INFO for routine operations). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
345 lines
10 KiB
Go
345 lines
10 KiB
Go
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
|
|
debug bool
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
// SetDebug enables or disables debug logging for the geo sync manager.
|
|
func (g *GeoSyncManager) SetDebug(debug bool) {
|
|
g.debug = debug
|
|
}
|
|
|
|
// 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()
|
|
}()
|
|
|
|
g.logger.Printf("[INFO] [cloudflare] Geo-restriction sync starting")
|
|
|
|
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("[INFO] [cloudflare] 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 == "" {
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] Zone ID not cached, resolving via API for domain %s", g.domain)
|
|
}
|
|
var err error
|
|
zoneID, err = g.client.GetZoneID(ctx, g.domain)
|
|
if err != nil {
|
|
g.saveError(zoneID, "", err.Error())
|
|
return fmt.Errorf("resolve zone: %w", err)
|
|
}
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] Resolved zone ID: %s", zoneID)
|
|
}
|
|
} else if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] Using cached zone ID: %s", zoneID)
|
|
}
|
|
|
|
// 2. Get or create the custom WAF ruleset
|
|
rulesetID := geo.RulesetID
|
|
if rulesetID == "" {
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] Ruleset ID not cached, looking up for zone %s", zoneID)
|
|
}
|
|
var err error
|
|
rulesetID, err = g.client.GetCustomRulesetID(ctx, zoneID)
|
|
if err != nil {
|
|
g.saveError(zoneID, "", err.Error())
|
|
return fmt.Errorf("get ruleset: %w", err)
|
|
}
|
|
if rulesetID == "" {
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] No existing custom ruleset found, creating new one")
|
|
}
|
|
rulesetID, err = g.client.CreateCustomRuleset(ctx, zoneID)
|
|
if err != nil {
|
|
g.saveError(zoneID, "", err.Error())
|
|
return fmt.Errorf("create ruleset: %w", err)
|
|
}
|
|
}
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] Using ruleset ID: %s", rulesetID)
|
|
}
|
|
} else if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] Using cached ruleset ID: %s", rulesetID)
|
|
}
|
|
|
|
// 3. List existing felhom-managed rules
|
|
existing, err := g.client.GetFelhomRules(ctx, zoneID, rulesetID)
|
|
if err != nil {
|
|
g.saveError(zoneID, rulesetID, err.Error())
|
|
return fmt.Errorf("list existing rules: %w", err)
|
|
}
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] Found %d existing felhom rules", len(existing))
|
|
for _, r := range existing {
|
|
g.logger.Printf("[DEBUG] [cloudflare] existing: %s (id=%s)", r.Description, r.ID)
|
|
}
|
|
}
|
|
|
|
// 4. Build desired rules
|
|
desired := g.buildDesiredRules(geo)
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] Built %d desired rules", len(desired))
|
|
for _, d := range desired {
|
|
g.logger.Printf("[DEBUG] [cloudflare] desired: %s", d.description)
|
|
}
|
|
}
|
|
|
|
// 5. Diff and apply
|
|
if err := g.applyDiff(ctx, 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, "")
|
|
// Count rules for summary
|
|
finalRules, _ := g.client.GetFelhomRules(ctx, zoneID, rulesetID)
|
|
g.logger.Printf("[INFO] [cloudflare] Geo-restriction sync complete (%d active rules)", len(finalRules))
|
|
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 == "" {
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] deleteAllRules: no cached zone/ruleset IDs, nothing to clean up")
|
|
}
|
|
// No cached IDs — nothing to clean up
|
|
return nil
|
|
}
|
|
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] deleteAllRules: listing rules for zone=%s ruleset=%s", zoneID, rulesetID)
|
|
}
|
|
|
|
existing, err := g.client.GetFelhomRules(ctx, zoneID, rulesetID)
|
|
if err != nil {
|
|
g.logger.Printf("[WARN] [cloudflare] Could not list rules for cleanup: %v", err)
|
|
return nil
|
|
}
|
|
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] deleteAllRules: found %d felhom rules to delete", len(existing))
|
|
}
|
|
|
|
deleted := 0
|
|
for _, r := range existing {
|
|
if err := g.client.DeleteRule(ctx, zoneID, rulesetID, r.ID); err != nil {
|
|
g.logger.Printf("[ERROR] [cloudflare] Failed to delete WAF rule %s: %v", r.ID, err)
|
|
} else {
|
|
deleted++
|
|
}
|
|
}
|
|
|
|
if len(existing) > 0 {
|
|
g.logger.Printf("[INFO] [cloudflare] Deleted %d felhom-geo rules (feature disabled)", len(existing))
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] deleteAllRules: successfully deleted %d/%d rules", deleted, 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()
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] buildDesiredRules: %d deployed hostnames from stacks", len(hostnames))
|
|
}
|
|
|
|
// 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 {
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] buildDesiredRules: skipping override for %q (not deployed)", appName)
|
|
}
|
|
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),
|
|
})
|
|
}
|
|
|
|
if g.debug && len(overrideApps) > 0 {
|
|
g.logger.Printf("[DEBUG] [cloudflare] buildDesiredRules: %d app overrides active (deployed)", len(overrideApps))
|
|
}
|
|
|
|
// Sort exclude hostnames for deterministic expression
|
|
sort.Strings(excludeHostnames)
|
|
|
|
// Global rule (excludes apps with their own rules)
|
|
globalExpr := BuildGlobalExpression(geo.AllowedCountries, excludeHostnames)
|
|
rules = append(rules, desiredRule{
|
|
description: globalRuleDesc,
|
|
expression: globalExpr,
|
|
})
|
|
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] buildDesiredRules: global rule expression: %s", globalExpr)
|
|
}
|
|
|
|
return rules
|
|
}
|
|
|
|
// applyDiff applies the difference between existing and desired rules.
|
|
func (g *GeoSyncManager) applyDiff(ctx context.Context, 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 {
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] applyDiff: updating rule %q (id=%s) — expression changed", d.description, ex.ID)
|
|
}
|
|
r := newBlockRule(d.description, d.expression)
|
|
if err := g.client.UpdateRule(ctx, zoneID, rulesetID, ex.ID, r); err != nil {
|
|
return fmt.Errorf("update rule %q: %w", d.description, err)
|
|
}
|
|
g.logger.Printf("[INFO] [cloudflare] Updated WAF rule %s", d.description)
|
|
} else if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] applyDiff: rule %q unchanged, skipping", d.description)
|
|
}
|
|
} else {
|
|
// New rule — create
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] applyDiff: creating new rule %q", d.description)
|
|
}
|
|
r := newBlockRule(d.description, d.expression)
|
|
if _, err := g.client.CreateRule(ctx, zoneID, rulesetID, r); err != nil {
|
|
return fmt.Errorf("create rule %q: %w", d.description, err)
|
|
}
|
|
g.logger.Printf("[INFO] [cloudflare] Created WAF rule %s", d.description)
|
|
}
|
|
}
|
|
|
|
// Delete rules that are no longer desired
|
|
for _, ex := range existing {
|
|
if _, ok := desiredByDesc[ex.Description]; !ok {
|
|
if g.debug {
|
|
g.logger.Printf("[DEBUG] [cloudflare] applyDiff: deleting obsolete rule %q (id=%s)", ex.Description, ex.ID)
|
|
}
|
|
if err := g.client.DeleteRule(ctx, zoneID, rulesetID, ex.ID); err != nil {
|
|
return fmt.Errorf("delete rule %q: %w", ex.Description, err)
|
|
}
|
|
g.logger.Printf("[INFO] [cloudflare] Deleted WAF rule %s", ex.Description)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// saveError updates the sync state in settings.
|
|
func (g *GeoSyncManager) saveError(zoneID, rulesetID, errMsg string) {
|
|
if errMsg != "" {
|
|
g.logger.Printf("[ERROR] [cloudflare] Geo-sync error: %v", errMsg)
|
|
}
|
|
if err := g.settings.SetGeoSyncState(zoneID, rulesetID, errMsg); err != nil {
|
|
g.logger.Printf("[WARN] [cloudflare] Failed to save sync state: %v", err)
|
|
}
|
|
}
|