Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ca8fb03
test: specify the GeoIP alert and database card end to end
Wikid82 Nov 10, 2026
ff02dc3
refactor: move the GeoIP handlers into their own file and share the r…
Wikid82 Nov 10, 2026
fc0af5e
feat: show active alerts in the notification bell
Wikid82 Nov 10, 2026
0ddb6da
feat: report active alerts to the notification bell
Wikid82 Nov 10, 2026
a77c1bf
fix: keep country list hosts reachable while the GeoIP database is mi…
Wikid82 Nov 10, 2026
369a854
feat: show the GeoIP database source, date and size in its status
Wikid82 Nov 10, 2026
ac69ddc
feat: add the GeoIP database card to the Security page
Wikid82 Nov 10, 2026
d622521
feat: replace or remove the GeoIP database from the API
Wikid82 Nov 10, 2026
0bb6a00
feat: check whether an access list would refuse your current address
Wikid82 Nov 10, 2026
ea757ac
feat: note when a country list would block you or is not enforced
Wikid82 Nov 10, 2026
c22b9cd
chore: translate the GeoIP database card
Wikid82 Nov 10, 2026
4640317
test: enable the GeoIP database card end to end
Wikid82 Nov 10, 2026
38240b7
docs: describe the GeoIP database card
Wikid82 Nov 10, 2026
9a344f3
test: keep the login overlay audit test independent of timers
Wikid82 Nov 10, 2026
46b38d6
fix: refuse GeoIP database files with an out-of-range build date and …
Wikid82 Nov 10, 2026
d9d80ef
docs: add the QA report
Wikid82 Nov 10, 2026
4fbb192
test: anchor the alert link patterns in the GeoIP alert spec
Wikid82 Nov 10, 2026
c6abd1e
chore: update toolchain
renovate[bot] Nov 10, 2026
51dbfe3
chore: update Go version to 1.27.4 across all modules
Wikid82 Nov 10, 2026
b1dc0e5
chore: update Go version to 1.27.4 in toolchain fixture
Wikid82 Nov 10, 2026
ab4883c
chore(deps): update modernc.org/libc to v1.81.0 and modernc.org/cc/v4…
Wikid82 Nov 10, 2026
66fabd1
chore(docker): sync toolchain image pin to caddy-crowdsec-2ae83568f0f…
github-actions[bot] Nov 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -880,7 +880,7 @@ RUN set -e; \
_retry go get go.opentelemetry.io/[email protected]; \
# 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/[email protected].26; \
_retry go get github.com/aws/aws-sdk-go-v2/aws/protocol/[email protected].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/[email protected]; \
_retry go get github.com/aws/aws-sdk-go-v2/service/[email protected]; \
Expand Down
2 changes: 1 addition & 1 deletion agent/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion agent/go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 4 additions & 4 deletions backend/go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 6 additions & 6 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
103 changes: 103 additions & 0 deletions backend/internal/alerts/alerts.go
Original file line number Diff line number Diff line change
@@ -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
}
}
121 changes: 121 additions & 0 deletions backend/internal/alerts/alerts_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading