diff --git a/.codecov.yml b/.codecov.yml index a6458e44..3c38b724 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -55,7 +55,6 @@ ignore: # Backend non-source files - "backend/cmd/seed/**" - - "backend/cmd/api/**" - "backend/data/**" - "backend/coverage/**" - "backend/bin/**" diff --git a/.dockerignore b/.dockerignore index 8de6d2f0..098ab0bc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -39,6 +39,9 @@ frontend/node_modules/ frontend/coverage/ frontend/test-results/ frontend/dist/ +frontend/.cache +frontend/.eslintcache +data/geoip frontend/.vite/ frontend/*.tsbuildinfo frontend/frontend/ diff --git a/.gitignore b/.gitignore index 96ec7d48..4b4340c1 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ frontend/coverage/ frontend/test-results/ frontend/.vite/ frontend/*.tsbuildinfo +/frontend/.cache/ +/frontend/.eslintcache +/backend/.vscode/ +/data/geoip/ /frontend/frontend/ # ----------------------------------------------------------------------------- diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go index 19727cda..fa4c3d60 100644 --- a/backend/internal/api/handlers/auth_handler.go +++ b/backend/internal/api/handlers/auth_handler.go @@ -32,13 +32,32 @@ func isProduction() bool { return env == "production" || env == "prod" } +func requestScheme(c *gin.Context) string { + if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { + // Honor first entry in a comma-separated header + parts := strings.Split(proto, ",") + return strings.ToLower(strings.TrimSpace(parts[0])) + } + if c.Request != nil && c.Request.TLS != nil { + return "https" + } + if c.Request != nil && c.Request.URL != nil && c.Request.URL.Scheme != "" { + return strings.ToLower(c.Request.URL.Scheme) + } + return "http" +} + // setSecureCookie sets an auth cookie with security best practices // - HttpOnly: prevents JavaScript access (XSS protection) -// - Secure: only sent over HTTPS (in production) -// - SameSite=Strict: prevents CSRF attacks +// - Secure: derived from request scheme to allow HTTP/IP logins when needed +// - SameSite: Strict for HTTPS, Lax for HTTP/IP to allow forward-auth redirects func setSecureCookie(c *gin.Context, name, value string, maxAge int) { - secure := isProduction() + scheme := requestScheme(c) + secure := isProduction() && scheme == "https" sameSite := http.SameSiteStrictMode + if scheme != "https" { + sameSite = http.SameSiteLaxMode + } // Use the host without port for domain domain := "" @@ -78,7 +97,7 @@ func (h *AuthHandler) Login(c *gin.Context) { return } - // Set secure cookie (HttpOnly, Secure in prod, SameSite=Strict) + // Set secure cookie (scheme-aware) and return token for header fallback setSecureCookie(c, "auth_token", token, 3600*24) c.JSON(http.StatusOK, gin.H{"token": token}) diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index 77340c13..1eaa7ea6 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "testing" "github.com/Wikid82/charon/backend/internal/config" @@ -60,6 +61,39 @@ func TestAuthHandler_Login(t *testing.T) { assert.Contains(t, w.Body.String(), "token") } +func TestSetSecureCookie_HTTPS_Strict(t *testing.T) { + gin.SetMode(gin.TestMode) + os.Setenv("CHARON_ENV", "production") + defer os.Unsetenv("CHARON_ENV") + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest("POST", "https://example.com/login", http.NoBody) + ctx.Request = req + + setSecureCookie(ctx, "auth_token", "abc", 60) + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + c := cookies[0] + assert.True(t, c.Secure) + assert.Equal(t, http.SameSiteStrictMode, c.SameSite) +} + +func TestSetSecureCookie_HTTP_Lax(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest("POST", "http://192.0.2.10/login", http.NoBody) + req.Header.Set("X-Forwarded-Proto", "http") + ctx.Request = req + + setSecureCookie(ctx, "auth_token", "abc", 60) + cookies := recorder.Result().Cookies() + require.Len(t, cookies, 1) + c := cookies[0] + assert.False(t, c.Secure) + assert.Equal(t, http.SameSiteLaxMode, c.SameSite) +} + func TestAuthHandler_Login_Errors(t *testing.T) { handler, _ := setupAuthHandler(t) gin.SetMode(gin.TestMode) diff --git a/backend/internal/api/handlers/crowdsec_handler.go b/backend/internal/api/handlers/crowdsec_handler.go index f0e814fd..fae4dd52 100644 --- a/backend/internal/api/handlers/crowdsec_handler.go +++ b/backend/internal/api/handlers/crowdsec_handler.go @@ -20,6 +20,7 @@ import ( "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/util" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -327,7 +328,7 @@ func (h *CrowdsecHandler) ExportConfig(c *gin.Context) { } defer func() { if err := f.Close(); err != nil { - logger.Log().WithError(err).Warn("failed to close file while archiving", "path", path) + logger.Log().WithError(err).Warn("failed to close file while archiving", "path", util.SanitizeForLog(path)) } }() @@ -570,7 +571,7 @@ func (h *CrowdsecHandler) PullPreset(c *gin.Context) { // Log cache directory before pull if h.Hub != nil && h.Hub.Cache != nil { cacheDir := filepath.Join(h.DataDir, "hub_cache") - logger.Log().WithField("cache_dir", cacheDir).WithField("slug", slug).Info("attempting to pull preset") + logger.Log().WithField("cache_dir", util.SanitizeForLog(cacheDir)).WithField("slug", util.SanitizeForLog(slug)).Info("attempting to pull preset") if stat, err := os.Stat(cacheDir); err == nil { logger.Log().WithField("cache_dir_mode", stat.Mode()).WithField("cache_dir_writable", stat.Mode().Perm()&0o200 != 0).Debug("cache directory exists") } else { @@ -581,7 +582,7 @@ func (h *CrowdsecHandler) PullPreset(c *gin.Context) { res, err := h.Hub.Pull(ctx, slug) if err != nil { status := mapCrowdsecStatus(err, http.StatusBadGateway) - logger.Log().WithError(err).WithField("slug", slug).WithField("hub_base_url", h.Hub.HubBaseURL).Warn("crowdsec preset pull failed") + logger.Log().WithError(err).WithField("slug", util.SanitizeForLog(slug)).WithField("hub_base_url", h.Hub.HubBaseURL).Warn("crowdsec preset pull failed") c.JSON(status, gin.H{"error": err.Error(), "hub_endpoints": h.hubEndpoints()}) return } @@ -661,11 +662,11 @@ func (h *CrowdsecHandler) ApplyPreset(c *gin.Context) { // Log cache status before apply if h.Hub != nil && h.Hub.Cache != nil { cacheDir := filepath.Join(h.DataDir, "hub_cache") - logger.Log().WithField("cache_dir", cacheDir).WithField("slug", slug).Info("attempting to apply preset") + logger.Log().WithField("cache_dir", util.SanitizeForLog(cacheDir)).WithField("slug", util.SanitizeForLog(slug)).Info("attempting to apply preset") // Check if cached if cached, err := h.Hub.Cache.Load(ctx, slug); err == nil { - logger.Log().WithField("slug", slug).WithField("cache_key", cached.CacheKey).WithField("archive_path", cached.ArchivePath).WithField("preview_path", cached.PreviewPath).Info("preset found in cache") + logger.Log().WithField("slug", util.SanitizeForLog(slug)).WithField("cache_key", cached.CacheKey).WithField("archive_path", cached.ArchivePath).WithField("preview_path", cached.PreviewPath).Info("preset found in cache") // Verify files still exist if _, err := os.Stat(cached.ArchivePath); err != nil { logger.Log().WithError(err).WithField("archive_path", cached.ArchivePath).Error("cached archive file missing") @@ -674,7 +675,7 @@ func (h *CrowdsecHandler) ApplyPreset(c *gin.Context) { logger.Log().WithError(err).WithField("preview_path", cached.PreviewPath).Error("cached preview file missing") } } else { - logger.Log().WithError(err).WithField("slug", slug).Warn("preset not found in cache before apply") + logger.Log().WithError(err).WithField("slug", util.SanitizeForLog(slug)).Warn("preset not found in cache before apply") // List what's actually in the cache if entries, listErr := h.Hub.Cache.List(ctx); listErr == nil { slugs := make([]string, len(entries)) @@ -689,7 +690,7 @@ func (h *CrowdsecHandler) ApplyPreset(c *gin.Context) { res, err := h.Hub.Apply(ctx, slug) if err != nil { status := mapCrowdsecStatus(err, http.StatusInternalServerError) - logger.Log().WithError(err).WithField("slug", slug).WithField("hub_base_url", h.Hub.HubBaseURL).WithField("backup_path", res.BackupPath).WithField("cache_key", res.CacheKey).Warn("crowdsec preset apply failed") + logger.Log().WithError(err).WithField("slug", util.SanitizeForLog(slug)).WithField("hub_base_url", h.Hub.HubBaseURL).WithField("backup_path", res.BackupPath).WithField("cache_key", res.CacheKey).Warn("crowdsec preset apply failed") if h.DB != nil { _ = h.DB.Create(&models.CrowdsecPresetEvent{Slug: slug, Action: "apply", Status: "failed", CacheKey: res.CacheKey, BackupPath: res.BackupPath, Error: err.Error()}).Error } @@ -771,7 +772,7 @@ func (h *CrowdsecHandler) ConsoleEnroll(c *gin.Context) { } else if strings.Contains(strings.ToLower(err.Error()), "required") { httpStatus = http.StatusBadRequest } - logger.Log().WithError(err).WithField("tenant", payload.Tenant).WithField("agent", payload.AgentName).WithField("correlation_id", status.CorrelationID).Warn("crowdsec console enrollment failed") + logger.Log().WithError(err).WithField("tenant", util.SanitizeForLog(payload.Tenant)).WithField("agent", util.SanitizeForLog(payload.AgentName)).WithField("correlation_id", status.CorrelationID).Warn("crowdsec console enrollment failed") if h.Security != nil { _ = h.Security.LogAudit(&models.SecurityAudit{Actor: actorFromContext(c), Action: "crowdsec_console_enroll_failed", Details: fmt.Sprintf("status=%s tenant=%s agent=%s correlation_id=%s", status.Status, payload.Tenant, payload.AgentName, status.CorrelationID)}) } @@ -970,7 +971,7 @@ func (h *CrowdsecHandler) BanIP(c *gin.Context) { } _, err := h.CmdExec.Execute(ctx, "cscli", args...) if err != nil { - logger.Log().WithError(err).WithField("ip", ip).Warn("Failed to execute cscli decisions add") + logger.Log().WithError(err).WithField("ip", util.SanitizeForLog(ip)).Warn("Failed to execute cscli decisions add") c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to ban IP"}) return } @@ -996,7 +997,7 @@ func (h *CrowdsecHandler) UnbanIP(c *gin.Context) { } _, err := h.CmdExec.Execute(ctx, "cscli", args...) if err != nil { - logger.Log().WithError(err).WithField("ip", ip).Warn("Failed to execute cscli decisions delete") + logger.Log().WithError(err).WithField("ip", util.SanitizeForLog(ip)).Warn("Failed to execute cscli decisions delete") c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unban IP"}) return } diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go index 82194bfc..8e874df5 100644 --- a/backend/internal/api/middleware/auth.go +++ b/backend/internal/api/middleware/auth.go @@ -11,18 +11,17 @@ import ( func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") + if authHeader == "" { - // Try cookie - cookie, err := c.Cookie("auth_token") - if err == nil { + // Try cookie first for browser flows + if cookie, err := c.Cookie("auth_token"); err == nil && cookie != "" { authHeader = "Bearer " + cookie } } if authHeader == "" { - // Try query param - token := c.Query("token") - if token != "" { + // Try query param (token passthrough) + if token := c.Query("token"); token != "" { authHeader = "Bearer " + token } } diff --git a/backend/internal/api/middleware/auth_test.go b/backend/internal/api/middleware/auth_test.go index 7fb4e077..f9724973 100644 --- a/backend/internal/api/middleware/auth_test.go +++ b/backend/internal/api/middleware/auth_test.go @@ -127,6 +127,29 @@ func TestAuthMiddleware_ValidToken(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) } +func TestAuthMiddleware_PrefersAuthorizationHeader(t *testing.T) { + authService := setupAuthService(t) + user, _ := authService.Register("header@example.com", "password", "Header User") + token, _ := authService.GenerateToken(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(AuthMiddleware(authService)) + r.GET("/test", func(c *gin.Context) { + userID, _ := c.Get("userID") + assert.Equal(t, user.ID, userID) + c.Status(http.StatusOK) + }) + + req, _ := http.NewRequest("GET", "/test", http.NoBody) + req.Header.Set("Authorization", "Bearer "+token) + req.AddCookie(&http.Cookie{Name: "auth_token", Value: "stale"}) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + func TestAuthMiddleware_InvalidToken(t *testing.T) { authService := setupAuthService(t) diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index c1aa87b2..9943d1e7 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -253,6 +253,12 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { protected.DELETE("/notifications/external-templates/:id", notificationTemplateHandler.Delete) protected.POST("/notifications/external-templates/preview", notificationTemplateHandler.Preview) + // Ensure uptime feature flag exists to avoid record-not-found logs + defaultUptime := models.Setting{Key: "feature.uptime.enabled", Value: "true", Type: "bool", Category: "feature"} + if err := db.Where(models.Setting{Key: defaultUptime.Key}).Attrs(defaultUptime).FirstOrCreate(&defaultUptime).Error; err != nil { + logger.Log().WithError(err).Warn("Failed to ensure uptime feature flag default") + } + // Start background checker (every 1 minute) go func() { // Wait a bit for server to start diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index afd60c07..f3d2c0e6 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -3,6 +3,7 @@ package caddy import ( "encoding/json" "fmt" + "net" "path/filepath" "strings" @@ -139,6 +140,8 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir // Initialize routes slice routes := make([]*Route, 0) + // Track IP-only hostnames to skip AutoHTTPS/ACME + ipSubjects := make([]string, 0) // Track processed domains to prevent duplicates (Ghost Host fix) processedDomains := make(map[string]bool) @@ -177,6 +180,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir // Parse comma-separated domains rawDomains := strings.Split(host.DomainNames, ",") var uniqueDomains []string + isIPOnly := true for _, d := range rawDomains { d = strings.TrimSpace(d) @@ -190,12 +194,19 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir } processedDomains[d] = true uniqueDomains = append(uniqueDomains, d) + if net.ParseIP(d) == nil { + isIPOnly = false + } } if len(uniqueDomains) == 0 { continue } + if isIPOnly { + ipSubjects = append(ipSubjects, uniqueDomains...) + } + // Build handlers for this host handlers := make([]Handler, 0) @@ -397,18 +408,36 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir routes = append(routes, catchAllRoute) } + autoHTTPS := &AutoHTTPSConfig{Disable: false, DisableRedir: false} + if len(ipSubjects) > 0 { + // Skip AutoHTTPS/ACME for IP literals to avoid ERR_SSL_PROTOCOL_ERROR + autoHTTPS.Skip = append(autoHTTPS.Skip, ipSubjects...) + } + config.Apps.HTTP.Servers["charon_server"] = &Server{ - Listen: []string{":80", ":443"}, - Routes: routes, - AutoHTTPS: &AutoHTTPSConfig{ - Disable: false, - DisableRedir: false, - }, + Listen: []string{":80", ":443"}, + Routes: routes, + AutoHTTPS: autoHTTPS, Logs: &ServerLogs{ DefaultLoggerName: "access_log", }, } + // Provide internal certificates for IP subjects when present so optional TLS can succeed without ACME + if len(ipSubjects) > 0 { + if config.Apps.TLS == nil { + config.Apps.TLS = &TLSApp{} + } + policy := &AutomationPolicy{ + Subjects: ipSubjects, + IssuersRaw: []interface{}{map[string]interface{}{"module": "internal"}}, + } + if config.Apps.TLS.Automation == nil { + config.Apps.TLS.Automation = &AutomationConfig{} + } + config.Apps.TLS.Automation.Policies = append(config.Apps.TLS.Automation.Policies, policy) + } + return config, nil } diff --git a/backend/internal/caddy/config_test.go b/backend/internal/caddy/config_test.go index 43bb4588..055b89a0 100644 --- a/backend/internal/caddy/config_test.go +++ b/backend/internal/caddy/config_test.go @@ -139,6 +139,43 @@ func TestGenerateConfig_Logging(t *testing.T) { require.Equal(t, 7, config.Logging.Logs["access"].Writer.RollKeepDays) } +func TestGenerateConfig_IPHostsSkipAutoHTTPS(t *testing.T) { + hosts := []models.ProxyHost{ + { + UUID: "uuid-ip", + DomainNames: "192.0.2.10", + ForwardHost: "app", + ForwardPort: 8080, + Enabled: true, + }, + } + + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil) + require.NoError(t, err) + + server := config.Apps.HTTP.Servers["charon_server"] + require.NotNil(t, server) + require.Contains(t, server.AutoHTTPS.Skip, "192.0.2.10") + + // Ensure TLS automation adds internal issuer for IP literals + require.NotNil(t, config.Apps.TLS) + require.NotNil(t, config.Apps.TLS.Automation) + require.GreaterOrEqual(t, len(config.Apps.TLS.Automation.Policies), 1) + foundIPPolicy := false + for _, p := range config.Apps.TLS.Automation.Policies { + if len(p.Subjects) == 0 { + continue + } + if p.Subjects[0] == "192.0.2.10" { + foundIPPolicy = true + require.Len(t, p.IssuersRaw, 1) + issuer := p.IssuersRaw[0].(map[string]interface{}) + require.Equal(t, "internal", issuer["module"]) + } + } + require.True(t, foundIPPolicy, "expected internal issuer policy for IP host") +} + func TestGenerateConfig_Advanced(t *testing.T) { hosts := []models.ProxyHost{ { diff --git a/backend/internal/crowdsec/hub_cache.go b/backend/internal/crowdsec/hub_cache.go index c6d30200..1c9989ad 100644 --- a/backend/internal/crowdsec/hub_cache.go +++ b/backend/internal/crowdsec/hub_cache.go @@ -13,6 +13,7 @@ import ( "time" "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/util" ) var ( @@ -67,10 +68,10 @@ func (c *HubCache) Store(ctx context.Context, slug, etag, source, preview string return CachedPreset{}, fmt.Errorf("invalid slug") } dir := filepath.Join(c.baseDir, cleanSlug) - logger.Log().WithField("slug", cleanSlug).WithField("cache_dir", dir).WithField("archive_size", len(archive)).Debug("storing preset in cache") + logger.Log().WithField("slug", util.SanitizeForLog(cleanSlug)).WithField("cache_dir", util.SanitizeForLog(dir)).WithField("archive_size", len(archive)).Debug("storing preset in cache") if err := os.MkdirAll(dir, 0o755); err != nil { - logger.Log().WithError(err).WithField("dir", dir).Error("failed to create cache directory") + logger.Log().WithError(err).WithField("dir", util.SanitizeForLog(dir)).Error("failed to create cache directory") return CachedPreset{}, fmt.Errorf("create slug dir: %w", err) } @@ -102,11 +103,11 @@ func (c *HubCache) Store(ctx context.Context, slug, etag, source, preview string return CachedPreset{}, fmt.Errorf("marshal metadata: %w", err) } if err := os.WriteFile(metaPath, raw, 0o640); err != nil { - logger.Log().WithError(err).WithField("meta_path", metaPath).Error("failed to write metadata file") + logger.Log().WithError(err).WithField("meta_path", util.SanitizeForLog(metaPath)).Error("failed to write metadata file") return CachedPreset{}, fmt.Errorf("write metadata: %w", err) } - logger.Log().WithField("slug", cleanSlug).WithField("cache_key", cacheKey).WithField("archive_path", archivePath).WithField("preview_path", previewPath).WithField("meta_path", metaPath).Info("preset successfully stored in cache") + logger.Log().WithField("slug", util.SanitizeForLog(cleanSlug)).WithField("cache_key", cacheKey).WithField("archive_path", util.SanitizeForLog(archivePath)).WithField("preview_path", util.SanitizeForLog(previewPath)).WithField("meta_path", util.SanitizeForLog(metaPath)).Info("preset successfully stored in cache") return meta, nil } @@ -121,29 +122,29 @@ func (c *HubCache) Load(ctx context.Context, slug string) (CachedPreset, error) return CachedPreset{}, fmt.Errorf("invalid slug") } metaPath := filepath.Join(c.baseDir, cleanSlug, "metadata.json") - logger.Log().WithField("slug", cleanSlug).WithField("meta_path", metaPath).Debug("attempting to load cached preset") + logger.Log().WithField("slug", util.SanitizeForLog(cleanSlug)).WithField("meta_path", util.SanitizeForLog(metaPath)).Debug("attempting to load cached preset") data, err := os.ReadFile(metaPath) if err != nil { if errors.Is(err, os.ErrNotExist) { - logger.Log().WithField("slug", cleanSlug).WithField("meta_path", metaPath).Debug("preset not found in cache (cache miss)") + logger.Log().WithField("slug", util.SanitizeForLog(cleanSlug)).WithField("meta_path", util.SanitizeForLog(metaPath)).Debug("preset not found in cache (cache miss)") return CachedPreset{}, ErrCacheMiss } - logger.Log().WithError(err).WithField("slug", cleanSlug).WithField("meta_path", metaPath).Error("failed to read cached preset metadata") + logger.Log().WithError(err).WithField("slug", util.SanitizeForLog(cleanSlug)).WithField("meta_path", util.SanitizeForLog(metaPath)).Error("failed to read cached preset metadata") return CachedPreset{}, err } var meta CachedPreset if err := json.Unmarshal(data, &meta); err != nil { - logger.Log().WithError(err).WithField("slug", cleanSlug).Error("failed to unmarshal cached preset metadata") + logger.Log().WithError(err).WithField("slug", util.SanitizeForLog(cleanSlug)).Error("failed to unmarshal cached preset metadata") return CachedPreset{}, fmt.Errorf("unmarshal metadata: %w", err) } if c.ttl > 0 && c.nowFn().After(meta.RetrievedAt.Add(c.ttl)) { - logger.Log().WithField("slug", cleanSlug).WithField("retrieved_at", meta.RetrievedAt).WithField("ttl", c.ttl).Debug("cached preset expired") + logger.Log().WithField("slug", util.SanitizeForLog(cleanSlug)).WithField("retrieved_at", meta.RetrievedAt).WithField("ttl", c.ttl).Debug("cached preset expired") return CachedPreset{}, ErrCacheExpired } - logger.Log().WithField("slug", meta.Slug).WithField("cache_key", meta.CacheKey).WithField("archive_path", meta.ArchivePath).Debug("successfully loaded cached preset") + logger.Log().WithField("slug", util.SanitizeForLog(meta.Slug)).WithField("cache_key", meta.CacheKey).WithField("archive_path", util.SanitizeForLog(meta.ArchivePath)).Debug("successfully loaded cached preset") return meta, nil } diff --git a/docker-compose.local.yml b/docker-compose.local.yml index ce910308..47d59371 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -1,13 +1,13 @@ services: charon: image: charon:local - container_name: charon-debug + container_name: charon 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 (CPM+) + - "8080:8080" # Management UI (Charon) - "2345:2345" # Delve Debugger environment: - CHARON_ENV=development @@ -55,8 +55,3 @@ volumes: driver: local crowdsec_data: driver: local - -networks: - default: - name: containers_default - external: true diff --git a/docs/features.md b/docs/features.md index e9ef5338..2d55e702 100644 --- a/docs/features.md +++ b/docs/features.md @@ -106,6 +106,33 @@ When you disable a feature: - Check the box if you want to delete the orphaned certificate - Leave unchecked to keep the certificate (in case you need it later) +--- + +## πŸ“Š Dashboard + +### Certificate Status Card + +**What it does:** Displays a real-time overview of all your SSL certificates directly on the Dashboard. + +**Why you care:** Know at a glance if any certificates need attentionβ€”expired, expiring soon, or still provisioning. + +**What you see:** +- **Certificate Breakdown** β€” Visual count of certificates by status: + - βœ… Valid certificates (healthy, not expiring soon) + - ⚠️ Expiring certificates (within 30 days) + - πŸ§ͺ Staging certificates (for testing) + - ❌ Expired certificates (need immediate attention) +- **Pending Indicator** β€” Shows when certificates are being provisioned with a progress bar +- **Auto-Refresh** β€” Card automatically updates during certificate provisioning + +**How it works:** +- The card polls for certificate status changes during active provisioning +- Progress bar shows visual feedback while Let's Encrypt/ZeroSSL issues certificates +- Once all certificates are ready, auto-refresh stops to save resources + +**What you do:** Check the Dashboard after adding new hosts to monitor certificate provisioning. If you see pending certificates, the system is workingβ€”just wait a moment for issuance to complete. + + --- ## \ud83d\udee1\ufe0f Security (Optional) @@ -211,6 +238,21 @@ Charon includes **Cerberus**, a security system that blocks bad guys. It's off b **[Detailed Import Guide](import-guide.md)** +### Import Success Modal + +**What it does:** After importing a Caddyfile, displays a detailed summary modal showing exactly what happened. + +**Why you care:** Know immediately how your import wentβ€”no guessing or digging through logs. + +**What you see:** +- **Hosts Created** β€” New proxy hosts that were added to your configuration +- **Hosts Updated** β€” Existing hosts that were modified with new settings +- **Hosts Skipped** β€” Entries that weren't imported (duplicates or unsupported) +- **Certificate Guidance** β€” Instructions for SSL certificate provisioning +- **Quick Navigation** β€” Buttons to go directly to Dashboard or Proxy Hosts + +**What you do:** Review the summary after each import. If hosts were skipped, check the details to understand why. Use the navigation buttons to proceed with your workflow. + --- ## \u26a1 Zero Downtime Updates diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 7427eddd..2584a341 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,32 +1,645 @@ -# Plan: Fix 'No packages found' for Integration Tests +# Feature Spec: Post-Import Notification & Certificate Status Dashboard -## Problem -VS Code reports "No packages found" for files with `//go:build integration` tags (e.g., `backend/integration/coraza_integration_test.go`). This is because `gopls` (the Go language server) does not include the `integration` build tag by default, causing it to ignore these files during analysis. +**Status:** Planning +**Created:** December 11, 2025 +**Priority:** Medium -## Solution -Configure `gopls` in the workspace settings to include the `integration` build tag. +--- -## Steps +## Overview -### 1. Check `.vscode/settings.json` -- [x] Verify `.vscode/settings.json` exists. (Confirmed) +Two related features to improve user experience around the import workflow and certificate provisioning visibility: -### 2. Update `gopls` settings -- [ ] Edit `.vscode/settings.json`. -- [ ] Locate the `"gopls"` configuration object. -- [ ] Add or update the `"buildFlags"` property to include `"-tags=integration"`. +1. **Post-Import Success Notification** - Replace the current `alert()` with a proper modal showing import results and guidance about certificate provisioning +2. **Certificate Status Indicator on Dashboard** - Add visibility into certificate provisioning status with counts and visual indicators -**Target Configuration:** -```json -"gopls": { - "buildFlags": [ - "-tags=integration" - ], - // ... existing settings ... +--- + +## Feature 1: Post-Import Success Notification + +### Current State + +In [ImportCaddy.tsx](../../../frontend/src/pages/ImportCaddy.tsx#L42), after a successful import commit: + +```tsx +const handleCommit = async (resolutions: Record, names: Record) => { + try { + await createBackup() + await commit(resolutions, names) + setContent('') + setShowReview(false) + alert('Import completed successfully!') // ← Replace this + } catch { + // Error is already set by hook + } } ``` -### 3. Verification -- [ ] Reload VS Code window (or restart the Go language server). -- [ ] Open `backend/integration/coraza_integration_test.go`. -- [ ] Verify that the "No packages found" error is resolved and IntelliSense works for the file. +### Backend Response (import_handler.go) + +The `/import/commit` endpoint returns a detailed response: + +```json +{ + "created": 5, + "updated": 2, + "skipped": 1, + "errors": [] +} +``` + +This data is currently **not captured** by the frontend. + +### Requirements + +1. Replace `alert()` with a modal dialog component +2. Display import summary: + - βœ… Number of hosts created + - πŸ”„ Number of hosts updated (overwrites) + - ⏭️ Number of hosts skipped + - ❌ Any errors encountered +3. Include informational message about certificate provisioning: + - "Certificate provisioning may take 1-5 minutes" + - "Monitor the Dashboard for certificate status" +4. Provide navigation options: + - "Go to Dashboard" button + - "View Proxy Hosts" button + - "Close" button + +### Implementation Plan + +#### 1. Create Import Success Modal Component + +**File:** `frontend/src/components/dialogs/ImportSuccessModal.tsx` + +```tsx +interface ImportSuccessModalProps { + visible: boolean + onClose: () => void + onNavigateDashboard: () => void + onNavigateHosts: () => void + results: { + created: number + updated: number + skipped: number + errors: string[] + } +} +``` + +**Design Pattern:** Follow existing modal patterns from: +- [ImportSitesModal.tsx](../../../frontend/src/components/ImportSitesModal.tsx) - Portal/overlay structure +- [CertificateCleanupDialog.tsx](../../../frontend/src/components/dialogs/CertificateCleanupDialog.tsx) - Form submission pattern + +#### 2. Update API Types + +**File:** `frontend/src/api/import.ts` + +Add return type for commit: + +```typescript +export interface ImportCommitResult { + created: number + updated: number + skipped: number + errors: string[] +} + +export const commitImport = async ( + sessionUUID: string, + resolutions: Record, + names: Record +): Promise => { + const { data } = await client.post('/import/commit', { + session_uuid: sessionUUID, + resolutions, + names + }) + return data +} +``` + +#### 3. Update useImport Hook + +**File:** `frontend/src/hooks/useImport.ts` + +```typescript +// Add to return type +commitResult: ImportCommitResult | null + +// Capture result in mutation +const commitMutation = useMutation({ + mutationFn: async ({ resolutions, names }) => { + const sessionId = statusQuery.data?.session?.id + if (!sessionId) throw new Error("No active session") + return commitImport(sessionId, resolutions, names) // Now returns result + }, + onSuccess: (result) => { + setCommitResult(result) // New state + setCommitSucceeded(true) + // ... existing invalidation logic + }, +}) +``` + +#### 4. Update ImportCaddy.tsx + +**File:** `frontend/src/pages/ImportCaddy.tsx` + +```tsx +import { useNavigate } from 'react-router-dom' +import ImportSuccessModal from '../components/dialogs/ImportSuccessModal' + +// In component: +const navigate = useNavigate() +const { commitResult, clearCommitResult } = useImport() // New fields +const [showSuccessModal, setShowSuccessModal] = useState(false) + +const handleCommit = async (resolutions, names) => { + try { + await createBackup() + await commit(resolutions, names) + setContent('') + setShowReview(false) + setShowSuccessModal(true) // Show modal instead of alert + } catch { + // Error is already set by hook + } +} + +// In JSX: + { + setShowSuccessModal(false) + clearCommitResult() + }} + onNavigateDashboard={() => navigate('/')} + onNavigateHosts={() => navigate('/proxy-hosts')} + results={commitResult} +/> +``` + +--- + +## Feature 2: Certificate Status Indicator on Dashboard + +### Current State + +In [Dashboard.tsx](../../../frontend/src/pages/Dashboard.tsx#L43-L47), the certificates card shows: + +```tsx + +
SSL Certificates
+
{certificates.length}
+
{certificates.filter(c => c.status === 'valid').length} valid
+ +``` + +### Requirements + +1. Show certificate breakdown by status: + - βœ… Valid (production, trusted) + - ⏳ Pending (hosts without certs yet) + - ⚠️ Expiring (within 30 days) + - πŸ”Έ Staging/Untrusted +2. Visual progress indicator for pending certificates +3. Link to filtered certificate list or proxy hosts without certs +4. Auto-refresh to show provisioning progress + +### Certificate Provisioning Detection + +Certificates are provisioned by Caddy automatically. A host is "pending" if: +- `ProxyHost.certificate_id` is NULL +- `ProxyHost.ssl_forced` is true (expects a cert) +- No matching certificate exists in the certificates list + +**Key insight:** The certificate service ([certificate_service.go](../../../backend/internal/services/certificate_service.go)) syncs certificates from Caddy's cert directory every 5 minutes. New hosts won't have certificates immediately. + +### Implementation Plan + +#### 1. Create Certificate Status Summary API Endpoint (Optional Enhancement) + +**File:** `backend/internal/api/handlers/certificate_handler.go` + +Add new endpoint for dashboard summary: + +```go +// GET /certificates/summary +func (h *CertificateHandler) Summary(c *gin.Context) { + certs, _ := h.service.ListCertificates() + + summary := gin.H{ + "total": len(certs), + "valid": 0, + "expiring": 0, + "expired": 0, + "untrusted": 0, // staging certs + } + + for _, c := range certs { + switch c.Status { + case "valid": + summary["valid"] = summary["valid"].(int) + 1 + case "expiring": + summary["expiring"] = summary["expiring"].(int) + 1 + case "expired": + summary["expired"] = summary["expired"].(int) + 1 + case "untrusted": + summary["untrusted"] = summary["untrusted"].(int) + 1 + } + } + + c.JSON(http.StatusOK, summary) +} +``` + +**Note:** This is optional. The frontend can compute this from existing certificate list. + +#### 2. Add Pending Hosts Detection + +The more important metric is "hosts awaiting certificates": + +**Option A: Client-side calculation (simpler, no backend change)** + +```tsx +// In Dashboard.tsx +const hostsWithSSL = hosts.filter(h => h.ssl_forced && h.enabled) +const hostsWithCerts = hosts.filter(h => h.certificate_id != null) +const pendingCerts = hostsWithSSL.length - hostsWithCerts.length +``` + +**Option B: Backend endpoint (more accurate)** + +Add to proxy_host_handler.go: + +```go +// GET /proxy-hosts/cert-status +func (h *ProxyHostHandler) CertStatus(c *gin.Context) { + hosts, _ := h.service.List() + + withSSL := 0 + withCert := 0 + + for _, h := range hosts { + if h.SSLForced && h.Enabled { + withSSL++ + if h.CertificateID != nil { + withCert++ + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "total_ssl_enabled": withSSL, + "with_certificate": withCert, + "pending": withSSL - withCert, + }) +} +``` + +**Recommendation:** Start with Option A (client-side) for simplicity. + +#### 3. Create CertificateStatusCard Component + +**File:** `frontend/src/components/CertificateStatusCard.tsx` + +```tsx +interface CertificateStatusCardProps { + certificates: Certificate[] + hosts: ProxyHost[] +} + +export default function CertificateStatusCard({ certificates, hosts }: CertificateStatusCardProps) { + const validCount = certificates.filter(c => c.status === 'valid').length + const expiringCount = certificates.filter(c => c.status === 'expiring').length + const untrustedCount = certificates.filter(c => c.status === 'untrusted').length + + // Pending = hosts with ssl_forced but no certificate_id + const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) + const hostsWithCerts = sslHosts.filter(h => h.certificate_id != null) + const pendingCount = sslHosts.length - hostsWithCerts.length + + const hasProvisioning = pendingCount > 0 + + return ( + +
SSL Certificates
+
{certificates.length}
+ + {/* Status breakdown */} +
+ {validCount} valid + {expiringCount > 0 && {expiringCount} expiring} + {untrustedCount > 0 && {untrustedCount} staging} +
+ + {/* Pending indicator */} + {hasProvisioning && ( +
+
+ ... + {pendingCount} host{pendingCount > 1 ? 's' : ''} awaiting certificate +
+
+
+
+
+ )} + + ) +} +``` + +#### 4. Update Dashboard.tsx + +**File:** `frontend/src/pages/Dashboard.tsx` + +```tsx +import CertificateStatusCard from '../components/CertificateStatusCard' + +// Add auto-refresh when there are pending certs +const hasPendingCerts = useMemo(() => { + const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) + return sslHosts.some(h => !h.certificate_id) +}, [hosts]) + +// Use React Query with conditional refetch +const { certificates } = useCertificates({ + refetchInterval: hasPendingCerts ? 15000 : false // Poll every 15s when pending +}) + +// In JSX, replace static certificates card: + +``` + +#### 5. Update useCertificates Hook + +**File:** `frontend/src/hooks/useCertificates.ts` + +```typescript +interface UseCertificatesOptions { + refetchInterval?: number | false +} + +export function useCertificates(options?: UseCertificatesOptions) { + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['certificates'], + queryFn: getCertificates, + refetchInterval: options?.refetchInterval, + }) + + return { + certificates: data || [], + isLoading, + error, + refetch, + } +} +``` + +--- + +## File Changes Summary + +### New Files + +| File | Description | +|------|-------------| +| `frontend/src/components/dialogs/ImportSuccessModal.tsx` | Modal for import completion | +| `frontend/src/components/CertificateStatusCard.tsx` | Dashboard card with cert status | + +### Modified Files + +| File | Changes | +|------|---------| +| `frontend/src/api/import.ts` | Add `ImportCommitResult` type, update `commitImport` return | +| `frontend/src/hooks/useImport.ts` | Capture and expose commit result | +| `frontend/src/hooks/useCertificates.ts` | Add optional refetch interval | +| `frontend/src/pages/ImportCaddy.tsx` | Replace alert with modal, add navigation | +| `frontend/src/pages/Dashboard.tsx` | Use new CertificateStatusCard component | + +### Optional Backend Changes (for enhanced accuracy) + +| File | Changes | +|------|---------| +| `backend/internal/api/handlers/certificate_handler.go` | Add `/certificates/summary` endpoint | +| `backend/internal/api/routes/routes.go` | Register summary route | + +--- + +## Unit Test Requirements + +### Feature 1: ImportSuccessModal + +**File:** `frontend/src/components/dialogs/__tests__/ImportSuccessModal.test.tsx` + +```typescript +describe('ImportSuccessModal', () => { + it('renders import summary correctly', () => { + render() + expect(screen.getByText('5 hosts created')).toBeInTheDocument() + expect(screen.getByText('2 hosts updated')).toBeInTheDocument() + expect(screen.getByText('1 host skipped')).toBeInTheDocument() + }) + + it('displays certificate provisioning guidance', () => { + render() + expect(screen.getByText(/certificate provisioning/i)).toBeInTheDocument() + }) + + it('shows errors when present', () => { + render() + expect(screen.getByText('example.com: duplicate')).toBeInTheDocument() + }) + + it('calls onNavigateDashboard when clicking Dashboard button', () => { + const onNavigate = vi.fn() + render() + fireEvent.click(screen.getByText('Go to Dashboard')) + expect(onNavigate).toHaveBeenCalled() + }) + + it('does not render when visible is false', () => { + render() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) +}) +``` + +### Feature 2: CertificateStatusCard + +**File:** `frontend/src/components/__tests__/CertificateStatusCard.test.tsx` + +```typescript +describe('CertificateStatusCard', () => { + it('shows valid certificate count', () => { + render() + expect(screen.getByText('3 valid')).toBeInTheDocument() + }) + + it('shows pending indicator when hosts lack certificates', () => { + const hostsWithPending = [ + { ...mockHost, ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, ssl_forced: true, certificate_id: 1, enabled: true }, + ] + render() + expect(screen.getByText(/1 host awaiting certificate/)).toBeInTheDocument() + }) + + it('hides pending indicator when all hosts have certificates', () => { + const hostsComplete = [ + { ...mockHost, ssl_forced: true, certificate_id: 1, enabled: true }, + ] + render() + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('shows expiring count when certificates are expiring', () => { + const expiringCerts = [{ ...mockCert, status: 'expiring' }] + render() + expect(screen.getByText('1 expiring')).toBeInTheDocument() + }) + + it('shows staging count for untrusted certificates', () => { + const stagingCerts = [{ ...mockCert, status: 'untrusted' }] + render() + expect(screen.getByText('1 staging')).toBeInTheDocument() + }) + + it('calculates progress bar correctly', () => { + const hosts = [ + { ...mockHost, ssl_forced: true, certificate_id: 1, enabled: true }, + { ...mockHost, ssl_forced: true, certificate_id: null, enabled: true }, + ] + const { container } = render() + const progressBar = container.querySelector('[style*="width: 50%"]') + expect(progressBar).toBeInTheDocument() + }) +}) +``` + +### Integration Tests for useImport Hook + +**File:** `frontend/src/hooks/__tests__/useImport.test.tsx` + +```typescript +describe('useImport - commit result', () => { + it('captures commit result on success', async () => { + mockCommitImport.mockResolvedValue({ created: 3, updated: 1, skipped: 0, errors: [] }) + + const { result } = renderHook(() => useImport(), { wrapper }) + await result.current.commit({}, {}) + + expect(result.current.commitResult).toEqual({ created: 3, updated: 1, skipped: 0, errors: [] }) + expect(result.current.commitSuccess).toBe(true) + }) +}) +``` + +--- + +## UI/UX Design Notes + +### ImportSuccessModal + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β”‚ +β”‚ βœ… Import Completed β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ πŸ“¦ 5 hosts created β”‚ β”‚ +β”‚ β”‚ πŸ”„ 2 hosts updated β”‚ β”‚ +β”‚ β”‚ ⏭️ 1 host skipped β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ ℹ️ Certificate Provisioning β”‚ +β”‚ SSL certificates will be automatically β”‚ +β”‚ provisioned by Let's Encrypt. This typically β”‚ +β”‚ takes 1-5 minutes per domain. β”‚ +β”‚ β”‚ +β”‚ Monitor the Dashboard to track certificate β”‚ +β”‚ provisioning progress. β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Dashboardβ”‚ β”‚ View Hosts β”‚ β”‚ Close β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Certificate Status Card (Dashboard) + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SSL Certificates β”‚ +β”‚ β”‚ +β”‚ 12 β”‚ +β”‚ β”‚ +β”‚ βœ… 10 valid ⚠️ 1 expiring β”‚ +β”‚ πŸ”Έ 1 staging β”‚ +β”‚ β”‚ +β”‚ ────────────────────────────────────── β”‚ +β”‚ ⏳ 3 hosts awaiting certificate β”‚ +β”‚ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘ 75% β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Implementation Order + +1. **Phase 1: Import Success Modal** (Higher priority) + - Update `api/import.ts` types + - Update `useImport` hook + - Create `ImportSuccessModal` component + - Update `ImportCaddy.tsx` + - Write unit tests + +2. **Phase 2: Certificate Status Card** (Depends on Phase 1 for testing flow) + - Update `useCertificates` hook with refetch option + - Create `CertificateStatusCard` component + - Update `Dashboard.tsx` + - Write unit tests + +3. **Phase 3: Polish** + - Add loading states + - Responsive design adjustments + - Accessibility review (ARIA labels, focus management) + +--- + +## Acceptance Criteria + +### Feature 1: Post-Import Success Notification + +- [ ] No `alert()` calls remain in import flow +- [ ] Modal displays created/updated/skipped counts +- [ ] Modal shows certificate provisioning guidance +- [ ] Navigation buttons work correctly +- [ ] Modal closes properly and clears state +- [ ] Unit tests pass with >80% coverage + +### Feature 2: Certificate Status Indicator + +- [ ] Dashboard shows certificate breakdown by status +- [ ] Pending count reflects hosts without certificates +- [ ] Progress bar animates as certs are provisioned +- [ ] Auto-refresh when there are pending certificates +- [ ] Links navigate to appropriate views +- [ ] Unit tests pass with >80% coverage + +--- + +## References + +- Existing modal pattern: [ImportSitesModal.tsx](../../../frontend/src/components/ImportSitesModal.tsx) +- Dialog pattern: [CertificateCleanupDialog.tsx](../../../frontend/src/components/dialogs/CertificateCleanupDialog.tsx) +- Toast utility: [toast.ts](../../../frontend/src/utils/toast.ts) +- Certificate types: [certificates.ts](../../../frontend/src/api/certificates.ts) +- Import hook: [useImport.ts](../../../frontend/src/hooks/useImport.ts) +- Dashboard: [Dashboard.tsx](../../../frontend/src/pages/Dashboard.tsx) diff --git a/docs/plans/current_spec.md.bak2 b/docs/plans/current_spec.md.bak2 new file mode 100644 index 00000000..45283819 --- /dev/null +++ b/docs/plans/current_spec.md.bak2 @@ -0,0 +1,124 @@ +Proxy TLS & IP Login Recovery Plan +================================== + +Context + +- Proxy hosts return ERR_SSL_PROTOCOL_ERROR after container build succeeds; TLS handshake likely broken in generated Caddy config or certificate provisioning. +- Charon login fails with β€œinvalid credentials” when UI is accessed via raw IP/port; likely cookie or header handling across HTTP/non-SNI scenarios. +- Security scans can wait until connectivity and login paths are stable. + +Goals + +- Restore HTTPS/HTTP reachability for proxy hosts and admin UI without TLS protocol errors. +- Make login succeed when using IP:port access while preserving secure defaults for domain-based HTTPS. +- Keep changes minimal per request; batch verification runs. + +Phase 1 β€” Fast Repro & Evidence (single command batch) + +- Build is running remotely; use the deployed host [http://100.98.12.109:8080](http://100.98.12.109:8080) (not localhost) for repro. If HTTPS is exposed, also probe [https://100.98.12.109](https://100.98.12.109). +- Capture logs remotely: docker logs (Caddy + Charon) to logs/build/proxy-ssl.log and logs/build/login-ip.log on the remote node. +- From the remote container, fetch live Caddy config: curl [http://127.0.0.1:2019/config](http://127.0.0.1:2019/config) > logs/build/caddy-live.json. +- Snapshot TLS handshake from a reachable vantage point: openssl s_client -connect 100.98.12.109:443 -servername {first-proxy-domain} -tls1_2 to capture protocol/alert. + +Phase 2 β€” Diagnose ERR_SSL_PROTOCOL_ERROR in Caddy pipeline + +- Inspect generation path: [backend/internal/caddy/manager.go](backend/internal/caddy/manager.go) ApplyConfig β†’ GenerateConfig; ensure ACME email/provider/flags are loaded from settings. +- Review server wiring: [backend/internal/caddy/config.go](backend/internal/caddy/config.go) sets servers to listen on :80/:443 with AutoHTTPS enabled. Check whether hosts with IP literals are being treated like domain names (Caddy cannot issue ACME for IP; may yield protocol alerts). +- Inspect per-host TLS inputs: models.ProxyHost.CertificateID/Certificate.Provider (custom vs ACME), DomainNames normalization, and AdvancedConfig WAF handlers that might inject broken handlers. +- Validate stored config at runtime: data/caddy/caddy.json (if persisted) vs live admin API to see if TLS automation policies or certificates are missing. +- Verify entrypoint sequencing: [docker-entrypoint.sh](docker-entrypoint.sh) seeds empty Caddy config then relies on charon to push config; ensure ApplyConfig runs before first request. + +Phase 3 β€” Plan fixes for TLS/HTTPS reachability + +- Add IP-aware TLS handling in [backend/internal/caddy/config.go](backend/internal/caddy/config.go): detect hosts whose DomainNames are IPs; for those, set explicit HTTP listener only or `tls internal` to avoid failed ACME, and skip AutoHTTPS redirect for IP-only sites. +- Add guardrails/tests: extend [backend/internal/caddy/config_test.go](backend/internal/caddy/config_test.go) with a table case for IP hosts (expects HTTP route present, no AutoHTTPS redirect, optional internal TLS when requested). +- If admin UI also rides on :443, consider a fallback self-signed cert for bare IP by injecting a static certificate loader (same file) or disabling redirect when no hostname SNI is present. +- Re-apply config through [backend/internal/caddy/manager.go](backend/internal/caddy/manager.go) and confirm via admin API; ensure rollback still works if validation fails. + +Phase 4 β€” Diagnose login failures on IP:port + +- Backend cookie issuance: [backend/internal/api/handlers/auth_handler.go](backend/internal/api/handlers/auth_handler.go) `setSecureCookie` forces `Secure` when CHARON_ENV=production; on HTTP/IP this prevents cookie storage β†’ follow-up /auth/me returns 401, surfaced as β€œLogin failed/invalid credentials”. +- Request-aware secure flag: derive `Secure` from request scheme or `X-Forwarded-Proto`, and relax SameSite to Lax for forward_auth flows; keep Strict for HTTPS hostnames. +- Auth flow: [backend/internal/services/auth_service.go](backend/internal/services/auth_service.go) handles credentials; [backend/internal/api/middleware/auth.go](backend/internal/api/middleware/auth.go) accepts cookie/Authorization/query token. Ensure fallback to Authorization header using login response token when cookie is absent (IP/HTTP). +- Frontend: [frontend/src/api/client.ts](frontend/src/api/client.ts) uses withCredentials; [frontend/src/pages/Login.tsx](frontend/src/pages/Login.tsx) currently ignores returned token. Add optional storage/Authorization injection when cookie not set (feature-flagged), and surface clearer error when /auth/me fails post-login. +- Security headers: review [backend/internal/api/middleware/security_headers.go](backend/internal/api/middleware/security_headers.go) (HSTS/CSP) to ensure HTTP over IP is not force-upgraded to HTTPS unexpectedly during troubleshooting. + +Phase 5 β€” Validation & Regression + +- Unit tests: add table-driven cases for setSecureCookie in auth handler (HTTP vs HTTPS, IP vs hostname) and AuthMiddleware behavior when token is supplied via header instead of cookie. +- Caddy config tests: ensure IP host generation passes validation and does not emit duplicate routes or ghost hosts. +- Frontend tests: extend [frontend/src/pages/__tests__/Login.test.tsx](frontend/src/pages/__tests__/Login.test.tsx) to cover the no-cookie fallback path. +- Manual: rerun "Go: Build Backend", `npm run build`, task "Build & Run Local Docker", then verify login via IP:8080 and HTTPS domain, and re-run a narrow Caddy integration test if available (e.g., "Coraza: Run Integration Go Test"). + +Phase 6 β€” Hygiene (.gitignore / .dockerignore / .codecov.yml / Dockerfile) + +- .gitignore: add frontend/.cache, frontend/.eslintcache, data/geoip/ (downloaded in Dockerfile), and backend/.vscode/ if it appears locally. +- .dockerignore: mirror the new ignores (frontend/.cache, frontend/.eslintcache, data/geoip/) to keep context slim; keep docs exclusions as-is. +- .codecov.yml: reconsider excluding backend/cmd/api/** if we touch startup or ApplyConfig wiring so coverage reflects new logic. +- Dockerfile: after TLS/login fixes, assess adding a healthcheck or a post-start verification curl to :2019 and :8080; keep current multi-stage caching intact. + +Exit Criteria + +- Proxy hosts and admin UI respond over HTTP/HTTPS without ERR_SSL_PROTOCOL_ERROR; TLS handshake succeeds for domain hosts, HTTP works for IP-only access. +- Login succeeds via IP:port and via domain/HTTPS; cookies or header-based fallback maintain session across /auth/me. +- Updated ignore lists prevent new artifacts from leaking; coverage targets remain achievable after test additions. + +Build Failure & Security Scan Battle Plan +========================================= + +Phasing principle: collapse the effort into the fewest high-signal requests by batching commands (backend + frontend + container + scans) and only re-running the narrowest slice after each fix. Keep evidence artifacts for every step. + +Phase 1 β€” Reproduce and Capture the Failure (single pass) + +- Run the workspace tasks in this order to get a complete signal stack: "Go: Build Backend", then "Frontend: Type Check", then `npm run build` inside frontend (captures Vite/React errors near [frontend/src/main.tsx](frontend/src/main.tsx) and `App`), then "Build & Run Local Docker" to surface multi-stage Dockerfile issues. +- Preserve raw outputs to `logs/build/`: backend (`backend/build.log`), frontend (`frontend/build.log`), docker (`docker/build.log`). If a stage fails, stop and annotate the failing command, module, and package. +- If Docker fails before build, try `docker build --progress=plain --no-cache` once to expose failing layer context (Caddy build, Golang, or npm). Keep the resulting layer logs. + +Phase 2 β€” Backend Compilation & Test Rehab (one request) + +- Inspect error stack for the Go layer; focus on imports and CGO flags in [backend/cmd/api/main.go](backend/cmd/api/main.go) and router bootstrap [backend/internal/server/server.go](backend/internal/server/server.go). +- If module resolution fails, run "Go: Mod Tidy (Backend)" once, then re-run "Go: Build Backend"; avoid extra tidies to limit churn. +- If CGO/SQLite headers are missing, verify `apk add --no-cache gcc musl-dev sqlite-dev` step in Dockerfile backend-builder stage; mirror locally via `apk add` or `sudo apt-get` equivalents depending on host env. +- Run "Go: Test Backend" (or narrower `go test ./internal/...` if failure is localized) to ensure handlers (e.g., `routes.Register`, `handlers.CheckMountedImport`) still compile after fixes; capture coverage deltas if touched. + +Phase 3 β€” Frontend Build & Type Discipline (one request) + +- If type-check passes but build fails, inspect Vite config and rollup native skip flags in Dockerfile frontend-builder; cross-check `npm_config_rollup_skip_nodejs_native` and `ROLLUP_SKIP_NODEJS_NATIVE` envs. +- Validate entry composition in [frontend/src/main.tsx](frontend/src/main.tsx) and any failing component stack (e.g., `ThemeProvider`, `App`). Run `npm run lint -- --fix` only after root cause is understood to avoid masking errors. +- Re-run `npm run build` only after code fixes; stash bundle warnings for later size/security audits. + +Phase 4 β€” Container Build Reliability (one request) + +- Reproduce Docker failure with `--progress=plain`; pinpoint failing stage: `frontend-builder` (npm ci/build), `backend-builder` (xx-go build of `cmd/api`), or `caddy-builder` (xcaddy patch loop). +- If failure is in Caddy patch block, test with a narrowed build arg (e.g., `--build-arg CADDY_VERSION=2.10.2`) and confirm the fallback path works. Consider pinning quic-go/expr/smallstep versions if Renovate lagged. +- Verify entrypoint expectations in [docker-entrypoint.sh](docker-entrypoint.sh) align with built assets (`/app/frontend/dist`, `/app/charon`). Ensure symlink `cpmp` creation does not fail when `/app` is read-only. + +Phase 5 β€” CodeQL Scan & Triage (single run, then focused reruns) + +- Execute "Run CodeQL Scan (Local)" task once the code builds. Preserve SARIF to `codeql-agent-results/` and convert critical findings into issues. +- Triage hotspots: server middleware (`RequestID`, `RequestLogger`, `Recovery`), auth handlers under `internal/api/handlers`, and config loader `internal/config`. Prioritize SQL injections, path traversal in `handlers.CheckMountedImport`, and logging of secrets. +- After fixes, re-run only the affected language pack (Go or JS) to minimize cycle time; attach SARIF diff to the plan. + +Phase 6 β€” Trivy Image Scan & Triage (single run) + +- After a successful Docker build (`charon:local`), run "Run Trivy Scan (Local)". Persist report in `.trivy_logs/trivy-report.txt` (already ignored). +- Bucket findings: base image vulns (alpine), Caddy plugins, CrowdSec bundle, Go binary CVEs. Cross-check with Dockerfile upgrade levers (`CADDY_VERSION`, `CROWDSEC_VERSION`, `golang:1.25.5-alpine`). +- For OS-level CVEs, prefer `apk --no-cache upgrade` (already present) and version bumps; for Go deps, adjust go.mod and rebuild. + +Phase 7 β€” Coverage & Quality Gates + +- Ensure Codecov target (85%) still reachable; if exclusions are too broad (e.g., entire `backend/cmd/api`), reassess in [.codecov.yml](.codecov.yml) after fixes to keep new logic covered. +- If new backend logic lands in handlers or middleware, add table-driven tests under `backend/internal/api/...` to keep coverage from regressing. + +Phase 8 β€” Hygiene Checks (.gitignore, .dockerignore, Dockerfile, Codecov) + +- .gitignore: consider adding `frontend/.cache/` and `backend/.vscode/` artifacts if they appear during debugging; keep `.trivy_logs/` already present. +- .dockerignore: keep build context lean; add `frontend/.cache/`, `backend/.vscode/`; `codeql-results*.sarif` is already excluded. Ensure `docs/` exclusion is acceptable (only README/CONTRIBUTING/LICENSE kept) so Docker builds stay small. +- .codecov.yml: exclusions already cover e2e/integration and configs; if we add security helpers, avoid excluding them to keep visibility. Review whether ignoring `backend/cmd/api/**` is desired; we may want to include it if main wiring changes. +- Dockerfile: if builds fail due to xcaddy patch drift, add guard logs or split the patch block into a script under `scripts/` for clearer diffing. Consider caching npm and go modules via `--mount=type=cache` already present; avoid expanding build args further to limit attack surface. + +Exit Criteria + +- All four commands succeed in sequence: "Go: Build Backend", `npm run build`, `docker build` (local multi-stage), "Run CodeQL Scan (Local)", and "Run Trivy Scan (Local)" on `charon:local`. +- Logs captured and linked; actionable items opened for any CodeQL/Trivy HIGH/CRITICAL. +- No new untracked artifacts thanks to updated ignore lists. diff --git a/docs/reports/implementation_notes.md b/docs/reports/implementation_notes.md new file mode 100644 index 00000000..c32288cb --- /dev/null +++ b/docs/reports/implementation_notes.md @@ -0,0 +1,607 @@ +# Proxy TLS & IP Login Recovery β€” Implementation Notes + +The following patches implement the approved plan while respecting the constraint to **not modify source files directly**. Apply them in order. Tests to add are included at the end. + +## Backend β€” Caddy IP-aware TLS/HTTP handling + +**Goal:** avoid ACME/AutoHTTPS on IP literals, allow HTTP-only or internal TLS for IP hosts, and add coverage. + +Apply this patch to `backend/internal/caddy/config.go`: + +```diff +*** Begin Patch +*** Update File: backend/internal/caddy/config.go +@@ +-import ( +- "encoding/json" +- "fmt" +- "path/filepath" +- "strings" ++import ( ++ "encoding/json" ++ "fmt" ++ "net" ++ "path/filepath" ++ "strings" +@@ +-func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir, sslProvider string, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) { ++func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir, sslProvider string, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) { +@@ +- // Initialize routes slice +- routes := make([]*Route, 0) ++ // Initialize routes slice ++ routes := make([]*Route, 0) ++ // Track IP-only hostnames to skip AutoHTTPS/ACME ++ ipSubjects := make([]string, 0) +@@ +- // Parse comma-separated domains +- rawDomains := strings.Split(host.DomainNames, ",") +- var uniqueDomains []string ++ // Parse comma-separated domains ++ rawDomains := strings.Split(host.DomainNames, ",") ++ var uniqueDomains []string ++ isIPOnly := true +@@ +- processedDomains[d] = true +- uniqueDomains = append(uniqueDomains, d) ++ processedDomains[d] = true ++ uniqueDomains = append(uniqueDomains, d) ++ if net.ParseIP(d) == nil { ++ isIPOnly = false ++ } + } + + if len(uniqueDomains) == 0 { + continue + } ++ ++ if isIPOnly { ++ ipSubjects = append(ipSubjects, uniqueDomains...) ++ } +@@ +- route := &Route{ +- Match: []Match{ +- {Host: uniqueDomains}, +- }, +- Handle: mainHandlers, +- Terminal: true, +- } ++ route := &Route{ ++ Match: []Match{ ++ {Host: uniqueDomains}, ++ }, ++ Handle: mainHandlers, ++ Terminal: true, ++ } + + routes = append(routes, route) + } +@@ +- config.Apps.HTTP.Servers["charon_server"] = &Server{ +- Listen: []string{":80", ":443"}, +- Routes: routes, +- AutoHTTPS: &AutoHTTPSConfig{ +- Disable: false, +- DisableRedir: false, +- }, +- Logs: &ServerLogs{ +- DefaultLoggerName: "access_log", +- }, +- } ++ autoHTTPS := &AutoHTTPSConfig{Disable: false, DisableRedir: false} ++ if len(ipSubjects) > 0 { ++ // Skip AutoHTTPS/ACME for IP literals to avoid ERR_SSL_PROTOCOL_ERROR ++ autoHTTPS.Skip = append(autoHTTPS.Skip, ipSubjects...) ++ } ++ ++ config.Apps.HTTP.Servers["charon_server"] = &Server{ ++ Listen: []string{":80", ":443"}, ++ Routes: routes, ++ AutoHTTPS: autoHTTPS, ++ Logs: &ServerLogs{ ++ DefaultLoggerName: "access_log", ++ }, ++ } ++ ++ // Provide internal certificates for IP subjects when present so optional TLS can succeed without ACME ++ if len(ipSubjects) > 0 { ++ if config.Apps.TLS == nil { ++ config.Apps.TLS = &TLSApp{} ++ } ++ policy := &AutomationPolicy{ ++ Subjects: ipSubjects, ++ IssuersRaw: []interface{}{map[string]interface{}{"module": "internal"}}, ++ } ++ if config.Apps.TLS.Automation == nil { ++ config.Apps.TLS.Automation = &AutomationConfig{} ++ } ++ config.Apps.TLS.Automation.Policies = append(config.Apps.TLS.Automation.Policies, policy) ++ } + + return config, nil + } +*** End Patch +``` + +Add a focused test to `backend/internal/caddy/config_test.go` to cover IP hosts: + +```diff +*** Begin Patch +*** Update File: backend/internal/caddy/config_test.go +@@ + func TestGenerateConfig_Logging(t *testing.T) { +@@ + } ++ ++func TestGenerateConfig_IPHostsSkipAutoHTTPS(t *testing.T) { ++ hosts := []models.ProxyHost{ ++ { ++ UUID: "uuid-ip", ++ DomainNames: "192.0.2.10", ++ ForwardHost: "app", ++ ForwardPort: 8080, ++ Enabled: true, ++ }, ++ } ++ ++ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil) ++ require.NoError(t, err) ++ ++ server := config.Apps.HTTP.Servers["charon_server"] ++ require.NotNil(t, server) ++ require.Contains(t, server.AutoHTTPS.Skip, "192.0.2.10") ++ ++ // Ensure TLS automation adds internal issuer for IP literals ++ require.NotNil(t, config.Apps.TLS) ++ require.NotNil(t, config.Apps.TLS.Automation) ++ require.GreaterOrEqual(t, len(config.Apps.TLS.Automation.Policies), 1) ++ foundIPPolicy := false ++ for _, p := range config.Apps.TLS.Automation.Policies { ++ if len(p.Subjects) == 0 { ++ continue ++ } ++ if p.Subjects[0] == "192.0.2.10" { ++ foundIPPolicy = true ++ require.Len(t, p.IssuersRaw, 1) ++ issuer := p.IssuersRaw[0].(map[string]interface{}) ++ require.Equal(t, "internal", issuer["module"]) ++ } ++ } ++ require.True(t, foundIPPolicy, "expected internal issuer policy for IP host") ++} +*** End Patch +``` + +## Backend β€” Auth cookie scheme-aware flags and header fallback + +**Goal:** allow login over IP/HTTP by deriving `Secure` and `SameSite` from the request scheme/X-Forwarded-Proto, and keep Authorization fallback. + +Patch `backend/internal/api/handlers/auth_handler.go`: + +```diff +*** Begin Patch +*** Update File: backend/internal/api/handlers/auth_handler.go +@@ +-import ( +- "net/http" +- "os" +- "strconv" +- "strings" ++import ( ++ "net/http" ++ "os" ++ "strconv" ++ "strings" +@@ +-func isProduction() bool { +- env := os.Getenv("CHARON_ENV") +- return env == "production" || env == "prod" +-} ++func isProduction() bool { ++ env := os.Getenv("CHARON_ENV") ++ return env == "production" || env == "prod" ++} ++ ++func requestScheme(c *gin.Context) string { ++ if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { ++ // Honor first entry in a comma-separated header ++ parts := strings.Split(proto, ",") ++ return strings.ToLower(strings.TrimSpace(parts[0])) ++ } ++ if c.Request != nil && c.Request.TLS != nil { ++ return "https" ++ } ++ if c.Request != nil && c.Request.URL != nil && c.Request.URL.Scheme != "" { ++ return strings.ToLower(c.Request.URL.Scheme) ++ } ++ return "http" ++} +@@ +-// setSecureCookie sets an auth cookie with security best practices +-// - HttpOnly: prevents JavaScript access (XSS protection) +-// - Secure: only sent over HTTPS (in production) +-// - SameSite=Strict: prevents CSRF attacks +-func setSecureCookie(c *gin.Context, name, value string, maxAge int) { +- secure := isProduction() +- sameSite := http.SameSiteStrictMode ++// setSecureCookie sets an auth cookie with security best practices ++// - HttpOnly: prevents JavaScript access (XSS protection) ++// - Secure: derived from request scheme to allow HTTP/IP logins when needed ++// - SameSite: Strict for HTTPS, Lax for HTTP/IP to allow forward-auth redirects ++func setSecureCookie(c *gin.Context, name, value string, maxAge int) { ++ scheme := requestScheme(c) ++ secure := isProduction() && scheme == "https" ++ sameSite := http.SameSiteStrictMode ++ if scheme != "https" { ++ sameSite = http.SameSiteLaxMode ++ } +@@ + func (h *AuthHandler) Login(c *gin.Context) { +@@ +- // Set secure cookie (HttpOnly, Secure in prod, SameSite=Strict) +- setSecureCookie(c, "auth_token", token, 3600*24) ++ // Set secure cookie (scheme-aware) and return token for header fallback ++ setSecureCookie(c, "auth_token", token, 3600*24) +@@ +- c.JSON(http.StatusOK, gin.H{"token": token}) ++ c.JSON(http.StatusOK, gin.H{"token": token}) + } +*** End Patch +``` + +Add unit tests to `backend/internal/api/handlers/auth_handler_test.go` to cover scheme-aware cookies and header fallback: + +```diff +*** Begin Patch +*** Update File: backend/internal/api/handlers/auth_handler_test.go +@@ + func TestAuthHandler_Login(t *testing.T) { +@@ + } ++ ++func TestSetSecureCookie_HTTPS_Strict(t *testing.T) { ++ gin.SetMode(gin.TestMode) ++ ctx, w := gin.CreateTestContext(httptest.NewRecorder()) ++ req := httptest.NewRequest("POST", "https://example.com/login", http.NoBody) ++ ctx.Request = req ++ ++ setSecureCookie(ctx, "auth_token", "abc", 60) ++ cookies := w.Result().Cookies() ++ require.Len(t, cookies, 1) ++ c := cookies[0] ++ assert.True(t, c.Secure) ++ assert.Equal(t, http.SameSiteStrictMode, c.SameSite) ++} ++ ++func TestSetSecureCookie_HTTP_Lax(t *testing.T) { ++ gin.SetMode(gin.TestMode) ++ ctx, w := gin.CreateTestContext(httptest.NewRecorder()) ++ req := httptest.NewRequest("POST", "http://192.0.2.10/login", http.NoBody) ++ req.Header.Set("X-Forwarded-Proto", "http") ++ ctx.Request = req ++ ++ setSecureCookie(ctx, "auth_token", "abc", 60) ++ cookies := w.Result().Cookies() ++ require.Len(t, cookies, 1) ++ c := cookies[0] ++ assert.False(t, c.Secure) ++ assert.Equal(t, http.SameSiteLaxMode, c.SameSite) ++} +*** End Patch +``` + +Patch `backend/internal/api/middleware/auth.go` to explicitly prefer Authorization header when present and keep cookie/query fallback (behavioral clarity, no functional change): + +```diff +*** Begin Patch +*** Update File: backend/internal/api/middleware/auth.go +@@ +- authHeader := c.GetHeader("Authorization") +- if authHeader == "" { +- // Try cookie +- cookie, err := c.Cookie("auth_token") +- if err == nil { +- authHeader = "Bearer " + cookie +- } +- } +- +- if authHeader == "" { +- // Try query param +- token := c.Query("token") +- if token != "" { +- authHeader = "Bearer " + token +- } +- } ++ authHeader := c.GetHeader("Authorization") ++ ++ if authHeader == "" { ++ // Try cookie first for browser flows ++ if cookie, err := c.Cookie("auth_token"); err == nil && cookie != "" { ++ authHeader = "Bearer " + cookie ++ } ++ } ++ ++ if authHeader == "" { ++ // Try query param (token passthrough) ++ if token := c.Query("token"); token != "" { ++ authHeader = "Bearer " + token ++ } ++ } +*** End Patch +``` + +## Frontend β€” login header fallback when cookies are blocked + +**Goal:** when cookies aren’t set (IP/HTTP), use the returned token to set the `Authorization` header for subsequent requests. + +Patch `frontend/src/api/client.ts` to expose a token setter and persist optional header: + +```diff +*** Begin Patch +*** Update File: frontend/src/api/client.ts +@@ +-import axios from 'axios'; ++import axios from 'axios'; +@@ + const client = axios.create({ + baseURL: '/api/v1', + withCredentials: true, // Required for HttpOnly cookie transmission + timeout: 30000, // 30 second timeout + }); ++ ++export const setAuthToken = (token: string | null) => { ++ if (token) { ++ client.defaults.headers.common.Authorization = `Bearer ${token}`; ++ } else { ++ delete client.defaults.headers.common.Authorization; ++ } ++}; +@@ + export default client; +*** End Patch +``` + +Patch `frontend/src/context/AuthContext.tsx` to reuse stored token when cookies are unavailable: + +```diff +*** Begin Patch +*** Update File: frontend/src/context/AuthContext.tsx +@@ +-import client from '../api/client'; ++import client, { setAuthToken } from '../api/client'; +@@ +- const checkAuth = async () => { +- try { +- const response = await client.get('/auth/me'); +- setUser(response.data); +- } catch { +- setUser(null); +- } finally { +- setIsLoading(false); +- } +- }; ++ const checkAuth = async () => { ++ try { ++ const stored = localStorage.getItem('charon_auth_token'); ++ if (stored) { ++ setAuthToken(stored); ++ } ++ const response = await client.get('/auth/me'); ++ setUser(response.data); ++ } catch { ++ setAuthToken(null); ++ setUser(null); ++ } finally { ++ setIsLoading(false); ++ } ++ }; +@@ +- const login = async () => { +- // Token is stored in cookie by backend, but we might want to store it in memory or trigger a re-fetch +- // Actually, if backend sets cookie, we just need to fetch /auth/me +- try { +- const response = await client.get('/auth/me'); +- setUser(response.data); +- } catch (error) { +- setUser(null); +- throw error; +- } +- }; ++ const login = async (token?: string) => { ++ if (token) { ++ localStorage.setItem('charon_auth_token', token); ++ setAuthToken(token); ++ } ++ try { ++ const response = await client.get('/auth/me'); ++ setUser(response.data); ++ } catch (error) { ++ setUser(null); ++ setAuthToken(null); ++ localStorage.removeItem('charon_auth_token'); ++ throw error; ++ } ++ }; +@@ +- const logout = async () => { +- try { +- await client.post('/auth/logout'); +- } catch (error) { +- console.error("Logout failed", error); +- } +- setUser(null); +- }; ++ const logout = async () => { ++ try { ++ await client.post('/auth/logout'); ++ } catch (error) { ++ console.error("Logout failed", error); ++ } ++ localStorage.removeItem('charon_auth_token'); ++ setAuthToken(null); ++ setUser(null); ++ }; +*** End Patch +``` + +Patch `frontend/src/pages/Login.tsx` to use the returned token when cookies aren’t set: + +```diff +*** Begin Patch +*** Update File: frontend/src/pages/Login.tsx +@@ +-import client from '../api/client' ++import client from '../api/client' +@@ +- await client.post('/auth/login', { email, password }) +- await login() ++ const res = await client.post('/auth/login', { email, password }) ++ const token = (res.data as { token?: string }).token ++ await login(token) +@@ +- toast.error(error.response?.data?.error || 'Login failed') ++ toast.error(error.response?.data?.error || 'Login failed') +*** End Patch +``` + +Update types to reflect login signature change in `frontend/src/context/AuthContextValue.ts`: + +```diff +*** Begin Patch +*** Update File: frontend/src/context/AuthContextValue.ts +@@ +-export interface AuthContextType { +- user: User | null; +- login: () => Promise; ++export interface AuthContextType { ++ user: User | null; ++ login: (token?: string) => Promise; +*** End Patch +``` + +Patch `frontend/src/hooks/useAuth.ts` to satisfy the updated context type (no behavioral change needed). + +## Frontend Tests β€” cover token fallback + +Extend `frontend/src/pages/__tests__/Login.test.tsx` with a new case ensuring the token is passed to `login` when present and that `/auth/me` is retried with the Authorization header (mocked via context): + +```diff +*** Begin Patch +*** Update File: frontend/src/pages/__tests__/Login.test.tsx +@@ + it('shows error toast when login fails', async () => { +@@ + }) ++ ++ it('uses returned token when cookie is unavailable', async () => { ++ vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: false }) ++ const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce({ data: { token: 'bearer-token' } }) ++ const loginFn = vi.fn().mockResolvedValue(undefined) ++ vi.spyOn(authHook, 'useAuth').mockReturnValue({ login: loginFn } as unknown as AuthContextType) ++ ++ renderWithProviders() ++ const email = screen.getByPlaceholderText(/admin@example.com/i) ++ const pass = screen.getByPlaceholderText(/β€’β€’β€’β€’β€’β€’β€’β€’/i) ++ fireEvent.change(email, { target: { value: 'a@b.com' } }) ++ fireEvent.change(pass, { target: { value: 'pw' } }) ++ fireEvent.click(screen.getByRole('button', { name: /Sign In/i })) ++ ++ await waitFor(() => expect(postSpy).toHaveBeenCalled()) ++ expect(loginFn).toHaveBeenCalledWith('bearer-token') ++ }) +*** End Patch +``` + +## Backend Tests β€” auth middleware clarity + +Add a small assertion to `backend/internal/api/middleware/auth_test.go` to confirm Authorization header is preferred when both cookie and header exist: + +```diff +*** Begin Patch +*** Update File: backend/internal/api/middleware/auth_test.go +@@ + func TestAuthMiddleware_ValidToken(t *testing.T) { +@@ + } ++ ++func TestAuthMiddleware_PrefersAuthorizationHeader(t *testing.T) { ++ authService := setupAuthService(t) ++ user, _ := authService.Register("header@example.com", "password", "Header User") ++ token, _ := authService.GenerateToken(user) ++ ++ gin.SetMode(gin.TestMode) ++ r := gin.New() ++ r.Use(AuthMiddleware(authService)) ++ r.GET("/test", func(c *gin.Context) { ++ userID, _ := c.Get("userID") ++ assert.Equal(t, user.ID, userID) ++ c.Status(http.StatusOK) ++ }) ++ ++ req, _ := http.NewRequest("GET", "/test", http.NoBody) ++ req.Header.Set("Authorization", "Bearer "+token) ++ req.AddCookie(&http.Cookie{Name: "auth_token", Value: "stale"}) ++ w := httptest.NewRecorder() ++ r.ServeHTTP(w, req) ++ ++ assert.Equal(t, http.StatusOK, w.Code) ++} +*** End Patch +``` + +## Hygiene β€” ignores and coverage + +Update `.gitignore` to include transient caches and geoip data (mirrors plan): + +```diff +*** Begin Patch +*** Update File: .gitignore +@@ + frontend/.vite/ + frontend/*.tsbuildinfo ++/frontend/.cache/ ++/frontend/.eslintcache ++/backend/.vscode/ ++/data/geoip/ +*** End Patch +``` + +Update `.dockerignore` with the same cache directories (add if present): + +```diff +*** Begin Patch +*** Update File: .dockerignore +@@ + node_modules + frontend/node_modules + backend/node_modules + frontend/dist ++frontend/.cache ++frontend/.eslintcache ++data/geoip +*** End Patch +``` + +Adjust `.codecov.yml` to **include** backend startup logic in coverage (remove the `backend/cmd/api/**` ignore) and leave other ignores untouched: + +```diff +*** Begin Patch +*** Update File: .codecov.yml +@@ +- - "backend/cmd/api/**" +*** End Patch +``` + +## Tests to run after applying patches + +1. Backend unit tests: `go test ./backend/internal/caddy ./backend/internal/api/...` +2. Frontend unit tests: `cd frontend && npm test -- Login.test.tsx` +3. Backend build: run workspace task **Go: Build Backend**. +4. Frontend type-check/build: `cd frontend && npm run type-check` then `npm run build`. + +## Notes + +- The IP-aware TLS policy uses Caddy’s `internal` issuer for IP literals while skipping AutoHTTPS to prevent ACME on IPs. +- Cookie flags now respect the inbound scheme (or `X-Forwarded-Proto`), enabling HTTP/IP logins without disabling secure defaults on HTTPS. +- Frontend stores the login token only when provided; it clears the header and storage on logout or auth failure. +- Remove the `.codecov.yml` ignore entry only if new code paths should count toward coverage; otherwise keep existing thresholds. diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md index 2483ffe9..0cf5b892 100644 --- a/docs/reports/qa_report.md +++ b/docs/reports/qa_report.md @@ -1,44 +1,280 @@ -# QA Audit Report +# QA Security Audit Report +## Import Modal and Certificate Status Card Features **Date:** December 11, 2025 +**Auditor:** QA_Security Agent +**Overall Status:** ⚠️ **PARTIAL PASS** -## Summary +--- -A full QA audit was performed on the codebase. The following checks were executed: +## Executive Summary -1. **Pre-commit Hooks**: Ran `pre-commit run --all-files`. -2. **Backend Tests**: Ran `go test ./...` in the `backend` directory. -3. **Frontend Type Check**: Ran `npm run type-check` in the `frontend` directory. +The import modal (`ImportSuccessModal`) and certificate status card (`CertificateStatusCard`) features have been audited for code quality, type safety, accessibility, and proper testing. The core features are well-implemented with comprehensive test coverage, but there are **5 failing tests in CrowdSecConfig** (unrelated to the audited features) that need attention. -## Results +--- -### 1. Pre-commit Hooks +## Test Results Summary -**Status:** βœ… Passed +### 1. TypeScript Type Check βœ… PASS +``` +npm run type-check - Passed +No TypeScript errors detected +``` -All pre-commit hooks passed successfully. This includes: -- Go Vet -- Version match check -- Large file check -- CodeQL DB artifact check -- Data backup commit check -- Frontend TypeScript Check -- Frontend Lint (Fix) +### 2. Frontend Tests with Coverage ⚠️ PARTIAL PASS +``` +Test Files: 82 passed, 2 failed (84 total) +Tests: 723 passed, 5 failed, 2 skipped (730 total) +``` -### 2. Backend Tests +**Failed Tests (in CrowdSecConfig - not related to audited features):** +- `CrowdSecConfig.coverage.test.tsx`: 3 failures + - `auto-selects first preset and pulls preview` - Element not found `preset-select` + - `reads, edits, saves, and closes files` - Multiple textbox elements found + - `shows overlay messaging for preset pull, apply, import, write, and mode updates` - Multiple textbox elements found +- `CrowdSecConfig.spec.tsx`: 2 failures + - `lists files, reads file content and can save edits` - Multiple textbox elements found + - `disables apply and offers cached preview when hub is unavailable` - Element not found `preset-select` -**Status:** βœ… Passed +**ImportSuccessModal Tests:** βœ… All passing (12 tests) +**CertificateStatusCard Tests:** βœ… All passing (14 tests) -All backend unit tests passed. -- Coverage: 85.1% (minimum required 85%) -- Total time: ~1 minute +### 3. ESLint βœ… PASS (with warnings) +``` +5 warnings (0 errors) +``` +Warnings are in unrelated files (CrowdSecConfig.tsx): +- Missing dependencies in useEffect hook +- Explicit `any` types in test files -### 3. Frontend Type Check +### 4. Backend Build βœ… PASS +``` +go build ./... - Passed +No compilation errors +``` -**Status:** βœ… Passed +### 5. Backend Tests βœ… PASS +``` +All packages: PASS +Coverage: 85.1% (minimum required 85%) +``` -The TypeScript compiler (`tsc --noEmit`) completed without errors. +### 6. Pre-commit βœ… PASS +``` +All hooks passed: +- Go Vet: Passed +- Frontend TypeScript Check: Passed +- Frontend Lint (Fix): Passed +- Large file checks: Passed +- CodeQL DB artifact checks: Passed +``` + +--- + +## Code Review: ImportSuccessModal + +### File: `frontend/src/components/dialogs/ImportSuccessModal.tsx` + +#### βœ… Strengths + +1. **Type Safety** + - Well-defined `ImportSuccessModalProps` interface + - Explicit typing for all props including `results` structure + - Null safety with early return when `!visible || !results` + +2. **Error Handling** + - Dedicated error section with proper conditional rendering + - Scrollable error list with `max-h-24 overflow-y-auto` + - Clear error count display with proper pluralization + +3. **Accessibility** + - Backdrop click to close modal + - Clear visual hierarchy with icons (CheckCircle, AlertCircle, Info) + - Focus-visible button styles with `transition-colors` + +4. **Styling Consistency** + - Uses project's design tokens (`bg-dark-card`, `bg-blue-active`) + - Responsive layout with `flex-wrap` and `max-w-full mx-4` + - Consistent spacing and color scheme + +5. **Memory/Cleanup** + - No subscriptions or event listeners to clean up + - Pure functional component with no side effects + +#### ⚠️ Recommendations + +1. **Accessibility Enhancement** + - Add `role="dialog"` and `aria-modal="true"` to modal container + - Add `aria-labelledby` pointing to title element + - Consider focus trapping for keyboard navigation + +```tsx +// Recommended enhancement: +
+

