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
+24
View File
@@ -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
+110 -3
View File
@@ -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
+68
View File
@@ -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
+180
View File
@@ -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"})
}
+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) {
+113
View File
@@ -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
}
+276
View File
@@ -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
}
+254
View File
@@ -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)
}
}
+272
View File
@@ -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,
}
}
+66
View File
@@ -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
}
+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()
}
+43
View File
@@ -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
}
@@ -179,5 +179,141 @@ async function saveOptionalConfig(stackName) {
</script>
{{end}}
{{if .GeoGlobalEnabled}}
<div class="app-optional-config">
<h3>Földrajzi korlátozás</h3>
<p class="config-group-desc">
Az alkalmazás egyéni országkorlátozás nélkül a globális beállítást követi.
</p>
<label class="toggle" style="margin-bottom:1rem">
<input type="checkbox" id="app-geo-override-toggle"
{{if .GeoAppOverride}}checked{{end}}
onchange="toggleAppGeoOverride(this.checked)">
<span class="toggle-label">Egyéni országkorlátozás</span>
</label>
<div id="app-geo-override-details" {{if not .GeoAppOverride}}style="display:none"{{end}}>
<div class="form-group">
<label>Engedélyezett országok ehhez az alkalmazáshoz</label>
<div class="geo-country-selector">
<input type="text" id="app-geo-search" class="form-control config-input"
placeholder="Ország keresése..." autocomplete="off"
oninput="filterAppGeoCountries(this.value)"
onfocus="showAppGeoList()"
onblur="setTimeout(function(){hideAppGeoList()},200)">
<div class="geo-country-list" id="app-geo-country-list"></div>
</div>
<div class="geo-selected-tags" id="app-geo-tags"></div>
</div>
<div class="config-actions">
<button class="btn btn-primary" onclick="saveAppGeoOverride()">Mentés</button>
<span id="app-geo-status" class="config-save-status"></span>
</div>
</div>
</div>
<script>
(function(){
var allCountries = [];
var appGeoCountries = {{json .GeoAppOverrideCountries}};
var stackName = '{{.Stack.Name}}';
function loadCountries(cb) {
if (allCountries.length > 0) { cb(); return; }
fetch('/api/geo/countries', {headers: csrfHeaders()})
.then(function(r){return r.json()})
.then(function(d){ if(d.ok) allCountries = d.data; cb(); })
.catch(function(){ cb(); });
}
window.toggleAppGeoOverride = function(enabled) {
document.getElementById('app-geo-override-details').style.display = enabled ? '' : 'none';
if (enabled) {
if (!appGeoCountries || appGeoCountries.length === 0) {
appGeoCountries = {{json .GeoGlobalCountries}};
}
loadCountries(renderAppGeoTags);
} else {
// Remove override
fetch('/api/stacks/' + stackName + '/geo/override', {method:'DELETE', headers:csrfHeaders()});
}
};
window.showAppGeoList = function() { loadCountries(function(){ filterAppGeoCountries(''); }); };
window.hideAppGeoList = function() { document.getElementById('app-geo-country-list').style.display='none'; };
window.filterAppGeoCountries = function(q) {
var list = document.getElementById('app-geo-country-list');
q = q.toLowerCase();
var html = ''; var count = 0;
for (var i = 0; i < allCountries.length && count < 15; i++) {
var c = allCountries[i];
if (appGeoCountries.indexOf(c.code) >= 0) continue;
if (q && c.name.toLowerCase().indexOf(q) < 0 && c.code.toLowerCase().indexOf(q) < 0) continue;
html += '<div class="geo-country-option" onmousedown="addAppGeoCountry(\'' + c.code + '\')">'
+ esc(c.name) + ' <small>(' + c.code + ')</small></div>';
count++;
}
list.innerHTML = html || '<div class="geo-country-option" style="opacity:.5">Nincs találat</div>';
list.style.display = (count > 0 || q) ? '' : 'none';
};
window.addAppGeoCountry = function(code) {
if (appGeoCountries.indexOf(code) >= 0) return;
appGeoCountries.push(code);
renderAppGeoTags();
document.getElementById('app-geo-search').value = '';
hideAppGeoList();
};
window.removeAppGeoCountry = function(code) {
appGeoCountries = appGeoCountries.filter(function(c){return c !== code});
renderAppGeoTags();
};
function renderAppGeoTags() {
var el = document.getElementById('app-geo-tags');
var html = '';
for (var i = 0; i < appGeoCountries.length; i++) {
var code = appGeoCountries[i];
var name = code;
for (var j = 0; j < allCountries.length; j++) {
if (allCountries[j].code === code) { name = allCountries[j].name; break; }
}
html += '<span class="geo-tag">' + esc(name) + ' (' + code + ') '
+ '<span class="geo-tag-remove" onclick="removeAppGeoCountry(\'' + code + '\')">&times;</span></span>';
}
el.innerHTML = html;
}
window.saveAppGeoOverride = function() {
var status = document.getElementById('app-geo-status');
fetch('/api/stacks/' + stackName + '/geo/override', {
method: 'POST',
headers: Object.assign({'Content-Type':'application/json'}, csrfHeaders()),
body: JSON.stringify({allowed_countries: appGeoCountries})
})
.then(function(r){return r.json()})
.then(function(d){
status.textContent = d.ok ? (d.message || 'Mentve') : (d.error || 'Hiba');
status.className = 'config-save-status ' + (d.ok ? 'config-save-ok' : 'config-save-err');
setTimeout(function(){ status.textContent=''; }, 5000);
})
.catch(function(){
status.textContent = 'Hálózati hiba';
status.className = 'config-save-status config-save-err';
});
};
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
if (document.getElementById('app-geo-override-toggle') && document.getElementById('app-geo-override-toggle').checked) {
loadCountries(renderAppGeoTags);
}
})();
</script>
{{end}}
{{template "layout_end" .}}
{{end}}
@@ -376,6 +376,345 @@ function pollUntilBack() {
</details>
</div>
<!-- Section: Geo-Restriction -->
<div class="settings-card">
<h3>Földrajzi korlátozás</h3>
<p class="settings-card-desc">
Ország alapján korlátozható a webes alkalmazások elérése a Cloudflare WAF segítségével.
<br><span class="form-hint">A helyi hálózati hozzáférés mindig engedélyezett (nem halad át a Cloudflare-en).</span>
</p>
{{if not .CFConfigured}}
<div class="alert alert-info">
A Cloudflare API token nincs konfigurálva. Kérd az üzemeltetőt a beállításhoz.<br>
<small>A tokennek <strong>Zone WAF:Edit</strong> jogosultsággal kell rendelkeznie.</small>
</div>
{{else}}
<div id="geo-status-msg"></div>
<label class="toggle" style="margin-bottom:1rem">
<input type="checkbox" id="geo-enabled" {{if .GeoEnabled}}checked{{end}}
onchange="toggleGeo(this.checked)">
<span class="toggle-label">Geo-korlátozás aktív</span>
</label>
<div id="geo-details" {{if not .GeoEnabled}}style="display:none"{{end}}>
<!-- Global allowed countries -->
<div class="form-group">
<label>Engedélyezett országok (globális)</label>
<div class="geo-country-selector" id="geo-countries">
<input type="text" id="geo-search" class="form-control"
placeholder="Ország keresése..."
autocomplete="off"
oninput="filterCountries(this.value)"
onfocus="showCountryList()"
onblur="setTimeout(function(){hideCountryList()},200)">
<div class="geo-country-list" id="geo-country-list"></div>
</div>
<div class="geo-selected-tags" id="geo-selected-tags"></div>
<span class="form-hint">Csak a kiválasztott országokból érhető el a rendszer.</span>
</div>
<!-- Per-app overrides -->
<div class="form-group" style="margin-top:1.5rem">
<label>Alkalmazás-specifikus felülírások</label>
<div id="geo-app-overrides"></div>
{{if .DeployedApps}}
<div style="margin-top:.5rem;display:flex;align-items:center;gap:.5rem">
<select id="geo-add-app-select" class="form-control" style="max-width:250px">
<option value="">— Alkalmazás kiválasztása —</option>
{{range .DeployedApps}}
<option value="{{.Name}}">{{.Display}}</option>
{{end}}
</select>
<button class="btn btn-sm btn-outline" onclick="addAppOverride()">+ Hozzáadás</button>
</div>
{{end}}
</div>
<!-- Sync status & save -->
<div class="form-group" style="margin-top:1.5rem">
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
<button class="btn btn-primary" id="btn-geo-save" onclick="saveGeoSettings()">
Mentés és szinkronizálás
</button>
<button class="btn btn-sm btn-outline" onclick="triggerGeoSync()">Kézi szinkronizálás</button>
<span id="geo-sync-status" class="form-hint">
{{if .GeoLastSync}}Utolsó szinkronizálás: {{.GeoLastSync}}{{end}}
{{if .GeoLastError}} <span class="state-text-red">{{.GeoLastError}}</span>{{end}}
</span>
</div>
</div>
</div>
{{end}}
</div>
<script>
(function(){
// Geo-restriction UI state
var allCountries = [];
var selectedCountries = {{json .GeoAllowedCountries}};
var appOverrides = {{json .GeoAppOverrides}};
// Load countries list on first use
function ensureCountries(cb) {
if (allCountries.length > 0) { cb(); return; }
fetch('/api/geo/countries', {headers: csrfHeaders()})
.then(function(r){return r.json()})
.then(function(d){
if (d.ok) allCountries = d.data;
cb();
})
.catch(function(){ cb(); });
}
window.toggleGeo = function(enabled) {
document.getElementById('geo-details').style.display = enabled ? '' : 'none';
if (enabled) ensureCountries(renderTags);
};
window.showCountryList = function() {
ensureCountries(function(){ filterCountries(document.getElementById('geo-search').value); });
};
window.hideCountryList = function() {
document.getElementById('geo-country-list').style.display = 'none';
};
window.filterCountries = function(query) {
var list = document.getElementById('geo-country-list');
var q = query.toLowerCase();
var html = '';
var count = 0;
for (var i = 0; i < allCountries.length && count < 15; i++) {
var c = allCountries[i];
if (selectedCountries.indexOf(c.code) >= 0) continue;
if (q && c.name.toLowerCase().indexOf(q) < 0 && c.code.toLowerCase().indexOf(q) < 0) continue;
html += '<div class="geo-country-option" onmousedown="addCountry(\'' + c.code + '\',\'' + escHtml(c.name) + '\')">'
+ escHtml(c.name) + ' <small>(' + c.code + ')</small></div>';
count++;
}
list.innerHTML = html || '<div class="geo-country-option" style="opacity:.5">Nincs találat</div>';
list.style.display = count > 0 || q ? '' : 'none';
};
window.addCountry = function(code, name) {
if (selectedCountries.indexOf(code) >= 0) return;
selectedCountries.push(code);
renderTags();
document.getElementById('geo-search').value = '';
hideCountryList();
};
window.removeCountry = function(code) {
if (code === 'HU') {
if (!confirm('Figyelem: Magyarország eltávolítása azt jelenti, hogy magyar IP-ről sem lesz elérhető a rendszer távolról. Biztosan folytatja?')) return;
}
selectedCountries = selectedCountries.filter(function(c){return c !== code});
renderTags();
};
function renderTags() {
var el = document.getElementById('geo-selected-tags');
var html = '';
for (var i = 0; i < selectedCountries.length; i++) {
var code = selectedCountries[i];
var name = countryName(code);
var isHU = code === 'HU' ? ' geo-tag-hu' : '';
html += '<span class="geo-tag' + isHU + '">'
+ escHtml(name) + ' (' + code + ') '
+ '<span class="geo-tag-remove" onclick="removeCountry(\'' + code + '\')">&times;</span>'
+ '</span>';
}
el.innerHTML = html;
renderAppOverrides();
}
function countryName(code) {
for (var i = 0; i < allCountries.length; i++) {
if (allCountries[i].code === code) return allCountries[i].name;
}
return code;
}
// --- Per-app overrides ---
window.addAppOverride = function() {
var sel = document.getElementById('geo-add-app-select');
var appName = sel.value;
if (!appName) return;
if (!appOverrides) appOverrides = {};
if (appOverrides[appName]) { sel.value = ''; return; }
// Default: same countries as global
appOverrides[appName] = {allowed_countries: selectedCountries.slice()};
sel.value = '';
renderAppOverrides();
};
window.removeAppOverride = function(appName) {
delete appOverrides[appName];
renderAppOverrides();
};
window.toggleAppCountry = function(appName, code, el) {
var ov = appOverrides[appName];
if (!ov) return;
var idx = ov.allowed_countries.indexOf(code);
if (idx >= 0) {
if (code === 'HU' && !confirm('Magyarország eltávolítása nem ajánlott. Folytatja?')) {
el.checked = true;
return;
}
ov.allowed_countries.splice(idx, 1);
} else {
ov.allowed_countries.push(code);
}
};
function renderAppOverrides() {
var el = document.getElementById('geo-app-overrides');
if (!appOverrides || Object.keys(appOverrides).length === 0) {
el.innerHTML = '<p class="form-hint">Nincs alkalmazás-specifikus beállítás. Minden alkalmazás a globális beállítást követi.</p>';
return;
}
var html = '';
for (var appName in appOverrides) {
var ov = appOverrides[appName];
var displayName = appName;
// Try to find display name from select
var opts = document.getElementById('geo-add-app-select');
if (opts) {
for (var j = 0; j < opts.options.length; j++) {
if (opts.options[j].value === appName) { displayName = opts.options[j].text; break; }
}
}
html += '<div class="geo-app-override-row">';
html += '<strong>' + escHtml(displayName) + '</strong>';
html += '<div class="geo-selected-tags" style="flex:1;margin:0 .5rem">';
for (var i = 0; i < ov.allowed_countries.length; i++) {
var code = ov.allowed_countries[i];
html += '<span class="geo-tag geo-tag-sm">' + code + '</span>';
}
html += '</div>';
html += '<button class="btn btn-sm btn-outline" onclick="editAppOverride(\'' + appName + '\')">Szerkesztés</button>';
html += '<button class="btn btn-sm btn-danger-outline" onclick="removeAppOverride(\'' + appName + '\')">Törlés</button>';
html += '</div>';
}
el.innerHTML = html;
}
window.editAppOverride = function(appName) {
var ov = appOverrides[appName];
if (!ov) return;
ensureCountries(function(){
var checked = {};
for (var i = 0; i < ov.allowed_countries.length; i++) checked[ov.allowed_countries[i]] = true;
var html = '<div class="geo-edit-overlay" id="geo-edit-' + appName + '">';
html += '<h4>Engedélyezett országok: ' + escHtml(appName) + '</h4>';
html += '<div class="geo-edit-grid">';
for (var i = 0; i < allCountries.length; i++) {
var c = allCountries[i];
html += '<label class="geo-edit-item"><input type="checkbox" value="' + c.code + '"'
+ (checked[c.code] ? ' checked' : '') + ' onchange="toggleAppCountry(\'' + appName + '\',\'' + c.code + '\',this)">'
+ ' ' + escHtml(c.name) + ' (' + c.code + ')</label>';
}
html += '</div>';
html += '<button class="btn btn-sm btn-primary" style="margin-top:.5rem" onclick="closeAppEdit(\'' + appName + '\')">Kész</button>';
html += '</div>';
document.getElementById('geo-app-overrides').innerHTML += html;
});
};
window.closeAppEdit = function(appName) {
var el = document.getElementById('geo-edit-' + appName);
if (el) el.remove();
renderAppOverrides();
};
// --- Save & Sync ---
window.saveGeoSettings = function() {
var btn = document.getElementById('btn-geo-save');
var status = document.getElementById('geo-status-msg');
btn.disabled = true;
btn.textContent = 'Mentés...';
var payload = {
enabled: document.getElementById('geo-enabled').checked,
allowed_countries: selectedCountries
};
fetch('/api/geo/settings', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify(payload)
})
.then(function(r){return r.json()})
.then(function(d){
if (d.ok) {
status.innerHTML = '<div class="alert alert-info">' + (d.message || 'Mentve') + '</div>';
// Save per-app overrides
if (appOverrides && Object.keys(appOverrides).length > 0) {
saveAllAppOverrides();
}
} else {
status.innerHTML = '<div class="alert alert-error">' + (d.error || 'Hiba') + '</div>';
}
})
.catch(function(err){
status.innerHTML = '<div class="alert alert-error">Hálózati hiba</div>';
})
.finally(function(){
btn.disabled = false;
btn.textContent = 'Mentés és szinkronizálás';
setTimeout(function(){ status.innerHTML = ''; }, 8000);
});
};
function saveAllAppOverrides() {
for (var appName in appOverrides) {
(function(name, ov){
fetch('/api/stacks/' + name + '/geo/override', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({allowed_countries: ov.allowed_countries})
});
})(appName, appOverrides[appName]);
}
}
window.triggerGeoSync = function() {
fetch('/api/geo/sync', {method:'POST', headers: csrfHeaders()})
.then(function(r){return r.json()})
.then(function(d){
var status = document.getElementById('geo-sync-status');
status.textContent = d.ok ? 'Szinkronizálás elindítva...' : (d.error || 'Hiba');
setTimeout(function(){
fetch('/api/geo/status', {headers: csrfHeaders()})
.then(function(r){return r.json()})
.then(function(d){
if (d.ok && d.data) {
var sync = d.data.last_sync || '';
var err = d.data.last_sync_error || '';
status.innerHTML = sync ? ('Utolsó: ' + sync.substring(0,19).replace('T',' ')) : '';
if (err) status.innerHTML += ' <span class="state-text-red">' + escHtml(err) + '</span>';
}
});
}, 5000);
});
};
function escHtml(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// Initialize on load
if (document.getElementById('geo-enabled') && document.getElementById('geo-enabled').checked) {
ensureCountries(renderTags);
}
})();
</script>
<!-- Section B: Password Change -->
<div class="settings-card">
<h3>Jelszó módosítás</h3>
@@ -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); }