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:
@@ -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
|
||||
}
|
||||
+106
-35
@@ -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)
|
||||
}
|
||||
@@ -762,12 +749,7 @@ 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,
|
||||
"web.session_secret": 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