diff --git a/backend/internal/api/handlers/import_handler.go b/backend/internal/api/handlers/import_handler.go index 9bded7e6..843ff4b6 100644 --- a/backend/internal/api/handlers/import_handler.go +++ b/backend/internal/api/handlers/import_handler.go @@ -192,15 +192,34 @@ func (h *ImportHandler) GetPreview(c *gin.Context) { caddyfileContent = string(content) } - // Check for conflicts with existing hosts and append raw domain names + // Check for conflicts with existing hosts and build conflict details existingHosts, _ := h.proxyHostSvc.List() - existingDomains := make(map[string]bool) + existingDomainsMap := make(map[string]models.ProxyHost) for _, eh := range existingHosts { - existingDomains[eh.DomainNames] = true + existingDomainsMap[eh.DomainNames] = eh } + + conflictDetails := make(map[string]gin.H) for _, ph := range transient.Hosts { - if existingDomains[ph.DomainNames] { + if existing, found := existingDomainsMap[ph.DomainNames]; found { transient.Conflicts = append(transient.Conflicts, ph.DomainNames) + conflictDetails[ph.DomainNames] = gin.H{ + "existing": gin.H{ + "forward_scheme": existing.ForwardScheme, + "forward_host": existing.ForwardHost, + "forward_port": existing.ForwardPort, + "ssl_forced": existing.SSLForced, + "websocket": existing.WebsocketSupport, + "enabled": existing.Enabled, + }, + "imported": gin.H{ + "forward_scheme": ph.ForwardScheme, + "forward_host": ph.ForwardHost, + "forward_port": ph.ForwardPort, + "ssl_forced": ph.SSLForced, + "websocket": ph.WebsocketSupport, + }, + } } } @@ -208,6 +227,7 @@ func (h *ImportHandler) GetPreview(c *gin.Context) { "session": gin.H{"id": sid, "state": "transient", "source_file": h.mountPath}, "preview": transient, "caddyfile_content": caddyfileContent, + "conflict_details": conflictDetails, }) return } @@ -249,21 +269,41 @@ func (h *ImportHandler) Upload(c *gin.Context) { return } - // Check for conflicts with existing hosts and append raw domain names + // Check for conflicts with existing hosts and build conflict details existingHosts, _ := h.proxyHostSvc.List() - existingDomains := make(map[string]bool) + existingDomainsMap := make(map[string]models.ProxyHost) for _, eh := range existingHosts { - existingDomains[eh.DomainNames] = true + existingDomainsMap[eh.DomainNames] = eh } + + conflictDetails := make(map[string]gin.H) for _, ph := range result.Hosts { - if existingDomains[ph.DomainNames] { + if existing, found := existingDomainsMap[ph.DomainNames]; found { result.Conflicts = append(result.Conflicts, ph.DomainNames) + conflictDetails[ph.DomainNames] = gin.H{ + "existing": gin.H{ + "forward_scheme": existing.ForwardScheme, + "forward_host": existing.ForwardHost, + "forward_port": existing.ForwardPort, + "ssl_forced": existing.SSLForced, + "websocket": existing.WebsocketSupport, + "enabled": existing.Enabled, + }, + "imported": gin.H{ + "forward_scheme": ph.ForwardScheme, + "forward_host": ph.ForwardHost, + "forward_port": ph.ForwardPort, + "ssl_forced": ph.SSLForced, + "websocket": ph.WebsocketSupport, + }, + } } } c.JSON(http.StatusOK, gin.H{ - "session": gin.H{"id": sid, "state": "transient", "source_file": tempPath}, - "preview": result, + "session": gin.H{"id": sid, "state": "transient", "source_file": tempPath}, + "conflict_details": conflictDetails, + "preview": result, }) } @@ -398,7 +438,7 @@ func detectImportDirectives(content string) []string { func (h *ImportHandler) Commit(c *gin.Context) { var req struct { SessionUUID string `json:"session_uuid" binding:"required"` - Resolutions map[string]string `json:"resolutions"` // domain -> action (skip, rename, merge) + Resolutions map[string]string `json:"resolutions"` // domain -> action (keep/skip, overwrite, rename) } if err := c.ShouldBindJSON(&req); err != nil { @@ -457,7 +497,8 @@ func (h *ImportHandler) Commit(c *gin.Context) { for _, host := range proxyHosts { action := req.Resolutions[host.DomainNames] - if action == "skip" { + // "keep" means keep existing (don't import), same as "skip" + if action == "skip" || action == "keep" { skipped++ continue } diff --git a/docs/acme-staging.md b/docs/acme-staging.md index be4e1dc3..c4ce9a81 100644 --- a/docs/acme-staging.md +++ b/docs/acme-staging.md @@ -89,13 +89,45 @@ This is **expected** when using staging. Staging certificates are signed by a fa ### Switching from staging to production 1. Set `CPM_ACME_STAGING=false` (or remove the variable) 2. Restart the container -3. Delete the old staging certificates: `docker exec cpmp rm -rf /app/data/caddy/data/acme/acme-staging*` -4. Certificates will be automatically reissued from production +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* + docker exec cpmp rm -rf /data/acme/acme-staging* + ``` + +4. Certificates will be automatically reissued from production on next request ### Switching from production to staging 1. Set `CPM_ACME_STAGING=true` 2. Restart the container -3. Optionally delete old production certificates to force immediate reissue +3. **Optional:** Delete production certificates to force immediate reissue + ```bash + docker exec cpmp rm -rf /app/data/caddy/data/acme/acme-v02.api.letsencrypt.org-directory + docker exec cpmp rm -rf /data/acme/acme-v02.api.letsencrypt.org-directory + ``` + +### Cleaning up old certificates +Caddy automatically manages certificate renewal and cleanup. However, if you need to manually clear certificates: + +**Remove all ACME certificates (both staging and production):** +```bash +docker exec cpmp rm -rf /app/data/caddy/data/acme/* +docker exec cpmp rm -rf /data/acme/* +``` + +**Remove only staging certificates:** +```bash +docker exec cpmp rm -rf /app/data/caddy/data/acme/acme-staging* +docker exec cpmp rm -rf /data/acme/acme-staging* +``` + +After deletion, restart your proxy hosts or container to trigger fresh certificate requests. ## Best Practices diff --git a/frontend/src/api/import.ts b/frontend/src/api/import.ts index 9dbcf600..9970ca3d 100644 --- a/frontend/src/api/import.ts +++ b/frontend/src/api/import.ts @@ -16,6 +16,23 @@ export interface ImportPreview { errors: string[]; }; caddyfile_content?: string; + conflict_details?: Record; } export const uploadCaddyfile = async (content: string): Promise => { diff --git a/frontend/src/components/CertificateList.tsx b/frontend/src/components/CertificateList.tsx index 2936037c..1baf2c32 100644 --- a/frontend/src/components/CertificateList.tsx +++ b/frontend/src/components/CertificateList.tsx @@ -57,15 +57,18 @@ export default function CertificateList() { - {cert.provider === 'custom' && cert.id && ( + {cert.id && (cert.provider === 'custom' || cert.issuer?.includes('staging')) && ( diff --git a/frontend/src/components/ImportReviewTable.tsx b/frontend/src/components/ImportReviewTable.tsx index a877ffaa..563eac17 100644 --- a/frontend/src/components/ImportReviewTable.tsx +++ b/frontend/src/components/ImportReviewTable.tsx @@ -1,20 +1,45 @@ import { useState } from 'react' +import { AlertTriangle, CheckCircle2 } from 'lucide-react' interface HostPreview { domain_names: string + forward_scheme?: string + forward_host?: string + forward_port?: number + ssl_forced?: boolean + websocket_support?: boolean [key: string]: unknown } +interface ConflictDetail { + existing: { + forward_scheme: string + forward_host: string + forward_port: number + ssl_forced: boolean + websocket: boolean + enabled: boolean + } + imported: { + forward_scheme: string + forward_host: string + forward_port: number + ssl_forced: boolean + websocket: boolean + } +} + interface Props { hosts: HostPreview[] conflicts: string[] + conflictDetails?: Record errors: string[] caddyfileContent?: string onCommit: (resolutions: Record) => Promise onCancel: () => void } -export default function ImportReviewTable({ hosts, conflicts, errors, caddyfileContent, onCommit, onCancel }: Props) { +export default function ImportReviewTable({ hosts, conflicts, conflictDetails, errors, caddyfileContent, onCommit, onCancel }: Props) { const [resolutions, setResolutions] = useState>(() => { const init: Record = {} conflicts.forEach((d: string) => { init[d] = 'keep' }) @@ -23,6 +48,7 @@ export default function ImportReviewTable({ hosts, conflicts, errors, caddyfileC const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) const [showSource, setShowSource] = useState(false) + const [expandedRows, setExpandedRows] = useState>(new Set()) const handleCommit = async () => { setSubmitting(true) @@ -96,6 +122,9 @@ export default function ImportReviewTable({ hosts, conflicts, errors, caddyfileC Domain Names + + Status + Conflict Resolution @@ -105,29 +134,159 @@ export default function ImportReviewTable({ hosts, conflicts, errors, caddyfileC {hosts.map((h, idx) => { const domain = h.domain_names const hasConflict = conflicts.includes(domain) + const isExpanded = expandedRows.has(domain) + const details = conflictDetails?.[domain] + return ( - - -
{domain}
- - - {hasConflict ? ( - - ) : ( - - No conflict - - )} - - + <> + + +
+ {hasConflict && ( + + )} +
{domain}
+
+ + + {hasConflict ? ( + + + Conflict + + ) : ( + + + New + + )} + + + {hasConflict ? ( + + ) : ( + Will be imported + )} + + + + {hasConflict && isExpanded && details && ( + + +
+
+ {/* Existing Configuration */} +
+

+ + Current Configuration +

+
+
+
Target:
+
+ {details.existing.forward_scheme}://{details.existing.forward_host}:{details.existing.forward_port} +
+
+
+
SSL Forced:
+
+ {details.existing.ssl_forced ? 'Yes' : 'No'} +
+
+
+
WebSocket:
+
+ {details.existing.websocket ? 'Enabled' : 'Disabled'} +
+
+
+
Status:
+
+ {details.existing.enabled ? 'Enabled' : 'Disabled'} +
+
+
+
+ + {/* Imported Configuration */} +
+

+ + Imported Configuration +

+
+
+
Target:
+
+ {details.imported.forward_scheme}://{details.imported.forward_host}:{details.imported.forward_port} +
+
+
+
SSL Forced:
+
+ {details.imported.ssl_forced ? 'Yes' : 'No'} +
+
+
+
WebSocket:
+
+ {details.imported.websocket ? 'Enabled' : 'Disabled'} +
+
+
+
Status:
+
+ (Imported hosts are disabled by default) +
+
+
+
+
+ + {/* Recommendation */} +
+

+ 💡 Recommendation:{' '} + {getRecommendation(details)} +

+
+
+ + + )} + ) })} @@ -137,3 +296,24 @@ export default function ImportReviewTable({ hosts, conflicts, errors, caddyfileC ) } + +function getRecommendation(details: ConflictDetail): string { + 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 = + 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.' + } + + return 'The configurations are identical. You can safely keep the existing configuration.' +} diff --git a/frontend/src/components/__tests__/ImportReviewTable.test.tsx b/frontend/src/components/__tests__/ImportReviewTable.test.tsx index 2761cf72..a7084e0e 100644 --- a/frontend/src/components/__tests__/ImportReviewTable.test.tsx +++ b/frontend/src/components/__tests__/ImportReviewTable.test.tsx @@ -16,6 +16,7 @@ describe('ImportReviewTable', () => { { { { { {

Certificates

- View and manage SSL certificates. + View and manage SSL certificates. Production Let's Encrypt certificates are auto-managed by Caddy.

+
+ Note: You can delete custom certificates and staging certificates. + Production Let's Encrypt certificates are automatically renewed and should not be deleted unless switching environments. +
+ {isModalOpen && ( diff --git a/frontend/src/pages/ImportCaddy.tsx b/frontend/src/pages/ImportCaddy.tsx index 7a7bef82..a53aee9d 100644 --- a/frontend/src/pages/ImportCaddy.tsx +++ b/frontend/src/pages/ImportCaddy.tsx @@ -146,6 +146,7 @@ api.example.com {