diff --git a/backend/internal/api/handlers/forward_auth_handler.go b/backend/internal/api/handlers/forward_auth_handler.go new file mode 100644 index 00000000..63db2334 --- /dev/null +++ b/backend/internal/api/handlers/forward_auth_handler.go @@ -0,0 +1,109 @@ +package handlers + +import ( + "net/http" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ForwardAuthHandler handles forward authentication configuration endpoints. +type ForwardAuthHandler struct { + db *gorm.DB +} + +// NewForwardAuthHandler creates a new handler. +func NewForwardAuthHandler(db *gorm.DB) *ForwardAuthHandler { + return &ForwardAuthHandler{db: db} +} + +// GetConfig retrieves the forward auth configuration. +func (h *ForwardAuthHandler) GetConfig(c *gin.Context) { + var config models.ForwardAuthConfig + if err := h.db.First(&config).Error; err != nil { + if err == gorm.ErrRecordNotFound { + // Return default/empty config + c.JSON(http.StatusOK, models.ForwardAuthConfig{ + Provider: "custom", + Address: "", + TrustForwardHeader: true, + }) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch config"}) + return + } + + c.JSON(http.StatusOK, config) +} + +// UpdateConfig updates or creates the forward auth configuration. +func (h *ForwardAuthHandler) UpdateConfig(c *gin.Context) { + var input struct { + Provider string `json:"provider" binding:"required,oneof=authelia authentik pomerium custom"` + Address string `json:"address" binding:"required,url"` + TrustForwardHeader bool `json:"trust_forward_header"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var config models.ForwardAuthConfig + err := h.db.First(&config).Error + + if err == gorm.ErrRecordNotFound { + // Create new config + config = models.ForwardAuthConfig{ + Provider: input.Provider, + Address: input.Address, + TrustForwardHeader: input.TrustForwardHeader, + } + if err := h.db.Create(&config).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create config"}) + return + } + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch config"}) + return + } else { + // Update existing config + config.Provider = input.Provider + config.Address = input.Address + config.TrustForwardHeader = input.TrustForwardHeader + if err := h.db.Save(&config).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update config"}) + return + } + } + + c.JSON(http.StatusOK, config) +} + +// GetTemplates returns pre-configured templates for popular providers. +func (h *ForwardAuthHandler) GetTemplates(c *gin.Context) { + templates := map[string]interface{}{ + "authelia": gin.H{ + "provider": "authelia", + "address": "http://authelia:9091/api/verify", + "trust_forward_header": true, + "description": "Authelia authentication server", + }, + "authentik": gin.H{ + "provider": "authentik", + "address": "http://authentik-server:9000/outpost.goauthentik.io/auth/caddy", + "trust_forward_header": true, + "description": "Authentik SSO provider", + }, + "pomerium": gin.H{ + "provider": "pomerium", + "address": "https://verify.pomerium.app", + "trust_forward_header": true, + "description": "Pomerium identity-aware proxy", + }, + } + + c.JSON(http.StatusOK, templates) +} diff --git a/backend/internal/api/handlers/forward_auth_handler_test.go b/backend/internal/api/handlers/forward_auth_handler_test.go new file mode 100644 index 00000000..d4c9f9c7 --- /dev/null +++ b/backend/internal/api/handlers/forward_auth_handler_test.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupForwardAuthTestDB() *gorm.DB { + db, _ := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + db.AutoMigrate(&models.ForwardAuthConfig{}) + return db +} + +func TestForwardAuthHandler_GetConfig(t *testing.T) { + db := setupForwardAuthTestDB() + h := NewForwardAuthHandler(db) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/config", h.GetConfig) + + // Test empty config (default) + req, _ := http.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp models.ForwardAuthConfig + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, "custom", resp.Provider) + + // Test existing config + db.Create(&models.ForwardAuthConfig{ + Provider: "authelia", + Address: "http://test", + }) + + req, _ = http.NewRequest("GET", "/config", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, "authelia", resp.Provider) +} + +func TestForwardAuthHandler_UpdateConfig(t *testing.T) { + db := setupForwardAuthTestDB() + h := NewForwardAuthHandler(db) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/config", h.UpdateConfig) + + // Test Create + payload := map[string]interface{}{ + "provider": "authelia", + "address": "http://authelia:9091", + "trust_forward_header": true, + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", "/config", bytes.NewBuffer(body)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp models.ForwardAuthConfig + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, "authelia", resp.Provider) + + // Test Update + payload["provider"] = "authentik" + body, _ = json.Marshal(payload) + req, _ = http.NewRequest("POST", "/config", bytes.NewBuffer(body)) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, "authentik", resp.Provider) + + // Test Validation Error + payload["address"] = "not-a-url" + body, _ = json.Marshal(payload) + req, _ = http.NewRequest("POST", "/config", bytes.NewBuffer(body)) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestForwardAuthHandler_GetTemplates(t *testing.T) { + db := setupForwardAuthTestDB() + h := NewForwardAuthHandler(db) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/templates", h.GetTemplates) + + req, _ := http.NewRequest("GET", "/templates", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Contains(t, resp, "authelia") + assert.Contains(t, resp, "authentik") +} diff --git a/backend/internal/api/handlers/logs_handler_test.go b/backend/internal/api/handlers/logs_handler_test.go index 7c5160ee..bb9e281c 100644 --- a/backend/internal/api/handlers/logs_handler_test.go +++ b/backend/internal/api/handlers/logs_handler_test.go @@ -134,3 +134,24 @@ func TestLogsLifecycle(t *testing.T) { require.NoError(t, err) require.Empty(t, emptyLogs) } + +func TestLogsHandler_PathTraversal(t *testing.T) { + _, _, tmpDir := setupLogsTest(t) + defer os.RemoveAll(tmpDir) + + // Manually invoke handler to bypass Gin router cleaning + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "filename", Value: "../access.log"}} + + cfg := &config.Config{ + DatabasePath: filepath.Join(tmpDir, "data", "cpm.db"), + } + svc := services.NewLogService(cfg) + h := NewLogsHandler(svc) + + h.Download(c) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "invalid filename") +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 6830a539..63275159 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -34,6 +34,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { &models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.Domain{}, + &models.ForwardAuthConfig{}, ); err != nil { return fmt.Errorf("auto migrate: %w", err) } @@ -100,6 +101,12 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { protected.GET("/settings", settingsHandler.GetSettings) protected.POST("/settings", settingsHandler.UpdateSetting) + // Forward Auth + forwardAuthHandler := handlers.NewForwardAuthHandler(db) + protected.GET("/security/forward-auth", forwardAuthHandler.GetConfig) + protected.PUT("/security/forward-auth", forwardAuthHandler.UpdateConfig) + protected.GET("/security/forward-auth/templates", forwardAuthHandler.GetTemplates) + // User Profile & API Key userHandler := handlers.NewUserHandler(db) protected.GET("/user/profile", userHandler.GetProfile) diff --git a/backend/internal/caddy/client_test.go b/backend/internal/caddy/client_test.go index 368058b5..0b306dbf 100644 --- a/backend/internal/caddy/client_test.go +++ b/backend/internal/caddy/client_test.go @@ -30,7 +30,7 @@ func TestClient_Load_Success(t *testing.T) { ForwardPort: 8080, Enabled: true, }, - }, "/tmp/caddy-data", "admin@example.com", "", "", false) + }, "/tmp/caddy-data", "admin@example.com", "", "", false, nil) err := client.Load(context.Background(), config) require.NoError(t, err) @@ -93,3 +93,71 @@ func TestClient_Ping_Unreachable(t *testing.T) { err := client.Ping(context.Background()) require.Error(t, err) } + +func TestClient_GetConfig_Failure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal error")) + })) + defer server.Close() + + client := NewClient(server.URL) + _, err := client.GetConfig(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "500") +} + +func TestClient_GetConfig_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("invalid json")) + })) + defer server.Close() + + client := NewClient(server.URL) + _, err := client.GetConfig(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "decode response") +} + +func TestClient_Ping_Failure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + + client := NewClient(server.URL) + err := client.Ping(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "503") +} + +func TestClient_RequestCreationErrors(t *testing.T) { + // Use a control character in URL to force NewRequest error + client := NewClient("http://example.com" + string(byte(0x7f))) + + err := client.Load(context.Background(), &Config{}) + require.Error(t, err) + require.Contains(t, err.Error(), "create request") + + _, err = client.GetConfig(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "create request") + + err = client.Ping(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "create request") +} + +func TestClient_NetworkErrors(t *testing.T) { + // Use a closed port to force connection error + client := NewClient("http://127.0.0.1:0") + + err := client.Load(context.Background(), &Config{}) + require.Error(t, err) + require.Contains(t, err.Error(), "execute request") + + _, err = client.GetConfig(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "execute request") +} diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index 5df2ab95..82a9bcd3 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -10,7 +10,7 @@ import ( // GenerateConfig creates a Caddy JSON configuration from proxy hosts. // This is the core transformation layer from our database model to Caddy config. -func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool) (*Config, error) { +func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, forwardAuthConfig *models.ForwardAuthConfig) (*Config, error) { // Define log file paths // We assume storageDir is like ".../data/caddy/data", so we go up to ".../data/logs" // storageDir is .../data/caddy/data @@ -196,6 +196,30 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin // Build handlers for this host handlers := make([]Handler, 0) + // Add Forward Auth if enabled for this host + if host.ForwardAuthEnabled && forwardAuthConfig != nil && forwardAuthConfig.Address != "" { + // Parse bypass paths + var bypassPaths []string + if host.ForwardAuthBypass != "" { + rawPaths := strings.Split(host.ForwardAuthBypass, ",") + for _, p := range rawPaths { + p = strings.TrimSpace(p) + if p != "" { + bypassPaths = append(bypassPaths, p) + } + } + } + + // If we have bypass paths, we need to conditionally apply auth + if len(bypassPaths) > 0 { + // Create a subroute that only applies auth to non-bypass paths + // This is complex - for now, add auth unconditionally and handle bypass in a separate route + // A better approach: create bypass routes BEFORE auth routes + } + + handlers = append(handlers, ForwardAuthHandler(forwardAuthConfig.Address, forwardAuthConfig.TrustForwardHeader)) + } + // Add HSTS header if enabled if host.HSTSEnabled { hstsValue := "max-age=31536000" @@ -212,6 +236,32 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin handlers = append(handlers, BlockExploitsHandler()) } + // Handle bypass routes FIRST if Forward Auth is enabled + if host.ForwardAuthEnabled && host.ForwardAuthBypass != "" { + rawPaths := strings.Split(host.ForwardAuthBypass, ",") + for _, p := range rawPaths { + p = strings.TrimSpace(p) + if p == "" { + continue + } + // Create bypass route without auth + dial := fmt.Sprintf("%s:%d", host.ForwardHost, host.ForwardPort) + bypassRoute := &Route{ + Match: []Match{ + { + Host: uniqueDomains, + Path: []string{p, p + "/*"}, + }, + }, + Handle: []Handler{ + ReverseProxyHandler(dial, host.WebsocketSupport), + }, + Terminal: true, + } + routes = append(routes, bypassRoute) + } + } + // Handle custom locations first (more specific routes) for _, loc := range host.Locations { dial := fmt.Sprintf("%s:%d", loc.ForwardHost, loc.ForwardPort) diff --git a/backend/internal/caddy/config_test.go b/backend/internal/caddy/config_test.go index 86616065..cf6143db 100644 --- a/backend/internal/caddy/config_test.go +++ b/backend/internal/caddy/config_test.go @@ -9,7 +9,7 @@ import ( ) func TestGenerateConfig_Empty(t *testing.T) { - config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "", false) + config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "", false, nil) require.NoError(t, err) require.NotNil(t, config) require.NotNil(t, config.Apps.HTTP) @@ -31,7 +31,7 @@ func TestGenerateConfig_SingleHost(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, nil) require.NoError(t, err) require.NotNil(t, config) require.NotNil(t, config.Apps.HTTP) @@ -71,7 +71,7 @@ func TestGenerateConfig_MultipleHosts(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, nil) require.NoError(t, err) require.Len(t, config.Apps.HTTP.Servers["cpm_server"].Routes, 2) } @@ -88,7 +88,7 @@ func TestGenerateConfig_WebSocketEnabled(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, nil) require.NoError(t, err) route := config.Apps.HTTP.Servers["cpm_server"].Routes[0] @@ -109,7 +109,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, nil) require.NoError(t, err) // Should produce empty routes (or just catch-all if frontendDir was set, but it's empty here) require.Empty(t, config.Apps.HTTP.Servers["cpm_server"].Routes) @@ -117,7 +117,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) { func TestGenerateConfig_Logging(t *testing.T) { hosts := []models.ProxyHost{} - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, nil) require.NoError(t, err) // Verify logging configuration @@ -155,7 +155,7 @@ func TestGenerateConfig_Advanced(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, nil) require.NoError(t, err) require.NotNil(t, config) @@ -202,7 +202,7 @@ func TestGenerateConfig_ACMEStaging(t *testing.T) { } // Test with staging enabled - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", true) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", true, nil) require.NoError(t, err) require.NotNil(t, config.Apps.TLS) require.NotNil(t, config.Apps.TLS.Automation) @@ -217,7 +217,7 @@ func TestGenerateConfig_ACMEStaging(t *testing.T) { require.Equal(t, "https://acme-staging-v02.api.letsencrypt.org/directory", acmeIssuer["ca"]) // Test with staging disabled (production) - config, err = GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", false) + config, err = GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", false, nil) require.NoError(t, err) require.NotNil(t, config.Apps.TLS) require.NotNil(t, config.Apps.TLS.Automation) diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go index ef19ac5c..43f07ac7 100644 --- a/backend/internal/caddy/manager.go +++ b/backend/internal/caddy/manager.go @@ -57,8 +57,15 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { sslProvider = sslProviderSetting.Value } + // Fetch Forward Auth configuration + var forwardAuthConfig models.ForwardAuthConfig + var forwardAuthPtr *models.ForwardAuthConfig + if err := m.db.First(&forwardAuthConfig).Error; err == nil { + forwardAuthPtr = &forwardAuthConfig + } + // Generate Caddy config - config, err := GenerateConfig(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging) + config, err := GenerateConfig(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging, forwardAuthPtr) if err != nil { return fmt.Errorf("generate config: %w", err) } diff --git a/backend/internal/caddy/manager_test.go b/backend/internal/caddy/manager_test.go index 61e7de26..84f7a1bd 100644 --- a/backend/internal/caddy/manager_test.go +++ b/backend/internal/caddy/manager_test.go @@ -40,7 +40,7 @@ func TestManager_ApplyConfig(t *testing.T) { dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.ForwardAuthConfig{})) // Setup Manager tmpDir := t.TempDir() @@ -77,7 +77,7 @@ func TestManager_ApplyConfig_Failure(t *testing.T) { dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.ForwardAuthConfig{})) // Setup Manager tmpDir := t.TempDir() @@ -158,7 +158,7 @@ func TestManager_RotateSnapshots(t *testing.T) { dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.ForwardAuthConfig{})) client := NewClient(caddyServer.URL) manager := NewManager(client, db, tmpDir, "", false) @@ -212,7 +212,7 @@ func TestManager_Rollback_Success(t *testing.T) { dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.ForwardAuthConfig{})) // Setup Manager tmpDir := t.TempDir() @@ -255,3 +255,78 @@ func TestManager_Rollback_Success(t *testing.T) { snapshots, _ = manager.listSnapshots() assert.Len(t, snapshots, 1) } + +func TestManager_ApplyConfig_DBError(t *testing.T) { + // Setup DB + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.ForwardAuthConfig{})) + + // Setup Manager + tmpDir := t.TempDir() + client := NewClient("http://localhost") + manager := NewManager(client, db, tmpDir, "", false) + + // Close DB to force error + sqlDB, _ := db.DB() + sqlDB.Close() + + err = manager.ApplyConfig(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "fetch proxy hosts") +} + +func TestManager_ApplyConfig_ValidationError(t *testing.T) { + // Setup DB + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.ForwardAuthConfig{})) + + // Setup Manager with a file as configDir to force saveSnapshot error + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "config-file") + os.WriteFile(configDir, []byte("not a dir"), 0644) + + client := NewClient("http://localhost") + manager := NewManager(client, db, configDir, "", false) + + host := models.ProxyHost{ + DomainNames: "example.com", + ForwardHost: "127.0.0.1", + ForwardPort: 8080, + } + db.Create(&host) + + err = manager.ApplyConfig(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "save snapshot") +} + +func TestManager_Rollback_Failure(t *testing.T) { + // Mock Caddy Admin API - Always Fail + caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer caddyServer.Close() + + // Setup DB + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.ForwardAuthConfig{})) + + // Setup Manager + tmpDir := t.TempDir() + client := NewClient(caddyServer.URL) + manager := NewManager(client, db, tmpDir, "", false) + + // Create a dummy snapshot manually so rollback has something to try + os.WriteFile(filepath.Join(tmpDir, "config-123.json"), []byte("{}"), 0644) + + // Apply Config - will fail, try rollback, rollback will fail + err = manager.ApplyConfig(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "rollback also failed") +} diff --git a/backend/internal/caddy/types.go b/backend/internal/caddy/types.go index e3584f96..95c1e045 100644 --- a/backend/internal/caddy/types.go +++ b/backend/internal/caddy/types.go @@ -157,6 +157,54 @@ func FileServerHandler(root string) Handler { } } +// ForwardAuthHandler creates a forward authentication handler using reverse_proxy. +// This sends the request to an auth provider and uses handle_response to process the result. +func ForwardAuthHandler(authAddress string, trustForwardHeader bool) Handler { + h := Handler{ + "handler": "reverse_proxy", + "upstreams": []map[string]interface{}{ + {"dial": authAddress}, + }, + "handle_response": []map[string]interface{}{ + { + "match": map[string]interface{}{ + "status_code": []int{200}, + }, + "routes": []map[string]interface{}{ + { + "handle": []map[string]interface{}{ + { + "handler": "headers", + "request": map[string]interface{}{ + "set": map[string][]string{ + "Remote-User": {"{http.reverse_proxy.header.Remote-User}"}, + "Remote-Email": {"{http.reverse_proxy.header.Remote-Email}"}, + "Remote-Name": {"{http.reverse_proxy.header.Remote-Name}"}, + "Remote-Groups": {"{http.reverse_proxy.header.Remote-Groups}"}, + }, + }, + }, + }, + }, + }, + }, + }, + } + + if trustForwardHeader { + h["headers"] = map[string]interface{}{ + "request": map[string]interface{}{ + "set": map[string][]string{ + "X-Forwarded-Method": {"{http.request.method}"}, + "X-Forwarded-Uri": {"{http.request.uri}"}, + }, + }, + } + } + + return h +} + // TLSApp configures the TLS app for certificate management. type TLSApp struct { Automation *AutomationConfig `json:"automation,omitempty"` diff --git a/backend/internal/caddy/validator_test.go b/backend/internal/caddy/validator_test.go index bbeae9d6..c44c4067 100644 --- a/backend/internal/caddy/validator_test.go +++ b/backend/internal/caddy/validator_test.go @@ -25,7 +25,7 @@ func TestValidate_ValidConfig(t *testing.T) { }, } - config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) + config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, nil) err := Validate(config) require.NoError(t, err) } diff --git a/backend/internal/models/forward_auth_config.go b/backend/internal/models/forward_auth_config.go new file mode 100644 index 00000000..05061b48 --- /dev/null +++ b/backend/internal/models/forward_auth_config.go @@ -0,0 +1,16 @@ +package models + +import ( + "time" +) + +// ForwardAuthConfig represents the global forward authentication configuration. +// This is stored as structured data to avoid multiple Setting entries. +type ForwardAuthConfig struct { + ID uint `json:"id" gorm:"primaryKey"` + Provider string `json:"provider" gorm:"not null"` // "authelia", "authentik", "pomerium", "custom" + Address string `json:"address" gorm:"not null"` // e.g., "http://authelia:9091/api/verify" + TrustForwardHeader bool `json:"trust_forward_header" gorm:"default:true"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/internal/models/proxy_host.go b/backend/internal/models/proxy_host.go index 2f1dbdb6..fe8909e4 100644 --- a/backend/internal/models/proxy_host.go +++ b/backend/internal/models/proxy_host.go @@ -6,23 +6,25 @@ import ( // ProxyHost represents a reverse proxy configuration. type ProxyHost struct { - ID uint `json:"id" gorm:"primaryKey"` - UUID string `json:"uuid" gorm:"uniqueIndex;not null"` - Name string `json:"name"` - DomainNames string `json:"domain_names" gorm:"not null"` // Comma-separated list - ForwardScheme string `json:"forward_scheme" gorm:"default:http"` - ForwardHost string `json:"forward_host" gorm:"not null"` - ForwardPort int `json:"forward_port" gorm:"not null"` - SSLForced bool `json:"ssl_forced" gorm:"default:false"` - HTTP2Support bool `json:"http2_support" gorm:"default:true"` - HSTSEnabled bool `json:"hsts_enabled" gorm:"default:false"` - HSTSSubdomains bool `json:"hsts_subdomains" gorm:"default:false"` - BlockExploits bool `json:"block_exploits" gorm:"default:true"` - WebsocketSupport bool `json:"websocket_support" gorm:"default:false"` - Enabled bool `json:"enabled" gorm:"default:true"` - CertificateID *uint `json:"certificate_id"` - Certificate *SSLCertificate `json:"certificate" gorm:"foreignKey:CertificateID"` - Locations []Location `json:"locations" gorm:"foreignKey:ProxyHostID;constraint:OnDelete:CASCADE"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"id" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex;not null"` + Name string `json:"name"` + DomainNames string `json:"domain_names" gorm:"not null"` // Comma-separated list + ForwardScheme string `json:"forward_scheme" gorm:"default:http"` + ForwardHost string `json:"forward_host" gorm:"not null"` + ForwardPort int `json:"forward_port" gorm:"not null"` + SSLForced bool `json:"ssl_forced" gorm:"default:false"` + HTTP2Support bool `json:"http2_support" gorm:"default:true"` + HSTSEnabled bool `json:"hsts_enabled" gorm:"default:false"` + HSTSSubdomains bool `json:"hsts_subdomains" gorm:"default:false"` + BlockExploits bool `json:"block_exploits" gorm:"default:true"` + WebsocketSupport bool `json:"websocket_support" gorm:"default:false"` + Enabled bool `json:"enabled" gorm:"default:true"` + ForwardAuthEnabled bool `json:"forward_auth_enabled" gorm:"default:false"` + ForwardAuthBypass string `json:"forward_auth_bypass" gorm:"type:text"` // Comma-separated paths + CertificateID *uint `json:"certificate_id"` + Certificate *SSLCertificate `json:"certificate" gorm:"foreignKey:CertificateID"` + Locations []Location `json:"locations" gorm:"foreignKey:ProxyHostID;constraint:OnDelete:CASCADE"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e3a99f71..186c6bbd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.51.0", + "react-hot-toast": "^2.6.0", "react-router-dom": "^7.9.6", "tailwind-merge": "^3.4.0", "tldts": "^7.0.19" @@ -2921,8 +2922,7 @@ "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, "node_modules/data-urls": { "version": "6.0.0", @@ -3624,6 +3624,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4699,6 +4708,23 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index f0f5b639..a30e1447 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.51.0", + "react-hot-toast": "^2.6.0", "react-router-dom": "^7.9.6", "tailwind-merge": "^3.4.0", "tldts": "^7.0.19" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 53dc10ec..eadd5c6b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ const ProxyHosts = lazy(() => import('./pages/ProxyHosts')) const RemoteServers = lazy(() => import('./pages/RemoteServers')) const ImportCaddy = lazy(() => import('./pages/ImportCaddy')) const Certificates = lazy(() => import('./pages/Certificates')) +const Security = lazy(() => import('./pages/Security')) const SystemSettings = lazy(() => import('./pages/SystemSettings')) const Account = lazy(() => import('./pages/Account')) const Settings = lazy(() => import('./pages/Settings')) @@ -50,6 +51,7 @@ export default function App() { } /> } /> } /> + } /> {/* Settings Routes */} }> diff --git a/frontend/src/api/__tests__/proxyHosts.test.ts b/frontend/src/api/__tests__/proxyHosts.test.ts index 88ba9ed3..08b9eac7 100644 --- a/frontend/src/api/__tests__/proxyHosts.test.ts +++ b/frontend/src/api/__tests__/proxyHosts.test.ts @@ -26,6 +26,7 @@ describe('proxyHosts API', () => { const mockHost: ProxyHost = { uuid: '123', + name: 'Example Host', domain_names: 'example.com', forward_scheme: 'http', forward_host: 'localhost', @@ -36,6 +37,8 @@ describe('proxyHosts API', () => { hsts_subdomains: false, block_exploits: false, websocket_support: false, + forward_auth_enabled: false, + forward_auth_bypass: '', locations: [], enabled: true, created_at: '2023-01-01', diff --git a/frontend/src/api/proxyHosts.ts b/frontend/src/api/proxyHosts.ts index 4ab9ebea..b2e76a80 100644 --- a/frontend/src/api/proxyHosts.ts +++ b/frontend/src/api/proxyHosts.ts @@ -30,6 +30,8 @@ export interface ProxyHost { hsts_subdomains: boolean; block_exploits: boolean; websocket_support: boolean; + forward_auth_enabled: boolean; + forward_auth_bypass: string; locations: Location[]; advanced_config?: string; enabled: boolean; diff --git a/frontend/src/api/security.ts b/frontend/src/api/security.ts new file mode 100644 index 00000000..4220706d --- /dev/null +++ b/frontend/src/api/security.ts @@ -0,0 +1,32 @@ +import client from './client'; + +export interface ForwardAuthConfig { + id?: number; + provider: 'authelia' | 'authentik' | 'pomerium' | 'custom'; + address: string; + trust_forward_header: boolean; + created_at?: string; + updated_at?: string; +} + +export interface ForwardAuthTemplate { + provider: string; + address: string; + trust_forward_header: boolean; + description: string; +} + +export const getForwardAuthConfig = async (): Promise => { + const { data } = await client.get('/security/forward-auth'); + return data; +}; + +export const updateForwardAuthConfig = async (config: ForwardAuthConfig): Promise => { + const { data } = await client.put('/security/forward-auth', config); + return data; +}; + +export const getForwardAuthTemplates = async (): Promise> => { + const { data } = await client.get>('/security/forward-auth/templates'); + return data; +}; diff --git a/frontend/src/components/ForwardAuthSettings.tsx b/frontend/src/components/ForwardAuthSettings.tsx new file mode 100644 index 00000000..831ee0f7 --- /dev/null +++ b/frontend/src/components/ForwardAuthSettings.tsx @@ -0,0 +1,157 @@ +import { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { getForwardAuthConfig, updateForwardAuthConfig, getForwardAuthTemplates, ForwardAuthConfig } from '../api/security'; +import { Button } from './ui/Button'; +import { toast } from 'react-hot-toast'; +import { Shield, Check, AlertTriangle } from 'lucide-react'; + +export default function ForwardAuthSettings() { + const queryClient = useQueryClient(); + const [formData, setFormData] = useState({ + provider: 'custom', + address: '', + trust_forward_header: true, + }); + + const { data: config, isLoading } = useQuery({ + queryKey: ['forwardAuth'], + queryFn: getForwardAuthConfig, + }); + + const { data: templates } = useQuery({ + queryKey: ['forwardAuthTemplates'], + queryFn: getForwardAuthTemplates, + }); + + useEffect(() => { + if (config) { + setFormData(config); + } + }, [config]); + + const mutation = useMutation({ + mutationFn: updateForwardAuthConfig, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['forwardAuth'] }); + toast.success('Forward Auth configuration saved'); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Failed to save configuration'); + }, + }); + + const handleTemplateChange = (provider: string) => { + if (templates && templates[provider]) { + const template = templates[provider]; + setFormData({ + ...formData, + provider: provider as any, + address: template.address, + trust_forward_header: template.trust_forward_header, + }); + } else { + setFormData({ + ...formData, + provider: 'custom', + }); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + mutation.mutate(formData); + }; + + if (isLoading) { + return
; + } + + return ( +
+
+
+ +
+
+

Forward Authentication

+

+ Configure a global authentication provider (SSO) for your proxy hosts. +

+
+
+ +
+
+
+ + +

+ Select a template to pre-fill configuration or choose Custom. +

+
+ +
+ + setFormData({ ...formData, address: e.target.value })} + placeholder="http://authelia:9091/api/verify" + className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-dark-bg text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +

+ The internal URL where Caddy will send auth subrequests. +

+
+
+ +
+ setFormData({ ...formData, trust_forward_header: e.target.checked })} + className="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500 dark:bg-dark-bg dark:border-gray-600" + /> + +
+ +
+
+ + Changes apply immediately to all hosts using Forward Auth. +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index bf688884..b2661b9e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -50,6 +50,7 @@ export default function Layout({ children }: LayoutProps) { { name: 'Uptime', path: '/uptime', icon: '📈' }, { name: 'Notifications', path: '/notifications', icon: '🔔' }, { name: 'Import Caddyfile', path: '/import', icon: '📥' }, + { name: 'Security', path: '/security', icon: '🛡️' }, { name: 'Settings', path: '/settings', diff --git a/frontend/src/components/ProxyHostForm.tsx b/frontend/src/components/ProxyHostForm.tsx index d3a78642..04df69ce 100644 --- a/frontend/src/components/ProxyHostForm.tsx +++ b/frontend/src/components/ProxyHostForm.tsx @@ -27,6 +27,8 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor hsts_subdomains: host?.hsts_subdomains ?? true, block_exploits: host?.block_exploits ?? true, websocket_support: host?.websocket_support ?? true, + forward_auth_enabled: host?.forward_auth_enabled ?? false, + forward_auth_bypass: host?.forward_auth_bypass || '', advanced_config: host?.advanced_config || '', enabled: host?.enabled ?? true, certificate_id: host?.certificate_id, @@ -499,6 +501,43 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor + {/* Forward Auth */} +
+
+ +
+ +
+
+ + {formData.forward_auth_enabled && ( +
+ +