Add ImportSuccessModal tests, enhance AuthContext for token management, and improve useImport hook

- Implement tests for ImportSuccessModal to verify rendering and functionality.
- Update AuthContext to store authentication token in localStorage and manage token state.
- Modify useImport hook to capture and expose commit results, preventing unnecessary refetches.
- Enhance useCertificates hook to support optional refetch intervals.
- Update Dashboard to conditionally poll certificates based on pending status.
- Integrate ImportSuccessModal into ImportCaddy for user feedback on import completion.
- Adjust Login component to utilize returned token for authentication.
- Refactor CrowdSecConfig tests for improved readability and reliability.
- Add debug_db.py script for inspecting the SQLite database.
- Update integration and test scripts for better configuration and error handling.
- Introduce Trivy scan script for vulnerability assessment of Docker images.
This commit is contained in:
GitHub Actions
2025-12-12 00:05:15 +00:00
parent 03dadf6dcd
commit 7ca5a11572
40 changed files with 2723 additions and 137 deletions

View File

@@ -55,7 +55,6 @@ ignore:
# Backend non-source files
- "backend/cmd/seed/**"
- "backend/cmd/api/**"
- "backend/data/**"
- "backend/coverage/**"
- "backend/bin/**"

View File

@@ -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/

4
.gitignore vendored
View File

