feat: enhance import handling with overwrite support and detailed conflict resolution

feat: add subroute handler extraction for improved Caddyfile parsing
test: add tests for subroute handler extraction functionality
fix: update UI to display staging certificate status and improve dashboard metrics
docs: clarify staging certificate deletion process in ACME documentation
This commit is contained in:
Wikid82
2025-11-25 00:35:42 +00:00
parent 897959a621
commit 0415f5da77
9 changed files with 322 additions and 18 deletions
@@ -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,
})
+43 -2
View File
@@ -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 {
@@ -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)
}
}
+2 -2
View File
@@ -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*
+11 -2
View File
@@ -49,7 +49,16 @@ export default function CertificateList() {
<tr key={cert.id || cert.domain} className="hover:bg-gray-800/50 transition-colors">
<td className="px-6 py-4 font-medium text-white">{cert.name || '-'}</td>
<td className="px-6 py-4 font-medium text-white">{cert.domain}</td>
<td className="px-6 py-4">{cert.issuer}</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<span>{cert.issuer}</span>
{cert.issuer?.toLowerCase().includes('staging') && (
<span className="px-2 py-0.5 text-xs font-medium bg-yellow-500/10 text-yellow-400 border border-yellow-500/20 rounded">
STAGING
</span>
)}
</div>
</td>
<td className="px-6 py-4">
{new Date(cert.expires_at).toLocaleDateString()}
</td>
@@ -60,7 +69,7 @@ export default function CertificateList() {
{cert.id && (cert.provider === 'custom' || cert.issuer?.includes('staging')) && (
<button
onClick={() => {
const message = cert.provider === 'custom'
const message = cert.provider === 'custom'
? 'Are you sure you want to delete this certificate?'
: 'Delete this staging certificate? It will be regenerated on next request.'
if (confirm(message)) {
@@ -136,7 +136,7 @@ export default function ImportReviewTable({ hosts, conflicts, conflictDetails, e
const hasConflict = conflicts.includes(domain)
const isExpanded = expandedRows.has(domain)
const details = conflictDetails?.[domain]
return (
<>
<tr key={`${domain}-${idx}`} className="hover:bg-gray-900/50">
@@ -186,7 +186,7 @@ export default function ImportReviewTable({ hosts, conflicts, conflictDetails, e
)}
</td>
</tr>
{hasConflict && isExpanded && details && (
<tr key={`${domain}-details`} className="bg-gray-900/30">
<td colSpan={3} className="px-6 py-4">
@@ -298,19 +298,19 @@ export default function ImportReviewTable({ hosts, conflicts, conflictDetails, e
}
function getRecommendation(details: ConflictDetail): string {
const hasTargetChange =
const hasTargetChange =
details.imported.forward_host !== details.existing.forward_host ||
details.imported.forward_port !== details.existing.forward_port ||
details.imported.forward_scheme !== details.existing.forward_scheme
const hasConfigChange =
const hasConfigChange =
details.imported.ssl_forced !== details.existing.ssl_forced ||
details.imported.websocket !== details.existing.websocket
if (hasTargetChange) {
return 'The imported configuration points to a different backend server. Choose "Replace" if you want to update the target, or "Keep Existing" if the current setup is correct.'
}
if (hasConfigChange) {
return 'The imported configuration has different SSL or WebSocket settings. Choose "Replace" to update these settings, or "Keep Existing" to maintain current configuration.'
}
@@ -121,4 +121,140 @@ describe('ImportReviewTable', () => {
expect(screen.getByRole('combobox')).toBeInTheDocument()
expect(screen.queryByText('No conflict')).not.toBeInTheDocument()
})
it('expands and collapses conflict details', () => {
const conflicts = ['test.example.com']
const conflictDetails = {
'test.example.com': {
existing: {
forward_scheme: 'http',
forward_host: '192.168.1.1',
forward_port: 8080,
ssl_forced: true,
websocket: true,
enabled: true,
},
imported: {
forward_scheme: 'http',
forward_host: '192.168.1.2',
forward_port: 9090,
ssl_forced: false,
websocket: false,
},
},
}
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={conflicts}
conflictDetails={conflictDetails}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
// Initially collapsed
expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument()
// Find and click expand button (it's the ▶ button)
const expandButton = screen.getByText('▶')
fireEvent.click(expandButton)
// Now should show details
expect(screen.getByText('Current Configuration')).toBeInTheDocument()
expect(screen.getByText('Imported Configuration')).toBeInTheDocument()
expect(screen.getByText('http://192.168.1.1:8080')).toBeInTheDocument()
expect(screen.getByText('http://192.168.1.2:9090')).toBeInTheDocument()
// Click collapse button
const collapseButton = screen.getByText('▼')
fireEvent.click(collapseButton)
// Details should be hidden again
expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument()
})
it('shows recommendation based on configuration differences', () => {
const conflicts = ['test.example.com']
const conflictDetails = {
'test.example.com': {
existing: {
forward_scheme: 'http',
forward_host: '192.168.1.1',
forward_port: 8080,
ssl_forced: true,
websocket: false,
enabled: true,
},
imported: {
forward_scheme: 'http',
forward_host: '192.168.1.1',
forward_port: 8080,
ssl_forced: false,
websocket: false,
},
},
}
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={conflicts}
conflictDetails={conflictDetails}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
// Expand to see recommendation
const expandButton = screen.getByText('▶')
fireEvent.click(expandButton)
// Should show recommendation about config changes (SSL differs)
expect(screen.getByText(/different SSL or WebSocket settings/i)).toBeInTheDocument()
})
it('highlights configuration differences', () => {
const conflicts = ['test.example.com']
const conflictDetails = {
'test.example.com': {
existing: {
forward_scheme: 'http',
forward_host: '192.168.1.1',
forward_port: 8080,
ssl_forced: true,
websocket: true,
enabled: true,
},
imported: {
forward_scheme: 'https',
forward_host: '192.168.1.2',
forward_port: 9090,
ssl_forced: false,
websocket: false,
},
},
}
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={conflicts}
conflictDetails={conflictDetails}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
const expandButton = screen.getByText('▶')
fireEvent.click(expandButton)
// Check for differences being displayed
expect(screen.getByText('https://192.168.1.2:9090')).toBeInTheDocument()
expect(screen.getByText('http://192.168.1.1:8080')).toBeInTheDocument()
})
})
+1 -1
View File
@@ -53,7 +53,7 @@ export default function Certificates() {
</div>
<div className="mb-4 bg-blue-900/20 border border-blue-500/30 text-blue-300 px-4 py-3 rounded-lg text-sm">
<strong>Note:</strong> You can delete custom certificates and staging certificates.
<strong>Note:</strong> You can delete custom certificates and staging certificates.
Production Let's Encrypt certificates are automatically renewed and should not be deleted unless switching environments.
</div>
+6 -4
View File
@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react'
import { useProxyHosts } from '../hooks/useProxyHosts'
import { useRemoteServers } from '../hooks/useRemoteServers'
import { useCertificates } from '../hooks/useCertificates'
import { checkHealth } from '../api/health'
import { Link } from 'react-router-dom'
export default function Dashboard() {
const { hosts } = useProxyHosts()
const { servers } = useRemoteServers()
const { certificates } = useCertificates()
const [health, setHealth] = useState<{ status: string } | null>(null)
useEffect(() => {
@@ -41,11 +43,11 @@ export default function Dashboard() {
<div className="text-xs text-gray-500">{enabledServers} enabled</div>
</Link>
<div className="bg-dark-card p-6 rounded-lg border border-gray-800">
<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">0</div>
<div className="text-xs text-gray-500">Coming soon</div>
</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>
<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>