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:
@@ -0,0 +1,180 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
func (r *Router) geoStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
geo := r.sett.GetGeoRestriction()
|
||||
|
||||
data := map[string]interface{}{
|
||||
"cf_configured": r.cfg.Infrastructure.CFAPIToken != "",
|
||||
"geo_available": r.geoSync != nil,
|
||||
}
|
||||
|
||||
if geo != nil {
|
||||
data["enabled"] = geo.Enabled
|
||||
data["allowed_countries"] = geo.AllowedCountries
|
||||
data["app_overrides"] = geo.AppOverrides
|
||||
data["last_sync"] = geo.LastSync
|
||||
data["last_sync_error"] = geo.LastSyncError
|
||||
data["syncing"] = r.geoSync != nil && r.geoSync.IsRunning()
|
||||
} else {
|
||||
data["enabled"] = false
|
||||
data["allowed_countries"] = []string{"HU"}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
|
||||
}
|
||||
|
||||
func (r *Router) geoUpdateSettings(w http.ResponseWriter, req *http.Request) {
|
||||
limitBody(w, req)
|
||||
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AllowedCountries []string `json:"allowed_countries"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate country codes
|
||||
for _, code := range body.AllowedCountries {
|
||||
if !cf.ValidCountryCode(code) {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid country code: " + code})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing settings to preserve app overrides and sync state
|
||||
existing := r.sett.GetGeoRestriction()
|
||||
geo := &settings.GeoRestriction{
|
||||
Enabled: body.Enabled,
|
||||
AllowedCountries: body.AllowedCountries,
|
||||
}
|
||||
if existing != nil {
|
||||
geo.AppOverrides = existing.AppOverrides
|
||||
geo.ZoneID = existing.ZoneID
|
||||
geo.RulesetID = existing.RulesetID
|
||||
}
|
||||
|
||||
if err := r.sett.SetGeoRestriction(geo); err != nil {
|
||||
r.logger.Printf("[API] Failed to save geo settings: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
r.logger.Printf("[API] Geo settings updated: enabled=%v, countries=%v", body.Enabled, body.AllowedCountries)
|
||||
|
||||
// Trigger async CF sync
|
||||
if r.geoSync != nil {
|
||||
go func() {
|
||||
if err := r.geoSync.Sync(context.Background()); err != nil {
|
||||
r.logger.Printf("[API] Geo sync after settings update failed: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Geo-korlátozás beállítva"})
|
||||
}
|
||||
|
||||
func (r *Router) geoTriggerSync(w http.ResponseWriter, _ *http.Request) {
|
||||
if r.geoSync == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Cloudflare API nincs konfigurálva"})
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := r.geoSync.Sync(context.Background()); err != nil {
|
||||
r.logger.Printf("[API] Manual geo sync failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Szinkronizálás elindítva"})
|
||||
}
|
||||
|
||||
func (r *Router) geoCountries(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: cf.AllCountries()})
|
||||
}
|
||||
|
||||
func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, appName string) {
|
||||
if appName == "" {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid app name"})
|
||||
return
|
||||
}
|
||||
limitBody(w, req)
|
||||
|
||||
var body struct {
|
||||
AllowedCountries []string `json:"allowed_countries"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate country codes
|
||||
for _, code := range body.AllowedCountries {
|
||||
if !cf.ValidCountryCode(code) {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid country code: " + code})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Verify app exists
|
||||
if _, ok := r.stackMgr.GetStack(appName); !ok {
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "app not found: " + appName})
|
||||
return
|
||||
}
|
||||
|
||||
override := &settings.AppGeoOverride{AllowedCountries: body.AllowedCountries}
|
||||
if err := r.sett.SetGeoAppOverride(appName, override); err != nil {
|
||||
r.logger.Printf("[API] Failed to save geo override for %s: %v", appName, err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
r.logger.Printf("[API] Geo override set for %s: countries=%v", appName, body.AllowedCountries)
|
||||
|
||||
// Trigger async CF sync
|
||||
if r.geoSync != nil {
|
||||
go func() {
|
||||
if err := r.geoSync.Sync(context.Background()); err != nil {
|
||||
r.logger.Printf("[API] Geo sync after app override failed: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Alkalmazás geo-korlátozás beállítva"})
|
||||
}
|
||||
|
||||
func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, appName string) {
|
||||
if appName == "" {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid app name"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.sett.RemoveGeoAppOverride(appName); err != nil {
|
||||
r.logger.Printf("[API] Failed to remove geo override for %s: %v", appName, err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
r.logger.Printf("[API] Geo override removed for %s", appName)
|
||||
|
||||
// Trigger async CF sync
|
||||
if r.geoSync != nil {
|
||||
go func() {
|
||||
if err := r.geoSync.Sync(context.Background()); err != nil {
|
||||
r.logger.Printf("[API] Geo sync after override removal failed: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Alkalmazás geo-korlátozás eltávolítva"})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user