diff --git a/backend/internal/api/handlers/handlers_test.go b/backend/internal/api/handlers/handlers_test.go index 34083730..2013bb85 100644 --- a/backend/internal/api/handlers/handlers_test.go +++ b/backend/internal/api/handlers/handlers_test.go @@ -259,7 +259,7 @@ func TestProxyHostHandler_List(t *testing.T) { } db.Create(host) - handler := handlers.NewProxyHostHandler(db) + handler := handlers.NewProxyHostHandler(db, nil) router := gin.New() handler.RegisterRoutes(router.Group("/api/v1")) @@ -281,7 +281,7 @@ func TestProxyHostHandler_Create(t *testing.T) { gin.SetMode(gin.TestMode) db := setupTestDB() - handler := handlers.NewProxyHostHandler(db) + handler := handlers.NewProxyHostHandler(db, nil) router := gin.New() handler.RegisterRoutes(router.Group("/api/v1")) diff --git a/backend/internal/api/handlers/proxy_host_handler.go b/backend/internal/api/handlers/proxy_host_handler.go index 54349b1e..548c53be 100644 --- a/backend/internal/api/handlers/proxy_host_handler.go +++ b/backend/internal/api/handlers/proxy_host_handler.go @@ -7,19 +7,22 @@ import ( "github.com/google/uuid" "gorm.io/gorm" + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy" "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" ) // ProxyHostHandler handles CRUD operations for proxy hosts. type ProxyHostHandler struct { - service *services.ProxyHostService + service *services.ProxyHostService + caddyManager *caddy.Manager } // NewProxyHostHandler creates a new proxy host handler. -func NewProxyHostHandler(db *gorm.DB) *ProxyHostHandler { +func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager) *ProxyHostHandler { return &ProxyHostHandler{ - service: services.NewProxyHostService(db), + service: services.NewProxyHostService(db), + caddyManager: caddyManager, } } @@ -64,6 +67,13 @@ func (h *ProxyHostHandler) Create(c *gin.Context) { return } + if h.caddyManager != nil { + if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) + return + } + } + c.JSON(http.StatusCreated, host) } @@ -100,6 +110,13 @@ func (h *ProxyHostHandler) Update(c *gin.Context) { return } + if h.caddyManager != nil { + if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) + return + } + } + c.JSON(http.StatusOK, host) } @@ -118,6 +135,13 @@ func (h *ProxyHostHandler) Delete(c *gin.Context) { return } + if h.caddyManager != nil { + if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) + return + } + } + c.JSON(http.StatusOK, gin.H{"message": "proxy host deleted"}) } diff --git a/backend/internal/api/handlers/proxy_host_handler_test.go b/backend/internal/api/handlers/proxy_host_handler_test.go index 25228db2..f66a6b8b 100644 --- a/backend/internal/api/handlers/proxy_host_handler_test.go +++ b/backend/internal/api/handlers/proxy_host_handler_test.go @@ -10,10 +10,12 @@ import ( "testing" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy" "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" ) @@ -25,7 +27,7 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{})) - h := NewProxyHostHandler(db) + h := NewProxyHostHandler(db, nil) r := gin.New() api := r.Group("/api/v1") h.RegisterRoutes(api) @@ -94,27 +96,110 @@ func TestProxyHostLifecycle(t *testing.T) { } func TestProxyHostErrors(t *testing.T) { - router, _ := setupTestRouter(t) + // Mock Caddy Admin API that fails + caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer caddyServer.Close() - // Get non-existent - req := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/non-existent-uuid", nil) + // Setup DB + dsn := "file:" + t.Name() + "?mode=memory&cache=shared" + 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{})) + + // Setup Caddy Manager + tmpDir := t.TempDir() + client := caddy.NewClient(caddyServer.URL) + manager := caddy.NewManager(client, db, tmpDir) + + // Setup Handler + h := NewProxyHostHandler(db, manager) + r := gin.New() + api := r.Group("/api/v1") + h.RegisterRoutes(api) + + // Test Create - Bind Error + req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(`invalid json`)) + req.Header.Set("Content-Type", "application/json") resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusBadRequest, resp.Code) + + // Test Create - Apply Config Error + body := `{"name":"Fail Host","domain_names":"fail-unique-456.local","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true}` + req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusInternalServerError, resp.Code) + + // Create a host for Update/Delete/Get tests (manually in DB to avoid handler error) + host := models.ProxyHost{ + UUID: uuid.NewString(), + Name: "Existing Host", + DomainNames: "exist.local", + ForwardScheme: "http", + ForwardHost: "localhost", + ForwardPort: 8080, + Enabled: true, + } + db.Create(&host) + + // Test Get - Not Found + req = httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/non-existent-uuid", nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) require.Equal(t, http.StatusNotFound, resp.Code) - // Update non-existent - updateBody := `{"name":"Media Updated"}` - updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/non-existent-uuid", strings.NewReader(updateBody)) - updateReq.Header.Set("Content-Type", "application/json") - updateResp := httptest.NewRecorder() - router.ServeHTTP(updateResp, updateReq) - require.Equal(t, http.StatusNotFound, updateResp.Code) + // Test Update - Not Found + req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/non-existent-uuid", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusNotFound, resp.Code) - // Delete non-existent - delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/non-existent-uuid", nil) - delResp := httptest.NewRecorder() - router.ServeHTTP(delResp, delReq) - require.Equal(t, http.StatusNotFound, delResp.Code) + // Test Update - Bind Error + req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, strings.NewReader(`invalid json`)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusBadRequest, resp.Code) + + // Test Update - Apply Config Error + updateBody := `{"name":"Fail Host Update","domain_names":"fail-unique-update.local","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true}` + req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, strings.NewReader(updateBody)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusInternalServerError, resp.Code) + + // Test Delete - Not Found + req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/non-existent-uuid", nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusNotFound, resp.Code) + + // Test Delete - Apply Config Error + req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+host.UUID, nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusInternalServerError, resp.Code) + + // Test TestConnection - Bind Error + req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(`invalid json`)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusBadRequest, resp.Code) + + // Test TestConnection - Connection Failure + testBody := `{"forward_host": "invalid.host.local", "forward_port": 12345}` + req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(testBody)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusBadGateway, resp.Code) } func TestProxyHostValidation(t *testing.T) { @@ -178,3 +263,59 @@ func TestProxyHostConnection(t *testing.T) { router.ServeHTTP(resp, req) require.Equal(t, http.StatusOK, resp.Code) } + +func TestProxyHostWithCaddyIntegration(t *testing.T) { + // Mock Caddy Admin API + caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/load" && r.Method == "POST" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer caddyServer.Close() + + // Setup DB + dsn := "file:" + t.Name() + "?mode=memory&cache=shared" + 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{})) + + // Setup Caddy Manager + tmpDir := t.TempDir() + client := caddy.NewClient(caddyServer.URL) + manager := caddy.NewManager(client, db, tmpDir) + + // Setup Handler + h := NewProxyHostHandler(db, manager) + r := gin.New() + api := r.Group("/api/v1") + h.RegisterRoutes(api) + + // Test Create with Caddy Sync + body := `{"name":"Caddy Host","domain_names":"caddy.local","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusCreated, resp.Code) + + // Test Update with Caddy Sync + var createdHost models.ProxyHost + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &createdHost)) + + updateBody := `{"name":"Updated Caddy Host","domain_names":"caddy.local","forward_scheme":"http","forward_host":"localhost","forward_port":8081,"enabled":true}` + req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+createdHost.UUID, strings.NewReader(updateBody)) + req.Header.Set("Content-Type", "application/json") + + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusOK, resp.Code) + + // Test Delete with Caddy Sync + req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+createdHost.UUID, nil) + resp = httptest.NewRecorder() + r.ServeHTTP(resp, req) + require.Equal(t, http.StatusOK, resp.Code) +} diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go index 877f4132..31c1cdc5 100644 --- a/backend/internal/api/middleware/auth.go +++ b/backend/internal/api/middleware/auth.go @@ -19,6 +19,14 @@ func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc { } } + if authHeader == "" { + // Try query param + token := c.Query("token") + if token != "" { + authHeader = "Bearer " + token + } + } + if authHeader == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) return diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index bd32d9dd..3b7bf14e 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -9,6 +9,7 @@ import ( "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers" "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/middleware" + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy" "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config" "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" @@ -129,7 +130,11 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { }) } - proxyHostHandler := handlers.NewProxyHostHandler(db) + // Caddy Manager + caddyClient := caddy.NewClient(cfg.CaddyAdminAPI) + caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir) + + proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager) proxyHostHandler.RegisterRoutes(api) remoteServerHandler := handlers.NewRemoteServerHandler(db) diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index 9ab24d04..6c9e2d0f 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -2,6 +2,7 @@ package caddy import ( "fmt" + "path/filepath" "strings" "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" @@ -12,10 +13,11 @@ import ( func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string) (*Config, error) { // Define log file paths // We assume storageDir is like ".../data/caddy/data", so we go up to ".../data/logs" - // Or we can just use a relative path if Caddy's working directory is set correctly. - // In Docker, WORKDIR is /app, and storageDir passed here is usually /app/data/caddy. - // Let's put logs in /app/data/logs/access.log - logFile := "/app/data/logs/access.log" + // storageDir is .../data/caddy/data + // Dir -> .../data/caddy + // Dir -> .../data + logDir := filepath.Join(filepath.Dir(filepath.Dir(storageDir)), "logs") + logFile := filepath.Join(logDir, "access.log") config := &Config{ Logging: &LoggingConfig{ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f32ab540..f2fc3180 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,7 +15,9 @@ "lucide-react": "^0.554.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.9.6" + "react-router-dom": "^7.9.6", + "tailwind-merge": "^3.4.0", + "tldts": "^7.0.18" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.17", @@ -5135,6 +5137,15 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", @@ -5225,7 +5236,6 @@ "version": "7.0.18", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.18.tgz", "integrity": "sha512-lCcgTAgMxQ1JKOWrVGo6E69Ukbnx4Gc1wiYLRf6J5NN4HRYJtCby1rPF8rkQ4a6qqoFBK5dvjJ1zJ0F7VfDSvw==", - "dev": true, "dependencies": { "tldts-core": "^7.0.18" }, @@ -5236,8 +5246,7 @@ "node_modules/tldts-core": { "version": "7.0.18", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.18.tgz", - "integrity": "sha512-jqJC13oP4FFAahv4JT/0WTDrCF9Okv7lpKtOZUGPLiAnNbACcSg8Y8T+Z9xthOmRBqi/Sob4yi0TE0miRCvF7Q==", - "dev": true + "integrity": "sha512-jqJC13oP4FFAahv4JT/0WTDrCF9Okv7lpKtOZUGPLiAnNbACcSg8Y8T+Z9xthOmRBqi/Sob4yi0TE0miRCvF7Q==" }, "node_modules/to-regex-range": { "version": "5.0.1", diff --git a/frontend/package.json b/frontend/package.json index da0ea7da..631f0be3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,9 @@ "lucide-react": "^0.554.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.9.6" + "react-router-dom": "^7.9.6", + "tailwind-merge": "^3.4.0", + "tldts": "^7.0.18" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.17", diff --git a/frontend/src/components/LogTable.tsx b/frontend/src/components/LogTable.tsx index b97dddda..ac64b841 100644 --- a/frontend/src/components/LogTable.tsx +++ b/frontend/src/components/LogTable.tsx @@ -40,7 +40,24 @@ export const LogTable: React.FC = ({ logs, isLoading }) => { - {logs.map((log, idx) => ( + {logs.map((log, idx) => { + // Check if this is a structured access log or a plain text system log + const isAccessLog = log.status > 0 || (log.request && log.request.method); + + if (!isAccessLog) { + return ( + + + {format(new Date(log.ts * 1000), 'MMM d HH:mm:ss')} + + + {log.msg} + + + ); + } + + return ( {format(new Date(log.ts * 1000), 'MMM d HH:mm:ss')} @@ -75,7 +92,7 @@ export const LogTable: React.FC = ({ logs, isLoading }) => { {log.msg} - ))} + )})} diff --git a/frontend/src/components/ProxyHostForm.tsx b/frontend/src/components/ProxyHostForm.tsx index 8fb11377..f72e0c46 100644 --- a/frontend/src/components/ProxyHostForm.tsx +++ b/frontend/src/components/ProxyHostForm.tsx @@ -407,15 +407,6 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor - {/* Advanced Config */} @@ -433,6 +424,19 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor /> + {/* Enabled Toggle */} +
+ +
+ {/* Actions */}
- - {host.enabled ? 'Enabled' : 'Disabled'} - +
+ updateHost(host.uuid, { enabled: checked })} + /> + + {host.enabled ? 'Enabled' : 'Disabled'} + +