Files
deploy-felhom-compose/controller/internal/cloudflare/geosync.go
T
admin 95c821deb2 feat: comprehensive debug logging across all controller modules
Add detailed [DEBUG] logging to every controller module when
logging.level is set to "debug". Each module with stateful debug
uses SetDebug(bool) wired from main.go. Covers stacks, backup,
cloudflare, integrations, system, monitor, settings, scheduler,
web handlers, storage, metrics, API, selfupdate, and assets.

Also includes the app export/import (.fab bundles) feature from
v0.32.0 and its debug page integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:14:43 +01:00

335 lines
9.8 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()
}()
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 == "" {
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, "")
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 == "" {
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("[GEO] Warning: 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("[GEO] Warning: could not delete rule %s: %v", r.ID, err)
} else {
deleted++
}
}
if len(existing) > 0 {
g.logger.Printf("[GEO] 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)
}
} 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)
}
}
}
// 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)
}
}
}
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)
}
}