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
+47
View File
@@ -15,6 +15,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
@@ -46,9 +47,14 @@ type Router struct {
// OnCrossDriveComplete is called after a manual cross-drive backup completes (to push infra backup to Hub).
OnCrossDriveComplete func()
// OnGeoRelevantChange is called after deploy/remove to re-sync geo rules.
OnGeoRelevantChange func()
// Asset syncer for on-demand Hub asset sync
assetsSyncer *assets.Syncer
// Geo-restriction sync manager
geoSync *cf.GeoSyncManager
}
// SetAssetsSyncer sets the Hub asset syncer for on-demand sync triggers.
@@ -56,6 +62,11 @@ func (r *Router) SetAssetsSyncer(as *assets.Syncer) {
r.assetsSyncer = as
}
// SetGeoSync sets the geo-restriction sync manager.
func (r *Router) SetGeoSync(gs *cf.GeoSyncManager) {
r.geoSync = gs
}
func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, notif *notify.Notifier, logger *log.Logger) *Router {
return &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, updater: updater, notifier: notif, logger: logger}
}
@@ -218,6 +229,32 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case path == "/assets/status" && req.Method == http.MethodGet:
r.assetSyncStatus(w, req)
// --- Geo-restriction endpoints ---
// GET /api/geo/status — current geo settings + sync state
case path == "/geo/status" && req.Method == http.MethodGet:
r.geoStatus(w, req)
// POST /api/geo/settings — update global geo settings
case path == "/geo/settings" && req.Method == http.MethodPost:
r.geoUpdateSettings(w, req)
// POST /api/geo/sync — trigger manual Cloudflare sync
case path == "/geo/sync" && req.Method == http.MethodPost:
r.geoTriggerSync(w, req)
// GET /api/geo/countries — full country list for search UI
case path == "/geo/countries" && req.Method == http.MethodGet:
r.geoCountries(w, req)
// POST /api/stacks/{name}/geo/override — set per-app geo override
case hasSuffix(path, "/geo/override") && req.Method == http.MethodPost:
r.geoSetAppOverride(w, req, extractName(path, "/geo/override"))
// DELETE /api/stacks/{name}/geo/override — remove per-app geo override
case hasSuffix(path, "/geo/override") && req.Method == http.MethodDelete:
r.geoRemoveAppOverride(w, req, extractName(path, "/geo/override"))
default:
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"})
}
@@ -319,6 +356,11 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
}
r.notifier.NotifyAppDeployed(name, displayName)
}
// Re-sync geo rules (new hostname may need to be added)
if r.OnGeoRelevantChange != nil {
go r.OnGeoRelevantChange()
}
}
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
@@ -501,6 +543,11 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
if r.notifier != nil {
r.notifier.NotifyAppRemoved(name, name)
}
// Re-sync geo rules (hostname removed)
if r.OnGeoRelevantChange != nil {
go r.OnGeoRelevantChange()
}
}
func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) {