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:
2026-02-25 11:58:22 +01:00
parent 4c5d430b1a
commit e1fb85240b
15 changed files with 2091 additions and 3 deletions
+116
View File
@@ -46,6 +46,9 @@ type Settings struct {
// Pending events (queued for next Hub push)
PendingEvents []PendingEvent `json:"pending_events,omitempty"`
// Geo-restriction settings (Cloudflare WAF rules)
GeoRestriction *GeoRestriction `json:"geo_restriction,omitempty"`
}
// AppBackupPrefs holds per-app backup toggle state.
@@ -116,6 +119,24 @@ type PendingEvent struct {
CreatedAt string `json:"created_at"` // RFC3339
}
// GeoRestriction holds global and per-app geo-restriction settings.
type GeoRestriction struct {
Enabled bool `json:"enabled"`
AllowedCountries []string `json:"allowed_countries"`
AppOverrides map[string]AppGeoOverride `json:"app_overrides,omitempty"`
// Sync state (updated by geo sync manager)
LastSync string `json:"last_sync,omitempty"` // RFC3339
LastSyncError string `json:"last_sync_error,omitempty"`
ZoneID string `json:"zone_id,omitempty"` // cached Cloudflare zone ID
RulesetID string `json:"ruleset_id,omitempty"` // cached Cloudflare ruleset ID
}
// AppGeoOverride holds per-app country override.
type AppGeoOverride struct {
AllowedCountries []string `json:"allowed_countries"`
}
// DBValidationCache holds cached DB dump validation results.
type DBValidationCache struct {
ValidatedAt string `json:"validated_at"` // RFC3339
@@ -791,3 +812,98 @@ func (s *Settings) DrainPendingEvents() []PendingEvent {
}
return events
}
// --- Geo-Restriction ---
// GetGeoRestriction returns a deep copy of the geo-restriction settings.
func (s *Settings) GetGeoRestriction() *GeoRestriction {
s.mu.RLock()
defer s.mu.RUnlock()
if s.GeoRestriction == nil {
return nil
}
geo := *s.GeoRestriction
if len(s.GeoRestriction.AllowedCountries) > 0 {
geo.AllowedCountries = make([]string, len(s.GeoRestriction.AllowedCountries))
copy(geo.AllowedCountries, s.GeoRestriction.AllowedCountries)
}
if len(s.GeoRestriction.AppOverrides) > 0 {
geo.AppOverrides = make(map[string]AppGeoOverride, len(s.GeoRestriction.AppOverrides))
for k, v := range s.GeoRestriction.AppOverrides {
ov := AppGeoOverride{AllowedCountries: make([]string, len(v.AllowedCountries))}
copy(ov.AllowedCountries, v.AllowedCountries)
geo.AppOverrides[k] = ov
}
}
return &geo
}
// SetGeoRestriction replaces the entire geo-restriction config and saves to disk.
func (s *Settings) SetGeoRestriction(geo *GeoRestriction) error {
s.mu.Lock()
defer s.mu.Unlock()
if geo == nil {
s.GeoRestriction = nil
return s.save()
}
cp := *geo
if len(geo.AllowedCountries) > 0 {
cp.AllowedCountries = make([]string, len(geo.AllowedCountries))
copy(cp.AllowedCountries, geo.AllowedCountries)
}
if len(geo.AppOverrides) > 0 {
cp.AppOverrides = make(map[string]AppGeoOverride, len(geo.AppOverrides))
for k, v := range geo.AppOverrides {
ov := AppGeoOverride{AllowedCountries: make([]string, len(v.AllowedCountries))}
copy(ov.AllowedCountries, v.AllowedCountries)
cp.AppOverrides[k] = ov
}
}
s.GeoRestriction = &cp
return s.save()
}
// SetGeoAppOverride sets a per-app geo override. Creates the GeoRestriction if nil.
func (s *Settings) SetGeoAppOverride(appName string, override *AppGeoOverride) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.GeoRestriction == nil {
s.GeoRestriction = &GeoRestriction{AllowedCountries: []string{"HU"}}
}
if s.GeoRestriction.AppOverrides == nil {
s.GeoRestriction.AppOverrides = make(map[string]AppGeoOverride)
}
ov := AppGeoOverride{AllowedCountries: make([]string, len(override.AllowedCountries))}
copy(ov.AllowedCountries, override.AllowedCountries)
s.GeoRestriction.AppOverrides[appName] = ov
return s.save()
}
// RemoveGeoAppOverride removes a per-app override (app falls back to global).
func (s *Settings) RemoveGeoAppOverride(appName string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.GeoRestriction == nil || s.GeoRestriction.AppOverrides == nil {
return nil
}
delete(s.GeoRestriction.AppOverrides, appName)
return s.save()
}
// SetGeoSyncState updates the geo sync status fields.
func (s *Settings) SetGeoSyncState(zoneID, rulesetID, syncError string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.GeoRestriction == nil {
return nil
}
s.GeoRestriction.LastSync = time.Now().UTC().Format(time.RFC3339)
s.GeoRestriction.LastSyncError = syncError
if zoneID != "" {
s.GeoRestriction.ZoneID = zoneID
}
if rulesetID != "" {
s.GeoRestriction.RulesetID = rulesetID
}
return s.save()
}