diff --git a/backend/internal/api/handlers/import_handler.go b/backend/internal/api/handlers/import_handler.go index 843ff4b6..cf41ba6a 100644 --- a/backend/internal/api/handlers/import_handler.go +++ b/backend/internal/api/handlers/import_handler.go @@ -491,9 +491,17 @@ func (h *ImportHandler) Commit(c *gin.Context) { log.Printf("Import Commit: Parsed %d hosts, converted to %d proxy hosts", len(result.Hosts), len(proxyHosts)) created := 0 + updated := 0 skipped := 0 errors := []string{} + // Get existing hosts to check for overwrites + existingHosts, _ := h.proxyHostSvc.List() + existingMap := make(map[string]*models.ProxyHost) + for i := range existingHosts { + existingMap[existingHosts[i].DomainNames] = &existingHosts[i] + } + for _, host := range proxyHosts { action := req.Resolutions[host.DomainNames] @@ -507,8 +515,29 @@ func (h *ImportHandler) Commit(c *gin.Context) { host.DomainNames = host.DomainNames + "-imported" } - host.UUID = uuid.NewString() + // Handle overwrite: preserve existing ID, UUID, and certificate + if action == "overwrite" { + if existing, found := existingMap[host.DomainNames]; found { + host.ID = existing.ID + host.UUID = existing.UUID + host.CertificateID = existing.CertificateID // Preserve certificate association + host.CreatedAt = existing.CreatedAt + if err := h.proxyHostSvc.Update(&host); err != nil { + errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error()) + errors = append(errors, errMsg) + log.Printf("Import Commit Error (update): %s", errMsg) + } else { + updated++ + log.Printf("Import Commit Success: Updated host %s", host.DomainNames) + } + continue + } + // If "overwrite" but doesn't exist, fall through to create + } + + // Create new host + host.UUID = uuid.NewString() if err := h.proxyHostSvc.Create(&host); err != nil { errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error()) errors = append(errors, errMsg) @@ -537,6 +566,7 @@ func (h *ImportHandler) Commit(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "created": created, + "updated": updated, "skipped": skipped, "errors": errors, }) diff --git a/backend/internal/caddy/importer.go b/backend/internal/caddy/importer.go index 06eb67db..768ef7bd 100644 --- a/backend/internal/caddy/importer.go +++ b/backend/internal/caddy/importer.go @@ -62,6 +62,7 @@ type CaddyHandler struct { Handler string `json:"handler"` Upstreams interface{} `json:"upstreams,omitempty"` Headers interface{} `json:"headers,omitempty"` + Routes interface{} `json:"routes,omitempty"` // For subroute handlers } // ParsedHost represents a single host detected during Caddyfile import. @@ -114,6 +115,44 @@ func (i *Importer) ParseCaddyfile(caddyfilePath string) ([]byte, error) { return output, nil } +// extractHandlers recursively extracts handlers from a list, flattening subroutes. +func (i *Importer) extractHandlers(handles []*CaddyHandler) []*CaddyHandler { + var result []*CaddyHandler + + for _, handler := range handles { + // If this is a subroute, extract handlers from its first route + if handler.Handler == "subroute" { + if routes, ok := handler.Routes.([]interface{}); ok && len(routes) > 0 { + if subroute, ok := routes[0].(map[string]interface{}); ok { + if subhandles, ok := subroute["handle"].([]interface{}); ok { + // Convert the subhandles to CaddyHandler objects + for _, sh := range subhandles { + if shMap, ok := sh.(map[string]interface{}); ok { + subHandler := &CaddyHandler{} + if handlerType, ok := shMap["handler"].(string); ok { + subHandler.Handler = handlerType + } + if upstreams, ok := shMap["upstreams"]; ok { + subHandler.Upstreams = upstreams + } + if headers, ok := shMap["headers"]; ok { + subHandler.Headers = headers + } + result = append(result, subHandler) + } + } + } + } + } + } else { + // Regular handler, add it directly + result = append(result, handler) + } + } + + return result +} + // ExtractHosts parses Caddy JSON and extracts proxy host information. func (i *Importer) ExtractHosts(caddyJSON []byte) (*ImportResult, error) { var config CaddyConfig @@ -152,8 +191,10 @@ func (i *Importer) ExtractHosts(caddyJSON []byte) (*ImportResult, error) { SSLForced: strings.HasPrefix(domain, "https") || server.TLSConnectionPolicies != nil, } - // Find reverse_proxy handler - for _, handler := range route.Handle { + // Find reverse_proxy handler (may be nested in subroute) + handlers := i.extractHandlers(route.Handle) + + for _, handler := range handlers { if handler.Handler == "reverse_proxy" { upstreams, _ := handler.Upstreams.([]interface{}) if len(upstreams) > 0 { diff --git a/backend/internal/caddy/importer_subroute_test.go b/backend/internal/caddy/importer_subroute_test.go new file mode 100644 index 00000000..cfe3299c --- /dev/null +++ b/backend/internal/caddy/importer_subroute_test.go @@ -0,0 +1,86 @@ +package caddy + +import ( + "encoding/json" + "testing" +) + +func TestExtractHandlers_Subroute(t *testing.T) { + // Test JSON that mimics the plex.caddy structure + rawJSON := `{ + "apps": { + "http": { + "servers": { + "srv0": { + "routes": [{ + "match": [{"host": ["plex.hatfieldhosted.com"]}], + "handle": [{ + "handler": "subroute", + "routes": [{ + "handle": [{ + "handler": "headers" + }, { + "handler": "reverse_proxy", + "upstreams": [{"dial": "100.99.23.57:32400"}] + }] + }] + }] + }] + } + } + } + } + }` + + var config CaddyConfig + err := json.Unmarshal([]byte(rawJSON), &config) + if err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + importer := NewImporter("caddy") + route := config.Apps.HTTP.Servers["srv0"].Routes[0] + + handlers := importer.extractHandlers(route.Handle) + + // We should get 2 handlers: headers and reverse_proxy + if len(handlers) != 2 { + t.Fatalf("Expected 2 handlers, got %d", len(handlers)) + } + + if handlers[0].Handler != "headers" { + t.Errorf("Expected first handler to be 'headers', got '%s'", handlers[0].Handler) + } + + if handlers[1].Handler != "reverse_proxy" { + t.Errorf("Expected second handler to be 'reverse_proxy', got '%s'", handlers[1].Handler) + } + + // Check if upstreams are preserved + if handlers[1].Upstreams == nil { + t.Fatal("Upstreams should not be nil") + } + + upstreams, ok := handlers[1].Upstreams.([]interface{}) + if !ok { + t.Fatal("Upstreams should be []interface{}") + } + + if len(upstreams) == 0 { + t.Fatal("Upstreams should not be empty") + } + + upstream, ok := upstreams[0].(map[string]interface{}) + if !ok { + t.Fatal("First upstream should be map[string]interface{}") + } + + dial, ok := upstream["dial"].(string) + if !ok { + t.Fatal("Dial should be a string") + } + + if dial != "100.99.23.57:32400" { + t.Errorf("Expected dial to be '100.99.23.57:32400', got '%s'", dial) + } +} diff --git a/docs/acme-staging.md b/docs/acme-staging.md index c4ce9a81..72ff78cc 100644 --- a/docs/acme-staging.md +++ b/docs/acme-staging.md @@ -90,11 +90,11 @@ This is **expected** when using staging. Staging certificates are signed by a fa 1. Set `CPM_ACME_STAGING=false` (or remove the variable) 2. Restart the container 3. **Clean up staging certificates** (choose one method): - + **Option A - Via UI (Recommended):** - Go to **Certificates** page in the web interface - Delete any certificates with "acme-staging" in the issuer name - + **Option B - Via Terminal:** ```bash docker exec cpmp rm -rf /app/data/caddy/data/acme/acme-staging* diff --git a/frontend/src/components/CertificateList.tsx b/frontend/src/components/CertificateList.tsx index 1baf2c32..30270d1e 100644 --- a/frontend/src/components/CertificateList.tsx +++ b/frontend/src/components/CertificateList.tsx @@ -49,7 +49,16 @@ export default function CertificateList() { {cert.name || '-'} {cert.domain} - {cert.issuer} + +
+ {cert.issuer} + {cert.issuer?.toLowerCase().includes('staging') && ( + + STAGING + + )} +
+ {new Date(cert.expires_at).toLocaleDateString()} @@ -60,7 +69,7 @@ export default function CertificateList() { {cert.id && (cert.provider === 'custom' || cert.issuer?.includes('staging')) && (