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:
@@ -55,7 +55,6 @@ ignore:
|
||||
|
||||
# Backend non-source files
|
||||
- "backend/cmd/seed/**"
|
||||
- "backend/cmd/api/**"
|
||||
- "backend/data/**"
|
||||
- "backend/coverage/**"
|
||||
- "backend/bin/**"
|
||||
|
||||
@@ -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
4
.gitignore
vendored
@@ -31,6 +31,10 @@ frontend/coverage/
|
||||
frontend/test-results/
|
||||
frontend/.vite/
|
||||
frontend/*.tsbuildinfo
|
||||
/frontend/.cache/
|
||||
/frontend/.eslintcache
|
||||
/backend/.vscode/
|
||||
/data/geoip/
|
||||
/frontend/frontend/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
124
docs/plans/current_spec.md.bak2
Normal file
124
docs/plans/current_spec.md.bak2
Normal 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.
|
||||
607
docs/reports/implementation_notes.md
Normal file
607
docs/reports/implementation_notes.md
Normal 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 aren’t set (IP/HTTP), use the returned token to set the `Authorization` header for subsequent requests.
|
||||
|
||||
Patch `frontend/src/api/client.ts` to expose a token setter and persist optional header:
|
||||
|
||||
```diff
|
||||
*** Begin Patch
|
||||
*** Update File: frontend/src/api/client.ts
|
||||
@@
|
||||
-import axios from 'axios';
|
||||
+import axios from 'axios';
|
||||
@@
|
||||
const client = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
withCredentials: true, // Required for HttpOnly cookie transmission
|
||||
timeout: 30000, // 30 second timeout
|
||||
});
|
||||
+
|
||||
+export const setAuthToken = (token: string | null) => {
|
||||
+ if (token) {
|
||||
+ client.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||
+ } else {
|
||||
+ delete client.defaults.headers.common.Authorization;
|
||||
+ }
|
||||
+};
|
||||
@@
|
||||
export default client;
|
||||
*** End Patch
|
||||
```
|
||||
|
||||
Patch `frontend/src/context/AuthContext.tsx` to reuse stored token when cookies are unavailable:
|
||||
|
||||
```diff
|
||||
*** Begin Patch
|
||||
*** Update File: frontend/src/context/AuthContext.tsx
|
||||
@@
|
||||
-import client from '../api/client';
|
||||
+import client, { setAuthToken } from '../api/client';
|
||||
@@
|
||||
- const checkAuth = async () => {
|
||||
- try {
|
||||
- const response = await client.get('/auth/me');
|
||||
- setUser(response.data);
|
||||
- } catch {
|
||||
- setUser(null);
|
||||
- } finally {
|
||||
- setIsLoading(false);
|
||||
- }
|
||||
- };
|
||||
+ const checkAuth = async () => {
|
||||
+ try {
|
||||
+ const stored = localStorage.getItem('charon_auth_token');
|
||||
+ if (stored) {
|
||||
+ setAuthToken(stored);
|
||||
+ }
|
||||
+ const response = await client.get('/auth/me');
|
||||
+ setUser(response.data);
|
||||
+ } catch {
|
||||
+ setAuthToken(null);
|
||||
+ setUser(null);
|
||||
+ } finally {
|
||||
+ setIsLoading(false);
|
||||
+ }
|
||||
+ };
|
||||
@@
|
||||
- const login = async () => {
|
||||
- // Token is stored in cookie by backend, but we might want to store it in memory or trigger a re-fetch
|
||||
- // Actually, if backend sets cookie, we just need to fetch /auth/me
|
||||
- try {
|
||||
- const response = await client.get<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 aren’t set:
|
||||
|
||||
```diff
|
||||
*** Begin Patch
|
||||
*** Update File: frontend/src/pages/Login.tsx
|
||||
@@
|
||||
-import client from '../api/client'
|
||||
+import client from '../api/client'
|
||||
@@
|
||||
- await client.post('/auth/login', { email, password })
|
||||
- await login()
|
||||
+ const res = await client.post('/auth/login', { email, password })
|
||||
+ const token = (res.data as { token?: string }).token
|
||||
+ await login(token)
|
||||
@@
|
||||
- toast.error(error.response?.data?.error || 'Login failed')
|
||||
+ toast.error(error.response?.data?.error || 'Login failed')
|
||||
*** End Patch
|
||||
```
|
||||
|
||||
Update types to reflect login signature change in `frontend/src/context/AuthContextValue.ts`:
|
||||
|
||||
```diff
|
||||
*** Begin Patch
|
||||
*** Update File: frontend/src/context/AuthContextValue.ts
|
||||
@@
|
||||
-export interface AuthContextType {
|
||||
- user: User | null;
|
||||
- login: () => Promise<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 Caddy’s `internal` issuer for IP literals while skipping AutoHTTPS to prevent ACME on IPs.
|
||||
- Cookie flags now respect the inbound scheme (or `X-Forwarded-Proto`), enabling HTTP/IP logins without disabling secure defaults on HTTPS.
|
||||
- Frontend stores the login token only when provided; it clears the header and storage on logout or auth failure.
|
||||
- Remove the `.codecov.yml` ignore entry only if new code paths should count toward coverage; otherwise keep existing thresholds.
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
59
frontend/src/components/CertificateStatusCard.tsx
Normal file
59
frontend/src/components/CertificateStatusCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
186
frontend/src/components/__tests__/CertificateStatusCard.test.tsx
Normal file
186
frontend/src/components/__tests__/CertificateStatusCard.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
143
frontend/src/components/dialogs/ImportSuccessModal.tsx
Normal file
143
frontend/src/components/dialogs/ImportSuccessModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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('/')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
23
scripts/debug_db.py
Normal 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}")
|
||||
@@ -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
|
||||
|
||||
@@ -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
20
scripts/trivy-scan.sh
Executable 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
|
||||
Reference in New Issue
Block a user