95c821deb2
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>
335 lines
9.8 KiB
Go
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)
|
|
}
|
|
}
|