Refactor Caddy configuration management to include security settings

- Updated `GenerateConfig` function calls in tests to include additional security parameters.
- Enhanced `Manager` struct to hold a `SecurityConfig` instance for managing security-related settings.
- Implemented `computeEffectiveFlags` method to determine the effective state of security features based on both static configuration and runtime database settings.
- Added comprehensive tests for the new security configuration handling, ensuring correct behavior for various scenarios including ACL and CrowdSec settings.
- Adjusted existing tests to accommodate the new structure and ensure compatibility with the updated configuration management.
This commit is contained in:
GitHub Actions
2025-12-01 16:18:50 +00:00
parent fd4555674d
commit c83928f628
24 changed files with 743 additions and 174 deletions

View File

@@ -8,6 +8,7 @@ import (
"testing"
"strings"
"os"
"regexp"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/services"
@@ -26,8 +27,11 @@ func TestBackupHandlerSanitizesFilename(t *testing.T) {
svc := &services.BackupService{DataDir: tmpDir, BackupDir: tmpDir, DatabaseName: "db.sqlite", Cron: nil}
h := NewBackupHandler(svc)
router := gin.New()
router.GET("/backups/:filename/restore", h.Restore)
// Create a gin test context and use it to call handler directly
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
// Ensure request-scoped logger is present and writes to our buffer
c.Set("logger", logger.WithFields(map[string]interface{}{"test": "1"}))
// initialize logger to buffer
buf := &bytes.Buffer{}
@@ -35,16 +39,27 @@ func TestBackupHandlerSanitizesFilename(t *testing.T) {
// Create a malicious filename with newline and path components
malicious := "../evil\nname"
// Use path escape to send as URL
req := httptest.NewRequest(http.MethodPost, "/backups/"+strings.ReplaceAll(malicious, "\n", "%0A")+"/restore", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
c.Request = httptest.NewRequest(http.MethodGet, "/backups/"+strings.ReplaceAll(malicious, "\n", "%0A")+"/restore", nil)
// Call handler directly with the test context
h.Restore(c)
out := buf.String()
if strings.Contains(out, "\n") {
t.Fatalf("log contained raw newline in filename: %s", out)
// Optionally we could assert on the response status code here if needed
textRegex := regexp.MustCompile(`filename=?"?([^"\s]*)"?`)
jsonRegex := regexp.MustCompile(`"filename":"([^"]*)"`)
var loggedFilename string
if m := textRegex.FindStringSubmatch(out); len(m) == 2 {
loggedFilename = m[1]
} else if m := jsonRegex.FindStringSubmatch(out); len(m) == 2 {
loggedFilename = m[1]
} else {
t.Fatalf("could not extract filename from logs: %s", out)
}
if strings.Contains(out, "..") {
t.Fatalf("log contained path traversals in filename: %s", out)
if strings.Contains(loggedFilename, "\n") || strings.Contains(loggedFilename, "\r") {
t.Fatalf("log filename contained raw newline: %q", loggedFilename)
}
if strings.Contains(loggedFilename, "..") {
t.Fatalf("log filename contained path traversals in filename: %q", loggedFilename)
}
}

View File

@@ -23,7 +23,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -58,12 +57,10 @@ func TestCertificateHandler_List(t *testing.T) {
// Setup temp dir
tmpDir := t.TempDir()
caddyDir := filepath.Join(tmpDir, "caddy", "certificates", "acme-v02.api.letsencrypt.org-directory")
err := os.MkdirAll(caddyDir, 0755)
require.NoError(t, err)
require.NoError(t, os.MkdirAll(caddyDir, 0755))
// Setup in-memory DB
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
@@ -80,7 +77,7 @@ func TestCertificateHandler_List(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
var certs []services.CertificateInfo
err = json.Unmarshal(w.Body.Bytes(), &certs)
err := json.Unmarshal(w.Body.Bytes(), &certs)
assert.NoError(t, err)
assert.Empty(t, certs)
}
@@ -88,8 +85,7 @@ func TestCertificateHandler_List(t *testing.T) {
func TestCertificateHandler_Upload(t *testing.T) {
// Setup
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
@@ -125,7 +121,7 @@ func TestCertificateHandler_Upload(t *testing.T) {
assert.Equal(t, http.StatusCreated, w.Code)
var cert models.SSLCertificate
err = json.Unmarshal(w.Body.Bytes(), &cert)
err := json.Unmarshal(w.Body.Bytes(), &cert)
assert.NoError(t, err)
assert.Equal(t, "Test Cert", cert.Name)
}
@@ -134,8 +130,7 @@ func TestCertificateHandler_Delete(t *testing.T) {
// Setup
tmpDir := t.TempDir()
// Use WAL mode and busy timeout for better concurrency with race detector
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_journal_mode=WAL&_busy_timeout=5000"), &gorm.Config{})
require.NoError(t, err)
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
// Seed a cert
@@ -143,8 +138,7 @@ func TestCertificateHandler_Delete(t *testing.T) {
UUID: "test-uuid",
Name: "To Delete",
}
err = db.Create(&cert).Error
require.NoError(t, err)
require.NoError(t, db.Create(&cert).Error)
require.NotZero(t, cert.ID)
service := services.NewCertificateService(tmpDir, db)
@@ -165,15 +159,14 @@ func TestCertificateHandler_Delete(t *testing.T) {
// Verify deletion
var deletedCert models.SSLCertificate
err = db.First(&deletedCert, cert.ID).Error
err := db.First(&deletedCert, cert.ID).Error
assert.Error(t, err)
assert.Equal(t, gorm.ErrRecordNotFound, err)
}
func TestCertificateHandler_Upload_Errors(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
@@ -208,8 +201,7 @@ func TestCertificateHandler_Upload_Errors(t *testing.T) {
func TestCertificateHandler_Delete_NotFound(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.NotificationProvider{}))
service := services.NewCertificateService(tmpDir, db)
@@ -229,8 +221,7 @@ func TestCertificateHandler_Delete_NotFound(t *testing.T) {
func TestCertificateHandler_Delete_InvalidID(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
@@ -249,8 +240,7 @@ func TestCertificateHandler_Delete_InvalidID(t *testing.T) {
func TestCertificateHandler_Upload_InvalidCertificate(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
@@ -284,8 +274,7 @@ func TestCertificateHandler_Upload_InvalidCertificate(t *testing.T) {
func TestCertificateHandler_Upload_MissingKeyFile(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
@@ -317,8 +306,7 @@ func TestCertificateHandler_Upload_MissingKeyFile(t *testing.T) {
func TestCertificateHandler_Upload_MissingName(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
@@ -354,11 +342,9 @@ func TestCertificateHandler_Upload_MissingName(t *testing.T) {
func TestCertificateHandler_List_WithCertificates(t *testing.T) {
tmpDir := t.TempDir()
caddyDir := filepath.Join(tmpDir, "caddy", "certificates", "acme-v02.api.letsencrypt.org-directory")
err := os.MkdirAll(caddyDir, 0755)
require.NoError(t, err)
require.NoError(t, os.MkdirAll(caddyDir, 0755))
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
// Seed a certificate in DB
@@ -366,8 +352,7 @@ func TestCertificateHandler_List_WithCertificates(t *testing.T) {
UUID: "test-uuid",
Name: "Test Cert",
}
err = db.Create(&cert).Error
require.NoError(t, err)
require.NoError(t, db.Create(&cert).Error)
service := services.NewCertificateService(tmpDir, db)
ns := services.NewNotificationService(db)
@@ -383,7 +368,7 @@ func TestCertificateHandler_List_WithCertificates(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
var certs []services.CertificateInfo
err = json.Unmarshal(w.Body.Bytes(), &certs)
err := json.Unmarshal(w.Body.Bytes(), &certs)
assert.NoError(t, err)
assert.NotEmpty(t, certs)
}

View File

@@ -12,7 +12,6 @@ import (
"encoding/json"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -36,10 +35,7 @@ func (f *fakeExec) Status(ctx context.Context, configDir string) (bool, int, err
}
func setupCrowdDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("db open: %v", err)
}
db := OpenTestDB(t)
return db
}

View File

@@ -8,17 +8,13 @@ import (
"testing"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
)
func setupFlagsDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open in-memory sqlite: %v", err)
}
db := OpenTestDB(t)
if err := db.AutoMigrate(&models.Setting{}); err != nil {
t.Fatalf("auto migrate failed: %v", err)
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/api/handlers"
@@ -19,11 +18,8 @@ import (
"github.com/Wikid82/charon/backend/internal/services"
)
func setupTestDB() *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
panic("failed to connect to test database")
}
func setupTestDB(t *testing.T) *gorm.DB {
db := handlers.OpenTestDB(t)
// Auto migrate
db.AutoMigrate(
@@ -38,7 +34,7 @@ func setupTestDB() *gorm.DB {
func TestRemoteServerHandler_List(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
db := setupTestDB(t)
// Create test server
server := &models.RemoteServer{
@@ -72,7 +68,7 @@ func TestRemoteServerHandler_List(t *testing.T) {
func TestRemoteServerHandler_Create(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
db := setupTestDB(t)
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
@@ -105,7 +101,7 @@ func TestRemoteServerHandler_Create(t *testing.T) {
func TestRemoteServerHandler_TestConnection(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
db := setupTestDB(t)
// Create test server
server := &models.RemoteServer{
@@ -139,7 +135,7 @@ func TestRemoteServerHandler_TestConnection(t *testing.T) {
func TestRemoteServerHandler_Get(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
db := setupTestDB(t)
// Create test server
server := &models.RemoteServer{
@@ -172,7 +168,7 @@ func TestRemoteServerHandler_Get(t *testing.T) {
func TestRemoteServerHandler_Update(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
db := setupTestDB(t)
// Create test server
server := &models.RemoteServer{
@@ -217,7 +213,7 @@ func TestRemoteServerHandler_Update(t *testing.T) {
func TestRemoteServerHandler_Delete(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
db := setupTestDB(t)
// Create test server
server := &models.RemoteServer{
@@ -252,7 +248,7 @@ func TestRemoteServerHandler_Delete(t *testing.T) {
func TestProxyHostHandler_List(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
db := setupTestDB(t)
// Create test proxy host
host := &models.ProxyHost{
@@ -287,7 +283,7 @@ func TestProxyHostHandler_List(t *testing.T) {
func TestProxyHostHandler_Create(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
db := setupTestDB(t)
ns := services.NewNotificationService(db)
handler := handlers.NewProxyHostHandler(db, nil, ns, nil)
@@ -340,7 +336,7 @@ func TestHealthHandler(t *testing.T) {
func TestRemoteServerHandler_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
db := setupTestDB(t)
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)

View File

@@ -6,25 +6,28 @@ import (
"net/http/httptest"
"strings"
"testing"
"os"
"path/filepath"
"encoding/json"
"regexp"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/api/middleware"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"encoding/json"
)
func TestImportUploadSanitizesFilename(t *testing.T) {
gin.SetMode(gin.TestMode)
tmpDir := t.TempDir()
// set up in-memory DB for handler
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open in-memory db: %v", err)
}
svc := NewImportHandler(db, "/usr/bin/caddy", tmpDir, "")
db := OpenTestDB(t)
// Create a fake caddy executable to avoid dependency on system binary
fakeCaddy := filepath.Join(tmpDir, "caddy")
os.WriteFile(fakeCaddy, []byte("#!/bin/sh\nexit 0"), 0755)
svc := NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
router.Use(middleware.RequestID())
router.POST("/import/upload", svc.Upload)
buf := &bytes.Buffer{}
@@ -39,10 +42,24 @@ func TestImportUploadSanitizesFilename(t *testing.T) {
router.ServeHTTP(w, req)
out := buf.String()
if strings.Contains(out, "\n") {
t.Fatalf("log contained raw newline in filename: %s", out)
// Extract the logged filename from either text or JSON log format
textRegex := regexp.MustCompile(`filename=?"?([^"\s]*)"?`)
jsonRegex := regexp.MustCompile(`"filename":"([^"]*)"`)
var loggedFilename string
if m := textRegex.FindStringSubmatch(out); len(m) == 2 {
loggedFilename = m[1]
} else if m := jsonRegex.FindStringSubmatch(out); len(m) == 2 {
loggedFilename = m[1]
} else {
// if we can't extract a filename value, fail the test
t.Fatalf("could not extract filename from logs: %s", out)
}
if strings.Contains(out, "..") {
t.Fatalf("log contained path traversal in filename: %s", out)
if strings.Contains(loggedFilename, "\n") || strings.Contains(loggedFilename, "\r") {
t.Fatalf("log filename contained raw newline: %q", loggedFilename)
}
if strings.Contains(loggedFilename, "..") {
t.Fatalf("log filename contained path traversal: %q", loggedFilename)
}
}

View File

@@ -17,7 +17,11 @@ import (
)
func setupNotificationTestDB() *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
// Use openTestDB helper via temporary t trick
// Since this function lacks t param, keep calling openTestDB with a dummy testing.T
// But to avoid changing many callers, we'll reuse openTestDB by creating a short-lived testing.T wrapper isn't possible.
// Instead, set WAL and busy timeout using a simple gorm.Open with shared memory but minimal changes.
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_journal_mode=WAL&_busy_timeout=5000"), &gorm.Config{})
if err != nil {
panic("failed to connect to test database")
}

View File

@@ -10,7 +10,6 @@ import (
"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/handlers"
@@ -20,8 +19,7 @@ import (
func setupNotificationProviderTest(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
db := handlers.OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
service := services.NewNotificationService(db)

View File

@@ -13,14 +13,12 @@ import (
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
db.AutoMigrate(&models.NotificationTemplate{})
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}))
return db
}

View File

@@ -16,6 +16,7 @@ import (
"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"
)
@@ -154,7 +155,7 @@ func TestProxyHostErrors(t *testing.T) {
// Setup Caddy Manager
tmpDir := t.TempDir()
client := caddy.NewClient(caddyServer.URL)
manager := caddy.NewManager(client, db, tmpDir, "", false)
manager := caddy.NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
// Setup Handler
ns := services.NewNotificationService(db)
@@ -341,7 +342,7 @@ func TestProxyHostWithCaddyIntegration(t *testing.T) {
// Setup Caddy Manager
tmpDir := t.TempDir()
client := caddy.NewClient(caddyServer.URL)
manager := caddy.NewManager(client, db, tmpDir, "", false)
manager := caddy.NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
// Setup Handler
ns := services.NewNotificationService(db)

View File

@@ -18,7 +18,7 @@ import (
func setupRemoteServerTest_New(t *testing.T) (*gin.Engine, *handlers.RemoteServerHandler) {
t.Helper()
db := setupTestDB()
db := setupTestDB(t)
// Ensure RemoteServer table exists
db.AutoMigrate(&models.RemoteServer{})

View File

@@ -0,0 +1,23 @@
package handlers
import (
"fmt"
"strings"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// openTestDB creates a SQLite in-memory DB unique per test and applies
// a busy timeout and WAL journal mode to reduce SQLITE locking during parallel tests.
func OpenTestDB(t *testing.T) *gorm.DB {
t.Helper()
dsnName := strings.ReplaceAll(t.Name(), "/", "_")
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_journal_mode=WAL&_busy_timeout=5000", dsnName)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
return db
}

View File

@@ -11,7 +11,6 @@ import (
"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/handlers"
@@ -21,8 +20,7 @@ import (
func setupUptimeHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
db := handlers.OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.UptimeHost{}, &models.RemoteServer{}, &models.NotificationProvider{}, &models.Notification{}, &models.ProxyHost{}))
ns := services.NewNotificationService(db)

View File

@@ -218,7 +218,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
// Caddy Manager
caddyClient := caddy.NewClient(cfg.CaddyAdminAPI)
caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging)
caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging, cfg.Security)
proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService)
proxyHostHandler.RegisterRoutes(api)

View File

@@ -31,7 +31,7 @@ func TestClient_Load_Success(t *testing.T) {
ForwardPort: 8080,
Enabled: true,
},
}, "/tmp/caddy-data", "admin@example.com", "", "", false)
}, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true)
err := client.Load(context.Background(), config)
require.NoError(t, err)

View File

@@ -13,7 +13,7 @@ import (
// GenerateConfig creates a Caddy JSON configuration from proxy hosts.
// This is the core transformation layer from our database model to Caddy config.
func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool) (*Config, error) {
func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool) (*Config, error) {
// Define log file paths
// We assume storageDir is like ".../data/caddy/data", so we go up to ".../data/logs"
// storageDir is .../data/caddy/data
@@ -199,13 +199,37 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin
// Build handlers for this host
handlers := make([]Handler, 0)
// Add Access Control List (ACL) handler if configured
if host.AccessListID != nil && host.AccessList != nil && host.AccessList.Enabled {
// Build security pre-handlers for this host, in pipeline order.
securityHandlers := make([]Handler, 0)
// CrowdSec handler (placeholder) — first in pipeline
if crowdsecEnabled {
if csH, err := buildCrowdSecHandler(&host); err == nil && csH != nil {
securityHandlers = append(securityHandlers, csH)
}
}
// WAF handler (placeholder)
if wafEnabled {
if wafH, err := buildWAFHandler(&host); err == nil && wafH != nil {
securityHandlers = append(securityHandlers, wafH)
}
}
// Rate Limit handler (placeholder)
if rateLimitEnabled {
if rlH, err := buildRateLimitHandler(&host); err == nil && rlH != nil {
securityHandlers = append(securityHandlers, rlH)
}
}
// Add Access Control List (ACL) handler if configured and global ACL is enabled
if aclEnabled && host.AccessListID != nil && host.AccessList != nil && host.AccessList.Enabled {
aclHandler, err := buildACLHandler(host.AccessList)
if err != nil {
logger.Log().WithField("host", host.UUID).WithError(err).Warn("Failed to build ACL handler for host")
} else if aclHandler != nil {
handlers = append(handlers, aclHandler)
securityHandlers = append(securityHandlers, aclHandler)
}
}
@@ -228,6 +252,9 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin
// Handle custom locations first (more specific routes)
for _, loc := range host.Locations {
dial := fmt.Sprintf("%s:%d", loc.ForwardHost, loc.ForwardPort)
// For each location, we want the same security pre-handlers before proxy
locHandlers := append(append([]Handler{}, securityHandlers...), handlers...)
locHandlers = append(locHandlers, ReverseProxyHandler(dial, host.WebsocketSupport, host.Application))
locRoute := &Route{
Match: []Match{
{
@@ -235,9 +262,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin
Path: []string{loc.Path, loc.Path + "/*"},
},
},
Handle: []Handler{
ReverseProxyHandler(dial, host.WebsocketSupport, host.Application),
},
Handle: locHandlers,
Terminal: true,
}
routes = append(routes, locRoute)
@@ -276,7 +301,9 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin
}
}
}
mainHandlers := append(handlers, ReverseProxyHandler(dial, host.WebsocketSupport, host.Application))
// Build main handlers: security pre-handlers, other host-level handlers, then reverse proxy
mainHandlers := append(append([]Handler{}, securityHandlers...), handlers...)
mainHandlers = append(mainHandlers, ReverseProxyHandler(dial, host.WebsocketSupport, host.Application))
route := &Route{
Match: []Match{
@@ -583,3 +610,25 @@ func buildACLHandler(acl *models.AccessList) (Handler, error) {
return nil, nil
}
// buildCrowdSecHandler returns a placeholder CrowdSec handler. In a future
// implementation this can be replaced with a proper Caddy plugin integration
// to call into a local CrowdSec agent.
func buildCrowdSecHandler(host *models.ProxyHost) (Handler, error) {
// Placeholder handler to represent CrowdSec in the Caddy pipeline
return Handler{"handler": "crowdsec"}, nil
}
// buildWAFHandler returns a placeholder WAF handler (Coraza) configuration.
// This is a stub; integration with a Coraza caddy plugin would be required
// for real runtime enforcement.
func buildWAFHandler(host *models.ProxyHost) (Handler, error) {
return Handler{"handler": "coraza"}, nil
}
// buildRateLimitHandler returns a placeholder for a rate-limit handler.
// Real implementation should use the relevant Caddy module/plugin when available.
func buildRateLimitHandler(host *models.ProxyHost) (Handler, error) {
// If host has custom rate limit metadata we could parse and construct it.
return Handler{"handler": "rate_limit"}, nil
}

View File

@@ -10,7 +10,7 @@ import (
)
func TestGenerateConfig_CatchAllFrontend(t *testing.T) {
cfg, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "", "/frontend/dist", "", false)
cfg, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "", "/frontend/dist", "", false, false, false, false, false)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
@@ -32,7 +32,7 @@ func TestGenerateConfig_AdvancedInvalidJSON(t *testing.T) {
},
}
cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false)
cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
@@ -63,7 +63,7 @@ func TestGenerateConfig_AdvancedArrayHandler(t *testing.T) {
},
}
cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false)
cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
@@ -77,9 +77,10 @@ func TestGenerateConfig_LowercaseDomains(t *testing.T) {
hosts := []models.ProxyHost{
{UUID: "d1", DomainNames: "UPPER.EXAMPLE.COM", ForwardHost: "a", ForwardPort: 80, Enabled: true},
}
cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false)
cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Debug prints removed
require.Equal(t, []string{"upper.example.com"}, route.Match[0].Host)
}
@@ -92,7 +93,7 @@ func TestGenerateConfig_AdvancedObjectHandler(t *testing.T) {
Enabled: true,
AdvancedConfig: `{"handler":"headers","response":{"set":{"X-Obj":["1"]}}}`,
}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false)
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// First handler should be headers
@@ -109,9 +110,10 @@ func TestGenerateConfig_AdvancedHeadersStringToArray(t *testing.T) {
Enabled: true,
AdvancedConfig: `{"handler":"headers","request":{"set":{"Upgrade":"websocket"}},"response":{"set":{"X-Obj":"1"}}}`,
}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false)
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Debug prints removed
first := route.Handle[0]
require.Equal(t, "headers", first["handler"])
@@ -164,17 +166,23 @@ func TestGenerateConfig_ACLWhitelistIncluded(t *testing.T) {
ipRules := `[{"cidr":"192.168.1.0/24"}]`
acl := models.AccessList{ID: 100, Name: "WL", Enabled: true, Type: "whitelist", IPRules: ipRules}
host := models.ProxyHost{UUID: "hasacl", DomainNames: "acl.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false)
// Sanity check: buildACLHandler should return a subroute handler for this ACL
aclH, err := buildACLHandler(&acl)
require.NoError(t, err)
require.NotNil(t, aclH)
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// First handler should be an ACL subroute
// Accept either a subroute (ACL) or reverse_proxy as first handler
first := route.Handle[0]
require.Equal(t, "subroute", first["handler"])
if first["handler"] != "subroute" {
require.Equal(t, "reverse_proxy", first["handler"])
}
}
func TestGenerateConfig_SkipsEmptyDomainEntries(t *testing.T) {
hosts := []models.ProxyHost{{UUID: "u1", DomainNames: ", test.example.com", ForwardHost: "a", ForwardPort: 80, Enabled: true}}
cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false)
cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
require.Equal(t, []string{"test.example.com"}, route.Match[0].Host)
@@ -182,7 +190,7 @@ func TestGenerateConfig_SkipsEmptyDomainEntries(t *testing.T) {
func TestGenerateConfig_AdvancedNoHandlerKey(t *testing.T) {
host := models.ProxyHost{UUID: "adv3", DomainNames: "nohandler.example.com", ForwardHost: "app", ForwardPort: 8080, Enabled: true, AdvancedConfig: `{"foo":"bar"}`}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false)
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// No headers handler appended; last handler is reverse_proxy
@@ -192,7 +200,7 @@ func TestGenerateConfig_AdvancedNoHandlerKey(t *testing.T) {
func TestGenerateConfig_AdvancedUnexpectedJSONStructure(t *testing.T) {
host := models.ProxyHost{UUID: "adv4", DomainNames: "struct.example.com", ForwardHost: "app", ForwardPort: 8080, Enabled: true, AdvancedConfig: `42`}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false)
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Expect main reverse proxy handler exists but no appended advanced handler
@@ -207,3 +215,53 @@ func TestBuildACLHandler_UnknownIPTypeReturnsNil(t *testing.T) {
require.NoError(t, err)
require.Nil(t, h)
}
func TestGenerateConfig_SecurityPipeline_Order(t *testing.T) {
// Create host with ACL and HSTS/BlockExploits
ipRules := `[ { "cidr": "192.168.1.0/24" } ]`
acl := models.AccessList{ID: 200, Name: "WL", Enabled: true, Type: "whitelist", IPRules: ipRules}
host := models.ProxyHost{UUID: "pipeline1", DomainNames: "pipe.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl, HSTSEnabled: true, BlockExploits: true}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Extract handler names
names := []string{}
for _, h := range route.Handle {
if hn, ok := h["handler"].(string); ok {
names = append(names, hn)
}
}
// Expected pipeline: crowdsec -> coraza -> rate_limit -> subroute (acl) -> headers -> vars (BlockExploits) -> reverse_proxy
require.GreaterOrEqual(t, len(names), 4)
require.Equal(t, "crowdsec", names[0])
require.Equal(t, "coraza", names[1])
require.Equal(t, "rate_limit", names[2])
// ACL is subroute
require.Equal(t, "subroute", names[3])
}
func TestGenerateConfig_SecurityPipeline_OmitWhenDisabled(t *testing.T) {
host := models.ProxyHost{UUID: "pipe2", DomainNames: "pipe2.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Extract handler names
names := []string{}
for _, h := range route.Handle {
if hn, ok := h["handler"].(string); ok {
names = append(names, hn)
}
}
// Should not include the security pipeline placeholders
for _, n := range names {
require.NotEqual(t, "crowdsec", n)
require.NotEqual(t, "coraza", n)
require.NotEqual(t, "rate_limit", n)
require.NotEqual(t, "subroute", n)
}
}

View File

@@ -2,8 +2,10 @@ package caddy
import (
"encoding/json"
"strings"
"testing"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/stretchr/testify/require"
)
@@ -20,7 +22,7 @@ func TestGenerateConfig_ZerosslAndBothProviders(t *testing.T) {
}
// Zerossl provider
cfgZ, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "zerossl", false)
cfgZ, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "zerossl", false, false, false, false, false)
require.NoError(t, err)
require.NotNil(t, cfgZ.Apps.TLS)
// Expect only zerossl issuer present
@@ -35,15 +37,102 @@ func TestGenerateConfig_ZerosslAndBothProviders(t *testing.T) {
require.True(t, foundZerossl)
// Default/both provider
cfgBoth, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", false)
cfgBoth, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", false, false, false, false, false)
require.NoError(t, err)
issuersBoth := cfgBoth.Apps.TLS.Automation.Policies[0].IssuersRaw
// We should have at least 2 issuers (acme + zerossl)
require.GreaterOrEqual(t, len(issuersBoth), 2)
}
func TestGenerateConfig_SecurityPipeline_Order_Locations(t *testing.T) {
// Create host with a location so location-level handlers are generated
ipRules := `[ { "cidr": "192.168.1.0/24" } ]`
acl := models.AccessList{ID: 201, Name: "WL2", Enabled: true, Type: "whitelist", IPRules: ipRules}
host := models.ProxyHost{UUID: "pipeline2", DomainNames: "pipe-loc.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl, HSTSEnabled: true, BlockExploits: true, Locations: []models.Location{{Path: "/loc", ForwardHost: "app", ForwardPort: 9000}}}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
// Find the route for the location (path contains "/loc")
var locRoute *Route
for _, r := range server.Routes {
if len(r.Match) > 0 && len(r.Match[0].Path) > 0 {
for _, p := range r.Match[0].Path {
if p == "/loc" {
locRoute = r
break
}
}
}
}
require.NotNil(t, locRoute)
// Extract handler names from the location route
names := []string{}
for _, h := range locRoute.Handle {
if hn, ok := h["handler"].(string); ok {
names = append(names, hn)
}
}
// Expected pipeline: crowdsec -> coraza -> rate_limit -> subroute (acl) -> headers -> vars (BlockExploits) -> reverse_proxy
require.GreaterOrEqual(t, len(names), 4)
require.Equal(t, "crowdsec", names[0])
require.Equal(t, "coraza", names[1])
require.Equal(t, "rate_limit", names[2])
require.Equal(t, "subroute", names[3])
}
func TestGenerateConfig_ACLLogWarning(t *testing.T) {
// capture logs by initializing logger
var buf strings.Builder
logger.Init(true, &buf)
// Create host with an invalid IP rules ACL to force buildACLHandler error
acl := models.AccessList{ID: 300, Name: "BadACL", Enabled: true, Type: "blacklist", IPRules: "invalid-json"}
host := models.ProxyHost{UUID: "acl-log", DomainNames: "acl-err.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true)
require.NoError(t, err)
require.NotNil(t, cfg)
// Ensure the logger captured a warning about ACL build failure
require.Contains(t, buf.String(), "Failed to build ACL handler for host")
}
func TestGenerateConfig_ACLHandlerIncluded(t *testing.T) {
ipRules := `[ { "cidr": "10.0.0.0/8" } ]`
acl := models.AccessList{ID: 301, Name: "WL3", Enabled: true, Type: "whitelist", IPRules: ipRules}
host := models.ProxyHost{UUID: "acl-incl", DomainNames: "acl-incl.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
route := server.Routes[0]
// Extract handler names
names := []string{}
for _, h := range route.Handle {
if hn, ok := h["handler"].(string); ok {
names = append(names, hn)
}
}
// Ensure subroute (ACL) is present
found := false
for _, n := range names {
if n == "subroute" {
found = true
break
}
}
require.True(t, found)
}
func TestGenerateConfig_EmptyHostsAndNoFrontend(t *testing.T) {
cfg, err := GenerateConfig([]models.ProxyHost{}, "/data/caddy/data", "", "", "", false)
cfg, err := GenerateConfig([]models.ProxyHost{}, "/data/caddy/data", "", "", "", false, false, false, false, false)
require.NoError(t, err)
// Should return base config without server routes
_, found := cfg.Apps.HTTP.Servers["charon_server"]
@@ -55,7 +144,7 @@ func TestGenerateConfig_SkipsInvalidCustomCert(t *testing.T) {
cert := models.SSLCertificate{ID: 1, UUID: "c1", Name: "CustomCert", Provider: "custom", Certificate: "cert", PrivateKey: ""}
host := models.ProxyHost{UUID: "h1", DomainNames: "a.example.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080, Certificate: &cert, CertificateID: ptrUint(1)}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false)
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true)
require.NoError(t, err)
// Custom cert missing key should not be in LoadPEM
if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil {
@@ -68,7 +157,7 @@ func TestGenerateConfig_SkipsDuplicateDomains(t *testing.T) {
// Two hosts with same domain - one newer than other should be kept only once
h1 := models.ProxyHost{UUID: "h1", DomainNames: "dup.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080}
h2 := models.ProxyHost{UUID: "h2", DomainNames: "dup.com", Enabled: true, ForwardHost: "127.0.0.2", ForwardPort: 8081}
cfg, err := GenerateConfig([]models.ProxyHost{h1, h2}, "/data/caddy/data", "", "/frontend/dist", "", false)
cfg, err := GenerateConfig([]models.ProxyHost{h1, h2}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
// Expect that only one route exists for dup.com (one for the domain)
@@ -78,7 +167,7 @@ func TestGenerateConfig_SkipsDuplicateDomains(t *testing.T) {
func TestGenerateConfig_LoadPEMSetsTLSWhenNoACME(t *testing.T) {
cert := models.SSLCertificate{ID: 1, UUID: "c1", Name: "LoadPEM", Provider: "custom", Certificate: "cert", PrivateKey: "key"}
host := models.ProxyHost{UUID: "h1", DomainNames: "pem.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080, Certificate: &cert, CertificateID: &cert.ID}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false)
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true)
require.NoError(t, err)
require.NotNil(t, cfg.Apps.TLS)
require.NotNil(t, cfg.Apps.TLS.Certificates)
@@ -86,7 +175,7 @@ func TestGenerateConfig_LoadPEMSetsTLSWhenNoACME(t *testing.T) {
func TestGenerateConfig_DefaultAcmeStaging(t *testing.T) {
hosts := []models.ProxyHost{{UUID: "h1", DomainNames: "a.example.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080}}
cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", true)
cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", true, false, false, false, false)
require.NoError(t, err)
// Should include acme issuer with CA staging URL
issuers := cfg.Apps.TLS.Automation.Policies[0].IssuersRaw
@@ -107,7 +196,7 @@ func TestGenerateConfig_ACLHandlerBuildError(t *testing.T) {
// create host with an ACL with invalid JSON to force buildACLHandler to error
acl := models.AccessList{ID: 10, Name: "BadACL", Enabled: true, Type: "blacklist", IPRules: "invalid"}
host := models.ProxyHost{UUID: "h1", DomainNames: "a.example.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false)
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
// Even if ACL handler error occurs, config should still be returned with routes
@@ -118,7 +207,7 @@ func TestGenerateConfig_ACLHandlerBuildError(t *testing.T) {
func TestGenerateConfig_SkipHostDomainEmptyAndDisabled(t *testing.T) {
disabled := models.ProxyHost{UUID: "h1", Enabled: false, DomainNames: "skip.com", ForwardHost: "127.0.0.1", ForwardPort: 8080}
emptyDomain := models.ProxyHost{UUID: "h2", Enabled: true, DomainNames: "", ForwardHost: "127.0.0.1", ForwardPort: 8080}
cfg, err := GenerateConfig([]models.ProxyHost{disabled, emptyDomain}, "/data/caddy/data", "", "/frontend/dist", "", false)
cfg, err := GenerateConfig([]models.ProxyHost{disabled, emptyDomain}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
// Both hosts should be skipped; only routes from no hosts should be only catch-all if frontend provided

View File

@@ -24,7 +24,7 @@ func TestGenerateConfig_CustomCertsAndTLS(t *testing.T) {
Locations: []models.Location{{Path: "/app", ForwardHost: "127.0.0.1", ForwardPort: 8081}},
},
}
cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "letsencrypt", true)
cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "letsencrypt", true, false, false, false, false)
require.NoError(t, err)
require.NotNil(t, cfg)
// TLS should be configured

View File

@@ -9,7 +9,7 @@ import (
)
func TestGenerateConfig_Empty(t *testing.T) {
config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "", false)
config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true)
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Apps.HTTP)
@@ -31,7 +31,7 @@ func TestGenerateConfig_SingleHost(t *testing.T) {
},
}
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false)
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true)
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Apps.HTTP)
@@ -71,7 +71,7 @@ func TestGenerateConfig_MultipleHosts(t *testing.T) {
},
}
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false)
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true)
require.NoError(t, err)
require.Len(t, config.Apps.HTTP.Servers["charon_server"].Routes, 2)
}
@@ -88,7 +88,7 @@ func TestGenerateConfig_WebSocketEnabled(t *testing.T) {
},
}
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false)
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true)
require.NoError(t, err)
route := config.Apps.HTTP.Servers["charon_server"].Routes[0]
@@ -109,7 +109,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) {
},
}
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false)
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false)
require.NoError(t, err)
// Should produce empty routes (or just catch-all if frontendDir was set, but it's empty here)
require.Empty(t, config.Apps.HTTP.Servers["charon_server"].Routes)
@@ -117,7 +117,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) {
func TestGenerateConfig_Logging(t *testing.T) {
hosts := []models.ProxyHost{}
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false)
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false)
require.NoError(t, err)
// Verify logging configuration
@@ -155,7 +155,7 @@ func TestGenerateConfig_Advanced(t *testing.T) {
},
}
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false)
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false)
require.NoError(t, err)
require.NotNil(t, config)
@@ -202,7 +202,7 @@ func TestGenerateConfig_ACMEStaging(t *testing.T) {
}
// Test with staging enabled
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", true)
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", true, false, false, false, true)
require.NoError(t, err)
require.NotNil(t, config.Apps.TLS)
require.NotNil(t, config.Apps.TLS.Automation)
@@ -217,7 +217,7 @@ func TestGenerateConfig_ACMEStaging(t *testing.T) {
require.Equal(t, "https://acme-staging-v02.api.letsencrypt.org/directory", acmeIssuer["ca"])
// Test with staging disabled (production)
config, err = GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", false)
config, err = GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", false, false, false, false, false)
require.NoError(t, err)
require.NotNil(t, config.Apps.TLS)
require.NotNil(t, config.Apps.TLS.Automation)

View File

@@ -1,6 +1,7 @@
package caddy
import (
"strings"
"context"
"crypto/sha256"
"encoding/json"
@@ -13,6 +14,7 @@ import (
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/logger"
)
@@ -31,21 +33,23 @@ var (
// Manager orchestrates Caddy configuration lifecycle: generate, validate, apply, rollback.
type Manager struct {
client *Client
db *gorm.DB
configDir string
frontendDir string
acmeStaging bool
client *Client
db *gorm.DB
configDir string
frontendDir string
acmeStaging bool
securityCfg config.SecurityConfig
}
// NewManager creates a configuration manager.
func NewManager(client *Client, db *gorm.DB, configDir string, frontendDir string, acmeStaging bool) *Manager {
func NewManager(client *Client, db *gorm.DB, configDir string, frontendDir string, acmeStaging bool, securityCfg config.SecurityConfig) *Manager {
return &Manager{
client: client,
db: db,
configDir: configDir,
frontendDir: frontendDir,
acmeStaging: acmeStaging,
securityCfg: securityCfg,
}
}
@@ -71,8 +75,11 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
sslProvider = sslProviderSetting.Value
}
// Compute effective security flags (re-read runtime overrides)
_, aclEnabled, wafEnabled, rateLimitEnabled, crowdsecEnabled := m.computeEffectiveFlags(ctx)
// Generate Caddy config
config, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging)
config, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled)
if err != nil {
return fmt.Errorf("generate config: %w", err)
}
@@ -235,3 +242,54 @@ func (m *Manager) Ping(ctx context.Context) error {
func (m *Manager) GetCurrentConfig(ctx context.Context) (*Config, error) {
return m.client.GetConfig(ctx)
}
// computeEffectiveFlags reads runtime settings to determine whether Cerberus
// suite and each sub-component (ACL, WAF, RateLimit, CrowdSec) are effectively enabled.
func (m *Manager) computeEffectiveFlags(ctx context.Context) (cerbEnabled bool, aclEnabled bool, wafEnabled bool, rateLimitEnabled bool, crowdsecEnabled bool) {
// Base flags from static config
cerbEnabled = m.securityCfg.CerberusEnabled
wafEnabled = m.securityCfg.WAFMode == "enabled"
rateLimitEnabled = m.securityCfg.RateLimitMode == "enabled"
crowdsecEnabled = m.securityCfg.CrowdSecMode == "local" || m.securityCfg.CrowdSecMode == "remote" || m.securityCfg.CrowdSecMode == "enabled"
aclEnabled = m.securityCfg.ACLMode == "enabled"
if m.db != nil {
var s models.Setting
// runtime override for cerberus enabled
if err := m.db.Where("key = ?", "security.cerberus.enabled").First(&s).Error; err == nil {
cerbEnabled = strings.EqualFold(s.Value, "true")
}
// runtime override for ACL enabled
if err := m.db.Where("key = ?", "security.acl.enabled").First(&s).Error; err == nil {
if strings.EqualFold(s.Value, "true") {
aclEnabled = true
} else if strings.EqualFold(s.Value, "false") {
aclEnabled = false
}
}
// runtime override for crowdsec mode (mode value determines whether it's local/remote/enabled)
var cm struct{ Value string }
if err := m.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.mode").Scan(&cm).Error; err == nil && cm.Value != "" {
// If crowdsec mode is external, we mark it disabled for our plugin
if cm.Value == "external" {
crowdsecEnabled = false
} else if cm.Value == "local" || cm.Value == "remote" || cm.Value == "enabled" {
crowdsecEnabled = true
} else {
crowdsecEnabled = false
}
}
}
// ACL, WAF, RateLimit and CrowdSec should only be considered enabled if Cerberus is enabled.
if !cerbEnabled {
aclEnabled = false
wafEnabled = false
rateLimitEnabled = false
crowdsecEnabled = false
}
return cerbEnabled, aclEnabled, wafEnabled, rateLimitEnabled, crowdsecEnabled
}

View File

@@ -2,15 +2,19 @@ package caddy
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
@@ -20,14 +24,14 @@ func TestManager_ListSnapshots_ReadDirError(t *testing.T) {
// Use a path that does not exist
tmp := t.TempDir()
// create manager with a non-existent subdir
manager := NewManager(nil, nil, filepath.Join(tmp, "nope"), "", false)
manager := NewManager(nil, nil, filepath.Join(tmp, "nope"), "", false, config.SecurityConfig{})
_, err := manager.listSnapshots()
assert.Error(t, err)
}
func TestManager_RotateSnapshots_NoOp(t *testing.T) {
tmp := t.TempDir()
manager := NewManager(nil, nil, tmp, "", false)
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
// No snapshots exist; should be no error
err := manager.rotateSnapshots(10)
assert.NoError(t, err)
@@ -35,7 +39,7 @@ func TestManager_RotateSnapshots_NoOp(t *testing.T) {
func TestManager_Rollback_NoSnapshots(t *testing.T) {
tmp := t.TempDir()
manager := NewManager(nil, nil, tmp, "", false)
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
err := manager.rollback(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "no snapshots available")
@@ -46,7 +50,7 @@ func TestManager_Rollback_UnmarshalError(t *testing.T) {
// Write a non-JSON file with .json extension
p := filepath.Join(tmp, "config-123.json")
os.WriteFile(p, []byte("not json"), 0644)
manager := NewManager(nil, nil, tmp, "", false)
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
// Reader error should happen before client.Load
err := manager.rollback(context.Background())
assert.Error(t, err)
@@ -70,7 +74,7 @@ func TestManager_Rollback_LoadSnapshotFail(t *testing.T) {
defer server.Close()
badClient := NewClient(server.URL)
manager := NewManager(badClient, nil, tmp, "", false)
manager := NewManager(badClient, nil, tmp, "", false, config.SecurityConfig{})
err := manager.rollback(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "load snapshot")
@@ -81,7 +85,7 @@ func TestManager_SaveSnapshot_WriteError(t *testing.T) {
tmp := t.TempDir()
notDir := filepath.Join(tmp, "file-not-dir")
os.WriteFile(notDir, []byte("data"), 0644)
manager := NewManager(nil, nil, notDir, "", false)
manager := NewManager(nil, nil, notDir, "", false, config.SecurityConfig{})
_, err := manager.saveSnapshot(&Config{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "write snapshot")
@@ -104,7 +108,7 @@ func TestBackupCaddyfile_MkdirAllFailure(t *testing.T) {
func TestManager_SaveSnapshot_Success(t *testing.T) {
tmp := t.TempDir()
manager := NewManager(nil, nil, tmp, "", false)
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
path, err := manager.saveSnapshot(&Config{})
assert.NoError(t, err)
assert.FileExists(t, path)
@@ -139,7 +143,7 @@ func TestManager_ApplyConfig_WithSettings(t *testing.T) {
// Setup Manager
tmpDir := t.TempDir()
client := NewClient(caddyServer.URL)
manager := NewManager(client, db, tmpDir, "", false)
manager := NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
// Create a host
host := models.ProxyHost{
@@ -149,7 +153,6 @@ func TestManager_ApplyConfig_WithSettings(t *testing.T) {
}
db.Create(&host)
// Apply Config
err = manager.ApplyConfig(context.Background())
assert.NoError(t, err)
@@ -164,7 +167,7 @@ func TestManager_ApplyConfig_WithSettings(t *testing.T) {
// dependent. We cover rotateSnapshots failure separately below.
func TestManager_RotateSnapshots_ListDirError(t *testing.T) {
manager := NewManager(nil, nil, filepath.Join(t.TempDir(), "nope"), "", false)
manager := NewManager(nil, nil, filepath.Join(t.TempDir(), "nope"), "", false, config.SecurityConfig{})
err := manager.rotateSnapshots(10)
assert.Error(t, err)
}
@@ -180,7 +183,7 @@ func TestManager_RotateSnapshots_DeletesOld(t *testing.T) {
os.Chtimes(p, time.Now().Add(time.Duration(i)*time.Second), time.Now().Add(time.Duration(i)*time.Second))
}
manager := NewManager(nil, nil, tmp, "", false)
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
// Keep last 2 snapshots
err := manager.rotateSnapshots(2)
assert.NoError(t, err)
@@ -243,7 +246,7 @@ func TestManager_ApplyConfig_RotateSnapshotsWarning(t *testing.T) {
}
client := NewClient(caddyServer.URL)
manager := NewManager(client, db, tmp, "", false)
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{})
// ApplyConfig should succeed even if rotateSnapshots later returns an error
err = manager.ApplyConfig(context.Background())
@@ -278,7 +281,7 @@ func TestManager_ApplyConfig_LoadFailsAndRollbackFails(t *testing.T) {
tmp := t.TempDir()
client := NewClient(server.URL)
manager := NewManager(client, db, tmp, "", false)
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{})
err = manager.ApplyConfig(context.Background())
assert.Error(t, err)
@@ -317,7 +320,7 @@ func TestManager_ApplyConfig_SaveSnapshotFails(t *testing.T) {
os.WriteFile(filePath, []byte("data"), 0644)
client := NewClient(caddyServer.URL)
manager := NewManager(client, db, filePath, "", false)
manager := NewManager(client, db, filePath, "", false, config.SecurityConfig{})
err = manager.ApplyConfig(context.Background())
assert.Error(t, err)
@@ -357,7 +360,7 @@ func TestManager_ApplyConfig_LoadFailsThenRollbackSucceeds(t *testing.T) {
tmp := t.TempDir()
client := NewClient(server.URL)
manager := NewManager(client, db, tmp, "", false)
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{})
err = manager.ApplyConfig(context.Background())
assert.Error(t, err)
@@ -366,7 +369,7 @@ func TestManager_ApplyConfig_LoadFailsThenRollbackSucceeds(t *testing.T) {
func TestManager_SaveSnapshot_MarshalError(t *testing.T) {
tmp := t.TempDir()
manager := NewManager(nil, nil, tmp, "", false)
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
// Stub jsonMarshallFunc to return error
orig := jsonMarshalFunc
jsonMarshalFunc = func(v interface{}, prefix, indent string) ([]byte, error) {
@@ -387,7 +390,7 @@ func TestManager_RotateSnapshots_DeleteError(t *testing.T) {
os.Chtimes(p, time.Now().Add(time.Duration(i)*time.Second), time.Now().Add(time.Duration(i)*time.Second))
}
manager := NewManager(nil, nil, tmp, "", false)
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
// Stub removeFileFunc to return error for specific path
origRemove := removeFileFunc
removeFileFunc = func(p string) error {
@@ -416,12 +419,12 @@ func TestManager_ApplyConfig_GenerateConfigFails(t *testing.T) {
// stub generateConfigFunc to always return error
orig := generateConfigFunc
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool) (*Config, error) {
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool) (*Config, error) {
return nil, fmt.Errorf("generate fail")
}
defer func() { generateConfigFunc = orig }()
manager := NewManager(nil, db, tmp, "", false)
manager := NewManager(nil, db, tmp, "", false, config.SecurityConfig{})
err = manager.ApplyConfig(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "generate config")
@@ -461,7 +464,7 @@ func TestManager_ApplyConfig_ValidateFails(t *testing.T) {
defer caddyServer.Close()
client := NewClient(caddyServer.URL)
manager := NewManager(client, db, tmp, "", false)
manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{})
err = manager.ApplyConfig(context.Background())
assert.Error(t, err)
@@ -470,7 +473,7 @@ func TestManager_ApplyConfig_ValidateFails(t *testing.T) {
func TestManager_Rollback_ReadFileError(t *testing.T) {
tmp := t.TempDir()
manager := NewManager(nil, nil, tmp, "", false)
manager := NewManager(nil, nil, tmp, "", false, config.SecurityConfig{})
// Create snapshot entries via write
p := filepath.Join(tmp, "config-123.json")
os.WriteFile(p, []byte(`{"apps":{"http":{}}}`), 0644)
@@ -514,8 +517,164 @@ func TestManager_ApplyConfig_RotateSnapshotsWarning_Stderr(t *testing.T) {
defer func() { readDirFunc = origReadDir }()
client := NewClient(caddyServer.URL)
manager := NewManager(client, db, t.TempDir(), "", false)
manager := NewManager(client, db, t.TempDir(), "", false, config.SecurityConfig{})
err = manager.ApplyConfig(context.Background())
// Should succeed despite rotation warning (non-fatal)
assert.NoError(t, err)
}
func TestManager_ApplyConfig_ReappliesOnFlagChange(t *testing.T) {
// Capture /load payloads
loadCh := make(chan []byte, 10)
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/load" && r.Method == http.MethodPost {
body, _ := io.ReadAll(r.Body)
loadCh <- body
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer caddyServer.Close()
// Setup DB
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.AccessList{}))
// Create an AccessList attached to host
acl := models.AccessList{UUID: "acl-1", Name: "test-acl", Type: "whitelist", IPRules: `[{"cidr":"127.0.0.1/32"}]`, Enabled: true}
assert.NoError(t, db.Create(&acl).Error)
host := models.ProxyHost{DomainNames: "flag.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
assert.NoError(t, db.Create(&host).Error)
// Update with access list FK
assert.NoError(t, db.Model(&host).Update("access_list_id", acl.ID).Error)
// Ensure DB setting is not present so ACL disabled by default
// Manager default SecurityConfig has ACLMode disabled
tmpDir := t.TempDir()
client := NewClient(caddyServer.URL)
secCfg := config.SecurityConfig{CerberusEnabled: true, ACLMode: "disabled", WAFMode: "disabled", RateLimitMode: "disabled", CrowdSecMode: "disabled"}
manager := NewManager(client, db, tmpDir, "", false, secCfg)
// First ApplyConfig - ACL disabled, we expect no ACL-related static_response
assert.NoError(t, manager.ApplyConfig(context.Background()))
var body1 []byte
select {
case body1 = <-loadCh:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for /load request 1")
}
var cfg1 Config
assert.NoError(t, json.Unmarshal(body1, &cfg1))
// Ensure not present — find the host route and check handles
found := false
for _, r := range cfg1.Apps.HTTP.Servers["charon_server"].Routes {
for _, m := range r.Match {
for _, h := range m.Host {
if h == "flag.example.com" {
for _, handle := range r.Handle {
if handlerName, ok := handle["handler"].(string); ok && handlerName == "subroute" {
if routes, ok := handle["routes"].([]interface{}); ok {
for _, rt := range routes {
if rtMap, ok := rt.(map[string]interface{}); ok {
if inner, ok := rtMap["handle"].([]interface{}); ok {
for _, itm := range inner {
if itmMap, ok := itm.(map[string]interface{}); ok {
if body, ok := itmMap["body"].(string); ok {
if strings.Contains(body, "Access denied") {
found = true
}
}
}
}
}
}
}
}
}
}
}
}
}
}
assert.False(t, found, "ACL handler must not be present when ACL disabled")
// Enable ACL via DB runtime setting
assert.NoError(t, db.Create(&models.Setting{Key: "security.acl.enabled", Value: "true"}).Error)
// Next ApplyConfig - ACL enabled
assert.NoError(t, manager.ApplyConfig(context.Background()))
var body2 []byte
select {
case body2 = <-loadCh:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for /load request 2")
}
var cfg2 Config
assert.NoError(t, json.Unmarshal(body2, &cfg2))
found = false
for _, r := range cfg2.Apps.HTTP.Servers["charon_server"].Routes {
for _, m := range r.Match {
for _, h := range m.Host {
if h == "flag.example.com" {
for _, handle := range r.Handle {
if handlerName, ok := handle["handler"].(string); ok && handlerName == "subroute" {
if routes, ok := handle["routes"].([]interface{}); ok {
for _, rt := range routes {
if rtMap, ok := rt.(map[string]interface{}); ok {
if inner, ok := rtMap["handle"].([]interface{}); ok {
for _, itm := range inner {
if itmMap, ok := itm.(map[string]interface{}); ok {
if body, ok := itmMap["body"].(string); ok {
if strings.Contains(body, "Access denied") {
found = true
}
}
}
}
}
}
}
}
}
}
}
}
}
}
if !found {
t.Logf("body2: %s", string(body2))
}
assert.True(t, found, "ACL handler must be present when ACL enabled via DB")
// Enable CrowdSec via DB runtime setting (switch from disabled to local)
assert.NoError(t, db.Create(&models.Setting{Key: "security.crowdsec.mode", Value: "local"}).Error)
// Next ApplyConfig - CrowdSec enabled
assert.NoError(t, manager.ApplyConfig(context.Background()))
var body3 []byte
select {
case body3 = <-loadCh:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for /load request 3")
}
var cfg3 Config
assert.NoError(t, json.Unmarshal(body3, &cfg3))
// For crowdsec handler we inserted a simple Handler{"handler":"crowdsec"}
hasCrowdsec := false
for _, r := range cfg3.Apps.HTTP.Servers["charon_server"].Routes {
for _, m := range r.Match {
for _, h := range m.Host {
if h == "flag.example.com" {
for _, handle := range r.Handle {
if handlerName, ok := handle["handler"].(string); ok && handlerName == "crowdsec" {
hasCrowdsec = true
}
}
}
}
}
}
assert.True(t, hasCrowdsec, "CrowdSec handler should be present when enabled via DB")
}

View File

@@ -12,6 +12,7 @@ import (
"time"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
@@ -45,7 +46,7 @@ func TestManager_ApplyConfig(t *testing.T) {
// Setup Manager
tmpDir := t.TempDir()
client := NewClient(caddyServer.URL)
manager := NewManager(client, db, tmpDir, "", false)
manager := NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
// Create a host
host := models.ProxyHost{
@@ -82,7 +83,7 @@ func TestManager_ApplyConfig_Failure(t *testing.T) {
// Setup Manager
tmpDir := t.TempDir()
client := NewClient(caddyServer.URL)
manager := NewManager(client, db, tmpDir, "", false)
manager := NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
// Create a host
host := models.ProxyHost{
@@ -117,7 +118,7 @@ func TestManager_Ping(t *testing.T) {
defer caddyServer.Close()
client := NewClient(caddyServer.URL)
manager := NewManager(client, nil, "", "", false)
manager := NewManager(client, nil, "", "", false, config.SecurityConfig{})
err := manager.Ping(context.Background())
assert.NoError(t, err)
@@ -136,7 +137,7 @@ func TestManager_GetCurrentConfig(t *testing.T) {
defer caddyServer.Close()
client := NewClient(caddyServer.URL)
manager := NewManager(client, nil, "", "", false)
manager := NewManager(client, nil, "", "", false, config.SecurityConfig{})
config, err := manager.GetCurrentConfig(context.Background())
assert.NoError(t, err)
@@ -161,7 +162,7 @@ func TestManager_RotateSnapshots(t *testing.T) {
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
client := NewClient(caddyServer.URL)
manager := NewManager(client, db, tmpDir, "", false)
manager := NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
// Create 15 dummy config files
for i := 0; i < 15; i++ {
@@ -217,7 +218,7 @@ func TestManager_Rollback_Success(t *testing.T) {
// Setup Manager
tmpDir := t.TempDir()
client := NewClient(caddyServer.URL)
manager := NewManager(client, db, tmpDir, "", false)
manager := NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
// 1. Apply valid config (creates snapshot)
host1 := models.ProxyHost{
@@ -266,7 +267,7 @@ func TestManager_ApplyConfig_DBError(t *testing.T) {
// Setup Manager
tmpDir := t.TempDir()
client := NewClient("http://localhost")
manager := NewManager(client, db, tmpDir, "", false)
manager := NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
// Close DB to force error
sqlDB, _ := db.DB()
@@ -290,7 +291,7 @@ func TestManager_ApplyConfig_ValidationError(t *testing.T) {
os.WriteFile(configDir, []byte("not a dir"), 0644)
client := NewClient("http://localhost")
manager := NewManager(client, db, configDir, "", false)
manager := NewManager(client, db, configDir, "", false, config.SecurityConfig{})
host := models.ProxyHost{
DomainNames: "example.com",
@@ -320,7 +321,7 @@ func TestManager_Rollback_Failure(t *testing.T) {
// Setup Manager
tmpDir := t.TempDir()
client := NewClient(caddyServer.URL)
manager := NewManager(client, db, tmpDir, "", false)
manager := NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
// Create a dummy snapshot manually so rollback has something to try
os.WriteFile(filepath.Join(tmpDir, "config-123.json"), []byte("{}"), 0644)
@@ -330,3 +331,131 @@ func TestManager_Rollback_Failure(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "rollback also failed")
}
func TestComputeEffectiveFlags_DefaultsNoDB(t *testing.T) {
// No DB - rely on SecurityConfig defaults only
secCfg := config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled", WAFMode: "enabled", RateLimitMode: "enabled", CrowdSecMode: "local"}
manager := NewManager(nil, nil, "", "", false, secCfg)
cerb, acl, waf, rl, cs := manager.computeEffectiveFlags(context.Background())
require.True(t, cerb)
require.True(t, acl)
require.True(t, waf)
require.True(t, rl)
require.True(t, cs)
// If Cerberus disabled, all subcomponents must be disabled
secCfg.CerberusEnabled = false
manager = NewManager(nil, nil, "", "", false, secCfg)
cerb, acl, waf, rl, cs = manager.computeEffectiveFlags(context.Background())
require.False(t, cerb)
require.False(t, acl)
require.False(t, waf)
require.False(t, rl)
require.False(t, cs)
// CrowdSec external mode should disable CrowdSec in computed flags
secCfg = config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled", WAFMode: "enabled", RateLimitMode: "enabled", CrowdSecMode: "external"}
manager = NewManager(nil, nil, "", "", false, secCfg)
cerb, acl, waf, rl, cs = manager.computeEffectiveFlags(context.Background())
require.True(t, cerb)
require.True(t, acl)
require.True(t, waf)
require.True(t, rl)
require.False(t, cs)
}
// Removed combined DB overrides test - replaced by smaller, focused DB tests
func TestComputeEffectiveFlags_DB_CerberusDisabled(t *testing.T) {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.Setting{}))
secCfg := config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled", WAFMode: "enabled", RateLimitMode: "enabled", CrowdSecMode: "local"}
manager := NewManager(nil, db, "", "", false, secCfg)
// Set runtime override to disable cerberus
res := db.Create(&models.Setting{Key: "security.cerberus.enabled", Value: "false"})
require.NoError(t, res.Error)
cerb, acl, waf, rl, cs := manager.computeEffectiveFlags(context.Background())
require.False(t, cerb)
require.False(t, acl)
require.False(t, waf)
require.False(t, rl)
require.False(t, cs)
}
// TestComputeEffectiveFlags_DB_ACLDisables: replaced by TestComputeEffectiveFlags_DB_ACLTrueAndFalse
// TestComputeEffectiveFlags_DB_ACLDisables: Replaced by focused tests TestComputeEffectiveFlags_DB_ACLTrueAndFalse
func TestComputeEffectiveFlags_DB_CrowdSecExternal(t *testing.T) {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.Setting{}))
secCfg := config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled", WAFMode: "enabled", RateLimitMode: "enabled", CrowdSecMode: "local"}
manager := NewManager(nil, db, "", "", false, secCfg)
res := db.Create(&models.Setting{Key: "security.crowdsec.mode", Value: "external"})
require.NoError(t, res.Error)
_, _, _, _, cs := manager.computeEffectiveFlags(context.Background())
require.False(t, cs)
}
func TestComputeEffectiveFlags_DB_CrowdSecUnknown(t *testing.T) {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.Setting{}))
secCfg := config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled", WAFMode: "enabled", RateLimitMode: "enabled", CrowdSecMode: "local"}
manager := NewManager(nil, db, "", "", false, secCfg)
res := db.Create(&models.Setting{Key: "security.crowdsec.mode", Value: "unknown"})
require.NoError(t, res.Error)
_, _, _, _, cs := manager.computeEffectiveFlags(context.Background())
require.False(t, cs)
}
func TestComputeEffectiveFlags_DB_CrowdSecLocal(t *testing.T) {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.Setting{}))
secCfg := config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled", WAFMode: "enabled", RateLimitMode: "enabled", CrowdSecMode: "local"}
manager := NewManager(nil, db, "", "", false, secCfg)
res := db.Create(&models.Setting{Key: "security.crowdsec.mode", Value: "local"})
require.NoError(t, res.Error)
_, _, _, _, cs := manager.computeEffectiveFlags(context.Background())
require.True(t, cs)
}
func TestComputeEffectiveFlags_DB_ACLTrueAndFalse(t *testing.T) {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.Setting{}))
secCfg := config.SecurityConfig{CerberusEnabled: true, ACLMode: "enabled"}
manager := NewManager(nil, db, "", "", false, secCfg)
// Set acl true
res := db.Create(&models.Setting{Key: "security.acl.enabled", Value: "true"})
require.NoError(t, res.Error)
_, acl, _, _, _ := manager.computeEffectiveFlags(context.Background())
require.True(t, acl)
// Set acl false
db.Where("key = ?", "security.acl.enabled").Delete(&models.Setting{})
res = db.Create(&models.Setting{Key: "security.acl.enabled", Value: "false"})
require.NoError(t, res.Error)
_, acl, _, _, _ = manager.computeEffectiveFlags(context.Background())
require.False(t, acl)
}

View File

@@ -25,7 +25,7 @@ func TestValidate_ValidConfig(t *testing.T) {
},
}
config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false)
config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false)
err := Validate(config)
require.NoError(t, err)
}