diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index f25254d6..64df9f1a 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -8,11 +8,11 @@ import ( "path/filepath" "github.com/Wikid82/charon/backend/internal/api/handlers" - "github.com/Wikid82/charon/backend/internal/api/routes" "github.com/Wikid82/charon/backend/internal/api/middleware" - "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/api/routes" "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/database" + "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/server" "github.com/Wikid82/charon/backend/internal/version" diff --git a/backend/go.mod b/backend/go.mod index 7c06b630..e11cdb47 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,19 +8,22 @@ require ( github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 + github.com/prometheus/client_golang v1.19.1 github.com/robfig/cron/v3 v3.0.1 + github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.45.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 - github.com/sirupsen/logrus v1.9.3 ) require ( github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -60,6 +63,9 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0e1158f1..505133ba 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -2,12 +2,16 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -37,8 +41,6 @@ github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3G github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= -github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -60,7 +62,6 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= @@ -126,6 +127,14 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= @@ -140,6 +149,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -179,9 +189,9 @@ golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= diff --git a/backend/internal/api/handlers/backup_handler_sanitize_test.go b/backend/internal/api/handlers/backup_handler_sanitize_test.go index 9dfe83be..0e772525 100644 --- a/backend/internal/api/handlers/backup_handler_sanitize_test.go +++ b/backend/internal/api/handlers/backup_handler_sanitize_test.go @@ -1,65 +1,65 @@ package handlers import ( - "bytes" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - "strings" - "os" - "regexp" + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" - "github.com/Wikid82/charon/backend/internal/logger" - "github.com/Wikid82/charon/backend/internal/services" - "github.com/gin-gonic/gin" + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/services" + "github.com/gin-gonic/gin" ) func TestBackupHandlerSanitizesFilename(t *testing.T) { - gin.SetMode(gin.TestMode) - tmpDir := t.TempDir() - // prepare a fake "database" - dbPath := filepath.Join(tmpDir, "db.sqlite") - if err := os.WriteFile(dbPath, []byte("db"), 0o644); err != nil { - t.Fatalf("failed to create tmp db: %v", err) - } + gin.SetMode(gin.TestMode) + tmpDir := t.TempDir() + // prepare a fake "database" + dbPath := filepath.Join(tmpDir, "db.sqlite") + if err := os.WriteFile(dbPath, []byte("db"), 0o644); err != nil { + t.Fatalf("failed to create tmp db: %v", err) + } - svc := &services.BackupService{DataDir: tmpDir, BackupDir: tmpDir, DatabaseName: "db.sqlite", Cron: nil} - h := NewBackupHandler(svc) + svc := &services.BackupService{DataDir: tmpDir, BackupDir: tmpDir, DatabaseName: "db.sqlite", Cron: nil} + h := NewBackupHandler(svc) - // 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"})) + // 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{} - logger.Init(true, buf) + // initialize logger to buffer + buf := &bytes.Buffer{} + logger.Init(true, buf) - // Create a malicious filename with newline and path components - malicious := "../evil\nname" - c.Request = httptest.NewRequest(http.MethodGet, "/backups/"+strings.ReplaceAll(malicious, "\n", "%0A")+"/restore", nil) - // Call handler directly with the test context - h.Restore(c) + // Create a malicious filename with newline and path components + malicious := "../evil\nname" + 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() - // 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) - } + out := buf.String() + // 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(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) - } + 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) + } } diff --git a/backend/internal/api/handlers/coverage_quick_test.go b/backend/internal/api/handlers/coverage_quick_test.go index ee0df8ac..d8d5cc35 100644 --- a/backend/internal/api/handlers/coverage_quick_test.go +++ b/backend/internal/api/handlers/coverage_quick_test.go @@ -1,83 +1,99 @@ package handlers import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" - "github.com/Wikid82/charon/backend/internal/services" - "github.com/gin-gonic/gin" + "github.com/Wikid82/charon/backend/internal/services" + "github.com/gin-gonic/gin" ) // Use a real BackupService, but point it at tmpDir for isolation func TestBackupHandlerQuick(t *testing.T) { - gin.SetMode(gin.TestMode) - tmpDir := t.TempDir() - // prepare a fake "database" so CreateBackup can find it - dbPath := filepath.Join(tmpDir, "db.sqlite") - if err := os.WriteFile(dbPath, []byte("db"), 0o644); err != nil { - t.Fatalf("failed to create tmp db: %v", err) - } + gin.SetMode(gin.TestMode) + tmpDir := t.TempDir() + // prepare a fake "database" so CreateBackup can find it + dbPath := filepath.Join(tmpDir, "db.sqlite") + if err := os.WriteFile(dbPath, []byte("db"), 0o644); err != nil { + t.Fatalf("failed to create tmp db: %v", err) + } - svc := &services.BackupService{DataDir: tmpDir, BackupDir: tmpDir, DatabaseName: "db.sqlite", Cron: nil} - h := NewBackupHandler(svc) + svc := &services.BackupService{DataDir: tmpDir, BackupDir: tmpDir, DatabaseName: "db.sqlite", Cron: nil} + h := NewBackupHandler(svc) - r := gin.New() - // register routes used - r.GET("/backups", h.List) - r.POST("/backups", h.Create) - r.DELETE("/backups/:filename", h.Delete) - r.GET("/backups/:filename", h.Download) - r.POST("/backups/:filename/restore", h.Restore) + r := gin.New() + // register routes used + r.GET("/backups", h.List) + r.POST("/backups", h.Create) + r.DELETE("/backups/:filename", h.Delete) + r.GET("/backups/:filename", h.Download) + r.POST("/backups/:filename/restore", h.Restore) - // List - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/backups", nil) - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } + // List + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/backups", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } - // Create (backup) - w2 := httptest.NewRecorder() - req2 := httptest.NewRequest(http.MethodPost, "/backups", nil) - r.ServeHTTP(w2, req2) - if w2.Code != http.StatusCreated { t.Fatalf("create expected 201 got %d", w2.Code) } + // Create (backup) + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodPost, "/backups", nil) + r.ServeHTTP(w2, req2) + if w2.Code != http.StatusCreated { + t.Fatalf("create expected 201 got %d", w2.Code) + } - var createResp struct{ Filename string `json:"filename"` } - if err := json.Unmarshal(w2.Body.Bytes(), &createResp); err != nil { - t.Fatalf("invalid create json: %v", err) - } + var createResp struct { + Filename string `json:"filename"` + } + if err := json.Unmarshal(w2.Body.Bytes(), &createResp); err != nil { + t.Fatalf("invalid create json: %v", err) + } - // Delete missing - w3 := httptest.NewRecorder() - req3 := httptest.NewRequest(http.MethodDelete, "/backups/missing", nil) - r.ServeHTTP(w3, req3) - if w3.Code != http.StatusNotFound { t.Fatalf("delete missing expected 404 got %d", w3.Code) } + // Delete missing + w3 := httptest.NewRecorder() + req3 := httptest.NewRequest(http.MethodDelete, "/backups/missing", nil) + r.ServeHTTP(w3, req3) + if w3.Code != http.StatusNotFound { + t.Fatalf("delete missing expected 404 got %d", w3.Code) + } - // Download missing - w4 := httptest.NewRecorder() - req4 := httptest.NewRequest(http.MethodGet, "/backups/missing", nil) - r.ServeHTTP(w4, req4) - if w4.Code != http.StatusNotFound { t.Fatalf("download missing expected 404 got %d", w4.Code) } + // Download missing + w4 := httptest.NewRecorder() + req4 := httptest.NewRequest(http.MethodGet, "/backups/missing", nil) + r.ServeHTTP(w4, req4) + if w4.Code != http.StatusNotFound { + t.Fatalf("download missing expected 404 got %d", w4.Code) + } - // Download present (use filename returned from create) - w5 := httptest.NewRecorder() - req5 := httptest.NewRequest(http.MethodGet, "/backups/"+createResp.Filename, nil) - r.ServeHTTP(w5, req5) - if w5.Code != http.StatusOK { t.Fatalf("download expected 200 got %d", w5.Code) } + // Download present (use filename returned from create) + w5 := httptest.NewRecorder() + req5 := httptest.NewRequest(http.MethodGet, "/backups/"+createResp.Filename, nil) + r.ServeHTTP(w5, req5) + if w5.Code != http.StatusOK { + t.Fatalf("download expected 200 got %d", w5.Code) + } - // Restore missing - w6 := httptest.NewRecorder() - req6 := httptest.NewRequest(http.MethodPost, "/backups/missing/restore", nil) - r.ServeHTTP(w6, req6) - if w6.Code != http.StatusNotFound { t.Fatalf("restore missing expected 404 got %d", w6.Code) } + // Restore missing + w6 := httptest.NewRecorder() + req6 := httptest.NewRequest(http.MethodPost, "/backups/missing/restore", nil) + r.ServeHTTP(w6, req6) + if w6.Code != http.StatusNotFound { + t.Fatalf("restore missing expected 404 got %d", w6.Code) + } - // Restore ok - w7 := httptest.NewRecorder() - req7 := httptest.NewRequest(http.MethodPost, "/backups/"+createResp.Filename+"/restore", nil) - r.ServeHTTP(w7, req7) - if w7.Code != http.StatusOK { t.Fatalf("restore expected 200 got %d", w7.Code) } + // Restore ok + w7 := httptest.NewRecorder() + req7 := httptest.NewRequest(http.MethodPost, "/backups/"+createResp.Filename+"/restore", nil) + r.ServeHTTP(w7, req7) + if w7.Code != http.StatusOK { + t.Fatalf("restore expected 200 got %d", w7.Code) + } } diff --git a/backend/internal/api/handlers/crowdsec_exec_test.go b/backend/internal/api/handlers/crowdsec_exec_test.go index 7bea4623..45024e1f 100644 --- a/backend/internal/api/handlers/crowdsec_exec_test.go +++ b/backend/internal/api/handlers/crowdsec_exec_test.go @@ -1,77 +1,77 @@ package handlers import ( - "context" - "os" - "path/filepath" - "strconv" - "testing" - "time" + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" ) func TestDefaultCrowdsecExecutorPidFile(t *testing.T) { - e := NewDefaultCrowdsecExecutor() - tmp := t.TempDir() - expected := filepath.Join(tmp, "crowdsec.pid") - if p := e.pidFile(tmp); p != expected { - t.Fatalf("pidFile mismatch got %s expected %s", p, expected) - } + e := NewDefaultCrowdsecExecutor() + tmp := t.TempDir() + expected := filepath.Join(tmp, "crowdsec.pid") + if p := e.pidFile(tmp); p != expected { + t.Fatalf("pidFile mismatch got %s expected %s", p, expected) + } } func TestDefaultCrowdsecExecutorStartStatusStop(t *testing.T) { - e := NewDefaultCrowdsecExecutor() - tmp := t.TempDir() + e := NewDefaultCrowdsecExecutor() + tmp := t.TempDir() - // create a tiny script that sleeps and traps TERM - script := filepath.Join(tmp, "runscript.sh") - content := `#!/bin/sh + // create a tiny script that sleeps and traps TERM + script := filepath.Join(tmp, "runscript.sh") + content := `#!/bin/sh trap 'exit 0' TERM INT while true; do sleep 1; done ` - if err := os.WriteFile(script, []byte(content), 0o755); err != nil { - t.Fatalf("write script: %v", err) - } + if err := os.WriteFile(script, []byte(content), 0o755); err != nil { + t.Fatalf("write script: %v", err) + } - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - defer cancel() + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() - pid, err := e.Start(ctx, script, tmp) - if err != nil { - t.Fatalf("start err: %v", err) - } - if pid <= 0 { - t.Fatalf("invalid pid %d", pid) - } + pid, err := e.Start(ctx, script, tmp) + if err != nil { + t.Fatalf("start err: %v", err) + } + if pid <= 0 { + t.Fatalf("invalid pid %d", pid) + } - // ensure pid file exists and content matches - pidB, err := os.ReadFile(e.pidFile(tmp)) - if err != nil { - t.Fatalf("read pid file: %v", err) - } - gotPid, _ := strconv.Atoi(string(pidB)) - if gotPid != pid { - t.Fatalf("pid file mismatch got %d expected %d", gotPid, pid) - } + // ensure pid file exists and content matches + pidB, err := os.ReadFile(e.pidFile(tmp)) + if err != nil { + t.Fatalf("read pid file: %v", err) + } + gotPid, _ := strconv.Atoi(string(pidB)) + if gotPid != pid { + t.Fatalf("pid file mismatch got %d expected %d", gotPid, pid) + } - // Status should return running - running, got, err := e.Status(ctx, tmp) - if err != nil { - t.Fatalf("status err: %v", err) - } - if !running || got != pid { - t.Fatalf("status expected running for %d got %d running=%v", pid, got, running) - } + // Status should return running + running, got, err := e.Status(ctx, tmp) + if err != nil { + t.Fatalf("status err: %v", err) + } + if !running || got != pid { + t.Fatalf("status expected running for %d got %d running=%v", pid, got, running) + } - // Stop should terminate and remove pid file - if err := e.Stop(ctx, tmp); err != nil { - t.Fatalf("stop err: %v", err) - } + // Stop should terminate and remove pid file + if err := e.Stop(ctx, tmp); err != nil { + t.Fatalf("stop err: %v", err) + } - // give a little time for process to exit - time.Sleep(200 * time.Millisecond) + // give a little time for process to exit + time.Sleep(200 * time.Millisecond) - running2, _, _ := e.Status(ctx, tmp) - if running2 { - t.Fatalf("process still running after stop") - } + running2, _, _ := e.Status(ctx, tmp) + if running2 { + t.Fatalf("process still running after stop") + } } diff --git a/backend/internal/api/handlers/crowdsec_handler.go b/backend/internal/api/handlers/crowdsec_handler.go index 55bd6778..2647bac1 100644 --- a/backend/internal/api/handlers/crowdsec_handler.go +++ b/backend/internal/api/handlers/crowdsec_handler.go @@ -5,13 +5,13 @@ import ( "compress/gzip" "context" "fmt" + "github.com/Wikid82/charon/backend/internal/logger" "io" "net/http" "os" "path/filepath" - "time" - "github.com/Wikid82/charon/backend/internal/logger" "strings" + "time" "github.com/gin-gonic/gin" "gorm.io/gorm" diff --git a/backend/internal/api/handlers/crowdsec_handler_test.go b/backend/internal/api/handlers/crowdsec_handler_test.go index 8bb9fb19..df43b58d 100644 --- a/backend/internal/api/handlers/crowdsec_handler_test.go +++ b/backend/internal/api/handlers/crowdsec_handler_test.go @@ -3,13 +3,13 @@ package handlers import ( "bytes" "context" + "encoding/json" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "testing" - "encoding/json" "github.com/gin-gonic/gin" "gorm.io/gorm" diff --git a/backend/internal/api/handlers/domain_handler.go b/backend/internal/api/handlers/domain_handler.go index 3b201237..ac4d7cae 100644 --- a/backend/internal/api/handlers/domain_handler.go +++ b/backend/internal/api/handlers/domain_handler.go @@ -6,9 +6,9 @@ import ( "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/util" "github.com/gin-gonic/gin" "gorm.io/gorm" - "github.com/Wikid82/charon/backend/internal/util" ) type DomainHandler struct { diff --git a/backend/internal/api/handlers/import_handler.go b/backend/internal/api/handlers/import_handler.go index df789cc6..4b8e1203 100644 --- a/backend/internal/api/handlers/import_handler.go +++ b/backend/internal/api/handlers/import_handler.go @@ -14,11 +14,11 @@ import ( "github.com/google/uuid" "gorm.io/gorm" + "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/caddy" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/Wikid82/charon/backend/internal/util" - "github.com/Wikid82/charon/backend/internal/api/middleware" ) // ImportHandler handles Caddyfile import operations. diff --git a/backend/internal/api/handlers/import_handler_sanitize_test.go b/backend/internal/api/handlers/import_handler_sanitize_test.go index dab7836f..f4a405b2 100644 --- a/backend/internal/api/handlers/import_handler_sanitize_test.go +++ b/backend/internal/api/handlers/import_handler_sanitize_test.go @@ -1,65 +1,65 @@ package handlers import ( - "bytes" - "net/http" - "net/http/httptest" - "strings" - "testing" - "os" - "path/filepath" - "encoding/json" - "regexp" + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" - "github.com/Wikid82/charon/backend/internal/logger" - "github.com/Wikid82/charon/backend/internal/api/middleware" - "github.com/gin-gonic/gin" + "github.com/Wikid82/charon/backend/internal/api/middleware" + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/gin-gonic/gin" ) func TestImportUploadSanitizesFilename(t *testing.T) { - gin.SetMode(gin.TestMode) - tmpDir := t.TempDir() - // set up in-memory DB for handler - 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, "") + gin.SetMode(gin.TestMode) + tmpDir := t.TempDir() + // set up in-memory DB for handler + 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) + router := gin.New() + router.Use(middleware.RequestID()) + router.POST("/import/upload", svc.Upload) - buf := &bytes.Buffer{} - logger.Init(true, buf) + buf := &bytes.Buffer{} + logger.Init(true, buf) - maliciousFilename := "../evil\nfile.caddy" - payload := map[string]interface{}{"filename": maliciousFilename, "content": "site { respond \"ok\" }"} - bodyBytes, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/import/upload", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) + maliciousFilename := "../evil\nfile.caddy" + payload := map[string]interface{}{"filename": maliciousFilename, "content": "site { respond \"ok\" }"} + bodyBytes, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/import/upload", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) - out := buf.String() + out := buf.String() - // 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) - } + // 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(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) - } + 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) + } } diff --git a/backend/internal/api/handlers/notification_template_handler_test.go b/backend/internal/api/handlers/notification_template_handler_test.go index fef31b5b..3d49db7b 100644 --- a/backend/internal/api/handlers/notification_template_handler_test.go +++ b/backend/internal/api/handlers/notification_template_handler_test.go @@ -1,83 +1,83 @@ package handlers import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" - "gorm.io/gorm" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" - "github.com/Wikid82/charon/backend/internal/models" - "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" ) func TestNotificationTemplateHandler_CRUDAndPreview(t *testing.T) { - t.Helper() - db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) - svc := services.NewNotificationService(db) - h := NewNotificationTemplateHandler(svc) + svc := services.NewNotificationService(db) + h := NewNotificationTemplateHandler(svc) - r := gin.New() - api := r.Group("/api/v1") - api.GET("/notifications/templates", h.List) - api.POST("/notifications/templates", h.Create) - api.PUT("/notifications/templates/:id", h.Update) - api.DELETE("/notifications/templates/:id", h.Delete) - api.POST("/notifications/templates/preview", h.Preview) + r := gin.New() + api := r.Group("/api/v1") + api.GET("/notifications/templates", h.List) + api.POST("/notifications/templates", h.Create) + api.PUT("/notifications/templates/:id", h.Update) + api.DELETE("/notifications/templates/:id", h.Delete) + api.POST("/notifications/templates/preview", h.Preview) - // Create - payload := `{"name":"test","config":"{\"hello\":\"world\"}"}` - req := httptest.NewRequest(http.MethodPost, "/api/v1/notifications/templates", strings.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusCreated, w.Code) - var created models.NotificationTemplate - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) - require.NotEmpty(t, created.ID) + // Create + payload := `{"name":"test","config":"{\"hello\":\"world\"}"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/notifications/templates", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code) + var created models.NotificationTemplate + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) + require.NotEmpty(t, created.ID) - // List - req = httptest.NewRequest(http.MethodGet, "/api/v1/notifications/templates", nil) - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - var list []models.NotificationTemplate - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &list)) - require.True(t, len(list) >= 1) + // List + req = httptest.NewRequest(http.MethodGet, "/api/v1/notifications/templates", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var list []models.NotificationTemplate + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &list)) + require.True(t, len(list) >= 1) - // Update - updatedPayload := `{"name":"updated","config":"{\"hello\":\"updated\"}"}` - req = httptest.NewRequest(http.MethodPut, "/api/v1/notifications/templates/"+created.ID, strings.NewReader(updatedPayload)) - req.Header.Set("Content-Type", "application/json") - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - var up models.NotificationTemplate - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &up)) - require.Equal(t, "updated", up.Name) + // Update + updatedPayload := `{"name":"updated","config":"{\"hello\":\"updated\"}"}` + req = httptest.NewRequest(http.MethodPut, "/api/v1/notifications/templates/"+created.ID, strings.NewReader(updatedPayload)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var up models.NotificationTemplate + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &up)) + require.Equal(t, "updated", up.Name) - // Preview by id - previewPayload := `{"template_id":"` + created.ID + `", "data": {}}` - req = httptest.NewRequest(http.MethodPost, "/api/v1/notifications/templates/preview", strings.NewReader(previewPayload)) - req.Header.Set("Content-Type", "application/json") - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - var previewResp map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &previewResp)) - require.NotEmpty(t, previewResp["rendered"]) + // Preview by id + previewPayload := `{"template_id":"` + created.ID + `", "data": {}}` + req = httptest.NewRequest(http.MethodPost, "/api/v1/notifications/templates/preview", strings.NewReader(previewPayload)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var previewResp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &previewResp)) + require.NotEmpty(t, previewResp["rendered"]) - // Delete - req = httptest.NewRequest(http.MethodDelete, "/api/v1/notifications/templates/"+created.ID, nil) - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) + // Delete + req = httptest.NewRequest(http.MethodDelete, "/api/v1/notifications/templates/"+created.ID, nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) } diff --git a/backend/internal/api/handlers/proxy_host_handler.go b/backend/internal/api/handlers/proxy_host_handler.go index 81abb9f2..8803c44f 100644 --- a/backend/internal/api/handlers/proxy_host_handler.go +++ b/backend/internal/api/handlers/proxy_host_handler.go @@ -10,9 +10,9 @@ import ( "github.com/google/uuid" "gorm.io/gorm" + "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/caddy" "github.com/Wikid82/charon/backend/internal/models" - "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/services" "github.com/Wikid82/charon/backend/internal/util" ) @@ -94,12 +94,12 @@ func (h *ProxyHostHandler) Create(c *gin.Context) { } if h.caddyManager != nil { - if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { + if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { // Rollback: delete the created host if config application fails middleware.GetRequestLogger(c).WithError(err).Error("Error applying config") if deleteErr := h.service.Delete(host.ID); deleteErr != nil { idStr := strconv.FormatUint(uint64(host.ID), 10) - middleware.GetRequestLogger(c).WithField("host_id", idStr).WithError(deleteErr).Error("Critical: Failed to rollback host") + middleware.GetRequestLogger(c).WithField("host_id", idStr).WithError(deleteErr).Error("Critical: Failed to rollback host") } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) return diff --git a/backend/internal/api/handlers/remote_server_handler.go b/backend/internal/api/handlers/remote_server_handler.go index b8e95d9c..2518504f 100644 --- a/backend/internal/api/handlers/remote_server_handler.go +++ b/backend/internal/api/handlers/remote_server_handler.go @@ -137,17 +137,17 @@ func (h *RemoteServerHandler) Delete(c *gin.Context) { } // Send Notification - if h.notificationService != nil { - h.notificationService.SendExternal(c.Request.Context(), - "remote_server", - "Remote Server Deleted", - fmt.Sprintf("Remote Server %s deleted", util.SanitizeForLog(server.Name)), - map[string]interface{}{ - "Name": util.SanitizeForLog(server.Name), - "Action": "deleted", - }, - ) - } + if h.notificationService != nil { + h.notificationService.SendExternal(c.Request.Context(), + "remote_server", + "Remote Server Deleted", + fmt.Sprintf("Remote Server %s deleted", util.SanitizeForLog(server.Name)), + map[string]interface{}{ + "Name": util.SanitizeForLog(server.Name), + "Action": "deleted", + }, + ) + } c.JSON(http.StatusNoContent, nil) } diff --git a/backend/internal/api/handlers/security_handler.go b/backend/internal/api/handlers/security_handler.go index b73471d0..f053e2ea 100644 --- a/backend/internal/api/handlers/security_handler.go +++ b/backend/internal/api/handlers/security_handler.go @@ -10,17 +10,17 @@ import ( "github.com/gin-gonic/gin" "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" - "github.com/Wikid82/charon/backend/internal/caddy" ) // SecurityHandler handles security-related API requests. type SecurityHandler struct { - cfg config.SecurityConfig - db *gorm.DB - svc *services.SecurityService + cfg config.SecurityConfig + db *gorm.DB + svc *services.SecurityService caddyManager *caddy.Manager } @@ -211,16 +211,16 @@ func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "name required"}) return } - if err := h.svc.UpsertRuleSet(&payload); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upsert ruleset"}) - return - } - if h.caddyManager != nil { - if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) - return - } - } + if err := h.svc.UpsertRuleSet(&payload); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upsert ruleset"}) + return + } + if h.caddyManager != nil { + if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) + return + } + } // Create an audit event actor := c.GetString("user_id") if actor == "" { @@ -242,7 +242,7 @@ func (h *SecurityHandler) DeleteRuleSet(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } - if err := h.svc.DeleteRuleSet(uint(id)); err != nil { + if err := h.svc.DeleteRuleSet(uint(id)); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "ruleset not found"}) return @@ -250,12 +250,12 @@ func (h *SecurityHandler) DeleteRuleSet(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete ruleset"}) return } - if h.caddyManager != nil { - if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) - return - } - } + if h.caddyManager != nil { + if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) + return + } + } actor := c.GetString("user_id") if actor == "" { actor = c.ClientIP() @@ -268,7 +268,9 @@ func (h *SecurityHandler) DeleteRuleSet(c *gin.Context) { func (h *SecurityHandler) Enable(c *gin.Context) { // Look for requester's IP and optional breakglass token adminIP := c.ClientIP() - var body struct{ Token string `json:"break_glass_token"` } + var body struct { + Token string `json:"break_glass_token"` + } _ = c.ShouldBindJSON(&body) // If config exists, require that adminIP is in whitelist or token matches @@ -332,7 +334,9 @@ func (h *SecurityHandler) Enable(c *gin.Context) { // Disable toggles Cerberus off; requires break-glass token or localhost request func (h *SecurityHandler) Disable(c *gin.Context) { - var body struct{ Token string `json:"break_glass_token"` } + var body struct { + Token string `json:"break_glass_token"` + } _ = c.ShouldBindJSON(&body) // Allow requests from localhost to disable without token clientIP := c.ClientIP() diff --git a/backend/internal/api/handlers/security_handler_additional_test.go b/backend/internal/api/handlers/security_handler_additional_test.go index 5d88331b..a9fae5c6 100644 --- a/backend/internal/api/handlers/security_handler_additional_test.go +++ b/backend/internal/api/handlers/security_handler_additional_test.go @@ -1,69 +1,69 @@ package handlers import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" - "gorm.io/gorm" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" - "github.com/Wikid82/charon/backend/internal/config" - "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" ) func TestSecurityHandler_GetConfigAndUpdateConfig(t *testing.T) { - t.Helper() - // Setup DB and router - db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.SecurityConfig{})) + t.Helper() + // Setup DB and router + db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SecurityConfig{})) - cfg := config.SecurityConfig{} - h := NewSecurityHandler(cfg, db, nil) + cfg := config.SecurityConfig{} + h := NewSecurityHandler(cfg, db, nil) - // Create a gin test context for GetConfig when no config exists - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - req := httptest.NewRequest("GET", "/security/config", nil) - c.Request = req - h.GetConfig(c) - require.Equal(t, http.StatusOK, w.Code) - var body map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) - // Should return config: null - if _, ok := body["config"]; !ok { - t.Fatalf("expected 'config' in response, got %v", body) - } + // Create a gin test context for GetConfig when no config exists + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest("GET", "/security/config", nil) + c.Request = req + h.GetConfig(c) + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + // Should return config: null + if _, ok := body["config"]; !ok { + t.Fatalf("expected 'config' in response, got %v", body) + } - // Now update config - w = httptest.NewRecorder() - c, _ = gin.CreateTestContext(w) - payload := `{"name":"default","admin_whitelist":"127.0.0.1/32"}` - req = httptest.NewRequest("POST", "/security/config", strings.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - c.Request = req - h.UpdateConfig(c) - require.Equal(t, http.StatusOK, w.Code) + // Now update config + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + payload := `{"name":"default","admin_whitelist":"127.0.0.1/32"}` + req = httptest.NewRequest("POST", "/security/config", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + c.Request = req + h.UpdateConfig(c) + require.Equal(t, http.StatusOK, w.Code) - // Now call GetConfig again and ensure config is returned - w = httptest.NewRecorder() - c, _ = gin.CreateTestContext(w) - req = httptest.NewRequest("GET", "/security/config", nil) - c.Request = req - h.GetConfig(c) - require.Equal(t, http.StatusOK, w.Code) - var body2 map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body2)) - cfgVal, ok := body2["config"].(map[string]interface{}) - if !ok { - t.Fatalf("expected config object, got %v", body2["config"]) - } - if cfgVal["admin_whitelist"] != "127.0.0.1/32" { - t.Fatalf("unexpected admin_whitelist: %v", cfgVal["admin_whitelist"]) - } + // Now call GetConfig again and ensure config is returned + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + req = httptest.NewRequest("GET", "/security/config", nil) + c.Request = req + h.GetConfig(c) + require.Equal(t, http.StatusOK, w.Code) + var body2 map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body2)) + cfgVal, ok := body2["config"].(map[string]interface{}) + if !ok { + t.Fatalf("expected config object, got %v", body2["config"]) + } + if cfgVal["admin_whitelist"] != "127.0.0.1/32" { + t.Fatalf("unexpected admin_whitelist: %v", cfgVal["admin_whitelist"]) + } } diff --git a/backend/internal/api/handlers/security_handler_clean_test.go b/backend/internal/api/handlers/security_handler_clean_test.go index a1a76ab4..04e01b31 100644 --- a/backend/internal/api/handlers/security_handler_clean_test.go +++ b/backend/internal/api/handlers/security_handler_clean_test.go @@ -5,9 +5,9 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" - "strings" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" diff --git a/backend/internal/api/handlers/security_handler_rules_decisions_test.go b/backend/internal/api/handlers/security_handler_rules_decisions_test.go index 96372440..3953891c 100644 --- a/backend/internal/api/handlers/security_handler_rules_decisions_test.go +++ b/backend/internal/api/handlers/security_handler_rules_decisions_test.go @@ -1,171 +1,171 @@ package handlers import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "strconv" - "time" - "path/filepath" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "testing" + "time" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" - "gorm.io/gorm" + "github.com/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/config" - "github.com/Wikid82/charon/backend/internal/models" - "github.com/Wikid82/charon/backend/internal/caddy" + "github.com/Wikid82/charon/backend/internal/caddy" + "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" ) func setupSecurityTestRouterWithExtras(t *testing.T) (*gin.Engine, *gorm.DB) { - t.Helper() - // Use a file-backed sqlite DB to avoid shared memory connection issues in tests - dsn := filepath.Join(t.TempDir(), "test.db") - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.AccessList{}, &models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{})) + t.Helper() + // Use a file-backed sqlite DB to avoid shared memory connection issues in tests + dsn := filepath.Join(t.TempDir(), "test.db") + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.AccessList{}, &models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{})) - r := gin.New() - api := r.Group("/api/v1") - cfg := config.SecurityConfig{} - h := NewSecurityHandler(cfg, db, nil) - api.POST("/security/decisions", h.CreateDecision) - api.GET("/security/decisions", h.ListDecisions) - api.POST("/security/rulesets", h.UpsertRuleSet) - api.GET("/security/rulesets", h.ListRuleSets) - api.DELETE("/security/rulesets/:id", h.DeleteRuleSet) - return r, db + r := gin.New() + api := r.Group("/api/v1") + cfg := config.SecurityConfig{} + h := NewSecurityHandler(cfg, db, nil) + api.POST("/security/decisions", h.CreateDecision) + api.GET("/security/decisions", h.ListDecisions) + api.POST("/security/rulesets", h.UpsertRuleSet) + api.GET("/security/rulesets", h.ListRuleSets) + api.DELETE("/security/rulesets/:id", h.DeleteRuleSet) + return r, db } func TestSecurityHandler_CreateAndListDecisionAndRulesets(t *testing.T) { - r, _ := setupSecurityTestRouterWithExtras(t) + r, _ := setupSecurityTestRouterWithExtras(t) - payload := `{"ip":"1.2.3.4","action":"block","host":"example.com","rule_id":"manual-1","details":"test"}` - req := httptest.NewRequest(http.MethodPost, "/api/v1/security/decisions", strings.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - r.ServeHTTP(resp, req) - if resp.Code != http.StatusOK { - t.Fatalf("Create decision expected status 200, got %d; body: %s", resp.Code, resp.Body.String()) - } + payload := `{"ip":"1.2.3.4","action":"block","host":"example.com","rule_id":"manual-1","details":"test"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/security/decisions", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("Create decision expected status 200, got %d; body: %s", resp.Code, resp.Body.String()) + } - var decisionResp map[string]interface{} - require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &decisionResp)) - require.NotNil(t, decisionResp["decision"]) + var decisionResp map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &decisionResp)) + require.NotNil(t, decisionResp["decision"]) - req = httptest.NewRequest(http.MethodGet, "/api/v1/security/decisions?limit=10", nil) - resp = httptest.NewRecorder() - r.ServeHTTP(resp, req) - if resp.Code != http.StatusOK { - t.Fatalf("Upsert ruleset expected status 200, got %d; body: %s", resp.Code, resp.Body.String()) - } - var listResp map[string][]map[string]interface{} - require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &listResp)) - require.GreaterOrEqual(t, len(listResp["decisions"]), 1) + req = httptest.NewRequest(http.MethodGet, "/api/v1/security/decisions?limit=10", nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("Upsert ruleset expected status 200, got %d; body: %s", resp.Code, resp.Body.String()) + } + var listResp map[string][]map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &listResp)) + require.GreaterOrEqual(t, len(listResp["decisions"]), 1) - // Now test ruleset upsert - rpayload := `{"name":"owasp-crs","source_url":"https://example.com/owasp","mode":"owasp","content":"test"}` - req = httptest.NewRequest(http.MethodPost, "/api/v1/security/rulesets", strings.NewReader(rpayload)) - req.Header.Set("Content-Type", "application/json") - resp = httptest.NewRecorder() - r.ServeHTTP(resp, req) - if resp.Code != http.StatusOK { - t.Fatalf("Upsert ruleset expected status 200, got %d; body: %s", resp.Code, resp.Body.String()) - } - var rsResp map[string]interface{} - require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &rsResp)) - require.NotNil(t, rsResp["ruleset"]) + // Now test ruleset upsert + rpayload := `{"name":"owasp-crs","source_url":"https://example.com/owasp","mode":"owasp","content":"test"}` + req = httptest.NewRequest(http.MethodPost, "/api/v1/security/rulesets", strings.NewReader(rpayload)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("Upsert ruleset expected status 200, got %d; body: %s", resp.Code, resp.Body.String()) + } + var rsResp map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &rsResp)) + require.NotNil(t, rsResp["ruleset"]) - req = httptest.NewRequest(http.MethodGet, "/api/v1/security/rulesets", nil) - resp = httptest.NewRecorder() - r.ServeHTTP(resp, req) - if resp.Code != http.StatusOK { - t.Fatalf("List rulesets expected status 200, got %d; body: %s", resp.Code, resp.Body.String()) - } - var listRsResp map[string][]map[string]interface{} - require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &listRsResp)) - require.GreaterOrEqual(t, len(listRsResp["rulesets"]), 1) + req = httptest.NewRequest(http.MethodGet, "/api/v1/security/rulesets", nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("List rulesets expected status 200, got %d; body: %s", resp.Code, resp.Body.String()) + } + var listRsResp map[string][]map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &listRsResp)) + require.GreaterOrEqual(t, len(listRsResp["rulesets"]), 1) - // Delete the ruleset we just created - idFloat, ok := listRsResp["rulesets"][0]["id"].(float64) - require.True(t, ok) - id := int(idFloat) - req = httptest.NewRequest(http.MethodDelete, "/api/v1/security/rulesets/"+strconv.Itoa(id), nil) - resp = httptest.NewRecorder() - r.ServeHTTP(resp, req) - assert.Equal(t, http.StatusOK, resp.Code) - var delResp map[string]interface{} - require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &delResp)) - require.Equal(t, true, delResp["deleted"].(bool)) + // Delete the ruleset we just created + idFloat, ok := listRsResp["rulesets"][0]["id"].(float64) + require.True(t, ok) + id := int(idFloat) + req = httptest.NewRequest(http.MethodDelete, "/api/v1/security/rulesets/"+strconv.Itoa(id), nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + var delResp map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &delResp)) + require.Equal(t, true, delResp["deleted"].(bool)) } func TestSecurityHandler_UpsertDeleteTriggersApplyConfig(t *testing.T) { - t.Helper() - // Setup DB - db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{})) + t.Helper() + // Setup DB + db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{})) - // Ensure DB has expected tables (migrations executed above) + // Ensure DB has expected tables (migrations executed above) - // Ensure proxy_hosts table exists in case AutoMigrate didn't create it - db.Exec("CREATE TABLE IF NOT EXISTS proxy_hosts (id INTEGER PRIMARY KEY AUTOINCREMENT, domain_names TEXT, forward_host TEXT, forward_port INTEGER, enabled BOOLEAN)") - // Create minimal settings and caddy_configs tables to satisfy Manager.ApplyConfig queries - db.Exec("CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT, value TEXT, type TEXT, category TEXT, updated_at datetime)") - db.Exec("CREATE TABLE IF NOT EXISTS caddy_configs (id INTEGER PRIMARY KEY AUTOINCREMENT, config_hash TEXT, applied_at datetime, success BOOLEAN, error_msg TEXT)") - // debug: tables exist + // Ensure proxy_hosts table exists in case AutoMigrate didn't create it + db.Exec("CREATE TABLE IF NOT EXISTS proxy_hosts (id INTEGER PRIMARY KEY AUTOINCREMENT, domain_names TEXT, forward_host TEXT, forward_port INTEGER, enabled BOOLEAN)") + // Create minimal settings and caddy_configs tables to satisfy Manager.ApplyConfig queries + db.Exec("CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT, value TEXT, type TEXT, category TEXT, updated_at datetime)") + db.Exec("CREATE TABLE IF NOT EXISTS caddy_configs (id INTEGER PRIMARY KEY AUTOINCREMENT, config_hash TEXT, applied_at datetime, success BOOLEAN, error_msg TEXT)") + // debug: tables exist - // Caddy admin server to capture /load calls - loadCh := make(chan struct{}, 2) - caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/load" && r.Method == http.MethodPost { - loadCh <- struct{}{} - w.WriteHeader(http.StatusOK) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer caddyServer.Close() + // Caddy admin server to capture /load calls + loadCh := make(chan struct{}, 2) + caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/load" && r.Method == http.MethodPost { + loadCh <- struct{}{} + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer caddyServer.Close() - client := caddy.NewClient(caddyServer.URL) - tmp := t.TempDir() - m := caddy.NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"}) + client := caddy.NewClient(caddyServer.URL) + tmp := t.TempDir() + m := caddy.NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"}) - r := gin.New() - api := r.Group("/api/v1") - cfg := config.SecurityConfig{} - h := NewSecurityHandler(cfg, db, m) - api.POST("/security/rulesets", h.UpsertRuleSet) - api.DELETE("/security/rulesets/:id", h.DeleteRuleSet) + r := gin.New() + api := r.Group("/api/v1") + cfg := config.SecurityConfig{} + h := NewSecurityHandler(cfg, db, m) + api.POST("/security/rulesets", h.UpsertRuleSet) + api.DELETE("/security/rulesets/:id", h.DeleteRuleSet) - // Upsert ruleset should trigger manager.ApplyConfig -> POST /load - rpayload := `{"name":"owasp-crs","source_url":"https://example.com/owasp","mode":"owasp","content":"test"}` - req := httptest.NewRequest(http.MethodPost, "/api/v1/security/rulesets", strings.NewReader(rpayload)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - r.ServeHTTP(resp, req) - assert.Equal(t, http.StatusOK, resp.Code) - select { - case <-loadCh: - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for manager ApplyConfig /load post on upsert") - } + // Upsert ruleset should trigger manager.ApplyConfig -> POST /load + rpayload := `{"name":"owasp-crs","source_url":"https://example.com/owasp","mode":"owasp","content":"test"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/security/rulesets", strings.NewReader(rpayload)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + select { + case <-loadCh: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for manager ApplyConfig /load post on upsert") + } - // Now delete the ruleset and ensure /load is triggered again - // Read ID from DB - var rs models.SecurityRuleSet - assert.NoError(t, db.First(&rs).Error) - req = httptest.NewRequest(http.MethodDelete, "/api/v1/security/rulesets/"+strconv.Itoa(int(rs.ID)), nil) - resp = httptest.NewRecorder() - r.ServeHTTP(resp, req) - assert.Equal(t, http.StatusOK, resp.Code) - select { - case <-loadCh: - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for manager ApplyConfig /load post on delete") - } + // Now delete the ruleset and ensure /load is triggered again + // Read ID from DB + var rs models.SecurityRuleSet + assert.NoError(t, db.First(&rs).Error) + req = httptest.NewRequest(http.MethodDelete, "/api/v1/security/rulesets/"+strconv.Itoa(int(rs.ID)), nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + assert.Equal(t, http.StatusOK, resp.Code) + select { + case <-loadCh: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for manager ApplyConfig /load post on delete") + } } diff --git a/backend/internal/api/handlers/system_handler_test.go b/backend/internal/api/handlers/system_handler_test.go index d035f105..10647bdb 100644 --- a/backend/internal/api/handlers/system_handler_test.go +++ b/backend/internal/api/handlers/system_handler_test.go @@ -1,60 +1,60 @@ package handlers import ( - "net/http" - "net/http/httptest" - "testing" + "net/http" + "net/http/httptest" + "testing" - "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin" ) func TestGetClientIPHeadersAndRemoteAddr(t *testing.T) { - // Cloudflare header should win - req := httptest.NewRequest(http.MethodGet, "/", nil) - req.Header.Set("CF-Connecting-IP", "5.6.7.8") - ip := getClientIP(req) - if ip != "5.6.7.8" { - t.Fatalf("expected 5.6.7.8 got %s", ip) - } + // Cloudflare header should win + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("CF-Connecting-IP", "5.6.7.8") + ip := getClientIP(req) + if ip != "5.6.7.8" { + t.Fatalf("expected 5.6.7.8 got %s", ip) + } - // X-Real-IP should be preferred over RemoteAddr - req2 := httptest.NewRequest(http.MethodGet, "/", nil) - req2.Header.Set("X-Real-IP", "10.0.0.4") - req2.RemoteAddr = "1.2.3.4:5678" - ip2 := getClientIP(req2) - if ip2 != "10.0.0.4" { - t.Fatalf("expected 10.0.0.4 got %s", ip2) - } + // X-Real-IP should be preferred over RemoteAddr + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + req2.Header.Set("X-Real-IP", "10.0.0.4") + req2.RemoteAddr = "1.2.3.4:5678" + ip2 := getClientIP(req2) + if ip2 != "10.0.0.4" { + t.Fatalf("expected 10.0.0.4 got %s", ip2) + } - // X-Forwarded-For returns first in list - req3 := httptest.NewRequest(http.MethodGet, "/", nil) - req3.Header.Set("X-Forwarded-For", "192.168.0.1, 192.168.0.2") - ip3 := getClientIP(req3) - if ip3 != "192.168.0.1" { - t.Fatalf("expected 192.168.0.1 got %s", ip3) - } + // X-Forwarded-For returns first in list + req3 := httptest.NewRequest(http.MethodGet, "/", nil) + req3.Header.Set("X-Forwarded-For", "192.168.0.1, 192.168.0.2") + ip3 := getClientIP(req3) + if ip3 != "192.168.0.1" { + t.Fatalf("expected 192.168.0.1 got %s", ip3) + } - // Fallback to remote addr port trimmed - req4 := httptest.NewRequest(http.MethodGet, "/", nil) - req4.RemoteAddr = "7.7.7.7:8888" - ip4 := getClientIP(req4) - if ip4 != "7.7.7.7" { - t.Fatalf("expected 7.7.7.7 got %s", ip4) - } + // Fallback to remote addr port trimmed + req4 := httptest.NewRequest(http.MethodGet, "/", nil) + req4.RemoteAddr = "7.7.7.7:8888" + ip4 := getClientIP(req4) + if ip4 != "7.7.7.7" { + t.Fatalf("expected 7.7.7.7 got %s", ip4) + } } func TestGetMyIPHandler(t *testing.T) { - gin.SetMode(gin.TestMode) - r := gin.New() - handler := NewSystemHandler() - r.GET("/myip", handler.GetMyIP) + gin.SetMode(gin.TestMode) + r := gin.New() + handler := NewSystemHandler() + r.GET("/myip", handler.GetMyIP) - // With CF header - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/myip", nil) - req.Header.Set("CF-Connecting-IP", "5.6.7.8") - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 got %d", w.Code) - } + // With CF header + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/myip", nil) + req.Header.Set("CF-Connecting-IP", "5.6.7.8") + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 got %d", w.Code) + } } diff --git a/backend/internal/api/handlers/testdb.go b/backend/internal/api/handlers/testdb.go index d24ef127..b1911252 100644 --- a/backend/internal/api/handlers/testdb.go +++ b/backend/internal/api/handlers/testdb.go @@ -1,23 +1,23 @@ package handlers import ( - "fmt" - "strings" - "testing" + "fmt" + "strings" + "testing" - "gorm.io/driver/sqlite" - "gorm.io/gorm" + "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 + 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 } diff --git a/backend/internal/api/middleware/recovery.go b/backend/internal/api/middleware/recovery.go index 5783a508..f1696c8b 100644 --- a/backend/internal/api/middleware/recovery.go +++ b/backend/internal/api/middleware/recovery.go @@ -1,32 +1,32 @@ package middleware import ( - "net/http" - "runtime/debug" + "net/http" + "runtime/debug" - "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin" ) // Recovery logs panic information. When verbose is true it logs stacktraces // and basic request metadata for debugging. func Recovery(verbose bool) gin.HandlerFunc { - return func(c *gin.Context) { - defer func() { - if r := recover(); r != nil { - // Try to get a request-scoped logger; fall back to global logger - entry := GetRequestLogger(c) - if verbose { - entry.WithFields(map[string]interface{}{ - "method": c.Request.Method, - "path": SanitizePath(c.Request.URL.Path), - "headers": SanitizeHeaders(c.Request.Header), - }).Errorf("PANIC: %v\nStacktrace:\n%s", r, debug.Stack()) - } else { - entry.Errorf("PANIC: %v", r) - } - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) - } - }() - c.Next() - } + return func(c *gin.Context) { + defer func() { + if r := recover(); r != nil { + // Try to get a request-scoped logger; fall back to global logger + entry := GetRequestLogger(c) + if verbose { + entry.WithFields(map[string]interface{}{ + "method": c.Request.Method, + "path": SanitizePath(c.Request.URL.Path), + "headers": SanitizeHeaders(c.Request.Header), + }).Errorf("PANIC: %v\nStacktrace:\n%s", r, debug.Stack()) + } else { + entry.Errorf("PANIC: %v", r) + } + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + } + }() + c.Next() + } } diff --git a/backend/internal/api/middleware/recovery_test.go b/backend/internal/api/middleware/recovery_test.go index bc1ac4bf..fbe12240 100644 --- a/backend/internal/api/middleware/recovery_test.go +++ b/backend/internal/api/middleware/recovery_test.go @@ -1,115 +1,115 @@ package middleware import ( - "bytes" - "log" - "net/http" - "net/http/httptest" - "strings" - "testing" + "bytes" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" - "github.com/Wikid82/charon/backend/internal/logger" - "github.com/gin-gonic/gin" + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/gin-gonic/gin" ) func TestRecoveryLogsStacktraceVerbose(t *testing.T) { - old := log.Writer() - buf := &bytes.Buffer{} - log.SetOutput(buf) - defer log.SetOutput(old) - // Ensure structured logger writes to the same buffer and enable debug - logger.Init(true, buf) + old := log.Writer() + buf := &bytes.Buffer{} + log.SetOutput(buf) + defer log.SetOutput(old) + // Ensure structured logger writes to the same buffer and enable debug + logger.Init(true, buf) - router := gin.New() - router.Use(RequestID()) - router.Use(Recovery(true)) - router.GET("/panic", func(c *gin.Context) { - panic("test panic") - }) + router := gin.New() + router.Use(RequestID()) + router.Use(Recovery(true)) + router.GET("/panic", func(c *gin.Context) { + panic("test panic") + }) - req := httptest.NewRequest(http.MethodGet, "/panic", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/panic", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) - if w.Code != http.StatusInternalServerError { - t.Fatalf("expected status 500, got %d", w.Code) - } + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } - out := buf.String() - if !strings.Contains(out, "PANIC: test panic") { - t.Fatalf("log did not include panic message: %s", out) - } - if !strings.Contains(out, "Stacktrace:") { - t.Fatalf("verbose log did not include stack trace: %s", out) - } - if !strings.Contains(out, "request_id") { - t.Fatalf("verbose log did not include request_id: %s", out) - } + out := buf.String() + if !strings.Contains(out, "PANIC: test panic") { + t.Fatalf("log did not include panic message: %s", out) + } + if !strings.Contains(out, "Stacktrace:") { + t.Fatalf("verbose log did not include stack trace: %s", out) + } + if !strings.Contains(out, "request_id") { + t.Fatalf("verbose log did not include request_id: %s", out) + } } func TestRecoveryLogsBriefWhenNotVerbose(t *testing.T) { - old := log.Writer() - buf := &bytes.Buffer{} - log.SetOutput(buf) - defer log.SetOutput(old) + old := log.Writer() + buf := &bytes.Buffer{} + log.SetOutput(buf) + defer log.SetOutput(old) - // Ensure structured logger writes to the same buffer and keep debug off - logger.Init(false, buf) - router := gin.New() - router.Use(RequestID()) - router.Use(Recovery(false)) - router.GET("/panic", func(c *gin.Context) { - panic("brief panic") - }) + // Ensure structured logger writes to the same buffer and keep debug off + logger.Init(false, buf) + router := gin.New() + router.Use(RequestID()) + router.Use(Recovery(false)) + router.GET("/panic", func(c *gin.Context) { + panic("brief panic") + }) - req := httptest.NewRequest(http.MethodGet, "/panic", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/panic", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) - if w.Code != http.StatusInternalServerError { - t.Fatalf("expected status 500, got %d", w.Code) - } + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } - out := buf.String() - if !strings.Contains(out, "PANIC: brief panic") { - t.Fatalf("log did not include panic message: %s", out) - } - if strings.Contains(out, "Stacktrace:") { - t.Fatalf("non-verbose log unexpectedly included stacktrace: %s", out) - } + out := buf.String() + if !strings.Contains(out, "PANIC: brief panic") { + t.Fatalf("log did not include panic message: %s", out) + } + if strings.Contains(out, "Stacktrace:") { + t.Fatalf("non-verbose log unexpectedly included stacktrace: %s", out) + } } func TestRecoverySanitizesHeadersAndPath(t *testing.T) { - old := log.Writer() - buf := &bytes.Buffer{} - log.SetOutput(buf) - defer log.SetOutput(old) + old := log.Writer() + buf := &bytes.Buffer{} + log.SetOutput(buf) + defer log.SetOutput(old) - // Ensure structured logger writes to the same buffer and enable debug - logger.Init(true, buf) + // Ensure structured logger writes to the same buffer and enable debug + logger.Init(true, buf) - router := gin.New() - router.Use(RequestID()) - router.Use(Recovery(true)) - router.GET("/panic", func(c *gin.Context) { - panic("sensitive panic") - }) + router := gin.New() + router.Use(RequestID()) + router.Use(Recovery(true)) + router.GET("/panic", func(c *gin.Context) { + panic("sensitive panic") + }) - req := httptest.NewRequest(http.MethodGet, "/panic", nil) - // Add sensitive header that should be redacted - req.Header.Set("Authorization", "Bearer secret-token") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/panic", nil) + // Add sensitive header that should be redacted + req.Header.Set("Authorization", "Bearer secret-token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) - if w.Code != http.StatusInternalServerError { - t.Fatalf("expected status 500, got %d", w.Code) - } + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } - out := buf.String() - if strings.Contains(out, "secret-token") { - t.Fatalf("log contained sensitive token: %s", out) - } - if !strings.Contains(out, "") { - t.Fatalf("log did not include redaction marker: %s", out) - } + out := buf.String() + if strings.Contains(out, "secret-token") { + t.Fatalf("log contained sensitive token: %s", out) + } + if !strings.Contains(out, "") { + t.Fatalf("log did not include redaction marker: %s", out) + } } diff --git a/backend/internal/api/middleware/request_id.go b/backend/internal/api/middleware/request_id.go index e97bfab1..141e3513 100644 --- a/backend/internal/api/middleware/request_id.go +++ b/backend/internal/api/middleware/request_id.go @@ -1,39 +1,39 @@ package middleware import ( - "context" - "github.com/Wikid82/charon/backend/internal/logger" - "github.com/Wikid82/charon/backend/internal/trace" - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/sirupsen/logrus" + "context" + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/trace" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/sirupsen/logrus" ) const RequestIDHeader = "X-Request-ID" // RequestID generates a uuid per request and places it in context and header. func RequestID() gin.HandlerFunc { - return func(c *gin.Context) { - rid := uuid.New().String() - c.Set(string(trace.RequestIDKey), rid) - c.Writer.Header().Set(RequestIDHeader, rid) - // Add to logger fields for this request - entry := logger.WithFields(map[string]interface{}{"request_id": rid}) - c.Set("logger", entry) - // Propagate into the request context so it can be used by services - ctx := context.WithValue(c.Request.Context(), trace.RequestIDKey, rid) - c.Request = c.Request.WithContext(ctx) - c.Next() - } + return func(c *gin.Context) { + rid := uuid.New().String() + c.Set(string(trace.RequestIDKey), rid) + c.Writer.Header().Set(RequestIDHeader, rid) + // Add to logger fields for this request + entry := logger.WithFields(map[string]interface{}{"request_id": rid}) + c.Set("logger", entry) + // Propagate into the request context so it can be used by services + ctx := context.WithValue(c.Request.Context(), trace.RequestIDKey, rid) + c.Request = c.Request.WithContext(ctx) + c.Next() + } } // GetRequestLogger retrieves the request-scoped logger from context or the global logger func GetRequestLogger(c *gin.Context) *logrus.Entry { - if v, ok := c.Get("logger"); ok { - if entry, ok := v.(*logrus.Entry); ok { - return entry - } - } - // fallback - return logger.Log() + if v, ok := c.Get("logger"); ok { + if entry, ok := v.(*logrus.Entry); ok { + return entry + } + } + // fallback + return logger.Log() } diff --git a/backend/internal/api/middleware/request_id_test.go b/backend/internal/api/middleware/request_id_test.go index 1813e922..69598b13 100644 --- a/backend/internal/api/middleware/request_id_test.go +++ b/backend/internal/api/middleware/request_id_test.go @@ -1,37 +1,37 @@ package middleware import ( - "bytes" - "net/http" - "net/http/httptest" - "testing" + "bytes" + "net/http" + "net/http/httptest" + "testing" - "github.com/Wikid82/charon/backend/internal/logger" - "github.com/gin-gonic/gin" + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/gin-gonic/gin" ) func TestRequestIDAddsHeaderAndLogger(t *testing.T) { - buf := &bytes.Buffer{} - logger.Init(true, buf) + buf := &bytes.Buffer{} + logger.Init(true, buf) - router := gin.New() - router.Use(RequestID()) - router.GET("/test", func(c *gin.Context) { - // Ensure logger exists in context and header is present - if _, ok := c.Get("logger"); !ok { - t.Fatalf("expected request-scoped logger in context") - } - c.String(200, "ok") - }) + router := gin.New() + router.Use(RequestID()) + router.GET("/test", func(c *gin.Context) { + // Ensure logger exists in context and header is present + if _, ok := c.Get("logger"); !ok { + t.Fatalf("expected request-scoped logger in context") + } + c.String(200, "ok") + }) - req := httptest.NewRequest(http.MethodGet, "/test", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", w.Code) - } - if w.Header().Get(RequestIDHeader) == "" { - t.Fatalf("expected response to include X-Request-ID header") - } + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + if w.Header().Get(RequestIDHeader) == "" { + t.Fatalf("expected response to include X-Request-ID header") + } } diff --git a/backend/internal/api/middleware/request_logger.go b/backend/internal/api/middleware/request_logger.go index 64f11bef..b09629a0 100644 --- a/backend/internal/api/middleware/request_logger.go +++ b/backend/internal/api/middleware/request_logger.go @@ -1,25 +1,25 @@ package middleware import ( - "time" - "github.com/Wikid82/charon/backend/internal/util" + "github.com/Wikid82/charon/backend/internal/util" + "time" - "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin" ) // RequestLogger logs basic request information along with the request_id. func RequestLogger() gin.HandlerFunc { - return func(c *gin.Context) { - start := time.Now() - c.Next() - latency := time.Since(start) - entry := GetRequestLogger(c) - entry.WithFields(map[string]interface{}{ - "status": c.Writer.Status(), - "method": c.Request.Method, - "path": SanitizePath(c.Request.URL.Path), - "latency": latency.String(), - "client": util.SanitizeForLog(c.ClientIP()), - }).Info("handled request") - } + return func(c *gin.Context) { + start := time.Now() + c.Next() + latency := time.Since(start) + entry := GetRequestLogger(c) + entry.WithFields(map[string]interface{}{ + "status": c.Writer.Status(), + "method": c.Request.Method, + "path": SanitizePath(c.Request.URL.Path), + "latency": latency.String(), + "client": util.SanitizeForLog(c.ClientIP()), + }).Info("handled request") + } } diff --git a/backend/internal/api/middleware/request_logger_test.go b/backend/internal/api/middleware/request_logger_test.go index 59352639..8282c81e 100644 --- a/backend/internal/api/middleware/request_logger_test.go +++ b/backend/internal/api/middleware/request_logger_test.go @@ -1,72 +1,72 @@ package middleware import ( - "bytes" - "net/http" - "net/http/httptest" - "strings" - "testing" + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" - "github.com/Wikid82/charon/backend/internal/logger" - "github.com/gin-gonic/gin" + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/gin-gonic/gin" ) func TestRequestLoggerSanitizesPath(t *testing.T) { - old := logger.Log() - buf := &bytes.Buffer{} - logger.Init(true, buf) + old := logger.Log() + buf := &bytes.Buffer{} + logger.Init(true, buf) - longPath := "/" + strings.Repeat("a", 300) + longPath := "/" + strings.Repeat("a", 300) - router := gin.New() - router.Use(RequestID()) - router.Use(RequestLogger()) - router.GET(longPath, func(c *gin.Context) { c.Status(http.StatusOK) }) + router := gin.New() + router.Use(RequestID()) + router.Use(RequestLogger()) + router.GET(longPath, func(c *gin.Context) { c.Status(http.StatusOK) }) - req := httptest.NewRequest(http.MethodGet, longPath, nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, longPath, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) - out := buf.String() - if strings.Contains(out, strings.Repeat("a", 300)) { - t.Fatalf("logged unsanitized long path") - } - i := strings.Index(out, "path=") - if i == -1 { - t.Fatalf("could not find path in logs: %s", out) - } - sub := out[i:] - j := strings.Index(sub, " request_id=") - if j == -1 { - t.Fatalf("could not isolate path field from logs: %s", out) - } - pathField := sub[len("path=") : j] - if strings.Contains(pathField, "\n") || strings.Contains(pathField, "\r") { - t.Fatalf("path field contains control characters after sanitization: %s", pathField) - } - _ = old // silence unused var + out := buf.String() + if strings.Contains(out, strings.Repeat("a", 300)) { + t.Fatalf("logged unsanitized long path") + } + i := strings.Index(out, "path=") + if i == -1 { + t.Fatalf("could not find path in logs: %s", out) + } + sub := out[i:] + j := strings.Index(sub, " request_id=") + if j == -1 { + t.Fatalf("could not isolate path field from logs: %s", out) + } + pathField := sub[len("path="):j] + if strings.Contains(pathField, "\n") || strings.Contains(pathField, "\r") { + t.Fatalf("path field contains control characters after sanitization: %s", pathField) + } + _ = old // silence unused var } func TestRequestLoggerIncludesRequestID(t *testing.T) { - buf := &bytes.Buffer{} - logger.Init(true, buf) + buf := &bytes.Buffer{} + logger.Init(true, buf) - router := gin.New() - router.Use(RequestID()) - router.Use(RequestLogger()) - router.GET("/ok", func(c *gin.Context) { c.String(200, "ok") }) + router := gin.New() + router.Use(RequestID()) + router.Use(RequestLogger()) + router.GET("/ok", func(c *gin.Context) { c.String(200, "ok") }) - req := httptest.NewRequest(http.MethodGet, "/ok", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("unexpected status code: %d", w.Code) - } - out := buf.String() - if !strings.Contains(out, "request_id") { - t.Fatalf("expected log output to include request_id: %s", out) - } - if !strings.Contains(out, "handled request") { - t.Fatalf("expected log output to indicate handled request: %s", out) - } + req := httptest.NewRequest(http.MethodGet, "/ok", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("unexpected status code: %d", w.Code) + } + out := buf.String() + if !strings.Contains(out, "request_id") { + t.Fatalf("expected log output to include request_id: %s", out) + } + if !strings.Contains(out, "handled request") { + t.Fatalf("expected log output to indicate handled request: %s", out) + } } diff --git a/backend/internal/api/middleware/sanitize.go b/backend/internal/api/middleware/sanitize.go index beb7501b..ad8f878a 100644 --- a/backend/internal/api/middleware/sanitize.go +++ b/backend/internal/api/middleware/sanitize.go @@ -1,62 +1,62 @@ package middleware import ( - "net/http" - "strings" + "net/http" + "strings" - "github.com/Wikid82/charon/backend/internal/util" + "github.com/Wikid82/charon/backend/internal/util" ) // SanitizeHeaders returns a map of header keys to redacted/sanitized values // for safe logging. Sensitive headers are redacted; other values are // sanitized using util.SanitizeForLog and truncated. func SanitizeHeaders(h http.Header) map[string][]string { - if h == nil { - return nil - } - sensitive := map[string]struct{}{ - "authorization": {}, - "cookie": {}, - "set-cookie": {}, - "proxy-authorization": {}, - "x-api-key": {}, - "x-api-token": {}, - "x-access-token": {}, - "x-auth-token": {}, - "x-api-secret": {}, - "x-forwarded-for": {}, - } - out := make(map[string][]string, len(h)) - for k, vals := range h { - keyLower := strings.ToLower(k) - if _, ok := sensitive[keyLower]; ok { - out[k] = []string{""} - continue - } - sanitizedVals := make([]string, 0, len(vals)) - for _, v := range vals { - v2 := util.SanitizeForLog(v) - if len(v2) > 200 { - v2 = v2[:200] - } - sanitizedVals = append(sanitizedVals, v2) - } - out[k] = sanitizedVals - } - return out + if h == nil { + return nil + } + sensitive := map[string]struct{}{ + "authorization": {}, + "cookie": {}, + "set-cookie": {}, + "proxy-authorization": {}, + "x-api-key": {}, + "x-api-token": {}, + "x-access-token": {}, + "x-auth-token": {}, + "x-api-secret": {}, + "x-forwarded-for": {}, + } + out := make(map[string][]string, len(h)) + for k, vals := range h { + keyLower := strings.ToLower(k) + if _, ok := sensitive[keyLower]; ok { + out[k] = []string{""} + continue + } + sanitizedVals := make([]string, 0, len(vals)) + for _, v := range vals { + v2 := util.SanitizeForLog(v) + if len(v2) > 200 { + v2 = v2[:200] + } + sanitizedVals = append(sanitizedVals, v2) + } + out[k] = sanitizedVals + } + return out } // SanitizePath prepares a request path for safe logging by removing // control characters and truncating long values. It does not include // query parameters. func SanitizePath(p string) string { - // remove query string - if i := strings.Index(p, "?"); i != -1 { - p = p[:i] - } - p = util.SanitizeForLog(p) - if len(p) > 200 { - p = p[:200] - } - return p + // remove query string + if i := strings.Index(p, "?"); i != -1 { + p = p[:i] + } + p = util.SanitizeForLog(p) + if len(p) > 200 { + p = p[:200] + } + return p } diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 0810a41d..ec186abb 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -6,6 +6,8 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" "gorm.io/gorm" "github.com/Wikid82/charon/backend/internal/api/handlers" @@ -13,9 +15,10 @@ import ( "github.com/Wikid82/charon/backend/internal/caddy" "github.com/Wikid82/charon/backend/internal/cerberus" "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/metrics" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" - "github.com/Wikid82/charon/backend/internal/logger" ) // Register wires up API routes and performs automatic migrations. @@ -64,6 +67,13 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { router.GET("/api/v1/health", handlers.HealthHandler) + // Metrics endpoint (Prometheus) + reg := prometheus.NewRegistry() + metrics.Register(reg) + router.GET("/metrics", func(c *gin.Context) { + promhttp.HandlerFor(reg, promhttp.HandlerOpts{}).ServeHTTP(c.Writer, c.Request) + }) + api := router.Group("/api/v1") // Cerberus middleware applies the optional security suite checks (WAF, ACL, CrowdSec) diff --git a/backend/internal/caddy/manager_additional_test.go b/backend/internal/caddy/manager_additional_test.go index 2a179214..b92dbc50 100644 --- a/backend/internal/caddy/manager_additional_test.go +++ b/backend/internal/caddy/manager_additional_test.go @@ -245,7 +245,7 @@ func TestManager_ApplyConfig_RotateSnapshotsWarning(t *testing.T) { os.Chtimes(p, tmo, tmo) } - client := NewClient(caddyServer.URL) + client := NewClient(caddyServer.URL) manager := NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"}) @@ -718,15 +718,15 @@ func TestManager_ApplyConfig_IncludesWAFHandlerWithRuleset(t *testing.T) { if h == "ruleset.example.com" { for _, handle := range r.Handle { if handlerName, ok := handle["handler"].(string); ok && handlerName == "waf" { - // Validate include array (coraza-caddy schema) or inline ruleset_content presence - if incl, ok := handle["include"].([]interface{}); ok && len(incl) > 0 { - if rf, ok := incl[0].(string); ok && rf != "" { - // Ensure file exists and contains our content - // Note: manager prepends SecRuleEngine On directives, so we check Contains - b, err := os.ReadFile(rf) - if err == nil && strings.Contains(string(b), "test-rule-content") { - found = true - } + // Validate include array (coraza-caddy schema) or inline ruleset_content presence + if incl, ok := handle["include"].([]interface{}); ok && len(incl) > 0 { + if rf, ok := incl[0].(string); ok && rf != "" { + // Ensure file exists and contains our content + // Note: manager prepends SecRuleEngine On directives, so we check Contains + b, err := os.ReadFile(rf) + if err == nil && strings.Contains(string(b), "test-rule-content") { + found = true + } } } // Inline content may also exist as a fallback diff --git a/backend/internal/cerberus/cerberus.go b/backend/internal/cerberus/cerberus.go index 4289b657..90c624e6 100644 --- a/backend/internal/cerberus/cerberus.go +++ b/backend/internal/cerberus/cerberus.go @@ -8,6 +8,8 @@ import ( "gorm.io/gorm" "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/metrics" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" ) @@ -64,10 +66,30 @@ func (c *Cerberus) Middleware() gin.HandlerFunc { // WAF: naive example check - block requests containing