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
@@ -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)
}
}
@@ -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)
}
@@ -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
}
@@ -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)
}
+11 -15
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)
@@ -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)
}
}
@@ -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")
}
@@ -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)
@@ -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
}
@@ -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)
@@ -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{})
+23
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
}
@@ -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)
+1 -1
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)