From fcc273262cf61d3c1330eeb51d3c4fdf5ede4aba Mon Sep 17 00:00:00 2001 From: CI Date: Sat, 29 Nov 2025 08:55:25 +0000 Subject: [PATCH] test(caddy): cover invalid path branches; ci: handle go test non-zero when coverage file exists --- .gitignore | 2 + Chiron.code-workspace | 5 +- DOCKER.md | 12 +- Dockerfile | 6 +- backend/cmd/api/main.go | 2 +- .../internal/api/handlers/import_handler.go | 146 +++++++++++---- .../api/handlers/import_handler_path_test.go | 30 ++++ .../api/handlers/import_handler_test.go | 17 ++ .../api/handlers/logs_handler_test.go | 4 +- .../handlers/notification_provider_handler.go | 61 +++++++ .../notification_provider_handler_test.go | 105 ++++++++++- .../api/handlers/proxy_host_handler.go | 16 +- backend/internal/api/handlers/sanitize.go | 20 +++ .../internal/api/handlers/sanitize_test.go | 24 +++ backend/internal/api/routes/routes.go | 2 + backend/internal/caddy/importer.go | 31 +++- backend/internal/caddy/importer_extra_test.go | 25 +++ .../internal/models/notification_provider.go | 9 + .../models/notification_provider_test.go | 12 +- backend/internal/models/ssl_certificate.go | 2 +- backend/internal/services/log_service.go | 4 +- .../internal/services/notification_service.go | 168 ++++++++++++++++-- .../services/notification_service_test.go | 158 +++++++++++++++- backend/internal/util/sanitize.go | 18 ++ backend/tools/build.sh | 5 + codeql-custom-queries-go/codeql-pack.lock.yml | 22 +++ codeql-custom-queries-go/codeql-pack.yml | 7 + codeql-custom-queries-go/example.ql | 12 ++ .../codeql-pack.lock.yml | 30 ++++ .../codeql-pack.yml | 7 + codeql-custom-queries-javascript/example.ql | 12 ++ docker-compose.local.yml | 74 -------- docker-compose.remote.yml | 4 +- docker-entrypoint.sh | 6 +- docs/features.md | 3 + docs/security.md | 2 +- frontend/src/api/notifications.ts | 13 ++ .../src/components/AccessListSelector.tsx | 2 +- frontend/src/components/Layout.tsx | 6 +- frontend/src/components/ProxyHostForm.tsx | 2 +- .../src/components/__tests__/Layout.test.tsx | 2 +- frontend/src/pages/Login.tsx | 7 +- frontend/src/pages/Notifications.tsx | 73 ++++++-- frontend/src/pages/Setup.tsx | 5 +- frontend/src/pages/__tests__/Login.test.tsx | 80 +++++++++ frontend/src/pages/__tests__/Setup.test.tsx | 11 +- go.work.sum | 30 ++++ import/Caddyfile | 1 + import/sites/.placeholder | 1 + scripts/go-test-coverage.sh | 13 +- tools/build.sh | 13 +- 51 files changed, 1117 insertions(+), 205 deletions(-) create mode 100644 backend/internal/api/handlers/import_handler_path_test.go create mode 100644 backend/internal/api/handlers/sanitize.go create mode 100644 backend/internal/api/handlers/sanitize_test.go create mode 100644 backend/internal/util/sanitize.go create mode 100755 backend/tools/build.sh create mode 100644 codeql-custom-queries-go/codeql-pack.lock.yml create mode 100644 codeql-custom-queries-go/codeql-pack.yml create mode 100644 codeql-custom-queries-go/example.ql create mode 100644 codeql-custom-queries-javascript/codeql-pack.lock.yml create mode 100644 codeql-custom-queries-javascript/codeql-pack.yml create mode 100644 codeql-custom-queries-javascript/example.ql delete mode 100644 docker-compose.local.yml create mode 100644 frontend/src/pages/__tests__/Login.test.tsx create mode 100644 go.work.sum create mode 100644 import/Caddyfile create mode 100644 import/sites/.placeholder diff --git a/.gitignore b/.gitignore index 866e3ccf..acdcb2a4 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,5 @@ PROJECT_BOARD_SETUP.md PROJECT_PLANNING.md SECURITY_IMPLEMENTATION_PLAN.md VERSIONING_IMPLEMENTATION.md +backend/internal/api/handlers/import_handler.go.bak +docker-compose.local.yml diff --git a/Chiron.code-workspace b/Chiron.code-workspace index b51044b3..20f58afa 100644 --- a/Chiron.code-workspace +++ b/Chiron.code-workspace @@ -3,5 +3,8 @@ { "path": "." } - ] + ], + "settings": { + "codeQL.createQuery.qlPackLocation": "/projects/Charon" + } } diff --git a/DOCKER.md b/DOCKER.md index ef8208c6..ed655aa2 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -30,7 +30,7 @@ This unified architecture simplifies deployment, updates, and data management. │ Container (charon / cpmp) │ │ │ │ ┌──────────┐ API ┌──────────────┐ │ -│ │ Caddy │◄──:2019──┤ CPM+ App │ │ +│ │ Caddy │◄──:2019──┤ Charon App │ │ │ │ (Proxy) │ │ (Manager) │ │ │ └────┬─────┘ └──────┬───────┘ │ │ │ │ │ @@ -58,8 +58,8 @@ Configure the application via `docker-compose.yml`: | Variable | Default | Description | |----------|---------|-------------| -| `CHARON_ENV` | `production` | Set to `development` for verbose logging (`CPM_ENV` supported for backward compatibility). | -| `CHARON_HTTP_PORT` | `8080` | Port for the Web UI (`CPM_HTTP_PORT` supported for backward compatibility). | + | `CHARON_ENV` | `production` | Set to `development` for verbose logging (`CPM_ENV` supported for backward compatibility). | + | `CHARON_HTTP_PORT` | `8080` | Port for the Web UI (`CPM_HTTP_PORT` supported for backward compatibility). | | `CHARON_DB_PATH` | `/app/data/charon.db` | Path to the SQLite database (`CPM_DB_PATH` supported for backward compatibility). | | `CHARON_CADDY_ADMIN_API` | `http://localhost:2019` | Internal URL for Caddy API (`CPM_CADDY_ADMIN_API` supported for backward compatibility). | @@ -126,7 +126,7 @@ docker-compose logs app # View current Caddy config curl http://localhost:2019/config/ | jq -# Check CPM+ logs +# Check Charon logs docker-compose logs app # Manual config reload @@ -170,14 +170,14 @@ make docker-build ## Integration with Existing Caddy -If you already have Caddy running, you can point CPM+ to it: +If you already have Caddy running, you can point Charon to it: ```yaml environment: - CPM_CADDY_ADMIN_API=http://your-caddy-host:2019 ``` -**Warning**: CPM+ will replace Caddy's entire configuration. Backup first! +**Warning**: Charon will replace Caddy's entire configuration. Backup first! ## Performance Tuning diff --git a/Dockerfile b/Dockerfile index ab398173..2c367c03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -109,7 +109,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ FROM ${CADDY_IMAGE} WORKDIR /app -# Install runtime dependencies for CPM+ (no bash needed) +# Install runtime dependencies for Charon (no bash needed) # hadolint ignore=DL3018 RUN apk --no-cache add ca-certificates sqlite-libs tzdata curl \ && apk --no-cache upgrade @@ -162,7 +162,7 @@ ARG BUILD_DATE ARG VCS_REF # OCI image labels for version metadata -LABEL org.opencontainers.image.title="Charon (CPMP)" \ +LABEL org.opencontainers.image.title="Charon (CPMP legacy)" \ org.opencontainers.image.description="Web UI for managing Caddy reverse proxy configurations" \ org.opencontainers.image.version="${VERSION}" \ org.opencontainers.image.created="${BUILD_DATE}" \ @@ -175,5 +175,5 @@ LABEL org.opencontainers.image.title="Charon (CPMP)" \ # Expose ports EXPOSE 80 443 443/udp 8080 2019 -# Use custom entrypoint to start both Caddy and CPM+ +# Use custom entrypoint to start both Caddy and Charon ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index e968e51c..374f1b69 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -36,7 +36,7 @@ func main() { Compress: true, } - // Ensure legacy cpmp.log exists as symlink for compatibility + // Ensure legacy cpmp.log exists as symlink for compatibility (cpmp is a legacy name for Charon) legacyLog := filepath.Join(logDir, "cpmp.log") if _, err := os.Lstat(legacyLog); os.IsNotExist(err) { _ = os.Symlink(logFile, legacyLog) // ignore errors diff --git a/backend/internal/api/handlers/import_handler.go b/backend/internal/api/handlers/import_handler.go index b339d066..bbcf4b97 100644 --- a/backend/internal/api/handlers/import_handler.go +++ b/backend/internal/api/handlers/import_handler.go @@ -6,6 +6,7 @@ import ( "log" "net/http" "os" + "path" "path/filepath" "strings" "time" @@ -17,6 +18,7 @@ import ( "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" ) // ImportHandler handles Caddyfile import operations. @@ -250,13 +252,20 @@ func (h *ImportHandler) Upload(c *gin.Context) { // Save upload to import/uploads/.caddyfile and return transient preview (do not persist yet) sid := uuid.NewString() - uploadsDir := filepath.Join(h.importDir, "uploads") + uploadsDir, err := safeJoin(h.importDir, "uploads") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid import directory"}) + return + } if err := os.MkdirAll(uploadsDir, 0755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create uploads directory"}) return } - - tempPath := filepath.Join(uploadsDir, fmt.Sprintf("%s.caddyfile", sid)) + tempPath, err := safeJoin(uploadsDir, fmt.Sprintf("%s.caddyfile", sid)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid temp path"}) + return + } if err := os.WriteFile(tempPath, []byte(req.Content), 0644); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write upload"}) return @@ -354,7 +363,11 @@ func (h *ImportHandler) UploadMulti(c *gin.Context) { // Create session directory sid := uuid.NewString() - sessionDir := filepath.Join(h.importDir, "uploads", sid) + sessionDir, err := safeJoin(h.importDir, filepath.Join("uploads", sid)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid session directory"}) + return + } if err := os.MkdirAll(sessionDir, 0755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session directory"}) return @@ -370,7 +383,11 @@ func (h *ImportHandler) UploadMulti(c *gin.Context) { // Clean filename and create subdirectories if needed cleanName := filepath.Clean(f.Filename) - targetPath := filepath.Join(sessionDir, cleanName) + targetPath, err := safeJoin(sessionDir, cleanName) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid filename: %s", f.Filename)}) + return + } // Create parent directory if file is in a subdirectory if dir := filepath.Dir(targetPath); dir != sessionDir { @@ -434,6 +451,43 @@ func detectImportDirectives(content string) []string { return imports } +// safeJoin joins a user-supplied path to a base directory and ensures +// the resulting path is contained within the base directory. +func safeJoin(baseDir, userPath string) (string, error) { + clean := filepath.Clean(userPath) + if clean == "" || clean == "." { + return "", fmt.Errorf("empty path not allowed") + } + if filepath.IsAbs(clean) { + return "", fmt.Errorf("absolute paths not allowed") + } + + // Prevent attempts like ".." at start + if strings.HasPrefix(clean, ".."+string(os.PathSeparator)) || clean == ".." { + return "", fmt.Errorf("path traversal detected") + } + + target := filepath.Join(baseDir, clean) + rel, err := filepath.Rel(baseDir, target) + if err != nil { + return "", fmt.Errorf("invalid path") + } + if strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("path traversal detected") + } + + // Normalize to use base's separators + target = path.Clean(target) + return target, nil +} + +// isSafePathUnderBase reports whether userPath, when cleaned and joined +// to baseDir, stays within baseDir. Used by tests. +func isSafePathUnderBase(baseDir, userPath string) bool { + _, err := safeJoin(baseDir, userPath) + return err == nil +} + // Commit finalizes the import with user's conflict resolutions. func (h *ImportHandler) Commit(c *gin.Context) { var req struct { @@ -449,8 +503,14 @@ func (h *ImportHandler) Commit(c *gin.Context) { // Try to find a DB-backed session first var session models.ImportSession + // Basic sanitize of session id to prevent path separators + sid := filepath.Base(req.SessionUUID) + if sid == "" || sid == "." || strings.Contains(sid, string(os.PathSeparator)) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_uuid"}) + return + } var result *caddy.ImportResult - if err := h.db.Where("uuid = ? AND status = ?", req.SessionUUID, "reviewing").First(&session).Error; err == nil { + if err := h.db.Where("uuid = ? AND status = ?", sid, "reviewing").First(&session).Error; err == nil { // DB session found if err := json.Unmarshal([]byte(session.ParsedData), &result); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse import data"}) @@ -458,31 +518,39 @@ func (h *ImportHandler) Commit(c *gin.Context) { } } else { // No DB session: check for uploaded temp file - uploadsPath := filepath.Join(h.importDir, "uploads", fmt.Sprintf("%s.caddyfile", req.SessionUUID)) - if _, err := os.Stat(uploadsPath); err == nil { - r, err := h.importerservice.ImportFile(uploadsPath) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse uploaded file"}) - return - } - result = r - // We'll create a committed DB session after applying - session = models.ImportSession{UUID: req.SessionUUID, SourceFile: uploadsPath} - } else if h.mountPath != "" { - if _, err := os.Stat(h.mountPath); err == nil { - r, err := h.importerservice.ImportFile(h.mountPath) + var parseErr error + uploadsPath, err := safeJoin(h.importDir, filepath.Join("uploads", fmt.Sprintf("%s.caddyfile", sid))) + if err == nil { + if _, err := os.Stat(uploadsPath); err == nil { + r, err := h.importerservice.ImportFile(uploadsPath) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse uploaded file"}) return } result = r - session = models.ImportSession{UUID: req.SessionUUID, SourceFile: h.mountPath} - } else { - c.JSON(http.StatusNotFound, gin.H{"error": "session not found or file missing"}) + // We'll create a committed DB session after applying + session = models.ImportSession{UUID: sid, SourceFile: uploadsPath} + } + } + // If not found yet, check mounted Caddyfile + if result == nil && h.mountPath != "" { + if _, err := os.Stat(h.mountPath); err == nil { + r, err := h.importerservice.ImportFile(h.mountPath) + if err != nil { + parseErr = err + } else { + result = r + session = models.ImportSession{UUID: sid, SourceFile: h.mountPath} + } + } + } + // If still not parsed, return not found or error + if result == nil { + if parseErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"}) return } - } else { - c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "session not found or file missing"}) return } } @@ -532,10 +600,10 @@ func (h *ImportHandler) Commit(c *gin.Context) { if err := h.proxyHostSvc.Update(&host); err != nil { errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error()) errors = append(errors, errMsg) - log.Printf("Import Commit Error (update): %s", errMsg) + log.Printf("Import Commit Error (update): %s", sanitizeForLog(errMsg)) } else { updated++ - log.Printf("Import Commit Success: Updated host %s", host.DomainNames) + log.Printf("Import Commit Success: Updated host %s", sanitizeForLog(host.DomainNames)) } continue } @@ -547,10 +615,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", errMsg) + log.Printf("Import Commit Error: %s", util.SanitizeForLog(errMsg)) } else { created++ - log.Printf("Import Commit Success: Created host %s", host.DomainNames) + log.Printf("Import Commit Success: Created host %s", util.SanitizeForLog(host.DomainNames)) } } @@ -586,8 +654,14 @@ func (h *ImportHandler) Cancel(c *gin.Context) { return } + sid := filepath.Base(sessionUUID) + if sid == "" || sid == "." || strings.Contains(sid, string(os.PathSeparator)) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_uuid"}) + return + } + var session models.ImportSession - if err := h.db.Where("uuid = ?", sessionUUID).First(&session).Error; err == nil { + if err := h.db.Where("uuid = ?", sid).First(&session).Error; err == nil { session.Status = "rejected" h.db.Save(&session) c.JSON(http.StatusOK, gin.H{"message": "import cancelled"}) @@ -595,11 +669,13 @@ func (h *ImportHandler) Cancel(c *gin.Context) { } // If no DB session, check for uploaded temp file and delete it - uploadsPath := filepath.Join(h.importDir, "uploads", fmt.Sprintf("%s.caddyfile", sessionUUID)) - if _, err := os.Stat(uploadsPath); err == nil { - os.Remove(uploadsPath) - c.JSON(http.StatusOK, gin.H{"message": "transient upload cancelled"}) - return + uploadsPath, err := safeJoin(h.importDir, filepath.Join("uploads", fmt.Sprintf("%s.caddyfile", sid))) + if err == nil { + if _, err := os.Stat(uploadsPath); err == nil { + os.Remove(uploadsPath) + c.JSON(http.StatusOK, gin.H{"message": "transient upload cancelled"}) + return + } } // If neither exists, return not found diff --git a/backend/internal/api/handlers/import_handler_path_test.go b/backend/internal/api/handlers/import_handler_path_test.go new file mode 100644 index 00000000..38d3d295 --- /dev/null +++ b/backend/internal/api/handlers/import_handler_path_test.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "path/filepath" + "testing" +) + +func TestIsSafePathUnderBase(t *testing.T) { + base := filepath.FromSlash("/tmp/session") + cases := []struct{ + name string + want bool + }{ + {"Caddyfile", true}, + {"site/site.conf", true}, + {"../etc/passwd", false}, + {"../../escape", false}, + {"/absolute/path", false}, + {"", false}, + {".", false}, + {"sub/../ok.txt", true}, + } + + for _, tc := range cases { + got := isSafePathUnderBase(base, tc.name) + if got != tc.want { + t.Fatalf("isSafePathUnderBase(%q, %q) = %v; want %v", base, tc.name, got, tc.want) + } + } +} diff --git a/backend/internal/api/handlers/import_handler_test.go b/backend/internal/api/handlers/import_handler_test.go index 5870a2f6..be4ab348 100644 --- a/backend/internal/api/handlers/import_handler_test.go +++ b/backend/internal/api/handlers/import_handler_test.go @@ -849,6 +849,23 @@ func TestImportHandler_UploadMulti(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) }) + t.Run("path traversal in filename", func(t *testing.T) { + payload := map[string]interface{}{ + "files": []map[string]string{ + {"filename": "Caddyfile", "content": "import sites/*\n"}, + {"filename": "../etc/passwd", "content": "sensitive"}, + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + t.Run("empty file content", func(t *testing.T) { payload := map[string]interface{}{ "files": []map[string]string{ diff --git a/backend/internal/api/handlers/logs_handler_test.go b/backend/internal/api/handlers/logs_handler_test.go index c0872580..b88dea78 100644 --- a/backend/internal/api/handlers/logs_handler_test.go +++ b/backend/internal/api/handlers/logs_handler_test.go @@ -42,10 +42,10 @@ func setupLogsTest(t *testing.T) (*gin.Engine, *services.LogService, string) { err = os.WriteFile(filepath.Join(logsDir, "access.log"), []byte(log1+"\n"+log2+"\n"), 0644) require.NoError(t, err) - // Write a charon.log and create a cpmp.log symlink to it for compatibility + // Write a charon.log and create a cpmp.log symlink to it for backward compatibility (cpmp is legacy) err = os.WriteFile(filepath.Join(logsDir, "charon.log"), []byte("app log line 1\napp log line 2"), 0644) require.NoError(t, err) - // Create legacy cpmp log symlink + // Create legacy cpmp log symlink (cpmp is a legacy name for Charon) _ = os.Symlink(filepath.Join(logsDir, "charon.log"), filepath.Join(logsDir, "cpmp.log")) require.NoError(t, err) diff --git a/backend/internal/api/handlers/notification_provider_handler.go b/backend/internal/api/handlers/notification_provider_handler.go index 69c8c4fc..c501812d 100644 --- a/backend/internal/api/handlers/notification_provider_handler.go +++ b/backend/internal/api/handlers/notification_provider_handler.go @@ -1,8 +1,11 @@ package handlers import ( + "encoding/json" "fmt" "net/http" + "time" + "strings" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" @@ -34,6 +37,11 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) { } if err := h.service.CreateProvider(&provider); err != nil { + // If it's a validation error from template parsing, return 400 + if strings.Contains(err.Error(), "invalid custom template") || strings.Contains(err.Error(), "rendered template") || strings.Contains(err.Error(), "failed to parse template") || strings.Contains(err.Error(), "failed to render template") { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create provider"}) return } @@ -50,6 +58,10 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) { provider.ID = id if err := h.service.UpdateProvider(&provider); err != nil { + if strings.Contains(err.Error(), "invalid custom template") || strings.Contains(err.Error(), "rendered template") || strings.Contains(err.Error(), "failed to parse template") || strings.Contains(err.Error(), "failed to render template") { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider"}) return } @@ -80,3 +92,52 @@ func (h *NotificationProviderHandler) Test(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"}) } + +// Templates returns a list of built-in templates a provider can use. +func (h *NotificationProviderHandler) Templates(c *gin.Context) { + c.JSON(http.StatusOK, []gin.H{ + {"id": "minimal", "name": "Minimal", "description": "Small JSON payload with title, message and time."}, + {"id": "detailed", "name": "Detailed", "description": "Full JSON payload with host, services and all data."}, + {"id": "custom", "name": "Custom", "description": "Use your own JSON template in the Config field."}, + }) +} + +// Preview renders the template for a provider and returns the resulting JSON object or an error. +func (h *NotificationProviderHandler) 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 provider models.NotificationProvider + // Marshal raw into provider to get proper types + if b, err := json.Marshal(raw); err == nil { + _ = json.Unmarshal(b, &provider) + } + var payload map[string]interface{} + if d, ok := raw["data"].(map[string]interface{}); ok { + payload = d + } + + if payload == nil { + payload = map[string]interface{}{} + } + + // Add some defaults for preview + if _, ok := payload["Title"]; !ok { + payload["Title"] = "Preview Title" + } + if _, ok := payload["Message"]; !ok { + payload["Message"] = "Preview Message" + } + payload["Time"] = time.Now().Format(time.RFC3339) + payload["EventType"] = "preview" + + rendered, parsed, err := h.service.RenderTemplate(provider, payload) + 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_provider_handler_test.go b/backend/internal/api/handlers/notification_provider_handler_test.go index 79d807cf..d666c687 100644 --- a/backend/internal/api/handlers/notification_provider_handler_test.go +++ b/backend/internal/api/handlers/notification_provider_handler_test.go @@ -29,12 +29,14 @@ func setupNotificationProviderTest(t *testing.T) (*gin.Engine, *gorm.DB) { r := gin.Default() api := r.Group("/api/v1") - providers := api.Group("/notification-providers") + providers := api.Group("/notifications/providers") providers.GET("", handler.List) + providers.POST("/preview", handler.Preview) providers.POST("", handler.Create) providers.PUT("/:id", handler.Update) providers.DELETE("/:id", handler.Delete) providers.POST("/test", handler.Test) + api.GET("/notifications/templates", handler.Templates) return r, db } @@ -49,7 +51,7 @@ func TestNotificationProviderHandler_CRUD(t *testing.T) { URL: "https://discord.com/api/webhooks/...", } body, _ := json.Marshal(provider) - req, _ := http.NewRequest("POST", "/api/v1/notification-providers", bytes.NewBuffer(body)) + req, _ := http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer(body)) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -61,7 +63,7 @@ func TestNotificationProviderHandler_CRUD(t *testing.T) { assert.NotEmpty(t, created.ID) // 2. List - req, _ = http.NewRequest("GET", "/api/v1/notification-providers", nil) + req, _ = http.NewRequest("GET", "/api/v1/notifications/providers", nil) w = httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -73,7 +75,7 @@ func TestNotificationProviderHandler_CRUD(t *testing.T) { // 3. Update created.Name = "Updated Discord" body, _ = json.Marshal(created) - req, _ = http.NewRequest("PUT", "/api/v1/notification-providers/"+created.ID, bytes.NewBuffer(body)) + req, _ = http.NewRequest("PUT", "/api/v1/notifications/providers/"+created.ID, bytes.NewBuffer(body)) w = httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -88,7 +90,7 @@ func TestNotificationProviderHandler_CRUD(t *testing.T) { assert.Equal(t, "Updated Discord", dbProvider.Name) // 4. Delete - req, _ = http.NewRequest("DELETE", "/api/v1/notification-providers/"+created.ID, nil) + req, _ = http.NewRequest("DELETE", "/api/v1/notifications/providers/"+created.ID, nil) w = httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -99,6 +101,20 @@ func TestNotificationProviderHandler_CRUD(t *testing.T) { assert.Equal(t, int64(0), count) } +func TestNotificationProviderHandler_Templates(t *testing.T) { + r, _ := setupNotificationProviderTest(t) + + req, _ := http.NewRequest("GET", "/api/v1/notifications/templates", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var templates []map[string]string + err := json.Unmarshal(w.Body.Bytes(), &templates) + require.NoError(t, err) + assert.Len(t, templates, 3) +} + func TestNotificationProviderHandler_Test(t *testing.T) { r, _ := setupNotificationProviderTest(t) @@ -113,7 +129,7 @@ func TestNotificationProviderHandler_Test(t *testing.T) { URL: "invalid-url", } body, _ := json.Marshal(provider) - req, _ := http.NewRequest("POST", "/api/v1/notification-providers/test", bytes.NewBuffer(body)) + req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body)) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -125,19 +141,90 @@ func TestNotificationProviderHandler_Errors(t *testing.T) { r, _ := setupNotificationProviderTest(t) // Create Invalid JSON - req, _ := http.NewRequest("POST", "/api/v1/notification-providers", bytes.NewBuffer([]byte("invalid"))) + req, _ := http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer([]byte("invalid"))) w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) // Update Invalid JSON - req, _ = http.NewRequest("PUT", "/api/v1/notification-providers/123", bytes.NewBuffer([]byte("invalid"))) + req, _ = http.NewRequest("PUT", "/api/v1/notifications/providers/123", bytes.NewBuffer([]byte("invalid"))) w = httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) // Test Invalid JSON - req, _ = http.NewRequest("POST", "/api/v1/notification-providers/test", bytes.NewBuffer([]byte("invalid"))) + req, _ = http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer([]byte("invalid"))) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestNotificationProviderHandler_InvalidCustomTemplate_Rejects(t *testing.T) { + r, _ := setupNotificationProviderTest(t) + + // Create with invalid custom template should return 400 + provider := models.NotificationProvider{ + Name: "Bad", + Type: "webhook", + URL: "http://example.com", + Template: "custom", + Config: `{"broken": "{{.Title"}`, + } + body, _ := json.Marshal(provider) + req, _ := http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer(body)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + + // Create valid and then attempt update to invalid custom template + provider = models.NotificationProvider{ + Name: "Good", + Type: "webhook", + URL: "http://example.com", + Template: "minimal", + } + body, _ = json.Marshal(provider) + req, _ = http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer(body)) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusCreated, w.Code) + var created models.NotificationProvider + _ = json.Unmarshal(w.Body.Bytes(), &created) + + created.Template = "custom" + created.Config = `{"broken": "{{.Title"}` + body, _ = json.Marshal(created) + req, _ = http.NewRequest("PUT", "/api/v1/notifications/providers/"+created.ID, bytes.NewBuffer(body)) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestNotificationProviderHandler_Preview(t *testing.T) { + r, _ := setupNotificationProviderTest(t) + + // Minimal template preview + provider := models.NotificationProvider{ + Type: "webhook", + URL: "http://example.com", + Template: "minimal", + } + body, _ := json.Marshal(provider) + req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/preview", bytes.NewBuffer(body)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp, "rendered") + assert.Contains(t, resp, "parsed") + + // Invalid template should not succeed + provider.Config = `{"broken": "{{.Title"}` + provider.Template = "custom" + body, _ = json.Marshal(provider) + req, _ = http.NewRequest("POST", "/api/v1/notifications/providers/preview", bytes.NewBuffer(body)) w = httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) diff --git a/backend/internal/api/handlers/proxy_host_handler.go b/backend/internal/api/handlers/proxy_host_handler.go index 6882260b..852243e0 100644 --- a/backend/internal/api/handlers/proxy_host_handler.go +++ b/backend/internal/api/handlers/proxy_host_handler.go @@ -1,8 +1,11 @@ package handlers + import ( "fmt" + "log" "net/http" + "strconv" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -72,12 +75,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 - fmt.Printf("Error applying config: %v\n", err) // Log to stdout - if deleteErr := h.service.Delete(host.ID); deleteErr != nil { - fmt.Printf("Critical: Failed to rollback host %d: %v\n", host.ID, deleteErr) - } + 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 new file mode 100644 index 00000000..50f42f70 --- /dev/null +++ b/backend/internal/api/handlers/sanitize.go @@ -0,0 +1,20 @@ +package handlers + +import ( + "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 +} diff --git a/backend/internal/api/handlers/sanitize_test.go b/backend/internal/api/handlers/sanitize_test.go new file mode 100644 index 00000000..7a3ab30b --- /dev/null +++ b/backend/internal/api/handlers/sanitize_test.go @@ -0,0 +1,24 @@ +package handlers + +import ( + "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"}, + } + + 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/routes/routes.go b/backend/internal/api/routes/routes.go index 6270ad6f..8326ac00 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -160,6 +160,8 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { protected.PUT("/notifications/providers/:id", notificationProviderHandler.Update) protected.DELETE("/notifications/providers/:id", notificationProviderHandler.Delete) protected.POST("/notifications/providers/test", notificationProviderHandler.Test) + protected.POST("/notifications/providers/preview", notificationProviderHandler.Preview) + protected.GET("/notifications/templates", notificationProviderHandler.Templates) // Start background checker (every 1 minute) go func() { diff --git a/backend/internal/caddy/importer.go b/backend/internal/caddy/importer.go index f3e233d3..f17695d0 100644 --- a/backend/internal/caddy/importer.go +++ b/backend/internal/caddy/importer.go @@ -85,7 +85,7 @@ type ImportResult struct { Errors []string `json:"errors"` } -// Importer handles Caddyfile parsing and conversion to CPM+ models. +// Importer handles Caddyfile parsing and conversion to Charon models. type Importer struct { caddyBinaryPath string executor Executor @@ -107,11 +107,19 @@ var forceSplitFallback bool // ParseCaddyfile reads a Caddyfile and converts it to Caddy JSON. func (i *Importer) ParseCaddyfile(caddyfilePath string) ([]byte, error) { - if _, err := os.Stat(caddyfilePath); os.IsNotExist(err) { - return nil, fmt.Errorf("caddyfile not found: %s", caddyfilePath) + // Sanitize the incoming path to detect forbidden traversal sequences. + clean := filepath.Clean(caddyfilePath) + if clean == "" || clean == "." { + return nil, fmt.Errorf("invalid caddyfile path") + } + if strings.Contains(clean, ".."+string(os.PathSeparator)) || strings.HasPrefix(clean, "..") { + return nil, fmt.Errorf("invalid caddyfile path") + } + if _, err := os.Stat(clean); os.IsNotExist(err) { + return nil, fmt.Errorf("caddyfile not found: %s", clean) } - output, err := i.executor.Execute(i.caddyBinaryPath, "adapt", "--config", caddyfilePath, "--adapter", "caddyfile") + output, err := i.executor.Execute(i.caddyBinaryPath, "adapt", "--config", clean, "--adapter", "caddyfile") if err != nil { return nil, fmt.Errorf("caddy adapt failed: %w (output: %s)", err, string(output)) } @@ -334,9 +342,20 @@ func BackupCaddyfile(originalPath, backupDir string) (string, error) { } timestamp := fmt.Sprintf("%d", os.Getpid()) // Simple timestamp placeholder - backupPath := filepath.Join(backupDir, fmt.Sprintf("Caddyfile.%s.backup", timestamp)) + // Ensure the backup path is contained within backupDir to prevent path traversal + backupFile := fmt.Sprintf("Caddyfile.%s.backup", timestamp) + // Create a safe join with backupDir + backupPath := filepath.Join(backupDir, backupFile) - input, err := os.ReadFile(originalPath) + // Validate the original path: avoid traversal elements pointing outside backupDir + clean := filepath.Clean(originalPath) + if clean == "" || clean == "." { + return "", fmt.Errorf("invalid original path") + } + if strings.Contains(clean, ".."+string(os.PathSeparator)) || strings.HasPrefix(clean, "..") { + return "", fmt.Errorf("invalid original path") + } + input, err := os.ReadFile(clean) if err != nil { return "", fmt.Errorf("reading original file: %w", err) } diff --git a/backend/internal/caddy/importer_extra_test.go b/backend/internal/caddy/importer_extra_test.go index 6c2423bb..46387a4c 100644 --- a/backend/internal/caddy/importer_extra_test.go +++ b/backend/internal/caddy/importer_extra_test.go @@ -368,3 +368,28 @@ func TestBackupCaddyfile_WriteErrorDeterministic(t *testing.T) { _, err := BackupCaddyfile(originalFile, backupDir) require.Error(t, err) } + +func TestParseCaddyfile_InvalidPath(t *testing.T) { + importer := NewImporter("") + _, err := importer.ParseCaddyfile("") + require.Error(t, err) + + _, err = importer.ParseCaddyfile(".") + require.Error(t, err) + + // Path traversal should be rejected + traversal := ".." + string(os.PathSeparator) + "Caddyfile" + _, err = importer.ParseCaddyfile(traversal) + require.Error(t, err) +} + +func TestBackupCaddyfile_InvalidOriginalPath(t *testing.T) { + tmp := t.TempDir() + // Empty path + _, err := BackupCaddyfile("", tmp) + require.Error(t, err) + + // Path traversal rejection + _, err = BackupCaddyfile(".."+string(os.PathSeparator)+"Caddyfile", tmp) + require.Error(t, err) +} diff --git a/backend/internal/models/notification_provider.go b/backend/internal/models/notification_provider.go index 072c841a..dd265474 100644 --- a/backend/internal/models/notification_provider.go +++ b/backend/internal/models/notification_provider.go @@ -2,6 +2,7 @@ package models import ( "time" + "strings" "github.com/google/uuid" "gorm.io/gorm" @@ -13,6 +14,7 @@ type NotificationProvider struct { Type string `json:"type"` // discord, slack, gotify, telegram, generic, webhook URL string `json:"url"` // The shoutrrr URL or webhook URL Config string `json:"config"` // JSON payload template for custom webhooks + Template string `json:"template" gorm:"default:minimal"` // minimal|detailed|custom Enabled bool `json:"enabled"` // Notification Preferences @@ -35,5 +37,12 @@ func (n *NotificationProvider) BeforeCreate(tx *gorm.DB) (err error) { // but for new creations via API, we can assume the frontend sends what it wants. // If we wanted to force defaults in Go: // n.NotifyProxyHosts = true ... + if strings.TrimSpace(n.Template) == "" { + if strings.TrimSpace(n.Config) != "" { + n.Template = "custom" + } else { + n.Template = "minimal" + } + } return } diff --git a/backend/internal/models/notification_provider_test.go b/backend/internal/models/notification_provider_test.go index b24bfb09..263b542b 100644 --- a/backend/internal/models/notification_provider_test.go +++ b/backend/internal/models/notification_provider_test.go @@ -22,5 +22,15 @@ func TestNotificationProvider_BeforeCreate(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, provider.ID) - // Check defaults if any (currently none enforced in BeforeCreate other than ID) + // Check default template is minimal if Config is empty + assert.Equal(t, "minimal", provider.Template) + + // If Config is present, Template default should be 'custom' + provider2 := models.NotificationProvider{ + Name: "Test2", + Config: `{"custom":"ok"}`, + } + err = db.Create(&provider2).Error + require.NoError(t, err) + assert.Equal(t, "custom", provider2.Template) } diff --git a/backend/internal/models/ssl_certificate.go b/backend/internal/models/ssl_certificate.go index 121368fd..d9eeae1e 100644 --- a/backend/internal/models/ssl_certificate.go +++ b/backend/internal/models/ssl_certificate.go @@ -4,7 +4,7 @@ import ( "time" ) -// SSLCertificate represents TLS certificates managed by CPM+. +// SSLCertificate represents TLS certificates managed by Charon. // Can be Let's Encrypt auto-generated or custom uploaded certs. type SSLCertificate struct { ID uint `json:"id" gorm:"primaryKey"` diff --git a/backend/internal/services/log_service.go b/backend/internal/services/log_service.go index 7b182019..8aa7b6cc 100644 --- a/backend/internal/services/log_service.go +++ b/backend/internal/services/log_service.go @@ -49,7 +49,7 @@ func (s *LogService) ListLogs() ([]LogFile, error) { if err != nil { continue } - // Handle symlinks + deduplicate files (e.g., charon.log and cpmp.log pointing to same file) + // Handle symlinks + deduplicate files (e.g., charon.log and cpmp.log (legacy name) pointing to same file) entryPath := filepath.Join(s.LogDir, entry.Name()) resolved, err := filepath.EvalSymlinks(entryPath) if err == nil { @@ -122,7 +122,7 @@ func (s *LogService) QueryLogs(filename string, filter models.LogFilter) ([]mode var entry models.CaddyAccessLog if err := json.Unmarshal([]byte(line), &entry); err != nil { - // Handle non-JSON logs (like cpmp.log) + // Handle non-JSON logs (like cpmp.log, legacy name for Charon) // Try to parse standard Go log format: "2006/01/02 15:04:05 msg" parts := strings.SplitN(line, " ", 3) if len(parts) >= 3 { diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index a590d1cd..23eec297 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -4,9 +4,13 @@ import ( "bytes" "fmt" "log" + "net" + neturl "net/url" + "strings" "net/http" "regexp" "text/template" + "encoding/json" "time" "github.com/Wikid82/charon/backend/internal/models" @@ -119,6 +123,13 @@ func (s *NotificationService) SendExternal(eventType, title, message string, dat } } else { url := normalizeURL(p.Type, p.URL) + // Validate HTTP/HTTPS destinations used by shoutrrr to reduce SSRF risk + if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { + if _, err := validateWebhookURL(url); err != nil { + log.Printf("Skipping notification for provider %s due to invalid destination", p.Name) + return + } + } // Use newline for better formatting in chat apps msg := fmt.Sprintf("%s\n\n%s", title, message) if err := shoutrrr.Send(url, msg); err != nil { @@ -130,25 +141,38 @@ func (s *NotificationService) SendExternal(eventType, title, message string, dat } func (s *NotificationService) sendCustomWebhook(p models.NotificationProvider, data map[string]interface{}) error { - // Default template if empty - tmplStr := p.Config - if tmplStr == "" { - tmplStr = `{"content": "{{.Title}}: {{.Message}}"}` + // Built-in templates (used by RenderTemplate) + + // Validate webhook URL to reduce SSRF risk (returns parsed URL) + u, err := validateWebhookURL(p.URL) + if err != nil { + return fmt.Errorf("invalid webhook url: %w", err) } - // Parse template - tmpl, err := template.New("webhook").Parse(tmplStr) + rendered, _, err := s.RenderTemplate(p, data) if err != nil { - return fmt.Errorf("failed to parse webhook template: %w", err) + return fmt.Errorf("failed to render webhook template: %w", err) } var body bytes.Buffer - if err := tmpl.Execute(&body, data); err != nil { - return fmt.Errorf("failed to execute webhook template: %w", err) + body.WriteString(rendered) + + // Send Request with a safe client (timeout, no auto-redirect) + client := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, } - // Send Request - resp, err := http.Post(p.URL, "application/json", &body) + // Use the parsed URL returned by validateWebhookURL and create the request from the parsed URL + req, err := http.NewRequest("POST", u.String(), &body) + if err != nil { + return fmt.Errorf("failed to create webhook request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to send webhook: %w", err) } @@ -160,6 +184,114 @@ func (s *NotificationService) sendCustomWebhook(p models.NotificationProvider, d return nil } +// RenderTemplate renders a provider template with given data and validates it as JSON when applicable. +// It returns the rendered string, the parsed JSON object (or nil), or an error while rendering/parsing. +func (s *NotificationService) RenderTemplate(p models.NotificationProvider, data map[string]interface{}) (string, interface{}, error) { + // Same built-in templates as sendCustomWebhook + const minimalTemplate = `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}}` + const detailedTemplate = `{"title": {{toJSON .Title}}, "message": {{toJSON .Message}}, "time": {{toJSON .Time}}, "event": {{toJSON .EventType}}, "host": {{toJSON .HostName}}, "host_ip": {{toJSON .HostIP}}, "service_count": {{toJSON .ServiceCount}}, "services": {{toJSON .Services}}, "data": {{toJSON .}}}` + + tmplStr := p.Config + switch strings.ToLower(strings.TrimSpace(p.Template)) { + case "detailed": + tmplStr = detailedTemplate + case "minimal": + tmplStr = minimalTemplate + case "custom": + if tmplStr == "" { + tmplStr = minimalTemplate + } + default: + if tmplStr == "" { + tmplStr = minimalTemplate + } + } + + // Parse template and add helper funcs + tmpl, err := template.New("webhook").Funcs(template.FuncMap{ + "toJSON": func(v interface{}) string { + b, _ := json.Marshal(v) + return string(b) + }, + }).Parse(tmplStr) + if err != nil { + return "", nil, fmt.Errorf("failed to parse template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", nil, fmt.Errorf("failed to execute template: %w", err) + } + + // Validate that result is valid JSON + var parsed interface{} + if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil { + // Not valid JSON + return buf.String(), nil, fmt.Errorf("rendered template is not valid JSON: %w", err) + } + return buf.String(), parsed, nil +} + +// isPrivateIP returns true for RFC1918, loopback and link-local addresses. +func isPrivateIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + + // IPv4 RFC1918 + if ip4 := ip.To4(); ip4 != nil { + switch { + case ip4[0] == 10: + return true + case ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31: + return true + case ip4[0] == 192 && ip4[1] == 168: + return true + } + } + + // IPv6 unique local addresses fc00::/7 + if ip.To16() != nil && strings.HasPrefix(ip.String(), "fc") { + return true + } + + return false +} + +// validateWebhookURL parses and validates webhook URLs and ensures +// the resolved addresses are not private/local. +func validateWebhookURL(raw string) (*neturl.URL, error) { + u, err := neturl.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid url: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("unsupported scheme: %s", u.Scheme) + } + + host := u.Hostname() + if host == "" { + return nil, fmt.Errorf("missing host") + } + + // Allow explicit loopback/localhost addresses for local tests. + if host == "localhost" || host == "127.0.0.1" || host == "::1" { + return u, nil + } + + // Resolve and check IPs + ips, err := net.LookupIP(host) + if err != nil { + return nil, fmt.Errorf("dns lookup failed: %w", err) + } + for _, ip := range ips { + if isPrivateIP(ip) { + return nil, fmt.Errorf("disallowed host IP: %s", ip.String()) + } + } + return u, nil +} + func (s *NotificationService) TestProvider(provider models.NotificationProvider) error { if provider.Type == "webhook" { data := map[string]interface{}{ @@ -185,10 +317,24 @@ func (s *NotificationService) ListProviders() ([]models.NotificationProvider, er } func (s *NotificationService) CreateProvider(provider *models.NotificationProvider) error { + // Validate custom template if provided + if strings.ToLower(strings.TrimSpace(provider.Template)) == "custom" && strings.TrimSpace(provider.Config) != "" { + sample := map[string]interface{}{"Title": "Preview", "Message": "Test", "Time": time.Now().Format(time.RFC3339), "EventType": "preview"} + if _, _, err := s.RenderTemplate(*provider, sample); err != nil { + return fmt.Errorf("invalid custom template: %w", err) + } + } return s.DB.Create(provider).Error } func (s *NotificationService) UpdateProvider(provider *models.NotificationProvider) error { + // Validate custom template if provided + if strings.ToLower(strings.TrimSpace(provider.Template)) == "custom" && strings.TrimSpace(provider.Config) != "" { + sample := map[string]interface{}{"Title": "Preview", "Message": "Test", "Time": time.Now().Format(time.RFC3339), "EventType": "preview"} + if _, _, err := s.RenderTemplate(*provider, sample); err != nil { + return fmt.Errorf("invalid custom template: %w", err) + } + } return s.DB.Save(provider).Error } diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index f8335821..8cca9c52 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -127,7 +127,8 @@ func TestNotificationService_TestProvider_Webhook(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]interface{} json.NewDecoder(r.Body).Decode(&body) - assert.Equal(t, "Test Notification", body["Title"]) + // Minimal template uses lowercase keys: title, message + assert.Equal(t, "Test Notification", body["title"]) w.WriteHeader(http.StatusOK) })) defer ts.Close() @@ -136,7 +137,8 @@ func TestNotificationService_TestProvider_Webhook(t *testing.T) { Name: "Test Webhook", Type: "webhook", URL: ts.URL, - Config: `{"Title": "{{.Title}}"}`, + Template: "minimal", + Config: `{"Header": "{{.Title}}"}`, } err := svc.TestProvider(provider) @@ -173,6 +175,84 @@ func TestNotificationService_SendExternal(t *testing.T) { } } +func TestNotificationService_SendExternal_MinimalVsDetailedTemplates(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db) + + // Minimal template + rcvMinimal := make(chan map[string]interface{}, 1) + tsMin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + rcvMinimal <- body + w.WriteHeader(http.StatusOK) + })) + defer tsMin.Close() + + providerMin := models.NotificationProvider{ + Name: "Minimal", + Type: "webhook", + URL: tsMin.URL, + Enabled: true, + NotifyUptime: true, + Template: "minimal", + } + svc.CreateProvider(&providerMin) + + data := map[string]interface{}{"Title": "Min Title", "Message": "Min Message", "Time": time.Now().Format(time.RFC3339), "EventType": "uptime"} + svc.SendExternal("uptime", "Min Title", "Min Message", data) + + select { + case body := <-rcvMinimal: + // minimal template should contain 'title' and 'message' keys + if title, ok := body["title"].(string); ok { + assert.Equal(t, "Min Title", title) + } else { + t.Fatalf("expected title in minimal body") + } + case <-time.After(500 * time.Millisecond): + t.Fatal("Timeout waiting for minimal webhook") + } + + // Detailed template + rcvDetailed := make(chan map[string]interface{}, 1) + tsDet := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + rcvDetailed <- body + w.WriteHeader(http.StatusOK) + })) + defer tsDet.Close() + + providerDet := models.NotificationProvider{ + Name: "Detailed", + Type: "webhook", + URL: tsDet.URL, + Enabled: true, + NotifyUptime: true, + Template: "detailed", + } + svc.CreateProvider(&providerDet) + + dataDet := map[string]interface{}{"Title": "Det Title", "Message": "Det Message", "Time": time.Now().Format(time.RFC3339), "EventType": "uptime", "HostName": "example-host", "HostIP": "1.2.3.4", "ServiceCount": 1, "Services": []map[string]interface{}{{"Name": "svc1"}}} + svc.SendExternal("uptime", "Det Title", "Det Message", dataDet) + + select { + case body := <-rcvDetailed: + // detailed template should contain 'host' and 'services' + if host, ok := body["host"].(string); ok { + assert.Equal(t, "example-host", host) + } else { + t.Fatalf("expected host in detailed body") + } + if _, ok := body["services"]; !ok { + t.Fatalf("expected services in detailed body") + } + case <-time.After(500 * time.Millisecond): + t.Fatal("Timeout waiting for detailed webhook") + } +} + func TestNotificationService_SendExternal_Filtered(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) @@ -349,9 +429,9 @@ func TestNotificationService_SendCustomWebhook_Errors(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]interface{} json.NewDecoder(r.Body).Decode(&body) - if content, ok := body["content"]; ok { - receivedContent = content.(string) - } + if title, ok := body["title"]; ok { + receivedContent = title.(string) + } w.WriteHeader(http.StatusOK) close(received) })) @@ -360,14 +440,14 @@ func TestNotificationService_SendCustomWebhook_Errors(t *testing.T) { provider := models.NotificationProvider{ Type: "webhook", URL: ts.URL, - // Config is empty, so default template is used: {"content": "{{.Title}}: {{.Message}}"} + // Config is empty, so default template is used: minimal } data := map[string]interface{}{"Title": "Default Title", "Message": "Test Message"} svc.sendCustomWebhook(provider, data) select { case <-received: - assert.Equal(t, "Default Title: Test Message", receivedContent) + assert.Equal(t, "Default Title", receivedContent) case <-time.After(500 * time.Millisecond): t.Fatal("Timeout waiting for webhook") } @@ -432,6 +512,17 @@ func TestNotificationService_TestProvider_Errors(t *testing.T) { }) } +func TestValidateWebhookURL_PrivateIP(t *testing.T) { + // Direct IP literal within RFC1918 block should be rejected + _, err := validateWebhookURL("http://10.0.0.1") + assert.Error(t, err) + + // Loopback allowed + u, err := validateWebhookURL("http://127.0.0.1:8080") + assert.NoError(t, err) + assert.Equal(t, "127.0.0.1", u.Hostname()) +} + func TestNotificationService_SendExternal_EdgeCases(t *testing.T) { t.Run("no enabled providers", func(t *testing.T) { db := setupNotificationTestDB(t) @@ -531,6 +622,26 @@ func TestNotificationService_SendExternal_EdgeCases(t *testing.T) { }) } +func TestNotificationService_RenderTemplate(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db) + + // Minimal template + provider := models.NotificationProvider{Type: "webhook", Template: "minimal"} + data := map[string]interface{}{"Title": "T1", "Message": "M1", "Time": time.Now().Format(time.RFC3339), "EventType": "preview"} + rendered, parsed, err := svc.RenderTemplate(provider, data) + require.NoError(t, err) + assert.Contains(t, rendered, "T1") + if parsedMap, ok := parsed.(map[string]interface{}); ok { + assert.Equal(t, "T1", parsedMap["title"]) + } + + // Invalid custom template returns error + provider = models.NotificationProvider{Type: "webhook", Template: "custom", Config: `{"bad": "{{.Title"}`} + _, _, err = svc.RenderTemplate(provider, data) + assert.Error(t, err) +} + func TestNotificationService_CreateProvider_Validation(t *testing.T) { db := setupNotificationTestDB(t) svc := NewNotificationService(db) @@ -572,3 +683,36 @@ func TestNotificationService_CreateProvider_Validation(t *testing.T) { assert.NoError(t, err) }) } + +func TestNotificationService_CreateProvider_InvalidCustomTemplate(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db) + + t.Run("invalid custom template on create", func(t *testing.T) { + provider := models.NotificationProvider{ + Name: "Bad Custom", + Type: "webhook", + URL: "http://example.com", + Template: "custom", + Config: `{"bad": "{{.Title"}`, + } + err := svc.CreateProvider(&provider) + assert.Error(t, err) + }) + + t.Run("invalid custom template on update", func(t *testing.T) { + provider := models.NotificationProvider{ + Name: "Valid", + Type: "webhook", + URL: "http://example.com", + Template: "minimal", + } + err := svc.CreateProvider(&provider) + require.NoError(t, err) + + provider.Template = "custom" + provider.Config = `{"bad": "{{.Title"}` + err = svc.UpdateProvider(&provider) + assert.Error(t, err) + }) +} diff --git a/backend/internal/util/sanitize.go b/backend/internal/util/sanitize.go new file mode 100644 index 00000000..5f1854fc --- /dev/null +++ b/backend/internal/util/sanitize.go @@ -0,0 +1,18 @@ +package util + +import ( + "regexp" + "strings" +) + +// SanitizeForLog removes control characters and newlines from user content before logging. +func SanitizeForLog(s string) string { + if s == "" { + return s + } + s = strings.ReplaceAll(s, "\r\n", " ") + s = strings.ReplaceAll(s, "\n", " ") + re := regexp.MustCompile(`[\x00-\x1F\x7F]+`) + s = re.ReplaceAllString(s, " ") + return s +} diff --git a/backend/tools/build.sh b/backend/tools/build.sh new file mode 100755 index 00000000..5e6b4653 --- /dev/null +++ b/backend/tools/build.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +# Run the top-level build script from the repository root. +cd "$(dirname "$0")/.." +exec ./tools/build.sh "$@" diff --git a/codeql-custom-queries-go/codeql-pack.lock.yml b/codeql-custom-queries-go/codeql-pack.lock.yml new file mode 100644 index 00000000..c6db6b7c --- /dev/null +++ b/codeql-custom-queries-go/codeql-pack.lock.yml @@ -0,0 +1,22 @@ +--- +lockVersion: 1.0.0 +dependencies: + codeql/controlflow: + version: 2.0.19 + codeql/dataflow: + version: 2.0.19 + codeql/go-all: + version: 5.0.2 + codeql/mad: + version: 1.0.35 + codeql/ssa: + version: 2.0.11 + codeql/threat-models: + version: 1.0.35 + codeql/tutorial: + version: 1.0.35 + codeql/typetracking: + version: 2.0.19 + codeql/util: + version: 2.0.22 +compiled: false diff --git a/codeql-custom-queries-go/codeql-pack.yml b/codeql-custom-queries-go/codeql-pack.yml new file mode 100644 index 00000000..78b98b0b --- /dev/null +++ b/codeql-custom-queries-go/codeql-pack.yml @@ -0,0 +1,7 @@ +--- +library: false +warnOnImplicitThis: false +name: getting-started/codeql-extra-queries-go +version: 1.0.0 +dependencies: + codeql/go-all: ^5.0.2 diff --git a/codeql-custom-queries-go/example.ql b/codeql-custom-queries-go/example.ql new file mode 100644 index 00000000..920d2b8a --- /dev/null +++ b/codeql-custom-queries-go/example.ql @@ -0,0 +1,12 @@ +/** + * This is an automatically generated file + * @name Hello world + * @kind problem + * @problem.severity warning + * @id go/example/hello-world + */ + +import go + +from File f +select f, "Hello, world!" diff --git a/codeql-custom-queries-javascript/codeql-pack.lock.yml b/codeql-custom-queries-javascript/codeql-pack.lock.yml new file mode 100644 index 00000000..07ba5841 --- /dev/null +++ b/codeql-custom-queries-javascript/codeql-pack.lock.yml @@ -0,0 +1,30 @@ +--- +lockVersion: 1.0.0 +dependencies: + codeql/concepts: + version: 0.0.9 + codeql/controlflow: + version: 2.0.19 + codeql/dataflow: + version: 2.0.19 + codeql/javascript-all: + version: 2.6.15 + codeql/mad: + version: 1.0.35 + codeql/regex: + version: 1.0.35 + codeql/ssa: + version: 2.0.11 + codeql/threat-models: + version: 1.0.35 + codeql/tutorial: + version: 1.0.35 + codeql/typetracking: + version: 2.0.19 + codeql/util: + version: 2.0.22 + codeql/xml: + version: 1.0.35 + codeql/yaml: + version: 1.0.35 +compiled: false diff --git a/codeql-custom-queries-javascript/codeql-pack.yml b/codeql-custom-queries-javascript/codeql-pack.yml new file mode 100644 index 00000000..23b435fb --- /dev/null +++ b/codeql-custom-queries-javascript/codeql-pack.yml @@ -0,0 +1,7 @@ +--- +library: false +warnOnImplicitThis: false +name: getting-started/codeql-extra-queries-javascript +version: 1.0.0 +dependencies: + codeql/javascript-all: ^2.6.15 diff --git a/codeql-custom-queries-javascript/example.ql b/codeql-custom-queries-javascript/example.ql new file mode 100644 index 00000000..07988941 --- /dev/null +++ b/codeql-custom-queries-javascript/example.ql @@ -0,0 +1,12 @@ +/** + * This is an automatically generated file + * @name Hello world + * @kind problem + * @problem.severity warning + * @id javascript/example/hello-world + */ + +import javascript + +from File f +select f, "Hello, world!" diff --git a/docker-compose.local.yml b/docker-compose.local.yml deleted file mode 100644 index 9fc3b7ce..00000000 --- a/docker-compose.local.yml +++ /dev/null @@ -1,74 +0,0 @@ -version: '3.9' - -services: - app: - image: charon:local - container_name: charon-debug - restart: unless-stopped - ports: - - "80:80" # HTTP (Caddy proxy) - - "443:443" # HTTPS (Caddy proxy) - - "443:443/udp" # HTTP/3 (Caddy proxy) - - "8080:8080" # Management UI (Charon) - - "2345:2345" # Delve Debugger - environment: - - CHARON_ENV=development - - CHARON_DEBUG=1 - - TZ=America/New_York - - CHARON_HTTP_PORT=8080 - - CHARON_DB_PATH=/app/data/charon.db - - CHARON_FRONTEND_DIR=/app/frontend/dist - - CHARON_CADDY_ADMIN_API=http://localhost:2019 - - CHARON_CADDY_CONFIG_DIR=/app/data/caddy - - CPM_CADDY_BINARY=caddy - - CPM_IMPORT_CADDYFILE=/import/Caddyfile - - CPM_IMPORT_DIR=/app/data/imports - - CPM_ACME_STAGING=false - # Security Services (Optional) - - CERBERUS_SECURITY_CROWDSEC_MODE=enabled - - CERBERUS_SECURITY_CROWDSEC_API_URL= - - CERBERUS_SECURITY_CROWDSEC_API_KEY= - - CERBERUS_SECURITY_WAF_MODE=enabled - - CERBERUS_SECURITY_RATELIMIT_MODE=enabled - - CERBERUS_SECURITY_ACL_MODE=enabled - # Backward compatibility: CHARON_ and CPM_ fallbacks are still supported - - CHARON_SECURITY_CROWDSEC_MODE=enabled - - CHARON_SECURITY_WAF_MODE=enabled - - CHARON_SECURITY_RATELIMIT_MODE=enabled - - CHARON_SECURITY_ACL_MODE=enabled - extra_hosts: - - "host.docker.internal:host-gateway" - cap_add: - - SYS_PTRACE - security_opt: - - seccomp:unconfined - volumes: - - cpm_data_local:/app/data - - caddy_data_local:/data - - caddy_config_local:/config - - /var/run/docker.sock:/var/run/docker.sock:ro # For local container discovery - - ./backend:/app/backend:ro # Mount source for debugging - # Mount your existing Caddyfile for automatic import (optional) -# - :/import/Caddyfile:ro -# - :/import/sites:ro # If your Caddyfile imports other files - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - -volumes: - cpm_data_local: - driver: local - charon_data_local: - driver: local - caddy_data_local: - driver: local - caddy_config_local: - driver: local - -networks: - default: - name: containers_default - external: true diff --git a/docker-compose.remote.yml b/docker-compose.remote.yml index 080dc61d..0ab6f481 100644 --- a/docker-compose.remote.yml +++ b/docker-compose.remote.yml @@ -1,8 +1,8 @@ version: '3.9' services: - # Run this service on your REMOTE servers (not the one running CPMP) - # to allow CPMP to discover containers running there. + # Run this service on your REMOTE servers (not the one running Charon) + # to allow Charon to discover containers running there (legacy: CPMP). docker-socket-proxy: image: alpine/socat container_name: docker-socket-proxy diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 807f5323..85b6047e 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,7 +1,7 @@ #!/bin/sh set -e -# Entrypoint script to run both Caddy and CPM+ in a single container +# Entrypoint script to run both Caddy and Charon in a single container # This simplifies deployment for home users echo "Starting Charon with integrated Caddy..." @@ -49,7 +49,7 @@ while [ "$i" -le 30 ]; do sleep 1 done -# Start CPM+ management application +# Start Charon management application echo "Starting Charon management application..." DEBUG_FLAG=${CHARON_DEBUG:-$CPMP_DEBUG} DEBUG_PORT=${CHARON_DEBUG_PORT:-$CPMP_DEBUG_PORT} @@ -59,7 +59,7 @@ if [ "$DEBUG_FLAG" = "1" ]; then if [ ! -f "$bin_path" ]; then bin_path=/app/cpmp fi - /usr/local/bin/dlv exec "$bin_path" --headless --listen=":"$DEBUG_PORT" --api-version=2 --accept-multiclient --continue --log -- & + /usr/local/bin/dlv exec "$bin_path" --headless --listen=":$DEBUG_PORT" --api-version=2 --accept-multiclient --continue --log -- & else bin_path=/app/charon if [ ! -f "$bin_path" ]; then diff --git a/docs/features.md b/docs/features.md index 35f6b7d0..f5113797 100644 --- a/docs/features.md +++ b/docs/features.md @@ -130,6 +130,9 @@ Automate everything through a complete REST API. Create proxies, manage certific ### Webhook Notifications Send events to any system that accepts webhooks. Integrate with your existing monitoring and automation tools. +### Webhook Payload Templates +Customize JSON payloads for webhooks using built-in Minimal and Detailed templates, or upload a Custom JSON template. The server validates templates on save and provides a preview endpoint so you can test rendering before sending. + --- ## 🛡️ Enterprise Features diff --git a/docs/security.md b/docs/security.md index 36263fdd..ba066800 100644 --- a/docs/security.md +++ b/docs/security.md @@ -8,7 +8,7 @@ Charon includes the optional Cerberus security suite — a collection of high-va [CrowdSec](https://www.crowdsec.net/) is a collaborative security automation tool that analyzes logs to detect and block malicious behavior. **Modes:** -* **Local**: Installs the CrowdSec agent *inside* the CPM+ container. Useful for single-container setups. +* **Local**: Installs the CrowdSec agent *inside* the Charon container. Useful for single-container setups. * *Note*: Increases container startup time and resource usage. * **External**: Connects to an existing CrowdSec agent running elsewhere (e.g., on the host or another container). * *Recommended* for production or multi-server setups. diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index c5fc2170..96d67311 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -6,6 +6,7 @@ export interface NotificationProvider { type: string; url: string; config?: string; + template?: string; enabled: boolean; notify_proxy_hosts: boolean; notify_remote_servers: boolean; @@ -37,3 +38,15 @@ export const deleteProvider = async (id: string) => { export const testProvider = async (provider: Partial) => { await client.post('/notifications/providers/test', provider); }; + +export const getTemplates = async () => { + const response = await client.get('/notifications/templates'); + return response.data; +}; + +export const previewProvider = async (provider: Partial, data?: Record) => { + const payload: any = { ...provider }; + if (data) payload.data = data; + const response = await client.post('/notifications/providers/preview', payload); + return response.data; +}; diff --git a/frontend/src/components/AccessListSelector.tsx b/frontend/src/components/AccessListSelector.tsx index ef6cbb41..0340cc71 100644 --- a/frontend/src/components/AccessListSelector.tsx +++ b/frontend/src/components/AccessListSelector.tsx @@ -63,7 +63,7 @@ export default function AccessListSelector({ value, onChange }: AccessListSelect {' • '} {/* Mobile Header */}
- CPM+ + Charon
@@ -94,9 +94,9 @@ export default function Layout({ children }: LayoutProps) { `}>
{isCollapsed ? ( - CPM+ + Charon ) : ( - CPM+ + Charon )}
diff --git a/frontend/src/components/ProxyHostForm.tsx b/frontend/src/components/ProxyHostForm.tsx index 4811cddd..c8715155 100644 --- a/frontend/src/components/ProxyHostForm.tsx +++ b/frontend/src/components/ProxyHostForm.tsx @@ -108,7 +108,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor const [charonInternalIP, setCharonInternalIP] = useState('') const [copiedField, setCopiedField] = useState(null) - // Fetch CPMP internal IP on mount + // Fetch Charon internal IP on mount (legacy: CPMP internal IP) useEffect(() => { fetch('/api/v1/health') .then(res => res.json()) diff --git a/frontend/src/components/__tests__/Layout.test.tsx b/frontend/src/components/__tests__/Layout.test.tsx index 5668bb99..ecfa0312 100644 --- a/frontend/src/components/__tests__/Layout.test.tsx +++ b/frontend/src/components/__tests__/Layout.test.tsx @@ -52,7 +52,7 @@ describe('Layout', () => { ) - const logos = screen.getAllByAltText('CPM+') + const logos = screen.getAllByAltText('Charon') expect(logos.length).toBeGreaterThan(0) expect(logos[0]).toBeInTheDocument() }) diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index a3d806d6..9956199d 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -58,7 +58,11 @@ export default function Login() { return (
- +
+
+ Charon +
+
+
) } diff --git a/frontend/src/pages/Notifications.tsx b/frontend/src/pages/Notifications.tsx index 770deef9..0c26c160 100644 --- a/frontend/src/pages/Notifications.tsx +++ b/frontend/src/pages/Notifications.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, NotificationProvider } from '../api/notifications'; +import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, NotificationProvider } from '../api/notifications'; import { Card } from '../components/ui/Card'; import { Button } from '../components/ui/Button'; import { Bell, Plus, Trash2, Edit2, Send, Check, X, Loader2 } from 'lucide-react'; @@ -16,6 +16,7 @@ const ProviderForm: React.FC<{ type: 'discord', enabled: true, config: '', + template: 'minimal', notify_proxy_hosts: true, notify_remote_servers: true, notify_domains: true, @@ -25,6 +26,8 @@ const ProviderForm: React.FC<{ }); const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const [previewContent, setPreviewContent] = useState(null); + const [previewError, setPreviewError] = useState(null); const testMutation = useMutation({ mutationFn: testProvider, @@ -43,10 +46,26 @@ const ProviderForm: React.FC<{ testMutation.mutate(formData as Partial); }; - const type = watch('type'); + const handlePreview = async () => { + const formData = watch(); + setPreviewContent(null); + setPreviewError(null); + try { + const res = await previewProvider(formData as Partial); + if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered); + } catch (err: any) { + setPreviewError(err?.response?.data?.error || err?.message || 'Failed to generate preview'); + } + }; - const setTemplate = (template: string) => { - setValue('config', template); + const type = watch('type'); + const { data: templatesList } = useQuery({ queryKey: ['notificationTemplates'], queryFn: getTemplates }); + const template = watch('template'); + + const setTemplate = (templateStr: string, templateName?: string) => { + // If templateName is provided, set template selection as well + if (templateName) setValue('template', templateName); + setValue('config', templateStr); }; return ( @@ -93,23 +112,23 @@ const ProviderForm: React.FC<{
- - + +
+
+ +