diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index d3d4e28ef..63b92a446 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -603,6 +603,8 @@ check.
#### Security Suite (`internal/cerberus/`)
- **ACL (Access Control Lists):** IP-based allow/deny rules, GeoIP blocking
+ - **Country lists (GeoIP):** Enforced in Caddy, not in Charon's API. When at least one enabled country list is in use, the generator adds the `geoip2` app (database directory and edition split from the configured `.mmdb` path, lock file in Charon's Caddy data directory, no account or key) and a `geoip2` handler with `enable: "strict"` immediately before the country subroute. Charon checks database availability at generation time (file exists, name ends in `.mmdb`, `GeoIPService` loaded it). When unavailable, each enabled country list becomes a 403 deny handler, with one Warn per apply and a passive `geoip_unavailable` reason on `/security/status`. `POST /security/geoip/reload` triggers a proxy apply after a successful reload.
+ - **Allow lists without rules:** An enabled allow list with no rules, or rules that cannot be read, becomes a 403 deny handler with one Warn per apply. A block list with no rules is left out, since it blocks nobody.
- **WAF (Web Application Firewall):** Coraza engine with user-provided rule sets (CRS-compatible)
- **CrowdSec:** Behavior-based threat detection with global intelligence
- **Rate Limiter:** Opt-in per-client API throttling (shared `internal/ratelimit` component)
@@ -725,12 +727,12 @@ export const useProxyHosts = () => {
**Route Pattern: Main Route (plus a recovery route for Charon's own host)**
-For each proxy host, Charon generates a **main route** with the domain and no path matchers. Its handlers run the full Cerberus security pipeline before the reverse proxy. Locations are generated as separate path-matched routes that run the same pipeline.
+For each proxy host, Charon generates a **main route** with the domain and no path matchers. Its handlers run in a fixed order before the reverse proxy: security headers first, then the Cerberus security checks, then the proxy. Locations are generated as separate path-matched routes that run the same order.
1. **Main Route** (without path matchers):
- Matches: All paths for the domain
- Purpose: Normal application traffic with full security
- - Handlers: Full Cerberus security suite, then the reverse proxy
+ - Handlers: Security headers (applied to every response, including refusals), then the Cerberus security checks, then the reverse proxy. Country lists are checked by a `geoip2` handler placed immediately before the country subroute.
2. **Recovery Route** (exact path matchers, only for a host that proxies Charon itself):
- Matches: exactly `/api/v1/emergency/security-reset` and `/emergency/security-reset`, with no wildcards
@@ -754,7 +756,7 @@ Caddy processes routes in order, so the recovery route (when present) is evaluat
// Main Route (evaluated second)
{
"match": [{"host": ["app.example.com"]}],
- "handle": [/* Security middleware + proxy */],
+ "handle": [/* Security headers, security checks, then proxy */],
"terminal": true
}
```
diff --git a/Dockerfile b/Dockerfile
index bf4359703..62c69dfa5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1036,29 +1036,33 @@ SHELL ["/bin/ash", "-o", "pipefail", "-c"]
# Note: In production, users should provide their own MaxMind license key
# This uses the publicly available GeoLite2 database
# In CI, timeout quickly rather than retrying to save build time
+# The download is checked against GEOLITE2_COUNTRY_SHA256; a mismatch only prints a warning because the upstream copy
+# changes between hash updates. A failed or empty download prints a warning and writes the marker
+# /app/data/geoip/.missing so the runtime can report it. The build does not fail: the image then ships without a
+# database and country access lists refuse requests.
ARG GEOLITE2_COUNTRY_SHA256=2c2d05c474baeb669298cce912a6236b5ecc4e50f0316a93e01ba5cf22183600
RUN mkdir -p /app/data/geoip && \
- if [ "$CI" = "true" ] || [ "$CI" = "1" ]; then \
- echo "⏱️ CI detected - quick download (10s timeout, no retries)"; \
- if wget -qO /app/data/geoip/GeoLite2-Country.mmdb \
- -T 10 "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" 2>/dev/null \
- && [ -s /app/data/geoip/GeoLite2-Country.mmdb ]; then \
- echo "✅ GeoIP downloaded"; \
- else \
- echo "⚠️ GeoIP skipped"; \
- touch /app/data/geoip/GeoLite2-Country.mmdb.placeholder; \
- fi; \
+ db=/app/data/geoip/GeoLite2-Country.mmdb && \
+ if [ "$CI" = "true" ] || [ "$CI" = "1" ]; then \
+ echo "⏱️ CI detected - quick download (10s timeout, no retries)"; \
+ wget_opts="-T 10"; \
+ else \
+ echo "Local - full download (30s timeout, 3 retries)"; \
+ wget_opts="-T 30 -t 4"; \
+ fi && \
+ if wget -qO "$db" $wget_opts "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" \
+ && [ -s "$db" ]; then \
+ if echo "${GEOLITE2_COUNTRY_SHA256} $db" | sha256sum -c - >/dev/null 2>&1; then \
+ echo "✅ GeoIP downloaded and checksum verified"; \
else \
- echo "Local - full download (30s timeout, 3 retries)"; \
- if wget -qO /app/data/geoip/GeoLite2-Country.mmdb \
- -T 30 -t 4 "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" \
- && [ -s /app/data/geoip/GeoLite2-Country.mmdb ]; then \
- echo "✅ GeoIP downloaded"; \
- else \
- echo "⚠️ GeoIP download failed or empty — skipping"; \
- touch /app/data/geoip/GeoLite2-Country.mmdb.placeholder; \
- fi; \
- fi && \
+ echo "WARNING: GeoLite2-Country checksum differs from GEOLITE2_COUNTRY_SHA256 (the upstream copy was updated); keeping the downloaded database." >&2; \
+ fi; \
+ else \
+ echo "WARNING: GeoLite2-Country database not bundled (download failed or empty)." >&2; \
+ echo "WARNING: The image has no GeoIP database. Country access lists will refuse requests until one is provided at CHARON_GEOIP_DB_PATH." >&2; \
+ rm -f "$db"; \
+ touch /app/data/geoip/GeoLite2-Country.mmdb.placeholder /app/data/geoip/.missing; \
+ fi && \
chown -R charon:charon /app/data
# Copy Caddy binary from caddy-builder (overwriting the one from base image)
diff --git a/backend/internal/api/handlers/access_list_handler.go b/backend/internal/api/handlers/access_list_handler.go
index 3bcbee009..cf20628ab 100644
--- a/backend/internal/api/handlers/access_list_handler.go
+++ b/backend/internal/api/handlers/access_list_handler.go
@@ -1,10 +1,12 @@
package handlers
import (
+ "errors"
"fmt"
"net/http"
"strconv"
+ "github.com/Wikid82/charon/backend/internal/api/middleware"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
@@ -13,21 +15,57 @@ import (
// AccessListHandler handles access list API requests.
type AccessListHandler struct {
- service *services.AccessListService
+ service *services.AccessListService
+ caddyManager CaddyConfigManager
}
-// NewAccessListHandler creates a new AccessListHandler.
+// NewAccessListHandler creates a new AccessListHandler without proxy apply, for callers that only manage lists.
func NewAccessListHandler(db *gorm.DB) *AccessListHandler {
+ return NewAccessListHandlerWithDeps(db, nil)
+}
+
+// NewAccessListHandlerWithDeps creates an AccessListHandler that re-applies the proxy config when an edit reaches a
+// list that hosts use.
+func NewAccessListHandlerWithDeps(db *gorm.DB, caddyManager CaddyConfigManager) *AccessListHandler {
return &AccessListHandler{
- service: services.NewAccessListService(db),
+ service: services.NewAccessListService(db),
+ caddyManager: nilIfTypedNil(caddyManager),
}
}
+// applyIfInUse re-applies the proxy config after a change to a list. A list no host uses is not part of the proxy
+// config, so nothing is applied for it. It returns the apply error, if any.
+func (h *AccessListHandler) applyIfInUse(c *gin.Context, aclID uint) error {
+ if h.caddyManager == nil {
+ return nil
+ }
+ inUse, err := h.service.InUse(aclID)
+ if err != nil {
+ return err
+ }
+ if !inUse {
+ return nil
+ }
+ return applyProxyConfigDetached(c.Request.Context(), h.caddyManager, proxyApplyTimeout)
+}
+
// SetGeoIPService sets the GeoIP service for geo-based ACL lookups.
func (h *AccessListHandler) SetGeoIPService(geoipSvc *services.GeoIPService) {
h.service.SetGeoIPService(geoipSvc)
}
+// accessListRulesRequiredCode is the stable error_code for ErrAccessListRulesRequired; the UI maps it to a message.
+const accessListRulesRequiredCode = "access_list_rules_required"
+
+// respondAccessListSaveError answers a refused create or update with 400. Known refusals carry a stable error_code.
+func respondAccessListSaveError(c *gin.Context, err error) {
+ if errors.Is(err, services.ErrAccessListRulesRequired) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "error_code": accessListRulesRequiredCode})
+ return
+ }
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+}
+
// resolveAccessList resolves an access list by either numeric ID or UUID.
// It first attempts to parse as uint (backward compatibility), then tries UUID.
func (h *AccessListHandler) resolveAccessList(idOrUUID string) (*models.AccessList, error) {
@@ -54,7 +92,7 @@ func (h *AccessListHandler) Create(c *gin.Context) {
}
if err := h.service.Create(&acl); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ respondAccessListSaveError(c, err)
return
}
@@ -118,7 +156,14 @@ func (h *AccessListHandler) Update(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
return
}
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ respondAccessListSaveError(c, err)
+ return
+ }
+
+ // The edit is saved at this point; an apply failure is reported, and the next apply picks the saved rules up.
+ if err = h.applyIfInUse(c, acl.ID); err != nil {
+ middleware.GetRequestLogger(c).WithError(err).Warn("failed to apply proxy config after access list update")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "saved, but applying the configuration failed"})
return
}
diff --git a/backend/internal/api/handlers/access_list_handler_apply_test.go b/backend/internal/api/handlers/access_list_handler_apply_test.go
new file mode 100644
index 000000000..97c0d83a6
--- /dev/null
+++ b/backend/internal/api/handlers/access_list_handler_apply_test.go
@@ -0,0 +1,159 @@
+package handlers
+
+import (
+ "bytes"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+// accessListApplyRouter wires the access list routes to a handler that applies the proxy config through mgr.
+func accessListApplyRouter(t *testing.T, mgr CaddyConfigManager) (*gin.Engine, *gorm.DB) {
+ t.Helper()
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.AccessList{}, &models.ProxyHost{}))
+
+ h := NewAccessListHandlerWithDeps(db, mgr)
+ r := gin.New()
+ r.POST("/access-lists", h.Create)
+ r.PUT("/access-lists/:id", h.Update)
+ r.DELETE("/access-lists/:id", h.Delete)
+ return r, db
+}
+
+// createAccessListForApply stores a whitelist with one rule and returns its ID.
+func createAccessListForApply(t *testing.T, db *gorm.DB) uint {
+ t.Helper()
+ acl := &models.AccessList{
+ UUID: "apply-acl",
+ Name: "Office",
+ Type: "whitelist",
+ IPRules: `[{"cidr":"10.0.0.0/8"}]`,
+ Enabled: true,
+ }
+ require.NoError(t, db.Create(acl).Error)
+ return acl.ID
+}
+
+// attachToProxyHost makes a proxy host use the access list, which is what puts the list into the proxy config.
+func attachToProxyHost(t *testing.T, db *gorm.DB, aclID uint) {
+ t.Helper()
+ host := &models.ProxyHost{
+ UUID: "apply-host",
+ Name: "app",
+ DomainNames: "app.example.com",
+ ForwardHost: "app",
+ ForwardPort: 8080,
+ Enabled: true,
+ AccessListID: &aclID,
+ }
+ require.NoError(t, db.Create(host).Error)
+}
+
+func putAccessList(r *gin.Engine, id uint, body string) *httptest.ResponseRecorder {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/access-lists/"+strconv.FormatUint(uint64(id), 10), bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ r.ServeHTTP(w, req)
+ return w
+}
+
+const updatedWhitelistBody = `{"name":"Office","type":"whitelist","ip_rules":"[{\"cidr\":\"192.168.0.0/16\"}]","enabled":true}`
+
+func TestAccessListHandler_Update_AttachedListAppliesProxyConfig(t *testing.T) {
+ mgr := &countingCaddyManager{}
+ r, db := accessListApplyRouter(t, mgr)
+ id := createAccessListForApply(t, db)
+ attachToProxyHost(t, db, id)
+
+ w := putAccessList(r, id, updatedWhitelistBody)
+
+ require.Equal(t, http.StatusOK, w.Code)
+ require.Equal(t, 1, mgr.calls, "an edit to a list attached to a host reaches the proxy")
+}
+
+func TestAccessListHandler_Update_UnattachedListSkipsApply(t *testing.T) {
+ mgr := &countingCaddyManager{}
+ r, db := accessListApplyRouter(t, mgr)
+ id := createAccessListForApply(t, db)
+
+ w := putAccessList(r, id, updatedWhitelistBody)
+
+ require.Equal(t, http.StatusOK, w.Code)
+ require.Zero(t, mgr.calls, "a list no host uses does not change the proxy config")
+}
+
+func TestAccessListHandler_Update_ApplyFailureIsReported(t *testing.T) {
+ mgr := &countingCaddyManager{applyErr: errors.New("caddy refused the config")}
+ r, db := accessListApplyRouter(t, mgr)
+ id := createAccessListForApply(t, db)
+ attachToProxyHost(t, db, id)
+
+ w := putAccessList(r, id, updatedWhitelistBody)
+
+ require.Equal(t, http.StatusInternalServerError, w.Code)
+ require.Contains(t, w.Body.String(), "saved, but applying the configuration failed")
+ require.Equal(t, 1, mgr.calls)
+
+ var stored models.AccessList
+ require.NoError(t, db.First(&stored, id).Error)
+ require.Contains(t, stored.IPRules, "192.168.0.0/16", "the edit is saved even when the apply fails")
+}
+
+func TestAccessListHandler_Update_RefusedEditSkipsApply(t *testing.T) {
+ mgr := &countingCaddyManager{}
+ r, db := accessListApplyRouter(t, mgr)
+ id := createAccessListForApply(t, db)
+ attachToProxyHost(t, db, id)
+
+ // An enabled whitelist with no rules would deny every request, so the service refuses it before any apply.
+ w := putAccessList(r, id, `{"name":"Office","type":"whitelist","ip_rules":"[]","enabled":true}`)
+
+ require.Equal(t, http.StatusBadRequest, w.Code)
+ require.Zero(t, mgr.calls)
+}
+
+func TestAccessListHandler_Delete_UnattachedListSkipsApply(t *testing.T) {
+ mgr := &countingCaddyManager{}
+ r, db := accessListApplyRouter(t, mgr)
+ id := createAccessListForApply(t, db)
+
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/access-lists/"+strconv.FormatUint(uint64(id), 10), http.NoBody))
+
+ require.Equal(t, http.StatusOK, w.Code)
+ require.Zero(t, mgr.calls, "only an unattached list can be deleted, so the proxy config does not change")
+}
+
+func TestAccessListHandler_Delete_AttachedListIsRefused(t *testing.T) {
+ mgr := &countingCaddyManager{}
+ r, db := accessListApplyRouter(t, mgr)
+ id := createAccessListForApply(t, db)
+ attachToProxyHost(t, db, id)
+
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/access-lists/"+strconv.FormatUint(uint64(id), 10), http.NoBody))
+
+ require.Equal(t, http.StatusConflict, w.Code)
+ require.Zero(t, mgr.calls)
+}
+
+func TestAccessListHandler_Update_WithoutManagerSkipsApply(t *testing.T) {
+ r, db := accessListApplyRouter(t, nil)
+ id := createAccessListForApply(t, db)
+ attachToProxyHost(t, db, id)
+
+ w := putAccessList(r, id, updatedWhitelistBody)
+
+ require.Equal(t, http.StatusOK, w.Code)
+}
diff --git a/backend/internal/api/handlers/access_list_handler_test.go b/backend/internal/api/handlers/access_list_handler_test.go
index b2f22fb99..993eecb00 100644
--- a/backend/internal/api/handlers/access_list_handler_test.go
+++ b/backend/internal/api/handlers/access_list_handler_test.go
@@ -207,6 +207,7 @@ func TestAccessListHandler_Update(t *testing.T) {
UUID: "test-uuid",
Name: "Original Name",
Type: "whitelist",
+ IPRules: `[{"cidr":"192.168.1.0/24"}]`,
Enabled: true,
}
db.Create(&acl)
@@ -237,7 +238,7 @@ func TestAccessListHandler_Update(t *testing.T) {
"description": "UUID update description",
"enabled": true,
"type": "whitelist",
- "ip_rules": `[]`,
+ "ip_rules": `[{"cidr":"10.0.0.0/8"}]`,
},
wantStatus: http.StatusOK,
},
diff --git a/backend/internal/api/handlers/access_list_rules_required_test.go b/backend/internal/api/handlers/access_list_rules_required_test.go
new file mode 100644
index 000000000..4bbeca7ac
--- /dev/null
+++ b/backend/internal/api/handlers/access_list_rules_required_test.go
@@ -0,0 +1,56 @@
+package handlers
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAccessListHandler_RulesRequiredErrorCode(t *testing.T) {
+ router, db := setupAccessListTestRouter(t)
+
+ t.Run("create refuses an enabled allow-list without rules", func(t *testing.T) {
+ body := []byte(`{"name":"wl","type":"whitelist","enabled":true,"ip_rules":""}`)
+ req := httptest.NewRequest(http.MethodPost, "/access-lists", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusBadRequest, w.Code)
+ var resp map[string]any
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
+ require.Equal(t, "access_list_rules_required", resp["error_code"])
+ require.NotEmpty(t, resp["error"])
+ })
+
+ t.Run("create keeps a block-list without rules", func(t *testing.T) {
+ body := []byte(`{"name":"bl","type":"blacklist","enabled":true,"ip_rules":""}`)
+ req := httptest.NewRequest(http.MethodPost, "/access-lists", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusCreated, w.Code)
+ })
+
+ t.Run("update refuses enabling an allow-list without rules", func(t *testing.T) {
+ acl := models.AccessList{UUID: "rules-required-update", Name: "wl", Type: "whitelist", Enabled: false, IPRules: `[{"cidr":"10.0.0.1"}]`}
+ require.NoError(t, db.Create(&acl).Error)
+
+ body := []byte(`{"name":"wl","type":"whitelist","enabled":true,"ip_rules":""}`)
+ req := httptest.NewRequest(http.MethodPut, "/access-lists/"+acl.UUID, bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusBadRequest, w.Code)
+ var resp map[string]any
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
+ require.Equal(t, "access_list_rules_required", resp["error_code"])
+ })
+}
diff --git a/backend/internal/api/handlers/access_list_usage_check_test.go b/backend/internal/api/handlers/access_list_usage_check_test.go
new file mode 100644
index 000000000..ace29c981
--- /dev/null
+++ b/backend/internal/api/handlers/access_list_usage_check_test.go
@@ -0,0 +1,25 @@
+package handlers
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+func TestAccessListHandler_Update_UsageCheckFailureReportsErrorWithoutApply(t *testing.T) {
+ mgr := &countingCaddyManager{}
+ r, db := accessListApplyRouter(t, mgr)
+ id := createAccessListForApply(t, db)
+ attachToProxyHost(t, db, id)
+ // Dropping the host table makes the usage count fail while the list update itself still succeeds.
+ require.NoError(t, db.Migrator().DropTable(&models.ProxyHost{}))
+
+ w := putAccessList(r, id, updatedWhitelistBody)
+
+ require.Equal(t, http.StatusInternalServerError, w.Code)
+ require.Contains(t, w.Body.String(), "saved, but applying the configuration failed")
+ require.Zero(t, mgr.calls, "the proxy config is not applied when usage cannot be determined")
+}
diff --git a/backend/internal/api/handlers/acl_enable_guard_test.go b/backend/internal/api/handlers/acl_enable_guard_test.go
new file mode 100644
index 000000000..c50f9ccc6
--- /dev/null
+++ b/backend/internal/api/handlers/acl_enable_guard_test.go
@@ -0,0 +1,198 @@
+package handlers
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+
+ "github.com/Wikid82/charon/backend/internal/api/middleware"
+ "github.com/Wikid82/charon/backend/internal/config"
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+const aclGuardRemoteAddr = "192.0.2.10"
+
+// aclGuardDB opens a migrated test database and seeds the default security config with the given admin allow-list.
+// A nil whitelist pointer leaves the default row absent.
+func aclGuardDB(t *testing.T, whitelist *string) *gorm.DB {
+ t.Helper()
+ db := OpenTestDB(t)
+ require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{}, &models.SecurityAudit{}))
+ if whitelist != nil {
+ require.NoError(t, db.Create(&models.SecurityConfig{Name: models.DefaultSecurityConfigName, Enabled: true, AdminWhitelist: *whitelist}).Error)
+ }
+ return db
+}
+
+// aclGuardContext builds an admin request from the given remote address with an optional JSON body.
+func aclGuardContext(t *testing.T, method, body string) (*gin.Context, *httptest.ResponseRecorder) {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ req := httptest.NewRequest(method, "/", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.RemoteAddr = aclGuardRemoteAddr + ":4242"
+ c.Request = req
+ c.Set("role", "admin")
+ c.Set("userID", uint(1))
+ return c, w
+}
+
+func aclGuardStoredValue(db *gorm.DB) string {
+ var s models.Setting
+ if err := db.Where("key = ?", models.SecurityACLEnabledSettingKey).First(&s).Error; err != nil {
+ return ""
+ }
+ return s.Value
+}
+
+func ptrString(s string) *string { return &s }
+
+func TestACLEnableGuard_HelperAllowsWhenPolicyPermits(t *testing.T) {
+ tests := []struct {
+ name string
+ whitelist *string
+ emergency bool
+ }{
+ {name: "no default row", whitelist: nil},
+ {name: "empty allow-list", whitelist: ptrString("")},
+ {name: "whitespace-only allow-list", whitelist: ptrString(" ")},
+ {name: "client address listed", whitelist: ptrString("192.0.2.0/24")},
+ {name: "emergency context with non-matching list", whitelist: ptrString("203.0.113.0/24"), emergency: true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ db := aclGuardDB(t, tt.whitelist)
+ c, w := aclGuardContext(t, http.MethodPost, "")
+ if tt.emergency {
+ c.Set(middleware.EmergencyBypassContextKey, true)
+ }
+ assert.True(t, allowACLEnable(c, db))
+ assert.Equal(t, http.StatusOK, w.Code, "helper must not write a response when allowing")
+ })
+ }
+}
+
+func TestACLEnableGuard_HelperRefusesOutsideAllowList(t *testing.T) {
+ db := aclGuardDB(t, ptrString("203.0.113.0/24"))
+ c, w := aclGuardContext(t, http.MethodPost, "")
+ assert.False(t, allowACLEnable(c, db))
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "admin IP not present in admin_whitelist")
+}
+
+func TestACLEnableGuard_HelperReportsUnreadableConfig(t *testing.T) {
+ db := OpenTestDB(t)
+ require.NoError(t, db.AutoMigrate(&models.Setting{}))
+ c, w := aclGuardContext(t, http.MethodPost, "")
+ assert.False(t, allowACLEnable(c, db))
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+}
+
+// TestACLEnableGuard_ToggleSecurityModule covers the toggle route, which calls the helper before writing.
+func TestACLEnableGuard_ToggleSecurityModule(t *testing.T) {
+ tests := []struct {
+ name string
+ whitelist string
+ enable bool
+ wantStatus int
+ wantStored string
+ }{
+ {name: "empty allow-list allows", whitelist: "", enable: true, wantStatus: http.StatusOK, wantStored: "true"},
+ {name: "client address listed allows", whitelist: "192.0.2.0/24", enable: true, wantStatus: http.StatusOK, wantStored: "true"},
+ {name: "client address not listed refuses", whitelist: "203.0.113.0/24", enable: true, wantStatus: http.StatusForbidden},
+ {name: "disabling is never refused", whitelist: "203.0.113.0/24", enable: false, wantStatus: http.StatusOK, wantStored: "false"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ db := aclGuardDB(t, &tt.whitelist)
+ h := NewSecurityHandler(config.SecurityConfig{}, db, nil)
+ t.Cleanup(h.Close)
+
+ c, w := aclGuardContext(t, http.MethodPost, "")
+ if tt.enable {
+ h.EnableACL(c)
+ } else {
+ h.DisableACL(c)
+ }
+
+ assert.Equal(t, tt.wantStatus, w.Code)
+ if tt.wantStored != "" {
+ assert.Equal(t, tt.wantStored, aclGuardStoredValue(db))
+ } else {
+ assert.NotEqual(t, "true", aclGuardStoredValue(db))
+ }
+ })
+ }
+}
+
+// TestACLEnableGuard_UpdateSetting covers the generic settings key form of the ACL switch.
+func TestACLEnableGuard_UpdateSetting(t *testing.T) {
+ tests := []struct {
+ name string
+ whitelist string
+ value string
+ wantStatus int
+ }{
+ {name: "empty allow-list allows", whitelist: "", value: "true", wantStatus: http.StatusOK},
+ {name: "client address listed allows", whitelist: "192.0.2.0/24", value: "true", wantStatus: http.StatusOK},
+ {name: "client address not listed refuses", whitelist: "203.0.113.0/24", value: "true", wantStatus: http.StatusForbidden},
+ {name: "disabling is never refused", whitelist: "203.0.113.0/24", value: "false", wantStatus: http.StatusOK},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ db := aclGuardDB(t, &tt.whitelist)
+ h := NewSettingsHandlerWithDeps(db, nil, nil, nil, "")
+
+ body := `{"key":"security.acl.enabled","value":"` + tt.value + `"}`
+ c, w := aclGuardContext(t, http.MethodPost, body)
+ h.UpdateSetting(c)
+
+ assert.Equal(t, tt.wantStatus, w.Code)
+ if tt.wantStatus == http.StatusOK {
+ assert.Equal(t, tt.value, aclGuardStoredValue(db))
+ } else {
+ assert.NotEqual(t, "true", aclGuardStoredValue(db))
+ }
+ })
+ }
+}
+
+// TestACLEnableGuard_PatchConfig covers the nested bulk config form of the ACL switch.
+func TestACLEnableGuard_PatchConfig(t *testing.T) {
+ tests := []struct {
+ name string
+ whitelist string
+ value string
+ wantStatus int
+ }{
+ {name: "empty allow-list allows", whitelist: "", value: "true", wantStatus: http.StatusOK},
+ {name: "client address listed allows", whitelist: "192.0.2.0/24", value: "true", wantStatus: http.StatusOK},
+ {name: "client address not listed refuses", whitelist: "203.0.113.0/24", value: "true", wantStatus: http.StatusForbidden},
+ {name: "disabling is never refused", whitelist: "203.0.113.0/24", value: "false", wantStatus: http.StatusOK},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ db := aclGuardDB(t, &tt.whitelist)
+ h := NewSettingsHandlerWithDeps(db, nil, nil, nil, "")
+
+ body := `{"security":{"acl":{"enabled":` + tt.value + `}}}`
+ c, w := aclGuardContext(t, http.MethodPatch, body)
+ h.PatchConfig(c)
+
+ assert.Equal(t, tt.wantStatus, w.Code)
+ if tt.wantStatus == http.StatusOK {
+ assert.Equal(t, tt.value, aclGuardStoredValue(db))
+ } else {
+ assert.NotEqual(t, "true", aclGuardStoredValue(db))
+ }
+ })
+ }
+}
diff --git a/backend/internal/api/handlers/security_geoip_endpoints_test.go b/backend/internal/api/handlers/security_geoip_endpoints_test.go
index b29f5d1c2..7fc06596e 100644
--- a/backend/internal/api/handlers/security_geoip_endpoints_test.go
+++ b/backend/internal/api/handlers/security_geoip_endpoints_test.go
@@ -49,7 +49,8 @@ func TestSecurityHandler_GetGeoIPStatus_Initialized_NotLoaded(t *testing.T) {
var body map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.Equal(t, false, body["loaded"])
- assert.Equal(t, "GeoIP service available", body["message"])
+ assert.Equal(t, false, body["available"])
+ assert.Equal(t, "GeoIP database unavailable", body["message"])
}
func TestSecurityHandler_ReloadGeoIP_NotInitialized(t *testing.T) {
diff --git a/backend/internal/api/handlers/security_geoip_reason_test.go b/backend/internal/api/handlers/security_geoip_reason_test.go
new file mode 100644
index 000000000..e9fb5c5e4
--- /dev/null
+++ b/backend/internal/api/handlers/security_geoip_reason_test.go
@@ -0,0 +1,129 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+
+ "github.com/Wikid82/charon/backend/internal/config"
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/Wikid82/charon/backend/internal/services"
+)
+
+// geoipReasonStatusBody requests /security/status and returns the decoded body.
+func geoipReasonStatusBody(t *testing.T, db *gorm.DB, cfg config.SecurityConfig) map[string]any {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ h := NewSecurityHandler(cfg, db, nil)
+ t.Cleanup(h.Close)
+
+ router := gin.New()
+ router.GET("/api/v1/security/status", h.GetStatus)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/security/status", http.NoBody))
+ require.Equal(t, http.StatusOK, w.Code)
+
+ var body map[string]any
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+ return body
+}
+
+// openGeoIPReasonTestDB opens a file-backed SQLite database with the tables the status reads.
+func openGeoIPReasonTestDB(t *testing.T) *gorm.DB {
+ t.Helper()
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "geoip-reason.db")), &gorm.Config{})
+ require.NoError(t, err)
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = sqlDB.Close() })
+ require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{}, &models.AccessList{}))
+ return db
+}
+
+func aclReasonFromStatus(t *testing.T, body map[string]any) (string, bool) {
+ t.Helper()
+ acl, ok := body["acl"].(map[string]any)
+ require.True(t, ok, "status has an acl object")
+ reason, has := acl["reason"]
+ if !has {
+ return "", false
+ }
+ str, ok := reason.(string)
+ require.True(t, ok, "reason is a string")
+ return str, true
+}
+
+func TestSecurityStatus_GeoIPStatusReportsUnavailableWithoutService(t *testing.T) {
+ h := NewSecurityHandler(config.SecurityConfig{}, nil, nil)
+ router := gin.New()
+ router.GET("/security/geoip/status", h.GetGeoIPStatus)
+
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/security/geoip/status", http.NoBody))
+ require.Equal(t, http.StatusOK, w.Code)
+
+ var body map[string]any
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+ assert.Equal(t, false, body["available"])
+ assert.Equal(t, "geoip_unavailable", body["reason"])
+}
+
+func TestSecurityStatus_GeoIPStatusReportsUnavailableWhenServiceNotLoaded(t *testing.T) {
+ h := NewSecurityHandler(config.SecurityConfig{}, nil, nil)
+ h.SetGeoIPService(&services.GeoIPService{})
+ router := gin.New()
+ router.GET("/security/geoip/status", h.GetGeoIPStatus)
+
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/security/geoip/status", http.NoBody))
+
+ var body map[string]any
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+ assert.Equal(t, false, body["available"])
+ assert.Equal(t, "geoip_unavailable", body["reason"])
+}
+
+func TestSecurityStatus_ACLReasonWhenCountryListWithoutDatabase(t *testing.T) {
+ db := openGeoIPReasonTestDB(t)
+ require.NoError(t, db.Create(&models.AccessList{UUID: "geo-1", Name: "geo", Type: "geo_whitelist", CountryCodes: "US", Enabled: true}).Error)
+
+ body := geoipReasonStatusBody(t, db, config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled"})
+ reason, has := aclReasonFromStatus(t, body)
+ assert.True(t, has)
+ assert.Equal(t, "geoip_unavailable", reason)
+}
+
+func TestSecurityStatus_ACLReasonAbsentWithoutCountryList(t *testing.T) {
+ db := openGeoIPReasonTestDB(t)
+ require.NoError(t, db.Create(&models.AccessList{UUID: "ip-1", Name: "ips", Type: "whitelist", IPRules: "[]", Enabled: true}).Error)
+
+ body := geoipReasonStatusBody(t, db, config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled"})
+ _, has := aclReasonFromStatus(t, body)
+ assert.False(t, has)
+}
+
+func TestSecurityStatus_ACLReasonAbsentWhenCountryListDisabled(t *testing.T) {
+ db := openGeoIPReasonTestDB(t)
+ require.NoError(t, db.Create(&models.AccessList{UUID: "geo-2", Name: "geo", Type: "geo_blacklist", CountryCodes: "CN", Enabled: false}).Error)
+
+ body := geoipReasonStatusBody(t, db, config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled"})
+ _, has := aclReasonFromStatus(t, body)
+ assert.False(t, has)
+}
+
+func TestSecurityStatus_ACLReasonAbsentWhenACLDisabled(t *testing.T) {
+ db := openGeoIPReasonTestDB(t)
+ require.NoError(t, db.Create(&models.AccessList{UUID: "geo-3", Name: "geo", Type: "geo_whitelist", CountryCodes: "US", Enabled: true}).Error)
+
+ body := geoipReasonStatusBody(t, db, config.SecurityConfig{CerberusEnabled: true, ACLMode: "disabled"})
+ _, has := aclReasonFromStatus(t, body)
+ assert.False(t, has)
+}
diff --git a/backend/internal/api/handlers/security_geoip_reload_apply_test.go b/backend/internal/api/handlers/security_geoip_reload_apply_test.go
new file mode 100644
index 000000000..ceee3b835
--- /dev/null
+++ b/backend/internal/api/handlers/security_geoip_reload_apply_test.go
@@ -0,0 +1,159 @@
+package handlers
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/config"
+ "github.com/Wikid82/charon/backend/internal/services"
+)
+
+// minimalCountryMMDB returns the smallest MaxMind DB the reader accepts: an empty search tree, the data separator and
+// one metadata map that names a country database. It holds no addresses, so every lookup finds nothing.
+func minimalCountryMMDB() []byte {
+ // MaxMind strings with a one-byte size header hold at most 28 bytes; the guard keeps the byte conversion safe.
+ const maxOneByteSize = 28
+ str := func(s string) []byte {
+ n := len(s)
+ if n < 0 || n > maxOneByteSize {
+ panic("minimalCountryMMDB: string exceeds one-byte size header")
+ }
+ return append([]byte{0x40 | byte(n)}, s...)
+ }
+ uint16Value := func(v uint16) []byte {
+ b := make([]byte, 2)
+ binary.BigEndian.PutUint16(b, v)
+ return append([]byte{0xA0 | 2}, b...)
+ }
+ var meta []byte
+ meta = append(meta, 0xE0|6) // map with six entries
+ meta = append(meta, str("node_count")...)
+ meta = append(meta, 0xC0) // uint32 zero
+ meta = append(meta, str("record_size")...)
+ meta = append(meta, uint16Value(24)...)
+ meta = append(meta, str("ip_version")...)
+ meta = append(meta, uint16Value(4)...)
+ meta = append(meta, str("database_type")...)
+ meta = append(meta, str("GeoLite2-Country")...)
+ meta = append(meta, str("binary_format_major_version")...)
+ meta = append(meta, uint16Value(2)...)
+ meta = append(meta, str("binary_format_minor_version")...)
+ meta = append(meta, uint16Value(0)...)
+
+ out := make([]byte, 16) // data section separator, no search tree
+ out = append(out, []byte("\xAB\xCD\xEFMaxMind.com")...)
+ return append(out, meta...)
+}
+
+// countingCaddyManager records applies and returns applyErr from each one.
+type countingCaddyManager struct {
+ calls int
+ applyErr error
+}
+
+func (m *countingCaddyManager) ApplyConfig(_ context.Context) error {
+ m.calls++
+ return m.applyErr
+}
+
+func reloadGeoIPRouter(h *SecurityHandler) *gin.Engine {
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "admin")
+ c.Next()
+ })
+ r.POST("/security/geoip/reload", h.ReloadGeoIP)
+ return r
+}
+
+func TestSecurityHandler_ReloadGeoIP_AppliesProxyConfigAfterReload(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ require.NoError(t, os.WriteFile(path, minimalCountryMMDB(), 0o600))
+ svc, err := services.NewGeoIPService(path)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = svc.Close() })
+
+ mgr := &countingCaddyManager{}
+ h := NewSecurityHandler(config.SecurityConfig{}, openGeoIPReasonTestDB(t), mgr)
+ h.SetGeoIPService(svc)
+
+ w := httptest.NewRecorder()
+ reloadGeoIPRouter(h).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/security/geoip/reload", bytes.NewReader(nil)))
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, 1, mgr.calls, "a successful reload triggers one proxy apply")
+}
+
+func TestSecurityHandler_ReloadGeoIP_ApplyFailureIsReported(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ require.NoError(t, os.WriteFile(path, minimalCountryMMDB(), 0o600))
+ svc, err := services.NewGeoIPService(path)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = svc.Close() })
+
+ mgr := &countingCaddyManager{applyErr: errors.New("caddy refused the config")}
+ h := NewSecurityHandler(config.SecurityConfig{}, openGeoIPReasonTestDB(t), mgr)
+ h.SetGeoIPService(svc)
+
+ w := httptest.NewRecorder()
+ reloadGeoIPRouter(h).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/security/geoip/reload", bytes.NewReader(nil)))
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Equal(t, 1, mgr.calls)
+}
+
+func TestSecurityHandler_ReloadGeoIP_LoadFailureSkipsApply(t *testing.T) {
+ mgr := &countingCaddyManager{}
+ h := NewSecurityHandler(config.SecurityConfig{}, openGeoIPReasonTestDB(t), mgr)
+ h.SetGeoIPService(&services.GeoIPService{}) // no database path, so the load fails
+
+ w := httptest.NewRecorder()
+ reloadGeoIPRouter(h).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/security/geoip/reload", bytes.NewReader(nil)))
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Equal(t, 0, mgr.calls, "a failed reload does not apply")
+}
+
+func TestSecurityHandler_ReloadGeoIP_InitialisesServiceAddedAfterStartup(t *testing.T) {
+ // Startup without a database: the service exists but is not loaded.
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ svc := services.NewUnloadedGeoIPService(path)
+ mgr := &countingCaddyManager{}
+ h := NewSecurityHandler(config.SecurityConfig{}, openGeoIPReasonTestDB(t), mgr)
+ h.SetGeoIPService(svc)
+
+ w := httptest.NewRecorder()
+ reloadGeoIPRouter(h).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/security/geoip/reload", bytes.NewReader(nil)))
+ require.Equal(t, http.StatusInternalServerError, w.Code, "reload fails while the file is missing")
+ require.Equal(t, 0, mgr.calls)
+
+ // The operator adds the database; the reload loads it and applies the proxy config.
+ require.NoError(t, os.WriteFile(path, minimalCountryMMDB(), 0o600))
+ t.Cleanup(func() { _ = svc.Close() })
+
+ w = httptest.NewRecorder()
+ reloadGeoIPRouter(h).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/security/geoip/reload", bytes.NewReader(nil)))
+ require.Equal(t, http.StatusOK, w.Code)
+ require.Equal(t, 1, mgr.calls)
+ require.True(t, svc.IsLoaded())
+
+ statusW := httptest.NewRecorder()
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.GET("/security/geoip/status", h.GetGeoIPStatus)
+ r.ServeHTTP(statusW, httptest.NewRequest(http.MethodGet, "/security/geoip/status", http.NoBody))
+ require.Equal(t, http.StatusOK, statusW.Code)
+ require.Contains(t, statusW.Body.String(), `"available":true`)
+ require.Contains(t, statusW.Body.String(), `"message":"GeoIP service available"`)
+}
diff --git a/backend/internal/api/handlers/security_handler.go b/backend/internal/api/handlers/security_handler.go
index d484e19f8..91f352954 100644
--- a/backend/internal/api/handlers/security_handler.go
+++ b/backend/internal/api/handlers/security_handler.go
@@ -15,6 +15,7 @@ import (
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/api/middleware"
+ "github.com/Wikid82/charon/backend/internal/caddy"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/crowdsec"
"github.com/Wikid82/charon/backend/internal/logger"
@@ -207,6 +208,11 @@ func (h *SecurityHandler) GetStatus(c *gin.Context) {
"enabled": rateLimitEnabled,
}
h.addAppliedState(wafStatus, rateLimitStatus, wafEnabled, rateLimitEnabled)
+ aclStatus := gin.H{
+ "mode": aclMode,
+ "enabled": aclEnabled,
+ }
+ h.addACLGeoIPReason(aclStatus, aclEnabled)
c.JSON(http.StatusOK, gin.H{
"cerberus": gin.H{"enabled": enabled},
@@ -215,12 +221,9 @@ func (h *SecurityHandler) GetStatus(c *gin.Context) {
"api_url": crowdsec.LocalLAPIURL,
"enabled": crowdsecEnabled,
},
- "waf": wafStatus,
- "rate_limit": rateLimitStatus,
- "acl": gin.H{
- "mode": aclMode,
- "enabled": aclEnabled,
- },
+ "waf": wafStatus,
+ "rate_limit": rateLimitStatus,
+ "acl": aclStatus,
"config_apply": latestConfigApplyState(h.db),
})
}
@@ -698,21 +701,26 @@ func (h *SecurityHandler) GetRateLimitPresets(c *gin.Context) {
}
// GetGeoIPStatus returns the current status of the GeoIP service.
+// The reason is present only when the database is unavailable, so the card can show why country lists cannot match.
func (h *SecurityHandler) GetGeoIPStatus(c *gin.Context) {
- if h.geoipSvc == nil {
- c.JSON(http.StatusOK, gin.H{
- "loaded": false,
- "message": "GeoIP service not initialized",
- "db_path": "",
- })
- return
+ available := geoipDatabaseAvailable(h.geoipSvc)
+ body := gin.H{
+ "available": available,
+ "loaded": false,
+ "db_path": "",
+ "message": "GeoIP service not initialized",
+ }
+ if h.geoipSvc != nil {
+ body["loaded"] = h.geoipSvc.IsLoaded()
+ body["db_path"] = h.geoipSvc.GetDatabasePath()
+ body["message"] = "GeoIP database unavailable"
+ }
+ if available {
+ body["message"] = "GeoIP service available"
+ } else {
+ body["reason"] = caddy.ReasonGeoIPUnavailable
}
-
- c.JSON(http.StatusOK, gin.H{
- "loaded": h.geoipSvc.IsLoaded(),
- "db_path": h.geoipSvc.GetDatabasePath(),
- "message": "GeoIP service available",
- })
+ c.JSON(http.StatusOK, body)
}
// ReloadGeoIP reloads the GeoIP database from disk.
@@ -736,6 +744,16 @@ func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) {
return
}
+ // The proxy reads the database only when its config is generated, so a reloaded database reaches traffic on the
+ // next apply. Apply now, so country lists follow the reload at once.
+ if h.caddyManager != nil {
+ if err := applyProxyConfigDetached(c.Request.Context(), h.caddyManager, proxyApplyTimeout); err != nil {
+ log.WithError(err).Warn("failed to apply proxy config after GeoIP reload")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": msgModuleApplyFailed})
+ return
+ }
+ }
+
// Log audit event
actor := auditActor(c)
_ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "reload_geoip", Details: "GeoIP database reloaded successfully"})
@@ -1203,7 +1221,7 @@ func (h *SecurityHandler) toggleSecurityModule(c *gin.Context, settingKey string
return
}
- if settingKey == models.SecurityACLEnabledSettingKey && enabled && !h.allowACLEnable(c) {
+ if settingKey == models.SecurityACLEnabledSettingKey && enabled && !allowACLEnable(c, h.db) {
return
}
@@ -1336,14 +1354,17 @@ func upsertSetting(db *gorm.DB, setting models.Setting) error {
return nil
}
-func (h *SecurityHandler) allowACLEnable(c *gin.Context) bool {
+// allowACLEnable is a lock-out guard, not authorisation: it stops an admin from enabling the ACL from an address the
+// admin allow-list would already cut off. It does not decide who may change security settings (that is requireAdmin).
+// An empty allow-list allows.
+func allowACLEnable(c *gin.Context, db *gorm.DB) bool {
if middleware.IsEmergencyBypass(c) {
return true
}
- cfg, err := h.svc.Get()
+ cfg, err := models.FindDefaultSecurityConfig(db)
if err != nil {
- if errors.Is(err, services.ErrSecurityConfigNotFound) {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
return true
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read security config"})
diff --git a/backend/internal/api/handlers/security_status_enforcement.go b/backend/internal/api/handlers/security_status_enforcement.go
index 3344304db..a3f28a1dd 100644
--- a/backend/internal/api/handlers/security_status_enforcement.go
+++ b/backend/internal/api/handlers/security_status_enforcement.go
@@ -4,6 +4,10 @@ import (
"github.com/gin-gonic/gin"
"github.com/Wikid82/charon/backend/internal/caddy"
+ "github.com/Wikid82/charon/backend/internal/geoip"
+ "github.com/Wikid82/charon/backend/internal/logger"
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/Wikid82/charon/backend/internal/services"
)
// enforcementReporter is implemented by the caddy manager. It returns the enforcement report of the last successful
@@ -43,3 +47,38 @@ func addModuleAppliedState(status gin.H, module caddy.ModuleEnforcement) {
status["reason"] = module.Reason
}
}
+
+// countryListTypes are the access list types that match on the GeoIP country.
+var countryListTypes = []string{"geo_whitelist", "geo_blacklist"}
+
+// geoipDatabaseAvailable reports whether the GeoIP service has a usable database loaded.
+func geoipDatabaseAvailable(svc *services.GeoIPService) bool {
+ if svc == nil {
+ return false
+ }
+ return geoip.DatabaseAvailable(svc.GetDatabasePath(), svc.IsLoaded())
+}
+
+// addACLGeoIPReason adds the geoip_unavailable reason to the ACL status when the ACL is on, an enabled country list
+// is in use, and no GeoIP database is available. Such a list cannot match, so the status says why.
+func (h *SecurityHandler) addACLGeoIPReason(aclStatus gin.H, aclEnabled bool) {
+ if !aclEnabled || geoipDatabaseAvailable(h.geoipSvc) || !h.hasEnabledCountryList() {
+ return
+ }
+ aclStatus["reason"] = caddy.ReasonGeoIPUnavailable
+}
+
+// hasEnabledCountryList reports whether at least one enabled access list matches on country. A failed read counts as
+// none, so the status never reports a reason it cannot confirm.
+func (h *SecurityHandler) hasEnabledCountryList() bool {
+ if h.db == nil {
+ return false
+ }
+ var count int64
+ err := h.db.Model(&models.AccessList{}).Where("enabled = ? AND type IN ?", true, countryListTypes).Count(&count).Error
+ if err != nil {
+ logger.Log().WithError(err).Warn("Failed to count country access lists for status")
+ return false
+ }
+ return count > 0
+}
diff --git a/backend/internal/api/handlers/settings_handler.go b/backend/internal/api/handlers/settings_handler.go
index 65a06d89a..f404db25e 100644
--- a/backend/internal/api/handlers/settings_handler.go
+++ b/backend/internal/api/handlers/settings_handler.go
@@ -320,6 +320,12 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
// The module switches are written through the shared module writer, with the same proxy apply and restore as the
// toggle routes.
+ if req.Key == models.SecurityACLEnabledSettingKey {
+ if on, recognised := models.ParseModuleSettingValue(req.Value); recognised && on && !allowACLEnable(c, h.DB) {
+ return
+ }
+ }
+
if models.IsSecurityModuleKey(req.Key) {
setting, ok := h.writeModuleSwitchSetting(c, req.Key, req.Value)
if ok {
@@ -516,6 +522,11 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
moduleSwitches[key] = on
}
+ // Turning the ACL on from an address the admin allow-list excludes is refused before any write.
+ if on, found := moduleSwitches[models.SecurityACLEnabledSettingKey]; found && on && !allowACLEnable(c, h.DB) {
+ return
+ }
+
// Validate the whole batch before opening the transaction so an invalid
// uptime.* value can never produce a partial write.
if err := validateUptimeUpdates(updates); err != nil {
diff --git a/backend/internal/api/routes/geoip_marker.go b/backend/internal/api/routes/geoip_marker.go
new file mode 100644
index 000000000..657eb9d63
--- /dev/null
+++ b/backend/internal/api/routes/geoip_marker.go
@@ -0,0 +1,36 @@
+package routes
+
+import (
+ "os"
+
+ "github.com/Wikid82/charon/backend/internal/geoip"
+ "github.com/Wikid82/charon/backend/internal/logger"
+ "github.com/Wikid82/charon/backend/internal/services"
+)
+
+// reportGeoIPBuildMarker logs one Info line when the image build could not bundle the GeoIP database. It reports
+// whether the marker was present. loaded is whether the GeoIP service loaded a database at startup.
+func reportGeoIPBuildMarker(dbPath string, loaded bool) bool {
+ if _, err := os.Stat(geoip.MissingMarkerPath(dbPath)); err != nil { //nolint:gosec // G703: marker path derived from CHARON_GEOIP_DB_PATH, read-only stat
+ return false
+ }
+ logger.Log().WithField("path", dbPath).WithField("database_loaded", loaded).Info(geoIPBuildMarkerMessage(loaded))
+ return true
+}
+
+// geoIPBuildMarkerMessage describes the GeoIP state for the build-marker Info line. Country lists are refused only
+// while no database is loaded.
+func geoIPBuildMarkerMessage(loaded bool) string {
+ if loaded {
+ return "Image was built without the GeoIP database; the database loaded from CHARON_GEOIP_DB_PATH is in use"
+ }
+ return "Image was built without the GeoIP database; country access lists stay refused until one is provided at CHARON_GEOIP_DB_PATH"
+}
+
+// geoIPProxyState returns the source of the GeoIP database state the proxy config reads at each apply. The database is
+// usable when the GeoIP service has loaded it and the file is a database the proxy module can open.
+func geoIPProxyState(path string, svc *services.GeoIPService) func() (string, bool) {
+ return func() (string, bool) {
+ return path, geoip.DatabaseAvailable(path, svc != nil && svc.IsLoaded())
+ }
+}
diff --git a/backend/internal/api/routes/geoip_marker_message_test.go b/backend/internal/api/routes/geoip_marker_message_test.go
new file mode 100644
index 000000000..f309717f3
--- /dev/null
+++ b/backend/internal/api/routes/geoip_marker_message_test.go
@@ -0,0 +1,16 @@
+package routes
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGeoIPBuildMarkerMessage_FollowsDatabaseState(t *testing.T) {
+ assert.Contains(t, geoIPBuildMarkerMessage(false), "country access lists stay refused")
+ assert.Contains(t, geoIPBuildMarkerMessage(false), "CHARON_GEOIP_DB_PATH")
+
+ loaded := geoIPBuildMarkerMessage(true)
+ assert.NotContains(t, loaded, "stay refused", "a loaded database means country lists are not refused")
+ assert.Contains(t, loaded, "is in use")
+}
diff --git a/backend/internal/api/routes/geoip_marker_test.go b/backend/internal/api/routes/geoip_marker_test.go
new file mode 100644
index 000000000..27ff5e9ab
--- /dev/null
+++ b/backend/internal/api/routes/geoip_marker_test.go
@@ -0,0 +1,20 @@
+package routes
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestReportGeoIPBuildMarker_ReportsOnlyWhenMarkerPresent(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+
+ assert.False(t, reportGeoIPBuildMarker(dbPath, false), "no marker, nothing to report")
+
+ require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(dbPath), ".missing"), nil, 0o600))
+ assert.True(t, reportGeoIPBuildMarker(dbPath, false))
+ assert.True(t, reportGeoIPBuildMarker(dbPath, true), "marker is reported even when a database is provided later")
+}
diff --git a/backend/internal/api/routes/geoip_proxy_state_test.go b/backend/internal/api/routes/geoip_proxy_state_test.go
new file mode 100644
index 000000000..fce46da2e
--- /dev/null
+++ b/backend/internal/api/routes/geoip_proxy_state_test.go
@@ -0,0 +1,28 @@
+package routes
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/services"
+)
+
+func TestGeoIPProxyState_UnavailableWithoutService(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ state := geoIPProxyState(path, nil)
+
+ gotPath, available := state()
+ require.Equal(t, path, gotPath)
+ require.False(t, available, "no service means no database for the proxy")
+}
+
+func TestGeoIPProxyState_UnavailableWhenServiceNotLoaded(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ state := geoIPProxyState(path, &services.GeoIPService{})
+
+ gotPath, available := state()
+ require.Equal(t, path, gotPath)
+ require.False(t, available)
+}
diff --git a/backend/internal/api/routes/geoip_startup.go b/backend/internal/api/routes/geoip_startup.go
new file mode 100644
index 000000000..3bffbcf2b
--- /dev/null
+++ b/backend/internal/api/routes/geoip_startup.go
@@ -0,0 +1,22 @@
+package routes
+
+import (
+ "os"
+
+ "github.com/Wikid82/charon/backend/internal/logger"
+ "github.com/Wikid82/charon/backend/internal/services"
+)
+
+// loadGeoIPAtStartup loads the GeoIP database at geoipPath into svc when the file exists. A missing or unreadable file
+// is logged and leaves svc unloaded, so country lists are refused until a database is provided and reloaded.
+func loadGeoIPAtStartup(svc *services.GeoIPService, geoipPath string) {
+ if _, err := os.Stat(geoipPath); err != nil { //nolint:gosec // G703: operator-configured path from CHARON_GEOIP_DB_PATH, read-only stat
+ logger.Log().WithField("path", geoipPath).Info("GeoIP database not found - geo-blocking features will be unavailable")
+ return
+ }
+ if err := svc.Load(); err != nil {
+ logger.Log().WithError(err).WithField("path", geoipPath).Warn("Failed to load GeoIP database - geo-blocking features will be unavailable")
+ return
+ }
+ logger.Log().WithField("path", geoipPath).Info("GeoIP database loaded successfully")
+}
diff --git a/backend/internal/api/routes/geoip_startup_test.go b/backend/internal/api/routes/geoip_startup_test.go
new file mode 100644
index 000000000..4c4f22c77
--- /dev/null
+++ b/backend/internal/api/routes/geoip_startup_test.go
@@ -0,0 +1,30 @@
+package routes
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/services"
+)
+
+func TestLoadGeoIPAtStartup_MissingFileLeavesServiceUnloaded(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ svc := services.NewUnloadedGeoIPService(path)
+
+ loadGeoIPAtStartup(svc, path)
+
+ require.False(t, svc.IsLoaded())
+}
+
+func TestLoadGeoIPAtStartup_UnreadableFileLeavesServiceUnloaded(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ require.NoError(t, os.WriteFile(path, []byte("not a database"), 0o600))
+ svc := services.NewUnloadedGeoIPService(path)
+
+ loadGeoIPAtStartup(svc, path)
+
+ require.False(t, svc.IsLoaded())
+}
diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go
index 555ab46a7..d00629a39 100644
--- a/backend/internal/api/routes/routes.go
+++ b/backend/internal/api/routes/routes.go
@@ -944,24 +944,16 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg
geoipPath = "/app/data/geoip/GeoLite2-Country.mmdb"
}
- var geoipSvc *services.GeoIPService
- if _, err := os.Stat(geoipPath); err == nil { //nolint:gosec // G703: operator-configured path from CHARON_GEOIP_DB_PATH, read-only stat
- var geoErr error
- geoipSvc, geoErr = services.NewGeoIPService(geoipPath)
- if geoErr != nil {
- logger.Log().WithError(geoErr).WithField("path", geoipPath).Warn("Failed to load GeoIP database - geo-blocking features will be unavailable")
- } else {
- logger.Log().WithField("path", geoipPath).Info("GeoIP database loaded successfully")
- }
- } else {
- logger.Log().WithField("path", geoipPath).Info("GeoIP database not found - geo-blocking features will be unavailable")
- }
+ // The service always exists, even without a database file, so a database added later is loaded by
+ // POST /security/geoip/reload and every consumer (including the proxy state) sees the same live service.
+ geoipSvc := services.NewUnloadedGeoIPService(geoipPath)
+ loadGeoIPAtStartup(geoipSvc, geoipPath)
+ reportGeoIPBuildMarker(geoipPath, geoipSvc.IsLoaded())
+ caddyManager.SetGeoIPState(geoIPProxyState(geoipPath, geoipSvc))
// Security Status
securityHandler := handlers.NewSecurityHandlerWithDeps(cfg.Security, db, caddyManager, cerb).WithCrowdsecLifecycle(crowdsecHandler)
- if geoipSvc != nil {
- securityHandler.SetGeoIPService(geoipSvc)
- }
+ securityHandler.SetGeoIPService(geoipSvc)
management.GET("/security/status", securityHandler.GetStatus)
// Security Config management
@@ -1084,10 +1076,8 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg
management.GET("/stats/ws", statsHandler.StatsWS)
// Access Lists
- accessListHandler := handlers.NewAccessListHandler(db)
- if geoipSvc != nil {
- accessListHandler.SetGeoIPService(geoipSvc)
- }
+ accessListHandler := handlers.NewAccessListHandlerWithDeps(db, caddyManager)
+ accessListHandler.SetGeoIPService(geoipSvc)
// ACLs are a security control: reads + the non-persisting /:id/test
// dry-run stay on management; create/update/delete are admin-only.
management.GET("/access-lists/templates", accessListHandler.GetTemplates)
diff --git a/backend/internal/caddy/client.go b/backend/internal/caddy/client.go
index e67de27e2..7356e08a1 100644
--- a/backend/internal/caddy/client.go
+++ b/backend/internal/caddy/client.go
@@ -82,6 +82,9 @@ func (c *Client) Load(ctx context.Context, config *Config) error {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
+ // Caddy skips reloading an identical config. must-revalidate makes every load reach the running config, so
+ // data read at startup (such as the GeoIP database) is re-read even when the generated JSON did not change.
+ req.Header.Set("Cache-Control", "must-revalidate")
resp, err := c.httpClient.Do(req)
if err != nil {
diff --git a/backend/internal/caddy/client_load_header_test.go b/backend/internal/caddy/client_load_header_test.go
new file mode 100644
index 000000000..2a2fbcee6
--- /dev/null
+++ b/backend/internal/caddy/client_load_header_test.go
@@ -0,0 +1,30 @@
+package caddy
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+func TestClient_Load_SendsMustRevalidate(t *testing.T) {
+ // Caddy skips /load for an identical config, which would keep data loaded at startup (such as a GeoIP database
+ // replaced on disk). The must-revalidate header makes every load reach the running config.
+ var got string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ got = r.Header.Get("Cache-Control")
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ client := NewClientWithExpectedPort(server.URL, expectedPortFromURL(t, server.URL))
+ config, err := GenerateConfig([]models.ProxyHost{}, t.TempDir(), "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
+ require.NoError(t, err)
+
+ require.NoError(t, client.Load(context.Background(), config))
+ require.Equal(t, "must-revalidate", got)
+}
diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go
index a9d7e6ec8..9143a2d19 100644
--- a/backend/internal/caddy/config.go
+++ b/backend/internal/caddy/config.go
@@ -2,6 +2,7 @@ package caddy
import (
"encoding/json"
+ "errors"
"fmt"
"net"
"os"
@@ -15,6 +16,7 @@ import (
"github.com/Wikid82/charon/backend/internal/crowdsec/bouncerkey"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/models"
+ "github.com/Wikid82/charon/backend/internal/network"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
@@ -53,6 +55,11 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
defer func() { resolvedOpts.enforcement(report) }()
}
+ // The module opens only
/.mmdb, so a database path that does not name such a file cannot be used by
+ // the proxy, whatever the caller said. Country lists are refused while it is unavailable.
+ geoIPAvailable := resolvedOpts.geoIPAvailable && strings.HasSuffix(filepath.Base(resolvedOpts.geoIPPath), ".mmdb")
+ var geoRefusedLists []string
+
// Define log file paths for Caddy access logs.
// When CrowdSec is enabled, we use /var/log/caddy/access.log which is the standard
// location that CrowdSec's acquis.yaml is configured to monitor.
@@ -441,6 +448,9 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
// see docs/plans/archive/2026-09-23_redirection-hosts-1367_spec.md §4.2/§4.3).
processedDomains := make(map[string]bool)
wafUnprotectedHosts := 0
+ // Allow-lists that cannot admit anyone get a deny handler; counted so one Warn covers the whole apply.
+ failClosedACLHosts := 0
+ var failClosedACLErr error
redirectRoutes, redirectIPSubjects := BuildRedirectRoutes(redirectHosts, processedDomains)
routes = append(routes, redirectRoutes...)
ipSubjects = append(ipSubjects, redirectIPSubjects...)
@@ -493,6 +503,10 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
// Build handlers for this host
handlers := make([]Handler, 0)
+ // Security header handlers run first on every route, so refusals from the
+ // security pre-handlers still carry the profile headers.
+ headerHandlers := make([]Handler, 0)
+
// Build security pre-handlers for this host, in pipeline order.
securityHandlers := make([]Handler, 0)
@@ -577,24 +591,43 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
// Add Access Control List (ACL) handler if configured and global ACL is enabled
if aclEnabled && host.AccessListID != nil && host.AccessList != nil && host.AccessList.Enabled {
- aclHandler, err := buildACLHandler(host.AccessList, adminWhitelist)
- if err != nil {
- logger.Log().WithField("host", host.UUID).WithError(err).Warn("Failed to build ACL handler for host")
- } else if aclHandler != nil {
- securityHandlers = append(securityHandlers, aclHandler)
+ if isCountryList(host.AccessList) && !geoIPAvailable {
+ // A country list cannot match without the database, so the host refuses every request instead.
+ securityHandlers = append(securityHandlers, geoUnavailableDenyHandler())
+ if !slices.Contains(geoRefusedLists, host.AccessList.Name) {
+ geoRefusedLists = append(geoRefusedLists, host.AccessList.Name)
+ }
+ } else {
+ if isCountryList(host.AccessList) {
+ // The geoip2 handler sits directly before the country subroute and reads the database the app opens.
+ config.Apps.GeoIP2 = geoIP2AppFor(resolvedOpts.geoIPPath, storageDir)
+ securityHandlers = append(securityHandlers, geoIP2Handler())
+ }
+ aclHandler, err := buildACLHandler(host.AccessList, adminWhitelist)
+ if err != nil && errors.Is(err, errAllowListFailClosed) {
+ failClosedACLHosts++
+ if failClosedACLErr == nil {
+ failClosedACLErr = err
+ }
+ } else if err != nil {
+ logger.Log().WithField("host", host.UUID).WithError(err).Warn("Failed to build ACL handler for host")
+ }
+ if aclHandler != nil {
+ securityHandlers = append(securityHandlers, aclHandler)
+ }
}
}
// Add Security Headers handler
secHeaderHandlers := buildSecurityHeaderHandlers(&host)
- handlers = append(handlers, secHeaderHandlers...)
+ headerHandlers = append(headerHandlers, secHeaderHandlers...)
// Add HSTS header if enabled (legacy - deprecated in favor of SecurityHeaderProfile).
// A profile that already sets HSTS wins outright: the non-deferred and
// deferred passes apply handlers in opposite orders, so emitting both
// would make the winner depend on the response path.
if host.HSTSEnabled && !setsResponseHeader(secHeaderHandlers, hstsHeader) {
- handlers = append(handlers, HeaderHandlers(map[string][]string{
+ headerHandlers = append(headerHandlers, HeaderHandlers(map[string][]string{
hstsHeader: {legacyHSTSValue(host.HSTSSubdomains)},
})...)
}
@@ -604,11 +637,15 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
handlers = append(handlers, BlockExploitsHandler())
}
+ // Pipeline order for every route of this host: header pair, then security
+ // pre-handlers, then the remaining host handlers and the reverse proxy.
+ prefixHandlers := append(append([]Handler{}, headerHandlers...), securityHandlers...)
+
// Handle custom locations first (more specific routes)
for _, loc := range host.Locations {
dial := fmt.Sprintf("%s:%d", loc.ForwardHost, loc.ForwardPort)
// For each location, we want the same security pre-handlers before proxy
- locHandlers := append(append([]Handler{}, securityHandlers...), handlers...)
+ locHandlers := append(append([]Handler{}, prefixHandlers...), handlers...)
// Determine if standard headers should be enabled (default true if nil)
enableStdHeaders := host.EnableStandardHeaders == nil || *host.EnableStandardHeaders
locHandlers = append(locHandlers, resolvedOpts.proxyHandler(dial, false, host.Application, enableStdHeaders))
@@ -668,7 +705,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
}
}
}
- // Build main handlers: security pre-handlers, other host-level handlers, then reverse proxy
+ // Build main handlers: header pair, security pre-handlers, other host-level handlers, then reverse proxy
// Determine if standard headers should be enabled (default true if nil)
enableStdHeaders := host.EnableStandardHeaders == nil || *host.EnableStandardHeaders
// Recovery route for the reset endpoint. It skips the security pre-handlers,
@@ -676,7 +713,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
// matches the exact reset paths only. The reset endpoint performs its own
// token and management-network checks.
if resolvedOpts.selfHop.isSelfUpstream(dial, resolvedOpts.isRemoteHost(host.UUID)) {
- recoveryHandlers := append(append([]Handler{}, handlers...), resolvedOpts.proxyHandler(dial, resolvedOpts.isRemoteHost(host.UUID), host.Application, enableStdHeaders))
+ recoveryHandlers := append(append(append([]Handler{}, headerHandlers...), handlers...), resolvedOpts.proxyHandler(dial, resolvedOpts.isRemoteHost(host.UUID), host.Application, enableStdHeaders))
recoveryRoute := &Route{
Match: []Match{
{
@@ -697,7 +734,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
routes = append(routes, recoveryRoute)
}
- mainHandlers := append(append([]Handler{}, securityHandlers...), handlers...)
+ mainHandlers := append(append([]Handler{}, prefixHandlers...), handlers...)
mainHandlers = append(mainHandlers, resolvedOpts.proxyHandler(dial, resolvedOpts.isRemoteHost(host.UUID), host.Application, enableStdHeaders))
route := &Route{
@@ -727,6 +764,19 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
"WAF is enabled but some hosts have no WAF handler; the security status shows the reason")
}
+ // An enabled allow-list has no usable rules, so its hosts refuse every request except admin whitelist addresses.
+ if failClosedACLHosts > 0 {
+ logger.Log().WithError(failClosedACLErr).WithField("hosts", failClosedACLHosts).Warn(
+ "Access list is enabled but has no usable IP rules; requests to those hosts are refused")
+ }
+
+ // A country list is enabled but no GeoIP database is usable, so its hosts refuse every request. One line per apply
+ // names the lists.
+ if len(geoRefusedLists) > 0 {
+ logger.Log().WithField("lists", geoRefusedLists).Warn(
+ "Country access list needs the GeoIP database, which is unavailable; requests to its hosts are refused")
+ }
+
// The rate limit is enabled but its request count or window is not set, so the hosts get no rate limit handler.
// One line per apply carries the count; the stored values stay as they are.
if rateLimitUnsetHosts := report.RateLimit.Eligible - report.RateLimit.Protected; rateLimitUnsetHosts > 0 {
@@ -898,17 +948,66 @@ func NormalizeAdvancedConfig(parsed any) any {
}
}
+// geoAccessDeniedBody is the response body for a request refused by a country list.
+const geoAccessDeniedBody = "Access denied: Geographic restriction"
+
+// geoIP2LockFileName is the lock file the caddy-geoip2 module uses, kept in Charon's Caddy data directory.
+const geoIP2LockFileName = "geoip2.lock"
+
+// isCountryList reports whether the access list matches on the visitor's country, which needs the GeoIP database.
+func isCountryList(acl *models.AccessList) bool {
+ return strings.HasPrefix(acl.Type, "geo_")
+}
+
+// geoIP2AppFor returns the caddy-geoip2 app settings for the database at path. The module opens
+// /.mmdb, so only a path whose file name ends in .mmdb can be expressed.
+func geoIP2AppFor(path, storageDir string) *GeoIP2App {
+ return &GeoIP2App{
+ DatabaseDirectory: filepath.Dir(path),
+ EditionIDs: []string{strings.TrimSuffix(filepath.Base(path), ".mmdb")},
+ LockFile: filepath.Join(storageDir, geoIP2LockFileName),
+ }
+}
+
+// geoIP2Handler returns the caddy-geoip2 handler. It reads the visitor address from the connection only: the trusted
+// proxy setting would let a forwarded header decide the country until the trust model is narrowed.
+func geoIP2Handler() Handler {
+ return Handler{"handler": "geoip2", "enable": "strict"}
+}
+
+// geoUnavailableDenyHandler refuses every request. It replaces a country list while no GeoIP database is usable.
+func geoUnavailableDenyHandler() Handler {
+ return Handler{
+ "handler": "subroute",
+ "routes": []map[string]any{
+ {
+ "handle": []map[string]any{
+ {
+ "handler": "static_response",
+ "status_code": 403,
+ "body": geoAccessDeniedBody,
+ },
+ },
+ "terminal": true,
+ },
+ },
+ }
+}
+
// buildACLHandler creates access control handlers based on the AccessList configuration
func buildACLHandler(acl *models.AccessList, adminWhitelist string) (Handler, error) {
// For geo-blocking, we use CEL (Common Expression Language) matcher with caddy-geoip2 placeholders
// For IP-based ACLs, we use Caddy's native remote_ip matcher
- if strings.HasPrefix(acl.Type, "geo_") {
+ if isCountryList(acl) {
// Geo-blocking using caddy-geoip2
- countryCodes := strings.Split(acl.CountryCodes, ",")
+ // Legacy rows may hold codes in any case; the matcher compares against upper-case ISO codes.
var trimmedCodes []string
- for _, code := range countryCodes {
- trimmedCodes = append(trimmedCodes, `"`+strings.TrimSpace(code)+`"`)
+ for _, code := range strings.Split(models.NormalizeCountryCodes(acl.CountryCodes), ",") {
+ if code == "" {
+ continue
+ }
+ trimmedCodes = append(trimmedCodes, `"`+code+`"`)
}
var expression string
@@ -933,7 +1032,7 @@ func buildACLHandler(acl *models.AccessList, adminWhitelist string) (Handler, er
{
"handler": "static_response",
"status_code": 403,
- "body": "Access denied: Geographic restriction",
+ "body": geoAccessDeniedBody,
},
},
"terminal": true,
@@ -956,7 +1055,7 @@ func buildACLHandler(acl *models.AccessList, adminWhitelist string) (Handler, er
{
"handler": "static_response",
"status_code": 403,
- "body": "Access denied: Geographic restriction",
+ "body": geoAccessDeniedBody,
},
},
"terminal": true,
@@ -977,16 +1076,7 @@ func buildACLHandler(acl *models.AccessList, adminWhitelist string) (Handler, er
"not": []map[string]any{
{
"remote_ip": map[string]any{
- "ranges": []string{
- "10.0.0.0/8",
- "172.16.0.0/12",
- "192.168.0.0/16",
- "127.0.0.0/8",
- "169.254.0.0/16",
- "fc00::/7",
- "fe80::/10",
- "::1/128",
- },
+ "ranges": network.LocalNetworkCIDRs(),
},
},
},
@@ -1005,17 +1095,23 @@ func buildACLHandler(acl *models.AccessList, adminWhitelist string) (Handler, er
}, nil
}
- // Parse IP rules
- if acl.IPRules == "" {
- return nil, nil
- }
-
+ // Parse IP rules. A block-list with nothing to block adds no handler. An allow-list with no usable rules
+ // is never left out: it gets a deny handler, and the error tells the caller to log it.
var rules []models.AccessListRule
- if err := json.Unmarshal([]byte(acl.IPRules), &rules); err != nil {
- return nil, fmt.Errorf("invalid IP rules JSON: %w", err)
+ if acl.IPRules != "" {
+ if err := json.Unmarshal([]byte(acl.IPRules), &rules); err != nil {
+ parseErr := fmt.Errorf("invalid IP rules JSON: %w", err)
+ if acl.Type == "whitelist" {
+ return allowListFailClosed(adminWhitelist, parseErr)
+ }
+ return nil, parseErr
+ }
}
if len(rules) == 0 {
+ if acl.Type == "whitelist" {
+ return allowListFailClosed(adminWhitelist, errors.New("no IP rules"))
+ }
return nil, nil
}
@@ -1026,63 +1122,17 @@ func buildACLHandler(acl *models.AccessList, adminWhitelist string) (Handler, er
}
if acl.Type == "whitelist" {
- // Allow only these IPs (block everything else)
- // Merge adminWhitelist into allowed cidrs so that admins always bypass whitelist checks
- if adminWhitelist != "" {
- adminParts := strings.Split(adminWhitelist, ",")
- for _, p := range adminParts {
- p = strings.TrimSpace(p)
- if p == "" {
- continue
- }
- cidrs = append(cidrs, p)
- }
- }
- return Handler{
- "handler": "subroute",
- "routes": []map[string]any{
- {
- "match": []map[string]any{
- {
- "not": []map[string]any{
- {
- "remote_ip": map[string]any{
- "ranges": cidrs,
- },
- },
- },
- },
- },
- "handle": []map[string]any{
- {
- "handler": "static_response",
- "status_code": 403,
- "body": "Access denied: IP not in whitelist",
- },
- },
- "terminal": true,
- },
- },
- }, nil
+ // Allow only these IPs (block everything else). Admin whitelist addresses always bypass whitelist checks.
+ cidrs = append(cidrs, parseAdminWhitelist(adminWhitelist)...)
+ return whitelistDenyHandler(cidrs), nil
}
if acl.Type == "blacklist" {
// Block these IPs (allow everything else)
// For blacklist, add an explicit 'not' clause excluding adminWhitelist ranges from the match
var adminExclusion any
- if adminWhitelist != "" {
- adminParts := strings.Split(adminWhitelist, ",")
- trims := make([]string, 0)
- for _, p := range adminParts {
- p = strings.TrimSpace(p)
- if p == "" {
- continue
- }
- trims = append(trims, p)
- }
- if len(trims) > 0 {
- adminExclusion = map[string]any{"not": []map[string]any{{"remote_ip": map[string]any{"ranges": trims}}}}
- }
+ if trims := parseAdminWhitelist(adminWhitelist); len(trims) > 0 {
+ adminExclusion = map[string]any{"not": []map[string]any{{"remote_ip": map[string]any{"ranges": trims}}}}
}
// Build matcher parts
matchParts := []map[string]any{}
@@ -1111,6 +1161,44 @@ func buildACLHandler(acl *models.AccessList, adminWhitelist string) (Handler, er
return nil, nil
}
+// errAllowListFailClosed marks an allow-list that was replaced by a deny handler because it has no usable rules.
+var errAllowListFailClosed = errors.New("allow-list has no usable IP rules; requests are refused")
+
+// allowListFailClosed returns the deny handler for an allow-list without usable rules, with the reason as an error.
+func allowListFailClosed(adminWhitelist string, cause error) (Handler, error) {
+ return whitelistDenyHandler(parseAdminWhitelist(adminWhitelist)), fmt.Errorf("%w: %w", errAllowListFailClosed, cause)
+}
+
+// whitelistDenyHandler refuses every request whose address is not in allowed. With no allowed address it refuses all.
+func whitelistDenyHandler(allowed []string) Handler {
+ deny := map[string]any{
+ "handler": "static_response",
+ "status_code": 403,
+ "body": "Access denied: IP not in whitelist",
+ }
+ route := map[string]any{
+ "handle": []map[string]any{deny},
+ "terminal": true,
+ }
+ if len(allowed) > 0 {
+ route["match"] = []map[string]any{
+ {"not": []map[string]any{{"remote_ip": map[string]any{"ranges": allowed}}}},
+ }
+ }
+ return Handler{"handler": "subroute", "routes": []map[string]any{route}}
+}
+
+// parseAdminWhitelist splits the comma-separated admin whitelist and drops blank entries.
+func parseAdminWhitelist(adminWhitelist string) []string {
+ var addresses []string
+ for _, p := range strings.Split(adminWhitelist, ",") {
+ if p = strings.TrimSpace(p); p != "" {
+ addresses = append(addresses, p)
+ }
+ }
+ return addresses
+}
+
// buildCrowdSecHandler returns a minimal CrowdSec handler for the caddy-crowdsec-bouncer plugin.
// The app-level configuration (apps.crowdsec) is populated in GenerateConfig(),
// so the handler only needs to reference the module name.
diff --git a/backend/internal/caddy/config_acl_country_empty_test.go b/backend/internal/caddy/config_acl_country_empty_test.go
new file mode 100644
index 000000000..ea21510d9
--- /dev/null
+++ b/backend/internal/caddy/config_acl_country_empty_test.go
@@ -0,0 +1,33 @@
+package caddy
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+// A geo list stored with no country codes (legacy rows) still produces a matcher with an empty set.
+func TestBuildACLHandler_EmptyCountryCodesGenerateEmptySet(t *testing.T) {
+ t.Run("whitelist blocks every request", func(t *testing.T) {
+ handler, err := buildACLHandler(&models.AccessList{Type: "geo_whitelist", CountryCodes: ""}, "")
+ require.NoError(t, err)
+
+ raw, err := json.Marshal(handler)
+ require.NoError(t, err)
+ require.Contains(t, string(raw), `"not"`)
+ require.Contains(t, string(raw), "{geoip2.country_code} in []")
+ })
+
+ t.Run("blacklist blocks nothing", func(t *testing.T) {
+ handler, err := buildACLHandler(&models.AccessList{Type: "geo_blacklist", CountryCodes: " , "}, "")
+ require.NoError(t, err)
+
+ raw, err := json.Marshal(handler)
+ require.NoError(t, err)
+ require.NotContains(t, string(raw), `"not"`)
+ require.Contains(t, string(raw), "{geoip2.country_code} in []")
+ })
+}
diff --git a/backend/internal/caddy/config_acl_country_test.go b/backend/internal/caddy/config_acl_country_test.go
new file mode 100644
index 000000000..818733b79
--- /dev/null
+++ b/backend/internal/caddy/config_acl_country_test.go
@@ -0,0 +1,34 @@
+package caddy
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+func TestBuildACLHandler_CountryCodesEmittedUpperCase(t *testing.T) {
+ cases := []struct {
+ name string
+ aclType string
+ codes string
+ want string
+ }{
+ {name: "lower-case allow list", aclType: "geo_whitelist", codes: "us,ca", want: `in [\"US\", \"CA\"]`},
+ {name: "mixed-case allow list", aclType: "geo_whitelist", codes: " Us , cA ", want: `in [\"US\", \"CA\"]`},
+ {name: "lower-case block list", aclType: "geo_blacklist", codes: "ru,cn", want: `in [\"RU\", \"CN\"]`},
+ {name: "mixed-case block list", aclType: "geo_blacklist", codes: "Ru, Cn", want: `in [\"RU\", \"CN\"]`},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ handler, err := buildACLHandler(&models.AccessList{Type: tc.aclType, CountryCodes: tc.codes}, "")
+ require.NoError(t, err)
+
+ raw, err := json.Marshal(handler)
+ require.NoError(t, err)
+ require.Contains(t, string(raw), "{geoip2.country_code} "+tc.want)
+ })
+ }
+}
diff --git a/backend/internal/caddy/config_acl_no_rules_test.go b/backend/internal/caddy/config_acl_no_rules_test.go
new file mode 100644
index 000000000..3fa45f29d
--- /dev/null
+++ b/backend/internal/caddy/config_acl_no_rules_test.go
@@ -0,0 +1,87 @@
+package caddy
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/Wikid82/charon/backend/internal/logger"
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/stretchr/testify/require"
+)
+
+const aclDenyBody = "Access denied: IP not in whitelist"
+
+func TestBuildACLHandler_AllowListWithoutRulesDeniesEveryone(t *testing.T) {
+ for name, ipRules := range map[string]string{
+ "empty string": "",
+ "empty array": "[]",
+ "unreadable JSON": "invalid-json",
+ } {
+ t.Run(name, func(t *testing.T) {
+ acl := &models.AccessList{Type: "whitelist", IPRules: ipRules, Enabled: true}
+ h, err := buildACLHandler(acl, "")
+ require.ErrorIs(t, err, errAllowListFailClosed)
+ require.NotNil(t, h)
+
+ b, _ := json.Marshal(h)
+ require.Contains(t, string(b), aclDenyBody)
+ require.NotContains(t, string(b), `"remote_ip"`, "no allowed address means no match clause")
+ })
+ }
+}
+
+func TestBuildACLHandler_AllowListWithoutRulesKeepsAdminWhitelist(t *testing.T) {
+ acl := &models.AccessList{Type: "whitelist", IPRules: "", Enabled: true}
+ h, err := buildACLHandler(acl, " 10.9.8.7 , 10.9.8.8 ")
+ require.ErrorIs(t, err, errAllowListFailClosed)
+ require.NotNil(t, h)
+
+ b, _ := json.Marshal(h)
+ require.Contains(t, string(b), `"10.9.8.7"`)
+ require.Contains(t, string(b), `"10.9.8.8"`)
+ require.Contains(t, string(b), aclDenyBody)
+}
+
+func TestBuildACLHandler_BlockListWithoutRulesAddsNothing(t *testing.T) {
+ for _, ipRules := range []string{"", "[]"} {
+ acl := &models.AccessList{Type: "blacklist", IPRules: ipRules, Enabled: true}
+ h, err := buildACLHandler(acl, "")
+ require.NoError(t, err)
+ require.Nil(t, h)
+ }
+}
+
+func TestGenerateConfig_AllowListWithoutRulesAddsDenyHandlerAndOneWarn(t *testing.T) {
+ var buf strings.Builder
+ logger.Init(true, &buf)
+
+ acl := models.AccessList{ID: 41, Name: "Empty", Enabled: true, Type: "whitelist", IPRules: ""}
+ hostA := models.ProxyHost{UUID: "no-rules-a", DomainNames: "a-norules.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
+ hostB := models.ProxyHost{UUID: "no-rules-b", DomainNames: "b-norules.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
+
+ cfg, err := GenerateConfig([]models.ProxyHost{hostA, hostB}, t.TempDir(), "", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
+ require.NoError(t, err)
+
+ routes, err := json.Marshal(cfg.Apps.HTTP.Servers["charon_server"].Routes)
+ require.NoError(t, err)
+ require.Equal(t, 2, strings.Count(string(routes), aclDenyBody), "each host gets a deny handler")
+
+ require.Equal(t, 1, strings.Count(buf.String(), "Access list is enabled but has no usable IP rules"), "one Warn per apply")
+}
+
+func TestGenerateConfig_BlockListWithoutRulesLogsNothing(t *testing.T) {
+ var buf strings.Builder
+ logger.Init(true, &buf)
+
+ acl := models.AccessList{ID: 42, Name: "Empty block", Enabled: true, Type: "blacklist", IPRules: ""}
+ host := models.ProxyHost{UUID: "block-no-rules", DomainNames: "block-norules.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
+
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, t.TempDir(), "", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
+ require.NoError(t, err)
+
+ routes, err := json.Marshal(cfg.Apps.HTTP.Servers["charon_server"].Routes)
+ require.NoError(t, err)
+ require.NotContains(t, string(routes), aclDenyBody)
+ require.NotContains(t, buf.String(), "no usable IP rules")
+}
diff --git a/backend/internal/caddy/config_extra_test.go b/backend/internal/caddy/config_extra_test.go
index f33f23cf5..c9f31c474 100644
--- a/backend/internal/caddy/config_extra_test.go
+++ b/backend/internal/caddy/config_extra_test.go
@@ -242,13 +242,15 @@ func TestGenerateConfig_SecurityPipeline_Order(t *testing.T) {
}
}
- // Expected pipeline: crowdsec -> waf -> rate_limit -> subroute (acl) -> headers -> vars (BlockExploits) -> reverse_proxy
- require.GreaterOrEqual(t, len(names), 4)
- require.Equal(t, "crowdsec", names[0])
- require.Equal(t, "waf", names[1])
- require.Equal(t, "rate_limit", names[2])
+ // Expected pipeline: headers (HSTS pair) -> crowdsec -> waf -> rate_limit -> subroute (acl) -> vars (BlockExploits) -> reverse_proxy
+ require.GreaterOrEqual(t, len(names), 6)
+ require.Equal(t, "headers", names[0])
+ require.Equal(t, "headers", names[1])
+ require.Equal(t, "crowdsec", names[2])
+ require.Equal(t, "waf", names[3])
+ require.Equal(t, "rate_limit", names[4])
// ACL is subroute
- require.Equal(t, "subroute", names[3])
+ require.Equal(t, "subroute", names[5])
}
func TestGenerateConfig_SecurityPipeline_OmitWhenDisabled(t *testing.T) {
diff --git a/backend/internal/caddy/config_generate_additional_test.go b/backend/internal/caddy/config_generate_additional_test.go
index c7575056f..68bbb2cc9 100644
--- a/backend/internal/caddy/config_generate_additional_test.go
+++ b/backend/internal/caddy/config_generate_additional_test.go
@@ -85,12 +85,14 @@ func TestGenerateConfig_SecurityPipeline_Order_Locations(t *testing.T) {
}
}
- // Expected pipeline: crowdsec -> waf -> rate_limit -> subroute (acl) -> headers -> vars (BlockExploits) -> reverse_proxy
- require.GreaterOrEqual(t, len(names), 4)
- require.Equal(t, "crowdsec", names[0])
- require.Equal(t, "waf", names[1])
- require.Equal(t, "rate_limit", names[2])
- require.Equal(t, "subroute", names[3])
+ // Expected pipeline: headers (HSTS pair) -> crowdsec -> waf -> rate_limit -> subroute (acl) -> vars (BlockExploits) -> reverse_proxy
+ require.GreaterOrEqual(t, len(names), 6)
+ require.Equal(t, "headers", names[0])
+ require.Equal(t, "headers", names[1])
+ require.Equal(t, "crowdsec", names[2])
+ require.Equal(t, "waf", names[3])
+ require.Equal(t, "rate_limit", names[4])
+ require.Equal(t, "subroute", names[5])
}
func TestGenerateConfig_ACLLogWarning(t *testing.T) {
diff --git a/backend/internal/caddy/config_geoip2_test.go b/backend/internal/caddy/config_geoip2_test.go
new file mode 100644
index 000000000..a1445bcfa
--- /dev/null
+++ b/backend/internal/caddy/config_geoip2_test.go
@@ -0,0 +1,179 @@
+package caddy
+
+import (
+ "encoding/json"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/geoip"
+ "github.com/Wikid82/charon/backend/internal/logger"
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+const (
+ testGeoIPDBPath = "/data/geoip/GeoLite2-Country.mmdb"
+ geoDeniedBody = "Access denied: Geographic restriction"
+ geoIP2HandlerMarker = `"handler":"geoip2"`
+ geoCountryMatcher = "{geoip2.country_code}"
+)
+
+// generateCountryListConfig builds one enabled host behind a country list of the given type and returns the config
+// and the warnings logged while generating it.
+func generateCountryListConfig(t *testing.T, listType, storageDir string, opts ...GenerateConfigOption) (cfg *Config, warnings string) {
+ t.Helper()
+ var buf strings.Builder
+ logger.Init(true, &buf)
+
+ acl := models.AccessList{ID: 70, Name: "Countries", Enabled: true, Type: listType, CountryCodes: "US,CA"}
+ host := models.ProxyHost{UUID: "geo-host", DomainNames: "geo.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
+
+ var err error
+ cfg, err = GenerateConfig([]models.ProxyHost{host}, storageDir, "", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil, opts...)
+ require.NoError(t, err)
+ return cfg, buf.String()
+}
+
+func routesJSON(t *testing.T, cfg *Config) string {
+ t.Helper()
+ b, err := json.Marshal(cfg.Apps.HTTP.Servers["charon_server"].Routes)
+ require.NoError(t, err)
+ return string(b)
+}
+
+func TestGenerateConfig_CountryListWithDatabase_AddsGeoIP2AppAndStrictHandler(t *testing.T) {
+ for _, listType := range []string{"geo_whitelist", "geo_blacklist"} {
+ t.Run(listType, func(t *testing.T) {
+ storageDir := t.TempDir()
+ cfg, warnings := generateCountryListConfig(t, listType, storageDir, WithGeoIPDatabase(testGeoIPDBPath, true))
+
+ require.NotNil(t, cfg.Apps.GeoIP2, "the geoip2 app is added when a country list is in use")
+ require.Equal(t, "/data/geoip", cfg.Apps.GeoIP2.DatabaseDirectory)
+ require.Equal(t, []string{"GeoLite2-Country"}, cfg.Apps.GeoIP2.EditionIDs)
+ require.Equal(t, filepath.Join(storageDir, geoIP2LockFileName), cfg.Apps.GeoIP2.LockFile)
+
+ appJSON, err := json.Marshal(cfg.Apps.GeoIP2)
+ require.NoError(t, err)
+ require.NotContains(t, string(appJSON), "accountId", "the module never downloads")
+ require.NotContains(t, string(appJSON), "licenseKey", "the module never downloads")
+
+ routes := routesJSON(t, cfg)
+ handlerAt := strings.Index(routes, geoIP2HandlerMarker)
+ countryAt := strings.Index(routes, geoCountryMatcher)
+ require.Positive(t, handlerAt, "the geoip2 handler is in the pipeline")
+ require.Greater(t, countryAt, handlerAt, "the geoip2 handler sits before the country subroute")
+ require.Contains(t, routes, `"enable":"strict"`)
+ require.NotContains(t, warnings, "needs the GeoIP database")
+ })
+ }
+}
+
+func TestGenerateConfig_CountryListWithDatabase_HandlerSitsImmediatelyBeforeSubroute(t *testing.T) {
+ cfg, _ := generateCountryListConfig(t, "geo_blacklist", t.TempDir(), WithGeoIPDatabase(testGeoIPDBPath, true))
+
+ handlers := cfg.Apps.HTTP.Servers["charon_server"].Routes[0].Handle
+ require.GreaterOrEqual(t, len(handlers), 2)
+ var names []string
+ for _, h := range handlers {
+ name, _ := h["handler"].(string)
+ names = append(names, name)
+ }
+ idx := -1
+ for i, h := range handlers {
+ if h["handler"] == "geoip2" {
+ idx = i
+ }
+ }
+ require.NotEqual(t, -1, idx, "handler names: %v", names)
+ require.Equal(t, "subroute", handlers[idx+1]["handler"], "the country subroute follows the geoip2 handler")
+ require.Equal(t, "strict", handlers[idx]["enable"])
+}
+
+func TestGenerateConfig_NoCountryListNoGeoIP2App(t *testing.T) {
+ acl := models.AccessList{ID: 71, Name: "IPs", Enabled: true, Type: "whitelist", IPRules: `[{"cidr":"10.1.1.0/24"}]`}
+ host := models.ProxyHost{UUID: "ip-host", DomainNames: "ip.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
+
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, t.TempDir(), "", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil, WithGeoIPDatabase(testGeoIPDBPath, true))
+ require.NoError(t, err)
+
+ require.Nil(t, cfg.Apps.GeoIP2)
+ require.NotContains(t, routesJSON(t, cfg), geoIP2HandlerMarker)
+
+ raw, err := json.Marshal(cfg)
+ require.NoError(t, err)
+ require.NotContains(t, string(raw), `"geoip2":`)
+}
+
+func TestGenerateConfig_CountryListWithoutACLEnabledNoGeoIP2App(t *testing.T) {
+ acl := models.AccessList{ID: 72, Name: "Countries", Enabled: true, Type: "geo_blacklist", CountryCodes: "US"}
+ host := models.ProxyHost{UUID: "off-host", DomainNames: "off.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
+
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, t.TempDir(), "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil, WithGeoIPDatabase(testGeoIPDBPath, true))
+ require.NoError(t, err)
+ require.Nil(t, cfg.Apps.GeoIP2)
+}
+
+func TestGenerateConfig_CountryListDatabaseUnavailable_DeniesEveryHost(t *testing.T) {
+ missingFile := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ cases := map[string]struct {
+ opts []GenerateConfigOption
+ }{
+ "database not loaded": {opts: []GenerateConfigOption{WithGeoIPDatabase(testGeoIPDBPath, false)}},
+ "non-mmdb path": {opts: []GenerateConfigOption{WithGeoIPDatabase("/data/geoip/GeoLite2-Country.dat", true)}},
+ "no file configured": {opts: []GenerateConfigOption{WithGeoIPDatabase("", false)}},
+ "missing file on disk": {
+ opts: []GenerateConfigOption{WithGeoIPDatabase(missingFile, geoip.DatabaseAvailable(missingFile, true))},
+ },
+ "no database option at all": {opts: nil},
+ }
+ for name, tc := range cases {
+ t.Run(name, func(t *testing.T) {
+ for _, listType := range []string{"geo_whitelist", "geo_blacklist"} {
+ cfg, warnings := generateCountryListConfig(t, listType, t.TempDir(), tc.opts...)
+
+ require.Nil(t, cfg.Apps.GeoIP2, "no geoip2 app without a usable database")
+ routes := routesJSON(t, cfg)
+ require.Contains(t, routes, geoDeniedBody, "the host is refused")
+ require.NotContains(t, routes, geoCountryMatcher, "the country subroute is not emitted")
+ require.NotContains(t, routes, geoIP2HandlerMarker)
+ require.Equal(t, 1, strings.Count(warnings, "needs the GeoIP database"), "one Warn per apply")
+ require.Contains(t, warnings, "Countries", "the Warn names the list")
+ }
+ })
+ }
+}
+
+func TestGenerateConfig_CountryListDatabaseUnavailable_WarnsOncePerApply(t *testing.T) {
+ var buf strings.Builder
+ logger.Init(true, &buf)
+
+ acl := models.AccessList{ID: 73, Name: "Shared", Enabled: true, Type: "geo_whitelist", CountryCodes: "US"}
+ hostA := models.ProxyHost{UUID: "geo-a", DomainNames: "a-geo.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
+ hostB := models.ProxyHost{UUID: "geo-b", DomainNames: "b-geo.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
+
+ cfg, err := GenerateConfig([]models.ProxyHost{hostA, hostB}, t.TempDir(), "", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
+ require.NoError(t, err)
+
+ require.Equal(t, 2, strings.Count(routesJSON(t, cfg), geoDeniedBody), "each host gets a deny handler")
+ require.Equal(t, 1, strings.Count(buf.String(), "needs the GeoIP database"), "one Warn per apply")
+}
+
+func TestGeoIP2AppFor_SplitsDirectoryAndEdition(t *testing.T) {
+ storageDir := "/app/data/caddy/data"
+ app := geoIP2AppFor("/srv/maps/Custom-City.mmdb", storageDir)
+ require.Equal(t, "/srv/maps", app.DatabaseDirectory)
+ require.Equal(t, []string{"Custom-City"}, app.EditionIDs)
+ require.Equal(t, filepath.Join(storageDir, geoIP2LockFileName), app.LockFile)
+}
+
+func TestGenerateConfig_GeoIPOptionDefaultsToUnavailable(t *testing.T) {
+ resolved := resolveGenerateConfigOptions([]GenerateConfigOption{WithGeoIPDatabase(testGeoIPDBPath, true), nil})
+ require.Equal(t, testGeoIPDBPath, resolved.geoIPPath)
+ require.True(t, resolved.geoIPAvailable)
+
+ empty := resolveGenerateConfigOptions(nil)
+ require.Empty(t, empty.geoIPPath)
+ require.False(t, empty.geoIPAvailable)
+}
diff --git a/backend/internal/caddy/config_options.go b/backend/internal/caddy/config_options.go
index 510ee72ef..c7d7b765e 100644
--- a/backend/internal/caddy/config_options.go
+++ b/backend/internal/caddy/config_options.go
@@ -31,6 +31,9 @@ type generateConfigOptions struct {
enforcement func(EnforcementReport)
// bouncerKeyPath is the saved CrowdSec bouncer key file the proxy reads.
bouncerKeyPath string
+ // geoIPPath is the configured GeoIP database file, and geoIPAvailable says whether the proxy may use it now.
+ geoIPPath string
+ geoIPAvailable bool
}
// resolveGenerateConfigOptions applies opts in order and returns the
@@ -94,3 +97,13 @@ func WithBouncerKeyPath(path string) GenerateConfigOption {
func WithEnforcementSink(sink func(EnforcementReport)) GenerateConfigOption {
return func(o *generateConfigOptions) { o.enforcement = sink }
}
+
+// WithGeoIPDatabase supplies the GeoIP database the proxy's country lists read and whether it is usable now. The
+// caller decides usability with geoip.DatabaseAvailable. Without this option no database is available, so every
+// country list is refused.
+func WithGeoIPDatabase(path string, available bool) GenerateConfigOption {
+ return func(o *generateConfigOptions) {
+ o.geoIPPath = path
+ o.geoIPAvailable = available
+ }
+}
diff --git a/backend/internal/caddy/config_recovery_route_test.go b/backend/internal/caddy/config_recovery_route_test.go
index 6bf2678f9..2f6445f8d 100644
--- a/backend/internal/caddy/config_recovery_route_test.go
+++ b/backend/internal/caddy/config_recovery_route_test.go
@@ -191,5 +191,5 @@ func TestRecoveryRouteTokenPathUsesMainRoute(t *testing.T) {
}
}
require.NotNil(t, mainRoute, "self upstream host keeps a main route")
- assertPipelineOrder(t, mainRoute.Handle)
+ assertPipelineOrder(t, mainRoute.Handle, false)
}
diff --git a/backend/internal/caddy/config_routes_pipeline_test.go b/backend/internal/caddy/config_routes_pipeline_test.go
index 4ebf38a87..bd32bc939 100644
--- a/backend/internal/caddy/config_routes_pipeline_test.go
+++ b/backend/internal/caddy/config_routes_pipeline_test.go
@@ -33,8 +33,15 @@ func pipelineRoutesFor(t *testing.T, hosts []models.ProxyHost, opts ...GenerateC
return server.Routes
}
+// pipelineHeaderProfile is the profile used by the ordering cases that carry a
+// security header pair.
+func pipelineHeaderProfile() *models.SecurityHeaderProfile {
+ return &models.SecurityHeaderProfile{XFrameOptions: "DENY", ReferrerPolicy: "no-referrer"}
+}
+
// TestGeneratedRoutesKeepPipelineOrder checks, for each host kind, that the
-// routes serving the host match the host and that the security pre-handlers
+// routes serving the host match the host, that the security header pair (when
+// the host has a profile) leads the route, and that the security pre-handlers
// run before the reverse proxy on the main route and on every location route.
func TestGeneratedRoutesKeepPipelineOrder(t *testing.T) {
secret := testHopSecret(t)
@@ -44,6 +51,7 @@ func TestGeneratedRoutesKeepPipelineOrder(t *testing.T) {
host models.ProxyHost
opts []GenerateConfigOption
locationPaths [][]string
+ wantHeaders bool
}{
{
name: "plain upstream",
@@ -69,6 +77,17 @@ func TestGeneratedRoutesKeepPipelineOrder(t *testing.T) {
},
locationPaths: [][]string{{"/api", "/api/*"}},
},
+ {
+ name: "host with security header profile and locations",
+ domain: "hdrs.example.com",
+ host: models.ProxyHost{
+ UUID: "hdrs", DomainNames: "hdrs.example.com", ForwardHost: "10.0.0.8", ForwardPort: 3002, Enabled: true,
+ SecurityHeaderProfile: pipelineHeaderProfile(),
+ Locations: []models.Location{{Path: "/api", ForwardHost: "10.0.0.9", ForwardPort: 9001}},
+ },
+ locationPaths: [][]string{{"/api", "/api/*"}},
+ wantHeaders: true,
+ },
}
for _, tt := range tests {
@@ -93,17 +112,68 @@ func TestGeneratedRoutesKeepPipelineOrder(t *testing.T) {
require.Len(t, mainRoutes, 1, "exactly one main route for the host")
main := mainRoutes[0]
assert.Equal(t, []string{tt.domain}, main.Match[0].Host)
- assertPipelineOrder(t, main.Handle)
+ assertPipelineOrder(t, main.Handle, tt.wantHeaders)
require.Len(t, locationRoutes, len(tt.locationPaths))
for _, r := range locationRoutes {
assert.Equal(t, []string{tt.domain}, r.Match[0].Host)
- assertPipelineOrder(t, r.Handle)
+ assertPipelineOrder(t, r.Handle, tt.wantHeaders)
}
})
}
}
+// TestRecoveryRouteCarriesHeaderPairFirst checks that the recovery route, which
+// skips the security pre-handlers, still leads with the security header pair
+// and proxies last.
+func TestRecoveryRouteCarriesHeaderPairFirst(t *testing.T) {
+ secret := testHopSecret(t)
+ host := models.ProxyHost{
+ UUID: "recovery", DomainNames: "recovery.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true,
+ SecurityHeaderProfile: pipelineHeaderProfile(),
+ }
+ routes := pipelineRoutesFor(t, []models.ProxyHost{host}, WithSelfHop(secret, "8080"))
+
+ var recovery *Route
+ for _, r := range routes {
+ if len(r.Match) > 0 && slices.Equal(r.Match[0].Path, resetRecoveryPaths) {
+ recovery = r
+ }
+ }
+ require.NotNil(t, recovery, "self upstream host has a recovery route")
+ require.GreaterOrEqual(t, len(recovery.Handle), 3)
+
+ set := assertHeaderPair(t, recovery.Handle[:2])
+ assert.Equal(t, []string{"DENY"}, set["X-Frame-Options"])
+ assert.Equal(t, "reverse_proxy", recovery.Handle[len(recovery.Handle)-1]["handler"])
+ assert.Equal(t, -1, handlerIndex(recovery.Handle, "subroute"), "recovery route has no security pre-handler")
+}
+
+// TestHostWithoutSecurityHeaderProfileKeepsRouteShape checks that a host with
+// no security header profile gets no headers handler: its main route is the
+// security pre-handler followed by the reverse proxy, as before the header pair
+// was moved to the front of the route.
+func TestHostWithoutSecurityHeaderProfileKeepsRouteShape(t *testing.T) {
+ host := models.ProxyHost{
+ UUID: "bare", DomainNames: "bare.example.com", ForwardHost: "10.0.0.20", ForwardPort: 3003, Enabled: true,
+ }
+ routes := pipelineRoutesFor(t, []models.ProxyHost{host})
+
+ var main *Route
+ for _, r := range routes {
+ if len(r.Match) > 0 && len(r.Match[0].Path) == 0 && len(r.Match[0].Host) == 1 && r.Match[0].Host[0] == "bare.example.com" {
+ main = r
+ }
+ }
+ require.NotNil(t, main)
+
+ names := make([]any, 0, len(main.Handle))
+ for _, h := range main.Handle {
+ names = append(names, h["handler"])
+ }
+ assert.Equal(t, []any{"subroute", "reverse_proxy"}, names)
+}
+
// isLocationPath reports whether path is the path matcher of one of the
// configured locations.
func isLocationPath(locationPaths [][]string, path []string) bool {
@@ -116,8 +186,12 @@ func isLocationPath(locationPaths [][]string, path []string) bool {
}
// assertPipelineOrder requires a security pre-handler and exactly one reverse
-// proxy, with the pre-handler first and the proxy last.
-func assertPipelineOrder(t *testing.T, handle []Handler) {
+// proxy, with the pre-handler first and the proxy last. When wantHeaders is
+// set, the route must open with the security header pair, which runs before
+// the security pre-handlers so refusals carry the headers; the deferred half
+// then replaces any upstream copy on proxied responses. Without a profile the
+// route must carry no headers handler at all.
+func assertPipelineOrder(t *testing.T, handle []Handler, wantHeaders bool) {
t.Helper()
proxyAt := handlerIndex(handle, "reverse_proxy")
require.GreaterOrEqual(t, proxyAt, 0, "route has a reverse_proxy handler")
@@ -126,4 +200,14 @@ func assertPipelineOrder(t *testing.T, handle []Handler) {
securityAt := handlerIndex(handle, "subroute")
require.GreaterOrEqual(t, securityAt, 0, "route has a security pre-handler")
assert.Less(t, securityAt, proxyAt, "security pre-handler runs before the proxy")
+
+ if !wantHeaders {
+ assert.Equal(t, -1, handlerIndex(handle, "headers"), "host without a profile has no headers handler")
+ assert.Equal(t, 0, securityAt, "security pre-handler is the first handler")
+ return
+ }
+ require.GreaterOrEqual(t, len(handle), 2)
+ set := assertHeaderPair(t, handle[:2])
+ assert.Equal(t, []string{"DENY"}, set["X-Frame-Options"])
+ assert.Less(t, 1, securityAt, "header pair runs before the security pre-handler so refusals carry it")
}
diff --git a/backend/internal/caddy/enforcement_report.go b/backend/internal/caddy/enforcement_report.go
index f32a05899..a2ffb220d 100644
--- a/backend/internal/caddy/enforcement_report.go
+++ b/backend/internal/caddy/enforcement_report.go
@@ -13,6 +13,8 @@ const (
ReasonRulesetEmpty = "ruleset_empty"
// ReasonThresholdsUnset means the rate limit is on without a request count or a window.
ReasonThresholdsUnset = "thresholds_unset"
+ // ReasonGeoIPUnavailable means a country list is in use but no GeoIP database is loaded, so the list cannot match.
+ ReasonGeoIPUnavailable = "geoip_unavailable"
)
// ModuleEnforcement counts the hosts one security module applies to.
diff --git a/backend/internal/caddy/enforcement_report_geoip_test.go b/backend/internal/caddy/enforcement_report_geoip_test.go
new file mode 100644
index 000000000..d46f161fd
--- /dev/null
+++ b/backend/internal/caddy/enforcement_report_geoip_test.go
@@ -0,0 +1,11 @@
+package caddy
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestEnforcementReport_GeoIPUnavailableReasonValue(t *testing.T) {
+ assert.Equal(t, "geoip_unavailable", ReasonGeoIPUnavailable)
+}
diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go
index a8e9ad092..b87a9d9a8 100644
--- a/backend/internal/caddy/manager.go
+++ b/backend/internal/caddy/manager.go
@@ -82,6 +82,8 @@ type Manager struct {
orthrusSvc OrthrusAddrResolver
hopSecret *selfhop.Secret
hopPort string
+ // geoIPState reports the GeoIP database path and whether the proxy may use it. It is read at every apply.
+ geoIPState func() (path string, available bool)
// enforcement is the report of the last successful apply. It is nil while an apply runs and after any failed one.
enforcement atomic.Pointer[EnforcementReport]
}
@@ -118,6 +120,12 @@ func (m *Manager) SetOrthrusServer(s OrthrusAddrResolver) {
m.orthrusSvc = s
}
+// SetGeoIPState sets the source of the GeoIP database state. It is read at every apply, so a database loaded or
+// reloaded after startup reaches the next generated config. Without it every country list is refused.
+func (m *Manager) SetGeoIPState(state func() (path string, available bool)) {
+ m.geoIPState = state
+}
+
// SetSelfHop enables recognition of requests proxied to Charon's own API on port.
// Snapshots written by this manager carry a placeholder instead of the secret.
// The secret changes at every restart, so a persisted copy goes stale, and any
@@ -494,6 +502,10 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
if m.hopSecret != nil {
genOpts = append(genOpts, WithSelfHop(m.hopSecret, m.hopPort))
}
+ if m.geoIPState != nil {
+ geoPath, geoAvailable := m.geoIPState()
+ genOpts = append(genOpts, WithGeoIPDatabase(geoPath, geoAvailable))
+ }
generatedConfig, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, effectiveProvider, effectiveStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg, dnsProviderConfigs, genOpts...)
if err != nil {
diff --git a/backend/internal/caddy/manager_geoip_state_test.go b/backend/internal/caddy/manager_geoip_state_test.go
new file mode 100644
index 000000000..b47818c54
--- /dev/null
+++ b/backend/internal/caddy/manager_geoip_state_test.go
@@ -0,0 +1,27 @@
+package caddy
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/config"
+)
+
+// TestManager_ApplyConfig_ReadsGeoIPStateAtEachApply pins that the GeoIP database state is read on every apply, so a
+// database loaded or reloaded after startup reaches the next generated config.
+func TestManager_ApplyConfig_ReadsGeoIPStateAtEachApply(t *testing.T) {
+ db := openEnforcementApplyDB(t)
+ manager := NewManager(&switchableCaddyClient{}, db, t.TempDir(), "", false, config.SecurityConfig{CerberusEnabled: true})
+
+ reads := 0
+ manager.SetGeoIPState(func() (string, bool) {
+ reads++
+ return testGeoIPDBPath, reads > 1
+ })
+
+ require.NoError(t, manager.ApplyConfig(context.Background()))
+ require.NoError(t, manager.ApplyConfig(context.Background()))
+ require.Equal(t, 2, reads, "one read per apply")
+}
diff --git a/backend/internal/caddy/types.go b/backend/internal/caddy/types.go
index f872c8da9..b70163c32 100644
--- a/backend/internal/caddy/types.go
+++ b/backend/internal/caddy/types.go
@@ -64,11 +64,21 @@ type CrowdSecApp struct {
EnableStreaming *bool `json:"enable_streaming,omitempty"`
}
+// GeoIP2App configures the caddy-geoip2 app module. Charon sets no account or licence key, so the module only reads
+// the local database and never downloads one. The module opens /.mmdb.
+// Reference: https://github.com/zhangjiayin/caddy-geoip2
+type GeoIP2App struct {
+ DatabaseDirectory string `json:"databaseDirectory"`
+ EditionIDs []string `json:"editionID"`
+ LockFile string `json:"lockFile"`
+}
+
// Apps contains all Caddy app modules.
type Apps struct {
HTTP *HTTPApp `json:"http,omitempty"`
TLS *TLSApp `json:"tls,omitempty"`
CrowdSec *CrowdSecApp `json:"crowdsec,omitempty"`
+ GeoIP2 *GeoIP2App `json:"geoip2,omitempty"`
}
// HTTPApp configures the HTTP app.
diff --git a/backend/internal/cerberus/admin_whitelist_status_test.go b/backend/internal/cerberus/admin_whitelist_status_test.go
new file mode 100644
index 000000000..87ca29bd0
--- /dev/null
+++ b/backend/internal/cerberus/admin_whitelist_status_test.go
@@ -0,0 +1,70 @@
+package cerberus
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+
+ "github.com/Wikid82/charon/backend/internal/config"
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+func newAdminWhitelistTestDB(t *testing.T) *gorm.DB {
+ t.Helper()
+ dsn := fmt.Sprintf("file:admin_whitelist_status_%d?mode=memory&cache=shared", time.Now().UnixNano())
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}))
+ return db
+}
+
+// TestAdminWhitelistStatus_NamedResultOrder pins the result order of adminWhitelistStatus:
+// the first value means "allowed" (client IP is in the admin whitelist) and the second
+// means "configured" (an admin whitelist exists and is non-empty).
+func TestAdminWhitelistStatus_NamedResultOrder(t *testing.T) {
+ t.Run("nil database is neither allowed nor configured", func(t *testing.T) {
+ c := New(config.SecurityConfig{}, nil)
+ allowed, configured := c.adminWhitelistStatus("10.0.0.5")
+ require.False(t, allowed)
+ require.False(t, configured)
+ })
+
+ t.Run("no security config is neither allowed nor configured", func(t *testing.T) {
+ db := newAdminWhitelistTestDB(t)
+ c := New(config.SecurityConfig{}, db)
+ allowed, configured := c.adminWhitelistStatus("10.0.0.5")
+ require.False(t, allowed)
+ require.False(t, configured)
+ })
+
+ t.Run("empty admin whitelist is neither allowed nor configured", func(t *testing.T) {
+ db := newAdminWhitelistTestDB(t)
+ require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: " "}).Error)
+ c := New(config.SecurityConfig{}, db)
+ allowed, configured := c.adminWhitelistStatus("10.0.0.5")
+ require.False(t, allowed)
+ require.False(t, configured)
+ })
+
+ t.Run("configured whitelist with matching IP is allowed and configured", func(t *testing.T) {
+ db := newAdminWhitelistTestDB(t)
+ require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.0/8"}).Error)
+ c := New(config.SecurityConfig{}, db)
+ allowed, configured := c.adminWhitelistStatus("10.0.0.5")
+ require.True(t, allowed)
+ require.True(t, configured)
+ })
+
+ t.Run("configured whitelist without matching IP is not allowed but configured", func(t *testing.T) {
+ db := newAdminWhitelistTestDB(t)
+ require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", Enabled: true, AdminWhitelist: "10.0.0.0/8"}).Error)
+ c := New(config.SecurityConfig{}, db)
+ allowed, configured := c.adminWhitelistStatus("203.0.113.9")
+ require.False(t, allowed)
+ require.True(t, configured)
+ })
+}
diff --git a/backend/internal/cerberus/cerberus.go b/backend/internal/cerberus/cerberus.go
index be01c3107..1966e59e0 100644
--- a/backend/internal/cerberus/cerberus.go
+++ b/backend/internal/cerberus/cerberus.go
@@ -285,7 +285,7 @@ func (c *Cerberus) isAuthenticatedAdmin(ctx *gin.Context) bool {
return ok && id > 0
}
-func (c *Cerberus) adminWhitelistStatus(clientIP string) (configured, allowed bool) {
+func (c *Cerberus) adminWhitelistStatus(clientIP string) (allowed, configured bool) {
if c.db == nil {
return false, false
}
diff --git a/backend/internal/geoip/database.go b/backend/internal/geoip/database.go
new file mode 100644
index 000000000..c892b037f
--- /dev/null
+++ b/backend/internal/geoip/database.go
@@ -0,0 +1,28 @@
+// Package geoip holds the GeoIP database facts shared by the API and the proxy config generator. It has no
+// dependencies on other Charon packages, so both can import it.
+package geoip
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// MissingMarkerName is the file the image build writes beside the database when it could not fetch it.
+const MissingMarkerName = ".missing"
+
+// DatabaseAvailable reports whether the GeoIP database at path can serve lookups. It is available when the GeoIP
+// service has loaded it (loaded), the path names a regular file, and the file name ends in .mmdb.
+func DatabaseAvailable(path string, loaded bool) bool {
+ if !loaded || path == "" || !strings.HasSuffix(filepath.Base(path), ".mmdb") {
+ return false
+ }
+ info, err := os.Stat(filepath.Clean(path)) //nolint:gosec // G703: operator-configured path, read-only stat
+ return err == nil && info.Mode().IsRegular()
+}
+
+// MissingMarkerPath returns the build-time marker path for the database at dbPath. The marker sits in the same
+// directory as the database.
+func MissingMarkerPath(dbPath string) string {
+ return filepath.Join(filepath.Dir(dbPath), MissingMarkerName)
+}
diff --git a/backend/internal/geoip/database_test.go b/backend/internal/geoip/database_test.go
new file mode 100644
index 000000000..be82d0aa0
--- /dev/null
+++ b/backend/internal/geoip/database_test.go
@@ -0,0 +1,43 @@
+package geoip
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDatabaseAvailable(t *testing.T) {
+ dir := t.TempDir()
+ mmdb := filepath.Join(dir, "GeoLite2-Country.mmdb")
+ require.NoError(t, os.WriteFile(mmdb, []byte("db"), 0o600))
+ placeholder := filepath.Join(dir, "GeoLite2-Country.mmdb.placeholder")
+ require.NoError(t, os.WriteFile(placeholder, nil, 0o600))
+ dirNamedLikeDB := filepath.Join(dir, "folder.mmdb")
+ require.NoError(t, os.Mkdir(dirNamedLikeDB, 0o700))
+
+ cases := []struct {
+ name string
+ path string
+ loaded bool
+ want bool
+ }{
+ {name: "loaded file with mmdb name", path: mmdb, loaded: true, want: true},
+ {name: "file present but service not loaded", path: mmdb, loaded: false, want: false},
+ {name: "file missing", path: filepath.Join(dir, "absent.mmdb"), loaded: true, want: false},
+ {name: "placeholder file", path: placeholder, loaded: true, want: false},
+ {name: "empty path", path: "", loaded: true, want: false},
+ {name: "directory with mmdb name", path: dirNamedLikeDB, loaded: true, want: false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, DatabaseAvailable(tc.path, tc.loaded))
+ })
+ }
+}
+
+func TestMissingMarkerPath(t *testing.T) {
+ assert.Equal(t, "/app/data/geoip/.missing", MissingMarkerPath("/app/data/geoip/GeoLite2-Country.mmdb"))
+}
diff --git a/backend/internal/models/access_list.go b/backend/internal/models/access_list.go
index 09a583b74..3c76c3d1e 100644
--- a/backend/internal/models/access_list.go
+++ b/backend/internal/models/access_list.go
@@ -1,6 +1,7 @@
package models
import (
+ "strings"
"time"
)
@@ -20,6 +21,18 @@ type AccessList struct {
UpdatedAt time.Time `json:"updated_at"`
}
+// NormalizeCountryCodes returns the comma-separated country codes trimmed, upper-cased and stripped of empty
+// entries, so the stored value and the generated proxy matchers agree on one canonical form.
+func NormalizeCountryCodes(raw string) string {
+ var codes []string
+ for _, part := range strings.Split(raw, ",") {
+ if code := strings.ToUpper(strings.TrimSpace(part)); code != "" {
+ codes = append(codes, code)
+ }
+ }
+ return strings.Join(codes, ",")
+}
+
// AccessListRule represents a single IP or CIDR rule
type AccessListRule struct {
CIDR string `json:"cidr"` // IP address or CIDR notation
diff --git a/backend/internal/models/access_list_country_test.go b/backend/internal/models/access_list_country_test.go
new file mode 100644
index 000000000..57426f6e7
--- /dev/null
+++ b/backend/internal/models/access_list_country_test.go
@@ -0,0 +1,20 @@
+package models
+
+import "testing"
+
+func TestNormalizeCountryCodes(t *testing.T) {
+ cases := map[string]string{
+ "": "",
+ "us": "US",
+ " us , ca ": "US,CA",
+ "Us,cA,de": "US,CA,DE",
+ "US,,CA,": "US,CA",
+ " , ": "",
+ "GB": "GB",
+ }
+ for raw, want := range cases {
+ if got := NormalizeCountryCodes(raw); got != want {
+ t.Errorf("NormalizeCountryCodes(%q) = %q, want %q", raw, got, want)
+ }
+ }
+}
diff --git a/backend/internal/network/local_networks.go b/backend/internal/network/local_networks.go
new file mode 100644
index 000000000..483b8657f
--- /dev/null
+++ b/backend/internal/network/local_networks.go
@@ -0,0 +1,22 @@
+package network
+
+// localNetworkCIDRs lists the private, loopback and link-local ranges that count as
+// "local network" for access control. Keep it as the single source of truth.
+var localNetworkCIDRs = []string{
+ "10.0.0.0/8", // RFC1918
+ "172.16.0.0/12", // RFC1918
+ "192.168.0.0/16", // RFC1918
+ "127.0.0.0/8", // localhost
+ "169.254.0.0/16", // link-local
+ "fc00::/7", // IPv6 ULA
+ "fe80::/10", // IPv6 link-local
+ "::1/128", // IPv6 localhost
+}
+
+// LocalNetworkCIDRs returns a fresh copy of the local-network CIDR list so callers
+// cannot mutate the shared definition.
+func LocalNetworkCIDRs() []string {
+ out := make([]string, len(localNetworkCIDRs))
+ copy(out, localNetworkCIDRs)
+ return out
+}
diff --git a/backend/internal/network/local_networks_test.go b/backend/internal/network/local_networks_test.go
new file mode 100644
index 000000000..cc9feb3bf
--- /dev/null
+++ b/backend/internal/network/local_networks_test.go
@@ -0,0 +1,30 @@
+package network
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// TestLocalNetworkCIDRs_Pinned pins the exact local-network range list shared by
+// the proxy ACL generator and the access list service.
+func TestLocalNetworkCIDRs_Pinned(t *testing.T) {
+ want := []string{
+ "10.0.0.0/8",
+ "172.16.0.0/12",
+ "192.168.0.0/16",
+ "127.0.0.0/8",
+ "169.254.0.0/16",
+ "fc00::/7",
+ "fe80::/10",
+ "::1/128",
+ }
+ assert.Equal(t, want, LocalNetworkCIDRs())
+}
+
+// TestLocalNetworkCIDRs_ReturnsIndependentCopy guards against callers mutating the shared list.
+func TestLocalNetworkCIDRs_ReturnsIndependentCopy(t *testing.T) {
+ a := LocalNetworkCIDRs()
+ a[0] = "0.0.0.0/0"
+ assert.Equal(t, "10.0.0.0/8", LocalNetworkCIDRs()[0])
+}
diff --git a/backend/internal/services/access_list_country_codes_test.go b/backend/internal/services/access_list_country_codes_test.go
new file mode 100644
index 000000000..2f615a58c
--- /dev/null
+++ b/backend/internal/services/access_list_country_codes_test.go
@@ -0,0 +1,41 @@
+package services
+
+import (
+ "testing"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAccessListService_CountryCodesSavedUpperCase(t *testing.T) {
+ db := setupTestDB(t)
+ service := NewAccessListService(db)
+
+ for _, aclType := range []string{"geo_whitelist", "geo_blacklist"} {
+ t.Run(aclType, func(t *testing.T) {
+ acl := &models.AccessList{
+ Name: "Mixed case " + aclType,
+ Type: aclType,
+ CountryCodes: " us, ca ,de ",
+ Enabled: true,
+ }
+ require.NoError(t, service.Create(acl))
+
+ var stored models.AccessList
+ require.NoError(t, db.First(&stored, acl.ID).Error)
+ require.Equal(t, "US,CA,DE", stored.CountryCodes)
+ })
+ }
+
+ t.Run("update normalizes country codes", func(t *testing.T) {
+ acl := &models.AccessList{Name: "Update case", Type: "geo_blacklist", CountryCodes: "FR", Enabled: true}
+ require.NoError(t, service.Create(acl))
+
+ updates := &models.AccessList{Name: "Update case", Type: "geo_blacklist", CountryCodes: "es,it", Enabled: true}
+ require.NoError(t, service.Update(acl.ID, updates))
+
+ var stored models.AccessList
+ require.NoError(t, db.First(&stored, acl.ID).Error)
+ require.Equal(t, "ES,IT", stored.CountryCodes)
+ })
+}
diff --git a/backend/internal/services/access_list_normalized_country_test.go b/backend/internal/services/access_list_normalized_country_test.go
new file mode 100644
index 000000000..98a12f051
--- /dev/null
+++ b/backend/internal/services/access_list_normalized_country_test.go
@@ -0,0 +1,61 @@
+package services
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+func TestAccessListService_Create_ValidatesNormalizedCountryCodes(t *testing.T) {
+ cases := []struct {
+ name string
+ input string
+ want string
+ }{
+ {name: "surrounding spaces", input: " se , ", want: "SE"},
+ {name: "trailing comma", input: "se,us,", want: "SE,US"},
+ {name: "lower case", input: "ca", want: "CA"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ service := NewAccessListService(setupTestDB(t))
+ acl := &models.AccessList{Name: "Geo", Type: "geo_whitelist", CountryCodes: tc.input, Enabled: true}
+
+ require.NoError(t, service.Create(acl))
+ assert.Equal(t, tc.want, acl.CountryCodes)
+ })
+ }
+}
+
+func TestAccessListService_Create_BlankCountryCodesAreRequired(t *testing.T) {
+ service := NewAccessListService(setupTestDB(t))
+ acl := &models.AccessList{Name: "Geo", Type: "geo_blacklist", CountryCodes: " , ", Enabled: true}
+
+ err := service.Create(acl)
+ require.ErrorContains(t, err, "country codes are required")
+}
+
+func TestAccessListService_Create_RejectsInvalidNormalizedCountryCode(t *testing.T) {
+ service := NewAccessListService(setupTestDB(t))
+ acl := &models.AccessList{Name: "Geo", Type: "geo_blacklist", CountryCodes: " zz , ", Enabled: true}
+
+ err := service.Create(acl)
+ require.ErrorIs(t, err, ErrInvalidCountryCode)
+ require.ErrorContains(t, err, "invalid country code: ZZ")
+}
+
+func TestAccessListService_Update_ValidatesNormalizedCountryCodes(t *testing.T) {
+ service := NewAccessListService(setupTestDB(t))
+ acl := &models.AccessList{Name: "Geo", Type: "geo_whitelist", CountryCodes: "US", Enabled: true}
+ require.NoError(t, service.Create(acl))
+
+ err := service.Update(acl.ID, &models.AccessList{Name: "Geo", Type: "geo_whitelist", CountryCodes: " ca , ", Enabled: true})
+ require.NoError(t, err)
+
+ stored, err := service.GetByID(acl.ID)
+ require.NoError(t, err)
+ assert.Equal(t, "CA", stored.CountryCodes)
+}
diff --git a/backend/internal/services/access_list_rules_required_test.go b/backend/internal/services/access_list_rules_required_test.go
new file mode 100644
index 000000000..80c52cdbd
--- /dev/null
+++ b/backend/internal/services/access_list_rules_required_test.go
@@ -0,0 +1,79 @@
+package services
+
+import (
+ "testing"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAccessListService_RulesRequiredOnSave(t *testing.T) {
+ const oneRule = `[{"cidr":"192.168.1.0/24"}]`
+
+ tests := []struct {
+ name string
+ acl models.AccessList
+ wantErr error
+ }{
+ {
+ name: "enabled allow-list without rules is refused",
+ acl: models.AccessList{Name: "wl", Type: "whitelist", Enabled: true, IPRules: ""},
+ wantErr: ErrAccessListRulesRequired,
+ },
+ {
+ name: "enabled allow-list with empty rule array is refused",
+ acl: models.AccessList{Name: "wl", Type: "whitelist", Enabled: true, IPRules: "[]"},
+ wantErr: ErrAccessListRulesRequired,
+ },
+ {
+ name: "enabled allow-list with local network option needs no rules",
+ acl: models.AccessList{Name: "wl", Type: "whitelist", Enabled: true, LocalNetworkOnly: true},
+ },
+ {
+ name: "enabled allow-list with rules is accepted",
+ acl: models.AccessList{Name: "wl", Type: "whitelist", Enabled: true, IPRules: oneRule},
+ },
+ {
+ name: "disabled allow-list without rules is accepted",
+ acl: models.AccessList{Name: "wl", Type: "whitelist", Enabled: false, IPRules: ""},
+ },
+ {
+ name: "enabled block-list without rules is accepted",
+ acl: models.AccessList{Name: "bl", Type: "blacklist", Enabled: true, IPRules: ""},
+ },
+ {
+ name: "disabled block-list without rules is accepted",
+ acl: models.AccessList{Name: "bl", Type: "blacklist", Enabled: false, IPRules: "[]"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run("create: "+tt.name, func(t *testing.T) {
+ service := NewAccessListService(setupTestDB(t))
+ acl := tt.acl
+ err := service.Create(&acl)
+ if tt.wantErr != nil {
+ require.ErrorIs(t, err, tt.wantErr)
+ return
+ }
+ require.NoError(t, err)
+ })
+ }
+
+ t.Run("update: enabling an allow-list without rules is refused", func(t *testing.T) {
+ service := NewAccessListService(setupTestDB(t))
+ acl := models.AccessList{Name: "wl", Type: "whitelist", Enabled: false, IPRules: oneRule}
+ require.NoError(t, service.Create(&acl))
+
+ err := service.Update(acl.ID, &models.AccessList{Name: "wl", Type: "whitelist", Enabled: true, IPRules: ""})
+ require.ErrorIs(t, err, ErrAccessListRulesRequired)
+ })
+
+ t.Run("update: a disabled allow-list keeps saving without rules", func(t *testing.T) {
+ service := NewAccessListService(setupTestDB(t))
+ acl := models.AccessList{Name: "wl", Type: "whitelist", Enabled: true, IPRules: oneRule}
+ require.NoError(t, service.Create(&acl))
+
+ require.NoError(t, service.Update(acl.ID, &models.AccessList{Name: "wl", Type: "whitelist", Enabled: false, IPRules: ""}))
+ })
+}
diff --git a/backend/internal/services/access_list_service.go b/backend/internal/services/access_list_service.go
index 2a40811f0..d373e40a3 100644
--- a/backend/internal/services/access_list_service.go
+++ b/backend/internal/services/access_list_service.go
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/Wikid82/charon/backend/internal/models"
+ "github.com/Wikid82/charon/backend/internal/network"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -19,23 +20,14 @@ var (
ErrInvalidIPAddress = errors.New("invalid IP address or CIDR")
ErrInvalidCountryCode = errors.New("invalid country code")
ErrAccessListInUse = errors.New("access list is in use by proxy hosts")
+ // ErrAccessListRulesRequired rejects an enabled allow-list that would admit nobody. A block-list
+ // with no rules is allowed because it blocks nobody.
+ ErrAccessListRulesRequired = errors.New("an enabled allow-list needs at least one IP rule or the local network option")
)
// ValidAccessListTypes defines allowed access list types
var ValidAccessListTypes = []string{"whitelist", "blacklist", "geo_whitelist", "geo_blacklist"}
-// RFC1918PrivateNetworks defines private IP ranges
-var RFC1918PrivateNetworks = []string{
- "10.0.0.0/8",
- "172.16.0.0/12",
- "192.168.0.0/16",
- "127.0.0.0/8", // localhost
- "169.254.0.0/16", // link-local
- "fc00::/7", // IPv6 ULA
- "fe80::/10", // IPv6 link-local
- "::1/128", // IPv6 localhost
-}
-
// ISO 3166-1 alpha-2 country codes (comprehensive list for validation)
var validCountryCodes = map[string]bool{
// North America
@@ -95,6 +87,7 @@ func (s *AccessListService) Create(acl *models.AccessList) error {
return err
}
+ acl.CountryCodes = models.NormalizeCountryCodes(acl.CountryCodes)
acl.UUID = uuid.New().String()
return s.db.Create(acl).Error
}
@@ -155,18 +148,27 @@ func (s *AccessListService) Update(id uint, updates *models.AccessList) error {
if err := s.validateAccessList(acl); err != nil {
return err
}
+ acl.CountryCodes = models.NormalizeCountryCodes(acl.CountryCodes)
return s.db.Save(acl).Error
}
-// Delete deletes an access list if not in use
-func (s *AccessListService) Delete(id uint) error {
- // Check if ACL is in use by any proxy hosts
+// InUse reports whether any proxy host uses the access list, so its rules are part of the proxy config.
+func (s *AccessListService) InUse(id uint) (bool, error) {
var count int64
if err := s.db.Model(&models.ProxyHost{}).Where("access_list_id = ?", id).Count(&count).Error; err != nil {
+ return false, fmt.Errorf("count proxy hosts using access list %d: %w", id, err)
+ }
+ return count > 0, nil
+}
+
+// Delete deletes an access list if not in use
+func (s *AccessListService) Delete(id uint) error {
+ inUse, err := s.InUse(id)
+ if err != nil {
return err
}
- if count > 0 {
+ if inUse {
return ErrAccessListInUse
}
@@ -313,8 +315,8 @@ func (s *AccessListService) validateAccessList(acl *models.AccessList) error {
}
// Validate IP rules
+ var rules []models.AccessListRule
if acl.IPRules != "" {
- var rules []models.AccessListRule
if err := json.Unmarshal([]byte(acl.IPRules), &rules); err != nil {
return fmt.Errorf("invalid IP rules JSON: %w", err)
}
@@ -326,14 +328,20 @@ func (s *AccessListService) validateAccessList(acl *models.AccessList) error {
}
}
+ // An enabled allow-list with nothing to admit would deny every request. The local-network option counts as a rule.
+ if acl.Enabled && acl.Type == "whitelist" && !acl.LocalNetworkOnly && len(rules) == 0 {
+ return ErrAccessListRulesRequired
+ }
+
// Validate country codes for geo types
+ // Codes are checked in the same normalized form that gets stored, so stray spaces, case and empty entries are
+ // not reported as invalid codes.
if strings.HasPrefix(acl.Type, "geo_") {
- if acl.CountryCodes == "" {
+ normalized := models.NormalizeCountryCodes(acl.CountryCodes)
+ if normalized == "" {
return errors.New("country codes are required for geo-blocking")
}
- codes := strings.Split(acl.CountryCodes, ",")
- for _, code := range codes {
- code = strings.TrimSpace(strings.ToUpper(code))
+ for _, code := range strings.Split(normalized, ",") {
if !s.isValidCountryCode(code) {
return fmt.Errorf("%w: %s", ErrInvalidCountryCode, code)
}
@@ -392,7 +400,7 @@ func (s *AccessListService) ipMatchesCIDR(ip net.IP, cidr string) bool {
// isPrivateIP checks if an IP is in RFC1918 private ranges
func (s *AccessListService) isPrivateIP(ip net.IP) bool {
- for _, cidr := range RFC1918PrivateNetworks {
+ for _, cidr := range network.LocalNetworkCIDRs() {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
continue
diff --git a/backend/internal/services/access_list_service_inuse_test.go b/backend/internal/services/access_list_service_inuse_test.go
new file mode 100644
index 000000000..918c83925
--- /dev/null
+++ b/backend/internal/services/access_list_service_inuse_test.go
@@ -0,0 +1,21 @@
+package services
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestAccessListService_InUse_CountFailureIsReturned(t *testing.T) {
+ db := setupTestDB(t)
+ service := NewAccessListService(db)
+
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ require.NoError(t, sqlDB.Close())
+
+ inUse, err := service.InUse(1)
+ require.Error(t, err)
+ require.ErrorContains(t, err, "count proxy hosts using access list 1")
+ require.False(t, inUse)
+}
diff --git a/backend/internal/services/access_list_service_test.go b/backend/internal/services/access_list_service_test.go
index 426968ece..7366a2166 100644
--- a/backend/internal/services/access_list_service_test.go
+++ b/backend/internal/services/access_list_service_test.go
@@ -79,6 +79,7 @@ func TestAccessListService_Create(t *testing.T) {
acl := &models.AccessList{
Name: "",
Type: "whitelist",
+ IPRules: testAllowListRules,
Enabled: true,
}
@@ -152,6 +153,7 @@ func TestAccessListService_GetByID(t *testing.T) {
acl := &models.AccessList{
Name: "Test ACL",
Type: "whitelist",
+ IPRules: testAllowListRules,
Enabled: true,
}
err := service.Create(acl)
@@ -179,6 +181,7 @@ func TestAccessListService_GetByUUID(t *testing.T) {
acl := &models.AccessList{
Name: "Test ACL",
Type: "whitelist",
+ IPRules: testAllowListRules,
Enabled: true,
}
err := service.Create(acl)
@@ -227,7 +230,7 @@ func TestAccessListService_List(t *testing.T) {
service := NewAccessListService(db)
// Create multiple ACLs
- acl1 := &models.AccessList{Name: "ACL 1", Type: "whitelist", Enabled: true}
+ acl1 := &models.AccessList{Name: "ACL 1", Type: "whitelist", IPRules: testAllowListRules, Enabled: true}
acl2 := &models.AccessList{Name: "ACL 2", Type: "blacklist", Enabled: true}
err := service.Create(acl1)
@@ -261,6 +264,7 @@ func TestAccessListService_Update(t *testing.T) {
acl := &models.AccessList{
Name: "Original Name",
Type: "whitelist",
+ IPRules: testAllowListRules,
Enabled: true,
}
err := service.Create(acl)
@@ -286,14 +290,14 @@ func TestAccessListService_Update(t *testing.T) {
})
t.Run("fail update on non-existent ACL", func(t *testing.T) {
- updates := &models.AccessList{Name: "Test", Type: "whitelist", Enabled: true}
+ updates := &models.AccessList{Name: "Test", Type: "whitelist", IPRules: testAllowListRules, Enabled: true}
err := service.Update(99999, updates)
assert.Error(t, err)
assert.Equal(t, ErrAccessListNotFound, err)
})
t.Run("fail update with invalid data", func(t *testing.T) {
- updates := &models.AccessList{Name: "", Type: "whitelist", Enabled: true}
+ updates := &models.AccessList{Name: "", Type: "whitelist", IPRules: testAllowListRules, Enabled: true}
err := service.Update(acl.ID, updates)
assert.Error(t, err)
assert.Contains(t, err.Error(), "name is required")
@@ -305,7 +309,7 @@ func TestAccessListService_Delete(t *testing.T) {
service := NewAccessListService(db)
t.Run("delete successfully", func(t *testing.T) {
- acl := &models.AccessList{Name: "To Delete", Type: "whitelist", Enabled: true}
+ acl := &models.AccessList{Name: "To Delete", Type: "whitelist", IPRules: testAllowListRules, Enabled: true}
err := service.Create(acl)
assert.NoError(t, err)
@@ -326,7 +330,7 @@ func TestAccessListService_Delete(t *testing.T) {
t.Run("fail delete ACL in use", func(t *testing.T) {
// Create ACL
- acl := &models.AccessList{Name: "In Use", Type: "whitelist", Enabled: true}
+ acl := &models.AccessList{Name: "In Use", Type: "whitelist", IPRules: testAllowListRules, Enabled: true}
err := service.Create(acl)
assert.NoError(t, err)
@@ -471,7 +475,7 @@ func TestAccessListService_TestIP(t *testing.T) {
})
t.Run("fail with invalid IP", func(t *testing.T) {
- acl := &models.AccessList{Name: "Test", Type: "whitelist", Enabled: true}
+ acl := &models.AccessList{Name: "Test", Type: "whitelist", IPRules: testAllowListRules, Enabled: true}
err := service.Create(acl)
assert.NoError(t, err)
@@ -496,6 +500,9 @@ func TestAccessListService_GetTemplates(t *testing.T) {
assert.Contains(t, first, "type")
}
+// testAllowListRules is a rule set that keeps an enabled allow-list valid in fixtures that are not about rules.
+const testAllowListRules = `[{"cidr":"192.168.1.0/24"}]`
+
func TestAccessListService_Validation(t *testing.T) {
db := setupTestDB(t)
service := NewAccessListService(db)
@@ -624,6 +631,7 @@ func TestAccessListService_ListFunction(t *testing.T) {
acl1 := &models.AccessList{
Name: "List 1",
Type: "whitelist",
+ IPRules: testAllowListRules,
Enabled: true,
}
acl2 := &models.AccessList{
diff --git a/backend/internal/services/geoip_load_keeps_database_test.go b/backend/internal/services/geoip_load_keeps_database_test.go
new file mode 100644
index 000000000..eedbb01f9
--- /dev/null
+++ b/backend/internal/services/geoip_load_keeps_database_test.go
@@ -0,0 +1,20 @@
+package services
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestGeoIPService_Load_FailureKeepsLoadedDatabase(t *testing.T) {
+ missing := filepath.Join(t.TempDir(), "missing", "GeoLite2-Country.mmdb")
+ svc := &GeoIPService{dbPath: missing, db: &fakeGeoIPReader{isoCode: "SE"}}
+
+ require.Error(t, svc.Load(), "a file that cannot be opened is not loaded")
+ require.True(t, svc.IsLoaded(), "the database loaded before the failed reload stays in use")
+
+ country, err := svc.LookupCountry("8.8.8.8")
+ require.NoError(t, err)
+ require.Equal(t, "SE", country)
+}
diff --git a/backend/internal/services/geoip_service.go b/backend/internal/services/geoip_service.go
index fac74a807..60df2d5c6 100644
--- a/backend/internal/services/geoip_service.go
+++ b/backend/internal/services/geoip_service.go
@@ -3,6 +3,7 @@ package services
import (
"errors"
+ "fmt"
"net"
"net/netip"
"sync"
@@ -31,6 +32,13 @@ type geoIPCountryReader interface {
Close() error
}
+// NewUnloadedGeoIPService returns a service bound to dbPath that has not opened the database. Load opens it once the
+// file exists, so a database added after startup is picked up without restarting. Every consumer can hold the same
+// service from startup on.
+func NewUnloadedGeoIPService(dbPath string) *GeoIPService {
+ return &GeoIPService{dbPath: dbPath}
+}
+
// NewGeoIPService creates a new GeoIPService and loads the database.
// Returns an error if the database cannot be loaded.
func NewGeoIPService(dbPath string) (*GeoIPService, error) {
@@ -42,20 +50,19 @@ func NewGeoIPService(dbPath string) (*GeoIPService, error) {
}
// Load opens or reloads the GeoIP database.
+// The new file is opened before the current one is replaced, so a failed reload keeps the database already in use.
// This method is thread-safe and can be called to hot-reload the database.
func (s *GeoIPService) Load() error {
+ db, err := geoip2.Open(s.dbPath)
+ if err != nil {
+ return fmt.Errorf("open geoip database %s: %w", s.dbPath, err)
+ }
+
s.mu.Lock()
defer s.mu.Unlock()
- // Close existing database if open
if s.db != nil {
_ = s.db.Close()
- s.db = nil
- }
-
- db, err := geoip2.Open(s.dbPath)
- if err != nil {
- return err
}
s.db = db
return nil
diff --git a/backend/internal/services/geoip_service_unloaded_test.go b/backend/internal/services/geoip_service_unloaded_test.go
new file mode 100644
index 000000000..a1a0dcc03
--- /dev/null
+++ b/backend/internal/services/geoip_service_unloaded_test.go
@@ -0,0 +1,23 @@
+package services
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewUnloadedGeoIPService_LoadsOnceFileAppears(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ svc := NewUnloadedGeoIPService(path)
+ require.False(t, svc.IsLoaded())
+ require.Equal(t, path, svc.GetDatabasePath())
+
+ require.Error(t, svc.Load(), "a missing database file cannot be loaded")
+ require.False(t, svc.IsLoaded())
+
+ require.NoError(t, os.WriteFile(path, []byte("not a database"), 0o600))
+ require.Error(t, svc.Load(), "a file the reader cannot open is not loaded")
+ require.False(t, svc.IsLoaded())
+}
diff --git a/docs/cerberus.md b/docs/cerberus.md
index 53f092511..ac65f149a 100644
--- a/docs/cerberus.md
+++ b/docs/cerberus.md
@@ -256,13 +256,23 @@ Each `AccessList` defines:
Multiple ACLs can be assigned to a proxy host. The first denial wins.
+**Required entries:**
+
+- An **enabled allow list** (Whitelist or Geo Whitelist) must contain at least one IP address, range or country. Saving an enabled allow list with no entries is refused with the error `access_list_rules_required`. An allow list with no entries that is not enabled is still saved.
+- A **block list** (Blacklist or Geo Blacklist) may be empty. An empty block list blocks nobody.
+
+**Refused requests:** When a list refuses a request, the response is a 403 (or 429 for rate limits) and still carries the security headers from the host's profile. See [HTTP Security Headers](features/security-headers.md).
+
### GeoIP Database
-Uses MaxMind GeoLite2-Country database:
+Country lists (Geo Whitelist and Geo Blacklist) need the MaxMind GeoLite2-Country database. The proxy reads it to find the country of each visitor.
-- Path configured via `CHARON_GEOIP_DB_PATH`
-- Default: `/app/data/GeoLite2-Country.mmdb` (Docker)
-- Update monthly from MaxMind for accuracy
+- **Where it comes from:** The Docker image tries to download the database when it is built. If the download fails, the image is still built without it.
+- **Default location:** `/app/data/geoip/GeoLite2-Country.mmdb`.
+- **Custom location:** Set `CHARON_GEOIP_DB_PATH` to the full path of the `.mmdb` file. The file name must end in `.mmdb`. A file with any other ending is treated as unavailable.
+- **When the database is missing:** Country lists refuse every request until a database is provided. The build log shows a warning, and the Security page shows a note. Your other lists and sites are not affected.
+- **After adding or replacing the file:** Restart Charon, or send `POST /api/v1/security/geoip/reload`. The proxy then applies your country lists again.
+- **Updates:** Download a new copy from MaxMind about once a month to keep country data accurate.
---
diff --git a/docs/features/access-control.md b/docs/features/access-control.md
index 3291118cd..af55adcf3 100644
--- a/docs/features/access-control.md
+++ b/docs/features/access-control.md
@@ -62,6 +62,19 @@ Common configurations:
- **Block high-risk regions** — Deny specific countries, allow rest
- **Compliance zones** — Allow only EU countries for GDPR compliance
+**Country lists need a GeoIP database.** Charon uses a MaxMind country database to find where each visitor is from. The Docker image tries to include it, but the download can fail. If the database is missing, country lists refuse every request until one is provided:
+
+1. Get the MaxMind GeoLite2-Country database file (it ends in `.mmdb`).
+2. Make the file available to Charon and set the `CHARON_GEOIP_DB_PATH` environment variable to its full path. Your Docker Compose file sets environment variables.
+3. Restart Charon, or send `POST /api/v1/security/geoip/reload`. Charon then applies your country lists again.
+
+The Security page shows a note while the database is missing. Your IP lists and your other sites keep working.
+
+### Entries Required for Allow Lists
+
+- An **enabled allow list** needs at least one entry, such as an IP address, an IP range or a country. Charon will not save an enabled allow list that is empty.
+- A **block list** can be empty. An empty block list blocks nobody.
+
### Applying to Proxy Hosts
1. Edit your proxy host
@@ -80,6 +93,10 @@ Each proxy host can have one Access List assigned. Create multiple lists for dif
4. Apply default action
```
+## Refused Requests
+
+When an access list refuses a visitor, the visitor sees a 403 page (or a 429 page for rate limits). That page still includes the security headers you chose for the site. See [HTTP Security Headers](security-headers.md).
+
## Best Practices
| Scenario | Recommendation |
diff --git a/docs/features/security-headers.md b/docs/features/security-headers.md
index 1820456c9..baa8ee993 100644
--- a/docs/features/security-headers.md
+++ b/docs/features/security-headers.md
@@ -100,6 +100,7 @@ Some apps send their own security headers. Here is what happens when a profile i
- **Report-only CSP keeps your app's own policy.** If your profile uses a "report only" Content-Security-Policy and your app sends its own enforcing one, both reach the browser. Your app's policy keeps working.
- **HSTS: the profile beats the older per-host setting.** If both are set, the profile's Strict-Transport-Security is the one that is used.
- **Error pages are covered too.** When Charon shows its own error page (for example a 502 when your app is down), the profile's security headers are included.
+- **Refused requests are covered too.** When an access list or rate limit turns a visitor away (403 or 429), the response includes the profile's security headers.
## Security Score Calculator
diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md
index ddbd6495f..256f9c3de 100644
--- a/docs/reports/qa_report.md
+++ b/docs/reports/qa_report.md
@@ -1,77 +1,92 @@
-# QA Report: Route Handling in the Proxy Layer
+# QA Report: Access-List Enforcement
-- Branch: `fix/proxy-route-handling` (3 commits over `origin/development` `d7a3af60a`, HEAD `17eef751c`)
-- Scope: how the proxy configuration generator builds routes for a proxy host (`backend/internal/caddy/config.go`), the unit tests that pin those routes, one Playwright case in `tests/security-enforcement/acl-enforcement.spec.ts`, and the matching `ARCHITECTURE.md` section.
-- Change type: `fix:` (security scope). CodeQL Go was run locally as the spec requires; Trivy is deferred to CI (no dependency or image input changed).
+- Branch: `fix/access-list-enforcement` (HEAD `16c402eec`)
+- Scope: country lists in the proxy (GeoIP handler and database state), allow-lists without usable rules, the ACL switch check on every settings path, security headers on refused requests, the shared local network ranges, the image build step for the GeoIP database, the related UI messages, locale keys, E2E specs and docs (spec `docs/plans/cerberus_hardening_batch6_spec.md` section 4). This pass re-verifies the follow-up commits: country codes stored in one case, list edits applied to the proxy, GeoIP reload reaching the proxy (also when the database was missing at startup), and the image keeping a downloaded database whose checksum is out of date.
+- Image under test: `charon:local` rebuilt after the last application commit.
- Date: 2026-11-09
## Verdict
-**PASS. No blockers.** Every gate run locally passed.
+**APPROVED.** All earlier blocking items are resolved, patch coverage is back above the target (99.6%), and every behaviour check passes in throwaway containers. Three smaller findings (F2, F3, and the coverage gaps) were fixed after the re-check. One high finding in existing code (F1) is tracked separately as issue #1891 and ships as its own change.
-## Generated Routes (behaviour)
+## Blocking Items
-For each enabled proxy host the generator now emits:
+| ID | Item | Evidence | Remediation |
+| --- | --- | --- | --- |
+| B1 (resolved) | Patch coverage was 97.7% (260/266 backend lines) | `scripts/local-patch-report.sh` (strict). Uncovered: `backend/internal/services/access_list_service.go:160-161` (count error in `InUse`), `backend/internal/api/handlers/access_list_handler.go:44-45` (`InUse` error in `applyIfInUse`), `backend/internal/api/routes/routes.go:951` (database file present but not loadable at startup), `backend/internal/caddy/config.go:1008` (country list with no codes) | Add unit tests: `InUse` with a closed DB; `Update` on a handler whose `InUse` fails (expect 500, nothing applied); route registration with an unreadable `.mmdb` at `CHARON_GEOIP_DB_PATH` (service exists, not loaded); `buildACLHandler` for a `geo_` list with empty `country_codes` (pins the generated expression) |
-1. One route per custom location, matching the location path. Handlers: security pre-handlers, host-level handlers, then the reverse proxy.
-2. **Only when the host's main upstream is Charon itself** (same detection the generator already uses for the proxy hop headers; upstreams marked remote never qualify): a recovery route matching exactly `/api/v1/emergency/security-reset` and `/emergency/security-reset`, with no wildcard. Handlers: host-level handlers, then the reverse proxy. The reset endpoint performs its own token and management-network checks.
-3. The main route, matching the host with no path matcher. Handlers: security pre-handlers, host-level handlers, then the reverse proxy.
+## Earlier Items: Status
-Every other path, including everything else under `/emergency/` and `/api/v1/emergency/` (token endpoints included), is served by the main route or a location route and runs the security pre-handlers first. Hosts whose upstream is not Charon get no recovery route.
-
-Route table observed in the throwaway container (three hosts: allow-listed host, allow-listed host proxying Charon, WAF-only host):
-
-| Host | Path matcher | Handlers |
-|---|---|---|
-| WAF-only host | none | waf, headers, headers, vars, reverse_proxy |
-| Host proxying Charon | `/api/v1/emergency/security-reset`, `/emergency/security-reset` | headers, headers, vars, reverse_proxy |
-| Host proxying Charon | none | waf, access list, headers, headers, vars, reverse_proxy |
-| Allow-listed host | none | waf, access list, headers, headers, vars, reverse_proxy |
+| ID | Earlier item | Status | Evidence |
+| --- | --- | --- | --- |
+| Lint | `make lint-backend` reported 1 issue | Resolved | 0 issues |
+| Reload | A replaced database did not reach the proxy | Resolved | Container check (b) below |
+| Docs | Docs pointed to a reload control that does not exist | Resolved | Both pages now say: restart Charon or send `POST /api/v1/security/geoip/reload`; the reload works when the database was missing at startup (check (b)) |
+| Case | Country codes saved in lowercase were not matched | Resolved | Container check (a) below |
+| Edits | Editing an attached list did not reach the proxy | Resolved | Container check (c) below |
+| R1 | Images could ship without a database after an upstream update | Resolved | A checksum mismatch now keeps the download and prints a build warning; only a failed or empty download drops the file and writes the marker. The local build verified the checksum and bundled the database |
+| O1 | Startup line said lists "stay refused" after a database loaded | Resolved | The message now depends on whether the database loaded |
+| O2 | GeoIP status message said "available" while unavailable | Resolved | Unavailable state reports `message: "GeoIP database unavailable"` with `reason: geoip_unavailable` |
## Gate Results
| Gate | Result | Evidence |
-|---|---|---|
-| Diff review against the design (spec section 3, D1) | PASS | Recovery route only behind the self-upstream check; exact paths from one `resetRecoveryPaths` list; locations and main route unchanged and still start with the security pre-handlers; remote and agent upstreams excluded by the existing remote flag; redirect and location tests updated, not removed |
-| `go build ./...`, `go vet ./...` | PASS | Clean |
-| `go test ./internal/caddy/... ./internal/api/... ./internal/cerberus/... -count=1` | PASS | All packages `ok` (see note on one non-reproducible handlers failure below) |
-| Backend coverage (`scripts/go-test-coverage.sh`) | PASS | Statement 93.2%, line 92.1% (gate 87%). Zero failures |
-| Local patch coverage (`scripts/local-patch-report.sh`, strict) | PASS | Backend 100% (20/20 changed lines), overall 100% |
-| `make lint-fast` | PASS | 0 issues (backend and agent) |
-| `make lint-backend` (golangci-lint v2.15.0, full) | PASS | 0 issues |
-| GORM security scan | Not triggered | No model, query or migration changes |
-| CodeQL Go (local, CLI 2.27.2, `go-security-and-quality`) | PASS | 4 results, all matched by non-expired entries in `.github/codeql/codeql-suppressions.yml`; 0 blocking; extraction parity 394/394. None in the diff |
-| Container gate (throwaway container built from this branch, own network, loopback-only ports, removed afterwards) | PASS | See below |
-| Playwright `acl-enforcement.spec.ts --project=security-tests --retries=0` | PASS | 9 passed (5.2 s) |
-| `npm run lint:e2e` | PASS | 124 warnings, 0 errors (budget 124); none in the touched spec |
-
-### Container gate
-
-Requests were sent from a separate container whose address is on no list.
-
-| Check | Request | Result |
-|---|---|---|
-| (a) Allow-listed host refuses a non-listed client | `/`, `/emergency/x`, `/emergency/..;/admin`, `/api/v1/emergency/token/x`, `POST /api/v1/emergency/security-reset`, `POST /emergency/security-reset` | 403 on every path |
-| (b) Host proxying Charon, other paths | `/`, `/emergency/x`, `/emergency/..;/admin`, `/api/v1/emergency/token/x`, `/api/v1/emergency/token/status` | 403 on every path |
-| (b) Host proxying Charon, reset path without a token | `POST /api/v1/emergency/security-reset` | 401 from the reset handler |
-| (b) Host proxying Charon, reset path with a valid token | `POST /api/v1/emergency/security-reset` | 200; modules disabled as expected; the allow-listed host then stops refusing |
-| (b) Host proxying Charon, trailing-slash form | `POST /api/v1/emergency/security-reset/` | 403 (main route) |
-| (c) WAF host, test payload under the prefix | `/emergency/x?q=`, `/api/v1/emergency/`, `POST /api/v1/emergency/security-reset?q=` | 403 on every path; `/` without the payload reaches the (absent) upstream: 502 |
-
-On the host proxying Charon, the exact reset path is answered by Charon itself (route 2 above), so it is not subject to the proxy WAF or access list on that one host; the endpoint's own checks apply. This is the approved design (D1).
-
-## CodeQL suppression re-review (D11)
-
-| Rule | Location | Review by | Verdict |
-|---|---|---|---|
-| `go/cookie-secure-not-set` | `backend/internal/api/handlers/auth_handler.go:201` | 2026-12-05 | Still justified. `Secure` is false only for a plain-HTTP request whose connecting peer is local or private; every other request gets `Secure=true`. Behaviour and doc comment unchanged |
-| `go/log-injection` (two taint paths, one sink) | `backend/internal/api/handlers/remote_server_handler.go:148` | 2026-11-27 | Still justified. The logged value is a `uint` ID; `RemoteServer.ID` is `json:"-"`, so the request body cannot set it |
-| `go/log-injection` | `backend/internal/services/uptime_service.go:1551` | 2026-11-27 | Still justified. Logs the `uint remoteServerID` argument only |
-
-None has expired. Two entries reach their review date on 2026-11-27 and need renewal (or a fix) before then.
+| --- | --- | --- |
+| `make lint-backend` | PASS | 0 issues |
+| `go build ./...` | PASS | Clean |
+| Scoped `go test` (caddy, services, models, handlers, routes) | PASS | All packages ok |
+| Backend coverage (`scripts/go-test-coverage.sh`) | PASS | Statement 93.3%, line 92.2% (gate 87%); zero failures |
+| Frontend coverage (`scripts/frontend-test-coverage.sh`) | PASS | Lines 92.52%, statements 91.62%, branches 86.33%, functions 88.95%; 331 files, 4405 tests passed, 2 todo |
+| Local patch coverage (`scripts/local-patch-report.sh`, strict) | PASS | Overall 99.6% (281/282), backend 99.6% (275/276), frontend 100% (6/6) |
+| Playwright `acl-enforcement`, `security-headers-enforcement`, `rate-limit-enforcement` (`--project=security-tests --retries=0`) | PASS | 20 passed (11.1 s) on the rebuilt image |
+| E2E image rebuild (`docker-rebuild-e2e`) | PASS | Build log: GeoIP downloaded and checksum verified |
+
+CodeQL, Trivy and the GORM scan were run in the previous pass and passed. The follow-up commits are fixes with no new feature surface, so they are left to CI.
+
+## Container Checks
+
+Throwaway containers from `charon:local` with their own volumes (no tmpfs over `/app/data`), the API bound to loopback only, an admin allow-range for the API client, and three internal Docker networks whose subnets cover addresses in the MaxMind data. The bundled database resolved the clients as GB, SE and US. Each client request came from a separate container with a fixed source address. All containers, networks and volumes were removed afterwards.
+
+### (a) Country codes saved in lowercase (PASS)
+
+Lists created through the API with `country_codes: "gb"` (allow-list) and `"se"` (block-list) were stored as `GB` and `SE`, and the proxy matchers used the upper-case codes.
+
+| Client | Allow-list host (GB) | Block-list host (SE) |
+| --- | --- | --- |
+| GB | 200 | 200 |
+| SE | 403 | 403 |
+| US | 403 | 200 |
+
+### (b) GeoIP reload reaches the proxy (PASS)
+
+- **Replaced database at the same path:** the file was replaced with a valid country database that holds no addresses. Before the reload the proxy kept the old answers. After `POST /security/geoip/reload` (200) the GB client was refused by the GB allow-list and the SE client passed the SE block-list. Replacing the bundled database again and reloading restored the original answers. The container did not restart.
+- **Database missing at startup, then provided:** a container started with `CHARON_GEOIP_DB_PATH` pointing to a missing file refused every client on both country hosts. After copying the database to that path, the proxy still refused until the reload. `POST /security/geoip/reload` returned 200 with `loaded: true`, the status showed `available: true` with no reason, the `geoip2` app appeared in the proxy config, and the hosts answered as in check (a). No restart was needed.
+
+### (c) Editing an attached list applies at once (PASS)
+
+With no other apply in between:
+
+- Changing the attached country allow-list from `gb` to `se` through `PUT /access-lists/:id`: GB 403, SE 200, US 403. Changing it back restored GB 200.
+- Changing the attached IP allow-list from the GB subnet to the SE subnet: GB 403, SE 200. Changing it back restored GB 200.
+- Editing a list that no host uses returned 200 and did not apply the proxy config.
+
+### (d) Re-run of earlier checks (PASS)
+
+- **Database absent:** every client got 403 with `Access denied: Geographic restriction` and the profile security headers on both country hosts. No `geoip2` app in the proxy config. `/security/status` showed `acl.reason: geoip_unavailable`; `/security/geoip/status` showed `available: false`, the same reason and `message: "GeoIP database unavailable"`. One Warn per apply naming both lists.
+- **Allow-list without usable rules:** an attached IP allow-list rewritten in SQLite to `[]`, then to text that cannot be parsed, each followed by a restart. All clients got 403 both times, with one Warn per apply naming the cause (`hosts: 1`).
+
+## Findings
+
+| ID | Severity | Finding | Evidence | Suggested follow-up |
+| --- | --- | --- | --- | --- |
+| F1 | High (existing code, not caused by this branch) | An IP block-list refuses every visitor, not only the listed addresses, when an admin allow-range is set | `backend/internal/caddy/config.go` around line 1139 (`buildACLHandler`, `blacklist` branch) puts the listed ranges and the admin exclusion in two separate matcher sets of one route. The proxy treats separate matcher sets as alternatives, so any address outside the admin range matches. Container check: block-list for the GB subnet, admin range `172.16.0.0/12`; GB, SE and US clients all got 403 | Put both conditions in one matcher set (`remote_ip` plus `not`), add a test that pins it, and cover it in `acl-enforcement.spec.ts`. Tracked as #1891 and fixed in a separate change |
+| F2 (resolved) | Low | A reload with a file that cannot be opened discards the loaded database, while the proxy keeps the old data until the next apply | The reload returned 500 and the status changed to unavailable at once, but country hosts kept answering from the old data. The next unrelated apply then refused every client on those hosts | Open the new file before closing the old one and keep the old database on failure, or apply after a failed reload so the status and the proxy agree |
+| F3 (resolved) | Low | A country list with an empty entry (for example a trailing comma) is rejected | `country_codes: " se , "` returned 400 with the message "invalid country code" and an empty code | Normalize before validating, so empty entries are dropped |
+| F4 | Info | The `GeoIP service not initialized` branch of the reload handler can no longer be reached in the running app | The service is now always created at startup | Keep it as a guard or remove it |
+| F5 | Low (out of scope) | Creating a security header profile with `hsts_enabled`, `xss_protection` or `x_content_type_options` set to `false` stores `true` | The model's `gorm:"default:true"` replaces a zero value on create | File an issue; create with a field selection or pointer fields |
+| F6 | Info | The spec and `CLAUDE.md` refer to `make trivy`, which does not exist | No such Makefile target | Add the target or update the references |
## Notes
-- **Non-reproducible test failure:** the first scoped run reported `FAIL` for `internal/api/handlers` (209 s) without the failing test in the captured tail. The package then passed twice (a standalone rerun and the full coverage run). The package has no changes in this branch. Recorded for follow-up if it recurs.
-- **Branch base moved during the run:** the branch was rebased onto `origin/development` (`d7a3af60a`). The only tree difference from the earlier HEAD is one CI skill script, so results apply to the current HEAD.
-- **Wording:** commit subjects, the commit bodies (empty), code comments, test names and this report describe behaviour only.
+- The build now uses the checksum as a warning only, so any non-empty download is bundled. A download that is not a valid database is reported at runtime as unavailable, and country lists refuse requests until a valid file is provided.
+- The live `charon` containers were not touched; the shared `charon-e2e` container was used only by the image rebuild and the targeted Playwright run.
diff --git a/frontend/src/__tests__/securityEnforcementLocales.test.ts b/frontend/src/__tests__/securityEnforcementLocales.test.ts
new file mode 100644
index 000000000..6a2497dcd
--- /dev/null
+++ b/frontend/src/__tests__/securityEnforcementLocales.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from 'vitest'
+
+/** Shape of a locale file as far as this test reads it. */
+type LocaleFile = {
+ security?: { enforcement?: { acl?: Record } }
+ accessLists?: { errors?: Record }
+}
+
+/** Every locale the app bundles. A new locale file must be counted here, so it cannot skip the key checks. */
+const EXPECTED_LOCALE_COUNT = 28
+
+const localeFiles = import.meta.glob('../locales/*/translation.json', { import: 'default', eager: true }) as Record<
+ string,
+ LocaleFile
+>
+
+const NEW_KEYS: ReadonlyArray unknown]> = [
+ ['security.enforcement.acl.geoip_unavailable', (tree) => tree.security?.enforcement?.acl?.geoip_unavailable],
+ ['accessLists.errors.access_list_rules_required', (tree) => tree.accessLists?.errors?.access_list_rules_required],
+]
+
+describe('access-list enforcement messages in every locale', () => {
+ it(`bundles exactly ${EXPECTED_LOCALE_COUNT} locale files`, () => {
+ expect(Object.keys(localeFiles)).toHaveLength(EXPECTED_LOCALE_COUNT)
+ })
+
+ it.each(
+ Object.entries(localeFiles).flatMap(([path, tree]) => NEW_KEYS.map(([name, read]) => [path, tree, name, read] as const)),
+ )('%s has a non-empty %s', (_path, tree, _name, read) => {
+ const text = read(tree)
+ expect(typeof text === 'string' && text.trim().length > 0).toBe(true)
+ })
+})
diff --git a/frontend/src/api/__tests__/errorBody.accessList.test.ts b/frontend/src/api/__tests__/errorBody.accessList.test.ts
new file mode 100644
index 000000000..a77ae54a3
--- /dev/null
+++ b/frontend/src/api/__tests__/errorBody.accessList.test.ts
@@ -0,0 +1,34 @@
+import { AxiosError, type AxiosResponse } from 'axios'
+import { describe, it, expect } from 'vitest'
+
+import { accessListErrorMessage } from '../errorBody'
+
+import type { TFunction } from 'i18next'
+
+// Stand-in for i18next's t(): echoes the key so assertions name the message used.
+const t = ((key: string) => `translated:${key}`) as unknown as TFunction
+
+const axiosError = (data: unknown): AxiosError => {
+ const response = { status: 400, statusText: 'Bad Request', headers: {}, config: {}, data } as AxiosResponse
+ return new AxiosError('Request failed with status code 400', 'ERR_BAD_REQUEST', undefined, undefined, response)
+}
+
+describe('accessListErrorMessage', () => {
+ it('maps access_list_rules_required to its translated text', () => {
+ expect(accessListErrorMessage(axiosError({ error_code: 'access_list_rules_required' }), t)).toBe(
+ 'translated:accessLists.errors.access_list_rules_required',
+ )
+ })
+
+ it('returns null for a code it does not know', () => {
+ expect(accessListErrorMessage(axiosError({ error_code: 'crowdsec_busy' }), t)).toBeNull()
+ })
+
+ it('returns null when the response has no error code', () => {
+ expect(accessListErrorMessage(axiosError({ error: 'bad request' }), t)).toBeNull()
+ })
+
+ it('returns null for a non-axios error', () => {
+ expect(accessListErrorMessage(new Error('network'), t)).toBeNull()
+ })
+})
diff --git a/frontend/src/api/errorBody.ts b/frontend/src/api/errorBody.ts
index bfaf0bc8d..409e53c44 100644
--- a/frontend/src/api/errorBody.ts
+++ b/frontend/src/api/errorBody.ts
@@ -98,3 +98,26 @@ export function wafErrorMessage(err: unknown, t: TFunction, fallback: string): s
const code = getApiErrorCode(err, WAF_CODED_MESSAGES)
return code ? t(WAF_CODE_MESSAGE_KEYS[code]) : fallback
}
+
+/**
+ * Coded access-list save refusals with a translated text of their own, keyed by the server's `error_code`.
+ * Every other code keeps the generic error toast.
+ */
+const ACCESS_LIST_CODE_MESSAGE_KEYS = {
+ access_list_rules_required: 'accessLists.errors.access_list_rules_required',
+} as const
+
+type AccessListCodedMessage = keyof typeof ACCESS_LIST_CODE_MESSAGE_KEYS
+
+const ACCESS_LIST_CODED_MESSAGES = Object.keys(ACCESS_LIST_CODE_MESSAGE_KEYS) as AccessListCodedMessage[]
+
+/**
+ * Translated text for a coded access-list save refusal, for display inside the form.
+ * @param err - The value thrown by the request
+ * @param t - The translation function of the calling component or hook
+ * @returns The text, or null when the refusal has no text of its own
+ */
+export function accessListErrorMessage(err: unknown, t: TFunction): string | null {
+ const code = getApiErrorCode(err, ACCESS_LIST_CODED_MESSAGES)
+ return code ? t(ACCESS_LIST_CODE_MESSAGE_KEYS[code]) : null
+}
diff --git a/frontend/src/api/security.ts b/frontend/src/api/security.ts
index 7458b02da..ef33b4a0e 100644
--- a/frontend/src/api/security.ts
+++ b/frontend/src/api/security.ts
@@ -29,7 +29,7 @@ export interface SecurityStatus {
} & SecurityEnforcement
acl: {
enabled: boolean
- }
+ } & SecurityEnforcement
}
/**
diff --git a/frontend/src/components/AccessListForm.tsx b/frontend/src/components/AccessListForm.tsx
index 4a57eed16..1c979010c 100644
--- a/frontend/src/components/AccessListForm.tsx
+++ b/frontend/src/components/AccessListForm.tsx
@@ -19,6 +19,8 @@ interface AccessListFormProps {
onDelete?: () => void;
isLoading?: boolean;
isDeleting?: boolean;
+ /** Translated text for a refused save, shown above the actions. */
+ saveErrorMessage?: string | null;
}
export interface AccessListFormData {
@@ -74,7 +76,15 @@ const COUNTRIES = [
{ code: 'VN', name: 'Vietnam' },
];
-export function AccessListForm({ initialData, onSubmit, onCancel, onDelete, isLoading, isDeleting }: AccessListFormProps) {
+export function AccessListForm({
+ initialData,
+ onSubmit,
+ onCancel,
+ onDelete,
+ isLoading,
+ isDeleting,
+ saveErrorMessage,
+}: AccessListFormProps) {
const [formData, setFormData] = useState({
name: initialData?.name || '',
description: initialData?.description || '',
@@ -530,6 +540,12 @@ export function AccessListForm({ initialData, onSubmit, onCancel, onDelete, isLo
)}
+ {saveErrorMessage && (
+
+ {saveErrorMessage}
+
+ )}
+
{/* Actions */}
diff --git a/frontend/src/components/SecurityEnforcementNote.tsx b/frontend/src/components/SecurityEnforcementNote.tsx
index 69ee58b89..3e25a4e98 100644
--- a/frontend/src/components/SecurityEnforcementNote.tsx
+++ b/frontend/src/components/SecurityEnforcementNote.tsx
@@ -3,12 +3,13 @@ import { useTranslation } from 'react-i18next'
import type { SecurityEnforcement } from '../api/security'
/** Modules that report whether the proxy applies them. */
-export type EnforcementModuleName = 'waf' | 'rate_limit'
+export type EnforcementModuleName = 'waf' | 'rate_limit' | 'acl'
/** Reasons the backend reports per module. Any other reason shows the generic line. */
const KNOWN_REASONS: Record
= {
waf: ['no_ruleset', 'ruleset_not_selected', 'ruleset_unavailable', 'ruleset_empty'],
rate_limit: ['thresholds_unset'],
+ acl: ['geoip_unavailable'],
}
/** Translation key for a module's enforcement reason, or the generic line for an unknown reason. */
diff --git a/frontend/src/components/__tests__/AccessListForm.saveError.test.tsx b/frontend/src/components/__tests__/AccessListForm.saveError.test.tsx
new file mode 100644
index 000000000..ab4ee438b
--- /dev/null
+++ b/frontend/src/components/__tests__/AccessListForm.saveError.test.tsx
@@ -0,0 +1,39 @@
+import { render, screen } from '@testing-library/react'
+import { describe, it, expect, vi } from 'vitest'
+
+import { AccessListForm } from '../AccessListForm'
+
+vi.mock('../../api/system', () => ({
+ getMyIP: vi.fn(),
+}))
+
+globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+}))
+
+describe('AccessListForm save error', () => {
+ it('shows the save refusal above the actions', () => {
+ render(
+ ,
+ )
+ const alert = screen.getByRole('alert')
+ expect(alert).toHaveTextContent('An enabled allow-list needs at least one rule.')
+ expect(screen.getByTestId('access-list-save-error')).toBe(alert)
+ })
+
+ it('shows no alert when there is no save refusal', () => {
+ render()
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ })
+
+ it('shows no alert when the message is omitted', () => {
+ render()
+ expect(screen.queryByTestId('access-list-save-error')).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/__tests__/SecurityEnforcementNote.test.tsx b/frontend/src/components/__tests__/SecurityEnforcementNote.test.tsx
index f6f12c1d2..a947a0754 100644
--- a/frontend/src/components/__tests__/SecurityEnforcementNote.test.tsx
+++ b/frontend/src/components/__tests__/SecurityEnforcementNote.test.tsx
@@ -25,6 +25,7 @@ describe('SecurityEnforcementNote', () => {
['waf', 'ruleset_unavailable', 'The WAF is on, but the selected rule set could not be loaded. Check it on the WAF page.'],
['waf', 'ruleset_empty', 'The selected rule set has no rules, so it is not protecting anything yet. Add rules to it or choose another.'],
['rate_limit', 'thresholds_unset', 'Rate limiting is on, but no limits are set yet. Set the requests and window on the Rate Limiting page.'],
+ ['acl', 'geoip_unavailable', 'Country lists need the GeoIP database, which is missing from this install, so they refuse requests until it is provided. See the documentation.'],
] as const)('renders the %s line for reason %s', (moduleName, reason, text) => {
render()
expect(screen.getByTestId(`${moduleName}-enforcement-note`)).toHaveTextContent(text)
@@ -40,6 +41,11 @@ describe('SecurityEnforcementNote', () => {
expect(screen.getByTestId('rate_limit-enforcement-note')).toHaveTextContent('Not applied to every host yet.')
})
+ it('renders the generic line when a WAF reason is reported for the ACL', () => {
+ render()
+ expect(screen.getByTestId('acl-enforcement-note')).toHaveTextContent('Not applied to every host yet.')
+ })
+
it('renders the generic line when a reason belongs to the other module', () => {
render()
expect(screen.getByTestId('waf-enforcement-note')).toHaveTextContent('Not applied to every host yet.')
diff --git a/frontend/src/locales/af/translation.json b/frontend/src/locales/af/translation.json
index aaee75b66..59b9330d0 100644
--- a/frontend/src/locales/af/translation.json
+++ b/frontend/src/locales/af/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Landlyste benodig die GeoIP-databasis, wat nie in hierdie installasie is nie, en hulle weier versoeke totdat dit verskaf word. Sien die dokumentasie."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "'n Geaktiveerde toelatelys benodig ten minste een reël. Voeg 'n reël by, of skakel die lys af, en stoor weer."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/ar/translation.json b/frontend/src/locales/ar/translation.json
index aaee75b66..0ebdc98f6 100644
--- a/frontend/src/locales/ar/translation.json
+++ b/frontend/src/locales/ar/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "تحتاج قوائم الدول إلى قاعدة بيانات GeoIP، وهي غير موجودة في هذا التثبيت، لذلك ترفض الطلبات إلى أن تُوفَّر. راجع التوثيق."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "تحتاج قائمة السماح المفعّلة إلى قاعدة واحدة على الأقل. أضف قاعدة، أو أوقف تشغيل القائمة، ثم احفظ مرة أخرى."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/ca/translation.json b/frontend/src/locales/ca/translation.json
index aaee75b66..efd1209d7 100644
--- a/frontend/src/locales/ca/translation.json
+++ b/frontend/src/locales/ca/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Les llistes de països necessiten la base de dades GeoIP, que falta en aquesta instal·lació, així que rebutgen les sol·licituds fins que se n'proporcioni. Consulta la documentació."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "Una llista de permesos activada necessita almenys una regla. Afegeix una regla o desactiva la llista i torna a desar."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/cs/translation.json b/frontend/src/locales/cs/translation.json
index aaee75b66..040f5d381 100644
--- a/frontend/src/locales/cs/translation.json
+++ b/frontend/src/locales/cs/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Seznamy zemí potřebují databázi GeoIP, která v této instalaci chybí, takže požadavky odmítají, dokud nebude k dispozici. Viz dokumentace."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "Zapnutý seznam povolených potřebuje alespoň jedno pravidlo. Přidejte pravidlo nebo seznam vypněte a uložte znovu."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/da/translation.json b/frontend/src/locales/da/translation.json
index aaee75b66..ca493861d 100644
--- a/frontend/src/locales/da/translation.json
+++ b/frontend/src/locales/da/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Landelister kræver GeoIP-databasen, som mangler i denne installation, så de afviser anmodninger, indtil den er tilgængelig. Se dokumentationen."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "En aktiveret tilladelsesliste kræver mindst én regel. Tilføj en regel, eller slå listen fra, og gem igen."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json
index aa34dd1cc..c7fffde90 100644
--- a/frontend/src/locales/de/translation.json
+++ b/frontend/src/locales/de/translation.json
@@ -607,7 +607,10 @@
"rate_limit": {
"thresholds_unset": "Die Ratenbegrenzung ist aktiv, aber es sind noch keine Grenzwerte festgelegt. Lege Anfragen und Zeitfenster auf der Seite Ratenbegrenzung fest."
},
- "unknown": "Noch nicht auf alle Hosts angewendet. Prüfe die Einstellungen dieses Moduls."
+ "unknown": "Noch nicht auf alle Hosts angewendet. Prüfe die Einstellungen dieses Moduls.",
+ "acl": {
+ "geoip_unavailable": "Länderlisten benötigen die GeoIP-Datenbank, die in dieser Installation fehlt, und lehnen Anfragen so lange ab, bis sie bereitgestellt wird. Siehe Dokumentation."
+ }
}
},
"accessLists": {
@@ -640,7 +643,10 @@
"deleteItems": "{{count}} Elemente löschen",
"deleting": "Wird gelöscht...",
"noAccessLists": "Keine Zugriffslisten",
- "noAccessListsDescription": "Erstellen Sie Ihre erste Zugriffsliste, um zu kontrollieren, wer auf Ihre Dienste zugreifen kann"
+ "noAccessListsDescription": "Erstellen Sie Ihre erste Zugriffsliste, um zu kontrollieren, wer auf Ihre Dienste zugreifen kann",
+ "errors": {
+ "access_list_rules_required": "Eine aktivierte Allowlist benötigt mindestens eine Regel. Füge eine Regel hinzu oder deaktiviere die Liste und speichere erneut."
+ }
},
"rateLimiting": {
"title": "Ratenbegrenzungskonfiguration",
diff --git a/frontend/src/locales/el/translation.json b/frontend/src/locales/el/translation.json
index aaee75b66..63a1d9288 100644
--- a/frontend/src/locales/el/translation.json
+++ b/frontend/src/locales/el/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Οι λίστες χωρών χρειάζονται τη βάση δεδομένων GeoIP, η οποία λείπει από αυτή την εγκατάσταση, και απορρίπτουν αιτήματα μέχρι να παρασχεθεί. Δείτε την τεκμηρίωση."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "Μια ενεργοποιημένη λίστα επιτρεπόμενων χρειάζεται τουλάχιστον έναν κανόνα. Προσθέστε έναν κανόνα ή απενεργοποιήστε τη λίστα και αποθηκεύστε ξανά."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json
index b7d77f7d1..60d624b6d 100644
--- a/frontend/src/locales/en/translation.json
+++ b/frontend/src/locales/en/translation.json
@@ -607,7 +607,10 @@
"rate_limit": {
"thresholds_unset": "Rate limiting is on, but no limits are set yet. Set the requests and window on the Rate Limiting page."
},
- "unknown": "Not applied to every host yet. Check this module's settings page."
+ "unknown": "Not applied to every host yet. Check this module's settings page.",
+ "acl": {
+ "geoip_unavailable": "Country lists need the GeoIP database, which is missing from this install, so they refuse requests until it is provided. See the documentation."
+ }
}
},
"accessLists": {
@@ -640,7 +643,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "An enabled allow-list needs at least one rule. Add a rule, or turn the list off, then save again."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json
index f79cb529b..49ed8fe44 100644
--- a/frontend/src/locales/es/translation.json
+++ b/frontend/src/locales/es/translation.json
@@ -607,7 +607,10 @@
"rate_limit": {
"thresholds_unset": "El límite de velocidad está activado, pero todavía no hay límites definidos. Define las solicitudes y la ventana en la página de límite de velocidad."
},
- "unknown": "Todavía no se aplica a todos los hosts. Revisa la página de configuración de este módulo."
+ "unknown": "Todavía no se aplica a todos los hosts. Revisa la página de configuración de este módulo.",
+ "acl": {
+ "geoip_unavailable": "Las listas de países necesitan la base de datos GeoIP, que falta en esta instalación, por lo que rechazan las solicitudes hasta que se proporcione. Consulta la documentación."
+ }
}
},
"accessLists": {
@@ -640,7 +643,10 @@
"deleteItems": "Eliminar {{count}} Elementos",
"deleting": "Eliminando...",
"noAccessLists": "Sin Listas de Acceso",
- "noAccessListsDescription": "Crea tu primera lista de acceso para controlar quién puede acceder a tus servicios"
+ "noAccessListsDescription": "Crea tu primera lista de acceso para controlar quién puede acceder a tus servicios",
+ "errors": {
+ "access_list_rules_required": "Una lista de permitidos activada necesita al menos una regla. Añade una regla o desactiva la lista y guarda de nuevo."
+ }
},
"rateLimiting": {
"title": "Configuración de Limitación de Tasa",
diff --git a/frontend/src/locales/fi/translation.json b/frontend/src/locales/fi/translation.json
index aaee75b66..5bad46019 100644
--- a/frontend/src/locales/fi/translation.json
+++ b/frontend/src/locales/fi/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Maaluettelot tarvitsevat GeoIP-tietokannan, joka puuttuu tästä asennuksesta, joten ne hylkäävät pyynnöt, kunnes tietokanta on toimitettu. Katso dokumentaatio."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "Käytössä oleva sallittujen luettelo tarvitsee vähintään yhden säännön. Lisää sääntö tai poista luettelo käytöstä ja tallenna uudelleen."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json
index 6851e979a..5cfb7ae2c 100644
--- a/frontend/src/locales/fr/translation.json
+++ b/frontend/src/locales/fr/translation.json
@@ -607,7 +607,10 @@
"rate_limit": {
"thresholds_unset": "La limitation de débit est activée, mais aucune limite n'est encore définie. Définissez les requêtes et la fenêtre sur la page de limitation de débit."
},
- "unknown": "Pas encore appliqué à tous les hôtes. Consultez la page des paramètres de ce module."
+ "unknown": "Pas encore appliqué à tous les hôtes. Consultez la page des paramètres de ce module.",
+ "acl": {
+ "geoip_unavailable": "Les listes de pays nécessitent la base de données GeoIP, absente de cette installation : elles refusent les requêtes tant qu'elle n'est pas fournie. Voir la documentation."
+ }
}
},
"accessLists": {
@@ -640,7 +643,10 @@
"deleteItems": "Supprimer {{count}} Éléments",
"deleting": "Suppression...",
"noAccessLists": "Aucune Liste d'Accès",
- "noAccessListsDescription": "Créez votre première liste d'accès pour contrôler qui peut accéder à vos services"
+ "noAccessListsDescription": "Créez votre première liste d'accès pour contrôler qui peut accéder à vos services",
+ "errors": {
+ "access_list_rules_required": "Une liste d'autorisation activée nécessite au moins une règle. Ajoutez une règle ou désactivez la liste, puis enregistrez à nouveau."
+ }
},
"rateLimiting": {
"title": "Configuration de la Limitation de Débit",
diff --git a/frontend/src/locales/he/translation.json b/frontend/src/locales/he/translation.json
index aaee75b66..506d415d4 100644
--- a/frontend/src/locales/he/translation.json
+++ b/frontend/src/locales/he/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "רשימות מדינות דורשות את מסד הנתונים של GeoIP, שחסר בהתקנה זו, ולכן הן דוחות בקשות עד שיסופק. ראו את התיעוד."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "רשימת היתרים פעילה דורשת לפחות כלל אחד. הוסיפו כלל, או כבו את הרשימה, ושמרו שוב."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/hu/translation.json b/frontend/src/locales/hu/translation.json
index aaee75b66..5ce6519a9 100644
--- a/frontend/src/locales/hu/translation.json
+++ b/frontend/src/locales/hu/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Az országlistákhoz a GeoIP-adatbázis szükséges, amely hiányzik ebből a telepítésből, ezért addig elutasítják a kéréseket, amíg nincs biztosítva. Lásd a dokumentációt."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "Egy engedélyezett engedélyezési listához legalább egy szabály szükséges. Adjon hozzá szabályt, vagy kapcsolja ki a listát, majd mentse újra."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/it/translation.json b/frontend/src/locales/it/translation.json
index aaee75b66..a838de3ab 100644
--- a/frontend/src/locales/it/translation.json
+++ b/frontend/src/locales/it/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Le liste di paesi richiedono il database GeoIP, che manca in questa installazione, quindi rifiutano le richieste finché non viene fornito. Consulta la documentazione."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "Una lista di autorizzazione attiva richiede almeno una regola. Aggiungi una regola o disattiva la lista, quindi salva di nuovo."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/ja/translation.json b/frontend/src/locales/ja/translation.json
index aaee75b66..c50a53014 100644
--- a/frontend/src/locales/ja/translation.json
+++ b/frontend/src/locales/ja/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "国別リストには GeoIP データベースが必要ですが、このインストールには含まれていません。提供されるまで、国別リストに一致するリクエストは拒否されます。ドキュメントを参照してください。"
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "有効な許可リストには、少なくとも 1 つのルールが必要です。ルールを追加するか、リストを無効にしてから、もう一度保存してください。"
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/ko/translation.json b/frontend/src/locales/ko/translation.json
index aaee75b66..4fd89b856 100644
--- a/frontend/src/locales/ko/translation.json
+++ b/frontend/src/locales/ko/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "국가 목록에는 이 설치에 없는 GeoIP 데이터베이스가 필요하므로, 제공될 때까지 일치하는 요청을 거부합니다. 문서를 참조하세요."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "사용 중인 허용 목록에는 규칙이 하나 이상 필요합니다. 규칙을 추가하거나 목록을 끈 뒤 다시 저장하세요."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/nl/translation.json b/frontend/src/locales/nl/translation.json
index aaee75b66..896d976d7 100644
--- a/frontend/src/locales/nl/translation.json
+++ b/frontend/src/locales/nl/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Landenlijsten hebben de GeoIP-database nodig, die in deze installatie ontbreekt, dus ze weigeren verzoeken totdat die beschikbaar is. Zie de documentatie."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "Een ingeschakelde toestemmingslijst heeft minstens één regel nodig. Voeg een regel toe of schakel de lijst uit en sla opnieuw op."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/no/translation.json b/frontend/src/locales/no/translation.json
index aaee75b66..b6bc3e39c 100644
--- a/frontend/src/locales/no/translation.json
+++ b/frontend/src/locales/no/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Landlister trenger GeoIP-databasen, som mangler i denne installasjonen, så de avviser forespørsler til den er på plass. Se dokumentasjonen."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "En aktivert tillatelsesliste trenger minst én regel. Legg til en regel, eller slå av listen, og lagre på nytt."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/pl/translation.json b/frontend/src/locales/pl/translation.json
index aaee75b66..3395280d2 100644
--- a/frontend/src/locales/pl/translation.json
+++ b/frontend/src/locales/pl/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Listy krajów wymagają bazy danych GeoIP, której brak w tej instalacji, dlatego odrzucają żądania, dopóki nie zostanie ona dostarczona. Zobacz dokumentację."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "Włączona lista dozwolonych wymaga co najmniej jednej reguły. Dodaj regułę lub wyłącz listę i zapisz ponownie."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/pt/translation.json b/frontend/src/locales/pt/translation.json
index aaee75b66..63585f060 100644
--- a/frontend/src/locales/pt/translation.json
+++ b/frontend/src/locales/pt/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "As listas de países precisam da base de dados GeoIP, que não está nesta instalação, por isso recusam pedidos até que seja fornecida. Consulte a documentação."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "Uma lista de permissões ativada precisa de pelo menos uma regra. Adicione uma regra ou desative a lista e guarde novamente."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/ro/translation.json b/frontend/src/locales/ro/translation.json
index aaee75b66..8d67f6f45 100644
--- a/frontend/src/locales/ro/translation.json
+++ b/frontend/src/locales/ro/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Listele de țări au nevoie de baza de date GeoIP, care lipsește din această instalare, așa că refuză cererile până când aceasta este furnizată. Consultați documentația."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "O listă de permisiuni activată necesită cel puțin o regulă. Adăugați o regulă sau dezactivați lista, apoi salvați din nou."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/ru/translation.json b/frontend/src/locales/ru/translation.json
index aaee75b66..e2b2ee6de 100644
--- a/frontend/src/locales/ru/translation.json
+++ b/frontend/src/locales/ru/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Спискам стран нужна база данных GeoIP, которой нет в этой установке, поэтому запросы отклоняются, пока она не будет предоставлена. См. документацию."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "Включённому списку разрешённых нужно хотя бы одно правило. Добавьте правило или выключите список и сохраните снова."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/sr/translation.json b/frontend/src/locales/sr/translation.json
index aaee75b66..e963120eb 100644
--- a/frontend/src/locales/sr/translation.json
+++ b/frontend/src/locales/sr/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Листама држава потребна је GeoIP база података, која недостаје у овој инсталацији, па одбијају захтеве док се не обезбеди. Погледајте документацију."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "Укљученој листи дозвољених потребно је најмање једно правило. Додајте правило или искључите листу, па сачувајте поново."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/sv/translation.json b/frontend/src/locales/sv/translation.json
index aaee75b66..2264cc8da 100644
--- a/frontend/src/locales/sv/translation.json
+++ b/frontend/src/locales/sv/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Landlistor behöver GeoIP-databasen, som saknas i den här installationen, så de nekar förfrågningar tills den tillhandahålls. Se dokumentationen."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "En aktiverad tillåtelselista behöver minst en regel. Lägg till en regel eller stäng av listan och spara igen."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/tr/translation.json b/frontend/src/locales/tr/translation.json
index aaee75b66..a4d0f33b9 100644
--- a/frontend/src/locales/tr/translation.json
+++ b/frontend/src/locales/tr/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Ülke listeleri, bu kurulumda eksik olan GeoIP veritabanını gerektirir; veritabanı sağlanana kadar istekleri reddederler. Belgelere bakın."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "Etkin bir izin listesinin en az bir kurala ihtiyacı var. Bir kural ekleyin ya da listeyi kapatıp yeniden kaydedin."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/uk/translation.json b/frontend/src/locales/uk/translation.json
index aaee75b66..fde3f3abb 100644
--- a/frontend/src/locales/uk/translation.json
+++ b/frontend/src/locales/uk/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Спискам країн потрібна база даних GeoIP, якої немає в цій інсталяції, тож запити відхиляються, доки її не буде надано. Див. документацію."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "Увімкненому списку дозволених потрібне принаймні одне правило. Додайте правило або вимкніть список і збережіть знову."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/vi/translation.json b/frontend/src/locales/vi/translation.json
index aaee75b66..11b21befa 100644
--- a/frontend/src/locales/vi/translation.json
+++ b/frontend/src/locales/vi/translation.json
@@ -581,7 +581,12 @@
"loadError": "Login protection status could not be loaded."
},
"settingUpdated": "Security setting updated",
- "settingUpdateFailed": "Failed to update setting: {{error}}"
+ "settingUpdateFailed": "Failed to update setting: {{error}}",
+ "enforcement": {
+ "acl": {
+ "geoip_unavailable": "Danh sách quốc gia cần cơ sở dữ liệu GeoIP, hiện không có trong bản cài đặt này, nên các yêu cầu sẽ bị từ chối cho đến khi được cung cấp. Xem tài liệu."
+ }
+ }
},
"accessLists": {
"title": "Access Lists",
@@ -613,7 +618,10 @@
"deleteItems": "Delete {{count}} Items",
"deleting": "Deleting...",
"noAccessLists": "No Access Lists",
- "noAccessListsDescription": "Create your first access list to control who can access your services"
+ "noAccessListsDescription": "Create your first access list to control who can access your services",
+ "errors": {
+ "access_list_rules_required": "Danh sách cho phép đang bật cần ít nhất một quy tắc. Thêm quy tắc hoặc tắt danh sách, rồi lưu lại."
+ }
},
"rateLimiting": {
"title": "Rate Limiting Configuration",
diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json
index f258dccc6..60d36f47d 100644
--- a/frontend/src/locales/zh/translation.json
+++ b/frontend/src/locales/zh/translation.json
@@ -607,7 +607,10 @@
"rate_limit": {
"thresholds_unset": "速率限制已开启,但尚未设置限制。请在速率限制页面设置请求数和时间窗口。"
},
- "unknown": "尚未应用到所有主机。请查看此模块的设置页面。"
+ "unknown": "尚未应用到所有主机。请查看此模块的设置页面。",
+ "acl": {
+ "geoip_unavailable": "国家列表需要 GeoIP 数据库,但此安装中缺少该数据库,因此在提供之前会拒绝请求。请参阅文档。"
+ }
}
},
"accessLists": {
@@ -640,7 +643,10 @@
"deleteItems": "删除 {{count}} 项",
"deleting": "删除中……",
"noAccessLists": "无访问列表",
- "noAccessListsDescription": "创建您的第一个访问列表来控制谁可以访问您的服务"
+ "noAccessListsDescription": "创建您的第一个访问列表来控制谁可以访问您的服务",
+ "errors": {
+ "access_list_rules_required": "已启用的允许列表至少需要一条规则。请添加规则,或关闭该列表后再次保存。"
+ }
},
"rateLimiting": {
"title": "速率限制配置",
diff --git a/frontend/src/pages/AccessLists.tsx b/frontend/src/pages/AccessLists.tsx
index 295897315..8ba22a4c5 100644
--- a/frontend/src/pages/AccessLists.tsx
+++ b/frontend/src/pages/AccessLists.tsx
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { createBackup } from '../api/backups';
+import { accessListErrorMessage } from '../api/errorBody';
import { AccessListForm, type AccessListFormData } from '../components/AccessListForm';
import { PageShell } from '../components/layout/PageShell';
import {
@@ -41,6 +42,8 @@ export default function AccessLists() {
const updateMutation = useUpdateAccessList();
const deleteMutation = useDeleteAccessList();
const testIPMutation = useTestIP();
+ const createSaveError = accessListErrorMessage(createMutation.error, t);
+ const updateSaveError = accessListErrorMessage(updateMutation.error, t);
const [showCreateForm, setShowCreateForm] = useState(false);
const [editingACL, setEditingACL] = useState(null);
@@ -347,6 +350,7 @@ export default function AccessLists() {
onSubmit={handleCreate}
onCancel={() => setShowCreateForm(false)}
isLoading={createMutation.isPending}
+ saveErrorMessage={createSaveError}
/>
)}
@@ -361,6 +365,7 @@ export default function AccessLists() {
onCancel={() => setEditingACL(null)}
onDelete={() => setShowDeleteConfirm(editingACL)}
isLoading={updateMutation.isPending}
+ saveErrorMessage={updateSaveError}
isDeleting={isDeleting}
/>
diff --git a/frontend/src/pages/Security.tsx b/frontend/src/pages/Security.tsx
index 63af2ad5e..53206299d 100644
--- a/frontend/src/pages/Security.tsx
+++ b/frontend/src/pages/Security.tsx
@@ -516,6 +516,10 @@ export default function Security() {
{t('security.aclProtects')}
+ {/* The backend sends a reason only when the list is not applied, so the note is keyed on it. */}
+ {status.acl.reason && (
+
+ )}
diff --git a/frontend/src/pages/__tests__/Security.dashboard.test.tsx b/frontend/src/pages/__tests__/Security.dashboard.test.tsx
index b932a39f9..c1dd4cc59 100644
--- a/frontend/src/pages/__tests__/Security.dashboard.test.tsx
+++ b/frontend/src/pages/__tests__/Security.dashboard.test.tsx
@@ -358,4 +358,27 @@ describe('Security Dashboard - Card Status Tests', () => {
expect(screen.getByTestId('toggle-rate-limit')).not.toBeDisabled()
})
})
+
+ describe('ACL GeoIP enforcement note', () => {
+ it('shows the GeoIP note on the ACL card when the ACL reports geoip_unavailable', async () => {
+ vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
+ ...mockSecurityStatusAllEnabled,
+ acl: { enabled: true, reason: 'geoip_unavailable' },
+ })
+ await renderSecurityPage()
+
+ const note = await screen.findByTestId('acl-enforcement-note')
+ expect(note).toHaveTextContent('Country lists need the GeoIP database')
+ })
+
+ it('shows no ACL note when the ACL reports no reason', async () => {
+ vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatusAllEnabled)
+ await renderSecurityPage()
+
+ await waitFor(() => {
+ expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument()
+ })
+ expect(screen.queryByTestId('acl-enforcement-note')).not.toBeInTheDocument()
+ })
+ })
})
diff --git a/tests/security-enforcement/acl-enforcement.spec.ts b/tests/security-enforcement/acl-enforcement.spec.ts
index a0681f10b..7adc8a8f8 100644
--- a/tests/security-enforcement/acl-enforcement.spec.ts
+++ b/tests/security-enforcement/acl-enforcement.spec.ts
@@ -23,6 +23,13 @@ import {
restoreSecurityState,
SecurityStateSnapshot,
} from '../utils/security-helpers';
+import {
+ createCountryAllowList,
+ createProxyHost,
+ deleteAccessList,
+ deleteProxyHost,
+ fetchThroughProxy,
+} from '../utils/proxy-refusal-helpers';
/**
* Configure admin whitelist to allow test runner IPs.
@@ -208,6 +215,85 @@ test.describe('ACL Enforcement', () => {
}
});
+ test('should refuse an enabled allow-list saved with no rules', async () => {
+ // An allow-list that is enabled but has no rules would admit nobody at save time, so the API refuses it with a
+ // stable error code the UI maps to a message.
+ const response = await requestContext.post('/api/v1/access-lists', {
+ data: {
+ name: `No Rules Allow List ${Date.now()}`,
+ type: 'whitelist',
+ ip_rules: '[]',
+ enabled: true,
+ },
+ });
+
+ expect(response.status()).toBe(400);
+ expect(await response.json()).toMatchObject({ error_code: 'access_list_rules_required' });
+ });
+
+ test.describe('Country lists', () => {
+ const domain = `country-list-${Date.now()}.example.test`;
+ let proxyContext: APIRequestContext;
+ let aclUuid: string;
+ let proxyUuid: string;
+
+ test.beforeAll(async () => {
+ proxyContext = await request.newContext();
+ aclUuid = await createCountryAllowList(requestContext, `Country Allow List ${Date.now()}`);
+ proxyUuid = await createProxyHost(requestContext, {
+ domain,
+ forwardPort: 3001,
+ name: 'Country list host',
+ accessListUuid: aclUuid,
+ });
+
+ // Wait for the route to be loaded: the country list refuses the runner, which has no country.
+ await expect
+ .poll(async () => (await fetchThroughProxy(proxyContext, domain, '/')).status(), {
+ timeout: 30_000,
+ message: 'country list route should be loaded',
+ })
+ .toBe(403);
+ });
+
+ test.afterAll(async () => {
+ if (proxyUuid) {
+ await deleteProxyHost(requestContext, proxyUuid);
+ }
+ if (aclUuid) {
+ await deleteAccessList(requestContext, aclUuid);
+ }
+ await proxyContext.dispose();
+ });
+
+ test('should refuse a client outside the listed countries and report the GeoIP database state', async () => {
+ await test.step('Refused with the geographic body', async () => {
+ const response = await fetchThroughProxy(proxyContext, domain, '/');
+
+ expect(response.status()).toBe(403);
+ expect(await response.text()).toContain('Access denied: Geographic restriction');
+ });
+
+ await test.step('Status reports geoip_unavailable exactly when the database is not loaded', async () => {
+ // The e2e image bundles the GeoLite2 database when its download succeeds at build time. The database state
+ // decides which path answers, so the check reads it from the API instead of assuming it.
+ const geoipResponse = await requestContext.get('/api/v1/security/geoip/status');
+ expect(geoipResponse.ok()).toBe(true);
+ const { available } = (await geoipResponse.json()) as { available: boolean };
+
+ const statusResponse = await requestContext.get('/api/v1/security/status');
+ expect(statusResponse.ok()).toBe(true);
+ const status = (await statusResponse.json()) as { acl: { reason?: string } };
+
+ if (available) {
+ expect(status.acl).not.toHaveProperty('reason');
+ } else {
+ expect(status.acl.reason).toBe('geoip_unavailable');
+ }
+ });
+ });
+ });
+
test.describe('Route handling on proxy hosts', () => {
// An IP allow-list cannot express a non-listed runner here: the runner is
// admin-whitelisted, and the proxy ACL merges the admin whitelist into every
@@ -236,44 +322,9 @@ test.describe('ACL Enforcement', () => {
let selfProxyUuid: string;
let selfProxyDomain: string;
- /** Caddy proxy origin, derived the same way caddyProxyOrigin() does (no page here). */
- function proxyOrigin(): string {
- const url = new URL(APP_BASE_URL);
- return `${url.protocol}//${url.hostname}:${process.env.PLAYWRIGHT_CADDY_PROXY_PORT || '80'}`;
- }
-
/** Send a request through the proxy hop with the given Host header. */
- async function proxied(domain: string, path: string, method = 'GET') {
- return proxyContext.fetch(`${proxyOrigin()}${path}`, {
- method,
- headers: { Host: domain },
- data: method === 'POST' ? {} : undefined,
- failOnStatusCode: false,
- maxRedirects: 0,
- });
- }
-
- async function createProxyHost(
- domain: string,
- forwardPort: number,
- name: string,
- accessListUuid: string
- ): Promise {
- const response = await requestContext.post('/api/v1/proxy-hosts', {
- data: {
- domain_names: domain,
- forward_host: '127.0.0.1',
- forward_port: forwardPort,
- forward_scheme: 'http',
- websocket_support: false,
- enabled: true,
- name,
- access_list_id: accessListUuid,
- },
- });
- expect(response.ok(), `create proxy host ${domain}`).toBe(true);
- const body = await response.json();
- return body.uuid ?? body.id;
+ function proxied(domain: string, path: string, method = 'GET') {
+ return fetchThroughProxy(proxyContext, domain, path, method);
}
test.beforeAll(async () => {
@@ -281,25 +332,26 @@ test.describe('ACL Enforcement', () => {
// Allow-list that only admits one country; the runner has no country, so it is
// not on the list. Attached to both hosts.
- const aclResponse = await requestContext.post('/api/v1/access-lists', {
- data: {
- name: `Route Handling Allow List ${Date.now()}`,
- type: 'geo_whitelist',
- country_codes: 'US',
- enabled: true,
- },
- });
- expect(aclResponse.ok(), 'create allow list').toBe(true);
- aclUuid = (await aclResponse.json()).uuid;
+ aclUuid = await createCountryAllowList(requestContext, `Route Handling Allow List ${Date.now()}`);
// Plain allow-listed host: upstream is a port nothing listens on, so any
// request that gets past the ACL would fail upstream with 502.
allowListedDomain = `route-allow-${Date.now()}.example.test`;
- allowListedProxyUuid = await createProxyHost(allowListedDomain, 3001, 'Allow-listed host', aclUuid);
+ allowListedProxyUuid = await createProxyHost(requestContext, {
+ domain: allowListedDomain,
+ forwardPort: 3001,
+ name: 'Allow-listed host',
+ accessListUuid: aclUuid,
+ });
// Self-proxy host: upstream is Charon's own listener.
selfProxyDomain = `route-self-${Date.now()}.example.test`;
- selfProxyUuid = await createProxyHost(selfProxyDomain, 8080, 'Self-proxy host', aclUuid);
+ selfProxyUuid = await createProxyHost(requestContext, {
+ domain: selfProxyDomain,
+ forwardPort: 8080,
+ name: 'Self-proxy host',
+ accessListUuid: aclUuid,
+ });
// Wait for both routes to be loaded by the proxy. The ACL answers 403 on '/'.
for (const domain of [allowListedDomain, selfProxyDomain]) {
@@ -315,11 +367,11 @@ test.describe('ACL Enforcement', () => {
test.afterAll(async () => {
for (const uuid of [selfProxyUuid, allowListedProxyUuid]) {
if (uuid) {
- await requestContext.delete(`/api/v1/proxy-hosts/${uuid}`);
+ await deleteProxyHost(requestContext, uuid);
}
}
if (aclUuid) {
- await requestContext.delete(`/api/v1/access-lists/${aclUuid}`);
+ await deleteAccessList(requestContext, aclUuid);
}
await proxyContext.dispose();
});
diff --git a/tests/security-enforcement/multi-component-security-workflows.spec.ts b/tests/security-enforcement/multi-component-security-workflows.spec.ts
index 4abcc9cf2..4023fe85f 100644
--- a/tests/security-enforcement/multi-component-security-workflows.spec.ts
+++ b/tests/security-enforcement/multi-component-security-workflows.spec.ts
@@ -9,6 +9,11 @@ import {
getAuthTokenFromPage as getAuthToken,
} from '../utils/api-helpers';
import type { ProxyHostRecord, UserRecord } from '../utils/api-records';
+import {
+ readApiRateLimitBudget,
+ restoreApiRateLimitBudget,
+ type ApiRateLimitBudget,
+} from '../utils/security-settings-api';
async function resetSecurityState(page: import('@playwright/test').Page): Promise {
const emergencyToken = process.env.CHARON_EMERGENCY_TOKEN;
@@ -49,7 +54,10 @@ test.describe('Multi-Component Security Workflows', () => {
password: 'MultiFlow123!',
};
+ let apiBudgetBefore: ApiRateLimitBudget | undefined;
+
test.beforeEach(async ({ page, adminUser }) => {
+ apiBudgetBefore = undefined;
const suffix = uniqueSuffix();
testProxy = {
domain: `multi-workflow-${suffix}.local`,
@@ -68,6 +76,8 @@ test.describe('Multi-Component Security Workflows', () => {
await waitForLoadingComplete(page, { timeout: 15000 });
const meResponse = await page.request.get('/api/v1/auth/me');
expect(meResponse.ok()).toBe(true);
+ // The rate limit step writes a one-request budget that the API limiter keeps after this case.
+ apiBudgetBefore = await readApiRateLimitBudget(page.request);
});
test.afterEach(async ({ page }) => {
@@ -108,6 +118,13 @@ test.describe('Multi-Component Security Workflows', () => {
} catch {
// Ignore cleanup errors
} finally {
+ try {
+ if (apiBudgetBefore) {
+ await restoreApiRateLimitBudget(page.request, apiBudgetBefore);
+ }
+ } catch (error) {
+ console.error('Failed to restore the API rate limit budget:', error);
+ }
await resetSecurityState(page);
}
});
diff --git a/tests/security-enforcement/rate-limit-enforcement.spec.ts b/tests/security-enforcement/rate-limit-enforcement.spec.ts
index 6853f48d2..dd29a52ad 100644
--- a/tests/security-enforcement/rate-limit-enforcement.spec.ts
+++ b/tests/security-enforcement/rate-limit-enforcement.spec.ts
@@ -22,6 +22,25 @@ import {
restoreSecurityState,
SecurityStateSnapshot,
} from '../utils/security-helpers';
+import {
+ STANDARD_API_RATE_LIMIT_BUDGET,
+ getConfigRow,
+ readApiRateLimitBudget,
+ restoreApiRateLimitBudget,
+ saveConfig,
+ writeApiRateLimitBudget,
+ type ApiRateLimitBudget,
+ type SecurityConfigRow,
+} from '../utils/security-settings-api';
+import {
+ PROFILE_HEADER_EXPECTATIONS,
+ createHeaderProfile,
+ createProxyHost,
+ deleteHeaderProfile,
+ deleteProxyHost,
+ fetchThroughProxy,
+ type HeaderProfile,
+} from '../utils/proxy-refusal-helpers';
/**
* Configure admin whitelist to allow test runner IPs.
@@ -62,8 +81,12 @@ async function configureAdminWhitelist(requestContext: APIRequestContext) {
}
test.describe('Rate Limit Enforcement', () => {
+ // Shared security state and the API rate limit budget are changed here, so the cases must not interleave.
+ test.describe.configure({ mode: 'serial' });
+
let requestContext: APIRequestContext;
let originalState: SecurityStateSnapshot;
+ let originalApiBudget: ApiRateLimitBudget | undefined;
test.beforeAll(async () => {
requestContext = await request.newContext({
@@ -85,6 +108,15 @@ test.describe('Rate Limit Enforcement', () => {
console.error('Failed to capture original security state:', error);
}
+ // The API limiter reads its budget from these settings. Earlier cases can leave a tight budget behind, and that
+ // would refuse this case's own API calls, so the standard budget is set here and the captured one is restored.
+ try {
+ originalApiBudget = await readApiRateLimitBudget(requestContext);
+ await writeApiRateLimitBudget(requestContext, STANDARD_API_RATE_LIMIT_BUDGET);
+ } catch (error) {
+ console.error('Failed to set the standard API rate limit budget:', error);
+ }
+
// Enable Cerberus (master toggle) first
try {
await setSecurityModuleEnabled(requestContext, 'cerberus', true);
@@ -121,6 +153,13 @@ test.describe('Rate Limit Enforcement', () => {
}
}
}
+ if (originalApiBudget) {
+ try {
+ await restoreApiRateLimitBudget(requestContext, originalApiBudget);
+ } catch (error) {
+ console.error('Failed to restore the API rate limit budget:', error);
+ }
+ }
await requestContext.dispose();
});
@@ -212,4 +251,106 @@ test.describe('Rate Limit Enforcement', () => {
'Rate limiting configured - threshold enforcement active at Caddy layer'
);
});
+
+ test.describe('Headers on rate limited responses', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ // Small enough that a few proxied requests from the runner exhaust it within the window. This is the proxy
+ // limit stored in the security config. It does not reach the API limiter, which reads its own budget.
+ const RATE_LIMIT_REQUESTS = 2;
+ const RATE_LIMIT_WINDOW_SEC = 60;
+ let domain = '';
+ let proxyContext: APIRequestContext | undefined;
+ let profile: HeaderProfile | undefined;
+ let proxyUuid: string | undefined;
+ let previousConfig: SecurityConfigRow | null = null;
+
+ test.beforeAll(async () => {
+ // Retry-safe: a retried attempt starts from empty handles and a fresh domain, so it never collides with a
+ // host left by the attempt before it.
+ domain = `refusal-headers-429-${Date.now()}.example.test`;
+ profile = undefined;
+ proxyUuid = undefined;
+ previousConfig = null;
+ proxyContext = await request.newContext();
+
+ // Fixtures and the captured thresholds come first, while the stored limit is still the original one.
+ previousConfig = await getConfigRow(requestContext);
+ profile = await createHeaderProfile(requestContext, `Rate Limit Headers ${Date.now()}`);
+ proxyUuid = await createProxyHost(requestContext, {
+ domain,
+ // Nothing listens here: requests under the limit fail upstream with 502, requests over it are refused first.
+ forwardPort: 3001,
+ name: 'Rate limit headers host',
+ headerProfileUuid: profile.uuid,
+ });
+ await saveConfig(requestContext, {
+ name: 'default',
+ rate_limit_requests: RATE_LIMIT_REQUESTS,
+ rate_limit_window_sec: RATE_LIMIT_WINDOW_SEC,
+ });
+ });
+
+ test.afterAll(async () => {
+ // Every step runs even when an earlier one fails, so the stored thresholds and fixtures never leak into the
+ // cases that run after this one. The first failure is re-thrown once all steps have run.
+ const failures: unknown[] = [];
+ const cleanup = async (step: () => Promise) => {
+ try {
+ await step();
+ } catch (error) {
+ failures.push(error);
+ }
+ };
+
+ const original = previousConfig as SecurityConfigRow | null;
+ if (original) {
+ await cleanup(() =>
+ saveConfig(requestContext, {
+ name: 'default',
+ rate_limit_requests: original.rate_limit_requests,
+ rate_limit_window_sec: original.rate_limit_window_sec,
+ }),
+ );
+ }
+ const uuid = proxyUuid;
+ if (uuid) {
+ await cleanup(() => deleteProxyHost(requestContext, uuid));
+ }
+ const createdProfile = profile;
+ if (createdProfile) {
+ await cleanup(() => deleteHeaderProfile(requestContext, createdProfile));
+ }
+ await proxyContext?.dispose();
+
+ if (failures.length > 0) {
+ throw failures[0];
+ }
+ });
+
+ test('should send the profile security headers with a rate limit refusal', async () => {
+ let refusedHeaders: Record | undefined;
+ const context = proxyContext;
+ if (!context) {
+ throw new Error('proxy request context was not created in beforeAll');
+ }
+
+ // Requests are sent until the limit is exceeded once the route is loaded. The rate limit answers 429 on the
+ // first request past the limit, so this polls rather than assuming a fixed number of requests.
+ await expect
+ .poll(
+ async () => {
+ const response = await fetchThroughProxy(context, domain, '/');
+ if (response.status() === 429) {
+ refusedHeaders = response.headers();
+ }
+ return response.status();
+ },
+ { timeout: 30_000, message: 'rate limit should refuse requests over the limit' }
+ )
+ .toBe(429);
+
+ expect(refusedHeaders).toMatchObject(PROFILE_HEADER_EXPECTATIONS);
+ });
+ });
});
diff --git a/tests/security-enforcement/security-headers-enforcement.spec.ts b/tests/security-enforcement/security-headers-enforcement.spec.ts
index 755d21f08..9792a5879 100644
--- a/tests/security-enforcement/security-headers-enforcement.spec.ts
+++ b/tests/security-enforcement/security-headers-enforcement.spec.ts
@@ -13,6 +13,24 @@ import { test, expect } from '../fixtures/test';
import { request } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import { STORAGE_STATE } from '../constants';
+import {
+ captureSecurityState,
+ restoreSecurityState,
+ setSecurityModuleEnabled,
+ type SecurityStateSnapshot,
+} from '../utils/security-helpers';
+import { RUNNER_WHITELIST, saveConfig } from '../utils/security-settings-api';
+import {
+ PROFILE_HEADER_EXPECTATIONS,
+ createCountryAllowList,
+ createHeaderProfile,
+ createProxyHost,
+ deleteAccessList,
+ deleteHeaderProfile,
+ deleteProxyHost,
+ fetchThroughProxy,
+ type HeaderProfile,
+} from '../utils/proxy-refusal-helpers';
test.describe('Security Headers Enforcement', () => {
let requestContext: APIRequestContext;
@@ -105,4 +123,66 @@ test.describe('Security Headers Enforcement', () => {
console.log('CSP not configured (optional - set per proxy host)');
}
});
+
+ test.describe('Headers on access list refusals', () => {
+ const domain = `refusal-headers-acl-${Date.now()}.example.test`;
+ let snapshot: SecurityStateSnapshot;
+ let proxyContext: APIRequestContext;
+ let profile: HeaderProfile;
+ let aclUuid: string;
+ let proxyUuid: string;
+
+ test.beforeAll(async () => {
+ snapshot = await captureSecurityState(requestContext);
+
+ // The admin whitelist must be in place before the ACL is enabled, or the admin API refuses the runner.
+ await saveConfig(requestContext, { name: 'default', admin_whitelist: RUNNER_WHITELIST });
+ await setSecurityModuleEnabled(requestContext, 'cerberus', true);
+ // Rate limiting would answer first on a shared client IP and hide the ACL refusal.
+ await setSecurityModuleEnabled(requestContext, 'rateLimit', false);
+ await setSecurityModuleEnabled(requestContext, 'acl', true);
+
+ proxyContext = await request.newContext();
+ profile = await createHeaderProfile(requestContext, `Refusal Headers ${Date.now()}`);
+ // Country list that admits only US; the runner has no country, so the ACL refuses it.
+ aclUuid = await createCountryAllowList(requestContext, `Refusal Headers Allow List ${Date.now()}`);
+ proxyUuid = await createProxyHost(requestContext, {
+ domain,
+ forwardPort: 3001,
+ name: 'Refusal headers host',
+ accessListUuid: aclUuid,
+ headerProfileUuid: profile.uuid,
+ });
+
+ await expect
+ .poll(async () => (await fetchThroughProxy(proxyContext, domain, '/')).status(), {
+ timeout: 30_000,
+ message: 'refusal headers route should be loaded',
+ })
+ .toBe(403);
+ });
+
+ test.afterAll(async () => {
+ if (proxyUuid) {
+ await deleteProxyHost(requestContext, proxyUuid);
+ }
+ if (aclUuid) {
+ await deleteAccessList(requestContext, aclUuid);
+ }
+ if (profile) {
+ await deleteHeaderProfile(requestContext, profile);
+ }
+ await proxyContext.dispose();
+ if (snapshot) {
+ await restoreSecurityState(requestContext, snapshot);
+ }
+ });
+
+ test('should send the profile security headers with an access list refusal', async () => {
+ const response = await fetchThroughProxy(proxyContext, domain, '/');
+
+ expect(response.status()).toBe(403);
+ expect(response.headers()).toMatchObject(PROFILE_HEADER_EXPECTATIONS);
+ });
+ });
});
diff --git a/tests/security-enforcement/zzz-security-ui/access-lists-crud.spec.ts b/tests/security-enforcement/zzz-security-ui/access-lists-crud.spec.ts
index 7c3f82e29..3fc090249 100644
--- a/tests/security-enforcement/zzz-security-ui/access-lists-crud.spec.ts
+++ b/tests/security-enforcement/zzz-security-ui/access-lists-crud.spec.ts
@@ -20,6 +20,15 @@ import { clickSwitch } from '../../utils/ui-helpers';
import { generateUniqueId } from '../../fixtures/test-data';
import type { Page } from '@playwright/test';
+/**
+ * RFC 5737 TEST-NET-3 documentation range. No real client uses it, and the
+ * seeded lists are never attached to a proxy host, so it cannot lock out the
+ * test runner. An enabled allow-list needs at least one IP rule (or the
+ * local-network option) to be accepted, so seeds and name-only UI creations
+ * use this rule.
+ */
+const DOCUMENTATION_CIDR = '203.0.113.0/24';
+
/**
* Seed an access list directly via the API so list-view assertions (type
* badge, CGNAT banner, bulk-selection, rename) have a guaranteed, known row
@@ -47,6 +56,7 @@ async function seedAccessList(
name,
type: overrides.type ?? 'whitelist',
enabled: true,
+ ip_rules: JSON.stringify([{ cidr: DOCUMENTATION_CIDR, description: 'E2E seed rule' }]),
},
});
@@ -80,6 +90,15 @@ test.describe('Access Lists - CRUD Operations', () => {
const getCancelButton = (page: import('@playwright/test').Page) =>
page.getByRole('button', { name: /^cancel$/i }).first();
+ // Adds one IP/CIDR rule in the open create/edit form (Enter adds it, as in AccessListForm.tsx)
+ const addIpRule = async (page: Page, cidr: string) => {
+ const ipInput = page.locator('input[placeholder="192.168.1.0/24 or 10.0.0.1"]');
+ await ipInput.waitFor({ state: 'visible', timeout: 5000 });
+ await ipInput.fill(cidr);
+ await ipInput.press('Enter');
+ await expect(page.getByText(cidr)).toBeVisible({ timeout: 5000 });
+ };
+
test.describe('List View', () => {
test('should display access lists page with title', async ({ page }) => {
await test.step('Verify page title is visible', async () => {
@@ -246,7 +265,7 @@ test.describe('Access Lists - CRUD Operations', () => {
});
});
- test('should create ACL with name only (IP whitelist)', async ({ page }) => {
+ test('should create ACL with an IP rule (IP whitelist)', async ({ page }) => {
const aclName = `Test ACL ${generateUniqueId()}`;
await test.step('Open create form', async () => {
@@ -267,6 +286,10 @@ test.describe('Access Lists - CRUD Operations', () => {
await expect(typeSelect).toHaveValue('whitelist');
});
+ await test.step('Add an IP rule so the enabled allow-list is accepted', async () => {
+ await addIpRule(page, DOCUMENTATION_CIDR);
+ });
+
await test.step('Submit form', async () => {
const saveButton = getSaveButton(page);
await saveButton.click();
@@ -289,6 +312,63 @@ test.describe('Access Lists - CRUD Operations', () => {
});
});
+ test('should refuse an enabled name-only allow-list with the rules-required message', async ({ page }) => {
+ const aclName = `No Rules ACL ${generateUniqueId()}`;
+
+ await test.step('Open create form and enter only a name', async () => {
+ await getCreateButton(page).click();
+ await waitForModal(page, /create|access.*list/i);
+
+ await page.locator('#name').fill(aclName);
+ await expect(page.locator('#type')).toHaveValue('whitelist');
+ });
+
+ await test.step('Submit and expect the API refusal to be shown in the form', async () => {
+ await getSaveButton(page).click();
+
+ // The API answers 400 access_list_rules_required; the form stays open
+ // and renders the translated message (AccessListForm.tsx).
+ const saveError = page.getByTestId('access-list-save-error');
+ await expect(saveError).toBeVisible({ timeout: 5000 });
+ await expect(saveError).toHaveText(
+ /an enabled allow-list needs at least one rule/i
+ );
+ await expect(page.getByRole('heading', { name: /create.*access.*list/i })).toBeVisible();
+ // The form stays open with the error. The footer is pushed below the
+ // fold at this viewport, so Cancel is asserted attached rather than
+ // clicked; the page is discarded at test end.
+ await expect(getCancelButton(page)).toBeAttached();
+ });
+
+ await test.step('Verify the refused list was not persisted', async () => {
+ const response = await page.request.get('/api/v1/access-lists');
+ expect(response.ok()).toBeTruthy();
+ const lists = (await response.json()) as Array<{ name: string }>;
+ expect(lists.some((acl) => acl.name === aclName)).toBe(false);
+ });
+ });
+
+ test('should create a name-only block-list (blocks nobody, so no rule is required)', async ({ page }) => {
+ const aclName = `Block ACL ${generateUniqueId()}`;
+
+ await test.step('Open create form, choose blacklist, and enter only a name', async () => {
+ await getCreateButton(page).click();
+ await waitForModal(page, /create|access.*list/i);
+
+ await page.locator('#name').fill(aclName);
+ await page.locator('#type').selectOption('blacklist');
+ await expect(page.locator('#type')).toHaveValue('blacklist');
+ });
+
+ await test.step('Submit form and verify the block-list was saved', async () => {
+ await getSaveButton(page).click();
+
+ const formTitle = page.getByRole('heading', { name: /create.*access.*list/i });
+ await expect(formTitle).not.toBeVisible({ timeout: 10000 });
+ await expect(page.getByText(aclName)).toBeVisible({ timeout: 5000 });
+ });
+ });
+
test('should add client IP addresses', async ({ page }) => {
await test.step('Open create form', async () => {
await getCreateButton(page).click();
@@ -445,6 +525,7 @@ test.describe('Access Lists - CRUD Operations', () => {
await waitForModal(page, /create|access.*list/i);
await page.locator('#name').fill(aclName);
+ await addIpRule(page, DOCUMENTATION_CIDR);
await getSaveButton(page).click();
});
diff --git a/tests/security/crowdsec-hub-items.spec.ts b/tests/security/crowdsec-hub-items.spec.ts
index 676bd0200..16fb8fb2f 100644
--- a/tests/security/crowdsec-hub-items.spec.ts
+++ b/tests/security/crowdsec-hub-items.spec.ts
@@ -31,6 +31,7 @@ const HUB_UNREACHABLE_MESSAGE = 'the CrowdSec hub could not be reached; nothing
/** Anchored matchers: a loose glob would also match sibling endpoints. */
const IMPORT_ROUTE = /\/api\/v1\/admin\/crowdsec\/import(?:\?.*)?$/;
const RESTORE_ROUTE = /\/api\/v1\/admin\/crowdsec\/hub-items\/restore(?:\?.*)?$/;
+const BACKUPS_ROUTE = /\/api\/v1\/backups(?:\?.*)?$/;
interface HubItem {
type: string;
@@ -162,6 +163,19 @@ test.describe('CrowdSec Hub Item Restore @security', () => {
];
test.beforeEach(async ({ page, adminUser }) => {
+ // The import UI awaits a backup start before it posts the import. Backup jobs are single-flight on the
+ // server, so a real backup from the previous case can still be running when this one starts, and the start
+ // answers 409 ("another backup or restore is in progress"). The import is then never sent, the panel never
+ // renders, and the case times out. These cases test the panel, so the backup start is answered here.
+ await page.route(BACKUPS_ROUTE, (route) =>
+ route.request().method() === 'POST'
+ ? route.fulfill({
+ status: 202,
+ contentType: 'application/json',
+ json: { job_id: 'hub-items-panel-backup', type: 'create', status: 'pending' },
+ })
+ : route.fallback(),
+ );
await loginUser(page, adminUser);
await waitForLoadingComplete(page);
});
diff --git a/tests/utils/proxy-refusal-helpers.ts b/tests/utils/proxy-refusal-helpers.ts
new file mode 100644
index 000000000..a5b2ba341
--- /dev/null
+++ b/tests/utils/proxy-refusal-helpers.ts
@@ -0,0 +1,130 @@
+/**
+ * Helpers for security E2E cases that send requests through the Caddy proxy hop and check refused responses
+ * (403 from an access list, 429 from rate limiting).
+ *
+ * The proxy hop is reached on PLAYWRIGHT_CADDY_PROXY_PORT (default 80) with a Host header, so a case can target a
+ * proxy host it created without DNS. The shared setup keeps proxy hosts, header profiles and country lists in one
+ * place for the ACL, security headers and rate limit specs.
+ */
+
+import { expect, type APIRequestContext, type APIResponse } from '@playwright/test';
+import { PROXY_HOSTS_PATH, withRateLimitRetry } from './security-settings-api';
+
+const APP_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8080';
+const ACCESS_LISTS_PATH = '/api/v1/access-lists';
+const SECURITY_HEADER_PROFILES_PATH = '/api/v1/security/headers/profiles';
+
+/**
+ * Response headers that a profile created by createHeaderProfile() must produce.
+ * Playwright lowercases response header names.
+ */
+export const PROFILE_HEADER_EXPECTATIONS = {
+ 'x-frame-options': 'DENY',
+ 'x-content-type-options': 'nosniff',
+ 'referrer-policy': 'no-referrer',
+};
+
+export interface HeaderProfile {
+ id: number;
+ uuid: string;
+}
+
+export interface ProxyHostOptions {
+ domain: string;
+ forwardPort: number;
+ name: string;
+ accessListUuid?: string;
+ headerProfileUuid?: string;
+}
+
+/** Origin of the Caddy proxy hop, derived from the app URL the same way the route handling cases do. */
+export function proxyOrigin(): string {
+ const url = new URL(APP_BASE_URL);
+ return `${url.protocol}//${url.hostname}:${process.env.PLAYWRIGHT_CADDY_PROXY_PORT || '80'}`;
+}
+
+/** Sends one request through the proxy hop with the given Host header. Refused statuses are returned, not thrown. */
+export async function fetchThroughProxy(
+ context: APIRequestContext,
+ domain: string,
+ path: string,
+ method = 'GET'
+): Promise {
+ return context.fetch(`${proxyOrigin()}${path}`, {
+ method,
+ headers: { Host: domain },
+ data: method === 'POST' ? {} : undefined,
+ failOnStatusCode: false,
+ maxRedirects: 0,
+ });
+}
+
+/** Creates an allow-list that admits only the given countries. Returns its uuid. */
+export async function createCountryAllowList(
+ request: APIRequestContext,
+ name: string,
+ countryCodes = 'US'
+): Promise {
+ const response = await withRateLimitRetry(() =>
+ request.post(ACCESS_LISTS_PATH, {
+ data: { name, type: 'geo_whitelist', country_codes: countryCodes, enabled: true },
+ }),
+ );
+ expect(response.ok(), `create country allow list: ${response.status()}`).toBe(true);
+ return (await response.json()).uuid;
+}
+
+export async function deleteAccessList(request: APIRequestContext, uuid: string): Promise {
+ await withRateLimitRetry(() => request.delete(`${ACCESS_LISTS_PATH}/${uuid}`));
+}
+
+/** Creates a security header profile whose values match PROFILE_HEADER_EXPECTATIONS. */
+export async function createHeaderProfile(request: APIRequestContext, name: string): Promise {
+ const response = await withRateLimitRetry(() =>
+ request.post(SECURITY_HEADER_PROFILES_PATH, {
+ data: {
+ name,
+ hsts_enabled: false,
+ csp_enabled: false,
+ x_frame_options: 'DENY',
+ x_content_type_options: true,
+ referrer_policy: 'no-referrer',
+ xss_protection: false,
+ cache_control_no_store: false,
+ },
+ }),
+ );
+ expect(response.ok(), `create header profile: ${response.status()}`).toBe(true);
+ const { profile } = (await response.json()) as { profile: HeaderProfile };
+ return { id: profile.id, uuid: profile.uuid };
+}
+
+export async function deleteHeaderProfile(request: APIRequestContext, profile: HeaderProfile): Promise {
+ await withRateLimitRetry(() => request.delete(`${SECURITY_HEADER_PROFILES_PATH}/${profile.id}`));
+}
+
+/** Creates an enabled proxy host that forwards to a local port, optionally with an access list and header profile. */
+export async function createProxyHost(request: APIRequestContext, options: ProxyHostOptions): Promise {
+ const response = await withRateLimitRetry(() =>
+ request.post(PROXY_HOSTS_PATH, {
+ data: {
+ domain_names: options.domain,
+ forward_host: '127.0.0.1',
+ forward_port: options.forwardPort,
+ forward_scheme: 'http',
+ websocket_support: false,
+ enabled: true,
+ name: options.name,
+ access_list_id: options.accessListUuid,
+ security_header_profile_id: options.headerProfileUuid,
+ },
+ }),
+ );
+ expect(response.ok(), `create proxy host ${options.domain}: ${response.status()}`).toBe(true);
+ const body = await response.json();
+ return body.uuid ?? body.id;
+}
+
+export async function deleteProxyHost(request: APIRequestContext, uuid: string): Promise {
+ await withRateLimitRetry(() => request.delete(`${PROXY_HOSTS_PATH}/${uuid}`));
+}
diff --git a/tests/utils/security-settings-api.ts b/tests/utils/security-settings-api.ts
index a479611b8..51fcf22c3 100644
--- a/tests/utils/security-settings-api.ts
+++ b/tests/utils/security-settings-api.ts
@@ -93,8 +93,11 @@ interface RuleSetListEntry {
name: string;
}
-/** Retries a request the API rate-limits (HTTP 429), like the other security helpers. */
-async function withRateLimitRetry(send: () => Promise): Promise {
+/**
+ * Retries a request the API rate-limits (HTTP 429). Every API call from the runner shares one budget per client IP
+ * while the API limiter is on, so setup and cleanup calls use this too.
+ */
+export async function withRateLimitRetry(send: () => Promise): Promise {
const maxRetries = 5;
const retryDelayMs = 1000;
let response = await send();
@@ -186,6 +189,55 @@ export async function writeSettingValue(request: APIRequestContext, key: string,
expect(response.ok(), `POST ${SETTINGS_PATH} ${key} failed with ${response.status()}`).toBeTruthy();
}
+/**
+ * Settings that set the API limiter budget: the per-client limit on /api/v1 routes outside the admin control plane.
+ * Unlike the stored security config (which only drives the proxy limit), these keys are read by the API limiter itself,
+ * so a case that writes them must put them back.
+ */
+export const API_RATE_LIMIT_BUDGET_KEYS = [
+ 'security.rate_limit.requests',
+ 'security.rate_limit.window',
+ 'security.rate_limit.burst',
+] as const;
+
+export type ApiRateLimitBudgetKey = (typeof API_RATE_LIMIT_BUDGET_KEYS)[number];
+export type ApiRateLimitBudget = Record;
+
+/** The values the API limiter uses when no budget setting is stored. */
+export const STANDARD_API_RATE_LIMIT_BUDGET: ApiRateLimitBudget = {
+ 'security.rate_limit.requests': '100',
+ 'security.rate_limit.window': '60',
+ 'security.rate_limit.burst': '20',
+};
+
+/** Reads the stored API budget settings. An absent key is returned as ''. */
+export async function readApiRateLimitBudget(request: APIRequestContext): Promise {
+ const response = await withRateLimitRetry(() => request.get(SETTINGS_PATH));
+ expect(response.ok(), `GET ${SETTINGS_PATH} failed with ${response.status()}`).toBeTruthy();
+ const body = (await response.json()) as Record;
+ return Object.fromEntries(
+ API_RATE_LIMIT_BUDGET_KEYS.map((key) => [key, typeof body[key] === 'string' ? body[key] : '']),
+ ) as ApiRateLimitBudget;
+}
+
+/** Writes each budget key with the given value. */
+export async function writeApiRateLimitBudget(request: APIRequestContext, budget: ApiRateLimitBudget): Promise {
+ for (const key of API_RATE_LIMIT_BUDGET_KEYS) {
+ await writeSettingValue(request, key, budget[key]);
+ }
+}
+
+/**
+ * Puts back a budget captured by readApiRateLimitBudget. The settings API cannot delete a key, so a key that was
+ * absent is written with its standard value, which is the value the limiter already used for it.
+ */
+export async function restoreApiRateLimitBudget(request: APIRequestContext, captured: ApiRateLimitBudget): Promise {
+ const budget = Object.fromEntries(
+ API_RATE_LIMIT_BUDGET_KEYS.map((key) => [key, captured[key] || STANDARD_API_RATE_LIMIT_BUDGET[key]]),
+ ) as ApiRateLimitBudget;
+ await writeApiRateLimitBudget(request, budget);
+}
+
/** Calls the emergency endpoint with the break-glass token. Only the endpoint's own status is checked. */
export async function callEmergencyEndpoint(request: APIRequestContext): Promise {
const response = await withRateLimitRetry(() =>