diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7be39bf4e..84049ec18 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -603,7 +603,9 @@ 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.
+ - **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, country lists are not enforced: no `geoip2` handler and no country deny route are generated for them (fail-open, so a missing database never locks out visitors), with one Warn per apply and a passive `geoip_unavailable` reason on `/security/status`. The enforcement report of the last successful apply records which lists were left unenforced (`EnforcementReport.CountryListsWithoutDatabase`, read through `Manager.LastAppliedReport`); a failed apply keeps that report, because Caddy keeps running the last applied config. `POST /security/geoip/reload` loads the file and applies the proxy config even when the load fails.
+ - **Active alerts:** `internal/alerts` computes alerts at request time from the last applied report, not from stored rows. `GET /notifications/alerts` (management) returns them, and the notification bell shows them in red with a link to the GeoIP troubleshooting page. The current alert is `geoip_unavailable`, which appears while country lists are not enforced and clears by itself once a successful apply enforces them again.
+ - **GeoIP database store:** Replace, remove and reload (admin only, same-origin for cookie sessions) write only the data-folder copy `data/geoip/GeoLite2-Country.mmdb` (plus transient `.prev` and upload temp files). The bundled copy in `/app/geoip` is never modified. Backups do not include `data/geoip/`.
- **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
diff --git a/Dockerfile b/Dockerfile
index 0549910fc..f8672e434 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -19,8 +19,8 @@ ARG CHARON_TOOLCHAIN_IMAGE=ghcr.io/wikid82/charon-toolchain
# NOT Renovate-tracked (a content-hash tag has no series to follow, N7) — the
# toolchain-image.yml bot owns these two lines. DIGEST is the arch-independent
# manifest-list (OCI index) digest, so one pin covers linux/amd64 + linux/arm64.
-ARG CHARON_TOOLCHAIN_TAG=caddy-crowdsec-09e8bb5906bd32dc
-ARG CHARON_TOOLCHAIN_DIGEST=sha256:9a3dc19502d4db2b076c3e013c1e3ec11f631a718fb66c3b3014f67edb7cb351
+ARG CHARON_TOOLCHAIN_TAG=caddy-crowdsec-2ae83568f0fc23e9
+ARG CHARON_TOOLCHAIN_DIGEST=sha256:2fed4bb3067cb23229e7628590f6a0e2397bf44998ad913501a06768d24704c8
# Stage selector — default consumes the prebuilt toolchain image (no compile).
# Fork PRs / bootstrap / offline builds pass
@@ -31,7 +31,7 @@ ARG CROWDSEC_BUILDER_SRC=toolchain-prebuilt
# ---- Pinned Toolchain Versions ----
# renovate: datasource=docker depName=golang versioning=docker
-ARG GOLANG_IMAGE=golang:1.27.3-alpine@sha256:fa3d596898d9f126af7c01d14ee7597bab3f39cccf392ef36b69fe77cd681f18
+ARG GOLANG_IMAGE=golang:1.27.4-alpine@sha256:0a5f7fe796599ff487605987432e7619ae45beeb2fba4c2ce0b907879932aebf
# renovate: datasource=docker depName=alpine versioning=docker
ARG ALPINE_IMAGE=alpine:3.24.4@sha256:f59abe656b692d901e184be3445e084786602108599d2a51d229c0f5a48c71bf
@@ -880,7 +880,7 @@ RUN set -e; \
_retry go get go.opentelemetry.io/otel@v1.47.0; \
# GHSA-xmrv-pmrh-hhx2: AWS SDK v2 event stream injection
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream
- _retry go get github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream@v1.7.26; \
+ _retry go get github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream@v1.7.27; \
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs
_retry go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.92.1; \
_retry go get github.com/aws/aws-sdk-go-v2/service/kinesis@v1.43.7; \
diff --git a/agent/Dockerfile b/agent/Dockerfile
index b5bc4a386..9595597d1 100644
--- a/agent/Dockerfile
+++ b/agent/Dockerfile
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# renovate: datasource=docker depName=golang versioning=docker
-ARG GO_VERSION=1.27.3
+ARG GO_VERSION=1.27.4
# Build on the host platform to avoid emulation overhead; cross-compile to the target.
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS builder
diff --git a/agent/go.mod b/agent/go.mod
index 3170e1ce9..aed56d190 100644
--- a/agent/go.mod
+++ b/agent/go.mod
@@ -1,6 +1,6 @@
module github.com/Wikid82/charon/agent
-go 1.27.3
+go 1.27.4
require (
github.com/gorilla/websocket v1.5.3
diff --git a/backend/go.mod b/backend/go.mod
index b6dd1e218..c31147787 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -1,6 +1,6 @@
module github.com/Wikid82/charon/backend
-go 1.27.3
+go 1.27.4
require (
filippo.io/age v1.3.2
@@ -16,6 +16,7 @@ require (
github.com/minio/minio-go/v7 v7.4.1
github.com/moby/moby/client v0.6.2
github.com/oschwald/geoip2-golang/v2 v2.5.0
+ github.com/oschwald/maxminddb-golang/v2 v2.9.0
github.com/pkg/sftp v1.13.12
github.com/prometheus/client_golang v1.25.0
github.com/robfig/cron/v3 v3.0.1
@@ -26,6 +27,7 @@ require (
golang.org/x/mod v0.43.0
golang.org/x/net v0.62.0
golang.org/x/oauth2 v0.38.0
+ golang.org/x/sys v0.50.0
golang.org/x/text v0.44.0
golang.org/x/time v0.16.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
@@ -82,7 +84,6 @@ require (
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
- github.com/oschwald/maxminddb-golang/v2 v2.9.0 // indirect
github.com/pandatix/go-abnf v0.5.5 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/philhofer/fwd v1.2.1 // indirect
@@ -107,10 +108,9 @@ require (
go.opentelemetry.io/otel/trace v1.47.0 // indirect
go.yaml.in/yaml/v3 v3.0.6 // indirect
golang.org/x/arch v0.33.0 // indirect
- golang.org/x/sys v0.50.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/ini.v1 v1.67.3 // indirect
- modernc.org/libc v1.80.2 // indirect
+ modernc.org/libc v1.81.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.62.0 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index bf5e4720b..11cd1ce3b 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -262,10 +262,10 @@ gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
-modernc.org/cc/v4 v4.30.6-0.20261101114202-1b9d1a076f6b h1:WE5SLOURcNd8s6uPR/n4WV4weQxxYKi5OpEZkfNVvdE=
-modernc.org/cc/v4 v4.30.6-0.20261101114202-1b9d1a076f6b/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
-modernc.org/ccgo/v4 v4.38.1 h1:wdxJVBuFT8JufZnnQyS6Bbz3NOWhViD0xJOGR96Bpd4=
-modernc.org/ccgo/v4 v4.38.1/go.mod h1:kOpEkJRbFwBvjUWwn4j1j9B7iFHbl1+orhgTMCDkDBk=
+modernc.org/cc/v4 v4.34.0 h1:ooKvVkbVJApIi1dVGl9R0cxQz+UKixXd+zjUH6l8af0=
+modernc.org/cc/v4 v4.34.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.39.0 h1:Dk2UbgzY/DwlSKNHAJeO1hxGkkjiY3S1sgqSCrlDhzU=
+modernc.org/ccgo/v4 v4.39.0/go.mod h1:/0Re1N2uIR+YRP3B57BDD2XW+zUAnxqlT5GZfSP+vBE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -274,8 +274,8 @@ modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
-modernc.org/libc v1.80.2 h1:3+a4fJZvvzg920ZC0mOepnKPGn7cqCaVeDxm/UnrM1A=
-modernc.org/libc v1.80.2/go.mod h1:pvo25Rs3OJEbDqhUV9jdEGevxeLJrJeDmNid/AcQV1g=
+modernc.org/libc v1.81.0 h1:Kx11pb+4ETDPgkN9P6nHNTeVfrS+1w46LoIDbYcZEtM=
+modernc.org/libc v1.81.0/go.mod h1:xlOEexcpOVlRiVw/CDpTqkXxG2uXXGF4i8uR7BAM9sY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
diff --git a/backend/internal/alerts/alerts.go b/backend/internal/alerts/alerts.go
new file mode 100644
index 000000000..19819350c
--- /dev/null
+++ b/backend/internal/alerts/alerts.go
@@ -0,0 +1,103 @@
+// Package alerts computes the active alerts the notification bell shows. Each Source inspects one condition at request
+// time, so an alert disappears by itself once its condition stops holding.
+package alerts
+
+import (
+ "context"
+ "slices"
+ "sort"
+
+ "github.com/Wikid82/charon/backend/internal/logger"
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+// KeyGeoIPUnavailable is the key of the alert that country lists are not being enforced because no GeoIP database is
+// usable.
+const KeyGeoIPUnavailable = "geoip_unavailable"
+
+// MaxAffectedLists is the most list names one alert carries. The total still counts them all.
+const MaxAffectedLists = 5
+
+// Alert is one active condition shown to management users. Its JSON shape is the GET /notifications/alerts contract.
+type Alert struct {
+ Key string `json:"key"`
+ Severity models.NotificationType `json:"severity"`
+ AffectedLists []string `json:"affected_lists,omitempty"`
+ AffectedListsTotal int `json:"affected_lists_total,omitempty"`
+ DatabaseAvailable *bool `json:"database_available,omitempty"`
+}
+
+// Source reports one alert. ok is false when its condition does not hold. A returned error skips the source for this
+// request only.
+type Source func(ctx context.Context) (alert Alert, ok bool, err error)
+
+// Service evaluates its sources on every call.
+type Service struct {
+ sources []Source
+}
+
+// NewService returns a service that evaluates the given sources.
+func NewService(sources ...Source) *Service {
+ return &Service{sources: sources}
+}
+
+// Active returns the alerts whose sources report them. A failing source is logged and left out. When two sources give
+// the same key, the first one in order is kept. Errors skip only their own source. The result is sorted by severity
+// and then key, and is never nil.
+func (s *Service) Active(ctx context.Context) []Alert {
+ active := make([]Alert, 0, len(s.sources))
+ seen := make(map[string]bool, len(s.sources))
+ for i, source := range s.sources {
+ alert, ok, err := source(ctx)
+ if err != nil {
+ logger.Log().WithError(err).WithField("source", i).Warn("Alert source failed; leaving its alert out")
+ continue
+ }
+ if !ok || seen[alert.Key] {
+ continue
+ }
+ seen[alert.Key] = true
+ active = append(active, capAffectedLists(alert))
+ }
+ sort.SliceStable(active, func(i, j int) bool {
+ if ri, rj := severityRank(active[i].Severity), severityRank(active[j].Severity); ri != rj {
+ return ri < rj
+ }
+ return active[i].Key < active[j].Key
+ })
+ return active
+}
+
+// capAffectedLists sorts a copy of the alert's list names and keeps the first MaxAffectedLists. The total is raised to
+// the full count when the source reported fewer.
+func capAffectedLists(alert Alert) Alert {
+ if len(alert.AffectedLists) == 0 {
+ return alert
+ }
+ if alert.AffectedListsTotal < len(alert.AffectedLists) {
+ alert.AffectedListsTotal = len(alert.AffectedLists)
+ }
+ lists := slices.Clone(alert.AffectedLists)
+ slices.Sort(lists)
+ if len(lists) > MaxAffectedLists {
+ lists = lists[:MaxAffectedLists]
+ }
+ alert.AffectedLists = lists
+ return alert
+}
+
+// severityRank orders alerts so that errors come first. Unknown severities come last.
+func severityRank(severity models.NotificationType) int {
+ switch severity {
+ case models.NotificationTypeError:
+ return 0
+ case models.NotificationTypeWarning:
+ return 1
+ case models.NotificationTypeSuccess:
+ return 2
+ case models.NotificationTypeInfo:
+ return 3
+ default:
+ return 4
+ }
+}
diff --git a/backend/internal/alerts/alerts_test.go b/backend/internal/alerts/alerts_test.go
new file mode 100644
index 000000000..c6439c312
--- /dev/null
+++ b/backend/internal/alerts/alerts_test.go
@@ -0,0 +1,121 @@
+package alerts
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+func fixedSource(a Alert, ok bool, err error) Source {
+ return func(context.Context) (Alert, bool, error) { return a, ok, err }
+}
+
+func TestActiveEmptyWithoutSources(t *testing.T) {
+ svc := NewService()
+
+ got := svc.Active(context.Background())
+
+ require.NotNil(t, got, "the result is never nil, so it encodes as an empty array")
+ assert.Empty(t, got)
+}
+
+func TestActiveEmptyWhenNoSourceReports(t *testing.T) {
+ svc := NewService(fixedSource(Alert{}, false, nil))
+
+ got := svc.Active(context.Background())
+
+ require.NotNil(t, got)
+ assert.Empty(t, got)
+}
+
+func TestActiveSkipsFailingSource(t *testing.T) {
+ failing := fixedSource(Alert{Key: "broken", Severity: models.NotificationTypeError}, true, errors.New("boom"))
+ healthy := fixedSource(Alert{Key: "ok", Severity: models.NotificationTypeWarning}, true, nil)
+
+ got := NewService(failing, healthy).Active(context.Background())
+
+ require.Len(t, got, 1)
+ assert.Equal(t, "ok", got[0].Key)
+}
+
+func TestActiveDropsDuplicateKeysKeepingFirst(t *testing.T) {
+ first := Alert{Key: KeyGeoIPUnavailable, Severity: models.NotificationTypeError, AffectedListsTotal: 1}
+ second := Alert{Key: KeyGeoIPUnavailable, Severity: models.NotificationTypeError, AffectedListsTotal: 9}
+
+ got := NewService(fixedSource(first, true, nil), fixedSource(second, true, nil)).Active(context.Background())
+
+ require.Len(t, got, 1)
+ assert.Equal(t, 1, got[0].AffectedListsTotal)
+}
+
+func TestActiveSortsBySeverityThenKey(t *testing.T) {
+ sources := []Source{
+ fixedSource(Alert{Key: "b-info", Severity: models.NotificationTypeInfo}, true, nil),
+ fixedSource(Alert{Key: "z-error", Severity: models.NotificationTypeError}, true, nil),
+ fixedSource(Alert{Key: "a-warning", Severity: models.NotificationTypeWarning}, true, nil),
+ fixedSource(Alert{Key: "a-error", Severity: models.NotificationTypeError}, true, nil),
+ fixedSource(Alert{Key: "x-unknown", Severity: models.NotificationType("other")}, true, nil),
+ fixedSource(Alert{Key: "a-success", Severity: models.NotificationTypeSuccess}, true, nil),
+ }
+
+ got := NewService(sources...).Active(context.Background())
+
+ keys := make([]string, 0, len(got))
+ for _, a := range got {
+ keys = append(keys, a.Key)
+ }
+ assert.Equal(t, []string{"a-error", "z-error", "a-warning", "a-success", "b-info", "x-unknown"}, keys)
+}
+
+func TestActiveCapsAffectedListsWithTotal(t *testing.T) {
+ names := []string{"g", "c", "f", "a", "e", "b", "d"}
+ alert := Alert{Key: KeyGeoIPUnavailable, Severity: models.NotificationTypeError, AffectedLists: names}
+
+ got := NewService(fixedSource(alert, true, nil)).Active(context.Background())
+
+ require.Len(t, got, 1)
+ assert.Equal(t, []string{"a", "b", "c", "d", "e"}, got[0].AffectedLists)
+ assert.Equal(t, 7, got[0].AffectedListsTotal)
+ assert.Equal(t, []string{"g", "c", "f", "a", "e", "b", "d"}, names, "the source's slice is not reordered")
+}
+
+func TestActiveKeepsLargerSourceTotal(t *testing.T) {
+ alert := Alert{Key: KeyGeoIPUnavailable, Severity: models.NotificationTypeError, AffectedLists: []string{"b", "a"}, AffectedListsTotal: 12}
+
+ got := NewService(fixedSource(alert, true, nil)).Active(context.Background())
+
+ require.Len(t, got, 1)
+ assert.Equal(t, []string{"a", "b"}, got[0].AffectedLists)
+ assert.Equal(t, 12, got[0].AffectedListsTotal)
+}
+
+func TestActiveLeavesAlertWithoutListsUntouched(t *testing.T) {
+ alert := Alert{Key: "plain", Severity: models.NotificationTypeInfo}
+
+ got := NewService(fixedSource(alert, true, nil)).Active(context.Background())
+
+ require.Len(t, got, 1)
+ assert.Nil(t, got[0].AffectedLists)
+ assert.Zero(t, got[0].AffectedListsTotal)
+}
+
+func TestActivePassesDatabaseFlagThrough(t *testing.T) {
+ available := false
+ alert := Alert{Key: KeyGeoIPUnavailable, Severity: models.NotificationTypeError, DatabaseAvailable: &available}
+
+ got := NewService(fixedSource(alert, true, nil)).Active(context.Background())
+
+ require.Len(t, got, 1)
+ require.NotNil(t, got[0].DatabaseAvailable)
+ assert.False(t, *got[0].DatabaseAvailable)
+}
+
+func TestConstants(t *testing.T) {
+ assert.Equal(t, "geoip_unavailable", KeyGeoIPUnavailable)
+ assert.Equal(t, 5, MaxAffectedLists)
+}
diff --git a/backend/internal/api/handlers/access_list_handler.go b/backend/internal/api/handlers/access_list_handler.go
index d5833a305..21bb41552 100644
--- a/backend/internal/api/handlers/access_list_handler.go
+++ b/backend/internal/api/handlers/access_list_handler.go
@@ -10,6 +10,7 @@ import (
"github.com/Wikid82/charon/backend/internal/api/middleware"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
+ "github.com/Wikid82/charon/backend/internal/util"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
@@ -250,6 +251,55 @@ func (h *AccessListHandler) TestIP(c *gin.Context) {
})
}
+// AccessListSelfCheckResponse is the decision the proxy would make for the caller's current address under an unsaved
+// access list draft. reason is one of the services.Reason* identifiers.
+type AccessListSelfCheckResponse struct {
+ IPAddress string `json:"ip_address"`
+ CountryCode string `json:"country_code"`
+ Allowed bool `json:"allowed"`
+ AdminExempt bool `json:"admin_exempt"`
+ GeoIPAvailable bool `json:"geoip_available"`
+ Reason string `json:"reason"`
+}
+
+// SelfCheck handles POST /api/v1/access-lists/self-check. It evaluates the posted draft for the address the caller
+// connects from, using the same evaluation as the tester and the proxy. Nothing is saved. Admin only.
+func (h *AccessListHandler) SelfCheck(c *gin.Context) {
+ if !requireAdmin(c) {
+ return
+ }
+
+ var draft models.AccessList
+ if err := c.ShouldBindJSON(&draft); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid access list draft"})
+ return
+ }
+ if err := h.service.ValidateDraft(&draft); err != nil {
+ respondAccessListSaveError(c, err)
+ return
+ }
+
+ ip, err := netip.ParseAddr(util.CanonicalizeIPForSecurity(c.ClientIP()))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
+ return
+ }
+ decision, err := h.service.Evaluate(&draft, ip.Unmap(), h.service.AdminWhitelistRanges())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
+ return
+ }
+
+ c.JSON(http.StatusOK, AccessListSelfCheckResponse{
+ IPAddress: ip.Unmap().String(),
+ CountryCode: decision.CountryCode,
+ Allowed: decision.Allowed,
+ AdminExempt: decision.AdminExempt,
+ GeoIPAvailable: decision.GeoIPAvailable,
+ Reason: decision.Reason,
+ })
+}
+
// accessListDecisionText returns the sentence the tester shows for a decision reason.
func accessListDecisionText(reason string) string {
switch reason {
@@ -260,7 +310,7 @@ func accessListDecisionText(reason string) string {
case services.ReasonAllowed:
return "Allowed by access list"
case services.ReasonGeoIPUnavailable:
- return "GeoIP database not available - country lists refuse every address except admin addresses"
+ return "GeoIP database not available - country lists are not enforced"
case services.ReasonCountryNotAllowed:
return "Blocked by geo whitelist: country is not in the allowed countries"
case services.ReasonCountryBlocked:
diff --git a/backend/internal/api/handlers/access_list_handler_evaluate_test.go b/backend/internal/api/handlers/access_list_handler_evaluate_test.go
index eb65508c7..6630d1b67 100644
--- a/backend/internal/api/handlers/access_list_handler_evaluate_test.go
+++ b/backend/internal/api/handlers/access_list_handler_evaluate_test.go
@@ -73,7 +73,8 @@ func TestAccessListTester_AdminWhitelistExemptsListedAddress(t *testing.T) {
assert.Equal(t, "Address is in the admin whitelist", body["reason"])
}
-func TestAccessListTester_CountryListRefusesWithoutDatabase(t *testing.T) {
+// With no database the country list is not enforced, so the tester allows the address, as the proxy does.
+func TestAccessListTester_CountryListAllowsWithoutDatabase(t *testing.T) {
router, db := setupTesterDB(t, true)
require.NoError(t, db.Create(&models.AccessList{
Name: "de only",
@@ -84,8 +85,8 @@ func TestAccessListTester_CountryListRefusesWithoutDatabase(t *testing.T) {
status, body := postAccessListTest(t, router, "203.0.113.9")
assert.Equal(t, http.StatusOK, status)
- assert.Equal(t, false, body["allowed"])
- assert.Contains(t, body["reason"], "GeoIP database not available")
+ assert.Equal(t, true, body["allowed"])
+ assert.Contains(t, body["reason"], "country lists are not enforced")
}
func TestAccessListTester_InvalidAddress(t *testing.T) {
@@ -136,7 +137,7 @@ func TestAccessListDecisionText(t *testing.T) {
services.ReasonListDisabled: "Access list is disabled - all traffic allowed",
services.ReasonAdminWhitelisted: "Address is in the admin whitelist",
services.ReasonAllowed: "Allowed by access list",
- services.ReasonGeoIPUnavailable: "GeoIP database not available - country lists refuse every address except admin addresses",
+ services.ReasonGeoIPUnavailable: "GeoIP database not available - country lists are not enforced",
services.ReasonCountryNotAllowed: "Blocked by geo whitelist: country is not in the allowed countries",
services.ReasonCountryBlocked: "Blocked by geo blacklist: country is in the blocked countries",
services.ReasonNoCountry: "No country found for IP",
diff --git a/backend/internal/api/handlers/access_list_self_check_test.go b/backend/internal/api/handlers/access_list_self_check_test.go
new file mode 100644
index 000000000..690861161
--- /dev/null
+++ b/backend/internal/api/handlers/access_list_self_check_test.go
@@ -0,0 +1,262 @@
+package handlers
+
+import (
+ "bytes"
+ "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/api/middleware"
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/Wikid82/charon/backend/internal/services"
+ "github.com/Wikid82/charon/backend/internal/testutil"
+)
+
+const selfCheckPath = "/access-lists/self-check"
+
+// selfCheckFixture serves the self-check handler directly, so its own admin check is what refuses non-admin callers.
+type selfCheckFixture struct {
+ t *testing.T
+ db *gorm.DB
+ handler *AccessListHandler
+ router *gin.Engine
+}
+
+// newSelfCheckFixture builds the router. role is the caller's role.
+func newSelfCheckFixture(t *testing.T, role string) *selfCheckFixture {
+ t.Helper()
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.AccessList{}, &models.ProxyHost{}, &models.SecurityConfig{}))
+
+ handler := NewAccessListHandler(db)
+ router := gin.New()
+ router.POST(selfCheckPath, func(c *gin.Context) {
+ c.Set(middleware.RoleKey, role)
+ c.Next()
+ }, handler.SelfCheck)
+ return &selfCheckFixture{t: t, db: db, handler: handler, router: router}
+}
+
+// post sends a draft to the self-check from remoteAddr and returns the status and decoded body.
+func (f *selfCheckFixture) post(draft any, remoteAddr string) (status int, body map[string]any) {
+ f.t.Helper()
+ payload, err := json.Marshal(draft)
+ require.NoError(f.t, err)
+ req := httptest.NewRequest(http.MethodPost, selfCheckPath, bytes.NewReader(payload))
+ req.Header.Set("Content-Type", "application/json")
+ req.RemoteAddr = remoteAddr
+ w := httptest.NewRecorder()
+ f.router.ServeHTTP(w, req)
+
+ require.NoError(f.t, json.Unmarshal(w.Body.Bytes(), &body), w.Body.String())
+ return w.Code, body
+}
+
+// accessListCount returns how many access list rows exist, so a test can confirm a draft was not saved.
+func (f *selfCheckFixture) accessListCount() int64 {
+ f.t.Helper()
+ var n int64
+ require.NoError(f.t, f.db.Model(&models.AccessList{}).Count(&n).Error)
+ return n
+}
+
+// clientAddr is the address the caller connects from in most tests.
+const clientAddr = "203.0.113.9:44321"
+
+// selfCheckDraft mirrors the request fields the self-check accepts.
+type selfCheckDraft struct {
+ Type string `json:"type"`
+ CountryCodes string `json:"country_codes"`
+ IPRules string `json:"ip_rules"`
+ LocalNetworkOnly bool `json:"local_network_only"`
+ Enabled bool `json:"enabled"`
+}
+
+// useCountryDatabase loads a country database that resolves every address to code and wires it into the handler.
+func (f *selfCheckFixture) useCountryDatabase(code string) {
+ f.t.Helper()
+ path := filepath.Join(f.t.TempDir(), "GeoLite2-Country.mmdb")
+ testutil.WriteGeoIPCountryDB(f.t, path, 1_700_000_000,
+ testutil.WithGeoIPDatabaseType("GeoLite2-Country"),
+ testutil.WithGeoIPRecordISOCode(code))
+ svc, err := services.NewGeoIPService(path)
+ require.NoError(f.t, err)
+ f.t.Cleanup(func() { _ = svc.Close() })
+ f.handler.SetGeoIPService(svc)
+}
+
+func TestAccessListSelfCheck_RequiresAdmin(t *testing.T) {
+ f := newSelfCheckFixture(t, string(models.RoleUser))
+
+ status, body := f.post(selfCheckDraft{Type: "blacklist", Enabled: true}, clientAddr)
+
+ assert.Equal(t, http.StatusForbidden, status)
+ assert.Equal(t, "permissions_admin_only", body["error_code"])
+}
+
+func TestAccessListSelfCheck_InvalidBody(t *testing.T) {
+ f := newSelfCheckFixture(t, string(models.RoleAdmin))
+ req := httptest.NewRequest(http.MethodPost, selfCheckPath, bytes.NewReader([]byte("{not json")))
+ req.Header.Set("Content-Type", "application/json")
+ req.RemoteAddr = clientAddr
+ w := httptest.NewRecorder()
+
+ f.router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestAccessListSelfCheck_ValidationRefusedLikeCreate(t *testing.T) {
+ f := newSelfCheckFixture(t, string(models.RoleAdmin))
+
+ status, body := f.post(selfCheckDraft{Type: "geo_whitelist", Enabled: true}, clientAddr)
+
+ assert.Equal(t, http.StatusBadRequest, status)
+ assert.Equal(t, "country codes are required for geo-blocking", body["error"])
+}
+
+func TestAccessListSelfCheck_InvalidTypeRefused(t *testing.T) {
+ f := newSelfCheckFixture(t, string(models.RoleAdmin))
+
+ status, _ := f.post(selfCheckDraft{Type: "nonsense", Enabled: true}, clientAddr)
+
+ assert.Equal(t, http.StatusBadRequest, status)
+}
+
+func TestAccessListSelfCheck_RulesRequiredCarriesErrorCode(t *testing.T) {
+ f := newSelfCheckFixture(t, string(models.RoleAdmin))
+
+ status, body := f.post(selfCheckDraft{Type: "whitelist", Enabled: true}, clientAddr)
+
+ assert.Equal(t, http.StatusBadRequest, status)
+ assert.Equal(t, "access_list_rules_required", body["error_code"])
+}
+
+func TestAccessListSelfCheck_NoDatabaseAllowsWithReason(t *testing.T) {
+ f := newSelfCheckFixture(t, string(models.RoleAdmin))
+
+ status, body := f.post(selfCheckDraft{Type: "geo_whitelist", CountryCodes: "DE", Enabled: true}, clientAddr)
+
+ require.Equal(t, http.StatusOK, status)
+ assert.Equal(t, "203.0.113.9", body["ip_address"])
+ assert.Equal(t, "", body["country_code"])
+ assert.Equal(t, true, body["allowed"])
+ assert.Equal(t, false, body["admin_exempt"])
+ assert.Equal(t, false, body["geoip_available"])
+ assert.Equal(t, services.ReasonGeoIPUnavailable, body["reason"])
+}
+
+func TestAccessListSelfCheck_CountryOutsideAllowedListRefused(t *testing.T) {
+ f := newSelfCheckFixture(t, string(models.RoleAdmin))
+ f.useCountryDatabase("US")
+
+ status, body := f.post(selfCheckDraft{Type: "geo_whitelist", CountryCodes: "DE", Enabled: true}, clientAddr)
+
+ require.Equal(t, http.StatusOK, status)
+ assert.Equal(t, "US", body["country_code"])
+ assert.Equal(t, false, body["allowed"])
+ assert.Equal(t, true, body["geoip_available"])
+ assert.Equal(t, services.ReasonCountryNotAllowed, body["reason"])
+}
+
+func TestAccessListSelfCheck_CountryInAllowedListAllowed(t *testing.T) {
+ f := newSelfCheckFixture(t, string(models.RoleAdmin))
+ f.useCountryDatabase("US")
+
+ status, body := f.post(selfCheckDraft{Type: "geo_whitelist", CountryCodes: "us", Enabled: true}, clientAddr)
+
+ require.Equal(t, http.StatusOK, status)
+ assert.Equal(t, true, body["allowed"])
+ assert.Equal(t, services.ReasonAllowed, body["reason"])
+}
+
+func TestAccessListSelfCheck_IPAllowListRefusesOtherAddress(t *testing.T) {
+ f := newSelfCheckFixture(t, string(models.RoleAdmin))
+
+ status, body := f.post(selfCheckDraft{
+ Type: "whitelist",
+ IPRules: `[{"cidr":"10.0.0.0/8"}]`,
+ Enabled: true,
+ }, clientAddr)
+
+ require.Equal(t, http.StatusOK, status)
+ assert.Equal(t, false, body["allowed"])
+ assert.Equal(t, services.ReasonIPNotAllowed, body["reason"])
+}
+
+func TestAccessListSelfCheck_AdminWhitelistExempts(t *testing.T) {
+ f := newSelfCheckFixture(t, string(models.RoleAdmin))
+ require.NoError(t, f.db.Create(&models.SecurityConfig{
+ Name: models.DefaultSecurityConfigName,
+ AdminWhitelist: "203.0.113.0/24",
+ }).Error)
+
+ status, body := f.post(selfCheckDraft{
+ Type: "whitelist",
+ IPRules: `[{"cidr":"10.0.0.0/8"}]`,
+ Enabled: true,
+ }, clientAddr)
+
+ require.Equal(t, http.StatusOK, status)
+ assert.Equal(t, true, body["allowed"])
+ assert.Equal(t, true, body["admin_exempt"])
+ assert.Equal(t, services.ReasonAdminWhitelisted, body["reason"])
+}
+
+func TestAccessListSelfCheck_DisabledDraftAllows(t *testing.T) {
+ f := newSelfCheckFixture(t, string(models.RoleAdmin))
+
+ status, body := f.post(selfCheckDraft{Type: "blacklist", Enabled: false}, clientAddr)
+
+ require.Equal(t, http.StatusOK, status)
+ assert.Equal(t, true, body["allowed"])
+ assert.Equal(t, services.ReasonListDisabled, body["reason"])
+}
+
+func TestAccessListSelfCheck_CanonicalizesMappedAddress(t *testing.T) {
+ f := newSelfCheckFixture(t, string(models.RoleAdmin))
+
+ status, body := f.post(selfCheckDraft{Type: "blacklist", Enabled: false}, "[::ffff:203.0.113.9]:44321")
+
+ require.Equal(t, http.StatusOK, status)
+ assert.Equal(t, "203.0.113.9", body["ip_address"])
+}
+
+func TestAccessListSelfCheck_UnresolvableClientAddress(t *testing.T) {
+ f := newSelfCheckFixture(t, string(models.RoleAdmin))
+
+ status, _ := f.post(selfCheckDraft{Type: "blacklist", Enabled: true}, "not-an-address")
+
+ assert.Equal(t, http.StatusInternalServerError, status)
+}
+
+func TestAccessListSelfCheck_EvaluateErrorIsGeneric(t *testing.T) {
+ f := newSelfCheckFixture(t, string(models.RoleAdmin))
+ require.NoError(t, f.db.Create(&models.SecurityConfig{
+ Name: models.DefaultSecurityConfigName,
+ AdminWhitelist: "not-a-range",
+ }).Error)
+
+ status, body := f.post(selfCheckDraft{Type: "blacklist", Enabled: true}, clientAddr)
+
+ assert.Equal(t, http.StatusInternalServerError, status)
+ assert.Equal(t, "internal server error", body["error"])
+}
+
+func TestAccessListSelfCheck_NothingPersisted(t *testing.T) {
+ f := newSelfCheckFixture(t, string(models.RoleAdmin))
+
+ status, _ := f.post(selfCheckDraft{Type: "blacklist", Enabled: true}, clientAddr)
+
+ require.Equal(t, http.StatusOK, status)
+ assert.Equal(t, int64(0), f.accessListCount())
+}
diff --git a/backend/internal/api/handlers/alerts_handler.go b/backend/internal/api/handlers/alerts_handler.go
new file mode 100644
index 000000000..0bea6ac08
--- /dev/null
+++ b/backend/internal/api/handlers/alerts_handler.go
@@ -0,0 +1,24 @@
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/Wikid82/charon/backend/internal/alerts"
+)
+
+// AlertsHandler serves the active alerts shown by the notification bell.
+type AlertsHandler struct {
+ svc *alerts.Service
+}
+
+// NewAlertsHandler returns a handler that lists the active alerts of svc.
+func NewAlertsHandler(svc *alerts.Service) *AlertsHandler {
+ return &AlertsHandler{svc: svc}
+}
+
+// List returns the active alerts computed at request time. It always answers 200.
+func (h *AlertsHandler) List(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"alerts": h.svc.Active(c.Request.Context())})
+}
diff --git a/backend/internal/api/handlers/alerts_handler_test.go b/backend/internal/api/handlers/alerts_handler_test.go
new file mode 100644
index 000000000..842f97a55
--- /dev/null
+++ b/backend/internal/api/handlers/alerts_handler_test.go
@@ -0,0 +1,75 @@
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/alerts"
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+func serveAlerts(t *testing.T, svc *alerts.Service) *httptest.ResponseRecorder {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.GET("/api/v1/notifications/alerts", NewAlertsHandler(svc).List)
+
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/notifications/alerts", http.NoBody))
+ return w
+}
+
+func TestAlertsHandler_EmptyStateIsEmptyArray(t *testing.T) {
+ w := serveAlerts(t, alerts.NewService())
+
+ require.Equal(t, http.StatusOK, w.Code)
+ assert.JSONEq(t, `{"alerts": []}`, w.Body.String())
+}
+
+func TestAlertsHandler_ReturnsAlertContract(t *testing.T) {
+ available := false
+ svc := alerts.NewService(func(context.Context) (alerts.Alert, bool, error) {
+ return alerts.Alert{
+ Key: alerts.KeyGeoIPUnavailable,
+ Severity: models.NotificationTypeError,
+ AffectedLists: []string{"Office only", "No RU/CN"},
+ AffectedListsTotal: 2,
+ DatabaseAvailable: &available,
+ }, true, nil
+ })
+
+ w := serveAlerts(t, svc)
+
+ require.Equal(t, http.StatusOK, w.Code)
+ assert.JSONEq(t, `{"alerts": [{
+ "key": "geoip_unavailable",
+ "severity": "error",
+ "affected_lists": ["No RU/CN", "Office only"],
+ "affected_lists_total": 2,
+ "database_available": false
+ }]}`, w.Body.String())
+}
+
+func TestAlertsHandler_FailingSourceStillReturns200(t *testing.T) {
+ svc := alerts.NewService(func(context.Context) (alerts.Alert, bool, error) {
+ return alerts.Alert{}, false, errors.New("source broke")
+ })
+
+ w := serveAlerts(t, svc)
+
+ require.Equal(t, http.StatusOK, w.Code)
+ var body struct {
+ Alerts []json.RawMessage `json:"alerts"`
+ }
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+ assert.NotNil(t, body.Alerts)
+ assert.Empty(t, body.Alerts)
+}
diff --git a/backend/internal/api/handlers/security_geoip_alert_source_test.go b/backend/internal/api/handlers/security_geoip_alert_source_test.go
new file mode 100644
index 000000000..5c39c2d5b
--- /dev/null
+++ b/backend/internal/api/handlers/security_geoip_alert_source_test.go
@@ -0,0 +1,132 @@
+package handlers
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+
+ "github.com/Wikid82/charon/backend/internal/alerts"
+ "github.com/Wikid82/charon/backend/internal/caddy"
+ "github.com/Wikid82/charon/backend/internal/config"
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/Wikid82/charon/backend/internal/services"
+)
+
+// aclOnAlertHandler returns a handler whose static configuration turns access control on, with the given GeoIP service.
+func aclOnAlertHandler(t *testing.T, db *gorm.DB, manager CaddyConfigManager, svc *services.GeoIPService) *SecurityHandler {
+ t.Helper()
+ h := NewSecurityHandler(config.SecurityConfig{ACLMode: "enabled"}, db, manager)
+ h.SetGeoIPService(svc)
+ t.Cleanup(h.Close)
+ return h
+}
+
+// unloadedAlertService returns a GeoIP service with no database loaded.
+func unloadedAlertService(t *testing.T) *services.GeoIPService {
+ t.Helper()
+ return services.NewUnloadedGeoIPService(filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb"))
+}
+
+// loadedAlertService returns a GeoIP service with a usable database loaded from disk.
+func loadedAlertService(t *testing.T) *services.GeoIPService {
+ t.Helper()
+ 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() })
+ return svc
+}
+
+// sourceAlert evaluates the GeoIP alert source once.
+func sourceAlert(t *testing.T, h *SecurityHandler) (alerts.Alert, bool) {
+ t.Helper()
+ alert, ok, err := h.GeoIPAlertSource()(context.Background())
+ require.NoError(t, err, "the source never fails")
+ return alert, ok
+}
+
+func TestGeoIPAlertSource_InactiveWithoutUnenforcedLists(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ h := aclOnAlertHandler(t, db, &plainManager{}, unloadedAlertService(t))
+
+ _, ok := sourceAlert(t, h)
+ assert.False(t, ok, "no country list in use, so no alert")
+}
+
+func TestGeoIPAlertSource_PendingApplyWithoutDatabaseNamesList(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ list := createCountryList(t, db, 1, "Countries", "geo_whitelist", true)
+ attachHost(t, db, "site", list, true)
+ h := aclOnAlertHandler(t, db, &plainManager{}, unloadedAlertService(t))
+
+ alert, ok := sourceAlert(t, h)
+ require.True(t, ok)
+ assert.Equal(t, alerts.KeyGeoIPUnavailable, alert.Key)
+ assert.Equal(t, models.NotificationTypeError, alert.Severity)
+ assert.Equal(t, []string{"Countries"}, alert.AffectedLists)
+ assert.Equal(t, 1, alert.AffectedListsTotal)
+ require.NotNil(t, alert.DatabaseAvailable)
+ assert.False(t, *alert.DatabaseAvailable)
+}
+
+// The database can be usable while the last applied config still does not enforce the list. The alert stays, and it
+// says the database is available so the bell can ask for a reload.
+func TestGeoIPAlertSource_DatabaseAvailableWithStaleAppliedReportStaysActive(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ manager := &appliedManager{applied: true, report: caddy.EnforcementReport{ACLOn: true, CountryListsWithoutDatabase: []string{"Countries"}}}
+ h := aclOnAlertHandler(t, db, manager, loadedAlertService(t))
+
+ alert, ok := sourceAlert(t, h)
+ require.True(t, ok)
+ assert.Equal(t, []string{"Countries"}, alert.AffectedLists)
+ require.NotNil(t, alert.DatabaseAvailable)
+ assert.True(t, *alert.DatabaseAvailable)
+}
+
+func TestGeoIPAlertSource_AppliedReportWithoutListsIsInactive(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ manager := &appliedManager{applied: true, report: caddy.EnforcementReport{ACLOn: true}}
+ h := aclOnAlertHandler(t, db, manager, unloadedAlertService(t))
+
+ _, ok := sourceAlert(t, h)
+ assert.False(t, ok)
+}
+
+func TestGeoIPAlertSource_AppliedReportWithAccessControlOffIsInactive(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ manager := &appliedManager{applied: true, report: caddy.EnforcementReport{ACLOn: false, CountryListsWithoutDatabase: []string{"Countries"}}}
+ h := aclOnAlertHandler(t, db, manager, unloadedAlertService(t))
+
+ _, ok := sourceAlert(t, h)
+ assert.False(t, ok)
+}
+
+// The alert carries at most alerts.MaxAffectedLists names, and the total counts them all.
+func TestGeoIPAlertSource_CapsAffectedListsWithTotal(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ names := []string{"A", "B", "C", "D", "E", "F", "G"}
+ manager := &appliedManager{applied: true, report: caddy.EnforcementReport{ACLOn: true, CountryListsWithoutDatabase: names}}
+ h := aclOnAlertHandler(t, db, manager, unloadedAlertService(t))
+
+ alert, ok := sourceAlert(t, h)
+ require.True(t, ok)
+ assert.Equal(t, names[:alerts.MaxAffectedLists], alert.AffectedLists)
+ assert.Equal(t, len(names), alert.AffectedListsTotal)
+}
+
+// The source plugs into the alert service the bell reads, and the alert comes out with its contract fields.
+func TestGeoIPAlertSource_ReachesAlertServiceActive(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ manager := &appliedManager{applied: true, report: caddy.EnforcementReport{ACLOn: true, CountryListsWithoutDatabase: []string{"Countries"}}}
+ h := aclOnAlertHandler(t, db, manager, unloadedAlertService(t))
+
+ active := alerts.NewService(h.GeoIPAlertSource()).Active(context.Background())
+ require.Len(t, active, 1)
+ assert.Equal(t, alerts.KeyGeoIPUnavailable, active[0].Key)
+}
diff --git a/backend/internal/api/handlers/security_geoip_database_test.go b/backend/internal/api/handlers/security_geoip_database_test.go
new file mode 100644
index 000000000..ef53621fe
--- /dev/null
+++ b/backend/internal/api/handlers/security_geoip_database_test.go
@@ -0,0 +1,745 @@
+package handlers
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/textproto"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "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/api/middleware"
+ "github.com/Wikid82/charon/backend/internal/config"
+ "github.com/Wikid82/charon/backend/internal/geoip"
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/Wikid82/charon/backend/internal/services"
+ "github.com/Wikid82/charon/backend/internal/testutil"
+)
+
+// geoipDBFixture is the GeoIP database API over a temporary data folder, bundled copy and audit database. The routes
+// mirror routes.go: the admin role gate and the same-origin check sit in front of the two mutating handlers.
+type geoipDBFixture struct {
+ t *testing.T
+ router *gin.Engine
+ handler *SecurityHandler
+ db *gorm.DB
+ root string
+ dataDir string
+ dataPath string
+ bundledPath string
+ applyCalls int
+}
+
+type geoipDBOptions struct {
+ role string
+ env bool
+ noStore bool
+ applyErr error
+ // apply, when set, replaces the default apply hook.
+ apply func(context.Context) error
+}
+
+func newGeoIPDBFixture(t *testing.T, opts geoipDBOptions) *geoipDBFixture {
+ t.Helper()
+ if opts.role == "" {
+ opts.role = string(models.RoleAdmin)
+ }
+ root := t.TempDir()
+ db, err := gorm.Open(sqlite.Open(filepath.Join(root, "test.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.SecurityAudit{}, &models.AccessList{}))
+
+ f := &geoipDBFixture{
+ t: t,
+ db: db,
+ root: root,
+ dataDir: filepath.Join(root, "data", "geoip"),
+ bundledPath: filepath.Join(root, "bundled", geoip.DatabaseFileName),
+ }
+ f.dataPath = filepath.Join(f.dataDir, geoip.DatabaseFileName)
+
+ h := NewSecurityHandler(config.SecurityConfig{}, db, nil)
+ t.Cleanup(h.Close)
+ f.handler = h
+ if !opts.noStore {
+ svc := services.NewUnloadedGeoIPService(f.dataPath)
+ apply := opts.apply
+ if apply == nil {
+ apply = func(context.Context) error {
+ f.applyCalls++
+ return opts.applyErr
+ }
+ }
+ h.geoipSvc = svc
+ h.geoipStore = services.NewGeoIPDatabaseStore(services.GeoIPDatabaseStoreConfig{
+ Service: svc,
+ Apply: apply,
+ DataPath: f.dataPath,
+ BundledPath: f.bundledPath,
+ EnvInEffect: func() bool { return opts.env },
+ })
+ }
+
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set(middleware.RoleKey, opts.role)
+ c.Set(middleware.UserIDKey, uint(7))
+ c.Next()
+ })
+ securityAdmin := r.Group("/security")
+ securityAdmin.Use(middleware.RequireRole(models.RoleAdmin))
+ securityAdmin.POST("/geoip/database", middleware.RequireSameOriginForBrowserAuth(), h.ReplaceGeoIPDatabase)
+ securityAdmin.DELETE("/geoip/database", middleware.RequireSameOriginForBrowserAuth(), h.RemoveGeoIPDatabase)
+ f.router = r
+ return f
+}
+
+// upload builds a multipart request with one file part named field, carrying data.
+func upload(t *testing.T, field string, data []byte) (body io.Reader, contentType string) {
+ t.Helper()
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ part, err := mw.CreateFormFile(field, "client-chosen-name.mmdb")
+ require.NoError(t, err)
+ _, err = part.Write(data)
+ require.NoError(t, err)
+ require.NoError(t, mw.Close())
+ return &buf, mw.FormDataContentType()
+}
+
+// validUpload is a country database that resolves every address, built with buildEpoch.
+func validUpload(t *testing.T, buildEpoch uint32, opts ...testutil.GeoIPCountryOption) []byte {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), geoip.DatabaseFileName)
+ all := append([]testutil.GeoIPCountryOption{
+ testutil.WithGeoIPDatabaseType("GeoLite2-Country"),
+ testutil.WithGeoIPRecordISOCode("US"),
+ }, opts...)
+ testutil.WriteGeoIPCountryDB(t, path, buildEpoch, all...)
+ data, err := os.ReadFile(path) //nolint:gosec // G304: t.TempDir-derived path
+ require.NoError(t, err)
+ return data
+}
+
+// do sends a request through the fixture router.
+func (f *geoipDBFixture) do(method string, body io.Reader, contentType string, headers map[string]string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, "/security/geoip/database", body)
+ if contentType != "" {
+ req.Header.Set("Content-Type", contentType)
+ }
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ w := httptest.NewRecorder()
+ f.router.ServeHTTP(w, req)
+ return w
+}
+
+// replace uploads data as the file part and returns the response.
+func (f *geoipDBFixture) replace(data []byte) *httptest.ResponseRecorder {
+ body, ctype := upload(f.t, "file", data)
+ return f.do(http.MethodPost, body, ctype, nil)
+}
+
+// seedDataCopy writes and loads a data-folder copy with buildEpoch.
+func (f *geoipDBFixture) seedDataCopy(buildEpoch uint32) {
+ f.t.Helper()
+ require.NoError(f.t, os.MkdirAll(f.dataDir, 0o750))
+ testutil.WriteGeoIPCountryDB(f.t, f.dataPath, buildEpoch, testutil.WithGeoIPDatabaseType("GeoLite2-Country"))
+ require.NoError(f.t, f.handler.geoipStore.Reload(context.Background()))
+}
+
+// auditRows returns the audit rows with the given action, after the audit worker has persisted them.
+func (f *geoipDBFixture) auditRows(action string) []models.SecurityAudit {
+ f.t.Helper()
+ f.handler.svc.Flush()
+ var rows []models.SecurityAudit
+ require.NoError(f.t, f.db.Where("action = ?", action).Find(&rows).Error)
+ return rows
+}
+
+// decode unmarshals a response body into a map.
+func decode(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
+ t.Helper()
+ var body map[string]any
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "body: %s", w.Body.String())
+ return body
+}
+
+func TestGeoIPDatabase_ViewerIsForbiddenAndNothingChanges(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{role: string(models.RoleUser)})
+ f.seedDataCopy(1_750_000_000)
+ before, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+
+ replaced := f.replace(validUpload(t, 1_800_000_000))
+ removed := f.do(http.MethodDelete, nil, "", nil)
+
+ assert.Equal(t, http.StatusForbidden, replaced.Code)
+ assert.Equal(t, http.StatusForbidden, removed.Code)
+ after, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+ assert.Equal(t, before, after)
+}
+
+func TestGeoIPDatabase_MissingStoreIsUnavailable(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{noStore: true})
+
+ replaced := f.replace(validUpload(t, 1_700_000_000))
+ removed := f.do(http.MethodDelete, nil, "", nil)
+
+ for _, w := range []*httptest.ResponseRecorder{replaced, removed} {
+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
+ assert.Equal(t, "geoip_service_unavailable", decode(t, w)["error_code"])
+ }
+}
+
+func TestReplaceGeoIPDatabase_InstallsUploadAndAudits(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{})
+ data := validUpload(t, 1_700_000_000)
+
+ w := f.replace(data)
+
+ require.Equal(t, http.StatusOK, w.Code, w.Body.String())
+ body := decode(t, w)
+ assert.Equal(t, false, body["replaced_previous"])
+ status, ok := body["status"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "GeoLite2-Country", status["database_type"])
+ assert.Equal(t, "2023-11-14T22:13:20Z", status["build_date"])
+
+ rows := f.auditRows("replace_geoip_database")
+ require.Len(t, rows, 1)
+ assert.Contains(t, rows[0].Details, "type=GeoLite2-Country build=2023-11-14T22:13:20Z size=")
+ assert.Contains(t, rows[0].Details, fmt.Sprintf("size=%d", len(data)))
+ assert.Regexp(t, `sha256=[0-9a-f]{64}$`, rows[0].Details)
+ assert.NotContains(t, rows[0].Details, f.root, "audit rows never carry a path")
+}
+
+func TestReplaceGeoIPDatabase_ReplacesExistingCopy(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{})
+ f.seedDataCopy(1_700_000_000)
+
+ w := f.replace(validUpload(t, 1_750_000_000))
+
+ require.Equal(t, http.StatusOK, w.Code, w.Body.String())
+ assert.Equal(t, true, decode(t, w)["replaced_previous"])
+}
+
+func TestReplaceGeoIPDatabase_RefusedUploadsReturnStableCodes(t *testing.T) {
+ cases := []struct {
+ name string
+ env bool
+ prepare func(f *geoipDBFixture)
+ body func(t *testing.T) (io.Reader, string)
+ wantStatus int
+ wantCode string
+ wantReason string // non-empty when a reject audit row is expected
+ }{
+ {
+ name: "not multipart",
+ body: func(t *testing.T) (io.Reader, string) {
+ return strings.NewReader("plain"), "text/plain"
+ },
+ wantStatus: http.StatusBadRequest, wantCode: "geoip_file_required",
+ },
+ {
+ name: "wrong part name",
+ body: func(t *testing.T) (io.Reader, string) {
+ return upload(t, "other", validUpload(t, 1_700_000_000))
+ },
+ wantStatus: http.StatusBadRequest, wantCode: "geoip_file_required",
+ },
+ {
+ name: "a second part after the file",
+ body: func(t *testing.T) (io.Reader, string) {
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ part, err := mw.CreateFormFile("file", "a.mmdb")
+ require.NoError(t, err)
+ _, err = part.Write(validUpload(t, 1_700_000_000))
+ require.NoError(t, err)
+ extra, err := mw.CreateFormField("note")
+ require.NoError(t, err)
+ _, err = extra.Write([]byte("x"))
+ require.NoError(t, err)
+ require.NoError(t, mw.Close())
+ return &buf, mw.FormDataContentType()
+ },
+ wantStatus: http.StatusBadRequest, wantCode: "geoip_file_required", wantReason: "geoip_file_required",
+ },
+ {
+ name: "a malformed trailer after the file",
+ body: func(t *testing.T) (io.Reader, string) {
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ part, err := mw.CreateFormFile("file", "a.mmdb")
+ require.NoError(t, err)
+ _, err = part.Write(validUpload(t, 1_700_000_000))
+ require.NoError(t, err)
+ buf.WriteString("--" + mw.Boundary() + "\r\nmalformed header line\r\n\r\n")
+ return &buf, mw.FormDataContentType()
+ },
+ wantStatus: http.StatusBadRequest, wantCode: "geoip_file_required", wantReason: "geoip_file_required",
+ },
+ {
+ name: "not a MaxMind database",
+ body: func(t *testing.T) (io.Reader, string) {
+ return upload(t, "file", []byte("plain text"))
+ },
+ wantStatus: http.StatusBadRequest, wantCode: "geoip_not_mmdb", wantReason: "geoip_not_mmdb",
+ },
+ {
+ name: "not a country database",
+ body: func(t *testing.T) (io.Reader, string) {
+ return upload(t, "file", validUpload(t, 1_700_000_000, testutil.WithGeoIPDatabaseType("GeoLite2-ASN")))
+ },
+ wantStatus: http.StatusBadRequest, wantCode: "geoip_wrong_type", wantReason: "geoip_wrong_type",
+ },
+ {
+ name: "no build date",
+ body: func(t *testing.T) (io.Reader, string) {
+ return upload(t, "file", validUpload(t, 0))
+ },
+ wantStatus: http.StatusBadRequest, wantCode: "geoip_corrupt", wantReason: "geoip_corrupt",
+ },
+ {
+ name: "build date in the future",
+ body: func(t *testing.T) (io.Reader, string) {
+ future := uint32(time.Now().Add(48 * time.Hour).Unix()) //nolint:gosec // G115: a 2026 Unix time fits in uint32
+ return upload(t, "file", validUpload(t, future))
+ },
+ wantStatus: http.StatusBadRequest, wantCode: "geoip_build_date_in_future", wantReason: "geoip_build_date_in_future",
+ },
+ {
+ name: "custom path is set",
+ env: true,
+ body: func(t *testing.T) (io.Reader, string) { return upload(t, "file", validUpload(t, 1_700_000_000)) },
+ wantStatus: http.StatusConflict, wantCode: "geoip_custom_path_set",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{env: tc.env})
+ if tc.prepare != nil {
+ tc.prepare(f)
+ }
+ body, ctype := tc.body(t)
+
+ w := f.do(http.MethodPost, body, ctype, nil)
+
+ require.Equal(t, tc.wantStatus, w.Code, w.Body.String())
+ respBody := decode(t, w)
+ assert.Equal(t, tc.wantCode, respBody["error_code"])
+ assert.NotContains(t, w.Body.String(), f.root, "error bodies carry no paths")
+ assert.NoFileExists(t, f.dataPath)
+ assert.Empty(t, leftoverUploadFiles(t, f.dataDir))
+
+ rows := f.auditRows("reject_geoip_database")
+ if tc.wantReason == "" {
+ assert.Empty(t, rows)
+ return
+ }
+ require.Len(t, rows, 1)
+ assert.Contains(t, rows[0].Details, "reason="+tc.wantReason)
+ assert.Contains(t, rows[0].Details, "size=")
+ })
+ }
+}
+
+// leftoverUploadFiles lists upload temp files in dir.
+func leftoverUploadFiles(t *testing.T, dir string) []string {
+ t.Helper()
+ matches, err := filepath.Glob(filepath.Join(dir, ".GeoLite2-Country.mmdb.upload-*"))
+ require.NoError(t, err)
+ return matches
+}
+
+func TestReplaceGeoIPDatabase_OlderThanCurrentReportsBothDates(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{})
+ f.seedDataCopy(1_750_000_000)
+
+ w := f.replace(validUpload(t, 1_700_000_000))
+
+ require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
+ body := decode(t, w)
+ assert.Equal(t, "geoip_older_than_current", body["error_code"])
+ assert.Equal(t, "2023-11-14T22:13:20Z", body["uploaded_build_date"])
+ assert.Equal(t, "2025-06-15T15:06:40Z", body["current_build_date"])
+}
+
+func TestReplaceGeoIPDatabase_OversizedUploadsAreTooLarge(t *testing.T) {
+ const limit = 32 << 20
+ t.Run("a file one byte over the limit", func(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{})
+
+ w := f.replace(make([]byte, limit+1))
+
+ require.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
+ assert.Equal(t, "geoip_too_large", decode(t, w)["error_code"])
+ rows := f.auditRows("reject_geoip_database")
+ require.Len(t, rows, 1)
+ assert.Contains(t, rows[0].Details, "reason=geoip_too_large")
+ })
+ t.Run("headers that exhaust the request limit while the file streams", func(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{})
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ header := textproto.MIMEHeader{
+ "Content-Disposition": {`form-data; name="file"; filename="a.mmdb"`},
+ "X-Padding": {strings.Repeat("p", 100<<10)},
+ }
+ part, err := mw.CreatePart(header)
+ require.NoError(t, err)
+ _, err = part.Write(make([]byte, limit))
+ require.NoError(t, err)
+ require.NoError(t, mw.Close())
+
+ w := f.do(http.MethodPost, &buf, mw.FormDataContentType(), nil)
+
+ require.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
+ assert.Equal(t, "geoip_too_large", decode(t, w)["error_code"])
+ })
+}
+
+func TestReplaceGeoIPDatabase_BusyWhileAnotherChangeRuns(t *testing.T) {
+ started := make(chan struct{})
+ release := make(chan struct{})
+ var once bool
+ f := newGeoIPDBFixture(t, geoipDBOptions{apply: func(context.Context) error {
+ if !once {
+ once = true
+ close(started)
+ <-release
+ }
+ return nil
+ }})
+
+ first := make(chan *httptest.ResponseRecorder, 1)
+ go func() { first <- f.replace(validUpload(t, 1_700_000_000)) }()
+ <-started
+
+ second := f.replace(validUpload(t, 1_750_000_000))
+ assert.Equal(t, http.StatusConflict, second.Code)
+ assert.Equal(t, "geoip_busy", decode(t, second)["error_code"])
+
+ close(release)
+ assert.Equal(t, http.StatusOK, (<-first).Code)
+}
+
+func TestReplaceGeoIPDatabase_ApplyFailureIsRolledBack(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{applyErr: errors.New("proxy rejected config")})
+
+ w := f.replace(validUpload(t, 1_700_000_000))
+
+ require.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Equal(t, "proxy_apply_failed", decode(t, w)["error_code"])
+ assert.NoFileExists(t, f.dataPath, "a failed apply removes the upload when there was no previous copy")
+ assert.NotContains(t, w.Body.String(), "proxy rejected config")
+}
+
+func TestReplaceGeoIPDatabase_ReadOnlyFolderIsConflict(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("permission checks do not apply to root")
+ }
+ f := newGeoIPDBFixture(t, geoipDBOptions{})
+ require.NoError(t, os.MkdirAll(f.dataDir, 0o750))
+ require.NoError(t, os.Chmod(f.dataDir, 0o500)) //nolint:gosec // G302: directory needs the exec bit; read-only dir fixture
+ t.Cleanup(func() { _ = os.Chmod(f.dataDir, 0o755) }) //nolint:gosec // G302: the test tree must be removable
+
+ w := f.replace(validUpload(t, 1_700_000_000))
+
+ require.Equal(t, http.StatusConflict, w.Code)
+ assert.Equal(t, "data_folder_read_only", decode(t, w)["error_code"])
+}
+
+func TestRemoveGeoIPDatabase_RestoresBundledCopyAndAudits(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{})
+ require.NoError(t, os.MkdirAll(filepath.Dir(f.bundledPath), 0o750))
+ testutil.WriteGeoIPCountryDB(t, f.bundledPath, 1_700_000_000)
+ f.seedDataCopy(1_750_000_000)
+
+ w := f.do(http.MethodDelete, nil, "", nil)
+
+ require.Equal(t, http.StatusOK, w.Code, w.Body.String())
+ status, ok := decode(t, w)["status"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, "2023-11-14T22:13:20Z", status["build_date"], "the bundled copy is in use")
+ assert.NoFileExists(t, f.dataPath)
+
+ rows := f.auditRows("remove_geoip_database")
+ require.Len(t, rows, 1)
+ assert.Equal(t, "build=2025-06-15T15:06:40Z", rows[0].Details)
+ assert.NotContains(t, rows[0].Details, f.root)
+}
+
+func TestRemoveGeoIPDatabase_RefusedCases(t *testing.T) {
+ t.Run("no data-folder copy is not found", func(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{})
+
+ w := f.do(http.MethodDelete, nil, "", nil)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+ assert.Equal(t, "geoip_no_data_copy", decode(t, w)["error_code"])
+ })
+ t.Run("custom path is a conflict", func(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{env: true})
+ f.seedDataCopy(1_750_000_000)
+
+ w := f.do(http.MethodDelete, nil, "", nil)
+
+ assert.Equal(t, http.StatusConflict, w.Code)
+ assert.Equal(t, "geoip_custom_path_set", decode(t, w)["error_code"])
+ assert.FileExists(t, f.dataPath)
+ })
+ t.Run("busy is a conflict", func(t *testing.T) {
+ started := make(chan struct{})
+ release := make(chan struct{})
+ f := newGeoIPDBFixture(t, geoipDBOptions{})
+ f.seedDataCopy(1_750_000_000)
+ var once bool
+ f.handler.geoipStore = services.NewGeoIPDatabaseStore(services.GeoIPDatabaseStoreConfig{
+ Service: f.handler.geoipSvc,
+ DataPath: f.dataPath,
+ BundledPath: f.bundledPath,
+ Apply: func(context.Context) error {
+ if !once {
+ once = true
+ close(started)
+ <-release
+ }
+ return nil
+ },
+ })
+
+ done := make(chan *httptest.ResponseRecorder, 1)
+ go func() { done <- f.replace(validUpload(t, 1_760_000_000)) }()
+ <-started
+ w := f.do(http.MethodDelete, nil, "", nil)
+ close(release)
+ assert.Equal(t, http.StatusConflict, w.Code)
+ assert.Equal(t, "geoip_busy", decode(t, w)["error_code"])
+ <-done
+ })
+ t.Run("apply failure restores the copy", func(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{})
+ require.NoError(t, os.MkdirAll(filepath.Dir(f.bundledPath), 0o750))
+ testutil.WriteGeoIPCountryDB(t, f.bundledPath, 1_700_000_000)
+ f.seedDataCopy(1_750_000_000)
+ f.handler.geoipStore = services.NewGeoIPDatabaseStore(services.GeoIPDatabaseStoreConfig{
+ Service: f.handler.geoipSvc,
+ DataPath: f.dataPath,
+ BundledPath: f.bundledPath,
+ Apply: func(context.Context) error { return errors.New("proxy rejected config") },
+ })
+
+ w := f.do(http.MethodDelete, nil, "", nil)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Equal(t, "proxy_apply_failed", decode(t, w)["error_code"])
+ assert.FileExists(t, f.dataPath)
+ })
+}
+
+func TestGeoIPDatabase_CrossOriginRequestsAreRefused(t *testing.T) {
+ crossSite := map[string]string{"Sec-Fetch-Site": "same-site", "Origin": "https://app.charon.test"}
+
+ t.Run("cookie session from a sibling origin cannot replace", func(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{})
+ body, ctype := upload(t, "file", validUpload(t, 1_700_000_000))
+
+ w := f.do(http.MethodPost, body, ctype, crossSite)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Equal(t, "cross_origin_refused", decode(t, w)["error_code"])
+ assert.NoFileExists(t, f.dataPath)
+ })
+ t.Run("cookie session from a sibling origin cannot remove", func(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{})
+ f.seedDataCopy(1_750_000_000)
+ applied := f.applyCalls
+
+ w := f.do(http.MethodDelete, nil, "", map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"})
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.FileExists(t, f.dataPath, "the copy is not removed when the origin check refuses")
+ assert.Equal(t, applied, f.applyCalls, "the handler does not run")
+ })
+ t.Run("header-authenticated client is not refused by the origin check", func(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{})
+ f.seedDataCopy(1_750_000_000)
+
+ w := f.do(http.MethodDelete, nil, "", map[string]string{
+ "Authorization": "Bearer token",
+ "Sec-Fetch-Site": "cross-site",
+ "Origin": "https://evil.example",
+ })
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ })
+}
+
+func TestGeoIPErrorFailure_MapsEveryFailureToItsCode(t *testing.T) {
+ older := &services.OlderThanCurrentError{Uploaded: 1, Current: 2}
+ cases := []struct {
+ name string
+ err error
+ wantStatus int
+ wantCode string
+ refused bool
+ }{
+ {name: "busy", err: services.ErrGeoIPBusy, wantStatus: 409, wantCode: "geoip_busy"},
+ {name: "custom path", err: services.ErrGeoIPCustomPathSet, wantStatus: 409, wantCode: "geoip_custom_path_set"},
+ {name: "no data copy", err: services.ErrGeoIPNoDataCopy, wantStatus: 404, wantCode: "geoip_no_data_copy"},
+ {name: "too large", err: services.ErrGeoIPTooLarge, wantStatus: 413, wantCode: "geoip_too_large", refused: true},
+ {
+ name: "http body limit",
+ err: fmt.Errorf("read: %w", &http.MaxBytesError{Limit: 1}),
+ wantStatus: 413, wantCode: "geoip_too_large", refused: true,
+ },
+ {name: "upload read", err: services.ErrGeoIPUploadRead, wantStatus: 400, wantCode: "geoip_file_required", refused: true},
+ {name: "not mmdb", err: geoip.ErrNotMMDB, wantStatus: 400, wantCode: "geoip_not_mmdb", refused: true},
+ {name: "corrupt", err: geoip.ErrCorruptDatabase, wantStatus: 400, wantCode: "geoip_corrupt", refused: true},
+ {name: "no build date", err: geoip.ErrNoBuildDate, wantStatus: 400, wantCode: "geoip_corrupt", refused: true},
+ {name: "wrong type", err: geoip.ErrWrongDatabaseType, wantStatus: 400, wantCode: "geoip_wrong_type", refused: true},
+ {name: "older", err: older, wantStatus: 409, wantCode: "geoip_older_than_current", refused: true},
+ {name: "future", err: services.ErrGeoIPBuildDateInFuture, wantStatus: 400, wantCode: "geoip_build_date_in_future", refused: true},
+ {name: "reload", err: services.ErrGeoIPReloadFailed, wantStatus: 500, wantCode: "geoip_reload_failed"},
+ {name: "apply", err: services.ErrGeoIPApplyFailed, wantStatus: 500, wantCode: "proxy_apply_failed"},
+ {name: "read only", err: services.ErrGeoIPDataFolderReadOnly, wantStatus: 409, wantCode: "data_folder_read_only"},
+ {name: "unknown", err: errors.New("/some/path failed"), wantStatus: 500, wantCode: "geoip_write_failed"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := geoipFailureFor(tc.err)
+
+ assert.Equal(t, tc.wantStatus, got.status)
+ assert.Equal(t, tc.wantCode, got.code)
+ assert.Equal(t, tc.refused, got.refused)
+ assert.NotEmpty(t, got.msg)
+ })
+ }
+}
+
+func TestGeoIPErrorResponse_OmitsDetailAndDatesWhenNotApplicable(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodPost, "/", http.NoBody)
+
+ geoipErrorResponse(c, errors.New("open /app/data/secret: permission denied"))
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ body := decode(t, w)
+ assert.Equal(t, "geoip_write_failed", body["error_code"])
+ assert.NotContains(t, w.Body.String(), "/app/data")
+ assert.NotContains(t, body, "uploaded_build_date")
+}
+
+func TestFormatBuildEpoch(t *testing.T) {
+ assert.Equal(t, "unknown", formatBuildEpoch(0))
+ assert.Equal(t, "2023-11-14T22:13:20Z", formatBuildEpoch(1_700_000_000))
+}
+
+func TestSinglePartReader_EndsOnlyWhenNoPartFollows(t *testing.T) {
+ body, ctype := upload(t, "file", []byte("content"))
+ mr := multipartReaderFor(t, body, ctype)
+ part, err := mr.NextPart()
+ require.NoError(t, err)
+
+ data, err := io.ReadAll(singlePartReader{part: part, mr: mr})
+
+ require.NoError(t, err)
+ assert.Equal(t, "content", string(data))
+}
+
+func TestSinglePartReader_RejectsAFollowingPart(t *testing.T) {
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ part, err := mw.CreateFormFile("file", "a.mmdb")
+ require.NoError(t, err)
+ _, err = part.Write([]byte("content"))
+ require.NoError(t, err)
+ _, err = mw.CreateFormField("note")
+ require.NoError(t, err)
+ require.NoError(t, mw.Close())
+ mr := multipartReaderFor(t, &buf, mw.FormDataContentType())
+ first, err := mr.NextPart()
+ require.NoError(t, err)
+
+ _, err = io.ReadAll(singlePartReader{part: first, mr: mr})
+
+ assert.ErrorIs(t, err, errUnexpectedPart)
+}
+
+// multipartReaderFor returns the multipart reader of a request body.
+func multipartReaderFor(t *testing.T, body io.Reader, contentType string) *multipart.Reader {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, "/", body)
+ req.Header.Set("Content-Type", contentType)
+ mr, err := req.MultipartReader()
+ require.NoError(t, err)
+ return mr
+}
+
+// The handlers check the admin role themselves, so a request that reaches them without the route's role gate is still
+// refused.
+func TestGeoIPDatabaseHandlers_RefuseNonAdminWithoutRouteGate(t *testing.T) {
+ f := newGeoIPDBFixture(t, geoipDBOptions{role: string(models.RoleUser)})
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set(middleware.RoleKey, string(models.RoleUser))
+ c.Next()
+ })
+ r.POST("/security/geoip/database", f.handler.ReplaceGeoIPDatabase)
+ r.DELETE("/security/geoip/database", f.handler.RemoveGeoIPDatabase)
+
+ body, ctype := upload(t, "file", validUpload(t, 1_700_000_000))
+ req := httptest.NewRequest(http.MethodPost, "/security/geoip/database", body)
+ req.Header.Set("Content-Type", ctype)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusForbidden, w.Code)
+
+ w = httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/security/geoip/database", http.NoBody))
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.NoFileExists(t, f.dataPath)
+}
+
+func TestSinglePartReader_ReportsTruncatedTrailer(t *testing.T) {
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ part, err := mw.CreateFormFile("file", "a.mmdb")
+ require.NoError(t, err)
+ _, err = part.Write([]byte("content"))
+ require.NoError(t, err)
+ // The body stops inside the header block of a second part.
+ buf.WriteString("\r\n--" + mw.Boundary() + "\r\nX-Partial")
+ mr := multipartReaderFor(t, &buf, mw.FormDataContentType())
+ first, err := mr.NextPart()
+ require.NoError(t, err)
+
+ _, err = io.ReadAll(singlePartReader{part: first, mr: mr})
+
+ assert.Error(t, err)
+ assert.NotErrorIs(t, err, errUnexpectedPart, "a read failure is not reported as an extra part")
+}
diff --git a/backend/internal/api/handlers/security_geoip_handler.go b/backend/internal/api/handlers/security_geoip_handler.go
new file mode 100644
index 000000000..547d542c4
--- /dev/null
+++ b/backend/internal/api/handlers/security_geoip_handler.go
@@ -0,0 +1,383 @@
+package handlers
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ log "github.com/sirupsen/logrus"
+
+ "github.com/Wikid82/charon/backend/internal/caddy"
+ "github.com/Wikid82/charon/backend/internal/geoip"
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/Wikid82/charon/backend/internal/services"
+ "github.com/Wikid82/charon/backend/internal/util"
+)
+
+// geoipStatusResponse is the body of GET /security/geoip/status. The first four fields and reason are the original
+// contract; the database card fields follow, flattened from services.GeoIPStatus. reason is present only when the
+// database is unavailable, so the card can show why country lists cannot match.
+type geoipStatusResponse struct {
+ Available bool `json:"available"`
+ Loaded bool `json:"loaded"`
+ DBPath string `json:"db_path"`
+ Message string `json:"message"`
+ Reason string `json:"reason,omitempty"`
+ services.GeoIPStatus
+}
+
+// GetGeoIPStatus returns the current status of the GeoIP service and the database card.
+func (h *SecurityHandler) GetGeoIPStatus(c *gin.Context) {
+ available := h.geoipSvc.Available()
+ body := geoipStatusResponse{
+ Available: available,
+ Message: "GeoIP service not initialized",
+ GeoIPStatus: h.geoipStatus(),
+ }
+ if h.geoipSvc != nil {
+ body.Loaded = h.geoipSvc.IsLoaded()
+ body.DBPath = h.geoipSvc.GetDatabasePath()
+ body.Message = "GeoIP database unavailable"
+ }
+ if available {
+ body.Message = "GeoIP service available"
+ } else {
+ body.Reason = caddy.ReasonGeoIPUnavailable
+ }
+ c.JSON(http.StatusOK, body)
+}
+
+// geoipStatus returns the database card status with the number of country lists the proxy is not enforcing. Without a
+// store (no GeoIP service is wired) it reports no database.
+func (h *SecurityHandler) geoipStatus() services.GeoIPStatus {
+ if h.geoipStore == nil {
+ return services.GeoIPStatus{Source: string(geoip.SourceNone)}
+ }
+ st := h.geoipStore.Status()
+ st.UnenforcedListsTotal = h.unenforcedCountryListsTotal()
+ return st
+}
+
+// unenforcedCountryListsTotal counts the country lists the proxy is not enforcing. Without a database there is no
+// access-list state to read, so it reports zero.
+func (h *SecurityHandler) unenforcedCountryListsTotal() int {
+ if h.db == nil {
+ return 0
+ }
+ return len(h.countryListsNotEnforced(h.aclEnabledForAlerts()))
+}
+
+// Stable error_code values and fixed messages of the GeoIP reload endpoint. The error text of the underlying failure
+// (which can name filesystem paths) is logged server-side only and never returned to the client.
+const (
+ codeGeoIPReloadFailed = "geoip_reload_failed"
+ codeGeoIPServiceUnavailable = "geoip_service_unavailable"
+ codeGeoIPBusy = "geoip_busy"
+ codeProxyApplyFailed = "proxy_apply_failed"
+ msgGeoIPReloadFailed = "Failed to reload the GeoIP database"
+ msgGeoIPBusy = "Another GeoIP database change is in progress"
+)
+
+// ReloadGeoIP reloads the GeoIP database from disk.
+func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) {
+ if !requireAdmin(c) {
+ return
+ }
+
+ if h.geoipStore == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{
+ "error": "GeoIP service not initialized",
+ "error_code": codeGeoIPServiceUnavailable,
+ })
+ return
+ }
+
+ // The store loads the file and applies the proxy config, so country lists follow the reload at once.
+ if err := h.geoipStore.Reload(c.Request.Context()); err != nil {
+ geoipErrorResponse(c, err)
+ return
+ }
+
+ // Log audit event
+ actor := auditActor(c)
+ _ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "reload_geoip", Details: "GeoIP database reloaded successfully"})
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": "GeoIP database reloaded successfully",
+ "loaded": h.geoipSvc.IsLoaded(),
+ "db_path": h.geoipSvc.GetDatabasePath(),
+ "status": h.geoipStatus(),
+ })
+}
+
+// Stable error_code values and fixed messages of the GeoIP database endpoints. Replace and remove share them with the
+// reload endpoint, so one failure has one code wherever it surfaces.
+const (
+ codeGeoIPFileRequired = "geoip_file_required"
+ codeGeoIPTooLarge = "geoip_too_large"
+ codeGeoIPNotMMDB = "geoip_not_mmdb"
+ codeGeoIPCorrupt = "geoip_corrupt"
+ codeGeoIPWrongType = "geoip_wrong_type" //nolint:gosec // G101: stable error_code string, not a credential
+ codeGeoIPOlderThanCurrent = "geoip_older_than_current"
+ codeGeoIPBuildInFuture = "geoip_build_date_in_future"
+ codeGeoIPCustomPathSet = "geoip_custom_path_set"
+ codeGeoIPNoDataCopy = "geoip_no_data_copy"
+ codeGeoIPDataReadOnly = "data_folder_read_only"
+ codeGeoIPWriteFailed = "geoip_write_failed" //nolint:gosec // G101: stable error_code string, not a credential
+
+ msgGeoIPFileRequired = "Upload the database as one file part named file"
+ msgGeoIPTooLarge = "The GeoIP database is larger than 32 MiB"
+ msgGeoIPNotMMDB = "The file is not a MaxMind database"
+ msgGeoIPCorrupt = "The file is a damaged MaxMind database or has no build date"
+ msgGeoIPWrongType = "The file is not a country database"
+ msgGeoIPOlderCurrent = "The file is older than the database in use or the bundled copy"
+ msgGeoIPBuildInFuture = "The file's build date is in the future"
+ msgGeoIPCustomPathSet = "The GeoIP database is set by CHARON_GEOIP_DB_PATH and cannot be changed here"
+ msgGeoIPNoDataCopy = "There is no uploaded GeoIP database to remove"
+ msgGeoIPDataReadOnly = "The GeoIP data folder is read-only"
+ msgGeoIPWriteFailed = "Failed to store the GeoIP database"
+)
+
+// geoipFailure is the HTTP answer for a failed GeoIP database change. refused marks an upload that was refused after its
+// content was read; those are written to the audit log.
+type geoipFailure struct {
+ status int
+ code string
+ msg string
+ refused bool
+}
+
+// geoipFailureFor maps a failure of the store or the upload to its answer. Unknown failures map to a generic 500 that
+// carries no detail.
+func geoipFailureFor(err error) geoipFailure {
+ var maxErr *http.MaxBytesError
+ var older *services.OlderThanCurrentError
+ switch {
+ case errors.Is(err, services.ErrGeoIPBusy):
+ return geoipFailure{status: http.StatusConflict, code: codeGeoIPBusy, msg: msgGeoIPBusy}
+ case errors.Is(err, services.ErrGeoIPCustomPathSet):
+ return geoipFailure{status: http.StatusConflict, code: codeGeoIPCustomPathSet, msg: msgGeoIPCustomPathSet}
+ case errors.Is(err, services.ErrGeoIPNoDataCopy):
+ return geoipFailure{status: http.StatusNotFound, code: codeGeoIPNoDataCopy, msg: msgGeoIPNoDataCopy}
+ case errors.Is(err, services.ErrGeoIPTooLarge), errors.As(err, &maxErr):
+ return geoipFailure{status: http.StatusRequestEntityTooLarge, code: codeGeoIPTooLarge, msg: msgGeoIPTooLarge, refused: true}
+ case errors.Is(err, services.ErrGeoIPUploadRead):
+ return geoipFailure{status: http.StatusBadRequest, code: codeGeoIPFileRequired, msg: msgGeoIPFileRequired, refused: true}
+ case errors.Is(err, geoip.ErrNotMMDB):
+ return geoipFailure{status: http.StatusBadRequest, code: codeGeoIPNotMMDB, msg: msgGeoIPNotMMDB, refused: true}
+ case errors.Is(err, geoip.ErrCorruptDatabase), errors.Is(err, geoip.ErrNoBuildDate):
+ return geoipFailure{status: http.StatusBadRequest, code: codeGeoIPCorrupt, msg: msgGeoIPCorrupt, refused: true}
+ case errors.Is(err, geoip.ErrWrongDatabaseType):
+ return geoipFailure{status: http.StatusBadRequest, code: codeGeoIPWrongType, msg: msgGeoIPWrongType, refused: true}
+ case errors.As(err, &older):
+ return geoipFailure{status: http.StatusConflict, code: codeGeoIPOlderThanCurrent, msg: msgGeoIPOlderCurrent, refused: true}
+ case errors.Is(err, services.ErrGeoIPBuildDateInFuture):
+ return geoipFailure{status: http.StatusBadRequest, code: codeGeoIPBuildInFuture, msg: msgGeoIPBuildInFuture, refused: true}
+ case errors.Is(err, services.ErrGeoIPReloadFailed):
+ return geoipFailure{status: http.StatusInternalServerError, code: codeGeoIPReloadFailed, msg: msgGeoIPReloadFailed}
+ case errors.Is(err, services.ErrGeoIPApplyFailed):
+ return geoipFailure{status: http.StatusInternalServerError, code: codeProxyApplyFailed, msg: msgModuleApplyFailed}
+ case errors.Is(err, services.ErrGeoIPDataFolderReadOnly):
+ return geoipFailure{status: http.StatusConflict, code: codeGeoIPDataReadOnly, msg: msgGeoIPDataReadOnly}
+ default:
+ return geoipFailure{status: http.StatusInternalServerError, code: codeGeoIPWriteFailed, msg: msgGeoIPWriteFailed}
+ }
+}
+
+// geoipErrorResponse writes the answer for a failed GeoIP database change. The body carries a fixed message, the
+// error_code, and for an older upload the two build dates. The detail of err is logged, sanitized, and never returned.
+func geoipErrorResponse(c *gin.Context, err error) {
+ f := geoipFailureFor(err)
+ body := gin.H{"error": f.msg, "error_code": f.code}
+ var older *services.OlderThanCurrentError
+ if errors.As(err, &older) {
+ body["uploaded_build_date"] = formatBuildEpoch(older.Uploaded)
+ body["current_build_date"] = formatBuildEpoch(older.Current)
+ }
+ detail := util.SanitizeForLog(err.Error())
+ if f.status >= http.StatusInternalServerError {
+ log.WithField("detail", detail).Error("GeoIP database change failed")
+ } else {
+ log.WithField("detail", detail).Warn("GeoIP database change refused")
+ }
+ c.JSON(f.status, body)
+}
+
+// formatBuildEpoch renders a MaxMind build epoch as an RFC 3339 UTC time, or "unknown" for a zero epoch.
+func formatBuildEpoch(epoch uint) string {
+ if epoch == 0 {
+ return "unknown"
+ }
+ return geoip.BuildTime(epoch).UTC().Format(time.RFC3339)
+}
+
+// geoipUploadField is the only multipart part name the upload accepts.
+const geoipUploadField = "file"
+
+// geoipMultipartOverhead is the room left in the request limit for multipart framing around the file.
+const geoipMultipartOverhead = 64 << 10
+
+// errUnexpectedPart reports a request with a part after the file, or a part that is not the file.
+var errUnexpectedPart = fmt.Errorf("%w: unexpected form part", services.ErrGeoIPUploadRead)
+
+// singlePartReader reads the file part and, at its end, requires that no further part follows. A trailing part is an
+// error, so the store never installs a file whose request carried more than the one part.
+type singlePartReader struct {
+ part *multipart.Part
+ mr *multipart.Reader
+}
+
+// Read reads the file part; at its end it checks that the multipart stream has no further part.
+func (r singlePartReader) Read(p []byte) (int, error) {
+ n, err := r.part.Read(p)
+ if !errors.Is(err, io.EOF) {
+ return n, err
+ }
+ next, nextErr := r.mr.NextPart()
+ switch {
+ case nextErr != nil && errors.Is(nextErr, io.EOF):
+ return n, io.EOF
+ case nextErr != nil:
+ return n, nextErr
+ default:
+ _ = next.Close()
+ return n, errUnexpectedPart
+ }
+}
+
+// ReplaceGeoIPDatabase installs an uploaded country database as the data-folder copy. The request is one multipart
+// part named file. The client's file name and content type are ignored and never stored.
+func (h *SecurityHandler) ReplaceGeoIPDatabase(c *gin.Context) {
+ if !requireAdmin(c) {
+ return
+ }
+ if h.geoipStore == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{
+ "error": "GeoIP service not initialized",
+ "error_code": codeGeoIPServiceUnavailable,
+ })
+ return
+ }
+
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, services.MaxGeoIPDatabaseBytes+geoipMultipartOverhead)
+ mr, err := c.Request.MultipartReader()
+ if err != nil {
+ geoipErrorResponse(c, services.ClassifyUploadRead(err))
+ return
+ }
+ part, err := mr.NextPart()
+ if err == nil && part.FormName() != geoipUploadField {
+ err = errUnexpectedPart
+ }
+ if err != nil {
+ geoipErrorResponse(c, services.ClassifyUploadRead(err))
+ return
+ }
+
+ result, err := h.geoipStore.Replace(c.Request.Context(), singlePartReader{part: part, mr: mr})
+ if err != nil {
+ h.auditRefusedUpload(c, err, result)
+ geoipErrorResponse(c, err)
+ return
+ }
+
+ h.auditGeoIP(c, "replace_geoip_database", fmt.Sprintf("type=%s build=%s size=%d sha256=%s",
+ util.SanitizeForLog(result.Info.DatabaseType), formatBuildEpoch(result.Info.BuildEpoch), result.SizeBytes, result.SHA256))
+ c.JSON(http.StatusOK, gin.H{
+ "status": h.geoipStatus(),
+ "replaced_previous": result.ReplacedPrevious,
+ })
+}
+
+// RemoveGeoIPDatabase deletes the data-folder copy so the image's bundled copy is used again.
+func (h *SecurityHandler) RemoveGeoIPDatabase(c *gin.Context) {
+ if !requireAdmin(c) {
+ return
+ }
+ if h.geoipStore == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{
+ "error": "GeoIP service not initialized",
+ "error_code": codeGeoIPServiceUnavailable,
+ })
+ return
+ }
+
+ removed := h.geoipStore.DataCopyInfo()
+ if err := h.geoipStore.RemoveDataCopy(c.Request.Context()); err != nil {
+ geoipErrorResponse(c, err)
+ return
+ }
+
+ h.auditGeoIP(c, "remove_geoip_database", "build="+formatBuildEpoch(removed.BuildEpoch))
+ c.JSON(http.StatusOK, gin.H{"status": h.geoipStatus()})
+}
+
+// auditRefusedUpload records an upload refused after its content was read, with its size and digest when known. The
+// reason is the error_code the client received.
+func (h *SecurityHandler) auditRefusedUpload(c *gin.Context, err error, result services.ReplaceResult) {
+ f := geoipFailureFor(err)
+ if !f.refused {
+ return
+ }
+ h.auditGeoIP(c, "reject_geoip_database", fmt.Sprintf("reason=%s size=%d sha256=%s", f.code, result.SizeBytes, result.SHA256))
+}
+
+// auditGeoIP writes one audit row for a GeoIP database change. The details never carry a path or a client file name.
+func (h *SecurityHandler) auditGeoIP(c *gin.Context, action, details string) {
+ _ = h.svc.LogAudit(&models.SecurityAudit{
+ Actor: auditActor(c),
+ Action: action,
+ Details: util.SanitizeForLog(details),
+ })
+}
+
+// LookupGeoIP performs a GeoIP lookup for a given IP address.
+func (h *SecurityHandler) LookupGeoIP(c *gin.Context) {
+ if !requireAdmin(c) {
+ return
+ }
+
+ var req struct {
+ IPAddress string `json:"ip_address" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "ip_address is required"})
+ return
+ }
+
+ if h.geoipSvc == nil || !h.geoipSvc.IsLoaded() {
+ c.JSON(http.StatusServiceUnavailable, gin.H{
+ "error": "GeoIP service not available",
+ })
+ return
+ }
+
+ country, err := h.geoipSvc.LookupCountry(req.IPAddress)
+ if err != nil {
+ if errors.Is(err, services.ErrInvalidGeoIP) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid IP address"})
+ return
+ }
+ if errors.Is(err, services.ErrCountryNotFound) {
+ c.JSON(http.StatusOK, gin.H{
+ "ip_address": req.IPAddress,
+ "country_code": "",
+ "found": false,
+ "message": "No country found for this IP address",
+ })
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "GeoIP lookup failed: " + err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "ip_address": req.IPAddress,
+ "country_code": country,
+ "found": true,
+ })
+}
diff --git a/backend/internal/api/handlers/security_geoip_lookup_test.go b/backend/internal/api/handlers/security_geoip_lookup_test.go
new file mode 100644
index 000000000..d49d3da19
--- /dev/null
+++ b/backend/internal/api/handlers/security_geoip_lookup_test.go
@@ -0,0 +1,108 @@
+package handlers
+
+import (
+ "bytes"
+ "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"
+
+ "github.com/Wikid82/charon/backend/internal/config"
+ "github.com/Wikid82/charon/backend/internal/geoip"
+ "github.com/Wikid82/charon/backend/internal/services"
+ "github.com/Wikid82/charon/backend/internal/testutil"
+)
+
+// lookupRouter serves LookupGeoIP for a caller with role over a service loaded from a fixture database with opts.
+func lookupRouter(t *testing.T, role string, opts ...testutil.GeoIPCountryOption) *gin.Engine {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), geoip.DatabaseFileName)
+ testutil.WriteGeoIPCountryDB(t, path, 1_750_000_000, opts...)
+ svc, err := services.NewGeoIPService(path)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = svc.Close() })
+
+ h := NewSecurityHandler(config.SecurityConfig{}, nil, nil)
+ h.SetGeoIPService(svc)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", role)
+ c.Next()
+ })
+ r.POST("/security/geoip/lookup", h.LookupGeoIP)
+ return r
+}
+
+// postLookup asks the router for the country of ip and decodes the JSON answer.
+func postLookup(t *testing.T, r *gin.Engine, ip string) (status int, body map[string]any) {
+ t.Helper()
+ payload, err := json.Marshal(map[string]string{"ip_address": ip})
+ require.NoError(t, err)
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/security/geoip/lookup", bytes.NewReader(payload))
+ req.Header.Set("Content-Type", "application/json")
+ r.ServeHTTP(w, req)
+
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+ return w.Code, body
+}
+
+func TestSecurityHandler_LookupGeoIP_Answers(t *testing.T) {
+ t.Run("a resolved address names its country", func(t *testing.T) {
+ code, body := postLookup(t, lookupRouter(t, "admin", testutil.WithGeoIPRecordISOCode("US")), "8.8.8.8")
+
+ assert.Equal(t, http.StatusOK, code)
+ assert.Equal(t, true, body["found"])
+ assert.Equal(t, "US", body["country_code"])
+ assert.Equal(t, "8.8.8.8", body["ip_address"])
+ })
+ t.Run("an address without a country is reported as not found", func(t *testing.T) {
+ r := lookupRouter(t, "admin", testutil.WithGeoIPRecord(map[string]any{"continent": map[string]any{"code": "EU"}}))
+
+ code, body := postLookup(t, r, "8.8.8.8")
+
+ assert.Equal(t, http.StatusOK, code)
+ assert.Equal(t, false, body["found"])
+ assert.Equal(t, "", body["country_code"])
+ })
+ t.Run("an invalid address is refused", func(t *testing.T) {
+ code, body := postLookup(t, lookupRouter(t, "admin"), "not-an-ip")
+
+ assert.Equal(t, http.StatusBadRequest, code)
+ assert.Equal(t, "Invalid IP address", body["error"])
+ })
+ t.Run("a record that cannot be decoded is a lookup failure", func(t *testing.T) {
+ r := lookupRouter(t, "admin", testutil.WithGeoIPRecord(map[string]any{"country": "US"}))
+
+ code, body := postLookup(t, r, "8.8.8.8")
+
+ assert.Equal(t, http.StatusInternalServerError, code)
+ assert.Contains(t, body["error"], "GeoIP lookup failed")
+ })
+}
+
+func TestSecurityHandler_GeoIPEndpoints_RequireAdmin(t *testing.T) {
+ t.Run("lookup", func(t *testing.T) {
+ code, _ := postLookup(t, lookupRouter(t, "user", testutil.WithGeoIPRecordISOCode("US")), "8.8.8.8")
+
+ assert.Equal(t, http.StatusForbidden, code)
+ })
+ t.Run("reload", func(t *testing.T) {
+ h := NewSecurityHandler(config.SecurityConfig{}, nil, nil)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("role", "user")
+ c.Next()
+ })
+ r.POST("/security/geoip/reload", h.ReloadGeoIP)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/security/geoip/reload", http.NoBody))
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ })
+}
diff --git a/backend/internal/api/handlers/security_geoip_reason_test.go b/backend/internal/api/handlers/security_geoip_reason_test.go
index e9fb5c5e4..f5e120ed9 100644
--- a/backend/internal/api/handlers/security_geoip_reason_test.go
+++ b/backend/internal/api/handlers/security_geoip_reason_test.go
@@ -44,7 +44,7 @@ func openGeoIPReasonTestDB(t *testing.T) *gorm.DB {
sqlDB, err := db.DB()
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
- require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{}, &models.AccessList{}))
+ require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{}, &models.AccessList{}, &models.ProxyHost{}))
return db
}
@@ -93,7 +93,9 @@ func TestSecurityStatus_GeoIPStatusReportsUnavailableWhenServiceNotLoaded(t *tes
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)
+ require.NoError(t, db.Create(&models.AccessList{ID: 1, UUID: "geo-1", Name: "geo", Type: "geo_whitelist", CountryCodes: "US", Enabled: true}).Error)
+ listID := uint(1)
+ require.NoError(t, db.Create(&models.ProxyHost{UUID: "geo-host", DomainNames: "geo.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &listID}).Error)
body := geoipReasonStatusBody(t, db, config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled"})
reason, has := aclReasonFromStatus(t, body)
diff --git a/backend/internal/api/handlers/security_geoip_reload_apply_test.go b/backend/internal/api/handlers/security_geoip_reload_apply_test.go
index ceee3b835..07c53e21d 100644
--- a/backend/internal/api/handlers/security_geoip_reload_apply_test.go
+++ b/backend/internal/api/handlers/security_geoip_reload_apply_test.go
@@ -113,7 +113,9 @@ func TestSecurityHandler_ReloadGeoIP_ApplyFailureIsReported(t *testing.T) {
assert.Equal(t, 1, mgr.calls)
}
-func TestSecurityHandler_ReloadGeoIP_LoadFailureSkipsApply(t *testing.T) {
+// A failed load still applies the proxy config once, so the proxy converges on the database state it can see, and the
+// response is 500 with the reload code.
+func TestSecurityHandler_ReloadGeoIP_LoadFailureStillAppliesOnce(t *testing.T) {
mgr := &countingCaddyManager{}
h := NewSecurityHandler(config.SecurityConfig{}, openGeoIPReasonTestDB(t), mgr)
h.SetGeoIPService(&services.GeoIPService{}) // no database path, so the load fails
@@ -122,7 +124,25 @@ func TestSecurityHandler_ReloadGeoIP_LoadFailureSkipsApply(t *testing.T) {
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")
+ assert.Equal(t, 1, mgr.calls, "a failed load applies the proxy config exactly once")
+ message, code := decodeReloadErrorBody(t, w.Body.String())
+ assert.Equal(t, msgGeoIPReloadFailed, message)
+ assert.Equal(t, codeGeoIPReloadFailed, code)
+}
+
+func TestSecurityHandler_ReloadGeoIP_LoadFailureWithApplyFailureKeepsReloadCode(t *testing.T) {
+ mgr := &countingCaddyManager{applyErr: errors.New("caddy refused the config at /secret/location")}
+ h := NewSecurityHandler(config.SecurityConfig{}, openGeoIPReasonTestDB(t), mgr)
+ h.SetGeoIPService(&services.GeoIPService{})
+
+ 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)
+ _, code := decodeReloadErrorBody(t, w.Body.String())
+ assert.Equal(t, codeGeoIPReloadFailed, code, "the reload failure is reported, not the apply failure")
+ assert.NotContains(t, w.Body.String(), "/secret/location", "no apply detail in the body")
}
func TestSecurityHandler_ReloadGeoIP_InitialisesServiceAddedAfterStartup(t *testing.T) {
@@ -136,7 +156,7 @@ func TestSecurityHandler_ReloadGeoIP_InitialisesServiceAddedAfterStartup(t *test
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)
+ require.Equal(t, 1, mgr.calls, "the failed reload still applies the proxy config once")
// The operator adds the database; the reload loads it and applies the proxy config.
require.NoError(t, os.WriteFile(path, minimalCountryMMDB(), 0o600))
@@ -145,7 +165,7 @@ func TestSecurityHandler_ReloadGeoIP_InitialisesServiceAddedAfterStartup(t *test
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.Equal(t, 2, mgr.calls, "the successful reload adds one apply")
require.True(t, svc.IsLoaded())
statusW := httptest.NewRecorder()
diff --git a/backend/internal/api/handlers/security_geoip_status_test.go b/backend/internal/api/handlers/security_geoip_status_test.go
new file mode 100644
index 000000000..2069d055c
--- /dev/null
+++ b/backend/internal/api/handlers/security_geoip_status_test.go
@@ -0,0 +1,189 @@
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "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/geoip"
+ "github.com/Wikid82/charon/backend/internal/services"
+)
+
+// getGeoIPStatusBody calls GET /security/geoip/status on h and decodes the JSON object.
+func getGeoIPStatusBody(t *testing.T, h *SecurityHandler) map[string]any {
+ t.Helper()
+ r := gin.New()
+ r.GET("/security/geoip/status", h.GetGeoIPStatus)
+ w := httptest.NewRecorder()
+ r.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))
+ return body
+}
+
+// loadedGeoIPService returns a service that has loaded a country database written at path.
+func loadedGeoIPService(t *testing.T, path string) *services.GeoIPService {
+ t.Helper()
+ require.NoError(t, os.WriteFile(path, minimalCountryMMDB(), 0o600))
+ svc, err := services.NewGeoIPService(path)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = svc.Close() })
+ return svc
+}
+
+func TestSecurityHandler_GetGeoIPStatus_ExtendedFieldsWhenLoaded(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ h := NewSecurityHandler(config.SecurityConfig{}, openGeoIPReasonTestDB(t), nil)
+ h.SetGeoIPService(loadedGeoIPService(t, path))
+
+ body := getGeoIPStatusBody(t, h)
+
+ // Existing fields keep their meaning.
+ assert.Equal(t, true, body["available"])
+ assert.Equal(t, true, body["loaded"])
+ assert.Equal(t, path, body["db_path"])
+ assert.Equal(t, "GeoIP service available", body["message"])
+ assert.NotContains(t, body, "reason", "the reason is present only when the database is unavailable")
+
+ // Card fields. The temporary path is not a known location, so no named source applies.
+ assert.Equal(t, string(geoip.SourceNone), body["source"])
+ assert.Equal(t, "GeoLite2-Country", body["database_type"])
+ assert.Nil(t, body["build_date"], "the fixture carries no build epoch")
+ assert.Equal(t, float64(len(minimalCountryMMDB())), body["size_bytes"])
+ assert.IsType(t, "", body["loaded_at"])
+ assert.IsType(t, false, body["data_copy_present"])
+ // Whether the real data folder is writable depends on the host; the reason must agree with the flag.
+ assert.Equal(t, body["can_replace"] == true, body["replace_blocked_reason"] == "")
+ assert.Equal(t, float64(0), body["unenforced_lists_total"])
+ assert.Contains(t, body, "included_build_date", "the field is always present, null when unknown")
+}
+
+func TestSecurityHandler_GetGeoIPStatus_CustomPathReportsSourceAndBlock(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "custom.mmdb")
+ t.Setenv(geoip.EnvDBPath, path)
+ h := NewSecurityHandler(config.SecurityConfig{}, openGeoIPReasonTestDB(t), nil)
+ h.SetGeoIPService(loadedGeoIPService(t, path))
+
+ body := getGeoIPStatusBody(t, h)
+
+ assert.Equal(t, string(geoip.SourceCustom), body["source"])
+ assert.Equal(t, false, body["can_replace"])
+ assert.Equal(t, "custom_path_set", body["replace_blocked_reason"])
+}
+
+func TestSecurityHandler_GetGeoIPStatus_UnloadedHasNullFields(t *testing.T) {
+ h := NewSecurityHandler(config.SecurityConfig{}, openGeoIPReasonTestDB(t), nil)
+ h.SetGeoIPService(services.NewUnloadedGeoIPService(filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")))
+
+ body := getGeoIPStatusBody(t, h)
+
+ assert.Equal(t, false, body["available"])
+ assert.Equal(t, geoip.DatabaseFileName, filepath.Base(body["db_path"].(string)))
+ assert.Equal(t, "geoip_unavailable", body["reason"])
+ assert.Equal(t, string(geoip.SourceNone), body["source"])
+ assert.Nil(t, body["build_date"])
+ assert.Nil(t, body["size_bytes"])
+ assert.Nil(t, body["loaded_at"])
+ assert.Equal(t, "", body["database_type"])
+}
+
+func TestSecurityHandler_GetGeoIPStatus_NotInitializedReportsNoSource(t *testing.T) {
+ h := NewSecurityHandler(config.SecurityConfig{}, nil, nil)
+
+ body := getGeoIPStatusBody(t, h)
+
+ assert.Equal(t, string(geoip.SourceNone), body["source"])
+ assert.Equal(t, false, body["can_replace"])
+ assert.Equal(t, float64(0), body["unenforced_lists_total"])
+}
+
+func TestSecurityHandler_SetGeoIPServiceNilClearsStore(t *testing.T) {
+ h := NewSecurityHandler(config.SecurityConfig{}, openGeoIPReasonTestDB(t), nil)
+ h.SetGeoIPService(services.NewUnloadedGeoIPService(filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")))
+ h.SetGeoIPService(nil)
+
+ body := getGeoIPStatusBody(t, h)
+
+ assert.Equal(t, string(geoip.SourceNone), body["source"])
+ assert.Nil(t, h.geoipStore)
+}
+
+func TestSecurityHandler_GetGeoIPStatus_CountsUnenforcedCountryLists(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ list := createCountryList(t, db, 1, "Countries", "geo_whitelist", true)
+ attachHost(t, db, "site", list, true)
+ h := aclOnAlertHandler(t, db, &plainManager{}, unloadedAlertService(t))
+
+ body := getGeoIPStatusBody(t, h)
+
+ assert.Equal(t, float64(1), body["unenforced_lists_total"])
+}
+
+// blockingCaddyManager holds ApplyConfig until release is closed, so a second request can find a change running.
+type blockingCaddyManager struct {
+ started chan struct{}
+ release chan struct{}
+}
+
+func (m *blockingCaddyManager) ApplyConfig(_ context.Context) error {
+ close(m.started)
+ <-m.release
+ return nil
+}
+
+func TestSecurityHandler_ReloadGeoIP_SuccessIncludesStatus(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ require.NoError(t, os.WriteFile(path, minimalCountryMMDB(), 0o600))
+ h := NewSecurityHandler(config.SecurityConfig{}, openGeoIPReasonTestDB(t), &countingCaddyManager{})
+ h.SetGeoIPService(services.NewUnloadedGeoIPService(path))
+
+ w := httptest.NewRecorder()
+ reloadGeoIPRouter(h).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/security/geoip/reload", 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, true, body["loaded"])
+ assert.Equal(t, path, body["db_path"])
+ status, ok := body["status"].(map[string]any)
+ require.True(t, ok, "the reload body carries the database status")
+ assert.Equal(t, "GeoLite2-Country", status["database_type"])
+ assert.Equal(t, float64(0), status["unenforced_lists_total"])
+ assert.Contains(t, status, "replace_blocked_reason")
+}
+
+func TestSecurityHandler_ReloadGeoIP_ConcurrentChangeIsBusy(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ require.NoError(t, os.WriteFile(path, minimalCountryMMDB(), 0o600))
+ mgr := &blockingCaddyManager{started: make(chan struct{}), release: make(chan struct{})}
+ h := NewSecurityHandler(config.SecurityConfig{}, openGeoIPReasonTestDB(t), mgr)
+ h.SetGeoIPService(services.NewUnloadedGeoIPService(path))
+ router := reloadGeoIPRouter(h)
+
+ first := make(chan *httptest.ResponseRecorder, 1)
+ go func() {
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/security/geoip/reload", http.NoBody))
+ first <- w
+ }()
+ <-mgr.started
+
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/security/geoip/reload", http.NoBody))
+ assert.Equal(t, http.StatusConflict, w.Code)
+ _, code := decodeReloadErrorBody(t, w.Body.String())
+ assert.Equal(t, "geoip_busy", code)
+
+ close(mgr.release)
+ assert.Equal(t, http.StatusOK, (<-first).Code)
+}
diff --git a/backend/internal/api/handlers/security_handler.go b/backend/internal/api/handlers/security_handler.go
index 0e48343bd..43d0ec7fe 100644
--- a/backend/internal/api/handlers/security_handler.go
+++ b/backend/internal/api/handlers/security_handler.go
@@ -1,6 +1,7 @@
package handlers
import (
+ "context"
"encoding/json"
"errors"
"fmt"
@@ -15,9 +16,9 @@ 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/geoip"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/models"
securitypkg "github.com/Wikid82/charon/backend/internal/security"
@@ -57,6 +58,7 @@ type SecurityHandler struct {
svc *services.SecurityService
caddyManager CaddyConfigManager
geoipSvc *services.GeoIPService
+ geoipStore *services.GeoIPDatabaseStore
cerberus CacheInvalidator
crowdsec CrowdsecLifecycle
}
@@ -84,9 +86,26 @@ func (h *SecurityHandler) Close() {
h.svc.Close()
}
-// SetGeoIPService sets the GeoIP service for the handler.
+// SetGeoIPService sets the GeoIP service for the handler and builds the database store over it. The store applies the
+// proxy config after a change when the handler has a caddy manager.
func (h *SecurityHandler) SetGeoIPService(geoipSvc *services.GeoIPService) {
h.geoipSvc = geoipSvc
+ h.geoipStore = nil
+ if geoipSvc == nil {
+ return
+ }
+ var apply func(context.Context) error
+ if mgr := h.caddyManager; mgr != nil {
+ apply = func(ctx context.Context) error {
+ return applyProxyConfigDetached(ctx, mgr, proxyApplyTimeout)
+ }
+ }
+ h.geoipStore = services.NewGeoIPDatabaseStore(services.GeoIPDatabaseStoreConfig{
+ Service: geoipSvc,
+ Apply: apply,
+ DataPath: geoip.DataDatabasePath(),
+ BundledPath: geoip.BundledDatabasePath(),
+ })
}
// GetStatus returns the current status of all security services.
@@ -700,132 +719,6 @@ func (h *SecurityHandler) GetRateLimitPresets(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"presets": presets})
}
-// 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) {
- available := h.geoipSvc.Available()
- 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, body)
-}
-
-// Stable error_code values and fixed messages of the GeoIP reload endpoint. The error text of the underlying failure
-// (which can name filesystem paths) is logged server-side only and never returned to the client.
-const (
- codeGeoIPReloadFailed = "geoip_reload_failed"
- codeGeoIPServiceUnavailable = "geoip_service_unavailable"
- codeProxyApplyFailed = "proxy_apply_failed"
- msgGeoIPReloadFailed = "Failed to reload the GeoIP database"
-)
-
-// ReloadGeoIP reloads the GeoIP database from disk.
-func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) {
- if !requireAdmin(c) {
- return
- }
-
- if h.geoipSvc == nil {
- c.JSON(http.StatusServiceUnavailable, gin.H{
- "error": "GeoIP service not initialized",
- "error_code": codeGeoIPServiceUnavailable,
- })
- return
- }
-
- if err := h.geoipSvc.Load(); err != nil {
- log.WithField("detail", util.SanitizeForLog(err.Error())).Error("Failed to reload GeoIP database")
- c.JSON(http.StatusInternalServerError, gin.H{
- "error": msgGeoIPReloadFailed,
- "error_code": codeGeoIPReloadFailed,
- })
- 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.WithField("detail", util.SanitizeForLog(err.Error())).Warn("failed to apply proxy config after GeoIP reload")
- c.JSON(http.StatusInternalServerError, gin.H{
- "error": msgModuleApplyFailed,
- "error_code": codeProxyApplyFailed,
- })
- return
- }
- }
-
- // Log audit event
- actor := auditActor(c)
- _ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "reload_geoip", Details: "GeoIP database reloaded successfully"})
-
- c.JSON(http.StatusOK, gin.H{
- "message": "GeoIP database reloaded successfully",
- "loaded": h.geoipSvc.IsLoaded(),
- "db_path": h.geoipSvc.GetDatabasePath(),
- })
-}
-
-// LookupGeoIP performs a GeoIP lookup for a given IP address.
-func (h *SecurityHandler) LookupGeoIP(c *gin.Context) {
- if !requireAdmin(c) {
- return
- }
-
- var req struct {
- IPAddress string `json:"ip_address" binding:"required"`
- }
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "ip_address is required"})
- return
- }
-
- if h.geoipSvc == nil || !h.geoipSvc.IsLoaded() {
- c.JSON(http.StatusServiceUnavailable, gin.H{
- "error": "GeoIP service not available",
- })
- return
- }
-
- country, err := h.geoipSvc.LookupCountry(req.IPAddress)
- if err != nil {
- if errors.Is(err, services.ErrInvalidGeoIP) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid IP address"})
- return
- }
- if errors.Is(err, services.ErrCountryNotFound) {
- c.JSON(http.StatusOK, gin.H{
- "ip_address": req.IPAddress,
- "country_code": "",
- "found": false,
- "message": "No country found for this IP address",
- })
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "GeoIP lookup failed: " + err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{
- "ip_address": req.IPAddress,
- "country_code": country,
- "found": true,
- })
-}
-
// GetWAFExclusions returns current WAF rule exclusions from SecurityConfig
func (h *SecurityHandler) GetWAFExclusions(c *gin.Context) {
cfg, err := h.svc.Get()
diff --git a/backend/internal/api/handlers/security_status_enforcement.go b/backend/internal/api/handlers/security_status_enforcement.go
index 7c3c162a3..f096cf00b 100644
--- a/backend/internal/api/handlers/security_status_enforcement.go
+++ b/backend/internal/api/handlers/security_status_enforcement.go
@@ -1,8 +1,11 @@
package handlers
import (
+ "context"
+
"github.com/gin-gonic/gin"
+ "github.com/Wikid82/charon/backend/internal/alerts"
"github.com/Wikid82/charon/backend/internal/caddy"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/models"
@@ -49,26 +52,84 @@ func addModuleAppliedState(status gin.H, module caddy.ModuleEnforcement) {
// countryListTypes are the access list types that match on the GeoIP country.
var countryListTypes = []string{"geo_whitelist", "geo_blacklist"}
-// 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 || h.geoipSvc.Available() || !h.hasEnabledCountryList() {
- return
+// appliedReporter is implemented by the caddy manager. It returns the report of the last successful apply.
+type appliedReporter interface {
+ LastAppliedReport() (caddy.EnforcementReport, bool)
+}
+
+// countryListsNotEnforced returns the sorted names of the country lists the proxy is not enforcing because no GeoIP
+// database is usable. The alert and the ACL status reason both come from it, so they never disagree.
+//
+// After a successful apply the last applied report decides, and live database availability is not consulted: the
+// report describes the running config until a newer apply succeeds. Before any successful apply there is no report, so
+// the same rule the config generator uses is evaluated against the database. aclEnabled is the current ACL state and
+// is used only for that fallback.
+func (h *SecurityHandler) countryListsNotEnforced(aclEnabled bool) []string {
+ if reporter, ok := h.caddyManager.(appliedReporter); ok {
+ if report, applied := reporter.LastAppliedReport(); applied {
+ if !report.ACLOn {
+ return nil
+ }
+ return report.CountryListsWithoutDatabase
+ }
}
- aclStatus["reason"] = caddy.ReasonGeoIPUnavailable
+ return h.pendingCountryLists(aclEnabled)
}
-// 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
+// pendingCountryLists evaluates, before any apply, the rule the config generator uses: access control is on, no
+// database is available, and an enabled country list is attached to an enabled host with a domain. A failed read
+// counts as none, so the status never reports a reason it cannot confirm.
+func (h *SecurityHandler) pendingCountryLists(aclEnabled bool) []string {
+ if h.db == nil || !aclEnabled || h.geoipSvc.Available() {
+ return nil
}
- var count int64
- err := h.db.Model(&models.AccessList{}).Where("enabled = ? AND type IN ?", true, countryListTypes).Count(&count).Error
+ var names []string
+ err := h.db.Model(&models.ProxyHost{}).
+ Joins("JOIN access_lists ON access_lists.id = proxy_hosts.access_list_id").
+ Where("proxy_hosts.enabled = ? AND proxy_hosts.domain_names <> ? AND access_lists.enabled = ? AND access_lists.type IN ?",
+ true, "", true, countryListTypes).
+ Distinct("access_lists.name").
+ Order("access_lists.name").
+ Pluck("access_lists.name", &names).Error
if err != nil {
- logger.Log().WithError(err).Warn("Failed to count country access lists for status")
- return false
+ logger.Log().WithError(err).Warn("Failed to read country access lists for status")
+ return nil
+ }
+ return names
+}
+
+// addACLGeoIPReason adds the geoip_unavailable reason to the ACL status when a country list is not being enforced.
+func (h *SecurityHandler) addACLGeoIPReason(aclStatus gin.H, aclEnabled bool) {
+ if len(h.countryListsNotEnforced(aclEnabled)) > 0 {
+ aclStatus["reason"] = caddy.ReasonGeoIPUnavailable
+ }
+}
+
+// aclEnabledForAlerts reports whether access control is switched on now, from the settings key and the static
+// configuration. It is the fallback input of countryListsNotEnforced before any apply has succeeded.
+func (h *SecurityHandler) aclEnabledForAlerts() bool {
+ res, err := models.ResolveACL(h.db, h.cfg.ACLMode == "enabled")
+ logStatusReadError("acl", err)
+ return res.Enabled
+}
+
+// GeoIPAlertSource returns the notification bell alert for country lists that are not enforced because no GeoIP
+// database is usable. It is active while countryListsNotEnforced names a list, so it follows the last applied config
+// and goes away once a later apply succeeds with a usable database. DatabaseAvailable tells the bell whether a reload
+// can help.
+func (h *SecurityHandler) GeoIPAlertSource() alerts.Source {
+ return func(context.Context) (alerts.Alert, bool, error) {
+ lists := h.countryListsNotEnforced(h.aclEnabledForAlerts())
+ if len(lists) == 0 {
+ return alerts.Alert{}, false, nil
+ }
+ available := h.geoipSvc.Available()
+ return alerts.Alert{
+ Key: alerts.KeyGeoIPUnavailable,
+ Severity: models.NotificationTypeError,
+ AffectedLists: lists[:min(len(lists), alerts.MaxAffectedLists)],
+ AffectedListsTotal: len(lists),
+ DatabaseAvailable: &available,
+ }, true, nil
}
- return count > 0
}
diff --git a/backend/internal/api/handlers/security_status_enforcement_test.go b/backend/internal/api/handlers/security_status_enforcement_test.go
new file mode 100644
index 000000000..ad85b726d
--- /dev/null
+++ b/backend/internal/api/handlers/security_status_enforcement_test.go
@@ -0,0 +1,205 @@
+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/caddy"
+ "github.com/Wikid82/charon/backend/internal/config"
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/Wikid82/charon/backend/internal/services"
+)
+
+// appliedManager is a config manager whose last applied report is set by the test.
+type appliedManager struct {
+ CaddyConfigManager
+ report caddy.EnforcementReport
+ applied bool
+}
+
+func (m *appliedManager) LastAppliedReport() (caddy.EnforcementReport, bool) {
+ return m.report, m.applied
+}
+
+// plainManager is a config manager with no last applied report.
+type plainManager struct {
+ CaddyConfigManager
+}
+
+// openEnforcementStatusDB opens a file-backed SQLite database with the tables the country list lookup reads.
+func openEnforcementStatusDB(t *testing.T) *gorm.DB {
+ t.Helper()
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "enforcement-status.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{}, &models.ProxyHost{}))
+ return db
+}
+
+// newEnforcementHandler returns a security handler with an unloaded GeoIP service, so no database is available.
+func newEnforcementHandler(t *testing.T, db *gorm.DB, manager CaddyConfigManager) *SecurityHandler {
+ t.Helper()
+ h := NewSecurityHandler(config.SecurityConfig{}, db, manager)
+ h.SetGeoIPService(services.NewUnloadedGeoIPService(filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")))
+ t.Cleanup(h.Close)
+ return h
+}
+
+// createCountryList stores an access list of the given type and enabled state.
+func createCountryList(t *testing.T, db *gorm.DB, id uint, name, listType string, enabled bool) models.AccessList {
+ t.Helper()
+ list := models.AccessList{ID: id, UUID: name, Name: name, Type: listType, CountryCodes: "US", Enabled: enabled}
+ require.NoError(t, db.Create(&list).Error)
+ return list
+}
+
+// attachHost stores a proxy host behind the list. An explicit disabled state is written after the create, because a
+// zero Enabled value would take the column default.
+func attachHost(t *testing.T, db *gorm.DB, uuid string, list models.AccessList, enabled bool) {
+ t.Helper()
+ listID := list.ID
+ host := models.ProxyHost{UUID: uuid, DomainNames: uuid + ".example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &listID}
+ require.NoError(t, db.Create(&host).Error)
+ if !enabled {
+ require.NoError(t, db.Model(&host).Update("enabled", false).Error)
+ }
+}
+
+func TestCountryListsNotEnforced_LastAppliedReportWins(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ manager := &appliedManager{applied: true, report: caddy.EnforcementReport{ACLOn: true, CountryListsWithoutDatabase: []string{"Countries"}}}
+ h := newEnforcementHandler(t, db, manager)
+
+ // The live state is ignored: ACL is off now, and no list is in the database, yet the report still names one.
+ assert.Equal(t, []string{"Countries"}, h.countryListsNotEnforced(false))
+}
+
+func TestCountryListsNotEnforced_LastAppliedWithACLOffIsEmpty(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ manager := &appliedManager{applied: true, report: caddy.EnforcementReport{ACLOn: false, CountryListsWithoutDatabase: []string{"Countries"}}}
+ h := newEnforcementHandler(t, db, manager)
+
+ assert.Empty(t, h.countryListsNotEnforced(true))
+}
+
+func TestCountryListsNotEnforced_LastAppliedWithNoListsIsEmpty(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ manager := &appliedManager{applied: true, report: caddy.EnforcementReport{ACLOn: true}}
+ h := newEnforcementHandler(t, db, manager)
+
+ assert.Empty(t, h.countryListsNotEnforced(true))
+}
+
+func TestCountryListsNotEnforced_FallbackNamesAttachedEnabledList(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ list := createCountryList(t, db, 1, "No RU/CN", "geo_blacklist", true)
+ attachHost(t, db, "site", list, true)
+ h := newEnforcementHandler(t, db, &plainManager{})
+
+ assert.Equal(t, []string{"No RU/CN"}, h.countryListsNotEnforced(true))
+}
+
+func TestCountryListsNotEnforced_FallbackNamesAreSortedAndUnique(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ countries := createCountryList(t, db, 1, "Countries", "geo_whitelist", true)
+ alpha := createCountryList(t, db, 2, "Alpha", "geo_blacklist", true)
+ attachHost(t, db, "one", countries, true)
+ attachHost(t, db, "two", countries, true)
+ attachHost(t, db, "three", alpha, true)
+ h := newEnforcementHandler(t, db, nil)
+
+ assert.Equal(t, []string{"Alpha", "Countries"}, h.countryListsNotEnforced(true))
+}
+
+func TestCountryListsNotEnforced_FallbackIgnoresIneligibleLists(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ disabledHost := createCountryList(t, db, 1, "Host off", "geo_whitelist", true)
+ disabledList := createCountryList(t, db, 2, "List off", "geo_whitelist", false)
+ ipList := createCountryList(t, db, 3, "IP only", "whitelist", true)
+ attachHost(t, db, "off", disabledHost, false)
+ attachHost(t, db, "list-off", disabledList, true)
+ attachHost(t, db, "ip", ipList, true)
+ h := newEnforcementHandler(t, db, nil)
+
+ assert.Empty(t, h.countryListsNotEnforced(true))
+}
+
+func TestCountryListsNotEnforced_FallbackNeedsAccessControlOn(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ list := createCountryList(t, db, 1, "Countries", "geo_whitelist", true)
+ attachHost(t, db, "site", list, true)
+ h := newEnforcementHandler(t, db, nil)
+
+ assert.Empty(t, h.countryListsNotEnforced(false))
+}
+
+func TestCountryListsNotEnforced_FallbackWithoutDatabaseHandleIsEmpty(t *testing.T) {
+ h := newEnforcementHandler(t, nil, nil)
+
+ assert.Empty(t, h.countryListsNotEnforced(true))
+}
+
+func TestCountryListsNotEnforced_FallbackQueryErrorIsEmpty(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ require.NoError(t, db.Migrator().DropTable(&models.ProxyHost{}))
+ h := newEnforcementHandler(t, db, nil)
+
+ assert.Empty(t, h.countryListsNotEnforced(true))
+}
+
+func TestAddACLGeoIPReason_SetsReasonFromLastAppliedReport(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ manager := &appliedManager{applied: true, report: caddy.EnforcementReport{ACLOn: true, CountryListsWithoutDatabase: []string{"Countries"}}}
+ h := newEnforcementHandler(t, db, manager)
+
+ aclStatus := gin.H{"enabled": true}
+ h.addACLGeoIPReason(aclStatus, true)
+
+ assert.Equal(t, caddy.ReasonGeoIPUnavailable, aclStatus["reason"])
+}
+
+func TestAddACLGeoIPReason_LeavesStatusWithoutUnenforcedLists(t *testing.T) {
+ db := openEnforcementStatusDB(t)
+ h := newEnforcementHandler(t, db, &plainManager{})
+
+ aclStatus := gin.H{"enabled": true}
+ h.addACLGeoIPReason(aclStatus, true)
+
+ _, has := aclStatus["reason"]
+ assert.False(t, has)
+}
+
+// TestEnforcementStatus_ReasonInResponse pins that the status response carries the reason computed from the report.
+func TestEnforcementStatus_ReasonInResponse(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ db := openEnforcementStatusDB(t)
+ list := createCountryList(t, db, 1, "Countries", "geo_whitelist", true)
+ attachHost(t, db, "site", list, true)
+ manager := &appliedManager{applied: true, report: caddy.EnforcementReport{ACLOn: true, CountryListsWithoutDatabase: []string{"Countries"}}}
+ h := NewSecurityHandler(config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled"}, db, manager)
+ h.SetGeoIPService(services.NewUnloadedGeoIPService(filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")))
+ 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))
+ reason, has := aclReasonFromStatus(t, body)
+ assert.True(t, has)
+ assert.Equal(t, caddy.ReasonGeoIPUnavailable, reason)
+}
diff --git a/backend/internal/api/middleware/browser_origin.go b/backend/internal/api/middleware/browser_origin.go
new file mode 100644
index 000000000..7df0c07de
--- /dev/null
+++ b/backend/internal/api/middleware/browser_origin.go
@@ -0,0 +1,43 @@
+package middleware
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/Wikid82/charon/backend/internal/logger"
+ "github.com/Wikid82/charon/backend/internal/util"
+)
+
+// crossOriginProtection is the standard library's cross-origin check. It has no trusted-origin list, so only same-origin
+// and non-browser requests pass for unsafe methods.
+var crossOriginProtection = http.NewCrossOriginProtection()
+
+// RequireSameOriginForBrowserAuth refuses cross-origin requests that a browser would send with ambient credentials. A
+// request that carries an Authorization header is header-authenticated, so it passes unchanged: a page on another
+// origin cannot add that header without a CORS preflight, which Charon does not grant. Any other request is checked
+// with the standard library's Sec-Fetch-Site and Origin rules. Safe methods and non-browser clients pass. A refused
+// request gets 403 cross_origin_refused before the handler runs.
+//
+// SameSite cookies alone do not cover same-site sibling origins, so mutating routes that accept cookie sessions use
+// this check.
+func RequireSameOriginForBrowserAuth() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if len(c.Request.Header.Values("Authorization")) > 0 {
+ c.Next()
+ return
+ }
+ if err := crossOriginProtection.Check(c.Request); err != nil {
+ logger.Log().
+ WithField("sec_fetch_site", util.SanitizeForLog(c.Request.Header.Get("Sec-Fetch-Site"))).
+ WithField("origin", util.SanitizeForLog(c.Request.Header.Get("Origin"))).
+ Debug("Refused cross-origin request to a cookie-authenticated route")
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
+ "error": "Cross-origin request refused",
+ "error_code": "cross_origin_refused",
+ })
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/backend/internal/api/middleware/browser_origin_test.go b/backend/internal/api/middleware/browser_origin_test.go
new file mode 100644
index 000000000..28a06f883
--- /dev/null
+++ b/backend/internal/api/middleware/browser_origin_test.go
@@ -0,0 +1,78 @@
+package middleware
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRequireSameOriginForBrowserAuth(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ const host = "charon.test"
+
+ cases := []struct {
+ name string
+ method string
+ authorization string
+ secFetchSite string
+ origin string
+ wantStatus int
+ }{
+ {name: "same-origin browser request", method: http.MethodPost, secFetchSite: "same-origin", wantStatus: http.StatusOK},
+ {name: "user-initiated request", method: http.MethodPost, secFetchSite: "none", wantStatus: http.StatusOK},
+ {
+ name: "same-site sibling subdomain", method: http.MethodPost, secFetchSite: "same-site",
+ origin: "https://app.charon.test", wantStatus: http.StatusForbidden,
+ },
+ {
+ name: "cross-site request", method: http.MethodPost, secFetchSite: "cross-site",
+ origin: "https://evil.example", wantStatus: http.StatusForbidden,
+ },
+ {name: "origin matches host without fetch metadata", method: http.MethodPost, origin: "https://charon.test", wantStatus: http.StatusOK},
+ {name: "origin of a sibling host without fetch metadata", method: http.MethodPost, origin: "https://app.charon.test", wantStatus: http.StatusForbidden},
+ {name: "non-browser client without either header", method: http.MethodPost, wantStatus: http.StatusOK},
+ {
+ name: "header-authenticated cross-site request", method: http.MethodPost, authorization: "Bearer x",
+ secFetchSite: "cross-site", origin: "https://evil.example", wantStatus: http.StatusOK,
+ },
+ {name: "cross-site safe method", method: http.MethodGet, secFetchSite: "cross-site", wantStatus: http.StatusOK},
+ {
+ name: "cross-site delete", method: http.MethodDelete, secFetchSite: "cross-site",
+ origin: "https://evil.example", wantStatus: http.StatusForbidden,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ r := gin.New()
+ r.Use(RequireSameOriginForBrowserAuth())
+ r.Handle(tc.method, "/security/geoip/database", func(c *gin.Context) { c.Status(http.StatusOK) })
+
+ req := httptest.NewRequest(tc.method, "/security/geoip/database", http.NoBody)
+ req.Host = host
+ if tc.authorization != "" {
+ req.Header.Set("Authorization", tc.authorization)
+ }
+ if tc.secFetchSite != "" {
+ req.Header.Set("Sec-Fetch-Site", tc.secFetchSite)
+ }
+ if tc.origin != "" {
+ req.Header.Set("Origin", tc.origin)
+ }
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, tc.wantStatus, w.Code)
+ if tc.wantStatus == http.StatusForbidden {
+ var body map[string]string
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+ assert.Equal(t, "cross_origin_refused", body["error_code"])
+ assert.Equal(t, "Cross-origin request refused", body["error"])
+ }
+ })
+ }
+}
diff --git a/backend/internal/api/routes/alerts_routes_test.go b/backend/internal/api/routes/alerts_routes_test.go
new file mode 100644
index 000000000..486c09086
--- /dev/null
+++ b/backend/internal/api/routes/alerts_routes_test.go
@@ -0,0 +1,48 @@
+package routes
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+const notificationAlertsPath = "/api/v1/notifications/alerts"
+
+func TestRegister_NotificationAlerts_UnauthenticatedRejected(t *testing.T) {
+ app := newThrottledApp(t, withAuthBudget(50, 50))
+
+ w := app.do(http.MethodGet, notificationAlertsPath, "", "", nil)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+}
+
+func TestRegister_NotificationAlerts_PassthroughRejected(t *testing.T) {
+ app := newThrottledApp(t, withAuthBudget(50, 50))
+ _, token := app.createUser(models.RolePassthrough)
+
+ w := app.do(http.MethodGet, notificationAlertsPath, token, "", nil)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+}
+
+// TestRegister_NotificationAlerts_ManagementUserGetsAlerts pins that GET resolves to the alerts handler for viewers and
+// admins. With no alert sources registered, the answer is an empty list.
+func TestRegister_NotificationAlerts_ManagementUserGetsAlerts(t *testing.T) {
+ app := newThrottledApp(t, withAuthBudget(50, 50))
+
+ for _, role := range []models.UserRole{models.RoleUser, models.RoleAdmin} {
+ _, token := app.createUser(role)
+
+ w := app.do(http.MethodGet, notificationAlertsPath, token, "", nil)
+
+ require.Equal(t, http.StatusOK, w.Code, "role %s body: %s", role, w.Body.String())
+ var body map[string]json.RawMessage
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+ assert.JSONEq(t, `[]`, string(body["alerts"]), "role %s", role)
+ }
+}
diff --git a/backend/internal/api/routes/geoip_database_routes_test.go b/backend/internal/api/routes/geoip_database_routes_test.go
new file mode 100644
index 000000000..115167f65
--- /dev/null
+++ b/backend/internal/api/routes/geoip_database_routes_test.go
@@ -0,0 +1,98 @@
+package routes
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "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"
+)
+
+// TestRegister_GeoIPDatabaseRoutesRefuseCrossOriginCookieRequests checks the two mutating GeoIP database routes: an admin
+// cookie session from another origin is refused before the handler runs, while header-authenticated and same-origin
+// requests reach it. The requests that reach the handler never touch the data folder, so the test cannot change a
+// database on the host.
+func TestRegister_GeoIPDatabaseRoutesRefuseCrossOriginCookieRequests(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+
+ db, err := gorm.Open(sqlite.Open(isolatedMemoryDSN(t)), &gorm.Config{})
+ require.NoError(t, err)
+ cfg := config.Config{JWTSecret: "test-secret"}
+ require.NoError(t, Register(context.Background(), router, db, cfg))
+
+ authSvc := services.NewAuthService(db, cfg)
+ adminAcct := &models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "admin-geoip-origin@example.com", Role: models.RoleAdmin, Enabled: true}
+ require.NoError(t, db.Create(adminAcct).Error)
+ adminToken, err := authSvc.GenerateToken(adminAcct)
+ require.NoError(t, err)
+ userAcct := &models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "user-geoip-origin@example.com", Role: models.RoleUser, Enabled: true}
+ require.NoError(t, db.Create(userAcct).Error)
+ userToken, err := authSvc.GenerateToken(userAcct)
+ require.NoError(t, err)
+
+ const path = "/api/v1/security/geoip/database"
+ send := func(method, token string, headers map[string]string, body string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, path, strings.NewReader(body))
+ req.Header.Set("Content-Type", "text/plain")
+ if token != "" {
+ req.AddCookie(&http.Cookie{Name: "auth_token", Value: token, HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode})
+ }
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ return w
+ }
+ crossSite := map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}
+ sameSite := map[string]string{"Sec-Fetch-Site": "same-site", "Origin": "https://app.charon.test"}
+
+ for _, method := range []string{http.MethodPost, http.MethodDelete} {
+ t.Run(method+" from another origin is refused", func(t *testing.T) {
+ w := send(method, adminToken, sameSite, "")
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ var body map[string]string
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+ assert.Equal(t, "cross_origin_refused", body["error_code"])
+ })
+ }
+
+ t.Run("POST from the same origin reaches the handler", func(t *testing.T) {
+ w := send(http.MethodPost, adminToken, map[string]string{"Sec-Fetch-Site": "same-origin"}, "not multipart")
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ assert.Contains(t, w.Body.String(), "geoip_file_required")
+ })
+ t.Run("header-authenticated cross-site request is not refused by the origin check", func(t *testing.T) {
+ headers := map[string]string{"Authorization": "Bearer " + adminToken, "Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}
+ w := send(http.MethodPost, "", headers, "not multipart")
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ assert.Contains(t, w.Body.String(), "geoip_file_required")
+ })
+ t.Run("a non-admin cookie session is still refused by the role gate", func(t *testing.T) {
+ w := send(http.MethodPost, userToken, map[string]string{"Sec-Fetch-Site": "same-origin"}, "")
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ })
+ t.Run("a cross-site delete with an admin cookie is refused", func(t *testing.T) {
+ w := send(http.MethodDelete, adminToken, crossSite, "")
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "cross_origin_refused")
+ })
+}
diff --git a/backend/internal/api/routes/geoip_marker.go b/backend/internal/api/routes/geoip_marker.go
index 395837630..c051fc544 100644
--- a/backend/internal/api/routes/geoip_marker.go
+++ b/backend/internal/api/routes/geoip_marker.go
@@ -39,3 +39,13 @@ func geoIPProxyState(svc *services.GeoIPService) func() (string, bool) {
return path, svc.Available()
}
}
+
+// sweepGeoIPDataFolder clears what an interrupted upload or removal left beside livePath, logging a Warn when that
+// fails. It reports whether the folder is clean, so startup carries on either way.
+func sweepGeoIPDataFolder(livePath string) bool {
+ if err := services.SweepGeoIPDataFolder(livePath); err != nil {
+ logger.Log().WithError(err).Warn("Could not clean up the GeoIP data folder after an interrupted change")
+ return false
+ }
+ return true
+}
diff --git a/backend/internal/api/routes/geoip_marker_test.go b/backend/internal/api/routes/geoip_marker_test.go
index 27ff5e9ab..4ba7fb057 100644
--- a/backend/internal/api/routes/geoip_marker_test.go
+++ b/backend/internal/api/routes/geoip_marker_test.go
@@ -18,3 +18,12 @@ func TestReportGeoIPBuildMarker_ReportsOnlyWhenMarkerPresent(t *testing.T) {
assert.True(t, reportGeoIPBuildMarker(dbPath, false))
assert.True(t, reportGeoIPBuildMarker(dbPath, true), "marker is reported even when a database is provided later")
}
+
+func TestSweepGeoIPDataFolder_ReportsWhetherTheFolderIsClean(t *testing.T) {
+ dir := t.TempDir()
+ assert.True(t, sweepGeoIPDataFolder(filepath.Join(dir, "geoip", "GeoLite2-Country.mmdb")), "a missing folder is clean")
+
+ notAFolder := filepath.Join(dir, "file")
+ require.NoError(t, os.WriteFile(notAFolder, []byte("x"), 0o600))
+ assert.False(t, sweepGeoIPDataFolder(filepath.Join(notAFolder, "GeoLite2-Country.mmdb")), "an unreadable folder is logged and startup goes on")
+}
diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go
index e1a4955b8..48b6c2058 100644
--- a/backend/internal/api/routes/routes.go
+++ b/backend/internal/api/routes/routes.go
@@ -14,6 +14,7 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
"gorm.io/gorm"
+ "github.com/Wikid82/charon/backend/internal/alerts"
"github.com/Wikid82/charon/backend/internal/api/handlers"
"github.com/Wikid82/charon/backend/internal/api/middleware"
"github.com/Wikid82/charon/backend/internal/caddy"
@@ -942,6 +943,10 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg
// caddyManager is already created early in Register() for use by settingsHandler
+ // An upload or removal interrupted by a crash leaves temp or previous files in the data folder. They are cleared
+ // before the database path is resolved, so the resolver sees the state the last completed change left.
+ sweepGeoIPDataFolder(geoip.DataDatabasePath())
+
// The GeoIP 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 := initGeoIPAtStartup(geoip.ResolveDatabasePath)
@@ -951,6 +956,10 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg
securityHandler := handlers.NewSecurityHandlerWithDeps(cfg.Security, db, caddyManager, cerb).WithCrowdsecLifecycle(crowdsecHandler)
securityHandler.SetGeoIPService(geoipSvc)
+ // Notification bell alerts: country lists not enforced because no GeoIP database is usable.
+ alertsSvc := alerts.NewService(securityHandler.GeoIPAlertSource())
+ management.GET("/notifications/alerts", handlers.NewAlertsHandler(alertsSvc).List)
+
management.GET("/security/status", securityHandler.GetStatus)
// Security Config management
management.GET("/security/config", securityHandler.GetConfig)
@@ -974,6 +983,9 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg
securityAdmin.DELETE("/rulesets/:id", securityHandler.DeleteRuleSet)
securityAdmin.POST("/geoip/reload", securityHandler.ReloadGeoIP)
securityAdmin.POST("/geoip/lookup", securityHandler.LookupGeoIP)
+ // Cookie sessions reach these two routes only from the same origin; see middleware.RequireSameOriginForBrowserAuth.
+ securityAdmin.POST("/geoip/database", middleware.RequireSameOriginForBrowserAuth(), securityHandler.ReplaceGeoIPDatabase)
+ securityAdmin.DELETE("/geoip/database", middleware.RequireSameOriginForBrowserAuth(), securityHandler.RemoveGeoIPDatabase)
securityAdmin.POST("/waf/exclusions", securityHandler.AddWAFExclusion)
securityAdmin.DELETE("/waf/exclusions/:rule_id", securityHandler.DeleteWAFExclusion)
@@ -1083,6 +1095,7 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg
management.PUT("/access-lists/:id", middleware.RequireRole(models.RoleAdmin), accessListHandler.Update)
management.DELETE("/access-lists/:id", middleware.RequireRole(models.RoleAdmin), accessListHandler.Delete)
management.POST("/access-lists/:id/test", accessListHandler.TestIP)
+ management.POST("/access-lists/self-check", middleware.RequireRole(models.RoleAdmin), accessListHandler.SelfCheck)
// Security Headers. Reads + the three pure calculator POSTs (they do
// not persist) stay on management so the role=user-reachable
diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go
index 972fe7d13..91aeea0d9 100644
--- a/backend/internal/caddy/config.go
+++ b/backend/internal/caddy/config.go
@@ -50,7 +50,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
redirectHosts := resolvedOpts.redirectHosts
// The enforcement report is filled while hosts are built and handed to the sink on every return path.
- report := EnforcementReport{WAFOn: wafEnabled, RateLimitOn: rateLimitEnabled}
+ report := EnforcementReport{WAFOn: wafEnabled, RateLimitOn: rateLimitEnabled, ACLOn: aclEnabled}
if resolvedOpts.enforcement != nil {
defer func() { resolvedOpts.enforcement(report) }()
}
@@ -58,7 +58,6 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
// 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
@@ -595,11 +594,9 @@ 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 {
if isCountryList(host.AccessList) && !geoIPAvailable {
- // A country list cannot match without the database, so the host refuses every request instead.
- securityHandlers = append(securityHandlers, geoUnavailableDenyHandler(adminRanges))
- if !slices.Contains(geoRefusedLists, host.AccessList.Name) {
- geoRefusedLists = append(geoRefusedLists, host.AccessList.Name)
- }
+ // Without a usable database the country list is not enforced: no country matcher and no ACL handler,
+ // so the host stays reachable. The report names the list for the status and the alert.
+ report.CountryListsWithoutDatabase = withSortedName(report.CountryListsWithoutDatabase, host.AccessList.Name)
} else {
if isCountryList(host.AccessList) {
// The geoip2 handler sits directly before the country subroute and reads the database the app opens.
@@ -773,11 +770,11 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
"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")
+ // A country list is enabled but no GeoIP database is usable, so the list is not enforced and its hosts are reachable.
+ // One line per apply names the lists.
+ if len(report.CountryListsWithoutDatabase) > 0 {
+ logger.Log().WithField("lists", report.CountryListsWithoutDatabase).Warn(
+ "Country access list is not enforced because the GeoIP database is unavailable; see the notification in the app")
}
// The rate limit is enabled but its request count or window is not set, so the hosts get no rate limit handler.
@@ -978,12 +975,6 @@ func geoIP2Handler() Handler {
return Handler{"handler": "geoip2", "enable": "strict"}
}
-// geoUnavailableDenyHandler refuses every request except admin whitelist addresses. It replaces a country list while
-// no GeoIP database is usable. With no admin whitelist it refuses every request.
-func geoUnavailableDenyHandler(adminRanges []string) Handler {
- return denyRoute(denyMatcherSet(nil, nil, adminRanges), geoAccessDeniedBody)
-}
-
// denyRoute returns a subroute with one terminal route that answers 403 with body when the matcher set matches.
// A nil set matches every request.
func denyRoute(set map[string]any, body string) Handler {
diff --git a/backend/internal/caddy/config_admin_exclusion_lists_test.go b/backend/internal/caddy/config_admin_exclusion_lists_test.go
index 49c36c9fb..fb5735514 100644
--- a/backend/internal/caddy/config_admin_exclusion_lists_test.go
+++ b/backend/internal/caddy/config_admin_exclusion_lists_test.go
@@ -165,28 +165,10 @@ func TestCountryListMatcherSetShape(t *testing.T) {
})
}
-func TestUnavailableDatabaseDenyAdminExclusion(t *testing.T) {
- cases := []refusalCase{
- {addr: "198.51.100.7", country: "DE", refused: true},
- {addr: "10.1.1.1", country: "DE"},
- }
-
- t.Run("handler exempts admin addresses", func(t *testing.T) {
- assertHandlerRefusals(t, geoUnavailableDenyHandler(network.SplitAddressList("10.0.0.0/8")), []refusalCase{
- {addr: "198.51.100.7", refused: true},
- {addr: "10.1.1.1"},
- })
- })
-
- t.Run("handler refuses everyone without admin and has no match", func(t *testing.T) {
- h := geoUnavailableDenyHandler(nil)
- b, err := json.Marshal(h)
- require.NoError(t, err)
- require.NotContains(t, string(b), `"match"`)
- assertHandlerRefusals(t, h, []refusalCase{{addr: "10.1.1.1", refused: true}})
- })
-
- t.Run("host with unavailable database, admin address passes", func(t *testing.T) {
+// A country list with no usable database is not enforced: its host is built without a country matcher, so no visitor
+// is refused by it, with or without an admin whitelist.
+func TestUnavailableDatabaseCountryListIsNotEnforced(t *testing.T) {
+ t.Run("allow-list with admin whitelist lets every visitor through", func(t *testing.T) {
hostACL := models.AccessList{ID: 12, Name: "countries", Type: "geo_whitelist", CountryCodes: "DE", Enabled: true}
host := models.ProxyHost{
UUID: "unavail-host",
@@ -199,10 +181,15 @@ func TestUnavailableDatabaseDenyAdminExclusion(t *testing.T) {
}
cfg, err := GenerateConfig([]models.ProxyHost{host}, t.TempDir(), "", "", "", false, false, false, false, true, "10.0.0.0/8", nil, nil, nil, nil, nil, WithGeoIPDatabase(testGeoIPDBPath, false))
require.NoError(t, err)
- assertHostRefusals(t, cfg, "unavail.example.com", cases)
+ assertHostRefusals(t, cfg, "unavail.example.com", []refusalCase{
+ {addr: "198.51.100.7", country: "DE"},
+ {addr: "198.51.100.7", country: "US"},
+ {addr: "10.1.1.1", country: "US"},
+ {addr: "198.51.100.7", country: ""},
+ })
})
- t.Run("host with unavailable database and no admin refuses every visitor", func(t *testing.T) {
+ t.Run("block-list without admin whitelist blocks no one", func(t *testing.T) {
hostACL := models.AccessList{ID: 13, Name: "countries", Type: "geo_blacklist", CountryCodes: "RU", Enabled: true}
host := models.ProxyHost{
UUID: "unavail-none-host",
@@ -215,7 +202,10 @@ func TestUnavailableDatabaseDenyAdminExclusion(t *testing.T) {
}
cfg, err := GenerateConfig([]models.ProxyHost{host}, t.TempDir(), "", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil, WithGeoIPDatabase(testGeoIPDBPath, false))
require.NoError(t, err)
- assertHostRefusals(t, cfg, "unavail-none.example.com", []refusalCase{{addr: "10.1.1.1", refused: true}})
+ assertHostRefusals(t, cfg, "unavail-none.example.com", []refusalCase{
+ {addr: "10.1.1.1", country: "RU"},
+ {addr: "198.51.100.7", country: "RU"},
+ })
})
}
diff --git a/backend/internal/caddy/config_geoip2_test.go b/backend/internal/caddy/config_geoip2_test.go
index a1445bcfa..d178eec33 100644
--- a/backend/internal/caddy/config_geoip2_test.go
+++ b/backend/internal/caddy/config_geoip2_test.go
@@ -18,6 +18,8 @@ const (
geoDeniedBody = "Access denied: Geographic restriction"
geoIP2HandlerMarker = `"handler":"geoip2"`
geoCountryMatcher = "{geoip2.country_code}"
+ // geoNotEnforcedWarning is the start of the one Warn line per apply that names the unenforced country lists.
+ geoNotEnforcedWarning = "Country access list is not enforced because the GeoIP database is unavailable"
)
// generateCountryListConfig builds one enabled host behind a country list of the given type and returns the config
@@ -65,7 +67,7 @@ func TestGenerateConfig_CountryListWithDatabase_AddsGeoIP2AppAndStrictHandler(t
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")
+ require.NotContains(t, warnings, geoNotEnforcedWarning)
})
}
}
@@ -115,7 +117,7 @@ func TestGenerateConfig_CountryListWithoutACLEnabledNoGeoIP2App(t *testing.T) {
require.Nil(t, cfg.Apps.GeoIP2)
}
-func TestGenerateConfig_CountryListDatabaseUnavailable_DeniesEveryHost(t *testing.T) {
+func TestGenerateConfig_CountryListDatabaseUnavailable_LeavesHostNotEnforced(t *testing.T) {
missingFile := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
cases := map[string]struct {
opts []GenerateConfigOption
@@ -135,10 +137,11 @@ func TestGenerateConfig_CountryListDatabaseUnavailable_DeniesEveryHost(t *testin
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, geoDeniedBody, "the host is not refused by the list")
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, routes, `"reverse_proxy"`, "the host keeps its proxy handler")
+ require.Equal(t, 1, strings.Count(warnings, geoNotEnforcedWarning), "one Warn per apply")
require.Contains(t, warnings, "Countries", "the Warn names the list")
}
})
@@ -156,8 +159,8 @@ func TestGenerateConfig_CountryListDatabaseUnavailable_WarnsOncePerApply(t *test
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")
+ require.Zero(t, strings.Count(routesJSON(t, cfg), geoDeniedBody), "no host gets a country deny handler")
+ require.Equal(t, 1, strings.Count(buf.String(), geoNotEnforcedWarning), "one Warn per apply")
}
func TestGeoIP2AppFor_SplitsDirectoryAndEdition(t *testing.T) {
diff --git a/backend/internal/caddy/enforcement_report.go b/backend/internal/caddy/enforcement_report.go
index a2ffb220d..789f09807 100644
--- a/backend/internal/caddy/enforcement_report.go
+++ b/backend/internal/caddy/enforcement_report.go
@@ -1,5 +1,7 @@
package caddy
+import "slices"
+
// Reasons a security module that is switched on is not applied to every eligible host. The security status reports
// them so the admin can see the applied state.
const (
@@ -49,6 +51,22 @@ func (m *ModuleEnforcement) record(hasHandler bool, reason string) {
type EnforcementReport struct {
WAFOn bool
RateLimitOn bool
- WAF ModuleEnforcement
- RateLimit ModuleEnforcement
+ // ACLOn is the access control state the config was generated for.
+ ACLOn bool
+ WAF ModuleEnforcement
+ // RateLimit is the rate limit's enforcement across the eligible hosts.
+ RateLimit ModuleEnforcement
+ // CountryListsWithoutDatabase names the enabled country lists on enabled hosts that were built without country
+ // matching because no GeoIP database was usable. The names are sorted and unique.
+ CountryListsWithoutDatabase []string
+}
+
+// withSortedName adds name to the sorted, unique names and returns the result. A name already present is not added
+// again.
+func withSortedName(names []string, name string) []string {
+ i, found := slices.BinarySearch(names, name)
+ if found {
+ return names
+ }
+ return slices.Insert(names, i, name)
}
diff --git a/backend/internal/caddy/enforcement_report_acl_test.go b/backend/internal/caddy/enforcement_report_acl_test.go
new file mode 100644
index 000000000..bb1403084
--- /dev/null
+++ b/backend/internal/caddy/enforcement_report_acl_test.go
@@ -0,0 +1,96 @@
+package caddy
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+// countryReportHost builds an enabled host behind an access list of the given name, type and enabled state.
+func countryReportHost(uuid string, hostEnabled bool, acl *models.AccessList) models.ProxyHost {
+ return models.ProxyHost{
+ UUID: uuid,
+ DomainNames: uuid + ".example.com",
+ Enabled: hostEnabled,
+ ForwardHost: "app",
+ ForwardPort: 8080,
+ AccessListID: &acl.ID,
+ AccessList: acl,
+ }
+}
+
+// generateACLReport generates a config with the ACL flag and database state given and returns the report it stamped.
+func generateACLReport(t *testing.T, hosts []models.ProxyHost, aclOn, databaseAvailable bool) EnforcementReport {
+ t.Helper()
+ var reports []EnforcementReport
+ _, err := GenerateConfig(hosts, t.TempDir(), "", "", "", false, false, false, false, aclOn, "", nil, nil, nil, nil, nil,
+ WithGeoIPDatabase(testGeoIPDBPath, databaseAvailable),
+ WithEnforcementSink(func(r EnforcementReport) { reports = append(reports, r) }))
+ require.NoError(t, err)
+ require.Len(t, reports, 1, "the sink is called exactly once per generation")
+ return reports[0]
+}
+
+func TestEnforcementReport_ACLOnFollowsFlag(t *testing.T) {
+ assert.True(t, generateACLReport(t, nil, true, false).ACLOn)
+ assert.False(t, generateACLReport(t, nil, false, false).ACLOn)
+}
+
+func TestEnforcementReport_CountryListWithoutDatabaseIsNamed(t *testing.T) {
+ acl := &models.AccessList{ID: 1, Name: "Countries", Enabled: true, Type: "geo_whitelist", CountryCodes: "US"}
+ hosts := []models.ProxyHost{countryReportHost("geo-a", true, acl)}
+
+ report := generateACLReport(t, hosts, true, false)
+
+ assert.Equal(t, []string{"Countries"}, report.CountryListsWithoutDatabase)
+}
+
+func TestEnforcementReport_CountryListNamesAreSortedAndUnique(t *testing.T) {
+ countries := &models.AccessList{ID: 1, Name: "Countries", Enabled: true, Type: "geo_blacklist", CountryCodes: "RU"}
+ alpha := &models.AccessList{ID: 2, Name: "Alpha", Enabled: true, Type: "geo_whitelist", CountryCodes: "DE"}
+ hosts := []models.ProxyHost{
+ countryReportHost("one", true, countries),
+ countryReportHost("two", true, countries),
+ countryReportHost("three", true, alpha),
+ }
+
+ report := generateACLReport(t, hosts, true, false)
+
+ assert.Equal(t, []string{"Alpha", "Countries"}, report.CountryListsWithoutDatabase)
+}
+
+func TestEnforcementReport_CountryListNameSortsBeforeExisting(t *testing.T) {
+ zeta := &models.AccessList{ID: 1, Name: "Zeta", Enabled: true, Type: "geo_whitelist", CountryCodes: "DE"}
+ beta := &models.AccessList{ID: 2, Name: "Beta", Enabled: true, Type: "geo_whitelist", CountryCodes: "FR"}
+ hosts := []models.ProxyHost{countryReportHost("z", true, zeta), countryReportHost("b", true, beta)}
+
+ report := generateACLReport(t, hosts, true, false)
+
+ assert.Equal(t, []string{"Beta", "Zeta"}, report.CountryListsWithoutDatabase)
+}
+
+func TestEnforcementReport_NoCountryListNamesWhenNotUnenforced(t *testing.T) {
+ countries := &models.AccessList{ID: 1, Name: "Countries", Enabled: true, Type: "geo_whitelist", CountryCodes: "US"}
+ ipList := &models.AccessList{ID: 2, Name: "IPs", Enabled: true, Type: "whitelist", IPRules: "[]"}
+ disabledList := &models.AccessList{ID: 3, Name: "Off", Enabled: false, Type: "geo_whitelist", CountryCodes: "US"}
+ hosts := []models.ProxyHost{
+ countryReportHost("disabled-host", false, countries),
+ countryReportHost("ip-host", true, ipList),
+ countryReportHost("off-host", true, disabledList),
+ }
+
+ t.Run("database missing with only ineligible lists", func(t *testing.T) {
+ assert.Empty(t, generateACLReport(t, hosts, true, false).CountryListsWithoutDatabase)
+ })
+ t.Run("database available", func(t *testing.T) {
+ report := generateACLReport(t, []models.ProxyHost{countryReportHost("geo", true, countries)}, true, true)
+ assert.Empty(t, report.CountryListsWithoutDatabase)
+ })
+ t.Run("access control off", func(t *testing.T) {
+ report := generateACLReport(t, []models.ProxyHost{countryReportHost("geo", true, countries)}, false, false)
+ assert.Empty(t, report.CountryListsWithoutDatabase)
+ })
+}
diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go
index b87a9d9a8..67cd9febb 100644
--- a/backend/internal/caddy/manager.go
+++ b/backend/internal/caddy/manager.go
@@ -86,12 +86,26 @@ type Manager struct {
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]
+ // lastApplied is the report of the last successful apply. Unlike enforcement it is not cleared when an apply starts
+ // or fails, because on every failure Caddy keeps running that configuration.
+ lastApplied atomic.Pointer[EnforcementReport]
}
// EnforcementReport returns the enforcement report of the last successful apply. ok is false when no successful apply
// has completed since the last apply attempt started. It reads memory only, so status requests do not wait for an apply.
func (m *Manager) EnforcementReport() (report EnforcementReport, ok bool) {
- stored := m.enforcement.Load()
+ return loadReport(&m.enforcement)
+}
+
+// LastAppliedReport returns the report of the last successful apply. Unlike EnforcementReport it stays set while a
+// later apply runs or fails, so it describes the configuration Caddy is running. It reads memory only.
+func (m *Manager) LastAppliedReport() (report EnforcementReport, ok bool) {
+ return loadReport(&m.lastApplied)
+}
+
+// loadReport copies the report held by ptr. ok is false when none is held.
+func loadReport(ptr *atomic.Pointer[EnforcementReport]) (report EnforcementReport, ok bool) {
+ stored := ptr.Load()
if stored == nil {
return EnforcementReport{}, false
}
@@ -571,6 +585,7 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
// Record successful application
m.recordConfigChange(configHash, true, "")
m.enforcement.Store(generatedReport)
+ m.lastApplied.Store(generatedReport)
// Cleanup old snapshots (keep last 10)
if err := m.rotateSnapshots(10); err != nil {
diff --git a/backend/internal/caddy/manager_last_applied_test.go b/backend/internal/caddy/manager_last_applied_test.go
new file mode 100644
index 000000000..06b0dfd33
--- /dev/null
+++ b/backend/internal/caddy/manager_last_applied_test.go
@@ -0,0 +1,93 @@
+package caddy
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/config"
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+// appliedOnce returns a manager whose first apply succeeded, and the report that apply recorded.
+func appliedOnce(t *testing.T, client *switchableCaddyClient) (*Manager, EnforcementReport) {
+ t.Helper()
+ manager := NewManager(client, openEnforcementApplyDB(t), t.TempDir(), "", false, config.SecurityConfig{CerberusEnabled: true})
+ require.NoError(t, manager.ApplyConfig(context.Background()))
+ report, ok := manager.LastAppliedReport()
+ require.True(t, ok)
+ return manager, report
+}
+
+// TestManager_LastAppliedReport_SetOnSuccess pins that a successful apply records its report as the last applied one.
+func TestManager_LastAppliedReport_SetOnSuccess(t *testing.T) {
+ manager := NewManager(&switchableCaddyClient{}, openEnforcementApplyDB(t), t.TempDir(), "", false, config.SecurityConfig{CerberusEnabled: true})
+
+ _, ok := manager.LastAppliedReport()
+ require.False(t, ok, "no last applied report before the first apply")
+
+ require.NoError(t, manager.ApplyConfig(context.Background()))
+ report, ok := manager.LastAppliedReport()
+ require.True(t, ok)
+ require.True(t, report.WAFOn)
+ require.Equal(t, ModuleEnforcement{Eligible: 1, Protected: 1}, report.WAF)
+}
+
+// TestManager_LastAppliedReport_KeptWhenLoadFails pins that a failed Caddy load keeps the report of the last successful
+// apply, because Caddy keeps running that configuration.
+func TestManager_LastAppliedReport_KeptWhenLoadFails(t *testing.T) {
+ client := &switchableCaddyClient{}
+ manager, before := appliedOnce(t, client)
+
+ client.loadErr = errors.New("caddy refused the config")
+ require.Error(t, manager.ApplyConfig(context.Background()))
+
+ after, ok := manager.LastAppliedReport()
+ require.True(t, ok, "the last applied report survives a failed load")
+ require.Equal(t, before, after)
+}
+
+// TestManager_LastAppliedReport_KeptWhenValidationFails pins that a configuration rejected before it is loaded keeps
+// the report of the last successful apply.
+func TestManager_LastAppliedReport_KeptWhenValidationFails(t *testing.T) {
+ manager, before := appliedOnce(t, &switchableCaddyClient{})
+
+ origValidate := validateConfigFunc
+ t.Cleanup(func() { validateConfigFunc = origValidate })
+ validateConfigFunc = func(*Config) error { return errors.New("invalid") }
+
+ require.Error(t, manager.ApplyConfig(context.Background()))
+
+ after, ok := manager.LastAppliedReport()
+ require.True(t, ok)
+ require.Equal(t, before, after)
+}
+
+// TestManager_LastAppliedReport_KeptWhenHostsUnreadable pins that a failure before any generation keeps the report.
+func TestManager_LastAppliedReport_KeptWhenHostsUnreadable(t *testing.T) {
+ manager, before := appliedOnce(t, &switchableCaddyClient{})
+ db := manager.db
+ require.NoError(t, db.Migrator().DropTable(&models.ProxyHost{}))
+
+ require.Error(t, manager.ApplyConfig(context.Background()))
+
+ after, ok := manager.LastAppliedReport()
+ require.True(t, ok)
+ require.Equal(t, before, after)
+}
+
+// TestManager_LastAppliedReport_UnchangedWhenApplyStarts pins that starting an apply does not clear the last applied
+// report, which keeps describing the running config until a new apply succeeds.
+func TestManager_LastAppliedReport_UnchangedWhenApplyStarts(t *testing.T) {
+ client := &switchableCaddyClient{}
+ manager, before := appliedOnce(t, client)
+
+ client.loadErr = errors.New("still failing")
+ require.Error(t, manager.ApplyConfig(context.Background()))
+
+ after, ok := manager.LastAppliedReport()
+ require.True(t, ok)
+ require.Equal(t, before, after)
+}
diff --git a/backend/internal/geoip/database.go b/backend/internal/geoip/database.go
index d9e4c62c3..61c14ee19 100644
--- a/backend/internal/geoip/database.go
+++ b/backend/internal/geoip/database.go
@@ -3,14 +3,11 @@
package geoip
import (
- "fmt"
"os"
"path/filepath"
"strings"
"sync"
- "github.com/oschwald/geoip2-golang/v2"
-
"github.com/Wikid82/charon/backend/internal/logger"
)
@@ -70,11 +67,61 @@ var (
// in favour of the readable one, and the data-folder copy otherwise. Without either, the bundled path is returned, and
// it reports as missing until a database is provided there.
func ResolveDatabasePath() string {
- r := resolveDatabasePath(os.Getenv(EnvDBPath), isRegularFile, readBuildEpoch)
+ r := resolveDatabasePath(os.Getenv(EnvDBPath), isRegularFile, Inspect)
reportResolution(r)
return r.path
}
+// Source names where the database in use came from. SourceNone covers a path that is not one of the known locations.
+type Source string
+
+const (
+ // SourceBundled is the copy the image ships.
+ SourceBundled Source = "bundled"
+ // SourceData is the copy in the data folder, placed by an operator or an upload.
+ SourceData Source = "data"
+ // SourceCustom is the database that CHARON_GEOIP_DB_PATH names.
+ SourceCustom Source = "custom"
+ // SourceNone means no database is in use, or the path is not a known location.
+ SourceNone Source = "none"
+)
+
+// SourceOf names the location of the database at path. When CHARON_GEOIP_DB_PATH is in effect (envInEffect) every
+// database is custom. Otherwise the data-folder and bundled paths are recognised, and any other path is none.
+func SourceOf(path string, envInEffect bool) Source {
+ switch {
+ case envInEffect:
+ return SourceCustom
+ case path == DataDatabasePath():
+ return SourceData
+ case path == BundledDatabasePath():
+ return SourceBundled
+ default:
+ return SourceNone
+ }
+}
+
+// EnvPathInEffect reports whether CHARON_GEOIP_DB_PATH selects the database: it is set, and it is not the former image
+// default without a file there.
+func EnvPathInEffect() bool {
+ return envPathInEffect(os.Getenv(EnvDBPath), isRegularFile)
+}
+
+// envPathInEffect implements EnvPathInEffect with the environment value and the file check injected.
+func envPathInEffect(envPath string, isFile func(string) bool) bool {
+ effective, _ := effectiveEnvPath(envPath, isFile)
+ return effective != ""
+}
+
+// effectiveEnvPath returns the path the environment value selects, or "" when it selects none. The second result is
+// true when the former image default was named, no file was there, and the value was treated as unset.
+func effectiveEnvPath(envPath string, isFile func(string) bool) (string, bool) {
+ if envPath == FormerImageDefaultDBPath && !isFile(envPath) {
+ return "", true
+ }
+ return envPath, false
+}
+
// reportResolution logs the resolution notes that need the operator's attention. Each note is logged at most once per
// process, so repeated calls to ResolveDatabasePath do not flood the log.
func reportResolution(r resolution) {
@@ -92,12 +139,9 @@ func reportResolution(r resolution) {
// resolveDatabasePath implements ResolveDatabasePath with the environment value, the file check and the build-time
// reader injected.
-func resolveDatabasePath(envPath string, isFile func(string) bool, buildEpoch func(string) (uint, error)) resolution {
+func resolveDatabasePath(envPath string, isFile func(string) bool, inspect func(string) (DatabaseInfo, error)) resolution {
var r resolution
- if envPath == FormerImageDefaultDBPath && !isFile(envPath) {
- envPath = ""
- r.ignoredFormerDefault = true
- }
+ envPath, r.ignoredFormerDefault = effectiveEnvPath(envPath, isFile)
if envPath != "" {
r.path = envPath
return r
@@ -114,8 +158,8 @@ func resolveDatabasePath(envPath string, isFile func(string) bool, buildEpoch fu
return r
}
- dataEpoch, dataErr := buildEpoch(dataPath)
- bundledEpoch, bundledErr := buildEpoch(bundledPath)
+ dataInfo, dataErr := inspect(dataPath)
+ bundledInfo, bundledErr := inspect(bundledPath)
switch {
case dataErr != nil && bundledErr == nil:
r.path = bundledPath
@@ -125,7 +169,7 @@ func resolveDatabasePath(envPath string, isFile func(string) bool, buildEpoch fu
case dataErr != nil && bundledErr != nil:
// Neither copy can be read; the data-folder copy keeps its place so the failure is reported against it.
r.path = dataPath
- case bundledEpoch > dataEpoch:
+ case bundledInfo.BuildEpoch > dataInfo.BuildEpoch:
r.path = bundledPath
default:
r.path = dataPath
@@ -133,17 +177,6 @@ func resolveDatabasePath(envPath string, isFile func(string) bool, buildEpoch fu
return r
}
-// readBuildEpoch returns the MaxMind build time, in Unix seconds, of the database at path. Opening the file reads its
-// metadata; the database is closed again straight away.
-func readBuildEpoch(path string) (uint, error) {
- db, err := geoip2.Open(filepath.Clean(path)) //nolint:gosec // G703: database path, read-only open
- if err != nil {
- return 0, fmt.Errorf("open geoip database %s: %w", path, err)
- }
- defer func() { _ = db.Close() }()
- return db.Metadata().BuildEpoch, nil
-}
-
// isRegularFile reports whether path names an existing regular file. The path is cleaned first; it is either a fixed
// database location or an operator-configured one, and only a read-only stat is made.
func isRegularFile(path string) bool {
diff --git a/backend/internal/geoip/database_test.go b/backend/internal/geoip/database_test.go
index 2c4ca2126..aa3d9a9e0 100644
--- a/backend/internal/geoip/database_test.go
+++ b/backend/internal/geoip/database_test.go
@@ -59,3 +59,51 @@ func TestReportResolutionNotes(t *testing.T) {
})
}
}
+
+func TestEnvPathInEffect(t *testing.T) {
+ cases := []struct {
+ name string
+ env string
+ present []string
+ want bool
+ }{
+ {name: "unset", env: "", want: false},
+ {name: "custom path", env: testCustom, want: true},
+ {name: "former default with no file", env: FormerImageDefaultDBPath, want: false},
+ {name: "former default with a file", env: FormerImageDefaultDBPath, present: []string{FormerImageDefaultDBPath}, want: true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, envPathInEffect(tc.env, fakeFiles(tc.present...)))
+ })
+ }
+}
+
+func TestSourceOf(t *testing.T) {
+ cases := []struct {
+ name string
+ path string
+ env bool
+ want Source
+ }{
+ {name: "env in effect is custom", path: testCustom, env: true, want: SourceCustom},
+ {name: "env in effect wins over the data path", path: DataDatabasePath(), env: true, want: SourceCustom},
+ {name: "data-folder copy", path: DataDatabasePath(), want: SourceData},
+ {name: "bundled copy", path: BundledDatabasePath(), want: SourceBundled},
+ {name: "empty path", path: "", want: SourceNone},
+ {name: "unknown location without env", path: "/srv/geo/other.mmdb", want: SourceNone},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, SourceOf(tc.path, tc.env))
+ })
+ }
+}
+
+func TestEnvPathInEffect_ReadsTheEnvironment(t *testing.T) {
+ t.Setenv(EnvDBPath, "")
+ assert.False(t, EnvPathInEffect())
+
+ t.Setenv(EnvDBPath, testCustom)
+ assert.True(t, EnvPathInEffect())
+}
diff --git a/backend/internal/geoip/inspect.go b/backend/internal/geoip/inspect.go
new file mode 100644
index 000000000..cfb893344
--- /dev/null
+++ b/backend/internal/geoip/inspect.go
@@ -0,0 +1,52 @@
+package geoip
+
+import (
+ "fmt"
+ "path/filepath"
+ "time"
+
+ "github.com/oschwald/maxminddb-golang/v2"
+)
+
+// DatabaseInfo is the metadata of a MaxMind database that Charon reports and compares.
+type DatabaseInfo struct {
+ // DatabaseType names the record structure, for example GeoLite2-Country.
+ DatabaseType string
+ // BuildEpoch is the MaxMind build time in Unix seconds.
+ BuildEpoch uint
+ // NodeCount is the number of nodes in the search tree.
+ NodeCount uint
+ // IPVersion is 4 for an IPv4-only database and 6 for IPv4 and IPv6.
+ IPVersion uint
+}
+
+// Inspect reads the metadata of the MaxMind database at path. Opening the file reads its metadata section; the reader
+// is closed again straight away, so no lookup state is kept.
+func Inspect(path string) (DatabaseInfo, error) {
+ reader, err := maxminddb.Open(filepath.Clean(path)) //nolint:gosec // G703: database path, read-only open
+ if err != nil {
+ return DatabaseInfo{}, fmt.Errorf("open geoip database %s: %w", path, err)
+ }
+ defer func() { _ = reader.Close() }()
+
+ meta := reader.Metadata
+ return DatabaseInfo{
+ DatabaseType: meta.DatabaseType,
+ BuildEpoch: meta.BuildEpoch,
+ NodeCount: meta.NodeCount,
+ IPVersion: meta.IPVersion,
+ }, nil
+}
+
+// maxBuildEpoch is the last second of the year 9999 UTC, the latest build time Charon represents. Later epochs would
+// overflow the conversion to time.Time and wrap into the past.
+const maxBuildEpoch = 253402300799
+
+// BuildTime returns a MaxMind build epoch as a time. The epoch comes from the file, so an epoch past maxBuildEpoch is
+// clamped to it: it then compares as far in the future instead of wrapping into the past.
+func BuildTime(epoch uint) time.Time {
+ if epoch > maxBuildEpoch {
+ epoch = maxBuildEpoch
+ }
+ return time.Unix(int64(epoch), 0) //nolint:gosec // G115: bounded by maxBuildEpoch above
+}
diff --git a/backend/internal/geoip/inspect_test.go b/backend/internal/geoip/inspect_test.go
new file mode 100644
index 000000000..b9aab7fa2
--- /dev/null
+++ b/backend/internal/geoip/inspect_test.go
@@ -0,0 +1,78 @@
+package geoip
+
+import (
+ "math"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/testutil"
+)
+
+func TestInspectReadsMetadata(t *testing.T) {
+ cases := []struct {
+ name string
+ epoch uint32
+ typ string
+ }{
+ {name: "country database", epoch: 1_700_000_000, typ: "GeoLite2-Country"},
+ {name: "zero epoch", epoch: 0, typ: "GeoIP2-Country"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), DatabaseFileName)
+ testutil.WriteGeoIPCountryDB(t, path, tc.epoch, testutil.WithGeoIPDatabaseType(tc.typ))
+
+ info, err := Inspect(path)
+ require.NoError(t, err)
+ assert.Equal(t, tc.typ, info.DatabaseType)
+ assert.Equal(t, uint(tc.epoch), info.BuildEpoch)
+ assert.Equal(t, uint(1), info.NodeCount)
+ assert.Equal(t, uint(6), info.IPVersion)
+ })
+ }
+}
+
+func TestInspectRejectsUnreadableFiles(t *testing.T) {
+ dir := t.TempDir()
+
+ valid := filepath.Join(dir, "valid.mmdb")
+ testutil.WriteGeoIPCountryDB(t, valid, 1, testutil.WithGeoIPDatabaseType("GeoIP2-Country"))
+ validBytes, err := os.ReadFile(valid) //nolint:gosec // G304: t.TempDir-derived path
+ require.NoError(t, err)
+
+ truncated := filepath.Join(dir, "truncated.mmdb")
+ require.NoError(t, os.WriteFile(truncated, validBytes[:len(validBytes)/2], 0o600)) //nolint:gosec // G703: t.TempDir-derived path
+
+ random := filepath.Join(dir, "random.mmdb")
+ require.NoError(t, os.WriteFile(random, []byte("not a maxmind database at all, just text"), 0o600))
+
+ cases := []struct {
+ name string
+ path string
+ }{
+ {name: "missing file", path: filepath.Join(dir, "absent.mmdb")},
+ {name: "truncated file", path: truncated},
+ {name: "random bytes", path: random},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := Inspect(tc.path)
+ assert.Error(t, err)
+ })
+ }
+}
+
+func TestBuildTime_ClampsEpochsPastTheTimeRange(t *testing.T) {
+ assert.Equal(t, time.Unix(1_750_000_000, 0), BuildTime(1_750_000_000))
+ last := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
+ assert.True(t, last.Equal(BuildTime(maxBuildEpoch)))
+ assert.True(t, last.Equal(BuildTime(uint(math.MaxInt64)+1)))
+ huge := BuildTime(math.MaxUint)
+ assert.True(t, last.Equal(huge), "an epoch past the int64 range must not wrap into the past")
+ assert.True(t, huge.After(time.Now()))
+}
diff --git a/backend/internal/geoip/resolve_test.go b/backend/internal/geoip/resolve_test.go
index adc13d78d..fb0c4f221 100644
--- a/backend/internal/geoip/resolve_test.go
+++ b/backend/internal/geoip/resolve_test.go
@@ -2,14 +2,10 @@ package geoip
import (
"errors"
- "os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-
- "github.com/Wikid82/charon/backend/internal/testutil"
)
var (
@@ -28,12 +24,12 @@ func fakeFiles(paths ...string) func(string) bool {
}
// fakeEpochs returns a buildEpoch function answering from epochs; a path absent from the map is unreadable.
-func fakeEpochs(epochs map[string]uint) func(string) (uint, error) {
- return func(p string) (uint, error) {
+func fakeEpochs(epochs map[string]uint) func(string) (DatabaseInfo, error) {
+ return func(p string) (DatabaseInfo, error) {
if e, ok := epochs[p]; ok {
- return e, nil
+ return DatabaseInfo{BuildEpoch: e}, nil
}
- return 0, errors.New("unreadable")
+ return DatabaseInfo{}, errors.New("unreadable")
}
}
@@ -147,21 +143,3 @@ func TestGeoIPResolveReadsEnvironment(t *testing.T) {
t.Setenv(EnvDBPath, "")
assert.Equal(t, filepath.Base(ResolveDatabasePath()), DatabaseFileName)
}
-
-func TestGeoIPReadBuildEpoch(t *testing.T) {
- dir := t.TempDir()
-
- valid := filepath.Join(dir, "valid.mmdb")
- testutil.WriteGeoIPCountryDB(t, valid, 1700000000)
- epoch, err := readBuildEpoch(valid)
- require.NoError(t, err)
- assert.Equal(t, uint(1700000000), epoch)
-
- notDatabase := filepath.Join(dir, "not-a-database.mmdb")
- require.NoError(t, os.WriteFile(notDatabase, []byte("not a database"), 0o600))
- _, err = readBuildEpoch(notDatabase)
- require.Error(t, err)
-
- _, err = readBuildEpoch(filepath.Join(dir, "absent.mmdb"))
- require.Error(t, err)
-}
diff --git a/backend/internal/geoip/validate.go b/backend/internal/geoip/validate.go
new file mode 100644
index 000000000..50aa2f7c9
--- /dev/null
+++ b/backend/internal/geoip/validate.go
@@ -0,0 +1,102 @@
+package geoip
+
+import (
+ "errors"
+ "fmt"
+ "iter"
+ "path/filepath"
+ "strings"
+
+ "github.com/oschwald/maxminddb-golang/v2"
+)
+
+// Sentinel errors of ValidateCountryDatabase. The wrapped detail is for the server log only; API responses carry a
+// fixed error_code per sentinel.
+var (
+ // ErrNotMMDB means the file cannot be opened as a MaxMind DB, or its binary format is not major version 2.
+ ErrNotMMDB = errors.New("not a MaxMind database")
+ // ErrCorruptDatabase means the file opens but fails the structural verification.
+ ErrCorruptDatabase = errors.New("corrupt MaxMind database")
+ // ErrWrongDatabaseType means the file is not a country database, or its records carry no country ISO code.
+ ErrWrongDatabaseType = errors.New("not a country database")
+ // ErrNoBuildDate means the metadata carries no build time.
+ ErrNoBuildDate = errors.New("database has no build date")
+)
+
+// maxRecordsChecked bounds how many records the country check decodes.
+const maxRecordsChecked = 64
+
+// ValidateCountryDatabase checks that the file at path is a usable country database and returns its metadata. It
+// opens the file, verifies its structure, requires binary format major version 2, a database type naming Country
+// (case-insensitive), a non-zero build epoch, and at least one record with a two-letter country.iso_code. The records
+// check catches a file whose metadata says Country but whose records do not carry the field the proxy's geoip2 handler
+// reads.
+func ValidateCountryDatabase(path string) (DatabaseInfo, error) {
+ reader, err := maxminddb.Open(filepath.Clean(path)) //nolint:gosec // G703: uploaded database in the data folder, read-only open
+ if err != nil {
+ return DatabaseInfo{}, fmt.Errorf("%w: %w", ErrNotMMDB, err)
+ }
+ defer func() { _ = reader.Close() }()
+
+ meta := reader.Metadata
+ if meta.BinaryFormatMajorVersion != 2 {
+ return DatabaseInfo{}, fmt.Errorf("%w: binary format major version %d", ErrNotMMDB, meta.BinaryFormatMajorVersion)
+ }
+ if err := reader.Verify(); err != nil {
+ return DatabaseInfo{}, fmt.Errorf("%w: %w", ErrCorruptDatabase, err)
+ }
+
+ switch {
+ case !strings.Contains(strings.ToLower(meta.DatabaseType), "country"):
+ return DatabaseInfo{}, fmt.Errorf("%w: database type %q", ErrWrongDatabaseType, meta.DatabaseType)
+ case meta.BuildEpoch == 0:
+ return DatabaseInfo{}, ErrNoBuildDate
+ case !hasCountryRecord(reader.Networks()):
+ return DatabaseInfo{}, fmt.Errorf("%w: no record carries a country iso_code", ErrWrongDatabaseType)
+ }
+
+ return DatabaseInfo{
+ DatabaseType: meta.DatabaseType,
+ BuildEpoch: meta.BuildEpoch,
+ NodeCount: meta.NodeCount,
+ IPVersion: meta.IPVersion,
+ }, nil
+}
+
+// countryOnly is the part of a country record the check reads.
+type countryOnly struct {
+ Country struct {
+ ISOCode string `maxminddb:"iso_code"`
+ } `maxminddb:"country"`
+}
+
+// hasCountryRecord reports whether one of the first maxRecordsChecked results decodes to a record with a two-letter
+// country ISO code. Results that do not decode are skipped.
+func hasCountryRecord(results iter.Seq[maxminddb.Result]) bool {
+ checked := 0
+ for result := range results {
+ if checked == maxRecordsChecked {
+ return false
+ }
+ checked++
+
+ var record countryOnly
+ if result.Decode(&record) == nil && isISOCountryCode(record.Country.ISOCode) {
+ return true
+ }
+ }
+ return false
+}
+
+// isISOCountryCode reports whether code is two ASCII letters, the shape of an ISO 3166-1 alpha-2 code.
+func isISOCountryCode(code string) bool {
+ if len(code) != 2 {
+ return false
+ }
+ for _, r := range code {
+ if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') {
+ return false
+ }
+ }
+ return true
+}
diff --git a/backend/internal/geoip/validate_test.go b/backend/internal/geoip/validate_test.go
new file mode 100644
index 000000000..948f630cb
--- /dev/null
+++ b/backend/internal/geoip/validate_test.go
@@ -0,0 +1,165 @@
+package geoip
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/oschwald/maxminddb-golang/v2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/testutil"
+)
+
+// writeFixture writes a database fixture into a fresh temp folder and returns its path.
+func writeFixture(t *testing.T, epoch uint32, opts ...testutil.GeoIPCountryOption) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), DatabaseFileName)
+ testutil.WriteGeoIPCountryDB(t, path, epoch, opts...)
+ return path
+}
+
+func TestValidateCountryDatabase_AcceptsCountryDatabase(t *testing.T) {
+ for _, typ := range []string{"GeoLite2-Country", "GeoIP2-Country", "DBIP-Country-Lite"} {
+ t.Run(typ, func(t *testing.T) {
+ path := writeFixture(t, 1_700_000_000,
+ testutil.WithGeoIPDatabaseType(typ), testutil.WithGeoIPRecordISOCode("US"))
+
+ info, err := ValidateCountryDatabase(path)
+
+ require.NoError(t, err)
+ assert.Equal(t, typ, info.DatabaseType)
+ assert.Equal(t, uint(1_700_000_000), info.BuildEpoch)
+ })
+ }
+}
+
+func TestValidateCountryDatabase_RejectsNonMMDB(t *testing.T) {
+ path := filepath.Join(t.TempDir(), DatabaseFileName)
+ require.NoError(t, os.WriteFile(path, []byte("this is plain text, not a database"), 0o600))
+
+ _, err := ValidateCountryDatabase(path)
+
+ assert.ErrorIs(t, err, ErrNotMMDB)
+}
+
+func TestValidateCountryDatabase_RejectsMissingFile(t *testing.T) {
+ _, err := ValidateCountryDatabase(filepath.Join(t.TempDir(), "absent.mmdb"))
+
+ assert.ErrorIs(t, err, ErrNotMMDB)
+}
+
+func TestValidateCountryDatabase_RejectsTruncatedFile(t *testing.T) {
+ path := writeFixture(t, 1_700_000_000, testutil.WithGeoIPRecordISOCode("US"))
+ data, err := os.ReadFile(path) //nolint:gosec // G304: t.TempDir-derived path
+ require.NoError(t, err)
+ // Cutting the file inside the metadata section removes the marker the reader searches for.
+ require.NoError(t, os.WriteFile(path, data[:len(data)-8], 0o600)) //nolint:gosec // G703: t.TempDir-derived path
+
+ _, err = ValidateCountryDatabase(path)
+
+ assert.ErrorIs(t, err, ErrNotMMDB)
+}
+
+func TestValidateCountryDatabase_RejectsUnsupportedFormatVersion(t *testing.T) {
+ path := writeFixture(t, 1_700_000_000, testutil.WithGeoIPRecordISOCode("US"))
+ data, err := os.ReadFile(path) //nolint:gosec // G304: t.TempDir-derived path
+ require.NoError(t, err)
+ // The metadata value after the key is the uint16 major version 2; change it to 3.
+ key := []byte("binary_format_major_version")
+ at := bytes.Index(data, key) + len(key)
+ require.Equal(t, byte(0xA1), data[at], "fixture layout changed")
+ data[at+1] = 3
+ require.NoError(t, os.WriteFile(path, data, 0o600)) //nolint:gosec // G703: t.TempDir-derived path
+
+ _, err = ValidateCountryDatabase(path)
+
+ assert.ErrorIs(t, err, ErrNotMMDB)
+}
+
+func TestValidateCountryDatabase_RejectsCorruptTree(t *testing.T) {
+ // The fixture without a record has a search tree whose only node points back at itself, which the structural
+ // verification rejects while the metadata still opens.
+ path := writeFixture(t, 1_700_000_000)
+
+ _, err := ValidateCountryDatabase(path)
+
+ assert.ErrorIs(t, err, ErrCorruptDatabase)
+}
+
+func TestValidateCountryDatabase_RejectsNonCountryType(t *testing.T) {
+ for _, typ := range []string{"GeoLite2-City", "GeoLite2-ASN"} {
+ t.Run(typ, func(t *testing.T) {
+ path := writeFixture(t, 1_700_000_000,
+ testutil.WithGeoIPDatabaseType(typ), testutil.WithGeoIPRecordISOCode("US"))
+
+ _, err := ValidateCountryDatabase(path)
+
+ assert.ErrorIs(t, err, ErrWrongDatabaseType)
+ })
+ }
+}
+
+func TestValidateCountryDatabase_RejectsZeroBuildEpoch(t *testing.T) {
+ path := writeFixture(t, 0, testutil.WithGeoIPRecordISOCode("US"))
+
+ _, err := ValidateCountryDatabase(path)
+
+ assert.ErrorIs(t, err, ErrNoBuildDate)
+}
+
+func TestValidateCountryDatabase_RejectsRecordsWithoutISOCode(t *testing.T) {
+ cases := []struct {
+ name string
+ record map[string]any
+ }{
+ {name: "no country field", record: map[string]any{"continent": map[string]any{"code": "EU"}}},
+ {name: "country without iso_code", record: map[string]any{"country": map[string]any{"names": "x"}}},
+ {name: "iso_code of three letters", record: map[string]any{"country": map[string]any{"iso_code": "USA"}}},
+ {name: "iso_code with a digit", record: map[string]any{"country": map[string]any{"iso_code": "U1"}}},
+ {name: "empty iso_code", record: map[string]any{"country": map[string]any{"iso_code": ""}}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ path := writeFixture(t, 1_700_000_000, testutil.WithGeoIPRecord(tc.record))
+
+ _, err := ValidateCountryDatabase(path)
+
+ assert.ErrorIs(t, err, ErrWrongDatabaseType)
+ })
+ }
+}
+
+func TestValidateCountryDatabase_RejectsRecordsOfUnreadableShape(t *testing.T) {
+ // A record whose country field is a string rather than a map decodes to nothing, so no country code is found.
+ path := writeFixture(t, 1_700_000_000, testutil.WithGeoIPRecord(map[string]any{"country": "US"}))
+
+ _, err := ValidateCountryDatabase(path)
+
+ assert.ErrorIs(t, err, ErrWrongDatabaseType)
+}
+
+func TestHasCountryRecord_StopsAfterBoundedRecords(t *testing.T) {
+ // Results that cannot be decoded are skipped. The scan stops after maxRecordsChecked results, so a tree of records
+ // without a country code costs bounded work and reports no country record.
+ yielded := 0
+ results := func(yield func(maxminddb.Result) bool) {
+ for range maxRecordsChecked + 10 {
+ yielded++
+ if !yield(maxminddb.Result{}) {
+ return
+ }
+ }
+ }
+
+ assert.False(t, hasCountryRecord(results))
+ assert.LessOrEqual(t, yielded, maxRecordsChecked+1, "the scan must stop at the bound")
+}
+
+func TestHasCountryRecord_EmptyTreeHasNoCountryRecord(t *testing.T) {
+ none := func(func(maxminddb.Result) bool) {}
+
+ assert.False(t, hasCountryRecord(none))
+}
diff --git a/backend/internal/services/access_list_draft_test.go b/backend/internal/services/access_list_draft_test.go
new file mode 100644
index 000000000..7e9b3b40a
--- /dev/null
+++ b/backend/internal/services/access_list_draft_test.go
@@ -0,0 +1,54 @@
+package services
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+func TestValidateDraft_AcceptsDraftWithoutName(t *testing.T) {
+ svc := NewAccessListService(nil)
+
+ err := svc.ValidateDraft(&models.AccessList{Type: "geo_whitelist", CountryCodes: "DE", Enabled: true})
+
+ require.NoError(t, err)
+}
+
+func TestValidateDraft_RefusesSameRulesAsCreate(t *testing.T) {
+ tests := []struct {
+ name string
+ acl models.AccessList
+ want error
+ }{
+ {name: "invalid type", acl: models.AccessList{Type: "bogus"}, want: ErrInvalidAccessListType},
+ {name: "malformed rules", acl: models.AccessList{Type: "whitelist", IPRules: "{"}},
+ {name: "bad cidr", acl: models.AccessList{Type: "whitelist", IPRules: `[{"cidr":"nope"}]`}, want: ErrInvalidIPAddress},
+ {name: "enabled allow-list without rules", acl: models.AccessList{Type: "whitelist", Enabled: true}, want: ErrAccessListRulesRequired},
+ {name: "geo list without codes", acl: models.AccessList{Type: "geo_blacklist"}},
+ {name: "geo list with bad code", acl: models.AccessList{Type: "geo_blacklist", CountryCodes: "ZZ"}, want: ErrInvalidCountryCode},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ svc := NewAccessListService(nil)
+
+ err := svc.ValidateDraft(&tt.acl)
+
+ require.Error(t, err)
+ if tt.want != nil {
+ assert.ErrorIs(t, err, tt.want)
+ }
+ })
+ }
+}
+
+func TestValidateAccessList_StillRequiresName(t *testing.T) {
+ svc := NewAccessListService(nil)
+
+ err := svc.validateAccessList(&models.AccessList{Type: "blacklist"})
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "name is required")
+}
diff --git a/backend/internal/services/access_list_evaluate.go b/backend/internal/services/access_list_evaluate.go
index fec78bd46..caf619126 100644
--- a/backend/internal/services/access_list_evaluate.go
+++ b/backend/internal/services/access_list_evaluate.go
@@ -49,7 +49,7 @@ type countryLookup interface {
// Evaluate returns the decision the proxy makes for a request from ip under the access list acl, given the admin
// whitelist entries. It mirrors the proxy configuration built for the list: the admin whitelist is checked first for
// every list type, country lists are decided before local-network-only (which they ignore), and an unusable GeoIP
-// database refuses country lists except for admin addresses.
+// database leaves country lists unenforced, so they allow the address with reason ReasonGeoIPUnavailable.
//
// An error means the stored list or admin whitelist holds an entry the proxy configuration cannot use.
func (s *AccessListService) Evaluate(acl *models.AccessList, ip netip.Addr, adminRanges []string) (Decision, error) {
@@ -110,8 +110,8 @@ func (s *AccessListService) countryAvailable() bool {
// when the lookup fails, so both reach here as no country: an allow-list refuses that, a block-list admits it.
func (s *AccessListService) evaluateCountry(acl *models.AccessList, ip netip.Addr, d Decision) Decision {
if !d.GeoIPAvailable {
- // Interim posture: with no usable database a country list refuses every address that is not an admin address.
- d.Reason = ReasonGeoIPUnavailable
+ // Fail open: with no usable database the proxy builds no country matcher for the list, so the address is allowed.
+ d.Allowed, d.Reason = true, ReasonGeoIPUnavailable
return d
}
diff --git a/backend/internal/services/access_list_evaluate_test.go b/backend/internal/services/access_list_evaluate_test.go
index 1b6e09f7b..b27cd7e52 100644
--- a/backend/internal/services/access_list_evaluate_test.go
+++ b/backend/internal/services/access_list_evaluate_test.go
@@ -96,18 +96,20 @@ func TestEvaluate(t *testing.T) {
reason: ReasonAdminWhitelisted,
},
{
- name: "country allow-list refuses everyone while the database is missing",
- acl: models.AccessList{Type: "geo_whitelist", CountryCodes: "DE", Enabled: true},
- lookup: dbMissing,
- addr: testVisitor,
- reason: ReasonGeoIPUnavailable,
+ name: "country allow-list is not enforced while the database is missing",
+ acl: models.AccessList{Type: "geo_whitelist", CountryCodes: "DE", Enabled: true},
+ lookup: dbMissing,
+ addr: testVisitor,
+ allowed: true,
+ reason: ReasonGeoIPUnavailable,
},
{
- name: "country block-list refuses everyone while the database is missing",
- acl: models.AccessList{Type: "geo_blacklist", CountryCodes: "RU", Enabled: true},
- lookup: dbMissing,
- addr: testVisitor,
- reason: ReasonGeoIPUnavailable,
+ name: "country block-list is not enforced while the database is missing",
+ acl: models.AccessList{Type: "geo_blacklist", CountryCodes: "RU", Enabled: true},
+ lookup: dbMissing,
+ addr: testVisitor,
+ allowed: true,
+ reason: ReasonGeoIPUnavailable,
},
{
name: "country allow-list admits a listed country",
diff --git a/backend/internal/services/access_list_service.go b/backend/internal/services/access_list_service.go
index 376f2c48e..180ea7645 100644
--- a/backend/internal/services/access_list_service.go
+++ b/backend/internal/services/access_list_service.go
@@ -302,11 +302,15 @@ func parseCountryCodes(codes string) []string {
// validateAccessList validates access list fields
func (s *AccessListService) validateAccessList(acl *models.AccessList) error {
- // Validate name
if strings.TrimSpace(acl.Name) == "" {
return errors.New("name is required")
}
+ return s.ValidateDraft(acl)
+}
+// ValidateDraft checks an access list's type, IP rules and country codes exactly as Create does, without the name.
+// An unsaved draft, such as one sent to the self-check, carries no name, so it is checked with this.
+func (s *AccessListService) ValidateDraft(acl *models.AccessList) error {
// Validate type
if !s.isValidType(acl.Type) {
return ErrInvalidAccessListType
diff --git a/backend/internal/services/backup_geoip_exclusion_test.go b/backend/internal/services/backup_geoip_exclusion_test.go
new file mode 100644
index 000000000..825ff8574
--- /dev/null
+++ b/backend/internal/services/backup_geoip_exclusion_test.go
@@ -0,0 +1,49 @@
+package services
+
+import (
+ "archive/zip"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/config"
+)
+
+// TestBackupService_ExcludesGeoIPFolder pins that a backup never carries the GeoIP data folder: the uploaded database,
+// its previous copy and any upload temp file stay on the host, and a restore brings back only what the backup holds.
+func TestBackupService_ExcludesGeoIPFolder(t *testing.T) {
+ dataDir := t.TempDir()
+ dbPath := filepath.Join(dataDir, "charon.db")
+ createSQLiteTestDB(t, dbPath)
+ caddyDir := filepath.Join(dataDir, "caddy")
+ require.NoError(t, os.MkdirAll(caddyDir, 0o700))
+ require.NoError(t, os.WriteFile(filepath.Join(caddyDir, "caddy.json"), []byte("{}"), 0o600))
+
+ geoipDir := filepath.Join(dataDir, "geoip")
+ require.NoError(t, os.MkdirAll(geoipDir, 0o750))
+ for _, name := range []string{"GeoLite2-Country.mmdb", "GeoLite2-Country.mmdb.prev", ".GeoLite2-Country.mmdb.upload-123"} {
+ require.NoError(t, os.WriteFile(filepath.Join(geoipDir, name), []byte("database bytes"), 0o600))
+ }
+
+ service := NewBackupService(&config.Config{DatabasePath: dbPath}, nil, nil)
+ t.Cleanup(service.discardRestoreSnapshot)
+ defer service.Stop()
+
+ filename, err := service.CreateBackup()
+ require.NoError(t, err)
+
+ r, err := zip.OpenReader(filepath.Join(service.BackupDir, filename))
+ require.NoError(t, err)
+ defer func() { _ = r.Close() }()
+
+ var names []string
+ for _, f := range r.File {
+ names = append(names, f.Name)
+ assert.False(t, strings.Contains(f.Name, "geoip"), "backup entry %q must not come from the GeoIP folder", f.Name)
+ }
+ assert.Contains(t, names, "caddy/caddy.json", "the archive does hold the rest of the data folder")
+}
diff --git a/backend/internal/services/geoip_database_store.go b/backend/internal/services/geoip_database_store.go
new file mode 100644
index 000000000..26bc11b80
--- /dev/null
+++ b/backend/internal/services/geoip_database_store.go
@@ -0,0 +1,261 @@
+package services
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "golang.org/x/sys/unix"
+
+ "github.com/Wikid82/charon/backend/internal/geoip"
+)
+
+// Sentinel errors of the GeoIP database store. Handlers map them to status codes and stable error codes; the wrapped
+// detail is for the server log only.
+var (
+ // ErrGeoIPBusy is returned when another change to the database is already running.
+ ErrGeoIPBusy = errors.New("geoip database change already in progress")
+ // ErrGeoIPReloadFailed is returned when the database file could not be loaded.
+ ErrGeoIPReloadFailed = errors.New("geoip database reload failed")
+ // ErrGeoIPApplyFailed is returned when the proxy config could not be applied after the database changed.
+ ErrGeoIPApplyFailed = errors.New("geoip proxy apply failed")
+)
+
+// Replace-blocked reasons reported by GeoIPStatus.ReplaceBlockedReason. An empty reason means replacing is possible.
+const (
+ ReplaceBlockedCustomPath = "custom_path_set"
+ ReplaceBlockedReadOnlyData = "data_folder_read_only"
+)
+
+// GeoIPStatus is the database card's view of the GeoIP database: where it comes from, what it is, and whether the
+// operator can replace it. Its JSON names are the API contract; nullable fields are null when unknown.
+type GeoIPStatus struct {
+ Source string `json:"source"`
+ DatabaseType string `json:"database_type"`
+ BuildDate *string `json:"build_date"`
+ SizeBytes *int64 `json:"size_bytes"`
+ LoadedAt *string `json:"loaded_at"`
+ IncludedBuildDate *string `json:"included_build_date"`
+ DataCopyPresent bool `json:"data_copy_present"`
+ CanReplace bool `json:"can_replace"`
+ ReplaceBlockedReason string `json:"replace_blocked_reason"`
+ UnenforcedListsTotal int `json:"unenforced_lists_total"`
+}
+
+// GeoIPDatabaseStoreConfig wires a GeoIPDatabaseStore. The paths are full file paths.
+type GeoIPDatabaseStoreConfig struct {
+ // Service is the lookup service whose database the store loads and reports.
+ Service *GeoIPService
+ // Apply applies the proxy config after the database changed. Nil when there is no proxy to apply to.
+ Apply func(context.Context) error
+ // DataPath is the data-folder copy that uploads will replace.
+ DataPath string
+ // BundledPath is the copy the image ships.
+ BundledPath string
+ // EnvInEffect reports whether CHARON_GEOIP_DB_PATH selects the database. Nil means geoip.EnvPathInEffect.
+ EnvInEffect func() bool
+}
+
+// GeoIPDatabaseStore owns the changes to the GeoIP database and the status the card shows. Every mutating method runs
+// under mu, acquired with TryLock: a request that finds another change running gets ErrGeoIPBusy at once instead of
+// queueing behind it.
+type GeoIPDatabaseStore struct {
+ svc *GeoIPService
+ apply func(context.Context) error
+ dataPath string
+ bundledPath string
+ envInEffect func() bool
+ // writable reports whether the folder accepts writes. It is writableFolder; tests replace it.
+ writable func(dir string) bool
+ // loadFile opens the data-folder copy after an upload is installed. It is svc.LoadFrom; tests replace it.
+ loadFile func(path string) error
+ // maxBytes is the largest upload accepted. It is MaxGeoIPDatabaseBytes; tests lower it.
+ maxBytes int64
+ // now supplies the clock for the build-date checks. Tests set it.
+ now func() time.Time
+ // files are the data-folder operations of an upload or removal. They are osGeoIPFileOps; tests replace single
+ // operations to reach the failure branches.
+ files geoipFileOps
+
+ mu sync.Mutex
+
+ // includedMu guards included, the cached build epoch of the bundled copy.
+ includedMu sync.Mutex
+ included includedCopy
+}
+
+// includedCopy caches the build epoch of the bundled copy, keyed on the file's path, size and modification time.
+type includedCopy struct {
+ path string
+ size int64
+ modTime time.Time
+ epoch uint
+ known bool
+ valid bool
+}
+
+// NewGeoIPDatabaseStore returns a store over the service and the configured copies.
+func NewGeoIPDatabaseStore(cfg GeoIPDatabaseStoreConfig) *GeoIPDatabaseStore {
+ envInEffect := cfg.EnvInEffect
+ if envInEffect == nil {
+ envInEffect = geoip.EnvPathInEffect
+ }
+ return &GeoIPDatabaseStore{
+ svc: cfg.Service,
+ apply: cfg.Apply,
+ dataPath: cfg.DataPath,
+ bundledPath: cfg.BundledPath,
+ envInEffect: envInEffect,
+ writable: writableFolder,
+ loadFile: cfg.Service.LoadFrom,
+ maxBytes: MaxGeoIPDatabaseBytes,
+ now: time.Now,
+ files: osGeoIPFileOps(),
+ }
+}
+
+// Status describes the database in use and whether it can be replaced. UnenforcedListsTotal is left zero here; the
+// caller that owns the access lists fills it in.
+func (s *GeoIPDatabaseStore) Status() GeoIPStatus {
+ info := s.svc.Info()
+ envInEffect := s.envInEffect()
+
+ st := GeoIPStatus{
+ Source: string(geoip.SourceNone),
+ IncludedBuildDate: s.includedBuildDate(),
+ DataCopyPresent: isRegularFile(s.dataPath),
+ }
+ if info.Loaded {
+ st.Source = string(geoip.SourceOf(info.Path, envInEffect))
+ st.DatabaseType = info.DatabaseType
+ st.BuildDate = formatEpochUTC(info.BuildEpoch)
+ st.LoadedAt = formatTimeUTC(info.LoadedAt)
+ st.SizeBytes = fileSize(info.Path)
+ }
+ st.CanReplace, st.ReplaceBlockedReason = s.replaceability(envInEffect)
+ return st
+}
+
+// replaceability reports whether an upload or a removal can change the data folder, and the reason when it cannot.
+// A custom path wins over folder permissions because the environment variable decides the database regardless.
+func (s *GeoIPDatabaseStore) replaceability(envInEffect bool) (canReplace bool, blockedReason string) {
+ switch {
+ case envInEffect:
+ return false, ReplaceBlockedCustomPath
+ case !s.writable(filepath.Dir(s.dataPath)):
+ return false, ReplaceBlockedReadOnlyData
+ default:
+ return true, ""
+ }
+}
+
+// Reload loads the database file again from its location and applies the proxy config. The load runs even when the
+// apply will follow, and the apply runs even when the load failed, so the proxy converges on the database state it can
+// see. The returned error wraps ErrGeoIPReloadFailed, ErrGeoIPApplyFailed, or both; ErrGeoIPBusy means nothing ran.
+func (s *GeoIPDatabaseStore) Reload(ctx context.Context) error {
+ if !s.mu.TryLock() {
+ return ErrGeoIPBusy
+ }
+ defer s.mu.Unlock()
+
+ loadErr := s.svc.Load()
+ var applyErr error
+ if s.apply != nil {
+ applyErr = s.apply(ctx)
+ }
+ return errors.Join(
+ wrapFailure(ErrGeoIPReloadFailed, loadErr),
+ wrapFailure(ErrGeoIPApplyFailed, applyErr),
+ )
+}
+
+// wrapFailure wraps err with sentinel, or returns nil when err is nil.
+func wrapFailure(sentinel, err error) error {
+ if err == nil {
+ return nil
+ }
+ return fmt.Errorf("%w: %w", sentinel, err)
+}
+
+// includedBuildDate returns the bundled copy's build date, or nil when that copy is absent or unreadable.
+func (s *GeoIPDatabaseStore) includedBuildDate() *string {
+ epoch, known := s.includedBuildEpoch()
+ if !known {
+ return nil
+ }
+ return formatEpochUTC(epoch)
+}
+
+// includedBuildEpoch reads the bundled copy's build epoch. The read is cached on (path, size, modification time), so
+// polling the status does not reopen the file each time.
+func (s *GeoIPDatabaseStore) includedBuildEpoch() (uint, bool) {
+ info, err := os.Stat(filepath.Clean(s.bundledPath))
+ if err != nil || !info.Mode().IsRegular() {
+ return 0, false
+ }
+
+ s.includedMu.Lock()
+ defer s.includedMu.Unlock()
+ if s.included.valid && s.included.path == s.bundledPath &&
+ s.included.size == info.Size() && s.included.modTime.Equal(info.ModTime()) {
+ return s.included.epoch, s.included.known
+ }
+
+ db, err := geoip.Inspect(s.bundledPath)
+ s.included = includedCopy{
+ path: s.bundledPath,
+ size: info.Size(),
+ modTime: info.ModTime(),
+ epoch: db.BuildEpoch,
+ known: err == nil,
+ valid: true,
+ }
+ return s.included.epoch, s.included.known
+}
+
+// writableFolder reports whether writes to the GeoIP folder dir are possible. The folder is created on the first write,
+// so when it does not exist yet its parent is probed instead. A probe that fails for any reason counts as not writable.
+func writableFolder(dir string) bool {
+ probe := filepath.Clean(dir)
+ if _, err := os.Stat(probe); err != nil {
+ probe = filepath.Dir(probe)
+ }
+ return unix.Access(probe, unix.W_OK) == nil
+}
+
+// isRegularFile reports whether path names an existing regular file.
+func isRegularFile(path string) bool {
+ info, err := os.Stat(filepath.Clean(path))
+ return err == nil && info.Mode().IsRegular()
+}
+
+// fileSize returns the size of the file at path, or nil when it cannot be read.
+func fileSize(path string) *int64 {
+ info, err := os.Stat(filepath.Clean(path))
+ if err != nil || !info.Mode().IsRegular() {
+ return nil
+ }
+ size := info.Size()
+ return &size
+}
+
+// formatEpochUTC renders a MaxMind build epoch as an RFC 3339 UTC time, or nil for an unknown (zero) epoch.
+func formatEpochUTC(epoch uint) *string {
+ if epoch == 0 {
+ return nil
+ }
+ return formatTimeUTC(geoip.BuildTime(epoch))
+}
+
+// formatTimeUTC renders t as an RFC 3339 UTC time, or nil for the zero time.
+func formatTimeUTC(t time.Time) *string {
+ if t.IsZero() {
+ return nil
+ }
+ formatted := t.UTC().Format(time.RFC3339)
+ return &formatted
+}
diff --git a/backend/internal/services/geoip_database_store_test.go b/backend/internal/services/geoip_database_store_test.go
new file mode 100644
index 000000000..2406fa7de
--- /dev/null
+++ b/backend/internal/services/geoip_database_store_test.go
@@ -0,0 +1,281 @@
+package services
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/geoip"
+ "github.com/Wikid82/charon/backend/internal/testutil"
+)
+
+// storeFixture is a store over a temporary data folder and bundled copy. The service starts unloaded at the data copy.
+type storeFixture struct {
+ store *GeoIPDatabaseStore
+ svc *GeoIPService
+ dataDir string
+ dataPath string
+ bundledPath string
+ apply func(context.Context) error
+ applyCalls int
+}
+
+func newStoreFixture(t *testing.T, env bool) *storeFixture {
+ t.Helper()
+ root := t.TempDir()
+ dataDir := filepath.Join(root, "data", "geoip")
+ require.NoError(t, os.MkdirAll(dataDir, 0o750))
+ f := &storeFixture{
+ dataDir: dataDir,
+ dataPath: filepath.Join(dataDir, geoip.DatabaseFileName),
+ bundledPath: filepath.Join(root, "bundled", geoip.DatabaseFileName),
+ }
+ f.svc = NewUnloadedGeoIPService(f.dataPath)
+ f.svc.now = fixedClock(time.Date(2026, 11, 10, 5, 0, 1, 0, time.UTC))
+ f.apply = func(context.Context) error { f.applyCalls++; return nil }
+ f.store = NewGeoIPDatabaseStore(GeoIPDatabaseStoreConfig{
+ Service: f.svc,
+ Apply: func(ctx context.Context) error { return f.apply(ctx) },
+ DataPath: f.dataPath,
+ BundledPath: f.bundledPath,
+ EnvInEffect: func() bool { return env },
+ })
+ return f
+}
+
+func writeCountryDB(t *testing.T, path string, epoch uint32) {
+ t.Helper()
+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750))
+ testutil.WriteGeoIPCountryDB(t, path, epoch, testutil.WithGeoIPDatabaseType("GeoLite2-Country"))
+}
+
+func TestGeoIPDatabaseStoreStatus_NoDatabaseInUse(t *testing.T) {
+ f := newStoreFixture(t, false)
+
+ st := f.store.Status()
+
+ assert.Equal(t, string(geoip.SourceNone), st.Source)
+ assert.Empty(t, st.DatabaseType)
+ assert.Nil(t, st.BuildDate)
+ assert.Nil(t, st.SizeBytes)
+ assert.Nil(t, st.LoadedAt)
+ assert.Nil(t, st.IncludedBuildDate)
+ assert.False(t, st.DataCopyPresent)
+ assert.True(t, st.CanReplace, "a writable data folder allows a replacement")
+ assert.Empty(t, st.ReplaceBlockedReason)
+ assert.Zero(t, st.UnenforcedListsTotal)
+}
+
+func TestGeoIPDatabaseStoreStatus_ReportsLoadedDatabase(t *testing.T) {
+ f := newStoreFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_700_000_000)
+ require.NoError(t, f.store.Reload(context.Background()))
+ info, err := os.Stat(f.dataPath)
+ require.NoError(t, err)
+
+ st := f.store.Status()
+
+ assert.Equal(t, "GeoLite2-Country", st.DatabaseType)
+ require.NotNil(t, st.BuildDate)
+ assert.Equal(t, "2023-11-14T22:13:20Z", *st.BuildDate)
+ require.NotNil(t, st.SizeBytes)
+ assert.Equal(t, info.Size(), *st.SizeBytes)
+ require.NotNil(t, st.LoadedAt)
+ assert.Equal(t, "2026-11-10T05:00:01Z", *st.LoadedAt)
+ assert.True(t, st.DataCopyPresent)
+ assert.Equal(t, string(geoip.SourceNone), st.Source, "a loaded file outside the known locations has no named source")
+}
+
+func TestGeoIPDatabaseStoreStatus_CustomPathBlocksReplacement(t *testing.T) {
+ f := newStoreFixture(t, true)
+ writeCountryDB(t, f.dataPath, 1_700_000_000)
+ require.NoError(t, f.store.Reload(context.Background()))
+
+ st := f.store.Status()
+
+ assert.Equal(t, string(geoip.SourceCustom), st.Source)
+ assert.False(t, st.CanReplace)
+ assert.Equal(t, "custom_path_set", st.ReplaceBlockedReason)
+}
+
+func TestGeoIPDatabaseStoreStatus_EnvPathFromEnvironment(t *testing.T) {
+ f := newStoreFixture(t, false)
+ custom := filepath.Join(t.TempDir(), "custom.mmdb")
+ writeCountryDB(t, custom, 1)
+ t.Setenv(geoip.EnvDBPath, custom)
+
+ store := NewGeoIPDatabaseStore(GeoIPDatabaseStoreConfig{
+ Service: f.svc, DataPath: f.dataPath, BundledPath: f.bundledPath,
+ })
+
+ assert.False(t, store.Status().CanReplace)
+ assert.Equal(t, "custom_path_set", store.Status().ReplaceBlockedReason)
+}
+
+func TestGeoIPDatabaseStoreStatus_ReadOnlyDataFolderBlocksReplacement(t *testing.T) {
+ f := newStoreFixture(t, false)
+ f.store.writable = func(string) bool { return false }
+
+ st := f.store.Status()
+
+ assert.False(t, st.CanReplace)
+ assert.Equal(t, "data_folder_read_only", st.ReplaceBlockedReason)
+}
+
+func TestWritableFolderProbesExistingFolderOrParent(t *testing.T) {
+ existing := t.TempDir()
+ assert.True(t, writableFolder(existing))
+ assert.True(t, writableFolder(filepath.Join(existing, "not-yet-created")), "the parent of a missing folder is probed")
+ assert.False(t, writableFolder(filepath.Join(existing, "missing", "parent")), "a probe that cannot run counts as not writable")
+}
+
+func TestGeoIPDatabaseStoreStatus_RemovedFileHasNoSize(t *testing.T) {
+ f := newStoreFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_700_000_000)
+ require.NoError(t, f.store.Reload(context.Background()))
+ require.NoError(t, os.Remove(f.dataPath))
+
+ st := f.store.Status()
+
+ assert.Nil(t, st.SizeBytes)
+ require.NotNil(t, st.LoadedAt, "the load time is still reported for the database in use")
+}
+
+func TestGeoIPDatabaseStoreStatus_DirectoryAsBundledCopyIsUnknown(t *testing.T) {
+ f := newStoreFixture(t, false)
+ require.NoError(t, os.MkdirAll(f.bundledPath, 0o750))
+
+ assert.Nil(t, f.store.Status().IncludedBuildDate)
+}
+
+func TestGeoIPDatabaseStoreStatus_MissingDataFolderProbesParent(t *testing.T) {
+ f := newStoreFixture(t, false)
+ require.NoError(t, os.RemoveAll(f.dataDir))
+
+ st := f.store.Status()
+
+ assert.True(t, st.CanReplace, "the GeoIP folder is created on the first write, so its parent decides")
+}
+
+func TestGeoIPDatabaseStoreStatus_IncludedCopyIsReadAndCached(t *testing.T) {
+ f := newStoreFixture(t, false)
+ stamp := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
+ writeCountryDB(t, f.bundledPath, 1_700_000_000)
+ require.NoError(t, os.Chtimes(f.bundledPath, stamp, stamp))
+
+ first := f.store.Status()
+ require.NotNil(t, first.IncludedBuildDate)
+ assert.Equal(t, "2023-11-14T22:13:20Z", *first.IncludedBuildDate)
+
+ // Same size and mtime after a rewrite: the cached epoch is returned without reopening the file.
+ writeCountryDB(t, f.bundledPath, 1_800_000_000)
+ require.NoError(t, os.Chtimes(f.bundledPath, stamp, stamp))
+ cached := f.store.Status()
+ require.NotNil(t, cached.IncludedBuildDate)
+ assert.Equal(t, "2023-11-14T22:13:20Z", *cached.IncludedBuildDate)
+
+ // A new modification time invalidates the cache.
+ later := stamp.Add(time.Hour)
+ require.NoError(t, os.Chtimes(f.bundledPath, later, later))
+ refreshed := f.store.Status()
+ require.NotNil(t, refreshed.IncludedBuildDate)
+ assert.Equal(t, "2027-01-15T08:00:00Z", *refreshed.IncludedBuildDate)
+}
+
+func TestGeoIPDatabaseStoreStatus_IncludedCopyAbsentOrUnreadable(t *testing.T) {
+ f := newStoreFixture(t, false)
+ assert.Nil(t, f.store.Status().IncludedBuildDate, "an absent bundled copy is unknown")
+
+ require.NoError(t, os.MkdirAll(filepath.Dir(f.bundledPath), 0o750))
+ require.NoError(t, os.WriteFile(f.bundledPath, []byte("not a database"), 0o600))
+ assert.Nil(t, f.store.Status().IncludedBuildDate, "an unreadable bundled copy is unknown")
+}
+
+func TestGeoIPDatabaseStoreStatus_ZeroEpochIsUnknownBuildDate(t *testing.T) {
+ f := newStoreFixture(t, false)
+ writeCountryDB(t, f.dataPath, 0)
+ require.NoError(t, f.store.Reload(context.Background()))
+
+ assert.Nil(t, f.store.Status().BuildDate)
+}
+
+func TestGeoIPDatabaseStoreReload_AppliesAfterLoad(t *testing.T) {
+ f := newStoreFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_700_000_000)
+
+ require.NoError(t, f.store.Reload(context.Background()))
+
+ assert.Equal(t, 1, f.applyCalls)
+ assert.True(t, f.svc.IsLoaded())
+}
+
+func TestGeoIPDatabaseStoreReload_LoadFailureStillAppliesAndReportsReload(t *testing.T) {
+ f := newStoreFixture(t, false)
+
+ err := f.store.Reload(context.Background())
+
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrGeoIPReloadFailed)
+ assert.NotErrorIs(t, err, ErrGeoIPApplyFailed)
+ assert.Equal(t, 1, f.applyCalls, "the proxy converges on the database state even when the load failed")
+}
+
+func TestGeoIPDatabaseStoreReload_ApplyFailureIsReported(t *testing.T) {
+ f := newStoreFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_700_000_000)
+ f.apply = func(context.Context) error { return errors.New("proxy rejected config") }
+
+ err := f.store.Reload(context.Background())
+
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrGeoIPApplyFailed)
+ assert.NotErrorIs(t, err, ErrGeoIPReloadFailed)
+}
+
+func TestGeoIPDatabaseStoreReload_BothFailuresAreReported(t *testing.T) {
+ f := newStoreFixture(t, false)
+ f.apply = func(context.Context) error { return errors.New("proxy rejected config") }
+
+ err := f.store.Reload(context.Background())
+
+ assert.ErrorIs(t, err, ErrGeoIPReloadFailed)
+ assert.ErrorIs(t, err, ErrGeoIPApplyFailed)
+}
+
+func TestGeoIPDatabaseStoreReload_NilApplyIsSkipped(t *testing.T) {
+ f := newStoreFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_700_000_000)
+ store := NewGeoIPDatabaseStore(GeoIPDatabaseStoreConfig{
+ Service: f.svc, DataPath: f.dataPath, BundledPath: f.bundledPath,
+ })
+
+ require.NoError(t, store.Reload(context.Background()))
+}
+
+func TestGeoIPDatabaseStoreReload_ConcurrentCallIsBusy(t *testing.T) {
+ f := newStoreFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_700_000_000)
+ started := make(chan struct{})
+ release := make(chan struct{})
+ f.apply = func(context.Context) error {
+ close(started)
+ <-release
+ return nil
+ }
+
+ done := make(chan error, 1)
+ go func() { done <- f.store.Reload(context.Background()) }()
+ <-started
+
+ err := f.store.Reload(context.Background())
+ assert.ErrorIs(t, err, ErrGeoIPBusy)
+
+ close(release)
+ require.NoError(t, <-done)
+}
diff --git a/backend/internal/services/geoip_database_upload.go b/backend/internal/services/geoip_database_upload.go
new file mode 100644
index 000000000..dc4ac9127
--- /dev/null
+++ b/backend/internal/services/geoip_database_upload.go
@@ -0,0 +1,436 @@
+package services
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "golang.org/x/sys/unix"
+
+ "github.com/Wikid82/charon/backend/internal/geoip"
+)
+
+// MaxGeoIPDatabaseBytes is the largest database an upload may carry.
+const MaxGeoIPDatabaseBytes = 32 << 20
+
+// geoipFutureTolerance is how far ahead of the clock a build date may be. It allows for clock skew and no more: the
+// resolver prefers the newest copy, so a future-dated file would win over every later bundled update.
+const geoipFutureTolerance = 24 * time.Hour
+
+const (
+ // prevSuffix names the copy kept beside the data-folder copy while a change is in progress, so a failed change can
+ // put it back.
+ prevSuffix = ".prev"
+ // uploadTempPrefix starts the temporary file an upload is streamed into. The name is generated by os.CreateTemp in
+ // the data folder; nothing from the client contributes to it.
+ uploadTempPrefix = "." + geoip.DatabaseFileName + ".upload-"
+ // uploadTempPattern is the os.CreateTemp pattern for uploads.
+ uploadTempPattern = uploadTempPrefix + "*"
+)
+
+// Sentinel errors of the upload, removal and rollback paths. Handlers map them to status codes and stable error codes;
+// the wrapped detail is for the server log only. The geoip package's validation sentinels pass through unchanged.
+var (
+ // ErrGeoIPCustomPathSet is returned when CHARON_GEOIP_DB_PATH selects the database, so the data folder is not changed.
+ ErrGeoIPCustomPathSet = errors.New("geoip database is set by CHARON_GEOIP_DB_PATH")
+ // ErrGeoIPNoDataCopy is returned when removing the data-folder copy and there is none.
+ ErrGeoIPNoDataCopy = errors.New("no data-folder geoip database to remove")
+ // ErrGeoIPTooLarge is returned when an upload is over MaxGeoIPDatabaseBytes.
+ ErrGeoIPTooLarge = errors.New("geoip database exceeds the upload limit")
+ // ErrGeoIPUploadRead is returned when the upload could not be read for a reason other than its size.
+ ErrGeoIPUploadRead = errors.New("geoip upload could not be read")
+ // ErrGeoIPBuildDateInFuture is returned when the upload's build date is past the clock plus the tolerance.
+ ErrGeoIPBuildDateInFuture = errors.New("geoip build date is in the future")
+ // ErrGeoIPOlderThanCurrent is returned when the upload is older than the database in use or the bundled copy.
+ ErrGeoIPOlderThanCurrent = errors.New("geoip database is older than the current database")
+ // ErrGeoIPDataFolderReadOnly is returned when the data folder cannot be written.
+ ErrGeoIPDataFolderReadOnly = errors.New("geoip data folder is read-only")
+ // ErrGeoIPWriteFailed is returned when the data folder could not be changed for another reason.
+ ErrGeoIPWriteFailed = errors.New("geoip database write failed")
+)
+
+// OlderThanCurrentError is ErrGeoIPOlderThanCurrent with the two build epochs, Unix seconds, for the response.
+type OlderThanCurrentError struct {
+ Uploaded uint
+ Current uint
+}
+
+// Error describes the refusal with both epochs.
+func (e *OlderThanCurrentError) Error() string {
+ return fmt.Sprintf("%s: uploaded %d, current %d", ErrGeoIPOlderThanCurrent, e.Uploaded, e.Current)
+}
+
+// Unwrap returns ErrGeoIPOlderThanCurrent so errors.Is matches the sentinel.
+func (e *OlderThanCurrentError) Unwrap() error { return ErrGeoIPOlderThanCurrent }
+
+// ReplaceResult describes an upload. It is returned with the error too, so the caller can audit a refused upload with
+// its size and digest when they are known.
+type ReplaceResult struct {
+ // ReplacedPrevious reports whether a data-folder copy was replaced.
+ ReplacedPrevious bool
+ // Info is the metadata of the installed database; zero when the upload was refused before validation.
+ Info geoip.DatabaseInfo
+ // SizeBytes is the number of bytes read from the upload.
+ SizeBytes int64
+ // SHA256 is the hex digest of the upload; empty when it was not read in full.
+ SHA256 string
+}
+
+// uploadFile is the temporary file an upload is streamed into. *os.File satisfies it.
+type uploadFile interface {
+ io.Writer
+ Name() string
+ Sync() error
+ Close() error
+}
+
+// geoipFileOps are the data-folder operations an upload or a removal uses.
+type geoipFileOps struct {
+ createTemp func(dir, pattern string) (uploadFile, error)
+ rename func(oldpath, newpath string) error
+ syncDir func(dir string) error
+}
+
+// osGeoIPFileOps returns the file operations of the operating system.
+func osGeoIPFileOps() geoipFileOps {
+ return geoipFileOps{
+ createTemp: func(dir, pattern string) (uploadFile, error) {
+ f, err := os.CreateTemp(dir, pattern)
+ if err != nil {
+ return nil, err
+ }
+ return f, nil
+ },
+ rename: os.Rename,
+ syncDir: syncDir,
+ }
+}
+
+// swapState records what a change replaced, so a failed step can put the previous state back.
+type swapState struct {
+ live, prev string
+ hadPrevious bool
+ before GeoIPInfo
+}
+
+// Replace installs an uploaded country database as the data-folder copy. The upload is streamed into a temporary file
+// in the data folder, validated, and renamed into place; the copy it replaces is kept as .prev until the new database
+// is loaded and the proxy config applied. Any failure after the rename restores the previous state.
+func (s *GeoIPDatabaseStore) Replace(ctx context.Context, upload io.Reader) (ReplaceResult, error) {
+ if !s.mu.TryLock() {
+ return ReplaceResult{}, ErrGeoIPBusy
+ }
+ defer s.mu.Unlock()
+ if s.envInEffect() {
+ return ReplaceResult{}, ErrGeoIPCustomPathSet
+ }
+
+ live := s.dataPath
+ dir := filepath.Dir(live)
+ if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:gosec // G301: the folder holds a public database; mode matches the bundled copy
+ return ReplaceResult{}, folderWriteError(err)
+ }
+ hadPrevious, err := regularTarget(live)
+ if err != nil {
+ return ReplaceResult{}, err
+ }
+
+ tmp, err := s.files.createTemp(dir, uploadTempPattern)
+ if err != nil {
+ return ReplaceResult{}, folderWriteError(err)
+ }
+ tmpPath := tmp.Name()
+ // The temporary file is removed on every path; after a successful rename the name no longer exists.
+ defer func() {
+ _ = tmp.Close()
+ _ = os.Remove(tmpPath)
+ }()
+
+ size, digest, err := s.copyUpload(tmp, upload)
+ result := ReplaceResult{SizeBytes: size, SHA256: digest}
+ if err != nil {
+ return result, err
+ }
+
+ info, err := geoip.ValidateCountryDatabase(tmpPath)
+ if err != nil {
+ return result, err
+ }
+ result.Info = info
+ if err := s.checkBuildEpoch(info.BuildEpoch); err != nil {
+ return result, err
+ }
+
+ st := swapState{live: live, prev: live + prevSuffix, hadPrevious: hadPrevious, before: s.svc.Info()}
+ if hadPrevious {
+ if err := s.files.rename(live, st.prev); err != nil {
+ return result, folderWriteError(err)
+ }
+ }
+ if err := s.files.rename(tmpPath, live); err != nil {
+ restoreFiles(st)
+ return result, folderWriteError(err)
+ }
+ if err := s.files.syncDir(dir); err != nil {
+ restoreFiles(st)
+ return result, folderWriteError(err)
+ }
+ if err := s.loadFile(live); err != nil {
+ s.restore(ctx, st)
+ return result, fmt.Errorf("%w: %w", ErrGeoIPReloadFailed, err)
+ }
+ if err := s.applyIfSet(ctx); err != nil {
+ s.restore(ctx, st)
+ return result, fmt.Errorf("%w: %w", ErrGeoIPApplyFailed, err)
+ }
+
+ if hadPrevious {
+ _ = os.Remove(st.prev) // a leftover .prev is removed by the next startup sweep
+ }
+ result.ReplacedPrevious = hadPrevious
+ return result, nil
+}
+
+// copyUpload streams the upload into dst and returns its size and digest. The read is capped at maxBytes+1 bytes, so
+// an over-size upload is detected without buffering it. A write failure is reported as a write failure, not as an
+// unreadable upload.
+func (s *GeoIPDatabaseStore) copyUpload(dst uploadFile, upload io.Reader) (size int64, digest string, err error) {
+ hasher := sha256.New()
+ w := &uploadWriter{dst: io.MultiWriter(dst, hasher)}
+ n, err := io.Copy(w, io.LimitReader(upload, s.maxBytes+1))
+ switch {
+ case n > s.maxBytes:
+ return n, "", ErrGeoIPTooLarge
+ case w.err != nil:
+ return n, "", folderWriteError(w.err)
+ case err != nil:
+ return n, "", ClassifyUploadRead(err)
+ }
+ if err := dst.Sync(); err != nil {
+ return n, "", folderWriteError(err)
+ }
+ if err := dst.Close(); err != nil {
+ return n, "", folderWriteError(err)
+ }
+ return n, hex.EncodeToString(hasher.Sum(nil)), nil
+}
+
+// ClassifyUploadRead marks a failure while reading an upload as ErrGeoIPUploadRead. An error that already carries that
+// sentinel is returned as it is, and so is an oversized body, which keeps its *http.MaxBytesError type so the API maps
+// it to 413.
+func ClassifyUploadRead(err error) error {
+ var maxErr *http.MaxBytesError
+ if errors.As(err, &maxErr) || errors.Is(err, ErrGeoIPUploadRead) {
+ return err
+ }
+ return fmt.Errorf("%w: %w", ErrGeoIPUploadRead, err)
+}
+
+// uploadWriter remembers the first write error, so a failed write can be told apart from a failed read.
+type uploadWriter struct {
+ dst io.Writer
+ err error
+}
+
+// Write forwards p to the destination and records the first failure.
+func (w *uploadWriter) Write(p []byte) (int, error) {
+ n, err := w.dst.Write(p)
+ if err != nil && w.err == nil {
+ w.err = err
+ }
+ return n, err
+}
+
+// checkBuildEpoch refuses a build date in the future, and one older than the database in use or the bundled copy,
+// whichever is newer. Equal dates are accepted.
+func (s *GeoIPDatabaseStore) checkBuildEpoch(epoch uint) error {
+ if geoip.BuildTime(epoch).After(s.now().Add(geoipFutureTolerance)) {
+ return ErrGeoIPBuildDateInFuture
+ }
+ var current uint
+ if info := s.svc.Info(); info.Loaded {
+ current = info.BuildEpoch
+ }
+ if bundled, ok := s.includedBuildEpoch(); ok && bundled > current {
+ current = bundled
+ }
+ if epoch < current {
+ return &OlderThanCurrentError{Uploaded: epoch, Current: current}
+ }
+ return nil
+}
+
+// DataCopyInfo returns the metadata of the data-folder copy. It is the zero value when there is no copy or it cannot be
+// read; the caller reads it before a removal so the audit entry can name the removed build.
+func (s *GeoIPDatabaseStore) DataCopyInfo() geoip.DatabaseInfo {
+ if !isRegularFile(s.dataPath) {
+ return geoip.DatabaseInfo{}
+ }
+ info, err := geoip.Inspect(s.dataPath)
+ if err != nil {
+ return geoip.DatabaseInfo{}
+ }
+ return info
+}
+
+// RemoveDataCopy deletes the data-folder copy and loads the bundled copy in its place, or unloads the database when the
+// image ships none. The proxy config is applied afterwards. A failure restores the removed copy.
+func (s *GeoIPDatabaseStore) RemoveDataCopy(ctx context.Context) error {
+ if !s.mu.TryLock() {
+ return ErrGeoIPBusy
+ }
+ defer s.mu.Unlock()
+ if s.envInEffect() {
+ return ErrGeoIPCustomPathSet
+ }
+
+ live := s.dataPath
+ exists, err := regularTarget(live)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return ErrGeoIPNoDataCopy
+ }
+
+ st := swapState{live: live, prev: live + prevSuffix, hadPrevious: true, before: s.svc.Info()}
+ if err := s.files.rename(live, st.prev); err != nil {
+ return folderWriteError(err)
+ }
+ if err := s.files.syncDir(filepath.Dir(live)); err != nil {
+ restoreFiles(st)
+ return folderWriteError(err)
+ }
+ if err := s.loadBundledOrUnload(); err != nil {
+ s.restore(ctx, st)
+ return fmt.Errorf("%w: %w", ErrGeoIPReloadFailed, err)
+ }
+ if err := s.applyIfSet(ctx); err != nil {
+ s.restore(ctx, st)
+ return fmt.Errorf("%w: %w", ErrGeoIPApplyFailed, err)
+ }
+
+ _ = os.Remove(st.prev) // a leftover .prev is removed by the next startup sweep
+ return nil
+}
+
+// loadBundledOrUnload loads the bundled copy by its explicit path, so the result does not depend on the path resolver.
+// When the image ships no copy, the service is closed and no database is in use.
+func (s *GeoIPDatabaseStore) loadBundledOrUnload() error {
+ if !isRegularFile(s.bundledPath) {
+ _ = s.svc.Close()
+ return nil
+ }
+ return s.loadFile(s.bundledPath)
+}
+
+// applyIfSet applies the proxy config when a proxy is wired to the store.
+func (s *GeoIPDatabaseStore) applyIfSet(ctx context.Context) error {
+ if s.apply == nil {
+ return nil
+ }
+ return s.apply(ctx)
+}
+
+// restore puts the previous files and database back after a failed change, then applies the proxy config again on a
+// best-effort basis. Each step ignores its own error: the caller already reports the original failure.
+func (s *GeoIPDatabaseStore) restore(ctx context.Context, st swapState) {
+ restoreFiles(st)
+ if st.before.Loaded {
+ _ = s.svc.LoadFrom(st.before.Path)
+ } else {
+ _ = s.svc.Close()
+ }
+ _ = s.applyIfSet(ctx)
+}
+
+// restoreFiles moves the previous copy back to the live path, or removes the new file when there was no previous copy.
+func restoreFiles(st swapState) {
+ if st.hadPrevious {
+ _ = os.Rename(st.prev, st.live)
+ return
+ }
+ _ = os.Remove(st.live)
+}
+
+// regularTarget reports whether path names an existing regular file. A missing path is not an error. A symlink or any
+// other non-regular entry is refused, so a write never follows a link out of the data folder.
+func regularTarget(path string) (bool, error) {
+ info, err := os.Lstat(path)
+ switch {
+ case errors.Is(err, fs.ErrNotExist):
+ return false, nil
+ case err != nil:
+ return false, folderWriteError(err)
+ case !info.Mode().IsRegular():
+ return false, fmt.Errorf("%w: target is not a regular file", ErrGeoIPWriteFailed)
+ }
+ return true, nil
+}
+
+// folderWriteError classifies a failed change to the data folder. A read-only folder gets its own sentinel so the API
+// can say so; the OS text is kept for the server log only.
+func folderWriteError(err error) error {
+ if errors.Is(err, fs.ErrPermission) || errors.Is(err, unix.EROFS) {
+ return fmt.Errorf("%w: %w", ErrGeoIPDataFolderReadOnly, err)
+ }
+ return fmt.Errorf("%w: %w", ErrGeoIPWriteFailed, err)
+}
+
+// syncDir flushes the directory entry of a rename to disk.
+func syncDir(dir string) error {
+ d, err := os.Open(filepath.Clean(dir))
+ if err != nil {
+ return err
+ }
+ defer func() { _ = d.Close() }()
+ return d.Sync()
+}
+
+// SweepGeoIPDataFolder clears what an interrupted upload or removal left in the data folder of livePath. Upload temp
+// files are deleted. A .prev copy is moved back when the live copy is missing, and deleted when both exist: the rename
+// that installs an upload is atomic, so the live copy is then complete. It runs once at startup, before the database is
+// resolved.
+func SweepGeoIPDataFolder(livePath string) error {
+ dir := filepath.Dir(livePath)
+ entries, err := os.ReadDir(dir)
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("read geoip data folder: %w", err)
+ }
+
+ var errs []error
+ for _, entry := range entries {
+ if !strings.HasPrefix(entry.Name(), uploadTempPrefix) {
+ continue
+ }
+ if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil {
+ errs = append(errs, fmt.Errorf("remove interrupted geoip upload: %w", err))
+ }
+ }
+
+ prev := livePath + prevSuffix
+ liveExists := isRegularFile(livePath)
+ switch {
+ case isRegularFile(prev) && !liveExists:
+ if err := os.Rename(prev, livePath); err != nil {
+ errs = append(errs, fmt.Errorf("restore previous geoip database: %w", err))
+ }
+ case isRegularFile(prev):
+ if err := os.Remove(prev); err != nil {
+ errs = append(errs, fmt.Errorf("remove previous geoip database: %w", err))
+ }
+ }
+ return errors.Join(errs...)
+}
diff --git a/backend/internal/services/geoip_database_upload_fileops_test.go b/backend/internal/services/geoip_database_upload_fileops_test.go
new file mode 100644
index 000000000..0176cb91d
--- /dev/null
+++ b/backend/internal/services/geoip_database_upload_fileops_test.go
@@ -0,0 +1,162 @@
+package services
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// errInjected is the failure the file-operation fakes return.
+var errInjected = errors.New("injected file failure")
+
+// faultyUploadFile is a real temporary file whose Write, Sync or Close fails on request. The real file is still closed,
+// so the store's cleanup can remove it.
+type faultyUploadFile struct {
+ *os.File
+ failWrite, failSync, failClose bool
+}
+
+func (f *faultyUploadFile) Write(p []byte) (int, error) {
+ if f.failWrite {
+ return 0, errInjected
+ }
+ return f.File.Write(p)
+}
+
+func (f *faultyUploadFile) Sync() error {
+ if f.failSync {
+ return errInjected
+ }
+ return f.File.Sync()
+}
+
+func (f *faultyUploadFile) Close() error {
+ err := f.File.Close()
+ if f.failClose {
+ return errInjected
+ }
+ return err
+}
+
+// withFaultyTemp makes the store stream uploads into a faultyUploadFile configured by configure.
+func withFaultyTemp(store *GeoIPDatabaseStore, configure func(*faultyUploadFile)) {
+ store.files.createTemp = func(dir, pattern string) (uploadFile, error) {
+ real, err := os.CreateTemp(dir, pattern)
+ if err != nil {
+ return nil, err
+ }
+ f := &faultyUploadFile{File: real}
+ configure(f)
+ return f, nil
+ }
+}
+
+func TestGeoIPDatabaseStoreReplace_TempFileFailuresAreWriteFailures(t *testing.T) {
+ cases := map[string]func(*faultyUploadFile){
+ "write": func(f *faultyUploadFile) { f.failWrite = true },
+ "sync": func(f *faultyUploadFile) { f.failSync = true },
+ "close": func(f *faultyUploadFile) { f.failClose = true },
+ }
+ for name, configure := range cases {
+ t.Run(name, func(t *testing.T) {
+ f := newUploadFixture(t, false)
+ withFaultyTemp(f.store, configure)
+
+ res, err := f.store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, 1_750_000_000)))
+
+ require.ErrorIs(t, err, ErrGeoIPWriteFailed)
+ assert.ErrorIs(t, err, errInjected)
+ assert.NotErrorIs(t, err, ErrGeoIPUploadRead)
+ assert.Empty(t, res.SHA256, "a digest is only reported for an upload written in full")
+ assert.NoFileExists(t, f.dataPath)
+ assert.Empty(t, leftoverUploads(t, f.dataDir))
+ assert.Equal(t, 0, f.applyCalls)
+ })
+ }
+}
+
+func TestGeoIPDatabaseStoreReplace_InstallRenameFailureRestoresPrevious(t *testing.T) {
+ f := newUploadFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_700_000_000)
+ require.NoError(t, f.store.Reload(context.Background()))
+ before, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+ applied := f.applyCalls
+ f.store.files.rename = func(oldpath, newpath string) error {
+ if strings.HasPrefix(filepath.Base(oldpath), uploadTempPrefix) {
+ return errInjected
+ }
+ return os.Rename(oldpath, newpath)
+ }
+
+ _, err = f.store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, 1_750_000_000)))
+
+ require.ErrorIs(t, err, ErrGeoIPWriteFailed)
+ after, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+ assert.Equal(t, before, after, "the previous copy is moved back")
+ assert.NoFileExists(t, f.dataPath+prevSuffix)
+ assert.Empty(t, leftoverUploads(t, f.dataDir))
+ assert.Equal(t, applied, f.applyCalls, "nothing is applied when the files could not be changed")
+}
+
+func TestGeoIPDatabaseStoreReplace_FolderSyncFailureRemovesNewCopy(t *testing.T) {
+ f := newUploadFixture(t, false)
+ f.store.files.syncDir = func(string) error { return errInjected }
+
+ _, err := f.store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, 1_750_000_000)))
+
+ require.ErrorIs(t, err, ErrGeoIPWriteFailed)
+ assert.NoFileExists(t, f.dataPath, "without a previous copy the new file is removed")
+ assert.Empty(t, leftoverUploads(t, f.dataDir))
+ assert.False(t, f.svc.Info().Loaded)
+ assert.Equal(t, 0, f.applyCalls)
+}
+
+func TestGeoIPDatabaseStoreRemove_FolderSyncFailureRestoresCopy(t *testing.T) {
+ f := newUploadFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_750_000_000)
+ f.store.files.syncDir = func(string) error { return errInjected }
+
+ err := f.store.RemoveDataCopy(context.Background())
+
+ require.ErrorIs(t, err, ErrGeoIPWriteFailed)
+ assert.FileExists(t, f.dataPath)
+ assert.NoFileExists(t, f.dataPath+prevSuffix)
+ assert.Equal(t, 0, f.applyCalls)
+}
+
+func TestSyncDir_MissingFolderIsAnError(t *testing.T) {
+ require.ErrorIs(t, syncDir(filepath.Join(t.TempDir(), "missing")), os.ErrNotExist)
+ require.NoError(t, syncDir(t.TempDir()))
+}
+
+func TestOSGeoIPFileOps_CreateTempFailureReturnsNoFile(t *testing.T) {
+ f, err := osGeoIPFileOps().createTemp(filepath.Join(t.TempDir(), "missing"), uploadTempPattern)
+
+ require.Error(t, err)
+ assert.Nil(t, f, "a failed create must not hand back a typed nil file")
+}
+
+func TestFormatTimeUTC_ZeroTimeIsUnknown(t *testing.T) {
+ assert.Nil(t, formatTimeUTC(time.Time{}))
+ got := formatTimeUTC(time.Date(2026, 11, 10, 5, 0, 1, 0, time.FixedZone("x", 3600)))
+ require.NotNil(t, got)
+ assert.Equal(t, "2026-11-10T04:00:01Z", *got)
+}
+
+func TestGeoIPDatabaseStoreCheckBuildEpoch_RefusesEpochsPastTheTimeRange(t *testing.T) {
+ f := newUploadFixture(t, false)
+
+ assert.ErrorIs(t, f.store.checkBuildEpoch(math.MaxUint), ErrGeoIPBuildDateInFuture)
+ assert.ErrorIs(t, f.store.checkBuildEpoch(uint(math.MaxInt64)+1), ErrGeoIPBuildDateInFuture)
+}
diff --git a/backend/internal/services/geoip_database_upload_test.go b/backend/internal/services/geoip_database_upload_test.go
new file mode 100644
index 000000000..750d9fd19
--- /dev/null
+++ b/backend/internal/services/geoip_database_upload_test.go
@@ -0,0 +1,679 @@
+package services
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/sys/unix"
+
+ "github.com/Wikid82/charon/backend/internal/geoip"
+ "github.com/Wikid82/charon/backend/internal/testutil"
+)
+
+// uploadNow is the store clock for the upload tests; epochs are placed relative to it.
+var uploadNow = time.Date(2026, 11, 10, 5, 0, 1, 0, time.UTC)
+
+// newUploadFixture is a store fixture whose clock is uploadNow.
+func newUploadFixture(t *testing.T, env bool) *storeFixture {
+ t.Helper()
+ f := newStoreFixture(t, env)
+ f.store.now = fixedClock(uploadNow)
+ return f
+}
+
+// uploadBytes returns a valid country database with a record that resolves every address to the US, built with buildEpoch
+// and any further options.
+func uploadBytes(t *testing.T, buildEpoch uint32, opts ...testutil.GeoIPCountryOption) []byte {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), geoip.DatabaseFileName)
+ all := append([]testutil.GeoIPCountryOption{
+ testutil.WithGeoIPDatabaseType("GeoLite2-Country"),
+ testutil.WithGeoIPRecordISOCode("US"),
+ }, opts...)
+ testutil.WriteGeoIPCountryDB(t, path, buildEpoch, all...)
+ data, err := os.ReadFile(path) //nolint:gosec // G304: t.TempDir-derived path
+ require.NoError(t, err)
+ return data
+}
+
+// placeFile writes data at path, creating the folder when needed.
+func placeFile(t *testing.T, path string, data []byte) {
+ t.Helper()
+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750))
+ require.NoError(t, os.WriteFile(path, data, 0o600))
+}
+
+// leftoverUploads lists the upload temp files in dir.
+func leftoverUploads(t *testing.T, dir string) []string {
+ t.Helper()
+ matches, err := filepath.Glob(filepath.Join(dir, uploadTempPrefix+"*"))
+ require.NoError(t, err)
+ return matches
+}
+
+// failingReader returns err on the first read.
+type failingReader struct{ err error }
+
+func (r failingReader) Read([]byte) (int, error) { return 0, r.err }
+
+func TestGeoIPDatabaseStoreReplace_InstallsUploadAndLoadsIt(t *testing.T) {
+ f := newUploadFixture(t, false)
+ data := uploadBytes(t, 1_700_000_000)
+
+ res, err := f.store.Replace(context.Background(), bytes.NewReader(data))
+
+ require.NoError(t, err)
+ assert.False(t, res.ReplacedPrevious)
+ assert.Equal(t, uint(1_700_000_000), res.Info.BuildEpoch)
+ assert.Equal(t, "GeoLite2-Country", res.Info.DatabaseType)
+ assert.Equal(t, int64(len(data)), res.SizeBytes)
+ sum := sha256.Sum256(data)
+ assert.Equal(t, hex.EncodeToString(sum[:]), res.SHA256)
+
+ installed, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+ assert.Equal(t, data, installed)
+ info, err := os.Stat(f.dataPath)
+ require.NoError(t, err)
+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "the upload is installed with owner-only permissions")
+
+ assert.True(t, f.svc.IsLoaded())
+ assert.Equal(t, f.dataPath, f.svc.Info().Path)
+ assert.Equal(t, 1, f.applyCalls)
+ assert.Empty(t, leftoverUploads(t, f.dataDir))
+ assert.NoFileExists(t, f.dataPath+prevSuffix)
+}
+
+func TestGeoIPDatabaseStoreReplace_ReplacesExistingCopyAndRemovesPrevious(t *testing.T) {
+ f := newUploadFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_700_000_000)
+ require.NoError(t, f.store.Reload(context.Background()))
+ data := uploadBytes(t, 1_750_000_000)
+
+ res, err := f.store.Replace(context.Background(), bytes.NewReader(data))
+
+ require.NoError(t, err)
+ assert.True(t, res.ReplacedPrevious)
+ installed, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+ assert.Equal(t, data, installed)
+ assert.NoFileExists(t, f.dataPath+prevSuffix)
+ assert.Equal(t, uint(1_750_000_000), f.svc.Info().BuildEpoch)
+}
+
+func TestGeoIPDatabaseStoreReplace_CreatesMissingGeoIPFolder(t *testing.T) {
+ root := t.TempDir()
+ dataPath := filepath.Join(root, "data", "geoip", geoip.DatabaseFileName)
+ svc := NewUnloadedGeoIPService(dataPath)
+ store := NewGeoIPDatabaseStore(GeoIPDatabaseStoreConfig{
+ Service: svc,
+ DataPath: dataPath,
+ BundledPath: filepath.Join(root, "bundled", geoip.DatabaseFileName),
+ })
+ store.now = fixedClock(uploadNow)
+ require.NoDirExists(t, filepath.Dir(dataPath))
+
+ _, err := store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, 1_700_000_000)))
+
+ require.NoError(t, err)
+ assert.FileExists(t, dataPath)
+}
+
+func TestGeoIPDatabaseStoreReplace_EpochRules(t *testing.T) {
+ cases := []struct {
+ name string
+ inUse uint32 // zero means no database in use
+ upload uint32
+ wantErr error
+ }{
+ {name: "equal to the database in use", inUse: 1_750_000_000, upload: 1_750_000_000},
+ {name: "23 hours ahead of the clock", upload: uint32(uploadNow.Add(23 * time.Hour).Unix())}, //nolint:gosec // G115: a 2026 Unix time fits in uint32
+ {
+ name: "24 hours and a second ahead of the clock",
+ upload: uint32(uploadNow.Add(24*time.Hour + time.Second).Unix()), //nolint:gosec // G115: a 2026 Unix time fits in uint32
+ wantErr: ErrGeoIPBuildDateInFuture,
+ },
+ {name: "older than the database in use", inUse: 1_750_000_000, upload: 1_700_000_000, wantErr: ErrGeoIPOlderThanCurrent},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newUploadFixture(t, false)
+ if tc.inUse != 0 {
+ writeCountryDB(t, f.dataPath, tc.inUse)
+ require.NoError(t, f.store.Reload(context.Background()))
+ }
+
+ _, err := f.store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, tc.upload)))
+
+ if tc.wantErr == nil {
+ require.NoError(t, err)
+ return
+ }
+ assert.ErrorIs(t, err, tc.wantErr)
+ })
+ }
+}
+
+func TestGeoIPDatabaseStoreReplace_OlderThanCurrentReportsBothDates(t *testing.T) {
+ f := newUploadFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_750_000_000)
+ require.NoError(t, f.store.Reload(context.Background()))
+ before, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+
+ _, err = f.store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, 1_700_000_000)))
+
+ var older *OlderThanCurrentError
+ require.ErrorAs(t, err, &older)
+ assert.Equal(t, uint(1_700_000_000), older.Uploaded)
+ assert.Equal(t, uint(1_750_000_000), older.Current)
+ assert.ErrorIs(t, err, ErrGeoIPOlderThanCurrent)
+ assert.Equal(t, "geoip database is older than the current database: uploaded 1700000000, current 1750000000",
+ older.Error())
+ after, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+ assert.Equal(t, before, after, "a refused upload leaves the data folder as it was")
+}
+
+func TestGeoIPDatabaseStoreReplace_OlderThanBundledCopyIsRefused(t *testing.T) {
+ f := newUploadFixture(t, false)
+ writeCountryDB(t, f.bundledPath, 1_750_000_000)
+
+ _, err := f.store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, 1_700_000_000)))
+
+ var older *OlderThanCurrentError
+ require.ErrorAs(t, err, &older)
+ assert.Equal(t, uint(1_750_000_000), older.Current)
+}
+
+func TestGeoIPDatabaseStoreReplace_RefusedUploadsLeaveNoTrace(t *testing.T) {
+ cases := []struct {
+ name string
+ data []byte
+ maxBytes func(size int64) int64
+ wantError error
+ }{
+ {name: "not a database", data: []byte("plain text"), wantError: geoip.ErrNotMMDB},
+ {
+ name: "not a country database",
+ data: uploadBytes(t, 1_700_000_000, testutil.WithGeoIPDatabaseType("GeoLite2-ASN")),
+ wantError: geoip.ErrWrongDatabaseType,
+ },
+ {name: "no build date", data: uploadBytes(t, 0), wantError: geoip.ErrNoBuildDate},
+ {
+ name: "over the size limit",
+ data: uploadBytes(t, 1_700_000_000),
+ maxBytes: func(size int64) int64 { return size - 1 },
+ wantError: ErrGeoIPTooLarge,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newUploadFixture(t, false)
+ if tc.maxBytes != nil {
+ f.store.maxBytes = tc.maxBytes(int64(len(tc.data)))
+ }
+
+ res, err := f.store.Replace(context.Background(), bytes.NewReader(tc.data))
+
+ assert.ErrorIs(t, err, tc.wantError)
+ assert.Equal(t, int64(len(tc.data)), res.SizeBytes)
+ assert.NoFileExists(t, f.dataPath)
+ assert.Empty(t, leftoverUploads(t, f.dataDir))
+ assert.Equal(t, 0, f.applyCalls)
+ })
+ }
+}
+
+func TestGeoIPDatabaseStoreReplace_SizeLimitIsInclusive(t *testing.T) {
+ data := uploadBytes(t, 1_700_000_000)
+
+ t.Run("exactly the limit is accepted", func(t *testing.T) {
+ f := newUploadFixture(t, false)
+ f.store.maxBytes = int64(len(data))
+
+ _, err := f.store.Replace(context.Background(), bytes.NewReader(data))
+
+ require.NoError(t, err)
+ })
+ t.Run("one byte over the limit is refused", func(t *testing.T) {
+ f := newUploadFixture(t, false)
+ f.store.maxBytes = int64(len(data)) - 1
+
+ _, err := f.store.Replace(context.Background(), bytes.NewReader(data))
+
+ assert.ErrorIs(t, err, ErrGeoIPTooLarge)
+ })
+}
+
+func TestGeoIPDatabaseStoreReplace_ClassifiesReadFailures(t *testing.T) {
+ t.Run("a failed read is an upload read error", func(t *testing.T) {
+ f := newUploadFixture(t, false)
+
+ _, err := f.store.Replace(context.Background(), failingReader{err: errors.New("connection reset")})
+
+ assert.ErrorIs(t, err, ErrGeoIPUploadRead)
+ assert.Empty(t, leftoverUploads(t, f.dataDir))
+ })
+ t.Run("a body over the request limit keeps its type for the handler", func(t *testing.T) {
+ f := newUploadFixture(t, false)
+ limitErr := fmt.Errorf("read part: %w", &http.MaxBytesError{Limit: 10})
+
+ _, err := f.store.Replace(context.Background(), failingReader{err: limitErr})
+
+ var maxErr *http.MaxBytesError
+ assert.ErrorAs(t, err, &maxErr)
+ assert.NotErrorIs(t, err, ErrGeoIPUploadRead)
+ })
+}
+
+func TestGeoIPDatabaseStoreReplace_RefusedWhileCustomPathIsSet(t *testing.T) {
+ f := newUploadFixture(t, true)
+
+ _, err := f.store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, 1_700_000_000)))
+
+ assert.ErrorIs(t, err, ErrGeoIPCustomPathSet)
+ assert.NoFileExists(t, f.dataPath)
+}
+
+func TestGeoIPDatabaseStoreReplace_BusyWhileAnotherChangeRuns(t *testing.T) {
+ f := newUploadFixture(t, false)
+ f.store.mu.Lock()
+ defer f.store.mu.Unlock()
+
+ _, err := f.store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, 1_700_000_000)))
+
+ assert.ErrorIs(t, err, ErrGeoIPBusy)
+}
+
+func TestGeoIPDatabaseStoreReplace_LoadFailureRestoresPrevious(t *testing.T) {
+ f := newUploadFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_700_000_000)
+ require.NoError(t, f.store.Reload(context.Background()))
+ before, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+ f.store.loadFile = func(string) error { return errors.New("load refused") }
+
+ _, err = f.store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, 1_750_000_000)))
+
+ assert.ErrorIs(t, err, ErrGeoIPReloadFailed)
+ installed, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+ assert.Equal(t, before, installed)
+ assert.NoFileExists(t, f.dataPath+prevSuffix)
+ assert.True(t, f.svc.IsLoaded())
+ assert.Equal(t, uint(1_700_000_000), f.svc.Info().BuildEpoch)
+ assert.Equal(t, 2, f.applyCalls, "the rollback re-applies the proxy config")
+}
+
+func TestGeoIPDatabaseStoreReplace_ApplyFailureRestoresPrevious(t *testing.T) {
+ f := newUploadFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_700_000_000)
+ require.NoError(t, f.store.Reload(context.Background()))
+ before, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+ calls := 0
+ f.store.apply = func(context.Context) error {
+ calls++
+ if calls == 1 {
+ return errors.New("proxy rejected config")
+ }
+ return nil
+ }
+
+ _, err = f.store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, 1_750_000_000)))
+
+ assert.ErrorIs(t, err, ErrGeoIPApplyFailed)
+ installed, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+ assert.Equal(t, before, installed)
+ assert.NoFileExists(t, f.dataPath+prevSuffix)
+ assert.Equal(t, uint(1_700_000_000), f.svc.Info().BuildEpoch)
+ assert.Equal(t, 2, calls)
+}
+
+func TestGeoIPDatabaseStoreReplace_ApplyFailureWithoutPreviousUnloads(t *testing.T) {
+ f := newUploadFixture(t, false)
+ f.store.apply = func(context.Context) error { return errors.New("proxy rejected config") }
+
+ _, err := f.store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, 1_700_000_000)))
+
+ assert.ErrorIs(t, err, ErrGeoIPApplyFailed)
+ assert.NoFileExists(t, f.dataPath)
+ assert.False(t, f.svc.IsLoaded(), "with nothing to restore, the upload is unloaded again")
+}
+
+func TestGeoIPDatabaseStoreReplace_RefusesSymlinkTarget(t *testing.T) {
+ f := newUploadFixture(t, false)
+ outside := filepath.Join(t.TempDir(), "outside.mmdb")
+ require.NoError(t, os.WriteFile(outside, []byte("untouched"), 0o600))
+ require.NoError(t, os.Symlink(outside, f.dataPath))
+
+ _, err := f.store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, 1_700_000_000)))
+
+ assert.ErrorIs(t, err, ErrGeoIPWriteFailed)
+ content, err := os.ReadFile(outside) //nolint:gosec // G304: t.TempDir-derived path
+ require.NoError(t, err)
+ assert.Equal(t, "untouched", string(content))
+}
+
+func TestGeoIPDatabaseStoreReplace_ReadOnlyFolderIsReported(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("permission checks do not apply to root")
+ }
+ t.Run("the GeoIP folder cannot be created", func(t *testing.T) {
+ root := t.TempDir()
+ dataPath := filepath.Join(root, "data", "geoip", geoip.DatabaseFileName)
+ require.NoError(t, os.MkdirAll(filepath.Join(root, "data"), 0o750))
+ require.NoError(t, os.Chmod(filepath.Join(root, "data"), 0o500)) //nolint:gosec // G302: directory needs the exec bit; read-only dir fixture
+ t.Cleanup(func() { _ = os.Chmod(filepath.Join(root, "data"), 0o755) }) //nolint:gosec // G302: the test tree must be removable
+ store := NewGeoIPDatabaseStore(GeoIPDatabaseStoreConfig{
+ Service: NewUnloadedGeoIPService(dataPath), DataPath: dataPath, BundledPath: filepath.Join(root, "b.mmdb"),
+ })
+ store.now = fixedClock(uploadNow)
+
+ _, err := store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, 1_700_000_000)))
+
+ assert.ErrorIs(t, err, ErrGeoIPDataFolderReadOnly)
+ })
+ t.Run("the GeoIP folder exists but refuses new files", func(t *testing.T) {
+ f := newUploadFixture(t, false)
+ require.NoError(t, os.Chmod(f.dataDir, 0o500)) //nolint:gosec // G302: directory needs the exec bit; read-only dir fixture
+ t.Cleanup(func() { _ = os.Chmod(f.dataDir, 0o755) }) //nolint:gosec // G302: the test tree must be removable
+
+ _, err := f.store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, 1_700_000_000)))
+
+ assert.ErrorIs(t, err, ErrGeoIPDataFolderReadOnly)
+ })
+}
+
+func TestFolderWriteError_ClassifiesReadOnlyFolders(t *testing.T) {
+ assert.ErrorIs(t, folderWriteError(unix.EROFS), ErrGeoIPDataFolderReadOnly)
+ assert.ErrorIs(t, folderWriteError(os.ErrPermission), ErrGeoIPDataFolderReadOnly)
+ assert.ErrorIs(t, folderWriteError(unix.ENOSPC), ErrGeoIPWriteFailed)
+}
+
+func TestUploadTempName_IsServerGenerated(t *testing.T) {
+ assert.True(t, strings.HasPrefix(uploadTempPrefix, "."), "temp files are hidden")
+ assert.NotContains(t, uploadTempPrefix, string(filepath.Separator))
+ assert.Equal(t, uploadTempPrefix+"*", uploadTempPattern)
+}
+
+func TestGeoIPDatabaseStoreRemove_RestoresBundledCopy(t *testing.T) {
+ f := newUploadFixture(t, false)
+ writeCountryDB(t, f.bundledPath, 1_700_000_000)
+ writeCountryDB(t, f.dataPath, 1_750_000_000)
+ require.NoError(t, f.store.Reload(context.Background()))
+
+ require.NoError(t, f.store.RemoveDataCopy(context.Background()))
+
+ assert.NoFileExists(t, f.dataPath)
+ assert.NoFileExists(t, f.dataPath+prevSuffix)
+ assert.Equal(t, f.bundledPath, f.svc.Info().Path)
+ assert.Equal(t, 2, f.applyCalls)
+}
+
+func TestGeoIPDatabaseStoreRemove_WithoutBundledCopyUnloads(t *testing.T) {
+ f := newUploadFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_750_000_000)
+ require.NoError(t, f.store.Reload(context.Background()))
+
+ require.NoError(t, f.store.RemoveDataCopy(context.Background()))
+
+ assert.False(t, f.svc.IsLoaded())
+ assert.Equal(t, string(geoip.SourceNone), f.store.Status().Source)
+ assert.Equal(t, 2, f.applyCalls)
+}
+
+func TestGeoIPDatabaseStoreRemove_RefusedCases(t *testing.T) {
+ t.Run("no data-folder copy", func(t *testing.T) {
+ f := newUploadFixture(t, false)
+
+ assert.ErrorIs(t, f.store.RemoveDataCopy(context.Background()), ErrGeoIPNoDataCopy)
+ })
+ t.Run("custom path set", func(t *testing.T) {
+ f := newUploadFixture(t, true)
+ writeCountryDB(t, f.dataPath, 1_750_000_000)
+
+ assert.ErrorIs(t, f.store.RemoveDataCopy(context.Background()), ErrGeoIPCustomPathSet)
+ assert.FileExists(t, f.dataPath)
+ })
+ t.Run("busy", func(t *testing.T) {
+ f := newUploadFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_750_000_000)
+ f.store.mu.Lock()
+ defer f.store.mu.Unlock()
+
+ assert.ErrorIs(t, f.store.RemoveDataCopy(context.Background()), ErrGeoIPBusy)
+ })
+ t.Run("symlink target", func(t *testing.T) {
+ f := newUploadFixture(t, false)
+ outside := filepath.Join(t.TempDir(), "outside.mmdb")
+ writeCountryDB(t, outside, 1_750_000_000)
+ require.NoError(t, os.Symlink(outside, f.dataPath))
+
+ assert.ErrorIs(t, f.store.RemoveDataCopy(context.Background()), ErrGeoIPWriteFailed)
+ assert.FileExists(t, outside)
+ })
+}
+
+func TestGeoIPDatabaseStoreRemove_FailuresRestoreCopy(t *testing.T) {
+ t.Run("unreadable bundled copy", func(t *testing.T) {
+ f := newUploadFixture(t, false)
+ placeFile(t, f.bundledPath, []byte("not a database"))
+ writeCountryDB(t, f.dataPath, 1_750_000_000)
+ require.NoError(t, f.store.Reload(context.Background()))
+ before, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+
+ err = f.store.RemoveDataCopy(context.Background())
+
+ assert.ErrorIs(t, err, ErrGeoIPReloadFailed)
+ installed, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+ assert.Equal(t, before, installed)
+ assert.True(t, f.svc.IsLoaded())
+ assert.NoFileExists(t, f.dataPath+prevSuffix)
+ })
+ t.Run("apply failure", func(t *testing.T) {
+ f := newUploadFixture(t, false)
+ writeCountryDB(t, f.bundledPath, 1_700_000_000)
+ writeCountryDB(t, f.dataPath, 1_750_000_000)
+ require.NoError(t, f.store.Reload(context.Background()))
+ calls := 0
+ f.store.apply = func(context.Context) error {
+ calls++
+ if calls == 1 {
+ return errors.New("proxy rejected config")
+ }
+ return nil
+ }
+
+ err := f.store.RemoveDataCopy(context.Background())
+
+ assert.ErrorIs(t, err, ErrGeoIPApplyFailed)
+ assert.FileExists(t, f.dataPath)
+ assert.Equal(t, uint(1_750_000_000), f.svc.Info().BuildEpoch)
+ })
+}
+
+func TestSweepGeoIPDataFolder_RemovesUploadTemps(t *testing.T) {
+ dir := t.TempDir()
+ live := filepath.Join(dir, geoip.DatabaseFileName)
+ placeFile(t, live, []byte("live"))
+ temp := filepath.Join(dir, uploadTempPrefix+"123")
+ placeFile(t, temp, []byte("partial"))
+
+ require.NoError(t, SweepGeoIPDataFolder(live))
+
+ assert.NoFileExists(t, temp)
+ assert.FileExists(t, live)
+}
+
+func TestSweepGeoIPDataFolder_RestoresPreviousWhenLiveMissing(t *testing.T) {
+ dir := t.TempDir()
+ live := filepath.Join(dir, geoip.DatabaseFileName)
+ placeFile(t, live+prevSuffix, []byte("previous"))
+
+ require.NoError(t, SweepGeoIPDataFolder(live))
+
+ restored, err := os.ReadFile(live) //nolint:gosec // G304: t.TempDir-derived path
+ require.NoError(t, err)
+ assert.Equal(t, "previous", string(restored))
+ assert.NoFileExists(t, live+prevSuffix)
+}
+
+func TestSweepGeoIPDataFolder_RemovesPreviousWhenLiveExists(t *testing.T) {
+ dir := t.TempDir()
+ live := filepath.Join(dir, geoip.DatabaseFileName)
+ placeFile(t, live, []byte("new"))
+ placeFile(t, live+prevSuffix, []byte("previous"))
+
+ require.NoError(t, SweepGeoIPDataFolder(live))
+
+ kept, err := os.ReadFile(live) //nolint:gosec // G304: t.TempDir-derived path
+ require.NoError(t, err)
+ assert.Equal(t, "new", string(kept))
+ assert.NoFileExists(t, live+prevSuffix)
+}
+
+func TestSweepGeoIPDataFolder_NothingToDo(t *testing.T) {
+ t.Run("empty folder", func(t *testing.T) {
+ assert.NoError(t, SweepGeoIPDataFolder(filepath.Join(t.TempDir(), geoip.DatabaseFileName)))
+ })
+ t.Run("folder absent", func(t *testing.T) {
+ assert.NoError(t, SweepGeoIPDataFolder(filepath.Join(t.TempDir(), "absent", geoip.DatabaseFileName)))
+ })
+}
+
+func TestSweepGeoIPDataFolder_UnreadableFolderIsReported(t *testing.T) {
+ parent := filepath.Join(t.TempDir(), "not-a-folder")
+ require.NoError(t, os.WriteFile(parent, []byte("file"), 0o600))
+
+ err := SweepGeoIPDataFolder(filepath.Join(parent, geoip.DatabaseFileName))
+
+ assert.Error(t, err)
+}
+
+func TestGeoIPDatabaseStoreDataCopyInfo(t *testing.T) {
+ t.Run("no data-folder copy", func(t *testing.T) {
+ f := newUploadFixture(t, false)
+
+ assert.Equal(t, geoip.DatabaseInfo{}, f.store.DataCopyInfo())
+ })
+ t.Run("data-folder copy is read", func(t *testing.T) {
+ f := newUploadFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_750_000_000)
+
+ info := f.store.DataCopyInfo()
+
+ assert.Equal(t, uint(1_750_000_000), info.BuildEpoch)
+ assert.Equal(t, "GeoLite2-Country", info.DatabaseType)
+ })
+ t.Run("unreadable data-folder copy is zero", func(t *testing.T) {
+ f := newUploadFixture(t, false)
+ placeFile(t, f.dataPath, []byte("not a database"))
+
+ assert.Equal(t, geoip.DatabaseInfo{}, f.store.DataCopyInfo())
+ })
+}
+
+func TestClassifyUploadRead(t *testing.T) {
+ limited := &http.MaxBytesError{Limit: 1}
+ assert.ErrorIs(t, ClassifyUploadRead(limited), limited)
+ assert.ErrorIs(t, ClassifyUploadRead(ErrGeoIPUploadRead), ErrGeoIPUploadRead)
+
+ wrapped := ClassifyUploadRead(errors.New("socket closed"))
+ assert.ErrorIs(t, wrapped, ErrGeoIPUploadRead)
+ assert.NotErrorIs(t, wrapped, ErrGeoIPTooLarge)
+}
+
+func TestGeoIPDatabaseStoreReplace_CannotMoveCopyAsideRefusesWithoutTouchingCopy(t *testing.T) {
+ f := newUploadFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_700_000_000)
+ require.NoError(t, f.store.Reload(context.Background()))
+ before, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+ // A directory where the previous copy would go makes the move aside fail.
+ require.NoError(t, os.MkdirAll(filepath.Join(f.dataPath+prevSuffix, "blocker"), 0o750))
+
+ _, err = f.store.Replace(context.Background(), bytes.NewReader(uploadBytes(t, 1_750_000_000)))
+
+ assert.ErrorIs(t, err, ErrGeoIPWriteFailed)
+ after, err := os.ReadFile(f.dataPath)
+ require.NoError(t, err)
+ assert.Equal(t, before, after)
+ assert.Empty(t, leftoverUploads(t, f.dataDir))
+}
+
+func TestGeoIPDatabaseStoreRemove_ReadOnlyFolderAndUnreachablePath(t *testing.T) {
+ t.Run("a path under a regular file cannot be inspected", func(t *testing.T) {
+ f := newUploadFixture(t, false)
+ parent := filepath.Join(t.TempDir(), "not-a-folder")
+ require.NoError(t, os.WriteFile(parent, []byte("file"), 0o600))
+ f.store.dataPath = filepath.Join(parent, geoip.DatabaseFileName)
+
+ assert.ErrorIs(t, f.store.RemoveDataCopy(context.Background()), ErrGeoIPWriteFailed)
+ })
+ t.Run("a read-only folder keeps the copy", func(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("permission checks do not apply to root")
+ }
+ f := newUploadFixture(t, false)
+ writeCountryDB(t, f.dataPath, 1_750_000_000)
+ require.NoError(t, os.Chmod(f.dataDir, 0o500)) //nolint:gosec // G302: directory needs the exec bit; read-only dir fixture
+ t.Cleanup(func() { _ = os.Chmod(f.dataDir, 0o755) }) //nolint:gosec // G302: the test tree must be removable
+
+ err := f.store.RemoveDataCopy(context.Background())
+
+ assert.ErrorIs(t, err, ErrGeoIPDataFolderReadOnly)
+ assert.FileExists(t, f.dataPath)
+ assert.Equal(t, 0, f.applyCalls)
+ })
+}
+
+func TestSweepGeoIPDataFolder_ReportsFilesThatCannotBeChanged(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("permission checks do not apply to root")
+ }
+ t.Run("an interrupted upload cannot be removed", func(t *testing.T) {
+ dir := t.TempDir()
+ live := filepath.Join(dir, geoip.DatabaseFileName)
+ placeFile(t, filepath.Join(dir, uploadTempPrefix+"1"), []byte("partial"))
+ require.NoError(t, os.Chmod(dir, 0o500)) //nolint:gosec // G302: directory needs the exec bit; read-only dir fixture
+ t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) //nolint:gosec // G302: the test tree must be removable
+
+ require.Error(t, SweepGeoIPDataFolder(live))
+ })
+ t.Run("a previous copy cannot be moved back", func(t *testing.T) {
+ dir := t.TempDir()
+ live := filepath.Join(dir, geoip.DatabaseFileName)
+ placeFile(t, live+prevSuffix, []byte("previous"))
+ require.NoError(t, os.Chmod(dir, 0o500)) //nolint:gosec // G302: directory needs the exec bit; read-only dir fixture
+ t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) //nolint:gosec // G302: the test tree must be removable
+
+ require.Error(t, SweepGeoIPDataFolder(live))
+ })
+ t.Run("a previous copy beside a live copy cannot be removed", func(t *testing.T) {
+ dir := t.TempDir()
+ live := filepath.Join(dir, geoip.DatabaseFileName)
+ placeFile(t, live, []byte("live"))
+ placeFile(t, live+prevSuffix, []byte("previous"))
+ require.NoError(t, os.Chmod(dir, 0o500)) //nolint:gosec // G302: directory needs the exec bit; read-only dir fixture
+ t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) //nolint:gosec // G302: the test tree must be removable
+
+ require.Error(t, SweepGeoIPDataFolder(live))
+ })
+}
diff --git a/backend/internal/services/geoip_service.go b/backend/internal/services/geoip_service.go
index 3a60bbbf7..2b75aed11 100644
--- a/backend/internal/services/geoip_service.go
+++ b/backend/internal/services/geoip_service.go
@@ -7,6 +7,7 @@ import (
"net"
"net/netip"
"sync"
+ "time"
"github.com/oschwald/geoip2-golang/v2"
@@ -30,6 +31,25 @@ type GeoIPService struct {
// resolve, when set, supplies the database path at each Load, so a reload finds a file placed at a location that
// was not in use at startup. It is fixed at construction and never changes.
resolve func() string
+ // Metadata of the database in use, recorded by LoadFrom and cleared by Close.
+ loadedAt time.Time
+ databaseType string
+ buildEpoch uint
+ // now supplies the load time. Nil means the wall clock; tests set it.
+ now func() time.Time
+}
+
+// GeoIPInfo describes the database a GeoIPService serves lookups from.
+type GeoIPInfo struct {
+ // Path is the configured database path. It is set before any load.
+ Path string
+ // Loaded reports whether a database is open.
+ Loaded bool
+ // LoadedAt is the time of the load that opened the database in use; zero when none is open.
+ LoadedAt time.Time
+ // DatabaseType and BuildEpoch come from the metadata of the database in use.
+ DatabaseType string
+ BuildEpoch uint
}
type geoIPCountryReader interface {
@@ -75,6 +95,8 @@ func (s *GeoIPService) LoadFrom(path string) error {
if err != nil {
return fmt.Errorf("open geoip database %s: %w", path, err)
}
+ meta := db.Metadata()
+ loadedAt := s.clock()
s.mu.Lock()
defer s.mu.Unlock()
@@ -84,9 +106,33 @@ func (s *GeoIPService) LoadFrom(path string) error {
}
s.db = db
s.dbPath = path
+ s.loadedAt = loadedAt
+ s.databaseType = meta.DatabaseType
+ s.buildEpoch = meta.BuildEpoch
return nil
}
+// clock returns the current time from the injected clock, or the wall clock when none is set.
+func (s *GeoIPService) clock() time.Time {
+ if s.now != nil {
+ return s.now()
+ }
+ return time.Now()
+}
+
+// Info returns the metadata of the database in use and the configured path. A failed load leaves it unchanged.
+func (s *GeoIPService) Info() GeoIPInfo {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return GeoIPInfo{
+ Path: s.dbPath,
+ Loaded: s.db != nil,
+ LoadedAt: s.loadedAt,
+ DatabaseType: s.databaseType,
+ BuildEpoch: s.buildEpoch,
+ }
+}
+
// pathToLoad returns the database path Load opens: the resolver's answer when one is set, else the stored path.
func (s *GeoIPService) pathToLoad() string {
if s.resolve != nil {
@@ -99,6 +145,9 @@ func (s *GeoIPService) pathToLoad() string {
func (s *GeoIPService) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
+ s.loadedAt = time.Time{}
+ s.databaseType = ""
+ s.buildEpoch = 0
if s.db != nil {
err := s.db.Close()
s.db = nil
diff --git a/backend/internal/services/geoip_service_available_test.go b/backend/internal/services/geoip_service_available_test.go
index f12188028..4504c04c1 100644
--- a/backend/internal/services/geoip_service_available_test.go
+++ b/backend/internal/services/geoip_service_available_test.go
@@ -43,13 +43,13 @@ func TestGeoIPServiceAvailable(t *testing.T) {
})
}
-func TestEvaluateWithoutGeoIPServiceRefusesCountryList(t *testing.T) {
+func TestEvaluateWithoutGeoIPServiceLeavesCountryListUnenforced(t *testing.T) {
svc := NewAccessListService(nil)
svc.SetGeoIPService(nil)
d, err := svc.Evaluate(&models.AccessList{Type: "geo_blacklist", CountryCodes: "RU", Enabled: true}, netip.MustParseAddr(testVisitor), nil)
require.NoError(t, err)
- assert.False(t, d.Allowed)
+ assert.True(t, d.Allowed)
assert.Equal(t, ReasonGeoIPUnavailable, d.Reason)
assert.False(t, d.GeoIPAvailable)
}
diff --git a/backend/internal/services/geoip_service_info_test.go b/backend/internal/services/geoip_service_info_test.go
new file mode 100644
index 000000000..daf513f68
--- /dev/null
+++ b/backend/internal/services/geoip_service_info_test.go
@@ -0,0 +1,93 @@
+package services
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wikid82/charon/backend/internal/testutil"
+)
+
+// fixedClock returns a clock that always reports at.
+func fixedClock(at time.Time) func() time.Time {
+ return func() time.Time { return at }
+}
+
+func TestGeoIPServiceInfo_BeforeLoadReportsPathOnly(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ svc := NewUnloadedGeoIPService(path)
+
+ info := svc.Info()
+
+ assert.Equal(t, path, info.Path)
+ assert.False(t, info.Loaded)
+ assert.True(t, info.LoadedAt.IsZero())
+ assert.Empty(t, info.DatabaseType)
+ assert.Zero(t, info.BuildEpoch)
+}
+
+func TestGeoIPServiceInfo_LoadRecordsMetadataAndClock(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ testutil.WriteGeoIPCountryDB(t, path, 1_700_000_000, testutil.WithGeoIPDatabaseType("GeoLite2-Country"))
+ at := time.Date(2026, 11, 10, 5, 0, 1, 0, time.UTC)
+
+ svc := NewUnloadedGeoIPService(path)
+ svc.now = fixedClock(at)
+ require.NoError(t, svc.Load())
+
+ info := svc.Info()
+ assert.Equal(t, path, info.Path)
+ assert.True(t, info.Loaded)
+ assert.Equal(t, at, info.LoadedAt)
+ assert.Equal(t, "GeoLite2-Country", info.DatabaseType)
+ assert.Equal(t, uint(1_700_000_000), info.BuildEpoch)
+}
+
+func TestGeoIPServiceInfo_FailedLoadKeepsDatabaseInUse(t *testing.T) {
+ dir := t.TempDir()
+ good := filepath.Join(dir, "good.mmdb")
+ testutil.WriteGeoIPCountryDB(t, good, 1_700_000_000, testutil.WithGeoIPDatabaseType("GeoLite2-Country"))
+ loadedAt := time.Date(2026, 11, 10, 5, 0, 0, 0, time.UTC)
+
+ svc := NewUnloadedGeoIPService(good)
+ svc.now = fixedClock(loadedAt)
+ require.NoError(t, svc.LoadFrom(good))
+ before := svc.Info()
+
+ badPath := filepath.Join(dir, "bad.mmdb")
+ require.NoError(t, os.WriteFile(badPath, []byte("not a database"), 0o600))
+ svc.now = fixedClock(loadedAt.Add(time.Hour))
+ require.Error(t, svc.LoadFrom(badPath))
+
+ assert.Equal(t, before, svc.Info(), "a failed load must not replace the reported metadata")
+}
+
+func TestGeoIPServiceInfo_CloseClearsMetadata(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ testutil.WriteGeoIPCountryDB(t, path, 1_700_000_000)
+
+ svc := NewUnloadedGeoIPService(path)
+ require.NoError(t, svc.Load())
+ require.NoError(t, svc.Close())
+
+ info := svc.Info()
+ assert.False(t, info.Loaded)
+ assert.True(t, info.LoadedAt.IsZero())
+ assert.Empty(t, info.DatabaseType)
+ assert.Zero(t, info.BuildEpoch)
+}
+
+func TestGeoIPServiceInfo_ZeroValueUsesWallClock(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ testutil.WriteGeoIPCountryDB(t, path, 1)
+
+ svc := &GeoIPService{dbPath: path}
+ before := time.Now()
+ require.NoError(t, svc.Load())
+
+ assert.False(t, svc.Info().LoadedAt.Before(before))
+}
diff --git a/backend/internal/testutil/geoip_country_db.go b/backend/internal/testutil/geoip_country_db.go
index 214f2ea1b..bbf3a085a 100644
--- a/backend/internal/testutil/geoip_country_db.go
+++ b/backend/internal/testutil/geoip_country_db.go
@@ -3,18 +3,52 @@ package testutil
import (
"bytes"
"os"
+ "sort"
"testing"
)
// mmdbMetadataMarker opens the metadata section of a MaxMind DB file.
var mmdbMetadataMarker = []byte("\xAB\xCD\xEFMaxMind.com")
-// WriteGeoIPCountryDB writes a minimal, valid MaxMind DB file to path. Its metadata carries buildEpoch and the search
-// tree is empty, so it opens and reports metadata but resolves no addresses to a country.
-func WriteGeoIPCountryDB(t testing.TB, path string, buildEpoch uint32) {
+// GeoIPCountryOption adjusts the database that WriteGeoIPCountryDB writes.
+type GeoIPCountryOption func(*geoIPCountryConfig)
+
+type geoIPCountryConfig struct {
+ databaseType string
+ // record is the data record every address resolves to. Nil means an empty search tree.
+ record map[string]any
+}
+
+// WithGeoIPDatabaseType sets the database_type metadata. The default is GeoIP2-Country.
+func WithGeoIPDatabaseType(databaseType string) GeoIPCountryOption {
+ return func(c *geoIPCountryConfig) { c.databaseType = databaseType }
+}
+
+// WithGeoIPRecord sets the data record that every address resolves to. The record is a tree of maps whose leaves are
+// strings, for example {"country": {"iso_code": "US"}}. Use it to write records of any shape, including records that
+// lack country.iso_code.
+func WithGeoIPRecord(record map[string]any) GeoIPCountryOption {
+ return func(c *geoIPCountryConfig) { c.record = record }
+}
+
+// WithGeoIPRecordISOCode sets the data record to {"country": {"iso_code": code}}, so every address resolves to that
+// country. Without a record option the search tree is empty and no address resolves to a country.
+func WithGeoIPRecordISOCode(code string) GeoIPCountryOption {
+ return WithGeoIPRecord(map[string]any{"country": map[string]any{"iso_code": code}})
+}
+
+// WriteGeoIPCountryDB writes a minimal, valid MaxMind DB file to path. Its metadata carries buildEpoch. By default the
+// search tree is empty, so it opens and reports metadata but resolves no addresses to a country; see
+// WithGeoIPRecordISOCode for a database that resolves every address.
+func WriteGeoIPCountryDB(t testing.TB, path string, buildEpoch uint32, opts ...GeoIPCountryOption) {
t.Helper()
- meta := []byte{0xE0 | 8} // map with eight entries
+ cfg := geoIPCountryConfig{databaseType: "GeoIP2-Country"}
+ for _, opt := range opts {
+ opt(&cfg)
+ }
+
+ meta := []byte{0xE0 | 9} // map with nine entries
meta = appendMMDBString(meta, "binary_format_major_version")
meta = append(meta, mmdbUint(5, 2)...) // uint16
meta = appendMMDBString(meta, "binary_format_minor_version")
@@ -22,7 +56,9 @@ func WriteGeoIPCountryDB(t testing.TB, path string, buildEpoch uint32) {
meta = appendMMDBString(meta, "build_epoch")
meta = append(meta, mmdbUint(6, uint64(buildEpoch))...) // uint32
meta = appendMMDBString(meta, "database_type")
- meta = appendMMDBString(meta, "GeoIP2-Country")
+ meta = appendMMDBString(meta, cfg.databaseType)
+ meta = appendMMDBString(meta, "description")
+ meta = appendMMDBValue(meta, map[string]any{"en": "Charon test fixture"})
meta = appendMMDBString(meta, "ip_version")
meta = append(meta, mmdbUint(5, 6)...)
meta = appendMMDBString(meta, "languages")
@@ -32,9 +68,18 @@ func WriteGeoIPCountryDB(t testing.TB, path string, buildEpoch uint32) {
meta = appendMMDBString(meta, "record_size")
meta = append(meta, mmdbUint(5, 24)...)
- // One node with 24-bit records is six bytes; the 16-byte zero separator follows it.
+ // One node with 24-bit records is six bytes; the 16-byte zero separator follows it. With a record option both
+ // records point at data offset zero, which is the node count (1) plus the separator length (16).
var buf bytes.Buffer
- buf.Write(make([]byte, 6+16))
+ if cfg.record == nil {
+ buf.Write(make([]byte, 6))
+ } else {
+ buf.Write([]byte{0x00, 0x00, 0x11, 0x00, 0x00, 0x11})
+ }
+ buf.Write(make([]byte, 16))
+ if cfg.record != nil {
+ buf.Write(appendMMDBValue(nil, cfg.record))
+ }
buf.Write(mmdbMetadataMarker)
buf.Write(meta)
@@ -43,6 +88,32 @@ func WriteGeoIPCountryDB(t testing.TB, path string, buildEpoch uint32) {
}
}
+// appendMMDBValue encodes a tree of maps whose leaves are strings as an MMDB value. Keys are written in sorted order so
+// the output is deterministic. Any other value type is a mistake in the test and panics.
+func appendMMDBValue(dst []byte, v any) []byte {
+ switch value := v.(type) {
+ case string:
+ return appendMMDBString(dst, value)
+ case map[string]any:
+ if len(value) > 28 {
+ panic("testutil: MMDB map too large for the fixture encoder")
+ }
+ keys := make([]string, 0, len(value))
+ for k := range value {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ dst = append(dst, 0xE0|byte(len(value))) //nolint:gosec // G115: length bounded to 28 above
+ for _, k := range keys {
+ dst = appendMMDBString(dst, k)
+ dst = appendMMDBValue(dst, value[k])
+ }
+ return dst
+ default:
+ panic("testutil: unsupported MMDB value for the fixture encoder")
+ }
+}
+
// appendMMDBString appends s as an MMDB UTF-8 string. The size must fit the control byte, so strings of 28 bytes or
// more are rejected; every string the fixture writes is far shorter.
func appendMMDBString(dst []byte, s string) []byte {
diff --git a/backend/internal/testutil/geoip_country_db_test.go b/backend/internal/testutil/geoip_country_db_test.go
index ce88eef26..232e97bb0 100644
--- a/backend/internal/testutil/geoip_country_db_test.go
+++ b/backend/internal/testutil/geoip_country_db_test.go
@@ -3,6 +3,7 @@ package testutil
import (
"fmt"
"math"
+ "net/netip"
"path/filepath"
"strings"
"testing"
@@ -101,3 +102,42 @@ func TestMMDBUintEncodesFewestBytes(t *testing.T) {
})
}
}
+
+func TestWriteGeoIPCountryDBDatabaseTypeOption(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-ASN.mmdb")
+ WriteGeoIPCountryDB(t, path, 1, WithGeoIPDatabaseType("GeoLite2-ASN"))
+
+ db, err := geoip2.Open(path)
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ assert.Equal(t, "GeoLite2-ASN", db.Metadata().DatabaseType)
+}
+
+func TestWriteGeoIPCountryDBRecordOptionResolvesCountry(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ WriteGeoIPCountryDB(t, path, 1, WithGeoIPRecordISOCode("DE"))
+
+ db, err := geoip2.Open(path)
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ record, err := db.Country(netip.MustParseAddr("192.0.2.1"))
+ require.NoError(t, err)
+ assert.Equal(t, "DE", record.Country.ISOCode)
+}
+
+func TestWriteGeoIPCountryDB_RefusesRecordsTheEncoderCannotWrite(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "GeoLite2-Country.mmdb")
+ tooLarge := make(map[string]any, 29)
+ for i := range 29 {
+ tooLarge[fmt.Sprintf("k%02d", i)] = "v"
+ }
+
+ assert.PanicsWithValue(t, "testutil: MMDB map too large for the fixture encoder", func() {
+ WriteGeoIPCountryDB(t, path, 1_700_000_000, WithGeoIPRecord(tooLarge))
+ })
+ assert.PanicsWithValue(t, "testutil: unsupported MMDB value for the fixture encoder", func() {
+ WriteGeoIPCountryDB(t, path, 1_700_000_000, WithGeoIPRecord(map[string]any{"count": 1}))
+ })
+}
diff --git a/docs-site/zensical.toml b/docs-site/zensical.toml
index b03193ad4..16d1e28f3 100644
--- a/docs-site/zensical.toml
+++ b/docs-site/zensical.toml
@@ -49,6 +49,7 @@ nav = [
"troubleshooting/proxy-headers.md",
"troubleshooting/websocket.md",
"troubleshooting/security-module-applied.md",
+ "troubleshooting/geoip-database.md",
"troubleshooting/react-production-errors.md",
"troubleshooting/e2e-tests.md",
"troubleshooting/go-gopls.md",
diff --git a/docs/api.md b/docs/api.md
index ce47642dc..9d02b8d46 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -319,6 +319,22 @@ Response 200: `{ "deleted": true }`
---
+#### GeoIP Database (Country Lists)
+
+Used by the Security page's Country database (GeoIP) card. All paths are under `/api/v1`.
+
+| Method and path | Who | What it does |
+| --- | --- | --- |
+| `GET /security/geoip/status` | Management users | Source, status, build date, size, and whether the database can be replaced. |
+| `POST /security/geoip/reload` | Admin | Reloads the database file and applies the proxy configuration. |
+| `POST /security/geoip/lookup` | Admin | Body `{"ip_address": "..."}`. Returns the country for an address. |
+| `POST /security/geoip/database` | Admin | Replaces the uploaded copy. Multipart form with one part named `file` (a `.mmdb` file, 32 MiB at most). |
+| `DELETE /security/geoip/database` | Admin | Removes the uploaded copy and goes back to the included copy. |
+| `POST /access-lists/self-check` | Admin | Checks an unsaved country list draft against the address you are using now. Nothing is saved. |
+| `GET /notifications/alerts` | Management users | Lists active alerts, such as country lists that are not enforced. |
+
+Errors carry a fixed `error` message and an `error_code`, such as `geoip_too_large`, `geoip_wrong_type` or `geoip_busy`. Replacing or removing the database from a browser session is refused with `cross_origin_refused` unless the request comes from Charon's own page. Every change is written to the audit log.
+
### Application URL Endpoints
#### Validate Application URL
diff --git a/docs/cerberus.md b/docs/cerberus.md
index d919371e8..612eaf6d1 100644
--- a/docs/cerberus.md
+++ b/docs/cerberus.md
@@ -269,10 +269,11 @@ Multiple ACLs can be assigned to a proxy host. The first denial wins.
Country lists (Geo Whitelist and Geo Blacklist) need the MaxMind GeoLite2-Country database. The proxy reads it to find the country of each visitor.
- **Where it comes from:** The Docker image tries to download the database when it is built and keeps it outside the data folder, in `/app/geoip/`, so a data volume does not hide it. If the download fails, the image is still built without it.
-- **Using a newer copy:** Put your own `GeoLite2-Country.mmdb` in the `geoip` folder inside your data folder (`/app/data/geoip/` in the container). When both that copy and the image's copy are present, Charon uses the one with the newer MaxMind build date. If the data-folder copy cannot be read, the image's copy is used. Then restart Charon or reload the database (see below).
-- **Custom location:** Set `CHARON_GEOIP_DB_PATH` to the full path of the `.mmdb` file. Charon uses that path exactly, and it wins over both copies above. The file name must end in `.mmdb`. A file with any other ending is treated as unavailable. If the variable is set to `/app/data/geoip/GeoLite2-Country.mmdb` and no file is there (an old setting copied from an earlier image), Charon ignores the variable and uses the image's copy.
-- **When the database is missing:** Country lists refuse every request until a database is provided, except requests from admin whitelist addresses. 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.
+- **Using a newer copy:** Admins can use **Replace database…** on the Security page's Country database (GeoIP) card, or put a `GeoLite2-Country.mmdb` file in the `geoip` folder inside your data folder (`/app/data/geoip/` in the container). When both that copy and the image's copy are present, Charon uses the one with the newer build date. If the data-folder copy cannot be read, the image's copy is used.
+- **Source values:** The card names the map in use as one of four sources. **Included with Charon** is the copy in the image. **Your data folder** is a copy you uploaded or placed yourself. **Custom path set on the server** means `CHARON_GEOIP_DB_PATH` is in use. **No database** means none could be found.
+- **Custom location:** Set `CHARON_GEOIP_DB_PATH` to the full path of the `.mmdb` file. Charon uses that path exactly, and it wins over both copies above. The file name must end in `.mmdb`. A file with any other ending is treated as unavailable. If the variable is set to `/app/data/geoip/GeoLite2-Country.mmdb` and no file is there (an old setting copied from an earlier image), Charon ignores the variable and uses the image's copy. While the variable is in use, the card cannot replace or remove the file; **Reload** still works.
+- **When the database is missing:** Country lists are not enforced. An allow-list lets everyone in, and a block-list blocks no one. A red alert appears in the notification bell (in the app only) until a database is available. Your other lists and sites are not affected. See [Country Access Lists Are Not Being Enforced](troubleshooting/geoip-database.md).
+- **After adding or replacing the file:** Press **Reload** on the card, or restart Charon. 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.md b/docs/features.md
index b882b5a31..780eec3a5 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -133,6 +133,8 @@ No SSH required. No CLI commands. Just open the Dashboard tab and see what's hap
Define exactly who can access what. Block specific countries, allow only certain IP ranges, or require authentication for sensitive applications. Fine-grained rules give you complete control.
+- **Country database card:** Admins can check, reload, or replace the country map from the Security page, without using a terminal.
+
→ [Learn More](features/access-control.md)
---
diff --git a/docs/features/access-control.md b/docs/features/access-control.md
index b1e0cc07d..22c2708d9 100644
--- a/docs/features/access-control.md
+++ b/docs/features/access-control.md
@@ -62,13 +62,56 @@ 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, except requests from addresses on your admin whitelist. To use a newer copy:
+**Country lists need a country map (the GeoIP database).** Charon uses this map to find out which country each visitor is from. Charon includes one in its Docker image, so you normally do not need to do anything. If the map is ever missing or cannot be read, country lists are **not enforced**: an allow-list lets everyone in, and a block-list blocks no one. Your other lists and sites keep working. Charon shows a red alert in the notification bell (in the app only) until the map is available again. See [Country Access Lists Are Not Being Enforced](../troubleshooting/geoip-database.md).
+
+#### The Country Database Card
+
+Admins manage the map on the **Security** page, in the card called **Country database (GeoIP)**. Other users do not see this card.
+
+- **Status:** **Ready** means Charon has a map it can use. **Not available** means no usable map is loaded.
+- **Source:** where the map in use comes from. It says **Included with Charon**, **Your data folder** (a copy you uploaded), **Custom path set on the server**, or **No database**.
+- **Build date** and **Size:** show how new the map is and how large the file is.
+- **File:** the location of the file inside Charon.
+- **Country lists not enforced:** appears only when at least one of your lists is affected.
+
+The card has these buttons:
+
+- **Reload:** Charon reads the map file again and applies your access lists. Use it after you change the file, instead of restarting Charon.
+- **Replace database…:** choose a new file from your computer. Charon accepts a file only when all of these are true:
+ - it ends in `.mmdb` and is no larger than 32 MiB;
+ - it is a **country** database, such as GeoLite2-Country or DB-IP's country file (city and other types are refused);
+ - it is as new as, or newer than, the map Charon uses now and the one included with Charon;
+ - its build date is not in the future.
+
+ If a file is refused, the card tells you why, and Charon keeps the map it had before.
+- **Use the included copy:** removes your uploaded copy and goes back to the map that comes with Charon. Charon asks you to confirm first. If Charon has no included map, country lists stay unenforced until you provide one.
+
+Results appear as a short message inside the card. **Replace database…** and **Use the included copy** are hidden when the server's map location is set by the `CHARON_GEOIP_DB_PATH` setting, or when the data folder cannot be written to. **Reload** still works in both cases.
+
+#### Look Up an Address
+
+Under the buttons, type an IP address (for example `203.0.113.7`) and press **Look up** to see which country it belongs to. Press **Use my address** to fill in the address you are browsing from. The lookup needs a loaded map. Without one, the lookup box is greyed out.
+
+#### The Note Under a Country List
+
+When you edit a country list as an admin, Charon shows a small grey note under the country fields. The note never stops you from saving.
+
+- **"This list will not be enforced until the GeoIP database is available."** The map is missing, so this list does nothing yet.
+- **"Based on the address you are using now, this list would refuse you. Saving is still allowed."** Charon checked the address you are using now against the list. Check the countries before you save, or add your address to the admin whitelist.
+
+#### Private and Home-Network Addresses Have No Country
+
+Addresses such as `192.168.1.20` or `10.0.0.5` belong to your local network, not to any country. A country allow-list therefore refuses them, unless the address is on your admin whitelist. A country block-list does not refuse them. The lookup says "No country found" for these addresses.
+
+#### Adding a Map by Hand (Advanced)
+
+If you prefer not to use the card, you can place the file yourself:
1. Get the MaxMind GeoLite2-Country database file (it ends in `.mmdb`) and name it `GeoLite2-Country.mmdb`.
-2. Put it in the `geoip` folder inside your data folder. In the container that is `/app/data/geoip/`, so on the host it is the `geoip` folder inside the directory you mount as `/app/data`. When the image also has a copy, Charon uses whichever has the newer MaxMind build date. Alternatively, set the `CHARON_GEOIP_DB_PATH` environment variable to its full path, which takes precedence over both locations.
-3. Restart Charon, or send `POST /api/v1/security/geoip/reload`. Charon then applies your country lists again.
+2. Put it in the `geoip` folder inside your data folder. In the container that is `/app/data/geoip/`. When the image also has a copy, Charon uses whichever has the newer build date. You can also set the `CHARON_GEOIP_DB_PATH` setting to the file's full path, which takes precedence over both locations.
+3. Press **Reload** on the Security page, or restart Charon. 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.
+For where to get a map and how to fix a missing one, see [Country Access Lists Are Not Being Enforced](../troubleshooting/geoip-database.md).
### Entries Required for Allow Lists
diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md
index 4ae8688e7..db9505089 100644
--- a/docs/reports/qa_report.md
+++ b/docs/reports/qa_report.md
@@ -1,317 +1,141 @@
-# QA Report: Admin Whitelist and Access List Matching
-# QA Report: Admin Whitelist and Access List Matching
-
-- Branch: `fix/admin-exclusion-matchers` (9 commits on `development`, `5e7fa0bc7`..`cd75c2e87`)
-- Scope: deny routes for blocked addresses, IP block-lists, country lists and local-network lists are built as one
- matcher set that excludes the admin whitelist; country and local-network lists honour the admin whitelist (also
- while no GeoIP database is available); the access list tester reports the proxy's decision; GeoIP reload errors
- return a stable `error_code` and a fixed message; documentation for the admin whitelist.
-- Image under test: `charon:local` from `docker-rebuild-e2e` on the branch head.
-- Branch: `fix/admin-exclusion-matchers` (9 commits on `development`, `5e7fa0bc7`..`cd75c2e87`)
-- Scope: deny routes for blocked addresses, IP block-lists, country lists and local-network lists are built as one
- matcher set that excludes the admin whitelist; country and local-network lists honour the admin whitelist (also
- while no GeoIP database is available); the access list tester reports the proxy's decision; GeoIP reload errors
- return a stable `error_code` and a fixed message; documentation for the admin whitelist.
-- Image under test: `charon:local` from `docker-rebuild-e2e` on the branch head.
+# QA Report: GeoIP Database Card and Unenforced Country List Alert
+
+- Branch: `feature/geoip-database-card` (13 commits on `development`, up to `fae16ffec`, plus the uncommitted QA
+ changes listed below)
+- Spec: `docs/plans/geoip_database_card_spec.md` (feature PR, commit C14)
+- Scope: notification bell alerts (`GET /notifications/alerts`), country lists left unenforced (with an alert) while
+ no GeoIP database is usable, GeoIP database status with source, date and size, replacing and removing the
+ data-folder copy (`POST`/`DELETE /security/geoip/database`) behind `RequireSameOriginForBrowserAuth`, the access
+ list self-check (`POST /access-lists/self-check`), the Security page card, the access list form note, 28 locales,
+ and docs.
+- Image under test: `charon:local` from `docker-rebuild-e2e` on the working tree described above.
- Date: 2026-11-10
## Verdict
-All gates pass. Local patch coverage is 100% (391 of 391 changed lines) after the coverage fixes made in response to the first QA pass.
+**Pass.** Every Definition of Done gate passes. Local patch coverage is 100% (983 of 983 changed lines). The QA pass
+added tests, two small test seams, and one small hardening of build-date handling; nothing else in the product was
+changed. No CRITICAL or HIGH findings in the code of this branch.
-## Static checks and build
+## Changes made during QA
-| Check | Result |
+| Area | Change |
| --- | --- |
-| `make lint-backend` (golangci-lint, full config) | 0 issues |
-| `make lint-fast` (backend + agent) | 0 issues |
-| `go build ./...` | pass |
-| `go list -deps ./cmd/api \| grep -c caddymatchtest` | `0` (test helper not linked into the binary) |
-| `lefthook run pre-commit` on the branch files (forced) | all hooks pass, including semgrep; `shellcheck` exits with "No files specified" because the branch changes no `.sh` files (artefact of forcing the hook, not a finding) |
-| Frontend | untouched (`git diff origin/development..HEAD -- frontend/` is empty) |
-| Models | untouched; GORM scan not required |
-| `npm run lint:e2e` | 0 errors, 124 warnings (limit 124) |
-| `npx tsc --noEmit -p tests/tsconfig.json` | pass |
+| `services/geoip_database_upload.go`, `geoip_database_store.go` | File operations of an upload or removal (create temp file, rename, folder sync) go through a `geoipFileOps` value on the store, defaulting to the OS calls. Behaviour is unchanged; tests replace single operations to reach the write, sync, close, rename and folder-sync failure branches. |
+| `services/geoip_database_upload_fileops_test.go` (new) | Failure-branch tests for the seam above: each failure reports a write failure, leaves no temp file, restores the previous copy (or removes the new one), and applies nothing. |
+| `geoip/inspect.go` | New `BuildTime(epoch)` that clamps a build epoch to the end of year 9999 before converting it to a time. Used by the build-date check and both date formatters. See the finding below. |
+| `api/routes/geoip_marker.go`, `routes.go` | The startup sweep of the data folder moved into `sweepGeoIPDataFolder`, so its failure log is testable. Same behaviour. |
+| Tests | Lookup handler answers and admin checks (`security_geoip_lookup_test.go`), `EnvPathInEffect`, fixture encoder refusals, `BuildTime`, the sweep helper, the GeoIP API client functions, and the locale key helper. |
+| `pages/__tests__/Login.overlay.audit.test.tsx` | Made deterministic: the mocked login request now stays pending until the overlay has been asserted. It failed once under CPU load because a 100 ms timer finished the request before the click returned. Not related to this branch; fixed per the DoD. |
+| Lint | `make lint-backend` reported 45 gosec findings, all in files of this branch (44 in tests). Test fixtures now use `0o750` folders or carry the repo's usual justified `nolint` comments; the remaining production one (data folder `MkdirAll` at `0o755`) carries a justified `nolint`. |
-## Backend tests
+## Static checks and build
-| Run | Result |
+| Check | Result |
| --- | --- |
-| `go test -race` scoped to access list, GeoIP, evaluate and reload tests in `services` and `handlers` | pass |
-| `go test -race` for `caddy`, `caddy/caddymatchtest`, `api/routes`, `cerberus` | pass |
-| Full backend with `-race` via `scripts/go-test-coverage.sh` (30 min per-package timeout) | pass, no failures |
-
-A plain `go test -race` of `services` and `handlers` with Go's default 10 minute timeout stops on the timeout
-(the slowest packages take 10-11 minutes under `-race`); the coverage script sets a 30 minute timeout for this reason.
-Not related to this branch.
+| `make lint-backend` (golangci-lint v2.15.0, full config) | 0 issues (after the fixes above) |
+| `lefthook run pre-commit --all-files` | all 20 hooks pass, including semgrep, golangci-lint-fast, frontend lint/type-check, locale lint, e2e lint (0 errors, 124 warnings at the limit) |
+| `cd frontend && npm run lint` | pass, 0 warnings |
+| `node node_modules/typescript/bin/tsc6 --noEmit` | pass |
+| `cd frontend && npm run build` | pass |
+| `cd backend && go build ./...`, `go vet` on touched packages | pass |
+| `./scripts/scan-gorm-security.sh --check` | 0 CRITICAL, 0 HIGH, 0 MEDIUM, 2 INFO (pre-existing index suggestions) |
## Coverage
-| Measure | Value |
-| --- | --- |
-| Backend statement coverage (full run) | 93.4% |
-| Backend line coverage gate | 92.2% (minimum 87%) |
-| Local patch coverage (`scripts/local-patch-report.sh`, strict) | 100% (391 of 391 changed lines) |
-
-Uncovered changed lines:
-
-| File | Lines | Code |
-| --- | --- | --- |
-| `backend/internal/caddy/config.go` | 1104-1107 | `denyMatcherSet`: merging an existing `"not"` list from `positive` (no caller passes one) |
-| `backend/internal/services/access_list_service.go` | 208-209 | `TestIP`: `netip.AddrFromSlice` failure after a successful `net.ParseIP` |
-| `backend/internal/services/access_list_evaluate.go` | 193 | `evaluateIPRules`: `default` branch for an unknown list type |
-| `backend/internal/caddy/caddymatchtest/caddymatchtest.go` | 252-253, 256-257, 271-272, 276-277, 282-283, 291-292, 300-301, 309-310, 317-318, 321-322 | guard and `t.Fatalf` branches of the test helper (`isHostCatchAll`, `parsePrefix`, `statusCode`, `decodeJSON`, `asMap`, `listOf`) |
-
-Remediation:
-
-- `config.go` 1104-1107: add a unit test calling `denyMatcherSet` with `positive` that already holds a `"not"` list
- and assert the admin set comes first, followed by the existing sets, then `negated`.
-- `access_list_service.go` 208-209: `net.ParseIP` always returns a 4 or 16 byte slice, so this branch cannot run.
- Replace the pair with `netip.ParseAddr` (keeping today's error) or drop the branch.
-- `access_list_evaluate.go` 193: add an `Evaluate` case with an unknown `Type` and no `LocalNetworkOnly`, asserting
- allowed (matching `buildACLHandler`, which adds no handler for an unknown type).
-- `caddymatchtest.go`: either cover the guards with a `testing.TB` fake that records `Fatalf`, or add
- `backend/internal/caddy/caddymatchtest/**` to the `ignore` list in `codecov.yml` (precedent:
- `backend/internal/crowdsec/hubcachetest/**`). The package is test-only and is not linked into the binary.
-
-## E2E (Playwright, `--project=security-tests`, after the image rebuild)
-
-`tests/security-enforcement/acl-enforcement.spec.ts`, `tests/security-enforcement/security-headers-enforcement.spec.ts`,
-`tests/security/self-proxy-requests.spec.ts`, run twice back-to-back against the same container:
-
| Run | Result |
| --- | --- |
-| 1 | 24 passed (24.9 s) |
-| 2 | 24 passed (22.2 s) |
-
-No flaky or skipped tests.
-
-## Container gates
+| `scripts/go-test-coverage.sh` (full) | all packages pass; statements 93.6%, lines 92.6% (gate 87%) |
+| `scripts/frontend-test-coverage.sh` (full) | 344 files, 4466 tests pass (2 todo); statements 91.76%, lines 92.65% |
+| `bash scripts/local-patch-report.sh` | overall 100.0% (983/983), backend 100.0% (794/794), frontend 100.0% (189/189) |
-Throwaway container from the rebuilt image, a control client on a private bridge network, and client containers
-on separate bridge networks whose subnets are public ranges, so the proxy sees public addresses with known
-countries (bundled GeoLite2 Country database):
+The patch report compares committed `HEAD` with `origin/development`. The uncommitted QA additions were checked
+separately: the backend coverage profile has no uncovered block in any Go file they touch.
-| Client | Address | Country | Admin whitelist |
-## Container gates
-
-Throwaway container from the rebuilt image, a control client on a private bridge network, and client containers
-on separate bridge networks whose subnets are public ranges, so the proxy sees public addresses with known
-countries (bundled GeoLite2 Country database):
+## E2E (Playwright, `--project=security-tests`, after the image rebuild)
-| Client | Address | Country | Admin whitelist |
-| --- | --- | --- | --- |
-| usa | 216.160.83.56 | US | yes (`/32`) |
-| usb | 50.114.0.10 | US | no |
-| gbc | 81.2.69.142 | GB | no |
-| sed | 89.160.20.112 | SE | no (added for the database-missing case) |
+`npx playwright test tests/security/geoip-unenforced-alert.spec.ts tests/security/geoip-database-card.spec.ts
+tests/security-enforcement/acl-enforcement.spec.ts tests/security-enforcement/security-headers-enforcement.spec.ts
+tests/security/self-proxy-requests.spec.ts --project=security-tests`: **33 passed**, run twice (before and after the
+QA code changes, each after a fresh `docker-rebuild-e2e`).
-Admin whitelist: the control client and `216.160.83.56/32`. Cerberus and ACL on. Hosts forward to the app, so a pass
-is 200 and a refusal is 403.
+## Security scans
-| Host | List |
+| Scan | Result |
| --- | --- |
-| plain.qa | none |
-| block.qa | IP block-list `50.114.0.0/24` |
-| geoallow.qa | country allow-list `GB` |
-| geoblock.qa | country block-list `US` |
-| local.qa | local network only |
-
-### Database available
+| CodeQL Go + JS (`lefthook run codeql`, CLI 2.27.2) | 0 blocking. Go: 4 results, all pre-existing entries in `codeql-suppressions.yml`, none in files of this branch. JS: 0 results. Parity check passes. |
+| Trivy filesystem (`skill-runner.sh security-scan-trivy`) | No findings in the branch's dependency manifests. The skill exits non-zero on items outside the tracked tree: stale untracked `.claude/worktrees/*` copies of other branches, and an ignored, untracked CA key generated by an earlier test run under `backend/internal/api/routes/keys/` (not in git, not in the image). |
+| Trivy image (`charon:local`, HIGH/CRITICAL) | Alpine base and the Charon binary: 0. One HIGH each in the bundled Caddy and CrowdSec binaries; both CVEs are already accepted in `.trivyignore` and `.grype.yaml`. The branch does not change the Dockerfile. |
-| Host | usa | usb | gbc |
-| usa | 216.160.83.56 | US | yes (`/32`) |
-| usb | 50.114.0.10 | US | no |
-| gbc | 81.2.69.142 | GB | no |
-| sed | 89.160.20.112 | SE | no (added for the database-missing case) |
-
-Admin whitelist: the control client and `216.160.83.56/32`. Cerberus and ACL on. Hosts forward to the app, so a pass
-is 200 and a refusal is 403.
+## Security review
-| Host | List |
+| Area | Result |
| --- | --- |
-| plain.qa | none |
-| block.qa | IP block-list `50.114.0.0/24` |
-| geoallow.qa | country allow-list `GB` |
-| geoblock.qa | country block-list `US` |
-| local.qa | local network only |
-
-### Database available
-
-| Host | usa | usb | gbc |
-| --- | --- | --- | --- |
-| plain.qa | 200 | 200 | 200 |
-| block.qa | 200 | 403 `Access denied: IP blacklisted` | 200 |
-| geoallow.qa | 200 | 403 `Access denied: Geographic restriction` | 200 |
-| geoblock.qa | 200 | 403 `Access denied: Geographic restriction` | 200 |
-| local.qa | 200 | 403 `Access denied: Not a local network IP` | 403 |
-
-- G-A0 IP block-list: only the listed subnet's client is refused; the other clients pass.
-- G-A1 country allow-list (GB): the whitelisted US client passes, the non-whitelisted US client is refused, GB passes.
- Country block-list (US): the same pattern.
-- G-A1 local network only: the whitelisted public client passes; the other public clients are refused.
-- The live Caddy config shows one matcher set per deny route, with the admin ranges as the first entry of `not`.
-
-### Security decision
-
-A manual `block` decision for `81.2.69.142`, after the next config apply:
-| plain.qa | 200 | 200 | 200 |
-| block.qa | 200 | 403 `Access denied: IP blacklisted` | 200 |
-| geoallow.qa | 200 | 403 `Access denied: Geographic restriction` | 200 |
-| geoblock.qa | 200 | 403 `Access denied: Geographic restriction` | 200 |
-| local.qa | 200 | 403 `Access denied: Not a local network IP` | 403 |
-
-- G-A0 IP block-list: only the listed subnet's client is refused; the other clients pass.
-- G-A1 country allow-list (GB): the whitelisted US client passes, the non-whitelisted US client is refused, GB passes.
- Country block-list (US): the same pattern.
-- G-A1 local network only: the whitelisted public client passes; the other public clients are refused.
-- The live Caddy config shows one matcher set per deny route, with the admin ranges as the first entry of `not`.
-
-### Security decision
-
-A manual `block` decision for `81.2.69.142`, after the next config apply:
-
-| Host | usa | usb | gbc |
-| Host | usa | usb | gbc |
-| --- | --- | --- | --- |
-| plain.qa | 200 | 200 | 403 `Access denied: Blocked by security decision` |
-| every listed host | as above | as above | 403 |
-
-Only the decided address is refused, on every host; the other clients keep their list outcomes. A `block` decision
-for the admin-whitelisted `216.160.83.56` has no effect (documented behaviour).
-
-### Database removed
-
-`/app/geoip/GeoLite2-Country.mmdb` removed (only the `.placeholder` left, `/app/data/geoip` empty), container
-restarted. `GET /security/geoip/status` reports `available: false`; `GET /security/status` reports
-`acl.reason: geoip_unavailable`.
-
-| Host | usa | usb | gbc (decided) | sed |
-| plain.qa | 200 | 200 | 403 `Access denied: Blocked by security decision` |
-| every listed host | as above | as above | 403 |
-
-Only the decided address is refused, on every host; the other clients keep their list outcomes. A `block` decision
-for the admin-whitelisted `216.160.83.56` has no effect (documented behaviour).
-
-### Database removed
-
-`/app/geoip/GeoLite2-Country.mmdb` removed (only the `.placeholder` left, `/app/data/geoip` empty), container
-restarted. `GET /security/geoip/status` reports `available: false`; `GET /security/status` reports
-`acl.reason: geoip_unavailable`.
-
-| Host | usa | usb | gbc (decided) | sed |
-| --- | --- | --- | --- | --- |
-| geoallow.qa | 200 | 403 | 403 | 403 |
-| geoblock.qa | 200 | 403 | 403 | 403 |
-| block.qa | 200 | 403 | 403 | 200 |
-| local.qa | 200 | 403 | 403 | 403 |
-
-The whitelisted client passes the country lists; non-whitelisted clients are refused by them, including the SE
-client that the US block-list admits while the database is present.
-
-### Tester parity (G-A2)
-
-`POST /access-lists/:id/test` for each client address and list, with and without the database:
-
-- Every answer equals the proxy's outcome for the access list. Reasons seen: "Address is in the admin whitelist",
- "Blocked by geo whitelist: country is not in the allowed countries", "Not a private network IP (RFC1918)",
- "GeoIP database not available - country lists refuse every address except admin addresses".
-- The tester evaluates the access list only. For the decided address `81.2.69.142` it reports the list's answer
- (for example allowed on `block.qa`) while the proxy refuses it because of the decision.
-- A zoned address (`fe80::1%eth0`) returns 400 `invalid IP address`; an IPv4-mapped IPv6 address is evaluated as
- the IPv4 address.
-
-### Reload errors (G-A3)
-
-| Case | Response |
+| Upload route authorization | `POST`/`DELETE /security/geoip/database` sit in the admin-only security group and check the role again in the handler. A non-admin gets 403. |
+| `RequireSameOriginForBrowserAuth` | Applied only to the two new routes. Requests with an `Authorization` header pass (a foreign page cannot add it without a preflight, which is not granted); other unsafe requests go through the standard library's cross-origin check. Spot check: a cookie upload with a foreign `Origin` and `Sec-Fetch-Site: cross-site` got 403 `cross_origin_refused`; same-origin and non-browser requests succeed. |
+| Upload handling | Body capped by `http.MaxBytesReader` (32 MiB plus framing); exactly one multipart part named `file`; client file name and content type ignored; temp file named by `os.CreateTemp` in the data folder; symlinked targets refused; validation (MaxMind format, country edition, record decode, build date) runs before the rename; failures restore the previous copy; one change at a time (busy answers 409 at once). Installed file is `0600`, owned by the app user. |
+| Build-date check | Hardened during QA (see finding). |
+| Alerts endpoint | Management access (any role except pass-through). It returns only the alert key, severity, up to five access list names, a count, and whether a database is available. Access list names are already readable by the same roles. |
+| Self-check endpoint | Admin only; evaluates a draft against the caller's own address and the admin whitelist; no data is stored. |
+| Error bodies | Fixed messages plus `error_code`; no paths or OS text in any response (verified in handler tests and the spot check). |
+| Logs | Failure detail, which can include the data-folder path, is written to the server log only and sanitized, as the spec allows. Audit rows carry type, build date, size and digest, never a path or client file name. |
+| Backups | Backups are built from an allow-list (database and `caddy/`), so the GeoIP folder, its previous copy and upload temp files are never included. Pinned by `TestBackupService_ExcludesGeoIPFolder`. |
+| Gotify tokens | None in the diff, test artifacts or logs reviewed. |
+
+### Finding (fixed in QA): out-of-range build dates
+
+Severity: low (admin-only upload). A database whose build epoch is outside the range Go's time conversion handles
+could pass the "not in the future" check by wrapping into the past, and would then always win over later bundled
+updates. `geoip.BuildTime` now clamps such an epoch to the end of year 9999, so the upload is refused as dated in the
+future. Covered by `TestBuildTime_ClampsEpochsPastTheTimeRange` and
+`TestGeoIPDatabaseStoreCheckBuildEpoch_RefusesEpochsPastTheTimeRange`.
+
+## Spot check (throwaway container)
+
+A separate `charon:local` container on a loopback-only port with a fresh data volume, removed afterwards. The dev
+containers were not touched.
+
+| Step | Result |
| --- | --- |
-| Database file removed | 500 `{"error":"Failed to reload the GeoIP database","error_code":"geoip_reload_failed"}` |
-| Corrupt file at `/app/data/geoip/GeoLite2-Country.mmdb` | same body |
-| Second container with `CHARON_GEOIP_DB_PATH` naming a missing file | same body |
-| Unauthenticated | 401 |
-
-No response body contains a file path. The path and OS error appear only in the server log (`detail` field).
-
-### Cerberus API middleware
-
-Unauthenticated `GET /api/v1/setup` from the public clients:
-
-| Enabled lists | usa | usb | gbc / sed |
-| --- | --- | --- | --- |
-| All four | 403 `Blocked by access control list` | 403 | 403 |
-| Country lists only (database present) | 200 | 200 | 200 |
-| Country lists only (database removed) | 200 | 200 | 200 |
-
-Country lists remain "allowed" on `/api`; the local-network list refuses public unauthenticated callers whether or
-not they are admin-whitelisted. These are the outcomes the branch keeps unchanged.
-
-## Security review
+| Remove the bundled copy, enable Cerberus and ACL, reload | 500 `geoip_reload_failed`, fixed message, no path |
+| Add a country allow-list on an enabled host | `GET /notifications/alerts` returns one `geoip_unavailable` error alert naming the list, `database_available: false`; status reports `unenforced_lists_total: 1` |
+| Cross-origin cookie upload | 403 `cross_origin_refused` |
+| Upload a file that is not a database | 400 `geoip_not_mmdb` |
+| Upload a valid country database | 200, source `data`, `unenforced_lists_total: 0`; alerts list empty |
+| Remove the data-folder copy (no bundled copy) | 200, source `none`; the alert returns |
-- No Gotify tokens in the diff, test helpers, logs or report.
-- Reload error bodies no longer carry OS error text; errors are logged through `util.SanitizeForLog`.
-- The admin exclusion uses the connection address (`remote_ip`), so forwarded headers cannot claim it.
-- Admin whitelist entries are validated on save (`invalid admin whitelist CIDR`), so the tester's admin range parse
- does not see malformed input from the API.
-- Local CodeQL/Trivy not run (fix-scoped PR; CI runs both).
+## Acceptance criteria (spec section 12, feature items)
-## Cleanup
-
-All throwaway containers, networks and the data volume were removed. `charon-e2e` was rebuilt by the skill and left
-running.
-| geoallow.qa | 200 | 403 | 403 | 403 |
-| geoblock.qa | 200 | 403 | 403 | 403 |
-| block.qa | 200 | 403 | 403 | 200 |
-| local.qa | 200 | 403 | 403 | 403 |
-
-The whitelisted client passes the country lists; non-whitelisted clients are refused by them, including the SE
-client that the US block-list admits while the database is present.
-
-### Tester parity (G-A2)
-
-`POST /access-lists/:id/test` for each client address and list, with and without the database:
-
-- Every answer equals the proxy's outcome for the access list. Reasons seen: "Address is in the admin whitelist",
- "Blocked by geo whitelist: country is not in the allowed countries", "Not a private network IP (RFC1918)",
- "GeoIP database not available - country lists refuse every address except admin addresses".
-- The tester evaluates the access list only. For the decided address `81.2.69.142` it reports the list's answer
- (for example allowed on `block.qa`) while the proxy refuses it because of the decision.
-- A zoned address (`fe80::1%eth0`) returns 400 `invalid IP address`; an IPv4-mapped IPv6 address is evaluated as
- the IPv4 address.
-
-### Reload errors (G-A3)
-
-| Case | Response |
+| Criterion | Evidence |
| --- | --- |
-| Database file removed | 500 `{"error":"Failed to reload the GeoIP database","error_code":"geoip_reload_failed"}` |
-| Corrupt file at `/app/data/geoip/GeoLite2-Country.mmdb` | same body |
-| Second container with `CHARON_GEOIP_DB_PATH` naming a missing file | same body |
-| Unauthenticated | 401 |
-
-No response body contains a file path. The path and OS error appear only in the server log (`detail` field).
-
-### Cerberus API middleware
-
-Unauthenticated `GET /api/v1/setup` from the public clients:
-
-| Enabled lists | usa | usb | gbc / sed |
-| --- | --- | --- | --- |
-| All four | 403 `Blocked by access control list` | 403 | 403 |
-| Country lists only (database present) | 200 | 200 | 200 |
-| Country lists only (database removed) | 200 | 200 | 200 |
-
-Country lists remain "allowed" on `/api`; the local-network list refuses public unauthenticated callers whether or
-not they are admin-whitelisted. These are the outcomes the branch keeps unchanged.
-
-## Security review
-
-- No Gotify tokens in the diff, test helpers, logs or report.
-- Reload error bodies no longer carry OS error text; errors are logged through `util.SanitizeForLog`.
-- The admin exclusion uses the connection address (`remote_ip`), so forwarded headers cannot claim it.
-- Admin whitelist entries are validated on save (`invalid admin whitelist CIDR`), so the tester's admin range parse
- does not see malformed input from the API.
-- Local CodeQL/Trivy not run (fix-scoped PR; CI runs both).
-
-## Cleanup
-
-All throwaway containers, networks and the data volume were removed. `charon-e2e` was rebuilt by the skill and left
-running.
+| No usable database: country-list hosts reachable, no country 403 | `acl-enforcement.spec.ts` "Country lists", `geoip-unenforced-alert.spec.ts` |
+| Bell shows the alert first, with links, no dismiss; stays during and after a failed apply | `NotificationCenter.test.tsx`, `geoip-unenforced-alert.spec.ts`, `manager_last_applied_test.go` |
+| A successful apply with the database clears the alert | E2E "Restored database"; spot check |
+| A failed reload still applies; proxy, status and alert agree | `security_geoip_reload_apply_test.go`; spot check |
+| `acl.reason = geoip_unavailable` and tester say "not enforced" | `security_status_enforcement_test.go`, `access_list_evaluate_test.go` |
+| Card, replace/remove, lookup and form note; hidden for non-admins | `GeoIPDatabaseCard.test.tsx`, `AccessListGeoNote.test.tsx`, `Security.geoip.test.tsx`, `geoip-database-card.spec.ts` |
+| Cross-origin cookie requests refused; UI and header-authenticated clients succeed | `browser_origin_test.go`, `geoip_database_routes_test.go`, E2E cross-origin rows; spot check |
+| No toasts or banners; strings in 28 locales; parity tests | diff review (results are inline); `geoipLocales.test.ts`, `notificationCenterLocales.test.ts` |
+| Troubleshooting page exists, linked, in the nav | `docs/troubleshooting/geoip-database.md`, `security-module-applied.md`, `docs-site/zensical.toml` |
+| Full DoD | this report |
+
+## Deviations from the spec
+
+- Two test seams were added that the spec does not describe: `geoipFileOps` on the store and the
+ `sweepGeoIPDataFolder` helper in `routes`. Neither changes behaviour.
+- `geoip.BuildTime` was added to harden build-date handling (see the finding).
+- The strict docs build (`zensical build --strict`) was not run locally because Zensical is not installed in this
+ environment; the docs workflow runs it in CI.
+- The spec's DoD floor for patch coverage is 85%; this branch reaches 100%.
+
+## Follow-ups (not blocking)
+
+- Low: `LookupGeoIP` (moved unchanged from the older handler) returns the lookup library's error text in its 500
+ body (`"GeoIP lookup failed: ..."`). The text carries no path, but a fixed message plus `error_code` would match
+ the other GeoIP endpoints.
+- Low, informational: when the database file disappears under a running instance and a reload fails, the service
+ keeps the database it already had open, so the status reports the previous source and build date with
+ `available: false` and a null size. The card and the alert use `available`, so users see the right state; the
+ extra fields may confuse someone reading the raw API.
+- Housekeeping: the local Trivy filesystem skill scans untracked `.claude/worktrees/` copies and git-ignored test
+ keys. Excluding those paths in the skill would make its exit code reflect the tracked tree only.
diff --git a/docs/security.md b/docs/security.md
index 8e773b2c6..71414ba1c 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -316,6 +316,10 @@ When the WAF or the rate limit is on but does not apply to every site, the Secur
Access lists let you block or allow specific countries, IP addresses, or networks.
+**Country lists need a map file (the GeoIP database).** Charon includes it in the Docker image. If that file is ever missing, country lists are not enforced: an allow-list lets everyone in and a block-list blocks no one. Charon chooses this on purpose so that a missing file never locks you out of your own sites. A red alert in the notification bell tells you when this is happening. See [Country Access Lists Are Not Being Enforced](troubleshooting/geoip-database.md).
+
+Changing the country map is limited to admins. Replacing it, removing your uploaded copy, and reloading it are each recorded in the audit log, and the browser must be on Charon's own page to make these changes, so a page on another website cannot make them for you. The uploaded file is kept only in your data folder, and the copy included with Charon is never changed.
+
### Example 1: Block a Country
**Scenario:** You only need access from the US, so block everyone else.
diff --git a/docs/troubleshooting/geoip-database.md b/docs/troubleshooting/geoip-database.md
new file mode 100644
index 000000000..64d8e6f59
--- /dev/null
+++ b/docs/troubleshooting/geoip-database.md
@@ -0,0 +1,83 @@
+---
+title: Country Access Lists Are Not Being Enforced
+description: What the red bell alert about the GeoIP database means, how to check it, and how to add the database yourself.
+---
+
+## Country Access Lists Are Not Being Enforced
+
+### What You See
+
+A red alert appears in the notification bell (the bell icon in the app) with this message:
+
+> **Country access lists are not being enforced**
+
+### What It Means
+
+Country access lists (rules that allow or block visitors by their country) need a small map file that tells Charon which country an address belongs to. This file is called the **GeoIP database**. Charon normally includes it in the Docker image, so you should not see this alert.
+
+When the file is missing or cannot be read:
+
+- **Country lists do nothing.** An allow-list lets everyone in. A block-list blocks no one.
+- **Your sites stay reachable.** Nothing is locked out.
+- **Everything else still works.** IP lists, the WAF, rate limits, CrowdSec and your other protections are not affected.
+- **The alert is shown in the app only.** It is not sent to Discord, email or any other place.
+
+Think of it like a bouncer who has lost the guest list for one door. The door stays open for everyone until a new list arrives, so you should fix it soon.
+
+### How to Check
+
+1. Open the notification bell in Charon. If the red alert is there, the database is missing.
+2. Look at the logs for a warning that starts with `Country access list is not enforced`. To see the logs, run `docker logs charon` in a terminal.
+
+### Option 1: Upload a Database from the Security Page (Recommended)
+
+This is the easiest way. You need an admin account in Charon.
+
+1. Get a country database file (see [Where to Get One](#where-to-get-one) below).
+2. In Charon, go to **Security** and find the **Country database (GeoIP)** card.
+3. Press **Replace database…** and choose the `.mmdb` file from your computer.
+4. Wait for the message inside the card. It says the database was replaced when Charon is using your file.
+
+Charon accepts the file only when it is:
+
+- a file ending in `.mmdb`, no larger than 32 MiB;
+- a **country** database. City databases and other types are refused;
+- as new as, or newer than, the database Charon uses now and the one included with Charon;
+- not dated in the future.
+
+If the file is refused, the card says why. Charon then keeps the database it had before.
+
+**Uploading through a WAF-protected address:** If you reach Charon through one of your own sites that has the WAF (web application firewall) turned on, the WAF can refuse a large upload before Charon sees it. Its limit is about 12.5 MiB. A country database is usually smaller, but if the upload fails, open Charon by its direct address (the address you use to reach Charon's own page, not a site that goes through Charon) and try again.
+
+**Want to go back?** Press **Use the included copy** on the same card. Charon removes your uploaded copy and uses the database that came with it.
+
+### Option 2: Place the File Yourself
+
+1. Get a country database file (see [Where to Get One](#where-to-get-one) below).
+2. Rename it to `GeoLite2-Country.mmdb` if it has a different name.
+3. Copy it into the `geoip` folder inside your data folder. In the container, that folder is `/app/data/geoip/`. On your computer, it is the `geoip` folder inside the folder you mounted as `/app/data`. Create the `geoip` folder if it does not exist.
+4. In Charon, go to **Security** and press **Reload** on the card. You can also restart Charon. In a terminal, run `docker restart charon`, or restart the container from your Docker tool.
+
+**Want to use a file in another location?** Set the `CHARON_GEOIP_DB_PATH` setting to the full path of the `.mmdb` file, then restart Charon. This path wins over the file in the `geoip` folder. The file name must end in `.mmdb`. While this setting is in use, the card cannot replace or remove the file. **Reload** still works.
+
+### Where to Get One
+
+Charon needs a **country** database in the MaxMind `.mmdb` format. Two free options work:
+
+- **MaxMind GeoLite2-Country:** Create a free account at MaxMind, then download the GeoLite2-Country database. MaxMind may send it as a `.tar.gz` archive. Open the archive first, and use the `GeoLite2-Country.mmdb` file inside it.
+- **DB-IP IP-to-Country Lite (MMDB format):** Download the free country file from DB-IP in the MMDB format. This file is licensed under CC BY 4.0, which requires you to give credit to DB-IP. Credit DB-IP with a link to [db-ip.com](https://db-ip.com) in any place where you publish or show your setup.
+
+Use only the **country** edition. City and ASN databases are not country databases, and Charon refuses them.
+
+### Confirm It Worked
+
+1. Open **Security** and look at the **Country database (GeoIP)** card. The status should say **Ready**, and the **Source** should show where the database came from.
+2. Open the notification bell. The red alert should be gone.
+
+The alert clears by itself once Charon can read the database. You do not need to dismiss it.
+
+### Still Seeing the Alert?
+
+Check that the file is in the right folder, that its name ends in `.mmdb`, and that it is a country database. Then press **Reload** on the card, or restart Charon. If the card says the file was refused, the message tells you why. If the alert is still there, please open a GitHub issue so we can help. The database is normally included, so a missing file may be a bug. Include the Charon version you use and the startup log lines from `docker logs charon`.
+
+[Open a GitHub issue](https://github.com/Wikid82/Charon/issues/new?template=bug_report.md)
diff --git a/docs/troubleshooting/security-module-applied.md b/docs/troubleshooting/security-module-applied.md
index 6fe27e3cd..3d24149b8 100644
--- a/docs/troubleshooting/security-module-applied.md
+++ b/docs/troubleshooting/security-module-applied.md
@@ -17,6 +17,7 @@ No line means the module is on and working on every site, or the module is off.
| No rule set is chosen | You have rule sets, but none is picked for the WAF. | Open the **WAF** page and pick a rule set from the list, or choose automatic selection. |
| The chosen rule set cannot be loaded | The rule set the WAF uses has no rule file ready. | Open the **WAF** page, check the rule set, or choose a different one. |
| No request count or time window is set | The rate limit is on, but its numbers are empty or zero. | Open the Rate Limiting card, enter the numbers, and save. |
+| Country lists are not enforced | The GeoIP database is missing, so country lists do not block or allow anything. | See [Country Access Lists Are Not Being Enforced](geoip-database.md). |
### Steps
diff --git a/frontend/src/__tests__/crowdsecImportErrorLocales.test.ts b/frontend/src/__tests__/crowdsecImportErrorLocales.test.ts
index c5767a131..9076c1ca9 100644
--- a/frontend/src/__tests__/crowdsecImportErrorLocales.test.ts
+++ b/frontend/src/__tests__/crowdsecImportErrorLocales.test.ts
@@ -1,28 +1,16 @@
import { describe, it, expect } from 'vitest'
-/** Shape of a locale file as far as this test reads it: the CrowdSec section of the translations. */
-type LocaleFile = { crowdsecConfig?: Record }
-
-/** Every locale the app bundles. A new locale file must be counted here, so it cannot skip the key check. */
-const EXPECTED_LOCALE_COUNT = 28
+import { EXPECTED_LOCALE_COUNT, localeTrees, missingLocaleKeys } from './utils/localeKeys'
/** Translation keys of the import refusal messages every locale must carry. */
const REFUSAL_KEYS = ['importErrorNotWritable', 'importErrorArchiveTooLarge', 'importErrorRequestTooLarge'] as const
-const localeFiles = import.meta.glob('../locales/*/translation.json', { import: 'default', eager: true }) as Record<
- string,
- LocaleFile
->
-
describe('import refusal messages in every locale', () => {
it(`bundles exactly ${EXPECTED_LOCALE_COUNT} locale files`, () => {
- expect(Object.keys(localeFiles)).toHaveLength(EXPECTED_LOCALE_COUNT)
+ expect(Object.keys(localeTrees)).toHaveLength(EXPECTED_LOCALE_COUNT)
})
- it.each(
- Object.entries(localeFiles).flatMap(([path, tree]) => REFUSAL_KEYS.map((key) => [path, tree, key] as const)),
- )('%s has a non-empty crowdsecConfig.%s', (_path, tree, key) => {
- const text: unknown = tree.crowdsecConfig?.[key]
- expect(typeof text === 'string' && text.trim().length > 0).toBe(true)
+ it('has a non-empty value for each refusal key in every locale', () => {
+ expect(missingLocaleKeys(REFUSAL_KEYS.map((key) => `crowdsecConfig.${key}`))).toEqual([])
})
})
diff --git a/frontend/src/__tests__/geoipLocales.test.ts b/frontend/src/__tests__/geoipLocales.test.ts
new file mode 100644
index 000000000..5ada0ba76
--- /dev/null
+++ b/frontend/src/__tests__/geoipLocales.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect } from 'vitest'
+
+import { EXPECTED_LOCALE_COUNT, localeTrees, missingLocaleKeys, readLocaleKey } from './utils/localeKeys'
+
+/** The English bundle is the source of truth: every `security.geoip.*` key is derived from it, so new keys cannot drift. */
+const EN_PATH_SUFFIX = '/en/translation.json'
+const enEntry = Object.entries(localeTrees).find(([path]) => path.endsWith(EN_PATH_SUFFIX))
+if (!enEntry) {
+ throw new Error(`No locale file ending in ${EN_PATH_SUFFIX} is bundled`)
+}
+const enTree = enEntry[1]
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+/** Flattens a nested locale object into dotted leaf keys, for example `source.label`. */
+function leafKeys(node: Record, prefix = ''): string[] {
+ return Object.entries(node).flatMap(([key, value]) =>
+ isRecord(value) ? leafKeys(value, `${prefix}${key}.`) : [`${prefix}${key}`],
+ )
+}
+
+const enGeoip = readLocaleKey(enTree, 'security.geoip')
+if (!isRecord(enGeoip)) {
+ throw new Error('The en translation.json has no security.geoip object')
+}
+const GEOIP_KEYS = leafKeys(enGeoip).map((key) => `security.geoip.${key}`)
+
+const PLACEHOLDER = /\{\{\s*[\w.]+\s*\}\}/g
+
+/** The sorted interpolation placeholders in a value, so translated strings must reuse exactly the same ones. */
+function placeholdersOf(value: unknown): string[] {
+ return typeof value === 'string' ? [...(value.match(PLACEHOLDER) ?? [])].sort() : []
+}
+
+describe('security.geoip strings in every locale', () => {
+ it(`bundles exactly ${EXPECTED_LOCALE_COUNT} locale files`, () => {
+ expect(Object.keys(localeTrees)).toHaveLength(EXPECTED_LOCALE_COUNT)
+ })
+
+ it('derives a non-empty key list from the English bundle', () => {
+ expect(GEOIP_KEYS.length).toBeGreaterThan(0)
+ })
+
+ it('has a non-empty value for every security.geoip key in every locale', () => {
+ expect(missingLocaleKeys(GEOIP_KEYS)).toEqual([])
+ })
+
+ it.each(GEOIP_KEYS)('keeps the placeholders of %s identical to English in every locale', (key) => {
+ const expected = placeholdersOf(readLocaleKey(enTree, key))
+ const mismatches = Object.entries(localeTrees).flatMap(([path, tree]) => {
+ const actual = placeholdersOf(readLocaleKey(tree, key))
+ return JSON.stringify(actual) === JSON.stringify(expected) ? [] : [`${path} ${key}`]
+ })
+ expect(mismatches).toEqual([])
+ })
+})
diff --git a/frontend/src/__tests__/localeKeys.test.ts b/frontend/src/__tests__/localeKeys.test.ts
new file mode 100644
index 000000000..20017c5e5
--- /dev/null
+++ b/frontend/src/__tests__/localeKeys.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+
+import { EXPECTED_LOCALE_COUNT, localeTrees, missingLocaleKeys, readLocaleKey } from './utils/localeKeys'
+
+describe('locale key helpers', () => {
+ it('names every locale that lacks a key', () => {
+ const missing = missingLocaleKeys(['common.__no_such_key__'])
+
+ expect(missing).toHaveLength(EXPECTED_LOCALE_COUNT)
+ expect(missing.every((entry) => entry.endsWith(' common.__no_such_key__'))).toBe(true)
+ expect(missing).toContain('../../locales/en/translation.json common.__no_such_key__')
+ })
+
+ it('reads nested keys and stops at missing branches', () => {
+ const tree = { a: { b: 'text', blank: ' ' } }
+
+ expect(readLocaleKey(tree, 'a.b')).toBe('text')
+ expect(readLocaleKey(tree, 'a.b.c')).toBeUndefined()
+ expect(readLocaleKey(tree, 'x.y')).toBeUndefined()
+ expect(Object.keys(localeTrees)).toHaveLength(EXPECTED_LOCALE_COUNT)
+ })
+})
diff --git a/frontend/src/__tests__/notificationCenterLocales.test.ts b/frontend/src/__tests__/notificationCenterLocales.test.ts
new file mode 100644
index 000000000..2ab5754cb
--- /dev/null
+++ b/frontend/src/__tests__/notificationCenterLocales.test.ts
@@ -0,0 +1,65 @@
+import { describe, it, expect } from 'vitest'
+
+import { ALERT_PRESENTATION } from '../constants/alerts'
+import { EXPECTED_LOCALE_COUNT, localeTrees, missingLocaleKeys, readLocaleKey } from './utils/localeKeys'
+
+/** Static bell strings, including the ones that were hard-coded before they were translated. */
+const BELL_KEYS = [
+ 'notificationCenter.title',
+ 'notificationCenter.bellLabel',
+ 'notificationCenter.markAllRead',
+ 'notificationCenter.empty',
+ 'notificationCenter.close',
+ 'notificationCenter.updateAvailable',
+ 'notificationCenter.viewChangelog',
+ 'notificationCenter.alerts.label',
+ 'notificationCenter.alerts.clearsItself',
+] as const;
+
+/** Keys every registered alert needs: its title and body, plus the docs and issue link labels. */
+const alertKeysFor = (alertKey: string, titleKey: string): string[] => [
+ titleKey,
+ `notificationCenter.alerts.${alertKey}.docsLink`,
+ `notificationCenter.alerts.${alertKey}.issueHint`,
+ `notificationCenter.alerts.${alertKey}.issueLink`,
+];
+
+const GEOIP_BODY_KEYS = [
+ 'notificationCenter.alerts.geoip_unavailable.message',
+ 'notificationCenter.alerts.geoip_unavailable.messagePendingApply',
+] as const;
+
+/** Placeholders each string must keep, so interpolation still fills every value in every language. */
+const REQUIRED_PLACEHOLDERS: ReadonlyArray = [
+ ['notificationCenter.bellLabel', ['{{count}}']],
+ ['notificationCenter.updateAvailable', ['{{version}}']],
+ ...GEOIP_BODY_KEYS.map((key): [string, readonly string[]] => [key, ['{{count}}', '{{lists}}']]),
+];
+
+describe('notification center strings in every locale', () => {
+ it(`bundles exactly ${EXPECTED_LOCALE_COUNT} locale files`, () => {
+ expect(Object.keys(localeTrees)).toHaveLength(EXPECTED_LOCALE_COUNT);
+ });
+
+ it('has a non-empty value for every static bell string', () => {
+ expect(missingLocaleKeys(BELL_KEYS)).toEqual([]);
+ });
+
+ it.each(Object.entries(ALERT_PRESENTATION))('has every text for the registered alert "%s"', (alertKey, presentation) => {
+ expect(missingLocaleKeys(alertKeysFor(alertKey, presentation.titleKey))).toEqual([]);
+ });
+
+ it('has the body variant for each geoip_unavailable message', () => {
+ expect(missingLocaleKeys(GEOIP_BODY_KEYS)).toEqual([]);
+ });
+
+ it.each(REQUIRED_PLACEHOLDERS.flatMap(([key, placeholders]) => placeholders.map((p) => [key, p] as const)))(
+ '%s keeps the %s placeholder in every locale',
+ (key, placeholder) => {
+ for (const [path, tree] of Object.entries(localeTrees)) {
+ const text = readLocaleKey(tree, key);
+ expect(typeof text === 'string' && text.includes(placeholder), `${path} ${key}`).toBe(true);
+ }
+ },
+ );
+});
diff --git a/frontend/src/__tests__/securityEnforcementLocales.test.ts b/frontend/src/__tests__/securityEnforcementLocales.test.ts
index 6a2497dcd..8ef080f1b 100644
--- a/frontend/src/__tests__/securityEnforcementLocales.test.ts
+++ b/frontend/src/__tests__/securityEnforcementLocales.test.ts
@@ -1,33 +1,15 @@
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 }
-}
+import { EXPECTED_LOCALE_COUNT, localeTrees, missingLocaleKeys } from './utils/localeKeys'
-/** 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],
-]
+const NEW_KEYS = ['security.enforcement.acl.geoip_unavailable', 'accessLists.errors.access_list_rules_required'] as const
describe('access-list enforcement messages in every locale', () => {
it(`bundles exactly ${EXPECTED_LOCALE_COUNT} locale files`, () => {
- expect(Object.keys(localeFiles)).toHaveLength(EXPECTED_LOCALE_COUNT)
+ expect(Object.keys(localeTrees)).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)
+ it('has a non-empty value for each enforcement key in every locale', () => {
+ expect(missingLocaleKeys(NEW_KEYS)).toEqual([])
})
})
diff --git a/frontend/src/__tests__/utils/localeKeys.ts b/frontend/src/__tests__/utils/localeKeys.ts
new file mode 100644
index 000000000..409a1466d
--- /dev/null
+++ b/frontend/src/__tests__/utils/localeKeys.ts
@@ -0,0 +1,37 @@
+/** Every locale the app bundles. A new locale file must be counted here, so it cannot skip the key checks. */
+export const EXPECTED_LOCALE_COUNT = 28
+
+/** A parsed `translation.json`, typed only as far as the key lookups need. */
+export type LocaleTree = Record
+
+/** Every bundled locale file, keyed by its module path. */
+export const localeTrees = import.meta.glob('../../locales/*/translation.json', {
+ import: 'default',
+ eager: true,
+}) as Record
+
+/** Reads a dot-separated key (for example `notificationCenter.alerts.label`) from a locale tree. */
+export function readLocaleKey(tree: LocaleTree, dottedKey: string): unknown {
+ return dottedKey
+ .split('.')
+ .reduce(
+ (node, part) =>
+ typeof node === 'object' && node !== null ? (node as Record)[part] : undefined,
+ tree,
+ );
+}
+
+/**
+ * Lists every "" pair whose value is missing or blank. An empty result means every listed key
+ * is a non-blank string in every bundled locale. Tests assert the result is `[]`, so a failure names each gap.
+ */
+export function missingLocaleKeys(keys: readonly string[]): string[] {
+ return Object.entries(localeTrees).flatMap(([path, tree]) =>
+ keys
+ .filter((key) => {
+ const text = readLocaleKey(tree, key);
+ return typeof text !== 'string' || text.trim().length === 0;
+ })
+ .map((key) => `${path} ${key}`),
+ );
+}
diff --git a/frontend/src/api/__tests__/accessLists.test.ts b/frontend/src/api/__tests__/accessLists.test.ts
index b403e68f7..59894c7a5 100644
--- a/frontend/src/api/__tests__/accessLists.test.ts
+++ b/frontend/src/api/__tests__/accessLists.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { accessListsApi, type AccessList } from '../accessLists';
+import { accessListsApi, selfCheckAccessList, type AccessList, type AccessListSelfCheckDraft } from '../accessLists';
import client from '../client';
@@ -174,3 +174,41 @@ describe('accessListsApi', () => {
});
});
});
+
+describe('selfCheckAccessList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('posts the unsaved draft and returns the decision', async () => {
+ const draft: AccessListSelfCheckDraft = {
+ type: 'geo_blacklist',
+ country_codes: 'RU,CN',
+ ip_rules: '',
+ local_network_only: false,
+ enabled: true,
+ };
+ const decision = {
+ ip_address: '203.0.113.7',
+ country_code: 'RU',
+ allowed: false,
+ admin_exempt: false,
+ geoip_available: true,
+ reason: 'country_blocked',
+ };
+ vi.mocked(client.post).mockResolvedValueOnce({ data: decision });
+
+ const result = await selfCheckAccessList(draft);
+
+ expect(client.post).toHaveBeenCalledWith<[string, AccessListSelfCheckDraft]>('/access-lists/self-check', draft);
+ expect(result).toEqual(decision);
+ });
+
+ it('propagates a refused request', async () => {
+ vi.mocked(client.post).mockRejectedValueOnce(new Error('forbidden'));
+
+ await expect(
+ selfCheckAccessList({ type: 'geo_whitelist', country_codes: 'DE', ip_rules: '', local_network_only: false, enabled: true })
+ ).rejects.toThrow('forbidden');
+ });
+});
diff --git a/frontend/src/api/__tests__/errorBody.geoip.test.ts b/frontend/src/api/__tests__/errorBody.geoip.test.ts
new file mode 100644
index 000000000..d3762cda8
--- /dev/null
+++ b/frontend/src/api/__tests__/errorBody.geoip.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest'
+
+import { GEOIP_ERROR_CODES, geoipErrorKey } from '../errorBody'
+
+describe('geoipErrorKey', () => {
+ it.each(GEOIP_ERROR_CODES)('maps %s to its own locale key', (code) => {
+ expect(geoipErrorKey(code)).toBe(`security.geoip.errors.${code}`)
+ })
+
+ it('falls back to the generic text for an unknown code', () => {
+ expect(geoipErrorKey('not_a_geoip_code')).toBe('security.geoip.errors.generic')
+ })
+
+ it('falls back to the generic text when the code is missing', () => {
+ expect(geoipErrorKey(undefined)).toBe('security.geoip.errors.generic')
+ })
+})
diff --git a/frontend/src/api/__tests__/security.test.ts b/frontend/src/api/__tests__/security.test.ts
index b4dd3cf96..af620ee96 100644
--- a/frontend/src/api/__tests__/security.test.ts
+++ b/frontend/src/api/__tests__/security.test.ts
@@ -263,4 +263,50 @@ describe('security API', () => {
expect(client.delete).toHaveBeenCalledWith('/security/rulesets/a%2Fb%20c')
})
})
+
+ describe('GeoIP database', () => {
+ it('getGeoIPStatus reads the card status', async () => {
+ const status = { source: 'bundled', can_replace: true }
+ vi.mocked(client.get).mockResolvedValue({ data: status })
+
+ await expect(security.getGeoIPStatus()).resolves.toEqual(status)
+ expect(client.get).toHaveBeenCalledWith('/security/geoip/status')
+ })
+
+ it('reloadGeoIP posts a reload', async () => {
+ vi.mocked(client.post).mockResolvedValue({ data: { message: 'ok' } })
+
+ await expect(security.reloadGeoIP()).resolves.toEqual({ message: 'ok' })
+ expect(client.post).toHaveBeenCalledWith('/security/geoip/reload')
+ })
+
+ it('lookupGeoIP sends the address as ip_address', async () => {
+ const answer = { ip_address: '8.8.8.8', country_code: 'US', found: true }
+ vi.mocked(client.post).mockResolvedValue({ data: answer })
+
+ await expect(security.lookupGeoIP('8.8.8.8')).resolves.toEqual(answer)
+ expect(client.post).toHaveBeenCalledWith('/security/geoip/lookup', { ip_address: '8.8.8.8' })
+ })
+
+ it('replaceGeoIPDatabase uploads the file as the single part named file', async () => {
+ const change = { status: { source: 'data' }, replaced_previous: false }
+ vi.mocked(client.post).mockResolvedValue({ data: change })
+ const file = new File(['mmdb'], 'GeoLite2-Country.mmdb')
+
+ await expect(security.replaceGeoIPDatabase(file)).resolves.toEqual(change)
+ const [url, body] = vi.mocked(client.post).mock.calls[0]
+ expect(url).toBe('/security/geoip/database')
+ expect(body).toBeInstanceOf(FormData)
+ expect([...(body as FormData).keys()]).toEqual(['file'])
+ expect((body as FormData).get('file')).toBeInstanceOf(File)
+ })
+
+ it('removeGeoIPDatabase deletes the data-folder copy', async () => {
+ const change = { status: { source: 'bundled' } }
+ vi.mocked(client.delete).mockResolvedValue({ data: change })
+
+ await expect(security.removeGeoIPDatabase()).resolves.toEqual(change)
+ expect(client.delete).toHaveBeenCalledWith('/security/geoip/database')
+ })
+ })
})
diff --git a/frontend/src/api/__tests__/system.test.ts b/frontend/src/api/__tests__/system.test.ts
index 268a8847d..a6df5ccfb 100644
--- a/frontend/src/api/__tests__/system.test.ts
+++ b/frontend/src/api/__tests__/system.test.ts
@@ -1,7 +1,13 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import client from '../client'
-import { checkUpdates, getNotifications, markNotificationRead, markAllNotificationsRead } from '../system'
+import {
+ checkUpdates,
+ getActiveAlerts,
+ getNotifications,
+ markNotificationRead,
+ markAllNotificationsRead,
+} from '../system'
vi.mock('../client', () => ({
default: {
@@ -60,4 +66,28 @@ describe('System API', () => {
expect(client.post).toHaveBeenCalledWith('/notifications/read-all')
})
+
+ it('getActiveAlerts calls /notifications/alerts and unwraps the alerts array', async () => {
+ const alerts = [
+ { key: 'geoip_unavailable', severity: 'error', affected_lists: ['Office'], affected_lists_total: 1, database_available: false },
+ ]
+ vi.mocked(client.get).mockResolvedValue({ data: { alerts } })
+
+ const result = await getActiveAlerts()
+
+ expect(client.get).toHaveBeenCalledWith('/notifications/alerts')
+ expect(result).toEqual(alerts)
+ })
+
+ it('getActiveAlerts returns an empty array when nothing needs attention', async () => {
+ vi.mocked(client.get).mockResolvedValue({ data: { alerts: [] } })
+
+ await expect(getActiveAlerts()).resolves.toEqual([])
+ })
+
+ it('getActiveAlerts propagates request failures', async () => {
+ vi.mocked(client.get).mockRejectedValue(new Error('network down'))
+
+ await expect(getActiveAlerts()).rejects.toThrow('network down')
+ })
})
diff --git a/frontend/src/api/accessLists.ts b/frontend/src/api/accessLists.ts
index ccb3a4667..3b1d4b296 100644
--- a/frontend/src/api/accessLists.ts
+++ b/frontend/src/api/accessLists.ts
@@ -129,3 +129,34 @@ export const accessListsApi = {
return response.data;
},
};
+
+/** Unsaved access list settings sent to the self-check; same shape as the create and update payloads. */
+export interface AccessListSelfCheckDraft {
+ type: AccessList['type'];
+ country_codes: string;
+ ip_rules: string;
+ local_network_only: boolean;
+ enabled: boolean;
+}
+
+/** Decision the proxy would make for the viewer's current address under the draft. */
+export interface AccessListSelfCheckResult {
+ ip_address: string;
+ country_code: string;
+ allowed: boolean;
+ admin_exempt: boolean;
+ geoip_available: boolean;
+ reason: string;
+}
+
+/**
+ * Checks whether an unsaved access list would refuse the address the viewer is using now (admin only).
+ * Nothing is persisted.
+ * @param draft - The list settings as they are currently edited
+ * @returns Promise resolving to the self-check decision
+ * @throws {AxiosError} If the request fails or the caller is not an admin
+ */
+export async function selfCheckAccessList(draft: AccessListSelfCheckDraft): Promise {
+ const response = await client.post('/access-lists/self-check', draft);
+ return response.data;
+}
diff --git a/frontend/src/api/errorBody.ts b/frontend/src/api/errorBody.ts
index 409e53c44..69b305456 100644
--- a/frontend/src/api/errorBody.ts
+++ b/frontend/src/api/errorBody.ts
@@ -121,3 +121,34 @@ export function accessListErrorMessage(err: unknown, t: TFunction): string | nul
const code = getApiErrorCode(err, ACCESS_LIST_CODED_MESSAGES)
return code ? t(ACCESS_LIST_CODE_MESSAGE_KEYS[code]) : null
}
+
+/**
+ * Error codes of the GeoIP database routes (reload, lookup, replace, remove) that have a translated text. Keep in step
+ * with the backend's error_code values and the `security.geoip.errors.*` locale keys.
+ */
+export const GEOIP_ERROR_CODES = [
+ 'geoip_file_required',
+ 'geoip_too_large',
+ 'geoip_not_mmdb',
+ 'geoip_corrupt',
+ 'geoip_wrong_type',
+ 'geoip_older_than_current',
+ 'geoip_build_date_in_future',
+ 'geoip_custom_path_set',
+ 'geoip_busy',
+ 'geoip_write_failed',
+ 'geoip_reload_failed',
+ 'geoip_no_data_copy',
+ 'geoip_service_unavailable',
+ 'proxy_apply_failed',
+] as const
+
+/**
+ * Locale key of the text for a GeoIP error code. An unknown or missing code uses the generic text.
+ * @param code - The `error_code` of the refusal, if any
+ * @returns The translation key under `security.geoip.errors`
+ */
+export function geoipErrorKey(code: string | undefined): string {
+ const known = (GEOIP_ERROR_CODES as readonly string[]).includes(code ?? '')
+ return known ? `security.geoip.errors.${code}` : 'security.geoip.errors.generic'
+}
diff --git a/frontend/src/api/security.ts b/frontend/src/api/security.ts
index ef33b4a0e..3f07e8951 100644
--- a/frontend/src/api/security.ts
+++ b/frontend/src/api/security.ts
@@ -286,3 +286,98 @@ export const deleteRuleSet = async (uuid: string) => {
const response = await client.delete(`/security/rulesets/${encodeURIComponent(uuid)}`)
return response.data
}
+
+/** Where the proxy's GeoIP database comes from: the image, the data folder, a server-set path, or nothing. */
+export type GeoIPSource = 'bundled' | 'data' | 'custom' | 'none'
+
+/** Why a database cannot be replaced from the UI; empty when it can. */
+export type GeoIPReplaceBlockedReason = '' | 'custom_path_set' | 'data_folder_read_only'
+
+/** Response of `GET /security/geoip/status`. `reason` is present only while the database is unavailable. */
+export interface GeoIPStatus {
+ available: boolean
+ loaded: boolean
+ db_path: string
+ message: string
+ reason?: string
+ source: GeoIPSource
+ database_type: string
+ build_date: string | null
+ size_bytes: number | null
+ loaded_at: string | null
+ included_build_date: string | null
+ data_copy_present: boolean
+ can_replace: boolean
+ replace_blocked_reason: GeoIPReplaceBlockedReason
+ /** Country lists the proxy is not enforcing because no database is usable. */
+ unenforced_lists_total: number
+}
+
+/** Response of the replace and remove routes: the status after the change. */
+export interface GeoIPStatusChange {
+ status: GeoIPStatus
+ /** Set by the replace route when a data-folder copy was overwritten. */
+ replaced_previous?: boolean
+}
+
+/** Response of `POST /security/geoip/lookup`. `country_code` is empty when `found` is false. */
+export interface GeoIPLookupResult {
+ ip_address: string
+ country_code: string
+ found: boolean
+ message?: string
+}
+
+/**
+ * Gets the GeoIP database status for the Security page card (admin).
+ * @returns Promise resolving to GeoIPStatus
+ * @throws {AxiosError} If the request fails
+ */
+export const getGeoIPStatus = async (): Promise => {
+ const response = await client.get('/security/geoip/status')
+ return response.data
+}
+
+/**
+ * Reloads the GeoIP database from disk and applies the proxy configuration.
+ * @returns Promise resolving to the server's reload message
+ * @throws {AxiosError} If the reload fails; `error_code` names the cause
+ */
+export const reloadGeoIP = async (): Promise => {
+ const response = await client.post('/security/geoip/reload')
+ return response.data
+}
+
+/**
+ * Looks up the country of an IP address with the loaded database.
+ * @param ipAddress - IPv4 or IPv6 address to look up
+ * @returns Promise resolving to GeoIPLookupResult
+ * @throws {AxiosError} If the request fails (400 for an invalid address, 503 when no database is loaded)
+ */
+export const lookupGeoIP = async (ipAddress: string): Promise => {
+ const response = await client.post('/security/geoip/lookup', { ip_address: ipAddress })
+ return response.data
+}
+
+/**
+ * Replaces the data-folder copy of the GeoIP database with an uploaded file.
+ * @param file - The uploaded database; sent as the multipart part `file`
+ * @returns Promise resolving to the status after the replacement
+ * @throws {AxiosError} If the upload is refused; `error_code` names the cause
+ */
+export const replaceGeoIPDatabase = async (file: File): Promise => {
+ const body = new FormData()
+ body.append('file', file)
+ const response = await client.post('/security/geoip/database', body)
+ return response.data
+}
+
+/**
+ * Removes the data-folder copy so Charon uses the database included with the image.
+ * @returns Promise resolving to the status after the removal
+ * @throws {AxiosError} If the removal fails; 404 when there is no data-folder copy
+ */
+export const removeGeoIPDatabase = async (): Promise => {
+ const response = await client.delete('/security/geoip/database')
+ return response.data
+}
diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts
index fdcaabb02..0bf564b32 100644
--- a/frontend/src/api/system.ts
+++ b/frontend/src/api/system.ts
@@ -21,6 +21,40 @@ export interface Notification {
created_at: string;
}
+/** Severity of an active alert, using the same values as a notification. */
+export type AlertSeverity = Notification['type'];
+
+/**
+ * A condition the server currently reports to the notification bell, mirroring one entry of
+ * `GET /notifications/alerts`. Alerts are computed per request and never stored.
+ */
+export interface ActiveAlert {
+ /** Stable identifier; the frontend maps it to text and links. Unknown keys are not rendered. */
+ key: string;
+ severity: AlertSeverity;
+ /** Up to five affected names, sorted by the server. */
+ affected_lists?: string[];
+ /** Full number of affected names, which can exceed `affected_lists.length`. */
+ affected_lists_total?: number;
+ /** Whether a usable database is loaded now; selects the message variant only. */
+ database_available?: boolean;
+}
+
+/** Wire shape of `GET /notifications/alerts`. */
+interface ActiveAlertsResponse {
+ alerts: ActiveAlert[];
+}
+
+/**
+ * Fetches the alerts that currently need attention.
+ * @returns Promise resolving to ActiveAlert objects (empty when nothing needs attention)
+ * @throws {AxiosError} If the request fails
+ */
+export const getActiveAlerts = async (): Promise => {
+ const response = await client.get('/notifications/alerts');
+ return response.data.alerts;
+};
+
/**
* Checks for available application updates.
* @returns Promise resolving to UpdateInfo
diff --git a/frontend/src/components/AccessListForm.tsx b/frontend/src/components/AccessListForm.tsx
index 1c979010c..9c0829d9c 100644
--- a/frontend/src/components/AccessListForm.tsx
+++ b/frontend/src/components/AccessListForm.tsx
@@ -4,6 +4,7 @@ import toast from 'react-hot-toast';
import { getMyIP } from '../api/system';
import { SECURITY_PRESETS, calculateTotalIPs, formatIPCount, type SecurityPreset } from '../data/securityPresets';
+import { AccessListGeoNote } from './AccessListGeoNote';
import { Button } from './ui/Button';
import { Input } from './ui/Input';
import { Switch } from './ui/Switch';
@@ -537,6 +538,16 @@ export function AccessListForm({
})}
)}
+
+
)}
diff --git a/frontend/src/components/AccessListGeoNote.tsx b/frontend/src/components/AccessListGeoNote.tsx
new file mode 100644
index 000000000..e171cad86
--- /dev/null
+++ b/frontend/src/components/AccessListGeoNote.tsx
@@ -0,0 +1,40 @@
+import { useTranslation } from 'react-i18next'
+
+import { useAuth } from '../hooks/useAuth'
+import { useAccessListSelfCheck, useGeoIPStatus } from '../hooks/useGeoIP'
+
+import type { AccessListSelfCheckDraft } from '../api/accessLists'
+
+interface AccessListGeoNoteProps {
+ /** The country list as it is currently edited; nothing here is saved. */
+ draft: AccessListSelfCheckDraft
+}
+
+/**
+ * Passive, muted line under a country list's form fields. It says the list is not enforced while no GeoIP database
+ * is usable, or that the viewer's current address would be refused. It never blocks saving.
+ *
+ * The self-check is admin-only, so for other users only the database status is considered.
+ */
+export function AccessListGeoNote({ draft }: AccessListGeoNoteProps) {
+ const { t } = useTranslation()
+ const { user } = useAuth()
+ const isAdmin = user?.role === 'admin'
+ const { data: status } = useGeoIPStatus()
+ const { data: selfCheck } = useAccessListSelfCheck(draft, isAdmin && draft.country_codes !== '')
+
+ let message: string | null = null
+ if (status?.available === false) {
+ message = t('security.geoip.formNote.unavailable')
+ } else if (selfCheck && !selfCheck.allowed && !selfCheck.admin_exempt) {
+ message = t('security.geoip.formNote.wouldRefuseYou')
+ }
+
+ if (message === null) return null
+
+ return (
+
diff --git a/frontend/src/components/__tests__/AccessListForm.geoNote.test.tsx b/frontend/src/components/__tests__/AccessListForm.geoNote.test.tsx
new file mode 100644
index 000000000..eb6250665
--- /dev/null
+++ b/frontend/src/components/__tests__/AccessListForm.geoNote.test.tsx
@@ -0,0 +1,101 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { AccessListForm } from '../AccessListForm'
+import { AccessListGeoNote } from '../AccessListGeoNote'
+
+import type { AccessListSelfCheckDraft } from '../../api/accessLists'
+
+vi.mock('../../api/system', () => ({
+ getMyIP: vi.fn(),
+}))
+
+vi.mock('../AccessListGeoNote', () => ({
+ AccessListGeoNote: vi.fn(() => null),
+}))
+
+globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+}))
+
+const lastDraft = (): AccessListSelfCheckDraft => {
+ const calls = vi.mocked(AccessListGeoNote).mock.calls
+ const props = calls[calls.length - 1]?.[0]
+ if (!props) throw new Error('AccessListGeoNote was not rendered')
+ return props.draft
+}
+
+describe('AccessListForm country note', () => {
+ const user = userEvent.setup()
+
+ beforeEach(() => {
+ vi.mocked(AccessListGeoNote).mockClear()
+ })
+
+ it('is not rendered for IP lists', () => {
+ render()
+
+ expect(AccessListGeoNote).not.toHaveBeenCalled()
+ })
+
+ it('is rendered for a country list and follows the edited countries', async () => {
+ render()
+
+ await user.selectOptions(screen.getByLabelText(/Type/i), 'geo_blacklist')
+ expect(lastDraft()).toEqual({
+ type: 'geo_blacklist',
+ country_codes: '',
+ ip_rules: '',
+ local_network_only: false,
+ enabled: true,
+ })
+
+ await user.selectOptions(screen.getByLabelText('Select Countries'), 'RU')
+ await user.selectOptions(screen.getByLabelText('Select Countries'), 'CN')
+ expect(lastDraft().country_codes).toBe('RU,CN')
+ })
+
+ it('reflects the enabled switch in the draft', async () => {
+ render(
+
+ )
+ expect(lastDraft()).toMatchObject({ type: 'geo_whitelist', country_codes: 'DE', enabled: true })
+
+ await user.click(screen.getByRole('checkbox', { name: 'Enabled' }))
+ expect(lastDraft().enabled).toBe(false)
+ })
+
+ it('never blocks saving while the note shows', async () => {
+ const onSubmit = vi.fn()
+ vi.mocked(AccessListGeoNote).mockImplementation(() =>
would refuse you
)
+ render()
+
+ await user.type(screen.getByLabelText(/Name/), 'Countries')
+ await user.selectOptions(screen.getByLabelText(/Type/i), 'geo_blacklist')
+ await user.selectOptions(screen.getByLabelText('Select Countries'), 'RU')
+
+ expect(screen.getByRole('status')).toHaveTextContent('would refuse you')
+ const save = screen.getByRole('button', { name: 'Create' })
+ expect(save).toBeEnabled()
+ await user.click(save)
+ expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ country_codes: 'RU' }))
+ })
+})
diff --git a/frontend/src/components/__tests__/AccessListForm.test.tsx b/frontend/src/components/__tests__/AccessListForm.test.tsx
index f3cbd841f..f5b56ee55 100644
--- a/frontend/src/components/__tests__/AccessListForm.test.tsx
+++ b/frontend/src/components/__tests__/AccessListForm.test.tsx
@@ -10,6 +10,11 @@ vi.mock('../../api/system', () => ({
getMyIP: vi.fn(),
}));
+// The country note reads the session and GeoIP queries; its own tests cover it
+vi.mock('../AccessListGeoNote', () => ({
+ AccessListGeoNote: () => null,
+}));
+
vi.mock('react-hot-toast', () => ({
default: {
success: vi.fn(),
diff --git a/frontend/src/components/__tests__/AccessListGeoNote.test.tsx b/frontend/src/components/__tests__/AccessListGeoNote.test.tsx
new file mode 100644
index 000000000..99fa48c83
--- /dev/null
+++ b/frontend/src/components/__tests__/AccessListGeoNote.test.tsx
@@ -0,0 +1,204 @@
+import { act, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import * as accessListsApi from '../../api/accessLists'
+import * as securityApi from '../../api/security'
+import { useAuth } from '../../hooks/useAuth'
+import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
+import { AccessListGeoNote } from '../AccessListGeoNote'
+
+import type { AccessListSelfCheckDraft, AccessListSelfCheckResult } from '../../api/accessLists'
+import type { GeoIPStatus } from '../../api/security'
+
+vi.mock('../../api/accessLists')
+vi.mock('../../api/security')
+vi.mock('../../hooks/useAuth')
+
+const draft: AccessListSelfCheckDraft = {
+ type: 'geo_blacklist',
+ country_codes: 'RU,CN',
+ ip_rules: '',
+ local_network_only: false,
+ enabled: true,
+}
+
+const bundledStatus: GeoIPStatus = {
+ available: true,
+ loaded: true,
+ db_path: '/app/geoip/GeoLite2-Country.mmdb',
+ message: 'GeoIP service available',
+ source: 'bundled',
+ database_type: 'GeoLite2-Country',
+ build_date: null,
+ size_bytes: null,
+ loaded_at: null,
+ included_build_date: null,
+ data_copy_present: false,
+ can_replace: true,
+ replace_blocked_reason: '',
+ unenforced_lists_total: 0,
+}
+
+const unavailableStatus: GeoIPStatus = {
+ ...bundledStatus,
+ available: false,
+ loaded: false,
+ reason: 'geoip_unavailable',
+ source: 'none',
+ database_type: '',
+ message: 'GeoIP database unavailable',
+ unenforced_lists_total: 1,
+}
+
+const selfCheckResult = (overrides: Partial = {}): AccessListSelfCheckResult => ({
+ ip_address: '203.0.113.7',
+ country_code: 'RU',
+ allowed: true,
+ admin_exempt: false,
+ geoip_available: true,
+ reason: 'allowed',
+ ...overrides,
+})
+
+const asAuth = (role: 'admin' | 'user' | null) =>
+ ({ user: role === null ? null : { user_id: 1, role } }) as unknown as ReturnType
+
+/** Lets the status query and any debounced self-check settle before asserting that nothing is shown. */
+const settle = async () => {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ })
+}
+
+describe('', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ vi.mocked(useAuth).mockReturnValue(asAuth('admin'))
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(bundledStatus)
+ vi.mocked(accessListsApi.selfCheckAccessList).mockResolvedValue(selfCheckResult())
+ })
+
+ describe('database not available (priority a)', () => {
+ it('shows the not-enforced line for an admin', async () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(unavailableStatus)
+ renderWithQueryClient()
+
+ expect(await screen.findByRole('status')).toHaveTextContent(
+ 'This list will not be enforced until the GeoIP database is available.'
+ )
+ })
+
+ it('shows the not-enforced line for a non-admin without running the self-check', async () => {
+ vi.mocked(useAuth).mockReturnValue(asAuth('user'))
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(unavailableStatus)
+ renderWithQueryClient()
+
+ expect(await screen.findByRole('status')).toHaveTextContent('will not be enforced')
+ await settle()
+ expect(accessListsApi.selfCheckAccessList).not.toHaveBeenCalled()
+ })
+
+ it('takes priority over a self-check refusal', async () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(unavailableStatus)
+ vi.mocked(accessListsApi.selfCheckAccessList).mockResolvedValue(
+ selfCheckResult({ allowed: false, admin_exempt: false, reason: 'country_blocked' })
+ )
+ renderWithQueryClient()
+
+ expect(await screen.findByRole('status')).toHaveTextContent('will not be enforced')
+ await waitFor(() => expect(accessListsApi.selfCheckAccessList).toHaveBeenCalled())
+ expect(screen.getByRole('status')).not.toHaveTextContent('would refuse you')
+ })
+ })
+
+ describe('self-check (priority b, admin only)', () => {
+ it('shows the would-refuse line when the viewer address is refused', async () => {
+ vi.mocked(accessListsApi.selfCheckAccessList).mockResolvedValue(
+ selfCheckResult({ allowed: false, admin_exempt: false, reason: 'country_blocked' })
+ )
+ renderWithQueryClient()
+
+ expect(await screen.findByRole('status')).toHaveTextContent(
+ 'Based on the address you are using now, this list would refuse you. Saving is still allowed.'
+ )
+ expect(accessListsApi.selfCheckAccessList).toHaveBeenCalledWith(draft)
+ })
+
+ it('shows nothing when the refusal is admin-exempt', async () => {
+ vi.mocked(accessListsApi.selfCheckAccessList).mockResolvedValue(
+ selfCheckResult({ allowed: false, admin_exempt: true, reason: 'admin_exempt' })
+ )
+ renderWithQueryClient()
+
+ await waitFor(() => expect(accessListsApi.selfCheckAccessList).toHaveBeenCalled())
+ await settle()
+ expect(screen.queryByRole('status')).not.toBeInTheDocument()
+ })
+
+ it('shows nothing when the address is allowed', async () => {
+ renderWithQueryClient()
+
+ await waitFor(() => expect(accessListsApi.selfCheckAccessList).toHaveBeenCalled())
+ await settle()
+ expect(screen.queryByRole('status')).not.toBeInTheDocument()
+ })
+
+ it('shows nothing when the self-check request fails', async () => {
+ vi.mocked(accessListsApi.selfCheckAccessList).mockRejectedValue(new Error('request failed'))
+ renderWithQueryClient()
+
+ await waitFor(() => expect(accessListsApi.selfCheckAccessList).toHaveBeenCalled())
+ await settle()
+ expect(screen.queryByRole('status')).not.toBeInTheDocument()
+ })
+
+ it('does not run for a non-admin', async () => {
+ vi.mocked(useAuth).mockReturnValue(asAuth('user'))
+ vi.mocked(accessListsApi.selfCheckAccessList).mockResolvedValue(
+ selfCheckResult({ allowed: false, admin_exempt: false })
+ )
+ renderWithQueryClient()
+
+ await waitFor(() => expect(securityApi.getGeoIPStatus).toHaveBeenCalled())
+ await settle()
+ expect(accessListsApi.selfCheckAccessList).not.toHaveBeenCalled()
+ expect(screen.queryByRole('status')).not.toBeInTheDocument()
+ })
+
+ it('does not run when there is no session user', async () => {
+ vi.mocked(useAuth).mockReturnValue(asAuth(null))
+ renderWithQueryClient()
+
+ await waitFor(() => expect(securityApi.getGeoIPStatus).toHaveBeenCalled())
+ await settle()
+ expect(accessListsApi.selfCheckAccessList).not.toHaveBeenCalled()
+ })
+
+ it('does not run while the country list is empty', async () => {
+ renderWithQueryClient()
+
+ await waitFor(() => expect(securityApi.getGeoIPStatus).toHaveBeenCalled())
+ await settle()
+ expect(accessListsApi.selfCheckAccessList).not.toHaveBeenCalled()
+ expect(screen.queryByRole('status')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('status not known yet', () => {
+ it('shows nothing while the status is loading', () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockReturnValue(new Promise(() => {}))
+ renderWithQueryClient()
+
+ expect(screen.queryByRole('status')).not.toBeInTheDocument()
+ })
+
+ it('shows nothing when the status request fails', async () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockRejectedValue(new Error('request failed'))
+ renderWithQueryClient()
+
+ await waitFor(() => expect(securityApi.getGeoIPStatus).toHaveBeenCalled())
+ await settle()
+ expect(screen.queryByRole('status')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/frontend/src/components/__tests__/GeoIPDatabaseCard.test.tsx b/frontend/src/components/__tests__/GeoIPDatabaseCard.test.tsx
new file mode 100644
index 000000000..14ea57885
--- /dev/null
+++ b/frontend/src/components/__tests__/GeoIPDatabaseCard.test.tsx
@@ -0,0 +1,348 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import * as securityApi from '../../api/security'
+import { GEOIP_TROUBLESHOOTING_DOCS_URL } from '../../constants/docs'
+import { GeoIPDatabaseCard } from '../GeoIPDatabaseCard'
+
+import type { GeoIPStatus } from '../../api/security'
+
+vi.mock('../../api/security')
+
+const bundled: GeoIPStatus = {
+ available: true,
+ loaded: true,
+ db_path: '/app/geoip/GeoLite2-Country.mmdb',
+ message: 'GeoIP service available',
+ source: 'bundled',
+ database_type: 'GeoLite2-Country',
+ build_date: '2026-11-04T07:12:33Z',
+ size_bytes: 9_876_543,
+ loaded_at: '2026-11-10T05:00:01Z',
+ included_build_date: '2026-11-04T07:12:33Z',
+ data_copy_present: false,
+ can_replace: true,
+ replace_blocked_reason: '',
+ unenforced_lists_total: 0,
+}
+
+const status = (overrides: Partial = {}): GeoIPStatus => ({ ...bundled, ...overrides })
+
+/** An error shaped like an Axios refusal carrying the server's error_code. */
+const refusal = (errorCode: string | undefined, httpStatus = 400): Error =>
+ Object.assign(new Error('request failed'), {
+ isAxiosError: true,
+ response: { status: httpStatus, data: errorCode ? { error: 'refused', error_code: errorCode } : { error: 'refused' } },
+ })
+
+const openCard = () => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ render(
+
+
+
+ )
+ return screen.findByRole('region', { name: 'Country database (GeoIP)' })
+}
+
+const statusRegion = (region: HTMLElement) => within(region).getByRole('status')
+
+describe('', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(bundled)
+ })
+
+ describe('states', () => {
+ it('shows a skeleton while the status loads', () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockReturnValue(new Promise(() => {}))
+ const queryClient = new QueryClient()
+ render(
+
+
+
+ )
+ expect(screen.getByTestId('geoip-card-skeleton')).toBeInTheDocument()
+ })
+
+ it('shows a load error when the status request fails', async () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockRejectedValue(new Error('network'))
+ const region = await openCard()
+ expect(await within(region).findByText('The database status could not be loaded.')).toBeInTheDocument()
+ expect(within(region).queryByRole('button', { name: 'Reload' })).not.toBeInTheDocument()
+ })
+
+ it('shows the bundled source, path, size and build date with Reload and Replace', async () => {
+ const region = await openCard()
+
+ expect(await within(region).findByText('Ready')).toBeInTheDocument()
+ expect(region).toHaveTextContent('Included with Charon')
+ expect(region).toHaveTextContent('/app/geoip/GeoLite2-Country.mmdb')
+ expect(region).toHaveTextContent('9.9 MB')
+ expect(region).toHaveTextContent('Nov 4, 2026')
+ expect(within(region).getByRole('button', { name: 'Reload' })).toBeInTheDocument()
+ expect(within(region).getByRole('button', { name: 'Replace database…' })).toBeInTheDocument()
+ })
+
+ it('offers no removal action for the bundled source', async () => {
+ const region = await openCard()
+ await within(region).findByText('Ready')
+ expect(within(region).queryByRole('button', { name: 'Use the included copy' })).not.toBeInTheDocument()
+ })
+
+ it('shows unknown values and the not-enforced count when the database is unavailable', async () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(
+ status({
+ available: false,
+ loaded: false,
+ source: 'none',
+ reason: 'geoip_unavailable',
+ build_date: null,
+ size_bytes: null,
+ included_build_date: null,
+ unenforced_lists_total: 2,
+ })
+ )
+ const region = await openCard()
+
+ expect(await within(region).findByText('Not available')).toBeInTheDocument()
+ expect(region).toHaveTextContent('Unknown')
+ expect(region).toHaveTextContent('Not available. Country lists are not enforced until a database is loaded.')
+ expect(region).toHaveTextContent('2 country list(s) are not enforced')
+ const docs = within(region).getByRole('link', { name: 'How to provide a database' })
+ expect(docs).toHaveAttribute('href', GEOIP_TROUBLESHOOTING_DOCS_URL)
+ expect(docs).toHaveAttribute('target', '_blank')
+ expect(docs).toHaveAttribute('rel', 'noopener noreferrer')
+ })
+
+ it('shows the data-folder source with the removal action', async () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(status({ source: 'data', data_copy_present: true }))
+ const region = await openCard()
+
+ expect(await within(region).findByText('Your data folder')).toBeInTheDocument()
+ expect(within(region).getByRole('button', { name: 'Use the included copy' })).toBeInTheDocument()
+ })
+
+ it('hides Replace and removal and explains a server-set custom path, keeping Reload', async () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(
+ status({ source: 'custom', can_replace: false, replace_blocked_reason: 'custom_path_set' })
+ )
+ const region = await openCard()
+
+ expect(await within(region).findByText('Custom path set on the server')).toBeInTheDocument()
+ expect(region).toHaveTextContent('The database path is set by the server (CHARON_GEOIP_DB_PATH)')
+ expect(within(region).getByRole('button', { name: 'Reload' })).toBeInTheDocument()
+ expect(within(region).queryByRole('button', { name: 'Replace database…' })).not.toBeInTheDocument()
+ expect(within(region).queryByRole('button', { name: 'Use the included copy' })).not.toBeInTheDocument()
+ expect(within(region).queryByText(/up to 32 MiB/)).not.toBeInTheDocument()
+ })
+
+ it('hides Replace and removal on a read-only data folder and explains why', async () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(
+ status({ source: 'data', can_replace: false, replace_blocked_reason: 'data_folder_read_only' })
+ )
+ const region = await openCard()
+
+ expect(await within(region).findByText('The data folder is read-only, so the database cannot be replaced here.')).toBeInTheDocument()
+ expect(within(region).queryByRole('button', { name: 'Replace database…' })).not.toBeInTheDocument()
+ expect(within(region).queryByRole('button', { name: 'Use the included copy' })).not.toBeInTheDocument()
+ })
+
+ it('omits the size, date and path fields when they are unknown', async () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(
+ status({ build_date: 'not a date', size_bytes: null, db_path: '' })
+ )
+ const region = await openCard()
+ await within(region).findByText('Ready')
+ expect(region).not.toHaveTextContent('Nov 4, 2026')
+ expect(region).toHaveTextContent('Unknown')
+ })
+
+ it('uses the no-bundled confirmation text when the image ships no database', async () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(
+ status({ source: 'data', data_copy_present: true, included_build_date: null })
+ )
+ const region = await openCard()
+ fireEvent.click(await within(region).findByRole('button', { name: 'Use the included copy' }))
+
+ const dialog = await screen.findByRole('dialog')
+ expect(dialog).toHaveTextContent('Country lists will not be enforced until you provide a database.')
+ })
+ })
+
+ describe('reload', () => {
+ it('reports success inline in a polite status region, not as a toast', async () => {
+ vi.mocked(securityApi.reloadGeoIP).mockResolvedValue({ message: 'ok' })
+ const region = await openCard()
+ await within(region).findByText('Ready')
+
+ fireEvent.click(within(region).getByRole('button', { name: 'Reload' }))
+
+ await waitFor(() => expect(statusRegion(region)).toHaveTextContent('Database reloaded.'))
+ expect(statusRegion(region)).toHaveAttribute('aria-live', 'polite')
+ expect(statusRegion(region)).toHaveClass('text-content-secondary')
+ })
+
+ it('reports a failed reload with the mapped error text', async () => {
+ vi.mocked(securityApi.reloadGeoIP).mockRejectedValue(refusal('geoip_reload_failed', 500))
+ const region = await openCard()
+ await within(region).findByText('Ready')
+
+ fireEvent.click(within(region).getByRole('button', { name: 'Reload' }))
+
+ await waitFor(() => expect(statusRegion(region)).toHaveTextContent('Charon could not load the database file.'))
+ expect(statusRegion(region)).toHaveClass('text-error')
+ })
+ })
+
+ describe('replace', () => {
+ it('states the 32 MiB size limit as a hint', async () => {
+ const region = await openCard()
+ expect(await within(region).findByText('Upload a GeoIP Country database (.mmdb), up to 32 MiB.')).toBeInTheDocument()
+ })
+
+ it('opens the file chooser from the Replace button', async () => {
+ const region = await openCard()
+ const replaceButton = await within(region).findByRole('button', { name: 'Replace database…' })
+ const input = within(region).getByTestId('geoip-file-input')
+ const clickSpy = vi.spyOn(input as HTMLInputElement, 'click')
+
+ fireEvent.click(replaceButton)
+
+ expect(clickSpy).toHaveBeenCalledTimes(1)
+ expect(input).toHaveAttribute('accept', '.mmdb')
+ })
+
+ it('uploads the chosen file and reports success inline', async () => {
+ vi.mocked(securityApi.replaceGeoIPDatabase).mockResolvedValue({ status: status({ source: 'data' }), replaced_previous: false })
+ const region = await openCard()
+ await within(region).findByText('Ready')
+ const input = within(region).getByTestId('geoip-file-input') as HTMLInputElement
+ const file = new File(['mmdb'], 'GeoLite2-Country.mmdb')
+
+ fireEvent.change(input, { target: { files: [file] } })
+
+ await waitFor(() => expect(securityApi.replaceGeoIPDatabase).toHaveBeenCalledWith(file))
+ await waitFor(() =>
+ expect(statusRegion(region)).toHaveTextContent('Database replaced. Charon is now using your copy.')
+ )
+ })
+
+ it('ignores a change event without a file', async () => {
+ const region = await openCard()
+ await within(region).findByText('Ready')
+ const input = within(region).getByTestId('geoip-file-input') as HTMLInputElement
+
+ fireEvent.change(input, { target: { files: [] } })
+
+ expect(securityApi.replaceGeoIPDatabase).not.toHaveBeenCalled()
+ })
+
+ it('maps a text-file refusal to its inline error', async () => {
+ vi.mocked(securityApi.replaceGeoIPDatabase).mockRejectedValue(refusal('geoip_not_mmdb'))
+ const region = await openCard()
+ await within(region).findByText('Ready')
+ const input = within(region).getByTestId('geoip-file-input') as HTMLInputElement
+
+ fireEvent.change(input, { target: { files: [new File(['text'], 'notes.txt', { type: 'text/plain' })] } })
+
+ await waitFor(() => expect(statusRegion(region)).toHaveTextContent('That file is not a GeoIP database.'))
+ expect(statusRegion(region)).toHaveClass('text-error')
+ })
+
+ it.each([
+ ['geoip_too_large', 'That file is larger than 32 MiB.'],
+ ['geoip_wrong_type', 'That is a GeoIP database, but not a country database.'],
+ ['geoip_older_than_current', 'That database is older than the one in use. Upload a newer one.'],
+ ['geoip_build_date_in_future', 'That database has a build date in the future.'],
+ ['geoip_busy', 'Another database change is in progress. Try again in a moment.'],
+ ['proxy_apply_failed', 'Charon could not apply the proxy configuration.'],
+ ])('maps %s to its text', async (code, text) => {
+ vi.mocked(securityApi.replaceGeoIPDatabase).mockRejectedValue(refusal(code))
+ const region = await openCard()
+ await within(region).findByText('Ready')
+ const input = within(region).getByTestId('geoip-file-input') as HTMLInputElement
+
+ fireEvent.change(input, { target: { files: [new File(['x'], 'a.mmdb')] } })
+
+ await waitFor(() => expect(statusRegion(region)).toHaveTextContent(text))
+ })
+
+ it('falls back to the generic text for an unknown error code', async () => {
+ vi.mocked(securityApi.replaceGeoIPDatabase).mockRejectedValue(refusal('something_new'))
+ const region = await openCard()
+ await within(region).findByText('Ready')
+ const input = within(region).getByTestId('geoip-file-input') as HTMLInputElement
+
+ fireEvent.change(input, { target: { files: [new File(['x'], 'a.mmdb')] } })
+
+ await waitFor(() => expect(statusRegion(region)).toHaveTextContent('Something went wrong. Try again.'))
+ })
+
+ it('falls back to the generic text for a refusal with no body', async () => {
+ vi.mocked(securityApi.replaceGeoIPDatabase).mockRejectedValue(new Error('network down'))
+ const region = await openCard()
+ await within(region).findByText('Ready')
+ const input = within(region).getByTestId('geoip-file-input') as HTMLInputElement
+
+ fireEvent.change(input, { target: { files: [new File(['x'], 'a.mmdb')] } })
+
+ await waitFor(() => expect(statusRegion(region)).toHaveTextContent('Something went wrong. Try again.'))
+ })
+ })
+
+ describe('use the included copy', () => {
+ const dataSource = () => status({ source: 'data', data_copy_present: true })
+
+ it('asks for confirmation before removing anything', async () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(dataSource())
+ const region = await openCard()
+
+ fireEvent.click(await within(region).findByRole('button', { name: 'Use the included copy' }))
+
+ const dialog = await screen.findByRole('dialog')
+ expect(dialog).toHaveTextContent('Your copy in the data folder will be removed')
+ expect(securityApi.removeGeoIPDatabase).not.toHaveBeenCalled()
+ })
+
+ it('closes the dialog without removing when cancelled', async () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(dataSource())
+ const region = await openCard()
+ fireEvent.click(await within(region).findByRole('button', { name: 'Use the included copy' }))
+ const dialog = await screen.findByRole('dialog')
+
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }))
+
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ expect(securityApi.removeGeoIPDatabase).not.toHaveBeenCalled()
+ })
+
+ it('removes the data-folder copy on confirm and reports it inline', async () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(dataSource())
+ vi.mocked(securityApi.removeGeoIPDatabase).mockResolvedValue({ status: bundled })
+ const region = await openCard()
+ fireEvent.click(await within(region).findByRole('button', { name: 'Use the included copy' }))
+ const dialog = await screen.findByRole('dialog')
+
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Use the included copy' }))
+
+ await waitFor(() => expect(securityApi.removeGeoIPDatabase).toHaveBeenCalledTimes(1))
+ await waitFor(() => expect(statusRegion(region)).toHaveTextContent('Charon is using the included copy again.'))
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ })
+
+ it('keeps the dialog open with the mapped error when removal is refused', async () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(dataSource())
+ vi.mocked(securityApi.removeGeoIPDatabase).mockRejectedValue(refusal('geoip_no_data_copy', 404))
+ const region = await openCard()
+ fireEvent.click(await within(region).findByRole('button', { name: 'Use the included copy' }))
+ const dialog = await screen.findByRole('dialog')
+
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Use the included copy' }))
+
+ await waitFor(() => expect(statusRegion(region)).toHaveTextContent('There is no copy in the data folder to remove.'))
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ })
+ })
+})
diff --git a/frontend/src/components/__tests__/GeoIPLookupForm.test.tsx b/frontend/src/components/__tests__/GeoIPLookupForm.test.tsx
new file mode 100644
index 000000000..c15209631
--- /dev/null
+++ b/frontend/src/components/__tests__/GeoIPLookupForm.test.tsx
@@ -0,0 +1,143 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import * as securityApi from '../../api/security'
+import * as systemApi from '../../api/system'
+import { GeoIPLookupForm } from '../GeoIPLookupForm'
+
+vi.mock('../../api/security')
+vi.mock('../../api/system')
+
+const refusal = (httpStatus: number, errorCode?: string): Error =>
+ Object.assign(new Error('request failed'), {
+ isAxiosError: true,
+ response: { status: httpStatus, data: errorCode ? { error: 'refused', error_code: errorCode } : { error: 'refused' } },
+ })
+
+const renderForm = (loaded = true) => {
+ const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
+ render(
+
+
+
+ )
+ return {
+ input: screen.getByRole('textbox', { name: 'IP address' }),
+ lookup: screen.getByRole('button', { name: 'Look up' }),
+ myAddress: screen.getByRole('button', { name: 'Use my address' }),
+ result: () => screen.getByTestId('geoip-lookup-result'),
+ }
+}
+
+describe('', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ })
+
+ it('disables the field and lookup with a hint while no database is loaded', () => {
+ const view = renderForm(false)
+
+ expect(view.input).toBeDisabled()
+ expect(view.lookup).toBeDisabled()
+ expect(screen.getByText('Lookup is available once a database is loaded.')).toBeInTheDocument()
+ })
+
+ it('keeps Look up disabled until an address is entered', () => {
+ const view = renderForm()
+ expect(view.lookup).toBeDisabled()
+
+ fireEvent.change(view.input, { target: { value: ' ' } })
+ expect(view.lookup).toBeDisabled()
+
+ fireEvent.change(view.input, { target: { value: '203.0.113.7' } })
+ expect(view.lookup).toBeEnabled()
+ })
+
+ it('looks up the trimmed address and names the country', async () => {
+ vi.mocked(securityApi.lookupGeoIP).mockResolvedValue({ ip_address: '203.0.113.7', country_code: 'DE', found: true })
+ const view = renderForm()
+
+ fireEvent.change(view.input, { target: { value: ' 203.0.113.7 ' } })
+ fireEvent.click(view.lookup)
+
+ await waitFor(() => expect(securityApi.lookupGeoIP).toHaveBeenCalledWith('203.0.113.7'))
+ expect(await screen.findByText('203.0.113.7 is in Germany.')).toBeInTheDocument()
+ expect(view.result()).toHaveAttribute('aria-live', 'polite')
+ })
+
+ it('falls back to the code when the country name cannot be resolved', async () => {
+ vi.mocked(securityApi.lookupGeoIP).mockResolvedValue({ ip_address: '198.51.100.1', country_code: 'X1', found: true })
+ const view = renderForm()
+
+ fireEvent.change(view.input, { target: { value: '198.51.100.1' } })
+ fireEvent.click(view.lookup)
+
+ expect(await screen.findByText(/198\.51\.100\.1 is in/)).toBeInTheDocument()
+ })
+
+ it('reports that no country was found', async () => {
+ vi.mocked(securityApi.lookupGeoIP).mockResolvedValue({
+ ip_address: '127.0.0.1',
+ country_code: '',
+ found: false,
+ message: 'No country found for this IP address',
+ })
+ const view = renderForm()
+
+ fireEvent.change(view.input, { target: { value: '127.0.0.1' } })
+ fireEvent.click(view.lookup)
+
+ expect(await screen.findByText('No country found for 127.0.0.1.')).toBeInTheDocument()
+ })
+
+ it('reports an unparseable address (400)', async () => {
+ vi.mocked(securityApi.lookupGeoIP).mockRejectedValue(refusal(400))
+ const view = renderForm()
+
+ fireEvent.change(view.input, { target: { value: 'not-an-ip' } })
+ fireEvent.click(view.lookup)
+
+ expect(await screen.findByText('Enter a valid IP address.')).toBeInTheDocument()
+ })
+
+ it('reports that no database is loaded (503)', async () => {
+ vi.mocked(securityApi.lookupGeoIP).mockRejectedValue(refusal(503))
+ const view = renderForm()
+
+ fireEvent.change(view.input, { target: { value: '203.0.113.7' } })
+ fireEvent.click(view.lookup)
+
+ expect(await screen.findByText('No database is loaded, so addresses cannot be looked up.')).toBeInTheDocument()
+ })
+
+ it('maps a coded refusal to its text', async () => {
+ vi.mocked(securityApi.lookupGeoIP).mockRejectedValue(refusal(409, 'geoip_busy'))
+ const view = renderForm()
+
+ fireEvent.change(view.input, { target: { value: '203.0.113.7' } })
+ fireEvent.click(view.lookup)
+
+ expect(await screen.findByText('Another database change is in progress. Try again in a moment.')).toBeInTheDocument()
+ })
+
+ it('fills the field with the address Charon sees for this browser', async () => {
+ vi.mocked(systemApi.getMyIP).mockResolvedValue({ ip: '203.0.113.9', source: 'remote' })
+ const view = renderForm()
+
+ fireEvent.click(view.myAddress)
+
+ await waitFor(() => expect(view.input).toHaveValue('203.0.113.9'))
+ expect(systemApi.getMyIP).toHaveBeenCalledTimes(1)
+ })
+
+ it('reports a failure to read the browser address', async () => {
+ vi.mocked(systemApi.getMyIP).mockRejectedValue(new Error('offline'))
+ const view = renderForm()
+
+ fireEvent.click(view.myAddress)
+
+ expect(await screen.findByText('Charon could not read your address.')).toBeInTheDocument()
+ expect(view.input).toHaveValue('')
+ })
+})
diff --git a/frontend/src/components/__tests__/Layout.test.tsx b/frontend/src/components/__tests__/Layout.test.tsx
index fae7918e8..696b30c8c 100644
--- a/frontend/src/components/__tests__/Layout.test.tsx
+++ b/frontend/src/components/__tests__/Layout.test.tsx
@@ -47,6 +47,7 @@ vi.mock('../../api/featureFlags', () => ({
// Mock System API to prevent unhandled network requests
vi.mock('../../api/system', () => ({
getNotifications: vi.fn().mockResolvedValue([]),
+ getActiveAlerts: vi.fn().mockResolvedValue([]),
markNotificationRead: vi.fn(),
markAllNotificationsRead: vi.fn(),
checkUpdates: vi.fn().mockResolvedValue({ available: false }),
diff --git a/frontend/src/components/__tests__/NotificationCenter.test.tsx b/frontend/src/components/__tests__/NotificationCenter.test.tsx
index 70662cb18..0523089dc 100644
--- a/frontend/src/components/__tests__/NotificationCenter.test.tsx
+++ b/frontend/src/components/__tests__/NotificationCenter.test.tsx
@@ -1,9 +1,12 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { render, screen, waitFor } from '@testing-library/react'
+import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import * as api from '../../api/system'
+import { ALERT_PRESENTATION } from '../../constants/alerts'
+import { GEOIP_TROUBLESHOOTING_DOCS_URL } from '../../constants/docs'
+import { GITHUB_BUG_REPORT_URL } from '../../constants/links'
import NotificationCenter from '../NotificationCenter'
// Mock the API
@@ -12,6 +15,7 @@ vi.mock('../../api/system', () => ({
markNotificationRead: vi.fn(),
markAllNotificationsRead: vi.fn(),
checkUpdates: vi.fn(),
+ getActiveAlerts: vi.fn(),
}))
const createWrapper = () => {
@@ -62,6 +66,18 @@ const mockNotifications: api.Notification[] = [
},
]
+const geoipAlert: api.ActiveAlert = {
+ key: 'geoip_unavailable',
+ severity: 'error',
+ affected_lists: ['No RU/CN', 'Office only'],
+ affected_lists_total: 2,
+ database_available: false,
+}
+
+const GEOIP_TITLE = 'Country access lists are not being enforced'
+const GEOIP_MESSAGE =
+ 'The GeoIP country database is missing or unusable, so 2 country list(s) (No RU/CN and Office only) let every visitor through until it is available again.'
+
describe('NotificationCenter', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -71,6 +87,7 @@ describe('NotificationCenter', () => {
latest_version: '0.0.0',
changelog_url: '',
})
+ vi.mocked(api.getActiveAlerts).mockResolvedValue([])
})
afterEach(() => {
@@ -131,7 +148,7 @@ describe('NotificationCenter', () => {
expect(screen.getByText('Info Notification')).toBeInTheDocument()
})
- const closeButtons = screen.getAllByRole('button', { name: /close/i })
+ const closeButtons = screen.getAllByRole('button', { name: 'Dismiss' })
await user.click(closeButtons[0])
await waitFor(() => {
@@ -192,7 +209,7 @@ describe('NotificationCenter', () => {
const link = await screen.findByRole('link', { name: /view changelog/i })
expect(link).toHaveAttribute('href', 'https://github.com/Wikid82/Charon/releases/tag/v0.46.6')
- expect(screen.getByText('Update Available: 0.46.6')).toBeInTheDocument()
+ expect(screen.getByText('Update available: 0.46.6')).toBeInTheDocument()
})
it('hides the changelog link when the update has no changelog_url', async () => {
@@ -208,7 +225,250 @@ describe('NotificationCenter', () => {
await user.click(screen.getByRole('button', { name: /notifications/i }))
- await screen.findByText('Update Available: 0.46.6')
+ await screen.findByText('Update available: 0.46.6')
expect(screen.queryByRole('link', { name: /view changelog/i })).toBeNull()
})
+
+ describe('active alerts', () => {
+ const openBell = async (user: ReturnType) => {
+ await user.click(screen.getByRole('button', { name: /notifications/i }))
+ }
+
+ it('shows an error alert first, in a labelled region, with its title and body', async () => {
+ const user = userEvent.setup()
+ vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
+ vi.mocked(api.getActiveAlerts).mockResolvedValue([geoipAlert])
+ render(, { wrapper: createWrapper() })
+
+ await openBell(user)
+
+ const region = await screen.findByRole('region', { name: 'Needs attention' })
+ expect(region).toHaveTextContent(GEOIP_TITLE)
+ expect(region).toHaveTextContent(GEOIP_MESSAGE)
+ expect(region).toHaveTextContent('This clears by itself when the problem is fixed.')
+ const firstNotification = screen.getByText('Info Notification')
+ expect(region.compareDocumentPosition(firstNotification) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+ })
+
+ it('turns the bell red and counts the alert in the badge and the accessible label', async () => {
+ const user = userEvent.setup()
+ vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
+ vi.mocked(api.getActiveAlerts).mockResolvedValue([geoipAlert])
+ render(, { wrapper: createWrapper() })
+
+ const bell = await screen.findByRole('button', { name: 'Notifications, 5 new' })
+ await waitFor(() => expect(within(bell).getByText('5')).toBeInTheDocument())
+ expect(bell).toHaveClass('text-red-500')
+
+ await user.click(bell)
+ expect(await screen.findByRole('region', { name: 'Needs attention' })).toBeInTheDocument()
+ })
+
+ it('labels the bell with the alert alone when nothing else is unread', async () => {
+ vi.mocked(api.getNotifications).mockResolvedValue([])
+ vi.mocked(api.getActiveAlerts).mockResolvedValue([geoipAlert])
+ render(, { wrapper: createWrapper() })
+
+ const bell = await screen.findByRole('button', { name: 'Notifications, 1 new' })
+ await waitFor(() => expect(within(bell).getByText('1')).toBeInTheDocument())
+ expect(bell).toHaveClass('text-red-500')
+ })
+
+ it('links to the GeoIP docs page in a new tab and to the bug report form', async () => {
+ const user = userEvent.setup()
+ vi.mocked(api.getNotifications).mockResolvedValue([])
+ vi.mocked(api.getActiveAlerts).mockResolvedValue([geoipAlert])
+ render(, { wrapper: createWrapper() })
+
+ await openBell(user)
+ const region = await screen.findByRole('region', { name: 'Needs attention' })
+
+ const docsLink = within(region).getByRole('link', { name: /How to provide the database/ })
+ expect(docsLink).toHaveAttribute('href', GEOIP_TROUBLESHOOTING_DOCS_URL)
+ expect(docsLink).toHaveAttribute('target', '_blank')
+ expect(docsLink).toHaveAttribute('rel', 'noopener noreferrer')
+
+ const issueLink = within(region).getByRole('link', { name: /Open an issue/ })
+ expect(issueLink).toHaveAttribute('href', GITHUB_BUG_REPORT_URL)
+ expect(issueLink).toHaveAttribute('target', '_blank')
+ expect(issueLink).toHaveAttribute('rel', 'noopener noreferrer')
+ })
+
+ it('offers no dismiss control on an alert and leaves it in place after Mark all read', async () => {
+ const user = userEvent.setup()
+ vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
+ vi.mocked(api.getActiveAlerts).mockResolvedValue([geoipAlert])
+ vi.mocked(api.markAllNotificationsRead).mockResolvedValue()
+ render(, { wrapper: createWrapper() })
+
+ await openBell(user)
+ const region = await screen.findByRole('region', { name: 'Needs attention' })
+ expect(within(region).queryByRole('button')).toBeNull()
+
+ await user.click(screen.getByRole('button', { name: 'Mark all read' }))
+
+ await waitFor(() => expect(api.markAllNotificationsRead).toHaveBeenCalled())
+ expect(screen.getByRole('region', { name: 'Needs attention' })).toHaveTextContent(GEOIP_TITLE)
+ })
+
+ it('uses the pending-apply message once a database is available again', async () => {
+ const user = userEvent.setup()
+ vi.mocked(api.getNotifications).mockResolvedValue([])
+ vi.mocked(api.getActiveAlerts).mockResolvedValue([{ ...geoipAlert, database_available: true }])
+ render(, { wrapper: createWrapper() })
+
+ await openBell(user)
+
+ const region = await screen.findByRole('region', { name: 'Needs attention' })
+ expect(region).toHaveTextContent(
+ 'The GeoIP database is available again, but 2 country list(s) (No RU/CN and Office only) are not enforced yet. Press Reload on the Country database card.',
+ )
+ expect(region).not.toHaveTextContent('is missing or unusable')
+ })
+
+ it('renders list names as plain text, never as markup', async () => {
+ const user = userEvent.setup()
+ vi.mocked(api.getNotifications).mockResolvedValue([])
+ vi.mocked(api.getActiveAlerts).mockResolvedValue([
+ { ...geoipAlert, affected_lists: ['bold'], affected_lists_total: 1 },
+ ])
+ render(, { wrapper: createWrapper() })
+
+ await openBell(user)
+
+ const region = await screen.findByRole('region', { name: 'Needs attention' })
+ expect(region).toHaveTextContent('(bold)')
+ expect(screen.queryByText('bold', { selector: 'b' })).toBeNull()
+ })
+
+ it('joins affected list names with a conjunction and counts the full total', async () => {
+ const user = userEvent.setup()
+ vi.mocked(api.getNotifications).mockResolvedValue([])
+ vi.mocked(api.getActiveAlerts).mockResolvedValue([
+ {
+ ...geoipAlert,
+ affected_lists: ['Alpha', 'Beta', 'Gamma'],
+ affected_lists_total: 7,
+ },
+ ])
+ render(, { wrapper: createWrapper() })
+
+ await openBell(user)
+
+ const region = await screen.findByRole('region', { name: 'Needs attention' })
+ expect(region).toHaveTextContent('so 7 country list(s) (Alpha, Beta, and Gamma)')
+ })
+
+ it('renders an alert without affected lists as an empty list, not an error', async () => {
+ const user = userEvent.setup()
+ vi.mocked(api.getNotifications).mockResolvedValue([])
+ vi.mocked(api.getActiveAlerts).mockResolvedValue([{ key: 'geoip_unavailable', severity: 'error' }])
+ render(, { wrapper: createWrapper() })
+
+ await openBell(user)
+
+ const region = await screen.findByRole('region', { name: 'Needs attention' })
+ expect(region).toHaveTextContent('so 0 country list(s) () let every visitor')
+ })
+
+ it('does not render or count an alert with an unknown key and logs nothing', async () => {
+ const user = userEvent.setup()
+ const consoleSpies = [vi.spyOn(console, 'log'), vi.spyOn(console, 'warn'), vi.spyOn(console, 'error')]
+ vi.mocked(api.getNotifications).mockResolvedValue([])
+ vi.mocked(api.getActiveAlerts).mockResolvedValue([{ key: 'future_alert', severity: 'error' }])
+ render(, { wrapper: createWrapper() })
+
+ await waitFor(() => expect(api.getActiveAlerts).toHaveBeenCalled())
+ expect(screen.getByRole('button', { name: 'Notifications' })).not.toHaveClass('text-red-500')
+ expect(screen.queryByText('future_alert')).toBeNull()
+
+ await openBell(user)
+
+ expect(screen.queryByRole('region', { name: 'Needs attention' })).toBeNull()
+ expect(screen.getByText('No new notifications')).toBeInTheDocument()
+ consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled())
+ consoleSpies.forEach((spy) => spy.mockRestore())
+ })
+
+ it('shows the alert and the update together, counting both', async () => {
+ const user = userEvent.setup()
+ vi.mocked(api.getNotifications).mockResolvedValue([])
+ vi.mocked(api.getActiveAlerts).mockResolvedValue([geoipAlert])
+ vi.mocked(api.checkUpdates).mockResolvedValue({
+ current_version: '0.46.5',
+ available: true,
+ latest_version: '0.46.6',
+ changelog_url: 'https://github.com/Wikid82/Charon/releases/tag/v0.46.6',
+ })
+ render(, { wrapper: createWrapper() })
+
+ const bell = await screen.findByRole('button', { name: 'Notifications, 2 new' })
+ await waitFor(() => expect(within(bell).getByText('2')).toBeInTheDocument())
+
+ await user.click(bell)
+
+ expect(await screen.findByRole('region', { name: 'Needs attention' })).toBeInTheDocument()
+ expect(screen.getByText('Update available: 0.46.6')).toBeInTheDocument()
+ })
+
+ it('turns the bell yellow, not red, for a warning-severity registered alert', async () => {
+ const user = userEvent.setup()
+ vi.mocked(api.getNotifications).mockResolvedValue([])
+ vi.mocked(api.getActiveAlerts).mockResolvedValue([{ ...geoipAlert, severity: 'warning' }])
+ render(, { wrapper: createWrapper() })
+
+ const bell = await screen.findByRole('button', { name: 'Notifications, 1 new' })
+ expect(bell).not.toHaveClass('text-red-500')
+ expect(bell).toHaveClass('text-yellow-500')
+
+ await user.click(bell)
+ expect(await screen.findByRole('region', { name: 'Needs attention' })).toHaveTextContent(GEOIP_TITLE)
+ })
+
+ it('omits the issue link for a registered alert that does not ask for one', async () => {
+ const user = userEvent.setup()
+ const presentation = ALERT_PRESENTATION.geoip_unavailable
+ const originalShowIssueLink = presentation.showIssueLink
+ Object.assign(presentation, { showIssueLink: false })
+ try {
+ vi.mocked(api.getNotifications).mockResolvedValue([])
+ vi.mocked(api.getActiveAlerts).mockResolvedValue([geoipAlert])
+ render(, { wrapper: createWrapper() })
+
+ await openBell(user)
+
+ const region = await screen.findByRole('region', { name: 'Needs attention' })
+ expect(within(region).getByRole('link', { name: /How to provide the database/ })).toBeInTheDocument()
+ expect(within(region).queryByRole('link', { name: /Open an issue/ })).toBeNull()
+ } finally {
+ Object.assign(presentation, { showIssueLink: originalShowIssueLink })
+ }
+ })
+
+ it('hides the Mark all read control and the empty state when only an alert is present', async () => {
+ const user = userEvent.setup()
+ vi.mocked(api.getNotifications).mockResolvedValue([])
+ vi.mocked(api.getActiveAlerts).mockResolvedValue([geoipAlert])
+ render(, { wrapper: createWrapper() })
+
+ await openBell(user)
+
+ await screen.findByRole('region', { name: 'Needs attention' })
+ expect(screen.queryByRole('button', { name: 'Mark all read' })).toBeNull()
+ expect(screen.queryByText('No new notifications')).toBeNull()
+ })
+
+ it('keeps the plain bell label and empty state when there is nothing to show', async () => {
+ const user = userEvent.setup()
+ vi.mocked(api.getNotifications).mockResolvedValue([])
+ render(, { wrapper: createWrapper() })
+
+ const bell = await screen.findByRole('button', { name: 'Notifications' })
+ expect(bell).toHaveClass('text-green-500')
+ expect(within(bell).queryByText(/^\d+$/)).toBeNull()
+
+ await user.click(bell)
+ expect(screen.getByText('No new notifications')).toBeInTheDocument()
+ })
+ })
})
diff --git a/frontend/src/components/__tests__/SecurityEnforcementNote.test.tsx b/frontend/src/components/__tests__/SecurityEnforcementNote.test.tsx
index a947a0754..fddb1efd8 100644
--- a/frontend/src/components/__tests__/SecurityEnforcementNote.test.tsx
+++ b/frontend/src/components/__tests__/SecurityEnforcementNote.test.tsx
@@ -25,7 +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.'],
+ ['acl', 'geoip_unavailable', 'Country lists are not enforced: the GeoIP database is missing from this install. Everyone can reach those hosts 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)
diff --git a/frontend/src/components/dialogs/WhatsNewModal.tsx b/frontend/src/components/dialogs/WhatsNewModal.tsx
index 26e8f46b0..8ea5cb4b1 100644
--- a/frontend/src/components/dialogs/WhatsNewModal.tsx
+++ b/frontend/src/components/dialogs/WhatsNewModal.tsx
@@ -1,6 +1,7 @@
import { useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
+import { GITHUB_REPO_URL } from '../../constants/links'
import { useAuth } from '../../hooks/useAuth'
import { useAckChangelog, useChangelogAll, useChangelogStatus } from '../../hooks/useChangelog'
import { Button } from '../ui/Button'
@@ -16,7 +17,7 @@ import { Label } from '../ui/Label'
import type { ChangelogAckAction, ChangelogEntry } from '../../api/changelog'
-const GITHUB_COMMIT_URL = 'https://github.com/Wikid82/Charon/commit/'
+const GITHUB_COMMIT_URL = `${GITHUB_REPO_URL}/commit/`
/** Commit shas are lowercase hex from `git log`; anything else gets no link (F17). */
const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,40}$/
diff --git a/frontend/src/constants/__tests__/alerts.test.ts b/frontend/src/constants/__tests__/alerts.test.ts
new file mode 100644
index 000000000..c3cdb613d
--- /dev/null
+++ b/frontend/src/constants/__tests__/alerts.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest'
+
+import { ALERT_PRESENTATION, filterKnownAlerts, getAlertPresentation } from '../alerts'
+import { GEOIP_TROUBLESHOOTING_DOCS_URL } from '../docs'
+
+import type { ActiveAlert } from '../../api/system'
+
+const geoip: ActiveAlert = {
+ key: 'geoip_unavailable',
+ severity: 'error',
+ affected_lists: ['No RU/CN'],
+ affected_lists_total: 1,
+ database_available: false,
+}
+
+describe('ALERT_PRESENTATION', () => {
+ it('registers geoip_unavailable with its docs page and issue link', () => {
+ const presentation = ALERT_PRESENTATION.geoip_unavailable
+ expect(presentation.titleKey).toBe('notificationCenter.alerts.geoip_unavailable.title')
+ expect(presentation.docsUrl).toBe(GEOIP_TROUBLESHOOTING_DOCS_URL)
+ expect(presentation.showIssueLink).toBe(true)
+ })
+
+ it('uses the plain message when no database is available', () => {
+ expect(ALERT_PRESENTATION.geoip_unavailable.messageKey(geoip)).toBe(
+ 'notificationCenter.alerts.geoip_unavailable.message',
+ )
+ expect(
+ ALERT_PRESENTATION.geoip_unavailable.messageKey({ ...geoip, database_available: undefined }),
+ ).toBe('notificationCenter.alerts.geoip_unavailable.message')
+ })
+
+ it('uses the pending-apply message when a database is available again', () => {
+ expect(
+ ALERT_PRESENTATION.geoip_unavailable.messageKey({ ...geoip, database_available: true }),
+ ).toBe('notificationCenter.alerts.geoip_unavailable.messagePendingApply')
+ })
+})
+
+describe('getAlertPresentation', () => {
+ it('returns the presentation for a registered key', () => {
+ expect(getAlertPresentation('geoip_unavailable')).toBe(ALERT_PRESENTATION.geoip_unavailable)
+ })
+
+ it('returns undefined for an unknown key', () => {
+ expect(getAlertPresentation('future_alert')).toBeUndefined()
+ })
+
+ it.each(['constructor', 'toString', '__proto__', 'hasOwnProperty'])(
+ 'does not resolve inherited property %s',
+ (key) => {
+ expect(getAlertPresentation(key)).toBeUndefined()
+ },
+ )
+})
+
+describe('filterKnownAlerts', () => {
+ it('keeps registered alerts in their order and drops unknown keys', () => {
+ const unknown: ActiveAlert = { key: 'future_alert', severity: 'warning' }
+ expect(filterKnownAlerts([unknown, geoip])).toEqual([geoip])
+ })
+
+ it('returns an empty array for an empty input', () => {
+ expect(filterKnownAlerts([])).toEqual([])
+ })
+})
diff --git a/frontend/src/constants/__tests__/docs.test.ts b/frontend/src/constants/__tests__/docs.test.ts
index 00638afd0..dabb201ea 100644
--- a/frontend/src/constants/__tests__/docs.test.ts
+++ b/frontend/src/constants/__tests__/docs.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
-import { DOCS_SITE_URL, LOGIN_PROTECTION_DOCS_URL, TRUSTED_PROXIES_DOCS_URL } from '../docs'
+import {
+ DOCS_SITE_URL,
+ GEOIP_TROUBLESHOOTING_DOCS_URL,
+ LOGIN_PROTECTION_DOCS_URL,
+ TRUSTED_PROXIES_DOCS_URL,
+} from '../docs'
describe('docs constants', () => {
it('builds page URLs under the docs site', () => {
@@ -8,4 +13,8 @@ describe('docs constants', () => {
expect(TRUSTED_PROXIES_DOCS_URL).toBe(`${DOCS_SITE_URL}/configuration/trusted-proxies`)
expect(LOGIN_PROTECTION_DOCS_URL).toBe(`${DOCS_SITE_URL}/features/login-protection`)
})
+
+ it('points the GeoIP alert at the database troubleshooting page', () => {
+ expect(GEOIP_TROUBLESHOOTING_DOCS_URL).toBe(`${DOCS_SITE_URL}/troubleshooting/geoip-database`)
+ })
})
diff --git a/frontend/src/constants/__tests__/links.test.ts b/frontend/src/constants/__tests__/links.test.ts
new file mode 100644
index 000000000..1248482a1
--- /dev/null
+++ b/frontend/src/constants/__tests__/links.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ GITHUB_BUG_REPORT_URL,
+ GITHUB_FEATURE_REQUEST_URL,
+ GITHUB_REPO_URL,
+} from '../links'
+
+describe('links constants', () => {
+ it('builds GitHub issue URLs under the repository', () => {
+ expect(GITHUB_REPO_URL).toBe('https://github.com/Wikid82/Charon')
+ expect(GITHUB_BUG_REPORT_URL).toBe(`${GITHUB_REPO_URL}/issues/new?template=bug_report.md`)
+ expect(GITHUB_FEATURE_REQUEST_URL).toBe(
+ `${GITHUB_REPO_URL}/issues/new?template=feature_request.md`
+ )
+ })
+})
diff --git a/frontend/src/constants/alerts.ts b/frontend/src/constants/alerts.ts
new file mode 100644
index 000000000..04e13d237
--- /dev/null
+++ b/frontend/src/constants/alerts.ts
@@ -0,0 +1,48 @@
+import { GEOIP_TROUBLESHOOTING_DOCS_URL } from './docs';
+
+import type { ActiveAlert } from '../api/system';
+
+/**
+ * How the notification bell presents one kind of active alert. Keys are the server's alert keys.
+ * Labels for the docs and issue links are derived from the alert key (`notificationCenter.alerts..docsLink`,
+ * `.issueHint`, `.issueLink`), so every registered key must provide them in every locale.
+ */
+export interface AlertPresentation {
+ /** i18n key of the alert headline. */
+ titleKey: string;
+ /** Picks the i18n key of the body text from the alert's own fields. */
+ messageKey: (alert: ActiveAlert) => string;
+ /** Documentation page the alert links to. */
+ docsUrl: string;
+ /** Whether to ask the user to report the problem as a GitHub issue. */
+ showIssueLink: boolean;
+}
+
+const GEOIP_UNAVAILABLE_PREFIX = 'notificationCenter.alerts.geoip_unavailable';
+
+export const ALERT_PRESENTATION: Readonly> = {
+ geoip_unavailable: {
+ titleKey: `${GEOIP_UNAVAILABLE_PREFIX}.title`,
+ messageKey: (alert) =>
+ alert.database_available === true
+ ? `${GEOIP_UNAVAILABLE_PREFIX}.messagePendingApply`
+ : `${GEOIP_UNAVAILABLE_PREFIX}.message`,
+ docsUrl: GEOIP_TROUBLESHOOTING_DOCS_URL,
+ showIssueLink: true,
+ },
+};
+
+/** Returns the presentation for a server alert key, or undefined when the key is not registered. */
+export function getAlertPresentation(key: string): AlertPresentation | undefined {
+ return Object.prototype.hasOwnProperty.call(ALERT_PRESENTATION, key)
+ ? ALERT_PRESENTATION[key]
+ : undefined;
+}
+
+/**
+ * Keeps only the alerts this frontend knows how to show. Unknown keys are dropped silently so a newer
+ * backend cannot put unexplained entries in the bell or inflate its count.
+ */
+export function filterKnownAlerts(alerts: readonly ActiveAlert[]): ActiveAlert[] {
+ return alerts.filter((alert) => getAlertPresentation(alert.key) !== undefined);
+}
diff --git a/frontend/src/constants/docs.ts b/frontend/src/constants/docs.ts
index 762aed1c7..45b5f93ad 100644
--- a/frontend/src/constants/docs.ts
+++ b/frontend/src/constants/docs.ts
@@ -6,3 +6,6 @@ export const TRUSTED_PROXIES_DOCS_URL = `${DOCS_SITE_URL}/configuration/trusted-
/** How sign-in throttling ("login protection") works. */
export const LOGIN_PROTECTION_DOCS_URL = `${DOCS_SITE_URL}/features/login-protection`
+
+/** How to provide the GeoIP country database when it is missing. */
+export const GEOIP_TROUBLESHOOTING_DOCS_URL = `${DOCS_SITE_URL}/troubleshooting/geoip-database`
diff --git a/frontend/src/constants/links.ts b/frontend/src/constants/links.ts
new file mode 100644
index 000000000..f45e4dc6c
--- /dev/null
+++ b/frontend/src/constants/links.ts
@@ -0,0 +1,8 @@
+/** Canonical GitHub repository URL for Charon. */
+export const GITHUB_REPO_URL = 'https://github.com/Wikid82/Charon'
+
+/** Pre-filled GitHub issue form for bug reports. */
+export const GITHUB_BUG_REPORT_URL = `${GITHUB_REPO_URL}/issues/new?template=bug_report.md`
+
+/** Pre-filled GitHub issue form for feature requests. */
+export const GITHUB_FEATURE_REQUEST_URL = `${GITHUB_REPO_URL}/issues/new?template=feature_request.md`
diff --git a/frontend/src/hooks/__tests__/useActiveAlerts.test.tsx b/frontend/src/hooks/__tests__/useActiveAlerts.test.tsx
new file mode 100644
index 000000000..c7cb50211
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useActiveAlerts.test.tsx
@@ -0,0 +1,51 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { renderHook, waitFor } from '@testing-library/react'
+import React from 'react'
+import { vi, describe, it, expect, beforeEach } from 'vitest'
+
+import { getActiveAlerts, type ActiveAlert } from '../../api/system'
+import { ACTIVE_ALERTS_POLL_INTERVAL_MS, ACTIVE_ALERTS_QUERY_KEY, useActiveAlerts } from '../useActiveAlerts'
+
+vi.mock('../../api/system', () => ({
+ getActiveAlerts: vi.fn(),
+}))
+
+const alerts: ActiveAlert[] = [
+ { key: 'geoip_unavailable', severity: 'error', affected_lists: ['Office'], affected_lists_total: 1, database_available: false },
+]
+
+const createClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })
+
+const createWrapper = (queryClient: QueryClient) => {
+ return ({ children }: { children: React.ReactNode }) =>
+ React.createElement(QueryClientProvider, { client: queryClient }, children)
+}
+
+describe('useActiveAlerts', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(getActiveAlerts).mockResolvedValue(alerts)
+ })
+
+ it('uses the shared active-alerts query key', () => {
+ expect(ACTIVE_ALERTS_QUERY_KEY).toEqual(['active-alerts'])
+ })
+
+ it('returns the alerts from getActiveAlerts', async () => {
+ const queryClient = createClient()
+ const { result } = renderHook(() => useActiveAlerts(), { wrapper: createWrapper(queryClient) })
+
+ await waitFor(() => expect(result.current.data).toEqual(alerts))
+ expect(getActiveAlerts).toHaveBeenCalledTimes(1)
+ })
+
+ it('polls every 30 seconds and refetches on window focus', async () => {
+ const queryClient = createClient()
+ const { result } = renderHook(() => useActiveAlerts(), { wrapper: createWrapper(queryClient) })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+ const query = queryClient.getQueryCache().find({ queryKey: ACTIVE_ALERTS_QUERY_KEY })
+ expect(ACTIVE_ALERTS_POLL_INTERVAL_MS).toBe(30_000)
+ expect(query?.options).toMatchObject({ refetchInterval: 30_000, refetchOnWindowFocus: true })
+ })
+})
diff --git a/frontend/src/hooks/__tests__/useGeoIP.test.tsx b/frontend/src/hooks/__tests__/useGeoIP.test.tsx
new file mode 100644
index 000000000..32eb436e9
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useGeoIP.test.tsx
@@ -0,0 +1,199 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act, renderHook } from '@testing-library/react'
+import { createElement, type ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
+
+import * as accessListsApi from '../../api/accessLists'
+import * as securityApi from '../../api/security'
+import * as systemApi from '../../api/system'
+import { ACTIVE_ALERTS_QUERY_KEY } from '../useActiveAlerts'
+import {
+ ACCESS_LIST_SELF_CHECK_DEBOUNCE_MS,
+ GEOIP_STATUS_QUERY_KEY,
+ useAccessListSelfCheck,
+ useGeoIPLookup,
+ useGeoIPStatus,
+ useMyIPAddress,
+ useRemoveGeoIPDatabase,
+ useReloadGeoIP,
+ useReplaceGeoIPDatabase,
+} from '../useGeoIP'
+import { SECURITY_STATUS_QUERY_KEY } from '../useSecurity'
+
+vi.mock('../../api/accessLists')
+vi.mock('../../api/security')
+vi.mock('../../api/system')
+
+const bundledStatus: securityApi.GeoIPStatus = {
+ available: true,
+ loaded: true,
+ db_path: '/app/geoip/GeoLite2-Country.mmdb',
+ message: 'GeoIP service available',
+ source: 'bundled',
+ database_type: 'GeoLite2-Country',
+ build_date: null,
+ size_bytes: null,
+ loaded_at: null,
+ included_build_date: null,
+ data_copy_present: false,
+ can_replace: true,
+ replace_blocked_reason: '',
+ unenforced_lists_total: 0,
+}
+
+/** Wraps a hook in a client and spies on invalidation so each test can check what was refreshed. */
+function setup(useHook: () => T) {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
+ const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
+ const wrapper = ({ children }: { children: ReactNode }) => createElement(QueryClientProvider, { client: queryClient }, children)
+ const view = renderHook(useHook, { wrapper })
+ return { view, invalidate }
+}
+
+/** The query keys a spy saw invalidated. */
+const invalidatedKeys = (spy: MockInstance) =>
+ spy.mock.calls.map(([filters]) => (filters as { queryKey: readonly unknown[] }).queryKey)
+
+/** The caches a database change must refresh. */
+const REFRESHED_KEYS = [[...GEOIP_STATUS_QUERY_KEY], [...SECURITY_STATUS_QUERY_KEY], [...ACTIVE_ALERTS_QUERY_KEY]]
+
+describe('GeoIP hooks', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ })
+
+ it('reads the status under the GeoIP cache key', async () => {
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue(bundledStatus)
+ const { view } = setup(() => useGeoIPStatus())
+
+ await vi.waitFor(() => expect(view.result.current.data).toEqual(bundledStatus))
+ expect(GEOIP_STATUS_QUERY_KEY).toEqual(['geoipStatus'])
+ })
+
+ it('reload refreshes the status, security status and active alerts on success', async () => {
+ const { view, invalidate } = setup(() => useReloadGeoIP())
+ vi.mocked(securityApi.reloadGeoIP).mockResolvedValue({})
+
+ await act(async () => {
+ await view.result.current.mutateAsync(undefined)
+ })
+
+ expect(invalidatedKeys(invalidate)).toEqual(expect.arrayContaining(REFRESHED_KEYS))
+ })
+
+ it('replace refreshes the status, security status and active alerts on success', async () => {
+ const { view, invalidate } = setup(() => useReplaceGeoIPDatabase())
+ vi.mocked(securityApi.replaceGeoIPDatabase).mockResolvedValue({ status: bundledStatus })
+
+ await act(async () => {
+ await view.result.current.mutateAsync(new File(['x'], 'GeoLite2-Country.mmdb'))
+ })
+
+ expect(securityApi.replaceGeoIPDatabase).toHaveBeenCalledTimes(1)
+ expect(invalidatedKeys(invalidate)).toEqual(expect.arrayContaining(REFRESHED_KEYS))
+ })
+
+ it('remove refreshes the status, security status and active alerts on success', async () => {
+ const { view, invalidate } = setup(() => useRemoveGeoIPDatabase())
+ vi.mocked(securityApi.removeGeoIPDatabase).mockResolvedValue({ status: bundledStatus })
+
+ await act(async () => {
+ await view.result.current.mutateAsync(undefined)
+ })
+
+ expect(invalidatedKeys(invalidate)).toEqual(expect.arrayContaining(REFRESHED_KEYS))
+ })
+
+ it('still refreshes when a reload fails, because the proxy configuration is applied regardless', async () => {
+ const { view, invalidate } = setup(() => useReloadGeoIP())
+ vi.mocked(securityApi.reloadGeoIP).mockRejectedValue(new Error('reload failed'))
+
+ await act(async () => {
+ await expect(view.result.current.mutateAsync(undefined)).rejects.toThrow('reload failed')
+ })
+
+ expect(invalidatedKeys(invalidate)).toEqual(expect.arrayContaining(REFRESHED_KEYS))
+ })
+
+ it('does not refresh caches for a lookup, which changes nothing', async () => {
+ vi.mocked(securityApi.lookupGeoIP).mockResolvedValue({ ip_address: '203.0.113.7', country_code: 'DE', found: true })
+ const { view, invalidate } = setup(() => useGeoIPLookup())
+
+ await act(async () => {
+ await view.result.current.mutateAsync('203.0.113.7')
+ })
+
+ expect(securityApi.lookupGeoIP).toHaveBeenCalledWith('203.0.113.7')
+ expect(invalidate).not.toHaveBeenCalled()
+ })
+
+ it('reads the address Charon sees for this browser', async () => {
+ vi.mocked(systemApi.getMyIP).mockResolvedValue({ ip: '203.0.113.9', source: 'remote' })
+ const { view } = setup(() => useMyIPAddress())
+
+ let address = ''
+ await act(async () => {
+ address = (await view.result.current.mutateAsync()).ip
+ })
+
+ expect(address).toBe('203.0.113.9')
+ })
+
+ describe('useAccessListSelfCheck', () => {
+ const draft = (countryCodes: string): accessListsApi.AccessListSelfCheckDraft => ({
+ type: 'geo_blacklist',
+ country_codes: countryCodes,
+ ip_rules: '',
+ local_network_only: false,
+ enabled: true,
+ })
+
+ const result: accessListsApi.AccessListSelfCheckResult = {
+ ip_address: '203.0.113.7',
+ country_code: 'DE',
+ allowed: true,
+ admin_exempt: false,
+ geoip_available: true,
+ reason: 'allowed',
+ }
+
+ it('does not call the self-check while disabled', async () => {
+ const { view } = setup(() => useAccessListSelfCheck(draft('DE'), false))
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ })
+ expect(view.result.current.fetchStatus).toBe('idle')
+ expect(accessListsApi.selfCheckAccessList).not.toHaveBeenCalled()
+ })
+
+ it('checks the draft once it is enabled and resolves with the decision', async () => {
+ vi.mocked(accessListsApi.selfCheckAccessList).mockResolvedValue(result)
+ const { view } = setup(() => useAccessListSelfCheck(draft('DE'), true))
+
+ await vi.waitFor(() => expect(view.result.current.data).toEqual(result))
+ expect(accessListsApi.selfCheckAccessList).toHaveBeenCalledWith(draft('DE'))
+ })
+
+ it('does not check intermediate drafts typed within the debounce window', async () => {
+ vi.mocked(accessListsApi.selfCheckAccessList).mockResolvedValue(result)
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ const wrapper = ({ children }: { children: ReactNode }) => createElement(QueryClientProvider, { client: queryClient }, children)
+ const { rerender, result: hook } = renderHook(
+ ({ value }: { value: accessListsApi.AccessListSelfCheckDraft }) => useAccessListSelfCheck(value, true),
+ { wrapper, initialProps: { value: draft('DE') } }
+ )
+ await vi.waitFor(() => expect(hook.current.data).toEqual(result))
+
+ rerender({ value: draft('DE,FR') })
+ rerender({ value: draft('DE,FR,IT') })
+
+ await vi.waitFor(
+ () => expect(accessListsApi.selfCheckAccessList).toHaveBeenLastCalledWith(draft('DE,FR,IT')),
+ { timeout: ACCESS_LIST_SELF_CHECK_DEBOUNCE_MS * 4 }
+ )
+ const checkedCodes = vi.mocked(accessListsApi.selfCheckAccessList).mock.calls.map(([sent]) => sent.country_codes)
+ expect(checkedCodes).toEqual(['DE', 'DE,FR,IT'])
+ })
+ })
+})
diff --git a/frontend/src/hooks/useActiveAlerts.ts b/frontend/src/hooks/useActiveAlerts.ts
new file mode 100644
index 000000000..1a9cc8da9
--- /dev/null
+++ b/frontend/src/hooks/useActiveAlerts.ts
@@ -0,0 +1,19 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { getActiveAlerts, type ActiveAlert } from '../api/system';
+
+/** Shared cache key so the bell and any future consumer read the same alert list. */
+export const ACTIVE_ALERTS_QUERY_KEY = ['active-alerts'] as const;
+
+/** Alerts are recomputed on the server, so the bell re-reads them every 30 seconds. */
+export const ACTIVE_ALERTS_POLL_INTERVAL_MS = 30_000;
+
+/** Reads the alerts that currently need attention (`GET /notifications/alerts`). */
+export function useActiveAlerts() {
+ return useQuery({
+ queryKey: ACTIVE_ALERTS_QUERY_KEY,
+ queryFn: getActiveAlerts,
+ refetchInterval: ACTIVE_ALERTS_POLL_INTERVAL_MS,
+ refetchOnWindowFocus: true,
+ });
+}
diff --git a/frontend/src/hooks/useGeoIP.ts b/frontend/src/hooks/useGeoIP.ts
new file mode 100644
index 000000000..3ee6c073d
--- /dev/null
+++ b/frontend/src/hooks/useGeoIP.ts
@@ -0,0 +1,106 @@
+import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
+
+import { selfCheckAccessList, type AccessListSelfCheckDraft } from '../api/accessLists'
+import {
+ getGeoIPStatus,
+ lookupGeoIP,
+ reloadGeoIP,
+ removeGeoIPDatabase,
+ replaceGeoIPDatabase,
+} from '../api/security'
+import { getMyIP } from '../api/system'
+import { ACTIVE_ALERTS_QUERY_KEY } from './useActiveAlerts'
+import { useDebouncedValue } from './useDebouncedValue'
+import { SECURITY_STATUS_QUERY_KEY } from './useSecurity'
+
+
+/** Cache key of the GeoIP database status read by the Security page card. */
+export const GEOIP_STATUS_QUERY_KEY = ['geoipStatus'] as const
+
+/** The status changes only through the card's own actions, so it is not polled. */
+const GEOIP_STATUS_STALE_MS = 60_000
+
+/** Pause after the last edit of an access list draft before the self-check runs. */
+export const ACCESS_LIST_SELF_CHECK_DEBOUNCE_MS = 500
+
+/** Cache key prefix of the access list self-check (keyed by the debounced draft). */
+export const ACCESS_LIST_SELF_CHECK_QUERY_KEY = ['accessListSelfCheck'] as const
+
+/** Reads the GeoIP database status (admin only: the caller decides whether to mount the reader). */
+export function useGeoIPStatus() {
+ return useQuery({
+ queryKey: GEOIP_STATUS_QUERY_KEY,
+ queryFn: getGeoIPStatus,
+ staleTime: GEOIP_STATUS_STALE_MS,
+ })
+}
+
+/**
+ * Refreshes everything a database change can alter: the card status, the security status (country lists and modules)
+ * and the active alerts (the missing-database alert). Runs after success and failure, because a failed reload still
+ * applies the proxy configuration.
+ */
+function refreshAfterGeoIPChange(queryClient: QueryClient) {
+ return Promise.all([
+ queryClient.invalidateQueries({ queryKey: GEOIP_STATUS_QUERY_KEY }),
+ queryClient.invalidateQueries({ queryKey: SECURITY_STATUS_QUERY_KEY }),
+ queryClient.invalidateQueries({ queryKey: ACTIVE_ALERTS_QUERY_KEY }),
+ ])
+}
+
+/** Reloads the database from disk; the proxy configuration is applied by the server. */
+export function useReloadGeoIP() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: reloadGeoIP,
+ onSettled: () => refreshAfterGeoIPChange(queryClient),
+ })
+}
+
+/** Uploads a database into the data folder, replacing the copy there. */
+export function useReplaceGeoIPDatabase() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: (file: File) => replaceGeoIPDatabase(file),
+ onSettled: () => refreshAfterGeoIPChange(queryClient),
+ })
+}
+
+/** Removes the data-folder copy so the included database is used again. */
+export function useRemoveGeoIPDatabase() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: removeGeoIPDatabase,
+ onSettled: () => refreshAfterGeoIPChange(queryClient),
+ })
+}
+
+/** Looks up the country of one address. Read-only, so it does not refresh any cache. */
+export function useGeoIPLookup() {
+ return useMutation({
+ mutationFn: (ipAddress: string) => lookupGeoIP(ipAddress),
+ })
+}
+
+/** Reads the address Charon sees for this browser, to fill the lookup field. */
+export function useMyIPAddress() {
+ return useMutation({
+ mutationFn: () => getMyIP(),
+ })
+}
+
+/**
+ * Asks whether the draft access list would refuse the viewer's current address. Runs only while `enabled` is true
+ * (admin and a country list with codes) and waits for the draft to stop changing for 500 ms, so typing does not
+ * send a request per keystroke. The draft is compared by value, so callers need not memoize it.
+ */
+export function useAccessListSelfCheck(draft: AccessListSelfCheckDraft, enabled: boolean) {
+ const draftKey = JSON.stringify(draft)
+ const debouncedKey = useDebouncedValue(draftKey, ACCESS_LIST_SELF_CHECK_DEBOUNCE_MS)
+ return useQuery({
+ queryKey: [...ACCESS_LIST_SELF_CHECK_QUERY_KEY, debouncedKey],
+ queryFn: () => selfCheckAccessList(JSON.parse(debouncedKey) as AccessListSelfCheckDraft),
+ enabled,
+ retry: false,
+ })
+}
diff --git a/frontend/src/locales/af/translation.json b/frontend/src/locales/af/translation.json
index 7dd4819c7..282c4fed7 100644
--- a/frontend/src/locales/af/translation.json
+++ b/frontend/src/locales/af/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Kennisgewings",
+ "bellLabel": "Kennisgewings, {{count}} nuwe",
+ "markAllRead": "Merk almal as gelees",
+ "empty": "Geen nuwe kennisgewings nie",
+ "close": "Verwerp",
+ "updateAvailable": "Opdatering beskikbaar: {{version}}",
+ "viewChangelog": "Sien veranderinglys",
+ "alerts": {
+ "label": "Benodig aandag",
+ "clearsItself": "Dit verdwyn self sodra die probleem opgelos is.",
+ "geoip_unavailable": {
+ "title": "Landtoegangslyste word nie afgedwing nie",
+ "message": "Die GeoIP-landdatabasis ontbreek of is onbruikbaar, so {{count}} landlys(te) ({{lists}}) laat elke besoeker deur totdat dit weer beskikbaar is.",
+ "messagePendingApply": "Die GeoIP-databasis is weer beskikbaar, maar {{count}} landlys(te) ({{lists}}) word nog nie afgedwing nie. Druk Herlaai op die Landedatabasis-kaart.",
+ "docsLink": "Hoe om die databasis te verskaf",
+ "issueHint": "Charon sluit gewoonlik hierdie databasis in. Maak asseblief ’n GitHub-issue oop sodat ons dit kan regmaak.",
+ "issueLink": "Maak ’n issue oop"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"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."
+ "geoip_unavailable": "Landlyste word nie afgedwing nie: die GeoIP-databasis ontbreek in hierdie installasie. Almal kan by daardie gashere uitkom totdat dit verskaf word. Sien die dokumentasie."
+ }
+ },
+ "geoip": {
+ "title": "Landedatabasis (GeoIP)",
+ "description": "Landtoegangslyste benodig 'n GeoIP-landedatabasis. Charon gebruik die kopie wat saam daarmee kom, tensy jy jou eie verskaf.",
+ "loadError": "Die databasisstatus kon nie gelaai word nie.",
+ "unknownValue": "Onbekend",
+ "status": {
+ "ready": "Gereed",
+ "unavailable": "Nie beskikbaar nie"
+ },
+ "source": {
+ "label": "Bron",
+ "bundled": "Saam met Charon ingesluit",
+ "data": "Jou datagids",
+ "custom": "Pasgemaakte pad op die bediener gestel",
+ "none": "Geen databasis nie"
+ },
+ "buildDate": "Boudatum",
+ "size": "Grootte",
+ "path": "Lêer",
+ "releaseNote": "Die ingeslote kopie word met elke Charon-vrystelling opgedateer.",
+ "customPathNote": "Die databasispad word deur die bediener gestel (CHARON_GEOIP_DB_PATH), so die kopie kan nie hier vervang word nie.",
+ "readOnlyNote": "Die datagids is leesalleen, so die databasis kan nie hier vervang word nie.",
+ "unavailableReason": "Nie beskikbaar nie. Landlyste word nie afgedwing totdat 'n databasis gelaai is nie.",
+ "docsLink": "Hoe om 'n databasis te verskaf",
+ "unenforcedLists": "{{count}} landlys(te) word nie afgedwing nie",
+ "formNote": {
+ "unavailable": "Hierdie lys sal nie afgedwing word totdat die GeoIP-databasis beskikbaar is nie.",
+ "wouldRefuseYou": "Op grond van die adres wat jy nou gebruik, sou hierdie lys jou keer. Stoor is steeds toegelaat."
+ },
+ "actions": {
+ "reload": "Herlaai",
+ "replace": "Vervang databasis…",
+ "replaceHint": "Laai 'n GeoIP-landedatabasis (.mmdb) op, tot {{max}} MiB.",
+ "useIncluded": "Gebruik die ingeslote kopie",
+ "useIncludedConfirmTitle": "Gebruik die ingeslote kopie?",
+ "useIncludedConfirmBody": "Jou kopie in die datagids sal verwyder word en Charon sal die ingeslote databasis gebruik.",
+ "useIncludedConfirmBodyNoBundled": "Jou kopie in die datagids sal verwyder word. Landlyste sal nie afgedwing word totdat jy 'n databasis verskaf nie."
+ },
+ "result": {
+ "reloaded": "Databasis herlaai.",
+ "replaced": "Databasis vervang. Charon gebruik nou jou kopie.",
+ "removed": "Charon gebruik weer die ingeslote kopie."
+ },
+ "lookup": {
+ "label": "IP-adres",
+ "placeholder": "Byvoorbeeld 203.0.113.7",
+ "useMyAddress": "Gebruik my adres",
+ "submit": "Soek op",
+ "found": "{{ip}} is in {{country}}.",
+ "notFound": "Geen land gevind vir {{ip}} nie.",
+ "disabled": "Opsoek is beskikbaar sodra 'n databasis gelaai is.",
+ "invalid": "Voer 'n geldige IP-adres in.",
+ "notLoaded": "Geen databasis is gelaai nie, so adresse kan nie opgesoek word nie.",
+ "myAddressFailed": "Charon kon nie jou adres lees nie."
+ },
+ "errors": {
+ "geoip_file_required": "Kies 'n databaslêer om op te laai.",
+ "geoip_too_large": "Daardie lêer is groter as 32 MiB.",
+ "geoip_not_mmdb": "Daardie lêer is nie 'n GeoIP-databasis nie.",
+ "geoip_corrupt": "Daardie databaslêer is beskadig en kan nie gelees word nie.",
+ "geoip_wrong_type": "Dit is 'n GeoIP-databasis, maar nie 'n landedatabasis nie.",
+ "geoip_older_than_current": "Daardie databasis is ouer as die een wat in gebruik is. Laai 'n nuwer een op.",
+ "geoip_build_date_in_future": "Daardie databasis het 'n boudatum in die toekoms.",
+ "geoip_custom_path_set": "'n Pasgemaakte databaspad is op die bediener gestel, so die kopie kan nie hier verander word nie.",
+ "geoip_busy": "Nog 'n databasisverandering is aan die gang. Probeer weer oor 'n oomblik.",
+ "geoip_write_failed": "Charon kon nie die databaslêer stoor nie.",
+ "geoip_reload_failed": "Charon kon nie die databaslêer laai nie.",
+ "geoip_no_data_copy": "Daar is geen kopie in die datagids om te verwyder nie.",
+ "geoip_service_unavailable": "Die GeoIP-diens loop nie.",
+ "proxy_apply_failed": "Charon kon nie die proxy-konfigurasie toepas nie.",
+ "generic": "Iets het verkeerd gegaan. Probeer weer."
}
}
},
diff --git a/frontend/src/locales/ar/translation.json b/frontend/src/locales/ar/translation.json
index 9e578b4d0..74b89e863 100644
--- a/frontend/src/locales/ar/translation.json
+++ b/frontend/src/locales/ar/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "الإشعارات",
+ "bellLabel": "الإشعارات، {{count}} جديدة",
+ "markAllRead": "تعليم الكل كمقروء",
+ "empty": "لا توجد إشعارات جديدة",
+ "close": "تجاهل",
+ "updateAvailable": "تحديث متوفر: {{version}}",
+ "viewChangelog": "عرض سجل التغييرات",
+ "alerts": {
+ "label": "يحتاج إلى انتباه",
+ "clearsItself": "يختفي هذا تلقائياً بعد حل المشكلة.",
+ "geoip_unavailable": {
+ "title": "قوائم الوصول حسب الدولة غير مفروضة",
+ "message": "قاعدة بيانات الدول GeoIP مفقودة أو غير صالحة للاستخدام، لذا تسمح {{count}} قائمة دول ({{lists}}) بمرور كل الزوار حتى تصبح متاحة مرة أخرى.",
+ "messagePendingApply": "قاعدة بيانات GeoIP متاحة مرة أخرى، لكن {{count}} قائمة دول ({{lists}}) لم تُطبَّق بعد. اضغط «إعادة التحميل» في بطاقة قاعدة بيانات الدول.",
+ "docsLink": "كيفية توفير قاعدة البيانات",
+ "issueHint": "يتضمن Charon عادةً قاعدة البيانات هذه. يرجى فتح issue على GitHub حتى نتمكن من إصلاح ذلك.",
+ "issueLink": "فتح issue"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"settingUpdateFailed": "Failed to update setting: {{error}}",
"enforcement": {
"acl": {
- "geoip_unavailable": "تحتاج قوائم الدول إلى قاعدة بيانات GeoIP، وهي غير موجودة في هذا التثبيت، لذلك ترفض الطلبات إلى أن تُوفَّر. راجع التوثيق."
+ "geoip_unavailable": "قوائم الدول غير مفروضة: قاعدة بيانات GeoIP غير موجودة في هذا التثبيت. يمكن لأي شخص الوصول إلى هذه المضيفات حتى يتم توفيرها. راجع التوثيق."
+ }
+ },
+ "geoip": {
+ "title": "قاعدة بيانات الدول (GeoIP)",
+ "description": "تحتاج قوائم الوصول حسب الدولة إلى قاعدة بيانات الدول من GeoIP. يستخدم Charon النسخة المرفقة ما لم تُوفِّر نسختك الخاصة.",
+ "loadError": "تعذّر تحميل حالة قاعدة البيانات.",
+ "unknownValue": "غير معروف",
+ "status": {
+ "ready": "جاهز",
+ "unavailable": "غير متاح"
+ },
+ "source": {
+ "label": "المصدر",
+ "bundled": "مرفقة مع Charon",
+ "data": "مجلد البيانات الخاص بك",
+ "custom": "مسار مخصص مضبوط على الخادم",
+ "none": "لا توجد قاعدة بيانات"
+ },
+ "buildDate": "تاريخ الإصدار",
+ "size": "الحجم",
+ "path": "الملف",
+ "releaseNote": "تُحدَّث النسخة المرفقة مع كل إصدار من Charon.",
+ "customPathNote": "يحدد الخادم مسار قاعدة البيانات (CHARON_GEOIP_DB_PATH)، لذا لا يمكن استبدال النسخة من هنا.",
+ "readOnlyNote": "مجلد البيانات للقراءة فقط، لذا لا يمكن استبدال قاعدة البيانات من هنا.",
+ "unavailableReason": "غير متاح. لا تُطبَّق قوائم الدول حتى يتم تحميل قاعدة بيانات.",
+ "docsLink": "كيفية توفير قاعدة بيانات",
+ "unenforcedLists": "لم يتم تطبيق {{count}} قائمة دول",
+ "formNote": {
+ "unavailable": "لن تُطبَّق هذه القائمة حتى تصبح قاعدة بيانات GeoIP متاحة.",
+ "wouldRefuseYou": "استنادًا إلى العنوان الذي تستخدمه الآن، ستمنعك هذه القائمة. الحفظ ما زال مسموحًا."
+ },
+ "actions": {
+ "reload": "إعادة التحميل",
+ "replace": "استبدال قاعدة البيانات…",
+ "replaceHint": "ارفع قاعدة بيانات الدول من GeoIP (.mmdb)، بحد أقصى {{max}} ميبيبايت.",
+ "useIncluded": "استخدام النسخة المرفقة",
+ "useIncludedConfirmTitle": "هل تريد استخدام النسخة المرفقة؟",
+ "useIncludedConfirmBody": "ستُحذف نسختك في مجلد البيانات وسيستخدم Charon قاعدة البيانات المرفقة.",
+ "useIncludedConfirmBodyNoBundled": "ستُحذف نسختك في مجلد البيانات. لن تُطبَّق قوائم الدول حتى توفر قاعدة بيانات."
+ },
+ "result": {
+ "reloaded": "تمت إعادة تحميل قاعدة البيانات.",
+ "replaced": "تم استبدال قاعدة البيانات. يستخدم Charon نسختك الآن.",
+ "removed": "يستخدم Charon النسخة المرفقة مرة أخرى."
+ },
+ "lookup": {
+ "label": "عنوان IP",
+ "placeholder": "مثال: 203.0.113.7",
+ "useMyAddress": "استخدام عنواني",
+ "submit": "بحث",
+ "found": "{{ip}} يقع في {{country}}.",
+ "notFound": "لم يُعثر على دولة لـ {{ip}}.",
+ "disabled": "البحث متاح بعد تحميل قاعدة بيانات.",
+ "invalid": "أدخل عنوان IP صالحًا.",
+ "notLoaded": "لم يتم تحميل قاعدة بيانات، لذا لا يمكن البحث عن العناوين.",
+ "myAddressFailed": "تعذّر على Charon قراءة عنوانك."
+ },
+ "errors": {
+ "geoip_file_required": "اختر ملف قاعدة بيانات للرفع.",
+ "geoip_too_large": "الملف أكبر من 32 ميبيبايت.",
+ "geoip_not_mmdb": "هذا الملف ليس قاعدة بيانات GeoIP.",
+ "geoip_corrupt": "ملف قاعدة البيانات تالف ولا يمكن قراءته.",
+ "geoip_wrong_type": "هذه قاعدة بيانات GeoIP، لكنها ليست قاعدة بيانات للدول.",
+ "geoip_older_than_current": "هذه قاعدة البيانات أقدم من القاعدة المستخدمة. ارفع نسخة أحدث.",
+ "geoip_build_date_in_future": "تاريخ إصدار قاعدة البيانات في المستقبل.",
+ "geoip_custom_path_set": "مسار قاعدة بيانات مخصص مضبوط على الخادم، لذا لا يمكن تغيير النسخة من هنا.",
+ "geoip_busy": "هناك تغيير آخر لقاعدة البيانات قيد التنفيذ. حاول مرة أخرى بعد قليل.",
+ "geoip_write_failed": "تعذّر على Charon حفظ ملف قاعدة البيانات.",
+ "geoip_reload_failed": "تعذّر على Charon تحميل ملف قاعدة البيانات.",
+ "geoip_no_data_copy": "لا توجد نسخة في مجلد البيانات لحذفها.",
+ "geoip_service_unavailable": "خدمة GeoIP غير قيد التشغيل.",
+ "proxy_apply_failed": "تعذّر على Charon تطبيق إعدادات الوكيل.",
+ "generic": "حدث خطأ ما. حاول مرة أخرى."
}
}
},
diff --git a/frontend/src/locales/ca/translation.json b/frontend/src/locales/ca/translation.json
index 7fc77624c..95f2063b5 100644
--- a/frontend/src/locales/ca/translation.json
+++ b/frontend/src/locales/ca/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Notificacions",
+ "bellLabel": "Notificacions, {{count}} de noves",
+ "markAllRead": "Marca-les totes com a llegides",
+ "empty": "No hi ha notificacions noves",
+ "close": "Descarta",
+ "updateAvailable": "Actualització disponible: {{version}}",
+ "viewChangelog": "Mostra el registre de canvis",
+ "alerts": {
+ "label": "Requereix atenció",
+ "clearsItself": "Això desapareix automàticament quan el problema es resol.",
+ "geoip_unavailable": {
+ "title": "Les llistes d'accés per país no s'estan aplicant",
+ "message": "La base de dades GeoIP de països no hi és o no es pot utilitzar, de manera que {{count}} llista(es) de països ({{lists}}) deixen passar tots els visitants fins que torni a estar disponible.",
+ "messagePendingApply": "La base de dades GeoIP torna a estar disponible, però {{count}} llista(es) de països ({{lists}}) encara no s'apliquen. Premeu Torna a carregar a la targeta Base de dades de països.",
+ "docsLink": "Com proporcionar la base de dades",
+ "issueHint": "Charon normalment inclou aquesta base de dades. Obre un issue a GitHub perquè el puguem corregir.",
+ "issueLink": "Obre un issue"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"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ó."
+ "geoip_unavailable": "Les llistes de països no s'apliquen: la base de dades GeoIP no hi és en aquesta instal·lació. Tothom pot accedir a aquests hosts fins que se n'proporcioni una. Consulta la documentació."
+ }
+ },
+ "geoip": {
+ "title": "Base de dades de països (GeoIP)",
+ "description": "Les llistes d'accés per país necessiten una base de dades de països GeoIP. Charon utilitza la còpia inclosa, tret que n'hi proporcioneu una de pròpia.",
+ "loadError": "No s'ha pogut carregar l'estat de la base de dades.",
+ "unknownValue": "Desconegut",
+ "status": {
+ "ready": "Preparat",
+ "unavailable": "No disponible"
+ },
+ "source": {
+ "label": "Origen",
+ "bundled": "Inclosa amb Charon",
+ "data": "La vostra carpeta de dades",
+ "custom": "Ruta personalitzada definida al servidor",
+ "none": "Cap base de dades"
+ },
+ "buildDate": "Data de compilació",
+ "size": "Mida",
+ "path": "Fitxer",
+ "releaseNote": "La còpia inclosa s'actualitza amb cada versió de Charon.",
+ "customPathNote": "El servidor defineix la ruta de la base de dades (CHARON_GEOIP_DB_PATH), per tant la còpia no es pot substituir aquí.",
+ "readOnlyNote": "La carpeta de dades és de només lectura, per tant la base de dades no es pot substituir aquí.",
+ "unavailableReason": "No disponible. Les llistes de països no s'apliquen fins que no es carrega una base de dades.",
+ "docsLink": "Com proporcionar una base de dades",
+ "unenforcedLists": "{{count}} llista(es) de països no aplicada(es)",
+ "formNote": {
+ "unavailable": "Aquesta llista no s'aplicarà fins que la base de dades GeoIP no estigui disponible.",
+ "wouldRefuseYou": "Segons l'adreça que feu servir ara, aquesta llista us bloquejaria. Encara es pot desar."
+ },
+ "actions": {
+ "reload": "Torna a carregar",
+ "replace": "Substitueix la base de dades…",
+ "replaceHint": "Pugeu una base de dades de països GeoIP (.mmdb), de fins a {{max}} MiB.",
+ "useIncluded": "Usa la còpia inclosa",
+ "useIncludedConfirmTitle": "Voleu usar la còpia inclosa?",
+ "useIncludedConfirmBody": "La vostra còpia de la carpeta de dades se suprimirà i Charon usarà la base de dades inclosa.",
+ "useIncludedConfirmBodyNoBundled": "La vostra còpia de la carpeta de dades se suprimirà. Les llistes de països no s'aplicaran fins que proporcioneu una base de dades."
+ },
+ "result": {
+ "reloaded": "Base de dades carregada de nou.",
+ "replaced": "Base de dades substituïda. Charon ara usa la vostra còpia.",
+ "removed": "Charon torna a usar la còpia inclosa."
+ },
+ "lookup": {
+ "label": "Adreça IP",
+ "placeholder": "Per exemple 203.0.113.7",
+ "useMyAddress": "Usa la meva adreça",
+ "submit": "Cerca",
+ "found": "{{ip}} és a {{country}}.",
+ "notFound": "No s'ha trobat cap país per a {{ip}}.",
+ "disabled": "La cerca està disponible quan es carrega una base de dades.",
+ "invalid": "Introduïu una adreça IP vàlida.",
+ "notLoaded": "No hi ha cap base de dades carregada, per tant no es poden cercar adreces.",
+ "myAddressFailed": "Charon no ha pogut llegir la vostra adreça."
+ },
+ "errors": {
+ "geoip_file_required": "Trieu un fitxer de base de dades per pujar.",
+ "geoip_too_large": "El fitxer és més gran de 32 MiB.",
+ "geoip_not_mmdb": "El fitxer no és una base de dades GeoIP.",
+ "geoip_corrupt": "El fitxer de la base de dades està danyat i no es pot llegir.",
+ "geoip_wrong_type": "És una base de dades GeoIP, però no de països.",
+ "geoip_older_than_current": "Aquesta base de dades és més antiga que la que s'està usant. Pugeu-ne una de més recent.",
+ "geoip_build_date_in_future": "Aquesta base de dades té una data de compilació al futur.",
+ "geoip_custom_path_set": "El servidor té definida una ruta personalitzada de la base de dades, per tant la còpia no es pot canviar aquí.",
+ "geoip_busy": "Hi ha un altre canvi de base de dades en curs. Torneu-ho a provar d'aquí a un moment.",
+ "geoip_write_failed": "Charon no ha pogut desar el fitxer de la base de dades.",
+ "geoip_reload_failed": "Charon no ha pogut carregar el fitxer de la base de dades.",
+ "geoip_no_data_copy": "No hi ha cap còpia a la carpeta de dades per suprimir.",
+ "geoip_service_unavailable": "El servei GeoIP no s'està executant.",
+ "proxy_apply_failed": "Charon no ha pogut aplicar la configuració del proxy.",
+ "generic": "Alguna cosa ha fallat. Torneu-ho a provar."
}
}
},
diff --git a/frontend/src/locales/cs/translation.json b/frontend/src/locales/cs/translation.json
index d5a0b0131..9fbc8cc45 100644
--- a/frontend/src/locales/cs/translation.json
+++ b/frontend/src/locales/cs/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Oznámení",
+ "bellLabel": "Oznámení, nových: {{count}}",
+ "markAllRead": "Označit vše jako přečtené",
+ "empty": "Žádná nová oznámení",
+ "close": "Zavřít",
+ "updateAvailable": "K dispozici je aktualizace: {{version}}",
+ "viewChangelog": "Zobrazit changelog",
+ "alerts": {
+ "label": "Vyžaduje pozornost",
+ "clearsItself": "Po opravení problému toto zmizí samo.",
+ "geoip_unavailable": {
+ "title": "Seznamy přístupu podle země se neuplatňují",
+ "message": "Databáze zemí GeoIP chybí nebo je nepoužitelná, takže {{count}} seznam(ů) zemí ({{lists}}) pustí každého návštěvníka, dokud nebude znovu dostupná.",
+ "messagePendingApply": "Databáze GeoIP je znovu k dispozici, ale {{count}} seznam(ů) zemí ({{lists}}) se zatím neuplatňuje. Stiskněte Znovu načíst na kartě Databáze zemí.",
+ "docsLink": "Jak databázi zpřístupnit",
+ "issueHint": "Charon tuto databázi obvykle obsahuje. Otevřete prosím issue na GitHubu, abychom to mohli opravit.",
+ "issueLink": "Otevřít issue"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"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."
+ "geoip_unavailable": "Seznamy zemí nejsou vynucovány: databáze GeoIP v této instalaci chybí. Ke všem těmto hostitelům se mohou dostat všichni, dokud nebude databáze k dispozici. Viz dokumentace."
+ }
+ },
+ "geoip": {
+ "title": "Databáze zemí (GeoIP)",
+ "description": "Seznamy přístupu podle země potřebují databázi zemí GeoIP. Charon používá přiloženou kopii, pokud nezadáte vlastní.",
+ "loadError": "Stav databáze se nepodařilo načíst.",
+ "unknownValue": "Neznámé",
+ "status": {
+ "ready": "Připraveno",
+ "unavailable": "Není k dispozici"
+ },
+ "source": {
+ "label": "Zdroj",
+ "bundled": "Součástí Charonu",
+ "data": "Vaše datová složka",
+ "custom": "Na serveru je nastavena vlastní cesta",
+ "none": "Žádná databáze"
+ },
+ "buildDate": "Datum sestavení",
+ "size": "Velikost",
+ "path": "Soubor",
+ "releaseNote": "Přiložená kopie se aktualizuje s každou verzí Charonu.",
+ "customPathNote": "Cestu k databázi nastavuje server (CHARON_GEOIP_DB_PATH), takže kopii zde nelze nahradit.",
+ "readOnlyNote": "Datová složka je jen pro čtení, takže databázi zde nelze nahradit.",
+ "unavailableReason": "Není k dispozici. Seznamy zemí se neuplatňují, dokud není načtena databáze.",
+ "docsLink": "Jak zadat databázi",
+ "unenforcedLists": "{{count}} seznam(ů) zemí se neuplatňuje",
+ "formNote": {
+ "unavailable": "Tento seznam se neuplatní, dokud nebude databáze GeoIP k dispozici.",
+ "wouldRefuseYou": "Podle adresy, kterou právě používáte, by vás tento seznam zablokoval. Uložení je stále povoleno."
+ },
+ "actions": {
+ "reload": "Znovu načíst",
+ "replace": "Nahradit databázi…",
+ "replaceHint": "Nahrajte databázi zemí GeoIP (.mmdb), až do {{max}} MiB.",
+ "useIncluded": "Použít přiloženou kopii",
+ "useIncludedConfirmTitle": "Použít přiloženou kopii?",
+ "useIncludedConfirmBody": "Vaše kopie v datové složce bude odstraněna a Charon použije přiloženou databázi.",
+ "useIncludedConfirmBodyNoBundled": "Vaše kopie v datové složce bude odstraněna. Seznamy zemí se neuplatní, dokud nezadáte databázi."
+ },
+ "result": {
+ "reloaded": "Databáze byla znovu načtena.",
+ "replaced": "Databáze byla nahrazena. Charon nyní používá vaši kopii.",
+ "removed": "Charon znovu používá přiloženou kopii."
+ },
+ "lookup": {
+ "label": "IP adresa",
+ "placeholder": "Například 203.0.113.7",
+ "useMyAddress": "Použít mou adresu",
+ "submit": "Vyhledat",
+ "found": "{{ip}} je v zemi {{country}}.",
+ "notFound": "Pro {{ip}} nebyla nalezena žádná země.",
+ "disabled": "Vyhledávání je k dispozici po načtení databáze.",
+ "invalid": "Zadejte platnou IP adresu.",
+ "notLoaded": "Není načtena žádná databáze, takže adresy nelze vyhledávat.",
+ "myAddressFailed": "Charon nemohl přečíst vaši adresu."
+ },
+ "errors": {
+ "geoip_file_required": "Vyberte soubor databáze k nahrání.",
+ "geoip_too_large": "Tento soubor je větší než 32 MiB.",
+ "geoip_not_mmdb": "Tento soubor není databází GeoIP.",
+ "geoip_corrupt": "Tento soubor databáze je poškozen a nelze jej přečíst.",
+ "geoip_wrong_type": "Toto je databáze GeoIP, ale ne databáze zemí.",
+ "geoip_older_than_current": "Tato databáze je starší než databáze v použití. Nahrajte novější.",
+ "geoip_build_date_in_future": "Tato databáze má datum sestavení v budoucnosti.",
+ "geoip_custom_path_set": "Na serveru je nastavena vlastní cesta k databázi, takže kopii zde nelze změnit.",
+ "geoip_busy": "Probíhá jiná změna databáze. Zkuste to za chvíli znovu.",
+ "geoip_write_failed": "Charon nemohl soubor databáze uložit.",
+ "geoip_reload_failed": "Charon nemohl soubor databáze načíst.",
+ "geoip_no_data_copy": "V datové složce není žádná kopie k odstranění.",
+ "geoip_service_unavailable": "Služba GeoIP neběží.",
+ "proxy_apply_failed": "Charon nemohl použít konfiguraci proxy.",
+ "generic": "Něco se pokazilo. Zkuste to znovu."
}
}
},
diff --git a/frontend/src/locales/da/translation.json b/frontend/src/locales/da/translation.json
index 46087e8a5..8f7245cc2 100644
--- a/frontend/src/locales/da/translation.json
+++ b/frontend/src/locales/da/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Notifikationer",
+ "bellLabel": "Notifikationer, {{count}} nye",
+ "markAllRead": "Markér alle som læst",
+ "empty": "Ingen nye notifikationer",
+ "close": "Afvis",
+ "updateAvailable": "Opdatering tilgængelig: {{version}}",
+ "viewChangelog": "Se changelog",
+ "alerts": {
+ "label": "Kræver opmærksomhed",
+ "clearsItself": "Dette forsvinder af sig selv, når problemet er løst.",
+ "geoip_unavailable": {
+ "title": "Landelister for adgang håndhæves ikke",
+ "message": "GeoIP-landedatabasen mangler eller kan ikke bruges, så {{count}} landeliste(r) ({{lists}}) lader alle besøgende igennem, indtil den er tilgængelig igen.",
+ "messagePendingApply": "GeoIP-databasen er tilgængelig igen, men {{count}} landeliste(r) ({{lists}}) håndhæves endnu ikke. Tryk på Genindlæs på kortet Landedatabase.",
+ "docsLink": "Sådan leverer du databasen",
+ "issueHint": "Charon inkluderer normalt denne database. Opret venligst et issue på GitHub, så vi kan rette det.",
+ "issueLink": "Opret et issue"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"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."
+ "geoip_unavailable": "Landelister håndhæves ikke: GeoIP-databasen mangler i denne installation. Alle kan nå disse værterne, indtil den er tilgængelig. Se dokumentationen."
+ }
+ },
+ "geoip": {
+ "title": "Landedatabase (GeoIP)",
+ "description": "Landeadgangslister kræver en GeoIP-landedatabase. Charon bruger den kopi, der følger med, medmindre du angiver din egen.",
+ "loadError": "Databasestatus kunne ikke indlæses.",
+ "unknownValue": "Ukendt",
+ "status": {
+ "ready": "Klar",
+ "unavailable": "Ikke tilgængelig"
+ },
+ "source": {
+ "label": "Kilde",
+ "bundled": "Følger med Charon",
+ "data": "Din datamappe",
+ "custom": "Brugerdefineret sti er angivet på serveren",
+ "none": "Ingen database"
+ },
+ "buildDate": "Byggedato",
+ "size": "Størrelse",
+ "path": "Fil",
+ "releaseNote": "Den medfølgende kopi opdateres med hver Charon-udgivelse.",
+ "customPathNote": "Databasestien angives af serveren (CHARON_GEOIP_DB_PATH), så kopien kan ikke erstattes her.",
+ "readOnlyNote": "Datamappen er skrivebeskyttet, så databasen kan ikke erstattes her.",
+ "unavailableReason": "Ikke tilgængelig. Landelister håndhæves ikke, før en database er indlæst.",
+ "docsLink": "Sådan angiver du en database",
+ "unenforcedLists": "{{count}} landeliste(r) håndhæves ikke",
+ "formNote": {
+ "unavailable": "Denne liste håndhæves ikke, før GeoIP-databasen er tilgængelig.",
+ "wouldRefuseYou": "Ud fra den adresse, du bruger nu, ville denne liste blokere dig. Du kan stadig gemme."
+ },
+ "actions": {
+ "reload": "Genindlæs",
+ "replace": "Erstat database…",
+ "replaceHint": "Upload en GeoIP-landedatabase (.mmdb), op til {{max}} MiB.",
+ "useIncluded": "Brug den medfølgende kopi",
+ "useIncludedConfirmTitle": "Brug den medfølgende kopi?",
+ "useIncludedConfirmBody": "Din kopi i datamappen fjernes, og Charon bruger den database, der følger med.",
+ "useIncludedConfirmBodyNoBundled": "Din kopi i datamappen fjernes. Landelister håndhæves ikke, før du angiver en database."
+ },
+ "result": {
+ "reloaded": "Databasen er genindlæst.",
+ "replaced": "Databasen er erstattet. Charon bruger nu din kopi.",
+ "removed": "Charon bruger igen den medfølgende kopi."
+ },
+ "lookup": {
+ "label": "IP-adresse",
+ "placeholder": "For eksempel 203.0.113.7",
+ "useMyAddress": "Brug min adresse",
+ "submit": "Slå op",
+ "found": "{{ip}} er i {{country}}.",
+ "notFound": "Intet land fundet for {{ip}}.",
+ "disabled": "Opslag er tilgængeligt, når en database er indlæst.",
+ "invalid": "Angiv en gyldig IP-adresse.",
+ "notLoaded": "Ingen database er indlæst, så adresser kan ikke slås op.",
+ "myAddressFailed": "Charon kunne ikke læse din adresse."
+ },
+ "errors": {
+ "geoip_file_required": "Vælg en databasefil at uploade.",
+ "geoip_too_large": "Filen er større end 32 MiB.",
+ "geoip_not_mmdb": "Filen er ikke en GeoIP-database.",
+ "geoip_corrupt": "Databasefilen er beskadiget og kan ikke læses.",
+ "geoip_wrong_type": "Det er en GeoIP-database, men ikke en landedatabase.",
+ "geoip_older_than_current": "Databasen er ældre end den, der er i brug. Upload en nyere.",
+ "geoip_build_date_in_future": "Databasen har en byggedato i fremtiden.",
+ "geoip_custom_path_set": "Der er angivet en brugerdefineret databasesti på serveren, så kopien kan ikke ændres her.",
+ "geoip_busy": "En anden databaseændring er i gang. Prøv igen om lidt.",
+ "geoip_write_failed": "Charon kunne ikke gemme databasefilen.",
+ "geoip_reload_failed": "Charon kunne ikke indlæse databasefilen.",
+ "geoip_no_data_copy": "Der er ingen kopi i datamappen at fjerne.",
+ "geoip_service_unavailable": "GeoIP-tjenesten kører ikke.",
+ "proxy_apply_failed": "Charon kunne ikke anvende proxy-konfigurationen.",
+ "generic": "Noget gik galt. Prøv igen."
}
}
},
diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json
index 9b73aa36a..f3349aa13 100644
--- a/frontend/src/locales/de/translation.json
+++ b/frontend/src/locales/de/translation.json
@@ -382,6 +382,27 @@
"updateFailed": "Fehler beim Aktualisieren",
"partialFailed": "Abgeschlossen mit {{count}} Fehler(n)"
},
+ "notificationCenter": {
+ "title": "Benachrichtigungen",
+ "bellLabel": "Benachrichtigungen, {{count}} neu",
+ "markAllRead": "Alle als gelesen markieren",
+ "empty": "Keine neuen Benachrichtigungen",
+ "close": "Verwerfen",
+ "updateAvailable": "Update verfügbar: {{version}}",
+ "viewChangelog": "Changelog anzeigen",
+ "alerts": {
+ "label": "Benötigt Aufmerksamkeit",
+ "clearsItself": "Dies verschwindet automatisch, sobald das Problem behoben ist.",
+ "geoip_unavailable": {
+ "title": "Länderzugriffslisten werden nicht durchgesetzt",
+ "message": "Die GeoIP-Länderdatenbank fehlt oder ist unbrauchbar, daher lassen {{count}} Länderliste(n) ({{lists}}) jeden Besucher durch, bis sie wieder verfügbar ist.",
+ "messagePendingApply": "Die GeoIP-Datenbank ist wieder verfügbar, aber {{count}} Länderliste(n) ({{lists}}) werden noch nicht erzwungen. Drücken Sie auf „Neu laden“ auf der Karte Länderdatenbank.",
+ "docsLink": "So stellst du die Datenbank bereit",
+ "issueHint": "Charon enthält diese Datenbank normalerweise. Bitte ein GitHub-Issue eröffnen, damit wir das beheben können.",
+ "issueLink": "Issue eröffnen"
+ }
+ }
+ },
"security": {
"title": "Sicherheit",
"description": "Sicherheitsebenen für Ihren Reverse-Proxy konfigurieren",
@@ -609,7 +630,80 @@
},
"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."
+ "geoip_unavailable": "Länderlisten werden nicht durchgesetzt: Die GeoIP-Datenbank fehlt in dieser Installation. Alle können diese Hosts erreichen, bis sie bereitgestellt wird. Siehe Dokumentation."
+ }
+ },
+ "geoip": {
+ "title": "Länderdatenbank (GeoIP)",
+ "description": "Länderzugriffslisten benötigen eine GeoIP-Länderdatenbank. Charon verwendet die mitgelieferte Kopie, sofern Sie keine eigene angeben.",
+ "loadError": "Der Datenbankstatus konnte nicht geladen werden.",
+ "unknownValue": "Unbekannt",
+ "status": {
+ "ready": "Bereit",
+ "unavailable": "Nicht verfügbar"
+ },
+ "source": {
+ "label": "Quelle",
+ "bundled": "Im Lieferumfang von Charon enthalten",
+ "data": "Ihr Datenordner",
+ "custom": "Benutzerdefinierter Pfad auf dem Server gesetzt",
+ "none": "Keine Datenbank"
+ },
+ "buildDate": "Erstellungsdatum",
+ "size": "Größe",
+ "path": "Datei",
+ "releaseNote": "Die mitgelieferte Kopie wird mit jeder Charon-Version aktualisiert.",
+ "customPathNote": "Der Datenbankpfad wird vom Server festgelegt (CHARON_GEOIP_DB_PATH), daher kann die Kopie hier nicht ersetzt werden.",
+ "readOnlyNote": "Der Datenordner ist schreibgeschützt, daher kann die Datenbank hier nicht ersetzt werden.",
+ "unavailableReason": "Nicht verfügbar. Länderlisten werden erst erzwungen, wenn eine Datenbank geladen ist.",
+ "docsLink": "Wie Sie eine Datenbank bereitstellen",
+ "unenforcedLists": "{{count}} Länderliste(n) werden nicht erzwungen",
+ "formNote": {
+ "unavailable": "Diese Liste wird erst erzwungen, wenn die GeoIP-Datenbank verfügbar ist.",
+ "wouldRefuseYou": "Basierend auf der Adresse, die Sie gerade verwenden, würde diese Liste Sie sperren. Speichern ist trotzdem erlaubt."
+ },
+ "actions": {
+ "reload": "Neu laden",
+ "replace": "Datenbank ersetzen…",
+ "replaceHint": "Laden Sie eine GeoIP-Länderdatenbank (.mmdb) hoch, bis zu {{max}} MiB.",
+ "useIncluded": "Mitgelieferte Kopie verwenden",
+ "useIncludedConfirmTitle": "Mitgelieferte Kopie verwenden?",
+ "useIncludedConfirmBody": "Ihre Kopie im Datenordner wird entfernt und Charon verwendet die mitgelieferte Datenbank.",
+ "useIncludedConfirmBodyNoBundled": "Ihre Kopie im Datenordner wird entfernt. Länderlisten werden erst erzwungen, wenn Sie eine Datenbank bereitstellen."
+ },
+ "result": {
+ "reloaded": "Datenbank neu geladen.",
+ "replaced": "Datenbank ersetzt. Charon verwendet jetzt Ihre Kopie.",
+ "removed": "Charon verwendet wieder die mitgelieferte Kopie."
+ },
+ "lookup": {
+ "label": "IP-Adresse",
+ "placeholder": "Zum Beispiel 203.0.113.7",
+ "useMyAddress": "Meine Adresse verwenden",
+ "submit": "Suchen",
+ "found": "{{ip}} liegt in {{country}}.",
+ "notFound": "Kein Land für {{ip}} gefunden.",
+ "disabled": "Die Suche ist verfügbar, sobald eine Datenbank geladen ist.",
+ "invalid": "Geben Sie eine gültige IP-Adresse ein.",
+ "notLoaded": "Keine Datenbank geladen, daher können Adressen nicht nachgeschlagen werden.",
+ "myAddressFailed": "Charon konnte Ihre Adresse nicht ermitteln."
+ },
+ "errors": {
+ "geoip_file_required": "Wählen Sie eine Datenbankdatei zum Hochladen aus.",
+ "geoip_too_large": "Die Datei ist größer als 32 MiB.",
+ "geoip_not_mmdb": "Die Datei ist keine GeoIP-Datenbank.",
+ "geoip_corrupt": "Die Datenbankdatei ist beschädigt und kann nicht gelesen werden.",
+ "geoip_wrong_type": "Das ist eine GeoIP-Datenbank, aber keine Länderdatenbank.",
+ "geoip_older_than_current": "Diese Datenbank ist älter als die aktuell verwendete. Laden Sie eine neuere hoch.",
+ "geoip_build_date_in_future": "Diese Datenbank hat ein Erstellungsdatum in der Zukunft.",
+ "geoip_custom_path_set": "Auf dem Server ist ein benutzerdefinierter Datenbankpfad gesetzt, daher kann die Kopie hier nicht geändert werden.",
+ "geoip_busy": "Eine andere Datenbankänderung läuft gerade. Versuchen Sie es in einem Moment erneut.",
+ "geoip_write_failed": "Charon konnte die Datenbankdatei nicht speichern.",
+ "geoip_reload_failed": "Charon konnte die Datenbankdatei nicht laden.",
+ "geoip_no_data_copy": "Im Datenordner gibt es keine Kopie zum Entfernen.",
+ "geoip_service_unavailable": "Der GeoIP-Dienst läuft nicht.",
+ "proxy_apply_failed": "Charon konnte die Proxy-Konfiguration nicht anwenden.",
+ "generic": "Etwas ist schiefgegangen. Versuchen Sie es erneut."
}
}
},
diff --git a/frontend/src/locales/el/translation.json b/frontend/src/locales/el/translation.json
index 3295409d1..26ef6c687 100644
--- a/frontend/src/locales/el/translation.json
+++ b/frontend/src/locales/el/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Ειδοποιήσεις",
+ "bellLabel": "Ειδοποιήσεις, {{count}} νέες",
+ "markAllRead": "Σήμανση όλων ως αναγνωσμένων",
+ "empty": "Δεν υπάρχουν νέες ειδοποιήσεις",
+ "close": "Απόρριψη",
+ "updateAvailable": "Διαθέσιμη ενημέρωση: {{version}}",
+ "viewChangelog": "Προβολή αλλαγών",
+ "alerts": {
+ "label": "Απαιτεί προσοχή",
+ "clearsItself": "Αυτό εξαφανίζεται μόνο του όταν επιλυθεί το πρόβλημα.",
+ "geoip_unavailable": {
+ "title": "Οι λίστες πρόσβασης ανά χώρα δεν εφαρμόζονται",
+ "message": "Η βάση δεδομένων χωρών GeoIP λείπει ή δεν είναι χρήσιμη, οπότε {{count}} λίστα(ες) χωρών ({{lists}}) αφήνουν να περάσει κάθε επισκέπτης μέχρι να είναι ξανά διαθέσιμη.",
+ "messagePendingApply": "Η βάση δεδομένων GeoIP είναι ξανά διαθέσιμη, αλλά {{count}} λίστα(ες) χωρών ({{lists}}) δεν εφαρμόζονται ακόμη. Πατήστε Επαναφόρτωση στην κάρτα Βάση δεδομένων χωρών.",
+ "docsLink": "Πώς να παρέχετε τη βάση δεδομένων",
+ "issueHint": "Το Charon συνήθως περιλαμβάνει αυτή τη βάση δεδομένων. Ανοίξτε ένα issue στο GitHub ώστε να μπορέσουμε να το διορθώσουμε.",
+ "issueLink": "Άνοιγμα issue"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"settingUpdateFailed": "Failed to update setting: {{error}}",
"enforcement": {
"acl": {
- "geoip_unavailable": "Οι λίστες χωρών χρειάζονται τη βάση δεδομένων GeoIP, η οποία λείπει από αυτή την εγκατάσταση, και απορρίπτουν αιτήματα μέχρι να παρασχεθεί. Δείτε την τεκμηρίωση."
+ "geoip_unavailable": "Οι λίστες χωρών δεν εφαρμόζονται: η βάση δεδομένων GeoIP λείπει από αυτή την εγκατάσταση. Όλοι μπορούν να φτάσουν σε αυτούς τους κεντρικούς υπολογιστές μέχρι να παρασχεθεί. Δείτε την τεκμηρίωση."
+ }
+ },
+ "geoip": {
+ "title": "Βάση δεδομένων χωρών (GeoIP)",
+ "description": "Οι λίστες πρόσβασης ανά χώρα χρειάζονται μια βάση δεδομένων χωρών GeoIP. Το Charon χρησιμοποιεί το αντίγραφο που συνοδεύει την εφαρμογή, εκτός αν παρέχετε δική σας βάση.",
+ "loadError": "Δεν ήταν δυνατή η φόρτωση της κατάστασης της βάσης δεδομένων.",
+ "unknownValue": "Άγνωστο",
+ "status": {
+ "ready": "Έτοιμο",
+ "unavailable": "Μη διαθέσιμο"
+ },
+ "source": {
+ "label": "Πηγή",
+ "bundled": "Περιλαμβάνεται στο Charon",
+ "data": "Ο φάκελος δεδομένων σας",
+ "custom": "Έχει οριστεί προσαρμοσμένη διαδρομή στον διακομιστή",
+ "none": "Καμία βάση δεδομένων"
+ },
+ "buildDate": "Ημερομηνία έκδοσης",
+ "size": "Μέγεθος",
+ "path": "Αρχείο",
+ "releaseNote": "Το συμπεριλαμβανόμενο αντίγραφο ενημερώνεται με κάθε έκδοση του Charon.",
+ "customPathNote": "Η διαδρομή της βάσης δεδομένων ορίζεται από τον διακομιστή (CHARON_GEOIP_DB_PATH), οπότε το αντίγραφο δεν μπορεί να αντικατασταθεί εδώ.",
+ "readOnlyNote": "Ο φάκελος δεδομένων είναι μόνο για ανάγνωση, οπότε η βάση δεδομένων δεν μπορεί να αντικατασταθεί εδώ.",
+ "unavailableReason": "Μη διαθέσιμο. Οι λίστες χωρών δεν εφαρμόζονται μέχρι να φορτωθεί μια βάση δεδομένων.",
+ "docsLink": "Πώς να παρέχετε βάση δεδομένων",
+ "unenforcedLists": "{{count}} λίστα(ες) χωρών δεν εφαρμόζονται",
+ "formNote": {
+ "unavailable": "Αυτή η λίστα δεν θα εφαρμοστεί μέχρι να είναι διαθέσιμη η βάση δεδομένων GeoIP.",
+ "wouldRefuseYou": "Με βάση τη διεύθυνση που χρησιμοποιείτε τώρα, αυτή η λίστα θα σας έμπλοκαρε. Η αποθήκευση εξακολουθεί να επιτρέπεται."
+ },
+ "actions": {
+ "reload": "Επαναφόρτωση",
+ "replace": "Αντικατάσταση βάσης δεδομένων…",
+ "replaceHint": "Ανεβάστε μια βάση δεδομένων χωρών GeoIP (.mmdb), έως {{max}} MiB.",
+ "useIncluded": "Χρήση του συμπεριλαμβανόμενου αντιγράφου",
+ "useIncludedConfirmTitle": "Χρήση του συμπεριλαμβανόμενου αντιγράφου;",
+ "useIncludedConfirmBody": "Το αντίγραφό σας στον φάκελο δεδομένων θα διαγραφεί και το Charon θα χρησιμοποιεί τη συμπεριλαμβανόμενη βάση δεδομένων.",
+ "useIncludedConfirmBodyNoBundled": "Το αντίγραφό σας στον φάκελο δεδομένων θα διαγραφεί. Οι λίστες χωρών δεν θα εφαρμόζονται μέχρι να παρέχετε βάση δεδομένων."
+ },
+ "result": {
+ "reloaded": "Η βάση δεδομένων επαναφορτώθηκε.",
+ "replaced": "Η βάση δεδομένων αντικαταστάθηκε. Το Charon χρησιμοποιεί τώρα το αντίγραφό σας.",
+ "removed": "Το Charon χρησιμοποιεί ξανά το συμπεριλαμβανόμενο αντίγραφο."
+ },
+ "lookup": {
+ "label": "Διεύθυνση IP",
+ "placeholder": "Για παράδειγμα 203.0.113.7",
+ "useMyAddress": "Χρήση της διεύθυνσής μου",
+ "submit": "Αναζήτηση",
+ "found": "Η {{ip}} βρίσκεται στη χώρα {{country}}.",
+ "notFound": "Δεν βρέθηκε χώρα για τη {{ip}}.",
+ "disabled": "Η αναζήτηση είναι διαθέσιμη μόλις φορτωθεί μια βάση δεδομένων.",
+ "invalid": "Εισαγάγετε μια έγκυρη διεύθυνση IP.",
+ "notLoaded": "Δεν έχει φορτωθεί βάση δεδομένων, οπότε δεν μπορούν να αναζητηθούν διευθύνσεις.",
+ "myAddressFailed": "Το Charon δεν μπόρεσε να διαβάσει τη διεύθυνσή σας."
+ },
+ "errors": {
+ "geoip_file_required": "Επιλέξτε ένα αρχείο βάσης δεδομένων για μεταφόρτωση.",
+ "geoip_too_large": "Το αρχείο είναι μεγαλύτερο από 32 MiB.",
+ "geoip_not_mmdb": "Το αρχείο δεν είναι βάση δεδομένων GeoIP.",
+ "geoip_corrupt": "Το αρχείο της βάσης δεδομένων είναι κατεστραμμένο και δεν μπορεί να διαβαστεί.",
+ "geoip_wrong_type": "Είναι βάση δεδομένων GeoIP, αλλά όχι βάση δεδομένων χωρών.",
+ "geoip_older_than_current": "Αυτή η βάση δεδομένων είναι παλαιότερη από αυτή που χρησιμοποιείται. Ανεβάστε μια νεότερη.",
+ "geoip_build_date_in_future": "Αυτή η βάση δεδομένων έχει ημερομηνία έκδοσης στο μέλλον.",
+ "geoip_custom_path_set": "Έχει οριστεί προσαρμοσμένη διαδρομή βάσης δεδομένων στον διακομιστή, οπότε το αντίγραφο δεν μπορεί να αλλάξει εδώ.",
+ "geoip_busy": "Μια άλλη αλλαγή βάσης δεδομένων βρίσκεται σε εξέλιξη. Δοκιμάστε ξανά σε λίγο.",
+ "geoip_write_failed": "Το Charon δεν μπόρεσε να αποθηκεύσει το αρχείο της βάσης δεδομένων.",
+ "geoip_reload_failed": "Το Charon δεν μπόρεσε να φορτώσει το αρχείο της βάσης δεδομένων.",
+ "geoip_no_data_copy": "Δεν υπάρχει αντίγραφο στον φάκελο δεδομένων για διαγραφή.",
+ "geoip_service_unavailable": "Η υπηρεσία GeoIP δεν εκτελείται.",
+ "proxy_apply_failed": "Το Charon δεν μπόρεσε να εφαρμόσει τη διαμόρφωση του proxy.",
+ "generic": "Κάτι πήγε στραβά. Δοκιμάστε ξανά."
}
}
},
diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json
index c77ca4ddf..69217b880 100644
--- a/frontend/src/locales/en/translation.json
+++ b/frontend/src/locales/en/translation.json
@@ -382,6 +382,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Notifications",
+ "bellLabel": "Notifications, {{count}} new",
+ "markAllRead": "Mark all read",
+ "empty": "No new notifications",
+ "close": "Dismiss",
+ "updateAvailable": "Update available: {{version}}",
+ "viewChangelog": "View changelog",
+ "alerts": {
+ "label": "Needs attention",
+ "clearsItself": "This clears by itself when the problem is fixed.",
+ "geoip_unavailable": {
+ "title": "Country access lists are not being enforced",
+ "message": "The GeoIP country database is missing or unusable, so {{count}} country list(s) ({{lists}}) let every visitor through until it is available again.",
+ "messagePendingApply": "The GeoIP database is available again, but {{count}} country list(s) ({{lists}}) are not enforced yet. Press Reload on the Country database card.",
+ "docsLink": "How to provide the database",
+ "issueHint": "Charon normally includes this database. Please open a GitHub issue so we can fix it.",
+ "issueLink": "Open an issue"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -609,7 +630,80 @@
},
"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."
+ "geoip_unavailable": "Country lists are not enforced: the GeoIP database is missing from this install. Everyone can reach those hosts until it is provided. See the documentation."
+ }
+ },
+ "geoip": {
+ "title": "Country database (GeoIP)",
+ "description": "Country access lists need a GeoIP country database. Charon uses the copy included with it unless you provide your own.",
+ "loadError": "The database status could not be loaded.",
+ "unknownValue": "Unknown",
+ "status": {
+ "ready": "Ready",
+ "unavailable": "Not available"
+ },
+ "source": {
+ "label": "Source",
+ "bundled": "Included with Charon",
+ "data": "Your data folder",
+ "custom": "Custom path set on the server",
+ "none": "No database"
+ },
+ "buildDate": "Build date",
+ "size": "Size",
+ "path": "File",
+ "releaseNote": "The included copy is updated with each Charon release.",
+ "customPathNote": "The database path is set by the server (CHARON_GEOIP_DB_PATH), so the copy cannot be replaced here.",
+ "readOnlyNote": "The data folder is read-only, so the database cannot be replaced here.",
+ "unavailableReason": "Not available. Country lists are not enforced until a database is loaded.",
+ "docsLink": "How to provide a database",
+ "unenforcedLists": "{{count}} country list(s) are not enforced",
+ "formNote": {
+ "unavailable": "This list will not be enforced until the GeoIP database is available.",
+ "wouldRefuseYou": "Based on the address you are using now, this list would refuse you. Saving is still allowed."
+ },
+ "actions": {
+ "reload": "Reload",
+ "replace": "Replace database…",
+ "replaceHint": "Upload a GeoIP Country database (.mmdb), up to {{max}} MiB.",
+ "useIncluded": "Use the included copy",
+ "useIncludedConfirmTitle": "Use the included copy?",
+ "useIncludedConfirmBody": "Your copy in the data folder will be removed and Charon will use the database included with it.",
+ "useIncludedConfirmBodyNoBundled": "Your copy in the data folder will be removed. Country lists will not be enforced until you provide a database."
+ },
+ "result": {
+ "reloaded": "Database reloaded.",
+ "replaced": "Database replaced. Charon is now using your copy.",
+ "removed": "Charon is using the included copy again."
+ },
+ "lookup": {
+ "label": "IP address",
+ "placeholder": "For example 203.0.113.7",
+ "useMyAddress": "Use my address",
+ "submit": "Look up",
+ "found": "{{ip}} is in {{country}}.",
+ "notFound": "No country found for {{ip}}.",
+ "disabled": "Lookup is available once a database is loaded.",
+ "invalid": "Enter a valid IP address.",
+ "notLoaded": "No database is loaded, so addresses cannot be looked up.",
+ "myAddressFailed": "Charon could not read your address."
+ },
+ "errors": {
+ "geoip_file_required": "Choose a database file to upload.",
+ "geoip_too_large": "That file is larger than 32 MiB.",
+ "geoip_not_mmdb": "That file is not a GeoIP database.",
+ "geoip_corrupt": "That database file is damaged and cannot be read.",
+ "geoip_wrong_type": "That is a GeoIP database, but not a country database.",
+ "geoip_older_than_current": "That database is older than the one in use. Upload a newer one.",
+ "geoip_build_date_in_future": "That database has a build date in the future.",
+ "geoip_custom_path_set": "A custom database path is set on the server, so the copy cannot be changed here.",
+ "geoip_busy": "Another database change is in progress. Try again in a moment.",
+ "geoip_write_failed": "Charon could not save the database file.",
+ "geoip_reload_failed": "Charon could not load the database file.",
+ "geoip_no_data_copy": "There is no copy in the data folder to remove.",
+ "geoip_service_unavailable": "The GeoIP service is not running.",
+ "proxy_apply_failed": "Charon could not apply the proxy configuration.",
+ "generic": "Something went wrong. Try again."
}
}
},
diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json
index 43787c3b0..410f4d4ee 100644
--- a/frontend/src/locales/es/translation.json
+++ b/frontend/src/locales/es/translation.json
@@ -382,6 +382,27 @@
"updateFailed": "Error al actualizar",
"partialFailed": "Completado con {{count}} error(es)"
},
+ "notificationCenter": {
+ "title": "Notificaciones",
+ "bellLabel": "Notificaciones, {{count}} nuevas",
+ "markAllRead": "Marcar todas como leídas",
+ "empty": "No hay notificaciones nuevas",
+ "close": "Descartar",
+ "updateAvailable": "Actualización disponible: {{version}}",
+ "viewChangelog": "Ver registro de cambios",
+ "alerts": {
+ "label": "Requiere atención",
+ "clearsItself": "Esto se resuelve solo cuando se corrige el problema.",
+ "geoip_unavailable": {
+ "title": "Las listas de acceso por país no se están aplicando",
+ "message": "La base de datos de países GeoIP falta o no se puede usar, así que {{count}} lista(s) de países ({{lists}}) dejan pasar a cualquier visitante hasta que vuelva a estar disponible.",
+ "messagePendingApply": "La base de datos de GeoIP está disponible de nuevo, pero {{count}} lista(s) de países ({{lists}}) aún no se aplican. Pulse Recargar en la tarjeta Base de datos de países.",
+ "docsLink": "Cómo proporcionar la base de datos",
+ "issueHint": "Charon normalmente incluye esta base de datos. Abre un issue en GitHub para que podamos corregirlo.",
+ "issueLink": "Abrir un issue"
+ }
+ }
+ },
"security": {
"title": "Seguridad",
"description": "Configura las capas de seguridad para tu proxy inverso",
@@ -609,7 +630,80 @@
},
"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."
+ "geoip_unavailable": "Las listas de países no se aplican: la base de datos GeoIP falta en esta instalación. Todos pueden acceder a esos hosts hasta que se proporcione. Consulta la documentación."
+ }
+ },
+ "geoip": {
+ "title": "Base de datos de países (GeoIP)",
+ "description": "Las listas de acceso por país necesitan una base de datos de países de GeoIP. Charon usa la copia incluida, a menos que proporcione la suya.",
+ "loadError": "No se pudo cargar el estado de la base de datos.",
+ "unknownValue": "Desconocido",
+ "status": {
+ "ready": "Listo",
+ "unavailable": "No disponible"
+ },
+ "source": {
+ "label": "Origen",
+ "bundled": "Incluida con Charon",
+ "data": "Su carpeta de datos",
+ "custom": "Ruta personalizada definida en el servidor",
+ "none": "Sin base de datos"
+ },
+ "buildDate": "Fecha de compilación",
+ "size": "Tamaño",
+ "path": "Archivo",
+ "releaseNote": "La copia incluida se actualiza con cada versión de Charon.",
+ "customPathNote": "La ruta de la base de datos la define el servidor (CHARON_GEOIP_DB_PATH), así que la copia no se puede reemplazar desde aquí.",
+ "readOnlyNote": "La carpeta de datos es de solo lectura, así que la base de datos no se puede reemplazar desde aquí.",
+ "unavailableReason": "No disponible. Las listas de países no se aplican hasta que se cargue una base de datos.",
+ "docsLink": "Cómo proporcionar una base de datos",
+ "unenforcedLists": "{{count}} lista(s) de países no se aplican",
+ "formNote": {
+ "unavailable": "Esta lista no se aplicará hasta que la base de datos de GeoIP esté disponible.",
+ "wouldRefuseYou": "Según la dirección que usa ahora, esta lista le bloquearía el acceso. Aun así se permite guardar."
+ },
+ "actions": {
+ "reload": "Recargar",
+ "replace": "Reemplazar base de datos…",
+ "replaceHint": "Suba una base de datos de países de GeoIP (.mmdb), de hasta {{max}} MiB.",
+ "useIncluded": "Usar la copia incluida",
+ "useIncludedConfirmTitle": "¿Usar la copia incluida?",
+ "useIncludedConfirmBody": "Su copia de la carpeta de datos se eliminará y Charon usará la base de datos incluida.",
+ "useIncludedConfirmBodyNoBundled": "Su copia de la carpeta de datos se eliminará. Las listas de países no se aplicarán hasta que proporcione una base de datos."
+ },
+ "result": {
+ "reloaded": "Base de datos recargada.",
+ "replaced": "Base de datos reemplazada. Charon ahora usa su copia.",
+ "removed": "Charon vuelve a usar la copia incluida."
+ },
+ "lookup": {
+ "label": "Dirección IP",
+ "placeholder": "Por ejemplo 203.0.113.7",
+ "useMyAddress": "Usar mi dirección",
+ "submit": "Buscar",
+ "found": "{{ip}} está en {{country}}.",
+ "notFound": "No se encontró ningún país para {{ip}}.",
+ "disabled": "La búsqueda está disponible cuando se carga una base de datos.",
+ "invalid": "Introduzca una dirección IP válida.",
+ "notLoaded": "No hay ninguna base de datos cargada, así que no se pueden buscar direcciones.",
+ "myAddressFailed": "Charon no pudo leer su dirección."
+ },
+ "errors": {
+ "geoip_file_required": "Elija un archivo de base de datos para subir.",
+ "geoip_too_large": "Ese archivo supera los 32 MiB.",
+ "geoip_not_mmdb": "Ese archivo no es una base de datos de GeoIP.",
+ "geoip_corrupt": "Ese archivo de base de datos está dañado y no se puede leer.",
+ "geoip_wrong_type": "Es una base de datos de GeoIP, pero no de países.",
+ "geoip_older_than_current": "Esa base de datos es más antigua que la que está en uso. Suba una más reciente.",
+ "geoip_build_date_in_future": "Esa base de datos tiene una fecha de compilación en el futuro.",
+ "geoip_custom_path_set": "Hay una ruta de base de datos personalizada en el servidor, así que la copia no se puede cambiar desde aquí.",
+ "geoip_busy": "Hay otro cambio de base de datos en curso. Inténtelo de nuevo en un momento.",
+ "geoip_write_failed": "Charon no pudo guardar el archivo de la base de datos.",
+ "geoip_reload_failed": "Charon no pudo cargar el archivo de la base de datos.",
+ "geoip_no_data_copy": "No hay ninguna copia en la carpeta de datos para eliminar.",
+ "geoip_service_unavailable": "El servicio GeoIP no está en ejecución.",
+ "proxy_apply_failed": "Charon no pudo aplicar la configuración del proxy.",
+ "generic": "Algo salió mal. Inténtelo de nuevo."
}
}
},
diff --git a/frontend/src/locales/fi/translation.json b/frontend/src/locales/fi/translation.json
index 8a2cfc618..66cfc568c 100644
--- a/frontend/src/locales/fi/translation.json
+++ b/frontend/src/locales/fi/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Ilmoitukset",
+ "bellLabel": "Ilmoitukset, {{count}} uutta",
+ "markAllRead": "Merkitse kaikki luetuiksi",
+ "empty": "Ei uusia ilmoituksia",
+ "close": "Hylkää",
+ "updateAvailable": "Päivitys saatavilla: {{version}}",
+ "viewChangelog": "Näytä muutosloki",
+ "alerts": {
+ "label": "Vaatii huomiota",
+ "clearsItself": "Tämä poistuu itsestään, kun ongelma on korjattu.",
+ "geoip_unavailable": {
+ "title": "Maakohtaisia pääsylistoja ei noudateta",
+ "message": "GeoIP-maatietokanta puuttuu tai ei ole käytettävissä, joten {{count}} maalista ({{lists}}) päästää jokaisen kävijän läpi, kunnes tietokanta on taas saatavilla.",
+ "messagePendingApply": "GeoIP-tietokanta on taas saatavilla, mutta {{count}} maalistaa ({{lists}}) ei vielä noudateta. Paina Lataa uudelleen Maatietokanta-kortilla.",
+ "docsLink": "Näin toimitat tietokannan",
+ "issueHint": "Charon sisältää yleensä tämän tietokannan. Avaa GitHubiin issue, jotta voimme korjata asian.",
+ "issueLink": "Avaa issue"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"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."
+ "geoip_unavailable": "Maaluetteloita ei noudateta: GeoIP-tietokanta puuttuu tästä asennuksesta. Kaikki pääsevät näihin isäntiin, kunnes tietokanta on toimitettu. Katso dokumentaatio."
+ }
+ },
+ "geoip": {
+ "title": "Maatietokanta (GeoIP)",
+ "description": "Maakohtaiset pääsylistat tarvitsevat GeoIP-maatietokannan. Charon käyttää mukana tulevaa kopiota, ellet anna omaa.",
+ "loadError": "Tietokannan tilaa ei voitu ladata.",
+ "unknownValue": "Tuntematon",
+ "status": {
+ "ready": "Valmis",
+ "unavailable": "Ei saatavilla"
+ },
+ "source": {
+ "label": "Lähde",
+ "bundled": "Mukana Charonissa",
+ "data": "Datakansiosi",
+ "custom": "Palvelimelle on asetettu mukautettu polku",
+ "none": "Ei tietokantaa"
+ },
+ "buildDate": "Koontipäivämäärä",
+ "size": "Koko",
+ "path": "Tiedosto",
+ "releaseNote": "Mukana tuleva kopio päivittyy jokaisen Charon-julkaisun yhteydessä.",
+ "customPathNote": "Palvelin asettaa tietokannan polun (CHARON_GEOIP_DB_PATH), joten kopiota ei voi korvata täällä.",
+ "readOnlyNote": "Datakansio on vain luettavissa, joten tietokantaa ei voi korvata täällä.",
+ "unavailableReason": "Ei saatavilla. Maalistoja ei noudateta, ennen kuin tietokanta on ladattu.",
+ "docsLink": "Näin annat tietokannan",
+ "unenforcedLists": "{{count}} maalistaa ei noudateta",
+ "formNote": {
+ "unavailable": "Tätä listaa ei noudateta, ennen kuin GeoIP-tietokanta on saatavilla.",
+ "wouldRefuseYou": "Nykyisen osoitteesi perusteella tämä lista estäisi sinut. Tallentaminen on silti sallittua."
+ },
+ "actions": {
+ "reload": "Lataa uudelleen",
+ "replace": "Korvaa tietokanta…",
+ "replaceHint": "Lataa GeoIP-maatietokanta (.mmdb), enintään {{max}} MiB.",
+ "useIncluded": "Käytä mukana tulevaa kopiota",
+ "useIncludedConfirmTitle": "Käytetäänkö mukana tulevaa kopiota?",
+ "useIncludedConfirmBody": "Datakansiosi kopio poistetaan ja Charon käyttää mukana tulevaa tietokantaa.",
+ "useIncludedConfirmBodyNoBundled": "Datakansiosi kopio poistetaan. Maalistoja ei noudateta, ennen kuin annat tietokannan."
+ },
+ "result": {
+ "reloaded": "Tietokanta ladattiin uudelleen.",
+ "replaced": "Tietokanta korvattiin. Charon käyttää nyt kopiotasi.",
+ "removed": "Charon käyttää taas mukana tulevaa kopiota."
+ },
+ "lookup": {
+ "label": "IP-osoite",
+ "placeholder": "Esimerkiksi 203.0.113.7",
+ "useMyAddress": "Käytä omaa osoitettani",
+ "submit": "Hae",
+ "found": "{{ip}} on maassa {{country}}.",
+ "notFound": "Maata ei löytynyt osoitteelle {{ip}}.",
+ "disabled": "Haku on käytettävissä, kun tietokanta on ladattu.",
+ "invalid": "Anna kelvollinen IP-osoite.",
+ "notLoaded": "Tietokantaa ei ole ladattu, joten osoitteita ei voi hakea.",
+ "myAddressFailed": "Charon ei voinut lukea osoitettasi."
+ },
+ "errors": {
+ "geoip_file_required": "Valitse ladattava tietokantatiedosto.",
+ "geoip_too_large": "Tiedosto on suurempi kuin 32 MiB.",
+ "geoip_not_mmdb": "Tiedosto ei ole GeoIP-tietokanta.",
+ "geoip_corrupt": "Tietokantatiedosto on vioittunut eikä sitä voi lukea.",
+ "geoip_wrong_type": "Tämä on GeoIP-tietokanta, mutta ei maatietokanta.",
+ "geoip_older_than_current": "Tietokanta on vanhempi kuin käytössä oleva. Lataa uudempi.",
+ "geoip_build_date_in_future": "Tietokannan koontipäivämäärä on tulevaisuudessa.",
+ "geoip_custom_path_set": "Palvelimelle on asetettu mukautettu tietokantapolku, joten kopiota ei voi muuttaa täällä.",
+ "geoip_busy": "Toinen tietokantamuutos on käynnissä. Yritä hetken kuluttua uudelleen.",
+ "geoip_write_failed": "Charon ei voinut tallentaa tietokantatiedostoa.",
+ "geoip_reload_failed": "Charon ei voinut ladata tietokantatiedostoa.",
+ "geoip_no_data_copy": "Datakansiossa ei ole kopiota poistettavaksi.",
+ "geoip_service_unavailable": "GeoIP-palvelu ei ole käynnissä.",
+ "proxy_apply_failed": "Charon ei voinut ottaa välityspalvelimen asetuksia käyttöön.",
+ "generic": "Jotain meni pieleen. Yritä uudelleen."
}
}
},
diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json
index de7374eb0..f5055e41f 100644
--- a/frontend/src/locales/fr/translation.json
+++ b/frontend/src/locales/fr/translation.json
@@ -382,6 +382,27 @@
"updateFailed": "Échec de la mise à jour",
"partialFailed": "Terminé avec {{count}} erreur(s)"
},
+ "notificationCenter": {
+ "title": "Notifications",
+ "bellLabel": "Notifications, {{count}} nouvelle(s)",
+ "markAllRead": "Tout marquer comme lu",
+ "empty": "Aucune nouvelle notification",
+ "close": "Ignorer",
+ "updateAvailable": "Mise à jour disponible : {{version}}",
+ "viewChangelog": "Voir le journal des modifications",
+ "alerts": {
+ "label": "Nécessite votre attention",
+ "clearsItself": "Cela disparaît automatiquement une fois le problème résolu.",
+ "geoip_unavailable": {
+ "title": "Les listes d'accès par pays ne sont pas appliquées",
+ "message": "La base de données GeoIP des pays est absente ou inutilisable : {{count}} liste(s) de pays ({{lists}}) laissent passer tous les visiteurs tant qu'elle n'est pas disponible.",
+ "messagePendingApply": "La base de données GeoIP est de nouveau disponible, mais {{count}} liste(s) de pays ({{lists}}) ne sont pas encore appliquées. Appuyez sur Recharger sur la carte Base de données des pays.",
+ "docsLink": "Comment fournir la base de données",
+ "issueHint": "Charon inclut normalement cette base de données. Merci d'ouvrir un ticket GitHub afin que nous puissions corriger cela.",
+ "issueLink": "Ouvrir un ticket"
+ }
+ }
+ },
"security": {
"title": "Sécurité",
"description": "Configurer les couches de sécurité pour votre proxy inverse",
@@ -609,7 +630,80 @@
},
"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."
+ "geoip_unavailable": "Les listes de pays ne sont pas appliquées : la base de données GeoIP est absente de cette installation. Tout le monde peut accéder à ces hôtes tant qu'elle n'est pas fournie. Voir la documentation."
+ }
+ },
+ "geoip": {
+ "title": "Base de données de pays (GeoIP)",
+ "description": "Les listes d'accès par pays nécessitent une base de données GeoIP des pays. Charon utilise la copie incluse, sauf si vous en fournissez une.",
+ "loadError": "L'état de la base de données n'a pas pu être chargé.",
+ "unknownValue": "Inconnu",
+ "status": {
+ "ready": "Prêt",
+ "unavailable": "Non disponible"
+ },
+ "source": {
+ "label": "Source",
+ "bundled": "Incluse avec Charon",
+ "data": "Votre dossier de données",
+ "custom": "Chemin personnalisé défini sur le serveur",
+ "none": "Aucune base de données"
+ },
+ "buildDate": "Date de compilation",
+ "size": "Taille",
+ "path": "Fichier",
+ "releaseNote": "La copie incluse est mise à jour à chaque version de Charon.",
+ "customPathNote": "Le chemin de la base de données est défini par le serveur (CHARON_GEOIP_DB_PATH), la copie ne peut donc pas être remplacée ici.",
+ "readOnlyNote": "Le dossier de données est en lecture seule, la base de données ne peut donc pas être remplacée ici.",
+ "unavailableReason": "Non disponible. Les listes de pays ne sont pas appliquées tant qu'une base de données n'est pas chargée.",
+ "docsLink": "Comment fournir une base de données",
+ "unenforcedLists": "{{count}} liste(s) de pays non appliquée(s)",
+ "formNote": {
+ "unavailable": "Cette liste ne sera pas appliquée tant que la base de données GeoIP ne sera pas disponible.",
+ "wouldRefuseYou": "D'après l'adresse que vous utilisez actuellement, cette liste vous bloquerait. L'enregistrement reste autorisé."
+ },
+ "actions": {
+ "reload": "Recharger",
+ "replace": "Remplacer la base de données…",
+ "replaceHint": "Importez une base de données GeoIP des pays (.mmdb), jusqu'à {{max}} MiB.",
+ "useIncluded": "Utiliser la copie incluse",
+ "useIncludedConfirmTitle": "Utiliser la copie incluse ?",
+ "useIncludedConfirmBody": "Votre copie du dossier de données sera supprimée et Charon utilisera la base de données incluse.",
+ "useIncludedConfirmBodyNoBundled": "Votre copie du dossier de données sera supprimée. Les listes de pays ne seront pas appliquées tant que vous n'aurez pas fourni de base de données."
+ },
+ "result": {
+ "reloaded": "Base de données rechargée.",
+ "replaced": "Base de données remplacée. Charon utilise maintenant votre copie.",
+ "removed": "Charon utilise de nouveau la copie incluse."
+ },
+ "lookup": {
+ "label": "Adresse IP",
+ "placeholder": "Par exemple 203.0.113.7",
+ "useMyAddress": "Utiliser mon adresse",
+ "submit": "Rechercher",
+ "found": "{{ip}} se trouve en {{country}}.",
+ "notFound": "Aucun pays trouvé pour {{ip}}.",
+ "disabled": "La recherche est disponible une fois une base de données chargée.",
+ "invalid": "Saisissez une adresse IP valide.",
+ "notLoaded": "Aucune base de données n'est chargée, les adresses ne peuvent donc pas être recherchées.",
+ "myAddressFailed": "Charon n'a pas pu lire votre adresse."
+ },
+ "errors": {
+ "geoip_file_required": "Choisissez un fichier de base de données à importer.",
+ "geoip_too_large": "Ce fichier dépasse 32 MiB.",
+ "geoip_not_mmdb": "Ce fichier n'est pas une base de données GeoIP.",
+ "geoip_corrupt": "Ce fichier de base de données est endommagé et ne peut pas être lu.",
+ "geoip_wrong_type": "Il s'agit d'une base de données GeoIP, mais pas d'une base de données de pays.",
+ "geoip_older_than_current": "Cette base de données est plus ancienne que celle en cours d'utilisation. Importez une version plus récente.",
+ "geoip_build_date_in_future": "Cette base de données porte une date de compilation future.",
+ "geoip_custom_path_set": "Un chemin de base de données personnalisé est défini sur le serveur, la copie ne peut donc pas être modifiée ici.",
+ "geoip_busy": "Une autre modification de la base de données est en cours. Réessayez dans un instant.",
+ "geoip_write_failed": "Charon n'a pas pu enregistrer le fichier de la base de données.",
+ "geoip_reload_failed": "Charon n'a pas pu charger le fichier de la base de données.",
+ "geoip_no_data_copy": "Il n'y a aucune copie dans le dossier de données à supprimer.",
+ "geoip_service_unavailable": "Le service GeoIP n'est pas en cours d'exécution.",
+ "proxy_apply_failed": "Charon n'a pas pu appliquer la configuration du proxy.",
+ "generic": "Une erreur s'est produite. Réessayez."
}
}
},
diff --git a/frontend/src/locales/he/translation.json b/frontend/src/locales/he/translation.json
index 461afa9d2..1d49fbccd 100644
--- a/frontend/src/locales/he/translation.json
+++ b/frontend/src/locales/he/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "התראות",
+ "bellLabel": "התראות, {{count}} חדשות",
+ "markAllRead": "סמן הכול כנקרא",
+ "empty": "אין התראות חדשות",
+ "close": "סגור",
+ "updateAvailable": "עדכון זמין: {{version}}",
+ "viewChangelog": "הצג יומן שינויים",
+ "alerts": {
+ "label": "דורש תשומת לב",
+ "clearsItself": "זה ייעלם מעצמו כשהבעיה תיפתר.",
+ "geoip_unavailable": {
+ "title": "רשימות גישה לפי מדינה אינן נאכפות",
+ "message": "מסד הנתונים של מדינות GeoIP חסר או אינו שמיש, ולכן {{count}} רשימות מדינות ({{lists}}) מתירות לכל מבקר לעבור עד שיהיה זמין שוב.",
+ "messagePendingApply": "מסד הנתונים של GeoIP זמין שוב, אך {{count}} רשימות מדינות ({{lists}}) עדיין לא נאכפות. לחץ על טען מחדש בכרטיס מסד נתוני המדינות.",
+ "docsLink": "כיצד לספק את מסד הנתונים",
+ "issueHint": "Charon כולל בדרך כלל את מסד הנתונים הזה. אנא פתחו issue ב-GitHub כדי שנוכל לתקן זאת.",
+ "issueLink": "פתח issue"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"settingUpdateFailed": "Failed to update setting: {{error}}",
"enforcement": {
"acl": {
- "geoip_unavailable": "רשימות מדינות דורשות את מסד הנתונים של GeoIP, שחסר בהתקנה זו, ולכן הן דוחות בקשות עד שיסופק. ראו את התיעוד."
+ "geoip_unavailable": "רשימות מדינות אינן נאכפות: מסד הנתונים של GeoIP חסר בהתקנה זו. כל אחד יכול להגיע למארחים אלה עד שיסופק. ראו את התיעוד."
+ }
+ },
+ "geoip": {
+ "title": "מסד נתונים של מדינות (GeoIP)",
+ "description": "רשימות גישה לפי מדינה דורשות מסד נתונים של מדינות מ-GeoIP. Charon משתמש בעותק המצורף, אלא אם תספק עותק משלך.",
+ "loadError": "לא ניתן היה לטעון את מצב מסד הנתונים.",
+ "unknownValue": "לא ידוע",
+ "status": {
+ "ready": "מוכן",
+ "unavailable": "לא זמין"
+ },
+ "source": {
+ "label": "מקור",
+ "bundled": "כלול ב-Charon",
+ "data": "תיקיית הנתונים שלך",
+ "custom": "נתיב מותאם אישית מוגדר בשרת",
+ "none": "אין מסד נתונים"
+ },
+ "buildDate": "תאריך בנייה",
+ "size": "גודל",
+ "path": "קובץ",
+ "releaseNote": "העותק המצורף מתעדכן עם כל גרסה של Charon.",
+ "customPathNote": "נתיב מסד הנתונים נקבע על ידי השרת (CHARON_GEOIP_DB_PATH), ולכן לא ניתן להחליף את העותק כאן.",
+ "readOnlyNote": "תיקיית הנתונים היא לקריאה בלבד, ולכן לא ניתן להחליף את מסד הנתונים כאן.",
+ "unavailableReason": "לא זמין. רשימות מדינות לא נאכפות עד שייטען מסד נתונים.",
+ "docsLink": "איך לספק מסד נתונים",
+ "unenforcedLists": "{{count}} רשימות מדינות לא נאכפות",
+ "formNote": {
+ "unavailable": "רשימה זו לא תיאכף עד שמסד הנתונים של GeoIP יהיה זמין.",
+ "wouldRefuseYou": "על פי הכתובת שבה אתה משתמש כעת, רשימה זו הייתה חוסמת אותך. שמירה עדיין מותרת."
+ },
+ "actions": {
+ "reload": "טען מחדש",
+ "replace": "החלף מסד נתונים…",
+ "replaceHint": "העלה מסד נתונים של מדינות מ-GeoIP (.mmdb), עד {{max}} MiB.",
+ "useIncluded": "השתמש בעותק המצורף",
+ "useIncludedConfirmTitle": "להשתמש בעותק המצורף?",
+ "useIncludedConfirmBody": "העותק שלך בתיקיית הנתונים יוסר, ו-Charon ישתמש במסד הנתונים הכלול.",
+ "useIncludedConfirmBodyNoBundled": "העותק שלך בתיקיית הנתונים יוסר. רשימות מדינות לא ייאכפו עד שתספק מסד נתונים."
+ },
+ "result": {
+ "reloaded": "מסד הנתונים נטען מחדש.",
+ "replaced": "מסד הנתונים הוחלף. Charon משתמש כעת בעותק שלך.",
+ "removed": "Charon משתמש שוב בעותק המצורף."
+ },
+ "lookup": {
+ "label": "כתובת IP",
+ "placeholder": "לדוגמה 203.0.113.7",
+ "useMyAddress": "השתמש בכתובת שלי",
+ "submit": "חפש",
+ "found": "{{ip}} נמצאת ב{{country}}.",
+ "notFound": "לא נמצאה מדינה עבור {{ip}}.",
+ "disabled": "החיפוש זמין לאחר טעינת מסד נתונים.",
+ "invalid": "הזן כתובת IP תקינה.",
+ "notLoaded": "לא נטען מסד נתונים, ולכן לא ניתן לחפש כתובות.",
+ "myAddressFailed": "Charon לא הצליח לקרוא את הכתובת שלך."
+ },
+ "errors": {
+ "geoip_file_required": "בחר קובץ מסד נתונים להעלאה.",
+ "geoip_too_large": "הקובץ גדול מ-32 MiB.",
+ "geoip_not_mmdb": "הקובץ אינו מסד נתונים של GeoIP.",
+ "geoip_corrupt": "קובץ מסד הנתונים פגום ואי אפשר לקרוא אותו.",
+ "geoip_wrong_type": "זהו מסד נתונים של GeoIP, אבל לא מסד נתונים של מדינות.",
+ "geoip_older_than_current": "מסד נתונים זה ישן מזה שבשימוש. העלה גרסה חדשה יותר.",
+ "geoip_build_date_in_future": "תאריך הבנייה של מסד הנתונים הוא בעתיד.",
+ "geoip_custom_path_set": "נתיב מסד נתונים מותאם אישית מוגדר בשרת, ולכן לא ניתן לשנות את העותק כאן.",
+ "geoip_busy": "שינוי אחר במסד הנתונים מתבצע כעת. נסה שוב בעוד רגע.",
+ "geoip_write_failed": "Charon לא הצליח לשמור את קובץ מסד הנתונים.",
+ "geoip_reload_failed": "Charon לא הצליח לטעון את קובץ מסד הנתונים.",
+ "geoip_no_data_copy": "אין עותק בתיקיית הנתונים להסרה.",
+ "geoip_service_unavailable": "שירות GeoIP אינו פועל.",
+ "proxy_apply_failed": "Charon לא הצליח להחיל את תצורת ה-proxy.",
+ "generic": "משהו השתבש. נסה שוב."
}
}
},
diff --git a/frontend/src/locales/hu/translation.json b/frontend/src/locales/hu/translation.json
index 43856d0fd..db6c85feb 100644
--- a/frontend/src/locales/hu/translation.json
+++ b/frontend/src/locales/hu/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Értesítések",
+ "bellLabel": "Értesítések, {{count}} új",
+ "markAllRead": "Összes megjelölése olvasottként",
+ "empty": "Nincs új értesítés",
+ "close": "Elvetés",
+ "updateAvailable": "Frissítés elérhető: {{version}}",
+ "viewChangelog": "Változásnapló megtekintése",
+ "alerts": {
+ "label": "Figyelmet igényel",
+ "clearsItself": "A probléma megoldása után ez automatikusan megszűnik.",
+ "geoip_unavailable": {
+ "title": "Az országlista-alapú hozzáférés nincs érvényesítve",
+ "message": "A GeoIP országadatbázis hiányzik vagy nem használható, ezért {{count}} országlista ({{lists}}) minden látogatót átenged, amíg újra elérhetővé nem válik.",
+ "messagePendingApply": "A GeoIP-adatbázis újra elérhető, de {{count}} országlista ({{lists}}) még nem érvényesül. Nyomja meg az Újratöltés gombot az Országadatbázis kártyán.",
+ "docsLink": "Hogyan biztosítsa az adatbázist",
+ "issueHint": "A Charon általában tartalmazza ezt az adatbázist. Kérjük, nyisson GitHub-hibajelentést (issue), hogy javíthassuk.",
+ "issueLink": "Issue megnyitása"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"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."
+ "geoip_unavailable": "Az országlisták nem érvényesülnek: a GeoIP-adatbázis hiányzik ebből a telepítésből. Mindenki elérheti ezeket a gépeket, amíg az adatbázis nincs biztosítva. Lásd a dokumentációt."
+ }
+ },
+ "geoip": {
+ "title": "Országadatbázis (GeoIP)",
+ "description": "Az országalapú hozzáférési listákhoz GeoIP országadatbázis szükséges. A Charon a mellékelt példányt használja, hacsak nem ad meg sajátot.",
+ "loadError": "Az adatbázis állapotát nem sikerült betölteni.",
+ "unknownValue": "Ismeretlen",
+ "status": {
+ "ready": "Kész",
+ "unavailable": "Nem elérhető"
+ },
+ "source": {
+ "label": "Forrás",
+ "bundled": "A Charon része",
+ "data": "Az adatmappa",
+ "custom": "Egyéni útvonal van beállítva a szerveren",
+ "none": "Nincs adatbázis"
+ },
+ "buildDate": "Létrehozás dátuma",
+ "size": "Méret",
+ "path": "Fájl",
+ "releaseNote": "A mellékelt példány minden Charon-kiadással frissül.",
+ "customPathNote": "Az adatbázis útvonalát a szerver állítja be (CHARON_GEOIP_DB_PATH), ezért a példány itt nem cserélhető.",
+ "readOnlyNote": "Az adatmappa csak olvasható, ezért az adatbázis itt nem cserélhető.",
+ "unavailableReason": "Nem elérhető. Az országlisták csak akkor érvényesülnek, ha adatbázis van betöltve.",
+ "docsLink": "Hogyan adjon meg adatbázist",
+ "unenforcedLists": "{{count}} országlista nem érvényesül",
+ "formNote": {
+ "unavailable": "Ez a lista nem érvényesül, amíg a GeoIP-adatbázis nem érhető el.",
+ "wouldRefuseYou": "A jelenleg használt cím alapján ez a lista megtiltaná Önnek a hozzáférést. A mentés továbbra is engedélyezett."
+ },
+ "actions": {
+ "reload": "Újratöltés",
+ "replace": "Adatbázis cseréje…",
+ "replaceHint": "Töltsön fel egy GeoIP országadatbázist (.mmdb), legfeljebb {{max}} MiB méretben.",
+ "useIncluded": "A mellékelt példány használata",
+ "useIncludedConfirmTitle": "Használja a mellékelt példányt?",
+ "useIncludedConfirmBody": "Az adatmappában lévő példánya törlődik, és a Charon a mellékelt adatbázist fogja használni.",
+ "useIncludedConfirmBodyNoBundled": "Az adatmappában lévő példánya törlődik. Az országlisták nem érvényesülnek, amíg nem ad meg adatbázist."
+ },
+ "result": {
+ "reloaded": "Az adatbázis újratöltve.",
+ "replaced": "Az adatbázis lecserélve. A Charon most az Ön példányát használja.",
+ "removed": "A Charon ismét a mellékelt példányt használja."
+ },
+ "lookup": {
+ "label": "IP-cím",
+ "placeholder": "Például 203.0.113.7",
+ "useMyAddress": "Saját címem használata",
+ "submit": "Keresés",
+ "found": "{{ip}} címe: {{country}}.",
+ "notFound": "Nem található ország a következőhöz: {{ip}}.",
+ "disabled": "A keresés az adatbázis betöltése után érhető el.",
+ "invalid": "Adjon meg érvényes IP-címet.",
+ "notLoaded": "Nincs betöltött adatbázis, ezért a címek nem kereshetők.",
+ "myAddressFailed": "A Charon nem tudta beolvasni az Ön címét."
+ },
+ "errors": {
+ "geoip_file_required": "Válasszon feltöltendő adatbázisfájlt.",
+ "geoip_too_large": "A fájl nagyobb 32 MiB-nál.",
+ "geoip_not_mmdb": "A fájl nem GeoIP-adatbázis.",
+ "geoip_corrupt": "Az adatbázisfájl sérült, nem olvasható.",
+ "geoip_wrong_type": "Ez GeoIP-adatbázis, de nem országadatbázis.",
+ "geoip_older_than_current": "Ez az adatbázis régebbi, mint a használatban lévő. Töltsön fel újabbat.",
+ "geoip_build_date_in_future": "Az adatbázis létrehozási dátuma a jövőben van.",
+ "geoip_custom_path_set": "A szerveren egyéni adatbázis-útvonal van beállítva, ezért a példány itt nem módosítható.",
+ "geoip_busy": "Másik adatbázis-módosítás folyamatban van. Próbálja újra kis idő múlva.",
+ "geoip_write_failed": "A Charon nem tudta menteni az adatbázisfájlt.",
+ "geoip_reload_failed": "A Charon nem tudta betölteni az adatbázisfájlt.",
+ "geoip_no_data_copy": "Nincs eltávolítandó példány az adatmappában.",
+ "geoip_service_unavailable": "A GeoIP szolgáltatás nem fut.",
+ "proxy_apply_failed": "A Charon nem tudta alkalmazni a proxy-konfigurációt.",
+ "generic": "Valami hiba történt. Próbálja újra."
}
}
},
diff --git a/frontend/src/locales/it/translation.json b/frontend/src/locales/it/translation.json
index e6facfb0f..ea932b43d 100644
--- a/frontend/src/locales/it/translation.json
+++ b/frontend/src/locales/it/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Notifiche",
+ "bellLabel": "Notifiche, {{count}} nuove",
+ "markAllRead": "Segna tutte come lette",
+ "empty": "Nessuna nuova notifica",
+ "close": "Ignora",
+ "updateAvailable": "Aggiornamento disponibile: {{version}}",
+ "viewChangelog": "Visualizza il changelog",
+ "alerts": {
+ "label": "Richiede attenzione",
+ "clearsItself": "Si risolve automaticamente quando il problema viene corretto.",
+ "geoip_unavailable": {
+ "title": "Le liste di accesso per paese non sono applicate",
+ "message": "Il database GeoIP dei paesi manca o non è utilizzabile, quindi {{count}} lista/e di paesi ({{lists}}) lasciano passare ogni visitatore finché non torna disponibile.",
+ "messagePendingApply": "Il database GeoIP è di nuovo disponibile, ma {{count}} lista/e per paese ({{lists}}) non sono ancora applicate. Premi Ricarica sulla scheda Database dei paesi.",
+ "docsLink": "Come fornire il database",
+ "issueHint": "Charon normalmente include questo database. Apri una segnalazione (issue) su GitHub così possiamo risolvere il problema.",
+ "issueLink": "Apri una issue"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"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."
+ "geoip_unavailable": "Le liste di paesi non vengono applicate: il database GeoIP manca in questa installazione. Chiunque può raggiungere questi host finché non viene fornito. Consulta la documentazione."
+ }
+ },
+ "geoip": {
+ "title": "Database dei paesi (GeoIP)",
+ "description": "Le liste di accesso per paese richiedono un database GeoIP dei paesi. Charon usa la copia inclusa, a meno che non ne fornisci una tua.",
+ "loadError": "Impossibile caricare lo stato del database.",
+ "unknownValue": "Sconosciuto",
+ "status": {
+ "ready": "Pronto",
+ "unavailable": "Non disponibile"
+ },
+ "source": {
+ "label": "Origine",
+ "bundled": "Incluso con Charon",
+ "data": "La tua cartella dati",
+ "custom": "Percorso personalizzato impostato sul server",
+ "none": "Nessun database"
+ },
+ "buildDate": "Data di creazione",
+ "size": "Dimensione",
+ "path": "File",
+ "releaseNote": "La copia inclusa viene aggiornata con ogni versione di Charon.",
+ "customPathNote": "Il percorso del database è impostato dal server (CHARON_GEOIP_DB_PATH), quindi la copia non può essere sostituita qui.",
+ "readOnlyNote": "La cartella dati è di sola lettura, quindi il database non può essere sostituito qui.",
+ "unavailableReason": "Non disponibile. Le liste per paese non vengono applicate finché non viene caricato un database.",
+ "docsLink": "Come fornire un database",
+ "unenforcedLists": "{{count}} lista/e per paese non applicata/e",
+ "formNote": {
+ "unavailable": "Questa lista non verrà applicata finché il database GeoIP non sarà disponibile.",
+ "wouldRefuseYou": "In base all'indirizzo che stai usando ora, questa lista ti bloccherebbe. Il salvataggio resta consentito."
+ },
+ "actions": {
+ "reload": "Ricarica",
+ "replace": "Sostituisci database…",
+ "replaceHint": "Carica un database GeoIP dei paesi (.mmdb), fino a {{max}} MiB.",
+ "useIncluded": "Usa la copia inclusa",
+ "useIncludedConfirmTitle": "Usare la copia inclusa?",
+ "useIncludedConfirmBody": "La tua copia nella cartella dati verrà rimossa e Charon userà il database incluso.",
+ "useIncludedConfirmBodyNoBundled": "La tua copia nella cartella dati verrà rimossa. Le liste per paese non verranno applicate finché non fornirai un database."
+ },
+ "result": {
+ "reloaded": "Database ricaricato.",
+ "replaced": "Database sostituito. Charon ora usa la tua copia.",
+ "removed": "Charon usa di nuovo la copia inclusa."
+ },
+ "lookup": {
+ "label": "Indirizzo IP",
+ "placeholder": "Ad esempio 203.0.113.7",
+ "useMyAddress": "Usa il mio indirizzo",
+ "submit": "Cerca",
+ "found": "{{ip}} si trova in {{country}}.",
+ "notFound": "Nessun paese trovato per {{ip}}.",
+ "disabled": "La ricerca è disponibile quando è caricato un database.",
+ "invalid": "Inserisci un indirizzo IP valido.",
+ "notLoaded": "Nessun database caricato, quindi non è possibile cercare indirizzi.",
+ "myAddressFailed": "Charon non è riuscito a leggere il tuo indirizzo."
+ },
+ "errors": {
+ "geoip_file_required": "Scegli un file di database da caricare.",
+ "geoip_too_large": "Il file supera i 32 MiB.",
+ "geoip_not_mmdb": "Il file non è un database GeoIP.",
+ "geoip_corrupt": "Il file del database è danneggiato e non può essere letto.",
+ "geoip_wrong_type": "È un database GeoIP, ma non un database dei paesi.",
+ "geoip_older_than_current": "Questo database è più vecchio di quello in uso. Carica una versione più recente.",
+ "geoip_build_date_in_future": "Questo database ha una data di creazione nel futuro.",
+ "geoip_custom_path_set": "Sul server è impostato un percorso personalizzato per il database, quindi la copia non può essere modificata qui.",
+ "geoip_busy": "Un'altra modifica al database è in corso. Riprova tra poco.",
+ "geoip_write_failed": "Charon non è riuscito a salvare il file del database.",
+ "geoip_reload_failed": "Charon non è riuscito a caricare il file del database.",
+ "geoip_no_data_copy": "Nella cartella dati non c'è nessuna copia da rimuovere.",
+ "geoip_service_unavailable": "Il servizio GeoIP non è in esecuzione.",
+ "proxy_apply_failed": "Charon non è riuscito ad applicare la configurazione del proxy.",
+ "generic": "Qualcosa è andato storto. Riprova."
}
}
},
diff --git a/frontend/src/locales/ja/translation.json b/frontend/src/locales/ja/translation.json
index 1b6767594..18bf13def 100644
--- a/frontend/src/locales/ja/translation.json
+++ b/frontend/src/locales/ja/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "通知",
+ "bellLabel": "通知、未読 {{count}} 件",
+ "markAllRead": "すべて既読にする",
+ "empty": "新しい通知はありません",
+ "close": "閉じる",
+ "updateAvailable": "アップデートがあります: {{version}}",
+ "viewChangelog": "変更履歴を見る",
+ "alerts": {
+ "label": "要対応",
+ "clearsItself": "問題が解決されると自動的に消えます。",
+ "geoip_unavailable": {
+ "title": "国別アクセスリストが適用されていません",
+ "message": "GeoIP国データベースが見つからないか使用できないため、{{count}} 件の国別リスト({{lists}})は、利用可能になるまですべての訪問者を通過させています。",
+ "messagePendingApply": "GeoIP データベースが再び利用可能になりましたが、適用されていない国のリストが {{count}} 件 ({{lists}}) あります。国データベースのカードで「再読み込み」を押してください。",
+ "docsLink": "データベースの用意方法",
+ "issueHint": "Charon には通常このデータベースが含まれています。修正できるよう GitHub で issue を作成してください。",
+ "issueLink": "issue を作成"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"settingUpdateFailed": "Failed to update setting: {{error}}",
"enforcement": {
"acl": {
- "geoip_unavailable": "国別リストには GeoIP データベースが必要ですが、このインストールには含まれていません。提供されるまで、国別リストに一致するリクエストは拒否されます。ドキュメントを参照してください。"
+ "geoip_unavailable": "国別リストは適用されていません: このインストールには GeoIP データベースが含まれていません。提供されるまで、すべてのユーザーがこれらのホストにアクセスできます。ドキュメントを参照してください。"
+ }
+ },
+ "geoip": {
+ "title": "国のデータベース (GeoIP)",
+ "description": "国別のアクセスリストには GeoIP の国データベースが必要です。独自のデータベースを指定しない限り、Charon は同梱のコピーを使用します。",
+ "loadError": "データベースの状態を読み込めませんでした。",
+ "unknownValue": "不明",
+ "status": {
+ "ready": "準備完了",
+ "unavailable": "利用不可"
+ },
+ "source": {
+ "label": "ソース",
+ "bundled": "Charon に同梱",
+ "data": "データフォルダー",
+ "custom": "サーバーでカスタムパスが設定されています",
+ "none": "データベースなし"
+ },
+ "buildDate": "ビルド日",
+ "size": "サイズ",
+ "path": "ファイル",
+ "releaseNote": "同梱のコピーは Charon の各リリースで更新されます。",
+ "customPathNote": "データベースのパスはサーバー (CHARON_GEOIP_DB_PATH) で設定されているため、ここではコピーを置き換えられません。",
+ "readOnlyNote": "データフォルダーは読み取り専用のため、ここではデータベースを置き換えられません。",
+ "unavailableReason": "利用不可。データベースが読み込まれるまで、国のリストは適用されません。",
+ "docsLink": "データベースの指定方法",
+ "unenforcedLists": "適用されていない国のリスト: {{count}} 件",
+ "formNote": {
+ "unavailable": "GeoIP データベースが利用可能になるまで、このリストは適用されません。",
+ "wouldRefuseYou": "現在お使いのアドレスに基づくと、このリストではアクセスがブロックされます。保存は引き続き可能です。"
+ },
+ "actions": {
+ "reload": "再読み込み",
+ "replace": "データベースを置き換え…",
+ "replaceHint": "GeoIP の国データベース (.mmdb) をアップロードします。上限は {{max}} MiB です。",
+ "useIncluded": "同梱のコピーを使用",
+ "useIncludedConfirmTitle": "同梱のコピーを使用しますか?",
+ "useIncludedConfirmBody": "データフォルダー内のコピーは削除され、Charon は同梱のデータベースを使用します。",
+ "useIncludedConfirmBodyNoBundled": "データフォルダー内のコピーは削除されます。データベースを指定するまで、国のリストは適用されません。"
+ },
+ "result": {
+ "reloaded": "データベースを再読み込みしました。",
+ "replaced": "データベースを置き換えました。Charon はアップロードしたコピーを使用しています。",
+ "removed": "Charon は再び同梱のコピーを使用しています。"
+ },
+ "lookup": {
+ "label": "IP アドレス",
+ "placeholder": "例 203.0.113.7",
+ "useMyAddress": "自分のアドレスを使用",
+ "submit": "検索",
+ "found": "{{ip}} は {{country}} にあります。",
+ "notFound": "{{ip}} に対応する国が見つかりません。",
+ "disabled": "データベースが読み込まれると検索できます。",
+ "invalid": "有効な IP アドレスを入力してください。",
+ "notLoaded": "データベースが読み込まれていないため、アドレスを検索できません。",
+ "myAddressFailed": "Charon はお使いのアドレスを読み取れませんでした。"
+ },
+ "errors": {
+ "geoip_file_required": "アップロードするデータベースファイルを選択してください。",
+ "geoip_too_large": "ファイルのサイズが 32 MiB を超えています。",
+ "geoip_not_mmdb": "このファイルは GeoIP データベースではありません。",
+ "geoip_corrupt": "このデータベースファイルは破損しており、読み込めません。",
+ "geoip_wrong_type": "GeoIP データベースですが、国のデータベースではありません。",
+ "geoip_older_than_current": "このデータベースは、使用中のものより古いです。新しいものをアップロードしてください。",
+ "geoip_build_date_in_future": "このデータベースのビルド日が未来になっています。",
+ "geoip_custom_path_set": "サーバーでカスタムのデータベースパスが設定されているため、ここではコピーを変更できません。",
+ "geoip_busy": "別のデータベース変更が進行中です。しばらくしてから再試行してください。",
+ "geoip_write_failed": "Charon はデータベースファイルを保存できませんでした。",
+ "geoip_reload_failed": "Charon はデータベースファイルを読み込めませんでした。",
+ "geoip_no_data_copy": "削除するコピーがデータフォルダーにありません。",
+ "geoip_service_unavailable": "GeoIP サービスが実行されていません。",
+ "proxy_apply_failed": "Charon はプロキシ設定を適用できませんでした。",
+ "generic": "問題が発生しました。もう一度お試しください。"
}
}
},
diff --git a/frontend/src/locales/ko/translation.json b/frontend/src/locales/ko/translation.json
index a60ab5e6a..ba1745f23 100644
--- a/frontend/src/locales/ko/translation.json
+++ b/frontend/src/locales/ko/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "알림",
+ "bellLabel": "알림, 새 항목 {{count}}개",
+ "markAllRead": "모두 읽음으로 표시",
+ "empty": "새 알림이 없습니다",
+ "close": "닫기",
+ "updateAvailable": "업데이트 가능: {{version}}",
+ "viewChangelog": "변경 로그 보기",
+ "alerts": {
+ "label": "주의 필요",
+ "clearsItself": "문제가 해결되면 자동으로 사라집니다.",
+ "geoip_unavailable": {
+ "title": "국가별 액세스 목록이 적용되지 않고 있습니다",
+ "message": "GeoIP 국가 데이터베이스가 없거나 사용할 수 없어서, 국가 목록 {{count}}개({{lists}})가 데이터베이스를 다시 사용할 수 있을 때까지 모든 방문자를 통과시키고 있습니다.",
+ "messagePendingApply": "GeoIP 데이터베이스를 다시 사용할 수 있지만 적용되지 않은 국가 목록 {{count}}개({{lists}})가 있습니다. 국가 데이터베이스 카드에서 다시 로드를 누르세요.",
+ "docsLink": "데이터베이스 제공 방법",
+ "issueHint": "Charon에는 보통 이 데이터베이스가 포함되어 있습니다. 수정할 수 있도록 GitHub에 issue를 열어 주세요.",
+ "issueLink": "issue 열기"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"settingUpdateFailed": "Failed to update setting: {{error}}",
"enforcement": {
"acl": {
- "geoip_unavailable": "국가 목록에는 이 설치에 없는 GeoIP 데이터베이스가 필요하므로, 제공될 때까지 일치하는 요청을 거부합니다. 문서를 참조하세요."
+ "geoip_unavailable": "국가 목록이 적용되지 않습니다: 이 설치에 GeoIP 데이터베이스가 없습니다. 데이터베이스가 제공될 때까지 모든 사용자가 해당 호스트에 접근할 수 있습니다. 문서를 참조하세요."
+ }
+ },
+ "geoip": {
+ "title": "국가 데이터베이스 (GeoIP)",
+ "description": "국가별 액세스 목록에는 GeoIP 국가 데이터베이스가 필요합니다. 직접 제공하지 않으면 Charon은 함께 제공되는 사본을 사용합니다.",
+ "loadError": "데이터베이스 상태를 불러올 수 없습니다.",
+ "unknownValue": "알 수 없음",
+ "status": {
+ "ready": "준비됨",
+ "unavailable": "사용할 수 없음"
+ },
+ "source": {
+ "label": "출처",
+ "bundled": "Charon에 포함됨",
+ "data": "데이터 폴더",
+ "custom": "서버에 사용자 지정 경로가 설정됨",
+ "none": "데이터베이스 없음"
+ },
+ "buildDate": "빌드 날짜",
+ "size": "크기",
+ "path": "파일",
+ "releaseNote": "포함된 사본은 Charon 릴리스마다 업데이트됩니다.",
+ "customPathNote": "데이터베이스 경로는 서버(CHARON_GEOIP_DB_PATH)에서 설정하므로 여기에서 사본을 바꿀 수 없습니다.",
+ "readOnlyNote": "데이터 폴더가 읽기 전용이므로 여기에서 데이터베이스를 바꿀 수 없습니다.",
+ "unavailableReason": "사용할 수 없음. 데이터베이스가 로드될 때까지 국가 목록이 적용되지 않습니다.",
+ "docsLink": "데이터베이스 제공 방법",
+ "unenforcedLists": "적용되지 않은 국가 목록 {{count}}개",
+ "formNote": {
+ "unavailable": "GeoIP 데이터베이스를 사용할 수 있게 될 때까지 이 목록은 적용되지 않습니다.",
+ "wouldRefuseYou": "현재 사용 중인 주소를 기준으로 이 목록은 접속을 차단합니다. 저장은 계속 가능합니다."
+ },
+ "actions": {
+ "reload": "다시 로드",
+ "replace": "데이터베이스 교체…",
+ "replaceHint": "GeoIP 국가 데이터베이스(.mmdb)를 업로드하세요. 최대 {{max}} MiB입니다.",
+ "useIncluded": "포함된 사본 사용",
+ "useIncludedConfirmTitle": "포함된 사본을 사용하시겠습니까?",
+ "useIncludedConfirmBody": "데이터 폴더의 사본이 삭제되고 Charon은 포함된 데이터베이스를 사용합니다.",
+ "useIncludedConfirmBodyNoBundled": "데이터 폴더의 사본이 삭제됩니다. 데이터베이스를 제공할 때까지 국가 목록이 적용되지 않습니다."
+ },
+ "result": {
+ "reloaded": "데이터베이스를 다시 로드했습니다.",
+ "replaced": "데이터베이스를 교체했습니다. Charon이 이제 사용자의 사본을 사용합니다.",
+ "removed": "Charon이 다시 포함된 사본을 사용합니다."
+ },
+ "lookup": {
+ "label": "IP 주소",
+ "placeholder": "예: 203.0.113.7",
+ "useMyAddress": "내 주소 사용",
+ "submit": "조회",
+ "found": "{{ip}}은(는) {{country}}에 있습니다.",
+ "notFound": "{{ip}}에 해당하는 국가를 찾을 수 없습니다.",
+ "disabled": "데이터베이스가 로드되면 조회할 수 있습니다.",
+ "invalid": "올바른 IP 주소를 입력하세요.",
+ "notLoaded": "데이터베이스가 로드되지 않아 주소를 조회할 수 없습니다.",
+ "myAddressFailed": "Charon이 사용자의 주소를 읽을 수 없습니다."
+ },
+ "errors": {
+ "geoip_file_required": "업로드할 데이터베이스 파일을 선택하세요.",
+ "geoip_too_large": "파일이 32 MiB보다 큽니다.",
+ "geoip_not_mmdb": "GeoIP 데이터베이스 파일이 아닙니다.",
+ "geoip_corrupt": "데이터베이스 파일이 손상되어 읽을 수 없습니다.",
+ "geoip_wrong_type": "GeoIP 데이터베이스이지만 국가 데이터베이스가 아닙니다.",
+ "geoip_older_than_current": "사용 중인 데이터베이스보다 오래되었습니다. 더 최신 파일을 업로드하세요.",
+ "geoip_build_date_in_future": "데이터베이스의 빌드 날짜가 미래로 되어 있습니다.",
+ "geoip_custom_path_set": "서버에 사용자 지정 데이터베이스 경로가 설정되어 있어 여기에서 사본을 변경할 수 없습니다.",
+ "geoip_busy": "다른 데이터베이스 변경 작업이 진행 중입니다. 잠시 후 다시 시도하세요.",
+ "geoip_write_failed": "Charon이 데이터베이스 파일을 저장할 수 없습니다.",
+ "geoip_reload_failed": "Charon이 데이터베이스 파일을 로드할 수 없습니다.",
+ "geoip_no_data_copy": "제거할 사본이 데이터 폴더에 없습니다.",
+ "geoip_service_unavailable": "GeoIP 서비스가 실행 중이 아닙니다.",
+ "proxy_apply_failed": "Charon이 프록시 구성을 적용할 수 없습니다.",
+ "generic": "문제가 발생했습니다. 다시 시도하세요."
}
}
},
diff --git a/frontend/src/locales/nl/translation.json b/frontend/src/locales/nl/translation.json
index 824919dce..0f5b01464 100644
--- a/frontend/src/locales/nl/translation.json
+++ b/frontend/src/locales/nl/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Meldingen",
+ "bellLabel": "Meldingen, {{count}} nieuw",
+ "markAllRead": "Alles als gelezen markeren",
+ "empty": "Geen nieuwe meldingen",
+ "close": "Negeren",
+ "updateAvailable": "Update beschikbaar: {{version}}",
+ "viewChangelog": "Changelog bekijken",
+ "alerts": {
+ "label": "Vraagt aandacht",
+ "clearsItself": "Dit verdwijnt vanzelf zodra het probleem is opgelost.",
+ "geoip_unavailable": {
+ "title": "Landenlijsten voor toegang worden niet afgedwongen",
+ "message": "De GeoIP-landendatabase ontbreekt of is niet bruikbaar, dus {{count}} landenlijst(en) ({{lists}}) laten elke bezoeker door totdat die weer beschikbaar is.",
+ "messagePendingApply": "De GeoIP-database is weer beschikbaar, maar {{count}} landenlijst(en) ({{lists}}) worden nog niet afgedwongen. Druk op Opnieuw laden op de kaart Landendatabase.",
+ "docsLink": "Hoe je de database aanlevert",
+ "issueHint": "Charon bevat deze database normaal gesproken. Open een GitHub-issue zodat we dit kunnen oplossen.",
+ "issueLink": "Een issue openen"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"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."
+ "geoip_unavailable": "Landenlijsten worden niet afgedwongen: de GeoIP-database ontbreekt in deze installatie. Iedereen kan die hosts bereiken totdat deze beschikbaar is. Zie de documentatie."
+ }
+ },
+ "geoip": {
+ "title": "Landendatabase (GeoIP)",
+ "description": "Landentoegangslijsten hebben een GeoIP-landendatabase nodig. Charon gebruikt de meegeleverde kopie, tenzij je er zelf een opgeeft.",
+ "loadError": "De status van de database kon niet worden geladen.",
+ "unknownValue": "Onbekend",
+ "status": {
+ "ready": "Gereed",
+ "unavailable": "Niet beschikbaar"
+ },
+ "source": {
+ "label": "Bron",
+ "bundled": "Meegeleverd met Charon",
+ "data": "Je gegevensmap",
+ "custom": "Aangepast pad ingesteld op de server",
+ "none": "Geen database"
+ },
+ "buildDate": "Builddatum",
+ "size": "Grootte",
+ "path": "Bestand",
+ "releaseNote": "De meegeleverde kopie wordt bij elke Charon-release bijgewerkt.",
+ "customPathNote": "Het databasepad wordt door de server ingesteld (CHARON_GEOIP_DB_PATH), dus de kopie kan hier niet worden vervangen.",
+ "readOnlyNote": "De gegevensmap is alleen-lezen, dus de database kan hier niet worden vervangen.",
+ "unavailableReason": "Niet beschikbaar. Landenlijsten worden pas afgedwongen als er een database is geladen.",
+ "docsLink": "Hoe je een database opgeeft",
+ "unenforcedLists": "{{count}} landenlijst(en) worden niet afgedwongen",
+ "formNote": {
+ "unavailable": "Deze lijst wordt pas afgedwongen als de GeoIP-database beschikbaar is.",
+ "wouldRefuseYou": "Op basis van het adres dat je nu gebruikt, zou deze lijst je blokkeren. Opslaan is nog steeds toegestaan."
+ },
+ "actions": {
+ "reload": "Opnieuw laden",
+ "replace": "Database vervangen…",
+ "replaceHint": "Upload een GeoIP-landendatabase (.mmdb), maximaal {{max}} MiB.",
+ "useIncluded": "De meegeleverde kopie gebruiken",
+ "useIncludedConfirmTitle": "De meegeleverde kopie gebruiken?",
+ "useIncludedConfirmBody": "Je kopie in de gegevensmap wordt verwijderd en Charon gebruikt de meegeleverde database.",
+ "useIncludedConfirmBodyNoBundled": "Je kopie in de gegevensmap wordt verwijderd. Landenlijsten worden niet afgedwongen totdat je een database opgeeft."
+ },
+ "result": {
+ "reloaded": "Database opnieuw geladen.",
+ "replaced": "Database vervangen. Charon gebruikt nu jouw kopie.",
+ "removed": "Charon gebruikt weer de meegeleverde kopie."
+ },
+ "lookup": {
+ "label": "IP-adres",
+ "placeholder": "Bijvoorbeeld 203.0.113.7",
+ "useMyAddress": "Mijn adres gebruiken",
+ "submit": "Opzoeken",
+ "found": "{{ip}} staat in {{country}}.",
+ "notFound": "Geen land gevonden voor {{ip}}.",
+ "disabled": "Opzoeken is beschikbaar zodra een database is geladen.",
+ "invalid": "Voer een geldig IP-adres in.",
+ "notLoaded": "Er is geen database geladen, dus adressen kunnen niet worden opgezocht.",
+ "myAddressFailed": "Charon kon je adres niet lezen."
+ },
+ "errors": {
+ "geoip_file_required": "Kies een databasebestand om te uploaden.",
+ "geoip_too_large": "Dat bestand is groter dan 32 MiB.",
+ "geoip_not_mmdb": "Dat bestand is geen GeoIP-database.",
+ "geoip_corrupt": "Dat databasebestand is beschadigd en kan niet worden gelezen.",
+ "geoip_wrong_type": "Dat is een GeoIP-database, maar geen landendatabase.",
+ "geoip_older_than_current": "Die database is ouder dan de database die in gebruik is. Upload een nieuwere.",
+ "geoip_build_date_in_future": "Die database heeft een builddatum in de toekomst.",
+ "geoip_custom_path_set": "Er is een aangepast databasepad op de server ingesteld, dus de kopie kan hier niet worden gewijzigd.",
+ "geoip_busy": "Er is al een andere databasewijziging bezig. Probeer het zo opnieuw.",
+ "geoip_write_failed": "Charon kon het databasebestand niet opslaan.",
+ "geoip_reload_failed": "Charon kon het databasebestand niet laden.",
+ "geoip_no_data_copy": "Er is geen kopie in de gegevensmap om te verwijderen.",
+ "geoip_service_unavailable": "De GeoIP-service draait niet.",
+ "proxy_apply_failed": "Charon kon de proxyconfiguratie niet toepassen.",
+ "generic": "Er ging iets mis. Probeer het opnieuw."
}
}
},
diff --git a/frontend/src/locales/no/translation.json b/frontend/src/locales/no/translation.json
index bd61441a7..b2692dc2f 100644
--- a/frontend/src/locales/no/translation.json
+++ b/frontend/src/locales/no/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Varsler",
+ "bellLabel": "Varsler, {{count}} nye",
+ "markAllRead": "Merk alle som lest",
+ "empty": "Ingen nye varsler",
+ "close": "Avvis",
+ "updateAvailable": "Oppdatering tilgjengelig: {{version}}",
+ "viewChangelog": "Vis endringslogg",
+ "alerts": {
+ "label": "Krever oppmerksomhet",
+ "clearsItself": "Dette forsvinner av seg selv når problemet er løst.",
+ "geoip_unavailable": {
+ "title": "Landlister for tilgang håndheves ikke",
+ "message": "GeoIP-landdatabasen mangler eller kan ikke brukes, så {{count}} landliste(r) ({{lists}}) slipper alle besøkende gjennom til den er tilgjengelig igjen.",
+ "messagePendingApply": "GeoIP-databasen er tilgjengelig igjen, men {{count}} landliste(r) ({{lists}}) håndheves ikke ennå. Trykk Last inn på nytt på kortet Landdatabase.",
+ "docsLink": "Slik skaffer du databasen",
+ "issueHint": "Charon inkluderer normalt denne databasen. Åpne en sak på GitHub, så vi kan rette opp i dette.",
+ "issueLink": "Åpne en sak"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"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."
+ "geoip_unavailable": "Landlister håndheves ikke: GeoIP-databasen mangler i denne installasjonen. Alle kan nå disse vertene til den er på plass. Se dokumentasjonen."
+ }
+ },
+ "geoip": {
+ "title": "Landdatabase (GeoIP)",
+ "description": "Landtilgangslister krever en GeoIP-landdatabase. Charon bruker kopien som følger med, med mindre du oppgir din egen.",
+ "loadError": "Databasestatusen kunne ikke lastes.",
+ "unknownValue": "Ukjent",
+ "status": {
+ "ready": "Klar",
+ "unavailable": "Ikke tilgjengelig"
+ },
+ "source": {
+ "label": "Kilde",
+ "bundled": "Følger med Charon",
+ "data": "Datamappen din",
+ "custom": "Egendefinert bane satt på serveren",
+ "none": "Ingen database"
+ },
+ "buildDate": "Byggedato",
+ "size": "Størrelse",
+ "path": "Fil",
+ "releaseNote": "Kopien som følger med oppdateres i hver Charon-utgave.",
+ "customPathNote": "Databasebanen settes av serveren (CHARON_GEOIP_DB_PATH), så kopien kan ikke erstattes her.",
+ "readOnlyNote": "Datamappen er skrivebeskyttet, så databasen kan ikke erstattes her.",
+ "unavailableReason": "Ikke tilgjengelig. Landlister håndheves ikke før en database er lastet.",
+ "docsLink": "Slik oppgir du en database",
+ "unenforcedLists": "{{count}} landliste(r) håndheves ikke",
+ "formNote": {
+ "unavailable": "Denne listen håndheves ikke før GeoIP-databasen er tilgjengelig.",
+ "wouldRefuseYou": "Basert på adressen du bruker nå, ville denne listen blokkert deg. Lagring er fortsatt tillatt."
+ },
+ "actions": {
+ "reload": "Last inn på nytt",
+ "replace": "Erstatt database…",
+ "replaceHint": "Last opp en GeoIP-landdatabase (.mmdb), opptil {{max}} MiB.",
+ "useIncluded": "Bruk kopien som følger med",
+ "useIncludedConfirmTitle": "Bruke kopien som følger med?",
+ "useIncludedConfirmBody": "Kopien din i datamappen fjernes, og Charon bruker databasen som følger med.",
+ "useIncludedConfirmBodyNoBundled": "Kopien din i datamappen fjernes. Landlister håndheves ikke før du oppgir en database."
+ },
+ "result": {
+ "reloaded": "Databasen er lastet inn på nytt.",
+ "replaced": "Databasen er erstattet. Charon bruker nå kopien din.",
+ "removed": "Charon bruker kopien som følger med igjen."
+ },
+ "lookup": {
+ "label": "IP-adresse",
+ "placeholder": "For eksempel 203.0.113.7",
+ "useMyAddress": "Bruk min adresse",
+ "submit": "Slå opp",
+ "found": "{{ip}} er i {{country}}.",
+ "notFound": "Fant ingen land for {{ip}}.",
+ "disabled": "Oppslag er tilgjengelig når en database er lastet.",
+ "invalid": "Skriv inn en gyldig IP-adresse.",
+ "notLoaded": "Ingen database er lastet, så adresser kan ikke slås opp.",
+ "myAddressFailed": "Charon kunne ikke lese adressen din."
+ },
+ "errors": {
+ "geoip_file_required": "Velg en databasefil å laste opp.",
+ "geoip_too_large": "Filen er større enn 32 MiB.",
+ "geoip_not_mmdb": "Filen er ikke en GeoIP-database.",
+ "geoip_corrupt": "Databasefilen er skadet og kan ikke leses.",
+ "geoip_wrong_type": "Dette er en GeoIP-database, men ikke en landdatabase.",
+ "geoip_older_than_current": "Databasen er eldre enn den som er i bruk. Last opp en nyere.",
+ "geoip_build_date_in_future": "Databasen har en byggedato i fremtiden.",
+ "geoip_custom_path_set": "Det er satt en egendefinert databasebane på serveren, så kopien kan ikke endres her.",
+ "geoip_busy": "En annen databaseendring pågår. Prøv igjen om litt.",
+ "geoip_write_failed": "Charon kunne ikke lagre databasefilen.",
+ "geoip_reload_failed": "Charon kunne ikke laste databasefilen.",
+ "geoip_no_data_copy": "Det finnes ingen kopi i datamappen å fjerne.",
+ "geoip_service_unavailable": "GeoIP-tjenesten kjører ikke.",
+ "proxy_apply_failed": "Charon kunne ikke bruke proxy-konfigurasjonen.",
+ "generic": "Noe gikk galt. Prøv igjen."
}
}
},
diff --git a/frontend/src/locales/pl/translation.json b/frontend/src/locales/pl/translation.json
index 4cd0fb569..b687639d5 100644
--- a/frontend/src/locales/pl/translation.json
+++ b/frontend/src/locales/pl/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Powiadomienia",
+ "bellLabel": "Powiadomienia, nowe: {{count}}",
+ "markAllRead": "Oznacz wszystkie jako przeczytane",
+ "empty": "Brak nowych powiadomień",
+ "close": "Odrzuć",
+ "updateAvailable": "Dostępna aktualizacja: {{version}}",
+ "viewChangelog": "Zobacz zmiany",
+ "alerts": {
+ "label": "Wymaga uwagi",
+ "clearsItself": "Ten komunikat zniknie samoczynnie po naprawie problemu.",
+ "geoip_unavailable": {
+ "title": "Listy dostępu według kraju nie są egzekwowane",
+ "message": "Baza danych krajów GeoIP jest niedostępna lub nieużyteczna, więc {{count}} list(y) krajów ({{lists}}) przepuszcza każdego odwiedzającego, dopóki baza nie zostanie ponownie udostępniona.",
+ "messagePendingApply": "Baza danych GeoIP jest znów dostępna, ale {{count}} list(y) krajów ({{lists}}) nadal nie są egzekwowane. Naciśnij Wczytaj ponownie na karcie Baza danych krajów.",
+ "docsLink": "Jak dostarczyć bazę danych",
+ "issueHint": "Charon zwykle zawiera tę bazę danych. Otwórz zgłoszenie (issue) na GitHubie, abyśmy mogli to naprawić.",
+ "issueLink": "Otwórz zgłoszenie"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"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ę."
+ "geoip_unavailable": "Listy krajów nie są egzekwowane: w tej instalacji brakuje bazy danych GeoIP. Każdy może połączyć się z tymi hostami, dopóki baza nie zostanie dostarczona. Zobacz dokumentację."
+ }
+ },
+ "geoip": {
+ "title": "Baza danych krajów (GeoIP)",
+ "description": "Listy dostępu oparte na krajach wymagają bazy danych krajów GeoIP. Charon używa dołączonej kopii, chyba że podasz własną.",
+ "loadError": "Nie udało się wczytać stanu bazy danych.",
+ "unknownValue": "Nieznane",
+ "status": {
+ "ready": "Gotowe",
+ "unavailable": "Niedostępne"
+ },
+ "source": {
+ "label": "Źródło",
+ "bundled": "Dołączona do Charon",
+ "data": "Twój folder danych",
+ "custom": "Ścieżka niestandardowa ustawiona na serwerze",
+ "none": "Brak bazy danych"
+ },
+ "buildDate": "Data kompilacji",
+ "size": "Rozmiar",
+ "path": "Plik",
+ "releaseNote": "Dołączona kopia jest aktualizowana przy każdej wersji Charon.",
+ "customPathNote": "Ścieżkę do bazy danych ustawia serwer (CHARON_GEOIP_DB_PATH), dlatego kopii nie można tutaj zastąpić.",
+ "readOnlyNote": "Folder danych jest tylko do odczytu, dlatego bazy danych nie można tutaj zastąpić.",
+ "unavailableReason": "Niedostępne. Listy krajów nie są egzekwowane, dopóki nie zostanie wczytana baza danych.",
+ "docsLink": "Jak dostarczyć bazę danych",
+ "unenforcedLists": "Liczba list krajów, które nie są egzekwowane: {{count}}",
+ "formNote": {
+ "unavailable": "Ta lista nie będzie egzekwowana, dopóki baza danych GeoIP nie będzie dostępna.",
+ "wouldRefuseYou": "Na podstawie adresu, którego używasz teraz, ta lista by Cię zablokowała. Zapisanie nadal jest dozwolone."
+ },
+ "actions": {
+ "reload": "Wczytaj ponownie",
+ "replace": "Zastąp bazę danych…",
+ "replaceHint": "Prześlij bazę danych krajów GeoIP (.mmdb), maksymalnie {{max}} MiB.",
+ "useIncluded": "Użyj dołączonej kopii",
+ "useIncludedConfirmTitle": "Użyć dołączonej kopii?",
+ "useIncludedConfirmBody": "Twoja kopia w folderze danych zostanie usunięta, a Charon użyje dołączonej bazy danych.",
+ "useIncludedConfirmBodyNoBundled": "Twoja kopia w folderze danych zostanie usunięta. Listy krajów nie będą egzekwowane, dopóki nie dostarczysz bazy danych."
+ },
+ "result": {
+ "reloaded": "Baza danych została wczytana ponownie.",
+ "replaced": "Baza danych została zastąpiona. Charon używa teraz Twojej kopii.",
+ "removed": "Charon ponownie używa dołączonej kopii."
+ },
+ "lookup": {
+ "label": "Adres IP",
+ "placeholder": "Na przykład 203.0.113.7",
+ "useMyAddress": "Użyj mojego adresu",
+ "submit": "Sprawdź",
+ "found": "{{ip}} znajduje się w: {{country}}.",
+ "notFound": "Nie znaleziono kraju dla {{ip}}.",
+ "disabled": "Wyszukiwanie jest dostępne po wczytaniu bazy danych.",
+ "invalid": "Wpisz poprawny adres IP.",
+ "notLoaded": "Nie wczytano bazy danych, więc nie można sprawdzać adresów.",
+ "myAddressFailed": "Charon nie mógł odczytać Twojego adresu."
+ },
+ "errors": {
+ "geoip_file_required": "Wybierz plik bazy danych do przesłania.",
+ "geoip_too_large": "Ten plik ma więcej niż 32 MiB.",
+ "geoip_not_mmdb": "Ten plik nie jest bazą danych GeoIP.",
+ "geoip_corrupt": "Ten plik bazy danych jest uszkodzony i nie można go odczytać.",
+ "geoip_wrong_type": "To jest baza danych GeoIP, ale nie baza danych krajów.",
+ "geoip_older_than_current": "Ta baza danych jest starsza niż używana. Prześlij nowszą.",
+ "geoip_build_date_in_future": "Ta baza danych ma datę kompilacji z przyszłości.",
+ "geoip_custom_path_set": "Na serwerze ustawiono niestandardową ścieżkę do bazy danych, dlatego kopii nie można tutaj zmienić.",
+ "geoip_busy": "Trwa inna zmiana bazy danych. Spróbuj ponownie za chwilę.",
+ "geoip_write_failed": "Charon nie mógł zapisać pliku bazy danych.",
+ "geoip_reload_failed": "Charon nie mógł wczytać pliku bazy danych.",
+ "geoip_no_data_copy": "W folderze danych nie ma kopii do usunięcia.",
+ "geoip_service_unavailable": "Usługa GeoIP nie działa.",
+ "proxy_apply_failed": "Charon nie mógł zastosować konfiguracji proxy.",
+ "generic": "Coś poszło nie tak. Spróbuj ponownie."
}
}
},
diff --git a/frontend/src/locales/pt/translation.json b/frontend/src/locales/pt/translation.json
index 40e799e8f..8dd354ace 100644
--- a/frontend/src/locales/pt/translation.json
+++ b/frontend/src/locales/pt/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Notificações",
+ "bellLabel": "Notificações, {{count}} novas",
+ "markAllRead": "Marcar todas como lidas",
+ "empty": "Nenhuma notificação nova",
+ "close": "Dispensar",
+ "updateAvailable": "Atualização disponível: {{version}}",
+ "viewChangelog": "Ver changelog",
+ "alerts": {
+ "label": "Requer atenção",
+ "clearsItself": "Isso é resolvido automaticamente quando o problema for corrigido.",
+ "geoip_unavailable": {
+ "title": "As listas de acesso por país não estão sendo aplicadas",
+ "message": "O banco de dados GeoIP de países está ausente ou não pode ser usado, então {{count}} lista(s) de países ({{lists}}) deixam passar todos os visitantes até que ele esteja disponível novamente.",
+ "messagePendingApply": "O banco de dados do GeoIP está disponível novamente, mas {{count}} lista(s) de países ({{lists}}) ainda não são aplicadas. Pressione Recarregar no cartão Banco de dados de países.",
+ "docsLink": "Como fornecer o banco de dados",
+ "issueHint": "O Charon normalmente inclui este banco de dados. Abra uma issue no GitHub para que possamos corrigir isso.",
+ "issueLink": "Abrir uma issue"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"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."
+ "geoip_unavailable": "As listas de países não são aplicadas: a base de dados GeoIP não está nesta instalação. Todos conseguem aceder a esses hosts até que seja fornecida. Consulte a documentação."
+ }
+ },
+ "geoip": {
+ "title": "Banco de dados de países (GeoIP)",
+ "description": "As listas de acesso por país precisam de um banco de dados de países do GeoIP. O Charon usa a cópia incluída, a menos que você forneça a sua.",
+ "loadError": "Não foi possível carregar o status do banco de dados.",
+ "unknownValue": "Desconhecido",
+ "status": {
+ "ready": "Pronto",
+ "unavailable": "Não disponível"
+ },
+ "source": {
+ "label": "Origem",
+ "bundled": "Incluído com o Charon",
+ "data": "Sua pasta de dados",
+ "custom": "Caminho personalizado definido no servidor",
+ "none": "Nenhum banco de dados"
+ },
+ "buildDate": "Data de compilação",
+ "size": "Tamanho",
+ "path": "Arquivo",
+ "releaseNote": "A cópia incluída é atualizada a cada versão do Charon.",
+ "customPathNote": "O caminho do banco de dados é definido pelo servidor (CHARON_GEOIP_DB_PATH), então a cópia não pode ser substituída aqui.",
+ "readOnlyNote": "A pasta de dados é somente leitura, então o banco de dados não pode ser substituído aqui.",
+ "unavailableReason": "Não disponível. As listas de países não são aplicadas até que um banco de dados seja carregado.",
+ "docsLink": "Como fornecer um banco de dados",
+ "unenforcedLists": "{{count}} lista(s) de países não aplicada(s)",
+ "formNote": {
+ "unavailable": "Esta lista não será aplicada até que o banco de dados do GeoIP esteja disponível.",
+ "wouldRefuseYou": "Com base no endereço que você está usando agora, esta lista bloquearia você. Salvar continua permitido."
+ },
+ "actions": {
+ "reload": "Recarregar",
+ "replace": "Substituir banco de dados…",
+ "replaceHint": "Envie um banco de dados de países do GeoIP (.mmdb), de até {{max}} MiB.",
+ "useIncluded": "Usar a cópia incluída",
+ "useIncludedConfirmTitle": "Usar a cópia incluída?",
+ "useIncludedConfirmBody": "Sua cópia na pasta de dados será removida e o Charon usará o banco de dados incluído.",
+ "useIncludedConfirmBodyNoBundled": "Sua cópia na pasta de dados será removida. As listas de países não serão aplicadas até que você forneça um banco de dados."
+ },
+ "result": {
+ "reloaded": "Banco de dados recarregado.",
+ "replaced": "Banco de dados substituído. O Charon agora está usando sua cópia.",
+ "removed": "O Charon voltou a usar a cópia incluída."
+ },
+ "lookup": {
+ "label": "Endereço IP",
+ "placeholder": "Por exemplo 203.0.113.7",
+ "useMyAddress": "Usar meu endereço",
+ "submit": "Pesquisar",
+ "found": "{{ip}} está em {{country}}.",
+ "notFound": "Nenhum país encontrado para {{ip}}.",
+ "disabled": "A pesquisa fica disponível quando um banco de dados é carregado.",
+ "invalid": "Insira um endereço IP válido.",
+ "notLoaded": "Nenhum banco de dados está carregado, então não é possível pesquisar endereços.",
+ "myAddressFailed": "O Charon não conseguiu ler seu endereço."
+ },
+ "errors": {
+ "geoip_file_required": "Escolha um arquivo de banco de dados para enviar.",
+ "geoip_too_large": "Esse arquivo tem mais de 32 MiB.",
+ "geoip_not_mmdb": "Esse arquivo não é um banco de dados do GeoIP.",
+ "geoip_corrupt": "Esse arquivo de banco de dados está danificado e não pode ser lido.",
+ "geoip_wrong_type": "Esse é um banco de dados do GeoIP, mas não de países.",
+ "geoip_older_than_current": "Esse banco de dados é mais antigo que o que está em uso. Envie um mais recente.",
+ "geoip_build_date_in_future": "Esse banco de dados tem data de compilação no futuro.",
+ "geoip_custom_path_set": "Há um caminho personalizado de banco de dados definido no servidor, então a cópia não pode ser alterada aqui.",
+ "geoip_busy": "Outra alteração no banco de dados está em andamento. Tente novamente em instantes.",
+ "geoip_write_failed": "O Charon não conseguiu salvar o arquivo do banco de dados.",
+ "geoip_reload_failed": "O Charon não conseguiu carregar o arquivo do banco de dados.",
+ "geoip_no_data_copy": "Não há nenhuma cópia na pasta de dados para remover.",
+ "geoip_service_unavailable": "O serviço GeoIP não está em execução.",
+ "proxy_apply_failed": "O Charon não conseguiu aplicar a configuração do proxy.",
+ "generic": "Algo deu errado. Tente novamente."
}
}
},
diff --git a/frontend/src/locales/ro/translation.json b/frontend/src/locales/ro/translation.json
index 3a9bd488a..f283c2c93 100644
--- a/frontend/src/locales/ro/translation.json
+++ b/frontend/src/locales/ro/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Notificări",
+ "bellLabel": "Notificări, {{count}} noi",
+ "markAllRead": "Marchează toate ca citite",
+ "empty": "Nicio notificare nouă",
+ "close": "Renunță",
+ "updateAvailable": "Actualizare disponibilă: {{version}}",
+ "viewChangelog": "Vezi jurnalul de modificări",
+ "alerts": {
+ "label": "Necesită atenție",
+ "clearsItself": "Acest lucru dispare de la sine când problema este rezolvată.",
+ "geoip_unavailable": {
+ "title": "Listele de acces pe țări nu sunt aplicate",
+ "message": "Baza de date GeoIP cu țări lipsește sau nu poate fi utilizată, deci {{count}} listă/liste de țări ({{lists}}) lasă să treacă orice vizitator până devine din nou disponibilă.",
+ "messagePendingApply": "Baza de date GeoIP este din nou disponibilă, dar {{count}} listă/liste de țări ({{lists}}) nu sunt încă aplicate. Apasă Reîncarcă pe cardul Baza de date a țărilor.",
+ "docsLink": "Cum se furnizează baza de date",
+ "issueHint": "Charon include de obicei această bază de date. Deschide un issue pe GitHub ca să putem remedia problema.",
+ "issueLink": "Deschide un issue"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"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."
+ "geoip_unavailable": "Listele de țări nu sunt aplicate: baza de date GeoIP lipsește din această instalare. Oricine poate accesa aceste gazde până când aceasta este furnizată. Consultați documentația."
+ }
+ },
+ "geoip": {
+ "title": "Baza de date a țărilor (GeoIP)",
+ "description": "Listele de acces pe țări au nevoie de o bază de date GeoIP a țărilor. Charon folosește copia inclusă, dacă nu furnizezi una proprie.",
+ "loadError": "Starea bazei de date nu a putut fi încărcată.",
+ "unknownValue": "Necunoscut",
+ "status": {
+ "ready": "Pregătit",
+ "unavailable": "Indisponibil"
+ },
+ "source": {
+ "label": "Sursă",
+ "bundled": "Inclus cu Charon",
+ "data": "Folderul tău de date",
+ "custom": "Cale personalizată setată pe server",
+ "none": "Nicio bază de date"
+ },
+ "buildDate": "Data compilării",
+ "size": "Dimensiune",
+ "path": "Fișier",
+ "releaseNote": "Copia inclusă se actualizează odată cu fiecare versiune Charon.",
+ "customPathNote": "Calea bazei de date este setată de server (CHARON_GEOIP_DB_PATH), deci copia nu poate fi înlocuită aici.",
+ "readOnlyNote": "Folderul de date este doar pentru citire, deci baza de date nu poate fi înlocuită aici.",
+ "unavailableReason": "Indisponibil. Listele de țări nu sunt aplicate până când nu este încărcată o bază de date.",
+ "docsLink": "Cum furnizezi o bază de date",
+ "unenforcedLists": "{{count}} listă/liste de țări nu sunt aplicate",
+ "formNote": {
+ "unavailable": "Această listă nu va fi aplicată până când baza de date GeoIP nu este disponibilă.",
+ "wouldRefuseYou": "Pe baza adresei pe care o folosești acum, această listă te-ar bloca. Salvarea este permisă în continuare."
+ },
+ "actions": {
+ "reload": "Reîncarcă",
+ "replace": "Înlocuiește baza de date…",
+ "replaceHint": "Încarcă o bază de date GeoIP a țărilor (.mmdb), de până la {{max}} MiB.",
+ "useIncluded": "Folosește copia inclusă",
+ "useIncludedConfirmTitle": "Folosești copia inclusă?",
+ "useIncludedConfirmBody": "Copia ta din folderul de date va fi eliminată, iar Charon va folosi baza de date inclusă.",
+ "useIncludedConfirmBodyNoBundled": "Copia ta din folderul de date va fi eliminată. Listele de țări nu vor fi aplicate până nu furnizezi o bază de date."
+ },
+ "result": {
+ "reloaded": "Baza de date a fost reîncărcată.",
+ "replaced": "Baza de date a fost înlocuită. Charon folosește acum copia ta.",
+ "removed": "Charon folosește din nou copia inclusă."
+ },
+ "lookup": {
+ "label": "Adresă IP",
+ "placeholder": "De exemplu 203.0.113.7",
+ "useMyAddress": "Folosește adresa mea",
+ "submit": "Caută",
+ "found": "{{ip}} se află în {{country}}.",
+ "notFound": "Nu s-a găsit nicio țară pentru {{ip}}.",
+ "disabled": "Căutarea este disponibilă după ce este încărcată o bază de date.",
+ "invalid": "Introdu o adresă IP validă.",
+ "notLoaded": "Nu este încărcată nicio bază de date, deci adresele nu pot fi căutate.",
+ "myAddressFailed": "Charon nu a putut citi adresa ta."
+ },
+ "errors": {
+ "geoip_file_required": "Alege un fișier de bază de date de încărcat.",
+ "geoip_too_large": "Fișierul depășește 32 MiB.",
+ "geoip_not_mmdb": "Fișierul nu este o bază de date GeoIP.",
+ "geoip_corrupt": "Fișierul bazei de date este deteriorat și nu poate fi citit.",
+ "geoip_wrong_type": "Este o bază de date GeoIP, dar nu una a țărilor.",
+ "geoip_older_than_current": "Această bază de date este mai veche decât cea folosită. Încarcă una mai nouă.",
+ "geoip_build_date_in_future": "Această bază de date are o dată a compilării din viitor.",
+ "geoip_custom_path_set": "Pe server este setată o cale personalizată pentru baza de date, deci copia nu poate fi modificată aici.",
+ "geoip_busy": "Este în curs o altă modificare a bazei de date. Încearcă din nou peste puțin timp.",
+ "geoip_write_failed": "Charon nu a putut salva fișierul bazei de date.",
+ "geoip_reload_failed": "Charon nu a putut încărca fișierul bazei de date.",
+ "geoip_no_data_copy": "Nu există nicio copie în folderul de date de eliminat.",
+ "geoip_service_unavailable": "Serviciul GeoIP nu rulează.",
+ "proxy_apply_failed": "Charon nu a putut aplica configurația proxy.",
+ "generic": "Ceva nu a mers bine. Încearcă din nou."
}
}
},
diff --git a/frontend/src/locales/ru/translation.json b/frontend/src/locales/ru/translation.json
index 24733f6f3..c6564206f 100644
--- a/frontend/src/locales/ru/translation.json
+++ b/frontend/src/locales/ru/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Уведомления",
+ "bellLabel": "Уведомления, новых: {{count}}",
+ "markAllRead": "Отметить все как прочитанные",
+ "empty": "Новых уведомлений нет",
+ "close": "Скрыть",
+ "updateAvailable": "Доступно обновление: {{version}}",
+ "viewChangelog": "Список изменений",
+ "alerts": {
+ "label": "Требует внимания",
+ "clearsItself": "Это исчезнет автоматически, когда проблема будет устранена.",
+ "geoip_unavailable": {
+ "title": "Списки доступа по странам не применяются",
+ "message": "База данных GeoIP по странам отсутствует или непригодна, поэтому {{count}} список(ков) стран ({{lists}}) пропускает всех посетителей, пока база не станет доступна снова.",
+ "messagePendingApply": "База данных GeoIP снова доступна, но {{count}} список(ов) стран ({{lists}}) пока не применяются. Нажмите «Перезагрузить» на карточке «База данных стран».",
+ "docsLink": "Как предоставить базу данных",
+ "issueHint": "Обычно Charon включает эту базу данных. Создайте, пожалуйста, issue на GitHub, чтобы мы могли это исправить.",
+ "issueLink": "Создать issue"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"settingUpdateFailed": "Failed to update setting: {{error}}",
"enforcement": {
"acl": {
- "geoip_unavailable": "Спискам стран нужна база данных GeoIP, которой нет в этой установке, поэтому запросы отклоняются, пока она не будет предоставлена. См. документацию."
+ "geoip_unavailable": "Списки стран не применяются: базы данных GeoIP нет в этой установке. Все могут обращаться к этим хостам, пока она не будет предоставлена. См. документацию."
+ }
+ },
+ "geoip": {
+ "title": "База данных стран (GeoIP)",
+ "description": "Списки доступа по странам требуют базу данных стран GeoIP. Charon использует встроенную копию, если вы не укажете свою.",
+ "loadError": "Не удалось загрузить состояние базы данных.",
+ "unknownValue": "Неизвестно",
+ "status": {
+ "ready": "Готово",
+ "unavailable": "Недоступно"
+ },
+ "source": {
+ "label": "Источник",
+ "bundled": "Входит в состав Charon",
+ "data": "Ваша папка данных",
+ "custom": "На сервере задан пользовательский путь",
+ "none": "Нет базы данных"
+ },
+ "buildDate": "Дата сборки",
+ "size": "Размер",
+ "path": "Файл",
+ "releaseNote": "Встроенная копия обновляется с каждым выпуском Charon.",
+ "customPathNote": "Путь к базе данных задаётся сервером (CHARON_GEOIP_DB_PATH), поэтому заменить копию здесь нельзя.",
+ "readOnlyNote": "Папка данных доступна только для чтения, поэтому заменить базу данных здесь нельзя.",
+ "unavailableReason": "Недоступно. Списки стран не применяются, пока не загружена база данных.",
+ "docsLink": "Как указать базу данных",
+ "unenforcedLists": "Не применяются списки стран: {{count}}",
+ "formNote": {
+ "unavailable": "Этот список не будет применяться, пока база данных GeoIP недоступна.",
+ "wouldRefuseYou": "По адресу, который вы используете сейчас, этот список заблокировал бы вас. Сохранение по-прежнему разрешено."
+ },
+ "actions": {
+ "reload": "Перезагрузить",
+ "replace": "Заменить базу данных…",
+ "replaceHint": "Загрузите базу данных стран GeoIP (.mmdb), размером до {{max}} МБ.",
+ "useIncluded": "Использовать встроенную копию",
+ "useIncludedConfirmTitle": "Использовать встроенную копию?",
+ "useIncludedConfirmBody": "Ваша копия в папке данных будет удалена, и Charon будет использовать встроенную базу данных.",
+ "useIncludedConfirmBodyNoBundled": "Ваша копия в папке данных будет удалена. Списки стран не будут применяться, пока вы не укажете базу данных."
+ },
+ "result": {
+ "reloaded": "База данных перезагружена.",
+ "replaced": "База данных заменена. Charon теперь использует вашу копию.",
+ "removed": "Charon снова использует встроенную копию."
+ },
+ "lookup": {
+ "label": "IP-адрес",
+ "placeholder": "Например 203.0.113.7",
+ "useMyAddress": "Использовать мой адрес",
+ "submit": "Найти",
+ "found": "{{ip}} находится в стране: {{country}}.",
+ "notFound": "Страна для {{ip}} не найдена.",
+ "disabled": "Поиск доступен после загрузки базы данных.",
+ "invalid": "Введите корректный IP-адрес.",
+ "notLoaded": "База данных не загружена, поэтому адреса нельзя найти.",
+ "myAddressFailed": "Charon не смог определить ваш адрес."
+ },
+ "errors": {
+ "geoip_file_required": "Выберите файл базы данных для загрузки.",
+ "geoip_too_large": "Файл больше 32 МБ.",
+ "geoip_not_mmdb": "Этот файл не является базой данных GeoIP.",
+ "geoip_corrupt": "Файл базы данных повреждён и не может быть прочитан.",
+ "geoip_wrong_type": "Это база данных GeoIP, но не база данных стран.",
+ "geoip_older_than_current": "Эта база данных старее используемой. Загрузите более новую.",
+ "geoip_build_date_in_future": "Дата сборки этой базы данных указана в будущем.",
+ "geoip_custom_path_set": "На сервере задан пользовательский путь к базе данных, поэтому копию нельзя изменить здесь.",
+ "geoip_busy": "Уже выполняется другое изменение базы данных. Повторите попытку немного позже.",
+ "geoip_write_failed": "Charon не смог сохранить файл базы данных.",
+ "geoip_reload_failed": "Charon не смог загрузить файл базы данных.",
+ "geoip_no_data_copy": "В папке данных нет копии для удаления.",
+ "geoip_service_unavailable": "Служба GeoIP не запущена.",
+ "proxy_apply_failed": "Charon не смог применить конфигурацию прокси.",
+ "generic": "Что-то пошло не так. Попробуйте ещё раз."
}
}
},
diff --git a/frontend/src/locales/sr/translation.json b/frontend/src/locales/sr/translation.json
index ff8879056..3b0fe6dd1 100644
--- a/frontend/src/locales/sr/translation.json
+++ b/frontend/src/locales/sr/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Obaveštenja",
+ "bellLabel": "Obaveštenja, {{count}} novih",
+ "markAllRead": "Označi sve kao pročitano",
+ "empty": "Nema novih obaveštenja",
+ "close": "Odbaci",
+ "updateAvailable": "Dostupno ažuriranje: {{version}}",
+ "viewChangelog": "Pogledaj izmene",
+ "alerts": {
+ "label": "Zahteva pažnju",
+ "clearsItself": "Ovo nestaje samo od sebe kada se problem reši.",
+ "geoip_unavailable": {
+ "title": "Liste pristupa po državi se ne primenjuju",
+ "message": "GeoIP baza podataka o državama nedostaje ili se ne može koristiti, pa {{count}} lista(e) država ({{lists}}) propušta svakog posetioca dok ponovo ne bude dostupna.",
+ "messagePendingApply": "GeoIP baza podataka je ponovo dostupna, ali {{count}} lista država ({{lists}}) se još ne primenjuje. Pritisnite Ponovo učitaj na kartici Baza podataka država.",
+ "docsLink": "Kako obezbediti bazu podataka",
+ "issueHint": "Charon obično uključuje ovu bazu podataka. Otvorite issue na GitHub-u kako bismo to mogli da popravimo.",
+ "issueLink": "Otvori issue"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"settingUpdateFailed": "Failed to update setting: {{error}}",
"enforcement": {
"acl": {
- "geoip_unavailable": "Листама држава потребна је GeoIP база података, која недостаје у овој инсталацији, па одбијају захтеве док се не обезбеди. Погледајте документацију."
+ "geoip_unavailable": "Листе држава се не примењују: GeoIP база података недостаје у овој инсталацији. Сви могу да приступе тим хостовима док се не обезбеди. Погледајте документацију."
+ }
+ },
+ "geoip": {
+ "title": "Baza podataka država (GeoIP)",
+ "description": "Liste pristupa po državi zahtevaju GeoIP bazu podataka država. Charon koristi priloženu kopiju, osim ako ne navedete sopstvenu.",
+ "loadError": "Stanje baze podataka nije moglo da se učita.",
+ "unknownValue": "Nepoznato",
+ "status": {
+ "ready": "Spremno",
+ "unavailable": "Nije dostupno"
+ },
+ "source": {
+ "label": "Izvor",
+ "bundled": "Uključeno uz Charon",
+ "data": "Vaša fascikla sa podacima",
+ "custom": "Na serveru je podešena prilagođena putanja",
+ "none": "Nema baze podataka"
+ },
+ "buildDate": "Datum izrade",
+ "size": "Veličina",
+ "path": "Datoteka",
+ "releaseNote": "Priložena kopija se ažurira uz svako izdanje Charon-a.",
+ "customPathNote": "Putanju baze podataka određuje server (CHARON_GEOIP_DB_PATH), pa se kopija ovde ne može zameniti.",
+ "readOnlyNote": "Fascikla sa podacima je samo za čitanje, pa se baza podataka ovde ne može zameniti.",
+ "unavailableReason": "Nije dostupno. Liste država se ne primenjuju dok se ne učita baza podataka.",
+ "docsLink": "Kako obezbediti bazu podataka",
+ "unenforcedLists": "Liste država koje se ne primenjuju: {{count}}",
+ "formNote": {
+ "unavailable": "Ova lista se neće primenjivati dok GeoIP baza podataka nije dostupna.",
+ "wouldRefuseYou": "Na osnovu adrese koju trenutno koristite, ova lista bi vas blokirala. Čuvanje je i dalje dozvoljeno."
+ },
+ "actions": {
+ "reload": "Ponovo učitaj",
+ "replace": "Zameni bazu podataka…",
+ "replaceHint": "Otpremite GeoIP bazu podataka država (.mmdb), najviše {{max}} MiB.",
+ "useIncluded": "Koristi priloženu kopiju",
+ "useIncludedConfirmTitle": "Koristiti priloženu kopiju?",
+ "useIncludedConfirmBody": "Vaša kopija u fascikli sa podacima biće uklonjena i Charon će koristiti priloženu bazu podataka.",
+ "useIncludedConfirmBodyNoBundled": "Vaša kopija u fascikli sa podacima biće uklonjena. Liste država se neće primenjivati dok ne obezbedite bazu podataka."
+ },
+ "result": {
+ "reloaded": "Baza podataka je ponovo učitana.",
+ "replaced": "Baza podataka je zamenjena. Charon sada koristi vašu kopiju.",
+ "removed": "Charon ponovo koristi priloženu kopiju."
+ },
+ "lookup": {
+ "label": "IP adresa",
+ "placeholder": "Na primer 203.0.113.7",
+ "useMyAddress": "Koristi moju adresu",
+ "submit": "Pretraži",
+ "found": "{{ip}} se nalazi u: {{country}}.",
+ "notFound": "Država za {{ip}} nije pronađena.",
+ "disabled": "Pretraga je dostupna kada se učita baza podataka.",
+ "invalid": "Unesite važeću IP adresu.",
+ "notLoaded": "Nijedna baza podataka nije učitana, pa adrese ne mogu da se pretražuju.",
+ "myAddressFailed": "Charon nije mogao da pročita vašu adresu."
+ },
+ "errors": {
+ "geoip_file_required": "Izaberite datoteku baze podataka za otpremanje.",
+ "geoip_too_large": "Datoteka je veća od 32 MiB.",
+ "geoip_not_mmdb": "Ta datoteka nije GeoIP baza podataka.",
+ "geoip_corrupt": "Datoteka baze podataka je oštećena i ne može da se pročita.",
+ "geoip_wrong_type": "Ovo je GeoIP baza podataka, ali ne baza podataka država.",
+ "geoip_older_than_current": "Ova baza podataka je starija od one koja se koristi. Otpremite noviju.",
+ "geoip_build_date_in_future": "Ova baza podataka ima datum izrade u budućnosti.",
+ "geoip_custom_path_set": "Na serveru je podešena prilagođena putanja baze podataka, pa se kopija ovde ne može menjati.",
+ "geoip_busy": "Druga izmena baze podataka je u toku. Pokušajte ponovo za trenutak.",
+ "geoip_write_failed": "Charon nije mogao da sačuva datoteku baze podataka.",
+ "geoip_reload_failed": "Charon nije mogao da učita datoteku baze podataka.",
+ "geoip_no_data_copy": "U fascikli sa podacima nema kopije za uklanjanje.",
+ "geoip_service_unavailable": "GeoIP usluga nije pokrenuta.",
+ "proxy_apply_failed": "Charon nije mogao da primeni konfiguraciju proksija.",
+ "generic": "Nešto nije u redu. Pokušajte ponovo."
}
}
},
diff --git a/frontend/src/locales/sv/translation.json b/frontend/src/locales/sv/translation.json
index e19c819fd..23d52bd7b 100644
--- a/frontend/src/locales/sv/translation.json
+++ b/frontend/src/locales/sv/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Aviseringar",
+ "bellLabel": "Aviseringar, {{count}} nya",
+ "markAllRead": "Markera alla som lästa",
+ "empty": "Inga nya aviseringar",
+ "close": "Avfärda",
+ "updateAvailable": "Uppdatering tillgänglig: {{version}}",
+ "viewChangelog": "Visa ändringslogg",
+ "alerts": {
+ "label": "Kräver uppmärksamhet",
+ "clearsItself": "Detta försvinner av sig självt när problemet är åtgärdat.",
+ "geoip_unavailable": {
+ "title": "Landslistor för åtkomst tillämpas inte",
+ "message": "GeoIP-landsdatabasen saknas eller kan inte användas, så {{count}} landslista(or) ({{lists}}) släpper igenom alla besökare tills den är tillgänglig igen.",
+ "messagePendingApply": "GeoIP-databasen är tillgänglig igen, men {{count}} landslista/listor ({{lists}}) tillämpas ännu inte. Tryck på Läs in igen på kortet Landsdatabas.",
+ "docsLink": "Så tillhandahåller du databasen",
+ "issueHint": "Charon brukar inkludera den här databasen. Öppna ett ärende på GitHub så att vi kan åtgärda det.",
+ "issueLink": "Öppna ett ärende"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"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."
+ "geoip_unavailable": "Landlistor tillämpas inte: GeoIP-databasen saknas i den här installationen. Alla kan nå dessa värdar tills den tillhandahålls. Se dokumentationen."
+ }
+ },
+ "geoip": {
+ "title": "Landsdatabas (GeoIP)",
+ "description": "Landsbaserade åtkomstlistor kräver en GeoIP-landsdatabas. Charon använder kopian som följer med om du inte anger en egen.",
+ "loadError": "Databasstatusen kunde inte läsas in.",
+ "unknownValue": "Okänt",
+ "status": {
+ "ready": "Klar",
+ "unavailable": "Inte tillgänglig"
+ },
+ "source": {
+ "label": "Källa",
+ "bundled": "Medföljer Charon",
+ "data": "Din datamapp",
+ "custom": "Anpassad sökväg angiven på servern",
+ "none": "Ingen databas"
+ },
+ "buildDate": "Byggdatum",
+ "size": "Storlek",
+ "path": "Fil",
+ "releaseNote": "Kopian som medföljer uppdateras med varje Charon-version.",
+ "customPathNote": "Databassökvägen anges av servern (CHARON_GEOIP_DB_PATH), så kopian kan inte ersättas här.",
+ "readOnlyNote": "Datamappen är skrivskyddad, så databasen kan inte ersättas här.",
+ "unavailableReason": "Inte tillgänglig. Landslistor tillämpas inte förrän en databas har lästs in.",
+ "docsLink": "Så här anger du en databas",
+ "unenforcedLists": "{{count}} landslista/listor tillämpas inte",
+ "formNote": {
+ "unavailable": "Den här listan tillämpas inte förrän GeoIP-databasen är tillgänglig.",
+ "wouldRefuseYou": "Utifrån adressen du använder just nu skulle den här listan blockera dig. Det går fortfarande att spara."
+ },
+ "actions": {
+ "reload": "Läs in igen",
+ "replace": "Ersätt databas…",
+ "replaceHint": "Ladda upp en GeoIP-landsdatabas (.mmdb), upp till {{max}} MiB.",
+ "useIncluded": "Använd kopian som medföljer",
+ "useIncludedConfirmTitle": "Använda kopian som medföljer?",
+ "useIncludedConfirmBody": "Din kopia i datamappen tas bort och Charon använder databasen som medföljer.",
+ "useIncludedConfirmBodyNoBundled": "Din kopia i datamappen tas bort. Landslistor tillämpas inte förrän du anger en databas."
+ },
+ "result": {
+ "reloaded": "Databasen lästes in igen.",
+ "replaced": "Databasen ersattes. Charon använder nu din kopia.",
+ "removed": "Charon använder kopian som medföljer igen."
+ },
+ "lookup": {
+ "label": "IP-adress",
+ "placeholder": "Till exempel 203.0.113.7",
+ "useMyAddress": "Använd min adress",
+ "submit": "Slå upp",
+ "found": "{{ip}} finns i {{country}}.",
+ "notFound": "Inget land hittades för {{ip}}.",
+ "disabled": "Uppslagning är tillgänglig när en databas har lästs in.",
+ "invalid": "Ange en giltig IP-adress.",
+ "notLoaded": "Ingen databas är inläst, så adresser kan inte slås upp.",
+ "myAddressFailed": "Charon kunde inte läsa din adress."
+ },
+ "errors": {
+ "geoip_file_required": "Välj en databasfil att ladda upp.",
+ "geoip_too_large": "Filen är större än 32 MiB.",
+ "geoip_not_mmdb": "Filen är inte en GeoIP-databas.",
+ "geoip_corrupt": "Databasfilen är skadad och kan inte läsas.",
+ "geoip_wrong_type": "Det här är en GeoIP-databas, men inte en landsdatabas.",
+ "geoip_older_than_current": "Databasen är äldre än den som används. Ladda upp en nyare.",
+ "geoip_build_date_in_future": "Databasen har ett byggdatum i framtiden.",
+ "geoip_custom_path_set": "En anpassad databassökväg är angiven på servern, så kopian kan inte ändras här.",
+ "geoip_busy": "En annan databasändring pågår. Försök igen om en stund.",
+ "geoip_write_failed": "Charon kunde inte spara databasfilen.",
+ "geoip_reload_failed": "Charon kunde inte läsa in databasfilen.",
+ "geoip_no_data_copy": "Det finns ingen kopia i datamappen att ta bort.",
+ "geoip_service_unavailable": "GeoIP-tjänsten körs inte.",
+ "proxy_apply_failed": "Charon kunde inte tillämpa proxykonfigurationen.",
+ "generic": "Något gick fel. Försök igen."
}
}
},
diff --git a/frontend/src/locales/tr/translation.json b/frontend/src/locales/tr/translation.json
index ba9f7c9a1..8062586db 100644
--- a/frontend/src/locales/tr/translation.json
+++ b/frontend/src/locales/tr/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Bildirimler",
+ "bellLabel": "Bildirimler, {{count}} yeni",
+ "markAllRead": "Tümünü okundu olarak işaretle",
+ "empty": "Yeni bildirim yok",
+ "close": "Kapat",
+ "updateAvailable": "Güncelleme mevcut: {{version}}",
+ "viewChangelog": "Değişiklik günlüğünü gör",
+ "alerts": {
+ "label": "Dikkat gerektiriyor",
+ "clearsItself": "Sorun giderildiğinde bu kendiliğinden kaybolur.",
+ "geoip_unavailable": {
+ "title": "Ülke erişim listeleri uygulanmıyor",
+ "message": "GeoIP ülke veritabanı eksik veya kullanılamıyor; bu nedenle {{count}} ülke listesi ({{lists}}) veritabanı yeniden kullanılabilir olana kadar tüm ziyaretçilerin geçmesine izin veriyor.",
+ "messagePendingApply": "GeoIP veritabanı yeniden kullanılabilir, ancak {{count}} ülke listesi ({{lists}}) henüz uygulanmıyor. Ülke veritabanı kartındaki Yeniden yükle düğmesine basın.",
+ "docsLink": "Veritabanı nasıl sağlanır",
+ "issueHint": "Charon genellikle bu veritabanını içerir. Düzeltebilmemiz için lütfen GitHub’da bir issue açın.",
+ "issueLink": "Issue aç"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"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."
+ "geoip_unavailable": "Ülke listeleri uygulanmıyor: GeoIP veritabanı bu kurulumda eksik. Veritabanı sağlanana kadar herkes bu sunuculara erişebilir. Belgelere bakın."
+ }
+ },
+ "geoip": {
+ "title": "Ülke veritabanı (GeoIP)",
+ "description": "Ülkeye göre erişim listeleri bir GeoIP ülke veritabanı gerektirir. Kendi veritabanınızı sağlamadığınız sürece Charon, birlikte gelen kopyayı kullanır.",
+ "loadError": "Veritabanı durumu yüklenemedi.",
+ "unknownValue": "Bilinmiyor",
+ "status": {
+ "ready": "Hazır",
+ "unavailable": "Kullanılamıyor"
+ },
+ "source": {
+ "label": "Kaynak",
+ "bundled": "Charon ile birlikte gelir",
+ "data": "Veri klasörünüz",
+ "custom": "Sunucuda özel yol ayarlanmış",
+ "none": "Veritabanı yok"
+ },
+ "buildDate": "Derleme tarihi",
+ "size": "Boyut",
+ "path": "Dosya",
+ "releaseNote": "Birlikte gelen kopya her Charon sürümüyle güncellenir.",
+ "customPathNote": "Veritabanı yolu sunucu tarafından ayarlanır (CHARON_GEOIP_DB_PATH), bu nedenle kopya buradan değiştirilemez.",
+ "readOnlyNote": "Veri klasörü salt okunurdur, bu nedenle veritabanı buradan değiştirilemez.",
+ "unavailableReason": "Kullanılamıyor. Bir veritabanı yüklenene kadar ülke listeleri uygulanmaz.",
+ "docsLink": "Veritabanı nasıl sağlanır",
+ "unenforcedLists": "{{count}} ülke listesi uygulanmıyor",
+ "formNote": {
+ "unavailable": "GeoIP veritabanı kullanılabilir olana kadar bu liste uygulanmayacak.",
+ "wouldRefuseYou": "Şu anda kullandığınız adrese göre bu liste sizi engellerdi. Kaydetmeye yine de izin verilir."
+ },
+ "actions": {
+ "reload": "Yeniden yükle",
+ "replace": "Veritabanını değiştir…",
+ "replaceHint": "Bir GeoIP ülke veritabanı (.mmdb) yükleyin, en fazla {{max}} MiB.",
+ "useIncluded": "Birlikte gelen kopyayı kullan",
+ "useIncludedConfirmTitle": "Birlikte gelen kopya kullanılsın mı?",
+ "useIncludedConfirmBody": "Veri klasöründeki kopyanız kaldırılacak ve Charon birlikte gelen veritabanını kullanacak.",
+ "useIncludedConfirmBodyNoBundled": "Veri klasöründeki kopyanız kaldırılacak. Bir veritabanı sağlayana kadar ülke listeleri uygulanmayacak."
+ },
+ "result": {
+ "reloaded": "Veritabanı yeniden yüklendi.",
+ "replaced": "Veritabanı değiştirildi. Charon artık kendi kopyanızı kullanıyor.",
+ "removed": "Charon yeniden birlikte gelen kopyayı kullanıyor."
+ },
+ "lookup": {
+ "label": "IP adresi",
+ "placeholder": "Örneğin 203.0.113.7",
+ "useMyAddress": "Adresimi kullan",
+ "submit": "Sorgula",
+ "found": "{{ip}} adresi {{country}} ülkesinde.",
+ "notFound": "{{ip}} için ülke bulunamadı.",
+ "disabled": "Sorgulama, bir veritabanı yüklendiğinde kullanılabilir.",
+ "invalid": "Geçerli bir IP adresi girin.",
+ "notLoaded": "Hiçbir veritabanı yüklü değil, bu nedenle adresler sorgulanamaz.",
+ "myAddressFailed": "Charon adresinizi okuyamadı."
+ },
+ "errors": {
+ "geoip_file_required": "Yüklemek için bir veritabanı dosyası seçin.",
+ "geoip_too_large": "Dosya 32 MiB'den büyük.",
+ "geoip_not_mmdb": "Bu dosya bir GeoIP veritabanı değil.",
+ "geoip_corrupt": "Bu veritabanı dosyası bozuk ve okunamıyor.",
+ "geoip_wrong_type": "Bu bir GeoIP veritabanı, ancak ülke veritabanı değil.",
+ "geoip_older_than_current": "Bu veritabanı, kullanılandan daha eski. Daha yeni bir tane yükleyin.",
+ "geoip_build_date_in_future": "Bu veritabanının derleme tarihi gelecekte.",
+ "geoip_custom_path_set": "Sunucuda özel bir veritabanı yolu ayarlanmış, bu nedenle kopya buradan değiştirilemez.",
+ "geoip_busy": "Başka bir veritabanı değişikliği sürüyor. Birazdan tekrar deneyin.",
+ "geoip_write_failed": "Charon veritabanı dosyasını kaydedemedi.",
+ "geoip_reload_failed": "Charon veritabanı dosyasını yükleyemedi.",
+ "geoip_no_data_copy": "Kaldırılacak bir kopya veri klasöründe yok.",
+ "geoip_service_unavailable": "GeoIP hizmeti çalışmıyor.",
+ "proxy_apply_failed": "Charon proxy yapılandırmasını uygulayamadı.",
+ "generic": "Bir şeyler ters gitti. Tekrar deneyin."
}
}
},
diff --git a/frontend/src/locales/uk/translation.json b/frontend/src/locales/uk/translation.json
index ca603aa0c..23c5ba62a 100644
--- a/frontend/src/locales/uk/translation.json
+++ b/frontend/src/locales/uk/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Сповіщення",
+ "bellLabel": "Сповіщення, нових: {{count}}",
+ "markAllRead": "Позначити всі як прочитані",
+ "empty": "Нових сповіщень немає",
+ "close": "Приховати",
+ "updateAvailable": "Доступне оновлення: {{version}}",
+ "viewChangelog": "Переглянути зміни",
+ "alerts": {
+ "label": "Потребує уваги",
+ "clearsItself": "Це зникне саме, коли проблему буде виправлено.",
+ "geoip_unavailable": {
+ "title": "Списки доступу за країнами не застосовуються",
+ "message": "База даних GeoIP за країнами відсутня або непридатна, тож {{count}} список(ки) країн ({{lists}}) пропускає всіх відвідувачів, доки база знову не стане доступною.",
+ "messagePendingApply": "База даних GeoIP знову доступна, але {{count}} список(ів) країн ({{lists}}) ще не застосовуються. Натисніть «Перезавантажити» на картці «База даних країн».",
+ "docsLink": "Як надати базу даних",
+ "issueHint": "Зазвичай Charon містить цю базу даних. Створіть, будь ласка, issue на GitHub, щоб ми могли це виправити.",
+ "issueLink": "Створити issue"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"settingUpdateFailed": "Failed to update setting: {{error}}",
"enforcement": {
"acl": {
- "geoip_unavailable": "Спискам країн потрібна база даних GeoIP, якої немає в цій інсталяції, тож запити відхиляються, доки її не буде надано. Див. документацію."
+ "geoip_unavailable": "Списки країн не застосовуються: бази даних GeoIP немає в цій інсталяції. Усі можуть звертатися до цих хостів, доки її не буде надано. Див. документацію."
+ }
+ },
+ "geoip": {
+ "title": "База даних країн (GeoIP)",
+ "description": "Списки доступу за країнами потребують бази даних країн GeoIP. Charon використовує вбудовану копію, якщо ви не вкажете власну.",
+ "loadError": "Не вдалося завантажити стан бази даних.",
+ "unknownValue": "Невідомо",
+ "status": {
+ "ready": "Готово",
+ "unavailable": "Недоступно"
+ },
+ "source": {
+ "label": "Джерело",
+ "bundled": "Входить до складу Charon",
+ "data": "Ваша тека даних",
+ "custom": "На сервері задано власний шлях",
+ "none": "Немає бази даних"
+ },
+ "buildDate": "Дата збірки",
+ "size": "Розмір",
+ "path": "Файл",
+ "releaseNote": "Вбудована копія оновлюється з кожним випуском Charon.",
+ "customPathNote": "Шлях до бази даних задає сервер (CHARON_GEOIP_DB_PATH), тож копію тут замінити не можна.",
+ "readOnlyNote": "Тека даних доступна лише для читання, тож базу даних тут замінити не можна.",
+ "unavailableReason": "Недоступно. Списки країн не застосовуються, доки не завантажено базу даних.",
+ "docsLink": "Як надати базу даних",
+ "unenforcedLists": "Не застосовуються списки країн: {{count}}",
+ "formNote": {
+ "unavailable": "Цей список не застосовуватиметься, доки база даних GeoIP не буде доступна.",
+ "wouldRefuseYou": "За адресою, яку ви використовуєте зараз, цей список заблокував би вас. Збереження все ж дозволено."
+ },
+ "actions": {
+ "reload": "Перезавантажити",
+ "replace": "Замінити базу даних…",
+ "replaceHint": "Завантажте базу даних країн GeoIP (.mmdb), завбільшки до {{max}} МБ.",
+ "useIncluded": "Використати вбудовану копію",
+ "useIncludedConfirmTitle": "Використати вбудовану копію?",
+ "useIncludedConfirmBody": "Вашу копію в теці даних буде видалено, і Charon використовуватиме вбудовану базу даних.",
+ "useIncludedConfirmBodyNoBundled": "Вашу копію в теці даних буде видалено. Списки країн не застосовуватимуться, доки ви не надасте базу даних."
+ },
+ "result": {
+ "reloaded": "Базу даних перезавантажено.",
+ "replaced": "Базу даних замінено. Charon тепер використовує вашу копію.",
+ "removed": "Charon знову використовує вбудовану копію."
+ },
+ "lookup": {
+ "label": "IP-адреса",
+ "placeholder": "Наприклад 203.0.113.7",
+ "useMyAddress": "Використати мою адресу",
+ "submit": "Шукати",
+ "found": "{{ip}} знаходиться в країні: {{country}}.",
+ "notFound": "Для {{ip}} країну не знайдено.",
+ "disabled": "Пошук доступний після завантаження бази даних.",
+ "invalid": "Введіть дійсну IP-адресу.",
+ "notLoaded": "Базу даних не завантажено, тож адреси не можна шукати.",
+ "myAddressFailed": "Charon не зміг прочитати вашу адресу."
+ },
+ "errors": {
+ "geoip_file_required": "Виберіть файл бази даних для завантаження.",
+ "geoip_too_large": "Файл більший за 32 МБ.",
+ "geoip_not_mmdb": "Цей файл не є базою даних GeoIP.",
+ "geoip_corrupt": "Файл бази даних пошкоджено, його не можна прочитати.",
+ "geoip_wrong_type": "Це база даних GeoIP, але не база даних країн.",
+ "geoip_older_than_current": "Ця база даних старіша за використовувану. Завантажте новішу.",
+ "geoip_build_date_in_future": "Дата збірки цієї бази даних у майбутньому.",
+ "geoip_custom_path_set": "На сервері задано власний шлях до бази даних, тож копію тут змінити не можна.",
+ "geoip_busy": "Вже виконується інша зміна бази даних. Спробуйте трохи пізніше.",
+ "geoip_write_failed": "Charon не зміг зберегти файл бази даних.",
+ "geoip_reload_failed": "Charon не зміг завантажити файл бази даних.",
+ "geoip_no_data_copy": "У теці даних немає копії для видалення.",
+ "geoip_service_unavailable": "Служба GeoIP не запущена.",
+ "proxy_apply_failed": "Charon не зміг застосувати конфігурацію проксі.",
+ "generic": "Щось пішло не так. Спробуйте ще раз."
}
}
},
diff --git a/frontend/src/locales/vi/translation.json b/frontend/src/locales/vi/translation.json
index baebc20d7..2360355e7 100644
--- a/frontend/src/locales/vi/translation.json
+++ b/frontend/src/locales/vi/translation.json
@@ -377,6 +377,27 @@
"updateFailed": "Failed to update",
"partialFailed": "Completed with {{count}} error(s)"
},
+ "notificationCenter": {
+ "title": "Thông báo",
+ "bellLabel": "Thông báo, {{count}} mới",
+ "markAllRead": "Đánh dấu tất cả là đã đọc",
+ "empty": "Không có thông báo mới",
+ "close": "Bỏ qua",
+ "updateAvailable": "Có bản cập nhật: {{version}}",
+ "viewChangelog": "Xem nhật ký thay đổi",
+ "alerts": {
+ "label": "Cần chú ý",
+ "clearsItself": "Mục này sẽ tự biến mất khi sự cố được khắc phục.",
+ "geoip_unavailable": {
+ "title": "Danh sách truy cập theo quốc gia chưa được áp dụng",
+ "message": "Cơ sở dữ liệu GeoIP quốc gia bị thiếu hoặc không dùng được, nên {{count}} danh sách quốc gia ({{lists}}) đang cho mọi khách truy cập đi qua cho đến khi cơ sở dữ liệu khả dụng trở lại.",
+ "messagePendingApply": "Cơ sở dữ liệu GeoIP đã khả dụng trở lại, nhưng {{count}} danh sách quốc gia ({{lists}}) chưa được áp dụng. Nhấn Tải lại trên thẻ Cơ sở dữ liệu quốc gia.",
+ "docsLink": "Cách cung cấp cơ sở dữ liệu",
+ "issueHint": "Charon thường đã bao gồm cơ sở dữ liệu này. Vui lòng mở issue trên GitHub để chúng tôi khắc phục.",
+ "issueLink": "Mở issue"
+ }
+ }
+ },
"security": {
"title": "Security",
"description": "Configure security layers for your reverse proxy",
@@ -584,7 +605,80 @@
"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."
+ "geoip_unavailable": "Danh sách quốc gia không được áp dụng: cơ sở dữ liệu GeoIP không có trong bản cài đặt này. Mọi người đều có thể truy cập các máy chủ đó cho đến khi cơ sở dữ liệu được cung cấp. Xem tài liệu."
+ }
+ },
+ "geoip": {
+ "title": "Cơ sở dữ liệu quốc gia (GeoIP)",
+ "description": "Danh sách truy cập theo quốc gia cần cơ sở dữ liệu quốc gia GeoIP. Charon dùng bản đi kèm, trừ khi bạn cung cấp bản riêng.",
+ "loadError": "Không thể tải trạng thái cơ sở dữ liệu.",
+ "unknownValue": "Không xác định",
+ "status": {
+ "ready": "Sẵn sàng",
+ "unavailable": "Không khả dụng"
+ },
+ "source": {
+ "label": "Nguồn",
+ "bundled": "Đi kèm Charon",
+ "data": "Thư mục dữ liệu của bạn",
+ "custom": "Đường dẫn tùy chỉnh được đặt trên máy chủ",
+ "none": "Không có cơ sở dữ liệu"
+ },
+ "buildDate": "Ngày build",
+ "size": "Kích thước",
+ "path": "Tệp",
+ "releaseNote": "Bản đi kèm được cập nhật cùng mỗi bản phát hành Charon.",
+ "customPathNote": "Đường dẫn cơ sở dữ liệu do máy chủ đặt (CHARON_GEOIP_DB_PATH), nên không thể thay bản sao tại đây.",
+ "readOnlyNote": "Thư mục dữ liệu chỉ đọc, nên không thể thay cơ sở dữ liệu tại đây.",
+ "unavailableReason": "Không khả dụng. Danh sách quốc gia chưa được áp dụng cho đến khi có cơ sở dữ liệu.",
+ "docsLink": "Cách cung cấp cơ sở dữ liệu",
+ "unenforcedLists": "{{count}} danh sách quốc gia chưa được áp dụng",
+ "formNote": {
+ "unavailable": "Danh sách này sẽ không được áp dụng cho đến khi cơ sở dữ liệu GeoIP khả dụng.",
+ "wouldRefuseYou": "Dựa trên địa chỉ bạn đang dùng, danh sách này sẽ chặn bạn. Vẫn có thể lưu."
+ },
+ "actions": {
+ "reload": "Tải lại",
+ "replace": "Thay cơ sở dữ liệu…",
+ "replaceHint": "Tải lên cơ sở dữ liệu quốc gia GeoIP (.mmdb), tối đa {{max}} MiB.",
+ "useIncluded": "Dùng bản đi kèm",
+ "useIncludedConfirmTitle": "Dùng bản đi kèm?",
+ "useIncludedConfirmBody": "Bản trong thư mục dữ liệu sẽ bị xóa và Charon sẽ dùng cơ sở dữ liệu đi kèm.",
+ "useIncludedConfirmBodyNoBundled": "Bản trong thư mục dữ liệu sẽ bị xóa. Danh sách quốc gia sẽ không được áp dụng cho đến khi bạn cung cấp cơ sở dữ liệu."
+ },
+ "result": {
+ "reloaded": "Đã tải lại cơ sở dữ liệu.",
+ "replaced": "Đã thay cơ sở dữ liệu. Charon hiện dùng bản của bạn.",
+ "removed": "Charon đã dùng lại bản đi kèm."
+ },
+ "lookup": {
+ "label": "Địa chỉ IP",
+ "placeholder": "Ví dụ 203.0.113.7",
+ "useMyAddress": "Dùng địa chỉ của tôi",
+ "submit": "Tra cứu",
+ "found": "{{ip}} thuộc {{country}}.",
+ "notFound": "Không tìm thấy quốc gia cho {{ip}}.",
+ "disabled": "Tra cứu khả dụng sau khi cơ sở dữ liệu được tải.",
+ "invalid": "Nhập địa chỉ IP hợp lệ.",
+ "notLoaded": "Chưa tải cơ sở dữ liệu nên không thể tra cứu địa chỉ.",
+ "myAddressFailed": "Charon không thể đọc địa chỉ của bạn."
+ },
+ "errors": {
+ "geoip_file_required": "Chọn tệp cơ sở dữ liệu để tải lên.",
+ "geoip_too_large": "Tệp lớn hơn 32 MiB.",
+ "geoip_not_mmdb": "Tệp này không phải cơ sở dữ liệu GeoIP.",
+ "geoip_corrupt": "Tệp cơ sở dữ liệu bị hỏng và không thể đọc.",
+ "geoip_wrong_type": "Đây là cơ sở dữ liệu GeoIP nhưng không phải cơ sở dữ liệu quốc gia.",
+ "geoip_older_than_current": "Cơ sở dữ liệu này cũ hơn bản đang dùng. Hãy tải lên bản mới hơn.",
+ "geoip_build_date_in_future": "Cơ sở dữ liệu này có ngày build ở tương lai.",
+ "geoip_custom_path_set": "Đường dẫn cơ sở dữ liệu tùy chỉnh được đặt trên máy chủ, nên không thể thay đổi bản sao tại đây.",
+ "geoip_busy": "Đang có một thay đổi cơ sở dữ liệu khác. Thử lại sau ít phút.",
+ "geoip_write_failed": "Charon không thể lưu tệp cơ sở dữ liệu.",
+ "geoip_reload_failed": "Charon không thể tải tệp cơ sở dữ liệu.",
+ "geoip_no_data_copy": "Không có bản sao nào trong thư mục dữ liệu để xóa.",
+ "geoip_service_unavailable": "Dịch vụ GeoIP không chạy.",
+ "proxy_apply_failed": "Charon không thể áp dụng cấu hình proxy.",
+ "generic": "Đã xảy ra lỗi. Vui lòng thử lại."
}
}
},
diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json
index e9df557db..3cfc9b33d 100644
--- a/frontend/src/locales/zh/translation.json
+++ b/frontend/src/locales/zh/translation.json
@@ -382,6 +382,27 @@
"updateFailed": "更新失败",
"partialFailed": "完成,有 {{count}} 个错误"
},
+ "notificationCenter": {
+ "title": "通知",
+ "bellLabel": "通知,{{count}} 条新消息",
+ "markAllRead": "全部标为已读",
+ "empty": "没有新通知",
+ "close": "忽略",
+ "updateAvailable": "有可用更新:{{version}}",
+ "viewChangelog": "查看更新日志",
+ "alerts": {
+ "label": "需要注意",
+ "clearsItself": "问题修复后将自动消失。",
+ "geoip_unavailable": {
+ "title": "国家/地区访问列表未生效",
+ "message": "GeoIP 国家数据库缺失或不可用,因此 {{count}} 个国家/地区列表({{lists}})在数据库恢复可用之前会放行所有访客。",
+ "messagePendingApply": "GeoIP 数据库已再次可用,但仍有 {{count}} 个国家列表({{lists}})未生效。请在“国家数据库”卡片上点击“重新加载”。",
+ "docsLink": "如何提供数据库",
+ "issueHint": "Charon 通常会内置此数据库。请在 GitHub 上提交 issue,以便我们修复。",
+ "issueLink": "提交 issue"
+ }
+ }
+ },
"security": {
"title": "安全",
"description": "为反向代理配置安全层",
@@ -609,7 +630,80 @@
},
"unknown": "尚未应用到所有主机。请查看此模块的设置页面。",
"acl": {
- "geoip_unavailable": "国家列表需要 GeoIP 数据库,但此安装中缺少该数据库,因此在提供之前会拒绝请求。请参阅文档。"
+ "geoip_unavailable": "国家列表未生效:此安装中缺少 GeoIP 数据库。在提供数据库之前,所有人都可以访问这些主机。请参阅文档。"
+ }
+ },
+ "geoip": {
+ "title": "国家数据库 (GeoIP)",
+ "description": "按国家的访问列表需要 GeoIP 国家数据库。除非你提供自己的数据库,否则 Charon 将使用随附的副本。",
+ "loadError": "无法加载数据库状态。",
+ "unknownValue": "未知",
+ "status": {
+ "ready": "就绪",
+ "unavailable": "不可用"
+ },
+ "source": {
+ "label": "来源",
+ "bundled": "随 Charon 提供",
+ "data": "你的数据文件夹",
+ "custom": "服务器上设置了自定义路径",
+ "none": "没有数据库"
+ },
+ "buildDate": "构建日期",
+ "size": "大小",
+ "path": "文件",
+ "releaseNote": "随附的副本会随每个 Charon 版本更新。",
+ "customPathNote": "数据库路径由服务器设置 (CHARON_GEOIP_DB_PATH),因此无法在此处替换该副本。",
+ "readOnlyNote": "数据文件夹为只读,因此无法在此处替换数据库。",
+ "unavailableReason": "不可用。加载数据库之前,国家列表不会生效。",
+ "docsLink": "如何提供数据库",
+ "unenforcedLists": "{{count}} 个国家列表未生效",
+ "formNote": {
+ "unavailable": "在 GeoIP 数据库可用之前,此列表不会生效。",
+ "wouldRefuseYou": "根据你当前使用的地址,此列表会阻止你访问。仍然可以保存。"
+ },
+ "actions": {
+ "reload": "重新加载",
+ "replace": "替换数据库…",
+ "replaceHint": "上传 GeoIP 国家数据库 (.mmdb),最大 {{max}} MiB。",
+ "useIncluded": "使用随附的副本",
+ "useIncludedConfirmTitle": "使用随附的副本?",
+ "useIncludedConfirmBody": "数据文件夹中的副本将被删除,Charon 将使用随附的数据库。",
+ "useIncludedConfirmBodyNoBundled": "数据文件夹中的副本将被删除。在你提供数据库之前,国家列表不会生效。"
+ },
+ "result": {
+ "reloaded": "数据库已重新加载。",
+ "replaced": "数据库已替换。Charon 现在使用你的副本。",
+ "removed": "Charon 已重新使用随附的副本。"
+ },
+ "lookup": {
+ "label": "IP 地址",
+ "placeholder": "例如 203.0.113.7",
+ "useMyAddress": "使用我的地址",
+ "submit": "查询",
+ "found": "{{ip}} 位于 {{country}}。",
+ "notFound": "未找到 {{ip}} 对应的国家。",
+ "disabled": "加载数据库后即可查询。",
+ "invalid": "请输入有效的 IP 地址。",
+ "notLoaded": "未加载数据库,因此无法查询地址。",
+ "myAddressFailed": "Charon 无法读取你的地址。"
+ },
+ "errors": {
+ "geoip_file_required": "请选择要上传的数据库文件。",
+ "geoip_too_large": "该文件超过 32 MiB。",
+ "geoip_not_mmdb": "该文件不是 GeoIP 数据库。",
+ "geoip_corrupt": "该数据库文件已损坏,无法读取。",
+ "geoip_wrong_type": "这是 GeoIP 数据库,但不是国家数据库。",
+ "geoip_older_than_current": "该数据库比当前使用的数据库旧。请上传更新的版本。",
+ "geoip_build_date_in_future": "该数据库的构建日期在未来。",
+ "geoip_custom_path_set": "服务器上设置了自定义数据库路径,因此无法在此处更改副本。",
+ "geoip_busy": "另一项数据库更改正在进行中。请稍后再试。",
+ "geoip_write_failed": "Charon 无法保存数据库文件。",
+ "geoip_reload_failed": "Charon 无法加载数据库文件。",
+ "geoip_no_data_copy": "数据文件夹中没有可删除的副本。",
+ "geoip_service_unavailable": "GeoIP 服务未运行。",
+ "proxy_apply_failed": "Charon 无法应用代理配置。",
+ "generic": "出现错误。请重试。"
}
}
},
diff --git a/frontend/src/pages/Security.tsx b/frontend/src/pages/Security.tsx
index 53206299d..df65edf2a 100644
--- a/frontend/src/pages/Security.tsx
+++ b/frontend/src/pages/Security.tsx
@@ -9,6 +9,7 @@ import { crowdsecErrorMessage } from '../api/errorBody'
import { type SecurityStatus } from '../api/security'
import { updateSetting } from '../api/settings'
import { CrowdSecKeyWarning } from '../components/CrowdSecKeyWarning'
+import { GeoIPDatabaseCard } from '../components/GeoIPDatabaseCard'
import { PageShell } from '../components/layout/PageShell'
import { LiveLogViewer } from '../components/LiveLogViewer'
import { ConfigReloadOverlay } from '../components/LoadingStates'
@@ -424,6 +425,7 @@ export default function Security() {
{/* Sign-in throttling is always on, so this sits outside the Cerberus-enabled sections */}
{user?.role === 'admin' && }
+ {user?.role === 'admin' && }
diff --git a/frontend/src/pages/__tests__/Login.overlay.audit.test.tsx b/frontend/src/pages/__tests__/Login.overlay.audit.test.tsx
index 9766cd93f..b30618cb6 100644
--- a/frontend/src/pages/__tests__/Login.overlay.audit.test.tsx
+++ b/frontend/src/pages/__tests__/Login.overlay.audit.test.tsx
@@ -46,8 +46,10 @@ describe('Login - Coin Overlay Security Audit', () => {
})
it('shows coin-themed overlay during login', async () => {
+ // The login request stays pending until the overlay has been checked, so a slow runner cannot finish it first.
+ let finishLogin: (value: { data: object }) => void = () => {}
vi.mocked(client.post).mockImplementation(
- () => new Promise(resolve => setTimeout(() => resolve({ data: {} }), 100))
+ () => new Promise(resolve => { finishLogin = resolve })
)
renderWithProviders()
@@ -69,10 +71,11 @@ describe('Login - Coin Overlay Security Audit', () => {
const overlay = document.querySelector('.bg-amber-950\\/90')
expect(overlay).toBeInTheDocument()
- // Wait for completion
+ // The overlay goes away once the request completes
+ finishLogin({ data: {} })
await waitFor(() => {
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
- }, { timeout: 200 })
+ })
})
it('ATTACK: rapid fire login attempts are blocked by overlay', async () => {
diff --git a/frontend/src/pages/__tests__/Security.dashboard.test.tsx b/frontend/src/pages/__tests__/Security.dashboard.test.tsx
index c1dd4cc59..0d63a5478 100644
--- a/frontend/src/pages/__tests__/Security.dashboard.test.tsx
+++ b/frontend/src/pages/__tests__/Security.dashboard.test.tsx
@@ -368,7 +368,7 @@ describe('Security Dashboard - Card Status Tests', () => {
await renderSecurityPage()
const note = await screen.findByTestId('acl-enforcement-note')
- expect(note).toHaveTextContent('Country lists need the GeoIP database')
+ expect(note).toHaveTextContent('Country lists are not enforced: the GeoIP database is missing')
})
it('shows no ACL note when the ACL reports no reason', async () => {
diff --git a/frontend/src/pages/__tests__/Security.geoip.test.tsx b/frontend/src/pages/__tests__/Security.geoip.test.tsx
new file mode 100644
index 000000000..e19f645da
--- /dev/null
+++ b/frontend/src/pages/__tests__/Security.geoip.test.tsx
@@ -0,0 +1,95 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { render, screen, waitFor } from '@testing-library/react'
+import { BrowserRouter } from 'react-router'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import * as crowdsecApi from '../../api/crowdsec'
+import * as securityApi from '../../api/security'
+import * as authHook from '../../hooks/useAuth'
+import Security from '../Security'
+
+import type { AuthContextType } from '../../context/AuthContextValue'
+import type * as useSecurity from '../../hooks/useSecurity'
+
+vi.mock('../../api/security')
+vi.mock('../../api/crowdsec')
+vi.mock('../../api/settings')
+vi.mock('../../hooks/useAuth')
+vi.mock('../../hooks/useSecurity', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useSecurityConfig: vi.fn(() => ({ data: { config: { admin_whitelist: '' } } })),
+ useUpdateSecurityConfig: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
+ useGenerateBreakGlassToken: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
+ }
+})
+
+const renderPage = (role: 'admin' | 'user' | null) => {
+ vi.spyOn(authHook, 'useAuth').mockReturnValue({
+ user: role ? { user_id: 1, role } : null,
+ } as unknown as AuthContextType)
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ render(
+
+
+
+
+
+ )
+}
+
+describe('Security page GeoIP database card', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
+ cerberus: { enabled: false },
+ crowdsec: { mode: 'disabled', api_url: '', enabled: false },
+ waf: { mode: 'disabled', enabled: false },
+ rate_limit: { enabled: false },
+ acl: { enabled: false },
+ })
+ vi.mocked(securityApi.getLoginProtectionStatus).mockResolvedValue({
+ enabled: true,
+ login: { requests: 10, window_seconds: 600 },
+ session: { requests: 60, window_seconds: 60 },
+ trusted_proxy_count: 0,
+ caller_client_key: '203.0.113.7',
+ caller_client_scope: 'public',
+ untrusted_forwarded_headers: {
+ local: { count: 0, last_seen: null, last_peer: '', last_peer_scope: '' },
+ public: { count: 0, last_seen: null, last_peer: '', last_peer_scope: '' },
+ },
+ })
+ vi.mocked(securityApi.getGeoIPStatus).mockResolvedValue({
+ available: true,
+ loaded: true,
+ db_path: '/app/geoip/GeoLite2-Country.mmdb',
+ message: 'GeoIP service available',
+ source: 'bundled',
+ database_type: 'GeoLite2-Country',
+ build_date: null,
+ size_bytes: null,
+ loaded_at: null,
+ included_build_date: null,
+ data_copy_present: false,
+ can_replace: true,
+ replace_blocked_reason: '',
+ unenforced_lists_total: 0,
+ })
+ vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false, pid: 0, lapi_ready: false })
+ })
+
+ it('shows the card to admins', async () => {
+ renderPage('admin')
+ expect(await screen.findByRole('region', { name: 'Country database (GeoIP)' })).toBeInTheDocument()
+ await waitFor(() => expect(securityApi.getGeoIPStatus).toHaveBeenCalled())
+ })
+
+ it.each(['user', null] as const)('hides the card and skips the status request for %s', async (role) => {
+ renderPage(role)
+ await waitFor(() => expect(securityApi.getSecurityStatus).toHaveBeenCalled())
+ expect(screen.queryByRole('region', { name: 'Country database (GeoIP)' })).not.toBeInTheDocument()
+ expect(securityApi.getGeoIPStatus).not.toHaveBeenCalled()
+ })
+})
diff --git a/go.work b/go.work
index 3f2993ab1..f8988088f 100644
--- a/go.work
+++ b/go.work
@@ -1,4 +1,4 @@
-go 1.27.3
+go 1.27.4
use (
./agent
diff --git a/plugins/powerdns/go.mod b/plugins/powerdns/go.mod
index 513bfe808..edf1335d6 100644
--- a/plugins/powerdns/go.mod
+++ b/plugins/powerdns/go.mod
@@ -1,6 +1,6 @@
module github.com/Wikid82/charon/plugins/powerdns
-go 1.27.3
+go 1.27.4
require github.com/Wikid82/charon/backend v0.0.0
diff --git a/scripts/tests/helpers/toolchain_fixture.bash b/scripts/tests/helpers/toolchain_fixture.bash
index b1d0e8a64..13864c90e 100644
--- a/scripts/tests/helpers/toolchain_fixture.bash
+++ b/scripts/tests/helpers/toolchain_fixture.bash
@@ -49,7 +49,7 @@ tf_write_dockerfile() {
cat > "$TF_DF" < {
test.describe('Country lists', () => {
const domain = `country-list-${Date.now()}.example.test`;
+ const listName = `Country Allow List ${Date.now()}`;
let snapshot: SecurityStateSnapshot | undefined;
let container: string;
let proxyContext: APIRequestContext | undefined;
@@ -273,7 +274,7 @@ test.describe('ACL Enforcement', () => {
snapshot = await captureSecurityState(requestContext);
await narrowAdminWhitelistForLoopbackClient(requestContext);
- aclUuid = await createCountryAllowList(requestContext, `Country Allow List ${Date.now()}`);
+ aclUuid = await createCountryAllowList(requestContext, listName);
proxyUuid = await createProxyHost(requestContext, {
domain,
forwardPort: DEAD_UPSTREAM_PORT,
@@ -281,14 +282,14 @@ test.describe('ACL Enforcement', () => {
accessListUuid: aclUuid,
});
- // Wait for the route to be loaded: the loopback client is refused. A missing route would answer the catch-all
- // page (200), so only a refusal ends the poll.
+ // Wait for the route to be loaded. A missing route answers the catch-all page (200). A loaded route answers 403
+ // when the list is enforced (database loaded) or 502 from the dead upstream when it is not (database missing).
await expect
.poll(async () => (await fromLoopback(container, domain)).status, {
timeout: 30_000,
- message: 'country list route should be loaded and refuse the loopback client',
+ message: 'country list route should be loaded (refusal or dead-upstream 502, not the catch-all page)',
})
- .toBe(403);
+ .toBeGreaterThanOrEqual(400);
});
test.afterAll(async () => {
@@ -300,42 +301,57 @@ test.describe('ACL Enforcement', () => {
]);
});
- test('should refuse a client outside the listed countries and report the GeoIP database state', async () => {
- await test.step('Loopback client is refused with the geographic body', async () => {
- const response = await fromLoopback(container, domain);
+ test('should enforce a country list only while the running config has the GeoIP database', async () => {
+ // The status reason is the source of truth for the running config: geoip_unavailable means country lists are
+ // not enforced (no country matching, every client admitted), and then the alert names the list.
+ let notEnforced = false;
- expect(response.status).toBe(403);
- expect(response.body).toContain(GEO_REFUSAL_BODY);
+ await test.step('Status reports geoip_unavailable exactly when the running config does not enforce country lists', async () => {
+ const statusResponse = await requestContext.get('/api/v1/security/status');
+ expect(statusResponse.ok()).toBe(true);
+ const status = (await statusResponse.json()) as { acl: { reason?: string } };
+
+ notEnforced = status.acl.reason !== undefined;
+ if (notEnforced) {
+ expect(status.acl.reason).toBe('geoip_unavailable');
+ }
+ test.info().annotations.push({
+ type: 'geoip',
+ description: notEnforced
+ ? 'database missing: country list not enforced, alert raised'
+ : 'database loaded: country list enforced',
+ });
});
- await test.step('Whitelisted runner is admitted: 502 from the dead upstream', async () => {
- const response = await fetchThroughProxy(proxyContext as APIRequestContext, domain, '/');
+ if (notEnforced) {
+ await test.step('Not enforced: loopback client is admitted, the dead upstream answers 502', async () => {
+ const response = await fromLoopback(container, domain);
- expect(response.status()).toBe(502);
- });
+ expect(response.status).not.toBe(403);
+ expect(response.body).not.toContain(GEO_REFUSAL_BODY);
+ });
- await test.step('Status reports geoip_unavailable exactly when the database is not loaded', async () => {
- // The database state decides which branch answers, so the check reads it from the API instead of assuming it.
- // Both branches refuse the loopback client above: while the database is missing, the interim database-missing
- // refusal still applies to non-admin clients (it is removed in the feature PR, C5). So reason geoip_unavailable
- // means country lists refuse non-admin clients in this PR, not that they are unenforced.
- const geoipResponse = await requestContext.get('/api/v1/security/geoip/status');
- expect(geoipResponse.ok()).toBe(true);
- const { available } = (await geoipResponse.json()) as { available: boolean };
- test.info().annotations.push({
- type: 'geoip',
- description: available ? 'database loaded: country rule answers' : 'database missing: interim refusal answers',
+ await test.step('Not enforced: the alert endpoint lists the test list', async () => {
+ const alertsResponse = await requestContext.get('/api/v1/notifications/alerts');
+ expect(alertsResponse.ok()).toBe(true);
+ const { alerts } = (await alertsResponse.json()) as { alerts: Array<{ key: string; affected_lists?: string[] }> };
+ const alert = alerts.find((candidate) => candidate.key === 'geoip_unavailable');
+
+ expect(alert?.affected_lists).toContain(listName);
});
+ } else {
+ await test.step('Enforced: loopback client is refused with the geographic body', async () => {
+ const response = await fromLoopback(container, domain);
- const statusResponse = await requestContext.get('/api/v1/security/status');
- expect(statusResponse.ok()).toBe(true);
- const status = (await statusResponse.json()) as { acl: { reason?: string } };
+ expect(response.status).toBe(403);
+ expect(response.body).toContain(GEO_REFUSAL_BODY);
+ });
+ }
- if (available) {
- expect(status.acl).not.toHaveProperty('reason');
- } else {
- expect(status.acl.reason).toBe('geoip_unavailable');
- }
+ await test.step('Whitelisted runner is admitted: 502 from the dead upstream', async () => {
+ const response = await fetchThroughProxy(proxyContext as APIRequestContext, domain, '/');
+
+ expect(response.status()).toBe(502);
});
});
});
@@ -403,12 +419,13 @@ test.describe('ACL Enforcement', () => {
accessListUuid: aclUuid,
});
- // Wait for both routes to be loaded by the proxy. The ACL answers 403 to the loopback client on '/'.
+ // Wait for both routes to be loaded by the proxy. The country list answers 403 to the loopback client on '/', which
+ // needs the GeoIP database loaded: without it the list is not enforced and the loopback client is admitted.
for (const domain of [allowListedDomain, selfProxyDomain]) {
await expect
.poll(async () => (await fromLoopback(container, domain)).status, {
timeout: 30_000,
- message: `route for ${domain} should be loaded`,
+ message: `route for ${domain} should be loaded and refuse the loopback client (GeoIP database must be loaded)`,
})
.toBe(403);
}
diff --git a/tests/security-enforcement/security-headers-enforcement.spec.ts b/tests/security-enforcement/security-headers-enforcement.spec.ts
index 2c06e41eb..a4de225d4 100644
--- a/tests/security-enforcement/security-headers-enforcement.spec.ts
+++ b/tests/security-enforcement/security-headers-enforcement.spec.ts
@@ -169,7 +169,7 @@ test.describe('Security Headers Enforcement', () => {
await expect
.poll(async () => (await fetchFromContainer(container, { host: domain, path: '/' })).status, {
timeout: 30_000,
- message: 'refusal headers route should be loaded and refuse the loopback client',
+ message: 'refusal headers route should be loaded and refuse the loopback client (GeoIP database must be loaded)',
})
.toBe(403);
});
diff --git a/tests/security/geoip-database-card.spec.ts b/tests/security/geoip-database-card.spec.ts
new file mode 100644
index 000000000..e4d405a59
--- /dev/null
+++ b/tests/security/geoip-database-card.spec.ts
@@ -0,0 +1,222 @@
+/**
+ * GeoIP database card on the Security page (spec 9.3 and A.6, C1).
+ *
+ * The admin card shows the database source and path, reloads the database, looks up an address, replaces the data-folder
+ * copy from a browser upload, refuses a text file, and removes the data-folder copy again. The upload and removal routes
+ * refuse cross-origin requests (403 `cross_origin_refused`) before the handler runs.
+ *
+ * Serial: each test builds on the database state the previous one leaves. The bundled copy is never hidden here; the
+ * data-folder copy is created by the replace test and removed by the last one.
+ *
+ * Labels come from the en locale (`security.geoip` in frontend/src/locales/en). The lookup lives in GeoIPLookupForm and
+ * its result paragraph carries `data-testid="geoip-lookup-result"` with no role, so the card's own `role="status"`
+ * region stays the only status region in the card.
+ */
+
+import { request } from '@playwright/test';
+import type { APIRequestContext, Page } from '@playwright/test';
+import { test, expect } from '../fixtures/test';
+import { STORAGE_STATE } from '../constants';
+import { findE2EContainer } from '../utils/container-exec';
+import { restoreLeftoverGeoIPDatabases } from '../utils/geoip-container';
+import { loadGeoIPUploadFixtures, type GeoIPUploadFixtures, type UploadFile } from '../utils/geoip-fixture';
+
+const APP_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8080';
+const STATUS_PATH = '/api/v1/security/geoip/status';
+const DATABASE_PATH = '/api/v1/security/geoip/database';
+const RELOAD_PATH = '/api/v1/security/geoip/reload';
+const ALERTS_PATH = '/api/v1/notifications/alerts';
+const ALERT_KEY = 'geoip_unavailable';
+const BUNDLED_PATH_TEXT = '/app/geoip/GeoLite2-Country.mmdb';
+
+/** Card region name (A.4 title, en). */
+const CARD_NAME = 'Country database (GeoIP)';
+const RELOAD_NAME = 'Reload';
+const REPLACE_NAME = 'Replace database…';
+const USE_INCLUDED_NAME = 'Use the included copy';
+/** Visible text of the data-folder source (A.6, en). */
+const DATA_SOURCE_TEXT = 'Your data folder';
+/** Text of the refusal for a file that is not a GeoIP database (A.6, en). */
+const NOT_A_DATABASE_TEXT = /not a GeoIP database/i;
+/** Lookup form labels (security.geoip.lookup.label and .submit, en). */
+const LOOKUP_FIELD_NAME = 'IP address';
+const LOOKUP_SUBMIT_NAME = 'Look up';
+/** Lookup result paragraph (GeoIPLookupForm). It has no role, so it is located by test id. */
+const LOOKUP_RESULT_TEST_ID = 'geoip-lookup-result';
+
+type GeoIPSource = 'bundled' | 'data' | 'custom' | 'none';
+
+interface GeoIPStatusBody {
+ source: GeoIPSource;
+ data_copy_present: boolean;
+}
+
+async function readStatus(requestContext: APIRequestContext): Promise {
+ const response = await requestContext.get(STATUS_PATH);
+ expect(response.ok()).toBe(true);
+ return (await response.json()) as GeoIPStatusBody;
+}
+
+test.describe('GeoIP database card', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let requestContext: APIRequestContext;
+ let fixtures: GeoIPUploadFixtures;
+ const baseHostname = new URL(APP_BASE_URL).hostname;
+
+ test.beforeAll(async () => {
+ // Loading the fixtures copies the bundled database, so no earlier interrupted run may still have it hidden.
+ restoreLeftoverGeoIPDatabases();
+ fixtures = loadGeoIPUploadFixtures(findE2EContainer());
+ requestContext = await request.newContext({ baseURL: APP_BASE_URL, storageState: STORAGE_STATE });
+ });
+
+ test.afterAll(async () => {
+ // Remove the data-folder copy if a failed run left it, then reload so the running config matches the bundled copy.
+ // A 404 means there was no data-folder copy, which is the expected state.
+ const removal = await requestContext.delete(DATABASE_PATH);
+ if (!removal.ok() && removal.status() !== 404) {
+ throw new Error(`removing the data-folder GeoIP copy failed: ${removal.status()}`);
+ }
+ const reload = await requestContext.post(RELOAD_PATH);
+ if (!reload.ok()) {
+ throw new Error(`geoip reload after cleanup failed: ${reload.status()}`);
+ }
+ await requestContext.dispose();
+ });
+
+ test('Admin sees the card with the bundled source and path, and no removal action', async ({ page }) => {
+ await page.goto('/security');
+ const card = page.getByRole('region', { name: CARD_NAME });
+
+ await test.step('Card shows the path, Reload and Replace', async () => {
+ await expect(card).toContainText(BUNDLED_PATH_TEXT);
+ await expect(card.getByRole('button', { name: RELOAD_NAME })).toBeVisible();
+ await expect(card.getByRole('button', { name: REPLACE_NAME })).toBeVisible();
+ });
+
+ await test.step('The bundled source has no removal action', async () => {
+ await expect(card.getByRole('button', { name: USE_INCLUDED_NAME })).toHaveCount(0);
+ expect((await readStatus(requestContext)).source).toBe('bundled');
+ });
+ });
+
+ test('Reload reports its result inline in a polite status region', async ({ page }) => {
+ await page.goto('/security');
+ const card = page.getByRole('region', { name: CARD_NAME });
+
+ await card.getByRole('button', { name: RELOAD_NAME }).click();
+
+ const result = card.getByRole('status');
+ await expect(result).toHaveAttribute('aria-live', 'polite');
+ await expect(result).not.toBeEmpty();
+ });
+
+ test('Lookup of 127.0.0.1 reports that no country is found', async ({ page }) => {
+ await page.goto('/security');
+
+ await page.getByRole('textbox', { name: LOOKUP_FIELD_NAME }).fill('127.0.0.1');
+ await page.getByRole('button', { name: LOOKUP_SUBMIT_NAME }).click();
+
+ // security.geoip.lookup.notFound (en): "No country found for {{ip}}."
+ await expect(page.getByTestId(LOOKUP_RESULT_TEST_ID)).toHaveText('No country found for 127.0.0.1.');
+ });
+
+ test('Replacing with a generated database uses the data folder and offers the included copy', async ({ page }) => {
+ await page.goto('/security');
+ const card = page.getByRole('region', { name: CARD_NAME });
+
+ await test.step('Browser upload from the card (same origin) is accepted', async () => {
+ await uploadThroughCard(page, fixtures.countryDatabase);
+
+ await expect(card).toContainText(DATA_SOURCE_TEXT);
+ await expect(card.getByRole('status')).not.toBeEmpty();
+ });
+
+ await test.step('Status reports the data-folder source with its copy present', async () => {
+ const status = await readStatus(requestContext);
+ expect(status.source).toBe('data');
+ expect(status.data_copy_present).toBe(true);
+ });
+
+ await test.step('The included-copy action is offered', async () => {
+ await expect(card.getByRole('button', { name: USE_INCLUDED_NAME })).toBeVisible();
+ });
+ });
+
+ test('Replacing with a text file is refused and the data-folder copy stays', async ({ page }) => {
+ await page.goto('/security');
+ const card = page.getByRole('region', { name: CARD_NAME });
+
+ await uploadThroughCard(page, fixtures.textFile);
+
+ await expect(card.getByRole('status')).toContainText(NOT_A_DATABASE_TEXT);
+ expect((await readStatus(requestContext)).source).toBe('data');
+ });
+
+ test('Cross-origin upload and removal are refused with 403 before the handler runs, and the data-folder copy stays', async () => {
+ await test.step('POST from a sibling origin with Sec-Fetch-Site same-site is refused', async () => {
+ const response = await requestContext.post(DATABASE_PATH, {
+ headers: { 'Sec-Fetch-Site': 'same-site', Origin: `https://other.${baseHostname}` },
+ multipart: { file: fixtures.countryDatabase },
+ failOnStatusCode: false,
+ });
+ expect(response.status()).toBe(403);
+ expect(await response.json()).toMatchObject({ error_code: 'cross_origin_refused' });
+ });
+
+ await test.step('DELETE from an unrelated origin is refused', async () => {
+ const response = await requestContext.delete(DATABASE_PATH, {
+ headers: { Origin: 'https://evil.example' },
+ failOnStatusCode: false,
+ });
+ expect(response.status()).toBe(403);
+ expect(await response.json()).toMatchObject({ error_code: 'cross_origin_refused' });
+ });
+
+ await test.step('The refused removal left the data-folder copy in place', async () => {
+ const status = await readStatus(requestContext);
+ expect(status.source).toBe('data');
+ expect(status.data_copy_present).toBe(true);
+ });
+ });
+
+ test('Use the included copy asks for confirmation, removes the data-folder copy, and raises no alert', async ({ page }) => {
+ await page.goto('/security');
+ const card = page.getByRole('region', { name: CARD_NAME });
+
+ await test.step('Confirmation dialog appears before anything is removed', async () => {
+ await card.getByRole('button', { name: USE_INCLUDED_NAME }).click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+ });
+
+ await test.step('Confirming removes the data-folder copy and returns to the bundled source', async () => {
+ await page.getByRole('dialog').getByRole('button', { name: USE_INCLUDED_NAME }).click();
+
+ await expect(card.getByRole('button', { name: USE_INCLUDED_NAME })).toHaveCount(0);
+ const status = await readStatus(requestContext);
+ expect(status.source).toBe('bundled');
+ expect(status.data_copy_present).toBe(false);
+ });
+
+ await test.step('No GeoIP alert is raised while the bundled copy is in use', async () => {
+ const response = await requestContext.get(ALERTS_PATH);
+ expect(response.ok()).toBe(true);
+ const { alerts } = (await response.json()) as { alerts: Array<{ key: string }> };
+ expect(alerts.map((alert) => alert.key)).not.toContain(ALERT_KEY);
+ });
+ });
+});
+
+/**
+ * Uploads a file through the card's Replace button, the way a browser user does: the button opens the file chooser
+ * and the chosen file is sent by the page itself (same origin).
+ */
+async function uploadThroughCard(page: Page, file: UploadFile): Promise {
+ const card = page.getByRole('region', { name: CARD_NAME });
+ const [chooser] = await Promise.all([
+ page.waitForEvent('filechooser'),
+ card.getByRole('button', { name: REPLACE_NAME }).click(),
+ ]);
+ await chooser.setFiles(file);
+}
diff --git a/tests/security/geoip-unenforced-alert.spec.ts b/tests/security/geoip-unenforced-alert.spec.ts
new file mode 100644
index 000000000..320aff46a
--- /dev/null
+++ b/tests/security/geoip-unenforced-alert.spec.ts
@@ -0,0 +1,249 @@
+/**
+ * GeoIP missing-database alert (spec 9.3, C1).
+ *
+ * With the GeoIP database hidden, a country list on an enabled host is not enforced. The app must say so: the reload
+ * endpoint answers 500 without a path, the loopback client is no longer refused, the notification bell shows one
+ * red error alert, and the access list status reports `geoip_unavailable`. Restoring the database clears the alert.
+ *
+ * Not enforced means the proxy applies the country list as a no-op, so the loopback client is admitted, not refused.
+ *
+ * The runner is admin-whitelisted, so the refusal side uses the in-container loopback client with a narrowed admin
+ * whitelist (see utils/loopback-client-case.ts). Each assertion checks both the loopback client and the runner.
+ */
+
+import { request } from '@playwright/test';
+import type { APIRequestContext } from '@playwright/test';
+import { test, expect } from '../fixtures/test';
+import { STORAGE_STATE } from '../constants';
+import {
+ captureSecurityState,
+ restoreSecurityState,
+ setSecurityModuleEnabled,
+ type SecurityStateSnapshot,
+} from '../utils/security-helpers';
+import { createCountryAllowList, createProxyHost, deleteAccessList, deleteProxyHost } from '../utils/proxy-refusal-helpers';
+import { fetchFromContainer, findE2EContainer } from '../utils/container-exec';
+import { narrowAdminWhitelistForLoopbackClient, runCleanupSteps } from '../utils/loopback-client-case';
+import { hideGeoIPDatabases, restoreGeoIPDatabases, restoreLeftoverGeoIPDatabases } from '../utils/geoip-container';
+
+const APP_BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8080';
+/** Port nothing listens on, so a route that is loaded and admits a request answers 502. */
+const DEAD_UPSTREAM_PORT = 3001;
+const ALERT_KEY = 'geoip_unavailable';
+/** Visible text of the alert title (notificationCenter.alerts.geoip_unavailable.title, en). */
+const ALERT_TITLE = 'Country access lists are not being enforced';
+/** Section that holds the bell's alerts (notificationCenter.alerts.label, en). Assumed to be a labelled region. */
+const ALERTS_SECTION_NAME = 'Needs attention';
+/**
+ * Bell accessible name while it has any unread count (notificationCenter.bellLabel, en). The count also includes
+ * regular notifications, so the pattern does not pin it to one.
+ */
+const BELL_NAME_WITH_COUNT = /^Notifications, \d+ new$/;
+/** Docs and issue link labels (notificationCenter.alerts.geoip_unavailable.*, en). */
+const DOCS_LINK_NAME = 'How to provide the database';
+const ISSUE_LINK_NAME = 'Open an issue';
+const DISMISS_NAME = 'Dismiss';
+const MARK_ALL_READ_NAME = 'Mark all read';
+/** Not-enforced note on the Security page (security.enforcement.acl.geoip_unavailable, C5, en). */
+const SECURITY_PAGE_NOTE = 'Country lists are not enforced: the GeoIP database is missing';
+/** Bell refreshes its alerts every 30 seconds, so the alert can take up to one poll to appear. */
+const ALERT_POLL_TIMEOUT_MS = 35_000;
+
+interface AlertBody {
+ key: string;
+ severity: string;
+ affected_lists?: string[];
+ affected_lists_total?: number;
+ database_available?: boolean;
+}
+
+function fromLoopback(container: string, domain: string) {
+ return fetchFromContainer(container, { host: domain, path: '/' });
+}
+
+async function reloadGeoIPDatabase(requestContext: APIRequestContext) {
+ return requestContext.post('/api/v1/security/geoip/reload');
+}
+
+async function getAlerts(requestContext: APIRequestContext): Promise {
+ const response = await requestContext.get('/api/v1/notifications/alerts');
+ expect(response.ok()).toBe(true);
+ return ((await response.json()) as { alerts: AlertBody[] }).alerts;
+}
+
+test.describe('GeoIP unenforced alert', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ const domain = `geoip-unenforced-${Date.now()}.example.test`;
+ const listName = `GeoIP alert list ${Date.now()}`;
+ let container: string;
+ let requestContext: APIRequestContext;
+ let proxyContext: APIRequestContext;
+ let snapshot: SecurityStateSnapshot | undefined;
+ let aclUuid: string | undefined;
+ let proxyUuid: string | undefined;
+
+ test.beforeAll(async () => {
+ container = findE2EContainer();
+ // A leftover from an interrupted run would leave the first test with no database to hide.
+ restoreLeftoverGeoIPDatabases();
+
+ requestContext = await request.newContext({ baseURL: APP_BASE_URL, storageState: STORAGE_STATE });
+ proxyContext = await request.newContext();
+ snapshot = await captureSecurityState(requestContext);
+
+ await setSecurityModuleEnabled(requestContext, 'cerberus', true);
+ await setSecurityModuleEnabled(requestContext, 'acl', true);
+ // The narrowed whitelist must be in place before any host or list is created, so the first applied config holds it.
+ await narrowAdminWhitelistForLoopbackClient(requestContext);
+
+ // A country list for Germany: the loopback client has no country, so it is refused while the database is loaded.
+ aclUuid = await createCountryAllowList(requestContext, listName, 'DE');
+ proxyUuid = await createProxyHost(requestContext, {
+ domain,
+ forwardPort: DEAD_UPSTREAM_PORT,
+ name: 'GeoIP alert host',
+ accessListUuid: aclUuid,
+ });
+
+ // Only a refusal ends the poll: a route that is not loaded answers the catch-all page with 200.
+ await expect
+ .poll(async () => (await fromLoopback(container, domain)).status, {
+ timeout: 30_000,
+ message: 'country list route should be loaded and refuse the loopback client',
+ })
+ .toBe(403);
+ });
+
+ test.afterAll(async () => {
+ await runCleanupSteps([
+ async () => {
+ restoreGeoIPDatabases();
+ },
+ async () => {
+ const response = await reloadGeoIPDatabase(requestContext);
+ if (!response.ok()) {
+ throw new Error(`geoip reload after cleanup failed: ${response.status()}`);
+ }
+ },
+ ...(proxyUuid ? [() => deleteProxyHost(requestContext, proxyUuid as string)] : []),
+ ...(aclUuid ? [() => deleteAccessList(requestContext, aclUuid as string)] : []),
+ ...(snapshot ? [() => restoreSecurityState(requestContext, snapshot as SecurityStateSnapshot)] : []),
+ () => proxyContext.dispose(),
+ () => requestContext.dispose(),
+ ]);
+ });
+
+ test('Hidden database: reload answers 500 without a path, the list is not enforced, and the bell shows the red alert', async ({ page }) => {
+ await test.step('Hide the database and reload: 500 geoip_reload_failed with no path in the body', async () => {
+ expect(hideGeoIPDatabases()).toBe(true);
+
+ const response = await reloadGeoIPDatabase(requestContext);
+ expect(response.status()).toBe(500);
+ const body = await response.text();
+ expect(body).toContain('geoip_reload_failed');
+ expect(body).not.toContain('/app/');
+ });
+
+ await test.step('Config is applied without country matching: the loopback client is no longer refused', async () => {
+ // The config is applied even though the reload failed (C5), so no other trigger is needed.
+ await expect
+ .poll(async () => (await fromLoopback(container, domain)).status, {
+ timeout: 30_000,
+ message: 'country list should stop refusing once the database is missing',
+ })
+ .not.toBe(403);
+ });
+
+ await test.step('The alert is in the API with the list name, and the access list status reports geoip_unavailable', async () => {
+ const alerts = await getAlerts(requestContext);
+ const alert = alerts.find((candidate) => candidate.key === ALERT_KEY);
+ expect(alert).toBeDefined();
+ expect(alert).toMatchObject({
+ key: ALERT_KEY,
+ severity: 'error',
+ affected_lists: [listName],
+ affected_lists_total: 1,
+ database_available: false,
+ });
+
+ const statusResponse = await requestContext.get('/api/v1/security/status');
+ expect(statusResponse.ok()).toBe(true);
+ const status = (await statusResponse.json()) as { acl: { reason?: string } };
+ expect(status.acl.reason).toBe(ALERT_KEY);
+ });
+
+ await test.step('The bell is red, counts the alert, and lists it with its links and no dismiss control', async () => {
+ await page.goto('/security');
+
+ const bell = page.getByRole('button', { name: BELL_NAME_WITH_COUNT });
+ await expect(bell).toHaveClass(/red/, { timeout: ALERT_POLL_TIMEOUT_MS });
+
+ await bell.click();
+ const alerts = page.getByRole('region', { name: ALERTS_SECTION_NAME });
+ await expect(alerts).toContainText(ALERT_TITLE);
+ await expect(alerts).toContainText(listName);
+
+ await expect(alerts.getByRole('link', { name: DOCS_LINK_NAME })).toHaveAttribute(
+ 'href',
+ /^https?:\/\/[^/]+\/(?:.+\/)?troubleshooting\/geoip-database\/?$/
+ );
+ await expect(alerts.getByRole('link', { name: ISSUE_LINK_NAME })).toHaveAttribute(
+ 'href',
+ /^https:\/\/github\.com\/Wikid82\/Charon\/issues\/new\?template=bug_report\.md$/
+ );
+ await expect(alerts.getByRole('button', { name: DISMISS_NAME })).toHaveCount(0);
+ });
+
+ await test.step('Mark all read does not clear the alert', async () => {
+ // The button renders only while regular notifications exist, so it is clicked only when present.
+ const markAllRead = page.getByRole('button', { name: MARK_ALL_READ_NAME });
+ if (await markAllRead.isVisible()) {
+ await markAllRead.click();
+ }
+
+ await expect(page.getByRole('region', { name: ALERTS_SECTION_NAME })).toContainText(ALERT_TITLE);
+ await expect(page.getByRole('button', { name: BELL_NAME_WITH_COUNT })).toBeVisible();
+ });
+
+ await test.step('The Security page shows the not-enforced note on the access list card', async () => {
+ await expect(page.getByRole('main')).toContainText(SECURITY_PAGE_NOTE);
+ });
+ });
+
+ test('Restored database: reload succeeds, the alert disappears, and the loopback client is refused again', async ({ page }) => {
+ await test.step('Restore the database and reload: 200 with the database loaded', async () => {
+ expect(restoreGeoIPDatabases()).toBe(true);
+
+ const response = await reloadGeoIPDatabase(requestContext);
+ expect(response.status()).toBe(200);
+ expect(await response.json()).toMatchObject({ loaded: true });
+ });
+
+ await test.step('The loopback client is refused again', async () => {
+ await expect
+ .poll(async () => (await fromLoopback(container, domain)).status, {
+ timeout: 30_000,
+ message: 'country list should refuse the loopback client once the database is back',
+ })
+ .toBe(403);
+ });
+
+ await test.step('The alert is gone from the API', async () => {
+ await expect
+ .poll(async () => (await getAlerts(requestContext)).map((alert) => alert.key), {
+ timeout: ALERT_POLL_TIMEOUT_MS,
+ })
+ .not.toContain(ALERT_KEY);
+ });
+
+ await test.step('The bell has no alert after a page reload', async () => {
+ await page.goto('/security');
+ await page.getByRole('button', { name: /^Notifications/ }).click();
+
+ await expect(page.getByRole('region', { name: ALERTS_SECTION_NAME })).toHaveCount(0, {
+ timeout: ALERT_POLL_TIMEOUT_MS,
+ });
+ });
+ });
+});
diff --git a/tests/utils/geoip-container.ts b/tests/utils/geoip-container.ts
new file mode 100644
index 000000000..5bc01046c
--- /dev/null
+++ b/tests/utils/geoip-container.ts
@@ -0,0 +1,96 @@
+/**
+ * Hides and restores the GeoIP country database inside the E2E container, so a spec can run the "database missing"
+ * path without rebuilding the image.
+ *
+ * Both copies the application can load are moved, not deleted: the bundled copy under /app/geoip and the data-folder
+ * copy under /app/data/geoip. A move keeps the file's contents, so restoring is exact. Only files this process moved
+ * are moved back; a copy the spec never hid is left alone. A leftover from an interrupted run (a `.e2e-hidden` file
+ * with no original beside it) is moved back the next time a spec hides or restores.
+ *
+ * Commands run as root (`docker exec -u 0`) because the bundled copy is owned by the application user.
+ */
+
+import { execInContainer, findE2EContainer } from './container-exec';
+
+export const BUNDLED_DATABASE_PATH = '/app/geoip/GeoLite2-Country.mmdb';
+export const DATA_DATABASE_PATH = '/app/data/geoip/GeoLite2-Country.mmdb';
+
+const DATABASE_PATHS: readonly string[] = [BUNDLED_DATABASE_PATH, DATA_DATABASE_PATH];
+const HIDDEN_SUFFIX = '.e2e-hidden';
+
+/** Database paths this process moved away. Restoring moves back only these. */
+const hiddenByThisProcess = new Set();
+
+function execAsRoot(container: string, args: readonly string[]): string {
+ return execInContainer(container, args, { user: '0' });
+}
+
+function fileExists(container: string, path: string): boolean {
+ // The path is passed as a positional argument, never interpolated into the script.
+ const answer = execAsRoot(container, [
+ 'sh',
+ '-c',
+ 'if [ -e "$1" ]; then echo yes; else echo no; fi',
+ 'sh',
+ path,
+ ]);
+ return answer.trim() === 'yes';
+}
+
+function movePath(container: string, from: string, to: string): void {
+ execAsRoot(container, ['mv', from, to]);
+}
+
+/** Moves back the copies an interrupted run left hidden. A copy already in place is never overwritten. */
+function restoreLeftovers(container: string): void {
+ for (const path of DATABASE_PATHS) {
+ const hidden = `${path}${HIDDEN_SUFFIX}`;
+ if (fileExists(container, hidden) && !fileExists(container, path)) {
+ movePath(container, hidden, path);
+ }
+ }
+}
+
+/** Moves back any database a previous interrupted run left hidden. Call before a spec needs the database present. */
+export function restoreLeftoverGeoIPDatabases(): void {
+ restoreLeftovers(findE2EContainer());
+}
+
+/**
+ * Hides every GeoIP country database copy the container has. Leftovers from an interrupted run are restored first.
+ * Returns true when at least one copy was hidden.
+ */
+export function hideGeoIPDatabases(): boolean {
+ const container = findE2EContainer();
+ restoreLeftovers(container);
+
+ let hiddenAny = false;
+ for (const path of DATABASE_PATHS) {
+ if (!fileExists(container, path)) {
+ continue;
+ }
+ movePath(container, path, `${path}${HIDDEN_SUFFIX}`);
+ hiddenByThisProcess.add(path);
+ hiddenAny = true;
+ }
+ return hiddenAny;
+}
+
+/**
+ * Moves back the copies this process hid. Returns true when at least one copy was moved back. Safe to call when
+ * nothing was hidden.
+ */
+export function restoreGeoIPDatabases(): boolean {
+ const container = findE2EContainer();
+
+ let restoredAny = false;
+ for (const path of hiddenByThisProcess) {
+ const hidden = `${path}${HIDDEN_SUFFIX}`;
+ if (fileExists(container, hidden)) {
+ movePath(container, hidden, path);
+ restoredAny = true;
+ }
+ }
+ hiddenByThisProcess.clear();
+ return restoredAny;
+}
diff --git a/tests/utils/geoip-fixture.ts b/tests/utils/geoip-fixture.ts
new file mode 100644
index 000000000..42b6cc271
--- /dev/null
+++ b/tests/utils/geoip-fixture.ts
@@ -0,0 +1,108 @@
+/**
+ * Upload fixtures for the GeoIP database specs.
+ *
+ * - `countryDatabase`: the GeoLite2-Country database the image bundles, with its build epoch set to the current time.
+ * It is a real, valid Country database, so the server accepts it as a replacement.
+ * - `textFile`: plain text under an `.mmdb` name, for the "not a GeoIP database" refusal. The name is deliberate: the
+ * server must judge the content, not the extension.
+ *
+ * Deviations from a generated fixture:
+ * - The Country database is copied from the container, not generated. Writing an MMDB search tree with records in
+ * TypeScript would be more code than these checks need. Only the 4-byte `build_epoch` value is rewritten, in place
+ * and at the same width, so the file stays a valid MMDB (the epoch is metadata; Verify() does not check it).
+ * - The copy is read with `docker exec ... base64` rather than `docker cp`: `docker cp` fails on the rootless Docker
+ * daemon used for local runs. base64 keeps the bytes exact.
+ *
+ * The bundled copy must be present when the fixtures are loaded. Load them before a spec hides the database.
+ */
+
+import { BUNDLED_DATABASE_PATH } from './geoip-container';
+import { execInContainer } from './container-exec';
+
+/** Shape Playwright accepts for `setInputFiles` and `FileChooser.setFiles`, and for multipart API uploads. */
+export interface UploadFile {
+ name: string;
+ mimeType: string;
+ buffer: Buffer;
+}
+
+export interface GeoIPUploadFixtures {
+ countryDatabase: UploadFile;
+ textFile: UploadFile;
+}
+
+/** Metadata section start marker (0xAB 0xCD 0xEF followed by "MaxMind.com"), per the MaxMind DB format. */
+const METADATA_START_MARKER = Buffer.concat([Buffer.from([0xab, 0xcd, 0xef]), Buffer.from('MaxMind.com')]);
+const BUILD_EPOCH_KEY = Buffer.from('build_epoch');
+/** MMDB control byte type 0 means the type is in the next byte, offset by 7. */
+const EXTENDED_TYPE_MARKER = 0;
+const EXTENDED_TYPE_OFFSET = 7;
+/** MMDB unsigned integer types: uint16, uint32, uint64. */
+const UINT_TYPES: ReadonlySet = new Set([5, 6, 9]);
+const MAX_UINT_BYTES = 8;
+
+function currentEpochSeconds(): number {
+ return Math.floor(Date.now() / 1000);
+}
+
+/**
+ * Returns a copy of an MMDB file whose metadata `build_epoch` is `epochSeconds`. The value is rewritten in place at its
+ * existing width; the function throws when the new value does not fit that width.
+ */
+export function withBuildEpoch(database: Buffer, epochSeconds: number): Buffer {
+ const out = Buffer.from(database);
+
+ const metadataStart = out.lastIndexOf(METADATA_START_MARKER);
+ if (metadataStart < 0) {
+ throw new Error('GeoIP fixture: database has no metadata section');
+ }
+ const keyStart = out.indexOf(BUILD_EPOCH_KEY, metadataStart);
+ if (keyStart < 0) {
+ throw new Error('GeoIP fixture: database metadata has no build_epoch');
+ }
+
+ let pos = keyStart + BUILD_EPOCH_KEY.length;
+ const control = out[pos];
+ pos += 1;
+ let type = control >> 5;
+ const size = control & 0x1f;
+ if (type === EXTENDED_TYPE_MARKER) {
+ type = out[pos] + EXTENDED_TYPE_OFFSET;
+ pos += 1;
+ }
+ if (!UINT_TYPES.has(type) || size === 0 || size > MAX_UINT_BYTES) {
+ throw new Error(`GeoIP fixture: unexpected build_epoch encoding (type ${type}, size ${size})`);
+ }
+ if (epochSeconds >= 2 ** (8 * size)) {
+ throw new Error('GeoIP fixture: build_epoch does not fit the stored width');
+ }
+
+ let remaining = epochSeconds;
+ for (let i = size - 1; i >= 0; i -= 1) {
+ out[pos + i] = remaining % 256;
+ remaining = Math.floor(remaining / 256);
+ }
+ return out;
+}
+
+/**
+ * Loads the upload fixtures. The Country database is the bundled copy from the container with its build epoch set to
+ * `nowSeconds`, which is after the bundled copy's own epoch, so the server accepts it as newer.
+ */
+export function loadGeoIPUploadFixtures(container: string, nowSeconds = currentEpochSeconds()): GeoIPUploadFixtures {
+ const encoded = execInContainer(container, ['base64', BUNDLED_DATABASE_PATH]);
+ const bundled = Buffer.from(encoded.replace(/\s+/g, ''), 'base64');
+
+ return {
+ countryDatabase: {
+ name: 'GeoLite2-Country.mmdb',
+ mimeType: 'application/octet-stream',
+ buffer: withBuildEpoch(bundled, nowSeconds),
+ },
+ textFile: {
+ name: 'not-a-database.mmdb',
+ mimeType: 'text/plain',
+ buffer: Buffer.from('This file is plain text, not a GeoIP database.\n'),
+ },
+ };
+}