+ Import Completed +

+``` + +2. **Keyboard Support** + - Add `onKeyDown` handler for Escape key to close modal + +--- + +## Code Review: CertificateStatusCard + +### File: `frontend/src/components/CertificateStatusCard.tsx` + +#### βœ… Strengths + +1. **Type Safety** + - Uses imported `Certificate` and `ProxyHost` types + - Clean interface definition for props + - No `any` types used + +2. **Computed Values** + - Efficient calculation of certificate status counts + - Smart pending detection logic (SSL forced + enabled + no cert) + - Progress percentage with edge case handling (empty array = 100%) + +3. **Accessibility** + - Uses `Link` component for navigation (accessible by default) + - Visible focus states inherited from router Link + +4. **Styling Consistency** + - Follows card design pattern used elsewhere + - Responsive hover transitions + - Animated spinner for pending state (`animate-spin`) + +5. **Memory/Cleanup** + - Stateless functional component + - No subscriptions or event listeners + +#### βœ… No Issues Found + +The component is clean, well-typed, and follows best practices. + +--- + +## Code Review: useImport Hook + +### File: `frontend/src/hooks/useImport.ts` + +#### βœ… Strengths + +1. **State Management** + - Proper use of `useState` for local state (`commitSucceeded`, `commitResult`) + - Correct query invalidation patterns + - Smart polling logic with `refetchInterval` + +2. **Error Handling** + - Comprehensive error aggregation from multiple sources + - Guards against 404 errors after commit (expected behavior) + - Clear error message extraction + +3. **Memory/Cleanup** + - React Query handles cleanup automatically + - Proper cache removal with `removeQueries` on success/cancel + - `clearCommitResult` function for state reset + +4. **Type Safety** + - Explicit type imports + - Type re-exports for consumers + +--- + +## Test Coverage Analysis + +### ImportSuccessModal.test.tsx βœ… +- **12 tests** covering all major functionality +- Tests for rendering, user interactions, and edge cases +- Proper mock setup with `vi.fn()` +- Grammar tests (singular/plural) +- Visibility/null result tests + +### CertificateStatusCard.test.tsx βœ… +- **14 tests** covering all states +- Router wrapper setup correct +- Progress calculation tests +- Edge cases (empty arrays, disabled hosts, no SSL) +- Link destination verification + +--- + +## Issues Found (Unrelated to Audited Features) + +### CrowdSecConfig Test Failures +The failing tests are in `CrowdSecConfig.spec.tsx` and `CrowdSecConfig.coverage.test.tsx`. The issues are: + +1. **Element selection conflict**: Tests use `screen.getByRole('textbox')` but the component now has multiple textbox elements (search input + textarea) +2. **Missing `preset-select` testid**: Some tests expect a `data-testid="preset-select"` element that may have been refactored + +**Recommendation**: Update CrowdSecConfig tests to use more specific selectors: +```tsx +// Instead of: +const textarea = screen.getByRole('textbox') +// Use: +const textarea = screen.getByTestId('crowdsec-file-textarea') +// Or: +const textarea = screen.getAllByRole('textbox')[1] // if order is consistent +``` + +--- + +## Security Checklist + +| Check | Status | +|-------|--------| +| No hardcoded secrets | βœ… | +| No console.log statements | βœ… | +| Input sanitization (handled by React) | βœ… | +| XSS prevention (React escapes by default) | βœ… | +| No direct DOM manipulation | βœ… | +| Proper error message display (no stack traces) | βœ… | + +--- + +## Final Assessment + +### Features Under Review +| Component | Status | Notes | +|-----------|--------|-------| +| ImportSuccessModal | βœ… PASS | Well-implemented, minor a11y enhancement recommended | +| CertificateStatusCard | βœ… PASS | Clean, no issues | +| useImport Hook | βœ… PASS | Proper state management | + +### Overall Codebase +| Check | Status | +|-------|--------| +| TypeScript | βœ… PASS | +| ESLint | βœ… PASS (warnings only) | +| Backend Build | βœ… PASS | +| Backend Tests | βœ… PASS (85.1% coverage) | +| Pre-commit | βœ… PASS | +| Frontend Tests | ⚠️ 5 failures (unrelated) | + +--- + +## Recommendations + +1. **High Priority**: Fix the 5 failing CrowdSecConfig tests by updating element selectors +2. **Medium Priority**: Add ARIA attributes to ImportSuccessModal for better accessibility +3. **Low Priority**: Address ESLint warnings in CrowdSecConfig.tsx (missing deps, any types) + +--- ## Conclusion -The codebase is in a healthy state. No critical issues were found during this audit. +**PARTIAL PASS** - The audited features (ImportSuccessModal, CertificateStatusCard, useImport) are well-implemented and pass all their tests. The failing tests are in an unrelated component (CrowdSecConfig) and should be addressed in a separate PR. + +The code demonstrates: +- Strong TypeScript usage +- Comprehensive test coverage for the audited features +- Consistent styling patterns +- Proper React Query patterns +- No memory leaks or cleanup issues diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4874c935..96389835 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -6,6 +6,14 @@ const client = axios.create({ timeout: 30000, // 30 second timeout }); +export const setAuthToken = (token: string | null) => { + if (token) { + client.defaults.headers.common.Authorization = `Bearer ${token}`; + } else { + delete client.defaults.headers.common.Authorization; + } +}; + // Global 401 error logging for debugging client.interceptors.response.use( (response) => response, diff --git a/frontend/src/api/import.ts b/frontend/src/api/import.ts index eb42b0ef..05fff3af 100644 --- a/frontend/src/api/import.ts +++ b/frontend/src/api/import.ts @@ -50,12 +50,24 @@ export const getImportPreview = async (): Promise => { return data; }; +export interface ImportCommitResult { + created: number; + updated: number; + skipped: number; + errors: string[]; +} + export const commitImport = async ( sessionUUID: string, resolutions: Record, names: Record -): Promise => { - await client.post('/import/commit', { session_uuid: sessionUUID, resolutions, names }); +): Promise => { + const { data } = await client.post('/import/commit', { + session_uuid: sessionUUID, + resolutions, + names, + }); + return data; }; export const cancelImport = async (): Promise => { diff --git a/frontend/src/components/CertificateStatusCard.tsx b/frontend/src/components/CertificateStatusCard.tsx new file mode 100644 index 00000000..daf7012d --- /dev/null +++ b/frontend/src/components/CertificateStatusCard.tsx @@ -0,0 +1,59 @@ +import { Link } from 'react-router-dom' +import { Loader2 } from 'lucide-react' +import type { Certificate } from '../api/certificates' +import type { ProxyHost } from '../api/proxyHosts' + +interface CertificateStatusCardProps { + certificates: Certificate[] + hosts: ProxyHost[] +} + +export default function CertificateStatusCard({ certificates, hosts }: CertificateStatusCardProps) { + const validCount = certificates.filter(c => c.status === 'valid').length + const expiringCount = certificates.filter(c => c.status === 'expiring').length + const untrustedCount = certificates.filter(c => c.status === 'untrusted').length + + // Pending = hosts with ssl_forced and enabled but no certificate_id + const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) + const hostsWithCerts = sslHosts.filter(h => h.certificate_id != null) + const pendingCount = sslHosts.length - hostsWithCerts.length + + const hasProvisioning = pendingCount > 0 + const progressPercent = sslHosts.length > 0 + ? Math.round((hostsWithCerts.length / sslHosts.length) * 100) + : 100 + + return ( + +
SSL Certificates
+
{certificates.length}
+ + {/* Status breakdown */} +
+ {validCount} valid + {expiringCount > 0 && {expiringCount} expiring} + {untrustedCount > 0 && {untrustedCount} staging} +
+ + {/* Pending indicator */} + {hasProvisioning && ( +
+
+ + {pendingCount} host{pendingCount !== 1 ? 's' : ''} awaiting certificate +
+
+
+
+
{progressPercent}% provisioned
+
+ )} + + ) +} diff --git a/frontend/src/components/__tests__/CertificateStatusCard.test.tsx b/frontend/src/components/__tests__/CertificateStatusCard.test.tsx new file mode 100644 index 00000000..839c0ff7 --- /dev/null +++ b/frontend/src/components/__tests__/CertificateStatusCard.test.tsx @@ -0,0 +1,186 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import CertificateStatusCard from '../CertificateStatusCard' +import type { Certificate } from '../../api/certificates' +import type { ProxyHost } from '../../api/proxyHosts' + +const mockCert: Certificate = { + id: 1, + name: 'Test Cert', + domain: 'example.com', + issuer: "Let's Encrypt", + expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + status: 'valid', + provider: 'letsencrypt', +} + +const mockHost: ProxyHost = { + uuid: 'test-uuid', + name: 'Test Host', + domain_names: 'example.com', + forward_scheme: 'http', + forward_host: 'localhost', + forward_port: 8080, + ssl_forced: false, + enabled: true, + certificate_id: null, + access_list_id: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + http2_support: false, + hsts_enabled: false, + hsts_subdomains: false, + block_exploits: false, + websocket_support: false, + application: 'none', + locations: [], +} + +function renderWithRouter(ui: React.ReactNode) { + return render({ui}) +} + +describe('CertificateStatusCard', () => { + it('shows total certificate count', () => { + const certs: Certificate[] = [mockCert, { ...mockCert, id: 2 }, { ...mockCert, id: 3 }] + renderWithRouter() + + expect(screen.getByText('3')).toBeInTheDocument() + expect(screen.getByText('SSL Certificates')).toBeInTheDocument() + }) + + it('shows valid certificate count', () => { + const certs: Certificate[] = [ + { ...mockCert, status: 'valid' }, + { ...mockCert, id: 2, status: 'valid' }, + { ...mockCert, id: 3, status: 'expired' }, + ] + renderWithRouter() + + expect(screen.getByText('2 valid')).toBeInTheDocument() + }) + + it('shows expiring count when certificates are expiring', () => { + const certs: Certificate[] = [ + { ...mockCert, status: 'expiring' }, + { ...mockCert, id: 2, status: 'valid' }, + ] + renderWithRouter() + + expect(screen.getByText('1 expiring')).toBeInTheDocument() + }) + + it('hides expiring count when no certificates are expiring', () => { + const certs: Certificate[] = [{ ...mockCert, status: 'valid' }] + renderWithRouter() + + expect(screen.queryByText(/expiring/)).not.toBeInTheDocument() + }) + + it('shows staging count for untrusted certificates', () => { + const certs: Certificate[] = [ + { ...mockCert, status: 'untrusted' }, + { ...mockCert, id: 2, status: 'untrusted' }, + ] + renderWithRouter() + + expect(screen.getByText('2 staging')).toBeInTheDocument() + }) + + it('hides staging count when no untrusted certificates', () => { + const certs: Certificate[] = [{ ...mockCert, status: 'valid' }] + renderWithRouter() + + expect(screen.queryByText(/staging/)).not.toBeInTheDocument() + }) + + it('shows pending indicator when hosts lack certificates', () => { + const hosts: ProxyHost[] = [ + { ...mockHost, ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h2', ssl_forced: true, certificate_id: 1, enabled: true }, + ] + renderWithRouter() + + expect(screen.getByText('1 host awaiting certificate')).toBeInTheDocument() + }) + + it('shows plural for multiple pending hosts', () => { + const hosts: ProxyHost[] = [ + { ...mockHost, uuid: 'h1', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h2', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h3', ssl_forced: true, certificate_id: null, enabled: true }, + ] + renderWithRouter() + + expect(screen.getByText('3 hosts awaiting certificate')).toBeInTheDocument() + }) + + it('hides pending indicator when all hosts have certificates', () => { + const hosts: ProxyHost[] = [ + { ...mockHost, ssl_forced: true, certificate_id: 1, enabled: true }, + ] + renderWithRouter() + + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('ignores disabled hosts when calculating pending', () => { + const hosts: ProxyHost[] = [ + { ...mockHost, uuid: 'h1', ssl_forced: true, certificate_id: null, enabled: false }, + { ...mockHost, uuid: 'h2', ssl_forced: true, certificate_id: 1, enabled: true }, + ] + renderWithRouter() + + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('ignores hosts without SSL when calculating pending', () => { + const hosts: ProxyHost[] = [ + { ...mockHost, uuid: 'h1', ssl_forced: false, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h2', ssl_forced: true, certificate_id: 1, enabled: true }, + ] + renderWithRouter() + + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('calculates progress percentage correctly', () => { + const hosts: ProxyHost[] = [ + { ...mockHost, uuid: 'h1', ssl_forced: true, certificate_id: 1, enabled: true }, + { ...mockHost, uuid: 'h2', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h3', ssl_forced: true, certificate_id: 1, enabled: true }, + { ...mockHost, uuid: 'h4', ssl_forced: true, certificate_id: null, enabled: true }, + ] + renderWithRouter() + + // 2 out of 4 = 50% + expect(screen.getByText('50% provisioned')).toBeInTheDocument() + }) + + it('shows spinning loader icon when pending', () => { + const hosts: ProxyHost[] = [ + { ...mockHost, ssl_forced: true, certificate_id: null, enabled: true }, + ] + const { container } = renderWithRouter( + + ) + + const spinner = container.querySelector('.animate-spin') + expect(spinner).toBeInTheDocument() + }) + + it('links to certificates page', () => { + renderWithRouter() + + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', '/certificates') + }) + + it('handles empty certificates array', () => { + renderWithRouter() + + expect(screen.getByText('0')).toBeInTheDocument() + expect(screen.getByText('0 valid')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/dialogs/ImportSuccessModal.tsx b/frontend/src/components/dialogs/ImportSuccessModal.tsx new file mode 100644 index 00000000..fa354829 --- /dev/null +++ b/frontend/src/components/dialogs/ImportSuccessModal.tsx @@ -0,0 +1,143 @@ +import { CheckCircle, Plus, RefreshCw, SkipForward, AlertCircle, Info } from 'lucide-react' + +export interface ImportSuccessModalProps { + visible: boolean + onClose: () => void + onNavigateDashboard: () => void + onNavigateHosts: () => void + results: { + created: number + updated: number + skipped: number + errors: string[] + } | null +} + +export default function ImportSuccessModal({ + visible, + onClose, + onNavigateDashboard, + onNavigateHosts, + results, +}: ImportSuccessModalProps) { + if (!visible || !results) return null + + const { created, updated, skipped, errors } = results + const hasErrors = errors.length > 0 + const totalProcessed = created + updated + skipped + + return ( +
+
+
+ {/* Header */} +
+
+ +
+
+

Import Completed

+

+ {totalProcessed} host{totalProcessed !== 1 ? 's' : ''} processed +

+
+
+ + {/* Results Summary */} +
+ {created > 0 && ( +
+ + + {created} host{created !== 1 ? 's' : ''} created + +
+ )} + {updated > 0 && ( +
+ + + {updated} host{updated !== 1 ? 's' : ''} updated + +
+ )} + {skipped > 0 && ( +
+ + + {skipped} host{skipped !== 1 ? 's' : ''} skipped + +
+ )} + {totalProcessed === 0 && ( +
+ + No hosts were processed +
+ )} +
+ + {/* Errors Section */} + {hasErrors && ( +
+
+ + + {errors.length} error{errors.length !== 1 ? 's' : ''} encountered + +
+
    + {errors.map((error, idx) => ( +
  • + β€’ + {error} +
  • + ))} +
+
+ )} + + {/* Certificate Provisioning Info */} + {created > 0 && ( +
+
+ +
+

Certificate Provisioning

+

+ SSL certificates will be automatically provisioned by Let's Encrypt. + This typically takes 1-5 minutes per domain. +

+

+ Monitor the Dashboard to track certificate provisioning progress. +

+
+
+
+ )} + + {/* Action Buttons */} +
+ + + +
+
+
+ ) +} diff --git a/frontend/src/components/dialogs/__tests__/ImportSuccessModal.test.tsx b/frontend/src/components/dialogs/__tests__/ImportSuccessModal.test.tsx new file mode 100644 index 00000000..8890dcab --- /dev/null +++ b/frontend/src/components/dialogs/__tests__/ImportSuccessModal.test.tsx @@ -0,0 +1,154 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import ImportSuccessModal from '../ImportSuccessModal' + +describe('ImportSuccessModal', () => { + const defaultProps = { + visible: true, + onClose: vi.fn(), + onNavigateDashboard: vi.fn(), + onNavigateHosts: vi.fn(), + results: { + created: 5, + updated: 2, + skipped: 1, + errors: [], + }, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders import summary correctly', () => { + render() + + expect(screen.getByText('Import Completed')).toBeInTheDocument() + expect(screen.getByText('8 hosts processed')).toBeInTheDocument() + expect(screen.getByText('5')).toBeInTheDocument() + expect(screen.getByText(/hosts created/)).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getByText(/hosts updated/)).toBeInTheDocument() + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText(/host skipped/)).toBeInTheDocument() + }) + + it('displays certificate provisioning guidance when hosts are created', () => { + render() + + expect(screen.getByText('Certificate Provisioning')).toBeInTheDocument() + expect(screen.getByText(/SSL certificates will be automatically provisioned/)).toBeInTheDocument() + expect(screen.getByText(/1-5 minutes per domain/)).toBeInTheDocument() + }) + + it('hides certificate provisioning guidance when no hosts are created', () => { + const props = { + ...defaultProps, + results: { created: 0, updated: 2, skipped: 0, errors: [] }, + } + render() + + expect(screen.queryByText('Certificate Provisioning')).not.toBeInTheDocument() + }) + + it('shows errors when present', () => { + const props = { + ...defaultProps, + results: { + created: 0, + updated: 0, + skipped: 0, + errors: ['example.com: duplicate entry', 'api.example.com: invalid config'], + }, + } + render() + + expect(screen.getByText('2 errors encountered')).toBeInTheDocument() + expect(screen.getByText('example.com: duplicate entry')).toBeInTheDocument() + expect(screen.getByText('api.example.com: invalid config')).toBeInTheDocument() + }) + + it('calls onNavigateDashboard when clicking Dashboard button', () => { + const onNavigateDashboard = vi.fn() + render() + + fireEvent.click(screen.getByText('Go to Dashboard')) + expect(onNavigateDashboard).toHaveBeenCalledTimes(1) + }) + + it('calls onNavigateHosts when clicking View Proxy Hosts button', () => { + const onNavigateHosts = vi.fn() + render() + + fireEvent.click(screen.getByText('View Proxy Hosts')) + expect(onNavigateHosts).toHaveBeenCalledTimes(1) + }) + + it('calls onClose when clicking Close button', () => { + const onClose = vi.fn() + render() + + fireEvent.click(screen.getByText('Close')) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('calls onClose when clicking backdrop', () => { + const onClose = vi.fn() + const { container } = render() + + // Click the backdrop (the overlay behind the modal) + const backdrop = container.querySelector('.bg-black\\/60') + if (backdrop) { + fireEvent.click(backdrop) + } + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('does not render when visible is false', () => { + render() + + expect(screen.queryByText('Import Completed')).not.toBeInTheDocument() + }) + + it('does not render when results is null', () => { + render() + + expect(screen.queryByText('Import Completed')).not.toBeInTheDocument() + }) + + it('handles singular grammar correctly for single host', () => { + const props = { + ...defaultProps, + results: { created: 1, updated: 0, skipped: 0, errors: [] }, + } + render() + + expect(screen.getByText('1 host processed')).toBeInTheDocument() + expect(screen.getByText(/host created/)).toBeInTheDocument() + }) + + it('handles single error with correct grammar', () => { + const props = { + ...defaultProps, + results: { + created: 0, + updated: 0, + skipped: 0, + errors: ['single error'], + }, + } + render() + + expect(screen.getByText('1 error encountered')).toBeInTheDocument() + }) + + it('shows message when no hosts were processed', () => { + const props = { + ...defaultProps, + results: { created: 0, updated: 0, skipped: 0, errors: [] }, + } + render() + + expect(screen.getByText('No hosts were processed')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 0ddca214..a0cd3058 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, type ReactNode, type FC } from 'react'; -import client from '../api/client'; +import client, { setAuthToken } from '../api/client'; import { AuthContext, User } from './AuthContextValue'; export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { @@ -9,9 +9,14 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { useEffect(() => { const checkAuth = async () => { try { + const stored = localStorage.getItem('charon_auth_token'); + if (stored) { + setAuthToken(stored); + } const response = await client.get('/auth/me'); setUser(response.data); } catch { + setAuthToken(null); setUser(null); } finally { setIsLoading(false); @@ -21,14 +26,18 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { checkAuth(); }, []); - const login = async () => { - // Token is stored in cookie by backend, but we might want to store it in memory or trigger a re-fetch - // Actually, if backend sets cookie, we just need to fetch /auth/me + const login = async (token?: string) => { + if (token) { + localStorage.setItem('charon_auth_token', token); + setAuthToken(token); + } try { const response = await client.get('/auth/me'); setUser(response.data); } catch (error) { setUser(null); + setAuthToken(null); + localStorage.removeItem('charon_auth_token'); throw error; } }; @@ -39,6 +48,8 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { } catch (error) { console.error("Logout failed", error); } + localStorage.removeItem('charon_auth_token'); + setAuthToken(null); setUser(null); }; diff --git a/frontend/src/context/AuthContextValue.ts b/frontend/src/context/AuthContextValue.ts index 51c48f61..ebd5c09e 100644 --- a/frontend/src/context/AuthContextValue.ts +++ b/frontend/src/context/AuthContextValue.ts @@ -9,7 +9,7 @@ export interface User { export interface AuthContextType { user: User | null; - login: () => Promise; + login: (token?: string) => Promise; logout: () => void; changePassword: (oldPassword: string, newPassword: string) => Promise; isAuthenticated: boolean; diff --git a/frontend/src/hooks/__tests__/useImport.test.tsx b/frontend/src/hooks/__tests__/useImport.test.tsx index 1e9d60e4..d087c105 100644 --- a/frontend/src/hooks/__tests__/useImport.test.tsx +++ b/frontend/src/hooks/__tests__/useImport.test.tsx @@ -127,6 +127,7 @@ describe('useImport', () => { vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) vi.mocked(api.commitImport).mockImplementation(async () => { isCommitted = true + return { created: 0, updated: 0, skipped: 0, errors: [] } }) const { result } = renderHook(() => useImport(), { wrapper: createWrapper() }) @@ -238,4 +239,110 @@ describe('useImport', () => { expect(result.current.error).toBe('Commit failed') }) }) + + it('captures and exposes commit result on success', async () => { + const mockSession = { + id: 'session-5', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } + + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + const mockCommitResult = { + created: 3, + updated: 1, + skipped: 2, + errors: [], + } + + let isCommitted = false + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockImplementation(async () => { + if (isCommitted) return { has_pending: false } + return { has_pending: true, session: mockSession } + }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + vi.mocked(api.commitImport).mockImplementation(async () => { + isCommitted = true + return mockCommitResult + }) + + const { result } = renderHook(() => useImport(), { wrapper: createWrapper() }) + + await act(async () => { + await result.current.upload('test') + }) + + await waitFor(() => { + expect(result.current.session).toEqual(mockSession) + }) + + await act(async () => { + await result.current.commit({}, {}) + }) + + expect(result.current.commitResult).toEqual(mockCommitResult) + expect(result.current.commitSuccess).toBe(true) + }) + + it('clears commit result when clearCommitResult is called', async () => { + const mockSession = { + id: 'session-6', + state: 'reviewing' as const, + created_at: '2025-01-18T10:00:00Z', + updated_at: '2025-01-18T10:00:00Z', + } + + const mockResponse = { + session: mockSession, + preview: { hosts: [], conflicts: [], errors: [] }, + } + + const mockCommitResult = { + created: 2, + updated: 0, + skipped: 0, + errors: [], + } + + let isCommitted = false + vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse) + vi.mocked(api.getImportStatus).mockImplementation(async () => { + if (isCommitted) return { has_pending: false } + return { has_pending: true, session: mockSession } + }) + vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse) + vi.mocked(api.commitImport).mockImplementation(async () => { + isCommitted = true + return mockCommitResult + }) + + const { result } = renderHook(() => useImport(), { wrapper: createWrapper() }) + + await act(async () => { + await result.current.upload('test') + }) + + await waitFor(() => { + expect(result.current.session).toEqual(mockSession) + }) + + await act(async () => { + await result.current.commit({}, {}) + }) + + expect(result.current.commitResult).toEqual(mockCommitResult) + + act(() => { + result.current.clearCommitResult() + }) + + expect(result.current.commitResult).toBeNull() + expect(result.current.commitSuccess).toBe(false) + }) }) diff --git a/frontend/src/hooks/useCertificates.ts b/frontend/src/hooks/useCertificates.ts index 817fd762..358f9bd3 100644 --- a/frontend/src/hooks/useCertificates.ts +++ b/frontend/src/hooks/useCertificates.ts @@ -1,15 +1,21 @@ import { useQuery } from '@tanstack/react-query' import { getCertificates } from '../api/certificates' -export function useCertificates() { - const { data, isLoading, error } = useQuery({ +interface UseCertificatesOptions { + refetchInterval?: number | false +} + +export function useCertificates(options?: UseCertificatesOptions) { + const { data, isLoading, error, refetch } = useQuery({ queryKey: ['certificates'], queryFn: getCertificates, + refetchInterval: options?.refetchInterval, }) return { certificates: data || [], isLoading, error, + refetch, } } diff --git a/frontend/src/hooks/useImport.ts b/frontend/src/hooks/useImport.ts index 25b4d80d..f25c96fb 100644 --- a/frontend/src/hooks/useImport.ts +++ b/frontend/src/hooks/useImport.ts @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { uploadCaddyfile, @@ -6,13 +7,18 @@ import { cancelImport, getImportStatus, ImportSession, - ImportPreview + ImportPreview, + ImportCommitResult, } from '../api/import'; export const QUERY_KEY = ['import-session']; export function useImport() { const queryClient = useQueryClient(); + // Track when commit has succeeded to disable preview fetching + const [commitSucceeded, setCommitSucceeded] = useState(false); + // Store the commit result for display in success modal + const [commitResult, setCommitResult] = useState(null); // Poll for status if we think there's an active session const statusQuery = useQuery({ @@ -31,7 +37,10 @@ export function useImport() { const previewQuery = useQuery({ queryKey: ['import-preview'], queryFn: getImportPreview, - enabled: !!statusQuery.data?.has_pending && (statusQuery.data?.session?.state === 'reviewing' || statusQuery.data?.session?.state === 'pending' || statusQuery.data?.session?.state === 'transient'), + // Only enable when there's an active session AND commit hasn't just succeeded + enabled: !!statusQuery.data?.has_pending && + (statusQuery.data?.session?.state === 'reviewing' || statusQuery.data?.session?.state === 'pending' || statusQuery.data?.session?.state === 'transient') && + !commitSucceeded, }); const uploadMutation = useMutation({ @@ -48,9 +57,15 @@ export function useImport() { if (!sessionId) throw new Error("No active session"); return commitImport(sessionId, resolutions, names); }, - onSuccess: () => { + onSuccess: (result) => { + // Store the commit result for display in success modal + setCommitResult(result); + // Mark commit as succeeded to prevent preview refetch (which would 404) + setCommitSucceeded(true); + // Remove preview cache entirely to prevent 404 refetch after commit + // (the session no longer exists, so preview endpoint returns 404) + queryClient.removeQueries({ queryKey: ['import-preview'] }); queryClient.invalidateQueries({ queryKey: QUERY_KEY }); - queryClient.invalidateQueries({ queryKey: ['import-preview'] }); // Also invalidate proxy hosts as they might have changed queryClient.invalidateQueries({ queryKey: ['proxy-hosts'] }); }, @@ -59,18 +74,29 @@ export function useImport() { const cancelMutation = useMutation({ mutationFn: () => cancelImport(), onSuccess: () => { + // Remove preview cache entirely to prevent 404 refetch after cancel + queryClient.removeQueries({ queryKey: ['import-preview'] }); queryClient.invalidateQueries({ queryKey: QUERY_KEY }); - queryClient.invalidateQueries({ queryKey: ['import-preview'] }); }, }); + const clearCommitResult = () => { + setCommitResult(null); + setCommitSucceeded(false); + }; + return { session: statusQuery.data?.session || null, preview: previewQuery.data || null, loading: statusQuery.isLoading || uploadMutation.isPending || commitMutation.isPending || cancelMutation.isPending, - error: (statusQuery.error || previewQuery.error || uploadMutation.error || commitMutation.error || cancelMutation.error) - ? ((statusQuery.error || previewQuery.error || uploadMutation.error || commitMutation.error || cancelMutation.error) as Error).message + // Only include previewQuery.error if there's an active session and commit hasn't succeeded + // (404 expected when no session or after commit) + error: (statusQuery.error || (previewQuery.error && statusQuery.data?.has_pending && !commitSucceeded) || uploadMutation.error || commitMutation.error || cancelMutation.error) + ? ((statusQuery.error || (previewQuery.error && statusQuery.data?.has_pending && !commitSucceeded ? previewQuery.error : null) || uploadMutation.error || commitMutation.error || cancelMutation.error) as Error)?.message : null, + commitSuccess: commitSucceeded, + commitResult, + clearCommitResult, upload: uploadMutation.mutateAsync, commit: (resolutions: Record, names: Record) => commitMutation.mutateAsync({ resolutions, names }), @@ -78,4 +104,4 @@ export function useImport() { }; } -export type { ImportSession, ImportPreview }; +export type { ImportSession, ImportPreview, ImportCommitResult }; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 68d54e6d..551500d2 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { useProxyHosts } from '../hooks/useProxyHosts' import { useRemoteServers } from '../hooks/useRemoteServers' import { useCertificates } from '../hooks/useCertificates' @@ -5,11 +6,22 @@ import { useQuery } from '@tanstack/react-query' import { checkHealth } from '../api/health' import { Link } from 'react-router-dom' import UptimeWidget from '../components/UptimeWidget' +import CertificateStatusCard from '../components/CertificateStatusCard' export default function Dashboard() { const { hosts } = useProxyHosts() const { servers } = useRemoteServers() - const { certificates } = useCertificates() + + // Detect if there are pending certificates (hosts with ssl_forced but no certificate_id) + const hasPendingCerts = useMemo(() => { + const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) + return sslHosts.some(h => !h.certificate_id) + }, [hosts]) + + // Poll certificates more frequently when there are pending certs + const { certificates } = useCertificates({ + refetchInterval: hasPendingCerts ? 15000 : false, // Poll every 15s when pending + }) // Use React Query for health check - benefits from global caching const { data: health } = useQuery({ @@ -39,11 +51,7 @@ export default function Dashboard() {
{enabledServers} enabled
- -
SSL Certificates
-
{certificates.length}
-
{certificates.filter(c => c.status === 'valid').length} valid
- +
System Status
diff --git a/frontend/src/pages/ImportCaddy.tsx b/frontend/src/pages/ImportCaddy.tsx index 2c836616..ae453a28 100644 --- a/frontend/src/pages/ImportCaddy.tsx +++ b/frontend/src/pages/ImportCaddy.tsx @@ -1,15 +1,19 @@ import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import { createBackup } from '../api/backups' import { useImport } from '../hooks/useImport' import ImportBanner from '../components/ImportBanner' import ImportReviewTable from '../components/ImportReviewTable' import ImportSitesModal from '../components/ImportSitesModal' +import ImportSuccessModal from '../components/dialogs/ImportSuccessModal' export default function ImportCaddy() { - const { session, preview, loading, error, upload, commit, cancel } = useImport() + const navigate = useNavigate() + const { session, preview, loading, error, upload, commit, cancel, commitResult, clearCommitResult } = useImport() const [content, setContent] = useState('') const [showReview, setShowReview] = useState(false) const [showMultiModal, setShowMultiModal] = useState(false) + const [showSuccessModal, setShowSuccessModal] = useState(false) const handleUpload = async () => { if (!content.trim()) { @@ -40,12 +44,17 @@ export default function ImportCaddy() { await commit(resolutions, names) setContent('') setShowReview(false) - alert('Import completed successfully!') + setShowSuccessModal(true) } catch { // Error is already set by hook } } + const handleCloseSuccessModal = () => { + setShowSuccessModal(false) + clearCommitResult() + } + const handleCancel = async () => { if (confirm('Are you sure you want to cancel this import?')) { try { @@ -170,6 +179,20 @@ api.example.com { onClose={() => setShowMultiModal(false)} onUploaded={() => setShowReview(true)} /> + + { + handleCloseSuccessModal() + navigate('/') + }} + onNavigateHosts={() => { + handleCloseSuccessModal() + navigate('/proxy-hosts') + }} + results={commitResult} + />
) } diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index bee352b5..db0a7bd2 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -36,8 +36,9 @@ export default function Login() { setLoading(true) try { - await client.post('/auth/login', { email, password }) - await login() + const res = await client.post('/auth/login', { email, password }) + const token = (res.data as { token?: string }).token + await login(token) await queryClient.invalidateQueries({ queryKey: ['setupStatus'] }) toast.success('Logged in successfully') navigate('/') diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.coverage.test.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.coverage.test.tsx index effeee18..7b5a0ca9 100644 --- a/frontend/src/pages/__tests__/CrowdSecConfig.coverage.test.tsx +++ b/frontend/src/pages/__tests__/CrowdSecConfig.coverage.test.tsx @@ -199,8 +199,7 @@ describe('CrowdSecConfig coverage', () => { it('auto-selects first preset and pulls preview', async () => { await renderPage() - const select = screen.getByTestId('preset-select') as HTMLSelectElement - expect(select.value).toBe(presetFromCatalog.slug) + // Component auto-selects first preset from the list on render await waitFor(() => expect(presetsApi.pullCrowdsecPreset).toHaveBeenCalledWith(presetFromCatalog.slug)) const previewText = screen.getByTestId('preset-preview').textContent?.replace(/\s+/g, ' ') expect(previewText).toContain('crowdsecurity/http-cve') @@ -375,7 +374,9 @@ describe('CrowdSecConfig coverage', () => { await renderPage() await userEvent.selectOptions(screen.getByTestId('crowdsec-file-select'), 'acquis.yaml') await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('acquis.yaml')) - const textarea = screen.getByRole('textbox') as HTMLTextAreaElement + // Use getAllByRole and filter for textarea (not the search input) + const textareas = screen.getAllByRole('textbox') + const textarea = textareas.find(el => el.tagName.toLowerCase() === 'textarea') as HTMLTextAreaElement expect(textarea.value).toBe('file-content') await userEvent.clear(textarea) await userEvent.type(textarea, 'updated') @@ -538,7 +539,9 @@ describe('CrowdSecConfig coverage', () => { await renderPage() await waitFor(() => expect(screen.getByTestId('preset-preview')).toBeInTheDocument()) await userEvent.selectOptions(screen.getByTestId('crowdsec-file-select'), 'acquis.yaml') - const textarea = screen.getByRole('textbox') as HTMLTextAreaElement + // Use getAllByRole and filter for textarea (not the search input) + const textareas = screen.getAllByRole('textbox') + const textarea = textareas.find(el => el.tagName.toLowerCase() === 'textarea') as HTMLTextAreaElement await userEvent.type(textarea, 'x') await userEvent.click(screen.getByText('Save')) expect(await screen.findByText('Guardian inscribes...')).toBeInTheDocument() diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx index 56f73518..f61e87e6 100644 --- a/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx +++ b/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx @@ -215,8 +215,9 @@ describe('CrowdSecConfig', () => { const select = screen.getByTestId('crowdsec-file-select') await userEvent.selectOptions(select, 'conf.d/a.conf') await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf')) - // ensure textarea populated - const textarea = screen.getByRole('textbox') + // ensure textarea populated - use getAllByRole and filter for textarea (not the search input) + const textareas = screen.getAllByRole('textbox') + const textarea = textareas.find(el => el.tagName.toLowerCase() === 'textarea')! expect(textarea).toHaveValue('rule1') // edit and save await userEvent.clear(textarea) @@ -319,9 +320,9 @@ describe('CrowdSecConfig', () => { renderWithProviders() - const select = await screen.findByTestId('preset-select') - await waitFor(() => expect(screen.getByText('Hub Only')).toBeInTheDocument()) - await userEvent.selectOptions(select, 'hub-only') + // Wait for presets to load and click on the preset card + const presetCard = await screen.findByText('Hub Only') + await userEvent.click(presetCard) await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument()) diff --git a/frontend/src/pages/__tests__/Login.test.tsx b/frontend/src/pages/__tests__/Login.test.tsx index b20e9c47..5e49cb49 100644 --- a/frontend/src/pages/__tests__/Login.test.tsx +++ b/frontend/src/pages/__tests__/Login.test.tsx @@ -60,4 +60,21 @@ describe('', () => { await waitFor(() => expect(postSpy).toHaveBeenCalled()) expect(toastSpy).toHaveBeenCalledWith('Bad creds') }) + + it('uses returned token when cookie is unavailable', async () => { + vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: false }) + const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce({ data: { token: 'bearer-token' } }) + const loginFn = vi.fn().mockResolvedValue(undefined) + vi.spyOn(authHook, 'useAuth').mockReturnValue({ login: loginFn } as unknown as AuthContextType) + + renderWithProviders() + const email = screen.getByPlaceholderText(/admin@example.com/i) + const pass = screen.getByPlaceholderText(/β€’β€’β€’β€’β€’β€’β€’β€’/i) + fireEvent.change(email, { target: { value: 'a@b.com' } }) + fireEvent.change(pass, { target: { value: 'pw' } }) + fireEvent.click(screen.getByRole('button', { name: /Sign In/i })) + + await waitFor(() => expect(postSpy).toHaveBeenCalled()) + expect(loginFn).toHaveBeenCalledWith('bearer-token') + }) }) diff --git a/go.work.sum b/go.work.sum index eae99b4a..1e280482 100644 --- a/go.work.sum +++ b/go.work.sum @@ -27,6 +27,7 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc= github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= @@ -74,6 +75,7 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= @@ -84,6 +86,7 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= @@ -93,11 +96,11 @@ golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= diff --git a/scripts/debug_db.py b/scripts/debug_db.py new file mode 100644 index 00000000..94f3eb07 --- /dev/null +++ b/scripts/debug_db.py @@ -0,0 +1,23 @@ +import sqlite3 +import os + +db_path = '/projects/Charon/backend/data/charon.db' + +if not os.path.exists(db_path): + print(f"Database not found at {db_path}") + exit(1) + +try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute("SELECT id, domain_names, forward_host, forward_port FROM proxy_hosts") + rows = cursor.fetchall() + + print("Proxy Hosts:") + for row in rows: + print(f"ID: {row[0]}, Domains: {row[1]}, ForwardHost: {row[2]}, Port: {row[3]}") + + conn.close() +except Exception as e: + print(f"Error: {e}") diff --git a/scripts/go-test-coverage.sh b/scripts/go-test-coverage.sh index 154e91d6..63f82063 100755 --- a/scripts/go-test-coverage.sh +++ b/scripts/go-test-coverage.sh @@ -23,6 +23,7 @@ EXCLUDE_PACKAGES=( "github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/metrics" "github.com/Wikid82/charon/backend/internal/trace" + "github.com/Wikid82/charon/backend/integration" ) # Try to run tests to produce coverage file; some toolchains may return a non-zero diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh index 69fbab62..a2f66f2f 100755 --- a/scripts/integration-test.sh +++ b/scripts/integration-test.sh @@ -128,10 +128,12 @@ echo "Using forward host: $FORWARD_HOST:$FORWARD_PORT" # Adjust the Caddy/Caddy proxy test port for local runs to avoid conflicts with # host services on port 80. -CADDY_PORT="80" -if [ -z "$CI" ] && [ -z "$GITHUB_ACTIONS" ]; then - # Use a non-privileged port locally when binding to host: 8082 - CADDY_PORT="8082" +if [ -z "$CADDY_PORT" ]; then + CADDY_PORT="80" + if [ -z "$CI" ] && [ -z "$GITHUB_ACTIONS" ]; then + # Use a non-privileged port locally when binding to host: 8082 + CADDY_PORT="8082" + fi fi echo "Using Caddy host port: $CADDY_PORT" # Retry creation up to 5 times if the apply config call fails due to Caddy reloads @@ -184,14 +186,14 @@ echo "Testing Proxy..." # We hit localhost:80 (Caddy) which should route to whoami HTTP_CODE=0 CONTENT="" -# Retry probing Caddy for the new route for up to 10 seconds -for i in $(seq 1 10); do +# Retry probing Caddy for the new route for up to 30 seconds +for i in $(seq 1 30); do HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: test.localhost" http://localhost:${CADDY_PORT} || true) CONTENT=$(curl -s -H "Host: test.localhost" http://localhost:${CADDY_PORT} || true) if [ "$HTTP_CODE" = "200" ] && echo "$CONTENT" | grep -q "Hostname:"; then break fi - echo "Waiting for Caddy to pick up new route ($i/10)..." + echo "Waiting for Caddy to pick up new route ($i/30)..." sleep 1 done diff --git a/scripts/trivy-scan.sh b/scripts/trivy-scan.sh new file mode 100755 index 00000000..2af9d845 --- /dev/null +++ b/scripts/trivy-scan.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +# Build the local image first to ensure it's up to date +echo "Building charon:local..." +docker build -t charon:local . + +# Run Trivy scan +echo "Running Trivy scan on charon:local..." +docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v $HOME/.cache/trivy:/root/.cache/trivy \ + -v $(pwd)/.trivy_logs:/logs \ + aquasec/trivy:latest image \ + --severity CRITICAL,HIGH \ + --output /logs/trivy-report.txt \ + charon:local + +echo "Scan complete. Report saved to .trivy_logs/trivy-report.txt" +cat .trivy_logs/trivy-report.txt