diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fb8f0b..2b876b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ ## Changelog +### v0.30.0 — Geo-Restriction via Cloudflare WAF (2026-02-25) + +#### Added +- **Geo-restriction feature** (`internal/cloudflare/`) — New package for managing Cloudflare WAF Custom Rules. Allows restricting access to apps by country using the `http_request_firewall_custom` phase. Rules are identified by `[felhom-geo]` description prefix — other WAF rules are untouched. +- **Cloudflare API client** (`internal/cloudflare/client.go`) — HTTP client with Bearer token auth for the Cloudflare v4 API. Supports zone lookup, ruleset management, and rule CRUD operations. +- **Country data** (`internal/cloudflare/countries.go`) — Embedded map of ~250 ISO 3166-1 alpha-2 country codes with Hungarian names. Includes search helpers for the UI. +- **Geo sync manager** (`internal/cloudflare/geosync.go`) — Orchestrator that diffs desired vs existing Cloudflare rules and applies changes. Runs on settings change, after app deploy/remove, and every 6 hours for verification. +- **Settings page UI** (`templates/settings.html`) — New "Földrajzi korlátozás" section with searchable country selector (autocomplete dropdown → tag chips), enable/disable toggle, per-app override summary, and sync status display. Hungary removal triggers a confirmation warning. +- **Per-app override** (`templates/app_info.html`) — Each app's detail page now has a "Földrajzi korlátozás" section (when the feature is globally enabled) to set app-specific allowed countries. +- **Geo API endpoints** (`internal/api/geo.go`) — `GET /api/geo/status`, `POST /api/geo/settings`, `POST /api/geo/sync`, `GET /api/geo/countries`, `POST/DELETE /api/stacks/{name}/geo/override`. +- **Settings model** (`internal/settings/settings.go`) — New `GeoRestriction` struct with `AllowedCountries`, `AppOverrides`, and sync state (zone ID, ruleset ID, last sync). Thread-safe getter/setter methods following existing RWMutex pattern. + +#### Changed +- **Router** (`internal/api/router.go`) — Added `OnGeoRelevantChange` callback triggered after app deploy/remove to re-sync geo rules when hostnames change. +- **Main wiring** (`cmd/controller/main.go`) — Cloudflare client, geo sync manager, and scheduler job initialized when `cf_api_token` is configured. New `geoStackAdapter` provides deployed app hostnames. + +#### Hub Changes +- **Config form** (`hub/internal/web/templates/config_form.html`) — Updated CF API token help text to indicate Zone WAF:Edit permission is needed for geo-restriction. + +#### Notes +- The existing `cf_api_token` needs **Zone WAF:Edit** permission added (in addition to existing Zone DNS:Edit for ACME). No new token field is needed. +- Local network access is inherently unaffected — local traffic bypasses Cloudflare entirely. +- Cloudflare Free plan supports up to 5 custom rules, which is sufficient for a global rule + a few per-app overrides. + ### v0.29.3 — Controller-side Health Probes (2026-02-25) #### Added diff --git a/controller/README.md b/controller/README.md index 4e79e13..6761da8 100644 --- a/controller/README.md +++ b/controller/README.md @@ -4,7 +4,7 @@ A single, lightweight Go container that replaces Portainer + scattered systemd scripts with a unified, Hungarian-language web dashboard for managing Docker Compose stacks, backups, storage, monitoring, and notifications on customer hardware. -**Current version: v0.29.2** +**Current version: v0.30.0** --- @@ -24,6 +24,7 @@ A single, lightweight Go container that replaces Portainer + scattered systemd s - [Disaster Recovery](#10-disaster-recovery) - [Asset Sync](#11-asset-sync) - [Debug Mode](#12-debug-mode) + - [Geo-Restriction](#13-geo-restriction) - [Repository Layout](#repository-layout) - [Configuration](#configuration) - [REST API](#rest-api) @@ -1096,11 +1097,109 @@ When `logging.level: "debug"` is set in `controller.yaml`, the controller expose --- +### 13. Geo-Restriction + +Country-based access control via **Cloudflare WAF Custom Rules**. The controller manages WAF rules in the `http_request_firewall_custom` phase to block requests from non-allowed countries. Rules are identified by a `[felhom-geo]` description prefix — other WAF rules are never touched. + +#### Prerequisites + +The existing `cf_api_token` (used for DNS-01 ACME) needs **Zone WAF:Edit** permission added. No new token is needed — just expanded permissions on the same token. The settings UI only appears when a CF API token is configured. + +#### Architecture + +``` +┌─────────────┐ ┌──────────────────┐ ┌──────────────────────┐ +│ Settings UI │────▶│ GeoSyncManager │────▶│ Cloudflare WAF API │ +│ (settings. │ │ (geosync.go) │ │ /zones/{id}/ │ +│ html) │ │ diff & apply │ │ rulesets/{id}/rules │ +└─────────────┘ └──────────────────┘ └──────────────────────┘ + │ ▲ + │ POST /api/geo/* │ Scheduler (6h) + ▼ │ + deploy/remove hooks +┌─────────────┐ │ +│ API layer │──────────────┘ +│ (geo.go) │ +└─────────────┘ +``` + +**Rule structure:** +- **Global rule**: `(not ip.src.country in {"HU"})` → block (with `http.host ne` exclusions for apps that have per-app overrides) +- **Per-app rule**: `(http.host eq "app.example.com" and not ip.src.country in {"HU" "US"})` → block +- **Block response**: HTTP 403 with Hungarian message + +**Local network access** is inherently unaffected — traffic from the LAN goes directly to the server, bypassing Cloudflare entirely. + +#### Cloudflare API Client (`internal/cloudflare/`) + +| File | Purpose | +|------|---------| +| `client.go` | HTTP client with Bearer token auth, 15s timeout, generic `do()` helper | +| `zone.go` | Zone ID resolution — tries exact domain, then parent domains progressively | +| `waf.go` | WAF rule CRUD, expression builders (`BuildGlobalExpression`, `BuildAppExpression`) | +| `countries.go` | ~250 ISO 3166-1 alpha-2 codes with Hungarian names | +| `geosync.go` | Sync orchestrator — diffs desired vs existing rules, creates/updates/deletes | + +**GeoSyncManager** uses a `StackLister` interface (implemented by `geoStackAdapter` in main.go) to get deployed app hostnames without circular imports. + +#### Settings Model + +Stored in `settings.json` (runtime-modifiable): + +```go +type GeoRestriction struct { + Enabled bool `json:"enabled"` + AllowedCountries []string `json:"allowed_countries"` + AppOverrides map[string]AppGeoOverride `json:"app_overrides,omitempty"` + LastSync string `json:"last_sync,omitempty"` + LastSyncError string `json:"last_sync_error,omitempty"` + ZoneID string `json:"zone_id,omitempty"` + RulesetID string `json:"ruleset_id,omitempty"` +} +``` + +Thread-safe access via `GetGeoRestriction()`, `SetGeoRestriction()`, `SetGeoAppOverride()`, `RemoveGeoAppOverride()`, `SetGeoSyncState()`. + +#### API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/geo/status` | Current geo settings + sync state | +| POST | `/api/geo/settings` | Update global settings (enable/disable, countries) | +| POST | `/api/geo/sync` | Trigger manual sync | +| GET | `/api/geo/countries` | Full country list for search UI | +| POST | `/api/stacks/{name}/geo/override` | Set per-app country override | +| DELETE | `/api/stacks/{name}/geo/override` | Remove per-app override | + +All mutating endpoints trigger an async Cloudflare sync. + +#### Sync Triggers + +1. **Settings change** — user saves geo settings or per-app override +2. **Deploy/remove** — app deployment or removal changes the hostname list +3. **Scheduler** — periodic verification every 6 hours +4. **Startup** — delayed initial sync 15s after boot +5. **Manual** — "Szinkronizálás" button on settings page + +#### UI + +**Settings page** ("Beállítások" → "Földrajzi korlátozás"): +- Enable/disable toggle +- Searchable country autocomplete with tag-based selection +- Hungary pinned with `confirm()` warning on removal +- Per-app overrides summary with add/edit/remove +- Sync status display (last sync time, errors) + +**App detail page** (per-app override, shown when geo is globally enabled): +- Toggle for custom country restriction +- Independent country selector + +--- + ## Repository Layout ``` controller/ -├── cmd/controller/main.go # Entry point, wires all 15 modules (setup mode branch + normal startup) +├── cmd/controller/main.go # Entry point, wires all 16 modules (setup mode branch + normal startup) ├── internal/ │ ├── config/config.go # YAML loader, validation, env overrides │ ├── crypto/crypto.go # AES-256-GCM encryption for app.yaml secrets, key management @@ -1130,8 +1229,16 @@ controller/ │ │ ├── restore_scan.go # DR: scan drives for backup data, build restore plan │ │ ├── restore_app_linux.go # DR: per-app restore (rsync config/data + docker compose up) │ │ └── restore_drives_linux.go # DR: auto-mount drives by UUID from Hub infra backup +│ ├── cloudflare/ +│ │ ├── client.go # CF API client (Bearer auth, generic JSON helper) +│ │ ├── zone.go # Zone ID resolution (domain → zone) +│ │ ├── waf.go # WAF rule CRUD + expression builders +│ │ ├── countries.go # ISO 3166-1 country codes + Hungarian names +│ │ └── geosync.go # Geo sync orchestrator (diff & apply rules) │ ├── assets/syncer.go # Hub asset sync (download, SHA-256 compare, resolve) -│ ├── api/router.go # REST API endpoints (~30 routes) +│ ├── api/ +│ │ ├── router.go # REST API endpoints (~36 routes) +│ │ └── geo.go # Geo-restriction API handlers │ ├── scheduler/scheduler.go # Central job scheduler (Every, Daily) │ ├── system/ │ │ ├── info.go, info_linux.go # RAM, disk, CPU, temperature, load average diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 10f684b..7408ead 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -19,6 +19,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/api" "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/crypto" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/metrics" @@ -604,6 +605,47 @@ func main() { if assetsSyncer != nil { apiRouter.SetAssetsSyncer(assetsSyncer) } + + // --- Initialize Cloudflare geo-restriction --- + var geoSync *cf.GeoSyncManager + if cfg.Infrastructure.CFAPIToken != "" { + cfClient := cf.New(cfg.Infrastructure.CFAPIToken, logger, cfg.Logging.Level == "debug") + geoStacks := &geoStackAdapter{mgr: stackMgr, domain: cfg.Customer.Domain} + geoSync = cf.NewGeoSyncManager(cfClient, sett, cfg.Customer.Domain, geoStacks, logger) + apiRouter.SetGeoSync(geoSync) + + // Re-sync geo rules when apps are deployed/removed + apiRouter.OnGeoRelevantChange = func() { + geo := sett.GetGeoRestriction() + if geo != nil && geo.Enabled { + if err := geoSync.Sync(context.Background()); err != nil { + logger.Printf("[WARN] Geo sync after app change failed: %v", err) + } + } + } + + // Periodic verification every 6 hours + sched.Every("geo-verify", 6*time.Hour, func(ctx context.Context) error { + geo := sett.GetGeoRestriction() + if geo == nil || !geo.Enabled { + return nil + } + return geoSync.Sync(ctx) + }) + + // Initial sync (delayed, non-blocking) + go func() { + time.Sleep(15 * time.Second) + if geo := sett.GetGeoRestriction(); geo != nil && geo.Enabled { + if err := geoSync.Sync(context.Background()); err != nil { + logger.Printf("[WARN] Initial geo sync failed: %v", err) + } + } + }() + + logger.Printf("[INFO] Geo-restriction support enabled (CF API token configured)") + } + // --- Initialize web server --- webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version) webServer.SetEncryptionKey(encKey) @@ -871,6 +913,32 @@ func (a *stackAdapter) GetStackHDDPath(name string) string { return "" } +// geoStackAdapter implements cloudflare.StackLister for geo-restriction sync. +type geoStackAdapter struct { + mgr *stacks.Manager + domain string +} + +func (a *geoStackAdapter) GetDeployedHostnames() map[string]string { + result := make(map[string]string) + for _, stack := range a.mgr.GetStacks() { + if !stack.Deployed { + continue + } + subdomain := stack.Meta.Subdomain + // Check for custom subdomain in app.yaml + if appCfg := a.mgr.LoadAppConfigByName(stack.Name); appCfg != nil { + if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" { + subdomain = sd + } + } + if subdomain != "" { + result[stack.Name] = subdomain + "." + a.domain + } + } + return result +} + // watchdogStackAdapter implements monitor.WatchdogStackProvider using stacks.Manager. type watchdogStackAdapter struct { mgr *stacks.Manager diff --git a/controller/internal/api/geo.go b/controller/internal/api/geo.go new file mode 100644 index 0000000..8a303ad --- /dev/null +++ b/controller/internal/api/geo.go @@ -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"}) +} diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 41c391d..ae8ddf1 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -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) { diff --git a/controller/internal/cloudflare/client.go b/controller/internal/cloudflare/client.go new file mode 100644 index 0000000..d9d824b --- /dev/null +++ b/controller/internal/cloudflare/client.go @@ -0,0 +1,113 @@ +package cloudflare + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" +) + +const apiBase = "https://api.cloudflare.com/client/v4" + +// Client handles Cloudflare API calls. +type Client struct { + apiToken string + httpClient *http.Client + logger *log.Logger + debug bool +} + +// New creates a Cloudflare API client. +func New(apiToken string, logger *log.Logger, debug bool) *Client { + return &Client{ + apiToken: apiToken, + httpClient: &http.Client{Timeout: 15 * time.Second}, + logger: logger, + debug: debug, + } +} + +// IsConfigured returns true if a CF API token is set. +func (c *Client) IsConfigured() bool { + return c.apiToken != "" +} + +// apiResponse is the generic Cloudflare API response wrapper. +type apiResponse struct { + Success bool `json:"success"` + Errors []apiError `json:"errors"` + Messages []apiMessage `json:"messages"` + Result json.RawMessage `json:"result"` +} + +type apiError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type apiMessage struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// do performs an HTTP request to the Cloudflare API and decodes the response. +func (c *Client) do(method, path string, body interface{}) (*apiResponse, error) { + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + bodyReader = bytes.NewReader(data) + if c.debug { + c.logger.Printf("[CF-DEBUG] %s %s body=%s", method, path, string(data)) + } + } else if c.debug { + c.logger.Printf("[CF-DEBUG] %s %s", method, path) + } + + url := apiBase + path + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.apiToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("http request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if c.debug { + c.logger.Printf("[CF-DEBUG] Response %d: %s", resp.StatusCode, string(respBody)) + } + + var apiResp apiResponse + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return nil, fmt.Errorf("decode response (status %d): %w", resp.StatusCode, err) + } + + if !apiResp.Success { + msg := "unknown error" + if len(apiResp.Errors) > 0 { + msg = apiResp.Errors[0].Message + for _, e := range apiResp.Errors[1:] { + msg += "; " + e.Message + } + } + return &apiResp, fmt.Errorf("cloudflare API error (status %d): %s", resp.StatusCode, msg) + } + + return &apiResp, nil +} diff --git a/controller/internal/cloudflare/countries.go b/controller/internal/cloudflare/countries.go new file mode 100644 index 0000000..78a7466 --- /dev/null +++ b/controller/internal/cloudflare/countries.go @@ -0,0 +1,276 @@ +package cloudflare + +import "sort" + +// Country represents a country for the geo selector UI. +type Country struct { + Code string `json:"code"` // ISO 3166-1 alpha-2 + Name string `json:"name"` // Hungarian name +} + +// countries maps ISO 3166-1 alpha-2 codes to Hungarian country names. +var countries = map[string]string{ + "AF": "Afganisztán", + "AL": "Albánia", + "DZ": "Algéria", + "AS": "Amerikai Szamoa", + "AD": "Andorra", + "AO": "Angola", + "AI": "Anguilla", + "AQ": "Antarktisz", + "AG": "Antigua és Barbuda", + "AR": "Argentína", + "AM": "Örményország", + "AW": "Aruba", + "AU": "Ausztrália", + "AT": "Ausztria", + "AZ": "Azerbajdzsán", + "BS": "Bahama-szigetek", + "BH": "Bahrein", + "BD": "Banglades", + "BB": "Barbados", + "BY": "Fehéroroszország", + "BE": "Belgium", + "BZ": "Belize", + "BJ": "Benin", + "BM": "Bermuda", + "BT": "Bhután", + "BO": "Bolívia", + "BA": "Bosznia-Hercegovina", + "BW": "Botswana", + "BR": "Brazília", + "BN": "Brunei", + "BG": "Bulgária", + "BF": "Burkina Faso", + "BI": "Burundi", + "CV": "Cabo Verde", + "KH": "Kambodzsa", + "CM": "Kamerun", + "CA": "Kanada", + "KY": "Kajmán-szigetek", + "CF": "Közép-afrikai Köztársaság", + "TD": "Csád", + "CL": "Chile", + "CN": "Kína", + "CO": "Kolumbia", + "KM": "Comore-szigetek", + "CG": "Kongó", + "CD": "Kongói Demokratikus Köztársaság", + "CK": "Cook-szigetek", + "CR": "Costa Rica", + "CI": "Elefántcsontpart", + "HR": "Horvátország", + "CU": "Kuba", + "CW": "Curaçao", + "CY": "Ciprus", + "CZ": "Csehország", + "DK": "Dánia", + "DJ": "Dzsibuti", + "DM": "Dominika", + "DO": "Dominikai Köztársaság", + "EC": "Ecuador", + "EG": "Egyiptom", + "SV": "Salvador", + "GQ": "Egyenlítői-Guinea", + "ER": "Eritrea", + "EE": "Észtország", + "SZ": "Eswatini", + "ET": "Etiópia", + "FK": "Falkland-szigetek", + "FO": "Feröer-szigetek", + "FJ": "Fidzsi-szigetek", + "FI": "Finnország", + "FR": "Franciaország", + "GF": "Francia Guyana", + "PF": "Francia Polinézia", + "GA": "Gabon", + "GM": "Gambia", + "GE": "Grúzia", + "DE": "Németország", + "GH": "Ghána", + "GI": "Gibraltár", + "GR": "Görögország", + "GL": "Grönland", + "GD": "Grenada", + "GP": "Guadeloupe", + "GU": "Guam", + "GT": "Guatemala", + "GG": "Guernsey", + "GN": "Guinea", + "GW": "Bissau-Guinea", + "GY": "Guyana", + "HT": "Haiti", + "HN": "Honduras", + "HK": "Hongkong", + "HU": "Magyarország", + "IS": "Izland", + "IN": "India", + "ID": "Indonézia", + "IR": "Irán", + "IQ": "Irak", + "IE": "Írország", + "IM": "Man-sziget", + "IL": "Izrael", + "IT": "Olaszország", + "JM": "Jamaica", + "JP": "Japán", + "JE": "Jersey", + "JO": "Jordánia", + "KZ": "Kazahsztán", + "KE": "Kenya", + "KI": "Kiribati", + "KP": "Észak-Korea", + "KR": "Dél-Korea", + "KW": "Kuvait", + "KG": "Kirgizisztán", + "LA": "Laosz", + "LV": "Lettország", + "LB": "Libanon", + "LS": "Lesotho", + "LR": "Libéria", + "LY": "Líbia", + "LI": "Liechtenstein", + "LT": "Litvánia", + "LU": "Luxemburg", + "MO": "Makaó", + "MG": "Madagaszkár", + "MW": "Malawi", + "MY": "Malajzia", + "MV": "Maldív-szigetek", + "ML": "Mali", + "MT": "Málta", + "MH": "Marshall-szigetek", + "MQ": "Martinique", + "MR": "Mauritánia", + "MU": "Mauritius", + "YT": "Mayotte", + "MX": "Mexikó", + "FM": "Mikronézia", + "MD": "Moldova", + "MC": "Monaco", + "MN": "Mongólia", + "ME": "Montenegró", + "MS": "Montserrat", + "MA": "Marokkó", + "MZ": "Mozambik", + "MM": "Mianmar", + "NA": "Namíbia", + "NR": "Nauru", + "NP": "Nepál", + "NL": "Hollandia", + "NC": "Új-Kaledónia", + "NZ": "Új-Zéland", + "NI": "Nicaragua", + "NE": "Niger", + "NG": "Nigéria", + "NU": "Niue", + "NF": "Norfolk-sziget", + "MK": "Észak-Macedónia", + "MP": "Északi-Mariana-szigetek", + "NO": "Norvégia", + "OM": "Omán", + "PK": "Pakisztán", + "PW": "Palau", + "PS": "Palesztina", + "PA": "Panama", + "PG": "Pápua Új-Guinea", + "PY": "Paraguay", + "PE": "Peru", + "PH": "Fülöp-szigetek", + "PL": "Lengyelország", + "PT": "Portugália", + "PR": "Puerto Rico", + "QA": "Katar", + "RE": "Réunion", + "RO": "Románia", + "RU": "Oroszország", + "RW": "Ruanda", + "BL": "Saint-Barthélemy", + "SH": "Szent Ilona", + "KN": "Saint Kitts és Nevis", + "LC": "Saint Lucia", + "MF": "Saint-Martin", + "PM": "Saint-Pierre és Miquelon", + "VC": "Saint Vincent és a Grenadine-szigetek", + "WS": "Szamoa", + "SM": "San Marino", + "ST": "São Tomé és Príncipe", + "SA": "Szaúd-Arábia", + "SN": "Szenegál", + "RS": "Szerbia", + "SC": "Seychelle-szigetek", + "SL": "Sierra Leone", + "SG": "Szingapúr", + "SX": "Sint Maarten", + "SK": "Szlovákia", + "SI": "Szlovénia", + "SB": "Salamon-szigetek", + "SO": "Szomália", + "ZA": "Dél-afrikai Köztársaság", + "SS": "Dél-Szudán", + "ES": "Spanyolország", + "LK": "Srí Lanka", + "SD": "Szudán", + "SR": "Suriname", + "SE": "Svédország", + "CH": "Svájc", + "SY": "Szíria", + "TW": "Tajvan", + "TJ": "Tádzsikisztán", + "TZ": "Tanzánia", + "TH": "Thaiföld", + "TL": "Kelet-Timor", + "TG": "Togo", + "TK": "Tokelau", + "TO": "Tonga", + "TT": "Trinidad és Tobago", + "TN": "Tunézia", + "TR": "Törökország", + "TM": "Türkmenisztán", + "TC": "Turks- és Caicos-szigetek", + "TV": "Tuvalu", + "UG": "Uganda", + "UA": "Ukrajna", + "AE": "Egyesült Arab Emírségek", + "GB": "Egyesült Királyság", + "US": "Egyesült Államok", + "UY": "Uruguay", + "UZ": "Üzbegisztán", + "VU": "Vanuatu", + "VA": "Vatikán", + "VE": "Venezuela", + "VN": "Vietnám", + "VG": "Brit Virgin-szigetek", + "VI": "Amerikai Virgin-szigetek", + "WF": "Wallis és Futuna", + "EH": "Nyugat-Szahara", + "YE": "Jemen", + "ZM": "Zambia", + "ZW": "Zimbabwe", +} + +// AllCountries returns all countries sorted by Hungarian name. +func AllCountries() []Country { + result := make([]Country, 0, len(countries)) + for code, name := range countries { + result = append(result, Country{Code: code, Name: name}) + } + sort.Slice(result, func(i, j int) bool { + return result[i].Name < result[j].Name + }) + return result +} + +// CountryName returns the Hungarian name for a country code, or the code itself if unknown. +func CountryName(code string) string { + if name, ok := countries[code]; ok { + return name + } + return code +} + +// ValidCountryCode returns true if code is a valid ISO 3166-1 alpha-2 code. +func ValidCountryCode(code string) bool { + _, ok := countries[code] + return ok +} diff --git a/controller/internal/cloudflare/geosync.go b/controller/internal/cloudflare/geosync.go new file mode 100644 index 0000000..6af4770 --- /dev/null +++ b/controller/internal/cloudflare/geosync.go @@ -0,0 +1,254 @@ +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 + + 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, + } +} + +// 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 == "" { + var err error + zoneID, err = g.client.GetZoneID(g.domain) + if err != nil { + g.saveError(zoneID, "", err.Error()) + return fmt.Errorf("resolve zone: %w", err) + } + } + + // 2. Get or create the custom WAF ruleset + rulesetID := geo.RulesetID + if rulesetID == "" { + var err error + rulesetID, err = g.client.GetCustomRulesetID(zoneID) + if err != nil { + g.saveError(zoneID, "", err.Error()) + return fmt.Errorf("get ruleset: %w", err) + } + if rulesetID == "" { + rulesetID, err = g.client.CreateCustomRuleset(zoneID) + if err != nil { + g.saveError(zoneID, "", err.Error()) + return fmt.Errorf("create ruleset: %w", err) + } + } + } + + // 3. List existing felhom-managed rules + existing, err := g.client.GetFelhomRules(zoneID, rulesetID) + if err != nil { + g.saveError(zoneID, rulesetID, err.Error()) + return fmt.Errorf("list existing rules: %w", err) + } + + // 4. Build desired rules + desired := g.buildDesiredRules(geo) + + // 5. Diff and apply + if err := g.applyDiff(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 == "" { + // No cached IDs — nothing to clean up + return nil + } + + existing, err := g.client.GetFelhomRules(zoneID, rulesetID) + if err != nil { + g.logger.Printf("[GEO] Warning: could not list rules for cleanup: %v", err) + return nil + } + + for _, r := range existing { + if err := g.client.DeleteRule(zoneID, rulesetID, r.ID); err != nil { + g.logger.Printf("[GEO] Warning: could not delete rule %s: %v", r.ID, err) + } + } + + if len(existing) > 0 { + g.logger.Printf("[GEO] Deleted %d felhom-geo rules (feature disabled)", 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() + + // 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 { + 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), + }) + } + + // Sort exclude hostnames for deterministic expression + sort.Strings(excludeHostnames) + + // Global rule (excludes apps with their own rules) + rules = append(rules, desiredRule{ + description: globalRuleDesc, + expression: BuildGlobalExpression(geo.AllowedCountries, excludeHostnames), + }) + + return rules +} + +// applyDiff applies the difference between existing and desired rules. +func (g *GeoSyncManager) applyDiff(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 { + r := newBlockRule(d.description, d.expression) + if err := g.client.UpdateRule(zoneID, rulesetID, ex.ID, r); err != nil { + return fmt.Errorf("update rule %q: %w", d.description, err) + } + } + } else { + // New rule — create + r := newBlockRule(d.description, d.expression) + if _, err := g.client.CreateRule(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 err := g.client.DeleteRule(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) + } +} diff --git a/controller/internal/cloudflare/waf.go b/controller/internal/cloudflare/waf.go new file mode 100644 index 0000000..fedc215 --- /dev/null +++ b/controller/internal/cloudflare/waf.go @@ -0,0 +1,272 @@ +package cloudflare + +import ( + "encoding/json" + "fmt" + "strings" +) + +const ( + // rulePrefix identifies felhom-managed WAF rules. + rulePrefix = "[felhom-geo]" + + // wafPhase is the Cloudflare ruleset phase for custom WAF rules. + wafPhase = "http_request_firewall_custom" + + // globalRuleDesc is the description for the global geo-restriction rule. + globalRuleDesc = "[felhom-geo] Global" + + // appRuleDescPrefix is the prefix for per-app geo-restriction rules. + appRuleDescPrefix = "[felhom-geo] app:" +) + +// ruleset represents a Cloudflare ruleset (minimal fields). +type ruleset struct { + ID string `json:"id"` + Name string `json:"name"` + Phase string `json:"phase"` + Kind string `json:"kind"` +} + +// rule represents a Cloudflare custom rule. +type rule struct { + ID string `json:"id,omitempty"` + Description string `json:"description"` + Expression string `json:"expression"` + Action string `json:"action"` + ActionParameters *actionParameters `json:"action_parameters,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +type actionParameters struct { + Response *blockResponse `json:"response,omitempty"` +} + +type blockResponse struct { + StatusCode int `json:"status_code"` + Content string `json:"content"` + ContentType string `json:"content_type"` +} + +// GeoRule represents a felhom-managed WAF custom rule (for external consumption). +type GeoRule struct { + ID string + Description string + Expression string + Action string +} + +// GetCustomRulesetID returns the zone's http_request_firewall_custom ruleset ID. +// Returns empty string if no such ruleset exists yet. +func (c *Client) GetCustomRulesetID(zoneID string) (string, error) { + path := fmt.Sprintf("/zones/%s/rulesets", zoneID) + resp, err := c.do("GET", path, nil) + if err != nil { + return "", fmt.Errorf("list rulesets: %w", err) + } + + var rulesets []ruleset + if err := json.Unmarshal(resp.Result, &rulesets); err != nil { + return "", fmt.Errorf("decode rulesets: %w", err) + } + + for _, rs := range rulesets { + if rs.Phase == wafPhase { + return rs.ID, nil + } + } + + return "", nil +} + +// CreateCustomRuleset creates the http_request_firewall_custom phase entry point ruleset. +func (c *Client) CreateCustomRuleset(zoneID string) (string, error) { + path := fmt.Sprintf("/zones/%s/rulesets", zoneID) + body := map[string]interface{}{ + "name": "felhom custom rules", + "kind": "zone", + "phase": wafPhase, + "rules": []interface{}{}, + } + + resp, err := c.do("POST", path, body) + if err != nil { + return "", fmt.Errorf("create ruleset: %w", err) + } + + var rs ruleset + if err := json.Unmarshal(resp.Result, &rs); err != nil { + return "", fmt.Errorf("decode created ruleset: %w", err) + } + + c.logger.Printf("[CF] Created custom ruleset %s for zone %s", rs.ID, zoneID) + return rs.ID, nil +} + +// GetRules returns all rules in a ruleset. +func (c *Client) GetRules(zoneID, rulesetID string) ([]rule, error) { + path := fmt.Sprintf("/zones/%s/rulesets/%s", zoneID, rulesetID) + resp, err := c.do("GET", path, nil) + if err != nil { + return nil, fmt.Errorf("get ruleset: %w", err) + } + + var rs struct { + Rules []rule `json:"rules"` + } + if err := json.Unmarshal(resp.Result, &rs); err != nil { + return nil, fmt.Errorf("decode rules: %w", err) + } + + return rs.Rules, nil +} + +// GetFelhomRules returns only rules with the [felhom-geo] prefix. +func (c *Client) GetFelhomRules(zoneID, rulesetID string) ([]GeoRule, error) { + rules, err := c.GetRules(zoneID, rulesetID) + if err != nil { + return nil, err + } + + var result []GeoRule + for _, r := range rules { + if strings.HasPrefix(r.Description, rulePrefix) { + result = append(result, GeoRule{ + ID: r.ID, + Description: r.Description, + Expression: r.Expression, + Action: r.Action, + }) + } + } + + return result, nil +} + +// CreateRule adds a new rule to the ruleset. +func (c *Client) CreateRule(zoneID, rulesetID string, r rule) (string, error) { + path := fmt.Sprintf("/zones/%s/rulesets/%s/rules", zoneID, rulesetID) + resp, err := c.do("POST", path, r) + if err != nil { + return "", fmt.Errorf("create rule: %w", err) + } + + // The response is the full ruleset; find the new rule by description. + var rs struct { + Rules []rule `json:"rules"` + } + if err := json.Unmarshal(resp.Result, &rs); err != nil { + return "", fmt.Errorf("decode created rule response: %w", err) + } + + for _, created := range rs.Rules { + if created.Description == r.Description { + c.logger.Printf("[CF] Created rule %q → %s", r.Description, created.ID) + return created.ID, nil + } + } + + return "", fmt.Errorf("created rule not found in response") +} + +// UpdateRule updates an existing rule in the ruleset. +func (c *Client) UpdateRule(zoneID, rulesetID, ruleID string, r rule) error { + path := fmt.Sprintf("/zones/%s/rulesets/%s/rules/%s", zoneID, rulesetID, ruleID) + _, err := c.do("PATCH", path, r) + if err != nil { + return fmt.Errorf("update rule %s: %w", ruleID, err) + } + c.logger.Printf("[CF] Updated rule %q (%s)", r.Description, ruleID) + return nil +} + +// DeleteRule removes a rule from the ruleset. +func (c *Client) DeleteRule(zoneID, rulesetID, ruleID string) error { + path := fmt.Sprintf("/zones/%s/rulesets/%s/rules/%s", zoneID, rulesetID, ruleID) + _, err := c.do("DELETE", path, nil) + if err != nil { + return fmt.Errorf("delete rule %s: %w", ruleID, err) + } + c.logger.Printf("[CF] Deleted rule %s", ruleID) + return nil +} + +// BuildGlobalExpression builds the Cloudflare filter expression for the global geo rule. +// countries: allowed ISO country codes. +// excludeHostnames: app hostnames that have their own rules (excluded from global). +// +// Example output: (not ip.src.country in {"HU" "DE"}) and (http.host ne "app1.example.com") +func BuildGlobalExpression(countries []string, excludeHostnames []string) string { + if len(countries) == 0 { + return "true" // block everything (no countries allowed) + } + + // Build country part: (not ip.src.country in {"HU" "DE"}) + quoted := make([]string, len(countries)) + for i, c := range countries { + quoted[i] = `"` + c + `"` + } + expr := "(not ip.src.country in {" + strings.Join(quoted, " ") + "})" + + // Add hostname exclusions for apps with their own rules + for _, host := range excludeHostnames { + expr += ` and (http.host ne "` + host + `")` + } + + return expr +} + +// BuildAppExpression builds the Cloudflare filter expression for a per-app geo rule. +// hostname: full hostname of the app (e.g., "nextcloud.demo-felhom.eu"). +// countries: allowed ISO country codes for this app. +// +// Example output: (http.host eq "nextcloud.demo-felhom.eu" and not ip.src.country in {"HU" "US"}) +func BuildAppExpression(hostname string, countries []string) string { + if len(countries) == 0 { + return fmt.Sprintf(`(http.host eq "%s")`, hostname) // block all traffic to this host + } + + quoted := make([]string, len(countries)) + for i, c := range countries { + quoted[i] = `"` + c + `"` + } + + return fmt.Sprintf(`(http.host eq "%s" and not ip.src.country in {%s})`, + hostname, strings.Join(quoted, " ")) +} + +// AppRuleDescription returns the rule description for a per-app rule. +func AppRuleDescription(appName string) string { + return appRuleDescPrefix + appName +} + +// IsGlobalRule checks if a rule description matches the global rule. +func IsGlobalRule(desc string) bool { + return desc == globalRuleDesc +} + +// IsAppRule checks if a rule description is a per-app rule and returns the app name. +func IsAppRule(desc string) (string, bool) { + if strings.HasPrefix(desc, appRuleDescPrefix) { + return strings.TrimPrefix(desc, appRuleDescPrefix), true + } + return "", false +} + +// newBlockRule creates a rule struct with the standard felhom block response. +func newBlockRule(description, expression string) rule { + enabled := true + return rule{ + Description: description, + Expression: expression, + Action: "block", + ActionParameters: &actionParameters{ + Response: &blockResponse{ + StatusCode: 403, + Content: "Hozzáférés megtagadva — az Ön országa nem engedélyezett.", + ContentType: "text/plain", + }, + }, + Enabled: &enabled, + } +} diff --git a/controller/internal/cloudflare/zone.go b/controller/internal/cloudflare/zone.go new file mode 100644 index 0000000..1e0270e --- /dev/null +++ b/controller/internal/cloudflare/zone.go @@ -0,0 +1,66 @@ +package cloudflare + +import ( + "encoding/json" + "fmt" + "net/url" +) + +// zone represents a Cloudflare zone (minimal fields). +type zone struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// GetZoneID resolves the Cloudflare zone ID for a domain. +// It tries the exact domain first, then strips subdomains progressively. +func (c *Client) GetZoneID(domain string) (string, error) { + // Try exact domain first (e.g., "demo-felhom.eu") + id, err := c.lookupZone(domain) + if err != nil { + return "", err + } + if id != "" { + return id, nil + } + + // Try parent domains (e.g., "felhom.eu" from "demo.felhom.eu") + for i := 0; i < len(domain); i++ { + if domain[i] == '.' { + parent := domain[i+1:] + if parent == "" { + break + } + id, err = c.lookupZone(parent) + if err != nil { + return "", err + } + if id != "" { + return id, nil + } + } + } + + return "", fmt.Errorf("no Cloudflare zone found for domain %q", domain) +} + +// lookupZone queries the CF API for a zone by name. +func (c *Client) lookupZone(name string) (string, error) { + path := "/zones?name=" + url.QueryEscape(name) + "&status=active" + resp, err := c.do("GET", path, nil) + if err != nil { + return "", fmt.Errorf("lookup zone %q: %w", name, err) + } + + var zones []zone + if err := json.Unmarshal(resp.Result, &zones); err != nil { + return "", fmt.Errorf("decode zones: %w", err) + } + + if len(zones) == 0 { + return "", nil + } + + c.logger.Printf("[CF] Resolved zone %q → %s", name, zones[0].ID) + return zones[0].ID, nil +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index af7983b..0c9cb82 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -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() +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 7e925e5..25b21d0 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -478,6 +478,22 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s data["HasOptionalConfig"] = found.Meta.HasOptionalConfig() data["EffectiveSubdomain"] = effectiveSubdomain + // Geo-restriction per-app data + geo := s.settings.GetGeoRestriction() + if geo != nil && geo.Enabled && s.cfg.Infrastructure.CFAPIToken != "" { + data["GeoGlobalEnabled"] = true + data["GeoGlobalCountries"] = geo.AllowedCountries + if ov, ok := geo.AppOverrides[found.Name]; ok { + data["GeoAppOverride"] = true + data["GeoAppOverrideCountries"] = ov.AllowedCountries + } else { + data["GeoAppOverrideCountries"] = []string{} + } + } else { + data["GeoGlobalCountries"] = []string{} + data["GeoAppOverrideCountries"] = []string{} + } + s.executeTemplate(w, r, "app_info", data) } @@ -1084,6 +1100,33 @@ func (s *Server) settingsData() map[string]interface{} { data["SupportEmail"] = "support@felhom.eu" data["SupportURL"] = "https://felhom.eu/kapcsolat" + // Geo-restriction data + data["CFConfigured"] = s.cfg.Infrastructure.CFAPIToken != "" + geo := s.settings.GetGeoRestriction() + if geo != nil { + data["GeoEnabled"] = geo.Enabled + data["GeoAllowedCountries"] = geo.AllowedCountries + data["GeoAppOverrides"] = geo.AppOverrides + data["GeoLastSync"] = geo.LastSync + data["GeoLastError"] = geo.LastSyncError + } else { + data["GeoEnabled"] = false + data["GeoAllowedCountries"] = []string{"HU"} + data["GeoAppOverrides"] = map[string]interface{}{} + } + // Deployed apps for per-app override selector + var deployedApps []map[string]string + for _, stack := range s.stackMgr.GetStacks() { + if !stack.Deployed || s.cfg.IsProtectedStack(stack.Name) { + continue + } + deployedApps = append(deployedApps, map[string]string{ + "Name": stack.Name, + "Display": stack.Meta.DisplayName, + }) + } + data["DeployedApps"] = deployedApps + return data } diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index 86c6ffc..d4e8a08 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -179,5 +179,141 @@ async function saveOptionalConfig(stackName) { {{end}} +{{if .GeoGlobalEnabled}} +
+

