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:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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*
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user