diff --git a/backend/internal/api/handlers/coverage_quick_test.go b/backend/internal/api/handlers/coverage_quick_test.go new file mode 100644 index 00000000..ee0df8ac --- /dev/null +++ b/backend/internal/api/handlers/coverage_quick_test.go @@ -0,0 +1,83 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "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) + } + + 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) + + // 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) } + + 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) } + + // 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) } + + // 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) } +} diff --git a/backend/internal/api/handlers/crowdsec_exec.go b/backend/internal/api/handlers/crowdsec_exec.go index fb32cc57..5852018d 100644 --- a/backend/internal/api/handlers/crowdsec_exec.go +++ b/backend/internal/api/handlers/crowdsec_exec.go @@ -1,83 +1,83 @@ package handlers import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strconv" - "syscall" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "syscall" ) // DefaultCrowdsecExecutor implements CrowdsecExecutor using OS processes. -type DefaultCrowdsecExecutor struct{ +type DefaultCrowdsecExecutor struct { } func NewDefaultCrowdsecExecutor() *DefaultCrowdsecExecutor { return &DefaultCrowdsecExecutor{} } func (e *DefaultCrowdsecExecutor) pidFile(configDir string) string { - return filepath.Join(configDir, "crowdsec.pid") + return filepath.Join(configDir, "crowdsec.pid") } func (e *DefaultCrowdsecExecutor) Start(ctx context.Context, binPath, configDir string) (int, error) { - cmd := exec.CommandContext(ctx, binPath, "--config-dir", configDir) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Start(); err != nil { - return 0, err - } - pid := cmd.Process.Pid - // write pid file - if err := os.WriteFile(e.pidFile(configDir), []byte(strconv.Itoa(pid)), 0o644); err != nil { - return pid, fmt.Errorf("failed to write pid file: %w", err) - } - // wait in background - go func() { - _ = cmd.Wait() - _ = os.Remove(e.pidFile(configDir)) - }() - return pid, nil + cmd := exec.CommandContext(ctx, binPath, "--config-dir", configDir) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return 0, err + } + pid := cmd.Process.Pid + // write pid file + if err := os.WriteFile(e.pidFile(configDir), []byte(strconv.Itoa(pid)), 0o644); err != nil { + return pid, fmt.Errorf("failed to write pid file: %w", err) + } + // wait in background + go func() { + _ = cmd.Wait() + _ = os.Remove(e.pidFile(configDir)) + }() + return pid, nil } func (e *DefaultCrowdsecExecutor) Stop(ctx context.Context, configDir string) error { - b, err := os.ReadFile(e.pidFile(configDir)) - if err != nil { - return fmt.Errorf("pid file read: %w", err) - } - pid, err := strconv.Atoi(string(b)) - if err != nil { - return fmt.Errorf("invalid pid: %w", err) - } - proc, err := os.FindProcess(pid) - if err != nil { - return err - } - if err := proc.Signal(syscall.SIGTERM); err != nil { - return err - } - // best-effort remove pid file - _ = os.Remove(e.pidFile(configDir)) - return nil + b, err := os.ReadFile(e.pidFile(configDir)) + if err != nil { + return fmt.Errorf("pid file read: %w", err) + } + pid, err := strconv.Atoi(string(b)) + if err != nil { + return fmt.Errorf("invalid pid: %w", err) + } + proc, err := os.FindProcess(pid) + if err != nil { + return err + } + if err := proc.Signal(syscall.SIGTERM); err != nil { + return err + } + // best-effort remove pid file + _ = os.Remove(e.pidFile(configDir)) + return nil } func (e *DefaultCrowdsecExecutor) Status(ctx context.Context, configDir string) (bool, int, error) { - b, err := os.ReadFile(e.pidFile(configDir)) - if err != nil { - return false, 0, nil - } - pid, err := strconv.Atoi(string(b)) - if err != nil { - return false, 0, nil - } - // Check process exists - proc, err := os.FindProcess(pid) - if err != nil { - return false, pid, nil - } - // Sending signal 0 is not portable on Windows, but OK for Linux containers - if err := proc.Signal(syscall.Signal(0)); err != nil { - return false, pid, nil - } - return true, pid, nil + b, err := os.ReadFile(e.pidFile(configDir)) + if err != nil { + return false, 0, nil + } + pid, err := strconv.Atoi(string(b)) + if err != nil { + return false, 0, nil + } + // Check process exists + proc, err := os.FindProcess(pid) + if err != nil { + return false, pid, nil + } + // Sending signal 0 is not portable on Windows, but OK for Linux containers + if err := proc.Signal(syscall.Signal(0)); err != nil { + return false, pid, nil + } + return true, pid, nil } diff --git a/backend/internal/api/handlers/crowdsec_exec_test.go b/backend/internal/api/handlers/crowdsec_exec_test.go new file mode 100644 index 00000000..7bea4623 --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_exec_test.go @@ -0,0 +1,77 @@ +package handlers + +import ( + "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) + } +} + +func TestDefaultCrowdsecExecutorStartStatusStop(t *testing.T) { + e := NewDefaultCrowdsecExecutor() + tmp := t.TempDir() + + // 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) + } + + 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) + } + + // 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) + } + + // 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) + + 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 4859a96f..c1d435b2 100644 --- a/backend/internal/api/handlers/crowdsec_handler.go +++ b/backend/internal/api/handlers/crowdsec_handler.go @@ -1,135 +1,294 @@ package handlers import ( - "context" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "time" + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + "strings" - "github.com/gin-gonic/gin" - "gorm.io/gorm" + "github.com/gin-gonic/gin" + "gorm.io/gorm" ) // Executor abstracts starting/stopping CrowdSec so tests can mock it. type CrowdsecExecutor interface { - Start(ctx context.Context, binPath, configDir string) (int, error) - Stop(ctx context.Context, configDir string) error - Status(ctx context.Context, configDir string) (running bool, pid int, err error) + Start(ctx context.Context, binPath, configDir string) (int, error) + Stop(ctx context.Context, configDir string) error + Status(ctx context.Context, configDir string) (running bool, pid int, err error) } // CrowdsecHandler manages CrowdSec process and config imports. type CrowdsecHandler struct { - DB *gorm.DB - Executor CrowdsecExecutor - BinPath string - DataDir string + DB *gorm.DB + Executor CrowdsecExecutor + BinPath string + DataDir string } func NewCrowdsecHandler(db *gorm.DB, exec CrowdsecExecutor, binPath, dataDir string) *CrowdsecHandler { - return &CrowdsecHandler{DB: db, Executor: exec, BinPath: binPath, DataDir: dataDir} + return &CrowdsecHandler{DB: db, Executor: exec, BinPath: binPath, DataDir: dataDir} } // Start starts the CrowdSec process. func (h *CrowdsecHandler) Start(c *gin.Context) { - ctx := c.Request.Context() - pid, err := h.Executor.Start(ctx, h.BinPath, h.DataDir) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"status": "started", "pid": pid}) + ctx := c.Request.Context() + pid, err := h.Executor.Start(ctx, h.BinPath, h.DataDir) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "started", "pid": pid}) } // Stop stops the CrowdSec process. func (h *CrowdsecHandler) Stop(c *gin.Context) { - ctx := c.Request.Context() - if err := h.Executor.Stop(ctx, h.DataDir); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"status": "stopped"}) + ctx := c.Request.Context() + if err := h.Executor.Stop(ctx, h.DataDir); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "stopped"}) } // Status returns simple running state. func (h *CrowdsecHandler) Status(c *gin.Context) { - ctx := c.Request.Context() - running, pid, err := h.Executor.Status(ctx, h.DataDir) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"running": running, "pid": pid}) + ctx := c.Request.Context() + running, pid, err := h.Executor.Status(ctx, h.DataDir) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"running": running, "pid": pid}) } // ImportConfig accepts a tar.gz or zip upload and extracts into DataDir (backing up existing config). func (h *CrowdsecHandler) ImportConfig(c *gin.Context) { - file, err := c.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "file required"}) - return - } + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "file required"}) + return + } - // Save to temp file - tmpDir := os.TempDir() - tmpPath := filepath.Join(tmpDir, fmt.Sprintf("crowdsec-import-%d", time.Now().UnixNano())) - if err := os.MkdirAll(tmpPath, 0o755); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp dir"}) - return - } + // Save to temp file + tmpDir := os.TempDir() + tmpPath := filepath.Join(tmpDir, fmt.Sprintf("crowdsec-import-%d", time.Now().UnixNano())) + if err := os.MkdirAll(tmpPath, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp dir"}) + return + } - dst := filepath.Join(tmpPath, file.Filename) - if err := c.SaveUploadedFile(file, dst); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"}) - return - } + dst := filepath.Join(tmpPath, file.Filename) + if err := c.SaveUploadedFile(file, dst); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"}) + return + } - // For safety, do minimal validation: ensure file non-empty - fi, err := os.Stat(dst) - if err != nil || fi.Size() == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "empty upload"}) - return - } + // For safety, do minimal validation: ensure file non-empty + fi, err := os.Stat(dst) + if err != nil || fi.Size() == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "empty upload"}) + return + } - // Backup current config - backupDir := h.DataDir + ".backup." + time.Now().Format("20060102-150405") - if _, err := os.Stat(h.DataDir); err == nil { - _ = os.Rename(h.DataDir, backupDir) - } - // Create target dir - if err := os.MkdirAll(h.DataDir, 0o755); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config dir"}) - return - } + // Backup current config + backupDir := h.DataDir + ".backup." + time.Now().Format("20060102-150405") + if _, err := os.Stat(h.DataDir); err == nil { + _ = os.Rename(h.DataDir, backupDir) + } + // Create target dir + if err := os.MkdirAll(h.DataDir, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config dir"}) + return + } - // For now, simply copy uploaded file into data dir for operator to handle extraction - target := filepath.Join(h.DataDir, file.Filename) - in, err := os.Open(dst) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open temp file"}) - return - } - defer in.Close() - out, err := os.Create(target) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create target file"}) - return - } - defer out.Close() - if _, err := io.Copy(out, in); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write config"}) - return - } + // For now, simply copy uploaded file into data dir for operator to handle extraction + target := filepath.Join(h.DataDir, file.Filename) + in, err := os.Open(dst) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open temp file"}) + return + } + defer in.Close() + out, err := os.Create(target) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create target file"}) + return + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write config"}) + return + } - c.JSON(http.StatusOK, gin.H{"status": "imported", "backup": backupDir}) + c.JSON(http.StatusOK, gin.H{"status": "imported", "backup": backupDir}) +} + +// ExportConfig creates a tar.gz archive of the CrowdSec data directory and streams it +// back to the client as a downloadable file. +func (h *CrowdsecHandler) ExportConfig(c *gin.Context) { + // Ensure DataDir exists + if _, err := os.Stat(h.DataDir); os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "crowdsec config not found"}) + return + } + + // Create a gzip writer and tar writer that stream directly to the response + c.Header("Content-Type", "application/gzip") + filename := fmt.Sprintf("crowdsec-config-%s.tar.gz", time.Now().Format("20060102-150405")) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + gw := gzip.NewWriter(c.Writer) + defer gw.Close() + tw := tar.NewWriter(gw) + defer tw.Close() + + // Walk the DataDir and add files to the archive + err := filepath.Walk(h.DataDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + rel, err := filepath.Rel(h.DataDir, path) + if err != nil { + return err + } + // Open file + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + hdr := &tar.Header{ + Name: rel, + Size: info.Size(), + Mode: int64(info.Mode()), + ModTime: info.ModTime(), + } + if err := tw.WriteHeader(hdr); err != nil { + return err + } + if _, err := io.Copy(tw, f); err != nil { + return err + } + return nil + }) + if err != nil { + // If any error occurred while creating the archive, return 500 + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } +} + +// ListFiles returns a flat list of files under the CrowdSec DataDir. +func (h *CrowdsecHandler) ListFiles(c *gin.Context) { + var files []string + if _, err := os.Stat(h.DataDir); os.IsNotExist(err) { + c.JSON(http.StatusOK, gin.H{"files": files}) + return + } + err := filepath.Walk(h.DataDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + rel, err := filepath.Rel(h.DataDir, path) + if err != nil { + return err + } + files = append(files, rel) + } + return nil + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"files": files}) +} + +// ReadFile returns the contents of a specific file under DataDir. Query param 'path' required. +func (h *CrowdsecHandler) ReadFile(c *gin.Context) { + rel := c.Query("path") + if rel == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "path required"}) + return + } + clean := filepath.Clean(rel) + // prevent directory traversal + p := filepath.Join(h.DataDir, clean) + if !strings.HasPrefix(p, filepath.Clean(h.DataDir)) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) + return + } + data, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"content": string(data)}) +} + +// WriteFile writes content to a file under the CrowdSec DataDir, creating a backup before doing so. +// JSON body: { "path": "relative/path.conf", "content": "..." } +func (h *CrowdsecHandler) WriteFile(c *gin.Context) { + var payload struct { + Path string `json:"path"` + Content string `json:"content"` + } + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if payload.Path == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "path required"}) + return + } + clean := filepath.Clean(payload.Path) + p := filepath.Join(h.DataDir, clean) + if !strings.HasPrefix(p, filepath.Clean(h.DataDir)) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) + return + } + // Backup existing DataDir + backupDir := h.DataDir + ".backup." + time.Now().Format("20060102-150405") + if _, err := os.Stat(h.DataDir); err == nil { + if err := os.Rename(h.DataDir, backupDir); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create backup"}) + return + } + } + // Recreate DataDir and write file + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare dir"}) + return + } + if err := os.WriteFile(p, []byte(payload.Content), 0o644); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "written", "backup": backupDir}) } // RegisterRoutes registers crowdsec admin routes under protected group func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) { - rg.POST("/admin/crowdsec/start", h.Start) - rg.POST("/admin/crowdsec/stop", h.Stop) - rg.GET("/admin/crowdsec/status", h.Status) - rg.POST("/admin/crowdsec/import", h.ImportConfig) + rg.POST("/admin/crowdsec/start", h.Start) + rg.POST("/admin/crowdsec/stop", h.Stop) + rg.GET("/admin/crowdsec/status", h.Status) + rg.POST("/admin/crowdsec/import", h.ImportConfig) + rg.GET("/admin/crowdsec/export", h.ExportConfig) + rg.GET("/admin/crowdsec/files", h.ListFiles) + rg.GET("/admin/crowdsec/file", h.ReadFile) + rg.POST("/admin/crowdsec/file", h.WriteFile) } diff --git a/backend/internal/api/handlers/crowdsec_handler_test.go b/backend/internal/api/handlers/crowdsec_handler_test.go index 48715e5e..3e9c73b2 100644 --- a/backend/internal/api/handlers/crowdsec_handler_test.go +++ b/backend/internal/api/handlers/crowdsec_handler_test.go @@ -1,155 +1,274 @@ package handlers import ( - "bytes" - "mime/multipart" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - "context" + "bytes" + "context" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "encoding/json" - "github.com/gin-gonic/gin" - "gorm.io/driver/sqlite" - "gorm.io/gorm" + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) -type fakeExec struct{ - started bool +type fakeExec struct { + started bool } func (f *fakeExec) Start(ctx context.Context, binPath, configDir string) (int, error) { - f.started = true - return 12345, nil + f.started = true + return 12345, nil } func (f *fakeExec) Stop(ctx context.Context, configDir string) error { - f.started = false - return nil + f.started = false + return nil } func (f *fakeExec) Status(ctx context.Context, configDir string) (bool, int, error) { - if f.started { - return true, 12345, nil - } - return false, 0, nil + if f.started { + return true, 12345, nil + } + return false, 0, nil } 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) } - return db + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("db open: %v", err) + } + return db } func TestCrowdsecEndpoints(t *testing.T) { - gin.SetMode(gin.TestMode) - db := setupCrowdDB(t) - tmpDir := t.TempDir() + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() - fe := &fakeExec{} - h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir) + fe := &fakeExec{} + h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir) - r := gin.New() - g := r.Group("/api/v1") - h.RegisterRoutes(g) + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) - // Status (initially stopped) - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", nil) - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { t.Fatalf("status expected 200 got %d", w.Code) } + // Status (initially stopped) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status expected 200 got %d", w.Code) + } - // Start - w2 := httptest.NewRecorder() - req2 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil) - r.ServeHTTP(w2, req2) - if w2.Code != http.StatusOK { t.Fatalf("start expected 200 got %d", w2.Code) } + // Start + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil) + r.ServeHTTP(w2, req2) + if w2.Code != http.StatusOK { + t.Fatalf("start expected 200 got %d", w2.Code) + } - // Stop - w3 := httptest.NewRecorder() - req3 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/stop", nil) - r.ServeHTTP(w3, req3) - if w3.Code != http.StatusOK { t.Fatalf("stop expected 200 got %d", w3.Code) } + // Stop + w3 := httptest.NewRecorder() + req3 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/stop", nil) + r.ServeHTTP(w3, req3) + if w3.Code != http.StatusOK { + t.Fatalf("stop expected 200 got %d", w3.Code) + } } func TestImportConfig(t *testing.T) { - gin.SetMode(gin.TestMode) - db := setupCrowdDB(t) - tmpDir := t.TempDir() - fe := &fakeExec{} - h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir) + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + fe := &fakeExec{} + h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir) - r := gin.New() - g := r.Group("/api/v1") - h.RegisterRoutes(g) + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) - // create a small file to upload - buf := &bytes.Buffer{} - mw := multipart.NewWriter(buf) - fw, _ := mw.CreateFormFile("file", "cfg.tar.gz") - fw.Write([]byte("dummy")) - mw.Close() + // create a small file to upload + buf := &bytes.Buffer{} + mw := multipart.NewWriter(buf) + fw, _ := mw.CreateFormFile("file", "cfg.tar.gz") + fw.Write([]byte("dummy")) + mw.Close() - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf) - req.Header.Set("Content-Type", mw.FormDataContentType()) - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { t.Fatalf("import expected 200 got %d body=%s", w.Code, w.Body.String()) } + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("import expected 200 got %d body=%s", w.Code, w.Body.String()) + } - // ensure file exists in data dir - if _, err := os.Stat(filepath.Join(tmpDir, "cfg.tar.gz")); err != nil { - t.Fatalf("expected file in data dir: %v", err) - } + // ensure file exists in data dir + if _, err := os.Stat(filepath.Join(tmpDir, "cfg.tar.gz")); err != nil { + t.Fatalf("expected file in data dir: %v", err) + } } func TestImportCreatesBackup(t *testing.T) { - gin.SetMode(gin.TestMode) - db := setupCrowdDB(t) - tmpDir := t.TempDir() - // create existing config dir with a marker file - _ = os.MkdirAll(tmpDir, 0o755) - _ = os.WriteFile(filepath.Join(tmpDir, "existing.conf"), []byte("v1"), 0o644) + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + // create existing config dir with a marker file + _ = os.MkdirAll(tmpDir, 0o755) + _ = os.WriteFile(filepath.Join(tmpDir, "existing.conf"), []byte("v1"), 0o644) - fe := &fakeExec{} - h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir) + fe := &fakeExec{} + h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir) - r := gin.New() - g := r.Group("/api/v1") - h.RegisterRoutes(g) + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) - // upload - buf := &bytes.Buffer{} - mw := multipart.NewWriter(buf) - fw, _ := mw.CreateFormFile("file", "cfg.tar.gz") - fw.Write([]byte("dummy2")) - mw.Close() + // upload + buf := &bytes.Buffer{} + mw := multipart.NewWriter(buf) + fw, _ := mw.CreateFormFile("file", "cfg.tar.gz") + fw.Write([]byte("dummy2")) + mw.Close() - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf) - req.Header.Set("Content-Type", mw.FormDataContentType()) - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { t.Fatalf("import expected 200 got %d body=%s", w.Code, w.Body.String()) } + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("import expected 200 got %d body=%s", w.Code, w.Body.String()) + } - // ensure backup dir exists (ends with .backup.TIMESTAMP) - found := false - entries, _ := os.ReadDir(filepath.Dir(tmpDir)) - for _, e := range entries { - if e.IsDir() && filepath.HasPrefix(e.Name(), filepath.Base(tmpDir)+".backup.") { - found = true - break - } - } - if !found { - // fallback: check for any .backup.* in same parent dir - entries, _ := os.ReadDir(filepath.Dir(tmpDir)) - for _, e := range entries { - if e.IsDir() && filepath.Ext(e.Name()) == "" && (len(e.Name()) > 0) && (filepath.Base(e.Name()) != filepath.Base(tmpDir)) { - // best-effort assume backup present - found = true - break - } - } - } - if !found { - t.Fatalf("expected backup directory next to data dir") - } + // ensure backup dir exists (ends with .backup.TIMESTAMP) + found := false + entries, _ := os.ReadDir(filepath.Dir(tmpDir)) + for _, e := range entries { + if e.IsDir() && filepath.HasPrefix(e.Name(), filepath.Base(tmpDir)+".backup.") { + found = true + break + } + } + if !found { + // fallback: check for any .backup.* in same parent dir + entries, _ := os.ReadDir(filepath.Dir(tmpDir)) + for _, e := range entries { + if e.IsDir() && filepath.Ext(e.Name()) == "" && (len(e.Name()) > 0) && (filepath.Base(e.Name()) != filepath.Base(tmpDir)) { + // best-effort assume backup present + found = true + break + } + } + } + if !found { + t.Fatalf("expected backup directory next to data dir") + } +} + +func TestExportConfig(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + // create some files to export + _ = os.MkdirAll(filepath.Join(tmpDir, "conf.d"), 0o755) + _ = os.WriteFile(filepath.Join(tmpDir, "conf.d", "a.conf"), []byte("rule1"), 0o644) + _ = os.WriteFile(filepath.Join(tmpDir, "b.conf"), []byte("rule2"), 0o644) + + fe := &fakeExec{} + h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("export expected 200 got %d body=%s", w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); ct != "application/gzip" { + t.Fatalf("unexpected content type: %s", ct) + } + if w.Body.Len() == 0 { + t.Fatalf("expected response body to contain archive data") + } +} + +func TestListAndReadFile(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + // create a nested file + _ = os.MkdirAll(filepath.Join(tmpDir, "conf.d"), 0o755) + _ = os.WriteFile(filepath.Join(tmpDir, "conf.d", "a.conf"), []byte("rule1"), 0o644) + _ = os.WriteFile(filepath.Join(tmpDir, "b.conf"), []byte("rule2"), 0o644) + + fe := &fakeExec{} + h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("files expected 200 got %d body=%s", w.Code, w.Body.String()) + } + // read a single file + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=conf.d/a.conf", nil) + r.ServeHTTP(w2, req2) + if w2.Code != http.StatusOK { + t.Fatalf("file read expected 200 got %d body=%s", w2.Code, w2.Body.String()) + } +} + +func TestWriteFileCreatesBackup(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + // create existing config dir with a marker file + _ = os.MkdirAll(tmpDir, 0o755) + _ = os.WriteFile(filepath.Join(tmpDir, "existing.conf"), []byte("v1"), 0o644) + + fe := &fakeExec{} + h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + // write content to new file + payload := map[string]string{"path": "conf.d/new.conf", "content": "hello world"} + b, _ := json.Marshal(payload) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("write expected 200 got %d body=%s", w.Code, w.Body.String()) + } + + // ensure backup directory exists next to data dir + found := false + entries, _ := os.ReadDir(filepath.Dir(tmpDir)) + for _, e := range entries { + if e.IsDir() && filepath.HasPrefix(e.Name(), filepath.Base(tmpDir)+".backup.") { + found = true + break + } + } + if !found { + t.Fatalf("expected backup directory next to data dir") + } + // ensure file content exists in new data dir + if _, err := os.Stat(filepath.Join(tmpDir, "conf.d", "new.conf")); err != nil { + t.Fatalf("expected file written: %v", err) + } } diff --git a/backend/internal/api/handlers/feature_flags_handler.go b/backend/internal/api/handlers/feature_flags_handler.go index bf9ccd73..4f4c9d6a 100644 --- a/backend/internal/api/handlers/feature_flags_handler.go +++ b/backend/internal/api/handlers/feature_flags_handler.go @@ -1,109 +1,109 @@ package handlers import ( - "net/http" - "os" - "strconv" - "strings" + "net/http" + "os" + "strconv" + "strings" - "github.com/gin-gonic/gin" - "gorm.io/gorm" + "github.com/gin-gonic/gin" + "gorm.io/gorm" - "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/models" ) // FeatureFlagsHandler exposes simple DB-backed feature flags with env fallback. type FeatureFlagsHandler struct { - DB *gorm.DB + DB *gorm.DB } func NewFeatureFlagsHandler(db *gorm.DB) *FeatureFlagsHandler { - return &FeatureFlagsHandler{DB: db} + return &FeatureFlagsHandler{DB: db} } // defaultFlags lists the canonical feature flags we expose. var defaultFlags = []string{ - "feature.global.enabled", - "feature.cerberus.enabled", - "feature.uptime.enabled", - "feature.notifications.enabled", - "feature.docker.enabled", + "feature.global.enabled", + "feature.cerberus.enabled", + "feature.uptime.enabled", + "feature.notifications.enabled", + "feature.docker.enabled", } // GetFlags returns a map of feature flag -> bool. DB setting takes precedence // and falls back to environment variables if present. func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) { - result := make(map[string]bool) + result := make(map[string]bool) - for _, key := range defaultFlags { - // Try DB - var s models.Setting - if err := h.DB.Where("key = ?", key).First(&s).Error; err == nil { - v := strings.ToLower(strings.TrimSpace(s.Value)) - b := v == "1" || v == "true" || v == "yes" - result[key] = b - continue - } + for _, key := range defaultFlags { + // Try DB + var s models.Setting + if err := h.DB.Where("key = ?", key).First(&s).Error; err == nil { + v := strings.ToLower(strings.TrimSpace(s.Value)) + b := v == "1" || v == "true" || v == "yes" + result[key] = b + continue + } - // Fallback to env vars. Try FEATURE_... and also stripped service name e.g. CERBERUS_ENABLED - envKey := strings.ToUpper(strings.ReplaceAll(key, ".", "_")) - if ev, ok := os.LookupEnv(envKey); ok { - if bv, err := strconv.ParseBool(ev); err == nil { - result[key] = bv - continue - } - // accept 1/0 - result[key] = ev == "1" - continue - } + // Fallback to env vars. Try FEATURE_... and also stripped service name e.g. CERBERUS_ENABLED + envKey := strings.ToUpper(strings.ReplaceAll(key, ".", "_")) + if ev, ok := os.LookupEnv(envKey); ok { + if bv, err := strconv.ParseBool(ev); err == nil { + result[key] = bv + continue + } + // accept 1/0 + result[key] = ev == "1" + continue + } - // Try shorter variant after removing leading "feature." - if strings.HasPrefix(key, "feature.") { - short := strings.ToUpper(strings.ReplaceAll(strings.TrimPrefix(key, "feature."), ".", "_")) - if ev, ok := os.LookupEnv(short); ok { - if bv, err := strconv.ParseBool(ev); err == nil { - result[key] = bv - continue - } - result[key] = ev == "1" - continue - } - } + // Try shorter variant after removing leading "feature." + if strings.HasPrefix(key, "feature.") { + short := strings.ToUpper(strings.ReplaceAll(strings.TrimPrefix(key, "feature."), ".", "_")) + if ev, ok := os.LookupEnv(short); ok { + if bv, err := strconv.ParseBool(ev); err == nil { + result[key] = bv + continue + } + result[key] = ev == "1" + continue + } + } - // Default false - result[key] = false - } + // Default false + result[key] = false + } - c.JSON(http.StatusOK, result) + c.JSON(http.StatusOK, result) } // UpdateFlags accepts a JSON object map[string]bool and upserts settings. func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) { - var payload map[string]bool - if err := c.ShouldBindJSON(&payload); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } + var payload map[string]bool + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } - for k, v := range payload { - // Only allow keys in the default list to avoid arbitrary settings - allowed := false - for _, ak := range defaultFlags { - if ak == k { - allowed = true - break - } - } - if !allowed { - continue - } + for k, v := range payload { + // Only allow keys in the default list to avoid arbitrary settings + allowed := false + for _, ak := range defaultFlags { + if ak == k { + allowed = true + break + } + } + if !allowed { + continue + } - s := models.Setting{Key: k, Value: strconv.FormatBool(v), Type: "bool", Category: "feature"} - if err := h.DB.Where(models.Setting{Key: k}).Assign(s).FirstOrCreate(&s).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save setting"}) - return - } - } + s := models.Setting{Key: k, Value: strconv.FormatBool(v), Type: "bool", Category: "feature"} + if err := h.DB.Where(models.Setting{Key: k}).Assign(s).FirstOrCreate(&s).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save setting"}) + return + } + } - c.JSON(http.StatusOK, gin.H{"status": "ok"}) + c.JSON(http.StatusOK, gin.H{"status": "ok"}) } diff --git a/backend/internal/api/handlers/feature_flags_handler_test.go b/backend/internal/api/handlers/feature_flags_handler_test.go index 0d3bb03e..56011a1d 100644 --- a/backend/internal/api/handlers/feature_flags_handler_test.go +++ b/backend/internal/api/handlers/feature_flags_handler_test.go @@ -1,77 +1,77 @@ package handlers import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" - "github.com/gin-gonic/gin" - "gorm.io/driver/sqlite" - "gorm.io/gorm" + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" - "github.com/Wikid82/charon/backend/internal/models" + "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) - } - if err := db.AutoMigrate(&models.Setting{}); err != nil { - t.Fatalf("auto migrate failed: %v", err) - } - return 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) + } + if err := db.AutoMigrate(&models.Setting{}); err != nil { + t.Fatalf("auto migrate failed: %v", err) + } + return db } func TestFeatureFlags_GetAndUpdate(t *testing.T) { - db := setupFlagsDB(t) + db := setupFlagsDB(t) - h := NewFeatureFlagsHandler(db) + h := NewFeatureFlagsHandler(db) - gin.SetMode(gin.TestMode) - r := gin.New() - r.GET("/api/v1/feature-flags", h.GetFlags) - r.PUT("/api/v1/feature-flags", h.UpdateFlags) + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/api/v1/feature-flags", h.GetFlags) + r.PUT("/api/v1/feature-flags", h.UpdateFlags) - // 1) GET should return all default flags (as keys) - req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String()) - } - var flags map[string]bool - if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil { - t.Fatalf("invalid json: %v", err) - } - // ensure keys present - for _, k := range defaultFlags { - if _, ok := flags[k]; !ok { - t.Fatalf("missing default flag key: %s", k) - } - } + // 1) GET should return all default flags (as keys) + req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String()) + } + var flags map[string]bool + if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil { + t.Fatalf("invalid json: %v", err) + } + // ensure keys present + for _, k := range defaultFlags { + if _, ok := flags[k]; !ok { + t.Fatalf("missing default flag key: %s", k) + } + } - // 2) PUT update a single flag - payload := map[string]bool{ - defaultFlags[0]: true, - } - b, _ := json.Marshal(payload) - req2 := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b)) - req2.Header.Set("Content-Type", "application/json") - w2 := httptest.NewRecorder() - r.ServeHTTP(w2, req2) - if w2.Code != http.StatusOK { - t.Fatalf("expected 200 on update got %d body=%s", w2.Code, w2.Body.String()) - } + // 2) PUT update a single flag + payload := map[string]bool{ + defaultFlags[0]: true, + } + b, _ := json.Marshal(payload) + req2 := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b)) + req2.Header.Set("Content-Type", "application/json") + w2 := httptest.NewRecorder() + r.ServeHTTP(w2, req2) + if w2.Code != http.StatusOK { + t.Fatalf("expected 200 on update got %d body=%s", w2.Code, w2.Body.String()) + } - // confirm DB persisted - var s models.Setting - if err := db.Where("key = ?", defaultFlags[0]).First(&s).Error; err != nil { - t.Fatalf("expected setting persisted, db error: %v", err) - } - if s.Value != "true" { - t.Fatalf("expected stored value 'true' got '%s'", s.Value) - } + // confirm DB persisted + var s models.Setting + if err := db.Where("key = ?", defaultFlags[0]).First(&s).Error; err != nil { + t.Fatalf("expected setting persisted, db error: %v", err) + } + if s.Value != "true" { + t.Fatalf("expected stored value 'true' got '%s'", s.Value) + } } diff --git a/backend/internal/api/handlers/import_handler.go b/backend/internal/api/handlers/import_handler.go index 43394134..affe0f81 100644 --- a/backend/internal/api/handlers/import_handler.go +++ b/backend/internal/api/handlers/import_handler.go @@ -307,7 +307,7 @@ func (h *ImportHandler) Upload(c *gin.Context) { log.Printf("Import Upload: no hosts parsed and no imports detected; content_len=%d", len(req.Content)) } if len(imports) > 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "no sites found in uploaded Caddyfile; imports detected; please upload the referenced site files using the multi-file import flow" , "imports": imports}) + c.JSON(http.StatusBadRequest, gin.H{"error": "no sites found in uploaded Caddyfile; imports detected; please upload the referenced site files using the multi-file import flow", "imports": imports}) return } c.JSON(http.StatusBadRequest, gin.H{"error": "no sites found in uploaded Caddyfile"}) @@ -679,10 +679,10 @@ func (h *ImportHandler) Commit(c *gin.Context) { if err := h.proxyHostSvc.Create(&host); err != nil { errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error()) errors = append(errors, errMsg) - log.Printf("Import Commit Error: %s", util.SanitizeForLog(errMsg)) + log.Printf("Import Commit Error: %s", util.SanitizeForLog(errMsg)) } else { created++ - log.Printf("Import Commit Success: Created host %s", util.SanitizeForLog(host.DomainNames)) + log.Printf("Import Commit Success: Created host %s", util.SanitizeForLog(host.DomainNames)) } } diff --git a/backend/internal/api/handlers/import_handler_path_test.go b/backend/internal/api/handlers/import_handler_path_test.go index 38d3d295..74c9ddce 100644 --- a/backend/internal/api/handlers/import_handler_path_test.go +++ b/backend/internal/api/handlers/import_handler_path_test.go @@ -7,7 +7,7 @@ import ( func TestIsSafePathUnderBase(t *testing.T) { base := filepath.FromSlash("/tmp/session") - cases := []struct{ + cases := []struct { name string want bool }{ diff --git a/backend/internal/api/handlers/notification_provider_handler.go b/backend/internal/api/handlers/notification_provider_handler.go index c501812d..1e18242c 100644 --- a/backend/internal/api/handlers/notification_provider_handler.go +++ b/backend/internal/api/handlers/notification_provider_handler.go @@ -4,8 +4,8 @@ import ( "encoding/json" "fmt" "net/http" - "time" "strings" + "time" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" diff --git a/backend/internal/api/handlers/notification_template_handler.go b/backend/internal/api/handlers/notification_template_handler.go index e9640a97..c1caa6c3 100644 --- a/backend/internal/api/handlers/notification_template_handler.go +++ b/backend/internal/api/handlers/notification_template_handler.go @@ -1,97 +1,97 @@ package handlers import ( - "net/http" - "github.com/Wikid82/charon/backend/internal/models" - "github.com/Wikid82/charon/backend/internal/services" - "github.com/gin-gonic/gin" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" + "github.com/gin-gonic/gin" + "net/http" ) type NotificationTemplateHandler struct { - service *services.NotificationService + service *services.NotificationService } func NewNotificationTemplateHandler(s *services.NotificationService) *NotificationTemplateHandler { - return &NotificationTemplateHandler{service: s} + return &NotificationTemplateHandler{service: s} } func (h *NotificationTemplateHandler) List(c *gin.Context) { - list, err := h.service.ListTemplates() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list templates"}) - return - } - c.JSON(http.StatusOK, list) + list, err := h.service.ListTemplates() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list templates"}) + return + } + c.JSON(http.StatusOK, list) } func (h *NotificationTemplateHandler) Create(c *gin.Context) { - var t models.NotificationTemplate - if err := c.ShouldBindJSON(&t); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := h.service.CreateTemplate(&t); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create template"}) - return - } - c.JSON(http.StatusCreated, t) + var t models.NotificationTemplate + if err := c.ShouldBindJSON(&t); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := h.service.CreateTemplate(&t); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create template"}) + return + } + c.JSON(http.StatusCreated, t) } func (h *NotificationTemplateHandler) Update(c *gin.Context) { - id := c.Param("id") - var t models.NotificationTemplate - if err := c.ShouldBindJSON(&t); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - t.ID = id - if err := h.service.UpdateTemplate(&t); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update template"}) - return - } - c.JSON(http.StatusOK, t) + id := c.Param("id") + var t models.NotificationTemplate + if err := c.ShouldBindJSON(&t); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + t.ID = id + if err := h.service.UpdateTemplate(&t); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update template"}) + return + } + c.JSON(http.StatusOK, t) } func (h *NotificationTemplateHandler) Delete(c *gin.Context) { - id := c.Param("id") - if err := h.service.DeleteTemplate(id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete template"}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "deleted"}) + id := c.Param("id") + if err := h.service.DeleteTemplate(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete template"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) } // Preview allows rendering an arbitrary template (provided in request) or a stored template by id. func (h *NotificationTemplateHandler) Preview(c *gin.Context) { - var raw map[string]interface{} - if err := c.ShouldBindJSON(&raw); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } + var raw map[string]interface{} + if err := c.ShouldBindJSON(&raw); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } - var tmplStr string - if id, ok := raw["template_id"].(string); ok && id != "" { - t, err := h.service.GetTemplate(id) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "template not found"}) - return - } - tmplStr = t.Config - } else if s, ok := raw["template"].(string); ok { - tmplStr = s - } + var tmplStr string + if id, ok := raw["template_id"].(string); ok && id != "" { + t, err := h.service.GetTemplate(id) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "template not found"}) + return + } + tmplStr = t.Config + } else if s, ok := raw["template"].(string); ok { + tmplStr = s + } - data := map[string]interface{}{} - if d, ok := raw["data"].(map[string]interface{}); ok { - data = d - } + data := map[string]interface{}{} + if d, ok := raw["data"].(map[string]interface{}); ok { + data = d + } - // Build a fake provider to leverage existing RenderTemplate logic - provider := models.NotificationProvider{Template: "custom", Config: tmplStr} - rendered, parsed, err := h.service.RenderTemplate(provider, data) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered}) - return - } - c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed}) + // Build a fake provider to leverage existing RenderTemplate logic + provider := models.NotificationProvider{Template: "custom", Config: tmplStr} + rendered, parsed, err := h.service.RenderTemplate(provider, data) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered}) + return + } + c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed}) } diff --git a/backend/internal/api/handlers/notification_template_handler_test.go b/backend/internal/api/handlers/notification_template_handler_test.go index 1fe8ddd0..f75ddccb 100644 --- a/backend/internal/api/handlers/notification_template_handler_test.go +++ b/backend/internal/api/handlers/notification_template_handler_test.go @@ -1,52 +1,52 @@ package handlers import ( - "encoding/json" - "net/http" - "net/http/httptest" - "io" - "testing" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" - "strings" + "strings" - "github.com/Wikid82/charon/backend/internal/models" - "github.com/Wikid82/charon/backend/internal/services" - "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/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{}) - return db + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + require.NoError(t, err) + db.AutoMigrate(&models.NotificationTemplate{}) + return db } func TestNotificationTemplateCRUD(t *testing.T) { - db := setupDB(t) - svc := services.NewNotificationService(db) - h := NewNotificationTemplateHandler(svc) + db := setupDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationTemplateHandler(svc) - // Create - payload := `{"name":"Simple","config":"{\"title\": \"{{.Title}}\"}","template":"custom"}` - req := httptest.NewRequest("POST", "/", nil) - req.Body = io.NopCloser(strings.NewReader(payload)) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = req - h.Create(c) - require.Equal(t, http.StatusCreated, w.Code) + // Create + payload := `{"name":"Simple","config":"{\"title\": \"{{.Title}}\"}","template":"custom"}` + req := httptest.NewRequest("POST", "/", nil) + req.Body = io.NopCloser(strings.NewReader(payload)) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + h.Create(c) + require.Equal(t, http.StatusCreated, w.Code) - // List - req2 := httptest.NewRequest("GET", "/", nil) - w2 := httptest.NewRecorder() - c2, _ := gin.CreateTestContext(w2) - c2.Request = req2 - h.List(c2) - require.Equal(t, http.StatusOK, w2.Code) - var list []models.NotificationTemplate - require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &list)) - require.Len(t, list, 1) + // List + req2 := httptest.NewRequest("GET", "/", nil) + w2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(w2) + c2.Request = req2 + h.List(c2) + require.Equal(t, http.StatusOK, w2.Code) + var list []models.NotificationTemplate + require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &list)) + require.Len(t, list, 1) } diff --git a/backend/internal/api/handlers/proxy_host_handler.go b/backend/internal/api/handlers/proxy_host_handler.go index 50551f18..b24d97e8 100644 --- a/backend/internal/api/handlers/proxy_host_handler.go +++ b/backend/internal/api/handlers/proxy_host_handler.go @@ -1,12 +1,11 @@ package handlers - import ( + "encoding/json" "fmt" "log" "net/http" "strconv" - "encoding/json" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -94,13 +93,13 @@ func (h *ProxyHostHandler) Create(c *gin.Context) { } if h.caddyManager != nil { - if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { - // Rollback: delete the created host if config application fails - log.Printf("Error applying config: %s", sanitizeForLog(err.Error())) - if deleteErr := h.service.Delete(host.ID); deleteErr != nil { - idStr := strconv.FormatUint(uint64(host.ID), 10) - log.Printf("Critical: Failed to rollback host %s: %s", sanitizeForLog(idStr), sanitizeForLog(deleteErr.Error())) - } + if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { + // Rollback: delete the created host if config application fails + log.Printf("Error applying config: %s", sanitizeForLog(err.Error())) + if deleteErr := h.service.Delete(host.ID); deleteErr != nil { + idStr := strconv.FormatUint(uint64(host.ID), 10) + log.Printf("Critical: Failed to rollback host %s: %s", sanitizeForLog(idStr), sanitizeForLog(deleteErr.Error())) + } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) return } diff --git a/backend/internal/api/handlers/sanitize.go b/backend/internal/api/handlers/sanitize.go index 50f42f70..0c280765 100644 --- a/backend/internal/api/handlers/sanitize.go +++ b/backend/internal/api/handlers/sanitize.go @@ -1,20 +1,20 @@ package handlers import ( - "regexp" - "strings" + "regexp" + "strings" ) // sanitizeForLog removes control characters and newlines from user content before logging. func sanitizeForLog(s string) string { - if s == "" { - return s - } - // Replace CRLF and LF with spaces and remove other control chars - s = strings.ReplaceAll(s, "\r\n", " ") - s = strings.ReplaceAll(s, "\n", " ") - // remove any other non-printable control characters - re := regexp.MustCompile(`[\x00-\x1F\x7F]+`) - s = re.ReplaceAllString(s, " ") - return s + if s == "" { + return s + } + // Replace CRLF and LF with spaces and remove other control chars + s = strings.ReplaceAll(s, "\r\n", " ") + s = strings.ReplaceAll(s, "\n", " ") + // remove any other non-printable control characters + re := regexp.MustCompile(`[\x00-\x1F\x7F]+`) + s = re.ReplaceAllString(s, " ") + return s } diff --git a/backend/internal/api/handlers/sanitize_test.go b/backend/internal/api/handlers/sanitize_test.go index 7a3ab30b..0efb982f 100644 --- a/backend/internal/api/handlers/sanitize_test.go +++ b/backend/internal/api/handlers/sanitize_test.go @@ -1,24 +1,24 @@ package handlers import ( - "testing" + "testing" ) func TestSanitizeForLog(t *testing.T) { - cases := []struct{ - in string - want string - }{ - {"normal text", "normal text"}, - {"line\nbreak", "line break"}, - {"carriage\rreturn\nline", "carriage return line"}, - {"control\x00chars", "control chars"}, - } + cases := []struct { + in string + want string + }{ + {"normal text", "normal text"}, + {"line\nbreak", "line break"}, + {"carriage\rreturn\nline", "carriage return line"}, + {"control\x00chars", "control chars"}, + } - for _, tc := range cases { - got := sanitizeForLog(tc.in) - if got != tc.want { - t.Fatalf("sanitizeForLog(%q) = %q; want %q", tc.in, got, tc.want) - } - } + for _, tc := range cases { + got := sanitizeForLog(tc.in) + if got != tc.want { + t.Fatalf("sanitizeForLog(%q) = %q; want %q", tc.in, got, tc.want) + } + } } diff --git a/backend/internal/api/handlers/security_handler.go b/backend/internal/api/handlers/security_handler.go index 1ad3c49c..0250595e 100644 --- a/backend/internal/api/handlers/security_handler.go +++ b/backend/internal/api/handlers/security_handler.go @@ -42,12 +42,33 @@ func (h *SecurityHandler) GetStatus(c *gin.Context) { } } + // Allow runtime overrides for CrowdSec mode + API URL via settings table + mode := h.cfg.CrowdSecMode + apiURL := h.cfg.CrowdSecAPIURL + if h.db != nil { + var m struct{ Value string } + if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.mode").Scan(&m).Error; err == nil && m.Value != "" { + mode = m.Value + } + var a struct{ Value string } + if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.api_url").Scan(&a).Error; err == nil && a.Value != "" { + apiURL = a.Value + } + } + + // Treat external crowdsec mode as unsupported in this release. If configured as 'external', + // present it as disabled so the UI doesn't attempt to call out to an external agent. + if mode == "external" { + mode = "disabled" + apiURL = "" + } + c.JSON(http.StatusOK, gin.H{ "cerberus": gin.H{"enabled": enabled}, "crowdsec": gin.H{ - "mode": h.cfg.CrowdSecMode, - "api_url": h.cfg.CrowdSecAPIURL, - "enabled": h.cfg.CrowdSecMode != "disabled", + "mode": mode, + "api_url": apiURL, + "enabled": mode == "local", }, "waf": gin.H{ "mode": h.cfg.WAFMode, diff --git a/backend/internal/api/handlers/security_handler_clean_test.go b/backend/internal/api/handlers/security_handler_clean_test.go index 955b8441..657e89d3 100644 --- a/backend/internal/api/handlers/security_handler_clean_test.go +++ b/backend/internal/api/handlers/security_handler_clean_test.go @@ -80,3 +80,77 @@ func TestSecurityHandler_Cerberus_DBOverride(t *testing.T) { cerb := response["cerberus"].(map[string]interface{}) assert.Equal(t, true, cerb["enabled"].(bool)) } + +func TestSecurityHandler_CrowdSec_Mode_DBOverride(t *testing.T) { + gin.SetMode(gin.TestMode) + + db := setupTestDB(t) + // set DB to configure crowdsec.mode to local + if err := db.Create(&models.Setting{Key: "security.crowdsec.mode", Value: "local"}).Error; err != nil { + t.Fatalf("failed to insert setting: %v", err) + } + + cfg := config.SecurityConfig{CrowdSecMode: "disabled"} + handler := NewSecurityHandler(cfg, db) + router := gin.New() + router.GET("/security/status", handler.GetStatus) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/security/status", nil) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + cs := response["crowdsec"].(map[string]interface{}) + assert.Equal(t, "local", cs["mode"].(string)) +} + +func TestSecurityHandler_CrowdSec_ExternalMappedToDisabled_DBOverride(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + // set DB to configure crowdsec.mode to external + if err := db.Create(&models.Setting{Key: "security.crowdsec.mode", Value: "external"}).Error; err != nil { + t.Fatalf("failed to insert setting: %v", err) + } + cfg := config.SecurityConfig{CrowdSecMode: "local"} + handler := NewSecurityHandler(cfg, db) + router := gin.New() + router.GET("/security/status", handler.GetStatus) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/security/status", nil) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + cs := response["crowdsec"].(map[string]interface{}) + assert.Equal(t, "disabled", cs["mode"].(string)) + assert.Equal(t, false, cs["enabled"].(bool)) +} + +func TestSecurityHandler_ExternalModeMappedToDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := config.SecurityConfig{ + CrowdSecMode: "external", + WAFMode: "disabled", + RateLimitMode: "disabled", + ACLMode: "disabled", + } + handler := NewSecurityHandler(cfg, nil) + router := gin.New() + router.GET("/security/status", handler.GetStatus) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/security/status", nil) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + cs := response["crowdsec"].(map[string]interface{}) + assert.Equal(t, "disabled", cs["mode"].(string)) + assert.Equal(t, false, cs["enabled"].(bool)) +} diff --git a/backend/internal/api/handlers/system_handler_test.go b/backend/internal/api/handlers/system_handler_test.go new file mode 100644 index 00000000..d035f105 --- /dev/null +++ b/backend/internal/api/handlers/system_handler_test.go @@ -0,0 +1,60 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "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) + } + + // 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) + } + + // 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) + + // 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/frontend/src/App.tsx b/frontend/src/App.tsx index 27f25497..4884dbf4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ const ImportCaddy = lazy(() => import('./pages/ImportCaddy')) const ImportCrowdSec = lazy(() => import('./pages/ImportCrowdSec')) const Certificates = lazy(() => import('./pages/Certificates')) const SystemSettings = lazy(() => import('./pages/SystemSettings')) +const CrowdSecConfig = lazy(() => import('./pages/CrowdSecConfig')) const Account = lazy(() => import('./pages/Account')) const Settings = lazy(() => import('./pages/Settings')) const Backups = lazy(() => import('./pages/Backups')) @@ -61,6 +62,7 @@ export default function App() { }> } /> } /> + } /> } /> diff --git a/frontend/src/api/crowdsec.ts b/frontend/src/api/crowdsec.ts index d7493008..2e75c990 100644 --- a/frontend/src/api/crowdsec.ts +++ b/frontend/src/api/crowdsec.ts @@ -24,4 +24,24 @@ export async function importCrowdsecConfig(file: File) { return resp.data } -export default { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig } +export async function exportCrowdsecConfig() { + const resp = await client.get('/admin/crowdsec/export', { responseType: 'blob' }) + return resp.data +} + +export async function listCrowdsecFiles() { + const resp = await client.get<{ files: string[] }>('/admin/crowdsec/files') + return resp.data +} + +export async function readCrowdsecFile(path: string) { + const resp = await client.get<{ content: string }>(`/admin/crowdsec/file?path=${encodeURIComponent(path)}`) + return resp.data +} + +export async function writeCrowdsecFile(path: string, content: string) { + const resp = await client.post('/admin/crowdsec/file', { path, content }) + return resp.data +} + +export default { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig, exportCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile } diff --git a/frontend/src/components/AccessListForm.tsx b/frontend/src/components/AccessListForm.tsx index 737f67f7..63e3f3a3 100644 --- a/frontend/src/components/AccessListForm.tsx +++ b/frontend/src/components/AccessListForm.tsx @@ -216,7 +216,7 @@ export function AccessListForm({ initialData, onSubmit, onCancel, onDelete, isLo