Földrajzi korlátozás

+

+ Az alkalmazás egyéni országkorlátozás nélkül a globális beállítást követi. +

+ + + +
+
+ +
+ +
+
+
+
+
+ + +
+
+
+ + +{{end}} + {{template "layout_end" .}} {{end}} diff --git a/controller/internal/web/templates/settings.html b/controller/internal/web/templates/settings.html index d3fb8fc..1da3412 100644 --- a/controller/internal/web/templates/settings.html +++ b/controller/internal/web/templates/settings.html @@ -376,6 +376,345 @@ function pollUntilBack() { + +
+

Földrajzi korlátozás

+

+ Ország alapján korlátozható a webes alkalmazások elérése a Cloudflare WAF segítségével. +
A helyi hálózati hozzáférés mindig engedélyezett (nem halad át a Cloudflare-en). +

+ + {{if not .CFConfigured}} +
+ A Cloudflare API token nincs konfigurálva. Kérd az üzemeltetőt a beállításhoz.
+ A tokennek Zone WAF:Edit jogosultsággal kell rendelkeznie. +
+ {{else}} +
+ + + +
+ +
+ +
+ +
+
+
+ Csak a kiválasztott országokból érhető el a rendszer. +
+ + +
+ +
+ {{if .DeployedApps}} +
+ + +
+ {{end}} +
+ + +
+
+ + + + {{if .GeoLastSync}}Utolsó szinkronizálás: {{.GeoLastSync}}{{end}} + {{if .GeoLastError}} {{.GeoLastError}}{{end}} + +
+
+
+ {{end}} +
+ + +

Jelszó módosítás

diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index c36d18a..eb384a6 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -3009,3 +3009,50 @@ a.stat-card:hover { margin-right: 0.3rem; } @keyframes debug-spin { to { transform: rotate(360deg); } } + +/* --- Geo-restriction UI --- */ +.geo-country-selector { position: relative; } +.geo-selected-tags { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.5rem; } +.geo-tag { + display: inline-flex; align-items: center; gap: 0.3rem; + background: rgba(0,136,204,0.15); color: var(--accent-light); + padding: 0.25rem 0.55rem; border-radius: 4px; font-size: 0.85rem; + white-space: nowrap; +} +.geo-tag-hu { background: rgba(35,134,54,0.2); color: var(--green); } +.geo-tag-sm { font-size: 0.75rem; padding: 0.15rem 0.4rem; } +.geo-tag-remove { cursor: pointer; opacity: 0.7; font-size: 1.1em; line-height: 1; } +.geo-tag-remove:hover { opacity: 1; } +.geo-country-list { + position: absolute; z-index: 10; background: var(--bg-card); + border: 1px solid var(--border-color); border-radius: 6px; + max-height: 220px; overflow-y: auto; width: 100%; margin-top: 2px; + display: none; +} +.geo-country-option { + padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.9rem; + color: var(--text-primary); +} +.geo-country-option:hover { background: rgba(0,136,204,0.1); } +.geo-country-option small { color: var(--text-muted); } +.geo-app-override-row { + display: flex; align-items: center; gap: 0.5rem; + padding: 0.5rem 0; border-bottom: 1px solid var(--border-color); + flex-wrap: wrap; +} +.geo-app-override-row strong { min-width: 120px; color: var(--text-primary); } +.geo-edit-overlay { + background: var(--bg-secondary); border: 1px solid var(--border-color); + border-radius: 8px; padding: 1rem; margin-top: 0.5rem; +} +.geo-edit-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.2rem; max-height: 250px; overflow-y: auto; margin-top: 0.5rem; +} +.geo-edit-item { font-size: 0.85rem; cursor: pointer; padding: 0.15rem 0; color: var(--text-primary); } +.geo-edit-item input { margin-right: 0.3rem; } +.btn-danger-outline { + background: transparent; color: var(--red); border: 1px solid var(--red); + border-radius: 6px; padding: 0.2rem 0.5rem; cursor: pointer; font-size: 0.8rem; +} +.btn-danger-outline:hover { background: var(--red-bg); }