Hub v0.6.0: Geo-restriction display + disable button + UUID cleanup
- Add geo-restriction section to customer detail page (status, countries, per-app overrides, sync state, errors) - Add "Összes geo-korlátozás eltávolítása" button that directly calls Cloudflare API to delete [felhom-geo] WAF rules (bypasses blocked tunnel) - Background retry to notify controller to disable geo in settings - New internal/cloudflare/unblock.go — minimal CF client for rule deletion - Remove legacy Monitoring UUIDs from config form, buildConfigJSON, handlePullConfig, volatileKeys, and controller.yaml.default Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
# Felhom Hub — Changelog
|
||||
|
||||
## v0.6.0 (2026-02-25)
|
||||
|
||||
### Added
|
||||
- **Geo-restriction display** (`customer_unified.html`) — New "Geo-korlátozás" section on customer detail pages showing: enabled/disabled status, allowed countries, per-app overrides, last sync time, and sync errors. Only visible when the controller reports geo_restriction data.
|
||||
- **"Összes geo-korlátozás eltávolítása" button** — One-click removal of all `[felhom-geo]` Cloudflare WAF rules. The Hub calls the Cloudflare API directly (bypasses potentially blocked tunnel), then retries notifying the controller in background (every 30s for up to 10 min) to disable geo in its settings.
|
||||
- **Cloudflare unblock client** (`internal/cloudflare/unblock.go`) — Minimal Cloudflare API client for deleting geo-restriction WAF rules. Resolves zone ID, finds the `http_request_firewall_custom` ruleset, and deletes rules with `[felhom-geo]` description prefix.
|
||||
- **`POST /customers/{id}/geo/disable`** route — CSRF-protected endpoint for the geo-disable action.
|
||||
|
||||
### Removed
|
||||
- **Legacy Monitoring UUIDs** — Removed the "Monitoring UUIDs" section from the config form (`config_form.html`), UUID form-field handling from `buildConfigJSON()`, UUID import from `handlePullConfig()`, volatile key entries for `monitoring.ping_uuids.*`, and the commented-out `ping_uuids` section from `controller.yaml.default`. Monitoring is fully handled by the Hub event system since v0.3.0.
|
||||
|
||||
## v0.5.0 (2026-02-25)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// Package cloudflare provides a minimal Cloudflare API client for removing
|
||||
// geo-restriction WAF rules created by felhom-controller.
|
||||
package cloudflare
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const apiBase = "https://api.cloudflare.com/client/v4"
|
||||
|
||||
// geoRulePrefix is the description prefix used by felhom-controller for geo-blocking rules.
|
||||
const geoRulePrefix = "[felhom-geo]"
|
||||
|
||||
// RemoveGeoRules deletes all WAF custom rules with a [felhom-geo] description prefix
|
||||
// from the Cloudflare zone associated with the given domain.
|
||||
func RemoveGeoRules(apiToken, domain string, logger *log.Logger) error {
|
||||
if apiToken == "" {
|
||||
return fmt.Errorf("no Cloudflare API token provided")
|
||||
}
|
||||
if domain == "" {
|
||||
return fmt.Errorf("no domain provided")
|
||||
}
|
||||
|
||||
// 1. Resolve zone ID
|
||||
zoneID, err := resolveZone(apiToken, domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve zone for %s: %w", domain, err)
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Printf("[INFO] cloudflare.RemoveGeoRules: zone=%s for domain=%s", zoneID, domain)
|
||||
}
|
||||
|
||||
// 2. Find the http_request_firewall_custom phase ruleset
|
||||
rulesetID, err := findFirewallRuleset(apiToken, zoneID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find firewall ruleset: %w", err)
|
||||
}
|
||||
if rulesetID == "" {
|
||||
if logger != nil {
|
||||
logger.Printf("[INFO] cloudflare.RemoveGeoRules: no firewall ruleset found — nothing to remove")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. List rules and filter by [felhom-geo] prefix
|
||||
rules, err := listRules(apiToken, zoneID, rulesetID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list rules: %w", err)
|
||||
}
|
||||
|
||||
var geoRuleIDs []string
|
||||
for _, r := range rules {
|
||||
if strings.HasPrefix(r.Description, geoRulePrefix) {
|
||||
geoRuleIDs = append(geoRuleIDs, r.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(geoRuleIDs) == 0 {
|
||||
if logger != nil {
|
||||
logger.Printf("[INFO] cloudflare.RemoveGeoRules: no [felhom-geo] rules found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. Delete each matching rule
|
||||
var errors []string
|
||||
for _, ruleID := range geoRuleIDs {
|
||||
if err := deleteRule(apiToken, zoneID, rulesetID, ruleID); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("delete rule %s: %v", ruleID, err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("deleted %d/%d rules; errors: %s",
|
||||
len(geoRuleIDs)-len(errors), len(geoRuleIDs), strings.Join(errors, "; "))
|
||||
}
|
||||
|
||||
if logger != nil {
|
||||
logger.Printf("[INFO] cloudflare.RemoveGeoRules: deleted %d [felhom-geo] rule(s)", len(geoRuleIDs))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Cloudflare API helpers ---
|
||||
|
||||
type cfResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
|
||||
type zone struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ruleset struct {
|
||||
ID string `json:"id"`
|
||||
Phase string `json:"phase"`
|
||||
}
|
||||
|
||||
type rule struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func resolveZone(apiToken, domain string) (string, error) {
|
||||
// Try exact domain first, then parent domain
|
||||
for _, name := range []string{domain, parentDomain(domain)} {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
resp, err := cfDo(apiToken, "GET", fmt.Sprintf("/zones?name=%s&status=active", name), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var zones []zone
|
||||
if err := json.Unmarshal(resp.Result, &zones); err != nil {
|
||||
return "", fmt.Errorf("parse zones: %w", err)
|
||||
}
|
||||
if len(zones) > 0 {
|
||||
return zones[0].ID, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no active zone found for %s", domain)
|
||||
}
|
||||
|
||||
func parentDomain(domain string) string {
|
||||
parts := strings.SplitN(domain, ".", 2)
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
func findFirewallRuleset(apiToken, zoneID string) (string, error) {
|
||||
resp, err := cfDo(apiToken, "GET", fmt.Sprintf("/zones/%s/rulesets", zoneID), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var rulesets []ruleset
|
||||
if err := json.Unmarshal(resp.Result, &rulesets); err != nil {
|
||||
return "", fmt.Errorf("parse rulesets: %w", err)
|
||||
}
|
||||
for _, rs := range rulesets {
|
||||
if rs.Phase == "http_request_firewall_custom" {
|
||||
return rs.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func listRules(apiToken, zoneID, rulesetID string) ([]rule, error) {
|
||||
resp, err := cfDo(apiToken, "GET", fmt.Sprintf("/zones/%s/rulesets/%s", zoneID, rulesetID), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rs struct {
|
||||
Rules []rule `json:"rules"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Result, &rs); err != nil {
|
||||
return nil, fmt.Errorf("parse ruleset detail: %w", err)
|
||||
}
|
||||
return rs.Rules, nil
|
||||
}
|
||||
|
||||
func deleteRule(apiToken, zoneID, rulesetID, ruleID string) error {
|
||||
_, err := cfDo(apiToken, "DELETE", fmt.Sprintf("/zones/%s/rulesets/%s/rules/%s", zoneID, rulesetID, ruleID), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func cfDo(apiToken, method, path string, body io.Reader) (*cfResponse, error) {
|
||||
url := apiBase + path
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request %s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
var cfResp cfResponse
|
||||
if err := json.Unmarshal(data, &cfResp); err != nil {
|
||||
return nil, fmt.Errorf("parse CF response (status %d): %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
if !cfResp.Success {
|
||||
msgs := make([]string, len(cfResp.Errors))
|
||||
for i, e := range cfResp.Errors {
|
||||
msgs[i] = e.Message
|
||||
}
|
||||
return nil, fmt.Errorf("CF API error: %s", strings.Join(msgs, "; "))
|
||||
}
|
||||
|
||||
return &cfResp, nil
|
||||
}
|
||||
+105
-34
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
cfClient "gitea.dooplex.hu/admin/felhom-hub/internal/cloudflare"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -725,20 +726,6 @@ func buildConfigJSON(r *http.Request) string {
|
||||
overrides["git"] = git
|
||||
}
|
||||
|
||||
// Monitoring UUIDs (legacy — only written if user explicitly provides values)
|
||||
uuids := make(map[string]interface{})
|
||||
for _, key := range []string{"heartbeat", "system_health", "db_dump", "backup", "backup_integrity"} {
|
||||
if v := strings.TrimSpace(r.FormValue("uuid_" + key)); v != "" {
|
||||
uuids[key] = v
|
||||
}
|
||||
}
|
||||
if len(uuids) > 0 {
|
||||
if _, ok := overrides["monitoring"]; !ok {
|
||||
overrides["monitoring"] = make(map[string]interface{})
|
||||
}
|
||||
overrides["monitoring"].(map[string]interface{})["ping_uuids"] = uuids
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(overrides)
|
||||
return string(data)
|
||||
}
|
||||
@@ -763,11 +750,6 @@ func extractControllerYAML(infraData []byte) string {
|
||||
// volatileKeys are YAML keys ignored during config comparison (always differ or deprecated).
|
||||
var volatileKeys = map[string]bool{
|
||||
"web.session_secret": true,
|
||||
"monitoring.ping_uuids.heartbeat": true,
|
||||
"monitoring.ping_uuids.system_health": true,
|
||||
"monitoring.ping_uuids.db_dump": true,
|
||||
"monitoring.ping_uuids.backup": true,
|
||||
"monitoring.ping_uuids.backup_integrity": true,
|
||||
}
|
||||
|
||||
// sensitiveKeyParts are substrings that indicate a value should be masked in diff output.
|
||||
@@ -1074,21 +1056,6 @@ func (s *Server) handlePullConfig(w http.ResponseWriter, r *http.Request, custom
|
||||
}
|
||||
}
|
||||
|
||||
// Monitoring UUIDs (legacy but still imported)
|
||||
if monitoring, ok := parsed["monitoring"].(map[string]interface{}); ok {
|
||||
if uuids, ok := monitoring["ping_uuids"].(map[string]interface{}); ok {
|
||||
uuidOverrides := make(map[string]interface{})
|
||||
for _, key := range []string{"heartbeat", "system_health", "db_dump", "backup", "backup_integrity"} {
|
||||
if v, ok := uuids[key].(string); ok && v != "" {
|
||||
uuidOverrides[key] = v
|
||||
}
|
||||
}
|
||||
if len(uuidOverrides) > 0 {
|
||||
overrides["monitoring"] = map[string]interface{}{"ping_uuids": uuidOverrides}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configJSON, _ := json.Marshal(overrides)
|
||||
cfg.ConfigJSON = string(configJSON)
|
||||
|
||||
@@ -1103,3 +1070,107 @@ func (s *Server) handlePullConfig(w http.ResponseWriter, r *http.Request, custom
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Config imported from controller"})
|
||||
}
|
||||
|
||||
// handleGeoDisable removes all [felhom-geo] WAF rules from Cloudflare for a customer,
|
||||
// and notifies the controller to disable geo-restriction in its settings.
|
||||
func (s *Server) handleGeoDisable(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
cfg, err := s.store.GetCustomerConfig(customerID)
|
||||
if err != nil || cfg == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Customer not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract CF API token from config_json → infrastructure.cf_api_token
|
||||
var overrides map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(cfg.ConfigJSON), &overrides); err != nil {
|
||||
overrides = make(map[string]interface{})
|
||||
}
|
||||
var cfToken string
|
||||
if infra, ok := overrides["infrastructure"].(map[string]interface{}); ok {
|
||||
cfToken, _ = infra["cf_api_token"].(string)
|
||||
}
|
||||
if cfToken == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No Cloudflare API token configured for this customer"})
|
||||
return
|
||||
}
|
||||
if cfg.Domain == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No domain configured for this customer"})
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Remove WAF rules directly via Cloudflare API
|
||||
if err := cfClient.RemoveGeoRules(cfToken, cfg.Domain, s.logger); err != nil {
|
||||
s.logger.Printf("[ERROR] Geo disable for %s: Cloudflare removal failed: %v", customerID, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Cloudflare API error: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Geo disable for %s: Cloudflare WAF rules removed", customerID)
|
||||
|
||||
// 2. Background: notify controller to disable geo in settings (retry for up to 10 min)
|
||||
customer, _ := s.store.GetCustomer(customerID)
|
||||
controllerURL := ""
|
||||
if customer != nil {
|
||||
controllerURL = customer.ControllerURL
|
||||
}
|
||||
if controllerURL == "" {
|
||||
var rpt struct {
|
||||
ControllerURL string `json:"controller_url"`
|
||||
}
|
||||
if customer != nil {
|
||||
json.Unmarshal([]byte(customer.ReportJSON), &rpt)
|
||||
controllerURL = rpt.ControllerURL
|
||||
}
|
||||
}
|
||||
|
||||
if controllerURL != "" && s.apiKey != "" {
|
||||
go s.notifyControllerGeoDisable(customerID, controllerURL)
|
||||
} else {
|
||||
s.logger.Printf("[WARN] Geo disable for %s: cannot notify controller (no URL or API key)", customerID)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Geo-restriction removed from Cloudflare. Controller will be notified."})
|
||||
}
|
||||
|
||||
// notifyControllerGeoDisable retries sending geo-disable to the controller every 30s for up to 10 min.
|
||||
func (s *Server) notifyControllerGeoDisable(customerID, controllerURL string) {
|
||||
geoURL := controllerURL + "/api/geo/settings"
|
||||
|
||||
for attempt := 1; attempt <= 20; attempt++ {
|
||||
req, err := http.NewRequest("POST", geoURL, strings.NewReader(`{"enabled":false,"allowed_countries":["HU"]}`))
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] Geo disable notify %s attempt %d: create request: %v", customerID, attempt, err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] Geo disable notify %s attempt %d: %v", customerID, attempt, err)
|
||||
time.Sleep(30 * time.Second)
|
||||
continue
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
s.logger.Printf("[INFO] Geo disable notify %s: controller confirmed (attempt %d): %s", customerID, attempt, string(body))
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[WARN] Geo disable notify %s attempt %d: status %d: %s", customerID, attempt, resp.StatusCode, string(body))
|
||||
time.Sleep(30 * time.Second)
|
||||
}
|
||||
|
||||
s.logger.Printf("[ERROR] Geo disable notify %s: gave up after 20 attempts", customerID)
|
||||
}
|
||||
|
||||
@@ -80,12 +80,6 @@ backup:
|
||||
monitoring:
|
||||
enabled: true
|
||||
healthchecks_base: "https://status.felhom.eu"
|
||||
# ping_uuids: (deprecated — monitoring is now handled by the Hub event system)
|
||||
# heartbeat: ""
|
||||
# system_health: ""
|
||||
# db_dump: ""
|
||||
# backup: ""
|
||||
# backup_integrity: ""
|
||||
system_health_interval: "5m"
|
||||
health_check_schedule: "06:00"
|
||||
thresholds:
|
||||
|
||||
@@ -190,6 +190,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/geo/disable"):
|
||||
customerID := strings.TrimPrefix(path, "/customers/")
|
||||
customerID = strings.TrimSuffix(customerID, "/geo/disable")
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleGeoDisable(w, r, customerID)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/push-config"):
|
||||
customerID := strings.TrimPrefix(path, "/customers/")
|
||||
customerID = strings.TrimSuffix(customerID, "/push-config")
|
||||
|
||||
@@ -100,45 +100,6 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="card">
|
||||
<summary><h2 style="display:inline">Monitoring UUIDs</h2> <span class="severity-badge severity-warning" style="font-size: 0.7em; vertical-align: middle;">Legacy</span></summary>
|
||||
<p class="text-muted" style="margin: 0.5rem 0;">Healthchecks ping UUIDs are deprecated. Monitoring is now handled natively by the Hub event system. These fields are kept for backward compatibility with older controllers.</p>
|
||||
<div class="form-grid" style="margin-top: 1rem;">
|
||||
{{$uuids := ""}}
|
||||
{{with .Overrides}}{{with index . "monitoring"}}{{with index . "ping_uuids"}}{{$uuids = .}}{{end}}{{end}}{{end}}
|
||||
<div class="form-group">
|
||||
<label for="uuid_heartbeat">Heartbeat</label>
|
||||
<input type="text" id="uuid_heartbeat" name="uuid_heartbeat"
|
||||
value="{{with $uuids}}{{with index . "heartbeat"}}{{.}}{{end}}{{end}}"
|
||||
placeholder="UUID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="uuid_system_health">System Health</label>
|
||||
<input type="text" id="uuid_system_health" name="uuid_system_health"
|
||||
value="{{with $uuids}}{{with index . "system_health"}}{{.}}{{end}}{{end}}"
|
||||
placeholder="UUID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="uuid_db_dump">DB Dump</label>
|
||||
<input type="text" id="uuid_db_dump" name="uuid_db_dump"
|
||||
value="{{with $uuids}}{{with index . "db_dump"}}{{.}}{{end}}{{end}}"
|
||||
placeholder="UUID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="uuid_backup">Backup</label>
|
||||
<input type="text" id="uuid_backup" name="uuid_backup"
|
||||
value="{{with $uuids}}{{with index . "backup"}}{{.}}{{end}}{{end}}"
|
||||
placeholder="UUID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="uuid_backup_integrity">Backup Integrity</label>
|
||||
<input type="text" id="uuid_backup_integrity" name="uuid_backup_integrity"
|
||||
value="{{with $uuids}}{{with index . "backup_integrity"}}{{.}}{{end}}{{end}}"
|
||||
placeholder="UUID">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
|
||||
<button type="submit" class="btn">{{if .IsNew}}Create Configuration{{else}}Save Changes{{end}}</button>
|
||||
<a href="{{if .IsNew}}/configs{{else}}/customers/{{.Config.CustomerID}}{{end}}" class="btn btn-outline">Cancel</a>
|
||||
|
||||
@@ -226,6 +226,76 @@
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Geo-restriction -->
|
||||
{{if .HasReports}}
|
||||
{{with .Report.geo_restriction}}
|
||||
<section class="card">
|
||||
<h2>Geo-korlátozás</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">Állapot</span>
|
||||
<span class="value">
|
||||
{{if index . "enabled"}}
|
||||
<span class="severity-badge severity-critical">Aktív</span>
|
||||
{{else}}
|
||||
<span class="severity-badge severity-ok">Inaktív</span>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{if index . "enabled"}}
|
||||
<div class="info-item">
|
||||
<span class="label">Engedélyezett országok</span>
|
||||
<span class="value">
|
||||
{{$countries := index . "allowed_countries"}}
|
||||
{{if $countries}}
|
||||
{{range $i, $c := $countries}}{{if $i}}, {{end}}{{$c}}{{end}}
|
||||
{{else}}
|
||||
—
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if index . "last_sync"}}
|
||||
<div class="info-item">
|
||||
<span class="label">Utolsó szinkron</span>
|
||||
<span class="value">{{index . "last_sync"}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if index . "last_sync_error"}}
|
||||
<div class="info-item">
|
||||
<span class="label">Szinkron hiba</span>
|
||||
<span class="value" style="color: #f87171">{{index . "last_sync_error"}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{$overrides := index . "app_overrides"}}
|
||||
{{if $overrides}}
|
||||
<h3 style="margin-top: 1rem; font-size: 0.95rem;">Alkalmazás felülírások</h3>
|
||||
<table class="data-table" style="margin-top: 0.5rem;">
|
||||
<thead><tr><th>Alkalmazás</th><th>Engedélyezett országok</th></tr></thead>
|
||||
<tbody>
|
||||
{{range $app, $override := $overrides}}
|
||||
<tr>
|
||||
<td>{{$app}}</td>
|
||||
<td>
|
||||
{{$ac := index $override "allowed_countries"}}
|
||||
{{if $ac}}{{range $i, $c := $ac}}{{if $i}}, {{end}}{{$c}}{{end}}{{else}}—{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
{{if index . "enabled"}}
|
||||
<div style="margin-top: 1rem;">
|
||||
<button class="btn btn-danger" id="btn-geo-disable" onclick="disableGeo('{{$.Customer.CustomerID}}')">Összes geo-korlátozás eltávolítása</button>
|
||||
<span id="geo-msg" style="display:none; margin-left: 0.75rem;"></span>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<!-- Infra Backup -->
|
||||
<section class="card">
|
||||
<h2>Infra Backup</h2>
|
||||
@@ -595,6 +665,38 @@
|
||||
});
|
||||
}
|
||||
|
||||
function disableGeo(customerID) {
|
||||
if (!confirm('Összes geo-korlátozás eltávolítása?\n\nEz közvetlenül törli a Cloudflare WAF szabályokat és értesíti a controllert.')) return;
|
||||
var btn = document.getElementById('btn-geo-disable');
|
||||
var msg = document.getElementById('geo-msg');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Eltávolítás...';
|
||||
msg.style.display = 'none';
|
||||
fetch('/customers/' + customerID + '/geo/disable', {method: 'POST', headers: csrfHeaders()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
msg.textContent = data.message || 'Geo-korlátozás eltávolítva';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#4ade80';
|
||||
setTimeout(function() { location.reload(); }, 2000);
|
||||
} else {
|
||||
msg.textContent = data.error || 'Hiba történt';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#f87171';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Összes geo-korlátozás eltávolítása';
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
msg.textContent = 'Kapcsolódási hiba';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#f87171';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Összes geo-korlátozás eltávolítása';
|
||||
});
|
||||
}
|
||||
|
||||
function triggerControllerUpdate(customerID) {
|
||||
if (!confirm('Trigger self-update on this controller?\n\nThe controller will be briefly unavailable during restart.')) return;
|
||||
var btn = document.getElementById('btn-trigger-update');
|
||||
|
||||
Reference in New Issue
Block a user