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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user