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:
@@ -1,5 +1,29 @@
|
|||||||
## Changelog
|
## 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)
|
### v0.29.3 — Controller-side Health Probes (2026-02-25)
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
|
|||||||
+110
-3
@@ -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.
|
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)
|
- [Disaster Recovery](#10-disaster-recovery)
|
||||||
- [Asset Sync](#11-asset-sync)
|
- [Asset Sync](#11-asset-sync)
|
||||||
- [Debug Mode](#12-debug-mode)
|
- [Debug Mode](#12-debug-mode)
|
||||||
|
- [Geo-Restriction](#13-geo-restriction)
|
||||||
- [Repository Layout](#repository-layout)
|
- [Repository Layout](#repository-layout)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [REST API](#rest-api)
|
- [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
|
## Repository Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
controller/
|
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/
|
├── internal/
|
||||||
│ ├── config/config.go # YAML loader, validation, env overrides
|
│ ├── config/config.go # YAML loader, validation, env overrides
|
||||||
│ ├── crypto/crypto.go # AES-256-GCM encryption for app.yaml secrets, key management
|
│ ├── 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_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_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
|
│ │ └── 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)
|
│ ├── 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)
|
│ ├── scheduler/scheduler.go # Central job scheduler (Every, Daily)
|
||||||
│ ├── system/
|
│ ├── system/
|
||||||
│ │ ├── info.go, info_linux.go # RAM, disk, CPU, temperature, load average
|
│ │ ├── info.go, info_linux.go # RAM, disk, CPU, temperature, load average
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
"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/crypto"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||||
@@ -604,6 +605,47 @@ func main() {
|
|||||||
if assetsSyncer != nil {
|
if assetsSyncer != nil {
|
||||||
apiRouter.SetAssetsSyncer(assetsSyncer)
|
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 ---
|
// --- Initialize web server ---
|
||||||
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version)
|
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version)
|
||||||
webServer.SetEncryptionKey(encKey)
|
webServer.SetEncryptionKey(encKey)
|
||||||
@@ -871,6 +913,32 @@ func (a *stackAdapter) GetStackHDDPath(name string) string {
|
|||||||
return ""
|
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.
|
// watchdogStackAdapter implements monitor.WatchdogStackProvider using stacks.Manager.
|
||||||
type watchdogStackAdapter struct {
|
type watchdogStackAdapter struct {
|
||||||
mgr *stacks.Manager
|
mgr *stacks.Manager
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Router) geoStatus(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
geo := r.sett.GetGeoRestriction()
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"cf_configured": r.cfg.Infrastructure.CFAPIToken != "",
|
||||||
|
"geo_available": r.geoSync != nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
if geo != nil {
|
||||||
|
data["enabled"] = geo.Enabled
|
||||||
|
data["allowed_countries"] = geo.AllowedCountries
|
||||||
|
data["app_overrides"] = geo.AppOverrides
|
||||||
|
data["last_sync"] = geo.LastSync
|
||||||
|
data["last_sync_error"] = geo.LastSyncError
|
||||||
|
data["syncing"] = r.geoSync != nil && r.geoSync.IsRunning()
|
||||||
|
} else {
|
||||||
|
data["enabled"] = false
|
||||||
|
data["allowed_countries"] = []string{"HU"}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) geoUpdateSettings(w http.ResponseWriter, req *http.Request) {
|
||||||
|
limitBody(w, req)
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
AllowedCountries []string `json:"allowed_countries"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate country codes
|
||||||
|
for _, code := range body.AllowedCountries {
|
||||||
|
if !cf.ValidCountryCode(code) {
|
||||||
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid country code: " + code})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing settings to preserve app overrides and sync state
|
||||||
|
existing := r.sett.GetGeoRestriction()
|
||||||
|
geo := &settings.GeoRestriction{
|
||||||
|
Enabled: body.Enabled,
|
||||||
|
AllowedCountries: body.AllowedCountries,
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
geo.AppOverrides = existing.AppOverrides
|
||||||
|
geo.ZoneID = existing.ZoneID
|
||||||
|
geo.RulesetID = existing.RulesetID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.sett.SetGeoRestriction(geo); err != nil {
|
||||||
|
r.logger.Printf("[API] Failed to save geo settings: %v", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Printf("[API] Geo settings updated: enabled=%v, countries=%v", body.Enabled, body.AllowedCountries)
|
||||||
|
|
||||||
|
// Trigger async CF sync
|
||||||
|
if r.geoSync != nil {
|
||||||
|
go func() {
|
||||||
|
if err := r.geoSync.Sync(context.Background()); err != nil {
|
||||||
|
r.logger.Printf("[API] Geo sync after settings update failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Geo-korlátozás beállítva"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) geoTriggerSync(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if r.geoSync == nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Cloudflare API nincs konfigurálva"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := r.geoSync.Sync(context.Background()); err != nil {
|
||||||
|
r.logger.Printf("[API] Manual geo sync failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Szinkronizálás elindítva"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) geoCountries(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: cf.AllCountries()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, appName string) {
|
||||||
|
if appName == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid app name"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limitBody(w, req)
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
AllowedCountries []string `json:"allowed_countries"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate country codes
|
||||||
|
for _, code := range body.AllowedCountries {
|
||||||
|
if !cf.ValidCountryCode(code) {
|
||||||
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid country code: " + code})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify app exists
|
||||||
|
if _, ok := r.stackMgr.GetStack(appName); !ok {
|
||||||
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "app not found: " + appName})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
override := &settings.AppGeoOverride{AllowedCountries: body.AllowedCountries}
|
||||||
|
if err := r.sett.SetGeoAppOverride(appName, override); err != nil {
|
||||||
|
r.logger.Printf("[API] Failed to save geo override for %s: %v", appName, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Printf("[API] Geo override set for %s: countries=%v", appName, body.AllowedCountries)
|
||||||
|
|
||||||
|
// Trigger async CF sync
|
||||||
|
if r.geoSync != nil {
|
||||||
|
go func() {
|
||||||
|
if err := r.geoSync.Sync(context.Background()); err != nil {
|
||||||
|
r.logger.Printf("[API] Geo sync after app override failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Alkalmazás geo-korlátozás beállítva"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, appName string) {
|
||||||
|
if appName == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid app name"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.sett.RemoveGeoAppOverride(appName); err != nil {
|
||||||
|
r.logger.Printf("[API] Failed to remove geo override for %s: %v", appName, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Printf("[API] Geo override removed for %s", appName)
|
||||||
|
|
||||||
|
// Trigger async CF sync
|
||||||
|
if r.geoSync != nil {
|
||||||
|
go func() {
|
||||||
|
if err := r.geoSync.Sync(context.Background()); err != nil {
|
||||||
|
r.logger.Printf("[API] Geo sync after override removal failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Alkalmazás geo-korlátozás eltávolítva"})
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
"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/config"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
"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 is called after a manual cross-drive backup completes (to push infra backup to Hub).
|
||||||
OnCrossDriveComplete func()
|
OnCrossDriveComplete func()
|
||||||
|
|
||||||
|
// OnGeoRelevantChange is called after deploy/remove to re-sync geo rules.
|
||||||
|
OnGeoRelevantChange func()
|
||||||
|
|
||||||
// Asset syncer for on-demand Hub asset sync
|
// Asset syncer for on-demand Hub asset sync
|
||||||
assetsSyncer *assets.Syncer
|
assetsSyncer *assets.Syncer
|
||||||
|
|
||||||
|
// Geo-restriction sync manager
|
||||||
|
geoSync *cf.GeoSyncManager
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAssetsSyncer sets the Hub asset syncer for on-demand sync triggers.
|
// 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
|
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 {
|
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}
|
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:
|
case path == "/assets/status" && req.Method == http.MethodGet:
|
||||||
r.assetSyncStatus(w, req)
|
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:
|
default:
|
||||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"})
|
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)
|
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) {
|
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 {
|
if r.notifier != nil {
|
||||||
r.notifier.NotifyAppRemoved(name, name)
|
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) {
|
func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -46,6 +46,9 @@ type Settings struct {
|
|||||||
|
|
||||||
// Pending events (queued for next Hub push)
|
// Pending events (queued for next Hub push)
|
||||||
PendingEvents []PendingEvent `json:"pending_events,omitempty"`
|
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.
|
// AppBackupPrefs holds per-app backup toggle state.
|
||||||
@@ -116,6 +119,24 @@ type PendingEvent struct {
|
|||||||
CreatedAt string `json:"created_at"` // RFC3339
|
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.
|
// DBValidationCache holds cached DB dump validation results.
|
||||||
type DBValidationCache struct {
|
type DBValidationCache struct {
|
||||||
ValidatedAt string `json:"validated_at"` // RFC3339
|
ValidatedAt string `json:"validated_at"` // RFC3339
|
||||||
@@ -791,3 +812,98 @@ func (s *Settings) DrainPendingEvents() []PendingEvent {
|
|||||||
}
|
}
|
||||||
return events
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -478,6 +478,22 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
|||||||
data["HasOptionalConfig"] = found.Meta.HasOptionalConfig()
|
data["HasOptionalConfig"] = found.Meta.HasOptionalConfig()
|
||||||
data["EffectiveSubdomain"] = effectiveSubdomain
|
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)
|
s.executeTemplate(w, r, "app_info", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1084,6 +1100,33 @@ func (s *Server) settingsData() map[string]interface{} {
|
|||||||
data["SupportEmail"] = "support@felhom.eu"
|
data["SupportEmail"] = "support@felhom.eu"
|
||||||
data["SupportURL"] = "https://felhom.eu/kapcsolat"
|
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
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,5 +179,141 @@ async function saveOptionalConfig(stackName) {
|
|||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{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 + '\')">×</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" .}}
|
{{template "layout_end" .}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -376,6 +376,345 @@ function pollUntilBack() {
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</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 + '\')">×</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 -->
|
<!-- Section B: Password Change -->
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h3>Jelszó módosítás</h3>
|
<h3>Jelszó módosítás</h3>
|
||||||
|
|||||||
@@ -3009,3 +3009,50 @@ a.stat-card:hover {
|
|||||||
margin-right: 0.3rem;
|
margin-right: 0.3rem;
|
||||||
}
|
}
|
||||||
@keyframes debug-spin { to { transform: rotate(360deg); } }
|
@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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user