@@ -31,6 +31,10 @@ frontend/coverage/
frontend/test-results/
frontend/.vite/
frontend/*.tsbuildinfo
/frontend/.cache/
/frontend/.eslintcache
/backend/.vscode/
/data/geoip/
/frontend/frontend/
# -----------------------------------------------------------------------------

View File

@@ -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})

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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{
{

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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<string, string>, names: Record<string, string>) => {
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<string, string>,
names: Record<string, string>
): Promise<ImportCommitResult> => {
const { data } = await client.post<ImportCommitResult>('/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:
<ImportSuccessModal
visible={showSuccessModal}
onClose={() => {
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
<Link to="/certificates" className="bg-dark-card p-6 rounded-lg...">
<div className="text-sm text-gray-400 mb-2">SSL Certificates</div>
<div className="text-3xl font-bold text-white mb-1">{certificates.length}</div>
<div className="text-xs text-gray-500">{certificates.filter(c => c.status === 'valid').length} valid</div>
</Link>
```
### 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 (
<Link to="/certificates" className="bg-dark-card p-6 rounded-lg border border-gray-800...">
<div className="text-sm text-gray-400 mb-2">SSL Certificates</div>
<div className="text-3xl font-bold text-white mb-1">{certificates.length}</div>
{/* Status breakdown */}
<div className="flex flex-wrap gap-2 mt-2 text-xs">
<span className="text-green-400">{validCount} valid</span>
{expiringCount > 0 && <span className="text-yellow-400">{expiringCount} expiring</span>}
{untrustedCount > 0 && <span className="text-orange-400">{untrustedCount} staging</span>}
</div>
{/* Pending indicator */}
{hasProvisioning && (
<div className="mt-3 pt-3 border-t border-gray-700">
<div className="flex items-center gap-2 text-blue-400 text-xs">
<svg className="animate-spin h-3 w-3" ...>...</svg>
<span>{pendingCount} host{pendingCount > 1 ? 's' : ''} awaiting certificate</span>
</div>
<div className="mt-1 h-1 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-500"
style={{ width: `${(hostsWithCerts.length / sslHosts.length) * 100}%` }}
/>
</div>
</div>
)}
</Link>
)
}
```
#### 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:
<CertificateStatusCard certificates={certificates} hosts={hosts} />
```
#### 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(<ImportSuccessModal results={{ created: 5, updated: 2, skipped: 1, errors: [] }} ... />)
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(<ImportSuccessModal results={{ created: 5, updated: 0, skipped: 0, errors: [] }} ... />)
expect(screen.getByText(/certificate provisioning/i)).toBeInTheDocument()
})
it('shows errors when present', () => {
render(<ImportSuccessModal results={{ created: 0, updated: 0, skipped: 0, errors: ['example.com: duplicate'] }} ... />)
expect(screen.getByText('example.com: duplicate')).toBeInTheDocument()
})
it('calls onNavigateDashboard when clicking Dashboard button', () => {
const onNavigate = vi.fn()
render(<ImportSuccessModal onNavigateDashboard={onNavigate} ... />)
fireEvent.click(screen.getByText('Go to Dashboard'))
expect(onNavigate).toHaveBeenCalled()
})
it('does not render when visible is false', () => {
render(<ImportSuccessModal visible={false} ... />)
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(<CertificateStatusCard certificates={mockCerts} hosts={mockHosts} />)
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(<CertificateStatusCard certificates={mockCerts} hosts={hostsWithPending} />)
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(<CertificateStatusCard certificates={mockCerts} hosts={hostsComplete} />)
expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument()
})
it('shows expiring count when certificates are expiring', () => {
const expiringCerts = [{ ...mockCert, status: 'expiring' }]
render(<CertificateStatusCard certificates={expiringCerts} hosts={[]} />)
expect(screen.getByText('1 expiring')).toBeInTheDocument()
})
it('shows staging count for untrusted certificates', () => {
const stagingCerts = [{ ...mockCert, status: 'untrusted' }]
render(<CertificateStatusCard certificates={stagingCerts} hosts={[]} />)
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(<CertificateStatusCard certificates={mockCerts} hosts={hosts} />)
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)

View File

@@ -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.

View File

@@ -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 arent 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<User>('/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<User>('/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 arent 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<void>;
+export interface AuthContextType {
+ user: User | null;
+ login: (token?: string) => Promise<void>;
*** 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(<Login />)
+ 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 Caddys `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.

View File

@@ -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:
<div
className="relative bg-dark-card rounded-lg..."
role="dialog"
aria-modal="true"
aria-labelledby="import-modal-title"
>
<h2 id="import-modal-title" className="text-xl font-bold text-white">
Import Completed
</h2>
```
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

View File

@@ -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,

View File

@@ -50,12 +50,24 @@ export const getImportPreview = async (): Promise<ImportPreview> => {
return data;
};
export interface ImportCommitResult {
created: number;
updated: number;
skipped: number;
errors: string[];
}
export const commitImport = async (
sessionUUID: string,
resolutions: Record<string, string>,
names: Record<string, string>
): Promise<void> => {
await client.post('/import/commit', { session_uuid: sessionUUID, resolutions, names });
): Promise<ImportCommitResult> => {
const { data } = await client.post<ImportCommitResult>('/import/commit', {
session_uuid: sessionUUID,
resolutions,
names,
});
return data;
};
export const cancelImport = async (): Promise<void> => {

View File

@@ -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 (
<Link
to="/certificates"
className="bg-dark-card p-6 rounded-lg border border-gray-800 hover:border-gray-700 transition-colors block"
>
<div className="text-sm text-gray-400 mb-2">SSL Certificates</div>
<div className="text-3xl font-bold text-white mb-1">{certificates.length}</div>
{/* Status breakdown */}
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2 text-xs">
<span className="text-green-400">{validCount} valid</span>
{expiringCount > 0 && <span className="text-yellow-400">{expiringCount} expiring</span>}
{untrustedCount > 0 && <span className="text-orange-400">{untrustedCount} staging</span>}
</div>
{/* Pending indicator */}
{hasProvisioning && (
<div className="mt-3 pt-3 border-t border-gray-700">
<div className="flex items-center gap-2 text-blue-400 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
<span>{pendingCount} host{pendingCount !== 1 ? 's' : ''} awaiting certificate</span>
</div>
<div className="mt-2 h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-500 rounded-full"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="text-xs text-gray-500 mt-1">{progressPercent}% provisioned</div>
</div>
)}
</Link>
)
}

View File

@@ -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(<BrowserRouter>{ui}</BrowserRouter>)
}
describe('CertificateStatusCard', () => {
it('shows total certificate count', () => {
const certs: Certificate[] = [mockCert, { ...mockCert, id: 2 }, { ...mockCert, id: 3 }]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
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(<CertificateStatusCard certificates={certs} hosts={[]} />)
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(<CertificateStatusCard certificates={certs} hosts={[]} />)
expect(screen.getByText('1 expiring')).toBeInTheDocument()
})
it('hides expiring count when no certificates are expiring', () => {
const certs: Certificate[] = [{ ...mockCert, status: 'valid' }]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
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(<CertificateStatusCard certificates={certs} hosts={[]} />)
expect(screen.getByText('2 staging')).toBeInTheDocument()
})
it('hides staging count when no untrusted certificates', () => {
const certs: Certificate[] = [{ ...mockCert, status: 'valid' }]
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
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(<CertificateStatusCard certificates={[mockCert]} hosts={hosts} />)
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(<CertificateStatusCard certificates={[mockCert]} hosts={hosts} />)
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(<CertificateStatusCard certificates={[mockCert]} hosts={hosts} />)
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(<CertificateStatusCard certificates={[mockCert]} hosts={hosts} />)
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(<CertificateStatusCard certificates={[mockCert]} hosts={hosts} />)
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(<CertificateStatusCard certificates={[mockCert]} hosts={hosts} />)
// 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(
<CertificateStatusCard certificates={[mockCert]} hosts={hosts} />
)
const spinner = container.querySelector('.animate-spin')
expect(spinner).toBeInTheDocument()
})
it('links to certificates page', () => {
renderWithRouter(<CertificateStatusCard certificates={[mockCert]} hosts={[]} />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/certificates')
})
it('handles empty certificates array', () => {
renderWithRouter(<CertificateStatusCard certificates={[]} hosts={[]} />)
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.getByText('0 valid')).toBeInTheDocument()
})
})

View File

@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative bg-dark-card rounded-lg p-6 w-[500px] max-w-full mx-4 border border-gray-800">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-green-900/30 flex items-center justify-center">
<CheckCircle className="h-6 w-6 text-green-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Import Completed</h2>
<p className="text-sm text-gray-400">
{totalProcessed} host{totalProcessed !== 1 ? 's' : ''} processed
</p>
</div>
</div>
{/* Results Summary */}
<div className="bg-gray-900/50 border border-gray-800 rounded-lg p-4 mb-4 space-y-3">
{created > 0 && (
<div className="flex items-center gap-3">
<Plus className="h-4 w-4 text-green-400" />
<span className="text-sm text-white">
<span className="font-medium text-green-400">{created}</span> host{created !== 1 ? 's' : ''} created
</span>
</div>
)}
{updated > 0 && (
<div className="flex items-center gap-3">
<RefreshCw className="h-4 w-4 text-blue-400" />
<span className="text-sm text-white">
<span className="font-medium text-blue-400">{updated}</span> host{updated !== 1 ? 's' : ''} updated
</span>
</div>
)}
{skipped > 0 && (
<div className="flex items-center gap-3">
<SkipForward className="h-4 w-4 text-gray-400" />
<span className="text-sm text-white">
<span className="font-medium text-gray-400">{skipped}</span> host{skipped !== 1 ? 's' : ''} skipped
</span>
</div>
)}
{totalProcessed === 0 && (
<div className="flex items-center gap-3">
<Info className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">No hosts were processed</span>
</div>
)}
</div>
{/* Errors Section */}
{hasErrors && (
<div className="bg-red-900/20 border border-red-800/50 rounded-lg p-4 mb-4">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="h-4 w-4 text-red-400" />
<span className="text-sm font-medium text-red-400">
{errors.length} error{errors.length !== 1 ? 's' : ''} encountered
</span>
</div>
<ul className="space-y-1 max-h-24 overflow-y-auto">
{errors.map((error, idx) => (
<li key={idx} className="text-xs text-red-300 flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
<span>{error}</span>
</li>
))}
</ul>
</div>
)}
{/* Certificate Provisioning Info */}
{created > 0 && (
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-4 mb-6">
<div className="flex items-start gap-3">
<Info className="h-4 w-4 text-blue-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-blue-300">Certificate Provisioning</p>
<p className="text-xs text-gray-400 mt-1">
SSL certificates will be automatically provisioned by Let's Encrypt.
This typically takes 1-5 minutes per domain.
</p>
<p className="text-xs text-gray-400 mt-2">
Monitor the <span className="text-blue-400">Dashboard</span> to track certificate provisioning progress.
</p>
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-wrap gap-3 justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
>
Close
</button>
<button
onClick={onNavigateHosts}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors border border-gray-700"
>
View Proxy Hosts
</button>
<button
onClick={onNavigateDashboard}
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors"
>
Go to Dashboard
</button>
</div>
</div>
</div>
)
}

View File

@@ -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(<ImportSuccessModal {...defaultProps} />)
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(<ImportSuccessModal {...defaultProps} />)
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(<ImportSuccessModal {...props} />)
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(<ImportSuccessModal {...props} />)
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(<ImportSuccessModal {...defaultProps} onNavigateDashboard={onNavigateDashboard} />)
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(<ImportSuccessModal {...defaultProps} onNavigateHosts={onNavigateHosts} />)
fireEvent.click(screen.getByText('View Proxy Hosts'))
expect(onNavigateHosts).toHaveBeenCalledTimes(1)
})
it('calls onClose when clicking Close button', () => {
const onClose = vi.fn()
render(<ImportSuccessModal {...defaultProps} onClose={onClose} />)
fireEvent.click(screen.getByText('Close'))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('calls onClose when clicking backdrop', () => {
const onClose = vi.fn()
const { container } = render(<ImportSuccessModal {...defaultProps} onClose={onClose} />)
// 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(<ImportSuccessModal {...defaultProps} visible={false} />)
expect(screen.queryByText('Import Completed')).not.toBeInTheDocument()
})
it('does not render when results is null', () => {
render(<ImportSuccessModal {...defaultProps} results={null} />)
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(<ImportSuccessModal {...props} />)
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(<ImportSuccessModal {...props} />)
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(<ImportSuccessModal {...props} />)
expect(screen.getByText('No hosts were processed')).toBeInTheDocument()
})
})

