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