Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 5 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,8 @@ check.
#### Security Suite (`internal/cerberus/`)

- **ACL (Access Control Lists):** IP-based allow/deny rules, GeoIP blocking
- **Country lists (GeoIP):** Enforced in Caddy, not in Charon's API. When at least one enabled country list is in use, the generator adds the `geoip2` app (database directory and edition split from the configured `.mmdb` path, lock file in Charon's Caddy data directory, no account or key) and a `geoip2` handler with `enable: "strict"` immediately before the country subroute. Charon checks database availability at generation time (file exists, name ends in `.mmdb`, `GeoIPService` loaded it). When unavailable, each enabled country list becomes a 403 deny handler, with one Warn per apply and a passive `geoip_unavailable` reason on `/security/status`. `POST /security/geoip/reload` triggers a proxy apply after a successful reload.
- **Allow lists without rules:** An enabled allow list with no rules, or rules that cannot be read, becomes a 403 deny handler with one Warn per apply. A block list with no rules is left out, since it blocks nobody.
- **WAF (Web Application Firewall):** Coraza engine with user-provided rule sets (CRS-compatible)
- **CrowdSec:** Behavior-based threat detection with global intelligence
- **Rate Limiter:** Opt-in per-client API throttling (shared `internal/ratelimit` component)
Expand Down Expand Up @@ -725,12 +727,12 @@ export const useProxyHosts = () => {

**Route Pattern: Main Route (plus a recovery route for Charon's own host)**

For each proxy host, Charon generates a **main route** with the domain and no path matchers. Its handlers run the full Cerberus security pipeline before the reverse proxy. Locations are generated as separate path-matched routes that run the same pipeline.
For each proxy host, Charon generates a **main route** with the domain and no path matchers. Its handlers run in a fixed order before the reverse proxy: security headers first, then the Cerberus security checks, then the proxy. Locations are generated as separate path-matched routes that run the same order.

1. **Main Route** (without path matchers):
- Matches: All paths for the domain
- Purpose: Normal application traffic with full security
- Handlers: Full Cerberus security suite, then the reverse proxy
- Handlers: Security headers (applied to every response, including refusals), then the Cerberus security checks, then the reverse proxy. Country lists are checked by a `geoip2` handler placed immediately before the country subroute.

2. **Recovery Route** (exact path matchers, only for a host that proxies Charon itself):
- Matches: exactly `/api/v1/emergency/security-reset` and `/emergency/security-reset`, with no wildcards
Expand All @@ -754,7 +756,7 @@ Caddy processes routes in order, so the recovery route (when present) is evaluat
// Main Route (evaluated second)
{
"match": [{"host": ["app.example.com"]}],
"handle": [/* Security middleware + proxy */],
"handle": [/* Security headers, security checks, then proxy */],
"terminal": true
}
```
Expand Down
44 changes: 24 additions & 20 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -1036,29 +1036,33 @@ SHELL ["/bin/ash", "-o", "pipefail", "-c"]
# Note: In production, users should provide their own MaxMind license key
# This uses the publicly available GeoLite2 database
# In CI, timeout quickly rather than retrying to save build time
# The download is checked against GEOLITE2_COUNTRY_SHA256; a mismatch only prints a warning because the upstream copy
# changes between hash updates. A failed or empty download prints a warning and writes the marker
# /app/data/geoip/.missing so the runtime can report it. The build does not fail: the image then ships without a
# database and country access lists refuse requests.
ARG GEOLITE2_COUNTRY_SHA256=2c2d05c474baeb669298cce912a6236b5ecc4e50f0316a93e01ba5cf22183600
RUN mkdir -p /app/data/geoip && \
if [ "$CI" = "true" ] || [ "$CI" = "1" ]; then \
echo "⏱️ CI detected - quick download (10s timeout, no retries)"; \
if wget -qO /app/data/geoip/GeoLite2-Country.mmdb \
-T 10 "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" 2>/dev/null \
&& [ -s /app/data/geoip/GeoLite2-Country.mmdb ]; then \
echo "✅ GeoIP downloaded"; \
else \
echo "⚠️ GeoIP skipped"; \
touch /app/data/geoip/GeoLite2-Country.mmdb.placeholder; \
fi; \
db=/app/data/geoip/GeoLite2-Country.mmdb && \
if [ "$CI" = "true" ] || [ "$CI" = "1" ]; then \
echo "⏱️ CI detected - quick download (10s timeout, no retries)"; \
wget_opts="-T 10"; \
else \
echo "Local - full download (30s timeout, 3 retries)"; \
wget_opts="-T 30 -t 4"; \
fi && \
if wget -qO "$db" $wget_opts "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" \
&& [ -s "$db" ]; then \
if echo "${GEOLITE2_COUNTRY_SHA256} $db" | sha256sum -c - >/dev/null 2>&1; then \
echo "✅ GeoIP downloaded and checksum verified"; \
else \
echo "Local - full download (30s timeout, 3 retries)"; \
if wget -qO /app/data/geoip/GeoLite2-Country.mmdb \
-T 30 -t 4 "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" \
&& [ -s /app/data/geoip/GeoLite2-Country.mmdb ]; then \
echo "✅ GeoIP downloaded"; \
else \
echo "⚠️ GeoIP download failed or empty — skipping"; \
touch /app/data/geoip/GeoLite2-Country.mmdb.placeholder; \
fi; \
fi && \
echo "WARNING: GeoLite2-Country checksum differs from GEOLITE2_COUNTRY_SHA256 (the upstream copy was updated); keeping the downloaded database." >&2; \
fi; \
else \
echo "WARNING: GeoLite2-Country database not bundled (download failed or empty)." >&2; \
echo "WARNING: The image has no GeoIP database. Country access lists will refuse requests until one is provided at CHARON_GEOIP_DB_PATH." >&2; \
rm -f "$db"; \
touch /app/data/geoip/GeoLite2-Country.mmdb.placeholder /app/data/geoip/.missing; \
fi && \
chown -R charon:charon /app/data

# Copy Caddy binary from caddy-builder (overwriting the one from base image)
Expand Down
55 changes: 50 additions & 5 deletions backend/internal/api/handlers/access_list_handler.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package handlers

import (
"errors"
"fmt"
"net/http"
"strconv"

"github.com/Wikid82/charon/backend/internal/api/middleware"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
Expand All @@ -13,21 +15,57 @@ import (

// AccessListHandler handles access list API requests.
type AccessListHandler struct {
service *services.AccessListService
service *services.AccessListService
caddyManager CaddyConfigManager
}

// NewAccessListHandler creates a new AccessListHandler.
// NewAccessListHandler creates a new AccessListHandler without proxy apply, for callers that only manage lists.
func NewAccessListHandler(db *gorm.DB) *AccessListHandler {
return NewAccessListHandlerWithDeps(db, nil)
}

// NewAccessListHandlerWithDeps creates an AccessListHandler that re-applies the proxy config when an edit reaches a
// list that hosts use.
func NewAccessListHandlerWithDeps(db *gorm.DB, caddyManager CaddyConfigManager) *AccessListHandler {
return &AccessListHandler{
service: services.NewAccessListService(db),
service: services.NewAccessListService(db),
caddyManager: nilIfTypedNil(caddyManager),
}
}

// applyIfInUse re-applies the proxy config after a change to a list. A list no host uses is not part of the proxy
// config, so nothing is applied for it. It returns the apply error, if any.
func (h *AccessListHandler) applyIfInUse(c *gin.Context, aclID uint) error {
if h.caddyManager == nil {
return nil
}
inUse, err := h.service.InUse(aclID)
if err != nil {
return err
}
if !inUse {
return nil
}
return applyProxyConfigDetached(c.Request.Context(), h.caddyManager, proxyApplyTimeout)
}

// SetGeoIPService sets the GeoIP service for geo-based ACL lookups.
func (h *AccessListHandler) SetGeoIPService(geoipSvc *services.GeoIPService) {
h.service.SetGeoIPService(geoipSvc)
}

// accessListRulesRequiredCode is the stable error_code for ErrAccessListRulesRequired; the UI maps it to a message.
const accessListRulesRequiredCode = "access_list_rules_required"

// respondAccessListSaveError answers a refused create or update with 400. Known refusals carry a stable error_code.
func respondAccessListSaveError(c *gin.Context, err error) {
if errors.Is(err, services.ErrAccessListRulesRequired) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "error_code": accessListRulesRequiredCode})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}

// resolveAccessList resolves an access list by either numeric ID or UUID.
// It first attempts to parse as uint (backward compatibility), then tries UUID.
func (h *AccessListHandler) resolveAccessList(idOrUUID string) (*models.AccessList, error) {
Expand All @@ -54,7 +92,7 @@ func (h *AccessListHandler) Create(c *gin.Context) {
}

if err := h.service.Create(&acl); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondAccessListSaveError(c, err)
return
}

Expand Down Expand Up @@ -118,7 +156,14 @@ func (h *AccessListHandler) Update(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondAccessListSaveError(c, err)
return
}

// The edit is saved at this point; an apply failure is reported, and the next apply picks the saved rules up.
if err = h.applyIfInUse(c, acl.ID); err != nil {
middleware.GetRequestLogger(c).WithError(err).Warn("failed to apply proxy config after access list update")
c.JSON(http.StatusInternalServerError, gin.H{"error": "saved, but applying the configuration failed"})
return
}

Expand Down
159 changes: 159 additions & 0 deletions backend/internal/api/handlers/access_list_handler_apply_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package handlers

import (
"bytes"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"testing"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"

"github.com/Wikid82/charon/backend/internal/models"
)

// accessListApplyRouter wires the access list routes to a handler that applies the proxy config through mgr.
func accessListApplyRouter(t *testing.T, mgr CaddyConfigManager) (*gin.Engine, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.AccessList{}, &models.ProxyHost{}))

h := NewAccessListHandlerWithDeps(db, mgr)
r := gin.New()
r.POST("/access-lists", h.Create)
r.PUT("/access-lists/:id", h.Update)
r.DELETE("/access-lists/:id", h.Delete)
return r, db
}

// createAccessListForApply stores a whitelist with one rule and returns its ID.
func createAccessListForApply(t *testing.T, db *gorm.DB) uint {
t.Helper()
acl := &models.AccessList{
UUID: "apply-acl",
Name: "Office",
Type: "whitelist",
IPRules: `[{"cidr":"10.0.0.0/8"}]`,
Enabled: true,
}
require.NoError(t, db.Create(acl).Error)
return acl.ID
}

// attachToProxyHost makes a proxy host use the access list, which is what puts the list into the proxy config.
func attachToProxyHost(t *testing.T, db *gorm.DB, aclID uint) {
t.Helper()
host := &models.ProxyHost{
UUID: "apply-host",
Name: "app",
DomainNames: "app.example.com",
ForwardHost: "app",
ForwardPort: 8080,
Enabled: true,
AccessListID: &aclID,
}
require.NoError(t, db.Create(host).Error)
}

func putAccessList(r *gin.Engine, id uint, body string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/access-lists/"+strconv.FormatUint(uint64(id), 10), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
return w
}

const updatedWhitelistBody = `{"name":"Office","type":"whitelist","ip_rules":"[{\"cidr\":\"192.168.0.0/16\"}]","enabled":true}`

func TestAccessListHandler_Update_AttachedListAppliesProxyConfig(t *testing.T) {
mgr := &countingCaddyManager{}
r, db := accessListApplyRouter(t, mgr)
id := createAccessListForApply(t, db)
attachToProxyHost(t, db, id)

w := putAccessList(r, id, updatedWhitelistBody)

require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, 1, mgr.calls, "an edit to a list attached to a host reaches the proxy")
}

func TestAccessListHandler_Update_UnattachedListSkipsApply(t *testing.T) {
mgr := &countingCaddyManager{}
r, db := accessListApplyRouter(t, mgr)
id := createAccessListForApply(t, db)

w := putAccessList(r, id, updatedWhitelistBody)

require.Equal(t, http.StatusOK, w.Code)
require.Zero(t, mgr.calls, "a list no host uses does not change the proxy config")
}

func TestAccessListHandler_Update_ApplyFailureIsReported(t *testing.T) {
mgr := &countingCaddyManager{applyErr: errors.New("caddy refused the config")}
r, db := accessListApplyRouter(t, mgr)
id := createAccessListForApply(t, db)
attachToProxyHost(t, db, id)

w := putAccessList(r, id, updatedWhitelistBody)

require.Equal(t, http.StatusInternalServerError, w.Code)
require.Contains(t, w.Body.String(), "saved, but applying the configuration failed")
require.Equal(t, 1, mgr.calls)

var stored models.AccessList
require.NoError(t, db.First(&stored, id).Error)
require.Contains(t, stored.IPRules, "192.168.0.0/16", "the edit is saved even when the apply fails")
}

func TestAccessListHandler_Update_RefusedEditSkipsApply(t *testing.T) {
mgr := &countingCaddyManager{}
r, db := accessListApplyRouter(t, mgr)
id := createAccessListForApply(t, db)
attachToProxyHost(t, db, id)

// An enabled whitelist with no rules would deny every request, so the service refuses it before any apply.
w := putAccessList(r, id, `{"name":"Office","type":"whitelist","ip_rules":"[]","enabled":true}`)

require.Equal(t, http.StatusBadRequest, w.Code)
require.Zero(t, mgr.calls)
}

func TestAccessListHandler_Delete_UnattachedListSkipsApply(t *testing.T) {
mgr := &countingCaddyManager{}
r, db := accessListApplyRouter(t, mgr)
id := createAccessListForApply(t, db)

w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/access-lists/"+strconv.FormatUint(uint64(id), 10), http.NoBody))

require.Equal(t, http.StatusOK, w.Code)
require.Zero(t, mgr.calls, "only an unattached list can be deleted, so the proxy config does not change")
}

func TestAccessListHandler_Delete_AttachedListIsRefused(t *testing.T) {
mgr := &countingCaddyManager{}
r, db := accessListApplyRouter(t, mgr)
id := createAccessListForApply(t, db)
attachToProxyHost(t, db, id)

w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/access-lists/"+strconv.FormatUint(uint64(id), 10), http.NoBody))

require.Equal(t, http.StatusConflict, w.Code)
require.Zero(t, mgr.calls)
}

func TestAccessListHandler_Update_WithoutManagerSkipsApply(t *testing.T) {
r, db := accessListApplyRouter(t, nil)
id := createAccessListForApply(t, db)
attachToProxyHost(t, db, id)

w := putAccessList(r, id, updatedWhitelistBody)

require.Equal(t, http.StatusOK, w.Code)
}
3 changes: 2 additions & 1 deletion backend/internal/api/handlers/access_list_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ func TestAccessListHandler_Update(t *testing.T) {
UUID: "test-uuid",
Name: "Original Name",
Type: "whitelist",
IPRules: `[{"cidr":"192.168.1.0/24"}]`,
Enabled: true,
}
db.Create(&acl)
Expand Down Expand Up @@ -237,7 +238,7 @@ func TestAccessListHandler_Update(t *testing.T) {
"description": "UUID update description",
"enabled": true,
"type": "whitelist",
"ip_rules": `[]`,
"ip_rules": `[{"cidr":"10.0.0.0/8"}]`,
},
wantStatus: http.StatusOK,
},
Expand Down
Loading
Loading