View File

@@ -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<User>('/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);
};

View File

@@ -9,7 +9,7 @@ export interface User {
export interface AuthContextType {
user: User | null;
login: () => Promise<void>;
login: (token?: string) => Promise<void>;
logout: () => void;
changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
isAuthenticated: boolean;

View File

@@ -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)
})
})

View File

@@ -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,
}
}

View File

@@ -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<ImportCommitResult | null>(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<string, string>, names: Record<string, string>) =>
commitMutation.mutateAsync({ resolutions, names }),
@@ -78,4 +104,4 @@ export function useImport() {
};
}
export type { ImportSession, ImportPreview };
export type { ImportSession, ImportPreview, ImportCommitResult };

View File

@@ -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() {
<div className="text-xs text-gray-500">{enabledServers} enabled</div>
</Link>
<Link to="/certificates" className="bg-dark-card p-6 rounded-lg border border-gray-800 hover:border-gray-700 transition-colors">
<div className="text-sm text-gray-400 mb-2">SSL Certificates</div>
<div className="text-3xl font-bold text-white mb-1">{certificates.length}</div>
<div className="text-xs text-gray-500">{certificates.filter(c => c.status === 'valid').length} valid</div>
</Link>
<CertificateStatusCard certificates={certificates} hosts={hosts} />
<div className="bg-dark-card p-6 rounded-lg border border-gray-800">
<div className="text-sm text-gray-400 mb-2">System Status</div>

View File

@@ -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)}
/>
<ImportSuccessModal
visible={showSuccessModal}
onClose={handleCloseSuccessModal}
onNavigateDashboard={() => {
handleCloseSuccessModal()
navigate('/')
}}
onNavigateHosts={() => {
handleCloseSuccessModal()
navigate('/proxy-hosts')
}}
results={commitResult}
/>
</div>
)
}

View File

@@ -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('/')

View File

@@ -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()

View File

@@ -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(<CrowdSecConfig />)
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())

View File

@@ -60,4 +60,21 @@ describe('<Login />', () => {
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(<Login />)
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')
})
})

View File

@@ -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=

23
scripts/debug_db.py Normal file
View File

@@ -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}")

View File

@@ -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

View File

@@ -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

20
scripts/trivy-scan.sh Executable file
View File

@@ -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